기존에 공통 컴포넌트를 구현하면 사실 재사용성이나 확장성을 깊게 고려하지 않고 당장 필요한 상황에 맞게 모달을 설계하곤 했다. 하지만 이는 실제 서비스를 운영하며 쏟아지는 다양한 요구사항에 부딪히면 한계를 드러낸다.
실무에서는 어떤식으로 Modal이라는 공통 컴포넌트를 설계할까? Compound Component Pattern으로 어떻게 유연하게 컴포넌트를 설계하고, Promise 기반의 명령형 API로 모달을 통제하는지, Modal 컴포넌트를 만들면서 경험한 과정을 적어보려한다.
왜 컴파운드 컴포넌트(Compound Component)인가?
기존에 만들던 Modal 컴포넌트는 이런 형태였다.
<Modal title="결제" content="결제하시겠습니까?" hasCancelButton={true} />
해당 모달에는 title, content, hasCancelButton만 필요했다. 하지만 이러한 방식은 다음과 같은 기능 추가 요청이 오면 대응하기 어렵다.
- 타이틀 옆에 아이콘을 넣어주세요~
- 본문에 체크박스도 넣어주세요 ~
- 저희 서비스는 모달에서 [확인] 버튼이 위로 가고 본문이 아래로 배치되는 경우도 고려해야 합니다~
모든 요구사항을 props로 빼다 보면 컴포넌트는 괴물..? 이 된다. Compound Pattern은 이 문제를 HTML의 <select>와 <option> 태그처럼 해결한다.
<Modal>
<Modal.Header>결제</Modal.Header>
<Modal.Body>결제하시겠습니까?</Modal.Body>
<Modal.Footer>...</Modal.Footer>
</Modal>
부모(Modal)와 자식(Header, Body, Footer)이 독립적으로 존재하면서도, 조립하면 하나의 완성된 UI가 되는 구조이다. 즉, 레이아웃의 유연성이 늘어가는것. 이는 Object.assign을 통해 구성할 수 있다.
Namespace Pattern
자바스크립트에서 함수는 일급 객체(First-class Object)이므로 속성(Property)을 할당할 수 있다. 이를 리액트 생태계에서는 네임스페이스 패턴(Namespace Pattern)이라고 부르며, Modal.Header처럼 직관적인 네이밍으로 컴포넌트의 소속을 명확히 할 수 있다.
따라서 ModalBase라는 본체 함수에 Header, Body 라는 프로퍼티를 끼워넣은 것 이다. 결과적으로 파일 하나만 import { Modal } 하여 사용할 수 있다.
부모와 자식은 어떻게 데이터를 공유할까? (Context API)
그렇다면 조각난 부모와 자식은 어떻게 데이터를 공유할까?
독립된 컴포넌트를 하나의 완성된 UI로 조립하면서 데이터는 Context API로 공유할 수 있다.
const ModalContext = createContext<ModalContextValue | null>(null);
function ModalBase(...) {
const titleId = useId(); // 고유한 ID 생성
// ...
return (
<ModalContext.Provider value={{ titleId }}>
<div aria-labelledby={titleId}> {/* 모달 전체가 이 ID를 바라봄 */}
{children}
</div>
</ModalContext.Provider>
)
}
function ModalHeader({ children }) {
const { titleId } = useModalContext(); // 부모가 만든 ID를 가져옴
return <div id={titleId}>{children}</div>;
}
부모(ModalBase)가 useId를 통해 고유한 ID를 생성한다. (웹 접근성을 위해 필수적)
이를 자식인 Header에게 props로 넘겨주지 않고, Context Provider를 씌워 공유한다. Header는 Context 안에서 titleId를 꺼내서 자신의 DOM id로 사용한다.
결과적으로 모달 전체가 "내 제목은 Header가 가진 저 ID야"라고 시맨틱하게 연결된다.
또한 Context API의 또 다른 장점은 방어적 프로그래밍이 가능하다는 점이다. 만약 동료 개발자가 <Modal> 바깥에서 <Modal.Header>를 단독으로 렌더링하려고 시도하면, useModalContext 내부에서 context가 null임을 감지하고 즉시 에러(throw new Error(...))를 던진다.
이를 통해 컴포넌트의 오용을 원천 차단할 수 있다.
웹 접근성(A11y)의 꽃, Focus Trap
Compound Pattern으로 유연한 모달을 만들었다면, 이제는 "모두를 위한 모달"을 만들 차례이다. 보통 마우스를 사용하는 사용자에게는 문제가 없지만, 키보드(Tab 키)로만 화면을 탐색하는 시각장애인이나 특정 기기 사용자에게 모달은 자칫 미로가 될 수 있다.
모달이 열렸는데 무심코 Tab 키를 계속 눌렀다가 포커스가 모달 뒤의 배경(원래 페이지)으로 넘어가 버리면, 사용자는 자신이 지금 어디에 있는지 완전히 길을 잃게 된다. 이를 방지하기 위해 모달 내부에 포커스를 가두는 Focus Trap 기술을 적용했다.
useFocusTrap 훅을 만들어서 Modal 내부의 키보드 이벤트를 직접 제어한다.
- 포커스 가능한 요소 찾기: 모달 안에서 포커스를 받을 수 있는 button, link, input 등을 querySelector로 긁어모은다.
- 이벤트 가로채기(e.preventDefault()):
- 포커스가 lastElement에 있을 때 사용자가 Tab을 누르면 강제로 firstElement로 보낸다.
- 포커스가 firstElement에 있을 때 사용자가 Shift + Tab을 누르면 강제로 lastElement로 보낸다.
그리고 중요한 웹 접근성 규칙이 하나 더 있다. ESC 키를 누르면 모달이 닫혀야 한다. 우리의 ModalBase 내부에 document.addEventListener('keydown', e => e.key === 'Escape')를 등록하여, 사용자가 언제든 쉽게 모달을 탈출할 수 있도록 접근성을 높였다.
DOM의 한계를 벗어나.. (feat. Portal)
모달을 만들 때 하나 고려해야 할 부분이 있다. 바로 CSS의 z-index와 overflow: hidden의 간섭이다. 모달 컴포넌트가 DOM 트리 깊숙한 곳에 렌더링되면, 부모 요소의 CSS 속성 때문에 모달이 화면에 잘리거나 다른 요소 밑에 깔리는 상황이 발생할 수 있다.
이 문제를 해결하기 위해 React의 createPortal 을 도입했다.
// 컴포넌트 위치는 상관없이, 실제 렌더링은 #modal-root 안에서 일어난다.
return createPortal(
<ModalContext.Provider value={{ titleId }}>
<div className='fixed inset-0 z-50 ...'>
{children}
</div>
</ModalContext.Provider>,
getPortalContainer() // document.getElementById('modal-root')
);
createPortal은 논리적인 리액트 컴포넌트 트리 구조는 그대로 유지하면서, 실제 브라우저 DOM 상에서는 요소들을 <body> 태그 바로 아래(혹은 특정 root)로 이동 시켜준다.
여기서 getPortalContainer() 함수에는 소소하지만 중요한 방어 로직이 있다. 단순히 modal-root를 찾는 것에 그치지 않고, 만약 해당 요소가 HTML에 없다면 동적으로 div를 생성하여 body 에 부착해준다. 덕분에 개발자가 index.html 을 건드리는 것을 깜빡하더라도 모달은 항상 안전하고 독립적인 공간에 렌더링된다.
추가적으로 이 작업을 하면서 기존에 Context API로 상태를 공유하던 구조를 Zustand로 변경하였다.
모달은 애플리케이션의 어느 위치에서든 열릴 수 있어야 한다. 예를 들어, 헤더, 리스트 아이템, 페이지 내부 등 다양한 위치에서 동일한 모달을 트리거해야 하는 경우가 많다. 이러한 요구사항은 자연스럽게 전역 상태 관리의 필요로 이어진다.
Context API 역시 전역적으로 상태를 공유할 수 있지만, 상태 변경이 발생할 때 해당 Context를 구독하는 모든 컴포넌트가 리렌더링되는 특성이 있다. 따라서 모달과 같이 빈번하게 열리고 닫히는 UI 상태를 관리할 경우, 불필요한 렌더링이 발생할 수 있다.
반면 Zustand는 필요한 컴포넌트만 선택적으로 구독할 수 있는 구조를 제공하여, 보다 효율적인 상태 관리를 가능하게 한다. 또한 별도의 Provider 없이도 전역에서 상태를 접근할 수 있기 때문에, 모달을 명령형으로 제어하기에도 적합하다.
특히 overlay 기반의 모달을 구현할 때는 특정 컴포넌트 트리와 무관하게 모달을 열어야 하는 경우가 많기 때문에, Zustand와 같은 전역 상태 관리 도구가 더 유연한 해결책이 된다. (아래에서 overlay를 다룬다)
Hydration Mismatch 해결을 위한 useSyncExternalStore
하지만 Portal을 도입하자 새로운 문제가 나타났다. 바로 Next.js 같은 SSR 환경에서 발생하는 Hydration Mismatch 에러다. createPortal은 브라우저의 document 객체에 접근해야 하는데, 서버(node.js) 환경에는 document가 존재하지 않는다.
만약 페이지 진입과 동시에 모달이 열려야 하는 상황이라면, 서버에서 렌더링한 HTML(모달 없음)과 클라이언트가 첫 렌더링한 결과물(모달 띄우려 시도)이 달라져 리액트가 에러를 던진다.
“그런 경우가 빈번하려나? 보통 모달은 버튼 눌러서 열지않나?” 라고 생각이 들어, 언제 우리 서비스에서 Hydration Mismatch이 발생할 수 있을지 고민해봤다.
- 전체 공지: 사용자가 메인 페이지에 진입하자마자 전체 공지 Modal을 띄우는 경우가 있을 것 같다.
- 상세 페이지: ‘직행’과 같은 사이트는 상세페이지 진입 시 같이 운영중인 오픈채팅방 링크를 보여준다. 이렇게 상세 페이지에서 필요한 정보를 알려줄 때가 있을 것 같다.
이를 위해 useEffect + useState를 조합해 isMounted 상태를 관리한다. 하지만 이 방식은 렌더링을 두 번 발생시키고, ESLint 경고를 피하기 위해 주석을 달아야 하는 찜찜함이 있다. 더 좋은 방법이 없을까? 바로 React 18의 useSyncExternalStore를 활용할 수 있다.
// 서버와 클라이언트의 렌더링 스냅샷을 분리하여 완벽한 동기화를 이룸
const isMounted = useSyncExternalStore(
() => () => { }, // subscribe: 구독할 외부 상태가 없으므로 빈 함수
() => true, // getSnapshot: 클라이언트 환경에서는 true 반환
() => false // getServerSnapshot: 서버 환경에서는 false 반환
);
if (!isMounted || !isOpen) return null;
이 훅을 사용하면 리액트에게 “서버에서는 false로 알고 있고, 브라우저에 마운트되면 true 로 처리해줘” 라고 선언적으로 알려줄 수 있다. 결과적으로 불필요한 useEffect 와 린트 무시 주석 없이 하이드레이션 에러를 방어할 수 있었다.
한계: 상태의 연쇄 작동과 명령형 모달의 필요성
자, Compound와 Portal로 유연한 Modal을 만들었다. 이대로 마치면 best일까? 만약 스터디 신청하기 3단계 퍼널을 상태 선언형으로 만든다면 어떨까?
- isDetailOpen, isFormOpen, isSuccessOpen 세 개의 useState 필요
- ‘신청하기’ 클릭 시 setDetailOpen(false), setFormOpen(true) 호출
- ‘제출’ 클릭 시 setFormOpen(false), setSuccessOpen(true) 호출
모달 하나 열고 닫을 때마다 상태가 꼬이고, 가독성은 나빠진다. 만약 중간에 ‘뒤로가기’라도 구현하려면 코드가 너무 복잡해진다. 이를 어떻게 해결할 수 있을까?
컴포넌트 내부에 숨겨져 있던 모달의 상태를 바깥으로 꺼내 함수 호출로 통제하는 명령형 모달 아키텍처를 도입하기로 했다.
전체 아키텍처 흐름 톺아보기
이 명령형 모달 시스템은 다음과 같은 흐름으로 동작한다.
[트리 깊숙한 컴포넌트] -> useOverlay().open() 호출 (모달 띄워줘!)
|
v
[useOverlayStore] -> Zustand 상태(Map)에 새로운 모달 데이터 추가
|
v
[최상위 layout.tsx] -> <OverlayProvider /> 가 상태 변경을 감지하고 모달 렌더링
- useOverlayStore: 현재 화면에 떠 있어야 할 모달들의 목록을 관리하는 전역 상태 저장소
- OverlayProvider: 전역 상태 저장소(useOverlayStore)를 바라보고 있다가, 모달이 추가되면 화면에 그려주는 렌더러
- layout.tsx: Provider와 Modal이 실제 그려질 공간(modal-root)을 애플리케이션 최상단에 배치
- useOverlay: 컴포넌트에서 모달을 열고 닫을 때 사용하는 Promise 기반 훅
기존처럼 모달 컴포넌트를 부모 트리에 일일히 마운트해둘 필요 없이, 로직과 UI를 완전히 분리한 구조이다.
화면에 그리기: 상태 관리(Zustand Store)와 Provider
먼저, 전역 상태를 관리할 때 Context API를 쓰지 않고 왜 Zustand를 썼을까?
가장 큰 이유는 렌더링 최적화와 React 외부에서의 접근성 때문이다. Context API는 Provider의 상태가 변하면 하위 컴포넌트 전체가 리렌더링의 영향을 받기 쉽다. 하지만 Zustand는 Modal이 추가될 때, 오직 스토어를 구독 중인 OverlayProvider 하나만 리렌더링되므로 성능적으로 효율적이다. 또한, 훅 외부에서도 쉽게 스토어에 접근할 수 있어 유연성이 높다.
먼저 데이터를 보관하는 useOverlayStore.ts 를 살펴보자.
export const useOverlayStore = create<OverlayState>((set) => ({
overlays: new Map(), // 배열이 아니라 Map을 사용!
mount: (id, element) => /* Map에 추가 */,
unmount: (id) => /* Map에서 삭제 */,
}));
왜 배열이 아닌 Map을 썼을까?
배열로 관리하면 특정 모달을 지우거나 수정할 때 배열을 순회해야 한다. 하지만 Map을 사용하면 고유한 id를 키로 삼아 O(1)의 속도로 즉시 찾아서 추가하거나 삭제할 수 있기 때문에 성능과 관리 측면에서 유리하기 때문이다.
라우팅을 견뎌.
생성된 모달들을 화면에 그려주는 건 애플리케이션 최상단에 위치한 OverlayProvider의 몫이다.
Next.js의 라우트 특성상 루트 Layout 레이어에 배치된 Provider는 페이지 이동이 발생해도 언마운트되지 않는다. 즉, ‘결제 진행’ 모달이 떠 있는데 주소창이 바뀌어 페이지가 이동하더라도 모달은 사라지지 않고 화면에 안전하게 띄워져 있게 된다. 사용자에게 끊김 없는 오버레이 경험을 제공하기에 좋다.
export function OverlayProvider() {
const overlays = useOverlayStore((state) => state.overlays);
return (
<>
{Array.from(overlays.entries()).map(([id, element]) => (
<Fragment key={id}>{element}</Fragment>
))}
</>
);
}
단순하다. Zustand Store를 구독하고 있다가, overlays Map에 새로운 모달이 들어오면 Fragment로 감싸서 화면에 뿌려준다.
본격 해부: useOverlay 훅
이제 실제 Modal을 제어하는 훅을 뜯어보자.
이 훅은 독특하게도 Modal 컴포넌트 그 자체가 아닌, 렌더링을 담당하는 콜백 주기(render)를 인자로 받는다.
제어의 역전
open: (render: (props: { isOpen: boolean; close: (value: T) => void }) => ReactNode)
이렇게 설계하면 useOverlay가 내부적으로 Modal의 isOpen 상태와 close 함수를 완전히 통제하면서, 그것들을 다시 Modal 컴포넌트의 props로 알아서 주입해 줄 수 있다. Modal을 언제 닫을지에 대한 주도권을 훅이 가져가는 ‘제어의 역전’ 패턴이다.
Promise와 300ms의 비밀
그렇다면 Modal이 닫히는 애니메이션을 어떻게 기다려줄까?
const close = (value: T) => {
// 1. 상태를 닫힘으로 변경 (애니메이션 유도)
mount(currentId, render({ isOpen: false, close }));
// 2. 일정 시간(닫힘 애니메이션 시간) 후 완전히 unmount
setTimeout(() => {
unmount(currentId);
activeIds.current.delete(currentId);
resolve(value); // 여기서 Promise가 풀림!
}, 300);
};
close를 호출하자마자 unmount로 DOM에서 Modal을 날려버리면 Modal이 바로 사라져서 Fade-out 같은 닫힘 애니메이션을 보여줄 시간이 없다.
그래서 close가 호출되면 isOpen: false 상태로 다시 마운트한다. (이때 Modal 내부에서 CSS 애니메이션이 작동)
그 후, setTimeout으로 애니메이션이 끝날 때까지 300ms를 기다려준다.
마지막으로, 300ms 뒤에 DOM에서 날려버리고(unmount), resolve(value)를 실행해 await하고 있던 다음 로직으로 넘어간다.
메모리 구조대(Cleanup)
만약 사용자가 Modal을 띄워 놓고 ‘뒤로 가기’를 눌러 컴포넌트가 파괴되면 어떻게 될까? 정상적인 종료 로직 close()를 타지 않았기 때문에, 화면에 Modal이 안 보이는데 전역 Store에는 Modal이 남아있는 좀비 모달(memory Leak)이 발생한다.
useEffect(() => {
const currentActiveIds = activeIds.current; // 값 박제 (클로저)
return () => {
// 페이지를 벗어나 컴포넌트가 파괴되면, 열어둔 모달도 강제로 다 닫아줌
currentActiveIds.forEach((id) => unmount(id));
currentActiveIds.clear();
};
}, [unmount]);
useEffect(() => {
const currentActiveIds = activeIds.current; // 모달 값 박제 (클로저)
const currentTimerIds = timerIds.current; // 타이머 값 박제
return () => {
// 페이지를 벗어나 컴포넌트가 파괴되면, 돌고 있던 타이머와 열려있던 모달 강제 종료
currentTimerIds.forEach(clearTimeout);
currentTimerIds.clear();
currentActiveIds.forEach((id) => unmount(id));
currentActiveIds.clear();
};
}, [unmount]);
이를 방지하기 위해 클로저를 활용하여 useEffect의 Cleanup 함수에서 당시 activeIds, timerIds를 안전하게 복사해두고, 컴포넌트가 죽을 때 자신이 열어둔 모든 Modal과 타이머를 깔끔하게 지우고 함께 죽도록 방어로직을 짜뒀다.
컴포넌트의 생명주기와 깜빡임(Flickering) 현상 극복하기
초기에는 useOverlay를 체이닝(await → await)하여 여러 단계의 신청 플로우(스터디 정보 확인 → 신청 폼 작성 → 완료)를 구현했다.
하지만 이 방식은 ux 결함이 있었다.
- 현상: Modal이 다음 단계로 넘어갈 때마다 검은색 배경이 사라졌다가 다시 나타나는 깜빡임 현상이 발생했다.
- 원인: useOverlay의 await는 ‘현재 Modal이 완전히 언마운트됨’을 기다린 후 다음 코드를 실행한다. 따라서 A Modal 파괴 → B Modal 생성 가정에서 공백이 생길 수 밖에 없는 구조였다.
- 해결은 간단하다. 모달의 틀은 독립적으로 띄워둔 채 그 안의 내용물만 퍼널 패턴으로 교체하는 것이다.
- 단일 Modal: 기존처럼 useOverlay를 사용해 독립적으로 띄움
- 연속 플로우 Funnel: useOverlay로는 ‘단 하나의 Modal’만 띄우고, 그 내부에서 상태(step)을 바꿔 내용물만 교체하는 useFunnel 패턴을 도입
useFunnel로 선언적인 스텝 통제
복잡하게 얽힌 상태 분기문(if (step === ‘A’) return <A />) 대신, 선언적으로 코드를 읽을 수 있도록 useFunnel을 구현했다.
// useFunnel 내부의 렌더링 로직
const Funnel = useMemo(() => {
return function FunnelComponent({ children }: FunnelProps<S>) {
// React.Children.toArray를 이용해 자식들을 배열로 변환하고
const childArray = React.Children.toArray(children).filter(isValidElement);
// 현재 step 상태와 이름(name)이 일치하는 Step 컴포넌트만 렌더링한다.
const targetStep = childArray.find((childStep) => childStep.props.name === step);
return targetStep || null;
};
}, [step]);
이를 통해 부모 컴포넌트에서는 <Step name=”INFO”>와 같이 선언적으로 플로우를 작성할 수 있게 되었고, 복잡한 렌더링 분기 처리는 훅 내부로 캡슐화되었다.
너가 없으면 조금 허전해.. 애니메이션 생명주기 제어
useOverlay라는 명령형 도구를 쓰지 않고, 일반적인 useState(isOpen)으로 Modal을 사용할 때도 ‘닫힘 애니메이션’이 부드럽게 적용되어야 한다.
이를 위해 Modal 컴포넌트 내부에 shouldRender 라는 지연 상태를 별도로 두었다.
- 변경 사항: 상태 변화를 추적하는 transition 대신, 렌더링 직후 즉시 실행되는 CSS @keyframes animation으로 전면 교체
- 이중 호환성: Modal 컴포넌트 내부에 shouldRender 상태를 추가. useOverlay를 통하지 않고 일반적인 useState로 모달을 닫더라도, setTimeout을 통해 300ms동안 DOM을 유지시키며 닫힘 애니메이션이 재생될 시간을 벌어준다.
마치며
처음엔 단순히 ‘재사용하기 편한 모달 하나 만들자’로 시작했는데, Compound Pattern으로 유연함을 챙기고, Provider와 Zustand를 도입해 제어권을 역전시키고, 최종적으로 Promise와 Funnel로 UX 흐름까지 책임지는 시스템을 만들게 되었다.
'개발 > 프로젝트' 카테고리의 다른 글
| 완성도(完成島): 회의 기능 구현 - 왜 LiveKit(SFU)을 선택했나 (0) | 2026.04.30 |
|---|---|
| AutoMeeting: 매일 20분 걸리던 회의 기록을 0으로 만든 AI 파이프라인 (0) | 2026.04.30 |
| AutoMeeting: 매일 쓰는 팀회의 STT, 왜 Cloud API 대신 로컬 Whisper를 골랐을까? (0) | 2026.04.30 |
| 완성도(完成島): 페어프로그래밍으로 만든 GatheringCard (0) | 2026.03.30 |