Android App architecture: Offline first
안드로이드 App architecture: Offline first를 알아보겠습니다.
출처:
https://developer.android.com/topic/architecture/data-layer/offline-first
Internet(인터넷) 연결 없이 핵심 기능을 제공해 주는 것을 offline-first(오프라인 우선)라고 합니다. 이것은 일부 또는 전체의 business(비즈니스) logic(논리)가 가능한 상태입니다.
offline-first 앱을 구상 중이라면 앱 data(데이터)와 business logic을 제공하는 data layer부터 고려해야 합니다. 앱이 만약 data 최신화를 시간 간격으로 외부에서 가져온다면, 네트워크 자료를 불러서 업데이트할 필요가 있습니다.
네트워크가 가능 여부는 항상 보장되지 않습니다. 기기는 보통 통신 불량이거나 느린 네트워크에 연결되어 있을 수 있습니다. 사용자는 아래의 경험을 겪은 적이 있습니다.
• 제한된 인터넷 속도
• 엘리베이터나 터널에서의 연결 끊김
• 와이파이만 가능한 기기
이러한 이유들이 있어도 앱은 동일하게 작동되어야 합니다. 확실하게 제공해 주기 위해 당신의 앱은 offline에서도 정확하게 작동해야 합니다. 아래의 경우를 보시죠.
• 네트워크 연결이 없더라도 사용 가능
• 네트워크에서 data 받기 완료나 실패를 기다리기 전에 내부 데이터를 먼저 보여줌
• 건전지나 data의 상태를 의식하며 최신화하기. 예를 들면 충전 중이거나 와이파이 사용 중에만 최신화하기.
위의 상황을 만족하는 앱을 보통 offline-first 앱이라고 부릅니다.
⊙ Design an offline-first app: Offline-first 앱 구성;
Offline-first 앱을 구상할 때, data layer와 두 개의 main(주요한) 작업들에서 시작해야 합니다.
• Reads: 읽기; 앱의 다른 부분에서 data를 찾아서 사용자에게 화면으로 보여주는 것.
• Writes: 쓰기; user(사용자)의 입력을 찾을 수 있게 저장하는 것.
Data layer에 있는 Reposiitories는 data source를 모으는 역할을 합니다. Offline-first 앱의 경우 반드시 네트워크 접근이 필요 없이 중요한 작업이 가능한 data source를 가지고 있습니다. 중요한 작업에는 data를 읽는 작업도 포함됩니다.
• 메모:
Offline-first 앱에서 최소한 네트워크 없이 읽기 작업이 가능해야 합니다.
⊙ Model data in an offline-first app: Offline-first 앱에서의 Model(모델) data
Offline-first 앱에서는 네트워크에 접근하는 모든 repository는 적어도 2개의 data sources를 가지고 있습니다.
• The local data source: 내부 data source;
• The network data source: 네트워크 data source;
• 메모:
네트워크에 접근하는 repository는 offline-first 앱에서 항상 local data source를 가지고 있습니다.
• The local data source: 내부 data source;
내부 data source는 앱의 표준적인 source of truth를 따릅니다. 내부 data source는 상위 layer의 정보를 포함하지 않습니다. 이것은 연결 상태에 따른 data의 일치성을 보장합니다. 내부 data source는 대부분 내부 disk(디스크)에 영구 저장됩니다. data에 영구저장된다는 것은 아래의 상황을 이야기합니다.
- 관계형 database(데이터베이스)인 Room 같은 구조화된 data sources.
- Protocol buffers를 사용한 Datastore 같은 구조화되지 않은 datasources
- 간단한 파일 형태
• The network data source: 네트워크 data source;
네트워크 data source는 실제 앱의 상태입니다. 내부 data source는 네트워크 data source와 동기화되어야 합니다. data가 최신화되지 않아 있을 수 있는데, 이는 online(온라인)으로 되면 업데이트해야 합니다. 반대로 네트워크 data source가 최신이 아닐 수 있습니다. 이때는 앱이 네트워크 결괏값을 받기 전일 때입니다. domain과 UI layer는 직접적으로 network layer와 연결되어서는 안됩니다. 내부와 네트워크를 연결하는 repository의 역할은 두 개를 연결하여 내부 data source를 최신화 시키는 것입니다.
• Exposing resources: resources 노출;
내부와 네트워크 data sources는 어떻게 그들을 읽고 쓸지 구조가 다를 수 있습니다. 내부 data source를 찾는 일은 빠르고 유연합니다. SQL queries 같이 말이죠. 이와는 다르게, 네트워크 data sources는 느리고 제한되어 있습니다. id로 점차적으로 RESTful(레스트 풀) resources에 접근하는 것처럼 말이죠. 이러한 이유로 각각의 data sources는 그들을 나타낼 수 있는 별개의 것이 필요합니다. 따라서, 내부 data source와 네트워크 data source는 그들만의 models(모델)만을 가지게 됩니다.
아래의 디렉터리 구조는 위의 개념을 시각화한 것입니다. AuthorEntity는 앱 내부 database에 저장된 글쓴이 정보를 나타냅니다. 그리고 NetworkAuthor은 네트워크에서 Serialized(직렬화) 된 글쓴이를 나타냅니다.
자세한 내용의 AuthorEntity와 NetworkAuthor은 다음과 같습니다.
AuthorEntity와 NetworkAuthor을 내부 data layer에 두고, 새로운 제3의 data layer를 만들어서 노출하는 것이 좋습니다. 이는 내부의 작은 변화가 있어도 새로운 data layer를 보호하여 앱의 작동 방식을 지킬 수 있게 해줍니다. 이것은 아래의 코드를 봐주세요.
네트워크 model은 내부 model로 변환할 수 있고, 내부 model은 네트워크 model로 변환할 수 있습니다.
• 메모:
위와 같은 변환 함수는 다른 modules(모듈)에 있는 models를 사용합니다. 그래서 보통 modules들이 강하게 연결되지 않게 하기 위해 사용되는 modules 내부에 만들어야 합니다. 자세한 내용은 modularization guide를 봐주세요.
⊙ Reads: 읽기:
Offline-first 앱에서 Reads(읽기) 작업은 기초적인 작업입니다. 따라서 당신은 반드시 앱이 data를 read 할 수 있게 만들어야 하고, 가능하다면 바로 새로운 data를 보여야 합니다. 이러한 것이 가능한 앱을 reactive(반응형) 앱이라고 합니다. 왜냐하면 그들은 read APIs를 추적 가능한 type(타입)으로 노출합니다.
아래의 있는 코드는 OfflineFirstTopicRepository로 모든 read APIs를 위해 Flows를 반환합니다. 이것은 새로운 data를 받았을 때, 모든 수신부 쪽에 최신화를 시킬 수 있습니다. 다른 말로 하자면, OfflineFirstTopicRepository가 내부 data source의 data가 구 버전일 시, 업데이트를 요청할 수 있게 됩니다. 그러므로 OfflineFirstTopicRepository를 받는 곳은 네트워크 연결이 완료될 시 발생될 data의 변화를 다룰 수 있게 준비해야 합니다. 더 나아가, OfflineFirstTopicRepository는 내부 data source를 직접적으로 읽습니다. 내부 data source를 먼저 최신화한 후, 모든 수신부에 알림을 보냅니다.
• 메모:
Offline-first 앱의 Repository에서의 Read 작업은 반드시 내부 data source를 직접적으로 읽습니다. 모든 업데이트는 내부 data source를 먼저 최신화를 하고 내부 data source는 자신의 수신부에 추적 가능한 형태로 알립니다.
• Error Handling strategies: 오류 관리 방법;
Offline-first 앱에서는 어떤 data source에서 오류가 나오냐에 따라 특별한 방식으로 오류를 관리합니다. 아래의 항목들을 참고하세요.
- Local data source: 내부 data source;
내부 data source에서 reading 중에 오류가 발생할 가능성은 낮아야 합니다. 읽는 작업을 오류로부터 보호하기 위해서는 data를 collecting 하는 곳의 Flows에 catch를 사용해야 합니다.
ViewModel에서 catch를 사용하는 예시입니다.
- 메모:
Catch는 오직 앱이 종료되는 것을 방지합니다. Flow는 정지됩니다. 다시 flow를 실행하고 싶다면 retry 함수를 고려해 보세요.
- Network data source: 네트워크 data source;
네트워크 data source에서 read 작업 중 오류가 발생한다면 heuristic(경험적) 재시도를 해야 합니다. 아래는 보통의 heuristic 경우들입니다.
- Exponential backoff: 재빠른 중단;
Exponential backoff에서는 시간 간격을 증가시키며 성공할 때까지 재시도를 하거나 정지해야 합니다.
Backing off를 사용하는 앱은 다음 항목을 포함해야 합니다.
= 네트워크 data source의 오류를 알려줘야 합니다. 예를 들어, 연결이 되지 않아 오류가 반환된다면 다시 시도를 해야 합니다. 반대로 허용되지 않은 요청이라면 권한을 얻을 때까지 재시도를 하지 말아야 합니다.
= 재시도 최대치를 설정합니다.
- Network connectivity monitoring: 네트워크 연결 관찰;
이 접근법에서는 requests(요청)은 앱이 네트워크 data source에 연결되기 전까지 queue(큐)에 대기됩니다. 연결이 완료되면 request가 발송됩니다. data를 read 하고 내부 data source를 최신화합니다. 안드로이드에서는 이러한 queue를 Room database로 관리할 수 있습니다. 그리고 영구적인 작업은 WorkManager를 사용합니다.
⊙ Writes: 쓰기;
Offline-first에서 read type은 추적 가능한 type입니다. write APIs에서는 비슷하게 suspend functions 같은 asynchronous(비동기) APIs를 사용합니다. 이것은 UI thread(스레드)를 막는 것을 방지합니다. 그리고 오류 관리를 돕습니다. writes는 보통 offline-first 앱에서 network가 변경될 때 일어납니다.
위의 코드에서 선택한 asynchronous API는 Coroutines(코루틴)이고 suspend mothed를 사용합니다.
- Write strategies: 쓰기 전략;
Offline-first 앱에서 data를 writing할 때에는 3가지 전략을 고려할 수 있습니다. 앱에 따라 전략을 선택하여 사용하시기 바랍니다.
- Online-only writes: 오직 온라인에서만 쓰기;
네트워크 변경 시 data를 write 할 때, 성공한다면 내부 data source를 최신화합니다. 그렇지 않다면 예외를 반환하여 적절히 처리합니다.
이 전략은 write가 반드시 실시간으로 일어나야 할 때 사용합니다. 예를 들면, 송금이 있습니다. Writes가 실패할 때, 앱은 사용자에게 write가 실패했다는 것을 알려주거나 또는 write 시도를 막아야 합니다. 이러한 전략을 사용하는 경우 다음과 같은 시나리오가 포함됩니다.
= Write 시, 인터넷 연결이 필요하다면, user에게 write가 가능한 UI를 보여주지 않거나 비활성화해야 합니다.
= User가 끌 수 없는 팝업 메시지를 보여주거나 사용자에게 offline이라는 사실을 알려줄 짧게 보이는 메시지를 보여줍니다.
- Queued writes: 예약된 쓰기;
Write 하고 싶은 Object(객체)가 있다면, queue에 집어넣습니다. exponential back off를 사용한 queue에 넣고 앱이 online이 되면 실행합니다. 안드로이드에서는 영구 작업이 필요한 offline queue는 WorkManger로 사용됩니다.
이러한 상황에서는 이 접근법이 좋습니다.
= data가 네트워크에 write 하는 것이 필수가 아니다.
= write 작업이 실시간일 필요가 없다.
= write가 실패해도 user에게 중요하지 않다.
- Lazy writes: 게으른 쓰기;
내부 data source에 먼저 write를 하고, 편의를 위해 네트워크 queue에 write 알림을 넣습니다. 이 방법은 앱이 online 상태일 때 내부 data source와 네트워크 data source의 충돌이 있는지 확인하는 것이 중요합니다. 다음 영역에서 충돌 해결 방법에서 자세히 다루도록 합니다.
이 접근법은 data가 중요할 때 사용합니다. 예를 들어, offline-first 앱에서 할 일 목록 앱이 있다고 칩시다. user가 할 일을 추가하게 된다면 우선 내부 data sourece에 저장하여 data가 손실되는 것을 막습니다.
- 메모:
Offline-first 앱에서 data를 writing 하는 것은 data를 read 하는 것보다 더 중요합니다. 이유는 충돌의 위험 때문입니다. Offline-first 앱은 offline 일 때, write 작업에서의 offline-first를 고려할 필요가 없습니다.
⊙ Synchronizatino and conflict resolution: 동기화 및 충돌 해결;
Offline-first 앱이 네트워크 연결을 회복하게 되면, 네트워크 data source와 내부 data source를 같게 만들 필요가 있습니다. 이러한 작업을 synchronization(동기화)라고 합니다. 여기에는 synchronize를 위한 두 가지 방법이 있습니다.
• Pull-based synchronization
• Push-based synchroization
• Pull-based synchronization: Pull 기반 동기화;
Pull-based synchronization에서는 네트워크를 통해 최신 data를 받아옵니다. 이것을 위한 일반적인 heuristic은 navigation-based입니다. 이것은 앱이 data를 user에게 보여주기 전에 먼저 fetch(최신화)를 하는 것입니다.
이 접근법은 앱이 짧은 기간 네트워크 연결이 되지 않을 것이 예상될 때 좋습니다. 왜냐하면 data를 새로 받아오는 것이 좋으며, 긴 시간 동안 연결되지 않으면 user는 오래된 정보나 비어있는 화면을 보게 됩니다.
앱이 끝없이 불러오는 목록을 위해 page(페이지) token(토큰)을 사용하는 경우, 이 접근법은 lazily 하게 네트워크에 연결합니다. 그리고 내부 data source에 저장하고 내부 data source를 읽어 user에게 보여줍니다. 네트워크가 없는 상태에서는 내부 data source에만 data를 요청하게 됩니다. 이 패턴은 Jetpack Paging Library의 RemoteMediator API에 사용되었습니다.
pull-based의 장점과 단점을 요약하면 아래와 같습니다.
장점:
- 적용하기 쉽다.
- 사용하지 않는 data는 최신화할 필요가 없다.
단점:
- 큰 data에 사용하기 힘들다. 왜냐하면, 화면에 도착할 때마다 네트워크 연결을 요청하게 됩니다. 이는 불필요한 요청과 변화가 없는 데이터를 항상 요청하게 됩니다. 당신은 이것을 적절한 caching으로 처리할 수 있습니다. 이것은 UI layer에서 cachedIn을 사용하여 할 수 있으며, 네트워크 layer의 HTTP cache를 사용할 수 있습니다.
- 관계형 data를 다루기 쉽지 않습니다. 만약 model이 다른 model의 fetch와 연관된다면 큰 data에서는 더 많은 문제가 발생할 수 있습니다. 게다가 여러 repositories와 상위 model 및 repositories와 연결된 model 간의 결합도가 증가하게 됩니다.
• Push-based synchronization: Push 기반의 동기화;
Push-based synchronization에서 내부 data source는 스스로 최대한 네트워크 data source를 복사하려고 합니다. 첫 실행 시, 선제적으로 baseline(기준점)을 정하고 fetches를 진행합니다. 그 후, server로부터 data가 정체되었다는 알림을 받습니다.
위의 정체 알림 도표에서 앱은 반드시 정체되었다는 알림이 있을 때에만 최신화를 진행합니다. 이 작업은 네트워크 data source와 연결된 Repository에서 처리하며, 완료 시, 내부 data source를 최신화시킵니다. Repository의 data가 추적 가능한 type으로 노출되기 때문에 수신부는 모두 변경된 사실을 알게 됩니다.
이 접근법에서 앱은 특정 시간 동안 네트워크 data source의 의존성이 줄어듭니다. read와 write 모두 offline 일 때에도 가능합니다. 왜냐하면 내부 data source가 최신의 data라는 확신이 있기 때문입니다.
Push-based synchronization의 장점과 단점은 아래와 같습니다.
장점:
- Offline에서도 앱이 지속 가능하다.
- 적은 data만 사용한다. 앱은 변경된 data만 읽을 뿐이다.
- 관계형 data도 잘 작동한다. 각각의 repository는 자신의 model의 data 최신화만 신경 쓰면 된다.
단점:
- 충돌을 회피하기 위한 버전이 표기된 data가 중요하다.
- Write를 synchronization 하기 위한 고려가 필요하다.
- 네트워크 data source는 synchronization을 지원해야 합니다.
• Hybrid synchronization: 혼합 동기화;
몇몇 앱들은 data에 따라 pull과 push가 hybrid(혼합) 형식으로 접근합니다. 예를 들면, 소셜 미디어 앱의 경우 pull-based synchronization을 사용하여 사용자에게 짧은 주기로 새로운 게시글이나 게시글을 팔로우하는 것을 반영합니다. 같은 앱이지만, push-based synchronization을 사용할 수도 있습니다. 이 경우는 signed-in(로그인) 한 사용자의 이름, 프로필 사진 등을 불러올 때 사용합니다.
결론적으로, offline-first synchronization은 제품의 요구사항과 가능한 기술들을 고려하여 결정합니다.
• 메모:
당신의 앱의 synchronization 방식은 앱의 요구에 맞춰서 사용합니다. 그리고 내부와 네트워크 data source의 제한 사항을 고려해야 합니다.
⊙ Conflict resolution: 충돌 해소;
앱이 offline 일 때 writes 한 data와 네트워크 data source의 data와 맞지 않아 충돌이 난다면, 당신은 반드시 synchronization 하기 전에 해결하고 진행해야 합니다.
Conflict(충돌) resolution(해소)는 보통 versioning(버전을 표시)으로 해결합니다. 앱은 변경이 일어난 것을 알기 위해 꾸준히 추적하고 기록해야 합니다. 이것을 가능하게 하려면, metadata(메타데이터, 데이터를 설명하는 데이터)를 네트워크 data source에 보내야 합니다. 네트워크 data source는 명확하게 한 곳에서 관리되어야 합니다. 많은 conflict resolution 방법이 있지만, 앱에 필요한 방식을 사용해야 합니다. 휴대폰 앱의 경우, 보통 last write wins(마지막 입력 승리) 방식을 사용합니다.
• Last write wins: 마지막 입력 승리;
이 접근법은 timestamp라는 metadata를 data에 포함하여 네트워크에 write 합니다. 네트워크 data source가 해당 data를 받게 되면, 현재 상태보다 오래된 data는 버리고 현재 상태보다 새로운 data를 받아들입니다.
위의 그림에서 두 기기는 네트워크 data를 받은 후, offline 상태입니다. Offline 상태에서 두 기기는 data를 write를 하는데, 시간을 추가하여 언제 write 한 것인지를 metadata로 표기합니다. 두 기기가 online이 되어 네트워크 data source와 synchronize가 될 때, 네트워크는 conflict를 해결하는 데, B 기기의 data를 최종적으로 저장합니다. 왜냐하면 B의 data가 가장 나중에 write 되었기 때문입니다.
⊙ WorkManager in offline-first app: Offline-first 앱에서의 WorkManager;
위에서 다룬 read와 write 전략에는 두 가지 사용법이 있습니다.
• Queues: 대기열;
- Reads: 네트워크 연결이 가능할 때까지 defer(지연) 된 reads
- Writes: 네트워크 연결이 가능할 때까지 defer된 writes와 재시도를 위한 requeue(대기열 다시 등록)
• Network connectivity monitors: 네트워크 연결 상태 확인;
- Reads: 앱이 네트워크 연결된 상태와 synchronization을 위한 신호를 사용하여 read queue 실행
- Wirtes: 앱이 네트워크 연결된 상태와 synchronization을 위한 신호를 사용하여 write queue 실행
두 가지 사용 사례는 지속적인 작업인 WorkManager에 대한 예시입니다. 예를 들어 Now in Android 예시 앱에서는 WorkManager는 read queue와 내부 data source의 synchronizing을 위한 네트워크 monitor를 사용합니다. 실행 시, 앱은 다음과 같은 작업을 합니다.
1. read synchronization 작업을 Enqueue(대기열에 추가) 합니다. 이는 내부 data source와 네트워크 data source가 같다는 걸 확신시킵니다.
2. 앱이 online 일 때, read synchronization queue를 실행하여 synchronizing을 합니다.
3. 네트워크 data source에서 exponential backoff 기술을 사용하여 data를 read 합니다.
4. Read 한 data는 conlifcts를 해결한 후, 내부 data source에 저장합니다.
5. 내부 data source에 저장한 data를 앱의 여러 layer에 노출시켜 수신하게 만듭니다.
아래에 그림으로 설명을 시각적으로 볼 수 있습니다.
WorkManager를 사용한 synchronization을 위한 enqueueing은 KEEP ExistingWorkPolicy를 사용하여 유일한 작업을 지정합니다.
• 메모:
Now in Android에 있는 read queue는 enqueueUniqueWork API를 사용하여 간단하게 표현됩니다. 좀 더 단단하게 어떤 queue가 실행될지를 결정하려면 Room과 Datastore를 사용하여 강력한 queue 사용이 필요합니다. 이러한 queue를 Worker가 순서대로 실행할 수 있게 합니다.
SyncWorker.startupSyncWork()는 아래처럼 정의됩니다.
특별히 Constraints(제한)는 SyncConstraints를 사용하여 NetworkType이 NetworkType.CONNECTED 일 때 작동하게 합니다. 이것은 네트워크가 가능할 때에만 이 작업을 실행합니다.
네트워크가 가능해지면, Worker는 유일한 work queue를 SyncWorkName으로 구분하여 적절한 Repository를 실행합니다. 만약 synchronization이 실패하면, doWork() 함수는 Result.retry()를 반환합니다. WorkManager는 자동으로 synchronization을 exponential backoff를 사용하여 재시도합니다. 그렇지 않다면, Result.success()를 반환하여 synchronization을 완료합니다.
카테고리: Android
댓글
댓글 쓰기
궁금한 점은 댓글 달아주세요.
Comment if you have any questions.