"지금 내 페어"라는 암묵적 기준이 만든 두 개의 버그
랜챗에는 신고 기능이 있다. 통화 중이거나 방금 끝난 상대를 신고할 수 있게 만든 기능인데, 이 기능을 만들면서 신고 대상을 어떻게 정할지에 대해 별 고민을 하지 않았다. 신고 버튼을 누르면 클라이언트는 그냥 사유만 담아 보냈고, “누구를” 신고하는지는 서버가 알아서 찾아야 했다. 서버 코드는 이렇게 대상을 찾았다.
this.partners.get(client.sessionId)
말 그대로 “지금 이 사람의 페어”다. 신고 대상이 요청 어디에도 명시적으로 담겨 있지 않고, 서버가 그 순간 들고 있는 상태에서 암묵적으로 유도됐다. 당장 코드 줄 수는 짧고, 어차피 신고는 통화 중이거나 통화가 막 끝난 상대에게 하는 거니까 이 정도면 충분하다고 생각했다. 그리고 이 결정 하나가 나중에 서로 다른 버그로 보이는 두 가지 증상을 만들어냈다. 한동안은 둘을 아예 별개의 문제로 취급했고, 각각 따로 원인을 찾으려 했다.
증상 1 — 신고가 사라진다
신고 모달을 열어둔 사이 상대가 창을 닫으면 페어가 끝난다. 이건 흔한 시나리오였다 — 상대가 무례한 말을 하고 바로 나가버리는 경우, 사용자는 화가 난 채로 신고 모달을 채우고 제출을 누른다. 그런데 그 시점에는 서버 쪽 this.partners.get(client.sessionId)가 이미 비어 있다. 페어가 끝나면서 그 항목이 지워졌기 때문이다. 서버는 대상을 찾지 못하니 로그 한 줄만 남기고 요청을 그냥 버렸다.
문제는 클라이언트에 접수 확인 UI가 따로 없었다는 점이다. 모달은 제출과 동시에 닫혔고, 사용자 입장에서는 그게 성공이든 실패든 똑같이 보였다. 사용자는 모달이 닫히는 것만 보고 신고가 정상 처리됐다고 믿었다. 실제로는 아무 것도 기록되지 않았다. 가장 화가 난 순간, 즉 상대가 무언가 잘못하고 즉시 도망친 바로 그 케이스에서 신고가 조용히 실패하고 있었다.
증상 2 — 엉뚱한 사람이 신고당한다
반대 방향의 사고도 있었다. 모달을 열어둔 채로 새 상대와 매칭이 잡히고, 그 상태에서 제출을 누르면 서버는 “현재 페어”를 다시 조회해서 신고 대상으로 삼았다. 그런데 그 시점의 “현재 페어”는 신고하려던 사람이 아니라 방금 새로 매칭된, 아무 잘못 없는 사람이었다. 그 사람이 신고되고 차단까지 걸렸다.
이 증상은 리뷰 과정에서 먼저 발견됐다. 당시 대응은 상대가 바뀌거나 통화가 끝나면 모달을 강제로 닫는 것이었다.
useEffect(() => setReportOpen(false), [status, match?.partnerId])
이 패치는 증상 2를 막았지만 증상 1을 더 악화시켰다. 이제는 상대가 나가는 순간 모달 자체가 사라지니, 사용자가 제출할 기회조차 없어진 것이다. 원인을 없앤 게 아니라 증상 하나를 다른 증상으로 옮긴 셈이었다.
같은 원인, 다른 얼굴
한참 뒤에야 두 증상의 원인이 같다는 걸 알았다. 왜 오래 걸렸냐면, 증상만 놓고 보면 정반대 방향처럼 보였기 때문이다. 하나는 대상이 사라져서 생긴 문제고 다른 하나는 대상이 엉뚱하게 바뀌어서 생긴 문제다. “사라짐”과 “오염”은 언뜻 서로 다른 카테고리의 버그처럼 읽힌다.
하지만 신고 대상이 “지금 이 사람의 페어”라는 암묵적 기준으로 정의돼 있으면, 그 기준이 가리키는 값이 바뀌는 모든 순간에 문제가 생긴다. 페어가 사라지면 대상을 잃고(증상 1), 페어가 교체되면 엉뚱한 대상을 가리킨다(증상 2). 둘은 같은 결함의 서로 다른 발현이었을 뿐이다. 조회 시점의 상태를 신뢰한다는 전제 자체가 깨지면, 그 전제가 깨지는 방향에 따라 완전히 다른 증상으로 나타난다.
해결 — 서버가 발급한 핸들을 되돌려 받기
고친 방향은 대상을 명시적으로 만드는 것이었다. 서버는 매칭이 성사되는 순간 이미 클라이언트에게 상대의 sessionId를 내려주고 있었다 — MATCHED 이벤트의 partnerId다. 신고할 때 그 값을 클라이언트가 다시 담아 보내도록 바꿨다. 클라이언트는 이 값을 저장해뒀다가 신고 요청에 그대로 실어 보내기만 하면 됐고, 서버는 더 이상 “지금 이 사람의 페어가 누구인지”를 스스로 다시 계산할 필요가 없어졌다. 대신 “네가 신고하겠다는 이 사람이 정말로 네 페어였는지”를 확인하는 문제로 바뀌었다. 계산의 방향이 반대가 된 셈이다.
여기서 중요한 건 이게 “클라이언트를 믿기로 했다”는 뜻이 아니라는 점이다. partnerId는 클라이언트가 마음대로 지어낸 값이 아니라 서버가 매칭 시점에 직접 발급한 값이다. 서버는 신고 요청이 들어오면 그 값이 실제로 이 클라이언트의 짝이었는지 대조한다. 현재 페어이거나, 최근에 끝난 페어이거나, 둘 다 아니면 요청을 거부한다. 클라이언트가 아무 사용자 id나 넣어 보내도 그 대조를 통과할 수 없다. 신뢰 경계는 그대로 두고, 대상을 가리키는 방식만 암묵적 조회에서 명시적 핸들로 바꾼 것이다. 서버 입장에서 달라진 건 “누구를 신고 대상으로 볼지 스스로 정하던 것”에서 “제시된 대상이 유효한지 검증하는 것”으로 책임이 바뀐 것뿐이다.
이렇게 바꾸고 나니 증상 2를 막으려고 넣었던 모달 강제 닫기를 걷어낼 수 있었다. 스테일 모달이 무고한 사람을 가리킬 경로 자체가 사라졌기 때문이다. 우회책을 치우고 원인을 없앤 것이다. 결과적으로 모달은 사용자가 직접 닫기 전까지 열려 있고, 제출 시점의 대상은 항상 모달이 열릴 때 서버가 건네준 값 그대로다. 그 사이 상대가 나가든 새 상대와 매칭되든 신고 대상은 흔들리지 않는다.
교훈
- 암묵적 대상은 상태가 바뀌는 순간 배신한다. “현재 X”로 유도되는 대상은 X가 변하는 모든 경계에서 틀릴 수 있다. 코드를 짤 때는 지금 이 순간의 상태가 아니라, 이 값이 언제 만들어졌고 그 이후로 무엇이 바뀔 수 있는지를 먼저 물어야 한다.
- 서로 다르게 보이는 두 증상이 같은 원인에서 나오는지 먼저 물어봐야 한다. 각각을 따로 막으면 한 증상에 대한 대응이 다른 증상을 악화시키기도 한다. 여기서는 정확히 그랬다 — 증상 2를 막은 패치가 증상 1을 심화시켰다. 근본 원인을 찾기 전에 증상 하나씩 땜질하면 이런 상호작용을 놓치기 쉽다.
- 클라이언트가 값을 보낸다고 반드시 신뢰 범위가 넓어지는 건 아니다. 서버가 발급한 핸들을 되돌려 받아 대조하면, 신뢰 경계는 그대로 두고 표현만 명시적으로 만들 수 있다. 클라이언트를 더 믿게 되는 게 아니라, 서버가 이미 알고 있던 사실을 검증 가능한 형태로 왕복시키는 것뿐이다.