이번 포스트에서는 실제 프로덕션 환경에서 OAuth 2.0 인증 시스템(Google, Apple Sign-In)을 구현하면서 겪은 TCA Dependencies 관련 문제와 해결 과정을 상세히 공유하겠습니다.
iOS 앱에서 TCA(The Composable Architecture)와 Clean Architecture를 사용하여 OAuth 인증을 구현하던 중, 다음과 같은 치명적인 의존성 주입 에러가 발생했습니다:
Fatal error: OAuthUseCase dependency has not been provided
이 에러는 앱 실행 중 로그인 화면에 진입하는 순간 크래시를 발생시켰으며, 특히 다음과 같은 상황에서 문제가 발생했습니다:
이 프로젝트는 Robert C. Martin의 Clean Architecture 원칙을 따라 설계되었습니다:
SseuDam/
├── Domain/ # 비즈니스 로직 레이어
│ ├── UseCase/OAuth/
│ │ ├── OAuthUseCase.swift # 실제 비즈니스 로직
│ │ └── OAuthUseCaseProtocol.swift # UseCase 인터페이스
│ ├── Repository/OAuth/
│ │ ├── OAuthRepositoryProtocol.swift # Repository 추상화
│ │ ├── GoogleOAuthServicing.swift # Google 서비스 인터페이스
│ │ └── AppleOAuthServicing.swift # Apple 서비스 인터페이스
│ └── Entity/OAuth/
│ ├── UserEntity.swift # 도메인 엔티티
│ ├── GoogleOAuthPayload.swift # Google 데이터 모델
│ └── AppleOAuthPayload.swift # Apple 데이터 모델
├── Data/ # 인프라스트럭처 레이어
│ └── Service/OAuth/
│ ├── GoogleOAuthService.swift # Google SDK 구현체
│ ├── AppleOAuthService.swift # Apple AuthKit 구현체
│ └── OAuthRepository.swift # Supabase 통신 구현체
└── Features/Login/ # 프레젠테이션 레이어
├── LoginView.swift # SwiftUI View
├── LoginFeature.swift # TCA Reducer
└── LoginAction.swift # TCA Actions
Clean Architecture의 핵심 원칙인 의존성 역전 원칙(DIP)을 적용:
Presentation → Domain ← Infrastructure
↓ ↑ ↑
LoginView → OAuthUseCase ← GoogleOAuthService
↓ ↑ ↑
LoginFeature → Repository ← OAuthRepository
TCA Dependencies는 Point-Free에서 개발한 의존성 주입 시스템으로, Swift의 타입 시스템을 활용해 컴파일 타임에 의존성을 관리합니다. 기본적인 구조는 다음과 같습니다:
처음에는 TCA Dependencies 공식 문서를 따라 표준적인 패턴을 사용했습니다:
// OAuthUseCase.swift
extension OAuthUseCase: DependencyKey {
public static var liveValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
public static var previewValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
public static var testValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
}
public extension DependencyValues {
var oAuthUseCase: any OAuthUseCaseProtocol {
get { self[OAuthUseCase.self] }
set { self[OAuthUseCase.self] = newValue }
}
}
// LoginView.swift - 기존 코드 (문제가 있는 버전)
#Preview {
NavigationView {
LoginView(store: Store(
initialState: LoginFeature.State(),
reducer: {
LoginFeature()
}
))
}
}
// LoginFeature.swift
struct LoginFeature: Reducer {
struct State: Equatable {
var isLoading = false
var statusMessage: String?
var currentNonce: String?
}
enum Action {
case view(ViewAction)
case delegate(DelegateAction)
enum ViewAction {
case appleSignIn(Result<ASAuthorization, Error>)
case appleNonceGenerated(String)
case googleButtonTapped
}
enum DelegateAction {
case loginCompleted(UserEntity)
}
}
// 여기서 의존성 주입이 실패함!
@Dependency(\.oAuthUseCase) var oAuthUseCase
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .view(.googleButtonTapped):
state.isLoading = true
return .run { send in
// 이 시점에서 크래시 발생!
let user = try await oAuthUseCase.signUpWithGoogleSuperBase()
await send(.delegate(.loginCompleted(user)))
}
// ... 더 많은 액션 처리
}
}
}
}
가장 큰 문제는 OAuthUseCase가 단독으로 존재하지 않는다는 점이었습니다. 실제로는 다음과 같은 복잡한 의존성 그래프를 가지고 있었습니다:
OAuthUseCase
├── OAuthRepositoryProtocol (Supabase 통신)
├── GoogleOAuthServicing (Google Sign-In SDK)
└── AppleOAuthServicing (Apple AuthenticationServices)
초기 DependencyKey 설정에서 liveValue도 Mock으로 설정했던 것이 문제였습니다:
// 문제가 있는 설정
extension OAuthUseCase: DependencyKey {
public static var liveValue: any OAuthUseCaseProtocol = MockOAuthUseCase() // 🚨 문제!
public static var previewValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
public static var testValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
}
이렇게 되면 실제 앱에서도 Mock이 사용되어 의도한 동작이 이루어지지 않습니다.
OAuthUseCase의 실제 구현체를 만들려면 다음 세 개의 구현체가 모두 필요했습니다:
// OAuthUseCase의 실제 생성자
public init(
repository: OAuthRepositoryProtocol,
googleService: GoogleOAuthServicing,
appleService: AppleOAuthServicing
) {
self.repository = repository
self.googleService = googleService
self.appleService = appleService
}
하지만 이들 각각도 자신만의 DependencyKey가 필요하고, 더 복잡한 의존성 체인을 만들었습니다.
SwiftUI Preview는 특별한 실행 환경을 가지고 있어서, 일반적인 앱 실행과 다른 의존성 해결 방식이 필요했습니다. Preview에서는 실제 외부 SDK 호출이 어려운 경우가 많기 때문입니다.
TCA Dependencies는 lazy loading 방식으로 동작하는데, @Dependency 프로퍼티에 처음 접근할 때 DependencyKey의 해당 값을 가져옵니다. 만약 이 시점에 필요한 의존성이 준비되지 않았다면 fatalError가 발생합니다.
여러 해결 방법을 검토한 결과, 가장 효과적이고 유지보수 가능한 방법을 찾을 수 있었습니다.
가장 직관적이고 효과적인 해결책은 TCA의 withDependencies를 사용하는 것이었습니다:
#Preview {
NavigationView {
LoginView(store: Store(
initialState: LoginFeature.State(),
reducer: {
LoginFeature()
},
withDependencies: {
$0.oAuthUseCase = OAuthUseCase(
repository: OAuthRepository(),
googleService: GoogleOAuthService(),
appleService: AppleOAuthService()
)
}
))
}
}
withDependencies 클로저 내에서 특정 의존성을 직접 지정// 1. 실제 구현체들을 직접 생성
let oauthRepository = OAuthRepository()
let googleService = GoogleOAuthService()
let appleService = AppleOAuthService()
// 2. UseCase에 주입하여 완전한 의존성 그래프 구성
let oauthUseCase = OAuthUseCase(
repository: oauthRepository,
googleService: googleService,
appleService: appleService
)
// 3. TCA Dependencies에 등록
withDependencies: {
$0.oAuthUseCase = oauthUseCase
}
또 다른 접근 방법은 DependencyKey의 liveValue를 실제 구현체로 변경하는 것이었습니다:
extension OAuthUseCase: DependencyKey {
public static var liveValue: any OAuthUseCaseProtocol = OAuthUseCase(
repository: OAuthRepository(),
googleService: GoogleOAuthService(),
appleService: AppleOAuthService()
)
public static var previewValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
public static var testValue: any OAuthUseCaseProtocol = MockOAuthUseCase()
}
Clean Architecture에서 자주 사용되는 DIContainer 패턴도 검토했습니다:
// DIContainer 예시 (사용하지 않음)
class DIContainer {
static let shared = DIContainer()
lazy var oAuthUseCase: OAuthUseCaseProtocol = OAuthUseCase(
repository: oAuthRepository,
googleService: googleOAuthService,
appleService: appleOAuthService
)
lazy var oAuthRepository: OAuthRepositoryProtocol = OAuthRepository()
// ... 더 많은 의존성들
}
방법 1 (withDependencies)을 선택한 핵심 이유:
// 의존성이 어떻게 구성되는지 한눈에 파악 가능
withDependencies: {
$0.oAuthUseCase = OAuthUseCase(
repository: OAuthRepository(), // Supabase 통신
googleService: GoogleOAuthService(), // Google SDK
appleService: AppleOAuthService() // Apple AuthKit
)
}
// Preview 1: 실제 서비스 사용
#Preview("Real Services") {
LoginView(store: Store(
// ...
withDependencies: {
$0.oAuthUseCase = OAuthUseCase(
repository: OAuthRepository(),
googleService: GoogleOAuthService(),
appleService: AppleOAuthService()
)
}
))
}
// Preview 2: Mock 서비스 사용
#Preview("Mock Services") {
LoginView(store: Store(
// ...
withDependencies: {
$0.oAuthUseCase = MockOAuthUseCase()
}
))
}
// 각 테스트마다 다른 시나리오 설정 가능
func testGoogleLoginSuccess() {
let store = TestStore(
initialState: LoginFeature.State(),
reducer: { LoginFeature() },
withDependencies: {
$0.oAuthUseCase = MockOAuthUseCase(shouldSucceed: true)
}
)
// 테스트 로직...
}
func testGoogleLoginFailure() {
let store = TestStore(
initialState: LoginFeature.State(),
reducer: { LoginFeature() },
withDependencies: {
$0.oAuthUseCase = MockOAuthUseCase(shouldSucceed: false)
}
)
// 테스트 로직...
}
OAuth 구현을 완료한 후, 실제 테스트 중에 Google 로그인에서 다음과 같은 시스템 레벨 에러가 발생했습니다:
Received port for identifier response: <(null)> with error:
Error Domain=RBSServiceErrorDomain Code=1 "Client not entitled"
UserInfo={RBSEntitlement=com.apple.runningboard.process-state,
NSLocalizedFailureReason=Client not entitled, RBSPermanent=false}
elapsedCPUTimeForFrontBoard couldn't generate a task port
이 에러는 iOS 시뮬레이터나 특정 기기에서 Google SDK가 시스템 권한을 요청할 때 발생하는 것으로, Google Sign-In 프로세스 중 앱이 백그라운드로 전환되었다가 다시 포어그라운드로 돌아오는 과정에서 시스템 권한 체크가 실패하는 현상입니다.
withCheckedThrowingContinuation을 사용하여 안전한 비동기 처리와 함께 자동 재시도 로직을 구현했습니다:
// GoogleOAuthService.swift
@MainActor
public func signIn() async throws -> GoogleOAuthPayload {
guard configuration.isValid else {
throw GoogleSignInError.configurationMissing
}
guard let presenting = Self.topViewController() else {
throw GoogleSignInError.missingPresentingController
}
let gidConfiguration = GIDConfiguration(
clientID: configuration.clientID,
serverClientID: configuration.serverClientID
)
GIDSignIn.sharedInstance.configuration = gidConfiguration
return try await withCheckedThrowingContinuation { continuation in
Task { @MainActor in
do {
let result = try await GIDSignIn.sharedInstance.signIn(withPresenting: presenting)
guard let idToken = result.user.idToken?.tokenString else {
continuation.resume(throwing: GoogleSignInError.missingIDToken)
return
}
let payload = GoogleOAuthPayload(
idToken: idToken,
accessToken: "",
authorizationCode: result.serverAuthCode,
displayName: result.user.profile?.name
)
Log.info("Google serverAuthCode present: \(payload.authorizationCode != nil ? "yes" : "no")")
continuation.resume(returning: payload)
} catch let error as NSError {
// RBSServiceErrorDomain 에러 감지 및 재시도
if error.domain == "RBSServiceErrorDomain" && error.code == 1 {
Log.error("RBSServiceErrorDomain detected, retrying Google sign-in...")
do {
// 0.5초 대기 후 재시도
try await Task.sleep(for: .seconds(0.5))
let retryResult = try await GIDSignIn.sharedInstance.signIn(withPresenting: presenting)
guard let idToken = retryResult.user.idToken?.tokenString else {
continuation.resume(throwing: GoogleSignInError.missingIDToken)
return
}
let payload = GoogleOAuthPayload(
idToken: idToken,
accessToken: "",
authorizationCode: retryResult.serverAuthCode,
displayName: retryResult.user.profile?.name
)
Log.info("Google sign-in retry succeeded")
continuation.resume(returning: payload)
} catch {
Log.error("Google sign-in retry failed: \(error.localizedDescription)")
continuation.resume(throwing: error)
}
} else if error.domain == "com.google.GIDSignIn",
error.code == GIDSignInError.canceled.rawValue {
Log.info("Google sign-in cancelled by user.")
continuation.resume(throwing: GoogleSignInError.userCancelled)
} else {
Log.error("Google sign-in failed: \(error.localizedDescription)")
continuation.resume(throwing: error)
}
}
}
}
}
error.domain == "RBSServiceErrorDomain" && error.code == 1Task.sleep(for: .seconds(0.5)) - 너무 짧으면 효과 없고, 너무 길면 UX 저하TCA Dependencies는 전통적인 DI 컨테이너와 다르게, Swift의 타입 시스템을 활용한 컴파일 타임 의존성 관리 시스템입니다. 따라서 의존성 그래프 전체를 고려해야 하며, 런타임에 동적으로 변경하기보다는 컴파일 타임에 타입 안전하게 관리하는 것이 중요합니다.
withDependencies는 단순히 테스트나 프리뷰에서만 사용하는 도구가 아닙니다. 실제 프로덕션 환경에서도 특정 기능이나 화면마다 다른 구현체를 주입해야 할 때 매우 유용합니다.
// 실제 사용 예시
struct ContentView: View {
var body: some View {
NavigationView {
if isDebugMode {
// 디버그 모드에서는 Mock 서비스 사용
LoginView(store: Store(
initialState: LoginFeature.State(),
reducer: { LoginFeature() },
withDependencies: {
$0.oAuthUseCase = MockOAuthUseCase()
}
))
} else {
// 실제 환경에서는 실제 서비스 사용
LoginView(store: Store(
initialState: LoginFeature.State(),
reducer: { LoginFeature() },
withDependencies: {
$0.oAuthUseCase = OAuthUseCase(
repository: OAuthRepository(),
googleService: GoogleOAuthService(),
appleService: AppleOAuthService()
)
}
))
}
}
}
}
Clean Architecture의 의존성 역전 원칙과 TCA의 단방향 데이터 플로우가 결합될 때, 각 레이어의 책임이 명확히 분리되면서도 강력한 상태 관리가 가능해집니다:
OAuth 구현 시에는 각 플랫폼(Google, Apple, Facebook 등)별로 고유한 에러나 제약사항이 존재합니다. 이러한 부분을 미리 고려하고 방어 로직을 구현하는 것이 중요합니다.
withCheckedThrowingContinuation은 callback 기반 API를 async/await로 변환할 때뿐만 아니라, 예외적인 상황에서의 재시도 로직 구현에도 매우 유용합니다.
현재는 withDependencies에서 수동으로 모든 구현체를 생성하고 있지만, 이를 좀 더 자동화할 수 있는 방법들을 고려해볼 수 있습니다:
// Factory 패턴 활용
struct OAuthDependencyFactory {
static func makeLiveDependencies() -> DependencyValues {
var dependencies = DependencyValues()
dependencies.oAuthUseCase = OAuthUseCase(
repository: OAuthRepository(),
googleService: GoogleOAuthService(),
appleService: AppleOAuthService()
)
return dependencies
}
static func makeMockDependencies() -> DependencyValues {
var dependencies = DependencyValues()
dependencies.oAuthUseCase = MockOAuthUseCase()
return dependencies
}
}
// 사용
withDependencies(OAuthDependencyFactory.makeLiveDependencies)
현재 Google 로그인의 재시도 로직은 단순하지만, 더 정교한 에러 분류와 처리 전략을 구현할 수 있습니다:
enum OAuthError: Error, Equatable {
case networkError(retryable: Bool)
case systemError(domain: String, code: Int)
case userCancelled
case configurationError
}
// 에러 타입에 따른 다양한 처리 전략
이 트러블슈팅을 통해 얻은 최종 성과:
Fatal error: OAuthUseCase dependency has not been provided 완전 해결// Before: 크래시 발생
❌ Fatal error: OAuthUseCase dependency has not been provided
// After: 안정적인 동작
✅ Apple 로그인 정상 동작
✅ Google 로그인 정상 동작 (재시도 로직 포함)
✅ Preview에서 실시간 UI 테스트 가능
✅ 단위 테스트에서 Mock 객체 활용 가능
✅ Clean Architecture 원칙 준수
이 경험을 통해 TCA Dependencies와 Clean Architecture를 함께 사용할 때의 베스트 프랙티스를 확립할 수 있었으며, 향후 비슷한 문제가 발생했을 때 빠르게 대응할 수 있는 노하우를 얻었습니다.
Tags: #TCA #SwiftUI #CleanArchitecture #OAuth #DependencyInjection #iOS #Troubleshooting #GoogleSignIn #AppleSignIn #withCheckedThrowingContinuation #TCADependencies