런로그 트러블슈팅 03

김도연·2025년 4월 21일

iOS

목록 보기
7/8

원문 보러가기 -> 런로그 위키

🚨 문제 발생

  • SwiftUI로 1차 프로젝트를 했기도 하고, MVC 패턴은 Controller가 너무 무거워진다는 문제점을 알고 있었기에 이번 프로젝트에서는 MVVM 패턴을 적용하기로 했다. 그리고 내부적으로 combine스터디를 진행하고 있었어서, 이번 기회에 Combine을 적극적으로 도입해보기로 결정하였다.
  • 하지만, 그동안 UIKit을 통해서 MVC패턴에 익숙해져있었던 점 + Combine을 모두가 배워나가는 단계라는 문제점이 있었기에, 아예 어떤 식으로 MVVM 패턴을 적용할지 미리 결정을 했는데,,
  • 현직자 리뷰와 다른 레퍼런스를 참고해서 개발 과정에서 계속해서 구조를 고쳐나갔다. 그 여정을 어떻게 해결했는지 트러블 슈팅으로 작성해보았다...

🧐 원인 분석 (Root Cause Analysis)

  • Input&Output 구조를 왜 쓰는가? -> 데이터의 흐름을 제어하고, Combine을 사용함으로써 발생하는 이점을 극대화하기 위해서라고 생각했다. 하지만 코드 리뷰에서 들었던 것은...

    모든 데이터흐름은 Input/output으로만 처리하도록 하는게 맞음(구조보고 기대하는거랑 실제 로직이랑 차이가 생겨버림)

  • 내 코드에서 모든 데이터 흐름을 Input/Output으로만 처리하고 있지 않았다는 것이다! 또한, 다음의 조언을 받았다.

    뷰모델-뷰컨 bind 시점 조정해라(init에서 부르지말것)

Input output 변수 있는경우는 struct가 나음(웬만해서 struct쓰자)

💡 해결 과정

  1. 변수가 있는 경우에 Struct로 처리해주기!
  • 이건 Input에는 적용하지 않았고, Output에만 적용해주었다. -> Why? 애초에 struct가 아니라 enum 구조를 채택한 이유가 bind해줄 때 switch-case문으로 깔끔하게 처리하고 싶었기 떄문이다!(by 몽피) 코드 상으로는 enum으로 처리하면 데이터 연결되는 지점을 모아볼 수 있어서 더 직관적인 구조였다. 그래서 이는 유지하고, 대신 output을 struct로 바꾸기로 결정했다.

enum Input {
    case loadData              // 유저 데이터 호출
    case menuItemSelected(Int) // 메뉴 항목 선택
}

struct Output {
    /// 로딩 상태 제어
    let stopLoading = CurrentValueSubject<Bool, Never>(false)
    
    /// 프로필 정보 업데이트
    let profileDataUpdated = CurrentValueSubject<UserInfoVO, Never>(
        UserInfoVO(nickname: "RunLogger", totalDistance: 0.0, streakCount: 0, logCount: 0)
    )
    
    /// 이동할 뷰컨트롤러 이벤트 전달
    let navigateToViewController = CurrentValueSubject<UIViewController?, Never>(nil)
}
  1. bind 시점 조정 및 ViewModel - ViewController 연결
  • 이부분은 ViewModel의 생명주기를 알아야하는데, 우리는 Controller에서 뷰모델을 생성해주거나, ViewModel을 생성해서 ViewController를 생성할 때 주입해주기 때문에, ViewModel의 init()에서 bind를 해버리면, 아직 뷰가 존재하지 않는데 bind를 해버리는 상황이 발생할 수 있었다. 그래서 ViewModel의 bind()를 ViewController의 viewDidLoad()에서 부르는 로직으로 수정하였다.

  • ViewController파일


override func viewDidLoad() {
    super.viewDidLoad()
    
    setupUI()                 // 화면 구성
    setupNavigationBar()     // 네비게이션 바 설정
    setupTableView()         // 테이블뷰 바인딩
    
    viewModel.bind()         // ViewModel의 입력 수신 준비
    bindViewModel()          // ViewModel의 출력 바인딩
}

// ... 중략 ...

private func bindViewModel() {
    // 로딩 상태 변경
    viewModel.output.stopLoading
        .receive(on: DispatchQueue.main)
        .sink { [weak self] off in
            off ? self?.stopLoading() : self?.startLoading()
        }
        .store(in: &cancellables)
    
    // 프로필 데이터 업데이트
    viewModel.output.profileDataUpdated
        .receive(on: DispatchQueue.main)
        .sink { [weak self] config in
            self?.mypageView.configure(with: config)
        }
        .store(in: &cancellables)
    
    // 화면 전환 처리
    viewModel.output.navigateToViewController
        .receive(on: DispatchQueue.main)
        .sink { [weak self] viewController in
            if let vc = viewController {
                self?.navigationController?.pushViewController(vc, animated: true)
            }
        }
        .store(in: &cancellables)
}
  • ViewModel 파일

// MARK: - Bind (Input -> Output)
func bind() {
    inputSubject
        .receive(on: DispatchQueue.main)
        .sink { [weak self] event in
            switch event {
            case .loadData:
                self?.fetchProfileData()
            case .menuItemSelected(let index):
                self?.handleMenuSelection(index)
            }
        }
        .store(in: &cancellables)
}

최종 구조 요약

MVVM

Weekly 2차 프로젝트 발표자료

Input/Output + Combine

Weekly 2차 프로젝트 발표자료 (1)

🎯 결과 및 교훈

  • Input/Output을 다들 struct로 쓰는 이유가 다 있구나...!라는걸 깨달았다.
  • Controller에서 했던 일을 ViewModel로 옮기면서 싱크를 맞추는 것, 데이터에 접근하는 방식이 달라졌는데 코드를 좀 진득하게 붙잡고 분석해보면서 데이터 전달을 어떤 식으로 하는게 책임과 역할을 분리하고자 했던 목표와 맞는 방향인지 체감할 수 있었다.
  • tableviewDatasource와 tableviewDelegate 내부 함수에서도 VM으로 처리하는 로직을 만드니까 훨씬 코드가 깔끔해졌다.
  • 그리고 무엇보다 뷰는 static으로 가져갈 수 있어서 좋았다.
  • UIKit에서의 Combine 적용의 편리함을 확실히 느낄 수 있었다.

🔗 참고 자료

참고 자료 1

profile
Kirby-like iOS developer

0개의 댓글