Skip to content

SwiftUI에서의 MVVM 패턴을 예제로 이해하기

SwiftUI에서는 MVVM(Model-View-ViewModel) 패턴이 권장되는 디자인 패턴 중 하나입니다. 이 패턴은 SwiftUI의 선언적 프로그래밍 스타일에 잘 맞으며, MVVM을 통해 데이터 흐름과 UI 업데이트를 더 잘 관리하고, 코드의 모듈화와 유지보수성을 유지할 수 있습니다.

단계별 예제

먼저 데이터 구조를 나타내는 Model을 정의합니다

// MARK: Model
struct Person:Identifiable {
var id = UUID()
var name:String = ""
var sex:String = ""
}

그다음, 페이지 응답을 트리거할 수 있는 ViewModel을 정의합니다. 이 예제에서는 데이터의 추가, 삭제, 수정, 조회를 시뮬레이션합니다.

// MARK: ViewModel
class PersonViewModel:ObservableObject {
// Person List
@Published var persons:[Person] = []
@Published var selected:Person?
// Query Person List
func fetchList(){
// Here it is assumed that you get the data from the server or a source such as CoreData and assign it to self.persons
}
// Insert Person
func insertPerson(){
// Simulation of additional operations
if let person = selected {
self.persons.append(person)
}
}
// Update Person
func updatePerson(){
// Simulation of modification operations
if let person = selected, let index = persons.firstIndex(where: { $0.id == person.id }) {
persons[index] = person
}
}
// Delete Person
func deletePerson(offsets: IndexSet){
// Here it is only deleted from the page list, if the data is still stored elsewhere, then it needs to be deleted together with the new related logic
offsets.forEach { persons.remove(at: $0) }
}
}

마지막으로 ViewModel을 통해 데이터를 뷰와 연결하여 뷰 계층을 구성합니다.

// List View
struct SwiftUIMVVM: View {
@StateObject var personVm:PersonViewModel = PersonViewModel()
@State var showEdit = false
var body: some View {
NavigationStack {
List {
ForEach(personVm.persons) { person in
Button(action: {
personVm.selected = person
showEdit = true
}, label: {
personView(person: person)
})
}
.onDelete(perform: { indexSet in
personVm.deletePerson(offsets: indexSet)
})
}
.listRowSpacing(10)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("", systemImage: "plus") {
showEdit = true
}
}
}
.sheet(isPresented: $showEdit, onDismiss: {
personVm.selected = nil
}, content: {
NavigationStack {
PersonEditView()
.environmentObject(personVm)
}
.presentationDetents([.medium])
})
}
}
// Display list information for each person
// Here we have abstracted the code to increase its readability
func personView(person:Person) -> some View {
VStack{
HStack{
Text("Name")
.fontWeight(.thin)
Text(person.name)
Spacer()
}
HStack{
Text("Sex")
.fontWeight(.thin)
Text(person.sex)
Spacer()
}
}
}
}
// Add/Edit View of Individual Personnel Information
struct PersonEditView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var personVm:PersonViewModel
@State var name:String = ""
@State var sex:String = "Man"
var body: some View {
Form {
TextField("Please enter person's name", text: $name)
Picker("Sex of personnel", selection: $sex) {
Text("Man")
.tag("Man")
Text("Woman")
.tag("Woman")
}
}
.navigationTitle("\(personVm.selected == nil ? "Add" : "Edit") Personnel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
var isUpdate = true
if personVm.selected == nil {
isUpdate = false
personVm.selected = Person()
}
personVm.selected?.name = name
personVm.selected?.sex = sex
if isUpdate {
personVm.updatePerson()
}else{
personVm.insertPerson()
}
dismiss()
}
}
}
.onAppear {
if let person = personVm.selected {
self.name = person.name
self.sex = person.sex
}
}
}
}

전체 코드

import SwiftUI
// MARK: Model
struct Person:Identifiable {
var id = UUID()
var name:String = ""
var sex:String = ""
}
// MARK: ViewModel
class PersonViewModel:ObservableObject {
// Person List
@Published var persons:[Person] = []
@Published var selected:Person?
// Query Person List
func fetchList(){
// Here it is assumed that you get the data from the server or a source such as CoreData and assign it to self.persons
}
// Insert Person
func insertPerson(){
// Simulation of additional operations
if let person = selected {
self.persons.append(person)
}
}
// Update Person
func updatePerson(){
// Simulation of modification operations
if let person = selected, let index = persons.firstIndex(where: { $0.id == person.id }) {
persons[index] = person
}
}
// Delete Person
func deletePerson(offsets: IndexSet){
// Here it is only deleted from the page list, if the data is still stored elsewhere, then it needs to be deleted together with the new related logic
offsets.forEach { persons.remove(at: $0) }
}
}
// List View
struct SwiftUIMVVM: View {
@StateObject var personVm:PersonViewModel = PersonViewModel()
@State var showEdit = false
var body: some View {
NavigationStack {
List {
ForEach(personVm.persons) { person in
Button(action: {
personVm.selected = person
showEdit = true
}, label: {
personView(person: person)
})
}
.onDelete(perform: { indexSet in
personVm.deletePerson(offsets: indexSet)
})
}
.listRowSpacing(10)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("", systemImage: "plus") {
showEdit = true
}
}
}
.sheet(isPresented: $showEdit, onDismiss: {
personVm.selected = nil
}, content: {
NavigationStack {
PersonEditView()
.environmentObject(personVm)
}
.presentationDetents([.medium])
})
}
}
// Display list information for each person
// Here we have abstracted the code to increase its readability
func personView(person:Person) -> some View {
VStack{
HStack{
Text("Name")
.fontWeight(.thin)
Text(person.name)
Spacer()
}
HStack{
Text("Sex")
.fontWeight(.thin)
Text(person.sex)
Spacer()
}
}
}
}
// Add/Edit View of Individual Personnel Information
struct PersonEditView: View {
@Environment(\.dismiss) var dismiss
@EnvironmentObject var personVm:PersonViewModel
@State var name:String = ""
@State var sex:String = "Man"
var body: some View {
Form {
TextField("Please enter person's name", text: $name)
Picker("Sex of personnel", selection: $sex) {
Text("Man")
.tag("Man")
Text("Woman")
.tag("Woman")
}
}
.navigationTitle("\(personVm.selected == nil ? "Add" : "Edit") Personnel")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
var isUpdate = true
if personVm.selected == nil {
isUpdate = false
personVm.selected = Person()
}
personVm.selected?.name = name
personVm.selected?.sex = sex
if isUpdate {
personVm.updatePerson()
}else{
personVm.insertPerson()
}
dismiss()
}
}
}
.onAppear {
if let person = personVm.selected {
self.name = person.name
self.sex = person.sex
}
}
}
}
#Preview {
SwiftUIMVVM()
}

요약

MVVM은 예제에서 다음과 같이 구성됩니다:

M(Model): Person은 데이터 구조를 담당하며 뷰 계층과 완전히 분리되어 있습니다.
V(View): View 프로토콜을 구현한 SwiftUIMVVMPersonEditView는 데이터를 표시하는 역할을 합니다.
VM(ViewModel): ObservableObject 프로토콜을 구현한 PersonViewModel은 데이터를 관리하고 비즈니스 로직을 담당합니다.

확장

코드에서 사용된 주요 개념들에 대해 간단히 설명하겠습니다.

1. ObservableObject
ObservableObject는 프로토콜로, 이를 준수하는 클래스는 속성의 변경을 SwiftUI의 뷰에 게시하여 데이터 변경 시 뷰가 자동으로 다시 렌더링되도록 할 수 있습니다.

  • 작동 방식: ObservableObject를 따르는 클래스는 속성을 게시함으로써(일반적으로 @Published를 사용하여 표시) 이 클래스에 의존하는 뷰에 알림을 보냅니다. 이러한 속성이 변경되면 SwiftUI는 자동으로 뷰를 다시 렌더링합니다.
  • 사용 시나리오: 일반적으로 MVVM 패턴에서 ViewModel에 사용됩니다. ViewModel은 데이터를 관리하고 비즈니스 로직을 담당하며, ObservableObject는 이러한 변경 사항이 뷰에 실시간으로 반영되도록 보장합니다.

2. @Published
@PublishedObservableObject의 속성을 표시하는 데 사용되는 속성 래퍼로, 속성 값이 변경될 때 이를 의존하는 뷰에 업데이트를 알리는 역할을 합니다.

  • 작동 원리: @Published 속성의 값이 변경될 때, ObservableObject는 자동으로 모든 청취 중인 뷰에 업데이트 알림을 발송하고, 뷰는 새 데이터를 기반으로 다시 렌더링됩니다.
  • 사용 시나리오: 데이터 변경 시 UI 업데이트가 자동으로 이루어져야 하는 경우, 주로 비즈니스 로직 또는 데이터 처리를 담당하는 ViewModel에서 사용됩니다.

3. @EnvironmentObject
@EnvironmentObject는 SwiftUI에서 제공하는 특수한 속성 래퍼로, 전역 ObservableObject 인스턴스를 앱 내 여러 뷰에서 공유할 수 있도록 해줍니다.

  • 작동 원리: 부모 뷰에서 environmentObject(_:)를 사용하여 객체를 환경에 주입하면, 자식 뷰는 @EnvironmentObject 속성 래퍼를 통해 이 공유 객체에 접근할 수 있습니다. 이는 데이터 전송을 직접적으로 하지 않고도 여러 뷰에서 상태를 공유할 수 있어 유용합니다.
  • 사용 시나리오: 애플리케이션의 여러 뷰 사이에서 전역 상태를 공유할 때 사용됩니다. 예를 들어, 애플리케이션의 여러 페이지에서 사용자 데이터, 설정 또는 기타 전역 상태를 공유할 때 적합합니다.