기존 뷰모델은 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
)
}
...
주요 변화
- 상태 변경 단일화
- Before: 여러 곳에서 output.currentCellData.accept(...)
- After: 모든 상태 변경은 scan(initialPosts, mutation)에서만 발생
→ 변경 경로가 한 곳으로 모여 추적과 테스트 용이
- 중복 네트워크 호출 방지
- Before: refresh/로딩/데이터 반영 단계에서 중복 호출 가능
- After: fetchStream = ...materialize().share() 한 곳에서만 호출
→ 네트워크 중복 실행 구조적으로 차단
- 초기값 & 누락 방어
- Before: 키 누락 시 dict[category]!로 크래시 가능
- After: initialPosts + dict[category, default: []]
→ 키 보장 + 누락 시 안전 값 자동 적용
- 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 방식도 충분히 동작했지만, 실수 가능성이 항상 열려 있었음
- 지금 구조는 초기값, 네트워크 중복, 상태 변경 경로를 구조적으로 닫아버림
- 코드 길이는 다소 늘었지만, 사고 방지 비용이 크게 줄고 유지보수성이 향상됨