LODY/정리

TCA 실전 / 컬렉션과 바인딩: forEach와 IdentifiedArray, 바인딩 변수

컬렉션과 바인딩: forEach와 IdentifiedArray, 바인딩 변수

🟣 목록의 각 행이 상태를 가질 때

앞 장까지로 화면 하나와 화면 사이 전환을 다뤘다. 남은 건 화면 안쪽에서 자주 나오는 두 패턴이다. 하나는 목록이고, 하나는 폼이다.

목록부터 보자. 할 일 목록처럼 같은 종류의 행이 여러 개 반복되고, 각 행이 자기 상태를 가지는 경우다. 각 행에 체크박스가 있고 눌리면 그 행만 완료 표시가 되어야 한다. 이걸 단순 배열 [TodoState]로 두면 문제가 생긴다. 세 번째 행의 체크박스가 눌렸을 때, 그 Action이 정확히 세 번째 행의 State에 가닿아야 하는데, 인덱스는 행이 추가·삭제되면 밀려서 엉뚱한 행을 건드리기 쉽다.

핵심은 각 행을 인덱스가 아니라 고유 id로 식별하는 것이다. TCA는 이를 위해 IdentifiedArray를 쓴다.


🟣 IdentifiedArray와 forEach

IdentifiedArray는 각 원소를 id로 식별하는 배열이다. 순서를 가진 배열이면서 동시에 id로 O(1)에 원소를 찾을 수 있다. 원소 타입이 Identifiable이면 IdentifiedArrayOf<T>로 바로 쓴다.

struct TodoListFeature: ReducerProtocol {
    struct State: Equatable {
        var todos: IdentifiedArrayOf<TodoFeature.State> = []
    }
    enum Action: Equatable {
        case todo(id: TodoFeature.State.ID, action: TodoFeature.Action)
    }
 
    var body: some ReducerProtocol<State, Action> {
        Reduce { state, action in
            return .none
        }
        .forEach(\.todos, action: /Action.todo(id:action:)) {
            TodoFeature()
        }
    }
}

forEach 연산자가 핵심이다. 부모의 IdentifiedArray 안 각 원소마다 자식 Reducer(TodoFeature)를 돌려 준다. 부모 Action을 보면 todo(id:action:) 형태로 어느 id의 행에서 무슨 일이 일어났는지가 함께 담긴다. 세 번째 행의 체크박스가 눌리면 그 행의 id가 실린 Action이 오고, forEach가 그 id의 원소를 찾아 자식 Reducer를 돌린다. 인덱스가 밀려도 id로 찾으니 항상 맞는 행에 가닿는다.

TodoListFeature.forEachtodo(id: A)todo(id: B)todo(id: C)id로 라우팅id로 라우팅id로 라우팅

3장의 ifLet과 짝을 이룬다는 점이 기억할 만하다. ifLet이 "있거나 없는 자식 하나"를 다뤘다면, forEach는 "여러 개인 자식들"을 다룬다. 둘 다 자식 State가 사라지면 그 자식의 진행 중인 Effect를 자동으로 정리한다는 규칙도 같다.

뷰에서는 ForEachStore로 이 컬렉션을 그린다. 각 행마다 자기 몫의 작은 Store가 만들어져 자식 뷰로 넘어간다.

ForEachStore(
    store.scope(state: \.todos, action: TodoListFeature.Action.todo(id:action:))
) { todoStore in
    TodoRowView(store: todoStore)
}

각 행이 자기 완결적인 기능이 되는 셈이다. 행 하나를 독립적으로 만들고 테스트한 뒤, 부모가 forEach로 여러 개 담기만 하면 된다. 1장에서 말한 조립 가능성이 목록에서도 그대로 나타난다.


🟣 폼의 양방향 바인딩 문제

두 번째 패턴은 폼이다. TextField에 이름을 입력하면 그 값이 State에 반영돼야 하고, 반대로 State가 바뀌면 TextField도 갱신돼야 한다. SwiftUI는 이걸 Binding으로 양방향 연결한다. 그런데 이게 TCA의 단방향 원칙과 부딪힌다. 뷰가 State를 직접 못 고친다고 1장에서 못 박았는데, TextField는 본질적으로 값을 직접 쓰려 하기 때문이다.

가장 단순한 해법은 입력 필드마다 Action을 하나씩 만드는 것이다. nameChanged(String), emailChanged(String) 같은 식이다. 하지만 필드가 열 개면 Action도 열 개, reducecase도 열 개가 되어 폼 하나가 보일러플레이트로 뒤덮인다. 실제로 이 반복이 상당히 성가셨다.


🟣 바인딩 변수로 반복을 걷어내기

TCA는 이 반복을 걷어내는 장치를 준다. 바인딩할 State 속성에 @BindingState를 붙이고, Action이 BindableAction을 따르게 한 뒤, Reducer에 BindingReducer()를 한 줄 추가하는 방식이다.

struct FormFeature: ReducerProtocol {
    struct State: Equatable {
        @BindingState var name = ""
        @BindingState var email = ""
        @BindingState var agreedToTerms = false
    }
    enum Action: BindableAction, Equatable {
        case binding(BindingAction<State>)
        case submitTapped
    }
 
    var body: some ReducerProtocol<State, Action> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .submitTapped:
                // name, email 은 이미 바인딩으로 채워져 있다
                return .none
            case .binding:
                return .none
            }
        }
    }
}

필드가 몇 개든 Action은 binding(BindingAction<State>) 하나로 끝난다. BindingReducer()가 그 바인딩 Action을 받아 해당 State 속성에 값을 대입해 준다. 뷰에서는 viewStore의 바인딩 변수를 그대로 TextField에 연결한다.

WithViewStore(store, observe: { $0 }) { viewStore in
    Form {
        TextField("이름", text: viewStore.$name)
        TextField("이메일", text: viewStore.$email)
        Toggle("약관 동의", isOn: viewStore.$agreedToTerms)
    }
}

여기서 중요한 건, 편해 보여도 단방향 원칙이 깨지지 않았다는 점이다. TextField가 값을 바꾸면 그건 곧바로 State를 고치는 게 아니라 binding Action이 되어 Store로 들어가고, BindingReducer가 그 Action을 받아 State를 바꾼다. 겉으로는 양방향 바인딩이지만 안에서는 여전히 Action을 거쳐 도는 것이다.

TextField 입력binding ActionBindingReducerState.name값 변경처리대입반영

특정 필드가 바뀔 때 곁다리 로직을 넣고 싶으면 BindingReducer 뒤에서 그 필드의 바인딩만 따로 잡아 처리할 수도 있다. 반복은 걷어내되, 필요한 곳에는 여전히 개입할 여지를 남겨 둔 설계다.


🟣 정리

  • 목록은 각 행을 인덱스가 아니라 id로 식별해야 한다. 인덱스는 추가·삭제로 밀려 엉뚱한 행을 건드린다.
  • IdentifiedArray는 순서를 가지면서 id로 O(1)에 원소를 찾는 배열이다. forEach가 각 원소에 자식 Reducer를 돌리고, Action에 실린 id로 맞는 행에 라우팅한다.
  • forEach는 컬렉션판 ifLet이다. 자식이 여럿이라는 점만 다르고, 사라진 자식의 Effect를 자동 정리하는 규칙은 같다. 각 행이 독립 기능이 되어 따로 만들고 테스트할 수 있다.
  • 폼의 양방향 바인딩은 단방향 원칙과 부딪힌다. 필드마다 Action을 만들면 보일러플레이트가 폭증한다.
  • @BindingState + BindableAction + BindingReducer()로 필드가 몇 개든 Action 하나로 처리한다. 겉은 양방향이지만 안에서는 여전히 binding Action을 거쳐 단방향으로 돈다.
  • 면접에서는 "양방향 바인딩을 어떻게 단방향 원칙 안에서 구현하는가"가 좋은 질문이다. BindingReducer가 바인딩 Action을 State 대입으로 바꿔 준다는 점을 짚으면 된다.

여기까지가 TCA를 실무에서 쓰며 익힌 사용법이다. 단방향 흐름으로 상태 변화를 추적 가능하게 만들고, 의존성 주입으로 테스트를 붙이고, 화면 전환과 목록·폼까지 같은 원칙 위에서 다뤘다. 러닝 커브와 보일러플레이트라는 비용은 분명하지만, 상태가 복잡한 화면에서 "무슨 일이 왜 일어났는가"를 언제나 되짚을 수 있다는 이점이 그 값을 했다. 앞에서 쓴 API가 왜 그렇게 생겼는지 보려면 다음 장 「TCA Under the Hood」로 이어 가면 된다.