MOM.SOFT

MOMSOFT  /  기술 블로그

Mobile

오프라인 우선 앱을 설계한다는 것

네트워크를 전제에서 끌어내려 부가 기능으로 만드는 설계. 충돌 규칙과 부분 실패, 로컬 스키마 마이그레이션까지 판단 순서로 정리합니다.

프로젝트 문의하기 →

결론부터: 오프라인 우선은 예외 처리가 아니라 소유권 이전입니다

오프라인 우선(offline-first)을 "네트워크가 끊겼을 때도 죽지 않는 앱"으로 이해하면 십중팔구 실패합니다. 그 정의로 출발하면 결국 기존의 온라인 앱에 캐시를 덧붙이고, 끊김을 감지해서 에러 화면을 예쁘게 그리는 작업으로 끝납니다. 그건 오프라인 내성이지 오프라인 우선이 아닙니다.

오프라인 우선의 실제 정의는 이것입니다. 진실의 원본(source of truth)을 서버에서 기기로 옮기고, 서버와의 통신을 앱 동작의 전제에서 부가 기능으로 강등시키는 것. 화면은 서버를 조회하지 않습니다. 화면은 로컬 데이터베이스만 봅니다. 사용자의 쓰기도 서버로 가지 않습니다. 로컬에 커밋되고, 그 사실이 별도의 백그라운드 경로를 타고 뒤늦게 서버에 반영됩니다. 네트워크는 그 경로의 속도에만 영향을 줄 뿐, 화면의 응답성에는 관여하지 않습니다.

이 전환은 UI 코드 몇 줄이 아니라 데이터 소유권의 이전입니다. 그래서 비용이 큽니다. 이 글은 그 비용이 무엇으로 구성되는지, 어떤 순서로 지불해야 하는지, 그리고 지불하지 말아야 할 때는 언제인지를 정리합니다.

왜 어려운가: 원본이 둘이 되는 순간 발생하는 일

온라인 전제 앱의 데이터 모델은 단순합니다. 상태는 서버에 하나 있고, 기기는 그 사본을 잠깐 들고 있을 뿐입니다. 사본이 틀렸으면 다시 받으면 됩니다. 여기서 기기의 사본은 언제든 버려도 되는 값이라는 점이 핵심입니다.

오프라인 우선으로 넘어가는 순간 이 전제가 깨집니다. 사용자가 비행기 안에서 만든 데이터는 서버에 없습니다. 버리면 사라집니다. 즉 기기의 데이터가 버릴 수 없는 값이 됩니다. 서버에도 버릴 수 없는 값이 있고 기기에도 버릴 수 없는 값이 있으면, 그 둘은 필연적으로 갈라집니다. 갈라진 상태를 다시 하나로 합치는 문제 — 이것이 오프라인 우선 설계의 전부라고 해도 과언이 아닙니다.

세 가지 어려움이 파생됩니다

이 셋은 별개 문제처럼 보이지만 뿌리가 같습니다. 셋 다 "권위 있는 시점이 하나가 아니다"에서 나옵니다. 그래서 해법도 개별 대응이 아니라 데이터 모델 수준에서 한 번에 정해야 합니다.

로컬을 원본으로 둔다는 것의 구체적 의미

읽기 경로에는 네트워크가 없습니다

가장 먼저 지켜야 할 규칙입니다. 화면을 그리는 코드가 HTTP 클라이언트를 직접 호출하는 일이 단 한 군데도 없어야 합니다. 화면은 로컬 저장소를 구독하고, 로컬 저장소가 바뀌면 다시 그립니다. 서버 응답은 로컬 저장소에 기록되고, 그 기록이 화면 갱신을 유발합니다. 즉 서버는 UI의 공급자가 아니라 로컬 저장소의 공급자입니다.

이 규칙 하나만 지켜도 로딩 스피너가 붙을 자리가 거의 사라집니다. 남는 자리는 "데이터가 아예 없는 최초 진입"과, 로컬에 아직 내려받지 않은 구간을 넘겨보는 무한 스크롤 정도입니다. 그 외에는 항상 무언가 보여줄 것이 있습니다. 이것이 오프라인 우선 앱이 체감상 빠른 이유입니다. 네트워크가 멀쩡할 때도 빠릅니다.

쓰기 경로는 두 단계로 쪼갭니다

사용자의 행동은 로컬 트랜잭션 하나로 즉시 확정됩니다. 그리고 같은 트랜잭션 안에서 "이 변경을 서버에 알려야 한다"는 사실을 별도의 큐에 남깁니다. 흔히 아웃박스(outbox) 패턴이라 부르는 구조입니다.

-- 로컬 변경과 발신 큐를 같은 트랜잭션에 넣습니다
BEGIN;
  UPDATE note SET body = ?, updated_at = ? WHERE id = ?;
  INSERT INTO outbox (op_id, entity, entity_id, op, payload, created_at, attempts)
  VALUES (?, 'note', ?, 'update', ?, ?, 0);
COMMIT;

이 구조의 값어치는 원자성에 있습니다. "로컬에는 반영됐는데 보낼 작업이 유실된" 상태나 "보내기는 했는데 로컬에는 없는" 상태가 원천적으로 생기지 않습니다. 앱이 커밋 직후 종료돼도 큐는 저장소에 남아 있고, 다음 실행 때 이어서 처리됩니다.

다만 "저장소에 남는다"는 보장이 어디까지인지는 저장소 설정에 달려 있습니다. 임베디드 데이터베이스는 저널 모드와 동기화 강도를 조절할 수 있고, 설정에 따라 프로세스 강제 종료에는 안전하지만 전원 차단이나 OS 크래시에서는 가장 최근 커밋을 잃을 수 있는 구성이 존재합니다. 아웃박스를 쓰기로 했다면 그 설정값을 기본값 그대로 두지 말고 한 번은 확인하십시오.

여기서 op_id는 클라이언트가 생성한 고유 식별자입니다. 서버는 이 값을 멱등 키로 씁니다. 같은 op_id가 두 번 오면 두 번째는 처리하지 않고 첫 번째 결과를 그대로 돌려줍니다. 네트워크가 불안정한 환경에서는 "요청은 도착했는데 응답이 유실된" 경우가 흔하고, 클라이언트는 그걸 실패로 오인해 재시도합니다. 멱등 키가 없으면 그 순간 중복 데이터가 생깁니다.

서버 쪽에서 놓치기 쉬운 것은 멱등 기록의 보관 기간입니다. 처리 결과를 얼마나 오래 들고 있을지 정하지 않으면 그 테이블은 무한히 자랍니다. 반대로 보관 기간을 짧게 잡으면, 오랫동안 오프라인이었던 기기가 뒤늦게 재전송했을 때 기록이 이미 지워져 중복이 만들어집니다. 보관 기간은 "우리 앱에서 현실적으로 가능한 최장 오프라인 기간"에 맞춰 잡고, 그보다 오래된 큐 항목은 클라이언트가 전송 전에 재검토하도록 설계하는 편이 안전합니다.

식별자는 기기가 만듭니다

서버가 채번하는 정수 자동 증가 키를 쓰면 오프라인에서 만든 레코드에 임시 ID를 붙였다가 나중에 교체해야 하고, 그 레코드를 참조하던 다른 레코드까지 줄줄이 고쳐야 합니다. 이 작업은 생각보다 자주 깨집니다. 클라이언트가 충돌 없이 만들 수 있는 식별자(UUID 계열, ULID 등)를 쓰면 이 문제 자체가 없어집니다. 생성 순서대로 정렬돼야 인덱스가 덜 흩어지므로, 정렬이 필요하면 시간 순서를 앞자리에 내장한 형식을 고르십시오.

큰 바이너리는 아웃박스에 넣지 마십시오

사진, 첨부파일, 음성 녹음 같은 대용량 데이터를 변경 페이로드에 통째로 직렬화해 넣으면 로컬 데이터베이스가 급격히 커지고, 재시도할 때마다 전체를 다시 올립니다. 바이너리는 파일 시스템에 두고 큐에는 경로와 해시만 담은 뒤, 업로드를 이어받기 가능한 별도 경로로 분리하는 편이 낫습니다. 메타데이터 동기화는 즉시 끝나고 첨부는 뒤늦게 따라붙는 구조가 되며, 화면에서는 "첨부 업로드 중" 상태를 따로 표현할 수 있게 됩니다.

동기화 시점: 언제 밀고 언제 당기는가

동기화를 "주기적으로 전체를 맞춘다"로 설계하면 데이터가 늘어날수록 무너집니다. 밀기(push)와 당기기(pull)를 분리하고, 각각 다른 트리거를 주는 편이 견고합니다.

구분트리거범위주의점
밀기아웃박스에 항목이 생겼을 때, 네트워크가 복구됐을 때, 앱이 포그라운드로 올라올 때큐에 쌓인 것만순서 보장이 필요한 항목은 직렬 처리, 독립적인 항목은 병렬 허용
당기기앱 시작, 화면 진입, 서버 푸시 알림, 사용자의 명시적 새로고침마지막 동기화 커서 이후 변경분만커서는 서버가 발급한 불투명 값으로 두고 클라이언트 시계에 의존하지 말 것

증분 동기화의 커서를 클라이언트 시계로 만들지 마십시오

"마지막으로 받은 updated_at 이후의 데이터를 주세요"는 직관적이지만 위험합니다. 기기 시계는 사용자가 바꿀 수 있고, 서버 노드 간에도 미세한 차이가 있으며, 같은 밀리초에 여러 레코드가 커밋될 수 있습니다. 더 까다로운 것은 트랜잭션 순서입니다. 타임스탬프가 이른 트랜잭션이 나중에 커밋되면, 커서가 이미 그 시각을 지나간 뒤라 해당 레코드는 영영 전달되지 않습니다. 서버가 변경 로그의 위치를 나타내는 커서(단조 증가 시퀀스나 불투명 토큰)를 발급하고, 클라이언트는 그걸 그대로 보관했다가 되돌려주는 방식이 안전합니다.

삭제는 흔적을 남겨야 합니다

증분 동기화에서 하드 삭제는 표현할 방법이 없습니다. 레코드가 사라지면 "삭제됐다"와 "애초에 준 적 없다"를 구분할 수 없기 때문입니다. 삭제 표식(tombstone)을 남기고, 모든 활성 클라이언트가 확실히 받아갔다고 판단되는 기간이 지난 뒤에 정리하는 방식이 일반적입니다. 그 보관 기간보다 오래 접속하지 않은 기기는 증분이 아니라 전체 재동기화로 유도해야 합니다. 즉 커서가 만료됐을 때 서버가 "이 커서는 더 이상 유효하지 않다"고 명시적으로 답하는 규약이 필요합니다. 이걸 빼먹으면 오래 잠들어 있던 기기가 삭제된 데이터를 되살립니다.

충돌 해결: 코드가 아니라 데이터 모델에서 정합니다

충돌 처리를 동기화 함수 안의 if 문으로 해결하려 들면 케이스가 기하급수로 늘어납니다. 대신 필드 단위로 병합 규칙을 미리 선언하고, 동기화 코드는 그 선언을 실행만 하도록 만드는 편이 유지보수됩니다.

데이터 성격적합한 규칙예시
마지막 값만 의미 있음Last-Write-Wins (LWW)제목, 색상, 즐겨찾기 여부
필드별로 독립적필드 단위 LWW (레코드 단위가 아님)프로필의 이름과 전화번호를 각각 다른 기기에서 수정
증감이 의미 있음델타 누적 (카운터)재고 수량, 조회수, 포인트
모음에 넣고 빼는 것추가/삭제 집합 병합태그, 참여자 목록
여러 사람이 동시에 편집하는 긴 텍스트텍스트 CRDT 또는 OT협업 문서 본문
틀리면 돈이나 안전에 영향자동 병합 금지, 서버 검증 + 사용자 확인결제, 예약 확정, 의료 기록

LWW를 쓸 거면 "나중"의 정의를 먼저 정하십시오

기기 시계로 LWW를 하면, 시계가 앞선 기기의 오래된 값이 시계가 정확한 기기의 최신 값을 덮어씁니다. 최소한 다음 중 하나는 갖춰야 합니다.

어느 것을 고르든, 규칙을 문서로 적어 두고 테스트로 고정하십시오. 충돌 규칙은 나중에 바꾸기가 대단히 어렵습니다. 이미 그 규칙으로 병합된 데이터가 기기마다 흩어져 있고, 규칙을 바꾸는 순간 예전 규칙으로 합쳐진 결과와 새 규칙으로 합쳐진 결과가 같은 계정 안에 섞이기 때문입니다.

자동 병합이 부적절한 곳을 골라내는 기준

"자동으로 합쳤을 때 사용자가 손해를 볼 수 있는가"를 물어보면 됩니다. 메모 제목이 덮어써지면 불편하지만 복구 가능합니다. 재고가 잘못 합산되면 팔 수 없는 물건을 팔게 됩니다. 후자는 자동 병합 대상이 아니라 서버가 최종 판정하고 클라이언트는 그 판정을 받아들이는 영역입니다. 오프라인에서는 "요청 접수"까지만 하고 "확정"은 온라인에서만 일어나도록 상태를 나누는 것이 정직한 설계입니다.

카운터도 같은 잣대로 봐야 합니다. 델타 누적은 "합계가 몇이든 상관없는" 값(조회수, 좋아요)에서는 잘 동작하지만, "0 아래로 내려가면 안 되는" 값에서는 병합 규칙만으로 불변식을 지킬 수 없습니다. 두 기기가 각각 재고 1을 차감하면 로컬에서는 둘 다 유효하고, 합치면 -1이 됩니다. 불변식이 있는 수치는 클라이언트가 아니라 서버가 판정해야 합니다.

부분 실패: 성공과 실패 사이에 있는 상태들

동기화는 전부 성공하거나 전부 실패하지 않습니다. 실무에서 만나는 상태는 훨씬 많고, 각각에 대한 처리를 미리 정해 두지 않으면 큐가 막히거나 데이터가 조용히 유실됩니다.

서버 응답성격처리
네트워크 오류, 타임아웃, 5xx일시적지수 백오프 + 지터로 재시도. 시도 횟수는 기록하되 간격에 상한을 두고, 항목을 버리지는 말 것
속도 제한(429)일시적, 서버가 지시서버가 재시도 시각을 알려주면 그 값을 우선하고 자체 백오프로 덮어쓰지 말 것
인증 만료(401)일시적이지만 개입 필요큐를 보존한 채 일시 정지, 재인증 후 재개. 큐를 비우면 안 됩니다
검증 실패(400 · 422 등)영구적재시도해도 같습니다. 해당 항목을 격리하고 사용자에게 알린 뒤, 뒤따르는 의존 항목을 어떻게 할지 결정
충돌(409 등)병합 대상서버의 현재 값을 받아 선언된 병합 규칙 적용, 결과를 새 변경으로 다시 밀기
응답 유실불확정같은 op_id로 재전송. 서버가 멱등 처리하므로 중복되지 않습니다

큐를 막는 항목 하나가 앱 전체를 세웁니다

영구 실패 항목을 큐 맨 앞에 그대로 두면 그 뒤의 모든 변경이 영원히 전송되지 않습니다. 사용자는 아무 문제 없다고 생각하며 계속 데이터를 만들고, 어느 날 기기를 바꾸면 전부 사라집니다. 구조적으로 이것이 오프라인 우선 앱에서 데이터를 잃기 가장 쉬운 경로입니다. 반드시 격리 영역(dead letter)을 두고, 격리된 항목이 하나라도 있으면 사용자에게 보이도록 하십시오. 격리 자체보다 중요한 것은 격리됐다는 사실이 어딘가에 표시되는 것입니다.

순서 의존성을 큐에 명시하십시오

모든 작업을 직렬로 보내면 안전하지만 느리고, 하나가 막히면 전부 막힙니다. 반대로 전부 병렬로 보내면 "생성 전에 수정이 도착"하는 사고가 납니다. 현실적인 절충은 엔티티 단위 직렬, 엔티티 간 병렬입니다. 같은 레코드에 대한 작업은 순서대로, 서로 다른 레코드는 동시에 보냅니다. 부모-자식 관계처럼 엔티티를 넘나드는 의존이 있다면 작업 레코드에 선행 작업 ID를 명시적으로 담아 두는 편이, 나중에 순서를 추론하려 애쓰는 것보다 훨씬 낫습니다.

같은 레코드에 대한 연속 수정은 전송 전에 접어버릴 수 있습니다. 오프라인 동안 제목을 열 번 고쳤다면 서버에 열 번 보낼 이유가 없습니다. 다만 접기(coalescing)는 최종 상태만 의미 있는 필드에서만 안전합니다. 델타 누적이나 집합 추가/삭제처럼 중간 연산 자체가 의미인 경우에는 접으면 결과가 달라집니다. 접기 규칙은 앞서 정한 필드별 병합 규칙과 반드시 같은 표에서 관리하십시오.

되돌리기(rollback)를 어떻게 할지 미리 정합니다

영구 실패한 변경을 로컬에서 되돌릴지, 아니면 실패 표시만 붙여 남겨 둘지는 제품 판단입니다. 자동으로 되돌리면 사용자가 작성한 내용이 눈앞에서 사라지는 최악의 경험이 됩니다. 대부분은 남겨 두고 "동기화되지 않음"으로 표시한 뒤 사용자가 수정하거나 폐기하도록 하는 편이 낫습니다. 사용자의 입력을 앱이 임의로 버리지 않는다는 원칙은 지킬 만한 가치가 있습니다.

로컬 스키마와 마이그레이션

서버 데이터베이스 마이그레이션과 로컬 마이그레이션은 난이도가 다릅니다. 서버는 실행 환경을 통제할 수 있고, 실패하면 롤백하고 재시도할 수 있으며, 백업이 있습니다. 로컬은 통제할 수 없는 다수의 기기에서, 각기 다른 이전 버전으로부터, 배터리가 나가고 앱이 죽는 환경에서, 백업 없이 실행됩니다.

규칙 몇 가지

테스트는 실제 이전 버전 파일로 합니다

새 스키마로 만든 빈 데이터베이스에 마이그레이션을 돌려 보는 테스트는 아무것도 검증하지 못합니다. 릴리스마다 실제 데이터가 든 데이터베이스 파일을 픽스처로 저장해 두고, 모든 이전 버전 픽스처에서 현재 버전까지 올라가는 테스트를 CI에서 돌리는 방식이 유일하게 믿을 만합니다. 픽스처에는 정상 데이터뿐 아니라 미전송 아웃박스 항목이 남아 있는 상태격리된 항목이 있는 상태도 포함시키십시오. 픽스처는 릴리스할 때마다 하나씩 늘려 두십시오. 나중에는 만들 수 없습니다.

네트워크가 없을 때의 UX

"오프라인입니다" 배너는 대부분 불필요합니다

오프라인 우선 앱에서 연결 상태는 대개 사용자가 알 필요 없는 정보입니다. 읽기도 되고 쓰기도 되니까요. 정말 알려야 하는 것은 연결 여부가 아니라 내 데이터가 어디까지 안전한가입니다. 상태 표시는 다음 세 가지로 충분합니다.

낙관적 UI는 되돌릴 수 있는 행동에만

낙관적 갱신은 오프라인 우선의 기본 동작이지만, 나중에 실패할 수 있다는 사실을 사용자가 감당할 수 있는 행동에만 적용해야 합니다. 메모 저장은 실패해도 다시 시도하면 됩니다. "결제 완료", "예약 확정", "메시지 전송 완료"를 오프라인에서 낙관적으로 표시하면, 그건 거짓말이 됩니다. 이런 행동은 화면의 문구 자체를 요청됨 수준으로 낮추고, 확정은 서버 응답 후에만 표시하십시오. 문구를 정직하게 쓰는 것이 기술적 복잡도를 크게 줄여 줍니다.

온라인 전용 기능은 숨기지 말고 상태를 설명합니다

검색 인덱스 조회, 외부 결제, 대용량 미디어 업로드처럼 로컬만으로 불가능한 기능은 반드시 남습니다. 이때 버튼을 사라지게 만들면 사용자는 앱이 고장 났다고 판단합니다. 버튼은 두되 비활성 상태와 이유를 함께 보여주고, 가능하면 "연결되면 자동으로 처리"라는 선택지를 주는 편이 낫습니다. 이 선택지가 곧 아웃박스에 항목을 하나 넣는 것과 같습니다.

지연된 오류의 문제

오프라인 우선의 구조적 단점 하나는, 오류가 사용자가 그 작업을 잊어버린 뒤에 도착한다는 것입니다. 세 시간 전에 쓴 글이 지금 거부됐다는 알림은 맥락이 없습니다. 오류를 알릴 때는 반드시 해당 데이터로 바로 이동할 수 있는 경로와, 무엇을 고쳐야 하는지를 함께 제시해야 합니다. 이걸 준비하지 않으면 사용자는 알림을 무시하고, 결국 데이터는 유실됩니다.

같은 이유로 서버의 검증 규칙은 가능한 한 클라이언트에도 복제해 두는 편이 낫습니다. 서버에만 있는 규칙은 오프라인 상태에서 통과한 것처럼 보였다가 몇 시간 뒤에 거부됩니다. 규칙을 두 곳에 두는 중복이 싫겠지만, 오프라인 우선에서는 그 중복이 지연된 오류의 총량을 직접 줄여 줍니다. 서버에서만 판정 가능한 규칙(중복 검사, 권한, 잔액)만 남기고 나머지는 입력 시점에 잡으십시오.

실제 진행 순서

한 번에 전부 하려 하면 대개 중간에 멈춥니다. 다음 순서로 쪼개면 각 단계마다 확인 가능한 결과가 나옵니다.

  1. 범위를 자릅니다. 전체 데이터를 오프라인화하지 않습니다. 사용자가 오프라인에서 실제로 하는 일 두세 가지를 고르고, 거기에 필요한 엔티티만 대상으로 삼습니다.
  2. 식별자를 클라이언트 생성으로 바꿉니다. 이걸 나중으로 미루면 전부 다시 짜야 합니다. 가장 먼저 해야 할 변경입니다.
  3. 읽기 경로를 로컬 저장소 구독으로 전환합니다. 이 단계까지만 해도 체감 성능이 눈에 띄게 좋아지므로, 여기서 한 번 릴리스해도 됩니다.
  4. 아웃박스와 멱등 키를 도입합니다. 서버 측 멱등 처리를 함께 준비합니다. 서버 협조 없이는 클라이언트만으로 완성되지 않습니다.
  5. 증분 동기화 커서와 삭제 표식을 서버에 만듭니다. 전체 재동기화 경로도 함께 남겨 둡니다. 오래 접속하지 않은 기기와 복구용 탈출구로 필요합니다.
  6. 충돌 규칙을 필드 단위로 선언하고 테스트로 고정합니다. 두 기기가 같은 레코드를 고치는 시나리오를 자동화 테스트로 만들어 두십시오.
  7. 실패 분류와 격리 영역, 그리고 그것을 노출하는 화면을 만듭니다. 여기까지 와야 데이터 유실 경로가 닫힙니다.
  8. 마이그레이션 픽스처 테스트를 CI에 넣습니다. 이후 모든 릴리스에서 자동으로 늘어나게 합니다.

출시 전 점검 목록

언제는 하지 말아야 하는가

오프라인 우선은 항상 옳은 선택이 아닙니다. 다음 조건에 해당한다면 하지 않는 편이 낫고, 억지로 하면 얻는 것보다 잃는 것이 많습니다.

1. 데이터의 최신성이 정확성의 일부일 때

좌석 예약, 재고 판매, 주문 체결, 실시간 시세처럼 내가 본 값이 지금도 참인지가 기능의 본질인 도메인에서는 로컬 사본이 오히려 위험합니다. 이런 곳에서 오프라인 우선은 "잘못된 정보를 빠르게 보여주는" 결과를 냅니다. 조회는 캐시로 빠르게, 확정은 반드시 서버에서 — 이 분리가 정답입니다.

2. 서버를 바꿀 수 없을 때

오프라인 우선은 클라이언트만의 과제가 아닙니다. 멱등 처리, 증분 커서, 삭제 표식, 충돌 응답 규약은 전부 서버 몫입니다. 서버가 레거시라 손댈 수 없거나 외부 API라면, 클라이언트는 결국 추측으로 재시도하고 중복을 만듭니다. 이 경우엔 읽기 캐시까지만 하고 쓰기는 온라인 전용으로 두는 것이 정직합니다.

3. 규제·감사 요구가 강한 데이터일 때

기기에 원본을 두는 것은 그 기기가 유출 표면이 된다는 뜻입니다. 개인정보나 금융·의료 데이터를 로컬에 장기 보관해야 한다면 저장 암호화, 원격 소거, 보관 기간 관리, 기기 분실 대응이 함께 따라옵니다. 특히 "삭제 요청을 받으면 지운다"는 요구는, 원본 사본이 이미 여러 기기에 흩어진 뒤에는 서버 한 곳을 지우는 것으로 끝나지 않습니다. 그 비용을 감당할 계획이 없다면 로컬 보관 범위를 최소화하는 편이 낫습니다.

4. 사용자가 사실상 항상 온라인일 때

내부망 사무용 앱처럼 접속 환경이 안정적이라면, 오프라인 우선이 주는 이득은 체감 응답 속도 정도입니다. 그건 캐시와 낙관적 갱신만으로도 상당 부분 얻을 수 있습니다. 동기화 엔진과 충돌 규칙을 통째로 들이는 것은 과잉입니다. 다만 현장 점검, 물류, 건설, 지하·산간 지역 업무처럼 실제로 끊기는 사용자가 있다면 이야기는 완전히 달라집니다.

5. 데이터가 기기 하나에 담기지 않을 때

오프라인 우선은 "필요한 데이터가 기기에 들어간다"를 전제합니다. 계정당 데이터가 크거나 사용자가 전체를 훑어야 하는 성격이라면, 부분 복제 범위를 정하는 문제가 새로 생기고 이게 충돌 처리보다 어려울 수 있습니다. 어떤 구간을 내려받고 언제 버릴지, 버린 구간을 다시 볼 때 어떻게 처리할지를 답할 수 없다면 아직 시작할 준비가 안 된 것입니다.

6. 팀이 이 구조를 유지할 준비가 안 됐을 때

이건 가장 자주 무시되는 조건입니다. 동기화 코드는 재현이 어려운 버그가 나오는 영역이고, 문제를 진단하려면 기기의 로컬 상태와 큐 내용을 들여다볼 수단이 필요합니다. 로컬 데이터베이스 덤프, 아웃박스 상태 조회, 동기화 로그 수집 같은 진단 도구를 만들 여유가 없다면, 오프라인 우선은 출시 후 원인을 알 수 없는 버그 보고서의 원천이 됩니다. 동기화 엔진을 직접 만들기보다 복제와 충돌 처리를 이미 제공하는 스택을 쓰는 선택지도 함께 저울질하십시오. 다만 그 경우에도 충돌 규칙과 마이그레이션은 여전히 우리 몫으로 남습니다. 도구는 배관을 대신해 줄 뿐 정책을 대신 정해 주지 않습니다.

정리

오프라인 우선의 값어치는 "인터넷 없이도 된다"가 아니라, 네트워크의 불확실성을 UI 코드 전체에서 걷어내 한 곳에 몰아넣는 것에 있습니다. 화면은 로컬만 보고, 불확실성은 동기화 계층 안에서만 다뤄집니다. 그 결과 앱은 연결이 좋을 때도 빨라지고, 나빠질 때도 동작이 달라지지 않습니다.

대신 그 한 곳이 대단히 어렵습니다. 충돌 규칙, 부분 실패, 스키마 시차라는 세 가지 비용을 전부 지불할 각오가 있을 때만 시작하십시오. 지불할 수 없다면 읽기 캐시와 정직한 상태 표시로 멈추는 것도 충분히 좋은 설계입니다.

같은 주제의 다른 글

맘소프트는 19년 넘게 금융 · 엔터프라이즈 · 모바일 시스템을 만들어 왔습니다. 여기 적는 글은 그 과정에서 실제로 부딪히고 해결한 것들입니다.

기술 판단이 필요하신가요

맘소프트는 소프트웨어 개발과 CTO 대행 · 기술 자문을 제공합니다. 편하게 문의해 주세요.

[email protected]