화면이 코드와 정반대를 말하고 있었다
LinkSquare의 랜챗(랜덤 1:1 화상채팅)에는 신고 기능이 있다. 신고 모달을 열면 제목 아래에 안내 문장이 조건 없이 떠 있었다. “{상대닉네임}님을 신고합니다. 신고하면 대화가 즉시 종료됩니다.” 이번 작업 전까지는 이 문장이 그냥 사실이었다. 신고를 접수하면 언제나 통화가 끊겼기 때문에, 문구와 동작이 어긋날 여지 자체가 없었다.
그런데 이번 작업으로 그 전제가 깨졌다. 상대가 이미 대화를 떠난 뒤에도 그 사람을 신고할 수 있게 만드는 작업이었다. 이걸 하면서 신고 대상이 지금 자신과 페어를 이루고 있는 상대가 아니면 통화를 끊지 않기로 결정했다. 과거에 헤어진 상대를 신고했다는 이유로 지금 진행 중인 다른 통화를 끊어버리면 안 되기 때문이다. 서버와 클라이언트 양쪽에 같은 규칙을 각각 명시적으로 넣었다. 그리고 문구는 손대지 않고 그대로 뒀다.
신고 규칙이 바뀌었다
바뀌기 전 규칙은 단순했다. 신고 = 통화 종료. 신고 대상은 언제나 지금 통화 중인 상대와 같았으므로, “신고 대상”과 “현재 페어”를 구분할 필요가 아예 없었다. 둘은 항상 같은 사람이었다.
바뀐 요구사항은 “상대가 나가도 신고할 수 있어야 한다”였다. 상대가 창을 닫고 나가면 세션이 끊기는데, 그 직후에도 신고 모달은 남아 있어야 신고가 가능하다. 그러면 사용자는 이미 떠난 사람을 신고하는 도중에 새로운 상대와 매칭될 수 있다. 이때 새 통화까지 끊어버리면 안 된다는 것이 이번에 추가한 규칙이었다. “신고 대상이 현재 페어가 아니면 끊지 않는다” — 이 규칙을 서버 쪽 신고 처리 로직과 클라이언트 쪽 통화 상태 전환 로직에 각각 명시적으로 넣었다. 두 곳에 같은 판단을 중복해서 넣은 이유는 서버는 신뢰할 수 없는 클라이언트를 전제로 최종 판단을 내려야 하고, 클라이언트는 서버 응답을 기다리지 않고도 화면을 즉시 갱신해야 하기 때문이다.
규칙은 두 군데 모두 제대로 반영했다. 다만 그 규칙을 사용자에게 설명하는 문장이 하나 더 있다는 걸 놓쳤다. 신고 모달의 안내 문구다.
그래서 무슨 일이 벌어지나
다음 순서로 재현된다.
- A가 B와 통화 중 신고 모달을 연다.
- B가 창을 닫고 나간다. 모달은 그대로 유지된다 — 이번 작업이 의도한 동작이다.
- A가 대기열에서 C와 새로 매칭된다. 신고 모달은 여전히 B를 대상으로 열려 있다.
- A는 C와 통화 중인 화면 위에서 “B님을 신고합니다. 신고하면 대화가 즉시 종료됩니다”를 읽는다.
실제 동작은 명확하다. B를 신고해도 B는 이미 페어가 아니므로 C와의 통화는 끊기지 않는다. 그런데 화면은 끊긴다고 말한다. C와 막 매칭돼 대화 중이던 사용자 입장에서는, 지금 이 대화가 끊길 거라는 경고로 읽을 수밖에 없다. 실제로 끊지 않겠다고 결정한 코드와, 끊는다고 못박아 둔 문구가 같은 화면 위에서 정반대를 말하고 있는 것이다.
이 오해는 신고를 취소하게 만든다. C와의 대화를 지키기 위해 A가 신고를 취소하면, 작성 중이던 B에 대한 신고는 그대로 사라진다. 이 작업이 애초에 고치려던 손실 — 상대가 떠나버리면 신고를 남기지 못하고 흘려보내는 상황 — 이 이번엔 코드가 아니라 문장 때문에 그대로 재현되는 셈이다. 코드로 막은 구멍을 문구가 다시 열어준 꼴이다.
리뷰 단계에서 이 지점을 잡았다. 코드는 “안 끊는다”고 결정했는데 화면은 “끊는다”고 말하고 있었다.
수정
대상이 이미 떠난 경우에만 뒷문장을 빼는 조건을 넣었다.
{partnerNickname}님을 신고합니다.{!ended && ' 신고하면 대화가 즉시 종료됩니다.'}
그리고 왜 조건부인지를 주석으로 남겼다. 서버의 종료 규칙, 클라이언트의 상태 전환 규칙과 정확히 같은 규칙이라는 것, 조건 없이 보여주면 다른 상대와 통화 중인 사용자가 신고를 취소하게 된다는 것을 적었다. 코드만 고치고 이유를 남기지 않으면, 다음 사람이 문구를 다시 손대면서 로직과 별개로 취급해 같은 문제를 재현할 수 있다. 문구와 로직이 “같은 규칙의 두 표현”이라는 사실 자체를 코드에 적어두는 것이 핵심이었다.
이 결함의 성질
테스트는 전부 초록불이었다. 당연한 결과다. 문구는 코드 계약이 아니기 때문이다. 컴포넌트 테스트는 그 문장이 화면에 있는지 정도만 검증하지, 그 문장이 지금 상황에서 사실인지는 검증하지 않는다. “신고하면 종료된다”는 문자열이 렌더되는지는 테스트할 수 있어도, 그 문자열이 실제 동작과 일치하는지는 테스트가 알 방법이 없다. 렌더 결과와 실제 진실 사이에는 테스트가 건드리지 않는 틈이 있다.
이런 결함은 타입 시스템도, 테스트도, 린터도 못 잡는다. 코드와 화면 문구가 같은 사실을 말하는지는 사람이 대조하는 수밖에 없다. 그래서 동작을 바꾸는 작업을 할 때는 그 동작을 설명하는 문장이 코드베이스 어디에 있는지를 함께 찾아야 한다. 로직을 담당하는 파일과 문구를 담당하는 파일이 다르면, 검색어를 바꿔가며 양쪽을 다 훑어야 한다.
관련해서 같은 세션에 고친 것
비슷한 성질의 문제가 하나 더 있었다. auth_invalid 에러 화면의 안내 문구는 “다시 로그인한 뒤 시도해 주세요”라고 말하는데, 정작 그 화면에는 로그인 수단이 없었다. 화면에 있는 “다시 시도” 버튼은 같은 무효 토큰으로 재접속을 시도할 뿐이라 도움이 안 됐고, “돌아가기”를 눌러 진입 화면까지 되돌아가야 비로소 로그인 버튼을 만날 수 있었다. 문구가 시키는 행동과 화면이 제공하는 행동이 서로 다른 화면에 나뉘어 있었던 셈이다.
문구가 요구하는 행동을 화면이 제공하지 않는 것도 같은 계열의 불일치다. 그 화면에 로그인 버튼을 직접 추가했고, 어떤 화면인지 판별은 에러 코드로 했다. 문구 문자열을 매칭하는 방식은 나중에 문구가 바뀌면 조용히 깨지기 때문에 피했다.
교훈
동작을 바꿀 때는 그 동작을 설명하는 문장을 함께 찾아야 한다. 코드 계약만 바꾸고 화면 문구를 그대로 두면, 화면이 거짓말을 하기 시작한다.
사용자에게는 화면에 쓰인 것이 곧 시스템의 동작이다. 코드가 옳아도 문구가 틀리면 사용자는 틀린 쪽에 맞춰 행동하고, 그 행동은 대개 손실로 이어진다.
문구가 요구하는 행동은 그 화면에서 실제로 할 수 있어야 한다. “다시 로그인하라”고 쓰려면 로그인 버튼이 그 화면에 있어야 하고, “끊는다”고 쓰려면 실제로 끊어야 한다.
이 계열의 결함은 자동으로 잡히지 않는다. 테스트도, 타입도, 린터도 문장의 진위를 모른다. 결국 리뷰에서 사람이 코드와 문구를 나란히 놓고 읽어보는 수밖에 없었다.