[SwiftUI][TCA] TCA Case Studies - 02. Signup Feature

별똥별·2025년 2월 7일

TCA

목록 보기
18/24

🚀 TCA로 회원가입 플로우 구현하기: Shared 상태와 InMemoryKey 활용법

안녕하세요, 별똥별🌠입니다!
이번 글에서는 TCA(The Composable Architecture)를 활용해 복잡한 회원가입 플로우를 구현하는 방법을 알아봅니다. 여러 단계의 화면이 공유 상태를 사용해 데이터를 실시간으로 반영하며, InMemoryKey를 통해 앱 실행 중에만 상태를 유지할 수 있도록 구성되어 있습니다.


🧐 회원가입 플로우 개요

회원가입 플로우는 세 단계와 최종 요약 화면으로 구성되어 있습니다.
각 단계는 다음과 같습니다:

  • Basics (기본 정보 입력)
    사용자는 이메일과 비밀번호(및 확인)를 입력합니다. 입력한 정보는 전체 회원가입 데이터에 바로 반영됩니다.

  • personal Info (개인 정보 입력)
    사용자는 이름, 전화번호 등 개인 정보를 입력합니다. 이 정보 역시 공유 상태에 저장되어 다른 화면에서도 즉시 업데이트됩니다.

  • topics (관심 주제 선택)
    사용자가 관심 있는 주제(예: 드럼, 베이스 등)를 선택합니다. 최소 하나 이상의 주제를 선택해야 하며, 선택되지 않으면 경고 메시지를 표시합니다.

  • Summary (요약 화면)
    지금까지 입력한 모든 정보를 한눈에 확인할 수 있으며, 수정 버튼을 눌러 이전 단계로 돌아가 데이터를 수정할 수 있습니다.


    모든 단계에서 회원가입 데이터는 SignUpData 구조체에 저장되고, TCA의 @Shared 프로퍼티를 통해 Reducer 간에 동일한 데이터가 공유됩니다.

📌 SignUpData와 Shared 상태 관리

SignUpData 모델

struct SignUpData: Equatable {
    var email = ""
    var firstName = ""
    var lastName = ""
    var password = ""
    var passwordConfirmation = ""
    var phoneNumber = ""
    var topics: Set<Topic> = []
    
    enum Topic: String, Identifiable, CaseIterable {
        case drum = "chodan"
        case base = "magenta"
        case guitar = "hina"
        case vocal = "siyeon"
        case band = "QWER"
        var id: Self { self }
    }
}
  • 역할 : 회원가입 과정에서 필요한 모든 데이터를 한 곳에 모아 관리합니다.
  • 특징 :
    • 이메일, 이름, 비밀번호, 전화번호 등 기본 정보를 포함합니다.
    • 관심 주제는 Set 타입으로 관리되어 중복 선택을 방지합니다.
    • Equatable 프로토콜 채택으로 값 비교가 용이해집니다.

Shared 상태

TCA에서는 @Shared 프로퍼티 래퍼를 사용하여 여러 Reducer에서 동일한 데이터를 공유할 수 있습니다.
예제에서는 SignUpFeature.State 내에서 @Shared var signUpData: SignUpData로 선언해, 모든 회원가입 단계에서 같은 데이터를 참조할 수 있도록 합니다.


🎯 SignUpFeature와 플로우 관리

SignUpFeature Reducer

전체 회원가입 플로우를 관리하는 메인 Reducer입니다.
이 Reducer는 각 단계(Basics, PersonalInfo, Topics, Summary)를 서브 Reducer로 포함하며, 화면 전환을 관리합니다.

@Reducer
private struct SignUpFeature {
  enum Path {
      case basics(BasicsFeature)
      case personalInfo(PersonalInfoFeature)
      case summary(SummaryFeature)
      case topics(TopicsFeature)
  }
  
  struct State: Equatable {
      var path = StackState<Path.State>()
      @Shared var signUpData: SignUpData
  }
  
  enum Action {
      case path(StackActionOf<Path>)
  }
  
  var body: some ReducerOf<Self> {
      Reduce { state, action in
          // Topics 화면에서 'stepFinished' delegate 액션이 발생하면 Summary 화면으로 전환
          switch action {
          case .path(.element(id: _, action: .topics(.delegate(.stepFinished)))):
              state.path.append(.summary(SummaryFeature.State(signUpData: state.$signUpData)))
              return .none
          case .path:
              return .none
          }
      }
      .forEach(\.path, action: \.path)
  }
}
  • 핵심

    • 회원가입의 각 단계에 해당하는 Reducer를 연결하여 단계 간 데이터를 공유합니다.
    • Topics 단계 완료(delegate 액션) 시 Summary 화면으로 이동하도록 처리합니다.

📌 각 단계별 Reducer 및 화면

1. BasicsFeature – 기본 정보 입력

  • Reducer 구성:
    기본 정보(이메일, 비밀번호 등)를 입력하는 화면의 상태를 관리합니다.
@Reducer
private struct BasicsFeature {
    struct State: Equatable {
        var isEditingFromSummary = false
        @Shared var signUpData: SignUpData
    }
    
    enum Action: BindableAction {
        case binding(BindingAction<State>)
    }
    
    var body: some ReducerOf<Self> {
        BindingReducer()
    }
}
  • 화면(BasicStep):
    이메일, 비밀번호, 비밀번호 확인을 입력하는 폼으로 구성되어 있으며, 입력된 값은 공유된 signUpData에 저장됩니다.
private struct BasicStep: View {
    @Environment(\.dismiss) private var dismiss
    @Bindable var store: StoreOf<BasicsFeature>
    
    var body: some View {
        Form {
            Section {
                TextField("Email", text: $store.signUpData.email)
            }
            
            Section {
                SecureField("Password", text: $store.signUpData.password)
                SecureField("Password confirmation", text: $store.signUpData.passwordConfirmation)
            }
        }
        .navigationTitle("Basics")
        .toolbar {
            ToolbarItem {
                if store.isEditingFromSummary {
                    Button("Done") { dismiss() }
                } else {
                    NavigationLink(
                        state: SignUpFeature.Path.State.personalInfo(
                            PersonalInfoFeature.State(signUpData: store.$signUpData)
                        )
                    ) { Text("Next") }
                }
            }
        }
    }
}

2. PersonalInfoFeature – 개인 정보 입력

  • Reducer 구성:
    개인 정보(이름, 전화번호 등)를 입력받아 공유 상태에 저장합니다.
@Reducer
private struct PersonalInfoFeature {
    struct State: Equatable {
        var isEditingFromSummary = false
        @Shared var signUpData: SignUpData
    }
    
    enum Action: BindableAction {
        case binding(BindingAction<State>)
    }
    
    @Dependency(\.dismiss) var dismiss
    var body: some ReducerOf<Self> {
        BindingReducer()
    }
}
  • 화면(PersonalInfoStep):
    이름과 전화번호를 입력하는 폼을 구성하며, ‘Next’ 버튼을 통해 다음 단계(Topics)로 이동합니다.
private struct PersonalInfoStep: View {
    @Environment(\.dismiss) private var dismiss
    @Bindable var store: StoreOf<PersonalInfoFeature>
    
    var body: some View {
        Form {
            Section {
                TextField("First name", text: $store.signUpData.firstName)
                TextField("Last name", text: $store.signUpData.lastName)
                TextField("Phone number", text: $store.signUpData.phoneNumber)
            }
        }
        .navigationTitle("Personal Info")
        .toolbar {
            ToolbarItem {
                if store.isEditingFromSummary {
                    Button("Done") { dismiss() }
                } else {
                    NavigationLink(
                        "Next",
                        state: SignUpFeature.Path.State.topics(
                            TopicsFeature.State(topics: store.$signUpData.topics)
                        )
                    )
                }
            }
        }
    }
}

3. TopicsFeature – 관심 주제 선택

  • Reducer 구성:
    사용자가 선택한 관심 주제를 관리합니다. 최소 하나 이상의 주제를 선택해야 다음 단계로 이동할 수 있도록 유효성 검사를 진행합니다.
@Reducer
private struct TopicsFeature {
    struct State: Equatable {
        @Presents var alert: AlertState<Never>?
        var isEditingFromSummary = false
        @Shared var topics: Set<SignUpData.Topic>
    }
    
    enum Action: BindableAction {
        case alert(PresentationAction<Never>)
        case binding(BindingAction<State>)
        case delegate(Delegate)
        case doneButtonTapped
        case nextButtonTapped
        
        enum Delegate { case stepFinished }
    }
    
    @Dependency(\.dismiss) var dismiss
    
    var body: some ReducerOf<Self> {
        BindingReducer()
        Reduce { state, action in
            switch action {
            case .alert, .binding, .delegate:
                return .none
            case .doneButtonTapped:
                if state.topics.isEmpty {
                    state.alert = AlertState { TextState("Please choose at least one topic.") }
                    return .none
                } else {
                    return .run { _ in await dismiss() }
                }
            case .nextButtonTapped:
                if state.topics.isEmpty {
                    state.alert = AlertState { TextState("Please choose at least one topic.") }
                    return .none
                } else {
                    return .send(.delegate(.stepFinished))
                }
            }
        }
        .ifLet(\\.$alert, action: \\ .alert)
    }
}
  • 화면(TopicsStep):
    여러 관심 주제를 토글 형식으로 보여주며, 사용자가 선택한 주제는 signUpData.topics에 저장됩니다.
private struct TopicsStep: View {
    @Bindable var store: StoreOf<TopicsFeature>
    
    var body: some View {
        Form {
            Section { Text("Please choose all the topics you are interested in.") }
            Section {
                ForEach(SignUpData.Topic.allCases) { topic in
                    Toggle(isOn: $store.topics[contains: topic]) { Text(topic.rawValue) }
                }
            }
        }
        .navigationTitle("Topics")
        .alert($store.scope(state: \.alert, action: \.alert))
        .toolbar {
            ToolbarItem {
                if store.isEditingFromSummary {
                    Button("Done") { store.send(.doneButtonTapped) }
                } else {
                    Button("Next") { store.send(.nextButtonTapped) }
                }
            }
        }
        .interactiveDismissDisabled()
    }
}

4. SummaryFeature – 최종 요약 및 수정

  • Reducer 구성:
    최종적으로 모든 입력 정보를 요약하여 보여주고, 사용자가 수정할 수 있도록 각 단계별로 다시 이동할 수 있게 합니다.
@Reducer
private struct SummaryFeature {
    enum Destination {
        case alert(AlertState<Never>)
        case basics(BasicsFeature)
        case personalInfo(PersonalInfoFeature)
        case topics(TopicsFeature)
    }
    struct State: Equatable {
        @Presents var destination: Destination.State?
        @Shared var signUpData: SignUpData
    }
    enum Action {
        case destination(PresentationAction<Destination.Action>)
        case editFavoriteTopicsButtonTapped
        case editPersonalInfoButtonTapped
        case editRequiredInfoButtonTapped
        case submitButtonTapped
    }
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .destination:
                return .none
            case .editFavoriteTopicsButtonTapped:
                state.destination = .topics(TopicsFeature.State(isEditingFromSummary: true, topics: state.$signUpData.topics))
                return .none
            case .editPersonalInfoButtonTapped:
                state.destination = .personalInfo(PersonalInfoFeature.State(isEditingFromSummary: true, signUpData: state.$signUpData))
                return .none
            case .editRequiredInfoButtonTapped:
                state.destination = .basics(BasicsFeature.State(isEditingFromSummary: true, signUpData: state.$signUpData))
                return .none
            case .submitButtonTapped:
                state.destination = .alert(AlertState { TextState("Thank you for signing up!") })
                return .none
            }
        }
        .ifLet(\\.$destination, action: \\ .destination)
    }
}
  • 화면(SummaryStep):
    회원가입 과정에서 입력한 모든 정보를 확인하고, 각 섹션마다 수정 버튼이 있어 필요한 부분을 다시 수정할 수 있도록 합니다.
    또한, 제출(Submit) 버튼을 누르면 최종적으로 회원가입이 완료됩니다.
private struct SummaryStep: View {
    @Bindable var store: StoreOf<SummaryFeature>
    
    var body: some View {
        Form {
            Section {
                Text(store.signUpData.email)
                Text(String(repeating: "•", count: store.signUpData.password.count))
            } header: {
                HStack {
                    Text("Required info")
                    Spacer()
                    Button("Edit") {
                        store.send(.editRequiredInfoButtonTapped)
                    }
                    .font(.caption)
                }
            }
            
            Section {
                Text(store.signUpData.firstName)
                Text(store.signUpData.lastName)
                Text(store.signUpData.phoneNumber)
            } header: {
                HStack {
                    Text("Personal info")
                    Spacer()
                    Button("Edit") {
                        store.send(.editPersonalInfoButtonTapped)
                    }
                    .font(.caption)
                }
            }
            
            Section {
                ForEach(store.signUpData.topics.sorted(by: { $0.rawValue < $1.rawValue })) { topic in
                    Text(topic.rawValue)
                }
            } header: {
                HStack {
                    Text("Favorite topics")
                    Spacer()
                    Button("Edit") {
                        store.send(.editFavoriteTopicsButtonTapped)
                    }
                    .font(.caption)
                }
            }
            
            Section {
                Button {
                    store.send(.submitButtonTapped)
                } label: {
                    Text("Submit")
                }
            }
        }
        .navigationTitle("Summary")
        .sheet(
            item: $store.scope(state: \.destination?.basics, action: \.destination.basics)
        ) { basicsStore in
            NavigationStack {
                BasicStep(store: basicsStore)
            }
            .presentationDetents([.medium])
        }
        .sheet(
            item: $store.scope(state: \.destination?.personalInfo, action: \.destination.personalInfo)
        ) { personalStore in
            NavigationStack {
                PersonalInfoStep(store: personalStore)
            }
            .presentationDetents([.medium])
        }
        .sheet(
            item: $store.scope(state: \.destination?.topics, action: \.destination.topics)
        ) { topicsStore in
            NavigationStack {
                TopicsStep(store: topicsStore)
            }
            .presentationDetents([.medium])
        }
        .alert($store.scope(state: \.destination?.alert, action: \.destination.alert))
    }
}


🛠️ 마무리 정리

  • Shared 상태 관리

    • TCA의 @Shared를 활용하면 여러 Reducer에서 동일한 데이터를 손쉽게 공유할 수 있습니다.
    • InMemoryKey를 사용하면 앱 실행 중에만 상태를 유지하고, 앱 종료 시 데이터가 초기화됩니다.
  • 각 단계별 Reducer 구성

    • BasicsFeature : 이메일과 비밀번호(및 확인)를 입력합니다.
    • PersonalInfoFeature : 이름과 전화번호 등 개인 정보를 입력합니다.
    • TopicsFeature : 사용자가 관심 있는 주제를 선택합니다. (최소 1개 이상 선택 필수)
    • SummaryFeature : 지금까지 입력한 모든 정보를 요약하여 보여주고, 필요 시 각 단계로 돌아가 수정할 수 있도록 합니다.
  • 화면 전환

    • NavigationStackNavigationLink를 사용해 각 회원가입 단계를 자연스럽게 전환합 니다.
    • 수정 버튼을 통해 사용자는 언제든지 이전 단계로 돌아가 데이터를 수정할 수 있습니다.
  • TCA의 장점

    • Reducer를 통해 상태 관리와 액션 처리를 일관성 있게 유지할 수 있습니다.
    • 공유 상태 덕분에 데이터가 여러 화면에서 실시간으로 반영되어, 복잡한 플로우에서도 데이터의 일관성을 보장합니다.

이 예제를 통해 TCA에서 Shared 상태를 활용하여 복잡한 회원가입 플로우를 효과적으로 구현하는 방법을 이해할 수 있기를 바랍니다. 다음 글에서는 TCA의 또 다른 활용 사례를 다루겠습니다. 감사합니다!

profile
밍밍

0개의 댓글