[SwiftUI] State, Action, Reducer

민경준·2026년 8월 11일

[SwiftUI] TCA Study

목록 보기
2/6
post-thumbnail

지난 글(TCA란 무엇인가?)에서는 TCA가 어떤 문제의식에서 출발했는지, 그리고 단방향 데이터 흐름이라는 핵심 철학을 정리했다. 이번 글에서는 그 철학을 실제로 구현하는 세 가지 요소, State와 Action, Reducer를 코드로 작성해본다.

준비: import

TCA 관련 타입을 사용하려면 ComposableArchitecture 모듈을 import한다.

import ComposableArchitecture

그리고 하나의 화면(State + Action + Reducer 묶음)을 정의할 때는 @Reducer 매크로를 구조체에 붙이는 것에서 시작한다.

@Reducer
struct Login {

}

Login이라는 이름이 곧 하나의 Feature를 가리키는 타입이 된다. 이제 이 안에 State, Action, Reducer 로직을 순서대로 채워 넣는다.

State 정의하기

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 정의하기

Action은 이 화면에서 일어날 수 있는 모든 이벤트를 나열한 열거형이다.

@Reducer
struct Login {
    .
    .
    .

    enum Action {
        case usernameChanged(String)
        case passwordChanged(String)
        case loginButtonTapped
        case loginResponse(Result<User, Error>)
    }
}

각 case가 어떤 상황을 나타내는지 보면, usernameChangedpasswordChanged는 사용자가 텍스트필드에 값을 입력했다는 이벤트다. loginButtonTapped는 로그인 버튼을 눌렀다는 이벤트다. loginResponse는 로그인 요청에 대한 서버 응답이 도착했다는 이벤트이며, 성공/실패를 Result 타입으로 감싸서 전달한다.

Action을 설계할 때 핵심은 이름을 "무엇을 할지"가 아니라 "무슨 일이 일어났는지"로 짓는 것이다. updateUsername 대신 usernameChanged라고 쓰는 이유가 여기에 있다. Action은 명령이 아니라 사건에 대한 기록이다.

왜 응답(Response)도 Action인가

loginResponse 처럼 사용자가 직접 하지 않은 일까지 Action에 들어있는 게 낯설게 느껴질 수 있다. "Action은 사용자의 행동을 나타내는 것 아닌가?"라고 생각하면 이상하지만, TCA에서 Action은 "사용자의 행동"이 아니라 "무엇이든 발생한 사건"을 뜻한다. 사용자가 버튼을 누른 것도 사건이고, 서버 응답이 도착한 것도 똑같이 사건이다.

이렇게 나누는 데는 구조적인 이유가 있다. loginButtonTapped가 들어왔을 때 그 자리에서 네트워크 통신까지 끝내고 State를 바꾸면 안 되는 이유는, Reducer의 reduce 함수가 항상 동기적(synchronous)으로 실행되어야 하기 때문이다. stateinout으로 전달되는데, 이 참조는 함수가 실행되는 그 짧은 순간에만 유효하다. 네트워크 응답을 몇 초씩 기다렸다가 그 결과로 State를 바꾸려 하면, 그 시점엔 이미 함수 호출이 끝난 뒤라 안전하게 접근할 방법이 없다.

그래서 TCA는 이 문제를 다음과 같이 나눠서 해결한다. loginButtonTapped는 로딩 상태를 켜는 것까지만 동기적으로 처리하고, 실제 네트워크 요청은 Effect라는 형태로 Reducer 바깥에 위임한다. 이 Effect가 비동기로 실행되다가 응답을 받으면, 그 결과를 다시 Reducer에게 전달할 수 있는 유일한 방법이 바로 새로운 Action(loginResponse)을 보내는 것이다.

즉 응답도 Action인 이유는, 비동기 작업의 결과가 다시 동기적인 Reducer 세계로 돌아오려면 반드시 Action이라는 통로를 거쳐야 하기 때문이다.

Reducer 로직 작성하기

이제 각 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 ... }stateinout으로 전달된다. 즉 새로운 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에 연결하고, 화면에서 직접 동작을 확인해본다.

profile
iOS Developer 💻

0개의 댓글