선언형 프레임워크인 SwiftUI가 도입된 지 오랜 시간이 지났지만, 여전히 많은 프로젝트가 과거 UIKit 시절의 관성대로 MVVM(Model-View-ViewModel) 패턴을 기계적으로 적용하고 있다. 비대해진 ViewController의 책임을 분산하기 위해 탄생했던 MVVM은, 구조와 철학이 완전히 다른 SwiftUI 환경에서 오히려 성능을 저하시키고 상용구 코드(Boilerplate)를 양산하는 주범이 되곤 한다.
이에 따라 최근 iOS 개발 생태계에서는 MVVM을 과감히 걷어내고, SwiftUI의 설계 의도에 맞춘 '도메인 중심의 단방향 흐름 아키텍처(서비스 패턴)'로 전환하는 움직임이 대세로 자리 잡고 있다. 전통적인 MVVM의 한계와 애플이 지향하는 현대적인 SwiftUI 아키텍처의 핵심을 정리한다.
모든 View마다 1:1로 짝을 지어 ViewModel 클래스를 만들고 @Published 변수를 채워 넣는 방식은 SwiftUI에서 다음과 같은 구조적 문제를 발생시킨다.
ObservableObject 기반의 MVVM 구조에서는 ViewModel 내부의 가변 상태 중 단 하나만 변경되어도, 해당 클래스를 구독하는 View 전체가 통째로 리렌더링된다. 뷰 계층이 복잡해질수록 불필요한 CPU 소모와 프레임 드랍을 유발한다.Struct)이다. 여기에 또 화면 전용 클래스(ViewModel)를 두고 isLoading, isPresented 같은 사소한 UI 상태값까지 중복 정의하는 것은 선언형 프레임워크의 유연성을 가로막는 행위이다.@Environment, @FetchRequest, @FocusState 등 SwiftUI가 제공하는 강력한 내장 기능들은 뷰 레이어에서 직접 동작하도록 설계되었다. 이를 억지로 ViewModel 내부로 밀어 넣으려는 시도는 코드의 복잡성을 가중시킨다.애플은 WWDC와 공식 가이드라인을 통해 억지스러운 1:1 ViewModel 구조 대신, 데이터의 생명주기와 성격에 따라 앱을 명확히 분리하는 단방향 데이터 흐름(Unidirectional Data Flow) 아키텍처를 권장한다. 특히 iOS 17에서 도입된 Observation 프레임워크는 이 변화를 완벽하게 뒷받침한다.
화면을 그리는 뷰 구조체이다. 로딩 상태, 애니메이션 플래그, 텍스트 필드 입력값 같은 '현재 화면에 종속된 일시적인 UI 상태'는 뷰 내부에서 @State로 직접 소유하고 제어한다.
화면(View)에 1:1로 묶인 중간자가 아니라, 앱의 핵심 비즈니스 로직을 담당하는 독립적인 클래스(Service 또는 Repository)이다. @Observable 매크로를 사용하여 선언하며, 특정 화면이 아닌 데이터 도메인을 기준으로 설계되므로 여러 뷰가 자유롭게 공유하고 주입받아 사용할 수 있다.
로컬 데이터베이스 관리를 담당하는 레이어이다. 애플이 공식 지원하는 SwiftData 환경과 직접 결합하여, 뷰 레이어에서 @Query를 통해 DB를 직접 구독하거나 비즈니스 레이어에서 데이터를 가공하는 구조를 취한다.
이 아키텍처는 데이터가 오직 한 방향으로만 흐르도록 통제하여 예측 가능성을 높인다.
[ 사용자 인터랙션 (버튼 탭) ]
│
▼
[ View 내부의 UI 상태 제어 (@State isLoading = true) ]
│
▼
[ 외부 도메인 서비스 호출 (await productService.fetchItems()) ]
│
▼
[ 데이터 업데이트 (@Observable 서비스 클래스 내부의 프로퍼티 변경) ]
│
▼
[ View의 정밀 재렌더링 (바뀐 데이터 부위만 시스템이 자동 감지) ]
import SwiftUI
import Observation
// 1. 도메인 서비스: 순수 비즈니스 로직과 데이터만 담당한다.
@Observable
final class ProductService {
var items: [String] = []
func fetchProducts() async {
try? await Task.sleep(for: .seconds(1.5)) // 네트워크 통신 시뮬레이션
self.items = ["M4 Mac mini", "iPhone 17 Pro", "Apple Vision Pro 2"]
}
}
// 2. 뷰 컴포넌트: 스스로 UI 상태를 관리하고 비즈니스는 서비스에 위임한다.
struct ProductListView: View {
@State private var productService = ProductService() // 비즈니스 서비스 소유
@State private var isLoading = false // 순수 UI 상태 소유
var body: some View {
NavigationStack {
List {
// productService.items가 변경될 때 이 Text 영역만 정밀하게 갱신된다.
ForEach(productService.items, id: \.self) { item in
Text(item)
}
}
.navigationTitle("Store")
.overlay {
if isLoading { ProgressView() }
}
.toolbar {
Button("🔄 불러오기") {
Task {
isLoading = true // UI 상태 변경
await productService.fetchProducts() // 비즈니스 로직 호출
isLoading = false // UI 상태 변경
}
}
}
}
}
}
뷰 내부에 수많은 @State 변수들이 위치하면 메모리와 CPU를 많이 차지할 것 같다는 우려가 존재하지만, 이는 SwiftUI의 런타임 매커니즘을 오해한 데서 비롯된다.
Struct)이다. 초당 수만 번 새로 생성되고 버려져도 성능에 영향이 없다.@State 실물 데이터는 외부에 저장된다: 뷰 내부에 선언된 @State 변수는 실제 데이터를 품고 있지 않다. 데이터 실물은 SwiftUI가 별도로 관리하는 '힙 영역의 그래프 저장소(Graph Storage)'에 안전하게 보관되며, 뷰 구조체 내부의 @State는 그 주소를 가리키는 아주 가벼운 포인터 역할만 수행한다.@Observable 매크로의 정밀 추적: iOS 17+의 @Observable은 뷰의 body 안에서 실제로 접근한 프로퍼티 단위로만 런타임 추적을 수행한다. 서비스 클래스 내부에 수십 개의 변수가 있어도, 뷰가 읽지 않은 데이터가 바뀔 때는 뷰가 재렌더링되지 않는다. 즉, 뷰모델이라는 억지스러운 방화벽 없이도 시스템이 자동 최적화를 수행한다.AI 코딩 툴이나 구형 튜토리얼은 여전히 과거 데이터에 의존하여 장황한 MVVM 코드를 제안하곤 한다. 그러나 돌아가는 코드를 만드는 것과 프레임워크의 이점을 100% 누리는 것은 별개의 문제이다.
화면 단위의 억지스러운 ViewModel 매핑을 걷어내고 UI 상태는 View의 @State로 날씬하게 처리하며, 무거운 로직은 @Observable 서비스 레이어로 격리하는 것. 이 얇고 직관적인 아키텍처야말로 복잡한 상용구 코드를 최소화하고 유지보수성을 극대화하기 위해 애플이 진정으로 의도한 현대적 SwiftUI 아키텍처의 정석이다.
참고 자료