포켓몬 도감앱 마무리 - Relay와 Kingfisher

maxminseok·2025년 1월 6일
1

RxSwift에서 Relay는 Subject와 마찬가지로 Observable 과 Observer 의 역할을 모두 수행할 수 있다.

다른 점이라면 Relay는 에러나 완료 이벤트를 방출하지 않도록 설계 되었다는 점이다.

따라서 Relay는 UI 이벤트 처리에 주로 사용된다고 한다.

에러나 완료가 UI랑 무슨 상관인가?

UI와 Relay의 특성

UI에서는 지속적으로 사용자와의 상호작용을 처리해야 하므로 에러완료 이벤트로 스트림이 종료되는 일이 없어야 한다.

예를 들어, 버튼 클릭 이벤트는 무한히 발생할 수 있어야 하며, 특정 시점에 에러가 발생하거나 완료 되었다고 끝나면 안된다.

Relay는 스트림 종료에 대한 걱정 없이 계속 데이터를 전달하기에 적합하다.

Subject와 비교한 안정성

Subject에러 이벤트완료 이벤트를 방출할 수 있는데, UI와 관련된 이벤트는 항상 살아 있어야 하며 끊어지면 안 되는 특징이 있다.

반면 Relay는 에러나 완료 이벤트를 방출하지 않으므로 Subject 대비 안정성을 보장하며, UI에서의 사용자 입력 이벤트 처리나 데이터 흐름 관리에 더 안전한 도구인 것이다.

UI 상태와의 연동 용이성

UI는 사용자 입력(버튼 클릭, 스크롤 등)을 트리거로 동작하며, 해당 입력에 따른 상태 변화가 필요하다.

  • 예를 들어, 텍스트 필드 입력을 실시간으로 처리하거나, 버튼 클릭 시 특정 액션을 수행하는 경우

Relay는 이러한 상태 변화 흐름을 간단하고 직관적으로 관리할 수 있도록 돕는다

상황에 따른 Relay

  • PublishRelay: 최신 이벤트만 전달하고, 구독 이후의 이벤트만 받는다.
    • 버튼 클릭, 스크롤 이벤트 등 단발적인 UI 이벤트 처리에 적합
  • BehaviorRelay: 최신 상태를 저장하며, 구독 시 가장 최근 값을 전달한다.
    • 텍스트 필드 입력, 슬라이더 값, 스위치 상태 등 현재 상태를 유지해야 하는 UI에 적합

내 코드엔 어디에 쓸 수 있을까

Relay가 어떤 특징이 있는지 알겠는데 그럼 내 코드엔 어디에 쓸 수 있을까

두 개의 View중에서 imageView와 , 포켓몬 정보를 띄우는 Label은 사용자와 상호작용 하지 않고 단발성으로 서버로부터 데이터를 받은 뒤, 그 데이터를 업데이트 하기만 하면 된다.

따라서 데이터 상태 추적이 필요하지 않으므로 지금처럼 Single로 처리하면 될 것이다.

사용자와 상호작용 하는 부분은 두가지이다.

하나는 컬렉션 뷰의 셀을 탭하는 것과, 다른 하나는 컬렉션 뷰를 스크롤 하는 것이다.

이 부분은 MainViewController에서 처리하고 있는데

extension MainViewController: UICollectionViewDelegate {
    
    // 셀 선택 처리
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        // detailViewController로 이동
        let selectedCell = pokemonData[indexPath.row]
        let detailVC = DetailViewController()
        detailVC.setDetailViewData(selectedCell)
        self.navigationController?.pushViewController(detailVC, animated: true)
    }
    
    // 스크롤 감지 메서드
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let offsetY = scrollView.contentOffset.y // 현재 스크롤된 Y 좌표
        let contentHeight = scrollView.contentSize.height // 스크롤 되어야 하는 컨텐츠의 총 높이
        let height = scrollView.frame.size.height // 화면에 보이는 스크롤뷰의 높이
        
        // 하단에 도달했을 때 새로운 데이터 로드하기
        if offsetY > contentHeight - height - 100 && contentHeight > height {
            mainViewModel.fetchPokemonData(reset: false)
        }
    }
}

이 부분이다.

수정

이것을 다음과 같이 수정하였다.

private func bind() {
    
    // 컬렉션 뷰 셀 선택 이벤트 전달
    collectionView.rx.itemSelected
        .bind(to: mainViewModel.cellSelectedRelay)
        .disposed(by: disposeBag)
    
    // 셀 선택 후 디테일 뷰로 이동
    mainViewModel.cellSelectedRelay
        .subscribe(onNext: { [weak self] indexPath in
            guard let self = self else { return }
            
            guard let selectedData = try? self.mainViewModel.pokemonSubject.value()[indexPath.row] else { return }
            
            let detailVC = DetailViewController()
            detailVC.setDetailViewData(selectedData)
            self.navigationController?.pushViewController(detailVC, animated: true)
        }).disposed(by: disposeBag)
    
    // 스크롤 이벤트 전달
    collectionView.rx.contentOffset
        .bind(to: mainViewModel.scrollRelay)
        .disposed(by: disposeBag)

    // 포켓몬 데이터 바인딩
    mainViewModel.pokemonSubject
        .observe(on: MainScheduler.instance)
        .bind(to: collectionView.rx.items(cellIdentifier: PokemonCell.id, cellType: PokemonCell.self)) { index, data, cell in
            cell.setImage(data)
        }.disposed(by: disposeBag)

    // 데이터 유무 확인하는 subject 바인딩
    mainViewModel.noMoreDataSubject
        .observe(on: MainScheduler.instance)
        .subscribe(onNext: { [weak self] in
            self?.showNoMoreAlert() // 더이상 데이터 없을 때 Alert
            self?.collectionView.bounces = false    // 데이터 없을 떄 스크롤 바운스 효과 비활성화
        }).disposed(by: disposeBag)
    
    // 컬렉션 뷰 델리게이트 설정
    collectionView.rx.setDelegate(self)
        .disposed(by: disposeBag)
}

기존에 있던 bind() 메서드에 Relay를 활용한 부분을 추가하여 셀 선택 이벤트 처리와 스크롤 이벤트 처리를 하도록 하였다.

하나씩 살펴보면,

컬렉션 뷰 셀 선택 이벤트 전달

collectionView.rx.itemSelected
    .bind(to: mainViewModel.cellSelectedRelay)
    .disposed(by: disposeBag)

컬렉션 뷰의 셀이 선택되었을 때, 그 이벤트를 mainViewModelcellSelectedRelay에 바인딩

셀 선택 후 디테일 뷰로 이동

mainViewModel.cellSelectedRelay
    .subscribe(onNext: { [weak self] indexPath in
        guard let self = self else { return }
        
        guard let selectedData = try? self.mainViewModel.pokemonSubject.value()[indexPath.row] else { return }
        
        let detailVC = DetailViewController()
        detailVC.setDetailViewData(selectedData)
        self.navigationController?.pushViewController(detailVC, animated: true)
    }).disposed(by: disposeBag)

cellSelectedRelay에 이벤트가 전달되면, 선택된 셀의 데이터를 가져와 DetailViewController로 이동

스크롤 이벤트 전달

collectionView.rx.contentOffset
    .bind(to: mainViewModel.scrollRelay)
    .disposed(by: disposeBag)

contentOffset를 이용해 컬렉션 뷰의 스크롤 위치 변경 이벤트를 mainViewModelscrollRelay에 바인딩

포켓몬 데이터 바인딩

mainViewModel.pokemonSubject
    .observe(on: MainScheduler.instance)
    .bind(to: collectionView.rx.items(cellIdentifier: PokemonCell.id, cellType: PokemonCell.self)) { index, data, cell in
        cell.setImage(data)
    }.disposed(by: disposeBag)

mainViewModelpokemonSubject에서 발행된 데이터를 컬렉션 뷰의 셀에 바인딩. 각 셀은 포켓몬 데이터를 설정

데이터 유무 확인하는 subject 바인딩

mainViewModel.noMoreDataSubject
    .observe(on: MainScheduler.instance)
    .subscribe(onNext: { [weak self] in
        self?.showNoMoreAlert() // 더 이상 데이터 없을 때 Alert
        self?.collectionView.bounces = false // 데이터 없을 때 스크롤 바운스 효과 비활성화
    }).disposed(by: disposeBag)

데이터가 더 이상 없을 때 사용자가 이를 알 수 있도록 알림을 표시하고, 컬렉션 뷰의 스크롤 바운스를 비활성화

컬렉션 뷰 델리게이트 설정

collectionView.rx.setDelegate(self)
    .disposed(by: disposeBag)

컬렉션 뷰의 델리게이트인 UICollectionViewDelegateRx 방식으로 설정

MainViewModel에 코드 추가

// Relay 추가
let cellSelectedRelay = PublishRelay<IndexPath>()
let scrollRelay = PublishRelay<CGPoint>()

/// 셀 선택 이벤트 처리
func bindCellSelection() {
    cellSelectedRelay
        .subscribe(onNext: { [weak self] indexPath in
            guard let self = self else { return }
            if let pokemonList = try? self.pokemonSubject.value() {
                let selectedData = pokemonList[indexPath.row]
                let detailVC = DetailViewController()
                detailVC.setDetailViewData(selectedData)
            }
        }).disposed(by: disposeBag)
}

/// 스크롤 이벤트 처리
func bindScrollEvent() {
    scrollRelay
        .throttle(.milliseconds(100), scheduler: MainScheduler.instance)
        .subscribe(onNext: { [weak self ] offset in
            self?.handleScroll(offset)
        }).disposed(by: disposeBag)
}

/// 스크롤시 새 데이터 로드
private func handleScroll(_ offset: CGPoint) {
    // 컨텐츠 크기와 컬렉션 뷰 크기를 계산 할 방법 찾기
    fetchPokemonData(reset: false)
}

그리고 이벤트를 전달 받은 MainViewModel에 이벤트 처리를 위한 코드를 추가하였다.


Kingfisher 적용

Kingfisher는 이미지 관련 라이브러리로, 비동기 이미지 다운로드와 캐싱 등을 좀 더 편하게 해주는 라이브러리이다.

라이브러리 설치 후, 스냅킷은 .snp 로 사용했던 것처럼 Kingfisher는 .kf 를 써서 사용하면 된다.

이전에 메인 뷰의 컬렉션 뷰 셀에 이미지를 띄우기 위해 이러한 코드들이 필요했다.

// NetworkManager에 작성

// 포켓몬 이미지 로드 메서드
func fetchImage(_ url: URL) -> Single<UIImage> {
    return Single.create { single in
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            if let error = error {
                single(.failure(error))
                return
            }
            
            guard let data = data, let image = UIImage(data: data) else {
                single(.failure(NetworkError.invalidData))
                return
            }
            
            single(.success(image))
        }
        task.resume()
        return Disposables.create {
            task.cancel()
        }
    }
}
// MainViewModel에 작성

// 포켓몬 이미지를 받아오는 메서드
func fetchPokemonImage(_ pokemonData: PokemonData) -> Single<UIImage> {
    guard let imageUrl = URL(string: "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemonData.id).png") else {
        return Single.error(NetworkError.invalidUrl)
    }
    return NetworkManager.shared.fetchImage(imageUrl)
}
// PokemonCell에 작성

private var disposeBag = DisposeBag()

func setImage(_ pokemonData: PokemonData, _ viewModel: MainViewModel) {
    // 이미지 로딩
    let pokemonId = pokemonData.id
    currentPokemonId = pokemonId // 현재 포켓몬 id 저장
    viewModel.fetchPokemonImage(pokemonData)
        .observe(on: MainScheduler.instance)
        .subscribe(onSuccess: { [weak self] image in
            guard self?.currentPokemonId == pokemonId else { return } // 셀이 재사용 되지 않았는지 확인
            self?.imageView.image = image
        }, onError: { error in
            print("이미지 에러: \(error)")
        }).disposed(by: disposeBag)
}

이렇게 많은 코드가 필요했었다.

단순히 이미지를 불러오는 것 뿐만 아니라, 스크롤 할 때 이미지 재사용 문제 때문에 포켓몬의 id로 검증하는 코드도 필요해서 더 길어졌었다.

여기에 캐싱까지 적용하려고 했으면 더더욱 길어졌을 것이다.

그런데 Kingfisher를 적용하니 허무할 정도로 간단해졌다.

func setImage(_ pokemonData: PokemonData) {
        let url = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(pokemonData.id).png"
        // 이미지 로딩
        if let imageUrl = URL(string: url)  {
            imageView.kf.setImage(with: imageUrl)
        }
    }

url을 하드코딩 한 부분은 과제에서 제공된 url을 사용한 것이기 때문에 이전에도 그대로였다. 따라서 달라진 부분은 단 두 줄이다.

Kingfisher는 내부적으로 이전 이미지 다운로드 작업이 완료되지 않은 상태에서 동일한 이미지 뷰에 새로 요청된 이미지를 설정할 때, 이전 작업을 취소하고 새 작업을 시작한다고 한다.

거기다 기존에 내가 작성하지 않았던 캐싱까지도 포함되어 있어, 겨우 저 두 줄의 코드로 비동기 이미지 로딩과 캐싱, 셀 재사용 문제까지 해결되었다.

private let imageView: UIImageView = {
    let imageView = UIImageView()
    imageView.contentMode = .scaleAspectFill
    imageView.clipsToBounds = true
    imageView.backgroundColor = UIColor.cellBackground
    imageView.layer.cornerRadius = 10
    imageView.kf.indicatorType = .activity // 로딩 인디케이터 추가
    return imageView
}()

그리고 imageView를 정의한 코드에 imageView.kf.indicatorType = .activity 라는 코드도 추가해서, 이미지를 불러오는 중일 때 사용자가 로딩 중임을 알 수 있도록 인디케이터를 추가할 수도 있다.

또, 지금은 setImage(with: ) 만 사용하였는데,

imageView.kf.setImage(
    with: url,
    placeholder: UIImage(systemName: "photo"),  // 이미지 없을 때 기본 이미지 설정
    options: [
        .processor(processor),
        .scaleFactor(UIScreen.main.scale),
        .transition(.fade(1)),  // 애니메이션 효과
        .cacheOriginalImage // 캐시에 다운로드한 이미지가 있으면 가져옴
    ])

위처럼 placeholderoptions의 여러 기능들을 활용할 수도 있고,

imageView.kf.setImage(with: url, completionHandler: { result in
    switch result {
    case .success(let value):
        print("Image loaded: \(value.image)")
    case .failure(let error):
        print("Error: \(error)")
    }
})

이런 식으로 completionHandler 를 사용해 이미지 로딩 후 추가 작업을 할 수도 있다고 한다.

이렇게 Kingfisher는 편리한 라이브러리이지만 사용할 때 신경써야 할 점이 있다. Kingfisher는 자동으로 이미지를 캐싱하여 메모리와 디스크에 저장한 다음 imageView에 보여준다.

따라서 너무 많은 이미지나 큰 이미지를 다룰 때는 메모리 사용량이 급격히 증가할 수 있어 캐시나 이미지 크기를 적절히 조절하는 과정이 필요하다.

자동으로 캐싱 과정을 거치기 때문에 아직 익숙하게 다루지 못하는 지금의 나는 깜빡할 수 있는 부분일 것 같다.

참고한 블로그 : https://velog.io/@minji0801/Swift-Library-Kingfisher

1개의 댓글

comment-user-thumbnail
2025년 1월 6일

해냈군 축하합니다

답글 달기