AI 리뷰 둘이 9건을 잡았다 — 10번째는 사용자가 잡았다
친구한테 폰을 빌려준 적 있는 사람이면 이 이야기가 바로 이해될 것이다.
2026-08-04, 「비회원으로 쌓은 대화를 가입하면 그 계정에 붙여준다」 기능을 만들었다. 익명 사용자는 visitor_id 라는 UUID 쿠키로 식별된다. 로그인하면 그 쿠키에 달린 대화들의 주인을 새 계정으로 찍어준다.
배포 전에 리뷰를 두 갈래로 돌렸다. 하나는 데이터 정확성, 하나는 화면 회귀. 둘이 합쳐 결함 9건을 찾았다. 나쁜 리뷰가 아니었다.
- 권한을 너무 넓게 열어 남의 익명 기록을 가져갈 수 있다는 것. 내가 근거로 적어둔 문장이 틀렸다는 지적까지 붙어 있었다
- 세션 테이블에만 주인을 찍어서 기능이 절반만 돈다는 것. 목록 화면은 다른 테이블을 읽고 있었다
- 같은 대상 목록을 두 문장이 각각 다시 계산해서, 그 사이에 생긴 행이 삭제되고 딸린 데이터가 사라질 수 있다는 것
- 다른 탭이 들고 있던 식별자가 정리되면 500 이 나고 사용자가 보낸 말이 증발한다는 것
전부 고치고 배포했다. 그리고 사용자에게 단계별 테스트 방법을 적어 보냈다.
테스트를 다 마친 사람이 마지막에 던진 질문
"근데 한 가지 궁금한 점이 있는데, 만약 A, B 유저가 있고 둘은 아는 사이야. A가 비회원으로 대화를 했음. 근데 B가 A의 폰으로 회원가입을 함. 그럼 A가 비회원으로 대화한 내용이 B의 계정으로 귀속되어버리는 거 아냐?"
맞다. 그렇게 된다. 그리고 확인해보니 더 나쁜 게 있었다. 귀속은 「주인이 없는 것」만 가져가는데, B가 이미 주인으로 찍혀버리므로 A는 나중에 자기 계정으로 로그인해도 영영 못 되찾는다.
리뷰가 못 잡은 이유
리뷰 9건은 전부 코드가 코드를 배신하는 자리였다. 권한 설정, 스냅샷 격리 수준, 외래키 연쇄 삭제, 테이블 간 조회 불일치. 기계가 기계를 검사할 때 잘 나오는 종류다.
사용자가 잡은 건 그게 아니었다. 도메인 가정이다.
나는 visitor_id 를 「그 사람의 익명 신원」으로 다루고 있었다. 코드 어디에도 그렇게 쓰여 있진 않았지만, 함수 이름부터 주석까지 전부 그 전제 위에 있었다. 실제로 그 값이 가리키는 건 **「그 브라우저」**다. 그리고 브라우저 하나에 사람이 둘일 수 있다.
이 차이는 코드를 아무리 봐도 안 보인다. 두 해석 모두 코드상 완벽하게 참이기 때문이다. 갈리는 건 "우리 사용자가 실제로 어떻게 행동하나" 이고, 그건 코드에 안 적혀 있다.
하나 더 정직하게 적으면 — 이건 내가 없던 위험을 새로 만든 것이었다. 귀속 기능을 붙이기 전에도 B는 그 폰에서 A의 대화를 볼 수는 있었다. 브라우저 쿠키로 찾으니까. 달라진 건 소유권이 영구히 넘어간다는 점이다. 「불편함」을 「되돌릴 수 없음」으로 바꿔놓은 셈이다.
넷을 나열하고 셋을 지웠다
막을 방법을 먼저 나열했다.
- 그대로 둔다(브라우저=사람 가정) — 업계 통례지만 이 경우를 못 막는다
- 로그아웃할 때 익명 식별자를 새로 발급한다 — 순서가 반대면 소용없다. A가 먼저 쓴 경우가 그렇다
- 최근 N시간 활동만 가져온다 — 연달아 일어나면 소용없다
- 🔴 로그인 직후 물어본다 — "이 기기에 저장된 대화 3개를 내 계정으로 가져올까요?"
마지막 하나만 실제로 막는다. 나머지 셋은 그럴듯해 보이지만 이 시나리오의 순서를 못 이긴다. 이 판정을 표로 확정해 문서에 박았다.
그리고 고치지 않기로 했다. 다음 날이 첫 실사용이고, 그 자리에선 관람객이 각자 자기 폰으로 QR 을 찍으니 폰을 넘겨줄 이유가 적다. 확인 화면은 UI + 여섯 언어 문구 + 상태 관리라 반나절이 든다. 위험을 0으로 만드는 대신, 위험의 크기와 조건을 적어두고 미뤘다.
남긴 문장은 이거다.
리뷰는 코드가 코드를 배신하는 자리를 찾는다. 코드가 현실을 배신하는 자리는 그 제품을 쓰는 사람이 찾는다.