← TCA 14일 마스터 7 / 14 · 1부

Day 7 · 1부 · 의존성 — @Dependency와 등록

뷰 밖에서도 읽히는 @Environment — 리듀서가 uuid·date·clock·API 클라이언트를 직접 꺼내고, 앱·Preview·테스트마다 다른 값을 받는다.
진행률
0 / 0 완료
리듀서 안에서 UUID()·Date()·Task.sleep·URLSession을 바로 부르면, 테스트는 결과를 예측할 수 없고 Preview는 진짜 시간을 기다린다. SwiftUI 코스 Day 13에서는 프로토콜 + @Environment로 이 연결을 끊었다. 그런데 @Environment는 뷰 안에서만 읽힌다. 그래서 뷰가 꺼내 모델 생성자로 넘겨 줘야 했다. TCA의 리듀서는 뷰가 아니다. @Dependency = 뷰 밖에서도 읽히는 @Environment다. 1부는 의존성을 꺼내 쓰는 법, 내장 의존성, 내 의존성을 등록하는 법(DependencyKey·@DependencyClient)을 다룬다.

1부 목표 · 28분

  • 리듀서에서 UUID()·Date()·Task.sleep을 찾아 @Dependency(\.uuid)·\.date.now·\.continuousClock로 바꾼다.
  • @Environment 주입과 @Dependency 주입이 어디서 읽히고, 테스트에서 안 바꾸면 무슨 일이 생기는지 표로 비교한다.
  • API 클라이언트를 struct로 만들고 DependencyKey(liveValue·previewValue·testValue) + DependencyValues 확장으로 등록한다.
  • 실행 컨텍스트(앱·Preview·테스트)별로 어떤 값이 쓰이는지 기본값 규칙을 따라가 예측한다.
  • @DependencyClient로 "미구현" testValue를 한 줄로 만들고, 기본값이 꼭 필요한 엔드포인트를 알아본다.

1. 의존성을 통제한다 — @Environment 없이 꺼내 쓰기 🔥 핵심

TCA 문서가 말하는 의존성은 API 클라이언트만 뜻하지 않는다. 호출할 때마다 결과가 달라지거나 진짜 시간을 쓰는 것은 모두 의존성이다.

📝 TCA 문서 원문

"Dependencies in an application are the types and functions that need to interact with outside systems that you do not control. … seemingly innocuous things such as UUID and Date initializers, and even clocks, can be thought of as dependencies." — TCA 문서 · Dependencies

풀이: 내가 통제하지 못하는 바깥 시스템과 이야기하는 타입·함수가 의존성이다. 네트워크뿐 아니라 UUID(), Date(), 시계(clock)도 여기에 들어간다.

문제: 리듀서가 바깥 세상을 직접 부른다

❌ 할 일 추가 — id·시각·대기가 모두 통제 밖이다
@Reducer
struct AddTodo {
  @ObservableState
  struct State: Equatable {
    var todos: [Todo] = []
    var toast: String?
  }
  enum Action { case addButtonTapped, toastTimerFired }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addButtonTapped:
        state.todos.append(Todo(id: UUID(), title: "", createdAt: Date()))  // 매번 다른 값
        state.toast = "추가했다"
        return .run { send in
          try await Task.sleep(for: .seconds(2))   // 테스트도 진짜 2초를 기다린다
          await send(.toastTimerFired)
        }
      case .toastTimerFired:
        state.toast = nil
        return .none
      }
    }
  }
}

테스트에서 기대 상태에 적을 id·createdAt을 미리 알 수 없고, 토스트가 사라지는지 보려면 진짜 2초를 기다려야 한다. Preview도 매번 2초를 기다린다.

SwiftUI(@Observable)로는 — 환경에서 꺼내 생성자로 넘긴다

SwiftUI 코스 Day 13 2부의 방식을 짧게 다시 보자. 환경 값은 뷰의 body 근처에서만 읽힌다. 그래서 모델은 생성자로 받아야 하고, 뷰가 그 다리 역할을 한다.

SwiftUI — 뷰가 환경에서 꺼내 모델에 넘겨 준다
protocol NowProviding: Sendable { func now() -> Date }
struct SystemNow: NowProviding { func now() -> Date { Date() } }

extension EnvironmentValues {
  @Entry var nowProvider: any NowProviding = SystemNow()
}

@Observable
final class StampModel {
  var stamps: [Date] = []
  private let nowProvider: any NowProviding
  init(nowProvider: any NowProviding) { self.nowProvider = nowProvider }   // 생성자로만 받을 수 있다
  func stamp() { stamps.append(nowProvider.now()) }
}

struct StampScreen: View {
  @Environment(\.nowProvider) private var nowProvider   // 뷰에서만 읽힌다
  @State private var model: StampModel?

  var body: some View {
    Button("찍기") { model?.stamp() }
      .task { if model == nil { model = StampModel(nowProvider: nowProvider) } }
  }
}

TCA로는 — 리듀서가 직접 꺼낸다

✅ @Dependency — 리듀서 프로퍼티로 선언하고 그대로 쓴다
struct Todo: Equatable, Identifiable {
  let id: UUID
  var title: String
  var createdAt: Date
}

@Reducer
struct Todos {
  @ObservableState
  struct State: Equatable {
    var todos: IdentifiedArrayOf<Todo> = []
    var toast: String?
  }
  enum Action { case addButtonTapped, toastTimerFired }

  @Dependency(\.uuid) var uuid                // UUID() 대신
  @Dependency(\.date.now) var now             // Date() 대신
  @Dependency(\.continuousClock) var clock    // Task.sleep 대신

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addButtonTapped:
        state.todos.append(Todo(id: uuid(), title: "", createdAt: now))
        state.toast = "추가했다"
        return .run { send in
          try await clock.sleep(for: .seconds(2))
          await send(.toastTimerFired)
        }
      case .toastTimerFired:
        state.toast = nil
        return .none
      }
    }
  }
}

뷰는 StoreOf<Todos>만 받고 아무것도 넘기지 않는다. FAQ도 이 관계를 한 문장으로 정리한다.

📝 TCA 문서 원문

"Dependencies are declared using the @Dependency property wrapper, which behaves much like SwiftUI's @Environment property wrapper, but it works outside of views." — TCA 문서 · FAQ

풀이: @Dependency는 @Environment와 거의 똑같이 동작한다. 차이는 뷰 밖(리듀서·모델·컨트롤러)에서도 쓸 수 있다는 것이다.

SwiftUI (@Observable) TCA App: .environment(\.nowProvider, …) StampScreen: @Environment(\.nowProvider) StampModel(nowProvider: …) 뷰 트리로만 흐른다 뷰가 꺼내 생성자로 넘긴다 Store · withDependencies { … } Reducer: @Dependency(\.uuid) Effect .run { … clock.sleep … } 뷰를 거치지 않는다 만들 때의 값을 캡처
SwiftUI는 "환경 → 뷰 → 생성자" 세 칸을 사람이 잇는다. TCA는 리듀서가 바로 꺼내므로 가운데 다리가 없다.
비교SwiftUI @EnvironmentTCA @Dependency
읽을 수 있는 곳뷰 안(body가 도는 동안)리듀서, @Observable 모델, UIViewController 등 어디든
모델까지 전달뷰가 꺼내 생성자로 넘긴다필요 없다 — 리듀서가 직접 꺼낸다
기본값 개수defaultValue 1개liveValue·previewValue·testValue 3개(3절)
테스트에서 안 바꾸면기본값(진짜 구현)이 조용히 돈다테스트 실패로 알려 준다(3절)
일부만 바꾸기.environment(_:_:) — 뷰 서브트리.dependency(_:_:) — 리듀서 서브트리(2부)
⚠️ 선언 위치

@Dependency는 리듀서 타입의 저장 프로퍼티로 선언한다. swift-dependencies 문서는 함수·계산 프로퍼티 안에서 쓰는 것도 가능하지만 "주의할 점이 있어 라이브러리에 익숙해지기 전에는 권하지 않는다"고 적는다(swift-dependencies · Using dependencies).

2. 내장 의존성 — uuid · date · continuousClock 🔥 핵심

흔한 의존성은 이미 등록돼 있다. TCA가 재노출하는 swift-dependencies에 들어 있다. TCA가 직접 정의하는 키는 \.dismiss·\.isPresented 둘뿐이고(Day 10에서 쓴다), 나머지는 전부 swift-dependencies 것이다(소스 기준).

키대신하는 것테스트에서 넣는 값테스트에서 안 바꾸면
\.uuidUUID().incrementing(0, 1, 2… 순서), .constant(UUID(0))테스트 실패(liveValue만 있음)
\.date / \.date.nowDate().constant(d), $0.date.now = d테스트 실패(liveValue만 있음)
\.continuousClockTask.sleep, 타이머ImmediateClock(), TestClock()테스트 실패(testValue가 UnimplementedClock)
\.withRandomNumberGenerator, \.mainQueue, \.openURL, \.calendar, \.locale, \.timeZone난수, 메인 큐 스케줄, URL 열기, 지역 설정각자 고정값—

"테스트에서 안 바꾸면" 칸은 소스 기준이다. \.uuid와 \.date는 liveValue만 등록돼 있어 테스트에서 읽는 순간 실패가 기록되고, \.continuousClock은 testValue 자체가 "부르면 실패하는 시계"다(swift-dependencies 소스 · Clocks.swift). 그래서 1절의 Todos를 테스트하려면 세 개를 모두 갈아 끼워야 한다.

✅ 테스트에서 세 의존성을 고정한다 (TestStore는 Day 8에서 자세히)
import Testing

@MainActor
@Test func 할일을_추가하면_토스트가_2초_뒤_사라진다() async {
  let store = TestStore(initialState: Todos.State()) {
    Todos()
  } withDependencies: {
    $0.uuid = .incrementing                                   // 00000000-…-000000000000부터
    $0.date.now = Date(timeIntervalSinceReferenceDate: 0)
    $0.continuousClock = ImmediateClock()                     // sleep이 즉시 끝난다
  }

  await store.send(.addButtonTapped) {
    $0.todos = [Todo(id: UUID(0), title: "", createdAt: Date(timeIntervalSinceReferenceDate: 0))]
    $0.toast = "추가했다"
  }
  await store.receive(\.toastTimerFired) {
    $0.toast = nil
  }
}

UUID(0)은 swift-dependencies의 편의 생성자로, .incrementing이 처음 만드는 값과 같다. 기대값을 정확히 적을 수 있고 2초도 기다리지 않는다.

🚨 흔한 실수: 선언해 놓고 직접 부른다

@Dependency(\.continuousClock)을 선언하고도 이펙트 안에서 Task.sleep을 부르면, 테스트에서 시계를 바꿔도 진짜 2초를 기다린다. 의존성은 통로를 지날 때만 바꿀 수 있다. 리듀서에 Date()·UUID()·Task.sleep·DispatchQueue.asyncAfter가 보이면 그 자리가 바꿀 곳이다.

SwiftUI(@Observable)로는 — 시계를 생성자로 받는다

Clock 프로토콜 자체는 Swift 표준 라이브러리에 있다(Apple 문서 · Clock). 그래서 순수 SwiftUI 모델도 시계를 추상화할 수 있다. 다만 바로 끝나는 ImmediateClock이나 손으로 돌리는 TestClock은 표준 라이브러리에 없고, Point-Free의 swift-clocks에 들어 있다.

SwiftUI — 시계를 생성자로 주입 (의존성마다 이 코드를 반복한다)
@Observable
final class ToastModel {
  var toast: String?
  private let clock: any Clock<Duration>

  init(clock: any Clock<Duration> = ContinuousClock()) {
    self.clock = clock
  }

  func show() async {
    toast = "추가했다"
    try? await clock.sleep(for: .seconds(2))
    toast = nil
  }
}

swift-dependencies는 TCA 전용이 아니다. 같은 @Dependency를 @Observable 모델에도 쓸 수 있다. 이때는 @ObservationIgnored를 함께 붙인다. 문서 설명대로 @Dependency가 프로퍼티 래퍼라서 관찰 매크로와 같이 쓸 수 없기 때문이다(swift-dependencies · What are dependencies?).

@Observable 모델에서 @Dependency — @ObservationIgnored가 필요하다
@Observable
final class ToastModelWithDeps {
  var toast: String?
  @ObservationIgnored @Dependency(\.continuousClock) var clock   // 생성자 인자가 사라진다

  func show() async {
    toast = "추가했다"
    try? await clock.sleep(for: .seconds(2))
    toast = nil
  }
}

3. 내 의존성 등록 — DependencyKey와 세 가지 값 🔥 핵심

숫자 이야기(number fact) API를 부르는 클라이언트를 등록해 보자. 순서는 SwiftUI의 EnvironmentKey 등록과 같다. ① 인터페이스 → ② 키 채택 → ③ 값 가방에 통로 추가.

✅ 등록 3단계, 그리고 리듀서에서 꺼내 쓰기
// ① 인터페이스: 클로저를 담은 struct (프로토콜과의 비교는 2부)
struct NumberFactClient: Sendable {
  var fetch: @Sendable (Int) async throws -> String
}

// ② 키 채택: 상황별 구현을 준다
extension NumberFactClient: DependencyKey {
  static let liveValue = NumberFactClient(
    fetch: { number in
      let url = URL(string: "http://numbersapi.com/\(number)")!
      let (data, _) = try await URLSession.shared.data(from: url)
      return String(decoding: data, as: UTF8.self)
    }
  )
  static let previewValue = NumberFactClient(
    fetch: { "\($0)은(는) Preview용 숫자다." }       // 즉시 가짜 데이터
  )
  static let testValue = NumberFactClient(
    fetch: { _ in
      reportIssue("NumberFactClient.fetch를 테스트에서 덮어쓰지 않았다")
      return ""
    }
  )
}

// ③ 값 가방에 통로: @Dependency(\.numberFact)로 꺼낼 수 있게
extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

// ④ 어느 리듀서에서든 꺼내 쓴다 — 결과는 액션으로 되돌린다 (Day 3)
@Reducer
struct NumberFact {
  @ObservableState
  struct State: Equatable {
    var count = 0
    var fact: String?
  }
  enum Action {
    case factButtonTapped
    case factResponse(String)
  }

  @Dependency(\.numberFact) var numberFact

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .factButtonTapped:
        return .run { [count = state.count] send in
          do {
            await send(.factResponse(try await numberFact.fetch(count)))
          } catch {
            await send(.factResponse("불러오지 못했다"))
          }
        }
      case let .factResponse(fact):
        state.fact = fact
        return .none
      }
    }
  }
}

어떤 값이 언제 쓰이나 — 기본값 규칙

필수는 liveValue 하나다. 나머지 둘을 비워 두면 라이브러리가 기본 구현으로 채운다. 규칙은 swift-dependencies · Live, preview, and test dependencies의 "Cascading rules"에 있다.

어디서 실행? 쓰이는 값 그 값을 구현 안 했으면 시뮬레이터 · 기기 Xcode Preview 테스트 (TestStore) liveValue previewValue testValue 필수 — 없으면 앱 실행 중 보라색 런타임 경고 liveValue로 넘어간다 previewValue → liveValue로 넘어가며 🛑 테스트 실패 기록
DependencyKey를 채택한 경우의 기본값 규칙. 테스트는 "진짜 구현이 몰래 도는 것"을 실패로 막는다.

테스트 칸이 핵심이다. liveValue만 등록한 의존성을 테스트에서 건드리면, 라이브러리가 아래 메시지로 테스트를 실패시킨다(메시지는 소스 기준 일부).

🚨 liveValue만 등록했을 때 테스트에서 보는 메시지

@Dependency(\.numberFact) has no test implementation, but was accessed from a test context … Dependencies registered with the library are not allowed to use their default, live implementations when run from tests.

풀이: 테스트용 구현이 없는 의존성을 테스트에서 읽었다. 진짜 구현을 테스트에서 몰래 쓰는 것은 허락하지 않는다. 고치려면 TestStore의 dependencies를 바꾸거나 testValue를 구현한다. 반대로 SwiftUI의 EnvironmentKey.defaultValue는 테스트에서도 조용히 진짜 구현을 돌린다.

상황SwiftUI EnvironmentKeyTCA DependencyKey
앱defaultValueliveValue
PreviewdefaultValue — 가짜를 쓰려면 매 Preview마다 .environment(…)previewValue — 한 번 정의하면 모든 Preview에 자동
테스트환경이 없다 — 모델 생성자로 가짜를 직접 넘긴다testValue, 없으면 테스트 실패로 알려 줌

한 줄 등록 — @DependencyEntry

SwiftUI의 @Entry가 EnvironmentKey 선언을 한 줄로 줄이듯, swift-dependencies에는 @DependencyEntry가 있다(swift-dependencies · Registering dependencies). 오른쪽 기본값은 testValue가 되고, liveValue: 인자로 앱용 구현을 따로 준다.

✅ @DependencyEntry — 기본값이 testValue, liveValue는 인자로
protocol Greeter: Sendable { func greet(_ name: String) -> String }
struct LiveGreeter: Greeter { func greet(_ name: String) -> String { "안녕하세요, \(name)님" } }
struct MockGreeter: Greeter { func greet(_ name: String) -> String { "mock \(name)" } }

extension DependencyValues {
  @DependencyEntry(liveValue: LiveGreeter())
  // 오른쪽 기본값이 testValue가 된다
  var greeter: any Greeter = MockGreeter()
}

@DependencyClient — 미구현 testValue를 공짜로

struct 인터페이스에 @DependencyClient 매크로를 붙이면 세 가지가 생긴다. TCA가 이 매크로 모듈(DependenciesMacros)도 재노출하므로 따로 import하지 않아도 된다(소스 기준). 케이스 스터디의 FactClient.swift가 이 형태다.

① 미구현 기본값
모든 엔드포인트에 "부르면 테스트 실패 + (throws면) 에러" 기본값. Self()가 곧 미구현 클라이언트다.
② 라벨 있는 메서드
(_ number: Int)처럼 라벨을 적으면 fetch(number:) 메서드가 생긴다.
③ 모든 엔드포인트 init
모듈을 나눌 때 필요한 public 생성자를 자동으로 만든다.
대가
SwiftSyntax에 의존하는 매크로라 빌드 시간이 늘어난다(문서: 약 20초).
✅ @DependencyClient — testValue = Self() 한 줄
@DependencyClient
struct FactClient: Sendable {
  var fetch: @Sendable (_ number: Int) async throws -> String
  var isFavorite: @Sendable (_ number: Int) -> Bool = { _ in false }   // 기본값 필수 (아래)
}

extension FactClient: DependencyKey {
  static let liveValue = FactClient(
    fetch: { number in "\(number)은(는) 멋진 숫자다" },
    isFavorite: { $0 == 7 }
  )
  static let testValue = FactClient()        // 모든 엔드포인트가 "미구현"
}

extension DependencyValues {
  var fact: FactClient {
    get { self[FactClient.self] }
    set { self[FactClient.self] = newValue }
  }
}

func useFact() async throws {
  @Dependency(\.fact) var fact
  let text = try await fact.fetch(number: 42)   // 라벨 있는 메서드가 생겼다
  print(text, fact.isFavorite(number: 42))
}
🚨 던지지 않고 값을 돌려주는 엔드포인트

var isFavorite: () -> Bool처럼 throws가 아니고 Void도 아닌 클로저에는 매크로가 미구현 기본값을 만들 수 없다. 에러를 던질 수도 없고 돌려줄 값도 모르기 때문이다. 그래서 "Default value required for non-throwing closure" 컴파일 오류가 난다. 위처럼 = { _ in false } 기본값을 적는다. 이 제약은 DependencyClient 문서 주석의 "Restrictions"에 있다. 기본값을 준 엔드포인트도 미구현 클라이언트(FactClient())에서 호출되면 실패가 기록된다(소스 기준: 매크로가 기본값 클로저 맨 앞에 reportIssue를 넣는다).

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
TCA 리듀서가 SwiftUI의 @Environment를 쓰지 않고 @Dependency를 따로 쓰는 이유는?
@Environment는 뷰 안에서만 읽힌다. 리듀서는 뷰가 아니다. @Environment로 하려면 뷰가 꺼내서 리듀서에 넘겨야 하는데, 그러면 SwiftUI 코스 Day 13처럼 생성자 다리가 다시 생긴다. @Dependency는 뷰 밖에서도 읽히므로 리듀서가 직접 꺼낸다. 덤으로 live·preview·test 세 값을 가진다.
쉽게 말하면 @Environment는 거실(뷰)에만 있는 공용 공구함이고, @Dependency는 부엌(리듀서)에서도 손 닿는 공구함이다.
liveValue만 등록한 API 클라이언트를 TestStore 테스트에서 덮어쓰지 않고 부르면 어떻게 되나?
라이브러리가 "has no test implementation, but was accessed from a test context"로 테스트 실패를 기록한다. 그러고 나서 기본 규칙대로 previewValue → liveValue를 돌려주므로, 진짜 네트워크 요청까지 나갈 수 있다. 고치려면 withDependencies:에서 덮어쓰거나 testValue를 구현한다.
쉽게 말하면 연습 경기에 진짜 선수를 몰래 넣으면 심판이 호루라기를 분다. 경기는 계속되지만 반칙은 기록된다.
🔮 코드 예측용 — testValue 없이 previewValue만 있는 키
struct Weather: Sendable { var today: @Sendable () -> String }

extension Weather: DependencyKey {
  static let liveValue = Weather(today: { "서버에서 받은 날씨" })
  static let previewValue = Weather(today: { "맑음(가짜)" })
}
// 테스트에서 덮어쓰지 않고 @Dependency(Weather.self) var weather 로 weather.today()를 부른다
🔮 코드 예측: 위 키를 테스트에서 덮어쓰지 않고 읽으면 ① 테스트는 통과하나? ② today()는 무엇을 돌려주나?
① 실패한다. testValue를 구현하지 않았으므로 기본 구현이 쓰이고, 기본 구현은 먼저 "no test implementation" 이슈를 보고한다. ② 그다음 Self.previewValue를 돌려주므로 "맑음(가짜)"다. previewValue가 있으니 liveValue까지 내려가지는 않는다. (swift-dependencies DependencyKey.swift의 기본 testValue 구현을 따라가 확인했다: reportIssue(...) 후 return Self.previewValue.)
쉽게 말하면 연습용 선수가 없어서 대타(Preview용)가 뛰긴 하지만, "연습용 선수를 안 정했다"는 경고는 그대로 남는다.
🔁 Day 6 복습: forEach(\.todos, action: \.todos)에서 행에서 보낸 액션이 어느 행 것인지 부모는 어떻게 아나?
행 액션은 IdentifiedAction의 .element(id:action:)으로 감싸져 올라온다. id가 IdentifiedArrayOf의 요소 id이므로 부모는 그 id로 해당 행 상태를 찾아 자식 리듀서를 돌린다. 오늘 배운 \.uuid를 쓰면 테스트에서 그 id까지 예측할 수 있다.
쉽게 말하면 반 전체에게 온 편지 봉투에 학생 번호가 적혀 있어서 선생님이 누구 것인지 바로 안다.
➡️ 2부 예고

의존성을 어떻게 설계할지(struct of closures vs protocol, 미구현 testValue), 어디서 바꿀지(withDependencies, 리듀서 .dependency, Preview), 그리고 바꾼 값이 얼마나 오래 살아남는지(task local 수명, 단일 진입점)를 본다.

출처 · 참고자료