TCA Dependencies와 Clean Architecture OAuth 구현 시 의존성 주입 에러 해결하기

Ios_Roy·2025년 11월 17일

트러블슈팅

목록 보기
3/3
post-thumbnail

📱 프로젝트 개요

이번 포스트에서는 실제 프로덕션 환경에서 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

이 에러는 앱 실행 중 로그인 화면에 진입하는 순간 크래시를 발생시켰으며, 특히 다음과 같은 상황에서 문제가 발생했습니다:

  • SwiftUI Preview 실행 시
  • 앱 첫 실행 시 로그인 화면 진입
  • TCA Store 초기화 과정에서

🏗️ 프로젝트 구조

Clean Architecture 레이어 설계

이 프로젝트는 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
  • Domain Layer: 외부 의존성 없이 순수한 비즈니스 로직만 포함
  • Infrastructure Layer: 외부 SDK(Google, Apple, Supabase)와의 실제 통신 담당
  • Presentation Layer: TCA를 통한 상태 관리와 UI 렌더링

🔧 기존 TCA Dependencies 설정

TCA Dependencies 이해하기

TCA Dependencies는 Point-Free에서 개발한 의존성 주입 시스템으로, Swift의 타입 시스템을 활용해 컴파일 타임에 의존성을 관리합니다. 기본적인 구조는 다음과 같습니다:

  1. DependencyKey 프로토콜: 의존성의 기본값들을 정의
  2. DependencyValues 확장: 의존성에 접근하는 computed property 제공
  3. @Dependency 프로퍼티 래퍼: 의존성을 주입받는 방법

초기 구현 시도

처음에는 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에서의 의존성 사용

// 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)))
        }
        // ... 더 많은 액션 처리
      }
    }
  }
}

❌ 문제 원인 분석

1. 의존성 그래프의 복잡성

가장 큰 문제는 OAuthUseCase가 단독으로 존재하지 않는다는 점이었습니다. 실제로는 다음과 같은 복잡한 의존성 그래프를 가지고 있었습니다:

OAuthUseCase
├── OAuthRepositoryProtocol (Supabase 통신)
├── GoogleOAuthServicing (Google Sign-In SDK)
└── AppleOAuthServicing (Apple AuthenticationServices)

2. Mock vs Live 환경의 혼재

초기 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이 사용되어 의도한 동작이 이루어지지 않습니다.

3. 하위 의존성들의 미주입

OAuthUseCase의 실제 구현체를 만들려면 다음 세 개의 구현체가 모두 필요했습니다:

// OAuthUseCase의 실제 생성자
public init(
  repository: OAuthRepositoryProtocol,
  googleService: GoogleOAuthServicing,
  appleService: AppleOAuthServicing
) {
  self.repository = repository
  self.googleService = googleService
  self.appleService = appleService
}

하지만 이들 각각도 자신만의 DependencyKey가 필요하고, 더 복잡한 의존성 체인을 만들었습니다.

4. SwiftUI Preview의 특수성

SwiftUI Preview는 특별한 실행 환경을 가지고 있어서, 일반적인 앱 실행과 다른 의존성 해결 방식이 필요했습니다. Preview에서는 실제 외부 SDK 호출이 어려운 경우가 많기 때문입니다.

5. TCA Dependencies의 초기화 시점

TCA Dependencies는 lazy loading 방식으로 동작하는데, @Dependency 프로퍼티에 처음 접근할 때 DependencyKey의 해당 값을 가져옵니다. 만약 이 시점에 필요한 의존성이 준비되지 않았다면 fatalError가 발생합니다.

✅ 해결 방법

여러 해결 방법을 검토한 결과, 가장 효과적이고 유지보수 가능한 방법을 찾을 수 있었습니다.

방법 1: withDependencies를 사용한 명시적 의존성 주입 (✅ 채택한 방법)

가장 직관적이고 효과적인 해결책은 TCA의 withDependencies를 사용하는 것이었습니다:

#Preview {
  NavigationView {
    LoginView(store: Store(
      initialState: LoginFeature.State(),
      reducer: {
        LoginFeature()
      },
      withDependencies: {
        $0.oAuthUseCase = OAuthUseCase(
          repository: OAuthRepository(),
          googleService: GoogleOAuthService(),
          appleService: AppleOAuthService()
        )
      }
    ))
  }
}

이 방법의 동작 원리

  1. Store 생성 시 의존성 오버라이드: withDependencies 클로저 내에서 특정 의존성을 직접 지정
  2. 스코프 격리: 해당 Store와 그 하위 Reducer에서만 유효한 의존성 설정
  3. 타입 안전성: 컴파일 타임에 타입 체크가 이루어져 런타임 에러 방지

구체적인 구현 과정

// 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
}

방법 2: DependencyKey의 liveValue 수정 (검토했지만 채택하지 않음)

또 다른 접근 방법은 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()
}

방법 2를 채택하지 않은 이유

  1. 글로벌 설정: 모든 곳에서 같은 구현체가 사용되어 유연성 부족
  2. 초기화 시점 문제: 앱 시작 시 모든 의존성이 즉시 생성되어 성능 이슈 가능
  3. 테스트 어려움: 테스트마다 다른 Mock을 주입하기 복잡

방법 3: DIContainer 사용 (고려했지만 제외)

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()
  // ... 더 많은 의존성들
}

제외한 이유

  • TCA Dependencies의 철학과 맞지 않음
  • 글로벌 상태 관리의 복잡성
  • TCA의 타입 안전성 장점을 포기하게 됨

🎯 선택한 해결책의 장점

방법 1 (withDependencies)을 선택한 핵심 이유:

1. 명시성 (Explicitness)

// 의존성이 어떻게 구성되는지 한눈에 파악 가능
withDependencies: {
  $0.oAuthUseCase = OAuthUseCase(
    repository: OAuthRepository(),      // Supabase 통신
    googleService: GoogleOAuthService(), // Google SDK
    appleService: AppleOAuthService()   // Apple AuthKit
  )
}

2. 유연성 (Flexibility)

// 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()
    }
  ))
}

3. 테스트 용이성 (Testability)

// 각 테스트마다 다른 시나리오 설정 가능
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)
    }
  )
  // 테스트 로직...
}

4. 의존성 분리 (Separation of Concerns)

  • DependencyKey 정의와 실제 사용처가 분리됨
  • 각 화면/기능마다 필요한 의존성만 명시적으로 주입
  • 전역 설정에 의존하지 않아 Side Effect 방지

🔍 추가 고려사항

Google 로그인 RBSServiceErrorDomain 에러 해결

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 적용

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)
        }
      }
    }
  }
}

재시도 로직의 핵심 포인트

  1. 에러 도메인 감지: error.domain == "RBSServiceErrorDomain" && error.code == 1
  2. 적절한 대기 시간: Task.sleep(for: .seconds(0.5)) - 너무 짧으면 효과 없고, 너무 길면 UX 저하
  3. 한 번만 재시도: 무한 루프 방지를 위해 1회만 재시도
  4. 타입 안전한 continuation 관리: 모든 경우에서 정확히 한 번만 resume 호출

📚 핵심 학습 포인트

1. TCA Dependencies는 단순한 DI 컨테이너가 아니다

TCA Dependencies는 전통적인 DI 컨테이너와 다르게, Swift의 타입 시스템을 활용한 컴파일 타임 의존성 관리 시스템입니다. 따라서 의존성 그래프 전체를 고려해야 하며, 런타임에 동적으로 변경하기보다는 컴파일 타임에 타입 안전하게 관리하는 것이 중요합니다.

2. withDependencies의 강력함

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()
            )
          }
        ))
      }
    }
  }
}

3. Clean Architecture + TCA의 시너지

Clean Architecture의 의존성 역전 원칙과 TCA의 단방향 데이터 플로우가 결합될 때, 각 레이어의 책임이 명확히 분리되면서도 강력한 상태 관리가 가능해집니다:

  • Domain Layer: 순수한 비즈니스 로직과 UseCase
  • Infrastructure Layer: 외부 SDK와의 실제 통신
  • Presentation Layer: TCA를 통한 상태 관리와 UI

4. 플랫폼별 특이사항 고려의 중요성

OAuth 구현 시에는 각 플랫폼(Google, Apple, Facebook 등)별로 고유한 에러나 제약사항이 존재합니다. 이러한 부분을 미리 고려하고 방어 로직을 구현하는 것이 중요합니다.

5. 비동기 프로그래밍에서의 안전성

withCheckedThrowingContinuation은 callback 기반 API를 async/await로 변환할 때뿐만 아니라, 예외적인 상황에서의 재시도 로직 구현에도 매우 유용합니다.

🔧 개선할 수 있는 부분들

1. 의존성 주입 자동화

현재는 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)

2. 에러 처리 개선

현재 Google 로그인의 재시도 로직은 단순하지만, 더 정교한 에러 분류와 처리 전략을 구현할 수 있습니다:

enum OAuthError: Error, Equatable {
  case networkError(retryable: Bool)
  case systemError(domain: String, code: Int)
  case userCancelled
  case configurationError
}

// 에러 타입에 따른 다양한 처리 전략

🎉 최종 결과

이 트러블슈팅을 통해 얻은 최종 성과:

✅ 해결된 문제들

  1. 의존성 주입 에러: Fatal error: OAuthUseCase dependency has not been provided 완전 해결
  2. Google 로그인 시스템 에러: RBSServiceErrorDomain 자동 재시도로 해결
  3. Clean Architecture 유지: 각 계층의 책임 분리 유지하면서 TCA 적용
  4. 테스트 용이성: Preview, 테스트, 실제 환경 모두에서 유연한 의존성 관리

🚀 얻은 이익들

// Before: 크래시 발생
❌ Fatal error: OAuthUseCase dependency has not been provided

// After: 안정적인 동작
✅ Apple 로그인 정상 동작
✅ Google 로그인 정상 동작 (재시도 로직 포함)
✅ Preview에서 실시간 UI 테스트 가능
✅ 단위 테스트에서 Mock 객체 활용 가능
✅ Clean Architecture 원칙 준수

📈 성능 및 안정성 개선

  • 컴파일 타임 타입 안전성: TCA Dependencies의 타입 시스템 활용
  • 런타임 안정성: withCheckedThrowingContinuation을 통한 안전한 비동기 처리
  • 사용자 경험 개선: Google 로그인 시스템 에러 자동 복구
  • 개발자 경험 개선: 명시적인 의존성 주입으로 디버깅 용이성 증대

이 경험을 통해 TCA Dependencies와 Clean Architecture를 함께 사용할 때의 베스트 프랙티스를 확립할 수 있었으며, 향후 비슷한 문제가 발생했을 때 빠르게 대응할 수 있는 노하우를 얻었습니다.


Tags: #TCA #SwiftUI #CleanArchitecture #OAuth #DependencyInjection #iOS #Troubleshooting #GoogleSignIn #AppleSignIn #withCheckedThrowingContinuation #TCADependencies

profile
iOS 개발자 공부하는 Roy

0개의 댓글