커피 주문 앱의 기존 MVVM 코드 분석
강의에서 제공된 프로젝트(커피주문앱)는 기능을 수행하는 데 문제가 없으나, 레이어 간의 강한 결합과 반복적인 의존성 주입이 구조적 복잡성을 야기함. 리팩토링 전의 핵심 로직들을 정리함.
1. ViewModel의 구조적 복잡성
각 View는 고유한 ViewModel을 가지며, 모든 통신 로직이 이 계층에 갇혀 있음.
struct CoffeeOrderListScreen: View {
let coffeeOrderListVM: CoffeeOrderListViewModel
@State private var isPresented: Bool = false
init(coffeeOrderListVM: CoffeeOrderListViewModel) {
self.coffeeOrderListVM = coffeeOrderListVM
}
// 나머지 생략
}
struct AddCoffeeOrderScreen: View {
let addOrderVM: AddOrderViewModel
// 나머지 생략
}
- 각 화면은 초기화될 때 전용 뷰모델(
CoffeeOrderListViewModel,AddOrderViewModel)을 넘겨받아야만 작동한다. - 단순한 기능을 가진 화면이라도 매번 새로운 뷰모델 클래스 파일을 만들고 관리해야 하므로, 프로젝트가 커질수록 챙겨야 할 파일이 너무 많아지는 번거로움이 생긴다.
2. 뷰 계층의 데이터 전달 방식 (수동 주입)
전용 뷰모델 클래스를 유지하기 위해 하위 뷰로 넘어갈 때마다 의존성을 릴레이 하듯 전달한다.
struct CoffeeOrderListScreen: View {
let coffeeOrderListVM: CoffeeOrderListViewModel
@State private var isPresented: Bool = false
init(coffeeOrderListVM: CoffeeOrderListViewModel) {
self.coffeeOrderListVM = coffeeOrderListVM
}
var body: some View {
List(coffeeOrderListVM.orders) {
// 생략
}
// 관계없는 Modifier 생략
.sheet(isPresented: $isPresented, content: {
AddCoffeeOrderScreen(addOrderVM: AddOrderViewModel(httpClient: HTTPClient(), onSave: { coffeeOrder in
coffeeOrderListVM.orders.append(coffeeOrder)
}))
})
}
}
- 중복된 의존성:
.sheet를 통해 새로운 화면을 띄울 때마다AddOrderViewModel을 즉석에서 생성하며, 이때 이미 존재하는HTTPClient()를 또다시 조립해서 넘겨줘야 하는 번거로움이 있다. - 수동 데이터 동기화: SwiftUI의 자동 기능을 활용하지 못하고,
onSave클로저를 통해 저장 완료를 알린 뒤 부모 뷰모델의 배열에 데이터를 직접 추가(append)해주는 수동 작업이 필요하다. - 계층의 비효율 (데이터 릴레이): 만약 1번 화면의 데이터를 5번 화면에서 사용해야 한다면, 중간에 있는 2, 3, 4번 화면은 그 데이터를 전혀 사용하지 않음에도 불구하고 오직 ‘전달’만을 위해 생성자 코드를 복잡하게 유지해야 한다. 이 과정에서 코드가 불필요하게 비대해진다.
💡 요약 및 리팩토링 포인트
- 의존성 중복: 동일한
HTTPClient를 화면마다 반복해서 생성하고 주입하는 비효율. - 수동 업데이트: SwiftUI의 선언적 특성을 활용하지 못하고 클로저를 통해 상태를 수동으로 갱신하는 구조.
- 계층 간 복잡도: 뷰 계층이 깊어질수록 데이터 전달을 위한 보일러플레이트 코드가 기하급수적으로 늘어남.
🔗 참고: 의존성 주입(DI)에 대한 과거의 기록
이전 포스팅에서 객체 생성과 주입의 원리에 대해 딥다이브를 했던 적이 있다. 당시에는 클래스가 참조 타입(Reference Type)이라는 점을 이용해, 동일한 메모리 주소를 공유함으로써 데이터 일관성을 지키는 ‘정석적인 DI’의 중요성을 깨달았었다.
과거에 고민했던 “동일한 메모리 공유를 통한 데이터 흐름의 단일화”라는 개념은 여전히 유효하다. 다만, 이번 강의에서는 그 과정을 수동으로 처리할 때 발생하는 ‘구조적 번거로움’을 확인하고, SwiftUI의 시스템(Environment)을 이용해 이를 얼마나 더 효율적으로 개선할 수 있는지에 집중해 보려 한다.
ViewModel 삭제 및 Environment 전환
불필요한 중간 계층인 뷰모델을 과감히 삭제하고 SwiftUI의 Environment 시스템을 활용해 의존성을 관리하는 방식으로 리팩토링을 진행한다.
기존에는 예를 들면
//before
let httpClient: HTTPClient
//after
@Environment(\.httpClient) private var httpClient
이런식으로 환경변수로 아예 쓰겠다는것이다.
1. Custom EnvironmentKey 구현 (HTTPClientKey)
하지만 이렇게 Environment를 사용하기 위해서는 별도의 작업이 필요하다.
import Foundation
import SwiftUI
private struct HTTPClientKey: EnvironmentKey {
static let defaultValue: HTTPClient = HTTPClient()
}
extension EnvironmentValues {
var httpClient: HTTPClient {
get { self[HTTPClientKey.self] }
set { self[HTTPClientKey.self] = newValue }
}
}
- 전역 접근성:
EnvironmentKey를 사용하여 앱 어디서든HTTPClient에 접근할 수 있는 경로를 확보한다. - 유연한 주입: 필요에 따라 실제 클라이언트나 테스트용 Mock 클라이언트를 손쉽게 교체하여 주입할 수 있는 기반을 마련한다.
여기서 잠깐!
🔗 커스텀 EnvironmentKey 구현의 원리
SwiftUI에서 우리가 만든 클래스나 구조체를 시스템 바구니(Environment)에 담기 위해서는 두 가지 약속된 작업이 필요하다.
1. EnvironmentKey 정의
private struct HTTPClientKey: EnvironmentKey {
static let defaultValue: HTTPClient = HTTPClient()
}
EnvironmentKey프로토콜: 이 프로토콜을 채택함으로써, SwiftUI 시스템에 해당 타입이 환경 변수 시스템에서 고유한 ‘키(Key)’ 역할을 할 것임을 선언한다.static let defaultValue: 가장 중요한 부분이다. 특정 뷰에서 이 값을 쓰려고 할 때, 상위 계층에서 아무런 값을 주입해주지 않았을 경우를 대비한 ‘기본값(Fallback)’이다.- 이 덕분에 앱은 런타임 에러(Crash) 없이 안전하게 동작하며, 특히 프리뷰(Preview) 환경에서 별도의 주입 없이도 즉시 작동하게 만드는 핵심 동력이 된다.
2. EnvironmentValues 확장
extension EnvironmentValues {
var httpClient: HTTPClient {
get { self[HTTPClientKey.self] }
set { self[HTTPClientKey.self] = newValue }
}
}
EnvironmentValues: SwiftUI가 기본적으로 제공하는dismiss,scenePhase등이 정의되어 있는 환경 값의 집합체이다.extension과 계산된 프로퍼티: 우리가 정의한 키를 사용해EnvironmentValues내부에httpClient라는 새로운 접근 경로를 생성하는 과정이다.- Getter/Setter: 딕셔너리에서 키값으로 데이터를 찾는 것과 같은 원리다.
HTTPClientKey.self를 인덱스로 사용하여 실제HTTPClient인스턴스를 저장하거나 불러온다.
3. 의존성 주입 (Dependency Injection)
정의된 환경 변수는 앱의 최상위 계층(App)이나 특정 부모 뷰에서 주입한다. 이 시점에 전달되는 인스턴스가 앞서 정의한 set 로직의 newValue로 전달되어 저장된다.
@main
struct HotCoffeeApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
CoffeeOrderListScreen()
}.environment(\.httpClient, HTTPClient())
}
}
}
- 이 과정을 통해 하위의 모든 뷰는 생성자를 거치지 않고도
@Environment프로퍼티 래퍼를 통해 즉시 해당 서비스에 접근할 수 있게 된다.
💡 왜 이런 번거로운 과정을 거치는가? (설계적 이점)
처음에는 직접 인스턴스를 만드는 것보다 복잡해 보이지만, 이 구조를 갖추면 다음과 같은 강력한 이점이 생긴다.
- 타입 안전성(Type Safety): 컴파일러가
httpClient가 어떤 타입인지 명확히 알고 있으므로, 잘못된 타입을 주입하거나 꺼내 쓸 위험이 없다. - 계층적 주입(Overriding): 앱 전체에서는 진짜 서버와 통신하더라도, 특정 하위 뷰 계층에서만 ‘테스트용 Mock Data’를 보여주고 싶을 때 그 지점에서만 값을 덮어씌울 수 있다.
- 독립성 유지: 뷰는 도구의 생성 출처를 따지지 않는다. 오직 환경에 해당 도구가 존재하는지만 확인하고 사용하면 되기 때문에 객체 간의 결합도가 낮아진다.
2. @State를 활용한 로컬 상태 관리 및 데이터 로딩
뷰모델이 담당하던 데이터 저장 역할을 뷰의 @State가 직접 수행한다.
//before
@Observable
class CoffeeOrderListViewModel {
var orders: [CoffeeOrder] = []
var httpClient: HTTPClient
// 생략
}
//after
struct CoffeeOrderListScreen: View {
@Environment(\.httpClient) private var httpClient
@State private var isPresented: Bool = false
@State private var orders: [CoffeeOrder] = []
// 생략
}
여기서 코드는 넣지 않았으나 ViewModel에 있던 loadOrders 함수는 CoffeeOrderListScreen 뷰 내에 함수를 새로 작성하여 해당 뷰에서 작동하도록 하였다.
- Source of Truth: 뷰 내부의
@State변수가 데이터의 원천이 되어 UI를 자동으로 갱신한다. - 단순한 데이터 흐름: 외부의 뷰모델을 거치지 않고, 뷰의 생명주기(task 등) 안에서 직접 데이터를 요청하고 배열에 담는 직관적인 구조를 가진다.
코드를 수정하고 나면 기존에 복잡하게 얽혀있던 ViewModel이 더 이상 필요하지 않게 된다. 이는 단순히 코드를 합친 것이 아니라, 역할과 책임을 명확하게 분리했기 때문이다.
-
의존성 관리의 단일화:
HTTPClient같은 통신 기능은 앱 어디서든 쓰이는 공통 서비스다. 이를Environment에 등록해 두면, 뷰모델이 중간에서 “자 여기 도구 있다”며 배달할 필요가 없다. 뷰가 필요할 때 직접 환경에서 꺼내 쓰면 그만이다. -
UI 로직의 뷰 내재화: 해당 화면에서만 쓰이는 상태(@State)와 간단한 함수(loadOrders)는 뷰모델이라는 별도의 파일을 만들기보다 뷰 내부에 두는 것이 응집도가 더 높다. 로직이 복잡하지 않다면 굳이 레이어를 나눌 이유가 없는 것이다.
결론적으로, 모든 화면에 MVVM을 강제하기보다, SwiftUI의 특성을 살려 환경 변수와 로컬 상태를 활용하는 것이 코드량을 줄이고 가독성을 높이는 더 직관적인 방법이 될 수 있다.
데이터 생성과 상태 전달 수정
조회(List) 화면에 이어, 새로운 주문을 추가하는 화면에서도 뷰모델을 제거하고 환경 변수를 활용한다. 여기서는 ‘데이터 저장’ 이후 부모 뷰와 상태를 동기화하는 전략이 핵심이다.
이전과 마찬가지로 여기도 기존에 있던 ViewModel을 지워주었다.
1. 뷰모델 제거와 @State를 통한 데이터 수집
- 필드 관리: 기존 뷰모델에 흩어져 있던 입력 필드들을 뷰 내부의
@State로 내재화하여 응집도를 높인다. - 환경 변수 재사용: 이미 앱 최상단에서 주입한
httpClient를@Environment를 통해 그대로 호출하여 사용한다.
2. 비즈니스 로직의 내재화: placeOrder()
- 단순화된 요청 과정: 뷰모델 클래스를 거치지 않고, 뷰 내부의 헬퍼 함수에서 직접
Resource를 생성하고 서버에 Post 요청을 보낸다.
3. 부모-자식 간 데이터 동기화 전략 (두 가지 선택지)
뷰모델이라는 중간 매개체가 사라진 상태에서, 하위 뷰가 상위 뷰의 데이터를 업데이트하는 실전적인 방법들을 검토한다.
3.1 클로저(Closure)를 이용한 전달 (onSave)
struct AddCoffeeOrderScreen: View {
var onSave: (CoffeeOrder) -> Void
// 생략
private func placeOrder() async {
do {
let order = CoffeeOrder(name: name, coffeeName: coffeeName, total: total, size: size)
let data = try JSONEncoder().encode(order)
let resource = Resource(url: APIs.addOrder.url, method: .post(data), modelType: CoffeeOrder.self)
let newOrder = try await httpClient.load(resource)
onSave(newOrder)
} catch {
print(error)
}
}
// 생략
}
struct CoffeeOrderListScreen: View {
// 생략
var body: some View {
// 생략
.sheet(isPresented: $isPresented, content: {
AddCoffeeOrderScreen { order in
orders.append(order)
}
})
}
}
- 방식: 저장 성공 시 부모로부터 넘겨받은 클로저를 실행하여 새 데이터를 넘겨준다.
- 장점: 부모와 자식 간의 인터페이스가 명확하고 구현이 직관적이다.
3.2 바인딩(@Binding)을 통한 직접 수정
struct AddCoffeeOrderScreen: View {
@Binding var orders: [CoffeeOrder]
// 생략
private func placeOrder() async {
do {
let order = CoffeeOrder(name: name, coffeeName: coffeeName, total: total, size: size)
let data = try JSONEncoder().encode(order)
let resource = Resource(url: APIs.addOrder.url, method: .post(data), modelType: CoffeeOrder.self)
let newOrder = try await httpClient.load(resource)
orders.append(newOrder)
} catch {
print(error)
}
}
}
struct CoffeeOrderListScreen: View {
// 생략
var body: some View {
// 생략
.sheet(isPresented: $isPresented, content: {
AddCoffeeOrderScreen(orders: $orders)
})
}
}
- 방식: 부모 뷰의
orders배열을 직접 참조하여 자식 뷰에서append를 수행한다. - 장점: 별도의 콜백 로직 없이 데이터 원천(Source of Truth)을 직접 건드려 UI를 즉시 갱신한다.
💡 아키텍처적 고찰: 뷰 계층이 깊어질 때의 문제점
현재의 리팩토링 방식이 소규모 앱에서는 효율적이지만, 앱 규모가 커질 때 발생할 수 있는 한계점을 짚어본다.
- 데이터 릴레이 재발: 뷰 계층이 5단계 이상 깊어진다면, 결국
@Binding역시 과거 뷰모델처럼 ‘전달만을 위한 코드’가 될 위험이 있다. - 전역 상태 관리의 필요성: 여러 화면에서 동시에 같은 데이터를 바라봐야 할 때, 단순한 릴레이 방식이 아닌 더 상위 레벨의 ‘데이터 스토어’가 필요한 시점을 예고한다.
Aggregate Model: 화면을 넘어 앱의 ‘상태’를 관리하는 법
이전글에서 ViewModel을 지우고 순수 SwiftUI 기능만으로 리팩토링을 진행했다면, 이번에는 앱의 규모가 커질 때 발생하는 ‘깊은 계층의 데이터 전달 문제’를 해결하기 위한 Store(Aggregate Model) 패턴을 도입한다.
이는 단순히 ViewModel의 이름을 바꾼 것이 아니라, 데이터의 원천을 화면 단위가 아닌 도메인 단위로 설계하는 철학의 변화이다.
1. Store 패턴의 핵심: Bounded Context (경계가 있는 컨텍스트)
강의에서 가장 강조하는 부분은 Store를 만드는 ‘기준’이다.
- MVVM의 한계: 화면(Screen) 하나당 뷰모델 하나를 만든다. 10개의 화면이면 10개의 뷰모델이 생기고, 데이터 동기화가 지옥이 된다.
- Store의 접근법: 화면이 아니라 도메인(주문, 재고, 카테고리 등)을 기준으로 만든다. 앱이 작다면
CoffeeStore하나가 앱 전체의 상태를 관리하고, 커지면 이를 도메인별로 쪼갠다. - ViewModel과의 차이: “이게 이름만 바꾼 뷰모델 아니야?”라는 의문이 들 수 있지만, 하나의 Store가 여러 화면의 상태를 동시에 관리하고 데이터의 일관성을 유지한다는 점에서 근본적으로 다르다.
2. CoffeeStore 구현: 데이터와 로직의 집약
@Observable을 활용하여 모든 화면이 공유할 ‘데이터의 원천’을 구축한다.
@Observable
class CoffeeStore {
// For mocking you can use protocol
// HTTPClientProtocol
let httpClient: HTTPClient
var orders: [CoffeeOrder] = []
init(httpClient: HTTPClient) {
self.httpClient = httpClient
}
func loadOrders() async throws {
let resource = Resource(url: APIs.orders.url, modelType: [CoffeeOrder].self)
orders = try await httpClient.load(resource)
}
func placeOrder(coffeeOrder: CoffeeOrder) async throws {
let coffeeOrderData = try JSONEncoder().encode(coffeeOrder)
let resource = Resource(url: APIs.addOrder.url, method: .post(coffeeOrderData), modelType: CoffeeOrder.self)
let savedCoffeeOrder = try await httpClient.load(resource)
orders.append(savedCoffeeOrder)
}
}
- 의존성 주입: 생성자를 통해
HTTPClient를 주입받아 통신 기능을 캡슐화했다. - 데이터 흐름의 중심:
placeOrder함수 내부를 보면, 서버 저장 성공 후 곧바로orders.append를 수행한다. 이 한 줄 덕분에 이 Store를 구독하는 모든 뷰가 동시에 업데이트되는 마법이 일어난다.
3. View에서의 활용: Environment 주입과 추출
앱의 진입점에서 Store를 주입하고, 필요한 뷰에서 꺼내 쓰는 방식을 사용한다.
@main
struct HotCoffeeApp: App {
var body: some Scene {
WindowGroup {
NavigationStack {
CoffeeOrderListScreen()
}.environment(CoffeeStore(httpClient: HTTPClient()))
}
}
}
struct CoffeeOrderListScreen: View {
@Environment(CoffeeStore.self) private var coffeeStore
// 생략
}
struct AddCoffeeOrderScreen: View {
@Environment(CoffeeStore.self) private var coffeeStore
// 생략
private func placeOrder() async {
do {
let coffeeOrder = CoffeeOrder(name: name, coffeeName: coffeeName, total: total, size: size)
try await coffeeStore.placeOrder(coffeeOrder: coffeeOrder)
} catch {
print(error)
}
}
}
- 주입의 간소화:
.environment(CoffeeStore(...))단 한 줄로 모든 하위 계층에 데이터를 뿌려준다. - 인터페이스 간소화 및 데이터 흐름의 혼선 제거:
AddCoffeeOrderScreen을 호출할 때 더 이상onSave클로저나@Binding을 복잡하게 설계할 필요가 없다. 부모와 자식 간에 데이터를 주고받는 ‘중계 로직’이 사라지면서, 뷰는 그저 Store에 명령을 내리고 Store는 상태를 갱신하는 단순하고 명확한 데이터 흐름을 갖게 된다.
4. 효율적인 렌더링 최적화의 원리
Store가 앱 전체의 상태를 가지고 있다고 해서, 모든 변화에 모든 뷰가 반응하는 것은 아니다.
- 추적 기능: SwiftUI는 뷰가 Store 내의 어떤 프로퍼티(예:
orders)를 실제로 사용하는지 추적한다. - 지능적 리렌더링: 주문 데이터가 바뀌어도, ‘카테고리’ 정보만 사용하는 뷰는 다시 그려지지 않는다.
- 설계 팁: 큰 뷰를 작은 reusable views로 쪼개고 필요한 데이터만 전달하면, 렌더링 성능을 극대화할 수 있다.
왜 ‘화면 단위’ 뷰모델보다 강력한가?
- 상태 불일치 해결: 기존 MVVM에서는 A 화면에서 수정하고 B 화면에 알리기 위해 복잡한 알림 시스템이 필요했지만, Store 패턴은 같은 메모리 공간을 공유하므로 원천적으로 데이터 불일치가 발생하지 않는다.
- 비즈니스 로직의 응집: 통신, 인코딩, 배열 업데이트 로직이
CoffeeStore라는 하나의 도메인 안에 모여 있어 유지보수가 훨씬 용이하다.
💡 아키텍처적 통찰: 펌프 위치의 차이 (ViewModel vs Store)
해당 강의를 수강하면서 느낀 점은, 결국 Store 패턴도 넓은 의미의 ViewModel이라는 것이다. 다만 결정적인 차이는 “인스턴스화를 어느 시점에서 하는가?”에 있다. 이를 저수지에서 물을 끌어다 쓰는 과정에 비유하면 이해가 명확해진다.
1. 일반적인 ViewModel (각 집마다 설치된 펌프)
- 구조: 각 세대(View)가 저수지에서 물을 직접 끌어오기 위해 각자의 펌프(ViewModel)를 집집마다 새로 설치하는 방식이다.
- 특징: 화면이 10개면 펌프도 10개가 필요하다. 각 집마다 펌프를 관리해야 하므로 코드가 반복되고, 한 집에서 물을 아무리 많이 써도 옆집 펌프는 그 상태를 알 수 없다. 즉, 데이터 동기화가 어렵다.
2. Store 패턴 (동 전체를 관리하는 중앙 물탱크)
- 구조: 아파트 동 전체를 관리하는 거대한 중앙 물탱크(Store)를 최상위 계층(App Main)에 딱 하나 설치한다. 펌프는 오직 이 물탱크에만 연결된다.
- 특징: 각 세대(View)는 펌프를 새로 설치할 필요 없이, 이미 물탱크에서 연결된 수도관(
Environment)을 통해 물을 끌어다 쓰기만 하면 된다.
💡 결론: 인스턴스화 시점이 결정하는 효율성
결국 인스턴스화의 시점이 아키텍처의 성격을 규정한다.
- 라이프사이클의 연속성: View 단위에서 생성되는 VM은 View가 사라지면 데이터도 사라질 위험이 있지만, 최상위(Main)에서 생성된 Store는 앱이 구동되는 동안 데이터의 영속성을 보장한다.
- 의존성 주입의 간소화: 각 집마다 펌프 부품(의존성)을 배달할 필요 없이, 중앙에서 한 번만 조립하면 모든 뷰가 그 혜택을 누린다.
- 프로퍼티 단일화(Single Source of Truth): 물탱크의 수위가 변하면 모든 집의 수도꼭지에서 나오는 물의 상태가 동시에 변한다. 이것이 인스턴스 공유를 통해 데이터 일관성을 지키는 가장 확실한 방법이다.