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 |