🟣 화면 하나가 감당이 안 되기 시작할 때
처음 몇 개 화면은 아키텍처를 몰라도 만들어진다. ViewController 하나에 화면 그리는 코드, 네트워크 호출, 데이터 가공, 화면 전환을 다 몰아넣어도 돌아가긴 한다. 문제는 그 화면이 커질 때 생긴다. 목록을 불러오고, 로딩 상태를 보여 주고, 검색을 걸고, 에러를 처리하고, 셀을 탭하면 상세로 넘어가는 화면 하나가 금세 수백 줄이 된다. 어디를 고쳐야 할지 찾는 데만 한참 걸리고, 한 곳을 고치면 엉뚱한 데가 깨진다.
아키텍처 패턴은 멋으로 쓰는 게 아니다. 한 덩어리로 뭉친 코드를 역할별로 나눠, 고칠 곳을 예측 가능하게 만드는 것이 목적이다. 그래서 "MVVM이 정답"이라는 식으로 외우면 오히려 길을 잃는다. 각 패턴이 어떤 문제를 풀려고 나왔는지를 봐야 언제 무엇을 쓸지 판단이 선다. 이 장은 iOS에서 가장 먼저 만나는 MVC와 MVVM을 그 관점으로 정리한다.
🟣 MVC와 비대해지는 컨트롤러
MVC는 코드를 세 역할로 나눈다. 데이터와 규칙을 담는 모델(Model), 화면에 보이는 뷰(View), 그리고 둘을 잇는 컨트롤러(Controller)다. 애플이 오래 권장해 온 구조이고, 개념 자체는 단순하다. 뷰는 화면만 그리고, 모델은 데이터만 들고, 컨트롤러가 사용자 입력을 받아 모델을 바꾸고 그 결과로 뷰를 갱신한다.
이론상 깔끔한데, iOS에서 실제로 써 보면 컨트롤러가 자꾸 부풀어 오른다. UIViewController가 이미 화면 생명주기를 쥐고 있어서, 여기에 데이터 가공과 네트워크 호출까지 얹으면 모든 코드가 컨트롤러로 빨려 들어간다. 화면 로직인지 비즈니스 로직인지 구분 없이 한곳에 쌓이는 이 상태를 흔히 비대한 뷰 컨트롤러라 부른다. MVC가 실무에서 "Massive View Controller"라고 놀림받는 이유가 여기 있다.
핵심은 MVC가 나쁜 패턴이라는 게 아니다. 화면이 단순할 때는 MVC가 가장 빠르고 명료하다. 다만 컨트롤러가 감당하는 책임이 늘어나는 순간, 그 책임 일부를 떼어 낼 자리가 필요해진다. MVVM은 바로 그 자리를 만든다.
🟣 MVVM: 화면에서 로직을 떼어 내기
MVVM은 컨트롤러가 짊어지던 화면 로직을 뷰모델(ViewModel)이라는 새 자리로 옮긴다. 뷰모델은 "화면에 무엇을 어떻게 보여 줄지"를 계산하는 순수한 코드다. 서버에서 받은 날짜를 화면에 맞게 다듬고, 로딩과 에러 같은 상태를 관리하고, 목록을 정렬하고 거른다. 여기에는 UIKit이 등장하지 않는다. 화면을 그리는 코드가 아니라 화면에 필요한 값을 준비하는 코드이기 때문이다.
이 분리가 주는 가장 큰 이득은 테스트 가능성이다. 화면 없이 뷰모델만 떼어 내 "이런 입력이 오면 이런 값이 나오는가"를 검증할 수 있다. 비대한 컨트롤러에 섞여 있던 로직은 화면을 띄우지 않고는 검증하기 어려웠는데, 그 로직을 화면 밖으로 꺼내면 일반 함수처럼 시험할 수 있다.
여기서 화살표 하나가 점선인 이유가 MVVM의 관건이다. 뷰는 뷰모델에 입력을 전하지만, 뷰모델은 뷰를 직접 알지 못한다. 대신 뷰모델의 상태가 바뀌면 뷰가 그 변화를 구독해 스스로 갱신한다. 이 통지를 어떻게 구현하느냐가 곧 바인딩 문제다.
🚀 바인딩을 무엇으로 연결할까
뷰모델의 상태 변화를 뷰에 전하는 방법은 여러 가지다. 가장 단순하게는 클로저로 "값이 바뀌면 이걸 실행해 달라"고 넘겨 둘 수 있다. 값이 바뀔 때마다 저장해 둔 클로저를 부르는 작은 관찰 도구를 직접 만들어 쓰는 식이다.
final class Observable<T> {
var value: T { didSet { listener?(value) } }
private var listener: ((T) -> Void)?
init(_ value: T) { self.value = value }
func bind(_ listener: @escaping (T) -> Void) {
listener(value) // 지금 값으로 한 번
self.listener = listener
}
}이 정도만 있어도 MVVM은 돌아간다. 다만 상태가 여러 개로 얽히고 비동기 흐름이 섞이면 손으로 관리하기가 버거워진다. 그래서 실무에서는 RxSwift나 Combine 같은 반응형 프레임워크로 이 바인딩을 대신하는 경우가 많다. 중요한 건 도구가 아니라 방향이다. 화면은 상태를 구독하고, 상태는 화면을 모른다는 원칙만 지키면, 클로저로 시작해 필요할 때 반응형 도구로 갈아타도 구조는 그대로다.
🟣 그래서 무엇을 언제 쓰나
패턴 선택은 화면의 복잡도와 팀 상황으로 갈린다. 설정 화면처럼 입력과 출력이 단순한 화면까지 MVVM으로 감싸면 파일만 늘고 얻는 게 없다. 이럴 땐 MVC가 낫다. 반대로 상태가 많고 비동기가 얽히고 테스트가 필요한 화면이라면, 로직을 뷰모델로 빼는 값이 파일 수보다 크다.
한 가지 덧붙이면, MVC와 MVVM은 배타적인 선택이 아니다. 앱 전체를 한 패턴으로 통일해야 한다는 규칙은 없다. 단순한 화면은 MVC로, 복잡한 화면은 MVVM으로 섞어 쓰는 것이 오히려 현실적이다. 패턴은 코드를 나누는 기준일 뿐, 지켜야 할 계율이 아니다. MVVM 위에 화면 전환 책임까지 분리하는 Coordinator를 얹거나, VIPER처럼 더 잘게 쪼갠 패턴으로 넘어가는 것도 결국 "이 화면에서 어떤 책임이 감당이 안 되는가"라는 같은 질문의 연장이다.
🟣 정리
- 아키텍처 패턴의 목적은 멋이 아니라, 뭉친 코드를 역할별로 나눠 고칠 곳을 예측 가능하게 만드는 것이다.
- MVC는 단순한 화면에서 가장 빠르고 명료하다. 다만 컨트롤러에 화면 로직과 비즈니스 로직이 함께 쌓이면 비대한 뷰 컨트롤러가 된다.
- MVVM은 화면 로직을 뷰모델로 떼어 낸다. 가장 큰 이득은 화면 없이 로직을 검증할 수 있는 테스트 가능성이다.
- 바인딩의 방향이 핵심이다. 화면은 상태를 구독하고 상태는 화면을 모른다. 이 방향만 지키면 클로저로 시작해
RxSwift나Combine으로 갈아타도 구조는 유지된다. - 면접에서 "MVVM을 왜 썼는가"에 "요즘 많이 써서"라고 답하면 약하다. "이 화면은 상태와 비동기가 많아 컨트롤러가 감당이 안 됐고, 로직을 빼서 테스트하려 했다"처럼 문제에서 출발해 답하는 편이 설득력이 있다.
코드를 화면 안에서 나눴으면, 이제 화면 바깥에서 앱 안으로 들어오는 경로를 봐야 한다. 먼저 푸시 알림이 기기에 닿고 앱이 깨어나는 생명주기부터 잡고, 그다음 딥링크로 이어진다.