현업에서 ‘모달’에 자주 쓰는 리액트 훅 모음
협업에서는 어떤 코드를 짜야 하는지 궁금하신가요? 리액트를 배우고 나서 실제 프로젝트를 시작하면, 예상보다 많은 고민이 생깁니다. "이 로직을 매번 반복해서 써야 하나?", "다른 개발자들은 이런 상황을 어떻게 처리하지?", "내 코드가 너무 길어지는 것 같은데..." 같은 생각들이요. 제가 실무에서 느낀 점은 자주 사용하는 패턴들을 잘 추상화해 놓으면, 코드 중복을 줄이고 훨씬 직관적인 코드를 작성할 수 있다는 점이었습니다. 그래서 실무진들은 이미 자주 쓰는 훅을 사내에서 정의해놓고 공유합니다. 빅테크들은 이런 노하우들을 모아서 따로 오픈소스로 공개하기도 하고요.
물론 정답은 없습니다. 저는 저만의 해결 방식이 있고, 여러분은 또 다른 방법을 찾을 수도 있어요. 대신 "이런 식으로도 접근할 수도 있구나"의 관점에서 봐주시면 좋겠습니다. 이번 글에서는 "모달"을 만든다고 가정하고, 여기에 어떤 훅들이 쓰일 수 있는지 알아볼게요. 모달 하나에도 여러 UX가 있고, 이걸 개발하려면 번거로운 부분이 있기 때문입니다.

모달 UX 패턴과 개발 고민들
모달을 만들다 보면 생각보다 많은 UX 고민이 생깁니다. 사용자들이 자연스럽게 기대하는 동작들이 있거든요.
상태 관리의 반복 패턴
가장 기본적인 것부터 시작해 볼게요. 우선 모달을 열고 닫는 상태 관리입니다. 매번 `useState`로 불린(Boolean) 상태를 만들고, 열기/닫기/토글 함수를 정의하게 됩니다. 모달뿐만 아니라 드롭다운, 사이드바, 아코디언 등 불린 상태를 다루는 모든 곳에서 똑같은 패턴이 반복되죠.
외부 클릭으로 닫기
사용자들은 당연히 모달 바깥을 클릭하면 닫힐 거라고 생각합니다. 하지만 이를 매번 구현하려면 번거롭습니다. DOM 이벤트 리스너를 등록하고 해제하는 로직이 필요하고, 여러 모달이 있으면 각각 관리해야 하며, 메모리 누수도 신경 써야 하죠.
키보드로 제어하기
접근성을 고려하면 키보드로도 모달을 제어할 수 있어야 합니다. ESC 키로 닫기, Enter 키로 확인하기 같은 것들이요. 하지만 매번 키 이벤트 핸들러를 작성하다 보면, 하드코딩된 키 이름들과 길어지는 조건문 때문에 가독성이 떨어집니다.
확인/취소 결과 처리
모달은 주로 사용자에게 무언가를 확인받기 위해 사용됩니다. "정말 삭제하시겠습니까?" 같은 확인 모달에서는 사용자가 확인 버튼을 누르면 어떤 작업을 진행하고, 취소하면 아무것도 하지 않아야 하죠. 네이티브 `confirm()`은 사용법이 간편하지만 스타일링이 불가능하고, 커스텀 모달을 만들면 콜백으로 결과를 처리해야 해서 복잡해집니다. "네이티브 API처럼 간편하게 사용하면서도 완전히 커스터마이징 가능한 모달은 없을까?" 하는 고민이 생깁니다.
이런 반복적인 패턴들과 고민을 해결하기 위해, 제가 실무에서 사용하는 몇 가지 훅들을 소개하려고 합니다. 모든 상황에서 완벽한 해답은 아니지만, 이런 방식도 있다는 걸 참고해 주시면 좋습니다.
useToggle
불린 상태를 다루는 건 정말 자주 하는 일입니다. 모달 열기/닫기, 드롭다운 표시/숨김, 사이드바 접기/펼치기 등 매번 똑같은 패턴을 반복하게 되죠. 저는 불린 상태와 그 상태를 조작하는 함수들을 하나로 묶어서 반환하는 방식을 자주 사용합니다. 이렇게 하면 매번 토글 로직을 작성할 필요가 없어지거든요. 단순히 상태만 반환하는 게 아니라, 그 상태를 조작하는 의미 있는 함수들까지 함께 제공하면 사용성이 좋습니다.
const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue);
const toggle = useCallback(() => setValue(prev => !prev), []);
const setTrue = useCallback(() => setValue(true), []);
const setFalse = useCallback(() => setValue(false), []);
return [value, toggle, setTrue, setFalse];
};
useToggle은 다른 회사의 오픈소스에도 종종 볼 수 있는 훅입니다.
사용법
모달에서 사용할 때는 이렇게 쓸 수 있죠.
// Before: 매번 반복하던 패턴
const [isModalOpen, setIsModalOpen] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
// After: useToggle 사용
const [isModalOpen, toggleModal, openModal, closeModal] = useToggle(false);
함수명이 의미를 명확하게 드러내니까 코드를 읽기도 쉬워집니다. `openModal()`, `closeModal()` 이렇게 쓰면 뭘 하는 건지 바로 알 수 있죠.
다른 UI 요소들에서도 마찬가지입니다.
// 로딩 상태
const [isLoading, toggleLoading, startLoading, stopLoading] = useToggle(false);
// 다크 모드
const [isDarkMode, toggleDarkMode] = useToggle(false);
// 사이드바
const [isSidebarOpen, toggleSidebar, openSidebar, closeSidebar] = useToggle(false);
매번 `!state` 같은 토글 로직을 작성하는 게 귀찮았는데, 이 반복적인 패턴을 훅 내부로 숨겨서 재사용할 수 있어서 편합니다. `setValue(true)` 보다는 `openModal()`이 훨씬 직관적이고, 코드만 봐도 뭘 하려는 건지 바로 이해할 수 있죠. 프로젝트 전체에서 불린 상태를 다루는 방식도 통일되고, 팀원들도 예측 가능한 패턴으로 코드를 작성할 수 있어서 좋았습니다.
useOutsideClick
모달이나 드롭다운 메뉴에서 바깥쪽을 클릭하면 닫히게 하는 로직은 자주 구현하게 됩니다. 그런데 그럴 때마다 useEffect와 DOM 참조를 직접 구현하는 건 귀찮은 일입니다.
기존 방식
보통은 이런 식으로 구현하게 되죠.
const modalRef = useRef(null);
useEffect(() => {
const handleClick = (e) => {
if (modalRef.current && !modalRef.current.contains(e.target)) {
setIsModalOpen(false);
}
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
매번 동일한 패턴을 반복해서 작성해야 하는 게 번거로워, 저는 ref 기반 콜백 인터페이스를 사용합니다. 이건 Svelte에서 사용하는 인터페이스를 차용한 건데, DOM에 접근할 필요가 있으니까 이런 방식을 택했습니다.
export const useOutsideClick = () => {
const handlersRef = useRef(new Map());
useEffect(() => {
const handleClick = (e) => {
const handlers = handlersRef.current;
handlers.forEach((callback, element) => {
if (!element.contains(e.target)) {
callback();
}
});
};
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
return (callback) => {
return (element) => {
if (!element) return;
handlersRef.current.set(element, callback);
return () => {
handlersRef.current.delete(element);
};
};
};
};
사용법
이렇게 사용할 수 있어요.
const MyModal = () => {
const [isOpen, toggleModal, openModal, closeModal] = useToggle(false);
const onOutsideClick = useOutsideClick();
return (
<div className="modal-overlay">
<div ref={onOutsideClick(() => closeModal())}>
<h2>모달 제목</h2>
<p>모달 내용</p>
<button onClick={closeModal}>닫기</button>
</div>
</div>
);
};
드롭다운에서는
const DropdownMenu = () => {
const [isOpen, toggleMenu, openMenu, closeMenu] = useToggle(false);
const onOutsideClick = useOutsideClick();
return (
<div className="dropdown">
<button onClick={toggleMenu}>메뉴 열기</button>
{isOpen && (
<ul ref={onOutsideClick(() => closeMenu())}>
<li>메뉴 1</li>
<li>메뉴 2</li>
<li>메뉴 3</li>
</ul>
)}
</div>
);
};
콜백을 DOM과 가까이 정의하는 게 가독성이 좋습니다. 어떤 라이브러리에서는 `<OutsideClick as="div" />` 이렇게 컴포넌트로 만들기도 하지만, 저는 DOM 태그의 가독성을 위해 이런 방식을 선호합니다. 실제 DOM 구조가 명확하게 보이고, 외부 클릭 처리 로직이 해당 요소 바로 옆에 있어서 이해하기 쉽거든요.
keyPress
keyPress는 훅은 아니지만, 모달에서 키보드 이벤트를 처리할 때 자주 사용하는 유틸리티 함수입니다. 모달에서 ESC 키로 닫기, Enter 키로 확인하기 같은 기능을 구현하다 보면 이런 코드를 자주 작성하게 됩니다.
const handleKeyPress = (event) => {
if (event.key === 'Escape') {
closeModal();
} else if (event.key === 'Enter') {
handleConfirm();
} else if (event.key === 'ArrowUp') {
selectPrevious();
} else if (event.key === 'ArrowDown') {
selectNext();
}
};
키가 많아질수록 조건문이 길어지고, 키 이름을 하드코딩하다 보면 오타도 생기고 가독성도 떨어집니다. 그래서 좀 더 선언적으로 하면 좋을 것 같은데요. onClick, onInput처럼 키보드의 특정 동작도 핸들러로 따로 등록할 수 있게 하면 어떨까요? "어떻게 처리하는지"보다는 "무엇을 처리하는지"에 집중할 수 있습니다.
export const keyPress = (handlers, options = {}) => {
const { preventDefault = false, caseSensitive = false } = options;
return (event) => {
const { key } = event;
// 기본 키 매핑
const keyMap = {
'Enter': 'onEnter',
'Escape': 'onEscape',
' ': 'onSpace',
'Tab': 'onTab',
'ArrowUp': 'onArrowUp',
'ArrowDown': 'onArrowDown',
'ArrowLeft': 'onArrowLeft',
'ArrowRight': 'onArrowRight'
};
let handler;
if (keyMap[key]) {
handler = handlers[keyMap[key]];
} else {
// 커스텀 키 처리
const normalizedKey = caseSensitive ? key : key.toLowerCase();
handler = handlers[normalizedKey];
}
if (handler) {
const shouldPreventDefault =
preventDefault === true ||
(Array.isArray(preventDefault) && preventDefault.includes(key));
if (shouldPreventDefault) {
event.preventDefault();
}
handler(event);
}
};
};
사용법
모달에서는 이렇게 사용할 수 있어요.
const ConfirmModal = ({ message, onConfirm, onCancel }) => {
return (
<div
className="modal-overlay"
tabIndex={0}
onKeyPress={keyPress({
onEnter: onConfirm,
onEscape: onCancel
})}
>
<div className="modal-content">
<p>{message}</p>
<button onClick={onConfirm}>확인 (Enter)</button>
<button onClick={onCancel}>취소 (ESC)</button>
</div>
</div>
);
};
드롭다운 메뉴에서 화살표 키로 선택하기
const DropdownMenu = ({ items, onSelect, onClose }) => {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<ul
tabIndex={0}
onKeyPress={keyPress({
onArrowUp: () => setSelectedIndex(prev =>
prev > 0 ? prev - 1 : items.length - 1
),
onArrowDown: () => setSelectedIndex(prev =>
prev < items.length - 1 ? prev + 1 : 0
),
onEnter: () => onSelect(items[selectedIndex]),
onEscape: () => onClose()
}, {
preventDefault: ['ArrowUp', 'ArrowDown']
})}
>
{items.map((item, index) => (
<li key={item.id} className={index === selectedIndex ? 'selected' : ''}>
{item.name}
</li>
))}
</ul>
);
};
키 이벤트가 어느 요소에서 발생하는지 명확하고, 처리 로직이 바로 옆에 있어서 이해하기 쉬워요. 멀리 떨어진 곳에서 복잡한 조건문으로 키 이벤트를 처리하는 것보다 훨씬 직관적입니다. 오타 방지도 중요한 장점이에요. `onEnter`, `onEscape` 같은 프로퍼티는 IDE에서 자동완성도 지원하고, TypeScript를 쓰면 오타 시 에러도 잡아줍니다. 작은 유틸리티지만 키보드 이벤트 처리가 훨씬 깔끔해져서 자주 사용하고 있어요.
useModal
네이티브 `confirm()`은 사용법이 간편하지만 스타일링이 불가능하고, 커스텀 모달을 만들면 콜백으로 결과를 처리해야 해서 복잡해집니다.
이런 코드 많이 써보셨죠?
const handleDelete = () => {
if (confirm("정말 삭제하시겠습니까?")) {
deleteItem();
console.log("삭제되었습니다!");
}
};
네이티브 API는 스타일링 불가능, 브라우저별로 다른 UI, 모바일에서 어색한 UX, 브랜딩 불가 같은 문제점들이 있습니다. 저는 네이티브 API와 동일한 사용법을 제공하면서도 완전히 커스터마이징 가능한 모달 시스템을 만드는 걸 목표로 했습니다. `await modal.confirm()`처럼 사용할 수 있게 하는 거죠.
원리
핵심은 Promise를 미리 만들어두고, 모달의 확인/취소 버튼에서 resolve를 호출하는 방식입니다.
const showConfirm = async (message) => {
// 1. Promise를 만들고 resolve, reject 함수를 저장
let resolvePromise;
const promise = new Promise((resolve) => {
resolvePromise = resolve;
});
// 2. 모달을 띄우고 resolve 함수를 전달
setModalState({
isOpen: true,
message,
onConfirm: () => resolvePromise(true), // 확인 버튼
onCancel: () => resolvePromise(false) // 취소 버튼
});
// 3. Promise를 반환 (사용자가 버튼을 누를 때까지 기다림)
return promise;
};
모달 컴포넌트에서는 전달받은 핸들러를 그대로 사용합니다.
const Modal = ({ isOpen, message, onConfirm, onCancel }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay">
<div className="modal">
<p>{message}</p>
<button onClick={onConfirm}>확인</button>
<button onClick={onCancel}>취소</button>
</div>
</div>
);
};
실제 구현입니다.
export const ModalProvider = ({ children }) => {
const [modalState, setModalState] = useState({
isOpen: false,
message: '',
onConfirm: null,
onCancel: null
});
const showConfirm = useCallback(async (message) => {
return new Promise((resolve) => {
setModalState({
isOpen: true,
message,
onConfirm: () => {
setModalState({ isOpen: false, message: '', onConfirm: null, onCancel: null });
resolve(true);
},
onCancel: () => {
setModalState({ isOpen: false, message: '', onConfirm: null, onCancel: null });
resolve(false);
}
});
});
}, []);
return (
<ModalContext.Provider value={{ confirm: showConfirm }}>
{children}
<Modal {...modalState} />
</ModalContext.Provider>
);
};
사용법
const MyComponent = () => {
const modal = useModal();
const handleDelete = async () => {
// 이 함수가 호출되면 Promise가 생성되고, 사용자가 버튼을 누를 때까지 기다림
const confirmed = await modal.confirm("정말 삭제하시겠습니까?");
if (confirmed) {
await deleteItem();
}
};
return <button onClick={handleDelete}>삭제</button>;
};
네이티브와 거의 동일한 사용법을 유지하면서도 완전히 커스터마이징 가능하다는 게 핵심이에요. Promise가 생성되자마자 모달이 뜨고, 사용자가 확인/취소 버튼을 누르면 그 결과로 Promise가 resolve되는 방식입니다. 콜백 지옥 대신 깔끔한 async/await를 쓸 수 있고, 브랜드에 맞는 디자인도 적용할 수 있어서 실무에서 정말 유용했습니다. 커스텀에서는 confirm에 리액트 노드를 넣을 수도 있고, 제목과 내용은 물론 예/아니오 버튼까지 커스텀이 가능합니다.
리액트 훅을 만들며
이처럼 모달 하나를 만드는 데도 생각보다 많은 패턴들이 숨어있었습니다. 상태 관리, 외부 클릭 감지, 키보드 이벤트 처리, Promise 기반 결과 처리... 각각은 작은 문제들이지만, 매번 반복해서 구현하다 보면 코드가 길어지고 실수도 생기죠.
이런 반복적인 패턴들을 훅이나 유틸리티 함수로 추상화해 두면, 코드가 훨씬 간결하고 의도가 명확해집니다. 무엇보다 같은 문제를 두 번 해결할 필요가 없어지는 게 가장 큰 장점입니다.
// Before: 매번 반복
const [isOpen, setIsOpen] = useState(false);
const toggle = () => setIsOpen(!isOpen);
// ... 복잡한 외부 클릭 로직
// ... 복잡한 키보드 이벤트 로직
// After: 추상화 활용
const [isOpen, toggle, open, close] = useToggle(false);
const onOutsideClick = useOutsideClick();
<div
ref={onOutsideClick(() => close())}
onKeyPress={keyPress({ onEscape: close })}
>
훨씬 읽기 쉽고, 의도도 명확하죠? 실무에서 자주 마주치는 패턴들이 정말 많습니다. 폼 처리, 데이터 페칭, 인피니트 스크롤, 디바운싱... 이런 것들도 잘 추상화해 두면 개발 경험을 크게 개선할 수 있죠. 오늘 이 글을 읽고, 댓글로 또 어떤 고민들이 있는지 알려주시면 열심히 모아볼게요!
©️요즘IT의 모든 콘텐츠는 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다.