모달은 그대로 떠 있는데 사용자가 입력한 내용만 사라졌다
랜챗 신고 기능을 고치는 중이었다. 목표는 명확했다 — 신고 모달을 연 사이 상대가 창을 닫아도 작성 중이던 신고가 사라지지 않게 하는 것. 랜덤 화상채팅은 매칭·이탈·재매칭이 초 단위로 일어나는 화면이라, 사용자가 신고 사유를 고르고 상세 내용을 적는 몇 초 사이에 상대가 나가버리는 일이 실제로 자주 일어난다. 그 타이밍에 신고 자체가 날아가면 신고 기능은 있으나 마나였다.
구현을 마치고 테스트 366개가 전부 통과했다. 모달은 상대가 나가도 화면에 그대로 남았고, 제출하면 떠난 상대에게 정확히 기록됐다. 눈으로 봐도, 테스트로 봐도 문제가 없었다. 끝난 줄 알았다.
리뷰가 짚은 것 — 루트 엘리먼트 타입
리뷰가 구조를 짚었다. RandChatView의 렌더가 두 갈래였는데 루트 엘리먼트 타입이 서로 달랐다.
// 대기 화면
return (
<>
<MatchingSpinner />
{reportModal}
</>
)
// 통화 화면
return (
<div style={...}>
...영상·채팅·컨트롤...
{reportModal}
</div>
)
코드만 보면 문제가 없어 보인다. 두 분기 모두 {reportModal}을 렌더한다. 위치도 같고, 조건도 명확하고, 각 분기의 JSX만 따로 읽으면 흠잡을 데가 없다. 그런데 React는 재조정(reconciliation)할 때 이전 렌더와 이번 렌더를 자식 단위로 하나씩 비교하는 게 아니라, 트리의 같은 위치에 있는 엘리먼트의 타입을 먼저 비교한다. 타입이 같으면 그 아래 자식들의 diff로 내려가지만, 타입이 다르면 React는 이전 서브트리가 재사용 가능한지 여부를 아예 따지지 않는다. 그 자리 전체를 언마운트하고, 새 타입으로 처음부터 다시 마운트한다. 이건 최적화를 위한 의도적인 설계다 — 서로 다른 타입의 엘리먼트가 내부 상태를 공유할 가능성은 거의 없다고 가정하고, 굳이 자식까지 내려가서 비교하는 비용을 아끼는 것이다.
그래서 루트가 Fragment에서 div로 바뀌는 순간, 그 아래 있던 ReportModal도 예외 없이 새 인스턴스가 된다. key를 준 것도 아니고, 두 분기가 서로 다른 조건부 렌더 블록 바깥에 있는 것도 아니었으니 React 입장에서는 지극히 정상적인 동작이었다. React를 탓할 자리가 아니라, 두 분기의 루트 타입을 갈라놓은 컴포넌트 구조를 탓해야 하는 자리였다.
그 컴포넌트 안에는 상태가 셋 있었다 — 선택한 사유, 직접입력 텍스트, 차단 체크박스. 리마운트되면 이 셋은 컴포넌트 정의에 적힌 기본값으로 되돌아간다. useState의 초기값이 다시 평가되는 것이니 당연한 결과다.
결과가 교활하다. 바깥의 reportTarget(신고 대상 스냅샷)은 RandChatView 상위에서 관리되는 상태라 리마운트의 영향을 받지 않고 살아남는다. 그래서 모달은 계속 보이고 신고 대상도 정확하다. 화면만 보면 완벽하게 작동하는 기능이다. 다만 사용자가 방금 전까지 골라뒀던 사유와 힘들게 입력하던 텍스트만 조용히 초기값으로 되돌아간다. 사용자는 자기가 고른 사유가 사라졌다는 걸 제출 버튼을 누르기 전까지 알 방법이 없다. 그리고 그게 정확히 이번 작업이 고치려고 했던 문제였다 — 상대가 나가는 순간에 사용자가 입력하던 것을 지키자는 게 애초의 목표였는데, 구현 자체가 그 목표를 미묘하게 어기고 있었던 셈이다.
왜 테스트가 못 잡았나 — 이 글의 핵심
테스트 366개가 초록불이었다. 그중에는 이 시나리오, 그러니까 "모달이 열린 채로 상대가 이탈하는 경우"를 직접 다루는 테스트도 있었다. 그런데도 놓쳤다. 이유는 단순했다.
두 테스트 모두 상태 전환 전에 사유나 상세를 기본값에서 바꾸지 않았다. 모달을 열고, 기본으로 선택된 사유를 그대로 둔 채 상대를 이탈시키고, 곧바로 제출했다. 그래서 리마운트로 상태가 초기화되더라도 제출된 payload가 우연히 기대값과 똑같았다.
// 이런 테스트는 "상태가 보존되는가"를 원리적으로 검증할 수 없다
모달 연다 → (기본 사유 그대로) → 상대 이탈 → 제출
expect(report).toHaveBeenCalledWith({ reason: 'abuse', detail: '', ... })
// 보존됐어도 abuse/'', 초기화됐어도 abuse/'' — 결과가 같다
기본값만 쓰는 테스트는 보존을 검증하지 못한다. 보존됐든 초기화됐든 관측 결과가 동일하기 때문이다. 테스트를 작성할 때는 분명 "리마운트가 일어나도 신고가 살아남는지"를 확인한다고 생각했을 것이다. 실제로는 "리마운트가 일어나도 기본값은 여전히 기본값인지"만 확인하고 있었다. 둘은 겉보기에 비슷해 보이지만 완전히 다른 명제다.
고친 테스트는 사유를 기타로 바꾸고 상세 입력창에 텍스트를 채운 뒤에 상대를 이탈시킨다. 그러자 수정 전 코드에서 실제로 실패했다 — 사유가 other에서 abuse로, 상세가 입력값에서 빈 문자열로 되돌아갔다. 같은 조건, 같은 assertion 구조인데 초깃값 하나를 기본값이 아닌 값으로 바꿨다는 이유만으로 없던 실패가 드러났다.
이건 "상태 보존"을 검증하는 테스트 전반에 적용되는 일반적인 함정이다. 어떤 값이 특정 이벤트를 거치고도 그대로 남아있는지 확인하고 싶다면, 그 값은 반드시 초기값과 구별되는 값이어야 한다. 초기값을 그대로 쓰고 이벤트를 거친 뒤 초기값과 같은지 확인하는 테스트는, 사실상 "초기값이 초기값이다"라는 동어반복을 검증하고 있을 뿐이다. 테스트 코드를 읽을 때 assertion만 보고 통과 여부를 판단하기 쉬운데, 그 assertion이 어떤 입력값에 대해 실제로 변별력을 갖는지까지 따져봐야 이런 함정을 피할 수 있다.
수정
최상위를 하나의 안정된 Fragment로 고정하고, 통화 화면과 대기 화면의 차이는 그 안의 본문만 삼항 연산자로 갈아끼우는 식으로 바꿨다. {reportModal}이 항상 루트의 같은 자식 위치에 있으므로 위치도 타입도 변하지 않고, React는 더 이상 서브트리를 버릴 이유가 없다.
return (
<>
{통화중 ? <div>...</div> : <MatchingSpinner />}
{reportModal}
</>
)
코드 자체는 몇 줄 바뀌지 않았다. 어려운 수정이 아니었다. 오히려 그래서 위험했다 — 나중에 누군가 "가독성을 위해 분기별로 return을 나누자"고 리팩터링하면 똑같은 버그가 그대로 재발한다. 그래서 이 구조를 되돌리지 말라는 주석을 코드와 구조 문서 양쪽에 남겼다. 코드만 보면 "분기별로 나누는 게 깔끔하다"고 판단하기 쉬운 자리라서, 왜 지금 형태여야 하는지 이유를 같이 적어두지 않으면 또 무너질 자리였다.
교훈
- React에서 조건부 렌더의 루트 타입이 바뀌면 그 아래 상태가 전부 사라진다. 같은 컴포넌트를 두 분기에서 각각 렌더한다고 같은 인스턴스가 유지되는 게 아니다. 트리 상의 위치와 엘리먼트 타입이 같아야 React가 같은 인스턴스로 취급한다.
- 기본값만 쓰는 테스트는 "값이 보존되는가"를 절대 검증할 수 없다. 보존과 초기화가 관측 가능한 결과 면에서 완전히 같기 때문이다. 보존을 검증하려면 반드시 기본값이 아닌 값을 넣고 확인해야 한다.
- 겉보기에 멀쩡한 결함이 제일 오래 산다. 여기서는 모달이 계속 보이고 신고 대상도 정확해서, 화면만 봐서는 아무 문제가 없었다. 화면이 멀쩡하다는 것과 상태가 온전하다는 것은 다른 이야기다.