Building Carousel, Part I: How we made our networked mobile app feel fast and local 의 내용을 정리합니다.

Dropbox가 얼마 전에 사진 관리 앱 Carousel을 런칭했는데, 기본 갤러리 앱 만큼 속도를 빠르게 느끼도록 만들기 위해 노력한 내용을 테크 블로그에 소개했습니다. 평소에 모바일 앱은 모든 리모트 리소스에 대해 앱에서 캐시를 해야 한다고 생각하고 있었는데, 이 포스트에 그 구현 방법이 상세히 적혀 있습니다.

해결하려는 문제 (Make it Faster!)

사진을 공유하거나 사진을 삭제할 때마다 서버와의 동기화를 위해 HTTPS 요청 대기 상태가 됩니다. 네트워크 상태가 안 좋으면 요청은 실패하고, 재시도하겠냐고 사용자에게 묻게 됩니다. 또한 디바이스에만 존재하는, 즉 Dropbox에 아직 업로드하지 않은 사진을 보거나 그것과 interaction 할 방법이 없습니다.

Client Architecture (클라이언트(모바일앱) 측 아키텍처)

로컬 사진과 리모트 사진 모두를 고려한 통합 데이터 모델을 만들었습니다. 사용자는 100ms 안에 응답을 받을 수 있도록 하고, 네트워크 요청에 대한 대기 상태를 사용자가 느끼지 않도록 만들었습니다. 즉, eventually consistent system 을 만든 것입니다. 사용자가 액션을 하면 즉시 locally 하게 처리해서 보여 주고, 나중에 globally 하게 다른 디바이스에 적용합니다. 이것을 학계에서는 낙관적 복제 “optimistic replication” 이라고 부릅니다.

로컬과 서버 컨텐츠를 병합한 뷰를 만들려면, 클라이언트가 원격 서버의 변화를 알고 최신 상태를 유지해야 합니다. 그래서 HTTP Long polling 을 사용해서 변경 사항이 있다는 사실을 노티 받고, Dropbox Delta API 를 써서 변경 데이터를 pull 하는 구조로 만들었습니다. Delta 는 이전 서버 호출 이후의 변경 사항을 리턴합니다. 변경 사항을 fetch 하면 가장 최신의 서버 메타데이터를 SQLite 의 server_photos 테이블에 넣습니다. server_photos 테이블은 순수하게 서버에 대한 캐시입니다.

클라이언트 측 카메라 롤 스캐너는 빠르게 각 사진의 해시값을 계산해서, Dropbox 에 아직 안 올린 사진인지를 판별합니다. 업로드가 필요한 사진은 SQLite 의 photo_upload_operation 테이블에 들어갑니다. 사용자가 사진을 숨기기 또는 삭제를 하면 즉시 액션을 반영하고, 이런 변경 사항은 비동기적으로 서버에 write 할 수 있습니다. 이렇게 하기 위해 HideOperation, DeleteOperation 을 만들었고 SQLite로 persisted 됩니다.

Carousel의 모든 사용자 액션은 결국 서버로 동기화됩니다. 이 작업들은 in-memory operation queues 에 들어가고, SQLite 에 persisted 됩니다. 각 큐 마다 별도의 독점적 스레드(dedicated operation sync thread) 가 있고, 실행할 준비가 될 때까지 대기하다가 HTTPS call 을 만들어 서버에 변경 사항을 보냅니다. 사용자에게 view를 그릴 필요가 있을 때마다 이러한 보류 작업(pending operations) 들을 확인하고 사용자의 최신 액션이 반영되어 있는지 확인합니다. view 에 최신 액션이 반영되어 있지 않으면 view 를 렌더링할 때 반영시키는 구조인 듯합니다. 이러한 operation 을 안전하게 삭제하는 유일한 방법은, delta API 로 서버에 반영된 것을 확인했을 때입니다. 그림으로 설명하면 다음과 같습니다. We thus end up with an architecture that looks like this

예를 들어 primary grid view 를 렌더링하는 코드는 다음과 같습니다.

class DbxPhotoClient {
    list list_photos();
};

list_photos() 함수의 구현은 다음 순서로 처리됩니다. 먼저 server_photos 테이블에서 서버의 모든 사진 메타데이터를 읽습니다. 그 다음 photo_upload_operations 테이블에서 업로드 보류 중인 로컬 사진들을 가져와서 모두 추가합니다. 마지막으로 photo_modification_operations 테이블에서 숨김, 삭제된 사진을 모두 가져와서 제거합니다.

위 예제를 보면 photo model 이 변경될 때마다 SQLite 처리를 하는 데 비용이 많이 듭니다. 그래서 그 대신에 photo model 을 메모리에 유지하고, 변경 사항을 받아 수정하도록 만들었습니다. 앱 내 변화든 서버의 변화든 모두 그렇습니다. 이것은 서버에서 변경 사항을 동기화하는 delta API 와 구조가 다르지 않습니다. 성능을 위해 디스크와 메모리 사이에 또 하나의 delta 레벨을 도입했다고 생각하면 됩니다. 다음에 쓸 블로그 포스트에 이것이 어떻게 동작하는지, 그리고 10만 개 이상의 사진을 어떻게 처리하는지 쓸 예정입니다.

핵심은 클라이언트에서 사진을 추가하거나 삭제하는 것과, 서버에서 사진을 업로드하거나 삭제하는 것이 동일한 결과를 가져온다는 것입니다. Carousel에서 데이터를 렌더링할 때마다 캐시된 서버 상태를 보고, 보류된 오퍼레이션을 replay 합니다. 숨김/삭제의 케이스는 last-write-win (최근 기록 우선 규칙) 으로 충돌을 해결합니다.

이러한 처리 방식은 사진이 Dropbox 서버에 있고 서버에서 ID를 부여 받았을 때는 아주 잘 작동합니다. 그런데 로컬에서 보류 중인 오퍼레이션은 서버에 적용될 때만 서버 ID를 부여 받을 수 있는데, 업로드가 아직 안 된 사진에 대한 변경이 일어나면 어떻게 해야 할까요? 이 부분은 다음 챕터에서 설명합니다.

Identifying a Photo (사진 식별하기)

로컬에만 존재하는 사진을 서버와 제대로 동기화하려면 여러 가지 방법이 있습니다. 상대적으로 stateless 하고 심플하게 클라이언트 로직을 이해할 수 있는 구조를 만들기 위해 device-specific ID 컨셉을 도입했습니다. 앞으로 이걸 LUID (locally unique ID) 라고 부르겠습니다. LUID 는 사진을 Dropbox 에 업로드하기 이전이든 이후든 ID가 변하지 않습니다. 단순한 autoincrement integer 값입니다.

동작은 이렇습니다. Dropbox 에 업로드해야 하는 새로운 사진을 디바이스에서 찾으면, 사진에 LUID를 생성합니다. LUID는 local_photo_luids 테이블에 추가되고, 네이티브 카메라 롤 ID 에 매핑됩니다. 만약 delta 에서 서버로부터의 새로운 사진 S 를 얻으면, 로컬에 있는 사진들의 hash 와 일치하는지 확인하고 없으면 LUID를 생성합니다. 그리고 server_photo_luids 테이블에 추가하고, LUID 는 서버 ID 에 매핑됩니다. 만약 hash 가 로컬의 사진 L 과 일치하면 이미 업로드된 사진이라는 의미이고, 서버 메타데이터를 사용할 수 있습니다. S.photo_luid = L.photo_luid 이렇게 assign 합니다. 동시에 photo_upload_operation 작업을 완료합니다. 충돌을 피하기 위해, 같은 해시의 첫 번째 서버측 사진이 LUID 를 받게 됩니다.

이 로직을 사용하면 서버에 있든 없든 걱정 없이 안정적으로 사진에 대한 작업을 수행할 수 있습니다. 클라이언트는 심플하게 LUID만 보면 됩니다. 로컬에 있는 사진이 Dropbox 에 업로드가 완료되는 시점에 새로운 서버 ID를 참조하도록 “upgrading” 되는 구조입니다. LUID 는 그것을 추상화합니다.

Faster Sharing (빠르게 공유하기)

Carousel 에서 사용자가 사진을 공유할 때 일어나는 일들을 살펴봅니다. 사용자가 여러 사진을 선택하는데, 로컬에만 있는 사진과 서버에 업로드된 사진을 모두 포함해서 선택했다고 가정합니다. 사진을 선택하는 도중에 서버에 업로드가 완료된 경우에도 상관없습니다. 사진은 LUID 기반이기 때문입니다.

사용자가 공유할 수신자(recipients) 들을 선택하면, share operation 을 SQLite 에 저장합니다. view 를 렌더링할 때 수신자들의 대화 식별값을 위한 상태를 캐시 서버로부터 가져옵니다. 그리고 지금까지의 렌더링과 동일한 방식으로 보류 상태의 공유 작업들을 re-play 합니다. 이 부분도 새로운 블로그 포스트에 써야 할 정도로 내용이 많습니다.

만약 share operation 내에 여전히 로컬에만 있는 LUID들이 존재한다면 (server_photo_luids 테이블에 없는 것), share operation 은 서버에 아직 보낼 수 없습니다. share operation 의 큐는 sleep 상태가 되고, 로컬에만 존재하는 사진이 Dropbox 서버에 모두 업로드될 때까지 기다립니다. 또한 photo_upload_operation 테이블에 “blocking a share” 로 표시해서, 업로드 큐에서 표시된 작업이 우선 처리되도록 만듭니다. 사진이 모두 업로드되면 share operation 은 서버에서 실행됩니다. server_photo_luids 룩업 테이블에서 서버 ID를 확인하고 서버에 요청을 보냅니다.

best part 는 이 모든 작업이 비동기적이라는 것입니다. 그래서 사용자는 앱을 계속 사용하는 데 방해받지 않습니다. 스피너도 안 보이고, 대기하는 것도 없습니다. No spinners, no waiting.

Lessons Learned (교훈)

가장 큰 교훈은 사용자를 방해하지 말라는 것입니다. don’t block the user. 변경 사항을 순차적으로 서버에 동기화하는 대신에 eventually consistent system 을 만들었습니다. 모바일 네트워크는 여전히 느리고 신뢰할 수 없습니다. 비동기 delta 기반 디자인은 클라이언트와 서버 사이의 latency 를 사용자가 느끼지 못하게 합니다.