컨버팅 필요성?
기존 탭탭 앱 진입점은 TCA 구조가 적용되어 있지 않은 일반적인 SwiftUI 방식으로 구현되어 있었습니다. 예를 들어, WindowGroup 안에서 launchState에 따라 SplashView, 온보딩, 홈 화면을 swift로 분기하고, DispatchQueue나 .alert를 직접 사용하여 상태를 관리하는 식이었습니다.
이 방식 자체가 성능상 문제를 일으키거나 기능상의 제약이 있는 것은 아니었지만, 앱 전체 구조의 일관성이 떨어지고 상태 관리가 흩어져 유지보수가 어려워진다는 단점이 있었습니다. 그래서 앱 진입점도 TCA 기반으로 통일하여 상태와 이벤트를 일관되게 관리할 수 있도록 리팩토링하게 되었습니다.
AppFeature 구현
먼저, 앱 진입점에서 사용할 Reducer를 구현해야 했습니다. 기존 코드에서는 앱 버전 체크나, UserDefault 같은 비즈니스 로직이 View 안에 작성되어 있었고, 이를 통해 온보딩 진행 여부를 판단했습니다.
TCA에서는 이런 비즈니스 로직과 사이드 이펙트는 Reducer에서 발생시키고, 실제 구현은 Dependency(클라이언트)로 분리하는 것이 권장됩니다. 그래서 앱 버전 체크와 UserDefault 관련 로직을 클라이언트로 구현하고, Reducer에서는 이를 호출하여 상태를 업데이트하도록 구조를 개선했습니다.
@Reducer
struct AppFeature {
@ObservableState
struct State: Equatable {
var launchState: LaunchState = .splash
var onboarding: OnboardingFeature.State?
init() {}
@Presents var alert: AlertState<Action.Alert>?
enum LaunchState: Equatable {
case splash
case onboarding
case home
}
}
enum Action: Equatable {
case onAppear
case checkAppVersion
case updateCheckResult(Bool)
case openAppStore
case splashFinished
case onboarding(OnboardingFeature.Action)
case alert(PresentationAction<Alert>)
@CasePathable
enum Alert: Equatable {
case openAppStore
}
}
@Dependency(\.userDefaultsClient) var userDefaultsClient
@Dependency(\.appVersionCheckClient) var appVersionCheckClient
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .onAppear:
return .run { send in
try await Task.sleep(for: .seconds(1.55))
await send(.checkAppVersion)
}
case .checkAppVersion:
return .run { send in
do {
let needUpdate = try await appVersionCheckClient.isUpdateAvailable()
await send(.updateCheckResult(needUpdate))
} catch {
await send(.updateCheckResult(false))
}
}
case .updateCheckResult(let result):
if result {
state.alert = AlertState {
TextState("업데이트 필요")
} actions: {
ButtonState(action: .openAppStore) {
TextState("업데이트하기")
}
} message: {
TextState("새로운 버전이 있습니다.\n업데이트 후 다시 이용해주세요.")
}
} else {
return .send(.splashFinished)
}
return .none
case .openAppStore:
return .none
case .splashFinished:
let hasCompletedOnboarding = userDefaultsClient.loadOnboardingState()
if hasCompletedOnboarding {
state.launchState = .home
state.onboarding = nil
} else {
state.launchState = .onboarding
state.onboarding = OnboardingFeature.State()
}
return .none
case .alert(.presented(.openAppStore)):
return .run { _ in
await appVersionCheckClient.openAppStore()
}
case .onboarding(.delegate(.onboardingCompleted)):
state.onboarding = nil
state.launchState = .home
return .none
case .onboarding:
return .none
case .alert:
return .none
}
}
.ifLet(\.$alert, action: \.alert)
.ifLet(\.onboarding, action: \.onboarding) {
OnboardingFeature()
}
}
}
개선 후
기존 코드에서는 View에서 비즈니스 로직까지 처리하고 있고, 앱 전체 구조의 일관성이 떨어지는 문제가 있었습니다. 구조를 개선하면서 View에서는 UI와 관련된 로직만 담당하도록 하고, 비즈니스 로직은 Reducer와 클라이언트로 분리하여 책임을 명확히 구분했습니다. 이 덕분에 코드의 가독성이 올라가고, 상태 관리와 이벤트 처리를 TCA 방식에 맞춰 일관성 있게 구성할 수 있었습니다.
또한, 이번 구조 개선은 단위 테스팅 측면에서도 큰 장점이 있었습니다. 기존 코드에서는 View 안에 앱 버전 체크나 UserDefault 조회 같은 비즈니스 로직이 섞여 있어, 테스트 시 실제 앱 상태나 환경에 의존해야 했기 때문에 단위 테스트를 작성하기 어려웠습니다.

반면, 리팩토링을 진행하면서 비즈니스 로직을 클라이언트로 분리했기 때문에, 테스트 시 위처럼 해당 클라이언트를 통해 독립적으로 단위 테스팅을 수행할 수 있게 되었습니다.
'탭탭 - TapTap > 리팩토링' 카테고리의 다른 글
| [리팩토링] 01. TapTap 리팩토링을 시작하다. - 도식화 및 문서화 (1) | 2026.01.05 |
|---|