TCA는 왜 의존성을 Protocol이 아닌 Struct로 만들까?

2024. 7. 19. 15:27·SWIFT개발일지

TCA 예제 코드를 보다가 신기한 걸 발견했어요.

struct LocationClient {
    var authorizationStatus: () -> CLAuthorizationStatus
    var requestAuthorization: () -> Void
    var startMonitoring: (Spot) -> Void
    var events: () -> AsyncStream<MonitorEvent>
}

의존성을 Protocol이 아니라 클로저를 가진 Struct로 만들더라고요.

 

“의존성 역전(DIP)을 위해선 보통 프로토콜로 추상화하지 않나?”
“테스트할 땐 mock 타입 만들어서 주입하는 게 정석 아닌가?”

근데 TCA는 왜 이렇게 만들었을까요? 

아주 기본적인 질문부터 다시 짚고 가야 합니다.

의존성(Dependencies)이란?

내가 통제하지 못하는 “바깥 시스템”과 상호작용해야 하는 타입/함수

예를 들면:

  • 네트워크 API
  • CoreLocation
  • UUID / Date 생성
  • 타이머, Task.sleep

겉보기엔 단순해 보이는 것들도 사실은 전부 의존성이에요

try await Task.sleep(for: .seconds(10))

이건 단순히 “10초 기다린다”가 아닙니다.

  • 실제 OS 타이머 / 스케줄러에 의존
  • 디바이스 부하, 백그라운드 상태에 영향 받음
  • 내 코드가 시간의 흐름을 제어할 수 없음

즉, 현실 세계(real time) 와 연결되는 순간, 그건 더 이상 순수한 로직이 아니고 의존성이 됩니다.

 

비슷한 예로 페이지에 나온 UUID() / Date()도:

  • UUID()는 매번 임의 값(외부/비결정적)을 만들고
  • Date()는 현재 시각(외부 상태)을 읽어오니까 테스트에서 재현 가능하게 만들기 어렵다 → 그래서 의존성이라고 부르는 거죠

의존성이 테스트를 망치는 순간들

Xcode Preview 문제

  • Preview에서 UI 스타일 조금 바꿔보려고 실행할 때마다 10초를 기달려야 하죠

테스트 문제 

테스트에서도 onAppear() 호출하면 Task.sleep 때문에 진짜 10초를 기다려야 하죠

 

👉 이게 바로 의존성을 분리해야 하는 이유입니다.

우리가 배워온 방식: Protocol 기반 DI

보통 우리는 이렇게 하잖아요

protocol LocationService {
    func requestAuthorization()
    func startMonitoring(_ spot: Spot)
    func stopMonitoring(_ spot: Spot)
}

// 실제 구현
final class LiveLocationService: LocationService { ... }

// 테스트용 Mock
struct MockLocationService: LocationService { ... }

깔끔하고 Swift스럽죠. Protocol로 추상화하고, 실제 구현과 Mock을 분리하는 전형적인 DI 패턴이에요.

근데 이 방식에는 치명적인 문제가 하나 있어요.

Protocol 방식의 한계: Mock 타입 폭발

의존성은 점점 커집니다.

protocol LocationService {
    func startMonitoring(_ spot: Spot)
}

그런데 실제 기능을 만들다 보면...

protocol LocationService {
    func authorizationStatus() -> CLAuthorizationStatus
    func requestAuthorization()
    
    func startMonitoring(_ spot: Spot)
    func stopMonitoring(_ spot: Spot)
    
    func events() -> AsyncStream<MonitorEvent>
}

메서드가 5개가 됐네요.

이제 테스트를 작성해볼까요?

 

시나리오 1: 권한이 거부된 경우

struct DeniedLocationService: LocationService {
    func authorizationStatus() -> CLAuthorizationStatus { .denied }
    func requestAuthorization() {}
    func startMonitoring(_ spot: Spot) {}
    func stopMonitoring(_ spot: Spot) {}
    func events() -> AsyncStream<MonitorEvent> { 
        AsyncStream { $0.finish() }
    }
}

 

시나리오 2: 정상 작동

struct HappyLocationService: LocationService {
    func authorizationStatus() -> CLAuthorizationStatus { .authorizedAlways }
    func requestAuthorization() {}
    func startMonitoring(_ spot: Spot) {}
    func stopMonitoring(_ spot: Spot) {}
    func events() -> AsyncStream<MonitorEvent> { ... }
}

시나리오 3: startMonitoring만 실패하는 경우

struct FailedStartLocationService: LocationService {
    func authorizationStatus() -> CLAuthorizationStatus { .authorizedAlways }
    func requestAuthorization() {}
    func startMonitoring(_ spot: Spot) { 
        // 실패 처리...근데 Protocol이라 throws도 안되는데?
    }
    func stopMonitoring(_ spot: Spot) {}
    func events() -> AsyncStream<MonitorEvent> { ... }
}

보이시나요?

시나리오마다 새로운 타입을 만들어야 해요. 이게 바로 타입 폭발이에요.

더 큰 문제는, 각 Mock 타입의 의미가 애매해진다는 거예요.

FailedStartLocationService는 "startMonitoring이 실패"를 의미하는데:

  • events()도 실패해야 할까요?
  • authorizationStatus()는 뭘 리턴해야 하죠?
  • requestAuthorization()은요?

Protocol의 모든 메서드를 구현해야 하는데, 실제로는 하나의 동작만 테스트하고 싶은 거잖아요.

 

TCA의 해법: Mock을 값으로 만들자

TCA는 이 문제를 완전히 다른 방식으로 접근해요.

"Mock을 타입으로 만들지 말고, 값으로 만들자"

 
struct LocationClient {
    var authorizationStatus: () -> CLAuthorizationStatus
    var requestAuthorization: () -> Void
    var startMonitoring: (Spot) -> Void
    var stopMonitoring: (Spot) -> Void
    var events: () -> AsyncStream<MonitorEvent>
}

이건 Protocol이 아니에요. 그냥 클로저들을 담고 있는 Struct예요.

근데 이게 어떻게 추상화일까요?

 

시나리오 1: 권한 거부

let deniedClient = LocationClient(
    authorizationStatus: { .denied },
    requestAuthorization: {},
    startMonitoring: { _ in },
    stopMonitoring: { _ in },
    events: { AsyncStream { $0.finish() } }
)

타입을 만들지 않았어요. 그냥 값이에요.

시나리오 2: enter 이벤트 한 번만

let enterOnceClient = LocationClient(
    authorizationStatus: { .authorizedAlways },
    requestAuthorization: {},
    startMonitoring: { _ in },
    stopMonitoring: { _ in },
    events: {
        AsyncStream { continuation in
            continuation.yield(.didEnter(UUID()))
            continuation.finish()
        }
    }
)

events() 클로저만 바꿨어요. 타입은 그대로 LocationClient예요.

시나리오 3: startMonitoring 실패

let startFailClient = LocationClient(
    authorizationStatus: { .authorizedAlways },
    requestAuthorization: {},
    startMonitoring: { spot in
        // 에러 처리 로직
        print("Failed to start monitoring")
    },
    stopMonitoring: { _ in },
    events: { AsyncStream { $0.finish() } }
)

역시 타입은 하나. LocationClient.

이게 핵심이에요.

  • Mock의 종류는 늘어나지만
  • Mock 타입은 늘어나지 않아요

이 스타일의 The power, power up, power, power 🎵

기존 값에서 “부분만 교체” 가능

var testClient = LocationClient.testValue

// events만 커스터마이징
testClient.events = {
    AsyncStream { continuation in
        continuation.yield(.didEnter(spot.id))
        continuation.yield(.didExit(spot.id))
    }
}

// authorizationStatus만 바꾸기
testClient.authorizationStatus = { .denied }
  • 실제 API는 그대로 사용
  • 특정 엔드포인트만 실패 시뮬레이션

👉 “현실적인 테스트”가 아주 쉬워집니다.

 

 

의존성이 커져도 타입은 하나

Protocol 방식:

  • 메서드 5개 → Mock 타입 10개
  • 메서드 10개 → Mock 타입 50개?

Struct 방식:

  • 메서드 5개 → LocationClient 1개
  • 메서드 10개 → LocationClient 1개

프로퍼티가 늘어나도 타입은 그대로예요.

 

테스트가 정확해진다

Protocol 방식에서 FailedStartLocationService를 만들면:

  • "start가 실패한다"는 의미인가?
  • "전체가 실패한다"는 의미인가?
  • 다른 메서드들은 어떻게 동작해야 하나?

Struct 방식은 명확해요:

let client = LocationClient(
    authorizationStatus: { .authorizedAlways },  // 권한은 OK
    requestAuthorization: {},                    // 요청도 OK
    startMonitoring: { _ in /* 여기만 실패 */ }, // 얘만 실패
    stopMonitoring: { _ in },                    // 중지는 OK
    events: { AsyncStream { $0.finish() } }      // 이벤트 없음
)

각 동작을 독립적으로 제어할 수 있어요.

 

TCA가 이 방식을 선택한 이유

TCA는 복잡한 앱을 만들기 위한 프레임워크예요. 복잡한 앱일수록:

  • 의존성이 많아지고
  • 각 의존성의 엔드포인트가 늘어나고
  • 테스트 시나리오가 복잡해져요

Protocol 방식은 이럴 때 빠르게 한계에 부딪혀요. Mock 타입이 기하급수적으로 늘어나거든요.

그리고 TCA의 철학과도 맞아요:

"State는 Struct다. Action은 Enum이다. 그리고 Dependency도 Struct다."

모든 걸 값으로 다루는 거죠.

 

 

'SWIFT개발일지' 카테고리의 다른 글

TCA- TestingCode  (1) 2024.10.26
CLMonitor  (1) 2024.10.22
TCA(2)-Store, ViewStore& Binding  (4) 2024.07.16
TCA(1)- (mvvm...-> NEXT?)  (1) 2024.07.04
CoreLocation과 Battery의 관계  (1) 2024.04.21
'SWIFT개발일지' 카테고리의 다른 글
  • TCA- TestingCode
  • CLMonitor
  • TCA(2)-Store, ViewStore& Binding
  • TCA(1)- (mvvm...-> NEXT?)
2료일
2료일
좌충우돌 모든것을 다 정리하려고 노력하는 J가 되려고 하는 세미개발자의 블로그입니다. 편하게 보고 가세요
  • 2료일
    GPT에게서 살아남기
    2료일
  • 전체
    오늘
    어제
    • 분류 전체보기 (143)
      • SWIFT개발일지 (39)
        • ARkit (1)
        • Vapor-Server with swift (3)
        • UIkit (2)
      • 알고리즘 (25)
      • Design (6)
      • iOS (56)
        • 반응형프로그래밍 (12)
      • 디자인패턴 (6)
      • CS (3)
      • 도서관 (3)
      • 인생회고 (1)
  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
2료일
TCA는 왜 의존성을 Protocol이 아닌 Struct로 만들까?
상단으로

티스토리툴바