본문 바로가기

Project/MovieClip

✅ MVVM + Combine로 설계한 검색에서 번역 기능 적용하는 최적 방법

🤔 번역 기능은 어디에 구현해야하나?

현재 검색 결과의 소개(overview)가 영어로 표시되고 있기 때문에
이를 Google Translate API를 사용하여 한국어로 변환하려고 함 

 

🔷 방법

1️⃣ SearchViewModel 에서 overview를 번역 후 @Publised 에 저장 

2️⃣ SearchResultCell 에서 configure(with: ) 시점에 번역 후 실행

🔷 방법 비교

방법 장점 단점
SearchViewModel에서 미리 번역 ✅ ViewModel에서 모든 데이터 관리 
✅ 데이터 변경 시 UI 자동 업데이트
❌ 검색 결과가 많으면 API 요청이 많아짐
❌ 한 번의 API 요청 지연이 전체 UI 업데이테 영향
SearchResultCell에서 번역 실행 ✅ 필요한 경우에만 번역 요청 
✅ UI 렌더링 속도 향상 
❌ 비동기 API 요청으로 인해 셀의 UI 업데이트 시점이 다를 수 있음

 

가장 최적의 해결 방법

1️⃣ SearchViewModel에서 overview 번역을 관리하지만, 비동기로 요청하여 UI가 멈추지 않도록 처리
2️⃣ configure(with:)에서 번역된 결과를 확인하고 즉시 업데이트

 

 

SearchViewModel에서 번역 실행 (비동기 처리)

class SearchViewModel: ObservableObject {
    
    ...
    
    @Published var translatedMovieOverviews: [Int: String] = [:]  // 영화 overview 번역 저장
    @Published var translatedTVOverviews: [Int: String] = [:]      // TV overview 번역 저장
    
    // MARK: - 검색 실행
    func search(query: String) {
        currentQuery = query
        resetState()
        
        Task {
            do {
                let results = try await searchService.searchAll(with: query, page: 1)
                DispatchQueue.main.async {
                    self.movies = results.movies.results
                    self.tvShows = results.tvShows.results
                    self.people = results.people.results
                    
                    self.totalMoviesCount = results.movies.totalResults
                    self.totalTVShowsCount = results.tvShows.totalResults
                    self.totalPeopleCount = results.people.totalResults
                    
                    self.translateOverviews(for: .movie(self.movies))
                    self.translateOverviews(for: .tv(self.tvShows))
                    
                    self.updateLoadMoreStatus()
                }
            } catch {
                print("❌ 검색 요청 실패: \(error.localizedDescription)")
            }
        }
    }
    
    ...
 
    
    private func translateOverviews(for media: SearchTranslatedItem) {
        switch media {
        case .movie(let movies):
            for movie in movies {
                Task {
                    let translatedText = await GoogleTranslateAPI.translateText(movie.overview ?? "정보 없음 😅")
                    DispatchQueue.main.async {
                        self.translatedMovieOverviews[movie.id] = translatedText
                    }
                }
            }
            
        case .tv(let tvs):
            for tv in tvs {
                Task {
                    let translatedText = await GoogleTranslateAPI.translateText(tv.overview ?? "정보 없음 😅")
                    DispatchQueue.main.async {
                        self.translatedTVOverviews[tv.id] = translatedText
                    }
                }
            }
        }
    }
}

 

SearchResultCell에서 번역된 데이터 적용

class SearchResultCell: UICollectionViewCell, SelfConfiguringSearchCell {
    
    
    // MARK: - Variable
    static var reuseIdentifier: String = "SearchResultCell"
    private var viewModel: SearchViewModel?
    private var cancellable = Set<AnyCancellable>()
    
    ...
    
    // MARK: - Function
    func setViewModel(_ viewModel: SearchViewModel) {
        self.viewModel = viewModel
    }
    
    
    func configure(with data: SearchItem) {
        switch data {
        case .movie(let movie):
            ...
            
            if let translatedOverview = viewModel?.translatedMovieOverviews[movie.id] {
                overviewLabel.text = translatedOverview
            } else {
                viewModel?.$translatedMovieOverviews
                    .receive(on: DispatchQueue.main)
                    .sink { [weak self] translatedDict in
                        if let translatedText = translatedDict[movie.id] {
                            self?.overviewLabel.text = translatedText
                        }
                    }
                    .store(in: &cancellable)
            }
            
            ...
            
        case .tv(let tv):
            
            ...
            if let translatedOverview = viewModel?.translatedTVOverviews[tv.id] {
                overviewLabel.text = translatedOverview
            } else {
                viewModel?.$translatedTVOverviews
                    .receive(on: DispatchQueue.main)
                    .sink { [weak self] translatedDict in
                        if let translatedText = translatedDict[tv.id] {
                            self?.overviewLabel.text = translatedText
                        }
                    }
                    .store(in: &cancellable)
            }
            ...
           
    }
}

 

📍 if let ~ else 구문에서 viewModel.$translatedMovieOverviews를 구독하는 이유

if let translatedOverview = viewModel.translatedMovieOverviews[movie.id] {
    overviewLabel.text = translatedOverview
} else {
    viewModel.$translatedMovieOverviews
        .receive(on: DispatchQueue.main)
        .sink { [weak self] translatedDict in
            if let translatedText = translatedDict[movie.id] {
                self?.overviewLabel.text = translatedText
            }
        }
        .store(in: &cancellables)
}

 

1. 이 코드의 역할

1️⃣ 먼저 viewModel.translatedMovieOverviews에서 해당 영화 ID의 번역된 값이 있는지 확인

  • 이미 번역된 텍스트가 있으면 즉시 overviewLabel에 적용 (UI 즉각 업데이트)

2️⃣ 없다면 viewModel.$translatedMovieOverviews를 구독하여 값이 변경될 때 업데이트하도록 설정

  • 번역이 완료되면 자동으로 overviewLabel이 업데이트되도록 비동기 처리

 

📍if let 문이 필요한 이유

🔹 번역이 이미 완료된 경우

👉 즉시 번역된 텍스트를 사용하여 UI 업데이트

if let translatedOverview = viewModel.translatedMovieOverviews[movie.id] {
    overviewLabel.text = translatedOverview
}

 

📌 이렇게 하면, 네트워크 요청 없이 빠르게 UI를 업데이트할 수 있음.

 

🔹 번역이 아직 완료되지 않은 경우

👉 Combine을 사용하여 번역된 텍스트가 도착할 때 overviewLabel을 업데이트

else {
    viewModel.$translatedMovieOverviews
        .receive(on: DispatchQueue.main)
        .sink { [weak self] translatedDict in
            if let translatedText = translatedDict[movie.id] {
                self?.overviewLabel.text = translatedText
            }
        }
        .store(in: &cancellables)
}

 

📌 이렇게 하면, translatedMovieOverviews가 변경될 때마다 UI가 자동으로 업데이트됨.
📌 즉, 비동기적으로 API 응답을 기다리고 있음을 의미.

 

 

📍만약 if let 없이 바로 viewModel.$translatedMovieOverviews를 구독하면?

viewModel.$translatedMovieOverviews
    .receive(on: DispatchQueue.main)
    .sink { [weak self] translatedDict in
        if let translatedText = translatedDict[movie.id] {
            self?.overviewLabel.text = translatedText
        }
    }
    .store(in: &cancellables)

🚨 문제점:

  • 이미 번역된 데이터가 있음에도 불필요하게 구독하게 됨
  • 즉각적으로 UI 업데이트하지 못하고 비동기 처리가 필요

👉 이렇게 하면 불필요한 구독이 발생하고, UI 업데이트 속도가 느려질 수 있음.