
안녕하세요, 별똥별🌠입니다!
이번 글에서는 TCA(The Composable Architecture)를 활용해 복잡한 회원가입 플로우를 구현하는 방법을 알아봅니다. 여러 단계의 화면이 공유 상태를 사용해 데이터를 실시간으로 반영하며, InMemoryKey를 통해 앱 실행 중에만 상태를 유지할 수 있도록 구성되어 있습니다.
회원가입 플로우는 세 단계와 최종 요약 화면으로 구성되어 있습니다.
각 단계는 다음과 같습니다:
- Basics (기본 정보 입력)
사용자는 이메일과 비밀번호(및 확인)를 입력합니다. 입력한 정보는 전체 회원가입 데이터에 바로 반영됩니다.
- personal Info (개인 정보 입력)
사용자는 이름, 전화번호 등 개인 정보를 입력합니다. 이 정보 역시 공유 상태에 저장되어 다른 화면에서도 즉시 업데이트됩니다.
- topics (관심 주제 선택)
사용자가 관심 있는 주제(예: 드럼, 베이스 등)를 선택합니다. 최소 하나 이상의 주제를 선택해야 하며, 선택되지 않으면 경고 메시지를 표시합니다.
- Summary (요약 화면)
지금까지 입력한 모든 정보를 한눈에 확인할 수 있으며, 수정 버튼을 눌러 이전 단계로 돌아가 데이터를 수정할 수 있습니다.
모든 단계에서 회원가입 데이터는 SignUpData 구조체에 저장되고, TCA의 @Shared 프로퍼티를 통해 Reducer 간에 동일한 데이터가 공유됩니다.
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 프로토콜 채택으로 값 비교가 용이해집니다.
TCA에서는 @Shared 프로퍼티 래퍼를 사용하여 여러 Reducer에서 동일한 데이터를 공유할 수 있습니다.
예제에서는 SignUpFeature.State 내에서 @Shared var signUpData: SignUpData로 선언해, 모든 회원가입 단계에서 같은 데이터를 참조할 수 있도록 합니다.
전체 회원가입 플로우를 관리하는 메인 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
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()
}
}
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") }
}
}
}
}
}
@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()
}
}
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)
)
)
}
}
}
}
}
@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)
}
}
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()
}
}
@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)
}
}
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 : 지금까지 입력한 모든 정보를 요약하여 보여주고, 필요 시 각 단계로 돌아가 수정할 수 있도록 합니다.
화면 전환
- NavigationStack과 NavigationLink를 사용해 각 회원가입 단계를 자연스럽게 전환합 니다.
- 수정 버튼을 통해 사용자는 언제든지 이전 단계로 돌아가 데이터를 수정할 수 있습니다.
TCA의 장점
- Reducer를 통해 상태 관리와 액션 처리를 일관성 있게 유지할 수 있습니다.
- 공유 상태 덕분에 데이터가 여러 화면에서 실시간으로 반영되어, 복잡한 플로우에서도 데이터의 일관성을 보장합니다.
이 예제를 통해 TCA에서 Shared 상태를 활용하여 복잡한 회원가입 플로우를 효과적으로 구현하는 방법을 이해할 수 있기를 바랍니다. 다음 글에서는 TCA의 또 다른 활용 사례를 다루겠습니다. 감사합니다!