Playreading은 작가가 낭독 일정을 올리고 배우가 지원하는 낭독극 매칭 서비스입니다. Next.js 앱을 Vercel에 올리고 DB는 Supabase Postgres를 씁니다. 어제 인앱 알림 기능을 배포했고, 오늘 오전 Sentry가 울렸습니다. 이 글은 그 알람 하나를 따라가서 원인을 찾고, 재현하고, 고친 기록입니다.
증상: DB 연결 한도 초과, 그런데 앱 코드가 없다
에러 메시지는 이랬습니다.
DriverAdapterError: (EMAXCONN) max client connections reached, limit: 200
Supabase가 DB 앞에 둔 연결 풀러(pgbouncer 계열)가 "클라이언트 자리 200개가 다 찼다"고 거절한 것입니다. 3분 동안 500건, 영향받은 사용자 6명. 거의 전부 알림 목록을 가져오는 GET /api/notifications에서 났습니다.
스택 트레이스를 열어 보니 Prisma 런타임과 pg 어댑터 프레임뿐이고 우리 코드는 한 줄도 없었습니다. 이런 에러는 코드 결함이 아니라 환경 문제로 분류하고 싶어집니다. "DB 플랜이 작아서 그렇다"로 끝내고 싶은 유혹이 있습니다. 하지만 배포 직후에만 터진 점, 특정 API에만 몰린 점이 걸렸습니다. 연결이 200개나 필요할 만큼 사용자가 많은 서비스가 아니기 때문입니다.
추적: 누가 얼마나 호출했나
Sentry의 트레이스 데이터(요청 10건 중 1건 샘플링)로 GET /api/notifications 호출을 사용자별로 세어 봤습니다.
| 사용자 | 호출 수(보정치) | 기간 |
|---|---|---|
| A | 약 4,300회 | 배포 후 105분 |
| B | 약 1,000회 | 90분 |
| C | 약 170회 | 1초 |
알림 클라이언트는 실시간 연결이 안 될 때 60초마다 한 번 폴링하도록 만들었습니다. 사용자 한 명이 분당 40회, 어떤 순간에는 초당 170회를 호출할 이유가 없습니다. 브라우저가 미친 게 아니라면 코드가 미친 것입니다.
한 가지가 더 보였습니다. 한 사용자의 요청이 서로 다른 Vercel 함수 인스턴스 20개에 흩어져 있었습니다. 서버리스는 동시에 들어온 요청을 여러 인스턴스로 나눠 받고, 인스턴스마다 DB 연결을 하나씩 엽니다. 즉 브라우저가 1초에 170번 호출하면 인스턴스가 수십 개 뜨고, 그만큼 DB 연결이 열립니다. 사용자 6명이 동시에 이러면 200개는 금방 찹니다.
DB 연결 한도 초과는 결과였고, 원인은 브라우저 쪽에 있었습니다.
원인: 구독을 버리는 도중에 콜백이 되돌아온다
알림 클라이언트는 Supabase Realtime(내부는 phoenix 채널)에 구독을 걸고, 끊기면 백오프로 재연결하고, 3번 연속 실패하면 폴링으로 내려가는 작은 상태 기계입니다. 문제의 부분은 이렇게 생겼습니다.
function dropSubscription() {
if (subscription) {
subscription.unsubscribe(); // ← 여기서 일이 벌어진다
subscription = null;
}
}
function scheduleReconnect() {
dropSubscription();
consecutiveFailures += 1;
if (consecutiveFailures >= 3) {
enterPolling(); // 폴링 진입 시 onRefresh() 1회
return;
}
// ... 백오프 타이머
}
// 채널 상태 콜백
onStatus: (status) => {
if (myAttempt !== attempt) return; // 옛 시도의 늦은 콜백은 무시
if (status === "SUBSCRIBED") { /* ... */ return; }
scheduleReconnect();
}
attempt는 연결 시도마다 올라가는 세대 번호입니다. 옛 시도에서 늦게 도착한 콜백을 무시하려고 둔 가드입니다. 여기까지는 문제가 없어 보입니다.
함정은 phoenix의 leave() 구현에 있었습니다. 소켓이 살아 있으면 서버에 leave를 보내고 응답이 오면 close 이벤트를 냅니다. 비동기입니다. 그런데 소켓이 이미 끊긴 상태면 서버에 물어볼 수 없으니 그 자리에서 바로 close를 냅니다. 같은 호출 스택 안에서, 동기적으로요.
// phoenix channel.leave() 요지
leavePush.receive("ok", () => this.trigger("close", "leave"));
leavePush.send();
if (!this.canPush()) { leavePush.trigger("ok", {}); } // 소켓이 죽었으면 즉시
supabase-js는 이 close를 구독 콜백의 "CLOSED" 상태로 넘깁니다. 이제 흐름을 따라가 보면 이렇습니다.
- 모바일에서 소켓이 끊긴다. 콜백에
CHANNEL_ERROR가 온다. scheduleReconnect()→dropSubscription()→unsubscribe().- phoenix가 같은 스택에서
close를 내고, 콜백에CLOSED가 온다. attempt는 아직 그대로다. 가드를 통과한다.subscription도 아직null이 되기 전이다. 다시unsubscribe().- 3번으로 돌아간다.
스택이 터질 때까지 재귀합니다. 그런데 RangeError로 끝나는 게 아닙니다. supabase-js의 unsubscribe()는 new Promise(...)로 감싸여 있어서, 가장 안쪽에서 터진 스택 오버플로는 그 Promise의 reject로 삼켜집니다. 바깥 단계들은 아무 일 없다는 듯 계속 실행됩니다.
계속 실행되면 무슨 일이 생기느냐. 각 단계의 scheduleReconnect()가 consecutiveFailures를 하나씩 올립니다. 세 번째 단계부터는 전부 enterPolling()으로 들어가고, enterPolling()은 즉시 onRefresh()를 부릅니다. 재귀 깊이가 335였다면 onRefresh()가 332번 불립니다. 그것이 곧 fetch("/api/notifications") 332개입니다. 한 틱에요.
재현: 진짜 라이브러리를 물려서 숫자를 본다
가설이 그럴듯해도 숫자로 확인하고 싶었습니다. 실제 supabase-js와 phoenix, 그리고 우리 transport를 그대로 연결하고, 소켓 주소만 열리지 않는 포트로 줬습니다.
const rt = new RealtimeClient("ws://127.0.0.1:9/realtime/v1", { params: { apikey: "x" } });
let refreshes = 0;
const transport = createNotificationTransport({
realtime: {
subscribe(topic, { onStatus }) {
const ch = rt.channel(topic, { config: { private: true } })
.subscribe((status) => onStatus(status));
return { unsubscribe() { void rt.removeChannel(ch); } };
},
// ...
},
onRefresh: () => { refreshes++; },
// ...
});
transport.start();
결과입니다.
statuses: { CHANNEL_ERROR: 1, CLOSED: 345 }
onRefresh calls: 332
연결 실패 한 번에 CLOSED 콜백이 345번 되돌아왔고, 새로고침 요청이 332번 나갔습니다. Sentry에서 본 "1초에 170회"는 모바일 브라우저의 스택이 더 얕아서 나온 숫자였을 뿐, 같은 현상이었습니다.
왜 테스트가 못 잡았나
transport에는 유닛 테스트가 열한 개나 있었습니다. 재연결, 백오프, 폴링 전환, 탭 숨김 처리까지 다 있었습니다. 그런데 테스트의 가짜 어댑터가 이랬습니다.
subscribe: vi.fn((topic, handlers) => {
const unsubscribe = vi.fn(); // 아무 일도 하지 않는다
subs.push({ topic, ...handlers, unsubscribe });
return { unsubscribe };
}),
unsubscribe()가 아무 일도 하지 않으니 "구독 해제 중에 콜백이 되돌아오는" 경우 자체가 테스트 세계에 존재하지 않았습니다. 가짜 객체가 진짜보다 얌전하면 테스트는 초록불이어도 운영은 빨간불입니다.
수정: 버리기 전에 무효화한다
고친 코드는 세 줄이 바뀝니다.
function dropSubscription() {
if (!subscription) return;
const current = subscription;
attempt += 1; // 이 구독의 콜백은 이제 전부 옛 세대다
subscription = null; // 재진입해도 다시 unsubscribe 하지 않는다
current.unsubscribe();
}
순서가 핵심입니다. 세대 번호를 먼저 올리고, 참조를 먼저 비우고, 그다음에 라이브러리를 부릅니다. 라이브러리가 그 자리에서 콜백을 되돌려 주더라도 myAttempt !== attempt 가드에 걸려 무시됩니다.
테스트에는 phoenix의 동기 close를 흉내 내는 옵션을 넣었습니다.
function fakeRealtime(opts: { closeOnUnsubscribe?: boolean } = {}) {
// ...
const unsubscribe = vi.fn(() => {
if (opts.closeOnUnsubscribe) handlers.onStatus("CLOSED");
});
이 옵션을 켠 테스트는 수정 전 코드에서 RangeError: Maximum call stack size exceeded로 실패하고, 수정 후에는 CHANNEL_ERROR 한 번에 재연결 한 번, onRefresh 0회로 통과합니다. 같은 재현 스크립트를 다시 돌리면 이렇게 나옵니다.
statuses: { CHANNEL_ERROR: 3, CLOSED: 3 }
onRefresh calls: 1
3번 시도하고 폴링으로 내려가며 한 번 새로고침. 설계한 그대로입니다.
덤: 서버리스 사용량도 같이 잡는다
원인을 잡고 나니 Vercel 사용량 화면도 다시 보게 됐습니다. Hobby 플랜의 월 Active CPU 한도 4시간을 이미 넘겨 있었습니다. 폭주 때문에 인스턴스 수백 개가 콜드 스타트한 탓도 있지만, 평소 폴링도 무시할 수 없었습니다.
두 가지를 더 바꿨습니다.
- 폴링 간격 60초 → 5분. 실시간 연결이 정상이면 폴링은 예비 경로입니다. 페이지 이동이나 탭 복귀 때 즉시 재조회하는 동작은 그대로라 사용자는 차이를 느끼지 못합니다.
- pg 연결 풀
max1 → 2. 예전에는 "서버리스 인스턴스는 한 번에 요청 하나"라는 전제로 연결을 하나만 뒀습니다. 지금 Vercel의 Fluid Compute는 인스턴스가 DB 응답을 기다리는 동안 다른 요청을 받습니다. 게다가 알림 목록 API는 목록 조회와 미읽음 카운트를Promise.all로 동시에 보냅니다. 연결이 하나면 둘이 줄을 섭니다.
연결 풀을 키울 때 지킬 것은 하나입니다. 동시에 떠 있는 인스턴스 수에 max를 곱한 값이 풀러 한도(200)보다 충분히 작아야 합니다. 이 서비스의 동시 인스턴스는 평소 한 자릿수라 2는 여유가 큽니다.
정리
- 서버리스에서 브라우저 버스트는 곧 DB 연결 수입니다. 요청 332개는 인스턴스 수십 개이고, 인스턴스마다 연결을 엽니다. "연결 한도 초과"의 원인이 서버가 아니라 클라이언트에 있을 수 있습니다.
- 라이브러리 콜백이 동기로 되돌아올 수 있으면 "버리기 전에 무효화"가 원칙입니다. 세대 번호를 올리고 참조를 비운 뒤에 해제 함수를 부릅니다. phoenix처럼 상황에 따라 동기·비동기가 갈리는 API는 특히 그렇습니다.
- 가짜 객체는 진짜만큼 못되게 굴어야 합니다. no-op
unsubscribe는 가장 위험한 경로를 테스트 세계에서 지워 버렸습니다. - 스택 트레이스에 앱 코드가 없어도 코드 결함일 수 있습니다. 누가, 얼마나, 어떤 인스턴스로 호출했는지 세어 보면 방향이 보입니다.