[내일배움캠프 11주차 (03/16)]

yeseul jang·2026년 3월 16일

내일배움캠프

목록 보기
21/32

📌 RxSwift MVVM에서 Input/Output 패턴과 데이터 흐름 구현

오늘은 RxSwift를 사용하여 MVVM 패턴의 HomeViewModel을 Input/Output 구조로 구현했다.
홈 화면에서 필요한 여러 API 데이터를 가져와 ViewModel에서 가공한 뒤 ViewController에 전달하는 구조를 만들었다.

  • MVVM에서 Input / Output 패턴
  • RxSwift에서 Relay, Driver, Signal 사용 이유
  • flatMapLatest, do, catch의 역할
  • Observable → Driver 변환 이유
  • 여러 API를 병렬(동시) 호출하기 위한 Single.zip
final class HomeViewModel {
    private let disposeBag = DisposeBag()
    
    // 받아올 정보: viewDidLoad 됐는가
    struct Input {
        let viewDidLoad: Observable<Void>
    }
    
    // 줘야 할 정보: 섹션 정보, 로딩중인지 아닌지 판별(스피너 보여주기), 에러일 경우 넘기기
    // 다 UI 관련임으로 Driver과 Signal 사용
    struct Output {
        let sections: Driver<[HomeSectionModel]>
        let isLoading: Driver<Bool>
        let errorMessage: Signal<String>
    }
    
    func transform(input: Input) -> Output {
        // 로딩 상태는 현재 상태값에 가깝기 때문에 BehaviorRelay 사용 - 초기값은 화면 전에는 로딩중일 수 없기때문에 false
        let loadingRelay = BehaviorRelay<Bool>(value: false)
        
        // 에러가 일어나는 건 이벤트에 가깝기 때문에(한번 발생) BehaviorRelay 사용
        let errorRelay = PublishRelay<String>()
        
        // sections UI를 그릴때 쓰기때문에 Driver타입으로 구현
        // isLoading 사이드 이펙트는 do로 구현(부수효과), relay는 onNext 대신 accept 사용
        let sections = input.viewDidLoad
            .do(onNext: { _ in
                loadingRelay.accept(true)
            })
        // 최신 요청만 적절하게 처리하기 위해서 flatMapLatest
            .flatMapLatest { [weak self] _ -> Observable<[HomeSectionModel]> in
                // 반환값으로 빈배열 가진 just로 하나 뱉음
                guard let self else { return .just([])}
                
                // fetchHomeSectionsData()는 Single<[HomeSectionModel]>를 반환 그래서 asObservable로 타입변환해줌
                return self.fetchHomeSectionsData()
                    .asObservable()
                    .do(onNext: { _ in
                        loadingRelay.accept(false) // 로딩 상태를 바꾸어줌
                    })
                    .catch { error in // 오류 잡기
                        loadingRelay.accept(false)
                        errorRelay.accept("데이터 전송오류")
                        return .just([])
                    }
            }
            .asDriver(onErrorJustReturn: [])
        // 성공시 나가는 반환타입은 Driver<[HomeSectionModel]>이기 떄문에 변환해줌
        // Driver는 오류는 못보내기 떄문에 여기서 오류시 어떤 값 내보낼지 정해야함)
        
        return Output(
            sections: sections,
            isLoading: loadingRelay.asDriver(), // Observable 이기떄문에 변환해줌
            errorMessage: errorRelay.asSignal() // Observable 이기떄문에 변환해줌
        )
    }
    
    // fetch해줌 다 같이 뜨는게 자연스럽기 때문에 zip 사용해서 HomeSectionModel에 넣어서 넘김
    func fetchHomeSectionsData() -> Single<[HomeSectionModel]> {
        let featuredAlbum = NetworkManager.shared
            .fetch(endpoint: .kpopAlbums) as Single<ITunesResponse<MusicItem>>
        
        let yhSongs = NetworkManager.shared
            .fetch(endpoint: .yhSongs) as Single<ITunesResponse<MusicItem>>
        
        let tySongs = NetworkManager.shared
            .fetch(endpoint: .tySongs) as Single<ITunesResponse<MusicItem>>
        
        let lofiAlbums = NetworkManager.shared
            .fetch(endpoint: .lofiChillAlbums) as Single<ITunesResponse<MusicItem>>
        
        let happyPopAlbums = NetworkManager.shared
            .fetch(endpoint: .happyPopAlbums) as Single<ITunesResponse<MusicItem>>
        
        return Single.zip(
            featuredAlbum,
            yhSongs,
            tySongs,
            lofiAlbums,
            happyPopAlbums
        ) { featured, yhSongs, tySongs, lofi, happy in
            return [
                HomeSectionModel(
                    type: .featuredAlbum,
                    items: featured.results
                ),
                HomeSectionModel(
                    type: .YHSongs,
                    items: yhSongs.results
                ),
                HomeSectionModel(
                    type: .TYSongs,
                    items: tySongs.results
                ),
                HomeSectionModel(
                    type: .lofiAlbums,
                    items: lofi.results
                ),
                HomeSectionModel(
                    type: .happyPopAlbums,
                    items: happy.results
                )
            ]
        }
    }
}

🔎 1. MVVM에서 Input / Output 패턴

ViewModel은 View와 직접적으로 강하게 결합되지 않도록
입력(Input)과 출력(Output)을 분리하는 구조를 가진다.

struct Input {
    let viewDidLoad: Observable<Void>
}

struct Output {
    let sections: Driver<[HomeSectionModel]>
    let isLoading: Driver<Bool>
    let errorMessage: Signal<String>
}

📘 Input

View에서 발생하는 이벤트를 의미한다.

  • viewDidLoad
  • 버튼 클릭
  • 검색어 입력
  • pull to refresh

이번 구현에서는 홈 화면이 처음 열렸을 때 데이터를 불러오기 위해 viewDidLoad이벤트만 사용했다

📘 Output

ViewController가 화면을 그리기 위해 필요한 데이터이다.
이번 Output은 통신으로 받아온 데이터를 뷰에 올리는 게 다이기 때문에 다음 세 가지로 비교적 간단하다

  • sections: collectionView에 표시할 데이터
  • isLoading: 로딩 상태 표시 (spinner)
  • errorMessage: 에러 발생 시 사용자에게 보여줄 메시지

🔎 2. Relay 사용 이유

RxSwift에서 상태 관리를 위해 Relay를 사용했다.

📘 BehaviorRelay

let loadingRelay = BehaviorRelay<Bool>(value: false)

특징

  • 현재 값을 저장
  • 구독 시 최신 값을 바로 전달
  • error / complete 이벤트 없음

사용 이유

로딩 상태는 현재 상태값이기 때문이다.
따라서 상태를 저장하는 BehaviorRelay가 적절하다.

📘 PublishRelay

let errorRelay = PublishRelay<String>()

특징

  • 값을 저장하지 않음
  • 이벤트 발생 시에만 전달

사용 이유

에러 메시지는 한 번 발생하는 이벤트이기 때문이다.
현재 상태가 아니라 순간적인 이벤트이므로 PublishRelay가 적합하다.

🔎 3. Driver / Signal 사용 이유

Output은 UI와 연결되는 데이터이기 때문에
Observable 대신 DriverSignal을 사용했다.

📘 Driver

sections
isLoading
  • error 이벤트 발생하지 않음
  • 항상 MainThread에서 실행
  • 이벤트 공유

이 특징은 UI 바인딩에 안전한 타입들이다.

📘 Signal

errorMessage

Signal은 Driver와 비슷하지만 이벤트 전달 목적에 더 가깝다.

🔎 4. 데이터 흐름 구현

핵심 로직은 여기 부분이다.

let sections = input.viewDidLoad

화면이 처음 로드되면 데이터 요청이 시작된다.

📘 do 연산자

.do(onNext: { _ in
    loadingRelay.accept(true)
})

do는 스트림을 변경하지 않고 부수 효과(side effect)만 수행한다.

여기서는 API 요청이 시작될 때

loading = true

이런식으로 로딩 상태를 변경한다.

🔎 5. flatMapLatest 사용 이유

.flatMapLatest { ... }

flatMapLatest는 가장 최신 이벤트만 처리하는 연산자이다.

주로 검색에서 마지막 입력이 들어오면 그 마지막 요청만 유효하게 처리한다.

이번 코드에서는 viewDidLoad로 이벤트 하나만 사용하지만 이후 새로고침, 재요청 확률이 있기에 flatMapLatest가 더 적절하다고 판단했다.

🔎 6. Single에서 Observable 변환

fetchHomeSectionsData()
    .asObservable()

fetchHomeSectionsData()Single<[HomeSectionModel]>타입을 반환한다.

Single는 성공 1회/실패 1회 둘 중 하나를 내보낸다. 하지만 flatMapLatest 내부에서는 Observable을 사용해야 하므로 asObservable() 로 변환했다.

🔎 7. 에러 처리

.catch { error in
    loadingRelay.accept(false)
    errorRelay.accept("데이터 전송오류")
    return .just([])
}

에러 발생 시 처리 과정

  • 로딩 종료
  • 에러 메시지 전달
  • 빈 배열 반환

여기서 중요한 이유는 이 스트림은 에러 발생 시 종료되기 때문에 catch를 사용하여 스트림이 끊어지지 않도록 했다.

🔎 8. Observable에서 Driver 변환

마지막으로 UI 바인딩을 위해 Driver로 변환한다.

.asDriver(onErrorJustReturn: [])

Driver는 error 이벤트를 발생시킬 수 없기 때문에 에러 발생 시 대체 값을 지정해야 한다.
여기서는 빈 배열을 반환하도록 설정했다.

🔎 9. 여러 API 요청 병렬 처리

홈 화면에서는 여러 데이터를 동시에 가져온다.

  • KPOP 앨범
  • 윤하 노래
  • 태연 노래
  • lofi 앨범
  • happy pop 앨범

이를 위해 Single.zip을 사용했다.
zip는 모든 요청이 완료되면 결과를 한 번에 반환해서 UI에서 한 번에 모든 섹션을 표시할 수 있다.

🔎 10. 전체 데이터 흐름

전체 흐름

viewDidLoad -> loadingRelay = true -> API 요청 (zip) 성공
-> sections 전달, loadingRelay = false -> collectionView reload

(에러 발생시: loadingRelay = false, errorRelay 전달 -> sections에는 빈값)

📌 오늘 느낀 점

RxSwift에서 제일 제일 중요한 것은 데이터 흐름을 명확하게 설계하는 것같다.
오늘 작업한건 전달해줄 곳이 하나뿐이고 input도 간단하고 비즈니스 로직도 딱히 들어가는 부분이 없어서 비교적 간단했지만, 아닐 경우에 이게 꼬이면 어려울 것이라 예상된다..🤯🤯🤯

profile
iOS 개발

0개의 댓글