프로젝트 기간: 24.12.23 ~ 25.01.06
- UICollectionView를 활용하여 복잡한 레이아웃을 구현합니다.
- RestAPI를 활용합니다.
- RxSwift를 이용하여 Observer Pattern 및 비동기 프로그래밍을 공부합니다.
- MVVM 아키텍처를 활용하여 ViewModel, View를 바인딩합니다.
MVVM
- ViewModel이 Model과 View 사이의 중재자 역할로 의존성 주입이 용이하고 추후 유닛 테스트를 효율적으로 사용할 수 있어 사용하였습니다.
Moya
- 네트워크 요청을 쉽게 만들수 있고 관련 코드가 간결하기 위해 사용하였습니다
Kingfisher
- 이미지 로딩 및 캐싱을 쉽게 처리할 수 있어 사용하였습니다
- Xcode 15.4
- Swift 5
- iOS 16 이상
기본 UI |
---|
✔️ NetworkManager 구현
✔️ MVVM 아키텍처 구현
✔️ CollectionViewCell 탭시 MainVC -> DetailVC로 화면 전환 구현
✔️ DetailVC에서 포켓몬 정보 보여주기 구현
✔️ 무한 스크롤 구현
✔️ Observable, Subject, Relay 차이를 공부하고, ViewModel에서 Relay를 활용
✔️ Kingfisher 활용
아래는 핵심 위주로 작성하였습니다 블로그에서 더보기
1️⃣ ViewModel 분리 및 의존성 관리
배경 및 문제 상황
PokeViewModel 하나로 모든 로직(포켓몬 리스트 및 상세 정보)을 처리하던 구조에서 MainViewModel과 DetailViewModel로 분리하여 각각의 역할을 명확히 하고자 했습니다.
그러나 ViewModel 분리 이후, MainViewController에서 두 개의 ViewModel(MainViewModel, DetailViewModel)을 동시에 주입받게 되면서 아래와 같은 문제가 발생했습니다:
의존성 혼란: MainVC에서 DetailVC로 화면 전환 시 어떤 ViewModel을 주입해야 하는지 모호해졌습니다. SRP 위반 가능성: MainViewController에서 DetailViewModel을 직접 다루는 방식은 단일 책임 원칙(SRP)을 벗어날 위험이 있었습니다.
문제 원인
하나의 ViewController가 두 개 이상의 ViewModel을 사용하는 구조는 의존성 흐름이 복잡해지고 관리가 어려워질 수 있습니다.
MainViewController → MainViewModel → PokeRepository 및 DetailViewController → DetailViewModel → PokeRepository라는 명확한 의존성 흐름을 설계하지 못했습니다.
두 ViewModel의 역할이 구분되었음에도 불구하고 DetailViewModel을 MainViewController에서 직접 참조하여 책임 분리가 제대로 이루어지지 않았습니다.
문제 해결 과정
- 의존성 흐름 재정의
- ViewModel 간 직접 참조를 제거하고, ViewController와 ViewModel 간의 1:1 매칭 구조를 유지했습니다.
- 책임 분리
- MainViewModel: 포켓몬 리스트 로드, 페이징, 새로고침 등 리스트 관련 로직 담당.
- DetailViewModel: 포켓몬 상세 정보 로드만 책임.
- 의존성 주입 흐름 정리
- MainViewController는 MainViewModel만 주입받습니다.
- DetailViewController는 DetailViewModel만 주입받습니다.
- MainViewModel이 createDetailViewModel(for:) 메서드를 통해 필요한 DetailViewModel을 생성하도록 했습니다.
최종 코드
final class MainViewModel {
private let repository: PokeRepositoryProtocol
init(repository: PokeRepositoryProtocol) {
self.repository = repository
}
func createDetailViewModel(for id: Int) -> DetailViewModel {
return DetailViewModel(repository: repository, pokemonID: id)
}
}
final class DetailViewModel {
private let repository: PokeRepositoryProtocol
private let disposeBag = DisposeBag()
private let pokemonID: Int
let pokeDetail = PublishRelay<PokeDetail>()
init(repository: PokeRepositoryProtocol, pokemonID: Int) {
self.repository = repository
self.pokemonID = pokemonID
}
func loadPokeDetail() {
repository.fetchPokeDetail(id: pokemonID)
.observe(on: MainScheduler.instance)
.subscribe(onSuccess: { [weak self] detail in
self?.pokeDetail.accept(detail)
}, onFailure: { error in
print("Error loading poke detail: \(error.localizedDescription)")
})
.disposed(by: disposeBag)
}
}
결론 및 교훈
- MainViewController는 MainViewModel과만 바인딩, DetailViewController는 DetailViewModel과만 바인딩하도록 하여 관리의 용이성을 높였습니다.
- ViewModel 생성은 필요할 때 상위 ViewModel에서 처리하여 ViewController의 의존성을 최소화했습니다.
- ViewController와 ViewModel 간의 1:1 매칭을 유지하여 SRP를 준수하고 코드 복잡도를 줄였습니다
2️⃣ ViewModel의 PublishRelay VS BehaviorRelay
배경 및 고민 상황
저는 처음에 BehaviorRelayfor pokeList및 pokeDetail속성을 사용했습니다.
왜냐하면 넷플릭스 클론 코딩 강의에서는 PublishRelay를 사용하였기때문에 BehaviorRelay과 PublishRelay에 대해 궁금점이 생겼습니다.
문제 해결 과정
BehaviorRelay와 PublishRelay를 먼저 비교해본 후 처음 코드 작성시에는 BehaviorRelay를 사용하였습니다. 그 이유는 pokeList와 같이 뷰에 지속적으로 표시되는 데이터 스트림에 적합하다고 판단하였기 때문입니다. 하지만 단순하게 UI처리만을 위해 PublishRelay 최종 결정하게 되었습니다.
특성 | BehaviorRelay | PublishRelay |
---|---|---|
최신 값 유지 | 최신 값을 유지하고 새로운 구독자에게 전달 | 최신 값은 유지되지 않음, 새로운 이벤트만 전달 |
초기값 설정 | 초기값을 설정해야 함 | 초기값 설정 필요 없음 |
상태 저장 | 지속적으로 사용 가능한 상태 저장 데이터를 유지 | 상태 저장 기능 없음 |
불필요한 업데이트 | 상태 저장 특성으로 불필요한 업데이트 발생 가능 | 불필요한 업데이트 없음 |
이벤트 전송 | 새로운 이벤트와 최신 값을 구독자에게 전달 | 새로운 이벤트만 전송 |
적합한 용도 | 상태 저장 데이터 흐름, 지속적인 상태 관리 필요 | 단방향 데이터 흐름, 최신 데이터만 중요할 때 적합 |
오버헤드 | 상태 저장으로 인해 오버헤드 발생 가능 | 가볍고 최신 상태만 유지하여 오버헤드 방지 |
결론 및 교훈
특정 데이터 흐름의 요구사항에 맞는 적절한 릴레이 유형( PublishRelayvs. BehaviorRelay)을 선택하는 것이 중요하는 것을 깨달았습니다. 각각의 특성을 확인하고 장단점을 알게되면 구현을 단순화하고 성능을 향상시킬 수 있다는 점을 배웠습니다.
RxSwift와 MVVM 패턴을 결합하여 사용하면 UnitTest가 용이하다는 장점이 있는데 Test추가하여 직접 경혐해보고싶다.