정리 코드가 지우기 전에 붙잡아야 했던 것들
앞선 글에서 다룬 해결책에는 조건이 하나 붙는다. 이미 끝난 페어를 신고하려면, 그 페어에 대한 정보가 신고 시점까지 남아 있어야 한다. 대상을 서버가 발급한 handle로 명시적으로 넘긴다고 해도, 그 handle이 가리키는 실제 데이터 — 상대의 uuid, 대화 기록, 사용자 행 — 가 사라져 있으면 아무 소용이 없다. 그런데 서버의 이탈 처리 로직은 그 정보를 상대가 나가는 순간 전부 지워버리고 있었다.
신고 한 건에 필요한 네 가지
신고를 DB에 기록하려면 최소 네 가지가 필요했다. 신고자의 uuid, 상대의 uuid, 그 대화에 해당하는 DB 세션 id, 그리고 차단까지 처리하려면 양쪽 사용자 행이 생성됐는지를 나타내는 promise. 이 중 하나만 빠져도 신고 레코드를 온전히 남길 수 없다 — 상대 uuid가 없으면 누구를 신고했는지 특정할 수 없고, DB 세션 id가 없으면 어떤 대화에서 나온 신고인지 알 수 없고, 사용자 행 promise가 없으면 차단 처리에서 참조할 행 자체가 없을 수 있다. 문제는 상대가 연결을 끊는 순간 이 네 가지가 거의 다 지워진다는 점이었다.
이탈 처리가 재료를 먼저 파괴한다
랜챗의 매칭은 계속 순환한다. 사용자가 통화를 끝내거나 상대가 마음에 안 들어 나가면 곧바로 다음 상대와 매칭된다. 그 과정에서 서버는 이전 페어에 대한 상태를 정리해야 하는데, 이 정리를 미루면 메모리에 죽은 페어 정보가 계속 쌓인다. 그래서 onLeave는 최대한 빨리, 최대한 깔끔하게 관련 맵을 비우도록 짜여 있었다. 문제는 “깔끔하게”가 “신고 기능이 나중에 필요로 할 값까지 포함해서”였다는 점이다. 서버의 이탈 처리 순서는 대략 이랬다.
onLeave(client):
endPair(id) // partners와 dbSessions를 양쪽 다 삭제
clientsById.delete(id)
nicknames.delete(id)
userIds.delete(id) // 상대 uuid가 여기서 사라진다
upserts.delete(id) // 사용자 행 promise도
사용자가 신고 제출 버튼을 누르는 시점에는 이미 userIds와 upserts에서 필요한 값이 지워진 뒤였다. 신고에 필요한 재료가 정리 코드에 의해 먼저 파괴되는 구조였다. 앞선 글에서 다룬 “서버가 발급한 핸들”은 여기서는 정확히는 상대의 sessionId였는데, sessionId 자체는 살아있어도 그걸로 조회할 userIds·upserts 항목이 없으면 신고 처리는 여전히 실패한다. 대상을 명시적으로 만든 것과, 그 대상에 딸린 데이터가 살아있는 것은 별개의 문제였다.
해결 — 지우기 직전에 붙잡기
endPair가 맵에서 값을 지우기 직전에, 필요한 값들을 별도 레코드로 캡처해 보관하도록 바꿨다. 핵심은 순서였다. endPair는 onLeave 안에서 userIds.delete나 upserts.delete보다 먼저 호출되므로, 그 함수 안에서 값을 붙잡으면 필요한 값이 모두 아직 살아 있는 상태다.
이렇게 하면 신고 처리 시점에 상대가 아직 접속해 있는지 여부와 무관하게, 캡처해둔 레코드에서 필요한 값을 그대로 꺼내 쓸 수 있다. 살아있는 맵이 지워졌는지 아닌지를 매번 신경 쓰지 않아도 되는 구조가 된다.
보관 개수는 클라이언트당 최근 3개로 정했다. 하나만 두면 “B를 신고하려고 준비하는 사이 C와 매칭됐다가 C가 곧바로 나가버리는” 흔한 흐름에서 B에 대한 레코드가 밀려나 버린다. 랜챗 특성상 매칭은 빠르게 반복되고, 신고 모달을 채우는 동안에도 뒤에서 다음 매칭이 진행될 수 있어서 여유를 뒀다.
이 레코드들은 접속이 끊기면 함께 지워지므로 별도의 타이머나 주기적 청소 로직이 필요 없다. 수명이 접속이라는 이미 존재하는 경계에 자연스럽게 묶여 있기 때문이다. 처음에는 “N분 지난 레코드는 청소한다”는 식의 타이머를 붙일까 고민했는데, 접속 자체가 사라지면 그 클라이언트의 레코드도 같이 사라지는 구조라 굳이 만들 필요가 없었다.
테스트가 지킬 수 없는 순서 의존
이 구조에는 약점이 하나 있다. endPair가 삭제 코드들보다 먼저 실행돼야 한다는 사실이 코드 어디에도 강제돼 있지 않다. 누군가 정리 코드를 보기 좋게 재배열하면 캡처가 빈 값으로 채워지고, 그러면 이탈한 상대를 신고할 수 없게 된다.
더 나쁜 건 이걸 테스트가 잡아주지 않는다는 점이다. 테스트는 함수 내부의 실행 순서를 검증하지 않는다. DB 호출을 mock으로 대체한 테스트라면 캡처가 undefined로 채워져도 그대로 통과한다. 단위 테스트는 “신고가 접수되는지”를 확인하지 “endPair가 몇 번째 줄에서 호출됐는지”를 확인하지 않기 때문이다. 함수 시그니처와 반환값이 같으면, 내부 순서가 바뀌어도 mock은 아무 차이를 느끼지 못한다.
타입 시스템도 이걸 막지 못한다. endPair를 먼저 부르든 나중에 부르든 컴파일은 똑같이 성공한다. 결국 이 자리에는 코드로 강제하는 대신 경고 주석을 남겼다. 순서를 바꾸면 기능이 깨지는데, mock 테스트는 그대로 통과한다는 사실까지 함께 적어뒀다. 테스트로 막을 수 없는 것을 인정하고, 다음 사람이 반드시 읽을 자리에 적어두는 것 말고는 다른 방법이 없었다.
교훈
- 정리 코드는 무언가를 파괴한다. 그 값이 나중에 필요해지는 기능을 추가할 때는 파괴 시점보다 앞에서 붙잡아야 한다. 기능을 추가하면서 “이 값이 아직 살아있나”를 확인하지 않으면, 정리 코드가 먼저 작성됐다는 이유만으로 새 기능이 조용히 실패한다.
- 수명을 이미 존재하는 경계(여기서는 접속)에 묶으면 타이머와 청소 로직이 통째로 사라진다. 유계를 만들기 위해 새 메커니즘을 도입하기 전에 이미 유계인 것에 묶을 수 있는지부터 봐야 한다. 접속이라는 경계는 이미 관리되고 있었고, 거기 얹기만 하면 됐다.
- 테스트가 지킬 수 없는 불변식이 있다. 함수 내부 문장의 순서 같은 것들이다. 그럴 때는 그 사실 자체를 주석에 남긴다. “이 순서를 바꾸면 깨지는데 테스트는 통과한다”는 문장이 다음 사람에게는 테스트보다 유용하다. 테스트가 초록불이라는 것과 코드가 옳다는 것은 다른 이야기라는 걸, 이 문장이 계속 상기시켜준다.