-
Notifications
You must be signed in to change notification settings - Fork 3
Description
🐞 문제 상황 (Problem Description)
Dropdown 컴포넌트에서 키보드 접근성을 추가하기 위해 ArrrowDown, ArrowUp키를 활용해 항목 간 포커스를 이동시키려고 했으나, 실제 동작이 반대로 작동되는 문제가 발생했습니다.
ex) 아래 방향키를 클릭했으나 아래로 이동하지 않고 위로 이동하거나 위 방향키를 누르면 아래로 이동하는 것과 같은 현상
문제 발생 코드
export default function DropdownWrapper({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
// 외부 클릭 또는 키보드 입력 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
const items = dropdownRef.current?.querySelectorAll<HTMLButtonElement>(
'ul[role="menu"] > li > button',
);
if (!items || items.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex =
focusedIndex === null ? 0 : (focusedIndex + 1) % items.length;
items[nextIndex].focus();
setFocusedIndex(nextIndex);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex =
focusedIndex === null
? items.length - 1
: (focusedIndex - 1 + items.length) % items.length;
items[prevIndex].focus();
setFocusedIndex(prevIndex);
}
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);💻 환경 정보 (Environment)
- 브라우저: Chrome 138
- 프론트엔드 프레임워크: React 19, Next.js
- 배포 환경: Vercel
- API 서버: 외부 REST API, 수정 불가
🔍 발생 원인 분석 (Investigation)
-
focusedIndex를useState로 관리했지만, 키보드 이벤트 리스너가 처음 등록될 때의 상태를 참조하는 클로저로 만들어진다고합니다.useEffect외부에서addEventListener로 키 이벤트를 등록했기 때문에, 해당 이벤트 핸들러는 처음 렌더링 시점의 상태(focusedIndex)를 기억한 채 유지됩니다.- 그 결과, 이후 상태가 바뀌어도 이벤트 핸들러 내부에서는 옛날 값을 참조하여 포커스 이동이 잘못 동작합니다.
-
setFocusedIndex는 비동기적으로 작동하기 때문에, 상태를 즉시 참조해도 반영되지 않은 이전 값일 수 있습니다.- 키보드 이벤트 내부에서
focusedIndex를 참조해도, 이는 아직 갱신되기 전의 값일 가능성이 크다고 합니다.
- 키보드 이벤트 내부에서
-
이 두 문제가 결합되면서, 포커스 이동 시 현재 가리키는 인덱스와 실제 포커스되는 DOM 요소 간에 불일치 현상이 발생하게 되었습니다.
💡 클로저란?
자바스크립트에서 함수가 생성될 때, 그 함수가 선언된 렉시컬 환경(즉, 주변 변수의 상태)을 함께 기억하는 개념입니다.
따라서 리액트 컴포넌트 내에서 이벤트 리스너를 등록할 때, 해당 함수는 등록 시점의 상태만을 기억하고 이후 변경 사항은 반영되지 않습니다.
이로 인해 이벤트 핸들러 내부에서는useState로 관리되는 최신 값을 사용할 수 없게 되고, 포커스 로직처럼 실시간 동기화가 중요한 경우에는useRef로 현재 값을 추적하는 방식이 더 안정적이라고 합니다.
🛠 시도해본 해결 방법 (Attempts)
❌ focusedIndex만으로 처리 → 이벤트 안에서 값이 stale
✅ useRef로 focusedIndexRef 추가하여 실시간 추적
✅ useEffect로 상태 변경 시 ref에도 동기화
✅ 이벤트 리스너에서는 focusedIndexRef.current 기준으로 포커스 이동
✅ 최종 해결 방법 (Final Solution)
export default function DropdownWrapper({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false); // 드롭다운 열림 상태
const [focusedIndex, setFocusedIndex] = useState<number | null>(null); // 현재 키보드로 포커스된 항목 인덱스
const focusedIndexRef = useRef<number | null>(null); // 현재 focusIndex를 추적하기 위한 ref
const dropdownRef = useRef<HTMLDivElement>(null); // 드롭다운 루트 요소 참조
// focusedIndex 상태가 변경될 때마다 ref에도 최신 값 저장
useEffect(() => {
focusedIndexRef.current = focusedIndex;
}, [focusedIndex]);
// 외부 클릭 또는 키보드 입력 시 드롭다운 닫기
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node)
) {
setIsOpen(false);
}
};
// 키보드 이벤트 처리 (ArrowUp / ArrowDown / Escape)
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
const items = dropdownRef.current?.querySelectorAll<HTMLButtonElement>(
'ul[role="menu"] > li > button',
);
if (!items || items.length === 0) return;
const currentIndex = focusedIndexRef.current; // 현재 포커스된 인덱스를 ref에서 가져옴
// 아래 방향키: 다음 항목으로 이동
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex =
currentIndex === null ? 0 : (currentIndex + 1) % items.length;
items[nextIndex].focus();
setFocusedIndex(nextIndex); // 상태 갱신
}
// 위 방향키: 이전 항목으로 이동
if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex =
currentIndex === null
? items.length - 1
: (currentIndex - 1 + items.length) % items.length;
items[prevIndex].focus();
setFocusedIndex(prevIndex);
}
// Escape 키: 드롭다운 닫기
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
}-
DropdownContext에는 focusedIndex, setFocusedIndex만 공유하고,
-
focusedIndexRef는 DropdownWrapper 내부에서만 관리합니다.
💡 알게 된 점 (Lessons Learned)
-
React의 상태(useState)는 비동기이며 이벤트 리스너 내부에서는 최신 상태를 보장하지 않음
-
useRef는 상태 변경 없이 항상 최신 값을 추적할 수 있는 방법
-
컴포넌트 외부(DOM에 직접 등록한 이벤트 리스너) 에서 이벤트가 발생할 경우, 내부 상태와의 싱크 문제를 반드시 고려해야 함