하루의 기록을 준비하고 있어요

잠시만 기다려 주세요.

목록으로

연결됨은 동기화됨이 아니었다 — 무료 한도·구형 iPhone·Realtime 누락을 다룬 이틀

무료 전송량 한도와 구형 iPhone의 멈춘 화면, Realtime 이벤트 누락을 겪으며 저장·신호·복구를 분리하고 연결 뒤에도 상태를 다시 맞추는 구조를 만든 이틀을 기록했습니다.

책처럼 보기
보통
검은 화면의 구형 스마트폰 옆에서 끊어진 파란 실 사이를 흰 복구 경로가 잇고, 주변의 작은 불빛 노드가 연결된 작업대

주제

하루스페이스 랩

연결됨은 동기화됨이 아니었다 — 무료 한도·구형 iPhone·Realtime 누락을 다룬 이틀

무료 전송량 한도와 구형 iPhone의 멈춘 화면, Realtime 이벤트 누락을 겪으며 저장·신호·복구를 분리하고 연결 뒤에도 상태를 다시 맞추는 구조를 만든 이틀을 기록했습니다.

요약

요약

  1. 당시 무료 플랜의 전송량 한도를 계기로 PostgreSQL, Blob과 Realtime의 역할을 분리하고 반복되는 전체 조회를 줄였습니다.
  2. 구독 성공을 동기화 완료로 보지 않고 명시적 인증, 구독 직후 재동기화와 가벼운 버전 확인을 겹쳐 누락에서 회복하도록 설계했습니다.
  3. 공식 지원 범위 밖의 구형 iPhone에는 완전 호환을 약속하는 대신 애니메이션을 줄이고 정적 복구 경로를 남겼습니다.
12페이지
검은 화면의 구형 스마트폰 옆에서 끊어진 파란 실 사이를 흰 복구 경로가 잇고, 주변의 작은 불빛 노드가 연결된 작업대

Summary

한눈에 보기

  • 당시 무료 플랜의 전송량 한도를 계기로 PostgreSQL, Blob과 Realtime의 역할을 분리하고 반복되는 전체 조회를 줄였습니다.
  • 구독 성공을 동기화 완료로 보지 않고 명시적 인증, 구독 직후 재동기화와 가벼운 버전 확인을 겹쳐 누락에서 회복하도록 설계했습니다.
  • 공식 지원 범위 밖의 구형 iPhone에는 완전 호환을 약속하는 대신 애니메이션을 줄이고 정적 복구 경로를 남겼습니다.

이 글은 2026년 7월 13–14일의 Git 기록과 당시 설계 문서를 바탕으로 쓴 회고다. 여기서 말하는 무료 플랜의 한도는 당시 장애를 이해하기 위한 역사적 맥락이며, 현재 서비스의 가격이나 제공량을 안내하는 내용이 아니다.

화면에는 연결됐다는 표시가 떠 있었다. 그런데 상대가 둔 돌은 내 화면에 나타나지 않았다.

새로고침하면 그제야 바뀐 상태가 보였다. 연결 자체가 끊긴 것이라면 오히려 원인을 찾기 쉬웠을 것이다. 문제는 브라우저와 실시간 채널이 모두 “정상”처럼 보이는데 두 사람이 서로 다른 장면을 보고 있다는 데 있었다.

그 이틀 동안 하루스페이스에서는 서로 무관해 보이는 문제가 한꺼번에 만났다. 당시 사용하던 데이터베이스 무료 플랜의 전송량 한도에 닿았고, 구형 iPhone에서는 초기 화면이 끝없이 로딩됐으며, 실시간 게임에서는 구독이 성공한 뒤에도 이벤트가 빠지는 순간이 있었다.

처음에는 세 개의 장애라고 생각했다. 돌아보면 셋은 같은 질문을 던지고 있었다.

연결에 성공했다는 사실만으로, 사용자가 최신 상태를 보고 있다고 말할 수 있는가.

답은 아니었다. 그래서 우리는 더 빠른 연결보다 연결이 틀렸을 때 다시 맞출 수 있는 구조를 만들기 시작했다.

무료 한도가 시스템의 해부도를 보여 줬다

당시 기록에는 무료 플랜의 네트워크 전송량 한도를 넘긴 장애가 남아 있다. 이것은 현재 요금제의 기준을 설명하는 사건이 아니다. 그 시점의 서비스가 무엇을 얼마나 자주 옮기고 있었는지를 처음으로 제대로 들여다보게 만든 사건이었다.

가장 먼저 의심한 것은 채팅의 주기적 조회와 미디어 데이터의 조합이었다. 화면은 새 메시지가 있는지 확인하려고 짧은 간격으로 서버에 물었고, 과거에 데이터베이스 안에 직접 들어간 큰 썸네일 문자열까지 목록 응답에 섞일 수 있었다. 사용자는 새 메시지 하나를 기다렸지만 시스템은 이전에 본 무거운 데이터까지 반복해서 옮기고 있었던 셈이다.

처음에는 조회 간격만 늘리면 될 것처럼 보였다. 실제로 간격을 조정하는 일도 필요했다. 그러나 그것만으로는 같은 문제가 다른 기능에서 되살아날 수 있었다. 원인은 “너무 자주 조회했다”보다 “서로 다른 책임을 한 통로에 넣었다”에 가까웠다.

우리는 저장과 전달의 역할을 다시 나눴다.

  • PostgreSQL은 메시지, 멤버십과 게임 상태처럼 다시 확인할 수 있어야 하는 원본을 맡는다.
  • Blob 저장소는 이미지와 영상 같은 큰 파일을 맡고, 데이터베이스에는 위치와 필요한 메타데이터만 남긴다.
  • Realtime은 원본을 대신하지 않고 “무언가 바뀌었다”는 작은 신호를 빠르게 전달한다.
  • API는 신호를 받았거나 연결을 회복했을 때 권한을 확인한 뒤 최신 원본을 다시 읽는 경로가 된다.

이 구분은 기술 이름을 예쁘게 정리한 것이 아니었다. 비용과 복구 방법을 동시에 정하는 정책이었다. 큰 파일을 데이터베이스 응답에서 빼면 반복 전송이 줄고, 이벤트를 영구 데이터처럼 믿지 않으면 한 번 놓쳐도 원본에서 다시 맞출 수 있다.

그렇다고 이 조치로 전송량 문제가 영구히 사라졌다고 말할 수는 없다. 사용자가 늘고 기능이 바뀌면 병목도 달라진다. 우리가 얻은 것은 “해결 완료”라는 도장이 아니라, 어느 저장소가 어떤 비용을 만들고 무엇을 관찰해야 하는지 설명할 수 있는 해부도였다.

폴링을 줄였지만 Realtime을 절대시하지 않았다

주기적으로 전체 상태를 가져오는 방식은 단순하다. 이벤트를 놓쳐도 다음 조회에서 맞춰진다. 대신 아무 변화가 없어도 계속 읽고, 사용자가 늘면 같은 비용이 반복된다. Realtime은 변화가 있을 때만 반응할 수 있어 훨씬 효율적으로 보였다.

그래서 채팅과 게임의 갱신을 실시간 이벤트 중심으로 옮기기 시작했다. 새 메시지나 게임 동작이 저장되면 작은 이벤트를 보내고, 수신자는 필요한 상태만 다시 가져오는 방식이었다. 연속해서 도착한 이벤트는 잠깐 모아 중복 조회를 줄였다. 화면이 보이지 않을 때는 보조 조회를 멈추고, 다시 열었을 때 한 번 맞추도록 했다.

여기까지는 전형적인 최적화처럼 보였다. 문제는 Realtime에도 빈틈이 있다는 사실을 너무 늦게 진지하게 받아들였다는 데 있었다.

초기 데이터를 읽은 직후 구독이 준비되기 전까지 상태가 바뀔 수 있다. 모바일 네트워크가 와이파이와 셀룰러 사이를 이동하는 동안 채널은 잠시 흔들릴 수 있다. 인증 토큰을 갱신하는 경계에서도 이벤트가 늦거나 빠질 수 있다. 브라우저가 백그라운드에 있다가 돌아오는 순간에는 연결 표시와 실제 상태가 어긋날 수 있다.

이때 구독 성공 콜백만 보고 “동기화 완료”라고 판단하면 조용한 오류가 생긴다. 연결 표시에는 문제가 없고 오류 로그도 없지만, 사용자는 서로 다른 게임판이나 채팅 목록을 보게 된다.

구독됨은 앞으로의 신호를 받을 준비가 됐다는 뜻이지, 지금까지의 상태가 같다는 증명이 아니었다.

구독보다 먼저 인증을 명시했다

실시간 채널은 빠르다는 이유만으로 권한 경계를 건너뛰어서는 안 됐다. 하루스페이스에서는 로그인 여부만으로 충분하지 않았다. 사용자가 현재 선택한 그룹과 방의 구성원인지, 해당 게임의 참가자인지까지 서버가 확인해야 했다.

초기 구현을 점검하면서 인증 정보를 클라이언트에 명시적으로 적용한 뒤 채널을 구독하는 순서를 고정했다. 채팅에서는 로그인 세션과 방 멤버십을, 게임에서는 그룹 소속과 실제 참가 여부를 각각 확인했다. 확인 뒤 해당 방이나 게임에만 쓸 수 있는 짧은 수명의 인증 정보를 발급했다. 브라우저는 그 인증을 먼저 적용한 다음 필요한 채널 하나만 연다. 방이나 게임을 떠나면 이전 채널은 해제한다.

이 순서가 중요했던 이유는 두 가지였다.

첫째, 공개 가능한 연결 정보와 서버만 가져야 하는 비밀을 분리할 수 있었다. 둘째, “채널 이름을 안다”와 “그 데이터를 받을 권한이 있다”를 같은 뜻으로 취급하지 않게 됐다.

당시 설계에는 데이터베이스 정책과 멤버십 검증을 함께 두는 방향도 기록했다. 그러나 이틀 만에 모든 행 수준 권한이 완성됐다고 과장하고 싶지는 않다. 권한은 한 번 켜는 스위치가 아니라 테이블, 읽기 경로와 운영 점검을 따라 계속 성숙시켜야 하는 영역이었다. 그때 확실해진 것은 적어도 브라우저의 구독 성공 여부를 권한 검증의 대리 지표로 삼아서는 안 된다는 점이었다.

이벤트를 놓쳐도 따라잡는 세 겹의 복구

실시간 동기화의 목표를 “모든 이벤트를 반드시 한 번에 받기”에서 “놓쳐도 최신 상태로 돌아오기”로 바꾸자 설계가 달라졌다.

첫 번째 층은 명시적인 초기 조회였다. 화면에 들어오면 서버에서 현재 상태를 먼저 읽는다. Realtime은 그다음 변경을 알려 주는 역할만 맡는다.

두 번째 층은 구독 직후 재동기화였다. 초기 조회와 채널 준비 사이에 생긴 틈을 메우기 위해 구독이 자리 잡은 직후 상태를 한 번 더 맞춘다. “처음 읽었으니 됐다”가 아니라, 듣기 시작한 시점에서 한 번 다시 확인하는 것이다.

세 번째 층은 단계적으로 느려지는 버전 확인이었다. 게임 상태 전체를 계속 읽는 대신 작은 정수 버전만 확인했다. 연결 직후처럼 어긋날 가능성이 큰 짧은 구간에는 자주 살피고, 시간이 지나 안정되면 확인 간격을 늘렸다. 버전이 커졌을 때만 전체 상태를 다시 가져왔다.

이 구조에서는 Realtime이 정상일 때 즉시 반영된다. 이벤트가 잠깐 빠지면 가벼운 버전 확인이 차이를 발견해 원본을 다시 읽는다. 네트워크가 복구되거나 화면이 다시 보이는 순간에도 한 번 재동기화한다.

중요한 것은 보조 조회를 없애지 않고 작게 만든 일이었다. 전체 게임 상태와 메시지를 짧은 간격으로 반복해서 읽는 대신, 버전 하나로 “내가 뒤처졌는가”만 묻는다. 빠른 경로는 Realtime에 맡기고, 정확성은 저장된 원본과 복구 경로가 책임지는 조합이었다.

이 방법 역시 이벤트 누락 자체를 제거하지는 않는다. 우리는 누락 가능성을 인정하고 탐지 시간과 복구 비용을 제한했다. 당시 운영 문서에 남긴 확인 절차도 “누락이 절대 없다”가 아니라 네트워크를 잠시 끊었다가 돌아왔을 때 일정 시간 안에 같은 버전으로 맞춰지는지를 보는 방식이었다.

구형 iPhone에는 완전 호환 대신 돌아갈 문을 남겼다

같은 시기, 한 구형 iPhone에서는 첫 화면이 로딩 상태에서 벗어나지 못했다. Chrome으로 접속했는데도 문제가 나타나 처음에는 브라우저 종류를 의심했다. 그러나 iPhone의 Chrome 역시 같은 시스템 WebKit 위에서 동작한다. 데스크톱 Chrome의 버전만 보고 호환성을 판단할 수 없었다.

당시 사용하던 프레임워크의 공식 최소 지원 범위보다 낮은 운영체제였다. 여기서 선택지는 두 가지였다. 지원한다고 말한 뒤 모든 현대 런타임 동작을 구형 환경에 맞추거나, 지원 경계를 솔직히 밝히되 사용자가 검은 화면이나 무한 로딩에 갇히지 않도록 최선 노력 복구를 제공하는 일이었다.

우리는 두 번째를 택했다.

  • 브라우저 빌드 대상을 가능한 범위까지 넓혔다.
  • 구형 환경에서는 펫과 우주 배경 같은 무거운 애니메이션을 줄이거나 정지시켰다.
  • 로그인처럼 중요한 이동은 클라이언트 전환에만 기대지 않고 전체 페이지 이동으로 돌아갈 수 있게 했다.
  • 초기 클라이언트 실행이 실패해도 일정 시간이 지나면 다시 불러오기와 로그인 링크가 정적인 화면 위에 나타나게 했다.
  • 초기 조회에는 끝나는 시간을 두고, 무한 로딩 대신 사용자가 재시도할 수 있는 상태를 보여 줬다.

마지막 항목이 특히 중요했다. React가 정상적으로 올라와야만 복구 버튼이 생기는 구조라면, React가 실패한 사용자에게는 복구 버튼도 나타나지 않는다. 그래서 최소한의 복구 경로는 화려한 앱 화면 바깥에 남겨야 했다.

이 조치는 구형 iPhone의 모든 기능을 공식 지원한다는 약속이 아니었다. 웹 푸시처럼 더 높은 운영체제 버전을 요구하는 기능도 있었고, 프레임워크 런타임 자체의 한계를 모두 되돌릴 수는 없었다. 우리가 약속할 수 있었던 것은 “완벽히 된다”가 아니라 “안 될 때 아무 설명 없이 갇히게 두지 않는다”였다.

잘못 생각했던 세 가지

이틀을 복기하면 구현보다 먼저 고쳐야 했던 생각이 보인다.

1. 무료라는 말을 비용이 없다는 뜻으로 받아들였다

무료 플랜에도 네트워크, 저장량, 연결과 호출 횟수의 경계가 있다. 가격표만 보고 비용을 계산하면 애플리케이션이 같은 데이터를 몇 번 옮기는지 놓친다. 무료 한도는 개발을 시작하게 도와주는 조건이지, 구조를 고민하지 않아도 된다는 면허가 아니었다.

2. 연결 표시를 제품의 진실로 받아들였다

소켓이 열렸고 채널 구독에 성공했다는 사실은 전송 경로의 상태다. 사용자가 보는 데이터의 최신성은 별도의 문제다. 동기화 여부는 원본의 버전과 마지막으로 확인한 버전을 비교해야 설명할 수 있었다.

3. 호환성을 한 줄짜리 지원 여부로만 보았다

오래된 기기를 완전히 지원하지 못하더라도 할 수 있는 일은 남아 있다. 무거운 기능을 끄고, 기다림에 끝을 두고, 정적인 복구 링크를 제공할 수 있다. “공식 지원 아님”과 “아무것도 하지 않음” 사이에는 넓은 설계 공간이 있었다.

실시간 기능을 만들 때 남긴 체크리스트

지금 비슷한 기능을 다시 만든다면 다음 질문부터 적어 둘 것이다.

원본은 어디에 있는가

이벤트 스트림이나 화면 상태를 원본으로 삼지 않는다. 연결이 모두 끊겨도 다시 읽어 같은 상태를 만들 수 있는 저장소를 정한다.

이벤트에는 얼마나 작은 정보만 담을 수 있는가

변경 전체를 반복 전송하기보다 어떤 대상의 버전이 바뀌었는지 알린다. 민감한 원본은 권한을 다시 확인하는 조회 경로에서 가져온다.

초기 조회와 구독 준비 사이의 틈을 어떻게 메우는가

구독 직후 재동기화하거나 커서를 기준으로 누락분을 읽는다. 순서가 문서와 테스트에 명시돼 있어야 한다.

인증은 구독 전에 적용되는가

로그인, 그룹 멤버십, 방 접근과 게임 참가 권한을 구분한다. 채널 이름을 숨기는 것으로 접근 제어를 대신하지 않는다.

복구 확인은 충분히 가벼운가

전체 상태를 계속 읽지 말고 버전이나 마지막 변경 시각처럼 작은 값을 확인한다. 연결 직후에는 짧게, 안정된 뒤에는 드물게 확인해 비용과 복구 시간을 함께 조절한다.

화면이 다시 보이거나 네트워크가 돌아올 때 무엇을 하는가

브라우저의 가시성 변경과 연결 복구를 재동기화 신호로 사용한다. 단순히 구독 상태 아이콘만 다시 그리지 않는다.

클라이언트 코드가 시작되지 않아도 탈출구가 있는가

지원 경계 밖의 브라우저에는 기능 보장과 복구 가능성을 구분해 안내한다. 핵심 복구 링크는 앱 런타임이 실패해도 보이게 한다.

당시의 한도와 현재의 한도를 구분했는가

요금제와 무료 제공량은 바뀔 수 있다. 회고에는 당시 판단 근거를 남기고, 현재 설계 결정을 할 때는 공식 정보를 다시 확인한다.

동기화 문제는 결국 권한 문제로 이어졌다

이틀이 끝났을 때 변경을 전달하는 경로는 주기 조회보다 즉시 반응하도록 바뀌었고, 누락을 발견해 다시 맞출 수 있는 길도 생겼다. 그렇다고 모든 네트워크와 모든 구형 기기에서 문제가 사라졌다고 결론 내리지는 않았다. 대신 어떤 상태를 원본으로 삼고, 연결이 흔들릴 때 어느 순서로 복구하며, 지원하지 못하는 환경에 무엇을 보여 줄지 설명할 수 있게 됐다.

그리고 다음 질문이 남았다.

누가 그 원본을 읽을 수 있고, 누가 변경 신호를 받을 수 있으며, 누가 실제 상태를 바꿀 수 있는가.

실시간 기능을 붙이기 전에는 연결 속도와 비용이 가장 큰 문제처럼 보였다. 연결이 안정될수록 권한의 층이 더 선명해졌다. 플랫폼을 운영할 권한, 그룹에 속했다는 사실, 특정 행동을 승인할 권한은 서로 같지 않았다.

다음 기록에서는 기능을 더하기 전에 이 권한들을 왜 분리했는지, 그룹 멤버십과 사람의 승인을 어떤 경계로 세웠는지 이어가려 한다.

이어 읽기

이전 글 · 다음 글

이전 글사진 한 장은 왜 비용 정책이 되었고, 채팅방 하나는 왜 보안 정책이 되었나다음 글 버튼보다 먼저 경계를 만들었다 — 플랫폼 권한·그룹 멤버십·사람 승인