자동완성이 다시 안 뜬다 — 닫힘 상태를 값으로 저장했을 때
자동완성 닫힘 상태를 검색어 값으로 기억하면 지웠다 같은 값을 다시 입력했을 때(IME 수정 동작) 패널이 영영 안 열립니다. 값 대신 편집 횟수 카운터로 바꿔야 합니다.
검색창 아래에 붙는 자동완성 패널을 만들었습니다. 요구사항은 흔한 것이었습니다. Esc·바깥 클릭·항목 선택으로 닫히고, 사용자가 다시 입력하면 열립니다.
"닫혔다"를 어떻게 기억할까요. 전역 boolean 으로 두면 한 번 닫은 뒤 계속 타이핑해도 안 열립니다. 그래서 닫을 때의 검색어를 저장하고, 지금 검색어와 다르면 열도록 했습니다.
const [dismissed, setDismissed] = useState("");
const open = query.length > 0 && dismissed !== query;한 글자만 더 쳐도 값이 달라지니 열립니다. 그럴듯하고, 실제로 몇 달을 잘 돌았습니다.
증상
QA 리포트가 하나 올라왔습니다.
검색 결과를 한 번 확인하고 나면, 같은 검색어로는 자동완성이 안 뜬다.
재현은 이렇습니다. 윈도우 로 검색해서 뜬 목록에서 하나를 열어 봅니다. 이때 dismissed = "윈도우" 가 됩니다. 다시 검색창으로 돌아와 마지막 글자 우 를 지웠다가, 그대로 우 를 다시 칩니다.
값이 "윈도우" 로 되돌아오는 순간 dismissed === query 가 되어 패널이 영영 안 열립니다.
핵심은 같은 값으로 되돌아오는 편집이 예외가 아니라 일상이라는 것입니다. 특히 한국어·일본어·중국어처럼 IME 로 조합하는 언어에서는 마지막 음절에 캐럿이 걸린 채 지웠다 다시 치는 것이 표준 수정 동작입니다. 윈도우 → 윈도 → 윈도우 는 사용자에게 "고쳐 쓴 것"이지만, 값 비교에는 "아무 일도 없었던 것"입니다.
당시 코드에는 이미 반쪽 보정이 들어 있었습니다.
useEffect(() => {
if (!query) setDismissed(""); // 다 지우면 닫힘도 푼다
}, [query]);전체 삭제만 구제되고 부분 삭제 후 재입력은 그대로 뚫립니다. 값 축에 남아 있는 한 이런 특수 케이스가 계속 늘어납니다. 이건 보정을 하나 더 붙일 문제가 아니라 축이 틀린 문제였습니다.
축을 값에서 편집으로
닫힘을 푸는 기준을 값이 아니라 편집 이벤트로 바꿉니다. 입력을 다루는 훅이 "사용자가 입력을 직접 건드린 횟수"를 카운터로 내고, 패널은 그 카운터가 바뀔 때만 닫힘을 풉니다. 값이 같아도 편집은 편집이므로 다시 열립니다.
카운터여야 하는 이유가 하나 더 있습니다. controlled input 의 값은 사용자 타이핑과 외부 주입(라우팅 복원, 뒤로가기, 프로그램적 초기화) 두 경로로 바뀌는데, 값만 봐서는 둘을 구분할 수 없습니다. 세터를 갈라 두면 구분이 공짜로 생깁니다.
function useSearchDraft(applied: string) {
const [draft, setDraftValue] = useState(applied);
const [editSeq, setEditSeq] = useState(0);
// 사용자 입력 — 값이 같아도 카운터는 오른다
const setDraft = useCallback((v: string) => {
setDraftValue(v);
setEditSeq((n) => n + 1);
}, []);
// 외부 동기화 — 카운터를 올리지 않는다
useEffect(() => setDraftValue(applied), [applied]);
return { draft, setDraft, editSeq };
}두 setState 는 같은 이벤트 핸들러 안에서 불리므로 자동 배치되어 렌더는 한 번만 더 돕니다.
패널 쪽은 이렇게 받습니다.
const [dismissed, setDismissed] = useState(true); // 초기값은 닫힘
const seenEdit = useRef(editSeq);
useEffect(() => {
if (seenEdit.current === editSeq) return; // 마운트 1회는 건너뛴다
seenEdit.current = editSeq;
setDismissed(!query); // 비우는 편집은 도로 잠근다
}, [editSeq, query]);
const open = query.length > 0 && !dismissed;세 줄에 각각 이유가 있습니다.
초기값이 true인 것 — 주소창에 검색어가 실린 채 진입하는 경우가 있습니다. 딥링크, 새로고침, 뒤로가기, 알림 링크. 이때 입력창은 처음부터 채워져 있으므로 값 축이었다면 타이핑 0회에 패널이 결과 화면을 덮습니다. 편집 축에서는 마운트가 편집이 아니므로 자동으로 막힙니다.
seenEdit ref 를 초기값으로 seed 하는 것 — effect 는 마운트 때도 한 번 돕니다. seed 해 두면 첫 실행이 그냥 return 이라 위 보호가 유지됩니다. useRef 값은 StrictMode 이중 실행에도 한 번만 확정되므로 안전합니다. 그리고 카운터를 setEditSeq(n => n + 1) 형태의 updater 로 올리는 것도 이 대목에서 제 몫을 합니다 — setEditSeq(editSeq + 1) 이었다면 개발 모드 이중 호출에서 어긋납니다.
setDismissed(!query) — 비우는 것도 편집입니다. 그런데 "빈 입력 + 닫힘 해제" 상태로 남겨 두면, 나중에 외부 주입으로 값이 채워지는 순간 타이핑 없이 열립니다. 빈 상자에는 어차피 열 것이 없으므로 잠금으로 되돌려도 잃는 동작이 없습니다. 참고로 × 버튼만 예외 처리하면 부족합니다. 전체 선택 후 Delete 경로가 남습니다. 축은 버튼이 아니라 "편집 결과가 빈 값인가"입니다.
곁다리로 딸려온 것 — 사라진 의존성
여기서 예상 못 한 것이 하나 나왔습니다.
닫힘이 값이던 시절에는 여러 콜백이 useCallback(..., [query]) 로 검색어에 묶여 있었습니다. 편집 축으로 옮기면서 그것들이 전부 안정 콜백이 됐습니다. 의존성이 줄어든 것 자체는 좋은 일입니다.
문제는 같은 의존성 배열을 공유하던 키보드 핸들러였습니다.
const onKeyDown = useCallback((e) => {
if (e.key === "Enter") submit(query);
}, [items, open /* submit 이 빠져 있다 */]);이전에는 검색어가 바뀔 때마다 [query] 에 묶인 콜백들이 갈리면서 이 핸들러도 덩달아 새로 만들어졌습니다. 그 우연한 갱신이 사라지자, 핸들러가 옛 검색어를 물고 있는 창이 생깁니다. abc → abcd 로 고쳐 치고 곧바로 Enter 를 누르면 abc 로 이동합니다. 타이핑 직후 Enter 는 아주 흔한 입력입니다.
react-hooks/exhaustive-deps 경고는 원래부터 떠 있었습니다. 다만 다른 의존성이 우연히 값을 실어 나르는 동안에는 증상이 없어서 무해한 경고로 보였을 뿐입니다.
교훈은 이렇습니다. 콜백을 안정화하는 리팩터는 그 자체로 옳더라도, 어떤 값이 어느 경로로 핸들러에 도달하고 있었는지를 같이 봐야 합니다. 우연한 갱신 경로를 끊으면 잠자던 stale closure 가 깨어납니다. 의존성 배열을 줄이는 변경은 "무엇이 줄었나"보다 "무엇이 더 이상 안 들어오나"를 봐야 합니다.
테스트로 고정할 때의 함정
이 stale closure 를 재현하려다 한 번 헛다리를 짚었습니다. 첫 시도는 이랬습니다.
type("abc");
await waitFor(() => expect(panel).toBeVisible());
type("abcd");
await new Promise((r) => setTimeout(r, 300)); // ← 이 줄이 버그를 숨긴다
pressEnter();
expect(submit).toHaveBeenCalledWith("abcd"); // 통과해 버린다300ms 를 기다리는 동안 쿼리 상태가 움직이고, 그 리렌더가 핸들러를 새로 만들어 버립니다. 테스트는 초록인데 실제 버그는 살아 있습니다. 기다림을 빼자 바로 빨간불이 됐습니다.
두 가지가 남습니다. 타이밍 의존 버그의 재현 조건은 "기다리지 않는 것"일 수 있습니다. 그리고 새로 쓴 가드는 반드시 고치기 전 코드에서 실패하는지 확인해야 합니다. 통과하는 가드는 없는 것보다 나쁩니다. 거짓 안심을 주기 때문입니다.
정리
- 사용자 인터랙션의 "닫음 / 봤음 / 무시함" 같은 상태를 콘텐츠 값으로 키잉하지 말아야 합니다. 값은 되돌아옵니다.
- controlled input 은 사용자 편집과 외부 주입이 같은 값 변화로 보입니다. 구분이 필요하면 세터를 갈라 신호를 만듭니다.
- IME 언어에서는 "지웠다 같은 글자 재입력"이 표준 수정 동작입니다. 값 비교 기반 UI 로직은 여기서 먼저 깨집니다. 영어권 테스트로는 잘 안 잡힙니다.