---
title: "자동완성이 다시 안 뜬다 — 닫힘 상태를 값으로 저장했을 때"
description: "자동완성 패널의 닫힘 상태를 검색어 값으로 관리하면 IME 입력 시 같은 단어 재입력 등 예외 상황에서 오작동이 발생합니다. 이를 해결하기 위해 값 대신 사용자의 편집 횟수를 추적하는 카운터 방식을 도입하고, 외부 주입과 사용자 입력을 구분하여 상태 관리의 정확성을 높이는 방법을 공유합니다"
date: 2026-09-07
updated: 2026-09-07T10:01:18.945Z
tags: [react, ime, frontend, 상태관리]
canonical: https://blog.wooncloud.com/posts/autocomplete-dismiss-by-edit-not-value
---

![닫힘 상태를 값으로 저장하면, 값이 제자리로 돌아오는 순간 잠긴 채로 남는다](/images/posts/autocomplete-dismiss-by-edit-not-value/126e2d10-1c11-4379-8aa0-c0ecc0fe6aaf.webp)

검색창 아래에 붙는 자동완성 패널을 만들었다. 요구사항은 흔한 것이었다. Esc·바깥 클릭·항목 선택으로 닫히고, 사용자가 다시 입력하면 열린다.

"닫혔다"를 어떻게 기억할까. 전역 boolean 으로 두면 한 번 닫은 뒤 계속 타이핑해도 안 열린다. 그래서 **닫을 때의 검색어**를 저장하고, 지금 검색어와 다르면 열도록 했다.

```ts
const [dismissed, setDismissed] = useState("");
const open = query.length > 0 && dismissed !== query;
```

한 글자만 더 쳐도 값이 달라지니 열린다. 그럴듯하고, 실제로 몇 달을 잘 돌았다.

## 증상

QA 리포트가 하나 올라왔다.

> 검색 결과를 한 번 확인하고 나면, 같은 검색어로는 자동완성이 안 뜬다.

재현은 이렇다. `윈도우` 로 검색해서 뜬 목록에서 하나를 열어 본다. 이때 `dismissed = "윈도우"` 가 된다. 다시 검색창으로 돌아와 마지막 글자 `우` 를 지웠다가, **그대로 `우` 를 다시 친다.**

값이 `"윈도우"` 로 되돌아오는 순간 `dismissed === query` 가 되어 패널이 영영 안 열린다.

핵심은 **같은 값으로 되돌아오는 편집이 예외가 아니라 일상**이라는 것이다. 특히 한국어·일본어·중국어처럼 IME 로 조합하는 언어에서는 마지막 음절에 캐럿이 걸린 채 지웠다 다시 치는 것이 표준 수정 동작이다. `윈도우` → `윈도` → `윈도우` 는 사용자에게 "고쳐 쓴 것"이지만, 값 비교에는 "아무 일도 없었던 것"이다.

당시 코드에는 이미 반쪽 보정이 들어 있었다.

```ts
useEffect(() => {
  if (!query) setDismissed("");   // 다 지우면 닫힘도 푼다
}, [query]);
```

전체 삭제만 구제되고 **부분 삭제 후 재입력**은 그대로 뚫린다. 값 축에 남아 있는 한 이런 특수 케이스가 계속 늘어난다. 이건 보정을 하나 더 붙일 문제가 아니라 축이 틀린 문제였다.

## 축을 값에서 편집으로

닫힘을 푸는 기준을 **값이 아니라 편집 이벤트**로 바꾼다. 입력을 다루는 훅이 "사용자가 입력을 직접 건드린 횟수"를 카운터로 내고, 패널은 그 카운터가 바뀔 때만 닫힘을 푼다. 값이 같아도 편집은 편집이므로 다시 열린다.

카운터여야 하는 이유가 하나 더 있다. controlled input 의 값은 **사용자 타이핑**과 **외부 주입**(라우팅 복원, 뒤로가기, 프로그램적 초기화) 두 경로로 바뀌는데, 값만 봐서는 둘을 구분할 수 없다. 세터를 갈라 두면 구분이 공짜로 생긴다.

```ts
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 는 같은 이벤트 핸들러 안에서 불리므로 자동 배치되어 렌더는 한 번만 더 돈다.

패널 쪽은 이렇게 받는다.

```ts
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])` 로 검색어에 묶여 있었다. 편집 축으로 옮기면서 그것들이 전부 **안정 콜백**이 됐다. 의존성이 줄어든 것 자체는 좋은 일이다.

문제는 같은 의존성 배열을 공유하던 키보드 핸들러였다.

```ts
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 를 재현하려다 한 번 헛다리를 짚었다. 첫 시도는 이랬다.

```ts
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 로직은 여기서 먼저 깨진다. 영어권 테스트로는 잘 안 잡힌다.

## 참고

- [MDN — compositionstart event](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)
- [React — useRef](https://react.dev/reference/react/useRef)
- [React — useCallback: my function is called on every re-render](https://react.dev/reference/react/useCallback#my-function-is-called-on-every-re-render)
