본문으로 건너뛰기
wooncloud

자동완성이 다시 안 뜬다 — 닫힘 상태를 값으로 저장했을 때

자동완성 패널의 닫힘 상태를 검색어 값으로 관리하면 IME 입력 시 같은 단어 재입력 등 예외 상황에서 오작동이 발생합니다. 이를 해결하기 위해 값 대신 사용자의 편집 횟수를 추적하는 카운터 방식을 도입하고, 외부 주입과 사용자 입력을 구분하여 상태 관리의 정확성을 높이는 방법을 공유합니다

·11 min read· views·

닫힘 상태를 값으로 저장하면, 값이 제자리로 돌아오는 순간 잠긴 채로 남는다

검색창 아래에 붙는 자동완성 패널을 만들었다. 요구사항은 흔한 것이었다. 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] 에 묶인 콜백들이 갈리면서 이 핸들러도 덩달아 새로 만들어졌다. 그 우연한 갱신이 사라지자, 핸들러가 옛 검색어를 물고 있는 창이 생긴다. abcabcd 로 고쳐 치고 곧바로 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 로직은 여기서 먼저 깨진다. 영어권 테스트로는 잘 안 잡힌다.

참고