로그인 계정 id를 그대로 신원으로 쓴 대가, 그리고 서버가 신원을 발급하게 바꾼 일
앞선 글에서 정리한 대응 — API 응답에서 uid 제거, Realtime publication 조정, anon 권한 회수 — 은 구멍을 막긴 했지만 근본 구조는 그대로였다. 신원을 클라이언트가 주장하고 서버가 검사하는 모델이었고, 소셜 유저의 랜챗 신원은 여전히 Supabase auth uid 그 자체였다. 검사를 아무리 촘촘히 해도, 신원을 클라이언트가 정할 수 있다는 전제 자체가 남아 있으면 언젠가 또 같은 종류의 문제가 나온다. 눈에 보이는 구멍 세 개를 막았다고 해서 구조가 안전해진 건 아니었다. 그래서 신원 모델을 다시 만들기로 했다.
익명 신원을 서버가 발급하게 바꿨다
전에는 브라우저가 localStorage에 uuid를 하나 만들어두고 "이게 내 신원"이라고 서버에 주장하는 구조였다. 서버는 그 값을 형식만 확인하고 받아들였다. 이제는 서버가 HMAC으로 서명한 토큰을 발급한다. 형식은 v1.<uuid>.<서명>이다. 클라이언트는 이 토큰을 보관만 하고, 다음 입장 때 그대로 제시한다. 서버는 서명이 맞는지 검증하기만 하면 되므로 DB 왕복이 0회다 — 무상태 검증이다. 핵심은 검사를 더 잘하게 된 게 아니라, 클라이언트가 애초에 신원을 주장할 수 있는 경로 자체가 사라졌다는 점이다. 토큰을 위조하려면 시크릿 없이 유효한 서명을 만들어야 하는데, 그건 uuid 형식 정규식 통과와는 완전히 다른 난이도다.
v1 버전 접두는 장식이 아니다. 서명 시크릿이 유출되면 누구든 임의의 신원 토큰을 위조해서 발급할 수 있게 되는데, 그때의 유일한 대응은 시크릿 교체이고 그건 곧 기존에 발급된 토큰을 전부 무효화하고 모든 사용자에게 새 신원을 주는 일이다. 접두 없이 시크릿만 바꾸면 예전 토큰과 새 토큰을 구분할 방법이 없어서 마이그레이션이 지저분해진다. 그 전환을 안전하게 하기 위해 버전 접두를 넣어뒀다 — 지금 당장 쓸 일이 없어도, 있어야 나중에 곤란하지 않은 종류의 장치였다.
소셜 id를 auth uid에서 분리했다
randchat_users.id는 이제 서버 내부에서 생성하는 uuid로 통일하고, Google 계정은 별도의 auth_uid 컬럼에 unique 제약으로 매핑했다 (마이그레이션 012). auth uid가 랜챗의 기본키 역할을 하던 결합이 여기서 끊어졌다. 앞선 글에서 문제가 됐던 것도 결국 이 결합이었다 — 어디선가 uid가 새면 그게 곧 랜챗 신원이 되는 구조. 이 구조 자체가 없어진 것이다. 이제 랜챗 id가 외부로 새더라도, 그 자체로는 Supabase 계정을 가리키지 않는다.
이 개편으로 예전에 소셜 유저인지 판별하던 isSocialUser 로직과, 클라이언트에서 uuid를 생성하던 코드가 통째로 삭제됐다. 판별할 필요가 없어졌고, 클라이언트가 만들 신원도 없어졌기 때문이다. 코드가 줄어든 게 부수 효과가 아니라, 애초에 그 코드들이 존재해야 했던 이유 — 클라이언트가 신원을 자체적으로 결정한다는 전제 — 가 사라진 결과였다.
이 변화를 앞선 글의 대응과 나란히 놓고 보면 차이가 분명해진다. 앞선 글에서는 uid가 새는 경로 세 개를 각각 찾아서 막았다 — API, Realtime, PostgREST. 그런데 그 세 경로를 아무리 완벽하게 막아도, "auth uid가 곧 랜챗 신원"이라는 전제가 남아 있는 한 uid가 새는 새로운 경로가 또 나오지 않는다는 보장이 없었다. id 분리는 그 전제 자체를 없애서, 앞으로 uid가 어디선가 또 샌다 해도 그게 랜챗 신원 도용으로 이어지지 않게 만드는 조치였다.
운영상 트레이드오프 하나
이 구조에는 대가가 있다. 서명용 시크릿 환경변수(RANDCHAT_IDENTITY_SECRET)가 없으면 game-server가 통째로 부팅에 실패한다. 랜챗 기능만 죽는 게 아니라, 같은 서버 프로세스에 얹혀 있는 호텔 로비까지 함께 내려간다. 조용히 익명으로 강등시켜 넘어가는 선택지도 있었지만, 그건 시크릿이 없는 상태를 "정상 동작"처럼 보이게 만드는 선택이었다. 부팅 자체를 막아서 시크릿 없는 상태로 서비스가 뜨는 일을 원천 차단하는 쪽이 안전하다고 판단해서 그렇게 뒀다. 대신 이 요구사항을 .env.example과 프로젝트 문서에 명시적으로 못 박아뒀다 — 배포 환경을 새로 세팅하는 사람이 이 값을 빠뜨리면 배포 직후 바로 알아차릴 수 있게 하기 위해서였다.
프로덕션에서 실제로 확인했다
배포 후 실제로 Google 계정으로 로그인한 창과 별도의 익명 창을 매칭까지 태워보고 DB를 확인했다. auth_type이 social인 행에서 id와 auth_uid가 서로 다른 값으로 들어가 있는 것을 확인했다. 개편 전이었다면 이 둘은 같은 값이었을 자리다. 코드만 보고 "이제 분리됐다"고 믿는 것과, 실제 매칭 트래픽을 태워서 DB 행으로 확인하는 것은 다른 확신이었다. 마이그레이션이 스키마 수준에서는 맞더라도, 실제 로그인·매칭 플로우를 거친 행이 기대한 모양으로 남는지는 따로 확인해야 하는 문제였다.
교훈
신원을 "버려도 되는 로컬 uuid"에서 "영속 계정"으로 격상시키면, 예전에 의도적으로 수용했던 위조 한계의 성격이 함께 바뀐다. 처음 랜챗을 설계할 때는 "익명 uuid 정도는 사칭당해도 크게 문제없다"고 판단하고 넘어간 지점이 있었을 것이다. 그런데 그 uuid 자리에 실제 로그인 계정이 들어오는 순간, 같은 문장이 더 이상 성립하지 않는다. 익명 신원을 사칭당하는 것과 계정을 사칭당하는 것은 전혀 다른 문제다. 스펙 어딘가에 "이 정도 위조는 감수한다"고 적어둔 문장이 있다면, 신원 모델이 바뀔 때 그 문장을 반드시 다시 읽어야 한다.
그리고 검사보다 구조가 낫다는 걸 다시 확인했다. 서버가 클라이언트의 신원 주장을 열심히 검증하는 대신, 애초에 클라이언트가 신원을 주장할 수 없게 만들면 검증할 것도 사라진다. 앞선 글에서 여러 계층을 돌아다니며 구멍을 막았던 것과 달리, 이번 개편은 애초에 문제가 생길 수 있는 자리 자체를 없애는 쪽이었다. 막을 문을 하나씩 찾아다니는 대응과, 문이 없는 구조로 다시 짓는 대응은 들이는 노력도 다르지만 남는 결과물의 성격도 다르다.
물론 구조를 다시 짓는 쪽이 항상 가능한 건 아니다. 이번에는 랜챗이라는 기능 하나의 신원 모델이었기 때문에 발급 방식과 스키마를 통째로 바꿀 여지가 있었다. 검사를 촘촘히 하는 대응은 지금 당장 막아야 할 구멍이 있을 때, 구조를 바꾸는 대응은 그 구멍들이 같은 뿌리에서 반복해서 나온다는 게 확인됐을 때 택할 일이라고 정리했다. 두 대응이 순서대로 필요했던 것이지, 어느 한쪽만으로 끝낼 수 있는 문제는 아니었다.