다이얼로그는 대부분의 웹사이트에서 사용되는 대표적인 UX 요소 중 하나입니다. 그만큼 웹을 구성하는 데 있어 중요도가 높고, 다양한 상황에서 활용되기 때문에 여러 가지 관리 방식이 적용됩니다. 동시에, 구현과 관리가 까다로운 컴포넌트이기도 합니다.
대규모 프로젝트의 요구사항에 적절히 대응 가능하면서, 유지보수가 용이한 다이얼로그를 만들기 위해 여러 시도를 거쳤습니다. 그 과정에서 다양한 시행착오를 겪었고, 세 차례의 마이그레이션을 통해 최종적인 형태를 완성할 수 있었습니다. 이번 글에서는 그 과정에서 겪었던 문제점과 개선 과정을 공유하고자 합니다.
첫 번째 다이얼로그 — Recoil 전역 상태
첫 번째 방법은 Recoil 전역 상태를 사용해 다이얼로그를 관리하는 방식이었습니다.
전역 layout.tsx 내부에서 createPortal로 모달을 렌더링하고, 해당 모달의 열림 상태를 Recoil 전역 상태값으로 제어했습니다.
const useModal = (modalId: ModalState) => {
const [modal, setModal] = useRecoilState(modalState)
const isOpen = modal[modalId]
const onOpen = () => {
setModal((current) => ({ ...current, [modalId]: true }))
document.body.style.overflow = 'hidden'
}
const onClose = useCallback(() => {
setModal((current) => ({ ...current, [modalId]: false }))
document.body.style.overflow = ''
}, [modalId, setModal])
return { isOpen, onOpen, onClose }
}export const modalState = atom<{
[key in ModalState]: boolean
}>({
key: 'modalState',
default: {
SIDEBAR: false,
MAIN_POPUP_BANNER: false,
//... 여러 키 값들
},
})사용 예시는 다음과 같습니다.
// 노출 시키고 싶은 다이얼로그 key값을 인자로 넘겨줍니다.
const { onClose, onOpen } = useModal('SIDEBAR')문제점
이 방식에는 두 가지 문제점이 존재했습니다.
1. 모달을 쓰는 페이지마다 Recoil key를 추가해야 함
새로운 화면에서 다이얼로그를 띄우기 위해서는 ModalState 타입과 modalState의 default 객체에 항목을 하나씩 추가해야 했습니다.
사용처가 늘어날수록 atom의 키 값도 계속 증가했고, 각 키가 어떤 역할을 하는지 한눈에 파악하기 어려워지면서 유지보수성이 저하되는 문제가 발생했습니다.
export const modalState = atom<{
[key in ModalState]: boolean
}>({
key: 'modalState',
default: {
SIDEBAR: false,
MAIN_POPUP_BANNER: false,
// 키 값을 추가해야합니다.
// PASSWORD : false
},
})export type ModalState =
| 'SIDEBAR'
| 'RECOM_GENRE'
//타입도 추가해야합니다.
// | 'PASSWORD'
2. 페이지 이동 시 다이얼로그가 닫히지 않음
다이얼로그를 Recoil 전역 상태로 관리하고 있었기 때문에, 다이얼로그가 열린 상태에서 뒤로 가기와 같은 페이지 이동이 발생해도 Recoil 상태값이 갱신되지 않는 문제가 있었습니다.
그 결과, 페이지가 변경되었음에도 다이얼로그가 닫히지 않고 그대로 남아있는 버그가 발생했습니다.
이런 이유로 전역 상태 기반 첫 번째 다이얼로그를 폐기 처분하고, 두 번째 다이얼로그를 만들게 됐습니다.
두 번째 다이얼로그 — 로컬 state 기반 컴포넌트 반환 훅 패턴
두 번째 다이얼로그에서는 Recoil 전역 상태 대신, 로컬 state를 기반으로 다이얼로그 상태를 관리하는 방식을 채택했습니다.
각 사용처에서 훅을 통해 다이얼로그 템플릿 컴포넌트를 제공받고 그 안에 필요한 콘텐츠를 채워 넣는 방식으로 변경했습니다.
export default function useDialog() {
const [isOpen, setOpen] = useState(false)
const handleClose = useCallback(() => {
setOpen(false)
}, [])
const handleOpen = useCallback(() => {
setOpen(true)
}, [])
const DialogComponent = useMemo(
() =>
({ title, children, onDimClick, buttons, className }: DialogProps) => (
<Dialog
isOpen={isOpen}
title={title}
onDimClick={() => {
onDimClick?.()
handleClose()
}}
buttons={buttons}
className={className}
>
{children}
</Dialog>
),
[isOpen, handleClose],
)
//다이얼로그 상태 관리, ui를 모두 넘겨줍니다.
return {
isOpen,
onClose: handleClose,
onOpen: handleOpen,
Dialog: DialogComponent,
}
}사용 예시는 다음과 같습니다.
useDialog() 훅으로부터 반환된 Dialog로 내부 컨텐츠를 감싸고, 버튼 정보와 콜백함수을 props로 넘기는 형태입니다.
const { onOpen: handleOpen, onClose: handleClose, Dialog: AlertDialog } = useDialog()
<AlertDialog
onDimClick={handleClose}
buttons={[{ text: '확인', color: 'blue-01', onClick: handleClose }]}
>
알림 다이얼로그 창입니다.
</AlertDialog>문제점
그러나 해당 방식에서도 치명적인 문제점을 발견했는데요.
1. 내부 상태값과 버튼 콜백의 스코프가 공유되지 않음
버튼 콜백과 다이얼로그 내부 컨텐츠의 상태 스코프가 공유되지 않는다는 점이었습니다.
Dialog는 useDialog가 반환하는 컴포넌트이고, 버튼의 콜백함수는 Dialog props로 넘깁니다.
다이얼로그의 UX 특성상, 내부 콘텐츠가 버튼의 콜백 함수에 접근해야 하는 경우가 많습니다. 하지만 Dialog가 내부 콘텐츠를 감싸는 구조였기 때문에, 버튼 콜백 함수에서 내부 콘텐츠의 스코프에 접근할 수 없는 문제가 있었습니다.
const { onOpen, onClose, Dialog } = useDialog()
<Dialog title="다이얼로그 제목" buttons={[{ text: '확인', onClose }]}>
//DetailReviewInfo 내부 상태값을 버튼 콜백에 넘겨야하는데...
//접근이 불가능...
<DetailReviewInfo />
</Dialog>때문에 실제로 프로젝트를 진행하면서, 아래와 같이 useDialog 훅 제작 의도와 다르게 사용되는 사례도 발생했습니다.
const { onOpen: onReviewWriteOpen, onClose: onReviewWriteClose, Dialog: ReviewWriteDialog } = useDialog()
// 다이얼로그 템플릿을 props로 넘겨 사용....
<DetailReviewWriteBox Dialog={ReviewWriteDialog} onClose={onReviewWriteClose} />export default function DetailReviewWriteBox({ Dialog, onClose }: DetailReviewWriteBoxProps) {
// 다이얼로그 내부에서 사용할 상태값들
const [comment, setComment] = useState('')
const [rating, setRating] = useState(5)
return (
<>
<Dialog
className={cn('dialog-content')}
buttons={[
{
text: '취소',
onClick: () => {
onClose()
},
},
{
text: '등록',
color: 'blue-01',
onClick: () => {
onClose()
},
},
]}
>
<Flex direction="column" gap={10}>
<Stars
onClick={(point) => {
setRating(point)
}}
point={rating}
size={32}
/>
<Typography.Body2 ta="center" c="mono-03">
{GRADE_TEXT[rating - 1]}
</Typography.Body2>
</Flex>
</Dialog>Dialog 템플릿과 내부 콘텐츠의 상태값 스코프를 공유하기 위해, Dialog를 내부 콘텐츠 컴포넌트의 props로 전달해야 하는 상황이 발생했습니다.
즉, 훅을 호출하는 영역과 실제로 관리되는 영역이 분리되는 문제가 생겼고, 결국 두 번째 다이얼로그 구조도 폐기하게 되었습니다…
세 번째 다이얼로그 — useOverlay + shadcn 조합 패턴
더 이상의 마이그레이션은 없다. 여기서 끝낸다 라는 다짐과 함께 세 번째 다이얼로그를 제작하게 되었습니다.
두 번의 시행착오를 겪으면서, 다이얼로그를 직접 구현하기보다는 이미 잘 만들어진 라이브러리를 사내 서비스에 맞게 커스터마이징하는 편이 더 완성도 높은 컴포넌트를 만드는 방법이라고 판단했습니다.
여러 라이브러리를 검토한 끝에, 토스(Toss) 라이브러리의 useOverlay 훅과 shadcn에서 제공하는 다이얼로그 컴포넌트를 활용해 다이얼로그를 재설계했습니다.
구현 과정
1. shadcn 다이얼로그
shadcn은 다이얼로그 UI 컴포넌트를 제공합니다. 기존에는 다이얼로그를 직접 구현해 두었는데, 이번에는 shadcn 다이얼로그를 가져와 커스터마이징해 UI를 구성하기로 했습니다. 라이브러리의 상태 관리 로직을 활용하면 직접 구현·유지보수할 부담을 줄일 수 있기 때문입니다.
공식 문서의 사용 예시는 아래와 같습니다.
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Field, FieldGroup } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export function DialogDemo() {
return (
<Dialog>
<form>
// 다이얼로그 트리거 컴포넌트
<DialogTrigger asChild>
<Button variant="outline">Open Dialog</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're
done.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<Button type="submit">Save changes</Button>
</DialogFooter>
</DialogContent>
</form>
</Dialog>
)
}
컴파운드 컴포넌트 패턴으로 다이얼로그 구조를 잡고, DialogTrigger로 열기까지 라이브러리가 처리해 주어 그대로 가져다 쓰기 편합니다. 다만 위 예시는 비제어(Uncontrolled) 방식입니다. DialogTrigger에 의해 내부에서 열림/닫힘이 관리되므로, 부모에서 open 상태를 제어하지 않습니다.
저희 서비스에서는 다이얼로그를 제어 컴포넌트(Controlled) 형태로 쓰는 편이 더 맞다고 판단했습니다. 토스에서 제공하는 useOverlay 훅을 사용할 예정이었기 때문입니다. 이에 shadcn 다이얼로그가 Radix UI 기반이라는 점을 확인했고, Radix 공식 문서에서 제어 방식 사용 예시를 찾았습니다.
import * as React from "react";
import { Dialog } from "radix-ui";
const wait = () => new Promise((resolve) => setTimeout(resolve, 1000));
export default () => {
const [open, setOpen] = React.useState(false);
return (
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>Open</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<form
onSubmit={(event) => {
wait().then(() => setOpen(false));
event.preventDefault();
}}
>
{/** some inputs */}
<button type="submit">Submit</button>
</form>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
};
open과 onOpenChange를 넘기면 state 기반의 제어 컴포넌트 형식으로 사용할 수 있습니다. 이 방식을 참고해, shadcn 다이얼로그를 한 겹 감싼 래퍼 컴포넌트를 만들고 open / onClose로 노출을 제어하도록 설계했습니다. 구현한 래퍼는 아래와 같습니다.
function Dialog({ open, onClose, className, children }: PropsWithChildren<DialogProps>) {
const handleOpenChange = (open: boolean) => {
if (!open) {
onClose()
}
}
return (
// 외부에서 넘겨받은 상태값과 핸들러를 주입
<DialogComponent open={open} onOpenChange={handleOpenChange}>
<DialogContent
//..
onClick={onClose}
>
{children}
</DialogContent>
</DialogComponent>
)
}
// .. 그 외 컴포넌트 조합에 사용될 다이얼로그 ui 블록들
export { Dialog, DialogTitle, DialogButtonFooter, DialogButton, DialogDescription }
이 래퍼와 DialogTitle, DialogDescription 등 블록들을 조합해 실제로 노출할 다이얼로그 컴포넌트를 만듭니다. 예를 들어 확인 다이얼로그는 아래처럼 사용할 수 있습니다.
export default function ConfirmDialog({ open, close, message }: ConfirmDialogProps) {
return (
<Dialog open={open} onClose={close}>
<DialogTitle />
<DialogDescription>
다이얼로그 컨텐츠
</DialogDescription>
<DialogButtonFooter>
<DialogButton text="확인" onClick={close} />
</DialogButtonFooter>
</Dialog>
)
}shadcn 다이얼로그를 쓰면서 얻은 이점이 하나 더 있었습니다. 웹 접근성입니다.
사내에서는 다이얼로그에 제목(title)이 있는 경우와 없는 경우가 모두 있었습니다. 그래서 제목이 있을 때만 shadcn의 DialogTitle을 사용했는데, 개발 중 콘솔에 에러가 찍히는 것을 발견했습니다.

에러 내용을 보니 Radix 다이얼로그를 쓸 때는 반드시 DialogTitle을 포함해야 한다는 요구사항이었습니다. 스크린 리더 사용자를 위해 다이얼로그에 제목이 있어야 한다는 Radix 라이브러리의 접근성 설계 때문입니다.
다만 앞서 말한 대로 제목을 노출하지 않는 다이얼로그도 있었습니다. 이 경우 VisuallyHidden을 사용해 해결했습니다. DialogTitle을 VisuallyHidden으로 감싸면 화면에는 보이지 않으면서 스크린 리더에만 읽히도록 할 수 있습니다.
// title이 없으면 시각적으로 숨기고 스크린 리더에만 노출
function DialogTitle({ title, className }: DialogTitleProps) {
if (!title) {
return (
<VisuallyHidden>
<DialogTitleComponent >
Dialog
</DialogTitleComponent>
</VisuallyHidden>
)
}
return (
<DialogHeader>
<DialogTitleComponent >
{title}
</DialogTitleComponent>
</DialogHeader>
)
}
export default function ConfirmDialog({ open, close, message }: ConfirmDialogProps) {
return (
<Dialog open={open} onClose={close}>
// 제목은 없지만 접근성을 위해 DialogTitle은 포함
<DialogTitle />
<DialogDescription>
다이얼로그 컨텐츠
</DialogDescription>
<DialogButtonFooter>
<DialogButton text="확인" onClick={close} />
</DialogButtonFooter>
</Dialog>
)
}이렇게 제목 유무에 따라 DialogTitle을 보이거나 VisuallyHidden으로 감싸는 방식으로, 웹 접근성까지 고려한 다이얼로그 컴포넌트를 구성할 수 있었습니다.
2. useOverlay 훅 사용
다이얼로그 ui를 만들었으니 노출시킬 유틸함수를 만들어야겠지여.
토스 useOverlay 훅을 채택한 이유는 다음과 같습니다.
a. 간단한 사용방법
useOverlay 훅 사용하기 위해선 프로젝트 전역 루트 layout.tsx OverlayProvider로 감싸주기만 하면 됩니다.
//layout.tsx
import { OverlayProvider } from '@toss/use-overlay';
export default function App({ Component, pageProps }: AppProps) {
return (
<OverlayProvider>
<Component {...pageProps} />
</OverlayProvider>
);
}
b. Promise와 함께 사용 가능
useOverlay 훅은 다이얼로그를 Promise와 함께 쓸 수 있게 해 줍니다. 예를 들어 overlay.open을 호출하는 부분을 Promise로 감싸면, 사용자 인터랙션(확인/취소 등) 결과를 비동기적으로 받을 수 있습니다. 그래서 확인 버튼 클릭 같은 콜백이 실행된 뒤에 다이얼로그가 닫히는 순서를 보장할 수 있습니다.
아래처럼 Promise 인스턴스를 만들고, 그 안에서 overlay.open을 호출하는 방식으로 사용할 수 있습니다.
function Home() {
const navigate = useNavigate();
const overlay = useOverlay();
const openAlert = () => {
return new Promise<boolean>((resolve) => {
overlay.open(({ isOpen, close, exit }) => (
<Alert
title='개인정보 수집에 동의하십니까?'
open={isOpen}
onButtonClick={() => {
resolve(true);
close();
}}
buttonLabel='동의'
/>
));
});
};
const onClickCreateCardButton = async () => {
const isAgreed = await openAlert();
if (isAgreed) {
navigate('/create-card');
} else {
alert('동의하지 않으면 카드를 만들 수 없습니다.');
}
};
return (
<Flex>
<Text>Home</Text>
<Button onClick={onClickCreateCardButton}>
카드 신청하기
</Button>
</Flex>
);
}다만 매번 사용하는 쪽에서 Promise로 감싸는 것이 번거로워서, useOverlay를 한 번 더 감싼 커스텀 훅을 만들었습니다. 이 훅의 open이 Promise를 반환하도록 해 두고, 호출부는 기존처럼 훅을 사용하면 됩니다.
export const useDialog = () => {
const overlay = useOverlay()
const open = (createOverlayElement: CreateOverlayElement) => {
return new Promise<void>((resolve, reject) => {
overlay.open(({ isOpen, close, exit }) => {
const handleClose = () => {
close()
resolve()
}
const handleExit = () => {
exit()
reject(new Error('User cancelled'))
}
return createOverlayElement({
isOpen,
close: handleClose,
exit: handleExit,
})
})
})
}
return {
open,
close: overlay.close,
exit: overlay.exit,
}
}사용처
const dialog = useDialog()
dialog.open(({ isOpen, close }) => (
<ConfirmDialog
open={isOpen}
close={close}
message={'다이얼로그 메시지'}
/>c. 선언적 UI 관리
useDialog는 다이얼로그를 띄우는 시점과 방법만 담당하고, 각 다이얼로그 컴포넌트는 어떤 내용을 보여줄지만 담당합니다. 사용하는 쪽에서는 “이 다이얼로그를 열어라”라고 선언만 해 주면 되고, 열림 상태를 직접 state로 들고 있을 필요가 없습니다. 역할이 나뉘어 있어 선언적으로 구현하기 좋습니다.
호출부에서는 아래처럼 훅으로 열기만 하고, isOpen·close는 훅이 넘겨 줍니다.
// 훅은 띄우는 역할만 담당
const dialog = useDialog()
// 열기와 함께 isOpen, close만 전달
dialog.open(({ isOpen, close }) => (
// 내부 콘텐츠와 UI는 다이얼로그 컴포넌트만 담당
<ConfirmDialog
open={isOpen}
close={close}
message={'다이얼로그 메시지'}
/>그래서 한 페이지에서 여러 종류의 다이얼로그를 띄우는 경우에도 useDialog는 한 번만 호출하면 됩니다. 같은 dialog 인스턴스로 dialog.open(...)에 넘기는 컴포넌트만 바꿔 가며 서로 다른 다이얼로그를 열 수 있습니다.
// 훅은 한 번만 호출
const dialog = useDialog()
// 다이얼로그별로 열기 핸들러만 나누어 구현
const openFirstDialog = () => {
dialog.open(({ isOpen, close }) => <FirstDialog open={isOpen} close={close} content={content} />)
}
const openSecondDialog = () => {
dialog.open(({ isOpen, close }) => <SecondDialog open={isOpen} close={close} />)
}
const openThirdDialog = () => {
dialog.open(({ isOpen, close }) => (
<ThirdDialog open={isOpen} close={close} message={ERROR_MESSAGES} />
))
}
마무리
위와 같은 시행착오를 거쳐 최종적으로 현재 형태의 다이얼로그로 정리했습니다.
세 번째 버전인 shadcn + useOverlay 기반 다이얼로그의 장점을 정리하면 다음과 같습니다.
| 구분 | 설명 |
|---|---|
| 웹 접근성 | shadcn 다이얼로그가 제공하는 접근성(포커스 트랩, 역할·라벨 등)을 그대로 활용할 수 있습니다. 노출 시 body에 overflow: hidden이 적용되어 배경 스크롤이 막혀 UX도 개선됩니다. |
| 페이지 이동 시 자동 닫힘 | 전역 상태가 아니어서 라우트가 바뀌면 오버레이가 unmount 되며 다이얼로그도 함께 사라집니다. 뒤로 가기 시 다이얼로그가 남던 문제가 해소되었습니다. |
| 버튼 핸들러와 내부 상태값이 동일한 스코프 공유 | 다이얼로그 UI(제목, 설명, 버튼)와 그 안의 상태(입력값, API 결과 등)를 같은 컴포넌트에서 다룹니다. 로컬 상태는 해당 컴포넌트에서 관리하거나, 외부 API 응답은 props로 넘겨 받아 사용할 수 있습니다. |
| key 추가 불필요 | 새 화면·새 플로우에서 다이얼로그를 쓸 때 전역 key를 추가하는 등 별도 작업이 필요 없습니다. |
| 관심사 분리 | useDialog는 “띄울지 말지, 언제 닫을지”만 담당하고 다이얼로그 내부 상태는 알 필요가 없습니다. 열기/닫기 로직과 다이얼로그 콘텐츠·상태가 분리되어 구조가 명확해졌습니다. |
차후 useOverlay 훅, shadcn dialog 컴포넌트를 딥다이브하고 해당 내용도 공유하는 포스팅을 올려볼 예정입니다.