익명 사용자에게 "다시 매칭되지 않습니다"라고 약속해도 되는가
랜챗의 신고 모달에는 "이 사용자 차단 (다시 매칭되지 않음)" 체크박스가 있고, 기본으로 켜져 있다. 어느 날 이 문구를 다시 들여다보다가 질문이 하나 떠올랐다. 로그인하지 않은, 익명으로 들어온 사용자에게 이 약속을 지킬 수 있는가.
먼저 사실관계부터 확인했다. 결론만 말하면 "익명에게는 차단이 없다"는 건 사실이 아니었다. 익명 차단은 이미 제대로 동작하고 있었다.
익명 차단은 이미 작동한다
익명 신원은 서버가 발급해서 브라우저 localStorage에 보관하는 서명 토큰이다. 재입장할 때 이 토큰이 검증되면 그 신원에 묶인 차단 목록도 그대로 불러온다. 로그인 사용자와 다를 게 없는 경로다. 그러니 이 기능이 익명 사용자를 완전히 배제하고 있다는 우려는 기우였다.
여기서 신원이라는 말의 의미를 짚고 넘어갈 필요가 있다. 로그인 계정에서 신원은 사람에게 묶인다. 이메일이나 소셜 로그인으로 본인임을 증명하고, 그 증명이 계정이라는 지속적인 실체를 만든다. 반면 익명 신원은 사람이 아니라 저장소에 묶인다. 서버가 내준 서명 토큰을 브라우저가 들고 있는 동안만 그 신원이 유지된다. 토큰과 사람 사이에는 서버가 검증할 수 있는 연결고리가 없다. 토큰을 들고 있는 브라우저가 곧 그 신원이다.
문제는 작동 여부가 아니라 유효 범위였다
진짜 질문은 "되냐 안 되냐"가 아니라 "어디까지 되냐"였다. 토큰 검증에 실패하면, 그러니까 토큰이 없거나 브라우저 데이터가 지워졌으면 서버는 새 신원을 발급하고 빈 차단 목록으로 시작한다. 여기서 누수가 양방향으로 생긴다.
하나는 내가 차단한 기록이 사라지는 쪽이다. 시크릿 창을 닫거나, 사이트 데이터를 지우거나, 다른 브라우저나 기기를 쓰면 그 신원과 함께 차단 이력도 끊긴다. DB에는 여전히 내 차단 행이 남아 있지만, 이제 그건 아무도 갖고 있지 않은 식별자를 가리키는 고아 레코드가 된다.
다른 하나는 나를 차단한 기록이 무력화되는 쪽이다. 이쪽이 더 심각하다고 봤다. 상대가 저장소를 비우면 새 식별자를 받는다. 그 순간 그 사람은 내 차단 목록에 없는, 완전히 새로운 사람이 된다.
이 두 누수는 성격이 다르다. 앞쪽은 내 실수나 환경 변화로 생기는, 말하자면 우발적인 손실이다. 뒤쪽은 상대가 마음만 먹으면 언제든 재현할 수 있는, 의도적으로 악용 가능한 경로다. 토큰과 사람이 묶여 있지 않다는 같은 사실에서 나오지만, 하나는 사고에 가깝고 하나는 우회로에 가깝다는 점에서 대응도 달라져야 했다.
한 체크박스에 섞여 있던 두 용도
여기서 중요한 구분을 하게 됐다. 차단이라는 체크박스 하나에 성격이 다른 두 가지 목적이 섞여 있었다.
하나는 우연한 재조우 방지다. 실사용의 대다수가 여기 해당한다. 상대가 신원을 유지하는 한 익명 상태에서도 제대로 작동하고, 보통은 유지한다. 다른 하나는 악의적 재접근 차단이다. 여기서는 익명 신원이 사실상 효력이 없다. 로그인 계정도 완벽한 방벽은 아니지만, 계정을 새로 파는 비용과 익명 토큰을 새로 받는 비용은 비교가 안 된다.
같은 체크박스라도 사용자가 무엇을 기대하고 누르는지는 이 두 용도 중 어느 쪽을 염두에 뒀는지에 따라 달라진다. 대다수 사용자는 그냥 불쾌한 상대를 다시 마주치고 싶지 않아서 누른다. 이 경우엔 지금 구현으로 충분하다. 반면 소수지만 특정 상대의 의도적인 재접근을 걱정해서 누르는 사용자도 있을 수 있다. 이 경우엔 지금 구현이 기대에 못 미친다. 같은 버튼, 같은 동작인데 사용자가 어떤 기대를 갖고 눌렀는지에 따라 결과에 대한 평가가 갈린다는 게 이 문제의 핵심이었다.
기술로 닫을까 고민하고 접은 이유
신원의 비용을 올려서 이 문제를 기술적으로 닫는 방법도 검토했다. IP 보조 식별이나 기기 지문 같은 접근이다. 하지만 접었다. 이동통신 NAT 환경에서는 같은 IP를 여러 무고한 사용자가 공유하기 때문에 대량 오탐 위험이 크고, 무엇보다 익명성을 제공하는 것 자체가 이 제품의 존재 이유 중 하나다. 그 전제와 정면으로 충돌하는 방향이었다.
익명성과 재접근 차단은 근본적으로 당기는 방향이 반대다. 상대를 확실하게 다시 식별하고 차단하려면 신원을 더 무겁고 지속적으로 만들어야 하는데, 그건 곧 익명성을 깎아내는 일이다. 반대로 익명성을 지키려면 신원을 가볍게 둬야 하고, 가벼운 신원은 그만큼 쉽게 버리고 새로 받을 수 있다. 이 제품에서는 익명성 쪽에 걸린 값어치가 더 크다고 판단했기 때문에, 차단의 완전한 실효성보다 익명성을 지키는 쪽을 택한 셈이다.
결론은 기능이 아니라 문구였다
그래서 결론은 UI 문구를 바로잡는 쪽으로 났다. 지금 UI는 무조건적인 약속을 하고 있고, 기본값도 켜짐이라 사용자는 자신이 완전히 보호받고 있다고 믿기 쉽다. 게다가 이 조건을 설명하는 문장이 입장 화면에는 있는데, 정작 차단 버튼을 누르는 그 모달에는 없었다. 정보가 필요한 바로 그 시점에 없었던 셈이다.
그래서 익명으로 입장한 경우에 한해 체크박스 아래에 유효 범위를 한 줄 덧붙이기로 했다. 반대로 익명 사용자에게서 차단 기능 자체를 뺏는 선택지, 즉 체크박스를 아예 숨기는 안도 검토했지만 반대했다. 우연한 재조우 방지라는 실제 값어치까지 함께 버리는 셈이기 때문이다.
같은 작업에서 이 신고 모달의 접근성도 함께 손봤다. 다이얼로그 역할 부여와 제목 연결, Esc로 닫기, 배경 클릭으로 닫기(카드 내부 클릭은 무시), 포커스 트랩을 추가했다.
교훈
지킬 수 없는 약속을 하는 UI는 기능이 아예 없는 것보다 나쁠 수 있다. 사용자가 보호받고 있다고 믿고 그에 맞춰 행동을 바꾸기 때문이다. 없는 기능은 없는 대로 조심하게 만들지만, 있다고 믿게 만든 기능은 방심하게 만든다.
하나의 컨트롤에 성격이 다른 용도가 섞여 있으면 각각의 실효성을 따로 따져봐야 한다. "차단이 되나 안 되나"는 답할 수 없는 질문이었다. "무엇으로부터의 차단인가"로 바꾸자 그제야 답이 나왔다.
정보는 필요한 시점에 있어야 한다. 입장 화면의 안내 문구는 이미 화면에 없었다. 어딘가에 적어뒀다는 것과, 사용자가 그 순간 실제로 볼 수 있다는 것은 다른 이야기다.
이번 점검에서 얻은 또 하나의 교훈은, 신뢰 관련 기능을 검토할 때 "동작하는가"와 "약속을 지키는가"를 따로 물어야 한다는 점이다. 익명 차단은 첫 번째 질문에는 이미 통과한 상태였다. 코드는 설계된 대로 정확히 동작하고 있었다. 문제는 두 번째 질문에서 나왔다. 그 동작이 사용자에게 전달된 문구가 약속하는 범위와 정확히 일치하는가였다. 기능이 멀쩡히 동작해도 그 문구가 실제 보장 범위보다 넓게 약속하고 있다면, 검토는 여기서 끝나지 않고 문구까지 내려가야 한다.