useOnClickOutside: 모달과 드롭다운을 닫는 이벤트 위임의 미학

크리스가 만든 사이트의 사용자들에게서 불만이 접수되었다.
"팝업창을 닫으려면 꼭 오른쪽 구석에 있는 쥐똥만 한 'X' 버튼을 눌러야 하나요? 그냥 바깥 배경을 누르면 꺼지게 해 주세요!"
생각해 보니 맞는 말이다. 네이버나 구글의 메뉴들은 바깥쪽 아무 데나 클릭하면 스르륵 닫힌다.
크리스는 이 기능을 구현하기 위해 useEffect를 열고 document에 이벤트 리스너를 달기 시작했다.
// ❌ 크리스의 하드코딩 (모든 컴포넌트에 복사 붙여넣기 중)
function Dropdown({ onClose }) {
const dropdownRef = useRef(null);
useEffect(() => {
const handleClick = (e) => {
// 내 영역(dropdownRef) 바깥을 클릭했는지 확인
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
onClose();
}
};
document.addEventListener('mousedown', handleClick);
return () => {
document.removeEventListener('mousedown', handleClick);
};
}, [onClose]);
return <div ref={dropdownRef}>메뉴 내용</div>;
}기능은 잘 동작한다. 하지만 드롭다운, 모달, 사이드바, 툴팁 등 이 기능이 필요한 컴포넌트가 한두 개가 아니다. 그때마다 useEffect와 contains 로직을 복사해서 붙여넣는 것은 끔찍한 일이다.
이 로직을 우아한 훅 하나로 분리해 보자.
1. 훅의 설계: "내 영역이 아니면 알려줘"
우리가 만들 useOnClickOutside 훅은 두 가지를 인자로 받아야 한다.
// useOnClickOutside.ts
import { useEffect, RefObject } from 'react';
type Handler = (event: MouseEvent | TouchEvent) => void;
export function useOnClickOutside<T extends HTMLElement = HTMLElement>(
ref: RefObject<T>,
handler: Handler
) {
useEffect(() => {
const listener = (event: MouseEvent | TouchEvent) => {
const el = ref.current;
// 1. 요소가 없거나, 클릭한 타겟이 내 요소 안쪽(contains)이라면 무시
if (!el || el.contains(event.target as Node)) {
return;
}
// 2. 바깥쪽이라면 핸들러 실행
handler(event);
};
// 마우스 클릭(mousedown)과 모바일 터치(touchstart) 모두 대응
document.addEventListener('mousedown', listener);
document.addEventListener('touchstart', listener);
return () => {
document.removeEventListener('mousedown', listener);
document.removeEventListener('touchstart', listener);
};
}, [ref, handler]); // ref나 handler가 바뀌면 리스너 재등록
}핵심 로직은 Node.contains() 메서드다. 부모.contains(자식)은 자식이 부모 안에 포함되어 있으면 true를 반환한다. 우리는 이를 뒤집어서 !contains일 때만 반응하도록 만들었다.
2. 실전 적용: 드롭다운 메뉴 닫기
이제 크리스는 복잡한 useEffect를 싹 지우고, 훅 한 줄로 기능을 구현할 수 있다.
// Dropdown.tsx
import { useRef } from 'react';
import { useOnClickOutside } from './useOnClickOutside';
function Dropdown({ onClose }: { onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
// ✅ ref 영역 바깥을 클릭하면 onClose가 실행된다.
useOnClickOutside(ref, () => {
onClose();
});
return (
<div ref={ref} className="dropdown-menu">
<ul>
<li>내 정보</li>
<li>설정</li>
<li>로그아웃</li>
</ul>
</div>
);
}이제 사용자가 메뉴를 열었다가 다른 곳을 클릭하면, 메뉴가 자연스럽게 닫힌다.
3. 심화: 왜 'click' 대신 'mousedown'을 쓸까?
많은 초보자가 document.addEventListener('click', ...)을 사용한다. 하지만 UI 라이브러리들은 대부분 mousedown을 선호한다. 왜일까?
드래그 이슈(Drag Issue) 때문이다.
mousedown을 사용하면 "누르는 순간" 바로 판정하기 때문에, 이런 드래그 오작동을 방지할 수 있다.
4. 모바일 대응: touchstart
모바일 환경에서는 click 이벤트가 300ms 지연 발생하거나, 특정 요소에서 잘 동작하지 않을 때가 있다.
따라서 touchstart 이벤트를 함께 리스닝 해주는 것이 모바일 반응성(Responsiveness) 면에서 훨씬 유리하다.
5. 주의할 점: 포탈(Portal)과 이벤트 버블링
만약 모달을 React Portal을 사용해 document.body 바로 아래에 렌더링 했다면 어떻게 될까?
DOM 트리 구조상으로는 모달이 메인 앱 바깥에 있지만, 리액트의 이벤트 버블링(Event Bubbling)은 포탈을 타고 전파되므로 기능에는 문제가 없다.
하지만 e.stopPropagation() 등으로 이벤트를 막아버리는 코드가 중간에 있다면 document까지 이벤트가 도달하지 못해 훅이 동작하지 않을 수 있다. 이 훅은 이벤트가 document까지 올라온다는 전제하에 동작함을 기억하자.
핵심 정리
이제 UI 인터랙션 훅도 마스터했다.
그런데 리액트에는 아주 고질적인 문제가 하나 있다. useEffect 안에서 setInterval을 쓰면, 타이머가 최신 상태 값을 읽지 못하고 멈춰버리는 현상이다.
일명 "Stale Closure(상한 클로저)" 문제를 해결하는 Dan Abramov의 마법 같은 패턴을 알아보자.
"useInterval & useTimeout: 리액트의 클로저 함정 탈출하기" 편에서 계속된다.