타임아웃 없는 DB 조회 하나가 좌석을 붙잡고 놓아주지 않았다
1편에서 다룬 onJoin 비동기화 작업을 마치고 나니, 이번엔 다른 문제가 남아 있었다. 차단 목록을 DB에서 불러오는 그 조회 자체에 상한이 없었다는 점이다. 겉보기엔 별문제 없어 보였지만, 이 하나의 빈틈이 매칭 큐 전체에 영향을 줄 수 있는 구조였다.
supabase-js는 fetch에 기본 타임아웃을 걸지 않는다. 조회가 응답하지 않고 매달리면 onJoin은 영원히 resolve되지 않고, 사용자는 무한 스피너를 보고, 서버에서는 그 좌석이 계속 점유된 채로 남는다. 매칭 대기열 좌석 하나가 통째로 죽는 셈이다. 다른 유저가 아무리 늘어나도 이 좌석 하나는 영원히 “입장 처리 중” 상태로 묶여 있게 된다.
타임아웃을 감싸는 것부터
가장 먼저 한 일은 DB 조회를 withTimeout으로 감싸는 것이었다.
async function fetchBlockedUserIds(userId) {
try {
return await withTimeout(loadBlockedFromDb(userId), 3000);
} catch {
return []; // 실패하면 빈 배열 — best-effort
}
}
여기까지는 무한 대기 문제를 해결한 것처럼 보였다. 3초가 지나면 어쨌든 onJoin은 끝나고, 좌석도 더 이상 무한정 점유되지 않는다. 그런데 이 코드에는 원래부터 있던 설계 문제가 하나 숨어 있었다.
“없음”과 “모름”이 같은 값이 되는 문제
당시 fetchBlockedUserIds는 실패하면 빈 배열 []을 반환하는 best-effort 계약이었다. 원래는 이례적인 DB 에러를 조용히 넘기기 위한 설계였을 텐데, 타임아웃이 그 실패 경로에 들어가면서 문제가 커졌다. “차단이 하나도 없음”과 “타임아웃이 나서 알 수 없음”이 똑같이 빈 배열이 되어 버린 것이다.
랜챗은 fail-closed 원칙을 따른다 — 차단 목록을 확정할 수 없으면 매칭 큐에 넣지 않고 입장을 막는다. 그런데 “없음”과 “모름”을 구분하지 못하면 이 원칙 자체를 지킬 수 없다. 타임아웃이 났는데도 빈 배열이 돌아오면, 호출부는 “차단된 사람이 없구나”라고 오판하고 그대로 매칭을 진행한다. 결과적으로 DB가 잠깐 느려지기만 해도 fail-closed 원칙이 조용히 fail-open으로 뒤집히는 셈이었다.
Symbol 센티넬로 타임아웃을 값으로 표현하기
그래서 타임아웃 전용 Symbol 센티넬을 뒀다.
const TIMED_OUT = Symbol('timed_out');
async function fetchBlockedUserIds(userId) {
try {
return await withTimeout(loadBlockedFromDb(userId), 3000);
} catch {
return TIMED_OUT;
}
}
const blocked = await fetchBlockedUserIds(client.userId);
if (blocked === TIMED_OUT) {
throw new Error('block_list_unavailable'); // fail-closed
}
배열도 아니고 null도 아닌 고유한 값이라 다른 정상 반환값과 절대 충돌하지 않는다. 이제 “차단 없음”은 빈 배열로, “확정 실패”는 TIMED_OUT으로 명확히 갈라졌다. 호출부는 더 이상 두 경우를 헷갈릴 수 없고, 확정 실패 시에는 곧바로 입장을 막을 수 있게 됐다.
서버만 고쳐선 부족했다
여기서 끝난 줄 알았는데, 웹 클라이언트 쪽을 보니 status === 'error' 상태를 그냥 스피너로 렌더링하고 있었다. 서버가 아무리 정확하게 “실패했다”고 통지해도, 사용자 화면에는 무한 스피너와 완전히 똑같이 보이고 있었다는 뜻이다. 서버 로그에는 block_list_unavailable이 정확히 찍히는데, 정작 사용자는 아무 정보도 받지 못한 채 그냥 기다리는 화면만 보고 있던 셈이다. 서버 쪽 수정만으로는 사용자 입장에서 아무것도 달라진 게 없었다.
그래서 RandChatEvents.ERROR 이벤트와 전용 에러 화면, 그리고 retry()를 함께 추가하고 나서야 이 수정이 비로소 완결됐다. 서버가 원인을 정확히 구분해도, 그 구분을 화면에 반영할 경로가 없으면 사용자 입장에서는 아무 의미가 없다는 걸 다시 확인한 셈이다. 서버 코드만 보고 “이제 타임아웃도 구분해서 처리하니 끝났다”고 여겼다면, 사용자는 여전히 원인 모를 무한 대기 화면 앞에 남아 있었을 것이다.
남아 있던 구멍: withTimeout의 rejection
참고로 이후 리뷰에서 하나 더 잡힌 게 있다. withTimeout에 rejection 처리가 빠져 있었던 부분이다. prepareJoin이 throw할 수 있는 함수를 품게 되면서 새로운 reject 표면이 생겼는데, 처음 작성할 때는 그걸 고려하지 않았다. resolve 경로만 보고 있으면 이런 구멍은 눈에 잘 띄지 않는다. 타임아웃을 감싸는 유틸리티 함수 하나를 고칠 때조차, 그 함수를 호출하는 쪽의 코드가 나중에 어떻게 확장될지까지 미리 다 예측하기는 어려웠다. 처음엔 “DB 조회 하나 감싸는 유틸”로 시작했던 함수가, 호출부가 늘어나면서 떠안아야 할 실패 케이스도 함께 늘어난 셈이다.
교훈
외부 I/O에는 상한을 건다. 특히 좌석·세션·락 같은 자원을 점유한 채로 기다리는 코드라면 더 그렇다. 또 best-effort 계약 — “실패하면 빈 값을 준다” — 은 그 값이 보안 결정의 근거가 되는 순간 무너진다. “없음”과 “모름”은 반드시 다른 값이어야 한다. 그리고 “서버가 정확히 통지한다”는 그 통지를 클라이언트가 표시할 경로가 있을 때만 수정이 완결된다. 서버 로그에만 남는 정확한 에러는, 사용자 입장에서는 무한 스피너와 구별되지 않는다. 이 세 가지는 결국 하나로 이어진다 — 자원을 점유하는 대기는 반드시 끝을 정해두고, 그 끝의 이유를 값으로 구분하고, 그 값을 실제로 보는 사람에게까지 전달해야 한다.