본문으로 건너뛰기

SwiftUI에서 과도한 추상화 없이 MVVM 적용하기

MVVM in SwiftUI: Using view models without overengineering

www.avanderlee.com

핵심 요약

  1. SwiftUI의 @State와 환경 값만으로 충분한 뷰에는 뷰 모델을 추가하지 않고, 테스트할 표현 로직이나 비동기 작업이 생길 때 MVVM을 도입한다.
  2. 뷰 모델은 화면에 필요한 상태와 사용자 동작을 제공하고, 실제 데이터 접근과 도메인 작업은 주입된 repository 같은 별도 타입에 맡긴다.
  3. @Observable 기반 뷰 모델을 별도의 SwiftUI 렌더링 없이 테스트할 수 있으며, mock repository로 작업 결과와 로딩·오류 상태를 검증한다.

도입

MVVM은 Model, View, ViewModel을 나눠 뷰의 렌더링과 표현 상태·동작을 분리하는 패턴이다. SwiftUI는 @State와 환경 값 등 자체 상태 관리 도구를 제공하므로 모든 뷰에 뷰 모델이 필요한 것은 아니다. 의미 있는 표현 로직이나 독립적으로 검증할 작업 흐름이 생겼을 때 적용하면 뷰의 책임을 줄이고 테스트 가능한 경계를 만들 수 있다.

상세 요약

MVVM의 역할

MVVM은 Model, View, ViewModel 세 계층으로 구성된다.

  • Model은 도메인 데이터와 관련 작업을 담는다. 예시의 Contact는 UUID와 이름을 가진다.
  • View는 상태를 화면에 렌더링하고 사용자 입력을 전달한다.
  • ViewModel은 모델 데이터를 화면에 필요한 상태로 변환하고, 뷰에서 요청한 동작을 조정한다.

뷰 모델은 SwiftUI 뷰를 참조하지 않는다. 대신 뷰가 관찰 가능한 상태를 읽고 사용자 동작에 해당하는 메서드를 호출한다. SwiftUI에서는 뷰가 @State나 환경 값, 간단한 모델 프로퍼티를 직접 사용할 수 있으므로 계층을 항상 엄격하게 나눌 필요는 없다.

삭제 흐름의 분리

연락처 삭제 예제에서 ContactRepository는 delete(_:)를 제공하는 @MainActor 프로토콜이다. ContactViewModel은 연락처와 repository를 주입받고, isDeleting과 errorMessage를 관리한다. deleteContact()가 호출되면 삭제 중 상태를 설정하고 오류를 초기화한 뒤 repository에 삭제를 요청한다. 실패하면 오류 설명을 상태에 기록하고, defer로 삭제 중 상태를 해제한다.

뷰 모델에는 @Observable을 적용해 뷰가 읽는 프로퍼티의 변경을 SwiftUI에 전달한다. @MainActor는 표현 상태 변경이 메인 액터에서 이뤄지도록 한다. 메서드 이름은 deleteContact()처럼 사용자 동작을 나타내며, 삭제가 데이터베이스·네트워크 요청·로컬 캐시 중 무엇을 사용하는지는 뷰에 노출하지 않는다.

뷰는 주입된 뷰 모델의 이름과 상태를 표시하고, 버튼을 누르면 Task 안에서 삭제 메서드를 비동기로 호출한다. 삭제 중에는 버튼을 비활성화하고, 오류 메시지가 있으면 화면에 보여 준다. 뷰가 값 읽기와 메서드 호출만 한다면 일반 저장 프로퍼티로 충분하다. 뷰 모델 프로퍼티에 대한 binding이 필요할 때는 @Bindable을 사용한다. 뷰 모델을 만드는 뷰는 @State로 인스턴스를 보존한다. ObservableObject를 쓰는 경우 소유 인스턴스에는 @StateObject, 주입 인스턴스에는 @ObservedObject를 사용한다.

표현 로직과 데이터 작업

뷰 모델에는 모델 데이터 변환, 로딩 상태, 검증 메시지, 사용자 요청의 조정 등 표현 로직을 둔다. 실제 삭제 같은 도메인 및 데이터 접근 작업은 전용 타입이 맡는다. 이 분리는 뷰 모델 하나가 네트워킹, 영속성, 분석 등 여러 서비스까지 알게 되는 상황을 막는다.

뷰 모델 테스트

ContactRepositoryMock은 삭제된 연락처를 배열에 기록하는 mock이다. 테스트는 이를 뷰 모델에 주입하고 deleteContact()를 실행한 뒤, 삭제된 연락처가 예상과 같은지, isDeleting이 false인지, errorMessage가 nil인지 확인한다. SwiftUI 계층을 렌더링하지 않고도 삭제 흐름의 동작을 검증한다. 뷰 테스트는 레이아웃과 상호작용을 확인하는 데 사용할 수 있다.

모든 뷰에 적용하지 않기

모델의 프로퍼티를 그대로 복사하는 뷰 모델은 의미 있는 분리를 제공하지 않고 타입만 늘린다. ContactNameView처럼 연락처 이름만 표시하는 뷰는 입력 모델을 직접 사용하면 된다. 비동기 작업 조정, 도메인 데이터의 화면 상태 변환, 검증이나 사용자 오류 처리, 별도 테스트가 필요한 동작, 여러 서비스나 repository 의존성이 있을 때 뷰 모델을 고려한다. 작은 표현 전용 뷰는 단순하게 유지하고, 분리가 가독성이나 테스트성을 높이는 화면에 MVVM을 적용한다. 뷰를 재사용 가능한 구성 단위로 나누는 방식은 SwiftUI 뷰를 재사용 가능하게 만드는 3가지 전략에서도 다룬다.

결론

뷰 모델에는 로딩 상태, 검증 결과, 화면에 표시할 오류처럼 표현 로직을 두고, 데이터 삭제 같은 실제 작업은 repository에 위임한다. @Observable과 의존성 주입을 사용하면 화면 계층과 작업 계층을 나눠 테스트할 수 있다. 단순히 모델 값을 전달하는 작은 뷰는 그대로 두고, 가독성이나 테스트성, 상태 관리가 나아지는 경우에만 뷰 모델을 추출한다.