[25.12.13] Tododok 트러블슈팅 - Coordinator 화면 전환 메서드, 어떤 방식으로 사용할까?

2025. 12. 13. 18:56·iOS/Swift

1. 문제 상황

지난 MOUP 프로젝트에서 처음으로 Coordinator를 사용해보고 꾸준히 든 고민, coordinator를 vc에 주입하는 등 이러한 과정들이 정말 꼭 필요하며 최적의 방법인가?를 고민했다.

 

ViewController 측에서 어느 이벤트를 통해 화면 이동을 해야한다는 판단을 하게 되면 화면 전환을 담당하는 coordinator의 메서드를 쓰도록 해야 하는데, 이 과정 속에서 지난 번엔 VC 내에 weak var coordinator를 통해 매번 주입받고 internal(접근제어자를 쓰지 않은 기본상태) 메서드를 사용해야 했는데 이 과정이 반복될수록 꽤나 번거로웠다.

화면 전환이 들어가는 ViewController를 생성할 때마다 파라미터로 coordinator = self라는 코드를 넣어주어야 했고 VC측에서도 weak var로 매번 선언해줘야 했기 때문

페이지가 많을수록 더 번거로워진다.

 

2. Coordinator의 페이지 이동 방향들

앞에서 썼던 문제들에서 좀 더 깔끔한 구조 없을까? 고민을 했고, coordinator를 처음 공부하게 되었을 때 알아보았던 방법은 다음과 같다.

 

2.1 직접 주입 방식

가장 직관적이고 초기 구현이 쉬운 방식이다.
위에서 사용했다고 한 방법인데 ViewController가 구체적인 Coordinator의 타입을 알고 직접 호출한다.

 

// Coordinator
class BookCoordinator: Coordinator {
    // ...
    func moveToDetail() { /* push logic */ }
}

// ViewController
class BookViewController: UIViewController {
    // 구체적인 타입(BookCoordinator)에 의존
    weak var coordinator: BookCoordinator?

    func nextButtonTapped() {
        // VC가 직접 Coordinator에게 명령
        coordinator?.moveToDetail()
    }
}

 

이 경우엔 코드가 직관적이고 구현이 빠른 대신, VC가 특정 Coordinator 구현체에 의존하므로 재사용이 어렵다.

그리고 weak 키워드를 통해 coordinator를 선언하지 않으면 메모리 누수 위험이 크다. (순환 참조)

 

2.2 델리게이트 패턴(Delegate Pattern)

iOS의 근본 디자인 패턴인 Delegate를 활용해 의존성을 역전시키는 방식이다. VC는 Delegate에게 처리하라고 알리기만 하면 된다.

 

// 프로토콜 정의
protocol BookViewControllerDelegate: AnyObject {
    func didTapNextStep()
}

// ViewController
class BookViewController: UIViewController {
    // VC는 Coordinator를 모른다. Delegate만 알 뿐.
    weak var delegate: BookViewControllerDelegate?

    func nextButtonTapped() {
        delegate?.didTapNextStep()
    }
}

// Coordinator (Delegate 채택)
class BookCoordinator: Coordinator, BookViewControllerDelegate {
    func start() {
        let vc = BookViewController()
        vc.delegate = self // 연결
        navigationController.pushViewController(vc, animated: true)
    }

    func didTapNextStep() {
        // 화면 전환 수행
    }
}

 

이 방식은 의존성 분리 측면에서 VC가 Coordinator를 모르기 때문에 더 좋고, 이어서 다른 Coordinator에서도 이 VC를 쉽게 가져다 쓸 수 있다.

반면, 화면마다 프로토콜 정의, 채택, 구현 코드가 반복되어 코드가 길어진다. 그리고 어디선가 vc.delegate = self 해주는 작업이 필요해 보일러플레이트다.

 

기존에 알게 된 방식은 이 두가지가 있는데, 이번 토도독 내 화면 이동 구조를 구현하며 고민하다가 떠오른 방식이 있다.

 

2.3 Reactive Approach (Combine 활용)

MVVM 패턴과 Combine을 사용한다면 추천할 것 같다. ViewModel은 신호를 보내고 Coordinator는 이를 구독한다는 구조다.

 

이 방식의 핵심은 VC가 화면 전환에 개입하지 않는다는 점.

 

final class BookSelectionViewModel {
    // Coordinator가 구독할 '화면 전환 전용' 통로
    // import UIKit 없음! 순수 로직의 결과
    let routeSubject = PassthroughSubject<Void, Never>()
    
    struct Input {
        let nextButtonTapped: AnyPublisher<Void, Never>
    }
    
    func transform(input: Input) {
        input.nextButtonTapped
            .sink { [weak self] _ in
                // 로직 검증(유효성 체크 등) 후 이동 신호 방출
                self?.routeSubject.send()
            }
            .store(in: &cancellables)
        // ... (View 바인딩용 Output 로직 별도)
    }
}

 

현재 프로젝트 내에선 Input, Output 패턴을 적용하고 있고, ViewController와 View를 분리하는 구조이다.

View에서 Button에 대한 Publisher를 extension을 통해 공개해두고 이를 VC에선 input으로 연결해주게 되어 ViewModel까지 타고 들어가면 스트림을 구독해두었다가 버튼이 눌렸을 때의 이벤트를 감지해 어느 과정들을 거치고 화면 이동을 하도록 한다.

 

위 코드는 예시일뿐이고, 현재 프로젝트 내에서는 이렇게 적용해보았다. 

ViewModel 내부 코드 일부를 보면,

let routeSubject = PassthroughSubject<Book, Never>() // Coordinator에서 구독할 subject

// ...

// MARK: - transform
    func transform(input: Input) -> Output {
        input.nextButtonTapped
            .compactMap { [weak self] _ in
                return self?.selectedBook.value
            }
            .sink(receiveValue: { [weak self] book in
                self?.routeSubject.send(book)
            })
            .store(in: &cancellables)
        
        let selectedBookOutput = selectedBook.map { book -> BookCoverItem? in
            guard let book else { return nil }
            return BookCoverItem(
                title: book.title,
                author: book.author,
                thumbnailURL: book.thumbnailURL
            )
        }
            .eraseToAnyPublisher()
        
        return Output(
            selectedBook: selectedBookOutput
        )
    }

 

VC에서 View의 tapPublisher(버튼 이벤트 스트림)를 ViewModel의 input으로 연결해두었고, ViewModel 내에선 이 input의 NextButtonTapped라는 스트림을 구독해두었다가 이벤트를 받게 되면 현재 선택되어있는 책인 selectedBook을 반환하는 스트림으로 바꿔주면서 이를 routeSubject라는 스트림으로 보내준다.

 

그렇다면, routeSubject를 Coordinator 측에선 어떻게 쓰냐면!

 

func moveToBookSelect(mode: BookTimerMode) {
        self.tempMode = mode
        let myBookSelectionVM = MyBooksSelectionViewModel()
        let viewModel = BookSelectionTabViewModel(selectedBook: myBookSelectionVM.selectedBook)
        let bookAddVM = BookAddViewModel()
        let vc = BookSelectionTabViewController(
            viewModel: viewModel,
            myBooksSelectionVM: myBookSelectionVM,
            bookAddVM: bookAddVM
        )
        viewModel.routeSubject.sink { [weak self] book in
            guard let self else { return }
            didSelectBook(book)
        }
        .store(in: &cancellables)
        navigationController.pushViewController(vc, animated: true)
    }
    
    func didSelectBook(_ book: Book) { // 모드에 따른 분기로 인해 네이밍 컨벤션 예외
        guard let mode = tempMode else {
            return
        } // 이전에 선택한 모드가 없을 경우 중
        let vc: UIViewController
        switch mode {
        case .timer:
            let viewModel = TimerSetupViewModel()
            vc = TimerSetupViewController(viewModel: viewModel)
        case .stopwatch:
            let viewModel = StartStopWatchViewModel()
            vc = StartStopWatchViewController(viewModel: viewModel)
        }
        
        self.navigationController.pushViewController(vc, animated: true)
    }

 

routeSubject를 통해 받아온 책을 didSelectBook의 인자로 넘겨주면서 실행한다.

기존에 선택했던 모드와 함께 다음 페이지에 필요한 정보로 쓰게 된다.

 

원래 했던 방식대로면 ViewController 측에 coordinator를 weak var 키워드로 선언하고 이 ViewController를 coordinator에서 생성하면서 self로 주입해주는 과정이 필요했을 것이고, 화면 이동 처리 시에도 moveToTimer~같은 메서드 내에 book을 넣는 과정일것이다.

viewModel의 스트림을 공개해두면 그저 이벤트만 보내주면 되고, Coordinator에선 이를 받아 처리하도록 하게 되어 플로우가 보다 더 깔끔해진다.

 

근데,, 화면 전환 로직은 VC에서만 수행해야 하는 것 아닌가?

라는 의문이 들었는데, 위 코드 중 주석에서도 쓰여있듯 ViewModel은 UI에 직접 관여하지 않도록 하는게 맞다.

그치만 ViewModel에서는 그저 스트림을 통해 화면 전환하셔도 됩니다!라는 이벤트를 남길 뿐 UI에 직접 관여하지 않는다. (이러한 점에서 UIKit이 import 되지 않음을 확인하면 된다는 말과 비슷함, viewModel이 UIKit을 import하지 않기에,,)

 

결론적으로, 3번 방법이 viewModel로 하여금 이동 여부를 결정하게 하고, Coordinator에선 실제 이동을 수행시키며 VC는 그저 화면을 그리도록 한다. -> 역할 분리

또한 Delegate 선언 같은 코드가 사라지면서 코드가 간결해지는 점, 그리고 프로젝트 내의 데이터 흐름이 전반적으로 스트림 기반인데 화면 이동도 스트림 기반을 채택하게 되어 일관성을 지킬 수 있다.

 

 


 

Coordinator의 화면 전환에 대해 이전에도 고민하면서 스트림 기반으로 할 생각이 들지 않았는데, 이전 프로젝트에서도 RxSwift를 썼기 때문에 이 방법을 그때 미리 적용해보았으면 어땠을까싶다.

 

아직 제 생각엔 이 방법이 전반적으로 더 좋아보이나 틀린 점이 있거나 더 좋은 방법이 있다면 편하게 말씀해주세요 !

'iOS > Swift' 카테고리의 다른 글

[TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 2  (0) 2025.10.11
[TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 1  (0) 2025.10.11
[25.08.14] MOUP 리팩토링 및 트러블슈팅 - 동적 높이를 가지는 테이블뷰 셀  (3) 2025.08.14
[25.08.04] MOUP 트러블슈팅 - .xcconfig 내 url 설정 시 주의사항  (5) 2025.08.05
[25.07.31] MOUP 트러블슈팅 - tableHeaderView 레이아웃 제약 경고  (2) 2025.07.31
'iOS/Swift' 카테고리의 다른 글
  • [TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 2
  • [TIL / 25.10.11] Combine,, 실습과 함께 훑어봤어요 1
  • [25.08.14] MOUP 리팩토링 및 트러블슈팅 - 동적 높이를 가지는 테이블뷰 셀
  • [25.08.04] MOUP 트러블슈팅 - .xcconfig 내 url 설정 시 주의사항
subkyu-ios
subkyu-ios
subkyu-ios 님의 블로그 입니다.
  • subkyu-ios
    subkyu-ios 님의 블로그
    subkyu-ios
  • 전체
    오늘
    어제
    • 분류 전체보기 (61)
      • iOS (41)
        • Swift (41)
      • 내일배움캠프 (7)
      • Git, Github (3)
      • Algorithm (6)
      • 회고 (1)
      • 면접 질문 정리 (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    til
    UIKit
    algorithm
    알고리즘
    프로그래머스
    moup
    사전캠프
    ios
    tabman
    Wil
    트러블슈팅
    KPT
    반응형 프로그래밍
    본캠프
    RxSwift
    Swift
    회고
    stackview
    내일배움캠프
    Combine
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
subkyu-ios
[25.12.13] Tododok 트러블슈팅 - Coordinator 화면 전환 메서드, 어떤 방식으로 사용할까?
상단으로

티스토리툴바