← TCA 14일 마스터 6 / 14

Day 6 · 조립 ② — 컬렉션

같은 자식이 여러 개인 목록 기능을 id 기반으로 조립하고, 행 스토어·삭제·재귀·성능 함정을 정리한다.
진행률
0 / 0 완료
어제는 자식 하나, 또는 있다 없다 하는 자식을 끼웠다. 실제 화면은 같은 자식이 여러 개인 경우가 더 많다 — 할 일 목록, 장바구니 항목, 채팅방 리스트. @Observable에서는 배열을 들고 firstIndex(where:)로 찾아 고쳤다. TCA는 id로 찾는 배열(IdentifiedArray)과 forEach로 행마다 리듀서와 스토어를 붙인다. 오늘은 목록 기능 하나를 처음부터 끝까지 조립한다.

오늘의 목표 · 30분

  • 인덱스를 들고 await를 건너는 코드를 찾아 IdentifiedArrayOf + id 접근으로 고친다.
  • IdentifiedActionOf<Row> 케이스와 .forEach(\.rows, action: \.rows)로 행 리듀서를 부모에 끼운다.
  • ForEach(store.scope(\.rows, action: \.rows))로 행 뷰에 스토어를 넘기고, 삭제·이동·필터 목록의 오프셋을 id로 바꿔 처리한다.
  • 재귀 기능(Self())을 만들고, 목록 성능에서 무엇을 피할지 설명한다.

1. 왜 IdentifiedArray인가 — 인덱스는 이름표가 아니다 🔥 핵심

배열의 인덱스는 "지금 몇 번째"일 뿐이다. 삭제·이동·정렬이 일어나면 같은 인덱스가 다른 항목을 가리킨다. 동기 코드에서는 티가 안 나지만, 비동기 작업이 끝난 뒤 저장해 둔 인덱스로 돌아오면 엉뚱한 항목을 고치거나 범위 밖으로 크래시가 난다.

Array + 인덱스 기억 0 1 2 ← 기억 A B C 동기화 await 도중 A 삭제 B C items[2] ? 인덱스 2 → 범위 밖 크래시 (정렬이었다면 다른 항목을 조용히 고친다) IdentifiedArray + id 기억 id A id B id C await 도중 A 삭제 id B id C rows[id: C] → 정확히 C (C가 지워졌다면 nil — 크래시 없음)
인덱스는 위치, id는 이름표다. 비동기 작업이 끼는 순간 위치는 믿을 수 없다.
📝 Point-Free 문서 원문

"An index is not a stable identifier: todos can be moved and removed at any time, and an index identifying "Buy lettuce" at one moment may identify "Call Mom" the next, or worse, may be a completely invalid index and crash your application!" — swift-identified-collections · IdentifiedArray

풀이: 인덱스는 언제든 다른 항목을 가리키게 되거나 아예 무효가 된다.

@Observable에서는 보통 id를 들고 다니다가 쓸 때마다 firstIndex(where:)로 찾는다. 안전하지만 매번 배열 전체를 훑는다.

SwiftUI(@Observable) — id를 기억하고 쓸 때마다 선형 탐색
struct TodoItem: Identifiable, Equatable {
  let id: Int
  var title: String
  var isDone = false
}

@MainActor @Observable
final class TodoListModel {
  var items: [TodoItem] = []
  func sync(id: TodoItem.ID) async {
    try? await Task.sleep(for: .seconds(1))
    // await 뒤에는 인덱스를 새로 찾아야 한다 — O(n)
    guard let index = items.firstIndex(where: { $0.id == id }) else { return }
    items[index].isDone = true
  }
}

IdentifiedArray는 순서를 가진 배열이면서 id → 원소 해시 테이블을 함께 들고 있다. TCA가 다시 내보내므로(import ComposableArchitecture만으로) 바로 쓸 수 있다.

IdentifiedArrayOf 기본 — id로 읽고, 고치고, 지운다
func identifiedBasics() {
  var todos: IdentifiedArrayOf<TodoItem> = [
    TodoItem(id: 1, title: "우유"),
    TodoItem(id: 2, title: "메일"),
  ]
  todos[id: 2]?.isDone = true            // id로 O(1) 접근·수정
  todos.remove(id: 1)                    // id로 삭제
  todos.append(TodoItem(id: 3, title: "운동"))
  todos[id: 3] = nil                     // nil 대입도 삭제
  print(todos.ids)                       // [2] — 순서 있는 id 집합(OrderedSet)
  print(todos[0].title)                  // 인덱스 접근도 그대로 된다
}
[TodoItem]IdentifiedArrayOf<TodoItem>
id로 찾기firstIndex(where:) — O(n)[id:] — 평균 O(1)
중복 id그냥 들어간다배열 리터럴·init(uniqueElements:)는 중복이면 멈춘다(precondition), append는 넣지 않는다
원소의 id 바꾸기가능크래시 — "Element identity must remain constant"
TCA forEach받지 않는다(컴파일 에러)필수 타입

2. forEach와 IdentifiedAction — 행마다 리듀서를 붙인다 🔥 핵심

Day 5의 Scope가 "자식 하나"였다면 forEach는 "같은 자식 여러 개"다. 준비물도 대응된다.

TCA — 행 기능 TodoRow (동기화 이펙트 포함)
@Reducer
struct TodoRow {
  @ObservableState
  struct State: Equatable, Identifiable {
    let id: Int
    var title = ""
    var isDone = false
    var isSyncing = false
  }
  enum Action {
    case checkboxTapped
    case titleChanged(String)
    case syncButtonTapped
    case syncFinished
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .checkboxTapped:
        state.isDone.toggle()
        return .none
      case let .titleChanged(title):
        state.title = title
        return .none
      case .syncButtonTapped:
        state.isSyncing = true
        return .run { send in
          try await Task.sleep(for: .seconds(1))
          await send(.syncFinished)
        }
      case .syncFinished:
        state.isSyncing = false
        return .none
      }
    }
  }
}
TCA — 부모 TodoList가 행들을 forEach로 품는다
@Reducer
struct TodoList {
  @ObservableState
  struct State: Equatable {
    var rows: IdentifiedArrayOf<TodoRow.State> = []
    var nextID = 0
  }
  enum Action {
    case addButtonTapped
    case delete(IndexSet)
    case move(IndexSet, Int)
    case rows(IdentifiedActionOf<TodoRow>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .addButtonTapped:
        state.rows.append(TodoRow.State(id: state.nextID))
        state.nextID += 1
        return .none
      case let .delete(offsets):
        state.rows.remove(atOffsets: offsets)
        return .none
      case let .move(source, destination):
        state.rows.move(fromOffsets: source, toOffset: destination)
        return .none
      case .rows(.element(id: _, action: .checkboxTapped)):
        // 행이 먼저 isDone을 바꾼 뒤 부모가 본다 → 완료 항목을 아래로
        state.rows.sort { !$0.isDone && $1.isDone }
        return .none
      case .rows:
        return .none
      }
    }
    .forEach(\.rows, action: \.rows) {
      TodoRow()
    }
  }
}

IdentifiedAction은 케이스가 .element(id:action:) 하나뿐인 enum이다(TCA 소스 · ForEachReducer). 행 뷰에서 버튼을 누르면 부모 스토어에는 이런 값이 도착한다.

행 액션이 부모에 도착한 모습
func actionShape() {
  let action: TodoList.Action = .rows(.element(id: 3, action: .checkboxTapped))
  // 테스트에서는 case key path로: \.rows[id: 3].checkboxTapped
  _ = action
}
① 행 뷰 (id 3) send(.checkbox Tapped) ② 행 스토어 .rows(.element( id: 3, action: …)) ③ forEach rows[id: 3]에 TodoRow 먼저 실행 ④ 부모 Reduce 바뀐 isDone 보고 정렬·삭제 등 ⑤ 부모 실행 전·후 rows.ids 비교 사라진 id가 있으면 그 행이 돌리던 이펙트를 자동 취소 없는 id로 오면 런타임 경고
forEach는 id로 행을 찾아 행 리듀서를 먼저 돌리고, 부모가 행을 지웠다면 그 행의 이펙트까지 정리한다. 모두 ForEachReducer 소스에 있는 동작이다.
📝 TCA 문서 원문

"It runs the child first, and then the parent. If the order was reversed, then it would be possible for the parent feature to remove the child state from the array, in which case the child feature would not be able to react to that action." — TCA 소스 · forEach

풀이: ifLet과 같은 이유로 행이 먼저, 부모가 나중이다. 위 코드의 정렬 case가 이미 토글된 isDone을 보는 이유다.

❌ 평범한 배열 + forEach — 컴파일 에러
struct State {
  var rows: [TodoRow.State] = []      // IdentifiedArray가 아니다
}
// .forEach(\.rows, action: \.rows) { TodoRow() }
// → forEach는 WritableKeyPath<State, IdentifiedArray<ID, Element>>만 받는다

3. 행 스토어 — ForEach(store.scope(…))

뷰도 행마다 스토어를 받는다. store.scope(\.rows, action: \.rows)는 IdentifiedArray에 쓰면 행 스토어의 컬렉션을 돌려주고, SwiftUI ForEach에 그대로 넣는다. Store는 객체 identity로 Identifiable이라 id:를 생략할 수 있다.

TCA — 목록 뷰와 행 뷰
struct TodoRowView: View {
  @Bindable var store: StoreOf<TodoRow>
  var body: some View {
    HStack {
      Button { store.send(.checkboxTapped) } label: {
        Image(systemName: store.isDone ? "checkmark.square" : "square")
      }
      .buttonStyle(.plain)
      TextField("할 일", text: $store.title.sending(\.titleChanged))
      if store.isSyncing { ProgressView() }
      else { Button("동기화") { store.send(.syncButtonTapped) } }
    }
  }
}

struct TodoListView: View {
  let store: StoreOf<TodoList>
  var body: some View {
    List {
      ForEach(store.scope(\.rows, action: \.rows)) { rowStore in
        TodoRowView(store: rowStore)
      }
      .onDelete { store.send(.delete($0)) }
      .onMove { store.send(.move($0, $1)) }
    }
    .toolbar {
      Button("추가") { store.send(.addButtonTapped) }
    }
  }
}
SwiftUI(@Observable) — 행에 Binding을 넘긴다
struct TodoListModelView: View {
  @Bindable var model: TodoListModel
  var body: some View {
    List {
      ForEach($model.items) { $item in
        HStack {
          Toggle("", isOn: $item.isDone).labelsHidden()
          TextField("할 일", text: $item.title)
          Button("동기화") { Task { await model.sync(id: item.id) } }
        }
      }
      .onDelete { model.items.remove(atOffsets: $0) }
    }
  }
}
TodoListView ForEach(store.scope(\.rows, action: \.rows)) 행 스토어 0 TodoRowView 행 스토어 1 TodoRowView 행 스토어 2 TodoRowView 루트 Store State.rows[id: n] 진실은 여기 하나 읽기·send 모두 루트를 거친다
행 스토어는 상태 복사본이 아니라 루트를 가리키는 창이다. 행 뷰는 자기 행 State만 읽으므로, 다른 행이 바뀌어도 다시 그려지지 않는다.
🔑 id: \.state.id는 언제 쓰나

문서 팁: 행 State의 id가 선택(selection) 바인딩 등에 쓰이지 않으면 id:를 생략한다. 행 id 자체에 기대는 경우엔 ForEach(store.scope(\.rows, action: \.rows), id: \.state.id)로 적는다(TCA 소스 · IdentifiedArray+Observation). 이 컬렉션은 메인 액터에서만 다뤄야 한다(소스의 precondition).

SwiftUI(@Observable)TCA
행에 넘기는 것$item Binding 또는 행 모델 참조행 스토어 StoreOf<TodoRow>
행의 비동기 작업부모 모델 메서드에 id를 넘겨 호출행 리듀서의 .run — 결과는 id가 붙어 돌아온다
행 삭제 시 진행 중 작업직접 추적·취소forEach가 자동 취소
대가행 타입 하나로 끝행 State·Action·리듀서·뷰 네 조각

4. 삭제·이동·필터 — 오프셋을 id로 바꿔라

onDelete·onMove는 화면에 보이는 목록의 오프셋(IndexSet)을 준다. 전체 목록을 그대로 보여 줄 땐 remove(atOffsets:)·move(fromOffsets:toOffset:)에 바로 넘겨도 된다(2절 코드). 함정은 필터된 목록을 보여 줄 때다.

❌ "남은 일"만 보여 주면서 오프셋을 전체 배열에 적용 — 엉뚱한 행이 지워진다
case let .delete(offsets):
  // 화면: [B, D] (완료 안 된 것만)   전체: [A✓, B, C✓, D]
  // offsets = [0] → 화면의 B를 지우려 했지만 전체의 A가 지워진다
  state.rows.remove(atOffsets: offsets)
  return .none
✅ 보이는 목록에서 id를 꺼내 id로 지운다 (공식 Todos 예제와 같은 방식)
extension TodoList.State {
  var remaining: IdentifiedArrayOf<TodoRow.State> {
    rows.filter { !$0.isDone }
  }
  mutating func deleteRemaining(at offsets: IndexSet) {
    let visible = remaining
    for index in offsets {
      rows.remove(id: visible[index].id)
    }
  }
}

이 방식은 공식 Todos 예제의 .delete 처리와 같다. 이동도 필터 상태에서는 오프셋을 전체 배열의 인덱스로 옮겨 적는다. 같은 예제는 뷰에서 ForEach(store.scope(\.filteredTodos, action: \.todos))처럼 계산 프로퍼티로 scope한다. 필터 한 번은 가볍지만, 무거운 계산이라면 Day 5에서 본 Performance 권고(저장 프로퍼티로만 scope)를 떠올린다.

⚠️ 부모가 행 액션에 반응할 때

2절의 정렬처럼 행 상태가 바뀐 결과에 부모가 반응하는 건 공식 Todos도 쓰는 패턴이다(.todos(.element(id: _, action: .binding(\.isComplete)))). 반대로 "행이 부모에게 무언가를 해 달라"는 경우(자기 삭제 요청 등)는 Day 5의 delegate 액션을 쓴다 — 미니 과제에서 연습한다.

5. 재귀 기능과 성능 메모

재귀: 자식이 자기 자신

폴더 안의 폴더, 댓글의 답글처럼 행이 다시 같은 목록을 가지는 경우 리듀서가 자기 자신을 forEach에 넣는다. 공식 Recursion 케이스 스터디의 구조다. 핵심은 두 곳: Action의 indirect case, body의 Self().

TCA — 자기 자신을 품는 Folder
@Reducer
struct Folder {
  @ObservableState
  struct State: Equatable, Identifiable {
    let id: Int
    var name = ""
    var children: IdentifiedArrayOf<State> = []
  }
  enum Action {
    case addChildTapped(id: Int)
    indirect case children(IdentifiedActionOf<Folder>)   // 재귀 enum이라 indirect
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .addChildTapped(id):
        state.children.append(State(id: id))
        return .none
      case .children:
        return .none
      }
    }
    .forEach(\.children, action: \.children) {
      Self()                                            // 자식도 Folder
    }
  }
}

struct FolderView: View {
  let store: StoreOf<Folder>
  var body: some View {
    List {
      ForEach(store.scope(\.children, action: \.children)) { child in
        NavigationLink(child.name.isEmpty ? "새 폴더" : child.name) {
          FolderView(store: child)                      // 뷰도 재귀
        }
      }
    }
  }
}
Folder (루트) Folder A Folder B Folder A-1 Folder A-2 모든 노드 = 같은 Folder() .forEach { Self() } A-1의 액션 = .children(.element(id: A, action: .children(.element(id: A-1, action: …))))
트리의 깊이만큼 .children(.element(…))가 겹친다. 리듀서와 뷰는 한 벌이고 깊이는 데이터가 정한다.

성능 메모 3줄

목록 모델한 행의 필드 변경 시 갱신 범위
@Observable 모델의 [struct]배열을 읽은 모든 body
@Observable 모델의 [@Observable class]그 필드를 읽은 뷰만(인스턴스 단위 추적)
TCA IdentifiedArrayOf<Row.State> + 행 스토어그 필드를 읽은 행 뷰만(ids가 같으면 목록은 안 바뀐 것)

✅ 복습 · 5분

답을 먼저 말해 보고 펼친다.
forEach가 [Row.State]가 아니라 IdentifiedArrayOf<Row.State>를 요구하는 이유는?
행 액션은 이펙트를 거쳐 나중에 돌아올 수 있다. 그 사이 삭제·이동·정렬이 일어나면 인덱스는 다른 행을 가리킨다. IdentifiedAction이 id를 싣고 다니고 forEach가 rows[id:]로 찾기 때문에, 행이 옮겨져도 정확히 그 행에 도착하고 지워졌으면 경고만 내고 무시한다. id가 있어야 "사라진 행의 이펙트 취소"도 할 수 있다.
쉽게 말하면 택배를 "세 번째 집"이 아니라 "김철수 집"으로 보내는 것이다. 이사를 해도 이름으로 찾아간다.
행 뷰에서 체크박스를 누르면 부모 스토어에 도착하는 액션 값은? 부모 Reduce가 보는 isDone은 바뀌기 전인가 후인가?
.rows(.element(id: 3, action: .checkboxTapped)). forEach는 행 리듀서를 먼저 돌리므로 부모는 바뀐 뒤의 isDone을 본다. 그래서 2절의 정렬 case가 정상 동작한다.
쉽게 말하면 반장이 먼저 자기 자리를 정리하고, 선생님은 정리된 결과를 보고 줄을 다시 세운다.
🔮 예측용 코드 — 동기화 중에 행을 지운다
import Testing

@MainActor
@Test func deleteWhileSyncing() async {
  let store = TestStore(
    initialState: TodoList.State(rows: [TodoRow.State(id: 0, title: "보고서")], nextID: 1)
  ) {
    TodoList()
  }
  await store.send(.rows(.element(id: 0, action: .syncButtonTapped))) {
    $0.rows[id: 0]?.isSyncing = true
  }
  await store.send(.delete([0])) {
    $0.rows = []
  }
  // 여기서 테스트가 끝난다 — receive(\.rows[id: 0].syncFinished)는 없다
}
🔮 코드 예측: 위 테스트는 통과하나, "이펙트가 아직 끝나지 않았다"며 실패하나?
통과한다. .delete([0])로 부모가 id 0을 지우면, forEach가 부모 실행 전·후의 rows.ids를 비교해 사라진 id 0에 묶인 이펙트(1초 sleep 중인 .run)를 취소한다(ForEachReducer 소스의 elementCancelEffects). 취소된 이펙트는 .syncFinished를 보내지 않으므로 받을 액션도, 남은 이펙트도 없다. @Observable 버전이었다면 Task는 계속 돌다가 1초 뒤 이미 없는 항목을 찾으려 했을 것이다.
쉽게 말하면 반에서 전학 간 학생 앞으로 온 심부름은 학교가 알아서 취소해 준다.
🔁 Day 5 복습: 옵셔널 자식에 Scope 대신 ifLet을 쓰는 이유 두 가지는?
① Scope는 옵셔널이 아닌 key path만 받는다. ② ifLet은 자식 먼저 → 부모 순서를 강제하고, 자식 상태가 nil이 되면 자식 이펙트를 자동 취소한다. 오늘의 forEach는 이 두 성질을 "여러 자식"으로 늘린 것이다.
쉽게 말하면 ifLet은 "있으면 한 명", forEach는 "있는 만큼 여러 명"을 같은 규칙으로 돌본다.

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

상황: 재생 목록 Playlist에 곡 행 Song이 여러 개다. 각 행에는 ♥ 버튼과 "빼기" 버튼이 있다. TCA 패키지가 추가된 프로젝트라고 가정한다.

  1. Song: State id: Int, title, isLiked. 액션 likeTapped(토글), removeTapped → delegate(.removeRequested).
  2. Playlist: songs: IdentifiedArrayOf<Song.State>, forEach로 행을 끼우고, delegate를 받으면 그 id의 행을 지운다.
  3. 확인: TestStore로 "1번 곡 좋아요 → 2번 곡 빼기"가 통과해야 한다(receive(\.songs[id: 2].delegate.removeRequested)).
정답 코드 보기
정답 — Song + Playlist + TestStore
import Testing

@Reducer
struct Song {
  @ObservableState
  struct State: Equatable, Identifiable {
    let id: Int
    var title: String
    var isLiked = false
  }
  enum Action {
    case likeTapped
    case removeTapped
    case delegate(Delegate)
    @CasePathable
    enum Delegate: Equatable {
      case removeRequested
    }
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .likeTapped:
        state.isLiked.toggle()
        return .none
      case .removeTapped:
        return .send(.delegate(.removeRequested))
      case .delegate:
        return .none
      }
    }
  }
}

@Reducer
struct Playlist {
  @ObservableState
  struct State: Equatable {
    var songs: IdentifiedArrayOf<Song.State> = []
  }
  enum Action {
    case songs(IdentifiedActionOf<Song>)
  }
  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .songs(.element(id: id, action: .delegate(.removeRequested))):
        state.songs.remove(id: id)
        return .none
      case .songs:
        return .none
      }
    }
    .forEach(\.songs, action: \.songs) { Song() }
  }
}

@MainActor
@Test func playlistFlow() async {
  let store = TestStore(
    initialState: Playlist.State(songs: [
      Song.State(id: 1, title: "A"),
      Song.State(id: 2, title: "B"),
    ])
  ) {
    Playlist()
  }
  await store.send(.songs(.element(id: 1, action: .likeTapped))) {
    $0.songs[id: 1]?.isLiked = true
  }
  await store.send(.songs(.element(id: 2, action: .removeTapped)))
  await store.receive(\.songs[id: 2].delegate.removeRequested) {
    $0.songs.remove(id: 2)
  }
}

출처 · 참고자료