SwiftUI 앱을 언제나 View → ViewModel → Service 세 층으로 나눠야 하는 것은 아니다. 화면 상태가 단순하다면 View가 observable model을 직접 읽는 구조로도 충분하다. ViewModel은 화면 전용 상태와 비동기 동작을 조정할 책임이 커질 때 도입하는 편이 낫다.
이름보다 변경 이유를 먼저 나눈다
| 구성 요소 | 주된 책임 | 넣지 않는 편이 좋은 것 |
|---|---|---|
| View | 상태를 화면으로 표현하고 사용자 이벤트 전달 | 네트워크 구현, 저장소 세부사항 |
| Model | 앱이 다루는 데이터와 핵심 규칙 표현 | 특정 화면 레이아웃 정보 |
| Service·Client | 네트워크·저장소 같은 외부 경계와 통신 | 버튼 활성화 같은 화면 상태 |
| ViewModel | 화면 상태와 사용자 동작 조정 | 모든 도메인 규칙과 인프라 구현 |
Service는 Swift나 SwiftUI가 정한 타입이 아니다. API client, repository, store처럼 프로젝트에서 의도가 잘 드러나는 이름을 고르면 된다. “Service에 모든 비즈니스 로직을 넣는다”처럼 경계가 모호하면 거대한 계층이 되기 쉽다.
View는 표현과 이벤트 전달에 집중한다
SwiftUI View는 현재 상태를 선언적으로 표현한다. 데이터의 출처까지 알 필요는 없다.
import SwiftUI
struct User: Identifiable, Decodable, Equatable {
let id: Int
let name: String
}
struct UserListView: View {
let users: [User]
let isLoading: Bool
let reload: () -> Void
var body: some View {
List(users) { user in
Text(user.name)
}
.overlay {
if isLoading {
ProgressView()
}
}
.refreshable {
reload()
}
}
}
이 View는 사용자를 어디에서 가져오는지 모른다. 입력과 이벤트가 명확해 Preview에서 성공·빈 목록·로딩 상태를 따로 확인하기 쉽다.
Client는 외부 시스템의 차이를 숨긴다
ViewModel이 URLSession의 세부사항까지 떠안지 않도록 필요한 기능만 작은 protocol로 정의할 수 있다.
import Foundation
enum UserClientError: Error {
case invalidResponse
}
protocol UserClient {
func fetchUsers() async throws -> [User]
}
struct LiveUserClient: UserClient {
let session: URLSession
let endpoint: URL
func fetchUsers() async throws -> [User] {
let (data, response) = try await session.data(from: endpoint)
guard let http = response as? HTTPURLResponse,
200..<300 ~= http.statusCode else {
throw UserClientError.invalidResponse
}
return try JSONDecoder().decode([User].self, from: data)
}
}
네트워크 전송, HTTP 응답 검증과 decoding은 client의 책임이다. 재시도와 캐시가 필요하다면 정책의 소유자를 따로 정하되 화면마다 같은 구현을 반복하지 않는다. endpoint와 client를 외부에서 주입하면 운영·Preview·테스트 구현을 바꾸기도 쉽다.
ViewModel이 필요한 신호를 찾는다
다음 조건이 늘어나면 화면 전용 조정 객체를 분리할 가치가 생긴다.
- 로딩·빈 상태·오류·성공 상태가 서로 전환된다.
- 여러 외부 호출을 순서대로 조합해야 한다.
- 검색어 debounce, pagination, 취소 같은 화면 동작이 있다.
- 같은 화면 로직을 View 없이 검증하고 싶다.
- View의
body보다 이벤트 처리 코드가 더 길어진다.
반대로 입력값과 @State 몇 개뿐이라면 ViewModel을 추가해도 파일과 전달 단계만 늘어날 수 있다. SwiftUI가 MVVM을 강제하지 않는다는 점이 출발점이다.
화면 상태는 하나의 타입으로 표현한다
서로 양립할 수 없는 isLoading, error, users를 독립 변수로 두면 “로딩 중이면서 오류가 있는 상태”가 만들어질 수 있다. 하나의 상태 enum으로 조합을 제한하면 전이가 명확해진다.
import Observation
@MainActor
@Observable
final class UserListViewModel {
enum State: Equatable {
case idle
case loading
case loaded([User])
case failed(String)
}
private(set) var state: State = .idle
private let client: any UserClient
init(client: any UserClient) {
self.client = client
}
func load() async {
state = .loading
do {
state = .loaded(try await client.fetchUsers())
} catch is CancellationError {
state = .idle
} catch {
state = .failed("사용자 목록을 불러오지 못했습니다.")
}
}
}
UI 상태 변경은 @MainActor에 두고, 네트워크 I/O는 async 함수가 기다리는 동안 실행 스레드를 붙잡지 않는다. 사용자가 화면을 떠났을 때 작업이 취소될 수 있다는 점도 상태 전이에 반영한다.
View는 상태를 읽고 동작을 전달한다
import SwiftUI
struct UserListScreen: View {
@State private var viewModel: UserListViewModel
init(client: any UserClient) {
_viewModel = State(
initialValue: UserListViewModel(client: client)
)
}
var body: some View {
Group {
switch viewModel.state {
case .idle, .loading:
ProgressView()
case .loaded(let users):
UserListView(
users: users,
isLoading: false,
reload: { Task { await viewModel.load() } }
)
case .failed(let message):
ContentUnavailableView(
"불러오기 실패",
systemImage: "exclamationmark.triangle",
description: Text(message)
)
}
}
.task {
await viewModel.load()
}
}
}
핵심은 MVVM이라는 이름보다 의존성이 한 방향으로 흐른다는 점이다. View는 상태를 읽고 이벤트를 전달하며, ViewModel은 화면 상태를 바꾸고, client는 외부 통신을 담당한다.
대체 가능한 외부 경계만 작게 추상화한다
실제 네트워크 없이 ViewModel의 성공·오류 상태를 확인할 수 있어야 한다.
struct StubUserClient: UserClient {
let result: Result<[User], Error>
func fetchUsers() async throws -> [User] {
try result.get()
}
}
URLSession.shared와 URL을 ViewModel 안에서 직접 만들면 대체하기 어렵고 테스트가 외부 네트워크에 묶인다. 다만 테스트 가능성을 이유로 모든 타입에 protocol을 붙일 필요는 없다. 교체해야 하는 외부 경계나 실제로 여러 구현이 필요한 지점에 작게 적용한다.
자주 커지는 구조를 경계한다
- ViewModel이 API, DB, navigation과 모든 도메인 규칙을 떠안는다.
- Model은 DTO로만 남고 같은 규칙이 여러 ViewModel에 복제된다.
- Service가 다른 Service를 계속 호출해 의존 방향이 보이지 않는다.
- 내부 오류와 사용자에게 보여 줄 메시지를 하나의 문자열로 섞는다.
- 화면이 사라진 뒤 완료된 오래된 요청이 최신 상태를 덮는다.
구조의 목적은 파일 수를 늘리는 것이 아니라 변경 이유를 분리하는 것이다. 같은 이유로 함께 바뀌는 코드는 가까이 두고, 네트워크나 저장소처럼 다른 이유로 바뀌는 외부 경계는 떼어 둔다.
상태의 소유권은 SwiftUI @State·@Binding·@Observable 선택 기준, View의 반환 타입은 some View와 Opaque Return Type, 작은 View 분리는 SwiftUI View 선언 방법에서 이어서 볼 수 있다.
핵심 정리
View는 상태를 표현하고, Model은 앱의 데이터를 나타내며, Client나 Service는 외부 시스템과 통신한다. ViewModel은 화면 상태와 비동기 동작의 조정이 복잡해질 때 추가한다. SwiftUI에 정해진 계층 수는 없으므로 변경 이유와 테스트 경계를 보고 필요한 만큼만 나누는 것이 좋다.
참고 자료
'배움과 성장 > 소프트웨어 개발' 카테고리의 다른 글
| Swift 접근 제어: open·public·package·internal·fileprivate·private (0) | 2024.10.24 |
|---|---|
| SwiftUI 상태 관리 기준: @State·@Binding·@Observable·@StateObject 구분하기 (2) | 2024.10.20 |
| Python 추상 클래스와 추상 메서드: ABC·abstractmethod의 실제 동작 (1) | 2024.09.08 |
| Python 상속과 Interface 설계: super·MRO·Protocol·Composition 선택 기준 (1) | 2024.09.08 |
| Python으로 다시 보는 SOLID: 다섯 원칙보다 변경 경계가 먼저다 (0) | 2024.09.08 |
댓글