애플 디벨로퍼 아카데미에서 진행한 프로젝트, 캐플 Qapple 리팩토링 작업의 네 번째 이야기입니다.
캐플은 애플 디벨로퍼 아카데미 @POSTECH 3기, TEAM QAPPLE에서 시작한 프로젝트로
아카데미 러너들과 익명으로 소통할 수 있는 커뮤니티 서비스입니다! 🍎 AppStore 다운로드하기
어느덧 캐플 리팩토링 작업이 마무리되어가고 있습니다. TCA를 이용한 비즈니스 로직, 화면 구현 모두 99% 이상 완료되었습니다. 이제는 슬슬 전체 리팩토링 회고 시간을 팀끼리 가지려고 했으나,,, 했으나,,, API 통신을 위한 Repository 모듈화까지 끝내놓고 4기 출시 이후 진행하는 걸로 결정이 났습니다.
생각보다(?) 순조로웠던 리팩토링이기도 했고 아직까지 큰 문제를 겪지 못해 이야기할 거리가 덜 쌓인 느낌도 있습니다. 아마 다가올 운영 단계에서 버그 수정과 기능 구현 등이 닥치면 이번 리팩토링의 의의가 커질 것 같기도 합니다 ㅎㅎ
Repository 모듈화
기존 모든 API 호출과 관련된 코드는 메인 프로젝트에 작성되어 있었습니다. API Endpoint 관리, URLSession을 이용한 네트워킹, DTO 등 말이죠. 모든 API는 iOS 프로젝트만을 위해 존재했기 때문에 문제없이 잘 사용할 수 있었습니다.
하지만 관리자 페이지가 등장한 순간 흐름은 달라졌습니다. macOS에서 개발할 관리자 페이지 또한 iOS 프로젝트에서 호출하던 API 코드가 필요해졌고, 이를 두 번이나 작성하는 것은 유지 보수 측면에서 너무나 비효율적이었습니다. API 명세가 달라지기라도 하면 두 곳을 수정해줘야 하는 상황인 것입니다.
캐플 팀은 논의 끝에 Repository 코드를 모듈화하기로 결정했습니다. API 관련 코드를 모듈화 함으로써, 재사용성을 높이고 자연스레 의존성을 느슨하게 만들어줄 것을 기대했습니다.
Swift Package Manager를 이용한 모듈화
캐플은 TCA, Firebase 등 외부 의존성을 SPM으로 관리하고 있었기 때문에 방식을 통일하고자 모듈화 할 QappleRepository 또한 SPM으로 배포했습니다. SPM은 `Package.swift`로 만들어진 파일로 의존성을 관리하고, Github과 연동되어 쉽게 버전 관리가 가능했습니다. (Github Releases에 버전을 올려두면 SPM으로 버전 추적이 가능함!)
[PR/#2] QappleRepository 초기 세팅
독립적으로 호출 가능한 설계
모듈화 전 캐플의 API 호출은 AccessToken과 강하게 결합되어 있었습니다. 회원가입 플로우를 제외한 대부분의 API는 AccessToken과 함께 네트워킹을 진행했기에 편의를 위한 구현이었습니다.
/// 네트워킹을 수행할 클라이언트 객체
struct NetworkService {
@Dependency(\.keychainService.fetchData) var fetchData
/// GET 요청을 수행합니다.
func get<T>(url: URL) async throws -> T where T: Decodable {
do {
let (data, response) = try await request(url: url, method: "GET")
return try decodeResponse(data: data, response: response)
} catch {
throw error
}
}
/// URLSession을 통한 네트워킹을 수행합니다.
func request(url: URL, body: Encodable? = nil, method: String) async throws -> (Data, URLResponse) {
let accessToken = "Bearer \(try fetchData(.accessToken))"
// ...
}
}
// 요렇게 호출하곤 했습니다.
let response: SomeDTO = try await get(url: url)
네트워크를 호출할 때는 분명 편했습니다. AccessToken을 어디선가 한번 잘 넣어주기만 하면 다음 호출 부터는 AccessToken을 신경 쓰지 않고도 네트워킹이 가능했죠. 하지만 AccessToken과 강하게 결합되어 있는 위 코드는 유닛 테스트를 진행하거나 모듈과 같이 값이 들어오는 시점을 특정 할 수 없는 경우 문제가 발생합니다.
모듈은 독립적이어야 하기 때문입니다. QappleRepository는 API 호출에 책임이 있는 것이지, Keychain과 같은 AccessToken을 암호화하는 책임은 없어야 합니다. 해당 모듈을 가져다 쓰는 곳에서 Keychain으로 AccessToken을 암호화한다는 보장은 할 수 없으니까요!
이를 해결하기 위해 모듈의 모든 API 호출 함수의 인자로 accessToken을 받아올 수 있게 수정했습니다. 어떤 방식 혹은 시점이든 상관없이 독립적으로 호출이 가능해졌습니다.
/// 네트워킹을 수행할 클라이언트 객체
enum NetworkService {
/// GET 요청을 수행합니다.
static func get<T: Decodable>(url: URL, accessToken: String) async throws -> T {
let (data, response) = try await request(url: url, method: .GET, accessToken: accessToken)
try checkStatusCode(response: response, data: data)
return try decoding(data: data, response: response)
}
/// URLSession을 통한 네트워킹을 수행합니다.
private static func request(url: URL, body: Encodable? = nil, method: HTTPMethod, accessToken: String) async throws -> (Data, URLResponse) {
let accessToken = "Bearer \(accessToken)"
// ...
}
}
/// 답변 API
public enum AnswerAPI: Sendable {
/// 작성한 답변 조회 API 입니다.
public static func fetchListOfMine(
threshold: Int?,
pageSize: Int,
server: Server,
accessToken: String
) async throws -> AnswerListOfMine {
let url = try QappleAPI.Answer.listOfMine(
threshold: threshold,
pageSize: Int32(pageSize)
).url(from: server)
return try await NetworkService.get(url: url, accessToken: accessToken)
}
}
해당 방식으로 변경 후 독립적으로 API를 호출할 수 있었지만, 모든 코드에 AccessToken을 명시적으로 넣어줘야 하는 사용의 불편함이 발생했습니다.
extension AnswerRepository: DependencyKey {
// 1. Keychain Dependency
@Dependency(\.keychainService) static var keychainService
private static let repositoryService = RepositoryService.shared
// 2. 액세스 토큰 편하게 가져오기 위한 계산 속성
private static func accessToken() throws -> String {
try keychainService.fetchData(.accessToken)
}
static let liveValue = Self(
fetchAnswerListOfProfile: { threshold in
let response = try await AnswerAPI.fetchListOfMine(
threshold: threshold,
pageSize: 30,
server: repositoryService.server, // 4. 덤으로 서버 값도 계속 설정해줘야함
accessToken: accessToken() // 3. 매번 AccessToken을 넣어줘야함
)
// ...
}
)
}
반복적인 AccessToken을 불러오기 위한 코드 + 서버 값을 넘겨주기 위한 코드 중복을 줄이고 응집성을 높이기 위해 Wrapper 역할을 수행할 `request(handler:)`함수를 구현했습니다. RepositoryService 내부 keychainService와 server 계산 속성에 접근해 API에 필요한 인자값을 넘겨주는 형태입니다.
또한 토큰이 만료될 경우(403 ERROR) 토큰을 재발급 받을 수 있는 로직도 함께 수행할 수 있도록 구현했습니다.
final class RepositoryService {
private var _server: Server?
@Dependency(\.keychainService) var keychainService
/// 현재 서버를 반환합니다.
var server: Server {
guard let server = _server else { fatalError("...") }
return server
}
/// AccessToken 및 Server 설정, 토큰 재발급 로직을 추가해 API를 호출합니다.
func request<T: Decodable>(
handler: @escaping (Server, AccessToken) async throws -> T
) async throws -> T {
let accessToken = try keychainService.fetchData(.accessToken)
do {
// 1. 네트워킹 성공 시, 기존 Token 값 사용
return try await handler(self.server, accessToken)
} catch NetworkError.authenticationFailed {
do {
// 2-1. 네트워킹 실패(403 에러 발생)시, Token 재발급
let refresh = try await TokenAPI.refresh(
server: self.server,
accessToken: accessToken
)
// 2-2. Keychain 내 기존 Token값 업데이트
try keychainService.createData(.accessToken, refresh.accessToken)
try keychainService.createData(.refreshToken, refresh.refreshToken)
// 2-3. 재발급 받은 Token으로 API 재호출
return try await handler(self.server, refresh.accessToken)
} catch {
// 2-4. 만약 토큰 재발급에도 실패할 시, 로그인 화면으로 이동
await QappleApp.mainFlowStore.send(.refreshTokenFailed)
throw error
}
} catch {
// 3. 이외의 네트워킹 오류 발생시 그대로 던지기
throw error
}
}
}
결과적으로 아래와 같이 `request(handler:)`함수로 감싸 API를 호출해 전체 API 코드에 대한 유지보수성을 높이고 유연한 에러 처리를 진행할 수 있었습니다.
extension AnswerRepository: DependencyKey {
static let liveValue = Self(
fetchAnswerListOfProfile: { threshold in
let response = try await RepositoryService.shared.request { server, accessToken in
try await AnswerAPI.fetchListOfMine(
threshold: threshold,
pageSize: 30,
server: server,
accessToken: accessToken
)
}
// ...
}
)
}
[PR/#314] 자동로그인 오류 수정 및 토큰 재발급 로직 구현
Secret Key 관리
기존 Port 번호나 BaseURL 등의 민감한 데이터는 xcconfig 파일을 gitignore 하는 방식으로 관리했습니다. 그러다 Scheme 설정의 Envrionemnt Variables로도 쉽게 관리가 가능하다고 해서 곧바로 적용했다,, 삽질을 해버렸습니다. 관련 트러블 슈팅은 여기서 확인이 가능합니다!
[PR/#18] BaseURL fetch 방식 업데이트
Slow Test
각 API가 정상적으로 호출되는 지 확인하기 위해 Slow Test 코드를 작성했습니다. Slow Test는 실제 네트워크 연결을 사용해 API를 호출하고 검증하는 테스트입니다. 서버에 의존적이기 때문에 실패할 확률이 있고 느리기 때문에 필요할 때만 선택적으로 구현해야 한다고 합니다!
각 API를 호출하고 에러가 던져지지 않았다면 성공으로 간주하는 테스트 코드를 작성했습니다. 아래는 사용자가 작성한 답변을 삭제하는 API 테스트로 테스트 답변 게시 후 삭제가 되었는지 여부에 따라 테스트 성공 여부를 판단합니다.
QappleRepository 모듈에 Github Actions를 이용해 CI를 적용할 예정이므로 Slow Test가 성공할 수 있게 구현이 필요할 것 같습니다.
이젠 리팩토링 진짜 끝,,?
'진짜' 리팩토링의 끝은 뒤엎은 코드를 프로덕션에 배포하고 이후에도 문제가 없을 시 달성할 수 있다고 생각합니다. 아직 배포하지 못하기도 했고,,,(짜잘짜잘한 문제는 역시 항상 발생하네요 하하) 욕심이 나는 부분도 있습니다. 무엇보다 회고까지는 꼭 해야 하거든요!! 조만간 팀끼리 오프라인 회고 모임도 가져보려 합니다 ㅎㅎ
다음 시간엔 CI/CD 및 Unit Test 혹은 배포 이후 생길(?) 이야기들을 주제로 이야기를 이어나가보겠습니다! 😇
'iOS > 프로젝트 일지' 카테고리의 다른 글
데이블럭 회고 - 하루 24개의 블럭을 가치있게 쌓아나가는 방법 (0) | 2025.03.09 |
---|---|
스쿱 트러블 슈팅 - 음악 추정 시간으로 정확도 개선하기 (1) | 2025.02.17 |
스쿱 트러블 슈팅 - API로부터 도메인을 안전하게 지키기 (1) | 2025.02.17 |
스쿱 트러블 슈팅 - 유연하고 구조적인 정규표현식 만들기 (0) | 2025.02.17 |
캐플 리팩토링 세 번째 이야기 - 트러블 슈팅 (2) | 2025.02.07 |