새로운 제품 파트너십을 받고 있습니다

오프라인 우선은 캐싱 기능이 아니라 데이터 모델의 결정입니다

대부분의 앱은 오프라인 지원을 마지막에 덧붙이고, 그것이 처음부터 구조의 문제였음을 깨닫습니다. 실제로 바뀌어야 하는 것들과, 반드시 골라야 하는 충돌 해결 규칙.

"오프라인에서도 되게 해 주세요"는 한 스프린트에 넣을 수 있는 기능처럼 들립니다. 아닙니다. 데이터가 어디에 있고 무엇이 참인지 누가 정하는지를 바꾸므로 모든 것을 건드립니다.

좋은 소식은 이 작업이 잘 이해되어 있다는 점입니다. 나쁜 소식은 나중에 덧붙이는 비용이 처음부터 넣는 비용의 몇 배라는 점입니다.

두 가지 구조

기본 구조는 서버 권위입니다. 화면은 서버를 비춥니다. 동작은 요청을 보내고, 기다리고, 응답으로 갱신합니다. 오프라인은 곧 고장입니다. 보여 줄 것도 없고 쓸 곳도 없으니까요.

다른 하나는 로컬 우선입니다. 기기가 자체 데이터베이스를 갖고 UI는 거기서 읽습니다. 쓰기는 즉시 로컬 저장소로 들어가고 화면은 그것으로 갱신됩니다. 백그라운드 처리가 연결이 있을 때 서버와 대조합니다.

이 뒤집힘이 전부입니다. 네트워크가 사용자와 그의 데이터 사이에서 빠집니다. 나머지는 거기서 따라옵니다.

실제로 바뀌는 것

모든 레코드에 오프라인에서 만들 수 있는 식별자가 필요하다

서버가 ID를 배정하면 기기는 오프라인에서 아무것도 만들 수 없습니다. 임시 ID를 지어내고 나중에 모든 참조를 다시 쓰는 수밖에요. 첫날부터 UUID 같은 것으로 클라이언트에서 ID를 생성하세요. 이 하나의 결정은 미리 하면 싸고 나중에 넣으면 고통스럽습니다.

쓰기가 호출이 아니라 큐가 된다

동작은 지속적인 로컬 큐에 의도를 덧붙이고 즉시 반환합니다. 동기화 처리가 그 큐를 비웁니다. 즉 큐는 앱 종료를 견뎌야 하고, 백오프를 두고 재시도해야 하며, 멱등해야 합니다. 어떤 동작은 반드시 두 번 보내게 되니까요.

삭제가 삭제가 아니게 된다

기기가 오프라인에서 한 행을 지웠는데 동기화가 "존재하는 것"만 보낸다면, 서버는 그 사실을 모르고 친절하게 되돌려 보냅니다. 툼스톤이 필요합니다. 삭제를 타임스탬프가 붙은 사실로 기록하고, 모든 기기가 볼 만큼 오래 보관하는 것이죠.

UI가 동기화 상태를 보여야 한다

쓰기가 로컬에서 성공한 뒤 나중에 실패할 수 있게 된 이상 사용자는 알아야 합니다. 모든 것에 스피너를 다는 게 아니라 정직한 표시를. 로컬 저장됨, 동기화 중, 동기화됨, 실패. 이걸 숨기면 최악의 결과가 나옵니다. 자기 작업이 안전하다고 믿는데 실은 아닌 상태 말입니다.

충돌 규칙 고르기

두 기기가 오프라인에서 같은 레코드를 고치고 둘 다 돌아옵니다. 무언가는 결정해야 합니다. 보편적으로 옳은 답은 없고, 여러분의 데이터에 옳은 답이 있을 뿐입니다.

전략작동 방식적합한 곳
나중 쓰기 우선타임스탬프가 큰 쪽이 덮어씀설정, 기기별 상태, 위험이 낮은 필드
필드 단위 병합필드마다 타임스탬프를 두고 충돌하지 않는 필드를 합침서로 다른 부분을 고치는 레코드
연산 로그의도를 저장하고 순서대로 재생카운터, 목록 등 더해지는 것
CRDT결정적으로 병합되는 자료구조공동 편집 텍스트와 공유 문서
사용자에게 묻기둘 다 보여 주고 고르게 함드물고 가치가 큰 충돌에만

경험에서 나온 경고 둘. 기기 시계에 기반한 "나중 쓰기 우선"은 조용히 데이터를 잃습니다. 기기 시계는 틀리기 때문입니다. 서버가 배정하거나 논리 시계를 쓰세요. 그리고 "사용자에게 묻기"는 기본값이 아닙니다. 자동 해결 비용이 지나치게 큰 경우를 위한 비상구입니다. 자주 물으면 사람들은 읽지 않고 눌러 버립니다.

충돌 규칙은 앱 단위가 아니라 데이터 종류별로 고르세요. 사용자의 플래시카드 진도는 더해지는 성질이라 깔끔하게 병합됩니다. 표시 이름은 나중 쓰기 우선으로 충분합니다. 둘에 같은 규칙을 쓰면 반드시 한쪽에는 틀립니다.

동기화 자체

대부분의 제품에서 통하는 형태:

  1. 각 레코드가 서버가 배정한 버전이나 갱신 시각을 가집니다.
  2. 클라이언트는 커서, 즉 따라잡은 지점을 저장합니다.
  3. 동기화 시 클라이언트는 큐에 있는 쓰기와 자기 커서를 보냅니다.
  4. 서버는 쓰기를 적용하고 충돌을 해결한 뒤, 그 커서 이후의 모든 변경과 새 커서를 돌려줍니다.
  5. 클라이언트는 변경 적용과 커서 전진을 원자적으로 처리합니다.

5단계의 원자성은 보기보다 중요합니다. 커서를 먼저 옮기고 적용에 실패하면 변경을 조용히 건너뛴 것이고, 그 사실을 알려 줄 것이 아무것도 없습니다.

지저분한 경우를 일부러 시험하세요

오프라인 버그는 평범한 테스트에서 나오지 않습니다. 평범한 테스트에는 와이파이가 있으니까요. 다음을 테스트 계획에 넣으세요.

  • 비행기 모드로 일주일, 그다음 수백 건이 쌓인 채로 재연결.
  • 동기화 도중 강제 종료. 배치가 날아가는 중에 앱을 죽입니다. 잃어서도 안 되고 두 번 적용되어서도 안 됩니다.
  • 공용 와이파이 로그인 포털. 최악의 네트워크 상태입니다. 기기는 연결됐다고 보고하는데 모든 요청이 로그인 페이지를 돌려줍니다. 요청은 깔끔하게 실패해야 하고 상태를 망가뜨리면 안 됩니다.
  • 시계 오차. 기기 시계를 하루 어긋나게 두고 순서가 유지되는지 확인합니다.
  • 같은 계정의 두 기기가 모두 오프라인에서 같은 레코드를 편집. 충돌 규칙이 값을 증명하는 자리입니다.

하지 않는 편이 나은 경우

오프라인 우선은 실제 작업량이고 언제나 정당화되지는 않습니다. 다음이면 넘어가세요. 네트워크가 없으면 어차피 의미가 없는 앱(실시간 채팅이나 결제). 데이터가 본질적으로 공유되고 동시적이어서 오프라인보다 실시간이 필요한 경우. 또는 사용 빈도가 낮은 사내 도구여서 비용이 이득을 넘는 경우.

해야 할 때는 연결이 불안정한 곳에서 앱이 쓰일 때입니다. 출퇴근길, 대중교통, 비행기, 시골, 신호가 나쁜 건물. 학습 앱이라면 그게 세션의 대부분이라, 저희는 기능 요청이 아니라 출발점의 전제로 다룹니다.

가장 싼 버전

완전한 로컬 우선 구조가 지금 단계에 과하다면, 가져갈 만한 중간 지대가 넓습니다.

  • 읽기를 적극적으로 캐시해서 앱이 스피너가 아니라 내용으로 열리게 하세요.
  • 쓰기를 지속적으로 큐에 넣으세요. 완전한 동기화가 없어도 동작이 끊긴 연결 때문에 사라지지 않게.
  • 지금부터 클라이언트에서 ID를 생성하세요. 오늘은 비용이 없고 나중에는 모든 것을 열어 줍니다.

이 셋이면 체감 이득의 대부분을 적은 노력으로 얻고, 나중에 더 나아가더라도 낭비되는 것은 하나도 없습니다.

모바일 엔지니어링 아키텍처