반응형

ViewController 비만화 방지 — 비즈니스 로직 분리

 

1. 들어가며

iOS 개발에서 가장 흔하면서도 치명적인 구조적 문제는 ViewController 비만화(ViewController Fatness)입니다.

ViewController가 UI 구성, 네트워크 호출, 데이터 변환, 비즈니스 로직, 상태관리까지 모두 담당하는 구조는 유지보수성, 테스트성, 협업 효율을 크게 떨어뜨립니다.

특히 실무에서는 기능이 추가될수록 ViewController는 점점 덩치가 커지고, 변경 시 사이드 이펙트가 발생합니다.

이 문서는 실제 기업 실무에서 사용하는 구조적 원칙, 설계 패턴, 적용 예시, 코드 샘플, 안티패턴, 개선 전략까지 모두 포함한 완성 가이드입니다.

 

2. 왜 ViewController 비만화가 문제가 되는가?


2.1 변경 시 Side Effect 증가

비즈니스 로직과 UI가 섞여 있으면 작은 UI 변경에도 핵심 로직이 영향을 받습니다.

2.2 테스트 불가능 구조

비즈니스 로직이 ViewController 내부에 있으면 유닛 테스트가 거의 불가능합니다.

실무에서 유지보수 비용이 폭발적으로 증가하는 핵심 원인입니다.

2.3 코드 재사용 실패

다른 화면에서 동일 기능이 필요할 때, 재사용이 아닌 복붙(copy & paste) 로 대응하게 됩니다.

2.4 팀 협업 시 충돌 증가

대형 ViewController는 여러 명이 동시에 작업하기 어려워 Git 충돌 위험도 매우 높습니다.

 

3. 해결 원칙 — UI와 비즈니스 로직의 완전 분리


핵심 개념 3가지

  1. ViewController는 오직 UI(View)만 담당
  2. 비즈니스 로직은 UseCase/Service 객체로 이동
  3. 상태 관리와 UI 연결은 ViewModel에서 처리

 

4. 이상적인 구조

Presentation Layer (View / ViewModel)
    ↓
Domain Layer (UseCase / Entity)
    ↓
Data Layer (Repository / API / Persistence)

ViewController는 이렇게만 사용

  • 화면 구성
  • UI 이벤트 전달
  • ViewModel의 상태 변경 구독

ViewModel은 이렇게

  • 비즈니스 로직 흐름 제어
  • 상태 변경
  • 네트워크 호출은 UseCase에 요청만 함

UseCase는 이렇게

  • 순수한 비즈니스 규칙
  • Domain Model 단위로 처리

 

5. 실무 적용 예시 — 로그인 화면


5.1 기존 안티패턴 (잘못된 예)

class LoginViewController: UIViewController {

    @IBAction func loginButtonTapped(_ sender: UIButton) {
        let id = idTextField.text ?? ""
        let pw = pwTextField.text ?? ""

        let body = ["id": id, "pw": pw]
        AF.request("/login", method: .post, parameters: body).responseDecodable(of: LoginResponse.self) { response in
            if response.value?.result == true {
                UserDefaults.standard.set(id, forKey: "userId")
                self.goToMain()
            } else {
                self.showAlert("로그인 실패")
            }
        }
    }
}

문제점:
- 네트워크 API 호출이 ViewController 안에 있음

- 화면 전환 로직도 함께 포함

- UserDefaults 저장도 함께 있음

- 테스트 불가능

- 재사용 불가능 

 

6. 개선된 구조


6.1 ViewModel

final class LoginViewModel {

    private let loginUseCase: LoginUseCase

    @Published var isLoading = false
    @Published var loginSuccess = false
    @Published var errorMessage: String?

    init(loginUseCase: LoginUseCase) {
        self.loginUseCase = loginUseCase
    }

    func login(id: String, password: String) {
        isLoading = true

        Task {
            do {
                let result = try await loginUseCase.execute(id: id, password: password)
                DispatchQueue.main.async {
                    self.loginSuccess = result
                    self.isLoading = false
                }
            } catch {
                DispatchQueue.main.async {
                    self.errorMessage = error.localizedDescription
                    self.isLoading = false
                }
            }
        }
    }
}

6.2 UseCase

protocol LoginUseCase {
    func execute(id: String, password: String) async throws -> Bool
}

final class LoginUseCaseImpl: LoginUseCase {

    private let repository: LoginRepository

    init(repository: LoginRepository) {
        self.repository = repository
    }

    func execute(id: String, password: String) async throws -> Bool {
        return try await repository.login(id: id, password: password)
    }
}

6.3 Repository

protocol LoginRepository {
    func login(id: String, password: String) async throws -> Bool
}

final class LoginRepositoryImpl: LoginRepository {

    func login(id: String, password: String) async throws -> Bool {
        let body = ["id": id, "pw": password]
        let response = try await AF.request(
            "/login",
            method: .post,
            parameters: body
        ).serializingDecodable(LoginResponse.self).value

        return response.result
    }
}

6.4 ViewController

final class LoginViewController: UIViewController {

    private let viewModel: LoginViewModel

    init(viewModel: LoginViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        viewModel.$loginSuccess.sink { [weak self] success in
            if success { self?.goToMain() }
        }

        viewModel.$errorMessage.sink { [weak self] message in
            guard let msg = message else { return }
            self?.showAlert(msg)
        }
    }

    @IBAction func loginButtonTapped(_ sender: UIButton) {
        viewModel.login(
            id: idTextField.text ?? "",
            password: pwTextField.text ?? ""
        )
    }
}

7. 실제 실무에서 적용할 때 단계별로 진행하는 방법

  1. ViewController 내부 로직을 함수로 분리
  2. 해당 함수들을 ViewModel로 이동
  3. 네트워크 로직을 Repository로 분리
  4. 비즈니스 로직을 UseCase에 위임
  5. UI 로직과 ViewModel을 Combine/Observation으로 연결

 

8. 실무에서 가장 많이 쓰는 조합

  • UIKit + MVVM + Combine
  • SwiftUI + ObservableObject
  • UIKit + MVVM + RxSwift
  • TCA (The Composable Architecture)
  • Clean Architecture + Tuist 모듈화

 

9. 팀 프로젝트에서의 장점

  • 코드 리뷰가 쉬워짐
  • 디자인 변경 시 로직 코드 영향 없음
  • QA 단계에서 버그 추적이 쉬움
  • 신규 인력 투입 시 온보딩 속도가 빠름
  • 모듈화와 함께 사용 시 빌드 속도 상승

 

10. 결론

ViewController 비만화를 해결하는 것은 iOS 아키텍처의 핵심입니다.

UI 담당, 비즈니스 로직 분리, 모듈화, 테스트 가능 구조는 실무 품질에서 압도적인 차이를 만듭니다.

반응형
Posted by 까칠코더
,