애플 디벨로퍼 아카데미에서 진행한 프로젝트, 캐플 Qapple 리팩토링 작업의 두 번째 이야기입니다.
캐플은 애플 디벨로퍼 아카데미 @POSTECH 3기, TEAM QAPPLE에서 시작한 프로젝트로
아카데미 러너들과 익명으로 소통할 수 있는 커뮤니티 서비스입니다! 🍎 AppStore 다운로드하기
첫 번째 이야기에서는 리팩토링의 방향성을 설정했습니다. 어떤 의도로 리팩토링하는지, 어떤 합의를 볼 것인지, 그 합의의 방법인 아키텍처는 어떻게 사용할지에 대해서 말이죠!
이제는 구체적으로 적용해나가기 위해 초기 세팅이 필요했습니다.
TCA 기본 세팅
합의에서 가장 크고 중요한 부분은 단연코 TCA였습니다. 기존 MVVM 아키텍처로 처리하던 방식과 크게 달랐기 때문에 논의 없이 프로젝트를 진행한다면 큰 혼란을 초래할 수 있었습니다.
TCA 튜토리얼을 진행하며 상태 관리 방법에 대해 학습하고, 캐플 화면 2개 정도를 리팩토링 후 코드 리뷰를 통해 몇가지 합의점을 도출했습니다.
1. 하나의 View == 하나의 Store
TCA는 이름처럼 Composable(구성 가능)합니다. 각 Reducer를 이리저리 조합해 부모 Reducer를 만들거나, 자식 Reducer의 Action을 전달하는 등의 장점을 가지고 있었기 때문에, 이를 최대한 활용하고 명확한 분리도 가능하도록 하나의 View는 하나의 Store를 가지도록 결정했습니다.
* 예외로 UIComponent와 하위 View, Cell 등의 경우 TCA와의 의존성을 분리하는 것이 재사용 측면에서 유리하다고 판단했기에, Store를 가지고 있지 않기로 합의했습니다.
2. Reducer의 네이밍은 Feature로 통일하기
TCA 튜토리얼의 네이밍을 그대로 차용해 'Feature'로 네이밍하기로 결정했습니다. Reducer라는 이름 보다 더 와닿는 것 같습니다.
3. Action 케이스의 네이밍은 UI 동작을 표현하도록
이 또한 TCA의 관용을 따라 결정했습니다. 이는 Action의 근원지가 어디인지 명확히 하기 위함으로 추측됩니다.
It is best to name the Action cases after literally what the user does in the UI, such as incrementButtonTapped, rather than what logic you want to perform, such as incrementCount.
Action 케이스의 이름을 increaseCount와 같이 수행하고자 하는 논리를 나타내는 것보다는 increaseButtonTapped와 같이 사용자가 UI에서 수행하는 작업을 그대로 나타내는 것이 가장 좋습니다.
4. @Dependency 인터페이스는 struct로 구현하기
TCA는 의존성 관리 방법으로 @Dependency 프로퍼티 래퍼를 제공하고 있으며 추상화하기 위한 방법으로 protocol과 struct을 이용할 수 있습니다. 처음엔 struct로 어떻게 추상화하지,,? 라는 의문이 있었지만 튜토리얼에서 아주 자세히 설명해주고 있었습니다.
간단히 요약하자면 함수를 타입으로 들고 있는 속성 값을 초기화하지 않은 채로 선언 후 DependencyKey라는 프로토콜을 채택해 인스턴스 자체를 반환하는 타입 계산 속성을 구현하도록 해 사용하는 방식입니다.
/// 1. 자신을 반환하는 타입 프로퍼티를 용도에 맞게 정의
/// * 실제 DependencyKey가 아닌, 이해를 위해 구현해둔 것!
protocol DependencyKey<Value> {
associatedtype Value = Self
static var liveValue: Value { get }
static var testValue: Value { get }
}
/// 2. 프로토콜을 대체할 수 있는 struct
/// 프로퍼티의 타입을 함수로 정의
struct Repository {
var someAction: (_ data: Data) async throws -> Data
}
/// 3. DependencyKey 프로토콜을 채택해
/// 용도에 맞는 인스턴스 반환
extension Repository: DependencyKey {
static var liveValue: Repository {
Repository { data in
// 실제 네트워킹 코드 작성...
}
}
static var testValue: Repository {
Repository { data in
// 테스트용 코드 작성...
}
}
}
/// 4. 인스턴스를 추가로 생성하지 않고도
/// 미리 정의된 용도에 맞게 의존성 컨트롤 가능!
try await Repository.liveValue.someAction(Data())
try await Repository.testValue.someAction(Data())
struct로 인터페이스를 만든다면, 명시적으로 어떤 구현체들을 가지고 있는지 추적할 수 있고 자연스럽게 응집되기 때문에 유지보수의 이점이 있습니다. 이는 protocol로 인터페이스를 만들었을 때의 네이밍이나 구현체의 관리가 어렵다는 점을 생각했을 때 명확한 장점이 있기 때문에 struct로 구현을 결정했습니다.
핵심 룰 정하기
사실 이외에는 TCA 특성상 엄격한 사용 방법이 정의되어 있었기 때문에 낮은 수준에서의 합의는 어느정도 정리가 되었습니다. 이제는 프로젝트의 핵심적인 부분을 논의해야 했습니다.
1. Presentation은 인덱싱 한 Feature 별로 정리하기
우선 '하나의 View는 하나의 Store를 가진다'와 'Reducer의 네이밍은 Feature로 통일한다'는 룰이 있었기 때문에 Presentation 쪽 폴더 구조는 Feature 별로 정리할 수 있었습니다.
하지만 화면이 늘어날 때마다 수많은 Feature들 사이에서 폴더를 찾는 것은 이전에도 겪었던 문제였기 때문에 인덱싱을 활용하기로 했습니다. 전체 Flow에 맞게 각 화면에 고유한 번호를 매겨 폴더 네이밍을 하는 것입니다.
이 방식은 Xcode 업데이트 이후 그룹에서 폴더 방식으로 업데이트하더라도 순서를 지정할 수 있다는 점에서도 장점이 있었습니다.(Conflict를 줄이기 위해 그룹에서 폴더로 업데이트했습니다!)
2. Data는 Repository와 Service로 나눠 정리하기
기존 Data 폴더에 네트워킹 코드와 Helper, Manager 등의 수많은 저수준 구현체가 섞여있었습니다. 이는 파일 추적과 네이밍을 어렵게 만들어 생산성을 떨어트렸습니다.
이를 해결하고자 데이터를 가져오는 목적의 Repository와 특정 기술 및 프레임워크(애플 로그인, 키체인, 메일 등)를 사용하는 Service 2가지로 통일해 Data 폴더를 정리했습니다.
그 외에 작은 함수, Service로 분리하기엔 작은 경우 Utility 폴더를 만들어 정리했습니다. 자세한 폴더 구성은 캐플 2.0 리팩토링 준비 PR을 통해 확인할 수 있습니다.
3. Entity, DTO 도입
캐플 리팩토링의 주요 목적 중 하나는 백엔드 서버와의 의존성을 줄이는 것이었습니다. 이를 위해 API 명세서를 표현하는 DTO와, 앱 내 핵심 콘셉트를 표현하는 Entity를 도입했습니다.
DTO는 API 명세서를 표현하고 있기 때문에 구체적이지만, 앱 내 곳곳에서 사용하는 것은 Entity이기 때문에 백엔드 서버의 변화에 대응하는 등 의존성을 크게 줄일 수 있었습니다.
API 명세가 업데이트 됐을 때 아래의 계산 속성 toEntity 정도만 업데이트해줘도 될 것입니다!(물론 크게 달라지지 않는다면 말이죠 🙃)
struct AnswersOfQuestionDTO: Codable {
let total: Int
let size: Int
let content: [Content]
let numberOfElements: Int
let threshold: String
let hasNext: Bool
struct Content: Codable, Hashable {
let answerId: Int
let writerId: Int
let profileImage: String?
let nickname: String
let content: String
let isMine: Bool
let isReported: Bool
let isLiked: Bool
let writeAt: String
}
var toEntity: [Answer] {
self.content.map {
Answer(
id: $0.answerId,
content: $0.content,
authorNickname: $0.nickname,
publishedDate: $0.writeAt.ISO8601ToDate,
isReported: $0.isReported,
isMine: $0.isMine,
isResignMember: $0.nickname == "알 수 없음"
)
}
}
}
4. View는 적당히 분리하기
SwiftUI에서 가장 어려운 부분 중 하나는 길게 늘어진 View 코드를 읽는 것입니다. 동작 상에는 문제가 없지만 수많은 VStack, HStack이 중첩되어 있으면 어떤 컴포넌트를 가리키는지 이해하고 재사용하기 어려워집니다.
이를 완화하기 위해 계산 속성, 함수, sturct를 활용해 큰 View를 적당히 분리하고자 했습니다.
일전 애플 디벨로퍼 아카데미 내 SwiftUI 컨벤션 논의 때 VStack, HStack이 중첩될 시 무조건 struct로 분리하자!와 같은 명확한 룰을 정하고 싶기도 했지만 너무 강경한 것 같아,,, 적당히(?)라는 단어로 우선 결정하게 되었습니다.
5. 기존 코드는 Legacy 처리하기
처음엔 리팩토링을 위해 프로젝트를 새로 생성하는 것이 좋을지 고민했습니다. 하지만 여전히 재사용할 수 있는 수많은 요소가 남아있고 프로젝트를 이전했을 때 드는 비용, 뭔가 우리의 유산(?)과 같은 프로젝트를 파기하기엔 아쉬운 점이 많았습니다.
그래서 기존 프로젝트를 이어갈 수 있도록 이름이 겹치는 파일은 Xcode의 Refactor 기능을 활용해 접두에 'Legacy'를 붙이고, Legacy 폴더를 생성해 분리하기로 결정했습니다.
이는 리팩토링이 진행됨에 따라 조금씩 삭제되어 끝에는 모두 삭제될 예정입니다.
이젠 정말 시작!
리팩토링 전 명확한 룰을 정하니 잘 될 것만 같은 기분이 드는 것 같습니다. 물론 앞으로 TCA를 적용하며 수많은 난관에 부딪히게 되겠지만 잘 헤쳐나갈 수 있지 않을까요? 애플 디벨로퍼 아카데미 4기가 시작되기 전 모든 준비를 마칠 수 있도록 다시 열심히 달려보겠습니다!
'iOS' 카테고리의 다른 글
ScrollView 리프레쉬 했을 때 화면이 멈추는 현상 해결하기 (0) | 2025.02.07 |
---|---|
Array / Set / Dictionary Swift의 컬렉션 알아보기 (2) | 2025.02.06 |
구조체로 싱글톤 만들기? (0) | 2025.01.30 |
캐플 리팩토링 첫 번째 이야기 - 방향성 설정하기 (0) | 2025.01.22 |
비동기 작업의 단위, Task 알아보기 (0) | 2025.01.22 |