창고, 공장, 건설 현장처럼 통신이 자주 끊기는 곳에서 쓰는 앱은 물론이고 지하철이나 엘리베이터에서 사용하는 일반 모바일 앱도 네트워크를 항상 믿을 수는 없습니다. 그런데 많은 앱이 화면을 열 때마다 서버를 호출하고, 응답이 없으면 사용자의 작업 전체를 멈춥니다. 온라인 상태만 가정한 설계가 실제 환경을 만나는 순간입니다.
오프라인 우선(Offline-first)은 단순히 데이터를 캐시에 잠시 보관하는 기술이 아닙니다. 사용자의 읽기와 쓰기를 로컬에서 먼저 완결하고, 네트워크는 로컬과 서버의 상태를 나중에 맞추는 통로로 취급하는 설계 방식입니다. 이 관점으로 바꾸면 앱은 빨라지지만, 동시에 중복 전송·순서 뒤바뀜·동시 수정·삭제 전파 같은 분산 시스템 문제가 나타납니다. 이번 글에서는 이 문제를 어떤 순서로 풀어야 하는지 살펴보겠습니다.
캐시와 오프라인 우선은 무엇이 다른가
일반적인 캐시는 서버 응답을 복사해 두었다가 더 빨리 읽기 위한 장치입니다. 서버가 원본이고 캐시는 없어져도 다시 만들 수 있습니다. 반면 오프라인 우선 앱의 로컬 데이터베이스에는 아직 서버에 도착하지 않은 사용자의 작업이 들어 있습니다. 이 데이터는 단순 복사본이 아니라 잠시 동안 유일하게 존재하는 원본일 수 있습니다.
예를 들어 점검원이 통신이 끊긴 지하 기계실에서 설비 상태와 사진을 기록했다고 해보겠습니다. 앱을 종료하거나 휴대폰이 재부팅되어도 기록은 남아야 하고, 연결이 돌아오면 자동으로 전송되어야 합니다. 전송 응답을 받기 직전에 연결이 끊겼다면 서버가 이미 저장했는지 알 수 없으므로 다시 보내도 중복이 생기지 않아야 합니다. 이것이 오프라인 우선 설계의 출발점입니다.
먼저 동기화 계약을 정의한다
구현보다 먼저 정해야 할 것은 서버와 클라이언트 사이의 계약입니다. 어떤 데이터는 클라이언트가 만들 수 있는가, 최종 결정권은 어디에 있는가, 같은 레코드를 두 기기에서 고치면 누가 이기는가, 삭제는 언제 확정되는가를 명시해야 합니다. 이 규칙이 없으면 동기화 코드는 예외 처리의 집합이 됩니다.
모든 테이블을 동일하게 취급할 필요도 없습니다. 상품 카탈로그는 서버가 원본이고 앱은 읽기만 할 수 있습니다. 현장 점검 기록은 클라이언트가 생성하고 서버가 수집합니다. 작업 상태는 여러 사용자가 바꿀 수 있어 충돌 규칙이 필요합니다. 데이터 종류별 소유권과 변경 방향을 표로 정리하면 설계가 훨씬 단순해집니다.
로컬 데이터 모델에 동기화 상태를 포함한다
오프라인에서 레코드를 만들려면 서버의 자동 증가 번호를 기다릴 수 없습니다. UUID나 ULID처럼 클라이언트가 독립적으로 만들 수 있는 식별자가 필요합니다. 각 레코드에는 업무 데이터 외에도 버전, 마지막 동기화 상태, 삭제 여부 같은 메타데이터가 붙습니다.
- id: 여러 기기에서 충돌 없이 생성할 수 있는 전역 식별자
- revision: 서버가 변경을 확정할 때 증가시키는 버전
- sync_status: pending, syncing, synced, failed, conflict 등의 상태
- deleted_at: 삭제를 다른 기기에 전파하기 위한 표시
- updated_at: 화면 표시와 진단에는 유용하지만 충돌 순서를 단독 판단하는 기준으로는 부족
특히 휴대폰 시계는 정확하다고 가정하면 안 됩니다. 사용자가 시간을 바꿀 수도 있고 기기마다 오차가 다릅니다. 단순히 가장 최신 타임스탬프가 이긴다는 규칙은 오래된 변경이 최신 변경을 덮는 사고를 만들 수 있습니다. 변경 순서가 중요하다면 서버가 발급한 revision이나 단조 증가 change sequence를 사용해야 합니다.
쓰기 경로의 핵심: 데이터와 전송 예약을 함께 저장한다
사용자가 저장 버튼을 누르면 업무 데이터만 로컬 DB에 기록한 뒤 별도로 네트워크 요청을 만들기 쉽습니다. 하지만 두 동작 사이에 앱이 종료되면 데이터는 남았는데 전송할 방법이 없어집니다. 반대로 요청만 전송되고 로컬 상태 기록이 실패하면 사용자는 저장되지 않았다고 생각해 같은 작업을 반복할 수 있습니다.
이를 막는 대표적인 방법이 Transactional Outbox입니다. 하나의 로컬 트랜잭션 안에서 업무 데이터와 전송할 작업을 Outbox 테이블에 함께 기록합니다. 백그라운드 동기화 작업은 Outbox를 읽어 서버에 전송하고, 서버의 확인을 받은 뒤에만 항목을 완료 처리합니다. 앱이 어느 순간 종료되어도 미완료 항목을 다시 찾을 수 있습니다.

각 작업에는 고유한 operation_id를 붙입니다. 서버는 처리한 operation_id를 기록하고 동일한 요청이 다시 오면 새로 실행하지 않고 이전 결과를 반환합니다. 네트워크 오류 뒤의 재시도는 예외가 아니라 정상 흐름이므로 API의 멱등성은 선택 사항이 아닙니다.
재시도 간격은 지수 백오프와 작은 무작위 지연을 사용해 서버에 요청이 한꺼번에 몰리지 않게 합니다. 다만 같은 주문의 상태 변경처럼 순서가 중요한 작업은 엔터티별 순서를 보존해야 합니다. 전체 큐를 직렬화하면 느려지고, 모든 항목을 병렬화하면 순서가 깨지므로 서로 독립된 엔터티는 병렬 처리하고 같은 엔터티는 순차 처리하는 방식이 현실적입니다.
읽기 경로: 전체 새로고침보다 변경분을 가져온다
서버 데이터를 받을 때 매번 전체 목록을 내려받으면 데이터가 커질수록 느려지고 배터리와 통신량을 낭비합니다. 서버는 변경 로그에 증가하는 sequence를 부여하고, 클라이언트는 마지막으로 적용한 cursor 이후의 변경만 페이지 단위로 요청하는 편이 좋습니다.
한 페이지를 로컬 DB에 적용하고 cursor를 갱신하는 작업도 하나의 트랜잭션으로 묶어야 합니다. 데이터만 적용되고 cursor가 남지 않으면 같은 페이지가 다시 오지만, upsert가 멱등적으로 구현되어 있다면 안전합니다. 반대로 cursor만 전진하고 데이터 반영이 실패하면 변경을 영원히 놓치므로 더 위험합니다.
동기화 중 서버 데이터가 계속 변할 때 페이지 경계가 흔들리지 않도록 시작 시점의 상한 sequence를 고정한 스냅샷 개념도 필요합니다. 클라이언트는 그 상한까지 모두 받은 뒤 다음 동기화를 시작합니다. 대량 데이터의 최초 동기화는 압축된 스냅샷을 받고, 이후부터 변경 로그를 따라가는 혼합 방식이 효율적입니다.
재시도와 충돌은 다른 문제다
응답을 받지 못해 같은 쓰기를 다시 보내는 것은 전송 문제이고, 두 사용자가 같은 데이터를 서로 다르게 고친 것은 의미상의 충돌입니다. operation_id는 전자를 해결하지만 후자를 해결하지는 못합니다. 서버가 클라이언트가 알고 있던 base revision과 현재 revision을 비교해 다를 때 충돌을 감지해야 합니다.
충돌 해결 방법은 데이터의 의미에 따라 달라집니다. 메모처럼 손실 비용이 낮은 필드는 마지막 쓰기 우선(LWW)을 쓸 수 있습니다. 서로 다른 필드를 수정했다면 필드 단위 자동 병합이 가능합니다. 재고 수량은 값을 덮어쓰기보다 입고·출고 이벤트를 합산하는 편이 안전합니다. 승인 상태나 계약 금액처럼 중요한 값은 자동으로 결정하지 말고 사람에게 두 버전을 보여줘야 합니다.

좋은 충돌 화면은 단순히 “동기화에 실패했습니다”라고 말하지 않습니다. 로컬 값과 서버 값, 변경한 사람과 시각, 자동 병합 가능한 부분을 보여주고 사용자가 선택할 수 있게 합니다. 충돌 해결 결과도 새로운 operation으로 저장해야 다시 오프라인이 되어도 사라지지 않습니다.
삭제는 지우는 순간 끝나지 않는다
레코드를 서버에서 즉시 물리 삭제하면 오랫동안 오프라인이었던 기기는 그 사실을 알 수 없습니다. 다시 연결된 기기가 오래된 복사본을 올리면 삭제한 데이터가 되살아날 수도 있습니다. 그래서 동기화 시스템은 일정 기간 Tombstone, 즉 삭제 표식을 변경 로그에 남깁니다.
Tombstone 보존 기간은 클라이언트가 오프라인일 수 있는 최대 기간보다 길어야 합니다. 그 기간을 넘긴 기기는 증분 동기화를 계속할 수 없다고 판단하고 전체 재동기화를 요구해야 합니다. 개인정보처럼 즉시 삭제가 중요한 데이터는 로컬 기기에서 제거할 수 있는 원격 폐기 정책과 감사 로그를 별도로 설계해야 합니다.
사용자 경험은 동기화 상태를 숨기지 않는 데서 시작한다
오프라인 앱이 빠르다는 이유로 서버 저장이 끝난 것처럼 표시하면 사용자가 중요한 오해를 할 수 있습니다. “기기에 저장됨”, “전송 중”, “동기화 완료”, “확인 필요”를 구분해 보여주는 것이 좋습니다. 상시 노출이 부담스럽다면 평소에는 작은 상태 아이콘을 쓰고 실패가 길어질 때만 구체적인 안내와 재시도 버튼을 제공합니다.
연결 여부만 보고 버튼을 비활성화하는 것도 피해야 합니다. 운영체제가 온라인이라고 알려도 실제 API까지 통신된다는 보장은 없습니다. 사용자의 작업은 먼저 로컬에 안전하게 저장하고, 전송 가능 여부는 동기화 계층이 판단하게 해야 합니다. 사진이나 영상은 본문 데이터와 분리해 업로드 진행률과 재개 가능한 청크 전송을 제공하면 실패 비용을 줄일 수 있습니다.
보안과 인증 만료도 오프라인 시나리오에 포함한다
로컬 DB에 데이터가 있다는 것은 분실된 기기에도 데이터가 남는다는 뜻입니다. 업무상 민감한 필드는 운영체제의 보안 저장소에서 관리하는 키로 암호화하고, 꼭 필요한 범위만 내려받아야 합니다. 로그와 Outbox에 비밀번호나 인증 토큰 같은 비밀값이 복사되지 않는지도 확인해야 합니다.
오프라인 동안 액세스 토큰이 만료될 수 있습니다. 재연결 시 토큰 갱신과 데이터 전송이 경쟁하지 않도록 인증 복구를 먼저 수행하고, 권한이 회수된 사용자의 보류 작업을 어떻게 처리할지도 정해야 합니다. 무조건 전송하는 것은 보안 문제이고 무조건 폐기하는 것은 데이터 손실이므로, 임시 보관 후 관리자 검토 같은 업무 규칙이 필요할 수 있습니다.
운영에서 봐야 할 지표
동기화 장애는 서버의 500 오류만으로는 잘 보이지 않습니다. 서버는 정상인데 특정 앱 버전의 Outbox가 계속 쌓일 수도 있습니다. 따라서 보류 작업 수와 가장 오래된 작업의 나이, 재시도율, 영구 실패율, 충돌률, 한 번의 동기화에 걸린 시간, cursor 지연을 함께 관찰해야 합니다.
로그에는 사용자 개인정보 대신 device_id, operation_id, entity_id, base revision, 결과 코드처럼 흐름을 추적할 수 있는 식별자를 남깁니다. 클라이언트 진단 화면에 마지막 성공 시각과 보류 건수를 보여주면 고객 지원팀도 문제를 훨씬 빨리 분류할 수 있습니다.
반드시 재현해 봐야 할 실패 시나리오
동기화는 정상 경로 테스트만으로 검증되지 않습니다. 아래 상황을 자동화 테스트와 실제 기기 테스트에 포함해야 합니다.
- 저장 직후 비행기 모드로 전환하고 앱을 강제 종료한다.
- 서버는 처리했지만 응답만 유실된 상황에서 같은 요청을 재전송한다.
- 여러 페이지를 받는 도중 앱을 종료하고 다음 실행에서 이어 받는다.
- 두 기기에서 같은 레코드의 같은 필드와 서로 다른 필드를 각각 수정한다.
- 한 기기에서 삭제한 항목을 오래된 다른 기기가 다시 전송한다.
- 기기 시간을 과거 또는 미래로 바꾸고 변경 순서를 확인한다.
- 대용량 첨부 파일 전송 중 연결을 반복해서 끊는다.
- 오프라인 중 권한과 인증 토큰이 만료된 뒤 다시 연결한다.
여기에 네트워크 속도 제한, 응답 지연, 중복 응답, 순서가 뒤바뀐 응답을 주입하면 실서비스에서만 보이던 버그를 개발 단계에서 찾을 수 있습니다.
작게 시작하는 구현 순서
처음부터 범용 동기화 엔진을 만들 필요는 없습니다. 가장 가치가 높은 하나의 엔터티를 선택해 로컬 저장과 Outbox, 멱등한 Push API를 먼저 완성합니다. 다음으로 서버 change sequence와 cursor 기반 Pull을 붙이고, 기본 충돌 정책과 삭제 표식을 추가합니다. 마지막에 백그라운드 실행, 대용량 첨부, 상세 관측성을 확장하는 편이 안전합니다.
이때 UI가 데이터베이스나 HTTP API를 직접 호출하지 않고 Repository 또는 Use Case 계층을 거치게 하면 온라인·오프라인 구현을 교체하기 쉽습니다. 동기화 엔진은 업무 규칙을 모두 알기보다 “무엇을 보낼지”와 “받은 변경을 어떻게 적용할지”를 도메인별 전략에 위임하는 구조가 유지보수에 유리합니다.
오프라인 우선은 예외 처리가 아니라 제품의 약속이다
안정적인 오프라인 앱은 네트워크가 끊겨도 화면이 열린다는 수준을 넘어섭니다. 사용자의 작업을 로컬에서 잃지 않고, 몇 번을 재시도해도 중복 처리하지 않으며, 다른 기기의 변경과 만났을 때 업무 의미에 맞게 병합해야 합니다. 삭제·인증·보안·운영 지표까지 같은 설계 안에 있어야 합니다.
핵심을 한 문장으로 줄이면 이렇습니다. 로컬 저장을 신뢰 가능한 작업 기록으로 만들고, 서버와의 동기화를 언제든 중단되고 다시 시작될 수 있는 프로토콜로 설계하라. 이 원칙을 지키면 통신이 불안정한 현장뿐 아니라 일반 모바일 서비스도 더 빠르고 복원력 있는 제품이 됩니다.