사전 오프라인 우선
패턴

오프라인 우선

gabury1고친 사람 github-actions[bot]

오프라인 우선은 네트워크가 끊겨도 앱을 계속 쓰게 해 주는 설계 방식입니다. 앱은 읽고 쓸 때 서버보다 기기 안의 저장소를 먼저 봅니다. 서버와 내용을 맞추는 일은 연결이 돌아왔을 때 뒤에서 합니다.

쉽고 빠른 이해

오프라인 우선은 앱이 서버 대신 기기 안의 저장소를 먼저 보게 하는 설계입니다. 지하철에서 신호가 끊겨도 메모 앱에 새 메모를 적고 저장할 수 있습니다.

이게 없으면 앱은 버튼을 누를 때마다 서버의 답을 기다립니다. 신호가 약하면 로딩 표시만 돕니다. 끊기면 방금 적은 내용이 오류 문구와 함께 날아갑니다.

어떻게 도는가:

  1. 앱은 읽기와 쓰기를 기기 안의 저장소에 합니다
  2. 서버로 보낼 변경은 대기열에 쌓아 둡니다
  3. 연결이 돌아오면 쌓인 변경을 보내고 서버의 새 변경을 받아 옵니다

대가도 있습니다. 같은 데이터가 기기와 서버에 두 벌 생깁니다. 두 기기에서 같은 것을 고치면 어느 쪽을 남길지 정하는 코드를 따로 짜야 합니다.

신호가 자주 끊기는 곳에서 쓰는 메모 앱이나 현장 점검 앱에 맞습니다. 송금이나 좌석 예매처럼 서버가 그 순간 판정해야 하는 일에는 안 맞습니다.

상세

이 절은 서버가 먼저인 보통 앱과 무엇이 달라지는지부터 봅니다. 그다음 기기 안의 저장소, 앱 파일, 쓰기 대기열, 동기화, 충돌, 서버 쪽 변화를 차례로 봅니다. 두 벌의 데이터가 낳는 비용과 이 설계가 맞는 곳으로 끝냅니다. 예로는 휴대폰과 노트북에서 함께 쓰는 메모 앱 하나를 끝까지 씁니다.

서버가 먼저인 앱

보통 웹 앱은 서버가 데이터를 쥐고 있습니다. 화면을 열면 서버에 요청을 보냅니다. 답이 와야 메모 목록이 뜹니다. 메모를 저장할 때도 서버가 받았다고 답해야 저장이 끝납니다.

이 구조에서 네트워크가 끊긴 상태는 오류입니다. 요청이 실패하면 앱은 오류 문구를 띄우고 멈춥니다. 연결이 약할 때도 괴롭습니다. 사용자는 서버를 한 번 다녀오는 시간만큼 매번 기다립니다.

오프라인 우선은 이 순서를 뒤집습니다. 끊긴 상태를 예외로 보지 않고 평소 상태 가운데 하나로 봅니다. 네트워크는 늘 있다고 믿는 대상이 아니라 있을 때 쓰는 통로가 됩니다.

기기 안의 저장소가 먼저

오프라인 우선 앱은 데이터를 기기 안에도 둡니다. 기기 안에 데이터를 두는 곳을 로컬 저장소라고 부릅니다. 앱이 서버를 거치지 않고 바로 읽고 쓸 수 있는 곳입니다.

웹 앱에서는 브라우저 안의 데이터베이스인 IndexedDB가 이 일을 맡습니다. 키로 값을 찾습니다. 여러 값을 한 번에 바꾸는 트랜잭션도 됩니다. 모바일 앱은 앱 안에 작은 데이터베이스를 하나 넣어 같은 일을 시킵니다.

화면은 이 저장소만 봅니다. 메모 목록을 그릴 때 서버에 묻지 않고 로컬 저장소에서 꺼냅니다. 그래서 신호가 없어도 목록이 뜹니다. 신호가 좋을 때도 서버를 기다리지 않습니다.

앱 파일도 기기에 남기기

데이터만 남아서는 앱이 안 열립니다. 웹 앱은 화면을 그리는 파일과 스크립트부터 서버에서 받아 옵니다. 이 파일을 못 받으면 로컬 저장소에 메모가 있어도 빈 화면만 뜹니다.

웹에서는 서비스 워커가 이 문제를 풉니다. 서비스 워커는 페이지가 보내는 요청을 중간에서 받아 대신 답하는 스크립트입니다. 첫 방문 때 앱 파일을 저장해 둡니다. 다음부터는 네트워크 대신 저장해 둔 파일로 답합니다.

이렇게 먼저 저장해 두는 앱의 뼈대 파일 묶음을 앱 셸이라고 부릅니다. 메뉴와 틀처럼 내용이 바뀌어도 그대로인 부분입니다. 내용은 로컬 저장소에서 채웁니다.

쓰기 대기열

사용자가 메모를 저장하면 앱은 두 가지를 합니다. 먼저 로컬 저장소에 메모를 적습니다. 그리고 이 메모가 새로 생겼다는 변경 기록을 대기열에 하나 넣습니다. 대기열도 로컬 저장소 안에 둡니다. 앱을 껐다 켜도 쌓인 변경은 그대로 남습니다.

화면은 로컬 저장소에 적힌 순간 바로 바뀝니다. 서버의 답을 기다리지 않습니다. 서버로 보내는 일은 앱이 대기열에서 변경을 꺼내 따로 합니다.

연결이 있으면 앱은 대기열의 변경을 곧 서버로 보냅니다. 연결이 없으면 변경은 대기열에 쌓인 채 기다립니다. 서버가 받았다고 답한 변경만 대기열에서 지웁니다.

아래 그림은 끊긴 동안 쓴 메모 하나가 서버에 닿기까지를 보입니다. 화면이 바뀌는 때와 서버에 닿는 때가 떨어져 있다는 것이 요점입니다.

sequenceDiagram
    participant 앱
    participant 로컬 저장소
    participant 대기열
    participant 서버
    앱->>로컬 저장소: 메모를 적는다
    앱->>대기열: 변경 기록을 넣는다
    Note over 앱: 화면은 이미 바뀌었다
    Note over 대기열,서버: 끊긴 동안 변경이 쌓인다
    대기열->>서버: 연결이 돌아오면 앱이 꺼내 보낸다
    서버-->>대기열: 받았다
    Note over 대기열: 받은 변경을 지운다

연결이 돌아왔을 때

연결이 돌아오면 앱은 서버와 내용을 맞춥니다. 이 일을 동기화라고 부릅니다. 방향은 둘입니다.

올리는 쪽은 대기열입니다. 끊긴 동안 쌓인 변경을 순서대로 서버에 보냅니다.

받는 쪽은 서버의 새 변경입니다. 같은 사용자가 노트북에서 고친 메모처럼 이 기기가 모르는 변경을 받아 로컬 저장소에 반영합니다. 앱은 마지막으로 맞춘 시점을 기억해 둡니다. 그 뒤에 바뀐 것만 서버에 달라고 합니다.

두 기기가 같은 메모를 고쳤을 때

끊긴 동안에는 기기끼리 서로의 변경을 모릅니다. 휴대폰과 노트북이 둘 다 오프라인이라고 해 봅니다. 휴대폰에서는 메모 제목을 「장보기」로 고칩니다. 같은 때 노트북에서는 「마트」로 고칩니다. 두 변경은 각자의 대기열에 쌓입니다.

연결이 돌아오면 서버에 같은 메모의 다른 값이 둘 도착합니다. 이것을 충돌이라고 부릅니다. 어느 쪽을 남길지 정하는 규칙이 충돌 해소입니다. 오프라인 우선을 고르면 이 규칙을 반드시 정해야 합니다.

자주 쓰는 방법 넷을 견주면 이렇습니다. 방법마다 잃는 것이 다릅니다.

방법 어떻게 정하나 잃는 것
마지막 쓰기 승리 늦게 쓴 값을 남긴다 먼저 쓴 값이 말없이 사라진다
칸 단위 병합 제목과 본문처럼 다른 칸을 고쳤으면 둘 다 살린다 같은 칸을 고치면 여전히 하나를 골라야 한다
사용자에게 묻기 두 값을 보여 주고 고르게 한다 사용자에게 할 일이 생긴다
충돌 없는 복제 자료형 합치는 규칙을 자료구조에 넣는다 자료구조가 복잡해지고 데이터가 커진다

첫째 방법은 Last Write Wins라는 영어 이름으로 더 자주 부릅니다. 가장 단순해서 흔히 씁니다.

이 방법은 늦고 이른 것을 기기의 시계로 가립니다. 그런데 기기마다 시계가 조금씩 다릅니다. 그래서 실은 먼저 쓴 값이 이기는 일도 생깁니다.

넷째 방법은 영어 이름을 줄인 CRDT(Conflict-free Replicated Data Type)로 더 자주 부릅니다. 합치는 규칙을 자료구조 안에 넣어 둔 자료형입니다. 변경을 어느 순서로 받아도 모든 기기가 같은 값에 닿습니다.

좋아요 수를 세는 카운터로 보면 이렇습니다. 숫자 하나를 두 기기가 함께 고치면 충돌합니다. 대신 기기마다 자기 칸을 따로 둡니다. 각 기기는 자기 칸만 올립니다. 휴대폰 칸이 3, 노트북 칸이 2면 좋아요 수는 둘을 더한 5입니다.

합칠 때는 칸마다 더 큰 수를 남깁니다. 한 칸을 올리는 기기는 하나뿐입니다. 그러니 더 큰 수가 곧 최신 값입니다. 어느 기기의 변경을 먼저 받아도 합친 결과는 같습니다.

서버 쪽에서 달라지는 것

오프라인 우선은 클라이언트만의 일이 아닙니다. 서버 API(Application Programming Interface, 애플리케이션 프로그래밍 인터페이스)도 몇 가지를 받아 줘야 합니다. 넷을 봅니다.

첫째, 같은 변경이 두 번 올 수 있습니다. 앱이 변경을 보낸 뒤 답이 오기 전에 연결이 끊길 수 있습니다. 그러면 앱은 서버가 받았는지 모릅니다. 그래서 다시 보냅니다. 같은 요청을 두 번 받아도 한 번 받은 것과 결과가 같게 만드는 성질을 멱등성이라고 부릅니다.

둘째, 식별자를 서버가 매길 수 없습니다. 오프라인에서 만든 메모는 서버를 거치지 않았으니 서버가 주는 번호가 없습니다. 그래서 앱이 기기에서 바로 식별자를 만듭니다. 기기끼리 겹치지 않게 아주 큰 난수로 만드는 UUID(Universally Unique Identifier, 범용 고유 식별자)를 흔히 씁니다.

셋째, 서버가 바뀐 것만 골라 돌려줄 수 있어야 합니다. 받는 쪽 동기화는 「이 시점 뒤로 바뀐 것을 달라」는 요청입니다. 서버가 레코드마다 바뀐 시점을 적어 두어야 이 요청에 답합니다.

넷째, 지운 레코드도 흔적을 남겨야 합니다. 그냥 지우면 다른 기기는 지워진 줄 모르고 옛 값을 계속 들고 있습니다. 그래서 지웠다는 표시만 남겨 둡니다. 이 삭제 표시를 툼스톤이라고 부릅니다.

두 벌의 데이터가 낳는 비용

같은 데이터가 기기와 서버에 두 벌 생깁니다. 두 벌은 동기화가 끝날 때까지 다를 수 있습니다. 결국에는 같아지지만 그 사이에는 다를 수 있는 성질을 결과적 일관성이라고 부릅니다.

화면에서 성공한 일이 나중에 실패할 수 있습니다. 로컬 저장소에 적은 순간 앱은 저장됐다고 보여 줍니다. 그 뒤에 서버가 권한이 없다거나 값이 틀렸다며 거절할 수 있습니다. 앱은 이미 보여 준 화면을 되돌리고 사용자에게 알리는 길을 따로 가져야 합니다.

코드가 늘어납니다. 대기열, 동기화, 충돌 해소는 서버가 먼저인 앱에는 없던 부분입니다. 로컬 저장소의 구조를 바꿀 때 기기마다 남은 옛 데이터를 새 구조로 옮기는 코드도 필요합니다.

기기 안의 저장소는 영원하지 않습니다. 기기의 공간이 모자라면 브라우저가 저장해 둔 데이터를 지우기도 합니다. 아직 서버로 못 보낸 변경이 대기열에 남아 있었다면 그 변경도 함께 사라집니다.

쓰는 곳과 안 쓰는 곳

신호가 자주 끊기는 곳에서 쓰는 앱이 이 설계를 고릅니다. 현장을 돌며 점검 결과를 적는 앱, 메모와 할 일 앱, 받은 편지를 읽는 메일 앱이 그렇습니다. 이 앱들에는 끊긴 동안 적은 것을 잃지 않는 것이 핵심입니다.

서버가 그 순간 판정해야 하는 일에는 맞지 않습니다. 송금이나 좌석 예매는 잔액과 남은 좌석을 서버가 확인해야 끝납니다. 오프라인에서 보냈다고 보여 주면, 나중에 거절될 때 사용자는 이미 그 말을 믿고 움직인 뒤입니다.

늘 최신 값이 필요한 화면도 맞지 않습니다. 주식 시세나 실시간 재고처럼 몇 초 전 값이 곧 틀린 값인 데이터는 기기에 남겨 둔 값으로 보여 줄 수 없습니다.

관련 항목

오프라인 우선을 받치는 브라우저 기술

서비스 워커 · IndexedDB · 캐시 스토리지 · 앱 셸 · 백그라운드 동기화 · 웹 스토리지 · 스토리지 할당량 · 영속 스토리지

오프라인 우선으로 짓는 애플리케이션 종류

프로그레시브 웹 앱 · 웹 앱 · 모바일 앱 · 모바일 개발 · 데스크톱 앱

오프라인 우선이 기기와 서버를 맞추는 방법

데이터 동기화 · 충돌 해소 · Last Write Wins · CRDT · 벡터 시계 · 운영 변환 · 툼스톤 · 델타 동기화 · 복제

오프라인 우선이 서버 API 에 요구하는 성질

멱등성 · 멱등성 키 · UUID · 재시도 · 결과적 일관성

오프라인 우선과 같은 문제를 다른 각도에서 푸는 설계 방식

로컬 우선 · 낙관적 UI · stale-while-revalidate · 캐시 우선 · 점진적 향상

오프라인 우선이 속하는 상위 분류

클라이언트 · 로컬 저장소 · 분산 시스템 · 프론트엔드

다른 이름: offline-first · Offline First · 오프라인 퍼스트 · 오프라인 우선 설계