서론
서버 데이터를 조회해 앱 화면에 보여주는 것은 앱 개발자라면 자주 하는 일입니다. 이때 앱 개발자들은 주요 작업 외에 아래와 같은 추가 작업들을 진행하기 위해 시간을 소비합니다.
- 한 번 로딩한 콘텐츠는 다음에 로딩 없이 바로 나타나게 하고, 네트워크에 연결되어 있지 않아도 표시되도록 합니다.
- 데이터가 변경되면 통지를 받아 화면이 업데이트되도록 합니다.
- 해당 기능을 모듈화해 간편하게 재사용합니다.
위와 같은 추가 작업 패턴을 모듈화해서 구현해 놓은 Google Android의 Fetcher를 소개하고, 적용기를 공유하려고 합니다.
Fetcher에 대한 내용은 Google Android에서 작성한 앱 아키텍처 가이드에서 찾아보실 수 있습니다. 이 가이드에서는 개념 설명과 함께 Kotlin으로 구현한 간단한 샘플 코드 몇 줄을 소개하고 있습니다(이 코드를 따로 모듈화해서 배포하고 있지는 않습니다). 그런데 이 가이드의 설명이 조금 어렵고 내용이 방대해 한 번에 가볍게 읽기가 어렵습니다. 이에 저는 앱 아키텍처 가이드에서 Fetcher에 대한 내용만 추려서 다른 관점에서 좀 더 쉽게 풀어서 설명해 보려고 합니다. 글의 후반부에서는 이를 iOS 개발에도 적용할 수 있도록 Swift로 구현해 본 것도 소개할 계획이며, 여기에는 저만의 개선 사항도 몇 가지 추가해 놓았습니다.
Fetcher에서 제공하는 기능
Fetcher는 서버에서 데이터를 조회하는 모듈로, 서버에서 데이터를 조회해 로컬 스토리지(데이터베이스나 전역 변수)에 저장하고, View와 로컬 스토리지를 연결(binding)해 주는 역할을 합니다.

로컬 스토리지에 저장한 데이터는 다음에 로딩할 때 화면을 더 빨리 보여주기 위해 사용하며, 네트워크에 연결돼 있지 않아도 콘텐츠를 표시할 수 있게 해 줍니다.
다음은 Fetcher를 활용한 화면과 그렇지 않은 화면을 비교한 그림입니다.
- Fetcher를 활용하면, 한 번 로딩한 콘텐츠는 다음에는 로딩 없이 곧바로 나타납니다.
콘텐츠와 함께 화면에 프로그레스를 보여줌으로써 현재 화면은 이전에 저장된 예전 화면이고 아직 로딩 중이라는 것을 알립니다. 로딩이 끝나면 화면이 다시 한 번 업데이트되면서 프로그레스가 사라집니다.


- Fetcher를 활용하면, 한 번 로딩한 콘텐츠는 네트워크에 연결되어 있지 않아도 표시됩니다.
네트워크에서 콘텐츠를 불러오지 못한 경우에는 로컬 스토리지에 저장해 놓은 콘텐츠를 보여줌과 동시에 화면에 에러 발생을 나타내는 UI를 추가로 표시합니다. 이를 통해 현재 화면은 저장해 놓았던 예전 화면이며, 네트워크에서 데이터를 불러오는 것은 실패했다는 것을 알려줍니다.


Fetcher 작동 방식
Fetcher는 리모트 데이터 소스(remote data source, 네트워크)와 로컬 데이터 소스(local data source, 데이터베이스)를 이용해서 구현합니다. 예를 들어 아래와 같이 뉴스 앱에서 뉴스 목록을 가져오는 화면을 구현한다고 가정하겠습니다.

위 이미지를 동작 순서대로 살펴보겠습니다.
1. View가 생성되면 사용할 Fetcher를 생성하고 데이터를 요청합니다.
2~3. Fetcher는 우선 로컬 스토리지에 저장된 데이터가 있는지 확인합니다. 데이터가 있다면 View로 리턴(없다면 null을 리턴)해서 화면을 그리게 하면서, 동시에 리모트 스토리지에서 데이터를 받아 옵니다(예: HttpClient
을 통해 JSON 데이터를 받음).
4. 네트워크를 통해 받아 온 데이터를 앱의 로컬 스토리지에 저장합니다. 로컬 스토리지는 Realm과 Room, Core Data 등을 사용하는 데이터베이스가 될 수도 있고, 전역 변수를 사용할 수도 있습니다.
5. 로컬 스토리지를 View에 바인딩합니다. 로컬 스토리지에 저장된 데이터가 화면에 그려집니다.
위 과정에서 Fetcher가 데이터를 바로 리턴하지 않고 로컬 스토리지에 저장한 후 View가 로컬 스토리지를 구독(subscribe)하게 하는데요. 이렇게 하면 다른 컴포넌트를 통해 데이터가 업데이트되더라도 동일한 데이터 소스를 바라보고 있기 때문에 변경된 내용이 View에 통지되고 화면이 업데이트됩니다.

Swift로 Fetcher 구현하기
이제 RxSwift를 이용해 Fetcher를 구현해 보겠습니다. Fetcher 설명에 집중하기 위해 메모리 관리나 외부 라이브러리 관련 코드는 생략하거나 간소화했습니다.
준비물
뉴스 앱을 만든다고 가정하겠습니다. 먼저 기사 목록을 나타내는 ArticleList
모델을 정의합니다. ArticleList
는 서버에서 받아오는 데이터와 매핑되며 로컬 DB에 저장할 수 있고, 뉴스 목록을 보여주는 화면에도 사용할 수 있습니다.
// 기사 목록
class ArticleList {
var article: [Article]
...

리모트 스토리지로 사용할 HttpClient
를 생성합니다. RxAlamofire 같은 것을 이용하면 Rx.Single을 반환하는 함수를 만들 수 있습니다.
class HttpClient {
// RxAlamofire 등을 이용해 네트워크를 통해 Single<ArticleList> 받아 오는 기능을 구현합니다.
func requestArticleList() -> Single<ArticleList> {
...
로컬 스토리지로 사용할 DBClient
를 구현합니다. DB 업데이트를 구독(subscribe)할 수 있도록 Rx.Observable을 리턴해야 합니다. Realm과 Room, Core Data 등을 이용하면 쉽게 구현할 수 있습니다. 단순히 전역 변수를 사용하려면 Rx.BehaviorRelay 등을 사용하셔도 됩니다.
class DBClient {
// ArticleList에 대한 구독이 가능한 Observable을 리턴합니다.
func getArticleListObservable() -> Observable<ArticleList> {
...
}
// ArticleList를 가져오는 쿼리입니다.
func getArticleList() -> ArticleList {
...
}
// ArticleList를 DB에 기록합니다.
func update(_ articleList: ArticleList) {
...
}
}
Fetcher 사용 코드
이제 뉴스 리스트를 보여주는 NewsListView
화면을 만들겠습니다. 아래는 NewsListView
에서 Fetcher를 생성해 사용하는 코드의 일부입니다.
class NewsListView: UIView {
func bind() {
// 1. fetcher 생성
let fetcher = Fetcher<ArticleList>()
// 2. fetcher에 대한 config 진행
fetcher.onRemoteRx = { httpClient.requestArticleList() }
fetcher.onLocalRx = { dbClient.getArticleListObservable() }
fetcher.onLocal = { dbClient.getArticleList() }
fetcher.onUpdateLocal = { dbClient.update($0) }
// 3. fetcher를 작동시키고 데이터를 구독합니다.
fetcher.fetch { status, data in
// data로 화면 그리기
rebuildView(data)
// loading 화면 보이기
showLoading(status == .loading)
// error 화면 보이기
showError(status == .error)
}.disposed(by: disposeAll)
}
}
// Fetcher.fetch에서 리턴하는 status는 아래 3가지 값을 가집니다.
enum Status {
case loading
case error
case success
}
NewsListView
를 생성한 뒤 다음과 같은 순서로 작업을 진행합니다.
- Fetcher를 생성합니다.
- 리모트 스토리지와 로컬 스토리지를 연결합니다. 위 준비물 섹션에서 생성한
HttpClient
와DBClient
를 연결하는 작업을 하면 됩니다. - Fetcher를 작동하고,
status
와data
를 구독(subscribe)합니다.
status
는 loading
, error
, success
중 한 가지 값을 가집니다. 계속 업데이트되므로 값을 받을 때마다 적절한 화면을 보여줍니다. data
는 ArticleList
입니다. 이 값도 계속 업데이트되므로 값을 받을 때마다 화면을 업데이트합니다.

fetch
작업이 끝났어도 계속 구독(subscribe)해야 합니다. 이를 통해 다른 화면에서 데이터를 fetch
해서 최신 데이터로 업데이트한다거나, 특정 기사를 즐겨찾기해서 로컬 스토리지가 변경될 경우에 별다른 처리 없이 화면이 업데이트되게 합니다.

추가로 View가 현재 화면에 보이는 동안만 구독(subscribe)한다면 리소스를 좀 더 효율적으로 사용할 수 있습니다.
class NewsListViewController: UIViewController {
let disposeDisappear = DisposeBag()
override func viewWillAppear(_ animated: Bool) {
...
fetcher.fetch { status, data in
...
}.disposed(by: disposeDisappear)
}
override func viewWillDisappear(_ animated: Bool) {
disposeDisappear = DisposeBag()
}
}
Fetcher 구현 코드
Fetcher는 모든 데이터를 다룰 수 있도록 generic
을 이용해 구현합니다. 아래는 Fetcher 클래스의 내부입니다.
class Fetcher<T> {
// 1. remote storage
var onRemoteRx: (() -> Single<T>)
// 2. local storage
var onLocalRx: (() -> Observable<T>)
var onLocal: (() -> T)
var onUpdateLocal: ((T) -> Void)
func fetch(_ onNext: (Status, T) -> Void) -> Disposable {
let disposeAll = DisposeBag()
// 3. 저장된 데이터와 함께, loading 시작을 알립니다.
onNext(.loading, onLocal())
// 4. remote 데이터 가져오기
onRemoteRx().subscribe(onSuccess: { data in
// 5. local storage를 업데이트합니다.
onUpdateLocal(data)
// 6. local storage를 구독하면서 데이터를 중계(relay)합니다.
onLocalRx()
.subscribe(onNext: { onNext(.success, $0)})
.disposed(by: disposeAll)
}, onError: { error in
// 7. 저장된 데이터와 함께, error 발생을 알립니다.
onNext(.error, onLocal())
}).disposed(by: disposeAll)
return disposeAll
}
}
위 코드를 주석에 달아 놓은 번호 순서대로 설명하겠습니다.
1~2. 각 Fetcher는 멤버 변수로 리모트 스토리지와 로컬 스토리지가 있습니다.
3. fetch()
명령을 받으면 로컬 스토리지에 저장된 데이터를 리턴하면서 동시에 로딩 시작을 알립니다.

4~5. 리모트 스토리지에서 데이터를 받아와 로컬 스토리지에 저장합니다.

6. 로컬 스토리지를 중계(relay)합니다.

7. 리모트 스토리지에서 값을 읽어오다가 에러가 발생하면 캐시해 놓은 데이터와 함께 에러를 전달합니다.
맺음말
반복되는 기능 패턴을 찾아 모듈화하는 것에는 여러 가지 이점이 있습니다. 몇 가지 모듈만 파악하면 소스 코드 전체를 더 쉽게 파악할 수 있게 되며, 모듈 단위로 테스트를 해 놓으면 전체 소스 코드를 테스트할 때 테스트 분량을 줄일 수 있습니다. Fetcher를 알고 난 후 최근 2년간 메일 앱과 채팅 앱을 포함해 여러 Android와 iOS 앱들을 개발하면서 다음과 같이 실무에 적용해 왔습니다.
- 사용자에게 로딩 화면 대신 캐시해 놓은 콘텐츠를 보여줌으로써 사용자 경험을 높일 수 있었습니다.
- 비행기 안이나 해외 등 네트워크에 연결하기 어려운 상황에서도 사용자가 저장된 메일을 다시 열어 보고 채팅 내역을 검색할 수 있게 되었습니다.
- 반복되는 소스 코드를 제거하면서 유지 보수 부담도 그만큼 줄일 수 있었습니다.
이번 글에서는 여러 패턴 중 하나인 Fetcher를 소개해 드렸습니다. 제 글이 앱을 개발하면서 여러 가지 문제에 부딪히는 개발자분들에게 또 하나의 선택지를 소개해 드린 글이 되었으면 합니다. 긴 글 읽어주셔서 감사합니다.
참고 자료
https://developer.android.com/topic/libraries/architecture/guide.html