Neoself의 기술 블로그

SwiftUI에서 MVVM + 서비스 레이어 패턴 구현하기 본문

개발지식 정리/Swift

SwiftUI에서 MVVM + 서비스 레이어 패턴 구현하기

Neoself 2024. 10. 1. 10:55

SwiftUI에서 MVVM (Model-View-ViewModel) 패턴을 어떻게 효과적으로 구현할 수 있는지, 제가 이전에 진행했던 프로젝트인 "TyTE"를 예로 들어 설명해드리고자 합니다.

 

MVVM은 사용자 인터페이스 로직을 비즈니스 로직과 분리하여 개발하는 아키텍처 패턴입니다. 이 패턴은 다음과 같은 세 가지 주요 구성 요소로 이루어져 있습니다.

  1. Model: 데이터 담당
  2. View: 사용자 인터페이스를 표현
  3. ViewModel: View와 Model 사이의 중개자 역할, UI 로직 처리

TyTE 프로젝트의 경우 위 MVVM 패턴에서 데이터 처리, 네트워크 통신과 같은 데이터접근 함수들을 담당하는 서비스 레이어을 추가적으로 분리하여 전체 프로젝트 구조를 정립하였는데요. 이 구조는 다음과 같이 도식화 해볼 수 있습니다.

사용자는 View(User Interface)와 직접적인 상호작용을 진행하게 됩니다. 이때 View는 ViewModel을 @ObservedObject를 통해 관찰하고 있기 때문에, 사용자의 입력을 ViewModel에 전달할수 있게 되며, ViewModel은 사용자 입력에 따라 앱의 데이터가 포함된 Model을 업데이트하는 일종의 중간다리 역할을 담당하게 됩니다. 

 

만약에 MVVM 패턴을 따르지 않고, 컴포넌트를 구성하게 되면 어떻게 될까요?

struct BadHomeView: View {
    @State private var todos: [Todo] = []
    @State private var todosForDate = []
    @State private var isLoading = false
    @State private var sortOption = "default"
    @State private var isBottomSheetPresented:Bool = false
    @State private var isPopupPresented:Bool = false
    ...
    
    var body: some View {
        VStack {
            if isLoading {
                ProgressView()
            } else {
                List(filteredTodos) { todo in
                    Text(todo.title)
                        .onTapGesture {
                            toggleTodo(todo)
                        }
                }
            }
            
            Button("정렬 변경") {
                changeSortOption()
            }
        }
        .onAppear {
            fetchTodos()
        }
    }
    
    private var filteredTodos: [Todo] {
        return todos
    }
    
    private func fetchTodos() {
        isLoading = true
        // 여기에 네트워크 요청 코드...
        // URLSession.shared.dataTask(...) 등
    }
    
    private func toggleTodo(_ todo: Todo) {
        // 여기에 Todo 상태 변경 및 서버 업데이트 코드...
    }
    
    private func changeSortOption() {
        // 정렬 옵션 변경 및 데이터 재정렬 로직...
    }
}

 

위 코드는 프로젝트 극 초창기에 제가 진행했던 구조인데요... MVVM 패턴을 따르지 않고, View 파일 내부에 데이터 fetching, 상태 관리, UI 로직을 모두 처리하고 있는 코드입니다. 위 코드의 경우 아래와 같은 문제점을 뽑아볼 수 있습니다.

  • 재사용성 저하: 이 View는 다른 곳에서 재사용하기 어렵습니다. 따라서 레이아웃이 유사한 다른 화면을 구현하고자 할때에도,  처리 로직이 조금이라도 다르면 Boilerplate가 발생할 수밖에 없습니다.
  • 확장성 부족: 새로운 기능을 추가하거나 기존 기능을 수정하기 어렵습니다.
  • 상태 관리의 복잡성: @State를 과도하게 사용하여 상태 관리가 복잡해집니다.

그러면 이제, 제가 진행했던 프로젝트의 MVVM 패턴 + 서비스 레이어 구조를 보여드리도록 하겠습니다.

 

1. Model

struct Todo: Identifiable, Codable {
    let id: String
    let user: String
    var tagId: Tag?
    
    var raw: String
    var title: String
    var isImportant: Bool
    var isLife: Bool
    var difficulty: Int
    var estimatedTime: Int
    var deadline: String
    var isCompleted: Bool
}

 

 

2. ViewModel

class HomeViewModel: ObservableObject {
    @Published var selectedTags: [String] = []
    @Published var sortOption: String = "default"
    @Published var currentTab: Int = 0
    
    private let todoService: TodoService 
    private let sharedVM: SharedTodoViewModel
    
    @Published var isLoading: Bool = false
    private var cancellables = Set<AnyCancellable>()
    
    init(
        sharedVM: SharedTodoViewModel,
        todoService: TodoService = TodoService()
    ) {
        self.sharedVM = sharedVM
        self.todoService = todoService
        fetchTodos()
        self.selectedTags = sharedVM.tags.map{$0.id}
    }
    
    var filteredTodos: [Todo] {
        // 필터링 로직...
    }
    
    func fetchTodos() {
        isLoading = true
        todoService.fetchAllTodos(mode: sortOption)
            .receive(on: DispatchQueue.main)
            .sink { [weak self] completion in
                // 에러 처리...
            } receiveValue: { [weak self] todos in
                // 데이터 처리...
            }
            .store(in: &cancellables)
    }
    
    // 기타 메서드들...
}

 

3. Service Layer

class TodoService {
    private let apiManager = APIManager.shared
    
    func fetchAllTodos(mode:String) -> AnyPublisher<[Todo], APIError> {
        let endpoint = APIEndpoint.fetchTodos(mode)
        
        return Future { promise in
            self.apiManager.request(endpoint) { (result: Result<[Todo], APIError>) in
                promise(result)
            }
        }.eraseToAnyPublisher()
    }
    
    func fetchTodosForDate(deadline: String) -> AnyPublisher<[Todo], APIError> {
        let endpoint = APIEndpoint.fetchTodosForDate(deadline)
        
        return Future { promise in
            self.apiManager.request(endpoint) { (result: Result<[Todo], APIError>) in
                promise(result)
            }
        }.eraseToAnyPublisher()
    }
    ...
    
  }

 

4. View

struct HomeView: View {
    @ObservedObject var viewModel: HomeViewModel
    @ObservedObject var sharedVM: SharedTodoViewModel
    
    var body: some View {
        VStack(spacing: 0) {
            // UI 구성...
            
            if viewModel.filteredTodos.isEmpty {
                EmptyStateBox()
            } else {
                List {
                    ForEach(viewModel.filteredTodos) { todo in
                        // Todo 아이템 표시...
                    }
                }
            }
        }
        .background(.gray00)
    }
}

 

먼저, Todo.swift 파일에서 데이터 구조를 정의합니다. 그 후, ViewModel에서 @Published 프로퍼티를 사용해 View에 바인딩될 데이터를 정의하고, 비즈니스 로직을 구현합니다. 이때 저는 네트워크 통신(API 함수)을 비롯한 데이터접근 기능들을 TodoService.swift 파일로 또 다시 분리해 서비스 레이어를 나누었습니다. 마지막으로 사용자에게 직접 보여지는 HomeView.swift 뷰 파일에서 @ObservedObject를 사용해 viewModel의 변경사항을 관찰하며, 데이터 표시 및 사용자 인터랙션 처리에 집중합니다. 

 

위 구조와 같이 프로젝트를 MVVM + Service Layer로 분리해 진행하게 될 경우, 아래와 같은 이점이 있습니다.

 

  • 관심사의 분리: UI 로직과 비즈니스 로직 그리고 데이터접근 로직이 명확히 분리되기 때문에, 가독성이 높아지고 코드 유지보수가 쉬워집니다.
  • 코드 재사용: ViewModel을 여러 View에서 재사용할 수 있기에, 코드 중복을 줄일 수 있습니다.
  • 확장성: 새로운 기능이나 데이터 소스를 추가할때, 큰 리소스 할애없이 새로운 서비스 추가가 가능해집니다.

 

감사합니다.