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인가 — 인덱스는 이름표가 아니다 🔥 핵심
배열의 인덱스는 "지금 몇 번째"일 뿐이다. 삭제·이동·정렬이 일어나면 같은 인덱스가 다른 항목을 가리킨다. 동기 코드에서는 티가 안 나지만, 비동기 작업이 끝난 뒤 저장해 둔 인덱스로 돌아오면 엉뚱한 항목을 고치거나 범위 밖으로 크래시가 난다.
"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:)로 찾는다. 안전하지만 매번 배열 전체를 훑는다.
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만으로) 바로 쓸 수 있다.
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는 "같은 자식 여러 개"다. 준비물도 대응된다.
- State:
var rows: IdentifiedArrayOf<TodoRow.State>— 행 State는Identifiable이어야 한다. - Action:
case rows(IdentifiedActionOf<TodoRow>)— "어느 id 행이 보낸 무슨 액션"을 담는다. - body: 부모
Reduce뒤에.forEach(\.rows, action: \.rows) { 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
}
}
}
}@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
}"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을 보는 이유다.
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:를 생략할 수 있다.
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) }
}
}
}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) }
}
}
}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 .noneextension 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().
@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) // 뷰도 재귀
}
}
}
}
}.children(.element(…))가 겹친다. 리듀서와 뷰는 한 벌이고 깊이는 데이터가 정한다.성능 메모 3줄
- 행 하나의 필드 변경은 목록 변경이 아니다:
@ObservableState는 IdentifiedArray를 비교할 때 원소 전체가 아니라 ids 순서만 본다(소스 기준 ·_$isIdentityEqual). 한 행의title만 바뀌면rows는 "안 바뀐 것"으로 판정되고, 그title을 읽은 행 뷰만 갱신된다. SwiftUI 코스 Day 5에서 본 "struct 배열 요소 하나만 바뀌어도 배열을 읽은 모든 body가 다시 불린다"와 대비된다. - scope는 저장 프로퍼티로: 행 스토어도 루트를 가리키므로, 무거운 계산 프로퍼티를 scope 경로에 두면 읽을 때마다 반복된다.
- 행 사이 로직 공유는 메서드로: 행이 다른 행에게 액션을 보내게 하지 말고, 부모 리듀서의 메서드에서
rows[id:]로 직접 고친다(Day 5 5절).
| 목록 모델 | 한 행의 필드 변경 시 갱신 범위 |
|---|---|
@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 패키지가 추가된 프로젝트라고 가정한다.
Song: Stateid: Int,title,isLiked. 액션likeTapped(토글),removeTapped→delegate(.removeRequested).Playlist:songs: IdentifiedArrayOf<Song.State>,forEach로 행을 끼우고, delegate를 받으면 그 id의 행을 지운다.- 확인: TestStore로 "1번 곡 좋아요 → 2번 곡 빼기"가 통과해야 한다(
receive(\.songs[id: 2].delegate.removeRequested)).
정답 코드 보기
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)
}
}