@Observable 클래스 하나를 @Environment로 뿌리거나 @AppStorage를 썼다(SwiftUI 코스 Day 5·Day 13). 클래스는 참조라서 누가 고치든 모두가 같은 값을 본다. TCA의 State는 struct다. 자식에게 넘긴 값은 복사본이라서, 자식이 고쳐도 부모는 모른다. 이 틈을 메우는 도구가 @Shared다. 1부는 @Shared를 쓰는 법이다. 명시적 공유, 세 가지 영속 전략, withLock, @SharedReader, 타입 안전 키까지 간다.1부 목표 · 30분
- 자식에게 값을 복사해 넘겨 동기화가 깨진 코드를 찾아
state.$count전달로 고친다. .inMemory·.appStorage·.fileStorage중 데이터에 맞는 전략을 고르고,@AppStorage와 무엇이 다른지 말한다.state.count += 1을state.$count.withLock { $0 += 1 }로 바꾸고, 왜 잠금이 필요한지 레이스 타임라인으로 설명한다.- 여러 곳에 흩어진
@Shared(.fileStorage(…))선언을 타입 안전 키 하나로 모은다.
1. 값 타입은 복사된다 — 명시적 @Shared 🔥 핵심
공식 문서는 공유 상태를 이렇게 정의한다. 여러 기능이 같은 데이터에 접근하고, 한 기능이 고치면 다른 모든 기능에 즉시 보이는 것이다. 문서는 바로 이어서 이 성질이 값 타입과 잘 맞지 않는다고 인정한다(TCA 문서 · Sharing state).
"Such sharing can be really handy, but also does not play nicely with value types, which are copied rather than shared."
공유는 편하지만 값 타입과는 궁합이 나쁘다. 값 타입은 공유되지 않고 복사되기 때문이다. TCA는 도메인을 값 타입으로 모델링하므로 공유 도구가 따로 필요하다.
먼저 문제를 코드로 본다. 부모가 자기 count를 자식 상태에 그냥 넣으면, 그 순간 값이 복사된다. 자식 화면에서 +를 눌러도 부모 화면의 숫자는 그대로다.
@Reducer
struct CopyParent {
@ObservableState
struct State: Equatable {
var count = 0
@Presents var child: CopyChild.State?
}
enum Action {
case child(PresentationAction<CopyChild.Action>)
case presentButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .presentButtonTapped:
state.child = CopyChild.State(count: state.count) // 여기서 복사된다
return .none
case .child:
return .none
}
}
.ifLet(\.$child, action: \.child) { CopyChild() }
}
}
@Reducer
struct CopyChild {
@ObservableState
struct State: Equatable { var count: Int }
enum Action { case incrementButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.count += 1 // 자식 복사본만 바뀐다
return .none
}
}
}Day 5에서 배운 delegate 액션으로 부모에게 "나 바뀌었어"를 알릴 수도 있다. 하지만 공유하는 값이 많고 화면 깊이가 깊어지면 액션 배관이 끝없이 늘어난다. @Shared는 이 배관을 없앤다. 양쪽이 모두 @Shared로 선언하고, 부모는 값 대신 참조($count)를 넘긴다.
@Reducer
struct ParentFeature {
@ObservableState
struct State: Equatable {
@Shared var count: Int
@Presents var child: ChildFeature.State?
}
enum Action {
case child(PresentationAction<ChildFeature.Action>)
case presentButtonTapped
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .presentButtonTapped:
state.child = ChildFeature.State(count: state.$count) // Shared<Int> 전달
return .none
case .child:
return .none
}
}
.ifLet(\.$child, action: \.child) { ChildFeature() }
}
}
@Reducer
struct ChildFeature {
@ObservableState
struct State: Equatable { @Shared var count: Int }
enum Action { case incrementButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.$count.withLock { $0 += 1 } // 부모의 count도 같이 바뀐다
return .none
}
}
}
// 루트에서 만들 때: 진실의 주인이 여기라면 Shared(value:)로 새로 만든다
let rootStore = Store(initialState: ParentFeature.State(count: Shared(value: 0))) {
ParentFeature()
}일부만 떼어 넘기기 — 파생
공유 값 전체가 아니라 필드 하나만 필요한 자식도 있다. $로 얻은 Shared에 점을 이어 붙이면 그 필드만 가리키는 Shared<String>이 나온다. 자식이 고치면 원본 구조체의 그 필드가 바뀐다(Sharing 문서 · Deriving shared state). 공식 케이스 스터디 SharedState-Onboarding의 가입 흐름이 이 방식이다.
struct SignUpData: Equatable {
var email = ""
var phoneNumber = ""
}
@Reducer
struct PhoneNumberFeature {
@ObservableState
struct State: Equatable { @Shared var phoneNumber: String } // 전체를 몰라도 된다
enum Action { case clearButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.$phoneNumber.withLock { $0 = "" }
return .none
}
}
}
@Reducer
struct SignUpFeature {
@ObservableState
struct State: Equatable {
@Shared var signUpData: SignUpData
@Presents var phone: PhoneNumberFeature.State?
}
enum Action {
case nextButtonTapped
case phone(PresentationAction<PhoneNumberFeature.Action>)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .nextButtonTapped:
state.phone = PhoneNumberFeature.State(phoneNumber: state.$signUpData.phoneNumber)
return .none
case .phone:
return .none
}
}
.ifLet(\.$phone, action: \.phone) { PhoneNumberFeature() }
}
}문서는 @Shared를 "SwiftUI @Bindable(또는 @Binding)의 TCA판"으로 생각하라고 한다. 진실은 다른 곳에 있고, 나는 최신 값을 읽고 쓸 손잡이만 들고 있다는 뜻이다. 차이는 @Binding이 뷰 안에서만 쓰이는 반면 @Shared는 리듀서 상태, @Observable 모델, UIKit 컨트롤러 어디에나 둘 수 있다는 점이다.
@Observable
final class CounterModel {
var count = 0
}
struct CounterParentView: View {
@State private var model = CounterModel()
@State private var isChildShown = false
var body: some View {
Button("부모: \(model.count)") { isChildShown = true }
.sheet(isPresented: $isChildShown) {
CounterChildView(model: model) // 같은 인스턴스를 넘긴다
}
}
}
struct CounterChildView: View {
let model: CounterModel
var body: some View {
Button("자식: \(model.count) +1") { model.count += 1 } // 부모 화면도 바뀐다
}
}| 할 일 | SwiftUI (@Observable) | TCA (@Shared) |
|---|---|---|
| 공유가 되는 이유 | 모델이 클래스(참조) | struct 안의 @Shared가 바깥 참조를 가리킴 |
| 자식에게 넘기기 | 모델 인스턴스 전달 | ChildFeature.State(count: state.$count) |
| 필드 하나만 넘기기 | $model.name (Binding, 뷰 안에서만) | state.$data.name → Shared<String> (어디서나) |
| 고치기 | model.count += 1 | state.$count.withLock { $0 += 1 } |
| 변경 추적·테스트 | 직접 관리 | TestStore가 전후 스냅샷으로 검증(2부) |
2. 앱 전역 공유와 영속 — inMemory · appStorage · fileStorage 🔥 핵심
1절의 방식은 부모가 직접 참조를 건네야 한다. 탭 두 개처럼 부모-자식 관계가 아닌 기능끼리는 건넬 길이 없다. 이때 @Shared에 키(SharedKey)를 준다. 같은 키로 선언한 모든 @Shared는 같은 참조를 받는다. 라이브러리가 제공하는 전략은 세 가지다(Sharing 문서 · Persistence strategies).
| 전략 | 저장 위치 | 값 제약 | 앱 재실행 후 | SwiftUI에서 비슷한 것 |
|---|---|---|---|---|
.inMemory("key") | 메모리 | 없음 | 기본값으로 리셋 | 앱 루트에서 @Environment로 뿌린 모델 |
.appStorage("key") | UserDefaults | 문자열·Bool·정수·Double·URL·Data 등 단순 타입 | 남는다 | @AppStorage |
.fileStorage(url) | 파일(JSON) | Codable | 남는다 | 직접 파일 저장 또는 SwiftData |
공식 케이스 스터디 SharedState-UserDefaults는 카운터 탭과 프로필 탭이 서로의 상태를 모른 채 같은 count를 본다. 두 탭은 서로 다른 모듈에 있어도 된다. 부모 리듀서는 둘을 Scope로 붙이기만 하고 동기화 코드는 한 줄도 없다.
@Reducer
struct CounterTab {
@ObservableState
struct State: Equatable {
@Shared(.appStorage("count")) var count = 0 // 기본값: 저장소가 비었을 때만 쓰인다
}
enum Action { case incrementButtonTapped, decrementButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.$count.withLock { $0 += 1 }
return .none
case .decrementButtonTapped:
state.$count.withLock { $0 -= 1 }
return .none
}
}
}
}
@Reducer
struct ProfileTab {
@ObservableState
struct State: Equatable {
@Shared(.appStorage("count")) var count = 0 // 같은 키 → 같은 참조
}
enum Action { case resetButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.$count.withLock { $0 = 0 } // 카운터 탭 숫자도 0이 된다
return .none
}
}
}"When using a persistence strategy with @Shared you must provide a default value, which is used for the first access of the shared state."
영속 전략을 쓰면 기본값이 필수다. 이 기본값은 첫 접근에만 쓰인다. 저장소에 이미 값이 있으면 무시된다. 그래서 = 0을 "초기화 값"이 아니라 "비었을 때의 대체값"으로 읽어야 한다. 커스텀 init에서의 규칙은 2부에서 본다.
구조체처럼 복잡한 값은 .appStorage에 들어가지 않는다. Codable로 만들고 .fileStorage를 쓴다. 이 전략은 값을 JSON으로 직렬화해 파일에 쓰고, 읽을 때 역직렬화한다.
struct User: Codable, Equatable, Identifiable, Sendable {
let id: Int
var name: String
}
extension URL {
static let usersURL = URL.documentsDirectory.appending(component: "users.json")
}
@Reducer
struct UsersFeature {
@ObservableState
struct State: Equatable {
@Shared(.fileStorage(.usersURL)) var users: [User] = []
}
enum Action { case addButtonTapped }
var body: some ReducerOf<Self> {
Reduce { state, action in
state.$users.withLock { $0.append(User(id: $0.count + 1, name: "새 사용자")) }
return .none // 파일 쓰기는 전략이 알아서 한다
}
}
}@AppStorage와 무엇이 다른가
Apple 문서는 @AppStorage를 "UserDefaults 값을 반영하고, 그 값이 바뀌면 뷰를 무효화하는 프로퍼티 래퍼"라고 정의한다(Apple 문서 · AppStorage). 뷰에 붙이는 도구다. 리듀서나 모델에 두면 관찰이 동작하지 않는다. @Shared(.appStorage)는 같은 UserDefaults를 쓰면서 리듀서 상태 안에 들어가고, 테스트에서는 격리된 저장소로 바뀐다(2부).
struct SettingsView: View {
@AppStorage("hapticsOn") private var hapticsOn = true // 뷰가 소유, 바뀌면 body 재계산
var body: some View {
Toggle("햅틱", isOn: $hapticsOn)
}
}소스 기준(AppStorageKey.swift): 키에 .이 들어 있거나 @로 시작하면 KVO(키-값 관찰)를 쓸 수 없어 이슈를 보고하고, 덜 정확한 NotificationCenter 관찰로 물러난다. 구분자가 필요하면 -나 /를 쓴다("settings-haptics").
3. 고치기는 withLock, 읽기만은 @SharedReader, 키는 타입 안전하게
왜 그냥 대입하면 안 되나
@Shared는 값처럼 보이지만 속은 참조다. 여러 스레드가 같은 참조를 고칠 수 있다. count += 1은 한 줄이지만 실제로는 "읽기 → 1 더하기 → 쓰기" 세 단계라서, 두 스레드가 끼어들면 한 번의 증가가 사라진다. Sharing 문서는 @AppStorage도 이 문제를 갖고 있다고 보여 준다. 태스크 1,000개가 @AppStorage 정수를 1씩 올리면 1,000보다 훨씬 적게 올라간다. 경고도 에러도 없이 컴파일된다(Sharing 문서 · Mutating shared state).
@AppStorage는 뷰 안에서만 쓰이고 뷰는 main actor에서 돌기 때문에 실제로는 거의 문제가 되지 않는다. @Shared는 모델, UIKit, actor 어디에나 놓일 수 있어서 이 보호막이 없다. 그래서 라이브러리는 직접 대입 대신 withLock을 요구한다.
case .incrementButtonTapped:
state.count += 1
return .nonelet currentCount = state.count
state.$count.withLock { $0 = currentCount + 1 }@Reducer
struct ScoreFeature {
@ObservableState
struct State: Equatable {
@Shared(.inMemory("score")) var score = 0
@Shared(.inMemory("bestScore")) var bestScore = 0
}
enum Action { case pointScored }
var body: some ReducerOf<Self> {
Reduce { state, action in
let newScore = state.$score.withLock { score -> Int in
score += 1
return score // withLock은 클로저의 반환값을 돌려준다
}
state.$bestScore.withLock { $0 = max($0, newScore) }
return .none
}
}
}문서도 "코드에서 레이스를 100% 막을 방법은 없다, actor도 재진입 문제가 있다"고 인정한다. 권장은 하나다. 가능한 한 많은 변경을 withLock 하나에 몰아넣는다.
@SharedReader — 읽기만 허용
자식이 값을 보기만 해야 하거나, 원격 설정처럼 쓰기 자체가 의미 없는 데이터라면 @SharedReader를 쓴다. 모든 영속 전략과 함께 쓸 수 있고, 쓰려고 하면 컴파일 에러다. 쓰기 가능한 Shared는 SharedReader($x)로 읽기 전용으로 좁혀 넘길 수 있다.
@Reducer
struct HapticsBadge {
@ObservableState
struct State: Equatable {
@SharedReader var isHapticsEnabled: Bool // 여기서 쓰기 시도 = 컴파일 에러
}
enum Action { case onAppear }
var body: some ReducerOf<Self> {
Reduce { _, _ in .none }
}
}
@Reducer
struct SettingsFeature {
@ObservableState
struct State: Equatable {
@Shared(.appStorage("isHapticsEnabled")) var isHapticsEnabled = true
var badge: HapticsBadge.State
init() {
// $isHapticsEnabled를 init 안에서 바로 쓰면 빌드 에러
// ("variable 'self._badge' used before being initialized") → Shared를 먼저 만들어 둘 다에 넘긴다
let isHapticsEnabled = Shared(wrappedValue: true, .appStorage("isHapticsEnabled"))
_isHapticsEnabled = isHapticsEnabled
badge = HapticsBadge.State(isHapticsEnabled: SharedReader(isHapticsEnabled))
}
}
enum Action { case badge(HapticsBadge.Action), toggleTapped }
var body: some ReducerOf<Self> {
Scope(\.badge, action: \.badge) { HapticsBadge() }
Reduce { state, action in
switch action {
case .toggleTapped:
state.$isHapticsEnabled.withLock { $0.toggle() }
return .none
case .badge:
return .none
}
}
}
}타입 안전 키 — 선언이 흩어지면 조용히 깨진다
같은 파일을 여러 기능이 @Shared(.fileStorage(.usersURL))로 선언했다고 하자. 어느 날 한 곳만 IdentifiedArrayOf<User>로 바꾸면 여전히 컴파일되지만, 두 선언은 더 이상 같은 상태를 공유하지 않는다(Sharing 문서 · Type-safe keys). 해법은 키와 값 타입(그리고 기본값)을 SharedReaderKey 확장 한 곳에 못 박는 것이다.
// 목록 화면
@Shared(.fileStorage(.usersURL)) var users: [User] = []
// 다른 화면 (리팩터링 중 한 곳만 바꿈)
@Shared(.fileStorage(.usersURL)) var users: IdentifiedArrayOf<User> = []extension SharedReaderKey where Self == FileStorageKey<IdentifiedArrayOf<User>>.Default {
static var users: Self {
Self[.fileStorage(.documentsDirectory.appending(component: "users.json")), default: []]
}
}
extension SharedReaderKey where Self == AppStorageKey<Bool>.Default {
static var isHapticsOn: Self {
Self[.appStorage("isHapticsOn"), default: true]
}
}
@Reducer
struct UserListFeature {
@ObservableState
struct State: Equatable {
@Shared(.users) var users // 타입·기본값 생략 가능
@SharedReader(.isHapticsOn) var isHapticsOn
}
enum Action { case deleteButtonTapped(User.ID) }
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case let .deleteButtonTapped(id):
_ = state.$users.withLock { $0.remove(id: id) }
return .none
}
}
}
}이제 @Shared(.users) var users: [User] = []처럼 다른 타입으로 선언하면 바로 컴파일 에러가 난다. 공식 케이스 스터디도 extension SharedKey where Self == InMemoryKey<Stats>로 키를 fileprivate static var stats에 모아 둔다. .inMemory·.appStorage·커스텀 전략 모두 같은 방식이 통한다.
✅ 복습 · 5분
TCA에서 부모 State의 count를 자식 State에 그냥 넣으면 왜 동기화가 깨지나? 무엇으로 고치나?
@Shared var count: Int로 선언하고, 부모가 ChildFeature.State(count: state.$count)처럼 참조를 넘긴다. 부모-자식 관계가 없는 기능끼리는 같은 키(.inMemory("count") 등)로 선언한다.$count는 "이 공책 같이 쓰자"는 표시다.@Shared(.appStorage("count")) var count = 0에서 0은 언제 쓰이나? @AppStorage와 가장 큰 차이는?
0은 저장소에 값이 없을 때, 첫 접근에서만 쓰인다. 이미 저장된 값이 있으면 무시된다. @AppStorage는 뷰를 무효화하는 뷰 전용 래퍼라 리듀서 상태에 둘 수 없다. @Shared(.appStorage)는 리듀서·모델·UIKit 어디에나 두고, 같은 키끼리 자동으로 동기화된다.@Reducer
struct Box {
@ObservableState
struct State: Equatable { @Shared var count: Int }
enum Action { case plus }
var body: some ReducerOf<Self> {
Reduce { state, _ in
state.$count.withLock { $0 += 1 }
return .none
}
}
}
@Reducer
struct Holder {
@ObservableState
struct State: Equatable {
@Shared var count: Int
var a: Box.State?
var b: Box.State?
}
enum Action { case make, a(Box.Action), b(Box.Action) }
var body: some ReducerOf<Self> {
Reduce { state, action in
if case .make = action {
state.a = Box.State(count: state.$count) // 참조 전달
state.b = Box.State(count: Shared(value: state.count)) // 새 저장소
}
return .none
}
.ifLet(\.a, action: \.a) { Box() }
.ifLet(\.b, action: \.b) { Box() }
}
}
// 보낸 순서: .make → .a(.plus) → .b(.plus) → .b(.plus)🔮 코드 예측: 위 순서대로 액션을 보낸 뒤 부모 count, a.count, b.count는 각각 몇인가?
a.count 1, b.count 2. a는 state.$count, 즉 부모와 같은 참조를 받았으므로 .a(.plus) 한 번이 부모에도 반영된다. b는 Shared(value: state.count)로 그 시점 값(0)을 담은 새 저장소를 받았다. 그래서 b에서 두 번 올린 것은 부모와 무관하다. Shared(value:)는 "진실의 주인이 여기"일 때 쓰는 생성자다.🔁 Day 11 복습: 스택의 특정 화면에 액션을 보내거나 그 화면을 pop할 때, 인덱스 대신 무엇으로 가리키나? 왜?
StackElementID다. StackState가 요소를 추가할 때마다 자동으로 붙이는 식별자라, 앞쪽 화면이 pop되어 인덱스가 밀려도 같은 화면을 정확히 가리킨다. .path(.element(id: id, action: …)), state.path.pop(from: id)처럼 쓴다.2부는 공유 상태를 "운영"하는 법이다. 커스텀 init에서 @Shared를 초기화하는 세 가지 규칙, 나만의 SharedKey 만들기, 변화 관찰(publisher·onChange)과 무한 루프 함정, 그리고 TestStore로 공유 상태를 검증하는 법을 본다. 마지막에 @AppStorage·@Environment와 전체 비교표로 정리한다.