카테고리 없음

TIL: ViewModel 리팩토링: Relay -> Driver, Signal

ghnn 2025. 8. 21. 22:25

기존 뷰모델은 enum Input + PublishRelay<Input> 구조에, struct Output 내부에서 BehaviorRelay들을 두는 방식이었다.

  • View → Input으로 이벤트 전달
  • ViewModel 내부에서 output.xxx.accept() 호출로 상태 갱신

장점

  • 직관적이고 빠르게 동작
  • 단순한 경우에는 코드가 짧고 이해하기 쉬움

단점

  • View에 노출되는 타입이 Relay → 외부에서 accept()가 가능해 불변성 위반 위험
  • UI 스레드 보장/에러 비전파를 개발자가 직접 챙겨야 함
  • 상태 캐시를 위해 Relay가 1:1로 증식

개선 방향

  • Relay 의존을 제거
  • transform(_:)에 입력 스트림을 주입
  • 출력은 Driver/Signal로만 전달

Before: Relay 중심

→ View에서 input.accept(...) / ViewModel에서 output.xxx.accept(...)

    ...
    
    enum Input {
        case segmentedControlChanged(Int)
        case pullToRefresh
        case fetchMoreData
    }
    
    struct Output {
        let sectionName = BehaviorRelay<[String]>(value: [])
        let selectedCategory = BehaviorRelay<CommunitySectionType>(value: .invLogBoard)
        let currentCellData = BehaviorRelay<[CommunitySectionType: [CommunitySection]]>(value: [:])
        let isUpdating = BehaviorRelay<Bool>(value: false)
    }
    
    let input = PublishRelay<Input>()
    let output = Output()
    
    ...
    
    private func transform() {
        self.input
            .bind(onNext: { [weak self] input in
                guard let self else { return }
                
                switch input {
                case .segmentedControlChanged(let index):
                    let category: CommunitySectionType = {
                        switch index {
                        case 0: return .invLogBoard
                        case 1: return .detectiveMateBoard
                        default: return .invLogBoard
                        }
                    }()
                    
                    self.output.selectedCategory.accept(category)
                    self.fetchData(category: category)
                    
                case .pullToRefresh:
                    self.output.isUpdating.accept(true)
                    self.fetchData(category: self.output.selectedCategory.value, refresh: true)
                    
                case .fetchMoreData:
                    self.fetchMoreData(category: self.output.selectedCategory.value)
                    
                }
            })
            .disposed(by: disposeBag)
    }

After: transform + Driver 중심

→ 상태 흐름을 fetchStream → Mutation → scan(State) 파이프라인 하나로 통일

    ...
    
    struct Input {
        let segmentIndexChanged: Observable<Int>
        let pullToRefresh: Observable<Void>
        let fetchMore: Observable<Void>
    }
    
    struct Output {
        let sectionName: Driver<[String]>
        let selectedCategory: Driver<CommunitySectionType>
        let currentCellData: Driver<[CommunitySectionType: [CommunitySection]]>
        let isUpdating: Driver<Bool>
    }
    
    func transform(_ input: Input) -> Output {
        
        // 선택된 카테고리: 초기값 invLogBoard
        let selectedCategory = input.segmentIndexChanged
            .map { index -> CommunitySectionType in
                // 안전 접근을 위해 한 번 확인 후 접근
                CommunitySectionType.allCases.indices.contains(index)
                ? CommunitySectionType.allCases[index]
                : .invLogBoard
            }
            .startWith(.invLogBoard)
            .share(replay: 1)
        
        // 섹션명 (고정: enum CommunitySectionType 에서 관리)
        let sectionName = Driver.just(CommunitySectionType.allCases.map { $0.name })
        
        // 새로고침 트리거: 초기 로드 + 카테고리 변경 + Pull to refresh
        let refreshTrigger = Observable.merge(
            selectedCategory.map { _ in () },
            input.pullToRefresh
        )
        .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
        .share()
        
        // 트리거가 발생하면 데이터 패치
        let fetchStream = refreshTrigger
            .withLatestFrom(selectedCategory)
            .flatMapLatest { [weak self] category in
                guard let self else { return Observable<(CommunitySectionType, Event<[CommunityModel]>)>.empty() }
                return self.fetchPosts(category: category)
                    .asObservable()
                    .materialize()
                    .map { (category, $0) }
            }
            .share()
        
        // 로딩 상태
        let isUpdating = Observable.merge(
            refreshTrigger.map { true },
            fetchStream.map { _ in false }
        )
            .startWith(false)
            .distinctUntilChanged()
            .asDriver(onErrorJustReturn: false)
        
        // 데이터 변경 Mutation
        enum Mutation {
            case set(category: CommunitySectionType, posts: [CommunityModel])
            case append(category: CommunitySectionType, posts: [CommunityModel])
        }
        
        // refresh
        let refreshMutation = fetchStream
            .compactMap { category, event in
                event.element.map { post in
                    Mutation.set(category: category, posts: post)
                }
            }
            .asObservable()
            .catchAndReturn(.set(category: .invLogBoard, posts: []))
        
        // append
        let appendMutation = input.fetchMore
            .withLatestFrom(selectedCategory)
            .map { category -> Mutation in
                // TODO: 무한스크롤 구현
                return .append(category: category, posts: [])
            }
        
        // 상태 축적
        let initialPosts: [CommunitySectionType: [CommunityModel]] = {
            var dict: [CommunitySectionType: [CommunityModel]] = [:]
            CommunitySectionType.allCases.forEach { dict[$0] = [] }
            return dict
        }()
        
        let postsDict = Observable.merge(refreshMutation, appendMutation)
            .scan(initialPosts) { dict, mutation in
                var next = dict
                switch mutation {
                case let .set(category, posts):
                    next[category] = posts
                case let .append(category, posts):
                    next[category, default: []] += posts
                }
                return next
            }
            .share(replay: 1)
        
        let currentCellData = postsDict
            .map { dict in
                dict.mapValues { posts in
                    posts.map { CommunitySection(model: $0, items: $0.contentImage) }
                }
            }
            .asDriver(onErrorJustReturn: [:])
        
        return Output(
            sectionName: sectionName,
            selectedCategory: selectedCategory.asDriver(onErrorJustReturn: .invLogBoard),
            currentCellData: currentCellData,
            isUpdating: isUpdating
        )
    }
    
    ...

주요 변화

  1. 상태 변경 단일화
    • Before: 여러 곳에서 output.currentCellData.accept(...)
    • After: 모든 상태 변경은 scan(initialPosts, mutation)에서만 발생
      → 변경 경로가 한 곳으로 모여 추적과 테스트 용이
  2. 중복 네트워크 호출 방지
    • Before: refresh/로딩/데이터 반영 단계에서 중복 호출 가능
    • After: fetchStream = ...materialize().share() 한 곳에서만 호출
      → 네트워크 중복 실행 구조적으로 차단
  3. 초기값 & 누락 방어
    • Before: 키 누락 시 dict[category]!로 크래시 가능
    • After: initialPosts + dict[category, default: []]
      → 키 보장 + 누락 시 안전 값 자동 적용
  4. UI 안전성 내장
    • Before: Output이 Relay라 UI에서 직접 accept 가능
    • After: Output 자체가 Driver 타입
      → UI는 무조건 메인 스레드·에러 비전파·최신값 전달 보장

사용 연산자 정리

  • startWith → 초기값 방출 보장 (.invLogBoard)
  • throttle → 이벤트 난사 방지 (Pull-to-refresh, 탭 전환)
  • withLatestFrom → 트리거 시점의 최신 카테고리로 fetch
  • flatMapLatest → 최신 요청만 유효, 이전 요청 자동 취소
  • materialize → 성공/실패 이벤트를 값으로 통합 처리
  • share / share(replay: 1) → 네트워크 호출 공유 & 상태 캐싱
  • scan → 상태 누적(스트리밍 reduce)
  • distinctUntilChanged → 불필요한 UI 리렌더링 방지

리팩토링 체감

  • “가능 여부”보다는 **“안전하게 강제되는가”**가 중요
  • Relay 방식도 충분히 동작했지만, 실수 가능성이 항상 열려 있었음
  • 지금 구조는 초기값, 네트워크 중복, 상태 변경 경로를 구조적으로 닫아버림
  • 코드 길이는 다소 늘었지만, 사고 방지 비용이 크게 줄고 유지보수성이 향상됨