넷플릭스 클론 코딩

maxminseok·2024년 12월 24일
1
post-thumbnail

오늘은 넷플릭스 클론 코딩을 하는 시간을 가졌다.

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와 RxSwift 이해하기

이번 넷플릭스 클론 코딩은 MVVM 구조를 적용하였다.

그 중 메인이 되는 뷰모델인 MainViewModel은 다음과 같이 작성하였다.

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만 떼서 살펴보기

이 중 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

그리고 서버와 통신 후 데이터를 받아와 위 코드에 전달할 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를 활용해 이벤트를 발생시키고, 이를 구독한 객체가 값을 전달받아 처리하는 흐름이다.


한 줄 한 줄 흐름 이해하기

처음엔 누가 누구를 구독하고, 어떻게 처리되는 건지 몰랐는데 강의와 코드를 찬찬히 다시 보고 또 다시 보다보니 조금 감이 오는듯 했다.

Single

NetworkManager.shared.fetch(url: url)Single<T>를 반환한다.

SingleRxSwift에서 제공하는 Observable로, 단 한 번의 성공(onSuccess) 또는 실패(onFailure) 이벤트를 발생시킨다.

여기서는 TMovieResponse 이므로 fetch 메서드는 Single<MovieResponse> 를 반환하는 것이다.

Observable

Single<MovieResponse>Observable이므로, 이를 구독(subscribe)하여 데이터 흐름에 반응할 수 있는데,

.subscribeonSuccessonFailure라는 두 가지 클로저를 정의해 성공 시 작업실패 시 작업을 지정한 것이다.

성공시 .onNext(movieResponse.results)를 호출하여 새로운 데이터를 전달하고, 실패시 .onError(error)를 호출하여 에러를 전달한다.

BehaviorSubject

popularMovieSubjectBehaviorSubject로, 조금 특별하게도 Observable이자 동시에 Observer 역할을 한다.

subscribe를 통해 데이터 스트림을 구독할 수 있고, onNext, onError, onCompleted 등 이벤트를 방출해 구독자에게 데이터를 전달도 할 수 있는 것이다.


MainViewController

이렇게 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의 흐름으로, 다음과 같이 요약할 수 있을 것 같다.

  1. 네트워크 요청 (ViewModel):

    • fetchPopularMovie() 호출 → 네트워크 요청 시작 (NetworkManager.fetch)
    • 요청 성공 시, 데이터를 popularMovieSubject.onNext(results)로 방출
    • 요청 실패 시, 에러를 popularMovieSubject.onError(error)로 방출
  2. 데이터 방출 (BehaviorSubject):

    • popularMovieSubject는 최신 데이터를 저장하고, 이를 구독자(ViewController)에게 전달
  3. 데이터 수신 및 UI 업데이트 (ViewController):

    • bind()에서 popularMovieSubject를 구독
    • 성공 시, onNext 클로저 실행 → 데이터 저장 및 UI 업데이트 (collectionView.reloadData)
    • 실패 시, onError 클로저 실행 → 에러 로그 출력

0개의 댓글