이번 포스팅에서는 MVVM과 Clean Architecture에 대해 알아보고, Popcorn 팀이 이를 적용한 이유에 대해 살펴보겠습니다.

MVVM(Model-View-ViewModel)은 UI(View)와 데이터를 처리하는 로직을 담당하는 ViewModel을 분리하여 코드의 유지보수성을 높이고, 테스트가 용이하도록 설계하는 아키텍처입니다. iOS 개발에서는 뷰 컨트롤러가 지나치게 많은 역할을 수행하는 것이 일반적인 문제인데, MVVM은 이를 해결하는 방법 중 하나로 많이 사용됩니다.
전통적인 MVC(Model-View-Controller) 패턴에서는 컨트롤러(뷰 컨트롤러)가 다음과 같은 역할을 수행합니다.
이로 인해 컨트롤러가 점점 비대해지는 문제가 발생합니다. 따라서 MVC는 Massive View Controller라는 별명도 생겼습니다.
반면, MVVM에서는 뷰 컨트롤러의 역할을 최소화하고, 데이터 처리 및 UI 상태 관리는 ViewModel이 담당합니다.
MVVM의 가장 큰 장점은 비즈니스 로직과 데이터를 받아오는 로직 등을 UI로부터 분리하여 testable한 코드 작성이 가능하게 된다는 점입니다.
MVVM은 뷰 컨트롤러의 역할을 줄여주는 장점이 있습니다. 그러나 ViewModel이 네트워크 호출, 데이터 가공, UI 바인딩 등 다양한 책임이 자게됩니다. 따라서 MVC와 비슷하게 뷰 모델의 책임이 거대해지는 문제가 발생할 수 있습니다.
클린 아키텍처(Clean Architecture)는 코드의 관심사를 명확히 분리하고, 높은 응집도와 낮은 결합도를 유지하도록 설계하는 소프트웨어 아키텍처 패턴입니다.

MVC에서 뷰 컨트롤러를 뷰와 뷰 모델로 나눈 것 처럼, 클린 아키텍처를 적용한다면 뷰 모델을 데이터 레이어, 도메인 레이어, 프레젠테이션 레이어로 나눌 수 있습니다.
이를 통해 MVVM에서 뷰 모델이 거대해지는 문제점을 해결할 수 있습니다.
클린 아키텍처에서 관심사를 분리하는 것만큼 중요한 것은 의존성 방향입니다.
사진에서 한 눈에 알 수 있듯, 의존성 방향은 외부에서 내부로 발생합니다.
즉, 도메인 레이어를 고수준 모듈(변경이 적은 모듈), 프레젠테이션 및 데이터 레이어를 저수준 모듈(변경이 잦은 모듈)로 분류합니다.
데이터 레이어는 API의 변화나 서버 개발자의 로직 변경에 영향을 받기 때문에 지속적으로 수정될 가능성이 큽니다.
프레젠테이션 레이어는 UI/UX 변경, 기획 변경 등에 따라 자주 수정될 필요가 있습니다.
반면, 도메인 레이어(비즈니스 로직)는 앱의 핵심 규칙을 담당하며, 외부 요인에 의해 쉽게 변경되어서는 안 됩니다.
도메인 레이어는 사용자 경험(UI)이나 데이터 저장 방식(API, DB 등)의 변화와는 독립적이어야 하며, 비즈니스 요구사항 자체가 바뀌지 않는 한 안정적으로 유지되어야 합니다.
이러한 이유로 클린 아키텍처에서는 저수준 모듈(변경이 잦은 모듈)이 고수준 모듈(변경이 적은 모듈)에 의존하는 것이 아니라, 반대로 고수준 모듈이 추상화된 인터페이스를 통해 저수준 모듈을 제어하는 구조를 설계합니다.
즉, 도메인 레이어가 데이터 레이어나 프레젠테이션 레이어의 세부 구현을 직접 참조하지 않고, 인터페이스를 통해 간접적으로 의존하도록 함으로써 변화에 유연하게 대응할 수 있습니다.
의존성 역전 원칙
고수준 모듈(변경이 적은 모듈. 도메인 레이어가 대표적)은 저수준 모듈(변경이 잦은 모듈. 프레젠테이션/데이터 레이어가 대표적)에 의존해서는 안되며, 양쪽 모듈 모두 추상화에 의존해야 합니다. 이를 통해 느슨한 결합을 유지할 수 있습니다.
경계의 분리
시스템을 여러 영역으로 나누고, 각 영역 사이의 인터페이스를 정의하여 각 영역의 독립성을 보장합니다.
인터페이스 분리 원칙
클라이언트가 자신이 사용하지 않는 메서드에 의존하지 않아야 합니다. 따라서 인터페이스를 잘게 분리해 변경 시 영향을 최소화 할 수 있습니다.
MVVM
정보 제공 플랫폼과 채팅 기능을 포함하는 프로젝트의 특성상 구현할 기능들이 많았습니다. 따라서 각각의 뷰가 복잡하였고, 뷰컨트롤러가 비대해지는 상황을 우려하였습니다.
이를 해결하기 위해 MVVM 아키텍처를 도입하였습니다. UI를 담당하는 뷰와 그 뷰를 위한 데이터 및 로직을 뷰모델로 분리하는 MVVM을 도입하였습니다. 그러나 MVVM도 뷰모델에 많은 로직이 집중되어 뷰모델이 비대해진다는 단점이 있습니다.
Clean Architecture
이와 같은 문제를 해결하기 위해 클린 아키텍처를 도입하여 시스템을 계층별로 관심사를 분리하였습니다.
변경이 자주 일어나는 외부 계층과 변경이 자주 일어나지 않는 유즈케이스 계층을 분리하여, 변경에 빠른 대응이 가능할 수 있도록 클린 아키텍처를 도입하였습니다.
각 계층이 독립적으로 테스트 가능하도록 설계되어 있어, 테스트 용이성을 높일 수 있고, 유지 보수성이 증가하게 됩니다.
특히 앱의 초기 단계에서는 기획과 디자인의 변경이 잦으므로 각 계층 별로 보다 독립적인 구조를 가지는 클린 아키텍처를 사용했을 때 변화에 빠르게 대응 할 수 있을 것이라고 판단하였습니다.
아키텍처를 실제로 적용 할 때 다양한 고민들이 존재합니다. MVVM은 뷰와 뷰 모델을 분리하는 것이 주된 내용이고, 클린 아키텍처는 관심사에 따라 레이어를 분리하자가 주된 내용입니다.
이에 대한 명확한 해답은 없지만, Popcorn 팀이 클린 아키텍처 적용 시 고민했던 부분들을 공유하고자 합니다.
클린 아키텍처에서의 데이터 흐름은 다음과 같습니다.

이 과정에서 DTO → Entity 변환을 어디에서 수행할 것인가에 대한 고민이 있었습니다.
Repository의 역할을 UseCase 입장에서 바라보면, Repository는 나에게 데이터를 제공해주는 객체입니다. 따라서 변환의 책임을 가지는지에 고민이 있었습니다.
그렇다면 DTO → Entity 변환이 비즈니스 로직에 해당하는가?라는 질문이 생깁니다.
DTO는 API 혹은 DB에서 가져온 데이터 전송 객체로, 이는 도메인 레이어와 독립적이어야 합니다.
반면, Entity는 도메인 레이어에서 비즈니스 로직을 수행하는 데 필요한 객체입니다.
즉, DTO → Entity 변환은 비즈니스 로직이라기보다는 데이터 가공에 해당하며, 데이터를 가져오는 과정에서 수행하는 것이 더 적절하다고 판단하였습니다.
따라서 DTO의 extension에 toEntity() 메서드를 구현하여, Repository가 UseCase에 데이터를 전달할 때 이미 변환된 Entity 형태로 전달하도록 설계하였습니다.
서버에서 데이터를 가져오는 경우 UseCase는 Repository를 이용해 데이터를 가져옵니다.
따라서 UseCase는 Repository에 의존하게 됩니다.
class PopupUseCase {
// ⭐️ 구현체에 의존
let repository: PopupRepository
init(repository: PopupRepository) {
self.repository = repository
}
func fetchPopupData() -> [PopupPreview] {
return repository.fetchPopups()
}
}
그러나 이는 고수준 모듈(UseCase)가 저수준 모듈(Repository)에 직접 의존하게 되는 형태입니다. 클린 아키텍처에서는 변경이 적은 핵심 비즈니스 로직이 변경이 잦은 외부 데이터 소스에 직접 의존하는 것을 피해야 합니다.
이러한 문제를 해결하기 위해 의존성 역전(Dependency Inversion)을 적용할 수 있습니다.
의존성 역전이란 구체적인 구현(Concrete Class)에 의존하지 않고, 추상화(Interface, Protocol)에 의존하도록 설계하는 것을 의미합니다.
즉, UseCase가 직접 Repository 구체적인 구현체에 의존하는 것이 아니라, Repository Interface(추상체)에 의존하게 하면 의존성을 역전시킬 수 있습니다.
이 방식의 장점은 다음과 같습니다.
이를 적용한 코드는 다음과 같습니다.
protocol PopupRepositoryProtocol {
func fetchPopups() -> [PopupPreview]
}
class PopupUseCase {
// ⭐️ 추상체에 의존
let repository: PopupRepositoryProtocol
init(repository: PopupRepositoryProtocol) {
self.repository = repository
}
func fetchPopupData() -> [PopupPreview] {
return repository.fetchPopups()
}
}
Repository Inteface(추상체)를 어디에 위치 시킬 것인가에 대한 고민이 있었습니다.
처음에는 Data Layer에 위치시키는 것이 자연스럽다고 생각했습니다.
그러나 Repository Interface는 도메인 레이어에서 사용되며, 실제 구현체는 Data Layer에서 관리됩니다.
따라서 Repository Interface가 Data Layer에 위치한다면, 개발자가 변경되거나 다른 개발자들이 코드를 볼 때 혼란을 줄 수 있을 것이라 생각하였습니다.
레퍼런스를 참고한 결과, Repository Interface는 도메인 레이어에 위치하는 것이 일반적인 설계 방식이라는 것을 확인할 수 있었습니다.
나름대로 생각을 한 결과 Interface는 변경이 자주 발생하지 않으며, 다른 레이어에 대한 의존성이 없는 순수한 추상체이므로 고수준 모듈로 볼 수 있습니다.
따라서 Repository Interface를 도메인 레이어의 Interface 폴더에 위치시키는 것이 적절하다고 생각하였습니다.
모든 프로젝트에 완벽하게 적용할 수 있는 만능 아키텍처는 존재하지 않습니다.
클린 아키텍처 또한 단점이 있으며, 모든 경우에 적합한 해결책이 되지는 않습니다.
예를 들어, 하나의 API가 추가될 때마다 Repository, Entity, UseCase, ViewModel, View등 여러 객체를 생성해야 하며, 필요에 따라 구현체와 추상체를 분리하는 작업이 요구됩니다.
이러한 과정은 비교적 간단한 화면이나 기능을 구현할 때 불필요한 복잡성을 초래할 수도 있습니다. 그렇기에 클린 아키텍처를 도입하는 것이 오버 엔지니어링이 될 가능성도 고려해야 합니다.
그러나 Popcorn 팀은 단순히 빠른 개발이 아닌, 학습과 아키텍처에 대한 깊은 이해를 목표로 했습니다. 또한, 클린 아키텍처를 제대로 응용하려면 기본 원칙부터 충실히 따라야 한다는 원칙을 세웠습니다.
따라서 클린 아키텍처의 구조를 최대한 유지한 상태에서 프로젝트를 설계하려고 노력하였습니다.
이를 통해
결과적으로, 단순한 기능에도 많은 구조가 필요하다는 점은 단점이 될 수 있지만, 장기적으로 유지보수성과 확장성을 고려했을 때 충분히 가치 있는 선택이었다고 생각합니다.
레퍼런스
https://tech.olx.com/clean-architecture-and-mvvm-on-ios-c9d167d9f5b3
https://medium.com/@jungkim/버터플라이-아키텍처를-소개합니다-9d4abd71c3c1
https://medium.com/cj-onstyle/android-버티컬-프로젝트의-클린-아키텍처-도입-a26d833e103c
https://medium.com/hcleedev/ios-swiftui의-mvvm-패턴과-mvc와의-비교-8662c96353cc
https://youtu.be/M58LqynqQHc?si=ZuLSJYXeBW9GqaKq