오늘은 넷플릭스 클론 코딩을 하는 시간을 가졌다.
MVVM 구조로 작성하고, RxSwift까지 활용해보며 이해를 높이는 시간이었다.
서버에서 영화 데이터를 받아오는 코드와, 그 데이터를 컬렉션뷰로 출력하는 코드까지 작성한 뒤 잘 나오는지 보기 위해 빌드 해보는데 에러가 발생했다.
핫한 영화인 PopularMovie와 곧 개봉되는 영화 upcomingMovie는 디코딩 실패, 가장 평점이 높은 영화인 topRatedMovie는 fetch 실패가 발생했다.
네트워크 통신은 싱글톤 패턴으로 작성한 뒤 사용하는 거라 다 같은 메서드를 쓰는데,
평점 높은 영화 데이터만 fetch 실패 에러가 발생하는게 이상해서 실제로 데이터가 불려오지 않는 건지 웹브라우저로 테스트 해보았다.
웹브라우저에 각 영화 데이터에 해당하는 url을 작성한 코드에서 복사 붙여넣기 하려고 하는데, 평점 높은 영화를 불러오는 url에 잘못된 걸 확인하였다.
top_rated?api_key=
형태로 돼 있어야 하는데 물음표를 두 개를 쓴 것이다..
이 부분을 바꿔 다시 실행하니 다른 영화 데이터와 마찬가지로 디코딩 에러
로 바뀌었다.
이제 디코딩 에러 문제를 해결해야 하는데, 디코딩 에러는 세 영화 데이터 모두 같은 메서드인 NetworkManager
클래스의 fetch
메서드를 사용하니 이 부분의 디코딩 코드가 문제일 수 있다고 생각했다.
NetworkManager
클래스를 살펴보는데, 디코딩에 대한 부분은 do-catch
문의
do {
let decodedData = try JSONDecoder().decode(T.self, from: data)
obsever(.success(decodedData))
} catch {
obsever(.failure(NetworkError.decodingFail))
}
이 부분인데 딱히 잘못된 것이 없어 보였다.
위 코드들의 if
문과 guard
문을 잘 통과해서 do-catch
까지 왔다면 data
가 잘 저장 되었다는 건데,
그렇게 저장된 데이터를 JSONDecoder()
로 디코딩 하는 것 뿐이니 잘못될 게 없어 보였다.
중간에 print()
를 추가해 데이터가 잘 넘어오는지 확인해 봤는데, 데이터가 실제로 잘 넘오는 것을 알 수 있었다.
이러면 영화 데이터를 저장하기 위해 선언한 구조체 Movie
가 잘못되지 않았나 싶었다.
코드를 보니 Movie
는 잘못이 없는데, 이 Movie를 채택한 MovieResponse
가 잘못 되었다.
실제로 넘어오는 데이터를 살펴보면 영화 목록이 results
라는 항목에 배열로 넘어오고, 이걸 저장하는게 MovieResponse
인데, results
가 아니라 result
라고 s
를 하나 빼먹은 것이다..
MovieResponse
를 고쳐주고, 이 MovieResponse
를 사용하는 부분의 코드들도 고쳐주니 서버에서 영화 데이터를 불러와 출력하는데 성공하였다.
역시 이미 정해진 이름을 가져다 쓸 때는 복사, 붙여넣기 하는 것이 실수를 줄이는 방법이 될 것 같다..
이번 넷플릭스 클론 코딩은 MVVM 구조를 적용하였다.
그 중 메인이 되는 뷰모델인 MainViewModel
은 다음과 같이 작성하였다.
import Foundation
import RxSwift
class MainViewModel {
private let apiKey = ""
private let disposeBag = DisposeBag()
// view가 구독할 subject
let popularMovieSubject = BehaviorSubject(value: [Movie]())
let topRatedMovieSubject = BehaviorSubject(value: [Movie]())
let upcomingMovieSubject = BehaviorSubject(value: [Movie]())
init() {
fetchPopularMovie()
fetchTopRatedMovie()
fetchUpcomingMovie()
}
// 인기 영화 목록 불러오기
func fetchPopularMovie() {
guard let url = URL(string: "https://api.themoviedb.org/3/movie/popular?api_key=\(apiKey)") else {
popularMovieSubject.onError(NetworkError.invalidUrl)
return
}
NetworkManager.shared.fetch(url: url) // Single<T> 반환 (Single은 RxSwift에서 한 번만 데이터를 방출하는 Observable)
.subscribe(onSuccess: { [weak self] (movieResponse: MovieResponse) in // onSuccess와 onFailure가 fetch 메서드를 구독
self?.popularMovieSubject.onNext(movieResponse.results) // 성공시 디코딩된 영화 데이터를 받아 popularMovieSubject에 전달
}, onFailure: { [weak self] error in // 실패시 에러 반환 받음
self?.popularMovieSubject.onError(error) // popularMovieSubject에 에러 전달
}).disposed(by: disposeBag)
}
// 평점이 높은 영화 목록 불러오기
func fetchTopRatedMovie() {
guard let url = URL(string: "https://api.themoviedb.org/3/movie/top_rated?api_key=\(apiKey)") else {
topRatedMovieSubject.onError(NetworkError.invalidUrl)
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (movieResponse: MovieResponse) in
self?.topRatedMovieSubject.onNext(movieResponse.results)
}, onFailure: { [weak self] error in
self?.topRatedMovieSubject.onError(error)
}).disposed(by: disposeBag)
}
// 개봉 예정 영화 목록 불러오기
func fetchUpcomingMovie() {
guard let url = URL(string: "https://api.themoviedb.org/3/movie/upcoming?api_key=\(apiKey)") else {
popularMovieSubject.onError(NetworkError.invalidUrl)
return
}
NetworkManager.shared.fetch(url: url)
.subscribe(onSuccess: { [weak self] (movieResponse: MovieResponse) in
self?.upcomingMovieSubject.onNext(movieResponse.results)
}, onFailure: { [weak self] error in
self?.upcomingMovieSubject.onError(error)
}).disposed(by: disposeBag)
}
// 동영상 키 값을 반환하는 메서드
func fetchTrailerKey(movie: Movie) -> Single<String> {
guard let movieId = movie.id else { return Single.error(NetworkError.dataFetchfail) }
let urlString = "https://api.themoviedb.org/3/movie/\(movieId)/videos?api_key=\(apiKey)"
guard let url = URL(string: urlString) else {
return Single.error(NetworkError.invalidUrl)
}
return NetworkManager.shared.fetch(url: url)
.flatMap{ (videoResponse: VideoReponse) -> Single<String> in
if let trailer = videoResponse.results.first(where: { $0.type == "Trailer" && $0.site == "YouTube" }) {
guard let key = trailer.key else { return Single.error(NetworkError.dataFetchfail) }
return Single.just(key)
} else {
return Single.error(NetworkError.dataFetchfail)
}
}
}
}
이 중 fetchPopularMovie
, fetchTopRatedMovie
, fetchUpcomingMovie
는 같은 코드를 사용하는 메서드들이니 fetchPopularMovie
만 살펴보려고 한다.
fetchPopularMovie
와 관련된 부분만 떼서 다시 보면,
import Foundation
import RxSwift
class MainViewModel {
private let apiKey = ""
private let disposeBag = DisposeBag()
// view가 구독할 subject
let popularMovieSubject = BehaviorSubject(value: [Movie]())
init() {
fetchPopularMovie()
}
// 인기 영화 목록 불러오기
func fetchPopularMovie() {
guard let url = URL(string: "https://api.themoviedb.org/3/movie/popular?api_key=\(apiKey)") else {
popularMovieSubject.onError(NetworkError.invalidUrl)
return
}
NetworkManager.shared.fetch(url: url) // Single<T> 반환 (Single은 RxSwift에서 한 번만 데이터를 방출하는 Observable)
.subscribe(onSuccess: { [weak self] (movieResponse: MovieResponse) in // 반환을 onSuccess와 onFailure로 나눠 처리
self?.popularMovieSubject.onNext(movieResponse.results) // 성공시 디코딩된 영화 데이터를 받아 popularMovieSubject에 전달
}, onFailure: { [weak self] error in // 실패시 에러 반환 받음
self?.popularMovieSubject.onError(error) // popularMovieSubject에 에러 전달
}).disposed(by: disposeBag)
}
위와 같다.
그리고 서버와 통신 후 데이터를 받아와 위 코드에 전달할 NetworkManager
는 다음과 같다.
import Foundation
import RxSwift
enum NetworkError: Error {
case invalidUrl
case dataFetchfail
case decodingFail
}
// 싱글톤 네트워크 매니저
class NetworkManager {
static let shared = NetworkManager()
private init() {}
func fetch<T: Decodable>(url: URL) -> Single<T> {
return Single.create { observer in // 이벤트를 방출할 수 있는 클로저를 정의
let session = URLSession(configuration: .default)
session.dataTask(with: URLRequest(url: url)) { data, response, error in
if let error = error {
observer(.failure(error))
return
}
guard let data = data,
let response = response as? HTTPURLResponse,
(200..<300).contains(response.statusCode) else {
observer(.failure(NetworkError.dataFetchfail))
return
}
do {
let decodedData = try JSONDecoder().decode(T.self, from: data)
observer(.success(decodedData))
} catch {
observer(.failure(NetworkError.decodingFail))
}
}.resume()
return Disposables.create()
}
}
}
RxSwift를 활용해 이벤트를 발생시키고, 이를 구독한 객체가 값을 전달받아 처리하는 흐름이다.
처음엔 누가 누구를 구독하고, 어떻게 처리되는 건지 몰랐는데 강의와 코드를 찬찬히 다시 보고 또 다시 보다보니 조금 감이 오는듯 했다.
NetworkManager.shared.fetch(url: url)
은 Single<T>
를 반환한다.
Single
은 RxSwift에서 제공하는 Observable로, 단 한 번의 성공(onSuccess) 또는 실패(onFailure) 이벤트를 발생시킨다.
여기서는 T
는 MovieResponse
이므로 fetch 메서드는 Single<MovieResponse>
를 반환하는 것이다.
Single<MovieResponse>
는 Observable이므로, 이를 구독(subscribe)하여 데이터 흐름에 반응할 수 있는데,
.subscribe
는 onSuccess
와 onFailure
라는 두 가지 클로저를 정의해 성공 시 작업과 실패 시 작업을 지정한 것이다.
성공시 .onNext(movieResponse.results)
를 호출하여 새로운 데이터를 전달하고, 실패시 .onError(error)
를 호출하여 에러를 전달한다.
popularMovieSubject
는 BehaviorSubject로, 조금 특별하게도 Observable이자 동시에 Observer 역할을 한다.
subscribe
를 통해 데이터 스트림을 구독할 수 있고, onNext
, onError
, onCompleted
등 이벤트를 방출해 구독자에게 데이터를 전달도 할 수 있는 것이다.
이렇게 popularMovieSubject
에 전달된 데이터는 ViewController
에 있는 메서드가 구독하고 있는데,
private func bind() {
viewModel.popularMovieSubject
.observe(on: MainScheduler.instance)
.subscribe(onNext: { [weak self] movies in
self?.popularMoives = movies
self?.collectionView.reloadData()
}, onError: { error in
print("popularMovieSubject 에러 발생 \(error)")
}).disposed(by: disposeBag)
이런 메서드이다.
위와 마찬가지로 popularMovieSubject
를 구독하고 있다가 값이 전달되면 해당 값을 popularMoives
라는 변수에 저장한 뒤 처리해 컬렉션뷰의 셀로 띄우게 되고, 에러가 전달되면 print()
로 에러를 띄우게 된다.
맨 처음 에러가 바로 여기서 출력된 에러이고, 이걸 보고 디코딩 문제인 걸 찾아서 오류를 해결한 것이었다.
Observable → Subscription → Observer의 흐름으로, 다음과 같이 요약할 수 있을 것 같다.
네트워크 요청 (ViewModel):
fetchPopularMovie()
호출 → 네트워크 요청 시작 (NetworkManager.fetch
)popularMovieSubject.onNext(results)
로 방출popularMovieSubject.onError(error)
로 방출데이터 방출 (BehaviorSubject):
popularMovieSubject
는 최신 데이터를 저장하고, 이를 구독자(ViewController)에게 전달데이터 수신 및 UI 업데이트 (ViewController):
bind()
에서 popularMovieSubject
를 구독onNext
클로저 실행 → 데이터 저장 및 UI 업데이트 (collectionView.reloadData
)onError
클로저 실행 → 에러 로그 출력