애플 디벨로퍼 아카데미에서 진행한 프로젝트, 캐플 Qapple 리팩토링 작업의 세 번째 이야기입니다.
캐플은 애플 디벨로퍼 아카데미 @POSTECH 3기, TEAM QAPPLE에서 시작한 프로젝트로
아카데미 러너들과 익명으로 소통할 수 있는 커뮤니티 서비스입니다! 🍎 AppStore 다운로드하기
방향성 설정, 프로젝트 세팅까지 모두 마쳤습니다. 이제는 실제 리팩토링을 진행하며 마주쳤던 다양한 트러블 슈팅을 정리해봅니다. (역시나 대개 TCA 관련 트러블 슈팅이었습니다 ㅎㅎㅎ,,,,,)
1. Navigation & TabBar 로직 구현하기
TCA에서 네비게이션 로직을 구현하는 것은 튜토리얼에서 어느정도 설명이 되었기에 크게 어렵지 않을 것(?)이라 생각했습니다. 하지만 늘 그렇듯 캐플 앱의 네비게이션 + 탭바 로직은 꽤나 복잡했고 이를 풀어내기 위한 방법이 필요했습니다.
우선 구현하는데 있어 최우선 목표는 네비게이션 로직의 중앙집중화였습니다. 산발적으로 퍼진 네비게이션 로직을 관리하는 것은(View에서 다음 View를 직접 생성한다거나, 로직을 처리한다거나) 이전 PathModel을 사용할 때 꽤나 관리가 힘든 부분 중 하나였기 때문입니다.
이를 위해 전체 로직을 묶어줄 MainFlowFeature를 구성했습니다. MainFlowFeature는 모든 네비게이션 + 탭 바 로직을 묶어줌으로써 중앙집중화를 목표로했습니다. 각 탭을 Scope를 이용해 자식 Reducer로 구성하고, StackState와 StackAction을 이용해 하위 View를 Path로 관리하고 있습니다.
import ComposableArchitecture
@Reducer
struct MainFlowFeature {
@ObservableState
struct State: Equatable {
var questionTab = QuestionTabFeature.State()
var bulletinBoardTab = BulletinBoardFeature.State()
var profileTab = ProfileFeature.State()
var path = StackState<Path.State>()
}
enum Action {
case questionTab(QuestionTabFeature.Action)
case bulletinBoardTab(BulletinBoardFeature.Action)
case profileTab(ProfileFeature.Action)
case path(StackActionOf<Path>)
}
var body: some ReducerOf<Self> {
Scope(state: \.questionTab, action: \.questionTab) {
QuestionTabFeature()
}
Scope(state: \.bulletinBoardTab, action: \.bulletinBoardTab) {
BulletinBoardFeature()
}
Scope(state: \.profileTab, action: \.profileTab) {
ProfileFeature()
}
Reduce { state, action in
// 이곳에서 모든 네비게이션 Flow 구현!
}
.forEach(\.path, action: \.path)
}
}
// MARK: - Path
extension MainFlowFeature {
@Reducer(state: .equatable)
enum Path {
case writeAnswer(WriteAnswerFeature)
case completeAnswer(CompleteAnswerFeature)
case answerList(AnswerListFeature)
case bulletinBoard(BulletinBoardFeature)
case bulletinBoardSearch(BulletinBoardSearchFeature)
case bulletinBoardPost(BulletinBoardPostFeature)
case comment(CommentFeature)
case profileEdit(ProfileEditFeature)
case myAnswerList(MyAnswerListFeature)
case peopleWhoMadeQapple
case notificationList(NotificationFeature)
case report(ReportFeature)
}
}
View에서는 NavigationStack의 path, destination을 이용해 실제 View를 Store와 함께 생성합니다.
import ComposableArchitecture
import SwiftUI
struct MainFlowView: View {
@Bindable var store: StoreOf<MainFlowFeature>
var body: some View {
NavigationStack(path: $store.scope(state: \.path, action: \.path)) {
TabView {
QuestionTabView(store: store.scope(state: \.questionTab, action: \.questionTab))
.tabItem {
Image(systemName: "questionmark.bubble.fill")
Text("오늘의 질문")
}
BulletinBoardView(store: store.scope(state: \.bulletinBoardTab, action: \.bulletinBoardTab))
.tabItem {
Image(systemName: "list.clipboard.fill")
Text("게시판")
}
ProfileView(store: store.scope(state: \.profileTab, action: \.profileTab))
.tabItem {
Image(systemName: "person.fill")
Text("내 정보")
}
}
.tint(.button)
.fixedTabBarBackground(color: .first)
} destination: { store in
switch store.case {
case let .writeAnswer(store): WriteAnswerView(store: store)
case let .completeAnswer(store): CompleteAnswerView(store: store)
case let .answerList(store): AnswerListView(store: store)
case let .bulletinBoard(store): BulletinBoardView(store: store)
case let .bulletinBoardSearch(store): BulletinBoardSearchView(store: store)
case let .bulletinBoardPost(store): BulletinBoardPostView(store: store)
case let .comment(store): CommentView(store: store)
case let .profileEdit(store): ProfileEditView(store: store)
case let .myAnswerList(store): MyAnswerListView(store: store)
case .peopleWhoMadeQapple: PeopleWhoMadeQappleView()
case let .notificationList(store): NotificationListView(store: store)
case let .report(store): ReportView(store: store)
}
}
}
}
이를 통해 자식 Reducer에서는 네비게이션 로직에 전혀 개입하지 않고, MainFlowFeature 내부에서 모든 네비게이션 로직을 관리할 수 있었습니다.(자식 Reducer에서는 오로지 Action만을 정의)
해당 방식은 앞서 이야기한 최우선 목표인 네비게이션 로직의 중앙집중화는 달성할 수 있었지만 비대해져 유지보수가 힘들어지는 역효과도 함께 발생했습니다. 여러 View를 협업해 작업할 때 네비게이션 로직을 구현하기 위해 MainFlowFeature 파일의 코드를 수정해야 했기에 충돌이 나는 것과 함께 복잡해지는 하위 Reducer의 모든 Action을 MainFeature에서 추적 해 작성하는 것의 어려움이 발생했습니다.
/// 실제로 보면 훨씬 복잡합니다 🤪
Reduce { state, action in
switch action {
case let .questionTab(.todayQuestion(.seeAllAnswerButtonTapped(question))):
state.path.append(.answerList(.init(question: question)))
return .none
// 이외에도 오늘의 질문 탭 네비게이션 로직 더 있음...
case let .bulletinBoardTab(.boardCellTapped(board)):
state.path.append(.comment(.init(board: board)))
return .none
// 이외에도 게시판 탭 네비게이션 로직 더 있음...
case let .profileTab(.editProfileButtonTapped(nickname)):
state.path.append(.profileEdit(.init(nickname: nickname, defaultNicknam
return .none
// 이외에도 내 정보 탭 네비게이션 로직 더 있음...
case let .path(stackAction):
switch stackAction {
case let .element(id: _, action: .writeAnswer(.postAnswerResponse(quest
state.path.append(.completeAnswer(.init(question: question)))
return .none
}
// 이외에도 각종 하위 View 네비게이션 로직 짱 많음...
default: return .none
}
}
.forEach(\.path, action: \.path)
결론부터 이야기하자면 이 부분은 아직 수정을 하지 못했습니다. 처음 리팩토링 기간으로 산정한 일자가 다가오기도 하고, 현재도 큰 문제 없이 동작하기 때문입니다.
하지만 추후 각 Tab별로 NavigationStack을 구성하는 방식을 계획하고 있습니다!(물론 해당 방식도 훨씬 전에 시도해보긴 했는데 TabView 안에 NavigationStack이 존재했을 때 발생하는 애니메이션 버그,,, 때문에,,, 이 부분은 나중에 단독 포스팅으로 심도있게 다뤄보려 합니다.)
2. 더보기 Sheet, 신고하기 View 재사용하기
캐플에는 더보기 Sheet와 신고하기 View가 여러 곳에서 사용되고 있습니다. 오늘의 질문, 답변 리스트, 게시판 리스트, 댓글 리스트 등 다양한 곳에서 동일한 형태의 View가 재사용되고 있죠.
TCA로 리팩토링을 진행하며 처음 고민했었던 것은 모양은 같으나 요구하는 데이터가 다르니 각각 다른 Recuder를 구현해야하는가? 였습니다.(오늘의 질문 더보기 Feature, 답변 리스트 더보기 Feature 등등..) 이렇게 진행하게 된다면 자연스레 View와 Reducer의 1:1 관계를 위해 View도 각각 생성해줘야했는데 너무 비효율적이라는 생각이 들었습니다.
이를 해결하기 위해 공통 State와 Action을 정의해 하나의 Reducer로 구현하는 방식을 도전했습니다. 공통 State에는 어떤 데이터를 표시하거나 신고할 것인가를 표현하는 타입이 필요했습니다. 어느 View에서 사용해도 결국 구체적인 데이터를 활용해야하기 때문입니다.
enum DataType: Equatable {
case answer(Answer)
case bulletinBoard(BulletinBoard)
case comment(BoardComment)
}
이를 더보기 Sheet, 신고하기 View에 생성과 동시에 초기화될 수 있게 State로 들고 있게 되면 목적에 맞는 데이터 타입의 구성이 가능합니다. 유연하게 데이터 타입을 선택할 수 있는 형태가 되었습니다.
@Reducer
struct SeeMoreSheetFeature {
@ObservableState
struct State: Equatable {
var sheetTarget: SheetTarget
var dataType: DataType // 요렇게!
@Presents var alert: AlertState<Action.Alert>?
}
}
@Reducer
struct ReportFeature {
@ObservableState
struct State: Equatable {
var dataType: DataType // 요렇게!
var isLoading = false
@Presents var alert: AlertState<Action.Alert>?
}
}
또한 Action에서도 DataType으로부터 넘겨받은 연관값을 사용해 실제 기능을 수행할 수 있습니다. 아래 코드는 연관값을 이용해 실제 신고 API를 호출하고 있습니다.
@Reducer
struct ReportFeature {
@ObservableState
struct State: Equatable {
var dataType: DataType
var isLoading = false
@Presents var alert: AlertState<Action.Alert>?
}
enum Action {
case backButtonTapped
case reportCellTapped(ReportType)
case completionReport
case toggleLoading(Bool)
case alert(PresentationAction<Alert>)
enum Alert: Equatable {
case confirmReport(ReportType)
case confirmCompletion
}
}
@Dependency(\.reportRepository) var reportRepository
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
...
case let .alert(.presented(.confirmReport(reportType))):
// 1. State에서 dataType 캡처
return .run { [dataType = state.dataType] send in
await send(.toggleLoading(true), animation: .bouncy)
do {
// 2. 데이터 타입에 따른 신고 API 호출
switch dataType {
case let .answer(answer):
try await reportRepository.reportAnswer(answer.id, reportType)
case let .bulletinBoard(board):
try await reportRepository.reportBulletinBoard(board.id, reportType)
case let .comment(comment):
try await reportRepository.reportComment(comment.id, reportType)
}
await send(.completionReport)
} catch {
print(error)
}
await send(.toggleLoading(false), animation: .bouncy)
}
...
}
.ifLet(\.$alert, action: \.alert)
}
}
이를 통해 더보기 Sheet, 신고하기 View의 로직을 일반화해 유지보수성과 일관성을 더할 수 있었습니다.(각각 따로 만들어줬을 때 생겼을 혼란은,,, 무섭군요 🥲)
3. TextField 바인딩 로직 삽질기
가장 오래 삽질을 한 부분인 것 같습니다. TextField 로직은 TCA 튜토리얼에서 친절히 설명하고 있었기 때문에 어려울 것 없다 생각했지만,, 예상치 못한 문제에 부딪혔습니다.
답변 작성 기능 구현을 위해 로직은 크게 2가지가 동작해야합니다.
- 글자 수 제한 확인
- 글자 크기 유동적으로 조정(화면 사이즈에 맞게)
이는 TextField와 바인딩 된 문자열이 타이핑 될 때마다 검사해야하는 로직으로 처음엔 아래 코드와 같이 구현했습니다.(다시 재연하려고 작성한 테스트용 코드입니다!)
@Reducer
struct WriteAnswerFeature {
@ObservableState
struct State: Equatable {
var answerText = ""
var answerTextFontSize: CGFloat = 48
let textLimit = 10
}
enum Action {
case typeText(String)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .typeText(text):
// 1. 글자수 제한에 맞게 자르기
state.answerText = text.slice(to: state.textLimit)
// 2. 유동적으로 폰트 사이즈 조정
state.answerTextFontSize = adaptiveFontSize(from: state.answerText)
return .none
}
}
}
func adaptiveFontSize(from text: String) -> CGFloat {
switch text.count {
case 0..<2: 48
case 2..<4: 40
case 4..<6: 32
case 6...8: 24
case 8...: 17
default: 48
}
}
}
struct WriteAnswerView: View {
@Bindable var store: StoreOf<WriteAnswerFeature>
var body: some View {
VStack {
Text("\(store.answerText.count)/\(store.textLimit)")
TextField(text: $store.answerText.sending(\.typeText)) {}
.foregroundStyle(.white)
.font(.system(size: store.answerTextFontSize))
.multilineTextAlignment(.center)
}
}
}
TextField의 텍스트가 타이핑 될 때마다 1. Reducer의 typeText Action을 호출하고, 2. 내부에서 answerText State에 직접적으로 값을 할당하는 방식입니다.
사실 이 방식은 텍스트를 그대로 할당할 경우엔 문제가 없습니다. 그러나 글자 수 제한을 적용하거나 폰트 사이즈를 조정하면 로직이 예상하는 대로 동작하지 않는 것을 확인할 수 있습니다. 정확히는 TextField에 표시되는 문자와 Reducer의 State 값이 일치하지 않아 발생하는 문제입니다.
Print문을 찍어보면 Reducer 내부의 answerText 값은 글자 수 제한이 정상적으로 동작하는 모습을 확인할 수 있습니다. 즉, TextField 화면에는 업데이트되지 않았지만 내부적으로는 업데이트된(?) 이상한 상태가 되어버린 것이죠.
이를 해결하기 위해 각종 TextField 예제와 TCA의 공식문서 Working with SwiftUI bindings를 참고해 코드를 수정했습니다. 정확히는 BindableAction, BindingReducer 부분을 활용했습니다.(사실 이 부분은 공부가 더 필요할 것 같습니다. 현재로서는 바인딩이 필요한 액션들을 하나로 묶어주는 역할을 수행한다! 정도로만 알고 있기 때문입니다)
하지만 이렇게도 문제가 해결되지 않았고,,, 수많은 서치 중에 제가 겪고 있는 현상과 동일한 문제가 올라온 TCA 공식 Github 내 Discussion 게시글을 찾을 수 있었습니다.
요약하자면 onChange modifier를 활용해 state 값을 업데이트시켜주는 것입니다. 사실 이 방법을 처음 봤을 때 외부에서 state 값의 변경이 되지 않나? 라는 의문이 있었는데 그에 대한 논의 또한 진행되있었습니다.
제 수준으로는 정확히 이해할 순 없었지만, 이는 BindingAction을 Redcuer로 보내는 것이라고 합니다.(문법 상 할당하는 것처럼 보이지만 store.send(.binding(\.field, ...)의 단축 구문이라고 하네요!)
결론적으로 onChange modifier를 활용한 방식은 문제를 말끔히 해결해주었습니다. 추가로 비즈니스 로직을 Reducer 안에서 처리할 수 있도록 Action을 한번 더 감싸줌으로써 최종 코드를 완성했습니다.
@Reducer
struct WriteAnswerFeature {
@ObservableState
struct State: Equatable {
let textLimit = 250
var answerText: String = ""
var answerTextFontSize: CGFloat = 48
}
/// 1. BindableAction 프로토콜 채택
enum Action: BindableAction {
case typeAnswerText(String)
/// 2. BindingAction을 하나로 묶어줄 Action
case binding(BindingAction<State>)
}
var body: some ReducerOf<Self> {
/// 3. BindingAction이 들어올 때 State를 업데이트 해주기 위한 Reducer
BindingReducer()
Reduce { state, action in
switch action {
/// 4. 타이핑 액션이 들어왔을 때 비즈니스 로직 수행
case let .typeAnswerText(text):
state.answerText = text.slice(to: state.textLimit)
state.answerTextFontSize = adaptiveFontSize(from: text)
return .none
/// 5. TextField의 값과 State의 answerText 바인딩
case .binding(\.answerText):
return .none
case .binding:
return .none
}
}
}
}
/// 6. onChanged에서 Action 호출
TextField(text: $store.answerText, axis: .vertical) {}
.onChange(of: store.answerText) { _, value in
store.send(.typeAnswerText(value))
}
4. 어디서든 접근 가능 한 공유 상태 관리하기
캐플은 회원가입 이후 signUpFlowFeature 내부 State인 isSignIn 값을 토글함으로써 MainFlowView를 표시하게 됩니다.
@main
struct QappleApp: App {
@UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
private let mainFlowStore = Store(initialState: .init()) {
MainFlowFeature()
}
private let signUpFlowStore = Store(initialState: .init()) {
SignUpFlowFeature()
}
var body: some Scene {
WindowGroup {
// 회원가입 이후 요 값을 토글하여 화면을 전환하는 것!
if signUpFlowStore.isSignIn {
MainFlowView(store: mainFlowStore)
} else {
SignUpFlowView(store: signUpFlowStore)
}
}
}
}
회원가입 직후 메인 화면 Flow에서는 isSignIn 값을 건드릴 필요가 없지만, 문제는 로그아웃 및 회원탈퇴 이후 다시 SignUpFlowView를 표시해야 할 때입니다. 기존 코드에서 isSignIn 값은 SignUpFlowFeature의 State에 있었고 이를 MainFlowFeature에서 접근할 수 있는 방법이 없었습니다.
이를 해결하기 위해 전통적으로 사용하는 UserDefaults를 사용할 수도 있었지만, TCA 자료를 찾던 중 Sharing State에 대한 공식문서를 찾아 TCA스럽게 적용할 수 있었습니다.
@Reducer
struct SignUpFlowFeature {
@ObservableState
struct State: Equatable {
/// 1. @Shared 프로퍼티 래퍼를 이용해 공유 상태를 생성할 수 있습니다.
@Shared(.inMemory(Constant.isSignIn)) var isSignIn = false
var socialLogin = SocialLoginFeature.State()
}
enum Action {
case socialLogin(SocialLoginFeature.Action)
}
var body: some ReducerOf<Self> {
Scope(state: \.socialLogin, action: \.socialLogin) {
SocialLoginFeature()
}
Reduce { state, action in
switch action {
case let .socialLogin(.delegate(.signInResponse(isSignUp))):
if isSignUp {
/// 2. 공유 상태는 어디서든 접근이 가능하기 때문에
/// 데이터 레이스 발생 위험이 있습니다.
/// 이를 막기 위해 withLock 함수의 클로저를 이용해
/// 값을 안전하게 업데이트 할 수 있습니다.
state.$isSignIn.withLock { $0 = true }
} else {
state.path.append(.emailForm(.init()))
}
return .none
}
}
}
}
이제 다른 모든 Reducer에서 @Shared 프로퍼티 래퍼와 key값을 이용해 공유 상태에 접근할 수 있게 되었습니다.
앞으로 해결해나가야 할 것들
현재 약 95% 이상 리팩토링이 완료되었습니다. 나머지 5%는 QA를 통해 이번 주(포스팅 날짜 기준 25년 2월 둘째 주)안에 완료할 예정입니다! 🎉(드디어!!) 하지만 새롭게 해결해나가야할 것들도 생겼습니다. 역시 개발은 하면 할 수록 일이 늘어나는 것 같아 걱정되면서도 기대가 됩니다.
첫 번째로, 유닛 테스트입니다. 사실 TCA를 사용해야하는 이유 중 가장 핵심적인 부분이었는데 적용하지 못한 핑계 아닌 핑계가 발생해 아직까지 적용을 못하고 말았습니다.
대략 3주째 삽질 중인 것 같은데 요약하자면 유닛 테스트의 실행 자체가 아무런 오류도 없이 무한 로딩에 빠지는(????) 현상입니다. 이 문제를 해결해보고자 열심히 찾아보는데 잘,,, 되지 않더군요,,,, 이상한건 함께 작업 중인 무니의 프로젝트에서는 또 잘 된다는 점입니다. Xcode의 문제인건지,,, TCA 설치 과정에서 문제가 있었던 건지 도통 모르겠지만 꼭 해결해야 할 과제입니다.(혹시라도 해결 방법을 알고 계신 분은 도움을 부탁드립니다 😭)
두 번째는, Repository의 모듈화입니다. 이번 리팩토링이 끝난 후 바로 진행하게 될 것은 macOS 관리자 App의 개발입니다. 기존의 질문 업로드, 신고함 관리 등 모든 작업은 백엔드 팀이 개발한 Swagger를 통해 진행되었기 때문에 관리의 편의성을 더하고자 결정된 사항이었습니다.
관리자 App은 TestFlight를 이용해 독립적으로 운영될 예정이기 때문에 새롭게 프로젝트 생성이 필요합니다. 결국 현 캐플 프로젝트 내부의 Repository 코드들을 공유할 수 없다는 뜻이기도합니다. 동일한 코드가 작성될 예정이기에 Repository를 모듈화하여 각 프로젝트에 import 하는 방식으로 개선할 예정입니다.
세 번째는 Google Analytics 기능 추가입니다. 기획 및 디자인 팀의 요청사항으로 정량 리서치를 위해 꼭 필요한 기능입니다. 항상 팀원이 진행했던 부분이기에 직접 구현해보고 싶었는데 좋은 기회가 될 것 같습니다.
마무리
세 번째 포스팅은 코드가 많아 꽤나 길어졌습니다. 언제 정리하지,,, 라는 생각이 있었는데 적다보니 금방 하게 되는 것 같습니다. 4기 런칭이 얼마 남지 않은 만큼 목표한 바를 일정 내 잘 끝마칠 수 있었으면 합니다. 마지막까지 캐플 팀 화이팅! 🍎
'iOS' 카테고리의 다른 글
ScrollView 리프레쉬 했을 때 화면이 멈추는 현상 해결하기 (0) | 2025.02.07 |
---|---|
Array / Set / Dictionary Swift의 컬렉션 알아보기 (2) | 2025.02.06 |
캐플 리팩토링 두 번째 이야기 - 프로젝트 세팅하기 (0) | 2025.01.30 |
구조체로 싱글톤 만들기? (0) | 2025.01.30 |
캐플 리팩토링 첫 번째 이야기 - 방향성 설정하기 (0) | 2025.01.22 |