---
title: "포커스 트랩 훅의 deps에 콜백을 두면 타이핑 중 포커스를 빼앗긴다"
description: "모달에 닫기 확인 가드를 넣었더니 타이핑할 때마다 포커스가 첫 입력칸으로 튀었습니다. effect deps 에 인라인 콜백이 들어 있던 포커스 트랩 훅을 ref 로 고친 과정입니다."
date: 2026-10-20
updated: 2026-10-20T00:30:50.428Z
tags: [react, accessibility, hooks, focus-trap]
canonical: https://blog.wooncloud.com/posts/focus-trap-effect-deps-steal-focus
---

모달에 "입력 중인 내용이 있으면 닫기 전에 확인"하는 가드를 넣다가 이상한 버그를 만났습니다. 닫기 요청을 가로채는 `requestClose` 함수를 만들어 모달의 `onClose` 로 넘겼을 뿐인데, 그때부터 본문 textarea 에 글자를 칠 때마다 포커스가 첫 입력칸으로 튀었습니다.

![콜백이 deps에 들어 있으면 매 렌더마다 effect가 재실행돼 타이핑 중 포커스가 첫 입력칸으로 끌려가지만, 콜백을 ref로 빼고 deps에서 제외하면 포커스가 입력 중인 칸에 그대로 남는다.](/images/posts/focus-trap-effect-deps-steal-focus/51f77289-438f-450a-93a2-e06c973cd7b6.webp)

두 번째 글자부터는 엉뚱한 칸에 들어가니, 사용자 입장에서는 "이 모달은 글을 쓸 수가 없다"로 보입니다.

## 범인은 포커스 트랩 훅이었다

접근성용 포커스 트랩 훅이 문제였습니다. 흔한 구현은 이렇게 생겼습니다.

```ts
useEffect(() => {
  previousActiveElement.current = document.activeElement;
  getFocusable()[0]?.focus();            // setup: 초기 포커스

  container.addEventListener("keydown", onKeyDown);
  return () => {
    container.removeEventListener("keydown", onKeyDown);
    previousActiveElement.current?.focus();  // teardown: 이전 포커스 복원
  };
}, [active, onEscape]);                  // 문제는 여기
```

`onEscape` 는 함수입니다. 호출부가 인라인 화살표 함수를 넘기면 매 렌더마다 새 identity 가 되고, deps 에 들어 있으니 effect 가 리렌더 때마다 teardown 과 setup 을 반복합니다. 그런데 이 effect 의 teardown 과 setup 은 둘 다 포커스를 강제로 옮기는 부수효과입니다.

teardown 은 마운트 시점의 activeElement(보통 `autoFocus` 가 걸린 첫 입력칸)로 포커스를 되돌리고, setup 은 첫 포커스 가능 요소로 이동하는데 이것도 첫 입력칸입니다.

모달 안에서 입력값을 state 로 들고 있으면 글자 하나마다 리렌더가 일어납니다. 그러니 **타이핑할 때마다 포커스가 첫 칸으로 강탈**되는 거죠.

![글자를 입력할 때마다 리렌더로 onEscape 가 새로 만들어지고, deps 가 바뀐 effect 가 teardown 과 setup 으로 포커스를 첫 입력칸으로 옮긴다.](/images/posts/focus-trap-effect-deps-steal-focus/31fff7a8-f326-4d14-aa16-cac1236231fa.svg)

기존에 안 터졌던 건 우연이었습니다. 호출부가 안정적인 prop 을 그대로 넘기고 있었을 뿐이고, 콜백 하나를 감싸는 순간 드러났습니다.

## useCallback 으로 막으면 되지 않을까?

반쪽짜리 처방입니다. 의존성에 dirty 같은 파생 state 가 끼면 그 값이 뒤집히는 렌더에서 identity 가 바뀌어 한 번은 포커스를 뺏깁니다. 한 번이라도 튀면 사용자는 알아챕니다.

## 콜백은 ref 로, deps 에서는 빼기

effect 의 수명은 "트랩이 살아 있는 기간"이어야 하고, 콜백의 identity 와는 무관해야 합니다. 그래서 최신 콜백만 ref 로 따라가게 하고 deps 에서 뺍니다.

```ts
const onEscapeRef = useRef(onEscape);
useEffect(() => {
  onEscapeRef.current = onEscape;
}, [onEscape]);

useEffect(() => {
  // ... setup / teardown (포커스 이동 포함)
  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === "Escape") onEscapeRef.current?.();
  };
  container.addEventListener("keydown", onKeyDown);
  return () => container.removeEventListener("keydown", onKeyDown);
}, [active]);                            // 콜백은 deps 에서 제외
```

일반화하면 이렇습니다. 부수효과가 "설정"이 아니라 "이동"인 effect 는 deps 를 최소로 유지해야 합니다. 포커스 이동, 스크롤 위치 복원, 미디어 재생 위치 seek 처럼 재실행 자체가 사용자 눈에 보이는 동작이면, 재실행 트리거를 콜백 identity 같은 우발적 변화에 묶으면 안 됩니다.

## 회귀 테스트는 두 번 입력해야 잡힌다

이런 버그는 "한 번 입력하고 끝"인 테스트를 그냥 통과합니다. `change` 를 연속으로 두 번 이상 발생시키고 `document.activeElement` 를 확인해야 잡힙니다.

```ts
textarea.focus();
fireEvent.change(textarea, { target: { value: "a" } });
fireEvent.change(textarea, { target: { value: "ab" } });
expect(document.activeElement).toBe(textarea);
```

값싼 가드라서 훅을 건드릴 때마다 같이 돌려 두려고 합니다.

참고

- [React, useEffect (Removing unnecessary object dependencies)](https://react.dev/reference/react/useEffect#removing-unnecessary-object-dependencies)
- [React, Separating Events from Effects](https://react.dev/learn/separating-events-from-effects)
- [WAI-ARIA APG, Dialog (Modal) Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/)
