채팅을 얹고 나니, 도망친 유저의 대화가 사라져 있었다
랜챗(랜덤 1:1 화상채팅) 데모에 텍스트 채팅과 DB 기록, 캠/마이크 토글을 하루 만에 붙였다. 기능 자체는 예상대로 끝났는데, 마지막 슬라이스를 검증하던 중 전혀 다른 곳에서 데이터가 조용히 사라지고 있는 걸 발견했다. 매칭이 성립된 직후 상대가 곧바로 나가면, 그 대화의 기록 자체가 DB에 남지 않는 버그였다.
오늘 한 일
기존 랜챗 스펙(완전 익명·DataChannel 채팅)을 사용자가 가져온 새 요구사항(닉네임 영속화·Google 로그인·신고 근거용 DB 저장)과 합쳐 스펙을 다시 정리했다. 결정한 것 셋:
- 매칭은 태그 없이 완전 랜덤으로 단순화(확장 여지만 남김)
- 채팅은 P2P DataChannel 대신 서버(Colyseus) relay로 바꿔 DB에 남긴다 — 신고 기능의 근거 데이터가 되어야 하니까
- 인증은 완전 익명 대신
localStorageuuid로 "영속 익명"을 만든다 — 차단 기능이 재접속 후에도 유지되려면 최소한의 신원이 필요하다
이 결정을 바탕으로 세 슬라이스를 순서대로 구현했다: 텍스트 채팅 → 영속 신원과 DB 저장 → 캠/마이크 토글.
슬라이스 3: 텍스트 채팅과 한글 조합 함정
채팅 자체는 단순하다. 서버가 페어 상대에게만 메시지를 중계하고, 클라이언트는 로컬에 즉시 자기 메시지를 붙인다(에코 없음). 리뷰에서 걸린 건 기능이 아니라 입력창이었다.
onKeyDown={(e) => { if (e.key === 'Enter') submit() }}
한글 IME로 조합 중에 Enter를 누르면, 조합을 확정하는 keydown이 isComposing: true 상태로 먼저 발생한다. 이 가드 없이는 마지막 글자가 잘리거나 메시지가 중복 전송된다. 한글 채팅 앱에서 흔히 나오는 결함이라 리뷰에서 바로 잡혔다.
onKeyDown={(e) => { if (e.key === 'Enter' && !e.nativeEvent.isComposing) submit() }}
슬라이스 4: DB에 남기려니 순서가 꼬였다
매칭이 성립하면 서버가 두 유저의 uuid로 randchat_sessions 행을 만든다. 그런데 유저 uuid는 randchat_users에 먼저 존재해야 한다(FK). 첫 구현은 유저 upsert를 void로 던져놓고 곧바로 세션을 만들었다.
void db.upsertUser(userId, nickname) // 완료를 기다리지 않음
db.createSession(userIdA, userIdB) // 신규 유저면 아직 DB에 없을 수 있다
신규 유저가 마침 대기 중이던 상대와 즉시 매칭되면, 유저 행이 커밋되기 전에 세션 INSERT가 FK 검사에 걸려 조용히 실패했다. 리뷰에서 이 경합을 잡아 upsert 완료를 기다린 뒤 세션을 만들도록 고쳤다. 여기까지는 예정된 작업이었다.
슬라이스 5: 캠 토글을 검증하다가 발견한 진짜 문제
캠/마이크 토글은 순수 UI 기능이라 리스크가 낮다고 생각했다. 실서버로 마지막 확인 삼아 매칭 → 상태 전송 → 종료까지 한 번 돌리는 E2E를 실행했는데, 서버 로그에 이런 게 찍혔다.
[randchat-db] createSession 실패: {
code: '23502',
message: 'null value in column "user1_id" of relation "randchat_sessions" violates not-null constraint'
}
캠 토글과는 아무 상관 없는 로그였다. 원인을 추적해보니 슬라이스 4에서 고쳤다고 생각한 그 경합이 다른 형태로 남아 있었다.
const sessionPromise = Promise.all([this.upserts.get(aId), this.upserts.get(bId)])
.then(() => db.createSession(this.userIds.get(aId)!, this.userIds.get(bId)!))
userIds.get(aId)가 .then() 콜백 안에서, 즉 upsert 완료를 기다린 뒤에 다시 조회된다. 그런데 유저가 나가면(onLeave) 서버는 그 즉시 userIds.delete(id)를 실행한다. upsert가 완료되기를 기다리는 그 짧은 틈에 상대가 스킵하거나 탭을 닫으면, .then()이 실행될 시점엔 이미 맵에서 지워진 뒤라 undefined가 조회된다. TypeScript의 !는 타입만 속일 뿐 런타임엔 그대로 undefined가 DB에 null로 들어가고, NOT NULL 제약에 걸려 세션 행 자체가 생성되지 않는다.
"매칭 직후 곧바로 나간다"는 랜챗에서 아주 흔한 행동이다. 배포 후 실사용자가 스킵을 몇 번만 눌러도 그 세션들의 기록이 계속 조용히 사라지고 있었다는 뜻이다.
수정: 비동기 경계를 넘기 전에 값을 붙잡아둔다
고치는 방법은 간단했다. 맵을 "나중에" 다시 조회하지 말고, 비동기 대기를 시작하기 전에 값을 지역 변수로 미리 꺼내둔다.
// uidA/uidB를 async 갭(upsert 대기) 이전에 캡처
const uidA = this.userIds.get(aId)!
const uidB = this.userIds.get(bId)!
const sessionPromise = Promise.all([this.upserts.get(aId), this.upserts.get(bId)])
.then(() => db.createSession(uidA, uidB))
회귀 테스트로 버그를 먼저 재현했다. A의 upsert를 인위적으로 지연시키고 그 사이 B를 이탈시킨 뒤, 지연이 끝나면 createSession이 무엇으로 호출되는지 확인하는 테스트다. 수정 전에는 실제로 createSession(UUID_A, undefined)가 찍혔다. 수정 후 프로덕션에 배포하고, 매칭 직후 즉시 나가는 시나리오를 실서버로 다시 돌려 이번엔 세션 행이 정상 생성되는 것까지 DB에서 직접 확인했다.
교훈
공유 Map에서 읽은 값을 비동기 콜백 안에서 "나중에" 다시 조회하지 않는다. 그 틈에 다른 이벤트가 먼저 지울 수 있다. 비동기 경계를 넘기 전에 항상 지역 변수로 캡처해두면, 그 뒤에 무슨 일이 일어나든 그 값은 안전하다.
이 버그는 캠 토글이라는 전혀 무관한 기능을 검증하다가 우연히 잡혔다. 단위 테스트는 DB 왕복 지연을 흉내 내지 않으니 이런 레이스는 거의 못 잡는다. 실서버 E2E를 매 슬라이스마다 돌리는 습관이, 이번엔 기능 하나가 아니라 조용히 새고 있던 데이터를 구했다.