iOS 개발을 하면서 MVC 패턴을 따르다 보면 자연스럽게 겪는 문제가 있습니다. 바로 ViewController가 점점 비대해지는( Massive ViewController)입니다.
처음에는 UI와 간단한 로직들만 관리하는 역할을 하던 ViewController가 점점 데이터 처리, 네트워크 요청 화면 이동까지 담당하게 되면서, 결국 하나의 거대한 클래스가 되어버립니다.
UMC 7기에서 겨울방학동안 진행한 Wegg 프로젝트에서 기능이 추가될수록 ViewController가 점점 무거워지는 문제를 경험했습니다. 처음에는 지도 UI만 관리하던 ViewController가 점점 데이터 처리, 네트워크 요청, 화면 이동까지 담당하면서 수정과 유지보수가 어려워졌습니다.
이를 해결하기 위해 MVVM 패턴을 적용하여 UI 로직과 데이터 로직을 분리하고, Coordinator 패턴을 활용하여 이동 로직을 ViewController에서 분리하면 좋겠다고 생각했습니다.
이 글에서는 실제 프로젝트에서 리팩토링을 진행한 과정이 아니라, 만약 같은 문제를 해결해야 한다면 어떤 방법으로 개선할 수 있을지 고민한 내용을 정리했습니다. 실제 프로젝트 진행하면서 적용을 하고 싶었지만, 팀원들이 UIKit으로 처음 프로젝트를 진행하는 것을 감안하면 새로운 아키텍처 패턴을 도입하기에는 기능만 구현해도 기간이 빠듯했기 때문입니다.
따라서, 해당 글은 MVC패턴에서 ViewController가 비대해지는 문제를 어떻게 해결할 수 있는지에 대한 고민을 실제 프로젝트가 아닌 해결방안에 대한 내용을 글로 다룬 것입니다.
먼저 문제를 구체적으로 살펴보기 위해, 프로젝트에서 작성한 MapViewController.swift
의 일부를 보겠습니다.(설명을 위해 일부 코드 제거한 상태)
class MapViewController:
UIViewController,
FloatingPanelControllerDelegate,
UIGestureRecognizerDelegate {
private let mapManager: MapManagerProtocol
private var apiManager: APIManager
private var hotplaceList: [HotPlacesResponse.HotPlace] = []
private var selectedPlaceDetailInfo: [HotplaceDetailInfoResponse.Detail] = []
private var mapSearchVC: MapSearchViewController?
let floatingPanel: FloatingPanelController
let hotPlaceSheetVC: HotPlaceSheetViewController
init(mapManager: MapManagerProtocol) {
self.mapManager = mapManager
self.apiManager = APIManager()
self.mapSearchVC = MapSearchViewController(
mapVC: nil,
mapManager: mapManager
)
self.floatingPanel = FloatingPanelController()
self.hotPlaceSheetVC = HotPlaceSheetViewController(mapVC: nil, apiManager: apiManager)
super.init(nibName: nil, bundle: nil)
// 각각의 ViewController에 `MapViewController`를 주입
self.hotPlaceSheetVC.mapVC = self
self.mapSearchVC?.mapVC = self
}
override func viewDidLoad() {
super.viewDidLoad()
...
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
mapManager.requestCurrentLocation()
fetchHotPlacesFromVisibleBounds() // API 호출
}
/// 바텀 시트 초기 설정
private func setupFloatingPanel() {
floatingPanel.delegate = self
floatingPanel.set(contentViewController: hotPlaceSheetVC)
floatingPanel.addPanel(toParent: self)
...
}
// 네비게이션 커스텀
private func pushUniqueSearchViewWithAnimation() {
...
}
/// API에서 가져온 장소들의 좌표를 기반으로 지도에 마커를 추가하는 함수
func setupMarkers<T>(from places: [T]) {
...
}
// MARK: - API 관련 함수
/// 화면 경계값 안에 존재하는 모든 핫플레이스 호출 및 UI 업데이트
func fetchHotPlacesFromVisibleBounds(
sortBy: String = "distance",
page: Int? = nil
) {
guard !isFetchingData else { return } // 중복 호출 방지
isFetchingData = true
let request = mapManager.getVisibleBounds(
sortBy: sortBy,
page: page ?? currentPage,
size: pageSize
)
Task {
do {
let response: HotPlacesResponse = try await apiManager.request(
target: HotPlacesAPI.getHotPlaces(request: request)
)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
// 기존 리스트에 새로운 데이터 추가
self.hotplaceList.append(contentsOf: newPlaces)
let section = self.convertToSectionModel(from: self.hotplaceList)
self.hotPlaceSheetVC.updateHotPlaceList(section)
self.setupMarkers(from: newPlaces)
}
} catch {
print("❌ 실패: \(error)")
}
}
}
/// 모델 변환 후 UI 업데이트
public func updateHotplaceDetailInfo(_ detailList: [HotplaceDetailInfoResponse.Detail]) {
selectedPlaceDetailInfo = detailList
let section = convertToSectionModel(from: selectedPlaceDetailInfo)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if !self.selectedPlaceDetailInfo.isEmpty {
self.setupMarkers(from: self.selectedPlaceDetailInfo)
}
self.hotPlaceSheetVC.updateHotPlaceList(section)
}
}
}
extension MapViewController:
MapOverlayGestureDelegate,
MapSearchBarDelegate {
// MARK: - MapOverlayGestureDelegate
func didTapPlaceSearchBar() {
pushUniqueSearchViewWithAnimation()
}
...
// MARK: - MapSearchBarDelegate
func didTapSearchBackButton() {
pushUniqueSearchViewWithAnimation()
}
}
위 ViewController를 보면 UI관리, 네트워크 처리, 데이터 가공, 사용자 이벤트 처리, 화면 전환까지 모든 역할을 수행하고 있습니다.
사실 처음부터 이러한 구조를 가진 것은 아닙니다. 초기에는 지도 UI를 표시하고, 사용자 입력(탭, 검색 등)을 처리하는 것이 핵심 역할이었습니다. 하지만 검색 기능, 바텀 시트를 활용한 상세 정보 표시, 네비게이션 로직 등의 기능이 추가될수록 점점 더 많은 역할을 담당하게 되었습니다.
이러한 구조는 다음과 같은 문제를 유발합니다.
현재는 프로젝트가 완료된 상태이고, 서버도 닫힌 상태여서 팀원들이 다같이 리팩토링의 시간을 갖지 않으면 코드를 수정하기가 어려운 상태입니다. 하지만 어떻게 하면 비대 문제를 해결할 수 있을지 고민은 할 수 있기 때문에 서두에서도 말했다시피 이에 대한 해결 과정을 글로 작성해 봅니다.
ViewController가 비대해지는 문제를 해결하기 위해 여러 가지 패턴들을 고민했습니다.
ViewController가 Presenter와 소통하고, UI를 업데이트하는 역할만 수행합니다. 하지만, iOS 개발에서는 MVP는 상대적으로 잘 사용되지 않으며 UIKit과 궁합이 좋지 않습니다.
역할이 분명하게 나뉘어 있지만, 구조가 복잡해지고, 유지보수 비용이 증가합니다. 현재 위그 프로젝트에서는 오버 엔지니어링으로 판단하여 제외했습니다.
동일한 비지니스 로직을 재사용할 일이 없고, 네트워크 요청과 데이터 변환 과정이 복잡하지 않아 오히려 계층이 불필요하게 많아지는 문제가 발생할 수 있습니다.
Swift의 Combine을 활용하여 UI 업데이트를 효율적으로 처리할 수 있었습니다. 또한 네트워크 요청 및 데이터 변환을 ViewModel이 담당하도록 분리할 수 있습니다.
여러 패턴을 비교한 결과, MVVM과 Coordinator 패턴만 적용해도 충분히 역할을 분리할 수 있다고 생각했습니다. ViewModel이 네트워크 요청과 비지니스 로직을 담당하고, Coordinator가 화면 전환을 관리하면 ViewController가 UI 이벤트만 처리하는 구조가 되어 충분히 역할 분리를 할 수 있기 때문입니다. 하지만, 만약 프로젝트가 더 확장되어 비지니스 로직을나 재사용해야 하거나 네트워크 요청과 데이터 변환 과정이 더 복잡해진다면 UseCase 도입하는 것도 좋은 선택이 될 것입니다.
기존의 MapViewController가 비대해진 가장 큰 원인 중 하나는 네트워크 요청과 UI 업데이트가 한 곳에서 처리된다는 점입니다. 이문제를 해결하기 위해 MVVM 패턴을 적용하여 UI 로직과 데이터 로직을 분리했습니다.
import Combine
class MapViewModel {
@Published var hotPlaces: [HotPlacesResponse.HotPlace] = []
private var cancellables = Set<AnyCancellable>()
private let apiManager = APIManager()
func fetchHotPlaces(request: HotPlacesAPI.Request) {
Task {
do {
let response: HotPlacesResponse = try await apiManager.request(
target: HotPlacesAPI.getHotPlaces(request: request)
)
DispatchQueue.main.async {
self.hotPlaces = response.result.hotPlaceList
}
} catch {
print("❌ 실패: \(error)")
}
}
}
}
class MapViewController: UIViewController {
private let viewModel = MapViewModel()
private var cancellables = Set<AnyCancellable>()
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
private func setupBindings() {
viewModel.$hotPlaces
.receive(on: DispatchQueue.main)
.sink { [weak self] places in
self?.setupMarkers(from: places)
}
.store(in: &cancellables)
}
}
기존 ViewController에서는 네트워크 요청과 UI 업데이트가 섞여 있었지만, MVVM 적용 후 ViewModel이 API 요청을 담당하고, UI는 @Published를 통해 반응형으로 업데이트 됩니다.
아래는 fetchHotPlacesFromVisibleBounds
함수가 MVVM 적용되기 전과 후에 대한 비교입니다.
func fetchHotPlacesFromVisibleBounds(
sortBy: String = "distance",
page: Int? = nil
) {
let request = mapManager.getVisibleBounds(
sortBy: sortBy,
page: page ?? currentPage,
size: pageSize
)
Task {
do {
let response: HotPlacesResponse = try await apiManager.request(
target: HotPlacesAPI.getHotPlaces(request: request)
)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.hotplaceList.append(contentsOf: newPlaces)
let section = self.convertToSectionModel(from: self.hotplaceList)
self.hotPlaceSheetVC.updateHotPlaceList(section)
self.setupMarkers(from: newPlaces)
}
} catch {
print("❌ 실패: \(error)")
}
}
}
func fetchHotPlacesFromVisibleBounds(
sortBy: String = "distance",
page: Int? = nil
) {
let request = mapManager.getVisibleBounds(
sortBy: "distance",
page: currentPage,
size: pageSize
)
viewModel.fetchHotPlaces(request: request)
}
기존에는 MapViewController에서 직접 pushUniqueSearchViewWithAnimation()을 호출하여 화면을 전환하고 있었습니다. 이에 Coordinator를 도입하여 ViewController에서 화면 이동 로직을 분리합니다.
protocol MapCoordinatorProtocol {
func showSearchView()
}
class MapCoordinator: MapCoordinatorProtocol {
weak var navigationController: UINavigationController?
let mapSearchVC = MapSearchViewController()
private func pushUniqueSearchViewWithAnimation() {
guard let navigationController = self.navigationController else { return }
var viewControllers = navigationController
.viewControllers.filter { !$0.isKind(of: MapSearchViewController.self) }
viewControllers.append(mapSearchVC)
// 커스텀 애니메이션 설정
let transition = CATransition()
transition.duration = 0.2
transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
transition.type = .fade
transition.subtype = .fromRight
// 애니메이션을 네비게이션 컨트롤러 뷰에 추가
navigationController.view.layer.add(transition, forKey: kCATransition)
// 네비게이션 스택 설정
navigationController.setViewControllers(viewControllers, animated: false)
}
func showSearchView() {
pushUniqueSearchViewWithAnimation()
}
}
class MapViewController: UIViewController {
private let coordinator: MapCoordinatorProtocol
init(coordinator: MapCoordinatorProtocol) {
self.coordinator = coordinator
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func didTapPlaceSearchBar() {
coordinator.showSearchView()
}
}
Coordinator를 적용한 후, ViewController에서 present나 push를 직접 호출하지 않게 되어, 뷰 전환 로직이 더 모듈화 되었습니다.
이제 화면 전환을 변경하려면 Coordinator만 수정하면 되므로 유지보수가 훨씬 쉬워졌습니다.
처음에 프로젝트를 시작할 때에도 뷰 컨트롤러가 비대해지는 원인과 문제점에 대해 알고 있었기 때문에 최대한 역할과 책임을 분리하고자 노력했습니다. 하지만 한달 반이라는 짧은 시간동안 구현하고자 했던 기능들을 모두 완성시켜야 했기 때문에 시간이 지날수록 아키텍처에 대해서는 신경을 덜 쓰게 된거 같습니다. 하지만 프로젝트가 마무리된 후 돌아보니, ViewController가 너무 많은 역할을 맡게 된 점이 가장 큰 문제였습니다.
개선 과정을 통해 얻은 주요 인사이트는 다음과 같습니다.
결국, 초기부터 아키텍처 설계를 신중하게 고민하고, 역할을 분리하는 것이 유지보수성과 확장성을 높이는 핵심이라는 점을 배울 수 있었습니다.