반응형

SwiftUI Study – 도메인 상태와 UI 상태를 분리하는 방법

 

1. 왜 중요한가 (문제 배경)

SwiftUI에서는 모든 것이 상태(State)를 기준으로 동작합니다.

이때 도메인 상태와 UI 상태를 구분하지 않으면 다음과 같은 문제가 자주 발생합니다.

  • 네트워크/DB 데이터와 일회성 UI 상태가 한 모델에 섞임
  • 여러 화면에서 동일 모델을 사용할 때 UI 전용 필드 때문에 재사용 불가
  • 로직 테스트가 어려워지고 화면 변경 시 도메인 모델이 불필요하게 수정됨

결국 앱 구조가 빠르게 복잡해지고 유지보수가 어려워집니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: API 응답 모델에 UI 전용 필드를 추가

struct User: Codable, Identifiable {
    let id: Int
    let name: String
    let email: String

    // ❌ 화면 전용 상태가 도메인 모델에 섞여버림
    var isExpanded: Bool = false
    var isSelected: Bool = false
}

문제점

  • 화면 로직 변경이 도메인 모델에 전파됨
  • 여러 화면에서 User를 재사용할 때 필요 없는 UI 필드가 따라다님
  • API 구조 변경과 UI 변경이 서로 영향을 줌

❌ 예시 2: View가 UI 상태와 도메인 상태를 모두 관리

struct UserListView: View {
    @State private var users: [User] = []        // 도메인 상태
    @State private var selectedUserID: Int?      // UI 상태
    @State private var isLoading = false         // UI 상태
    @State private var errorMessage: String?     // UI 상태

    var body: some View {
        // 비즈니스 + UI + 네트워크가 뒤섞인 구조
    }
}

문제점

  • View의 책임 과다
  • 테스트 불가능
  • 화면이 커질수록 복잡성이 폭증

❌ 예시 3: 도메인 로직에서 UI 상태까지 조작

final class UserService {
    func toggleFavorite(user: inout User) {
        // 서버 통신
        user.isSelected.toggle()  // ❌ UI 상태까지 변경
    }
}

문제점

  • 계층 분리가 무너짐
  • 모든 화면에서 예측 불가능한 상태 전파

 

3. 올바른 패턴 예시

✅ 예시 1: 도메인 모델과 UI 모델을 명확히 분리

struct User: Codable, Identifiable, Equatable {
    let id: Int
    let name: String
    let email: String
}

struct UserRowUIState: Identifiable, Equatable {
    let id: Int
    let name: String
    let email: String

    var isExpanded: Bool = false
    var isSelected: Bool = false

    init(user: User,
         isExpanded: Bool = false,
         isSelected: Bool = false) {
        self.id = user.id
        self.name = user.name
        self.email = user.email
        self.isExpanded = isExpanded
        self.isSelected = isSelected
    }
}

장점

  • 도메인 모델은 순수하게 유지
  • UI 상태 변경은 도메인 모델에 영향을 주지 않음
  • 다양한 화면에서 User 모델을 재사용 가능

✅ 예시 2: ViewModel에서 도메인 상태와 UI 상태를 함께 관리

final class UserListViewModel: ObservableObject {
    @Published private(set) var users: [User] = []          // 도메인 상태
    @Published var rows: [UserRowUIState] = []              // UI 상태
    @Published var isLoading = false
    @Published var errorMessage: String?

    func load() async {
        isLoading = true
        defer { isLoading = false }

        do {
            let fetched = try await api.fetchUsers()
            users = fetched
            rows = fetched.map { UserRowUIState(user: $0) }
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func toggleExpanded(for id: Int) {
        guard let index = rows.firstIndex(where: { $0.id == id }) else { return }
        rows[index].isExpanded.toggle()
    }
}

✅ 예시 3: View에서는 UI 상태만 표시

struct UserListView: View {
    @StateObject private var viewModel = UserListViewModel()

    var body: some View {
        List(viewModel.rows) { row in
            VStack(alignment: .leading) {
                Text(row.name)
                Text(row.email).font(.subheadline).foregroundColor(.secondary)

                if row.isExpanded {
                    Text("추가 정보 영역…")
                }
            }
            .onTapGesture {
                viewModel.toggleExpanded(for: row.id)
            }
        }
        .overlay {
            if viewModel.isLoading { ProgressView() }
        }
        .task {
            await viewModel.load()
        }
    }
}

장점

  • View는 “표현”에만 집중
  • 도메인과 UI 상태가 분리돼 관리가 쉬움

 

4. 실전 적용 팁

✔ 팁 1 – “이 필드는 서버에도 필요한가?”부터 판단

UI 전용이면 도메인 모델에 넣지 말 것.

✔ 팁 2 – 도메인 모델은 가능한 순수하게 유지

플랫폼에 독립적인 구조를 유지해야 재사용성이 높음.

✔ 팁 3 – UI 모델 분리는 테스트 구조를 크게 단순화

도메인 테스트와 UI 테스트를 분리할 수 있음.

✔ 팁 4 – TCA / 클린 아키텍처와 궁합이 매우 좋음

State 구조를 자연스럽게 분리 가능.

 

5. 정리

  • 도메인과 UI 상태를 혼합하면 변경 영향이 기하급수적으로 증가
  • 도메인 모델은 API/비즈니스 규칙만 담고 UI 상태는 별도로 유지
  • ViewModel에서 두 상태를 조합하면 유지보수성 상승
  • View는 표현에만 집중해 코드가 단순해짐
반응형
Posted by 까칠코더
,