dialog(대화 상자) 는 사용자와 컴퓨터 프로그램 간에 정보를 교환하거나 사용자의 입력을 받기 위해 사용되는 ui 요소입니다. modal 이나 popup 을 포함하는 넓은 개념으로 생각해도 좋을 것 같아요.! 👍
dialog 는 다음과 같이 다양한 상황에서 사용할 수 있습니다.
- 사용자에게 입력 혹은 선택을 요청할 때
- 경고 메시지를 표시하거나 잘못된 작업을 방지하기 위해 확인 메시지를 제공할 때
프로젝트가 요구하는 기능, 환경에 따라 다양하게 방법으로 dialog 를 구현할 수 있을 것 같아요,,,
브라우저에 직접 alert() , confirm(), prompt() 명령하는 것도 한 방법이겠죠.?
✋ react 를 통해 alert() 과 비슷한 기능을 하는 코드를 구현해 본 적이 있어요.!!
다음 게시물도 봐주시면 좋을 것 같습니다.!
편리한 window.alert(), 직접 만들어 보기
dialog 를 출력하는 건 딱히 어려운 일이 아니었지만,
특정 dialog 에서 결정된 내용을 바탕으로 또 다른 dialog 를 출력한 일은 구현이 조금 힘들다고 생각했어요.
구체적인 예로, 회원가입 에 대한 view 를 구성한다고 생각해봅시다.
- 사용자로부터 인적사항을 입력받을 수 있는 form 을 첫번째 dialog 로 출력
- '이메일' 을 작성하지 않은 상태에서 '중복검사' 버튼을 클릭 시, 경고문을 출력하는 두번째 dialog 를 출력
- '이메일' 을 작성한 상태에서 '중복검사' 버튼을 클릭 시, 중복된 여부를 알리는 세번째 dialog 를 출력
.
.
.
설계에 따라서 더 깊고 복잡한 구조로 dialog 가 중첩돼야 할 수도 있을 겁니다.
특히 위의 예시에서 가장 중요한 점은, 첫번째 dialog 에서 작성한 내용이 그 이후에 출력하는 dialog 의 open/close 상태와 상관없이 원래의 내용을 유지해야한다는 것입니다.
이메일,비밀번호,비밀번호 확인까지 작성한 후에 중복검사를 진행했는데, 기존 정보가 휘발되면 안되니까요.. 😓
이번 포스터에서는 위의 예와 비슷한 요구사항을 해결하기 위해, 일전에 제가 사용했던 코드와 문제점을 소개하고, 이를 더 개선할 수 있는 아이디어에 대해 이야기하겠습니다.!
전에 FE 담당으로 개발에 참여했던 중고거래 서비스 에서는 ui 설계 때부터 overlay component 를 참 많이 배치했었습니다.
* PEA project - figma design 중 일부
[모달 창]
[채팅 관련]
[회원가입 form 관련]
경고/알림 메시지 등을 띄우는 것부터 판매자와 구매자 간 채팅기록 또한 dialog를 활용했습니다...
위에서 설명드렸던 것처럼 특정 dialog 위, 사용자의 선택에 따라 또다른 dialog 를 출력해야하는 경우 역시 해당 프로젝트 설계 상에 있었습니다.
제가 담당했던 일 중 하나는, 다양한 overlay-component 를 쉽게 on/off 할 수 있는 기능을 설계하고 그 방법을 팀원들에게 공유하는 것이었습니다.
우선 overlay-component 의 공통적인 요소 (뒷 배경을 어둡게 하는 필터 등) 를 모아 OverlayBase 컴포넌트를 만들었어요.
const OverlayBase = ({
position = "midCenter", // 오버레이 컴포넌트 위치
isFiltered = false, // 뒷 배경을 어둡게 할 필터를 적용할지 여부
children,
}) => {
return (
<>
{isFiltered && <S.BackgroundFilter />}
<S.OverlayContainer $positionCSS={position}>
{children}
</S.OverlayContainer>
</>
);
};
const OverlayContainer = styled.div`
position: fixed;
top: 0;
left: 0;
z-index: 10000;
width: 100%;
height: 100vh;
padding: 1rem;
display: flex;
${({ $positionCSS }) => {
return PositionCSS[$positionCSS];
}}
`;
const BackgroundFilter = styled.div`
position: fixed;
top: 0;
left: 0;
z-index: 9999;
width: 100%;
height: 100vh;
background-color: ${COLOR.COMMON[0]};
opacity: 0.6;
`;
const S = {
OverlayContainer,
BackgroundFilter
}
이를 open/close 할 수 있는 함수를 선언, context-api 를 통해 전역적으로 사용할 수 있도록 했습니다.
저를 포함해 총 일곱 명이 참여했던 만큼, dialog 를 띄우는 로직을 컴포넌트 개별로 작성한다면 핵심적인 view 에 대한 집중이 분산될 거라고 판단했어요.! 🙃
import { createContext, useRef, useState } from "react";
import { OverlayBase } from "../components/overlay";
// overlay-component open 함수를 프로젝트 전반에 전달
export const OverlayContext = createContext({
onOpenOverlay: () => {},
});
export const OverlayProvider = ({ children }) => {
/** 오버레이 컴포넌트 */
const OverlayComponent = useRef(null);
/** OverlayBase 에 전달될 props */
const overlayBaseProps = useRef(null);
/** overlay 컴포넌트 에 전달할 props */
const [overlayComponentsProps, setOverlayComponentsProps] = useState({});
// open 한다는 것은,,
const onOpenOverlay = ({
overlayComponent,
position,
isFiltered,
...props
}) => {
// 1. overlay 하려는 컴포넌트를 useRef 에 등록
OverlayComponent.current = overlayComponent;
overlayBaseProps.current = { position, isFiltered };
if (
OverlayComponent.current &&
typeof OverlayComponent.current === "function"
) {
// 2. overlay 하려는 컴포넌트의 props 를 state 로 등록
setOverlayComponentsProps(() => ({ ...props }));
}
};
// close 한다는 것은 => 초기화
const onClose = () => {
OverlayComponent.current = null;
overlayBaseProps.current = null;
setOverlayComponentsProps(() => ({}));
};
return (
<OverlayContext.Provider value={{ onOpenOverlay }}>
{children}
// useRef 객체에 등록되어있는 컴포넌트가 있다면,
{OverlayComponent.current && (
// OverlayBase + 등록된 컴포넌트 출력
<OverlayBase {...overlayBaseProps.current}>
<OverlayComponent.current
{...overlayComponentsProps}
onClose={onClose}
/>
</OverlayBase>
)}
</OverlayContext.Provider>
);
};
OverlayBase 위에 chatting 혹은 회원가입 form, 단순 알림 텍스트 등 출력해야 할 컴포넌트를 overlay-content 라 하고,
open 하는 함수( = onOpenOverlay ) 의 parameter 로 overlay-content 컴포넌트 를 render 해주는 로직을 구현했습니다.
물론 해당 overlay-content 가 필요로 하는 props 또한 onOpenOverlay 의 parameter 로 받아 대신 전달해 줍니다.
(*다른 팀원 분이 사용해주신,, 실제 코드입니다.)
const SigninPage = () => {
...
const { onOpenOverlay } = useOverlay();
...
const handleOpenModal = () => {
onOpenOverlay({
overlayComponent: Modal, // `Modal` 이라는 컴포넌트를 띄우려나 봅니다.
noticeText: noticeText, // `Modal 컴포넌트` 로 전달되어야 할 props1
modalState: modalState, // `Modal 컴포넌트` 로 전달되어야 할 props2
buttonText: "확인", // `Modal 컴포넌트` 로 전달되어야 할 props3
isFiltered: true,
});
};
...
저 스스로 이정도(?) 설계를 생각해냈다는 것에 뿌듯하기도 하고, 팀원들한테 칭찬 받을 생각에 으쓱해하며 발표했던 기억이 나네요.. 😎
그 당신엔 뭐 그럭저럭 괜찮다고 생각던 것 같습니다.. (그랬"었"다구요,,,)
하지만, 너무나 중대한 문제를 간과했습니다.
dialog 가 2 개 이상 동시에 띄어져야 할 경우, 즉 overlay 가 층층이 쌓여야 할 경우에는 props 전달을 어떻게 하죠?
overlay 된 component 에서 다시 onOpenOverlay() 를 호출하면 될까요?
하지만 이것은 틀렸습니다 ~! 🤓
OverlayProvider 를 다시 보면, useRef 객체에 등록하여 관리할 수 있는 component 는 단 한 개 입니다. (useRef 객체를 하나만 선언했습니다..)

설계를 완전히 갈아엎진 않고서는 중첩된 dialog 를 띄울 방법이 전혀 없었어요..
하지만 이것도, 틀렸습니다 ~! 🤓
그럼에도 저는 방법을 꾸역꾸역 찾아냈죠. (근데 이제 광기를 조금 곁들인...)
중첩이 이뤄질 수 없는 이유는 useRef 의 선언이 단 한 개밖에 없기 때문이고, 설령 그것을 여러개 둔다 한들 어느 시점에 어떤 component 를 render 시켜야 할지 분기 처리도 어렵습니다...
그렇다면, component 하나에 고유한 useRef 객체를 하나씩 매핑하는 방법을 찾으면 되지 않을까요?
overlay 컴포넌트를 관리하는 useRef 객체는 OverlayProvider 에 하나씩 선언되어 있으니까,,, OverlayProvider 를 OverlayBase 안에서 재사용하는 방법은 어떤가요?
import { OverlayProvider } from "../../contexts";
const OverlayBase = ({
position = "midCenter",
isFiltered = false,
children,
}) => {
return (
{/**
* OverlayProvider 로 감쌌습니다. 👇
* 이 내부에도 useRef, `onOpenOverlay()` 등이 선언되어 있습니다.
* 현재 컴포넌트(= OverlayBase) 위에서 랜더링 된 컴포넌트 가
* `onOpenOverlay()` 호출 한다면, 이곳의 useRef 에 등록이 됩니다.
*/}
<OverlayProvider>
{isFiltered && <S.BackgroundFilter />}
<S.OverlayContainer $positionCSS={position}>
{children}
</S.OverlayContainer>
</OverlayProvider>
);
};
overlay 된 1번 컴포넌트에서 중첩될 2번 컴포넌트 를 대상으로onOpenDialog() 를 호출할 경우, 가까운 provider 의 onOpenDialog() 가 호출된 것이고 그것의 useRef 로 등록이 될 테니 (scope-chaining) , 이론상 무한하게 중첩할 수 있는 구조입니다.!
* (나름 설명하려고 열심히 준비해 봤는데 설명이 구구절절, 더 어려워지는 것 같아요 🥲, 이 부분은 그냥 넘어가셔도 좋습니다... 그냥 넘어가주세요..)
저는 dialog가 층층이 쌓여야 할 때, 그 순서대로 z-index 값을 증가시켜야 한다고 생각했습니다.
그래서 onOpenDialog() 에 z-index 를 값을 설정할 수 있도록 하고, 팀원들에게 해당 파라미터에 값을 꼭 명시하도록 강제했습니다;;
const OverlayBase = ({
position = "midCenter",
isFiltered = false,
zIndex = 0, // 추가, overlay-component 간에 상대적인 차이
onClose,
children,
}) => {
if (typeof zIndex !== "number" || zIndex < 0) zIndex = 0;
...
return (
<OverlayProvider>
{isFiltered && <S.BackgroundFilter $zIndex={zIndex} />}
<S.OverlayContainer
$positionCSS={position}
$zIndex={zIndex}
...
</S.OverlayContainer>
</OverlayProvider>
);
};
const OverlayContainer = styled.div`
...
z-index: ${({ $zIndex }) => 10000 + $zIndex * 2};
...
`
const BackgroundFilter = styled.div`
...
/** OverlayContainer 보다 한 층 아래 */
z-index: ${({ $zIndex }) => 10000 + $zIndex * 2 - 1};
...
`
const S = {
OverlayContainer,
BackgroundFilter,
};
"첫번째로 overlay 될 dialog 에는 zIndex 값을 0 으로 주세요. 🙏, 두번째로 overlay 되는 건 z-index 를 1 로 주셔요.🙏 , 근데 중첩 될 필요가 없다고 판단되면 값을 안주셔도 됩니다.🙏, 기본값을 0 으로 설정했거든요.."
중첩되는 dialog 간의 상대적인 위치 값이기 때문에, 꽤 큰 값으로 후처리하는 섬세함(?) 까지 코드에 녹였습니다.
일단 OverlayProvider 의 사용 위치가 이상합니다... (내가 쓴 코드지만 😝!)
useContext 는 전역적으로 사용되는 값을 쉽게 전달하도록 함이 그 목적이라고 생각하는데, 위와 같이 일반 컴포넌트에 포함시켜둔다면 render 상태에 따라 그 역할에 제한이 생깁니다.
또한 context-api 에서 관리하는 상태가 변경 될 시, 그 하위의 모든 ui 가 새롭게 그려져 사용을 조심해야하는 것으로 알고 있어요. 지속적으로 provider 객체를 만들어 내는 건 좋지 않을 것 같습니다. 😔
솔직히 저 스스로도 치밀하게 계산해서 작성한 코드라기 보다, 되지 않을까 해서 해봤는데 원하는 대로 화면이 그려진 케이스입니다. 다른 팀원분들에게도 동작 원리가 제대로 공유되었다고 생각되지 않습니다;;
z-index 설정을 필수로 적어달라고 요청한 것 또한 좋지 못한 방법입니다. overlay 누적값을 개발자들에게 계속 계산하도록 시킨 것이니까요...
뭔가 문제가 발생할 경우, 그에 대한 추적을 힘들어지게 될 것도 자명합니다.
나름 머리써서 열심히 해봤는데, 누가 봐도 요상한 코드를 적게 되어 실망이 컸습니다. 심지어 프로젝트까지 잘 마무리되지 못해, 조금 더 마음이 아프네요..
그래도 제 dialog 설계를 보시고 스스승님께서 저를 딱하게 여기시어, 한가지 좋은 아이디어💡 를 주셨으니,, 그 뒤로 조금은 더 괜찮은 방법을 생각할 수 있게 되었습니다.

이후부터는 그 방법에 대해 공유해보도록 하겠습니다.!
좋은 방법인지 같이 생각해보고, 다른 방법이 있다면 댓글로 공유해주세요. 👍
층층이 dialog 를 쌓아야하는 경우, z-index 를 각각 설정해주어야 한다는 게 저의 기본 전제였습니다.
하지만 이것은 틀렸습니다 ~! 🤓
다음의 코드를 생각해봅시다.
const SomeCompo = () => {
return (
<S.Center>
{/** 1. 큰 빨간 네모 */}
<S.FixedZIndex7 $size="big" $bgColor="red" />
{/** 2. 중간 노란 네모 */}
<S.FixedZIndex7 $size="middle" $bgColor="yellow" />
{/** 3.. 작은 초록 네모 */}
<S.FixedZIndex7 $size="small" $bgColor="green" />
</S.Center>
);
};
const FixedZIndex7 = styled.div`
position: fixed;
z-index: 7;
...
`
const S = {
FixedZIndex7
}
display: fixed & z-index: 7, 동일한 css 설정을 갖는 3 개의 컴포넌트에 대해 react 는 어떤 순서로 그려줄까요??
.
.
.
.
.
.
.
.
.
.
.
정답은 3번,
🙄 가장 나중에 쓰인 컴포넌트를 가장 앞에 서도록 그려주네요..
javascript 코드가 위에서부터 한줄씩 읽어지니까 당연하다면 당연한 결과일지도 모르겠어요. 😅
그래도 z-index 값을 계속 증가시키며 설정해줘야 한다고 생각했던 저에겐 나름 쇼킹한 현상이었습니다...
이를 활용하면, 중첩되는 dialog 구현을 조금 더 편하게 할 수 있을 거라고 생각되지 않으신가요??
이를 통해, 저는 아래의DialogProvider를 생각해어 다른 프로젝트에 바~로 적용시켜 버렸습니다.! 😊
context-api 를 활용해, dialog 의 open/close 여부 및 그것이 필요로 하는 props 를 state 로 관리하는 것은 동일합니다. 다만 이번에 달라질 것은,,
1. 여러 dialog 의 props 를 배열로 관리한다.
2. open/close 함수의 역할은 props 배열을FILO (first-in-last-out)순으로 관리하는 것으로 정의한다.
바로 코드를 보겠습니다.
// dialog.context.js
// dialog on/off 함수를 전역적으로 전달.!
const DialogContextInit = {
onOpen: () => {},
onClose: () => {},
};
const DialogContext = createContext(DialogContextInit);
export const useDialogStore = () => useContext(DialogContext);
export const DialogProvider = ({ children }) => {
// 여러 dialog 에 전달할 props 객체'들'을 배열 형태로 state 관리.!
const [dialogAttributesArray, setDialogAttributesArray] = useState([]);
// onOpen 을 호출한다는 것은 👇
const onOpen = ({ ...props }) => {
setDialogAttributesArray((prev) => {
const _prev = [...prev];
props.isOpen = true;
// 새로 출력할 dialog 의 props 를
// 배열의 가장 마지막 요소로 추가하는 것.!
_prev.push(props);
return _prev;
});
};
// onClose 을 호출한다는 것은 👇
const onClose = () => {
setDialogAttributesArray((prev) => {
if (!!!prev.length) return prev;
const _prev = [...prev];
// props 배열의 가장 마지막 요소를 제거하는 것.!
const latestDialogsProps = _prev.pop();
latestDialogsProps.isOpen = false;
return _prev;
});
};
return (
<DialogContext.Provider value={{ ...{ onOpen, onClose } }}>
{children}
// push 된 순서대로 `Dialog` render
{dialogAttributesArray.map((attributes, idx) => (
<Dialog key={idx} {...attributes} />
))}
</DialogContext.Provider>
);
};
주석으로 간간히 작성했지만, 사실 주석 내용이 전부 입니다. 😅
Dialog 컴포넌트 를 어떻게 설계하는 지에 따라 관리하는 props 객체가 달라지겠지만,,
open 된 순서로 ( = push() 된 순서로) Dialog 컴포넌트 에 전달 -> render 시키는 과정입니다.
🫠 이해를 돕기 위해, 한 가지 케이스에 대해 나름대로 쉽게 설명해보겠습니다.
프로젝트 전역에서
onOpen()이 3 번 호출되었고, 해당 함수를 통해 전달된 dialog props 가 다음과 같은 형태로 state 에 등록되었다고 가정합니다.[ {title:'dialog1', message:"1번, 나 열림"}, {title:'dialog2', message:"2번, 나도 열림"}, {title:'dialog3', message:"3번, 나는 왜?"}, ]현재 state 가 빈 배열이 아니니,
Dialog 컴포넌트가 배열의 수만큼 render 됩니다.return ( <DialogContext.Provider value={{ ...{ onOpen, onClose } }}> {children} {dialogAttributesArray.map((attributes, idx) => ( <Dialog key={idx} {...attributes} /> ))} </DialogContext.Provider> );조금 더 풀어서 보면,,
return ( <DialogContext.Provider value={{ ...{ onOpen, onClose } }}> {children} <Dialog title="dialog1" message="1번, 나 열림" /> <Dialog title="dialog2" message="2번, 나도 열림" /> <Dialog title="dialog3" message="3번, 나는 왜?" /> </DialogContext.Provider> );
main-ideasection 에서 보았듯이, 가장 마지막에 open(= push) 된 props 의Dialog가 가장 상단이 노출됩니다..
실제 사용을 해보겠습니다.
DialogContext,DialogProvider는 위에 코드로 작성해두었으니, 또 작성하진 않겠습니다.! 🙏
저는 Dialog 컴포넌트를 아래와 같이 작성하여, DialogProvider 에 심었습다.
이건 그냥 참고만 해주시길 바랍니다.!
export type DialogProps = { isOpen?: boolean // 현재 열린 상태인지, size?: keyof typeof SIZE_CSS onClose?: VoidFunction // 최상단의 `Dialog 컴포넌트` 제거 title: string isCloseButton?: boolean dialogContent?: ReactNode // Dialog 위에 그려질 컴포넌트 } const Dialog = ({ size = 'default', onClose, title, isCloseButton = true, dialogContent }: T.DialogProps) => { return ( <S.FullSizeFilter> <CenterFlexBox align="bothAlign"> <S.DialogBase $size={size} onClick={(e) => e.stopPropagation()}> {isCloseButton && <S.CloseButton onClick={onClose}>X</S.CloseButton>} <ColumnFlexBox gap="1rem"> <S.DialogTitle>{title}</S.DialogTitle> <CenterFlexBox align="bothAlign">{dialogContent}</CenterFlexBox> </ColumnFlexBox> </S.DialogBase> </CenterFlexBox> </S.FullSizeFilter> ) } export default Dialog
이제 Provider 를 가장 상위 파일 (저같은 경우엔 src > main.tsx) 에 감싸주시고, 그곳의 호출을 받는 하위 컴포넌트에서 Dialog 를 열어봅시다.
// src > App.jsx const App = () => { const { onOpen, onClose } = useDialogStore() const onClickButton = () => { onOpen({ title: '⚠️ Alert', size: 'small', dialogContent: <h2>😁</h2>, // dialog 위에 출력될 컴포넌트 onClose }) } return ( <S.MainContainer> <Button onClick={onClickButton}>그냥 버튼</Button> </S.MainContainer> ) }
잘 작동합니다. 👍👍
이제 오늘의 목적이었던, 중첩이 가능한지를 확인해보겠습니다.
간단하게 설계를 해보자면,,
1. 현재 페이지에서 버튼을 누르면, 두 개의 버튼을 포함하는 컴포넌트를 dialog 위에 출력된다.
2. 이 중, 어떤 버튼을 누르냐에 따라 각각 다른 텍스트를 가진 dialog 가 중첩된다.
첫번째로 overlay 될 컴포넌트를 다음과 같이 작성했습니다.
const HasTwoButton = () => { const { onOpen, onClose } = useDialogStore() /** 1번 버튼 클릭 시, */ const onClickButton1 = () => onOpen({ title: '1번 버튼 타이틀', size: 'small', dialogContent: <p>1번 버튼 내용</p>, onClose }) /** 2번 버튼 클릭 시, */ const onClickButton2 = () => onOpen({ title: '2번 버튼 타이틀', size: 'small', dialogContent: <p>2번 버튼 내용</p>, onClose }) return ( <S.MainContainer> <Button onClick={onClickButton1}>1번 버튼</Button> <Button onClick={onClickButton2}>2번 버튼</Button> </S.MainContainer> ) }설계에 따라 두 개의 버튼을 포함하는 view 를 가지고, 각 버튼 클릭 시에 등장할 두 번째 overlay 컴포넌트 도 지정해 두었습니다.
이제 첫 페이지를 다음과 같이 수정하면,
const App = () => { const { onOpen, onClose } = useDialogStore() const onClickButton = () => { onOpen({ title: '⚠️ Alert', dialogContent: <HasTwoButton />, // 첫번째 overlay component 지정 onClose }) } return ( <S.MainContainer> <Button onClick={onClickButton}>그냥 버튼</Button> </S.MainContainer> ) }
자주 사용하는 dialog-props 설정 등을 모아 custom-hook 으로 만들어 두면, 보다 직관적으로 분기처리할 수 있을 것 같습니다. 🫠
<dialog> - html elementHTML5 에 dialog 태그가 있다는 것을 알고 계신가요??
2022년부터 Chorem, Firefox, Safari 등 주요 browser 에서 지원하는 엄연한 html element 라고 합니다.
(*2024.04.05 기준)
사용 가능한 browswer/version 확인하기
저도 이걸 사용하는 코드를 봤는데, 제가 개선한 코드보다 훨씬 사용하기 편한 것 같더라구요... 😭 (겹쳐지는 것도 가능..)
그래도,, 혹시라도 위의 사진에서 지원 안되는 빨간색이 신경 쓰인다면,, 혹은 레트로 감성을 좋아하신다면,, 혹은 map 으로 순회출력(?) 하는 게 좋으시다면,, 제 코드(?)도 참고해 주시길 바랍니다~ 🙄