@Dependency로 꺼내 쓰고, DependencyKey로 등록하고, 실행 컨텍스트마다 다른 값이 쓰이는 규칙을 봤다. 2부는 세 가지를 묻는다. 의존성을 어떤 모양으로 설계해야 테스트에서 다루기 쉬운가? 앱·Preview·테스트에서 어디서 바꾸는가? 바꾼 값은 얼마나 오래, 어디까지 따라가는가? 답의 뿌리는 하나다. TCA는 단일 진입점이라, 한 번 바꾼 의존성이 자식 리듀서와 이펙트까지 자동으로 흐른다.2부 목표 · 24분
- 같은 오디오 플레이어를 protocol과 struct of closures로 각각 설계하고, 테스트에서 엔드포인트 하나만 덮어쓸 때의 차이를 설명한다.
testValue를 "미구현"(unimplemented,@DependencyClient)으로 만들어 테스트가 쓰지 않기로 한 엔드포인트를 부르면 실패하게 한다.- 앱 루트(
Store), 리듀서 서브트리(.dependency), Preview, 테스트(TestStore) 중 알맞은 곳에서 의존성을 바꾼다. - 이펙트 안의
DispatchQueue.asyncAfter, 직접 만든 자식@Observable모델처럼 오버라이드가 끊기는 자리를 찾아 고친다.
1. 설계 — struct of closures vs protocol 🔥 핵심
SwiftUI 코스 Day 13 2부는 프로토콜로 의존성을 끊었다. TripSearching 프로토콜을 두고 LiveTripSearch·MockTripSearch·FailingSearch 타입을 만들었다. swift-dependencies도 이 방식을 그대로 받는다. 문서는 "이 스타일이 편하면 바꿀 필요 없다"고 적는다(swift-dependencies · Designing dependencies).
protocol AudioPlayer: Sendable {
func play(url: URL) async throws
func stop() async
}
struct LiveAudioPlayer: AudioPlayer {
func play(url: URL) async throws { try await Task.sleep(for: .seconds(1)) } // 실제로는 AVFoundation
func stop() async {}
}
struct UnimplementedAudioPlayer: AudioPlayer { // 테스트용: 부르면 실패
func play(url: URL) async throws { reportIssue("AudioPlayer.play is unimplemented") }
func stop() async { reportIssue("AudioPlayer.stop is unimplemented") }
}
private enum AudioPlayerKey: DependencyKey {
static let liveValue: any AudioPlayer = LiveAudioPlayer()
static let testValue: any AudioPlayer = UnimplementedAudioPlayer()
}
extension DependencyValues {
var audioPlayer: any AudioPlayer {
get { self[AudioPlayerKey.self] }
set { self[AudioPlayerKey.self] = newValue }
}
}문제는 테스트에서 드러난다. "재생 버튼을 누르면 play가 이 URL로 한 번 불린다"를 확인하려면, play를 기록하는 새 타입을 또 만들어야 한다. stop도 함께 구현해야 한다. Point-Free 예제들이 쓰는 방식은 인터페이스를 클로저를 담은 struct로 만드는 것이다.
@DependencyClient
struct AudioPlayerClient: Sendable {
var play: @Sendable (_ url: URL) async throws -> Void
var stop: @Sendable () async -> Void
}
extension AudioPlayerClient: DependencyKey {
static let liveValue = AudioPlayerClient(
play: { _ in try await Task.sleep(for: .seconds(1)) }, // 실제로는 AVFoundation
stop: {}
)
static let testValue = AudioPlayerClient() // 모든 엔드포인트가 미구현 (1부)
}
extension DependencyValues {
var audioPlayerClient: AudioPlayerClient {
get { self[AudioPlayerClient.self] }
set { self[AudioPlayerClient.self] = newValue }
}
}
@Reducer
struct Player {
@ObservableState
struct State: Equatable { var isPlaying = false }
enum Action { case playButtonTapped, playbackFinished }
@Dependency(\.audioPlayerClient) var audioPlayer
let url = URL(string: "https://example.com/intro.m4a")!
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .playButtonTapped:
state.isPlaying = true
return .run { [url] send in
try? await audioPlayer.play(url: url)
await send(.playbackFinished)
}
case .playbackFinished:
state.isPlaying = false
return .none
}
}
}
}import Testing
@MainActor
@Test func 재생버튼은_play를_한번_부른다() async {
let played = LockIsolated<[URL]>([]) // 이펙트에서 안전하게 기록
let store = TestStore(initialState: Player.State()) {
Player()
} withDependencies: {
$0.audioPlayerClient.play = { url in played.withValue { $0.append(url) } }
}
await store.send(.playButtonTapped) { $0.isPlaying = true }
await store.receive(\.playbackFinished) { $0.isPlaying = false }
#expect(played.value == [URL(string: "https://example.com/intro.m4a")!])
}이 테스트가 통과하면 두 가지가 증명된다. play가 이 URL로 한 번 불렸고, 다른 엔드포인트는 하나도 불리지 않았다. 나머지는 여전히 미구현이라, 나중에 누군가 이 흐름에 stop 호출을 넣으면 테스트가 바로 실패해 "단언할 동작이 늘었다"고 알려 준다. 문서도 같은 설명을 한다.
| 비교 | protocol (SwiftUI Day 13 방식) | struct of closures |
|---|---|---|
| 구현 추가 | 타입을 새로 만든다 | 값을 하나 더 만든다 |
| 테스트에서 한 메서드만 바꾸기 | 그 메서드를 가진 Mock 타입을 새로 만든다 | $0.client.play = { … } 한 줄 |
| 필요한 부분만 의존 | 프로토콜 전체를 받는다 | @Dependency(\.audioPlayerClient.play)처럼 엔드포인트 하나만 |
| 미구현 testValue | Unimplemented 타입을 손으로 쓴다 | @DependencyClient면 Self() |
| 잃는 것 | — | 인자 라벨(→ @DependencyClient가 메서드로 되살림), 익숙함 |
미구현 testValue — "쓰기로 한 것만" 덮어쓰게 만든다
"we highly encourage users of our library to provide what is known as "unimplemented" versions of their dependencies for their testValue. These are implementations that cause a test failure if any of its endpoints are invoked." — swift-dependencies · Live, preview, and test dependencies
풀이: testValue는 "아무것도 안 하는 가짜"보다 "불리면 테스트를 실패시키는 가짜"가 낫다. 테스트가 덮어쓰지 않은 엔드포인트가 불리는 순간, 단언하지 않은 동작이 있다는 뜻이기 때문이다.
매크로 없이도 만들 수 있다. Issue Reporting 라이브러리(TCA를 통해 이미 들어와 있다)의 unimplemented는 거의 모든 시그니처의 "부르면 실패하는 함수"를 돌려준다. 던지지 않고 값을 돌려주는 엔드포인트에는 placeholder:로 돌려줄 값을 준다.
struct AnalyticsClient: Sendable {
var track: @Sendable (_ event: String, _ properties: [String: String]) async throws -> Void
var isEnabled: @Sendable () -> Bool
}
extension AnalyticsClient: TestDependencyKey {
static let testValue = AnalyticsClient(
track: unimplemented("AnalyticsClient.track"),
isEnabled: unimplemented("AnalyticsClient.isEnabled", placeholder: false)
)
}문서가 드는 이유가 설득력 있다. 새 분석 이벤트를 추가하고 테스트를 안 쓰면 바로 실패가 난다. 테스트가 실제 분석 서버에 가짜 이벤트를 보내 데이터를 더럽히는 일도 막는다.
2. 오버라이드 — 어디서 바꾸나 🔥 핵심
바꾸는 자리는 네 곳이다. SwiftUI에서 .environment(_:_:)를 어디에 붙이느냐와 같은 질문이다.
| 어디서 | SwiftUI (@Environment) | TCA (@Dependency) |
|---|---|---|
| 앱 전체 | 루트 뷰에 .environment(\.x, live) | Store(initialState:reducer:withDependencies:) — 보통은 liveValue 그대로 |
| 화면 일부 | 그 서브뷰에 .environment(…) | 그 자식 리듀서에 .dependency(\.x, value) |
| Preview | Preview마다 .environment(…) | previewValue 자동 + 필요할 때만 Store(… withDependencies:) |
| 테스트 | 모델 생성자에 가짜를 넘긴다 | TestStore(… withDependencies:), 도중에 store.dependencies.x = … |
리듀서 서브트리만 바꾸기 — .dependency
TCA 문서의 예는 온보딩이다. 사용법을 가르치는 동안 사용자의 조작이 진짜 디스크나 UserDefaults에 기록되면 안 된다. 앱 전체 설정은 그대로 두고 온보딩 안의 기능만 가짜로 돌린다(TCA 문서 · Dependencies — Overriding dependencies).
@DependencyClient
struct SettingsClient: Sendable {
var save: @Sendable (_ key: String, _ value: Bool) async throws -> Void
}
extension SettingsClient: DependencyKey {
static let liveValue = SettingsClient(save: { key, value in
UserDefaults.standard.set(value, forKey: key)
})
static let inMemory = SettingsClient(save: { _, _ in }) // 아무 데도 쓰지 않는다
}
extension DependencyValues {
var settingsClient: SettingsClient {
get { self[SettingsClient.self] }
set { self[SettingsClient.self] = newValue }
}
}
@Reducer
struct Settings {
@ObservableState
struct State: Equatable { var notificationsOn = false }
enum Action { case notificationsToggled }
@Dependency(\.settingsClient) var settingsClient
var body: some ReducerOf<Self> {
Reduce { state, action in
state.notificationsOn.toggle()
return .run { [on = state.notificationsOn] _ in
try await settingsClient.save(key: "notifications", value: on)
} catch: { _, _ in }
}
}
}
@Reducer
struct Onboarding {
@ObservableState
struct State: Equatable { var settings = Settings.State() }
enum Action { case settings(Settings.Action) }
var body: some ReducerOf<Self> {
Scope(\.settings, action: \.settings) {
Settings()
.dependency(\.settingsClient, .inMemory) // 이 서브트리와 그 이펙트에만 적용
}
}
}"This will cause the Feature reducer to use a mock user defaults and database dependency, as well as any reducer Feature uses under the hood, and any effects produced by Feature." — TCA 문서 · Dependencies
풀이: .dependency는 그 리듀서, 그 안에서 쓰는 모든 하위 리듀서, 그리고 그들이 만든 이펙트까지 바꾼다.
.dependency는 SwiftUI의 .environment처럼 "그 가지"에만 걸린다. 형제 가지는 영향을 받지 않는다.현재 값을 받아 조금 고치는 .transformDependency(\.x) { $0.endpoint = … }도 있다. 소스의 경고대로 이 클로저는 리듀서에 액션이 올 때마다 실행된다. 그 안에서 의존성을 새로 만들면 액션마다 새로 만들어진다(TCA 소스 · DependencyKeyWritingReducer.swift). 로그를 끼워 넣는 정도로만 쓴다.
Preview에서 바꾸기
대부분의 Preview는 아무것도 안 해도 된다. 1부의 규칙대로 previewValue가 자동으로 쓰인다. 에러 화면처럼 특정 상황을 보고 싶을 때만 스토어를 만들면서 덮어쓴다. 스토어 생성자의 withDependencies:는 iOS 17에서도 쓸 수 있다.
struct PlayerView: View {
let store: StoreOf<Player>
var body: some View {
Button(store.isPlaying ? "재생 중" : "재생") { store.send(.playButtonTapped) }
}
}
#Preview("재생 실패") {
PlayerView(
store: Store(initialState: Player.State()) {
Player()
} withDependencies: {
$0.audioPlayerClient.play = { _ in
struct Boom: Error {}
throw Boom()
}
}
)
}스토어를 만들지 않는 화면(예: @Observable 모델)이라면 #Preview(traits: .dependencies { $0.audioPlayerClient.play = … }) 트레이트를 쓸 수 있다. 문서에 따르면 이 트레이트는 iOS 18 이상이 필요하다. 배포 타깃이 iOS 17이면 Preview 본문에서 let _ = prepareDependencies { … }를 먼저 부른다(swift-dependencies · Live, preview, and test dependencies).
3. 수명 — task local과 단일 진입점 🔥 핵심
바꾼 값은 언제까지 살아 있나? 답은 swift-dependencies의 뼈대에 있다. 의존성은 @TaskLocal에 담긴다. task local은 정해진 범위(withValue의 클로저) 안에서만 바뀌고, 그 안에서 만든 Task·async let·TaskGroup에는 상속된다. 하지만 DispatchQueue.asyncAfter 같은 일반 탈출 클로저에서는 원래 값으로 돌아간다(swift-dependencies · Dependency lifetimes).
Task는 task local을 복사해 가져가고, 일반 탈출 클로저는 그러지 않는다. 의존성도 똑같다.TCA 이펙트는 만들 때의 의존성을 들고 간다
TCA의 .run은 만들어지는 순간 withEscapedDependencies로 현재 의존성을 붙잡고, 실제 작업을 그 값 안에서 돌린다(소스 기준: Effect.swift의 run). 그래서 .dependency로 바꾼 리듀서가 이미 값을 돌려준 뒤에도, 그 리듀서가 만든 이펙트는 바뀐 값을 계속 쓴다. 끊기는 곳은 이펙트 안에서 다시 task local을 상속하지 않는 탈출 클로저를 만들 때다.
return .run { send in
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
@Dependency(\.settingsClient) var settingsClient // .inMemory가 아니라 liveValue
Task { try await settingsClient.save(key: "seen", value: true) }
}
}@Reducer
struct Reminder {
struct State: Equatable {}
enum Action { case appeared, delayedCallbackFired }
@Dependency(\.continuousClock) var clock
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .appeared:
return .run { send in
try await clock.sleep(for: .seconds(2)) // ① 구조적 동시성 — 그대로 상속된다
await send(.delayedCallbackFired)
}
case .delayedCallbackFired:
return .run { _ in
withEscapedDependencies { dependencies in // ② 콜백 API를 써야 할 때
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
dependencies.yield {
// 이 안에서는 withEscapedDependencies를 부른 시점의 의존성이 쓰인다
}
}
}
}
}
}
}
}단일 진입점 — TCA가 전파를 공짜로 해 주는 이유
swift-dependencies 문서는 TCA를 SwiftUI 뷰와 나란히 "단일 진입점(single entry point)" 시스템으로 분류한다. 모든 로직이 reduce 한 곳으로 들어오므로, 거기서 의존성을 바꾸면 그 아래 전부가 바뀐 값 안에서 돈다. 반대로 @Observable 모델과 UIKit은 단일 진입점이 아니다. 모델이 다른 모델을 직접 만들면 부모가 받은 오버라이드가 자식에게 가지 않는다(swift-dependencies · Single entry point systems).
@Observable
final class EditModel {
@ObservationIgnored @Dependency(\.audioPlayerClient) var audioPlayer
}
@Observable
final class LibraryModel {
var edit: EditModel?
@ObservationIgnored @Dependency(\.audioPlayerClient) var audioPlayer
func editTapped() {
// edit = EditModel() ← 이렇게 만들면 앱 기본값(liveValue)으로 돌아간다
edit = withDependencies(from: self) { // 부모가 쓰는 의존성을 그대로 물려준다
EditModel()
}
}
}| 전파 방식 | SwiftUI 환경 | @Observable + @Dependency | TCA 리듀서 |
|---|---|---|---|
| 부모 → 자식 | 뷰 트리를 따라 자동 | 자식을 만들 때마다 withDependencies(from: self) | Scope·ifLet·forEach를 따라 자동 |
| 비동기 작업 | 모델에는 닿지 않는다(생성자로 넘김) | Task면 상속, 콜백이면 끊김 | .run이 만들 때 붙잡아 간다 |
| 빠뜨리면 | — | 조용히 liveValue로 돌아간다 | 탈출 클로저를 직접 만들 때만 문제 |
의존성을 struct of closures로 설계하고, testValue는 미구현으로 둔다. 바꾸는 자리는 TestStore·Store의 withDependencies:와 리듀서 .dependency다. 이펙트 안에서는 Task·clock 같은 구조적 도구를 써서 그 값이 끝까지 따라가게 한다.
✅ 복습 · 5분
테스트에서 "재생 버튼은 play만 부르고 stop은 안 부른다"를 확인할 때, struct of closures + 미구현 testValue가 protocol보다 나은 이유는?
$0.audioPlayerClient.play = { … } 한 줄로 play만 덮어쓰고, stop은 미구현으로 남는다. stop이 불리면 그 자체로 테스트가 실패하므로 "안 부른다"가 자동으로 증명된다. protocol 방식은 play를 기록하고 stop에서 실패를 내는 Mock 타입을 따로 만들어야 한다.@Observable 모델에서 자식 모델을 withDependencies(from: self) { … }로 만들어야 하는 이유는? TCA 리듀서는 왜 그럴 필요가 없나?
@Dependency는 만들어지는 순간의 의존성을 붙잡는다. 부모 모델 메서드 안에서 자식을 그냥 만들면 그 시점엔 오버라이드 범위 밖이라 기본값을 붙잡는다. withDependencies(from: self)가 부모가 붙잡은 값을 자식 생성에 다시 깔아 준다. TCA는 모든 로직이 스토어의 reduce라는 단일 진입점으로 들어오고, Scope 같은 조합 도구가 같은 의존성 안에서 자식을 돌리므로 자동으로 전파된다.🔮 코드 예측: 2절의 Onboarding 리듀서 body에 Scope 다음으로 Reduce { _, _ in .run { _ in try await settingsClient.save(key: "done", value: true) } catch: { _, _ in } }를 추가했다(Onboarding에도 @Dependency(\.settingsClient) 선언). .settings(.notificationsToggled)를 보내면 두 save는 각각 어떤 구현을 쓰나?
save는 .inMemory, Onboarding 쪽 save는 앱 기본값(liveValue)이다. .dependency가 만드는 _DependencyKeyWritingReducer는 withDependencies { update } operation: { base._reduce(...) }로 감싼 리듀서만 바뀐 값 안에서 돌린다(소스 기준). Settings가 돌려준 .run은 그 안에서 만들어져 .inMemory를 붙잡는다. 형제인 Reduce는 감싸지 않았으므로 원래 값을 쓴다.🔁 1부 복습: DependencyKey를 채택하고 liveValue만 구현했다. Xcode Preview에서는 어떤 값이 쓰이나? 테스트에서는?
previewValue가 liveValue를 돌려주므로 진짜 구현이 쓰인다(네트워크까지 나갈 수 있다). 테스트에서는 기본 testValue가 "no test implementation" 실패를 기록한 뒤 previewValue(= liveValue)를 돌려준다. 그래서 previewValue는 가짜 데이터로, testValue는 미구현으로 채우는 것이 권장이다.🛠 미니 과제 (선택 · 10분)
명언을 불러오는 기능을 만든다. Xcode 프로젝트에 TCA 패키지가 추가돼 있다고 가정한다.
@Reducer
struct Quote {
@ObservableState
struct State: Equatable { var text: String?; var fetchedAt: Date? }
enum Action { case refreshTapped, quoteLoaded(String) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .refreshTapped:
state.fetchedAt = Date() // 통제 밖
return .run { send in
let (data, _) = try await URLSession.shared // 통제 밖
.data(from: URL(string: "https://example.com/quote")!)
await send(.quoteLoaded(String(decoding: data, as: UTF8.self)))
}
case let .quoteLoaded(text):
state.text = text
return .none
}
}
}
}요구사항
@DependencyClient로QuoteClient(엔드포인트fetch)를 만들고, liveValue·previewValue·testValue(Self())를 준 뒤\.quoteClient로 등록한다.- 리듀서에서
Date()는\.date.now로, 네트워크는quoteClient.fetch()로 바꾼다. TestStore테스트 하나를 쓴다.fetch와date.now만 덮어쓰고 상태 변화를 모두 단언한다.
확인 방법: 테스트가 통과한다. withDependencies:에서 fetch 덮어쓰기를 지우면 "Unimplemented: 'QuoteClient.fetch'" 실패가 나는지 본다. Preview에서는 아무것도 덮어쓰지 않아도 previewValue의 문장이 보이는지 본다.
정답 코드
import Testing
@DependencyClient
struct QuoteClient: Sendable {
var fetch: @Sendable () async throws -> String
}
extension QuoteClient: DependencyKey {
static let liveValue = QuoteClient(fetch: {
let (data, _) = try await URLSession.shared.data(from: URL(string: "https://example.com/quote")!)
return String(decoding: data, as: UTF8.self)
})
static let previewValue = QuoteClient(fetch: { "Preview에서는 바로 보이는 명언" })
static let testValue = QuoteClient()
}
extension DependencyValues {
var quoteClient: QuoteClient {
get { self[QuoteClient.self] }
set { self[QuoteClient.self] = newValue }
}
}
@Reducer
struct QuoteFeature {
@ObservableState
struct State: Equatable { var text: String?; var fetchedAt: Date? }
enum Action { case refreshTapped, quoteLoaded(String) }
@Dependency(\.quoteClient) var quoteClient
@Dependency(\.date.now) var now
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .refreshTapped:
state.fetchedAt = now
return .run { send in
await send(.quoteLoaded(try await quoteClient.fetch()))
} catch: { _, send in
await send(.quoteLoaded("불러오지 못했다"))
}
case let .quoteLoaded(text):
state.text = text
return .none
}
}
}
}
@MainActor
@Test func 새로고침하면_명언과_시각이_채워진다() async {
let store = TestStore(initialState: QuoteFeature.State()) {
QuoteFeature()
} withDependencies: {
$0.quoteClient.fetch = { "테스트 명언" }
$0.date.now = Date(timeIntervalSinceReferenceDate: 0)
}
await store.send(.refreshTapped) {
$0.fetchedAt = Date(timeIntervalSinceReferenceDate: 0)
}
await store.receive(\.quoteLoaded) {
$0.text = "테스트 명언"
}
}