본문으로 건너뛰기

이름이 같은 Supabase Realtime 채널이 서로를 잡아먹은 이야기

2026-08-07 · 3분 읽기

온라인 바둑 로비 화면에는 "N명 접속 중"이라는 작은 표시가 있다. Supabase Realtime의 Presence 기능으로 지금 로비에 접속해 있는 인원 수를 세어 보여주는 용도다. 그런데 배포하고 나서 닉네임을 입력하고 들어가면 이 표시가 영원히 "연결 중..."에 멈춰 있다는 제보를 받았다.

증상

  • 콘솔에 빨간 에러 없음

  • Network 탭의 websocket 요청은 101 Switching Protocols — 연결 자체는 성공

  • 그런데도 접속 인원 숫자는 절대 뜨지 않고 계속 "연결 중..."

에러가 없다는 게 가장 헷갈리는 부분이었다. 뭔가 터졌으면 스택 트레이스라도 나올 텐데, 아무 일도 없었다는 듯이 그냥 아무것도 안 뜨는 상태였다.

삽질 순서

1. 서버/인프라부터 의심

이 프로젝트는 Oracle Cloud ARM 인스턴스에 Supabase를 self-host 중이다. 그래서 처음엔 당연히 인프라 쪽을 의심했다.

  • nginx가 /realtime/v1 경로에 WebSocket 업그레이드 헤더를 제대로 프록시하고 있는지 (Upgrade, Connection: upgrade, proxy_http_version 1.1) — 확인 완료, 정상

  • Realtime 컨테이너 로그 확인 — 5분간 트래픽이 없으면 idle-shutdown 되었다가 다음 연결에서 다시 DB 연결·복제 슬롯을 만드는 콜드스타트 패턴 발견. 유력한 용의자처럼 보였다.

  • Kong의 CORS·key-auth 플러그인 설정도 확인 — 문제 없음

콜드스타트로 인한 join 타임아웃이 원인이라고 가정하고, subscribe() 콜백에서 CHANNEL_ERROR / TIMED_OUT / CLOSED 상태를 처리하지 않고 있던 것도 고쳐서 재시도 로직을 붙여 배포했다. 하지만 재현 결과는 그대로 "연결 중..."

2. 진짜 웹소켓 프로토콜이 문제인지 직접 검증

서버가 정말 문제인지 확인하려고 Node.js에서 실제 프로덕션 서버에 대고 브라우저와 똑같은 @supabase/supabase-js 클라이언트로 raw 테스트를 해봤다.

const channel = supabase.channel('lobby', {
  config: { presence: { key: 'node-probe' } },
});
channel.on('presence', { event: 'sync' }, () => console.log('sync', channel.presenceState()));
channel.subscribe(async (status) => {
  console.log('STATUS', status);
  if (status === 'SUBSCRIBED') {
    console.log('TRACK', await channel.track({ nickname: 'node-probe' }));
  }
});

결과: SUBSCRIBED → track → join/sync 이벤트까지 전부 즉시 정상 동작. 서버, 프로토콜, 네트워크 전부 무죄로 판명.

3. 브라우저 쪽 의심 — 확장 프로그램, 캐시

사용자는 광고 차단 확장 프로그램을 쓰고 있었기에, 확장 프로그램이 웹소켓 프레임을 건드리는 건 아닌지 의심했다. 시크릿 창에서도 재현되어 기각. 배포된 JS 청크가 실제로 최신 코드인지도 해시까지 대조해서 확인했다 — 최신 코드 맞음.

4. 결정적 단서 — 콜백이 아예 한 번도 안 불림

channel.subscribe() 콜백 안에 console.log를 넣고 다시 배포한 뒤 사용자에게 재현을 부탁했다. 결과: 로그가 단 한 줄도 찍히지 않았다. effect는 정상적으로 실행되고 닉네임 값도 제대로 들어오는데, subscribe() 콜백만 마치 존재하지 않는 것처럼 조용했다.

이 시점에서 실마리가 잡혔다. "콜백이 아예 안 불린다"는 건 곧 "이 채널은 이미 누군가 구독해버린 채널"이라는 뜻일 수 있다.

진짜 원인

@supabase/realtime-js의 channel() 메서드는 같은 topic 이름으로 이미 만들어진 채널이 있으면 새로 만들지 않고 기존 채널 객체를 그대로 반환한다.

channel(topic: string, params = { config: {} }): RealtimeChannel {
  const realtimeTopic = `realtime:${topic}`;
  const exists = this.getChannels().find(c => c.topic === realtimeTopic);
  if (!exists) {
    const chan = new RealtimeChannel(`realtime:${topic}`, params, this);
    this.channels.push(chan);
    return chan;
  }
  return exists; // ⚠️ 새로 넘긴 params(=presence 설정)는 통째로 무시된다
}

그리고 로비 화면 컴포넌트 트리를 보면:

LobbyPage               ← supabase.channel('lobby', { presence 설정 }) 로 인원수 추적
└── RoomList             ← supabase.channel('lobby') 로 방 목록 실시간 갱신 구독

둘 다 'lobby'라는 같은 이름의 채널을 쓰고 있었다. React는 자식 컴포넌트의 effect를 부모보다 먼저 실행하기 때문에, RoomList의 useEffect가 먼저 돌면서 presence 설정 없이 'lobby' 채널을 구독해버린다. 그 다음 LobbyPage의 effect가 실행되어 supabase.channel('lobby', { presence 설정 })을 호출하지만, 이미 존재하는 채널이라 새로 만들어지지 않고 RoomList가 만든 presence 없는 채널을 그대로 돌려받는다.

그 채널은 이미 subscribe()가 호출되어 joined 상태이기 때문에, LobbyPage가 다시 subscribe(callback)을 호출해도 이미 조인된 채널에 대한 재구독은 조용히 무시되고 새로 넘긴 콜백은 절대 호출되지 않는다. 에러는 안 난다. 그냥 아무 일도 안 일어날 뿐이다.

고친 방법

두 기능이 서로 다른 목적으로 채널을 쓰고 있으니, 애초에 이름을 겹치지 않게 분리하면 끝이다.

- channel = supabase.channel('lobby', {
+ channel = supabase.channel('lobby-presence', {
    config: { presence: { key: nickname } },
  });

배운 점

  • "에러가 없다"는 "정상이다"의 증거가 아니다. 라이브러리가 조용히 no-op 하는 경우, 증상만 보면 마치 네트워크나 인프라 문제처럼 보인다.

  • 라이브러리가 리소스를 이름으로 캐싱/재사용하는 패턴(싱글턴 커넥션, 채널 dedup 등)이 있다면, 같은 이름을 쓰는 다른 코드가 있는지부터 grep 한 번 해보는 게 근본 원인에 가장 빨리 닿는 길이었다. 정작 인프라 로그 뒤지고, raw 웹소켓 프로토콜 테스트하고, 확장 프로그램 의심하는 데 쓴 시간이 훨씬 길었다.

  • 원격 서버 + SSH 접속만 가능한 환경에서 브라우저를 못 띄울 때는, 사용자에게 콘솔에 붙여넣을 진단 스크립트를 만들어주고 결과를 텍스트로 받는 것만으로도 충분히 이분탐색하듯 원인을 좁혀갈 수 있었다.