SwiftUI 상태 관리 가능한 ViewModel 아키텍처 : SSoT + 단방향

Minsang Kang·2025년 6월 19일

iOS Develop

목록 보기
10/12
post-thumbnail

이전 부모-자식 ViewModel 간의 상태 동기화 설계 고민을 토대로 SSoT '원칙'이 원칙을 준수하기 위한 '구현 방식' 간의 차이점을 인지하게 됐습니다.

부모가 자식 viewModel.send 함수를 호출하여 값 변화를 전달한다고 해서 SSoT 원칙을 따르지 않는 것은 아니며, 단지 '방식' 에서 sink 를 통해 수신받는 또 다른 '방식'이 있다는 사실이요.

이를 토대로 전부터 제가 이해가 잘 안됐었던 SwiftUI 의 상태를 관리하기 위한 아키텍처에 대해 명확한 이해가 생긴 것 같아 재차 질의응답을 통해 오늘 이제야 저만의 명확한 아키텍처가 그려저서 이를 정리하고자 합니다!

아래 방법을 통해 TCA와 같은 라이브러리 없이 '단방향 아키텍처'를 따르는 저만의 정리된 아키텍처를 소개합니다!


물론이죠! 저희가 함께 다듬은 아키텍처에 대한 내용을 블로그 글로 보기 좋게 정리해 드릴게요.


SwiftUI에서 예측 가능한 상태 관리를 위한 ViewModel 아키텍처

SwiftUI와 Combine으로 앱을 개발할 때 가장 큰 고민 중 하나는 '상태 관리'입니다. 여러 View와 ViewModel이 얽히기 시작하면 데이터 흐름은 복잡해지고, 작은 변화가 어디까지 영향을 미치는지 예측하기 어려워집니다.

이 글에서는 단일 진실 공급원(SSoT) 설계 원칙을 지키면서, 단방향 데이터 흐름을 구축하여 예측 가능하고 확장성 있는 ViewModel 아키텍처를 설계하는 방법을 공유합니다.

1. 설계 목표

1-1. SSoT: 원칙과 현실

단일 진실 공급원(Single Source of Truth, SSoT)은 특정 데이터의 '주인' 또는 '원본'이 애플리케이션 내에 단 한 곳에만 존재해야 한다는 설계 원칙입니다. 이는 특정 기술이나 코드가 아닌, 데이터 소유권에 대한 철학입니다.

하지만 이 원칙을 적용하려 할 때 "여러 View에서 이 상태를 바꾸면 흐름이 모호해지지 않을까?"하는 우려가 생깁니다. 이 우려를 해소하기 위해 상태를 '외부 공유 상태(SSoT)''캡슐화된 내부 상태'로 명확히 분리합니다.

1-2. 상태의 분리와 관리

  • Public @Published (외부 공유 상태)

    • 역할: 여러 컴포넌트가 공유하는 '진실의 원천(SSoT)'입니다.
    • @Binding: 자식 View가 이 SSoT를 안전하게 수정할 필요가 있을 때 사용합니다. @Binding은 직접적인 메모리 접근이 아닌, 소유자에게 '변경을 요청'하는 통로 역할을 하므로 SSoT 원칙을 해치지 않습니다.
    • sink: 자식 ViewModel이 SSoT의 값 변화를 수신하여 반응해야 할 때 사용합니다. 이를 통해 부모-자식 간의 느슨한 결합을 유지할 수 있습니다.
  • private(set) @Published (내부 상태)

    • 역할: 해당 ViewModel만이 소유하고 관리하는 캡슐화된 상태입니다.
    • send(action:): View는 이 ViewModel의 내부 상태를 직접 수정할 수 없습니다. 대신 "이런 이벤트가 발생했어"라는 의미의 Actionsend 함수를 통해 전달해야만 합니다. ViewModel은 이 Action을 받아 내부 로직에 따라 상태를 변경합니다. 이로써 '상태 변경의 이유와 시점'이 중앙에서 관리되어 예측 가능한 단방향 데이터 흐름(View Event → Action → ViewModel → State Change → View Update)이 완성됩니다.

2. 아키텍처를 반영한 ViewModel 템플릿

위 설계 목표를 종합하면 ViewModel의 역할은 다음과 같이 정리됩니다.

  • 상태(State): 공유 상태(SSoT)와 내부 상태(Internal State)를 명확히 분리하여 선언합니다.
  • 초기화(Initializer): 필요한 서비스나 부모의 SSoT 퍼블리셔를 의존성 주입(DI) 받습니다.
  • 액션(Action): View가 요청할 수 있는 이벤트의 종류를 enum으로 정의합니다.
  • 전송(Send): View로부터 받은 Action을 처리하는 유일한 공개 함수를 제공합니다.

ViewModel 템플릿 코드

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
        }
    }
}

3. 아키텍처를 반영한 View 템플릿

View는 ViewModel의 상태를 받아 UI를 그리고, 사용자 이벤트를 ViewModel의 Action으로 변환하는 역할에만 집중해야 합니다.

SwiftUI 프로퍼티 래퍼의 역할

  • @StateObject: View가 ViewModel의 소유권을 가지고 직접 생성할 때 사용합니다. View의 생명주기와 ViewModel의 생명주기가 동기화됩니다.
  • @ObservedObject: View가 ViewModel의 소유권 없이, 외부에서 주입받아 관찰만 할 때 사용합니다.
  • @State: View 내부에서만 사용되는 간단한 로컬 상태(e.g., isPresented)를 관리할 때 사용합니다.
  • @Binding: 부모가 소유한 상태(SSoT)를 자식 View에서 양방향으로 연결하여 읽고 쓸 때 사용합니다.

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")
        }
    }
}

4. 핵심 요약

  • SSoT는 '원칙'이다: 특정 상태의 주인은 단 한 곳이어야 합니다.
  • 상태를 분리하라: 외부와 공유할 SSoT(@Published)와 ViewModel이 독점하는 내부 상태(@Published private(set))를 분리하면 상태 변경 흐름이 명확해집니다.
  • 단방향 흐름을 강제하라: View는 send(action:)을 통해서만 ViewModel에 상태 변경을 '요청'할 수 있습니다.
  • 올바른 도구를 사용하라: View에서는 @StateObject(소유), @ObservedObject(관찰), @Binding(연결)의 역할을 명확히 구분하여 사용합니다.
  • View를 작게 유지하라: 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를 구독
    }
} 
profile
 iOS Developer

0개의 댓글