오늘은 이런 뷰를 만들고자 하였다.

SearchBar와 TableView 하나씩 있는 간단한 화면이다.
하지만 하나의 TableView로, SearchBar에 검색 문구가 없을 땐 CoreData에 저장된 지역 목록을 불러와 간단한 날씨 데이터를 띄우도록 하고,
검색 문구가 있을 땐 API 통신을 통해 해당 문구가 포함된 주소 데이터를 띄워야 해서 생각보다 간단하진 않았다.
이번 팀프로젝트는 MVVM 아키텍처와 Input/Output 구조, RxSwift를 사용하고 있었으니 이 부분을 고려해서 작성해보기로 하였다.
View에는 ViewController와 TableVIewCell, Model에는 CoreData와 API 통신 코드가 해당된다고 생각했고, ViewModel이 중간에서 SearchBar의 값이 있는지 없는지에 따라 CoreData 혹은 API 통신을 통해 데이터를 불러오도록 하면 될 것 같았다.
import Foundation
import RxSwift
import RxCocoa
import UIKit
// 검색어가 없을 때: CoreDataStack.fetchLocationPointList()를 사용해서 CoreData가져오기
// 검색어가 있을 때: AddressNetworkManager.shared.fetchData()를 사용해서 API 호출 데이터 가져오기
enum WeatherCellType {
case coreData(locationName: String, temperature: Double?, icon: UIImage?)
case searchResult(locationName: String)
}
struct WeatherCellData {
let cellType: WeatherCellType
}
class SearchLocationViewModel {
struct Input {
let searchText: Observable<String>
}
// 하나의 테이블 뷰를 활용할 예정이라 하나의 Output을 사용
struct Output {
let tableViewData: Driver<[WeatherCellData]>
}
private let weatherRelay = PublishRelay<[WeatherCellData]>()
private let disposeBag = DisposeBag()
func transform(_ input: Input) -> Output {
input.searchText
.debounce(.milliseconds(100), scheduler: MainScheduler.instance)
.distinctUntilChanged()
.flatMapLatest { query -> Observable<[WeatherCellData]> in
if query.isEmpty {
// coreData에서 데이터 가져오기
let coreDataList = CoreDataStack.shared.fetchLocationPointList()
let cells = coreDataList.map { point in
WeatherCellData(
cellType: .coreData(
locationName: point.regionName,
temperature: nil,
icon: nil
)
)
}
return Observable.just(cells)
} else {
// addressList 초기화
AddressNameInfo.shared.clearAddresses()
// api에서 데이터 가져오기
return Observable<[WeatherCellData]>.create { observer in
AddressNetworkManager.shared.fetchAddressData(query) {
let addressList = AddressNameInfo.shared.addressList.map { addressData in
WeatherCellData(cellType: .searchResult(locationName: addressData.addressName))
}
observer.onNext(addressList)
observer.onCompleted()
}
return Disposables.create()
}
}
}
.bind(to: weatherRelay)
.disposed(by: disposeBag)
return Output(tableViewData: weatherRelay.asDriver(onErrorJustReturn: []))
}
}
이런 식으로, ViewModel을 먼저 작성해 주었다.
Input은 SearchBar의 입력이기 때문에 Observable<String> 타입의 searchText 하나만 정의해 두었고,
Output은 하나의 테이블 뷰를 사용할 것이기 때문에 WeatherCellData 라는 커스텀 타입 하나만 정의해 두었다.
CoreData와 API 데이터를 구분하여 각각 다른 스트림으로 관리하기보다는, ViewModel에서 하나의 통합된 스트림으로 처리하여 View에서는 tableViewData 하나의 스트림만 구독하면 돼서 더 간단할 것이라고 생각했다.
이를 위해 transform 메서드 안에서 input인 SearchBar의 텍스트 입력 값을 가공해, if query.isEmpty {} 를 통해 검색 중인지 아닌지 판단 후 적절한 Model에 접근하도록 하였다.
각각의 Model에 해당하는 coreData와 검색 api는 팀원들이 작성해준 메서드가 있어서 가져다 쓰는 식으로 구현하였다.

검색이 되긴 하는데, 테이블에 데이터가 단 하나만 들어가는 문제가 발생했다.
print()를 사용해 api 데이터를 확인해보면, “구로”를 검색했을 때 “서울 구로구”에 이어 “서울 구로1동”부터 “서울 구로5동”까지 나오는 걸 볼 수 있는데, 테이블 뷰에는 맨 위 데이터 하나만 나온 것이다.
api 통신으로 지역 목록을 검색하는 네트워크 통신 코드는 다음과 같았다.
func fetchAddressData(_ inputData: String, completion: @escaping () -> Void) {
guard let url = URL(string: "https://dapi.kakao.com/v2/local/search/address.json?query=\(inputData)") else {
print("url 빌드 오류")
return
}
let header: HTTPHeaders = ["Authorization": "KakaoAK 430b247857c9b16b87d3f1a7a31d5888"]
fetchData(url, header)
.subscribe { (event: SingleEvent<AddressModel<String>>) in
switch event {
case .success(let data):
print("\(data)")
for info in data.documents {
AddressNameInfo.shared.update(
addressName: info.addressName,
lat: info.lat,
lon: info.lon
)
completion() // 반복문 안에 위치
}
case .failure(let error):
print("\(error.localizedDescription)")
}
}.disposed(by: disposeBag)
}
사용자가 입력한 검색어와 일치하는 지역 데이터를 카카오 API에서 불러오고, 이를 AddressNameInfo라는 객체에 저장한 후 completion()으로 알림을 보내 테이블 뷰를 업데이트하려는 코드이다.
fetchData 를 호출해 주어진 URL과 헤더를 사용해 API 요청을 보내고, 응답 데이터를 AddressModel<String> 타입으로 파싱하고 이를 SingleEvent로 반환한다.
응답 성공 시(.success), data.documents에 담긴 각 지역 데이터를 순회하며 AddressNameInfo.shared.update를 호출해 공유 객체에 데이터를 저장하고, 작업이 완료될 때마다 completion()을 호출하는 것이 원래 의도였을 것이다.
completion()이 반복문 내부에 위치해 있어, data.documents에 포함된 각 데이터를 저장한 후 매번 호출하게 되었다.
이렇게 호출된 completion()은 테이블 뷰의 데이터 바인딩 로직을 즉시 트리거하여, 반복적으로 테이블 뷰를 업데이트 하고, 결과적으로 테이블 뷰는 중간중간 데이터가 덜 채워진 상태로도 여러 번 갱신하게 되었다.
이로 인해 의도와 다른 동작을 하는 것은 물론, 매 반복마다 UI 갱신이 이루어져 오버헤드도 발생하게 되었을 것이다.
func fetchAddressData(_ inputData: String, completion: @escaping () -> Void) {
guard let url = URL(string: "https://dapi.kakao.com/v2/local/search/address.json?query=\(inputData)") else {
print("url 빌드 오류")
return
}
let header: HTTPHeaders = ["Authorization": "KakaoAK 430b247857c9b16b87d3f1a7a31d5888"]
fetchData(url, header)
.subscribe { (event: SingleEvent<AddressModel<String>>) in
switch event {
case .success(let data):
print("\(data)")
for info in data.documents {
AddressNameInfo.shared.update(
addressName: info.addressName,
lat: info.lat,
lon: info.lon
)
}
completion() // 반복문 밖으로 이동
case .failure(let error):
print("\(error.localizedDescription)")
}
}.disposed(by: disposeBag)
}
completion()을 반복문 밖으로 이동함으로써 data.documents의 모든 데이터를 처리한 후, 마지막에 한 번만 completion()을 호출 할 수 있게 되었다.

이제 테이블 뷰는 데이터가 완전히 준비된 상태에서 한 번만 갱신 가능하게 되었다.
테이블 뷰의 데이터 반영이 의도대로 동작하고, UI 업데이트도 마지막에 한 번만 하기 때문에 좀 더 효율적이게 되었다.
괄호 하나 차이의 사소한 실수인데, 그래서 더 발견하기 어려웠다..