← TCA 14일 마스터 12 / 14 · 2부

Day 12 · 2부 · 공유 상태 운영 — 초기화·커스텀 키·관찰·테스트

@Shared를 init에서 채우는 법, 나만의 저장소, 외부 변경에 반응하기, 참조인데도 exhaustive하게 테스트하기, @AppStorage·@Environment와의 비교.
진행률
0 / 0 완료
1부에서 @Shared로 참조를 나눠 갖고, 키로 앱 전역에 공유하고, withLock으로 고쳤다. 실제 앱에서는 곧 다음 질문이 나온다. 커스텀 init에서는 @Shared를 어떻게 채우나? UserDefaults·파일이 아닌 저장소는? 다른 탭이 값을 바꾸면 내 리듀서는 어떻게 알아채나? 참조인데 TestStore로 검증이 되나? 2부는 공유 상태를 운영하는 법이다. 마지막에 @AppStorage · @Environment · @Shared를 한 표로 비교한다.

2부 목표 · 21분

  • "영속 전략이 있나 · 진실의 주인이 누구인가" 두 질문으로 @Shared 초기화 세 가지 중 하나를 고른다.
  • load·subscribe·save·id 네 요구사항을 채워 커스텀 SharedKey를 만든다.
  • $count.publisher로 외부 변경에 반응하는 이펙트를 만들고, 무한 루프를 만드는 코드를 찾아 고친다.
  • 이펙트 안에서 바뀐 공유 상태를 store.assert로 검증하고, 공유 값 변경을 어느 액션에서 단언해야 하는지(1.26.2 실측) 설명한다.

1. 초기화 규칙과 커스텀 SharedKey 🔥 핵심

모듈로 나눈 기능은 public init이 필요하다. 그런데 @Shared는 프로퍼티 래퍼라서 init 안에서는 밑줄 붙은 저장소(_count)에 직접 넣어야 한다. SwiftUI의 @State를 init에서 채울 때(_x = State(initialValue:))와 같은 종류의 규칙이다. Sharing 문서는 경우를 셋으로 나눈다(Sharing 문서 · Initialization rules).

영속 전략(키)을 쓰나? 예 아니오 ① init(count: @autoclosure () -> Int) Shared(wrappedValue: count(), .appStorage(…)) 값은 저장소가 비었을 때만 쓰인다 진실의 주인이 이 기능? 예 부모 ② init(count: Int) Shared(value: count) ③ init(count: Shared<Int>) _count = count
두 질문이면 init 모양이 정해진다. 영속 전략은 init에서 정하므로 프로퍼티 선언은 인자 없는 @Shared로 둔다.
✅ 초기화 세 가지 — 선언은 모두 인자 없는 @Shared
// ① 영속: 평범한 값을 받아 키와 함께 만든다. @autoclosure로 필요할 때만 평가
public struct PersistedCounterState: Equatable {
  @Shared public var count: Int
  public init(count: @autoclosure () -> Int = 0) {
    _count = Shared(wrappedValue: count(), .appStorage("count"))
  }
}

// ② 비영속 + 이 기능이 주인: 새 저장소를 만든다
public struct OwnedCounterState: Equatable {
  @Shared public var count: Int
  public init(count: Int) {
    _count = Shared(value: count)
  }
}

// ③ 비영속 + 부모가 주인: 받은 참조를 그대로 넣는다
public struct BorrowedCounterState: Equatable {
  @Shared public var count: Int
  public init(count: Shared<Int>) {
    _count = count
  }
}
⚠️ ③ 자리에서 ②를 쓰면 조용히 끊긴다

부모의 참조를 받아야 할 자식이 init(count: Int)로 값을 받아 Shared(value:)를 만들면, 컴파일은 되지만 1부 🔮의 b처럼 새 저장소가 생긴다. 공유가 목적이라면 init 파라미터 타입을 Shared<Int>로 두어 호출부가 $count를 넘기도록 강제한다.

상황SwiftUITCA (@Shared)
내가 소유하는 값_x = State(initialValue: x)_x = Shared(value: x)
남이 소유한 값을 빌림_x = binding (Binding<T>)_x = shared (Shared<T>)
영속 저장소의 값@AppStorage("k") var x = 0 (뷰에서만)_x = Shared(wrappedValue: x(), .appStorage("k"))
초기값이 무시되는 때@State: 뷰 identity가 유지되는 동안영속 키: 저장소에 이미 값이 있을 때

나만의 저장소 — SharedKey 네 가지 요구사항

내장 3종으로 부족하면 SharedKey를 채택한다. 요구사항은 넷이다. 저장소에서 값을 읽는 load(context:continuation:), 외부 변경을 구독하는 subscribe(context:subscriber:), 값을 쓰는 save(_:context:continuation:), 같은 저장소를 가리키는 @Shared끼리 참조를 공유하게 하는 id다. 쓰기가 필요 없으면 save 없는 SharedReaderKey만 채택한다(Sharing 문서 · Custom persistence). 아래는 기기 간 동기화되는 iCloud 키-값 저장소를 붙이는 예다.

✅ iCloud 키-값 저장소 전략 — load · subscribe · save · id
struct UbiquitousKey<Value: Sendable>: SharedKey {
  let key: String
  var id: String { key }                      // 같은 id = 같은 참조

  func load(context: LoadContext<Value>, continuation: LoadContinuation<Value>) {
    if let value = NSUbiquitousKeyValueStore.default.object(forKey: key) as? Value {
      continuation.resume(returning: value)
    } else {
      continuation.resumeReturningInitialValue()   // 비었으면 선언의 기본값
    }
  }

  func subscribe(
    context: LoadContext<Value>, subscriber: SharedSubscriber<Value>
  ) -> SharedSubscription {
    let token = NotificationCenter.default.addObserver(
      forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
      object: nil, queue: nil
    ) { [key] _ in
      if let value = NSUbiquitousKeyValueStore.default.object(forKey: key) as? Value {
        subscriber.yield(value)                // 다른 기기에서 바뀐 값을 밀어 넣는다
      }
    }
    let box = UncheckedSendable(token)
    return SharedSubscription { NotificationCenter.default.removeObserver(box.value) }
  }

  func save(_ value: Value, context: SaveContext, continuation: SaveContinuation) {
    NSUbiquitousKeyValueStore.default.set(value, forKey: key)
    continuation.resume()
  }
}

extension SharedReaderKey {
  static func ubiquitous<Value>(_ key: String) -> Self where Self == UbiquitousKey<Value> {
    UbiquitousKey(key: key)
  }
}

@Reducer
struct ReaderSettings {
  @ObservableState
  struct State: Equatable {
    @Shared(.ubiquitous("fontSize")) var fontSize = 14   // 내장 전략과 똑같이 쓴다
  }
  enum Action { case biggerTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, _ in
      state.$fontSize.withLock { $0 += 1 }
      return .none
    }
  }
}
🚨 커스텀 전략은 테스트 격리를 직접 챙겨야 한다

위 예는 테스트에서도 진짜 iCloud 저장소에 쓴다. 내장 .appStorage는 defaultAppStorage 의존성으로 UserDefaults를 주입받고, .fileStorage는 테스트에서 [URL: Data] 딕셔너리에 쓰기 때문에 격리된다. 실무용 커스텀 키라면 저장소를 Day 7의 의존성으로 받아, 테스트·프리뷰에서 가짜로 바꿀 수 있게 만든다.

2. 변화 관찰 — 뷰는 자동, 리듀서는 publisher

뷰는 할 일이 없다. body에서 store.count를 읽으면 @Shared 값이 어디서 바뀌든(다른 탭, 다른 기기, 외부 파일 쓰기) body가 다시 계산된다(Sharing 문서 · Observing changes). 폼 필드처럼 SwiftUI 바인딩이 필요하면 Binding($shared)를 쓴다. 공식 Onboarding 케이스 스터디가 이 방식으로 BindableAction 없이 텍스트 필드를 공유 상태에 직접 연결한다.

✅ 뷰는 읽기만 하면 자동 갱신 · Binding(store.$x)로 폼에 직접 연결
struct Profile: Equatable {
  var nickname = ""
}

@Reducer
struct ProfileForm {
  @ObservableState
  struct State: Equatable { @Shared var profile: Profile }
  enum Action { case doneTapped }
  var body: some ReducerOf<Self> {
    Reduce { _, _ in .none }
  }
}

struct ProfileFormView: View {
  let store: StoreOf<ProfileForm>
  var body: some View {
    @Binding(store.$profile) var profile        // Shared → Binding
    Form {
      TextField("닉네임", text: $profile.nickname)   // 입력이 곧바로 공유 값에 쓰인다
      Text("미리보기: \(store.profile.nickname)")
    }
  }
}

리듀서는 다르다. 리듀서는 액션이 들어올 때만 돈다. 다른 기능이 공유 값을 바꾸면 이 리듀서에는 아무 액션도 오지 않는다. 변화에 반응해 로직을 돌려야 하면 $count.publisher를 오래 사는 이펙트로 구독해 액션으로 바꾼다.

✅ 외부 변경을 액션으로 — onAppear에서 publisher 구독
@Reducer
struct CountWatcher {
  @ObservableState
  struct State: Equatable {
    @Shared(.appStorage("count")) var count = 0
    var milestone = ""
  }
  enum Action { case onAppear, countUpdated(Int) }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .onAppear:
        return .publisher {
          state.$count.publisher.map { .countUpdated($0) }
        }
      case let .countUpdated(count):
        state.milestone = count.isMultiple(of: 10) ? "🎉 \(count)" : ""   // 다른 상태만 고친다
        return .none
      }
    }
  }
}
⚠️ publisher는 구독 즉시 현재 값을 한 번 내보낸다

$count.publisher는 구독하는 순간 현재 값을 한 번 먼저 내보낸다(소스 기준 subject.prepend(wrappedValue)). 그래서 onAppear 직후 곧바로 countUpdated(0)이 들어와 milestone이 "🎉 0"이 된다. 첫 방출이 필요 없으면 state.$count.publisher.dropFirst().map { .countUpdated($0) }처럼 .dropFirst()를 붙인다.

count 변경 $count.publisher 방출 .countUpdated 수신 리듀서가 count를 또 고침 count = count + 1
구독한 그 값을 반응 로직 안에서 다시 고치면 고리가 닫힌다. 반응 로직은 다른 상태만 고친다.
❌ 구독한 값을 반응에서 다시 고침 — 무한 루프 (문서가 경고하는 예)
case let .countUpdated(count):
  state.$count.withLock { $0 = count + 1 }   // 또 방출 → 또 수신 → …
  return .none

onChange로는 왜 안 되나

TCA의 .onChange(of:) 리듀서 연산자는 소스 기준으로 "붙은 리듀서를 돌리기 전 값과 후 값"을 비교한다. 문서의 Important 박스 그대로 그 리듀서가 직접 만든 변경만 잡는다(OnChange.swift). 다른 탭이 공유 값을 바꾼 것은 이 리듀서의 액션이 아니므로 놓친다.

도구잡는 변경쓸 때
뷰 body에서 읽기모든 변경화면 갱신 (자동)
Reduce{…}.onChange(of: \.count)이 리듀서가 만든 변경만내 액션으로 바뀐 뒤 후처리
.publisher { state.$count.publisher … }모든 변경외부 변경에 로직으로 반응
SwiftUI .onChange(of: store.count)모든 변경 (뷰가 살아 있는 동안)뷰 레벨 반응. 로직은 액션으로 보낸다
⚠️ @Shared를 품은 타입의 Hashable · Codable

@Shared는 값이 시간에 따라 바뀌는 참조라 Hashable이 아니고, 조건부 Codable도 없다. @Shared를 가진 상태를 Codable로 만들려면 CodingKeys에서 그 프로퍼티를 빼거나 init(from:)·encode(to:)를 직접 쓴다(Sharing 문서 · Gotchas).

3. 공유 상태 테스트 🔥 핵심

참조는 보통 exhaustive 테스트의 적이다. 액션 전후 상태를 복사해 비교해야 하는데 참조는 복사되지 않는다. TCA는 테스트 중에 @Shared가 이전 스냅샷을 따로 보관하게 해서 이 문제를 푼다. 그래서 대부분은 평범한 상태처럼 검증한다(TCA 문서 · Testing shared state).

경로 ① 리듀서가 고침 send(.increment) 스냅샷 0 → 실제 1 전후 비교 send 클로저에서 단언 경로 ② 이펙트가 캡처해서 고침 send(.increment) .run 안에서 withLock 액션 없이 변경 store.assert { … }로 단언 ② 단언을 빠뜨리면: "Test store finished before asserting against changes to shared state"
공유 값은 액션 없이도 바뀔 수 있다. TestStore는 그 변경도 추적해서, 단언하지 않으면 실패시킨다.
✅ 경로 ①·② — send 클로저와 store.assert
@Reducer
struct SharedCounter {
  @ObservableState
  struct State: Equatable { @Shared var count: Int }
  enum Action { case incrementButtonTapped, incrementLaterTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementButtonTapped:
        state.$count.withLock { $0 += 1 }                 // ① 리듀서에서
        return .none
      case .incrementLaterTapped:
        return .run { [sharedCount = state.$count] _ in   // ② 이펙트가 참조를 캡처
          sharedCount.withLock { $0 += 1 }
        }
      }
    }
  }
}

@MainActor
func testSharedCounter() async {
  let store = TestStore(initialState: SharedCounter.State(count: Shared(value: 0))) {
    SharedCounter()
  }
  await store.send(.incrementButtonTapped) {
    $0.$count.withLock { $0 = 1 }
  }
  await store.send(.incrementLaterTapped)   // 액션으로는 변화가 안 보인다
  store.assert {
    $0.$count.withLock { $0 = 2 }           // 빠뜨리면 테스트 실패
  }
  await store.finish()                      // 단언을 마친 뒤에 이펙트 종료 확인
}
🚨 finish()를 assert보다 먼저 부르면 실패

finish()를 assert보다 먼저 부르면 실패한다. 1.26.2 실측으로 send → finish → assert는 20번 중 20번 "Test store finished before asserting against changes to shared state"로 떨어지고, send → assert → finish는 20번 모두 통과했다.

함정 — 문서의 Gotchas 설명과 실제 동작이 다르다

TCA 문서(TCA 문서 · Sharing state (Gotchas))는 "TestStore가 이펙트가 보낸 액션을 단언 전에 미리 처리하므로, .response가 바꾼 공유 값은 첫 액션 send(.tap)에서 단언하라"고 적는다. 그런데 1.26.2에서 직접 돌려 보면 반대다. 문서대로 send(.tap) { … }에 단언을 달면 매번 2건으로 실패하고, 변경을 만든 액션의 receive에 달면 통과한다. 결론: 공유 값 변경은 그 변경을 만든 액션의 receive에서 단언한다(1.26.2 실측, 문서와 다름).

✅ response가 바꾼 공유 값을 receive(\.response)에서 단언 (1.26.2 실측 20/20 통과)
@Reducer
struct TapFeature {
  @ObservableState
  struct State: Equatable { @Shared(value: false) var isOn }
  enum Action { case tap, response }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .tap:
        return .run { send in await send(.response) }
      case .response:
        state.$isOn.withLock { $0.toggle() }
        return .none
      }
    }
  }
}

@MainActor
func testTapFeature() async {
  let store = TestStore(initialState: TapFeature.State()) { TapFeature() }
  await store.send(.tap)                // .tap 자체는 상태를 바꾸지 않는다
  await store.receive(\.response) {
    $0.$isOn.withLock { $0 = true }     // 변경을 만든 액션에서 단언
  }
}

영속 전략은 테스트에서 자동 격리된다

테스트에서 .appStorage는 테스트마다 임시 UserDefaults를, .fileStorage는 실제 디스크 대신 메모리 딕셔너리를 쓴다. 병렬로 돌려도 서로 오염되지 않는다(Sharing 문서 · Testing). 초기값이 필요하면 테스트 시작에서 같은 키를 선언한다. 테스트 안에서도 공유 참조를 직접 잡고 단언하면, 리팩터링 중 한 기능에서 @Shared가 빠졌을 때 테스트가 실패해서 알려 준다.

✅ 테스트에서 공유 값 준비 + 참조로 단언
@Reducer
struct StarFeature {
  @ObservableState
  struct State: Equatable { @Shared(.appStorage("stars")) var stars = 0 }
  enum Action { case starTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, _ in
      state.$stars.withLock { $0 += 1 }
      return .none
    }
  }
}

@MainActor
func testStars() async {
  @Shared(.appStorage("stars")) var stars = 41
  $stars.withLock { $0 = 41 }      // 앱 타깃이 먼저 접근했어도 다시 덮는다
  let store = TestStore(initialState: StarFeature.State()) { StarFeature() }
  await store.send(.starTapped) { _ in
    $stars.withLock { $0 = 42 }    // $0 대신 테스트가 잡은 참조로 단언
  }
}
🚨 앱 타깃 테스트의 조기 접근

테스트가 앱 타깃 안에 있으면 앱 진입점이 먼저 실행되어 @Shared의 기본값을 먼저 잡아 버릴 수 있다(Day 9의 테스트 호스트 함정). 가장 확실한 해법은 테스트 중에는 진입점을 실행하지 않는 것이다. if !isTesting { … }로 앱 본문을 감싼다. UI 테스트에서는 진입점에서 prepareDependencies { $0.defaultAppStorage = …; $0.defaultFileStorage = .inMemory }로 영속을 끈다. 진입점 문서 예제의 count = 42 같은 직접 대입은 현재 setter가 deprecated라서, 위처럼 withLock으로 쓴다.

정리 — 무엇을 언제 쓰나

@AppStorage@Environment + @Observable@Shared
둘 수 있는 곳뷰만뷰에서 꺼내 씀 (모델은 어디든)리듀서 상태·모델·뷰·UIKit
영속UserDefaults없음 (직접 구현)inMemory·appStorage·fileStorage·커스텀
값 타입단순 타입·RawRepresentable제한 없음전략마다 (fileStorage는 Codable)
동시 변경 보호main actor에 기댐모델 격리에 기댐withLock 강제
테스트실제 UserDefaults모델 직접 생성자동 격리 + exhaustive 단언

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
부모가 가진 비영속 공유 값을 자식이 빌려 쓴다. 자식 State의 init은 어떤 모양이어야 하고, 왜 init(count: Int)는 안 되나?
init(count: Shared<Int>) { _count = count }이다. Int를 받으면 자식이 Shared(value:)로 새 저장소를 만들게 되어 부모와 끊긴다. 파라미터 타입을 Shared<Int>로 두면 호출부가 state.$count를 넘길 수밖에 없다.
쉽게 말하면 "공책을 달라"고 적어 두면 복사본을 줄 수 없다. "공책에 적힌 숫자를 달라"고 하면 숫자만 베껴 주게 된다.
다른 탭에서 바뀐 공유 count에 반응해 알림을 띄우고 싶다. .onChange(of: \.count)가 안 되는 이유와 대안은?
onChange는 붙은 리듀서를 돌리기 전후 값을 비교하므로, 그 리듀서에 들어온 액션으로 바뀐 경우만 잡는다. 다른 탭의 변경은 이 리듀서의 액션이 아니다. onAppear에서 .publisher { state.$count.publisher.map { .countUpdated($0) } }로 구독하고, 반응 로직에서는 count를 다시 고치지 않는다(무한 루프).
쉽게 말하면 onChange는 "내가 방금 한 일"만 확인하는 거울이다. 남이 한 일을 알려면 초인종(publisher)을 달아야 한다.
🔮 코드 예측: 3절 TapFeature 테스트를 문서 Gotchas 절이 권하는 대로 아래처럼 바꾸면 어떻게 되나?
await store.send(.tap) { $0.$isOn.withLock { $0 = true } }
await store.receive(\.response)
두 곳에서 실패한다(1.26.2 실측 — TCA 문서의 Gotchas 절 설명과 다르다). .tap을 처리한 직후에는 아직 isOn이 false다. 그래서 send(.tap)에서 "A state change does not match expectation"(단언은 true인데 실제는 그대로)으로 실패하고, 이어서 .response가 실제로 값을 바꾸는데 receive에 클로저가 없으니 "State was not expected to change, but a change occurred"로 또 실패한다. 3절 본문 코드처럼 send(.tap) + receive(\.response) { … }로 두면 20번 모두 0건으로 통과한다.
쉽게 말하면 아직 불을 켜지도 않았는데 "불 켜졌지?"라고 적어서 한 번 틀리고, 정작 불이 켜질 때는 아무것도 안 적어서 또 틀린다. 불이 켜진 그 순간(receive)에 적어야 맞다.
🔁 1부 복습: let c = state.count; state.$count.withLock { $0 = c + 1 }은 왜 여전히 위험한가? @AppStorage는 왜 같은 문제가 있어도 보통 괜찮은가?
읽기가 잠금 밖에서 일어나서, 읽은 뒤 쓰기 전에 다른 스레드가 값을 바꾸면 그 변경을 덮어쓴다. 읽고-고치고-쓰기를 모두 withLock 하나 안에 넣어야 한다. @AppStorage는 뷰에서만 쓰이고 뷰는 main actor에서 돌아서 접근이 대부분 한 줄로 서기 때문이다. @Shared는 어디서나 쓰이므로 잠금을 강제한다.
쉽게 말하면 칠판 숫자를 보고 자리로 돌아가 계산한 뒤 고치러 가면, 그 사이 친구가 먼저 고쳐 놨을 수 있다. 칠판 앞에서 보고·계산하고·고치기를 한 번에 해야 한다.

🛠 미니 과제 (선택 · 10분)

설정 화면과 홈 화면이 햅틱 설정을 공유하게 만든다. 시작 코드는 다음과 같다.

시작 코드 — 두 기능이 각자 Bool을 들고 있다
@Reducer
struct TaskSettings {
  @ObservableState
  struct State: Equatable { var isHapticsOn = true }
  enum Action { case toggleTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, _ in
      state.isHapticsOn.toggle()
      return .none
    }
  }
}

@Reducer
struct TaskHome {
  @ObservableState
  struct State: Equatable { var isHapticsOn = true }
  enum Action { case onAppear }
  var body: some ReducerOf<Self> {
    Reduce { _, _ in .none }
  }
}

확인 방법: 테스트가 통과하고, TaskHome에서 state.isHapticsOn = false를 쓰면 컴파일 에러가 나는지 본다.

정답 보기
정답 — 키 한 곳, 쓰는 쪽 @Shared, 읽는 쪽 @SharedReader
extension SharedReaderKey where Self == AppStorageKey<Bool>.Default {
  static var isHapticsOn: Self {
    Self[.appStorage("isHapticsOn"), default: true]
  }
}

@Reducer
struct TaskSettings {
  @ObservableState
  struct State: Equatable { @Shared(.isHapticsOn) var isHapticsOn }
  enum Action { case toggleTapped }
  var body: some ReducerOf<Self> {
    Reduce { state, _ in
      state.$isHapticsOn.withLock { $0.toggle() }
      return .none
    }
  }
}

@Reducer
struct TaskHome {
  @ObservableState
  struct State: Equatable { @SharedReader(.isHapticsOn) var isHapticsOn }
  enum Action { case onAppear }
  var body: some ReducerOf<Self> {
    Reduce { _, _ in .none }
  }
}

@MainActor
func testHapticsShared() async {
  let store = TestStore(initialState: TaskSettings.State()) { TaskSettings() }
  await store.send(.toggleTapped) {
    $0.$isHapticsOn.withLock { $0 = false }
  }
  let home = TaskHome.State()
  assert(home.isHapticsOn == false)   // 같은 키 → 같은 참조
}

테스트 안에서는 .appStorage가 임시 UserDefaults를 쓰므로 실제 앱 설정을 건드리지 않는다. @Test를 쓴다면 assert 대신 #expect를 쓴다.

출처 · 참고자료