개발/React

[컴포넌트] 모달 관리 컴포넌트 만들기 (1) - hook

dev_jiwonpark 2025. 9. 23. 23:08

현업에서 '모달' 컴포넌트에 자주 사용되는 리액트 훅 모음 이라는 아티클을 읽고 이를 적용해서 스터디를 해보았다.

프로젝트를 진행하다 보면 모달은 정말 자주 사용되는 UI 중 하나이다!

그런데 막상 직접 만들어 쓰려고 하면 상태 관리, 키맵 사용해 이벤트 처리, 재사용성 등에서 고민이 많아진다.

현재 회사에서도 자주 개발하는 모달 컴포넌트를 hook 기반으로 JavaScript와 TypeScript 두 가지를 활용해서 개발하는 과정들을 기록하려고 한다.
그리고 마지막으로는 모노레포 구조로 프로젝트를 재구성하는 것까지 기록할 예정이다.

 

내가 참고했던 아티클은 다음과 같다.

https://yozm.wishket.com/magazine/detail/3267/?data=6dQERqqqkUCmpSl8GBOPV0nURB8pZpLgc7TE15LcAY0%3D&source=daily_latest_news

 

현업에서 ‘모달’에 자주 쓰는 리액트 훅 모음 | 요즘IT

리액트를 배우고 나서 실제 프로젝트를 시작하면, 예상보다 많은 고민이 생깁니다. "이 로직을 매번 반복해서 써야 하나?", "다른 개발자들은 이런 상황을 어떻게 처리하지?", "내 코드가 너무 길

yozm.wishket.com

 

아티클에서 언급하듯이 모달 컴포넌트를 개발하면서 다들 해봤을 법한 UX 고민들이 있다.

  1. 상태 관리의 반복 패턴
    1. 모달을 열거나 닫는 상태관리를 하기 위해 우린 매번 'useState' 로 boolean 상태를 만든다. 그리고 열거나 닫는 핸들러를 정의하게 된다. 하지만 이런 패턴은 모달 뿐만 아니라 열림/닫힘 상태가 적용되는 드롭다운, 사이드바 등등 똑같이 패턴이 반복되게 된다.
  2. 외부 클릭으로 닫기
    1. 다들 웹 서비스를 이용하면서 모달을 확인하고 나서 자연스럽게 화면 밖을 선택해 모달을 닫으려고 할것이다. 하지만 이것을 항상 구현하는것이 번거롭게 느껴진다 ㅠㅠ 
    2. DOM 이벤트 리스너 등록하고 그걸 해제해야 하며 모달이 여러개면 각각 관리를 해야하고 메모리 누수도 신경써야 한다.
  3. 키보드로 제어하기
    1. 또한 유저의 모달 컴포넌트 접근성을 고려한다면 키보드로도 모달을 제어할 수 있어야 한다.
    2. ESC 키로 닫기, Enter 키로 확인버튼 선택하기 이다. 하지만 매번 키 이벤트 핸들러를 작성하면 키 이름들과 조건문들 때문에 가독성이 떨어지게 된다.
  4. 확인/취소 결과 처리
    1. 모달 안에서 다양한 작업을 처리하곤 한다. 하지만 주로 사용자에게 어떤것을 확인 받기 위해 사용된다.
    2. 모달을 자유롭게 커스터마이징 할 수 있지만 사용성은 간편하게 만들수 없을까 고민하게 된다.

위 아티클은 이런 고민들을 해결하기 위해 몇가지 훅들을 제안한다! 

이번 글에서는 활용되는 hook들에 대해서 스터디 해보려고 한다.

 

우선 첫번째로 useToggle 이다. 

boolean 상태를 다루는건 정말 자주 하는 일이다. 모달 열기 닫기, 드롭다운 표시 숨김, 사이드바 열기 닫기 등 매번 똑같은 패턴을 반복하게 된다. 이런 boolean 상태와 그 상태를 조작하는 함수들을 하나로 묶어 반환하는 방식을 사용한다면 그런 매번 같은 로직을 작성할 필요가 없어진다.

 

먼저 매번 반복하던 패턴은 아래와 같다.

const [isModalOpen, setIsModalOpen] = useToggle(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);

 

만약 위의 패턴을 페이지 / 컴포넌트마다 같이 쓴다면 네이밍이 제각각이기 때문에 일관성이 떨어지게 된다.

핸들러가 늘어날수록 파일이 지저분해지며 테스트/리팩토링 시 중복 수정 포인트가 늘어나게 된다.

 

이를 해결하기 위해 useToggle 이라는 hook으로 추상화하면 된다.

import { useState, useCallback } from 'react';

const useToggle = (initialValue = false) => {
  // 토글의 현재 값(value)과 변경 함수(setValue)를 가짐. initialValue가 없으면 기본 false.
  const [value, setValue] = useState(initialValue);

  // toggle은 의존성에 value가 들어 있어 값이 바뀔 때마다 새 핸들러가 만들어짐.
  const toggle = useCallback(() => setValue(!value), [value]);
  // useCallback() : 참조 동일성 유지 목적.
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);

  return [value, toggle, setTrue, setFalse];
};

export default useToggle;
const [isModalOpen, toggleModal, openModal, closeModal] = useToggle(false);

 

이렇게 useToggle을 사용하면 불필요하게 반복되던 코드가 간결해지고, 어디서든 동일한 패턴으로 호출할 수 있기 때문에 상태 관리의 일관성이 높아진다.

또한 모달뿐만 아니라 스위치, 드롭다운 등 다양한 Boolean 상태 관리에도 재사용할 수 있어 유지보수성이 크게 향상된다.

 

두번째로 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);
}, []);

위 코드도 아까 toggle 상태관리처럼 매번 같은 패턴을 반복해야 하고 컴포넌트가 늘수록 관리해야하는 부분이 늘어나게 된다.

또한 modalRef 하나만 체크하기 때문에 만약 여러개를 동시에 닫기 위한 바깥 영역 클릭을 구현하려면 로직이 또 늘어나게 된다.

그리고 UI 로직안에 이벤트 등록 / 해제 까지 섞여 있기 때문에 가독성이 저하된다. 

즉 관심사가 뒤섞이게 되는것!

정리하자면 동적으로 요소가 추가 또는 제거 되거나 여러개가 공존하는 상황에서 대응이 번거로워진다는 한계점이 있다.

 

이를 아래와 같이 수정한다면?

import { useEffect, useRef } from "react";

export const useOutsideClick = (callback: () => void) => {
  const handlersRef = useRef<Map<Element, () => void>>(new Map());

  useEffect(() => {
    const handleClick = (event: MouseEvent) => {
      const handlers = handlersRef.current;
      handlers.forEach((callback, element) => {
        if (element && !element.contains(event.target as Node)) {
          callback();
        }
      });
    };
    document.addEventListener('mousedown', handleClick);
    return () => document.removeEventListener('mousedown', handleClick);
  }, []);

  // 등록 함수 (callback ref 형태로 사용)
  return (element: Element | null) => {
    if (!element) return;
    handlersRef.current.set(element, callback);
    return () => {
      handlersRef.current.delete(element);
    };
  };
};
  1. 재사용 / 캡슐화
    1. 이벤트 등록/해제 , 바깥 클릭 판정 로직을 hook안에 감추고 사용하는 쪽은 "대상 요소 등록"만 하면 된다. 
  2. 다중 요소 지원
    1. Map 으로 여러 요소들을 동시에 관리할 수 있게 된다. 
  3. 한번에 전역 리스너 부착 
    1. hook 인스턴스 당 document에 한번만 리스너를 부착해 각 요소별로 리스너를 따로 달지 않아도 되서 메모리 / 성능 관리가 수월해 진다.

실제로는 아래처럼 사용될 수 있다.

function Example() {
  const [isOpen, , open, close] = useToggle(false);
  const bindOutside = useOutsideClick(close); // 바깥 클릭 시 close

  return (
    <>
      <button onClick={open}>열기</button>

      {isOpen && (
        <div ref={bindOutside}>
          내용...
        </div>
      )}
    </>
  );
}

 

 

세번째는 keyPress 이다.

keypress는 hook은 아니고 모달에서 키보드 이벤트를 처리하기 위한 유틸리티 함수이다.

모달 ESC 키로 닫기, Enter로 확인 버튼 선택하기와 같은 기능을 구현할때 유용하다.

 

아래와 같이 작성하게 된다면 사용하는 키가 많아 질수록 조건문이 길어지게 되고 가독성 또한 떨어질 것이다.

const handelKeyPress = (event) => {
  if (event.key === 'Escape') {
    closeModal();
  }else if (event.key === 'Enter') {
    confirmModal();
  }
};

 

하지만 이를 onClick 처럼 키보드의 특정 동작을 핸들러로 따로 동작할 수 있게 선언적으로 처리한다면 어떻게 처리하는지 보다 

무엇을 처리하는지에 더 집중할수 있지 않을까?

interface KeyHandlers {
    onEnter?: (event: KeyboardEvent) => void;
    onEscape?: (event: KeyboardEvent) => void;
    onSpace?: (event: KeyboardEvent) => void;
    onTab?: (event: KeyboardEvent) => void;
    onArrowUp?: (event: KeyboardEvent) => void;
    onArrowDown?: (event: KeyboardEvent) => void;
    onArrowLeft?: (event: KeyboardEvent) => void;
    onArrowRight?: (event: KeyboardEvent) => void;
    [key: string]: ((event: KeyboardEvent) => void) | undefined;
  }
  
  interface KeyPressOptions {
    preventDefault?: boolean | string[];
    caseSensitive?: boolean;
  }
  
  export const keyPress = (handlers: KeyHandlers, options: KeyPressOptions = {}) => {
      const { preventDefault = false, caseSensitive = false } = options;
  
      return (event: KeyboardEvent) => {
          const { key } = event;
  
          // 기본 키 매핑
          const defaultKeyMap: Record<string, keyof KeyHandlers> = {
              'Enter': 'onEnter',
              'Escape': 'onEscape',
              ' ': 'onSpace',
              'Tab': 'onTab',
              'ArrowUp': 'onArrowUp',
              'ArrowDown': 'onArrowDown',
              'ArrowLeft': 'onArrowLeft',
              'ArrowRight': 'onArrowRight',
          };
  
          let handlerName: keyof KeyHandlers | undefined;
  
          if (defaultKeyMap[key]) {
              handlerName = defaultKeyMap[key];
          } else {
              // 커스텀 키 처리
              const normalizedKey = caseSensitive ? key : key.toLowerCase();
              handlerName = normalizedKey as keyof KeyHandlers;
          }
  
          if (handlerName && handlers[handlerName]) {
              const shouldPreventDefault = 
                  preventDefault === true || 
                  (Array.isArray(preventDefault) && preventDefault.includes(key));
  
              if (shouldPreventDefault) {
                  event.preventDefault();
              }
  
              handlers[handlerName]!(event);
          }
      };
  };

 

위 keypress는 아래와 같이 적용될 수 있다.

브라우저에서 Enter 키를 누를때마다 alert 가 뜨게 된다.

const handleKey = keyPress({ onEnter: () => alert("Enter 눌림!") });
document.addEventListener("keydown", handleKey);

 

 

아티클에서는 useModal 까지 다뤘지만 그건 다음 게시글에서 작성해보려고 한다.

Modal context를 활용해 전역적으로 모달 컴포넌트를 호출하고 설정하는 방식을 같이 설명하기 위함이다!