Combine with UIKit (MVVM-C) 구현하면서 알게된 점

Horus-iOS·2023년 1월 6일
1

Combine

목록 보기
9/9

UIKit 프레임워크와 컴바인 프레임워크를 동시에 사용하면서 생각했던 것들을 정리하는 글입니다. 잘못된 정보가 있을 수 있으며 알려주시면 감사하겠습니다.

아키텍처와 디자인 패턴의 큰 틀은 클린 아키텍처 예제로 유명한 Oleh Kudinov님의 영화 검색 예제 앱입니다. 링크는 아래와 같습니다.

https://github.com/kudoleh/iOS-Clean-Architecture-MVVM

진행한 프로젝트 링크는 아래와 같습니다.

https://github.com/panther222128/CombineWithUIKit-MVVMC

@Published

여러 가지 퍼블리셔 중 어떤 것을 사용해야 하는지 기준이 없었습니다. 먼저 @Published부터 살펴봤습니다. 프로퍼티 래퍼인 @Published는 프로토콜을 통한 접근이 불가능합니다. 아래와 같이 구현하면 '프로토콜 내부에 정의된 속성은 래퍼를 가질 수 없다'는 메시지가 나옵니다.

protocol MusicVideosViewModel: MusicVideoDataSource {
    @Published var published: String { get } // 프로토콜 내부에 정의된 속성은 래퍼를 가질 수 없음
}

프로토콜을 아래처럼 다시 바꿔주는 경우도 확인해보겠습니다.

protocol MusicVideosViewModel: MusicVideoDataSource {
    var published: String { get }
}

그리고 아래처럼 뷰 컨트롤러에서 접근하려고 하면 MusicVideosViewModelpublished 속성을 갖지 않는다고 합니다.

final class MusicVideoSearchViewController {

    private var viewModel: MusicVideoViewModel!

    override func viewDidLoad() {
        viewModel.$published // viewModel이 published 속성을 갖지 않음
    }

}

프로젝트에서 프로토콜을 통해 구현함으로써 테스트가 용이한 형태로 진행하고 있으므로 다른 퍼블리셔를 사용하는 편이 적합하다고 판단했습니다. 실제로 프로토콜에서 구현이 불가능하다는 내용을 확인하기도 했습니다. 다음 내용에서 살펴볼 수 있습니다.

@Published와 CurrentValueSubject

스택 오버플로에서 둘 사이의 차이를 묻는 글이 있었습니다. 링크는 아래와 같습니다.

https://stackoverflow.com/questions/58676249/difference-between-currentvaluesubject-and-published

CurrentValueSubject의 공식 문서 및 번역 링크는 아래를 참고하실 수 있습니다.

https://developer.apple.com/documentation/combine/currentvaluesubject
https://velog.io/@horus222128/CurrentValueSubject

채택된 글의 댓글에서 CurrentValueSubject의 사용 시 이점은 프로토콜에 선언할 수 있다는 내용이 보입니다. 또한, 둘은 유사하면서도 @PublishedwillSet에서 실행되고, CurrentValueSubjectdidSet에서 실행된다고 합니다.

아래에 채택되지 않은 답변을 보면 CurrentValueSubjectObservableObject 내부에서 사용될 때 objectWillChange.send()를 실행하지 않는다고 합니다. 반면에 @PublishedObservableObject 내부에서 사용될 때 objectWillChange.send()를 실행한다고 합니다. 그 밑에 있는 다른 답변을 보면 스위프트 포럼의 대화를 가져와 @Published는 SwiftUI에서 사용될 의도로 존재하는 것이라는 내용도 보입니다.

CurrentValueSubject와 PassthroughSubject

공식 문서 및 번역 링크를 아래에 남기겠습니다.

CurrentValueSubject
https://developer.apple.com/documentation/combine/currentvaluesubject
https://velog.io/@horus222128/CurrentValueSubject

PassthroughSubject
https://developer.apple.com/documentation/combine/passthroughsubject
https://velog.io/@horus222128/PassthroughSubject-ww7ocee1

둘 모두 Subject이며 Subscriber(이하 구독자)에게 요소를 보낼 수 있습니다. 차이점을 살펴보면 CurrentValueSubject는 초기값을 갖고 있고 최근 값의 버퍼를 유지하는 한 편, PassthroughSubject는 초기값을 갖고 있지도 않고 최근 값의 버퍼를 유지하지도 않습니다. 둘의 차이점을 생각해서 어떻게 활용할지를 생각해야 했습니다. 프로젝트는 아이튠즈 검색 API로 뮤직비디오를 검색하는 것이 내용인데, 네트워크 호출을 통해 보여주는 데이터를 보여줄 때 뷰모델에 있는 속성 타입을 고민해봤습니다. 리스폰스의 형태는 아래와 같습니다.

struct MusicVideosResponseDTO: Decodable {
    let resultCount: Int
    let results: [MusicVideoResponseDTO]
    
    private enum CodingKeys: String, CodingKey {
        case resultCount
        case results
    }
}

MusicVideoResponseDTO는 아래와 같습니다.

struct MusicVideoResponseDTO: Decodable {
    let wrapperType, kind: String
    let artistID, trackID: Int
    let artistName, trackName, trackCensoredName: String
    let artistViewURL, trackViewURL: String
    let previewURL: String?
    let artworkUrl30, artworkUrl60, artworkUrl100: String
    let collectionPrice: Double?
    let trackPrice: Double?
    let releaseDate: String
    let collectionExplicitness, trackExplicitness: String
    let trackTimeMillis: Int?
    let country, currency, primaryGenreName: String

    private enum CodingKeys: String, CodingKey {
        case wrapperType, kind
        case artistID = "artistId"
        case trackID = "trackId"
        case artistName, trackName, trackCensoredName
        case artistViewURL = "artistViewUrl"
        case trackViewURL = "trackViewUrl"
        case previewURL = "previewUrl"
        case artworkUrl30, artworkUrl60, artworkUrl100, collectionPrice, trackPrice, releaseDate, collectionExplicitness, trackExplicitness, trackTimeMillis, country, currency, primaryGenreName
    }
}

도메인 엔티티는 아래와 같습니다.

struct MusicVideos {
    let resultCount: Int
    let results: [MusicVideo]
}

struct MusicVideo {
    let wrapperType, kind: String
    let artistID, trackID: Int
    let artistName, trackName, trackCensoredName: String
    let artistViewURL, trackViewURL: String
    let previewURL: String?
    let artworkUrl30, artworkUrl60, artworkUrl100: String
    let collectionPrice: Double?
    let trackPrice: Double?
    let releaseDate: String
    let collectionExplicitness, trackExplicitness: String
    let trackTimeMillis: Int?
    let country, currency, primaryGenreName: String
}

뷰모델은 MusicVideosViewModel 프로토콜을 따르도록 하고 속성도 정의해야 합니다. 아래와 같이 프로토콜을 구현했습니다.

protocol MusicVideosViewModel: MusicVideoDataSource {
    var error: PassthroughSubject<String, Never> { get }
    var musicVideos: CurrentValueSubject<MusicVideos, Error> { get }
}

뷰모델은 DefaultMusicVideosViewModel로 구현했습니다.

final class DefaultMusicVideosViewModel: MusicVideosViewModel {
    
    var cancelBag: Set<AnyCancellable>
    var error: PassthroughSubject<String, Never>
    var musicVideos: CurrentValueSubject<MusicVideos, Error>
    
    init() {
        self.cancelBag = Set([])
        self.error = PassthroughSubject()
        self.musicVideos = CurrentValueSubject<MusicVideos, Error>(MusicVideos(resultCount: 0, results: []))
    }
    
}

글을 위해 UseCase 및 기타 속성은 지워서 작성하지만 실제 프로젝트는 UseCase가 존재하고, UseCase를 통해 레포지토리로부터 네트워크 요청을 해서 검색어에 해당하는 뮤직비디오 데이터를 가져오도록 하고 있습니다. 가져온 데이터를 musicVideos 속성 값에 할당하는 형태입니다. 그런데 만약 musicVideos의 타입이 지금처럼 CurrentValueSubject가 아니라 PassthroughSubject인 경우를 생각해볼 수 있습니다.

아직 컴바임 프레임워크 사용 경험이 적어 확신을 갖고 있지는 않지만, 테이블뷰 구현 시 dataSource에 보여줄 셀의 수를 반환하는 numberOfRowsInSection 메소드가 어떤 값을 반환해야 한다는 점을 생각해볼 수 있습니다. 초기값을 갖지 않는 PassthroughSubject는 어떠한 값도 반환할 수 없게 됩니다. 프로젝트는 뷰모델이 MusicVideoDataSource 프로토콜을 따르고 있고, 아래처럼 테이블뷰의 셀 수를 반환하고 있습니다.

extension DefaultMusicVideosViewModel: MusicVideoDataSource {
    func numberOfMusicVideos() -> Int {
        return musicVideos.value.resultCount
    }
}

동시에 CurrentValueSubjectvalue를 통해 값에 접근할 수 있지만 PassthroughSubject는 접근할 수 없는 것으로 보입니다. 그런데 오류 처리를 위해 사용할 속성은 PassthroughSubject로 구현했습니다. 앞서 살펴봤던 프로토콜을 다시 보면 확인할 수 있습니다.

protocol MusicVideosViewModel: MusicVideoDataSource {
    var error: PassthroughSubject<String, Never> { get }
    var musicVideos: CurrentValueSubject<MusicVideos, Error> { get }
}

현재 프로젝트는 사용자의 입력이 존재할 때 검색 결과가 비어있거나 네트워크 오류가 발생하는 경우 UIAlertController를 통해 알림을 띄우고 있습니다. error 속성에 값이 들어오면 알림을 바로 띄우게 되는데, 초기값을 갖는 경우 시뮬레이터 실행 시 바로 알림이 나옵니다. 아래와 같이 CurrentValueSubject로 구현해서 확인해봤습니다.

protocol MusicVideosViewModel: MusicVideoDataSource {
    var error: CurrentValueSubject<String, Never> { get }
    var musicVideos: CurrentValueSubject<MusicVideos, Error> { get }
}

final class DefaultMusicVideosViewModel: MusicVideosViewModel {
    
    var cancelBag: Set<AnyCancellable>
    var error: CurrentValueSubject<String, Never>
    var musicVideos: CurrentValueSubject<MusicVideos, Error>
    
    init() {
        self.cancelBag = Set([])
        self.error = CurrentValueSubject<String, Never>("Error")
        self.musicVideos = CurrentValueSubject<MusicVideos, Error>(MusicVideos(resultCount: 0, results: []))
    }
    
}

URLSession.DataTaskPublisher와 오류 처리

여러 가지 구현 예시를 보던 중 URLSession.DataTaskPublisher를 반환하는 메소드를 봤습니다. 공식 문서를 보면 Foundation 하위에 'URL Loading System' 카테고리 내에서 소개하고 있습니다. 간단하게 설명하면 URL을 통해 'URL session data task'를 감싸는 퍼블리셔를 반환한다고 합니다. task가 완료되면 데이터를 퍼블리시하는 퍼블리셔이며, 실패하는 경우 오류와 함께 종료된다고 합니다. 공식 문서 링크는 아래에 있습니다.

https://developer.apple.com/documentation/foundation/urlsession/3329708-datataskpublisher

프로젝트를 진행하면서 작성한 코드를 살펴보면 DefaultNetworkSessionManager 객체 내에 아래와 같은 메소드를 구현했습니다.

func request(_ request: URLRequest) throws -> URLSession.DataTaskPublisher {
    guard let url = request.url else { throw NetworkError.urlGeneration }
    return URLSession.shared.dataTaskPublisher(for: url)
}

그리고 DefaultNetworkService에서 아래처럼 다시 위 메소드를 반환하는 메소드를 구현했습니다.

func request(endpoint: any Requestable) throws -> AnyPublisher<Data, Error> {
    do {
        let urlRequest = try endpoint.urlRequest(with: configuration)
        return try networkSessionManager.request(urlRequest)
            .tryMap() { data, response in
                guard let httpResponse = response as? HTTPURLResponse else { throw NetworkError.httpURLResponse }
                if httpResponse.statusCode != 200 {
                    throw NetworkError.error(statusCode: httpResponse.statusCode, data: data)
                }
                return data
            }
            .eraseToAnyPublisher()
            
    } catch {
        throw NetworkError.urlGeneration
    }
}

DefaultDataTransferService 객체에서 다시 위 메소드를 반환하면서 데이터를 디코딩하는 메소드를 구현했습니다.

func request<T: Decodable, E: Requestable>(with endpoint: E) throws -> AnyPublisher<T, Error> where E.Response == T {
    do {
        return try networkService.request(endpoint: endpoint)
            .decode(type: T.self, decoder: endpoint.responseDecoder)
            .eraseToAnyPublisher()
    } catch {
        throw DataTransferError.noResponse
    }
}

흥미로웠던 점은 '디코딩이 실패했을 때 오류 처리를 어떻게 하는가'와 관련이 있습니다. 의도적으로 디코딩 오류가 발생하도록 MusicVideoResponseDTO에 있는 속성 중 한 가지 타입을 다른 것으로 변경하면 아래 이미지처럼 오류 발생 알림이 나타납니다.

디코딩 부분에 별다른 오류 처리를 하지 않았음에도 디코딩 오류라는 것을 알 수 있도록 해줍니다. URLSession.DataTaskPublisher의 공식 문서를 살펴보면 typealias로 URLSession.DataTaskPublisher.Failure가 보이는데, 이에 대한 설명을 보기 위해 다시 링크를 통해 URLSession.DataTaskPublisher.Failure를 보면 아래처럼 URLError 타입임을 알 수 있습니다.

typealias URLSession.DataTaskPublisher.Failure = URLError

https://developer.apple.com/documentation/foundation/urlsession/datataskpublisher
https://developer.apple.com/documentation/foundation/urlsession/datataskpublisher/failure

URLError의 공식 문서를 살펴보면 아래에 여러 가지 오류를 나타내는 변수들이 보입니다.

https://developer.apple.com/documentation/foundation/urlerror

만약 원하는 오류 타입으로 나타나도록 하려면 아래처럼 mapError 오퍼레이터를 활용할 수 있습니다.

func request<T: Decodable, E: Requestable>(with endpoint: E) throws -> AnyPublisher<T, Error> where E.Response == T {
    do {
        return try networkService.request(endpoint: endpoint)
            .decode(type: T.self, decoder: endpoint.responseDecoder)
            .mapError({ error in
                return DataTransferError.decode
            })
            .eraseToAnyPublisher()
    } catch {
        throw DataTransferError.noResponse
    }
}

0개의 댓글