
지난 글(TCA란 무엇인가?)에서는 TCA가 어떤 문제의식에서 출발했는지, 그리고 단방향 데이터 흐름이라는 핵심 철학을 정리했다. 이번 글에서는 그 철학을 실제로 구현하는 세 가지 요소, State와 Action, Reducer를 코드로 작성해본다.
TCA 관련 타입을 사용하려면 ComposableArchitecture 모듈을 import한다.
import ComposableArchitecture
그리고 하나의 화면(State + Action + Reducer 묶음)을 정의할 때는 @Reducer 매크로를 구조체에 붙이는 것에서 시작한다.
@Reducer
struct Login {
}
이 Login이라는 이름이 곧 하나의 Feature를 가리키는 타입이 된다. 이제 이 안에 State, Action, Reducer 로직을 순서대로 채워 넣는다.
State는 이 화면을 그리는 데 필요한 데이터를 담은 구조체다. 로그인 화면이라면 아이디, 비밀번호, 로딩 여부, 에러 메시지 정도가 필요할 것이다.
@Reducer
struct Login {
@ObservableState
struct State {
var username = ""
var password = ""
var isLoading = false
var errorMessage: String?
}
}
@ObservableState 매크로는 State의 프로퍼티가 변경될 때 그 State를 사용하는 View가 자동으로 다시 그려지도록 만들어준다. State를 정의할 때는 사실상 필수로 붙이는 매크로라고 생각하면 된다.
여기서 중요한 점은 State에는 오직 데이터만 있다는 것이다. "로그인 버튼을 눌렀을 때 무엇을 할지" 같은 로직은 전혀 들어있지 않다. State는 그저 현재 화면이 어떤 모습이어야 하는지를 나타내는 스냅샷일 뿐이다.
Action은 이 화면에서 일어날 수 있는 모든 이벤트를 나열한 열거형이다.
@Reducer
struct Login {
.
.
.
enum Action {
case usernameChanged(String)
case passwordChanged(String)
case loginButtonTapped
case loginResponse(Result<User, Error>)
}
}
각 case가 어떤 상황을 나타내는지 보면, usernameChanged와 passwordChanged는 사용자가 텍스트필드에 값을 입력했다는 이벤트다. loginButtonTapped는 로그인 버튼을 눌렀다는 이벤트다. loginResponse는 로그인 요청에 대한 서버 응답이 도착했다는 이벤트이며, 성공/실패를 Result 타입으로 감싸서 전달한다.
Action을 설계할 때 핵심은 이름을 "무엇을 할지"가 아니라 "무슨 일이 일어났는지"로 짓는 것이다. updateUsername 대신 usernameChanged라고 쓰는 이유가 여기에 있다. Action은 명령이 아니라 사건에 대한 기록이다.
loginResponse 처럼 사용자가 직접 하지 않은 일까지 Action에 들어있는 게 낯설게 느껴질 수 있다. "Action은 사용자의 행동을 나타내는 것 아닌가?"라고 생각하면 이상하지만, TCA에서 Action은 "사용자의 행동"이 아니라 "무엇이든 발생한 사건"을 뜻한다. 사용자가 버튼을 누른 것도 사건이고, 서버 응답이 도착한 것도 똑같이 사건이다.
이렇게 나누는 데는 구조적인 이유가 있다. loginButtonTapped가 들어왔을 때 그 자리에서 네트워크 통신까지 끝내고 State를 바꾸면 안 되는 이유는, Reducer의 reduce 함수가 항상 동기적(synchronous)으로 실행되어야 하기 때문이다. state는 inout으로 전달되는데, 이 참조는 함수가 실행되는 그 짧은 순간에만 유효하다. 네트워크 응답을 몇 초씩 기다렸다가 그 결과로 State를 바꾸려 하면, 그 시점엔 이미 함수 호출이 끝난 뒤라 안전하게 접근할 방법이 없다.
그래서 TCA는 이 문제를 다음과 같이 나눠서 해결한다. loginButtonTapped는 로딩 상태를 켜는 것까지만 동기적으로 처리하고, 실제 네트워크 요청은 Effect라는 형태로 Reducer 바깥에 위임한다. 이 Effect가 비동기로 실행되다가 응답을 받으면, 그 결과를 다시 Reducer에게 전달할 수 있는 유일한 방법이 바로 새로운 Action(loginResponse)을 보내는 것이다.
즉 응답도 Action인 이유는, 비동기 작업의 결과가 다시 동기적인 Reducer 세계로 돌아오려면 반드시 Action이라는 통로를 거쳐야 하기 때문이다.
이제 각 Action이 들어왔을 때 State를 어떻게 바꿀지 정의할 차례다. @Reducer 매크로가 적용된 타입은 body라는 프로퍼티에 로직을 채워 넣는다.
@Reducer
struct Login {
.
.
.
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .usernameChanged(let text):
state.username = text
return .none
case .passwordChanged(let text):
state.password = text
return .none
case .loginButtonTapped:
state.isLoading = true
state.errorMessage = nil
return .run { send in
let result = await api.login(
username: state.username,
password: state.password
)
await send(.loginResponse(result))
}
case .loginResponse(let result):
switch result {
case .success:
state.isLoading = false
return .none
case .failure(let error):
state.isLoading = false
state.errorMessage = error.localizedDescription
return .none
}
}
}
}
}
몇 가지 눈여겨볼 부분이 있다.
Reduce { state, action in ... }의 state는 inout으로 전달된다. 즉 새로운 State를 만들어서 반환하는 방식이 아니라, Store가 들고 있는 State를 그 자리에서 직접 변경한다. state.username = text처럼 바로 대입하는 이유가 여기에 있다.
switch action으로 모든 case를 분기 처리하는데, Swift는 enum의 모든 case를 빠짐없이 처리하도록 강제하기 때문에 새로운 Action이 추가되면 컴파일 에러로 즉시 알 수 있다. 이 강제성이 Reducer를 안전하게 만드는 요소 중 하나다.
각 분기는 마지막에 .none을 반환한다. Reducer는 항상 Effect<Action>을 반환해야 하는데, 지금은 State만 바꾸고 별도의 비동기 작업은 없으므로 "할 일 없음"을 뜻하는 .none을 반환한다.
이 세 조각을 다시 정리하면, State는 "지금 화면이 어떤 상태인가"를 나타내고, Action은 "무슨 일이 일어났는가"를 나타내며, Reducer는 "그 일이 일어났을 때 상태를 어떻게 바꿀 것인가"를 정의한다. 이 세 가지만으로 화면의 모든 로직이 하나의 타입(Login) 안에 응집된다. View는 이 로직에 전혀 관여하지 않고, 그저 State를 읽어서 그리고 Action을 보내는 역할만 하게 된다.
이번 글에서는 Login 화면을 예시로 State, Action, Reducer를 실제 코드로 작성해봤다. 다만 아직 이 Reducer를 실제로 화면에 붙여서 실행해보지는 않았다. 다음 글에서는 Store를 만들어 이 Login Reducer를 SwiftUI View에 연결하고, 화면에서 직접 동작을 확인해본다.