반응형
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는 표현에만 집중해 코드가 단순해짐
반응형
'Dev Study > SwiftUI' 카테고리의 다른 글
| SwiftUI Study – offset / position으로 레이아웃을 잡지 말아야 하는 이유 (0) | 2025.12.09 |
|---|---|
| SwiftUI Study – GeometryReader에 의존하지 않고 레이아웃을 안정적으로 구성하는 방법 (0) | 2025.12.09 |
| SwiftUI Study – 상태 변경을 한 곳으로 모으는 패턴 (0) | 2025.12.09 |
| SwiftUI Study – FocusState를 안전하게 설계하는 방법 (0) | 2025.12.09 |
| SwiftUI Study – @State / @Binding / @ObservedObject / @StateObject / @EnvironmentObject 정확한 역할 이해하기 (0) | 2025.12.09 |
| SwiftUI에서 많이 하는 실수 - Button 안에서 상태 변경 로직이 여러 계층에 중첩되어 UI가 비정상 업데이트되는 문제 (0) | 2025.12.08 |
| SwiftUI에서 많이 하는 실수 - 하나의 상태를 여러 곳에서 동시에 수정해 충돌이 나는 실수 (1) | 2025.12.05 |
| SwiftUI에서 많이 하는 실수 - AppStorage / SceneStorage를 남용해 예기치 않은 상태 유지가 발생하는 실수 (0) | 2025.12.05 |

