swift에서 pagination 전략 / limit=20을 유지하세요

임혜정·2024년 8월 9일
0

페이지네이션(pagination)이란

대량의 데이터를 작은 단위(페이지, 스크롤 등)로 나누어서 로드하는 기술이다. 이로서 기대할 수 있는 장점은

  1. 필요한 만큼만 로드되게 해서 앱의 성능,반응성을 높인다
  2. 필요한 만큼의 데이터만 전송하므로 네트워크 사용량을 줄일 수 있다

예제) limit=20을 유지하며 모든 포켓몬의 이미지가 로드될 때 까지 스크롤이 계속 되도록

import UIKit
import RxSwift

class MainViewModel {
    // DisposeBag은 RxSwift에서 메모리 누수 방지하는데 사용
    // 구독한 Observable들을 여기에 넣어두면 뷰모델이 해제될 때 자동으로 구독 해제
    private let disposeBag = DisposeBag()
    
    // 이미지 캐싱용 NSCache. 네트워크 요청 줄이고 성능 향상시키는데 중요
    // NSCache는 메모리 부족할 때 자동으로 일부 객체 제거해서 메모리 관리에 유용
    private var imageCache = NSCache<NSString, UIImage>()
    
    // BehaviorSubject는 RxSwift의 Subject종류. 현재 값을 가지고 있고. 구독 시 최신 값 바로 방출
    // 여기서는 포켓몬 목록을 관리하는데 사용. UI에서 이걸 구독해서 데이터 변경 감지하고 화면 업데이트
    let thumbnailImageSubject = BehaviorSubject(value: [Pokemon]())
    
    // 페이지네이션 구현을 위한 offset. API 요청 시 어디서부터 데이터 가져올지 지정
    // 20개씩 가져오니까 0, 20, 40 ... 이런 식으로 증가
    private var offset = 0
    
    // 중복 요청 방지용 플래그. 데이터 로딩 중에 true로 설정해서 추가 요청 막음
    // 네트워크 요청이 중복되면 불필요한 리소스 낭비되고 버그 발생할 수 있어서 그렇다
    private var isLoading = false
    
    // 포켓몬 데이터 가져오는 함수. 페이지네이션 구현 핵심
    func fetchThumbnail() {
        // 이미 로딩 중이면 함수 종료. 중복 요청 방지
        guard !isLoading else { return }
        isLoading = true
        
        // API 요청 URL 생성. offset 이용해 페이지네이션 구현
        // string interpolation 사용해서 offset 값 URL에 포함. limit=20은 한 번에 20개 가져온다는 의미
        let urlString = "https://pokeapi.co/api/v2/pokemon?limit=20&offset=\(offset)"
        guard let url = URL(string: urlString) else { return }
        
        // NetworkManager를 통해 데이터 fetch. Single은 RxSwift의 Observable 타입
        // 단일 이벤트만 발생시키고 종료되는 Observable. 네트워크 요청에 적합
        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self] (response: PokemonListResponse) in
                // weak self 사용해서 강한 참조 순환 방지.
                guard let self = self else { return }
                
                // 현재 리스트에 새로운 데이터 추가. try?는 에러 무시하고 nil 반환
                let currentList = try? self.thumbnailImageSubject.value()
                let newList = (currentList ?? []) + response.results
                
                // 업데이트된 리스트 발행. onNext로 새 값 방출
                self.thumbnailImageSubject.onNext(newList)
                
                // 오프셋 업데이트. 다음 페이지 준비
                self.offset += response.results.count
                
                // 로딩 상태 해제. 다음 요청 가능하게
                self.isLoading = false
            }, onFailure: { [weak self] error in
                // 에러 발생 시 처리. Subject에 에러 전달하고 로딩 상태 해제
                self?.thumbnailImageSubject.onError(error)
                self?.isLoading = false
            }).disposed(by: disposeBag) // 구독 해제 관리
    }
    
    // 추가 데이터 로드 필요한지 확인하는 함수
    func loadMore(currentIndex: Int) {
        // 현재 인덱스가 offset임계치 도달하면 추가 데이터 로드하는데
        // offset-5처럼 미리 로드 시작하도록 UX 개선
        let thresholdIndex = self.offset - 5
        if currentIndex == thresholdIndex {
            fetchThumbnail()
        }
    }
    
    // 포켓몬 이미지 가져오는 함수. 캐싱 구현으로 효율 높이기
    func getImage(for pokemon: Pokemon) -> Observable<UIImage?> {
        let id = pokemon.id
        // 이미지 URL 생성. 포켓몬 API의 이미지 URL 형식 따름
        let urlString = "https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/\(id).png"
        
        // 캐시에 이미지 있으면 즉시 반환. 네트워크 요청 없이 빠르게 이미지 제공
        if let cachedImage = imageCache.object(forKey: NSString(string: urlString)) {
            return Observable.just(cachedImage)
        }
        
        // URL 생성 실패시 nil 반환. 옵셔널 바인딩으로 안전성 확보
        guard let url = URL(string: urlString) else {
            return Observable.just(nil)
        }
        
        // Observable 생성해서 비동기적으로 이미지 로드
        return Observable.create { [weak self] observer in
            // URLSession으로 네트워크 요청. 비동기적으로 이미지 다운로드
            let task = URLSession.shared.dataTask(with: url) { data, response, error in
                // 옵셔널 바인딩으로 안전하게 언래핑. self, data, image 모두 확인
                guard let self = self, let data = data, let image = UIImage(data: data) else {
                    observer.onNext(nil)
                    observer.onCompleted()
                    return
                }
                
                // 다운로드한 이미지 캐시에 저장. 다음 요청 시 빠르게 제공 가능
                self.imageCache.setObject(image, forKey: NSString(string: urlString))
                observer.onNext(image)
                observer.onCompleted()
            }
            task.resume()
            
            // 구독 취소 시 네트워크 요청도 취소. 리소스 낭비 방지
            return Disposables.create {
                task.cancel()
            }
        }
    }
}

페이지네이션 전략

  1. offset 기반 - 가장 흔한 방식. 위에 구현한 방식이 이거
struct OffsetBasedPagination {
    var offset = 0
    let limit = 20

    mutating func nextPage() -> String {
        let urlString = "https://api.example.com/items?offset=\(offset)&limit=\(limit)"
        offset += limit
        return urlString
    }
}
// 장점: 구현 쉬움. 특정 페이지로 쉽게 이동 가능
// 단점: 데이터 추가/삭제 시 불일치 발생 가능. 대량 데이터에서 비효율적
  1. 커서 기반 - 서버에서 제공하는 커서 값 사용

struct CursorBasedPagination {
    var nextCursor: String?

    mutating func nextPage() -> String {
        let urlString = "https://api.example.com/items?cursor=\(nextCursor ?? "")"
        // API 호출 후 응답에서 nextCursor 업데이트 필요
        return urlString
    }
}
// 장점: 데이터 일관성 유지. 대량 데이터에 효율적
// 단점: 특정 페이지로 직접 이동 어려움

// 실제 사용 예시 (커서 기반)
class PaginationViewModel {
    private var pagination = CursorBasedPagination()
    private let disposeBag = DisposeBag()

    func fetchNextPage() {
        let urlString = pagination.nextPage()
        guard let url = URL(string: urlString) else { return }

        NetworkManager.shared.fetch(url: url)
            .subscribe(onSuccess: { [weak self] (response: ItemResponse) in
                // 데이터 처리
                self?.pagination.nextCursor = response.nextCursor
            }, onFailure: { error in
                // 에러 처리
            })
            .disposed(by: disposeBag)
    }
}
  1. 시간 기반
struct TimeBasedPagination {
    var lastTimestamp: TimeInterval?

    mutating func nextPage() -> String {
        let urlString = "https://api.example.com/items?since=\(lastTimestamp ?? 0)"
        // API 호출 후 응답에서 lastTimestamp 업데이트 필요
        return urlString
    }
}
// 장점: 실시간 데이터에 적합. 새로운 항목 쉽게 가져올 수 있음
// 단점: 과거 데이터 탐색 어려울 수 있음
  1. 키 셋
struct KeySetPagination {
    var lastId: Int?
    var lastValue: String?

    mutating func nextPage() -> String {
        guard let id = lastId, let value = lastValue else {
            return "https://api.example.com/items"
        }
        return "https://api.example.com/items?last_id=\(id)&last_value=\(value)"
        // API 호출 후 응답의 마지막 항목에서 lastId와 lastValue 업데이트 필요
    }
}
// 장점: 데이터베이스 성능 좋음. 대량 데이터에 효율적
// 단점: URL 복잡해질 수 있음. 구현 복잡도 높음
  • 서버 API 설계에 따라 구현 방식 달라질 수 있음
  • 성능과 사용 케이스 고려해서 적절한 전략 선택 필요
  • 복잡한 페이지네이션은 별도 객체로 분리해서 관리하면 좋음
  • 네트워크 요청 실패 시 재시도 로직 구현 고려
profile
오늘 배운걸 까먹었을 미래의 나에게..⭐️

0개의 댓글