기기 교체 후 수신 메시지 복구를 위한 서버 동기화 구현기
목차
기기 교체는 사용자한테는 소소한 이벤트지만, 앱 입장에서는 상태 전체가 증발하는 사건이다. 연락처나 사진은 클라우드 백업이 알아서 챙겨주지만, 앱 내부 데이터는 개발자가 직접 보존 경로를 만들어두지 않으면 그냥 없어진다. 이번에 구현한 건 수신 메시지 복구 - 로컬에만 쌓여 있던 데이터를 서버 원본과 병합하는 동기화 흐름이다.
왜 만들었나
사용자 문의가 꾸준히 들어왔다. "기기 바꿨더니 이전 메시지가 다 사라졌어요." 처음엔 간헐적이었는데 시간이 지날수록 같은 내용이 반복됐고, 로컬 DB에만 쌓아둔 구조라 복구 경로가 아예 없었다. 지워진 데이터는 그냥 지워진 거였다.
근본 원인은 설계 초기에 동기화를 고려하지 않은 것이다. 서버에 원본이 남아 있는데도 앱은 그걸 한 번 받아서 로컬에 박아두는 것으로 끝냈다. 처음부터 "기기가 여러 개일 수도 있다"는 전제를 깔았으면 달라졌겠지만, 런치 속도 우선으로 넘어간 결과다.
로컬-서버 동기화가 없는 앱은 사실 "이 기기에서만 동작하는 앱"이다. 사용자는 그 경계를 인식하지 못한다. 기기를 바꾸면 데이터가 날아간다는 걸 당연하게 받아들이지 않는다. 특히 메시지처럼 시간 순서가 중요한 데이터일수록 복구 불가는 신뢰 손상으로 직결된다. 나중에 고치는 비용이 처음부터 만드는 비용의 몇 배인지 이번에 다시 확인했다.
구조 잡기
레이어를 먼저 정의했다. 동기화 로직이 화면 곳곳에 퍼지면 나중에 손댈 곳이 불분명해지기 때문에, 책임 경계를 먼저 그어놓고 들어갔다.
| 레이어 | 역할 |
|---|---|
| 진입 화면 | 최초 진입 시 동기화 트리거 |
| 설정 화면 | 수동 재동기화 + 마지막 시각 노출 |
| 로컬 DB | 메시지 캐시 + sync 메타데이터 |
| 동기화 모델 | 서버 응답을 로컬 스키마로 매핑 |
서버 호출 시그니처는 단순하게 잡았다. since 하나만 넘기고 증분으로 받는 방식.
suspend fun sync(since: Long): Result<List<Message>>
since 기반 증분 동기화의 장점은 명확하다. 첫 실행 이후로는 변경분만 주고받으니 트래픽이 줄고, 로컬 DB와 병합할 범위도 한정된다. 풀싱크를 매번 돌리면 데이터가 조금만 쌓여도 응답이 느려지고, 그 비용을 사용자가 로딩 시간으로 그대로 떠안는다. 단점은 서버에서 과거 데이터가 삭제됐을 때 로컬에 반영이 안 된다는 것인데, 이번 요구사항에서는 해당 케이스를 다루지 않았다. 필요하면 나중에 전체 재동기화 옵션을 추가하면 된다.
진입 화면은 트리거만, 설정 화면은 상태 노출과 수동 버튼만 담당하게 했다. 동기화 자체 로직은 한 군데에 모아서 두 화면이 같은 함수만 호출하게끔. 처음엔 진입 화면 안에 코드를 다 박았다가 설정 화면에서 또 복붙하는 자신을 발견하고 바로 분리했다. 기능이 두 군데 이상에서 필요해지는 순간이 모듈화 시점이다.
삽질 모음
첫 진입 풀싱크. 처음엔 앱 켜자마자 전체 메시지를 다 받아오게 했다. 첫 화면이 5초 가까이 멈추니 컴플레인이 바로 들어왔다. 백그라운드로 빼고 증분 동기화로 바꾸면서 해소됐다.
// 진입 화면 - 트리거만, 결과를 기다리지 않음
LaunchedEffect(Unit) {
launch { syncRepository.sync(since = prefs.getLong("last_synced_at", 0L)) }
}
진입 UX는 데이터 정합성보다 우선이다. 화면이 멈추면 사용자는 앱이 죽었다고 판단한다. 동기화가 끝나야 화면을 보여줘야 한다는 생각 자체를 버려야 했다. 데이터가 조금 늦게 채워지더라도, 화면이 먼저 떠야 한다.
PK 충돌. 같은 메시지가 중복 인서트되면서 에러가 났다. 로컬에 이미 있는 ID와 서버에서 받아온 ID가 겹치는 케이스였다. INSERT를 UPSERT로 바꿔서 정리했다. 동기화 로직에서 단순 INSERT는 거의 쓸 일이 없다. 어디서 왔든 같은 ID면 덮어쓰는 게 맞고, 그게 UPSERT의 존재 이유다.
@Query("INSERT OR REPLACE INTO messages (id, content, receivedAt) VALUES (:id, :content, :receivedAt)")
suspend fun upsert(id: String, content: String, receivedAt: Long)
DB 마이그레이션 누락. sync 메타데이터를 담을 컬럼을 추가했는데, 기존 사용자는 마이그레이션 스크립트가 없으니 앱 켜자마자 크래시가 났다. 컬럼 추가는 항상 DEFAULT 값과 폴백을 같이 작성하는 습관을 이번에 들였다.
ALTER TABLE messages ADD COLUMN last_synced_at INTEGER DEFAULT 0 NOT NULL;
신규 사용자한테는 당연히 돌아가지만, 기존 사용자 입장에서 마이그레이션은 무조건 하위 호환을 보장해야 한다. Room을 쓴다면 Migration 객체를 명시적으로 등록하고, fallbackToDestructiveMigration은 프로덕션에서 쓰지 말 것. 데이터를 밀어버리는 건 개발 편의고, 사용자 입장에서는 그냥 데이터 손실이다.
마지막 동기화 시각 미기록. 동기화가 끝나도 언제 돌았는지 저장을 안 해뒀더니 디버깅이 지옥이었다. 서버 응답은 정상인데 since 값이 계속 0으로 넘어가고 있었다. 메타 필드는 처음부터 박아두는 게 답이다.
// 동기화 완료 후 반드시 기록
prefs.edit { putLong("last_synced_at", System.currentTimeMillis()) }
이번 작업에서 건진 것들을 정리하면:
- 첫 진입 UX가 데이터 정합성보다 우선. 무거운 작업은 무조건 백그라운드로
- upsert + lastSyncedAt 조합이 동기화 최소 단위. 둘 중 하나 빠지면 어디선가 반드시 터짐
- 인디케이터는 기능이 아니라 필수. 백그라운드에서 동기화가 돌아도 사용자한테 신호가 없으면 "안 됨"으로 인식한다. 진행 표시, 완료 토스트, 마지막 동기화 시각 - 보이지 않으면 없는 것이다
- 기능 하나 추가했을 뿐인데 진입 화면, 설정 화면, 로컬 DB, 모델 네 군데가 같이 흔들렸다
마지막 것이 제일 아팠다. 동기화는 기능이 아니라 인프라에 가깝다. 데이터 모델과 저장 전략에 처음부터 영향을 준다는 뜻이다. 다음번엔 "이 데이터가 기기 간에 공유될 수 있는가"를 설계 초기에 물어보는 것부터 시작할 생각이다.
댓글 0
첫 댓글 달아줘.