@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).
// ① 영속: 평범한 값을 받아 키와 함께 만든다. @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를 넘기도록 강제한다.
| 상황 | SwiftUI | TCA (@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 키-값 저장소를 붙이는 예다.
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 없이 텍스트 필드를 공유 상태에 직접 연결한다.
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를 오래 사는 이펙트로 구독해 액션으로 바꾼다.
@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
}
}
}
}$count.publisher는 구독하는 순간 현재 값을 한 번 먼저 내보낸다(소스 기준 subject.prepend(wrappedValue)). 그래서 onAppear 직후 곧바로 countUpdated(0)이 들어와 milestone이 "🎉 0"이 된다. 첫 방출이 필요 없으면 state.$count.publisher.dropFirst().map { .countUpdated($0) }처럼 .dropFirst()를 붙인다.
case let .countUpdated(count):
state.$count.withLock { $0 = count + 1 } // 또 방출 → 또 수신 → …
return .noneonChange로는 왜 안 되나
TCA의 .onChange(of:) 리듀서 연산자는 소스 기준으로 "붙은 리듀서를 돌리기 전 값과 후 값"을 비교한다. 문서의 Important 박스 그대로 그 리듀서가 직접 만든 변경만 잡는다(OnChange.swift). 다른 탭이 공유 값을 바꾼 것은 이 리듀서의 액션이 아니므로 놓친다.
| 도구 | 잡는 변경 | 쓸 때 |
|---|---|---|
| 뷰 body에서 읽기 | 모든 변경 | 화면 갱신 (자동) |
Reduce{…}.onChange(of: \.count) | 이 리듀서가 만든 변경만 | 내 액션으로 바뀐 뒤 후처리 |
.publisher { state.$count.publisher … } | 모든 변경 | 외부 변경에 로직으로 반응 |
SwiftUI .onChange(of: store.count) | 모든 변경 (뷰가 살아 있는 동안) | 뷰 레벨 반응. 로직은 액션으로 보낸다 |
@Shared는 값이 시간에 따라 바뀌는 참조라 Hashable이 아니고, 조건부 Codable도 없다. @Shared를 가진 상태를 Codable로 만들려면 CodingKeys에서 그 프로퍼티를 빼거나 init(from:)·encode(to:)를 직접 쓴다(Sharing 문서 · Gotchas).
3. 공유 상태 테스트 🔥 핵심
참조는 보통 exhaustive 테스트의 적이다. 액션 전후 상태를 복사해 비교해야 하는데 참조는 복사되지 않는다. TCA는 테스트 중에 @Shared가 이전 스냅샷을 따로 보관하게 해서 이 문제를 푼다. 그래서 대부분은 평범한 상태처럼 검증한다(TCA 문서 · Testing shared state).
@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보다 먼저 부르면 실패한다. 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 실측, 문서와 다름).
@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를 다시 고치지 않는다(무한 루프).🔮 코드 예측: 3절 TapFeature 테스트를 문서 Gotchas 절이 권하는 대로 아래처럼 바꾸면 어떻게 되나?
await store.send(.tap) { $0.$isOn.withLock { $0 = true } }
await store.receive(\.response)
.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분)
설정 화면과 홈 화면이 햅틱 설정을 공유하게 만든다. 시작 코드는 다음과 같다.
@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 }
}
}- 요구 1: 타입 안전 키
.isHapticsOn(AppStorageKey<Bool>.Default, 기본값true)을 만든다. - 요구 2:
TaskSettings는@Shared(.isHapticsOn),TaskHome은@SharedReader(.isHapticsOn)로 바꾼다. 쓰기는withLock. - 요구 3: TestStore 테스트에서 설정 토글 후, 새로 만든
TaskHome.State()의 값이false인지 확인한다.
확인 방법: 테스트가 통과하고, TaskHome에서 state.isHapticsOn = false를 쓰면 컴파일 에러가 나는지 본다.
정답 보기
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를 쓴다.