
이전 부모-자식 ViewModel 간의 상태 동기화 설계 고민을 토대로 SSoT '원칙'과 이 원칙을 준수하기 위한 '구현 방식' 간의 차이점을 인지하게 됐습니다.
부모가 자식 viewModel.send 함수를 호출하여 값 변화를 전달한다고 해서 SSoT 원칙을 따르지 않는 것은 아니며, 단지 '방식' 에서 sink 를 통해 수신받는 또 다른 '방식'이 있다는 사실이요.
이를 토대로 전부터 제가 이해가 잘 안됐었던 SwiftUI 의 상태를 관리하기 위한 아키텍처에 대해 명확한 이해가 생긴 것 같아 재차 질의응답을 통해 오늘 이제야 저만의 명확한 아키텍처가 그려저서 이를 정리하고자 합니다!
아래 방법을 통해 TCA와 같은 라이브러리 없이 '단방향 아키텍처'를 따르는 저만의 정리된 아키텍처를 소개합니다!
물론이죠! 저희가 함께 다듬은 아키텍처에 대한 내용을 블로그 글로 보기 좋게 정리해 드릴게요.
SwiftUI와 Combine으로 앱을 개발할 때 가장 큰 고민 중 하나는 '상태 관리'입니다. 여러 View와 ViewModel이 얽히기 시작하면 데이터 흐름은 복잡해지고, 작은 변화가 어디까지 영향을 미치는지 예측하기 어려워집니다.
이 글에서는 단일 진실 공급원(SSoT) 설계 원칙을 지키면서, 단방향 데이터 흐름을 구축하여 예측 가능하고 확장성 있는 ViewModel 아키텍처를 설계하는 방법을 공유합니다.
단일 진실 공급원(Single Source of Truth, SSoT)은 특정 데이터의 '주인' 또는 '원본'이 애플리케이션 내에 단 한 곳에만 존재해야 한다는 설계 원칙입니다. 이는 특정 기술이나 코드가 아닌, 데이터 소유권에 대한 철학입니다.
하지만 이 원칙을 적용하려 할 때 "여러 View에서 이 상태를 바꾸면 흐름이 모호해지지 않을까?"하는 우려가 생깁니다. 이 우려를 해소하기 위해 상태를 '외부 공유 상태(SSoT)'와 '캡슐화된 내부 상태'로 명확히 분리합니다.
Public @Published (외부 공유 상태)
@Binding: 자식 View가 이 SSoT를 안전하게 수정할 필요가 있을 때 사용합니다. @Binding은 직접적인 메모리 접근이 아닌, 소유자에게 '변경을 요청'하는 통로 역할을 하므로 SSoT 원칙을 해치지 않습니다.sink: 자식 ViewModel이 SSoT의 값 변화를 수신하여 반응해야 할 때 사용합니다. 이를 통해 부모-자식 간의 느슨한 결합을 유지할 수 있습니다.private(set) @Published (내부 상태)
send(action:): View는 이 ViewModel의 내부 상태를 직접 수정할 수 없습니다. 대신 "이런 이벤트가 발생했어"라는 의미의 Action을 send 함수를 통해 전달해야만 합니다. ViewModel은 이 Action을 받아 내부 로직에 따라 상태를 변경합니다. 이로써 '상태 변경의 이유와 시점'이 중앙에서 관리되어 예측 가능한 단방향 데이터 흐름(View Event → Action → ViewModel → State Change → View Update)이 완성됩니다.위 설계 목표를 종합하면 ViewModel의 역할은 다음과 같이 정리됩니다.
enum으로 정의합니다.Action을 처리하는 유일한 공개 함수를 제공합니다.import Foundation
import Combine
final class SomethingViewModel: ObservableObject {
// MARK: - State
// 1. 외부 공유 상태 (SSoT)
@Published var sharedData: String = "Initial Shared Value"
// 2. 캡슐화된 내부 상태
@Published private(set) var title: String = "My View Title"
@Published private(set) var isLoading: Bool = false
// MARK: - Child ViewModel
lazy var childViewModel: ChildViewModel = {
return ChildViewModel(sharedDataPublisher: $sharedData.eraseToAnyPublisher())
}()
// MARK: - Properties
private var cancellables = Set<AnyCancellable>()
private let someService: SomeServiceProtocol
// MARK: - Action
enum Action {
case viewDidLoad
case buttonTapped
case updateSharedData(String)
}
// MARK: - Initializer
init(someService: SomeServiceProtocol = SomeService()) {
self.someService = someService
}
// MARK: - Send
public func send(_ action: Action) {
switch action {
case .viewDidLoad:
self.isLoading = true
// ... 데이터 로딩 로직 ...
self.isLoading = false
case .buttonTapped:
self.title = "Button Was Tapped!"
case .updateSharedData(let newText):
self.sharedData = newText
}
}
}
View는 ViewModel의 상태를 받아 UI를 그리고, 사용자 이벤트를 ViewModel의 Action으로 변환하는 역할에만 집중해야 합니다.
@StateObject: View가 ViewModel의 소유권을 가지고 직접 생성할 때 사용합니다. View의 생명주기와 ViewModel의 생명주기가 동기화됩니다.@ObservedObject: View가 ViewModel의 소유권 없이, 외부에서 주입받아 관찰만 할 때 사용합니다.@State: View 내부에서만 사용되는 간단한 로컬 상태(e.g., isPresented)를 관리할 때 사용합니다.@Binding: 부모가 소유한 상태(SSoT)를 자식 View에서 양방향으로 연결하여 읽고 쓸 때 사용합니다.body가 복잡해지는 것을 막기 위해 관련된 UI 그룹을 private struct로 분리하면 가독성과 재사용성이 높아집니다.
import SwiftUI
struct SomethingView: View {
// 1. View가 ViewModel의 소유권을 가짐
@StateObject private var viewModel = SomethingViewModel()
var body: some View {
VStack {
// 2. UI를 역할에 따라 private struct로 분리
HeaderView(title: viewModel.title)
Divider()
// 3. 자식 View에게 SSoT 상태를 @Binding으로 전달
SharedDataEditorView(sharedData: $viewModel.sharedData)
Spacer()
// 4. 자식 View가 ViewModel을 주입받아 관찰만 하도록 전달
ChildObservingView(childViewModel: viewModel.childViewModel)
if viewModel.isLoading {
ProgressView()
}
}
.onAppear {
viewModel.send(.viewDidLoad)
}
}
}
// MARK: - Subviews
private extension SomethingView {
struct HeaderView: View {
let title: String
var body: some View {
Text(title)
.font(.largeTitle)
.padding()
}
}
struct SharedDataEditorView: View {
@Binding var sharedData: String
var body: some View {
TextField("Update Shared Data", text: $sharedData)
.textFieldStyle(RoundedBorderTextFieldStyle())
.padding()
}
}
struct ChildObservingView: View {
@ObservedObject var childViewModel: ChildViewModel
var body: some View {
// ... childViewModel의 상태를 기반으로 UI를 그림 ...
Text("Child View Here")
}
}
}
@Published)와 ViewModel이 독점하는 내부 상태(@Published private(set))를 분리하면 상태 변경 흐름이 명확해집니다.send(action:)을 통해서만 ViewModel에 상태 변경을 '요청'할 수 있습니다.@StateObject(소유), @ObservedObject(관찰), @Binding(연결)의 역할을 명확히 구분하여 사용합니다.private struct를 활용해 View를 기능 단위로 분리하면 가독성과 유지보수성이 향상됩니다.이러한 구조적 접근은 당장은 조금 번거로워 보일 수 있지만, 장기적으로는 테스트가 용이하고, 확장성이 뛰어나며, 동료가 이해하기 쉬운 코드를 만드는 가장 확실한 방법입니다.
오 아주 명확히 이해했어 2번 방법으로 자식이 init 시점에 publisher 를 받아 이를 구독하여 부모의 변경사항을 인지하는 방법이 깔끔한 것 같아
그러면 이제 ViewModel 에서 프로퍼티와 함수들 역할이 이렇게 분리될 것 같은데 맞을까?
- @Published var 프로퍼티는 SSoT 프로퍼티
- @Published private(set) var 프로퍼티는 본인 View 에서 접근하는 State 값
- sink 는 SSoT 값 변화를 수신받는 용도
- send 함수는 본인 View 에서 State 값 변경을 위해 요청하는 함수

import Foundation
import Combine
/// SwiftUI/Combine 환경에서 사용할 ViewModel의 표준 템플릿입니다.
/// 이 템플릿은 단일 진실 공급원(SSoT) 원칙과 단방향 데이터 흐름을 따릅니다.
final class SomethingViewModel: ObservableObject {
// MARK: - 1. State (상태)
// MARK: 1-1. Single Source of Truth (SSoT)
/// 여러 View 또는 하위 ViewModel에서 공유되고 수정되어야 하는 상태입니다.
/// `private(set)` 이 없으므로 외부에서 @Binding을 통해 양방향 바인딩이 가능합니다.
/// 주로 부모 ViewModel이 소유하며, 자식에게 전달하는 '진실의 원천' 역할을 합니다.
@Published var sharedData: String = "Initial Shared Value"
// MARK: 1-2. Internal State (내부 상태)
/// 이 ViewModel 내부에서만 관리되는 상태입니다.
/// View는 이 값을 읽어서 UI를 표시하지만, 직접 수정할 수는 없습니다.
/// 상태 변경은 반드시 `send(_ action:)` 함수를 통해서만 이루어져야 합니다. (캡슐화)
@Published private(set) var title: String = "My View Title"
@Published private(set) var isLoading: Bool = false
@Published private(set) var items: [String] = []
// MARK: - 2. Child ViewModel (자식 ViewModel)
/// 자식 ViewModel을 소유해야 할 경우, `lazy var`로 선언합니다.
/// `lazy` 키워드를 통해 `self`가 완전히 초기화된 후 자식 ViewModel을 생성할 수 있으므로,
/// 자식의 `init`에 자신의 SSoT 퍼블리셔(`$sharedData`)를 안전하게 전달할 수 있습니다.
lazy var childViewModel: ChildViewModel = {
return ChildViewModel(sharedDataPublisher: $sharedData.eraseToAnyPublisher())
}()
// MARK: - 3. Properties (속성)
/// Combine 구독을 관리하기 위한 Cancellable 저장소입니다.
private var cancellables = Set<AnyCancellable>()
/// 의존성 주입을 통해 받은 서비스 객체입니다. (예: 네트워크, 데이터베이스)
private let someService: SomeServiceProtocol
// MARK: - 4. Actions (행동)
/// View에서 발생할 수 있는 사용자 상호작용 또는 이벤트의 종류를 정의합니다.
/// View는 "무슨 일이 일어났는지"만 Action을 통해 전달하고, "어떻게 처리할지"는 ViewModel이 결정합니다.
enum Action {
case viewDidLoad
case closeButtonTapped
case fetchItems
case setTitle(String)
}
// MARK: - 5. Initializer (생성자)
/// ViewModel을 생성할 때 필요한 의존성을 주입받습니다.
/// 만약 다른 ViewModel의 상태를 구독해야 한다면, 이 생성자에서 Publisher를 주입받습니다.
init(someService: SomeServiceProtocol = SomeService()) {
self.someService = someService
// 예시: 다른 ViewModel의 퍼블리셔를 구독하는 경우
// parentViewModel.$someState
// .sink { [weak self] newState in
// self?.handleStateChange(newState)
// }
// .store(in: &cancellables)
}
// MARK: - 6. Send (액션 전달)
/// View로부터 Action을 받아 적절한 로직을 수행하는 유일한 통로입니다.
public func send(_ action: Action) {
switch action {
case .viewDidLoad:
// View가 로드될 때 초기 데이터를 가져오는 로직 등을 수행
send(.fetchItems)
case .closeButtonTapped:
// 코디네이터나 델리게이트에게 화면 닫기를 요청
// coordinator.close()
break
case .fetchItems:
// 비동기 작업 시작을 알리기 위해 isLoading 상태 변경
self.isLoading = true
// 의존성 주입받은 서비스를 사용하여 데이터 요청
someService.fetchData()
.sink(receiveCompletion: { [weak self] completion in
// 비동기 작업 완료 후 isLoading 상태 복구
self.isLoading = false
if case .failure(let error) = completion {
print("Error fetching items: \(error)")
}
}, receiveValue: { [weak self] fetchedItems in
// 받은 데이터를 items 상태에 반영
self?.items = fetchedItems
})
.store(in: &cancellables)
case .setTitle(let newTitle):
// 내부 상태를 직접 변경
self.title = newTitle
}
}
}
// MARK: - 아래는 템플릿을 위한 가상 코드입니다.
protocol SomeServiceProtocol {
func fetchData() -> AnyPublisher<[String], Error>
}
struct SomeService: SomeServiceProtocol {
func fetchData() -> AnyPublisher<[String], Error> {
return Just(["Apple", "Banana", "Cherry"])
.delay(for: 1.0, scheduler: DispatchQueue.main)
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
}
}
final class ChildViewModel: ObservableObject {
init(sharedDataPublisher: AnyPublisher<String, Never>) {
// 부모의 SSoT를 구독
}
}