Supabase Realtime은 조용히 끊긴다 — React Native 실시간 동기화 트러블슈팅
목차
1. 문제 상황: "아빠가 기록했는데 왜 안 보여?"
가족 공유 편에서 저는 실시간 반영에 대해 꽤 단호하게 적었습니다. WebSocket 연결을 상시 유지하는 것은 배터리와 서버 비용 측면에서 과잉 대응이고, 앱이 포그라운드로 돌아올 때 증분 동기화를 돌리는 것으로 충분하다고. 그 판단은 한동안 유효했습니다. 그런데 가족 공유 사용이 쌓이면서 "충분함"의 기준이 달라지는 장면들이 생겼습니다. 부부가 각자의 폰으로 같은 홈 화면을 열어둔 채 대화하는 상황이 대표적입니다. 아빠가 수유 종료 버튼을 눌렀는데 엄마 폰에는 여전히 "수유 중"이 떠 있으면, 엄마는 앱을 내렸다 올리거나 화면을 당겨서 새로고침을 해야 합니다. 몇 초의 지연 자체보다, "내 화면이 지금 최신인가?"를 사용자가 스스로 의심해야 한다는 것이 문제였습니다. 기록 앱에서 신뢰는 곧 습관이고, 습관은 곧 생존이기 때문입니다.
그래서 2.8 버전대에서 Supabase Realtime을 도입했습니다. records 테이블에 postgres_changes 구독을 걸고, 현재 선택된 아기의 baby_id로 필터를 걸어서, INSERT·UPDATE·DELETE 이벤트가 오면 Zustand 스토어의 로컬 헬퍼(addRecordToLocal 등)로 즉시 반영하는 구조입니다. 첫날부터 삽질이 하나 있었는데, 구독 코드는 완벽한데 이벤트가 한 건도 오지 않아 한참을 헤맸습니다. 원인은 코드가 아니라 설정이었습니다. Supabase는 테이블별로 Realtime(Replication)을 명시적으로 켜야 이벤트를 발행합니다. 대시보드에서 토글 하나를 켜자 그제야 이벤트가 흘러왔습니다.
토글을 켠 뒤의 경험은 꽤 감동적이었습니다. 한 폰에서 기록을 남기면 옆에 놓인 다른 폰의 리스트가 스르륵 갱신됩니다. 그런데 이 감동은 오래가지 않았습니다. 며칠 쓰다 보니 이상한 패턴이 보이기 시작했습니다. 분명 실시간으로 잘 되던 것이, 어느 순간부터 안 됩니다. 아빠 폰에서 기록했는데 엄마 폰에 안 뜨고, 앱을 껐다 켜면 그제야 뜹니다. 콘솔에 에러가 찍히는 것도 아니고, 크래시 리포트가 오는 것도 아닙니다. 실패인데 실패했다는 신호가 어디에도 없는, 전형적인 조용한 실패(silent failure)였습니다. 그리고 테스트 기기를 늘리다가 더 당황스러운 사실을 발견했습니다. Android에서는 간헐적으로 안 되는 수준이 아니라, 처음부터 아예 수신이 되지 않고 있었습니다.
2. 원인 추적 1: Android에서는 첫 이벤트조차 오지 않았다
문제를 둘로 쪼갰습니다. 하나는 "Android 전면 불통", 다른 하나는 "iOS 포함 모든 플랫폼에서 시간이 지나면 끊김"입니다. 성격이 달라 보였고, 실제로 원인도 달랐습니다.
먼저 Android부터 파고들었습니다. 재현은 단순했습니다. 기기 두 대를 나란히 두고 포그라운드를 유지한 채 한쪽에서 기록을 남기는 것입니다. iOS 두 대 조합은 잘 동작하는데, Android가 끼면 Android 쪽 수신이 없습니다. 이상한 것은 구독 자체는 성공한다는 점이었습니다. 채널 상태 콜백에 SUBSCRIBED가 정상적으로 찍히고, 그 뒤로 아무 일도 일어나지 않습니다. 구독은 됐다고 하는데 이벤트는 오지 않는 상태. 서버 문제인지 클라이언트 문제인지 가르기 위해 같은 계정으로 웹에서 구독을 걸어 보니 이벤트는 정상적으로 발행되고 있었습니다. 그렇다면 범인은 클라이언트, 그중에서도 Android 런타임에서만 다르게 동작하는 무언가입니다.
범인은 전혀 예상하지 못한 곳에 있었습니다. 몇 주 전 iOS 실기기 성능 최적화를 하면서, 네트워크가 불안정할 때 요청이 무한정 매달리는 것을 막으려고 Supabase 클라이언트의 global.fetch에 10초 AbortController 타임아웃을 넣어 두었습니다. REST 요청에는 합리적인 방어막입니다. 문제는 supabase-js의 global.fetch 옵션이 REST 호출에만 쓰이는 것이 아니라는 점입니다. Realtime 관련 요청까지 같은 fetch 래퍼를 타고 나가면서, 오래 유지되어야 할 연결이 10초마다 잘려 나가고 있었습니다. iOS에서는 플랫폼 네트워크 스택의 동작 차이로 증상이 드러나지 않았고, Android에서만 전면 불통이라는 형태로 나타났습니다. 수정은 허무할 만큼 간단했습니다. URL에 /realtime/ 경로가 포함된 요청은 타임아웃 대상에서 제외하는 것입니다.
// Supabase 클라이언트 — REST 요청에만 10초 타임아웃, Realtime은 제외
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
fetch: (url, options) => {
const urlStr = typeof url === "string" ? url : url?.toString?.() ?? "";
// Realtime(WebSocket) 요청은 장시간 유지되어야 하므로 제외
if (urlStr.includes("/realtime/")) {
return fetch(url, options);
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeoutId));
},
},
});이 버그에서 배운 것은 수정 코드 여섯 줄보다 큽니다. 타임아웃을 넣은 커밋은 "성능 최적화"였고, Realtime이 죽은 증상은 "가족 공유 동기화 불량"이었습니다. 두 지점 사이에는 표면적인 연관이 전혀 없어서, 커밋 이력을 시간순으로 되짚기 전까지는 의심조차 하지 못했습니다. 전역 레이어(이 경우 fetch 래퍼)를 건드리는 최적화는 그 영향 범위가 선언한 의도보다 항상 넓다는 것, 그리고 부작용은 가장 멀리 떨어진 기능에서 터진다는 것을 몸으로 배웠습니다.
3. 원인 추적 2: WebSocket은 죽었다고 말해주지 않는다
Android 문제를 고치고 나니 두 번째 문제의 윤곽이 선명해졌습니다. 모든 플랫폼에서, 앱을 백그라운드에 뒀다가 돌아오면 실시간 반영이 멈춰 있는 경우가 생깁니다. 재현 절차를 정리해 보니 패턴이 보였습니다. 앱을 백그라운드로 보내고 몇 분 뒤 복귀하거나, Wi-Fi에서 LTE로 네트워크가 전환되거나, 지하철처럼 연결이 출렁이는 환경을 지나오면 높은 확률로 멈춰 있습니다.
원리 자체는 모바일 개발자라면 익숙한 이야기입니다. OS는 백그라운드로 내려간 앱의 소켓을 언제든 회수할 수 있고, 네트워크 인터페이스가 바뀌면 기존 TCP 연결은 의미를 잃습니다. 진짜 문제는 그다음입니다. 소켓이 죽었다는 사실을 클라이언트가 즉시 알지 못한다는 것입니다. 채널 상태는 여전히 구독 중이라고 믿고 있고, 하트비트가 몇 차례 어긋난 뒤에야 TIMED_OUT이나 CLOSED 콜백이 도착합니다. 그 공백 동안 앱은 "실시간 연결이 살아 있다"고 착각한 채 조용히 이벤트를 놓칩니다. 에러가 나는 실패보다 다루기 어려운 것이 이런 종류의 실패입니다.
여기서 더 구조적인 문제와 마주쳤습니다. 재연결만으로는 데이터가 맞지 않는다는 점입니다. Supabase Realtime의 postgres_changes는 메시지 큐가 아니라 브로드캐스트입니다. 연결이 끊어진 동안 발생한 INSERT와 UPDATE는 어딘가에 쌓여서 기다려 주지 않습니다. 재구독에 성공해도 공백 기간의 변경분은 영영 오지 않습니다. 즉 "끊기면 다시 붙는다"는 전략만으로는, 다시 붙은 시점의 로컬 데이터가 이미 서버와 어긋나 있을 수 있습니다.
삭제는 또 다른 축의 문제였습니다. 데일리베이비의 증분 동기화는 updated_at이 마지막 동기화 시점보다 큰 행을 가져오는 방식입니다. 그런데 행이 물리적으로 DELETE되면 그 행은 쿼리 결과에 아예 존재하지 않으므로, 증분 fetch는 삭제를 원천적으로 감지할 수 없습니다. 한동안은 새로고침 시 최근 구간을 서버 데이터로 통째로 교체하는 방식으로 삭제를 흉내 냈는데, 이 교체 로직이 타임존 경계와 얽히면서 멀쩡한 기록이 잠깐 사라졌다 나타나는 부작용까지 만들고 있었습니다. 실시간 이벤트, 증분 fetch, 삭제 감지가 각자 다른 구멍을 가진 채 서로를 불완전하게 보완하는 형국이었습니다.
4. 해결: 이벤트는 신호일 뿐, 진실은 fetch에 있다
해결의 출발점은 코드가 아니라 관점 정리였습니다. Realtime 이벤트를 데이터의 원천(source of truth)으로 삼는 것을 포기하고, "로컬 캐시를 빨리 갱신하라는 힌트" 정도로 격하시켰습니다. 진실은 언제나 lastSyncedAt 기반의 증분 fetch가 가져오는 서버 데이터입니다. 이벤트가 잘 들어오면 체감 지연이 0에 가까워서 좋고, 이벤트가 유실되더라도 다음 보정 fetch가 어차피 데이터를 맞춰 줍니다. 이 원칙 위에서 복구 로직을 계층적으로 쌓았습니다.
첫째, 구독 상태 콜백을 유일한 재연결 트리거로 삼았습니다. CHANNEL_ERROR나 TIMED_OUT이 오면 3초에 시도 횟수를 곱한 지연을 두고 재구독하되, 최대 3회까지만 시도합니다. 3회를 소진하면 미련 없이 disconnected 상태로 전환하고, 그 시점에 증분 fetch를 한 번 돌려 공백 기간의 데이터를 보정한 뒤, 5분 후 재시도를 예약합니다. 연속 실패가 누적되면 재시도 간격을 10분으로 늘립니다. 초기 버전에는 주기적으로 연결 상태를 확인하는 health check 폴링도 있었는데, 상태 콜백과 역할이 겹치는 데다 타이머만 하나 더 늘리는 꼴이라 제거했습니다. 모바일에서 재연결은 "무한히 성실하게"가 아니라 상한과 백오프가 있는 정책이어야 합니다. 전파가 없는 곳에서 3초마다 영원히 재접속을 시도하는 앱은 배터리를 갉아먹는 앱입니다.
둘째, AppState 복귀를 보정의 축으로 삼았습니다. 앱이 active로 돌아오는 순간에는 소켓의 생사와 무관하게 증분 fetch를 실행합니다. 백그라운드 동안 이벤트를 놓쳤을 가능성이 가장 높은 시점이 바로 복귀 시점이기 때문입니다. 변경분만 가져오는 쿼리라 대부분 몇 건 이내로 끝나서 비용 부담도 없습니다. 이어서 동기화 상태가 disconnected면 재시도 카운터를 리셋하고 재구독을 겁니다. 전체 정책을 한 장으로 요약하면 이렇습니다.
구독 복구 정책 (수도코드):
상태 콜백:
SUBSCRIBED → 카운터 리셋, 상태 = connected
CHANNEL_ERROR/TIMED_OUT →
IF 재시도 < 3회 → 3초 × 회차 지연 후 재구독 (connecting)
ELSE → 상태 = disconnected
증분 fetch 1회 (놓친 변경 보정)
5분 후 재시도 예약 (연속 실패 시 10분)
AppState → "active":
증분 fetch (updated_at > lastSyncedAt)
IF 상태 == disconnected → 카운터 리셋 후 재구독
셋째, 보정 fetch의 과다 호출을 정리했습니다. 포그라운드 복귀라는 같은 신호에 반응하는 훅이 여러 개 생기면서 fetch가 겹치는 문제가 있었습니다. 역할을 나눠서, 실시간 훅의 복귀 보정은 즉시 1회, 장기 부재를 다루는 훅은 백그라운드 체류 5분 이상일 때만 동작하게 했습니다. 여기에 iOS의 AppState가 inactive를 거쳐 background로 내려가며 이탈 시점이 두 번 기록되는 문제를 최초 이탈 시점만 남기는 것으로 막고, 이미 동기화 중이면 건너뛰는 가드를 더해 동시 실행을 차단했습니다.
넷째, 멱등성입니다. 내가 기록을 저장하면 addRecord의 응답과 내 기기로 되돌아오는 INSERT 이벤트가 경쟁합니다. 어느 쪽이 먼저 도착하든 결과가 같도록, 로컬 반영 함수는 id 기준 중복 체크를 먼저 하도록 만들었습니다. 실시간이 끼어드는 순간 모든 쓰기 경로는 "두 번 실행돼도 안전한가"를 검사받아야 합니다.
다섯째, 삭제를 soft delete로 전환했습니다. 물리 삭제 대신 deleted_at 타임스탬프를 기록하는 UPDATE로 바꾸자 세 가지가 한 번에 풀렸습니다. Realtime에서는 UPDATE 이벤트의 deleted_at을 보고 로컬에서 제거하면 되고, 증분 fetch도 삭제를 "변경된 행"으로 자연스럽게 감지하며, 삭제 감지용으로 유지하던 구간 교체 로직은 통째로 걷어낼 수 있었습니다. 새로고침 때 기록이 깜빡이던 버그도 이때 함께 사라졌습니다.
마지막으로, 이 모든 것을 사용자에게 보이게 만들었습니다. 헤더에 연결 상태를 나타내는 클라우드 아이콘을 달아 connected, connecting, disconnected를 구분해 보여주고, 끊김 상태에서 아이콘을 탭하면 즉시 재연결을 시도합니다. 조용한 실패를 디버깅하는 첫걸음은 실패를 시끄럽게 만드는 것이었습니다. 상태 아이콘은 개발 중에는 관측 도구였고, 출시 후에는 "지금 실시간인가?"라는 사용자의 불안을 없애는 UI가 됐습니다.
5. 배운 점
이번 트러블슈팅을 관통하는 교훈을 한 문장으로 줄이면 이렇습니다. 실시간 연결은 "유지되는 것"이 아니라 "계속 다시 맺는 것"입니다. WebSocket은 기본적으로 끊긴다고 가정해야 하고, 모바일에서는 그 가정이 훨씬 자주 참이 됩니다. 백그라운드 전환, 네트워크 전환, OS의 소켓 회수는 예외 상황이 아니라 일상입니다. 구독을 거는 코드는 전체의 10%였고, 나머지 90%는 끊김을 감지하고 복구하고 보정하는 코드였습니다. Realtime 기능의 견적을 낼 때는 후자를 기준으로 잡아야 합니다.
설계 차원에서는 이벤트 스트림과 fetch의 역할 분리가 핵심이었습니다. 이벤트는 빠르지만 유실될 수 있고, fetch는 느리지만 확실합니다. 이벤트를 힌트로, 증분 fetch를 진실로 두는 이중 구조는 어느 한쪽이 무너져도 데이터 정합성이 유지되는 안전망이 되어 주었습니다. 그리고 soft delete처럼 데이터 모델을 조금 바꾸는 것이 클라이언트의 복잡한 보정 로직 여러 개를 대체할 수 있다는 것도 기억해 둘 만한 지점입니다. 클라이언트에서 애쓰고 있다면, 스키마에서 풀 수 있는 문제가 아닌지 먼저 의심해 보는 것이 좋습니다.
한 가지 더. 이번 사건의 시작이 성능 최적화 커밋이었다는 사실을 저는 꽤 오래 곱씹었습니다. 전역 fetch 래퍼에 넣은 10초 타임아웃이 몇 주 뒤 Realtime을 침묵시켰듯, 전역 레이어에 대한 수정은 반드시 의도보다 넓게 퍼집니다. 그 성능 최적화 자체도 사연이 깁니다. 모든 화면 위에 떠 있는 플로팅 퀵바가 앱 전체를 느리게 만들고 있었기 때문인데, 그 이야기는 다음 글에서 이어가겠습니다.
새벽 수유 중에 한 손으로 기록할 수 있는 앱을 찾고 있다면, 데일리베이비를 한번 써보시기 바랍니다.