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

Day 12 · 1부 · @Shared — 값 타입 앱에서 상태 공유하기

struct 상태는 복사된다 — @Shared로 참조를 나누고, 키로 앱 전역에 공유·영속하고, withLock으로 안전하게 고친다.
진행률
0 / 0 완료
SwiftUI에서는 여러 화면이 같은 데이터를 볼 때 @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).

📝 TCA 문서 원문

"Such sharing can be really handy, but also does not play nicely with value types, which are copied rather than shared."

공유는 편하지만 값 타입과는 궁합이 나쁘다. 값 타입은 공유되지 않고 복사되기 때문이다. TCA는 도메인을 값 타입으로 모델링하므로 공유 도구가 따로 필요하다.

먼저 문제를 코드로 본다. 부모가 자기 count를 자식 상태에 그냥 넣으면, 그 순간 값이 복사된다. 자식 화면에서 +를 눌러도 부모 화면의 숫자는 그대로다.

❌ Int를 그대로 넘김 — 자식이 고친 값이 부모에 안 돌아온다
@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)를 넘긴다.

✅ 부모·자식 모두 @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()
}
값 전달 (var count: Int) 참조 전달 (@Shared) 부모 State count = 3 자식 State(복사본) count = 4 넘기는 순간 복사 자식이 +1 → 자식만 4 부모 화면은 여전히 3 부모 @Shared 자식 @Shared 참조 저장소 하나 count = 4 누가 고치든 둘 다 4를 본다
@Shared는 struct 안에 들어 있지만 실제 값은 바깥의 참조 저장소에 있다. $count는 그 저장소를 가리키는 손잡이다.

일부만 떼어 넘기기 — 파생

공유 값 전체가 아니라 필드 하나만 필요한 자식도 있다. $로 얻은 Shared에 점을 이어 붙이면 그 필드만 가리키는 Shared<String>이 나온다. 자식이 고치면 원본 구조체의 그 필드가 바뀐다(Sharing 문서 · Deriving shared state). 공식 케이스 스터디 SharedState-Onboarding의 가입 흐름이 이 방식이다.

✅ Shared<SignUpData>에서 Shared<String>만 떼어 전화번호 화면에 넘긴다
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 컨트롤러 어디에나 둘 수 있다는 점이다.

SwiftUI(@Observable)로는 — 클래스 참조를 넘기면 자동으로 공유된다
@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 += 1state.$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로 붙이기만 하고 동기화 코드는 한 줄도 없다.

✅ 두 기능이 같은 키 "count"로 선언 — 부모 배관 없이 동기화 + UserDefaults 저장
@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
    }
  }
}
CounterTab.State .appStorage("count") ProfileTab.State .appStorage("count") 키로 찾은 참조 1개 count = 7 UserDefaults "count" → 7 저장 구독 부모는 두 탭을 Scope로 붙이기만 한다 — 동기화 코드 0줄
같은 키는 같은 참조다. 참조가 저장소에 쓰고, 저장소 쪽 변경(다른 프로세스·외부 쓰기)도 구독해 돌려받는다.
📝 TCA 문서 원문

"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으로 직렬화해 파일에 쓰고, 읽을 때 역직렬화한다.

✅ Codable 배열은 .fileStorage — URL은 한 곳에 모아 둔다
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부).

SwiftUI로는 — @AppStorage는 뷰 안에서 쓴다
struct SettingsView: View {
  @AppStorage("hapticsOn") private var hapticsOn = true   // 뷰가 소유, 바뀌면 body 재계산
  var body: some View {
    Toggle("햅틱", isOn: $hapticsOn)
  }
}
⚠️ appStorage 키에 점(.)이나 @로 시작하는 이름을 쓰지 않는다

소스 기준(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).

잠금 없음 (count += 1) 스레드 A 스레드 B 읽기 5 읽기 5 쓰기 6 쓰기 6 두 번 올렸는데 결과 6 — 한 번 사라짐 withLock { $0 += 1 } 스레드 A 스레드 B 잠금: 읽기 5 → 쓰기 6 기다림 잠금: 읽기 6 → 쓰기 7 결과 7 — 둘 다 반영
withLock은 "읽고-고치고-쓰기" 한 덩어리를 잠근다. 잠금 밖에서 읽은 값을 안에서 쓰면 이 보호가 무의미해진다.

@AppStorage는 뷰 안에서만 쓰이고 뷰는 main actor에서 돌기 때문에 실제로는 거의 문제가 되지 않는다. @Shared는 모델, UIKit, actor 어디에나 놓일 수 있어서 이 보호막이 없다. 그래서 라이브러리는 직접 대입 대신 withLock을 요구한다.

❌ 직접 대입 — setter가 deprecated라 경고가 뜬다 (실측: "setter for 'count' is deprecated: Use '$shared.withLock' …")
case .incrementButtonTapped:
  state.count += 1
  return .none
❌ 잠금 밖에서 읽고 안에서 쓴다 — 여전히 레이스
let currentCount = state.count
state.$count.withLock { $0 = currentCount + 1 }
✅ 한 단위의 작업은 withLock 하나 안에서 끝낸다
@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
    }
  }
}
🔑 withLock은 완벽한 방패가 아니다

문서도 "코드에서 레이스를 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> = []
✅ 키 + 타입 + 기본값을 한 곳에 — 사용처는 @Shared(.users) 한 줄
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에 그냥 넣으면 왜 동기화가 깨지나? 무엇으로 고치나?
State가 struct라서 넣는 순간 복사되기 때문이다. 부모·자식 모두 @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 어디에나 두고, 같은 키끼리 자동으로 동기화된다.
쉽게 말하면 서랍이 비어 있을 때만 "기본 장난감"을 넣어 둔다. 서랍에 뭔가 있으면 그걸 꺼내 쓴다.
🔮 코드 예측용 — 처음 count가 0일 때
@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는 각각 몇인가?
부모 1, a.count 1, b.count 2. a는 state.$count, 즉 부모와 같은 참조를 받았으므로 .a(.plus) 한 번이 부모에도 반영된다. b는 Shared(value: state.count)로 그 시점 값(0)을 담은 새 저장소를 받았다. 그래서 b에서 두 번 올린 것은 부모와 무관하다. Shared(value:)는 "진실의 주인이 여기"일 때 쓰는 생성자다.
쉽게 말하면 a는 공책을 같이 쓰고, b는 공책을 복사해 새 공책을 만들었다. b에 두 줄을 더 적어도 원래 공책은 한 줄만 늘었다.
🔁 Day 11 복습: 스택의 특정 화면에 액션을 보내거나 그 화면을 pop할 때, 인덱스 대신 무엇으로 가리키나? 왜?
StackElementID다. StackState가 요소를 추가할 때마다 자동으로 붙이는 식별자라, 앞쪽 화면이 pop되어 인덱스가 밀려도 같은 화면을 정확히 가리킨다. .path(.element(id: id, action: …)), state.path.pop(from: id)처럼 쓴다.
쉽게 말하면 줄 선 순서(몇 번째)는 앞사람이 빠지면 바뀌지만, 이름표는 안 바뀐다. 이름표로 부르면 헷갈릴 일이 없다.
➡️ 2부 예고

2부는 공유 상태를 "운영"하는 법이다. 커스텀 init에서 @Shared를 초기화하는 세 가지 규칙, 나만의 SharedKey 만들기, 변화 관찰(publisher·onChange)과 무한 루프 함정, 그리고 TestStore로 공유 상태를 검증하는 법을 본다. 마지막에 @AppStorage·@Environment와 전체 비교표로 정리한다.

출처 · 참고자료