모바일 웹 또는 WebView 환경에서 팝업이나 모달을 띄운 상태로 안드로이드 Back 버튼을 누르면 사용자가 기대하는 동작은 대부부 아래와 같다.
"현재 화면에 떠 있는 팝업이나 모달이 닫힌다."
하지만 실제로는 팝업이 닫히지 않고, 이전 페이지로 이동하는 현상이 발생했다. 🚨
이는 단순한 UX 불편을 넘어
대부분의 경우, 팝업이나 모달은 다음과 같이 구현한다.
이 경우, 라우팅과 관계가 없으므로 브라우저 히스토리에 아무것도 쌓이지 않는다. 🙅🏻♂️
안드로이드 시스템에서 Back 버튼을 실행하면 (버튼 터치 or 모션)
웹 관점에서는 window.history.back()이 수행된다.
즉, 히스토리에 쌓여있는 스택을 기준으로 이전 엔트리로 이동한다. 🔙
Next.js의 병렬 라우팅을 활용해서 팝업과 모달을 페이지로 만들어 준다.
페이지로 렌더링되기 때문에 히스토리에 쌓여 window.history.back() 으로 제어가 가능하다.
이번 포스팅에서는 버튼 팝업과, 바텀업 다이얼로그를 구현해 볼 예정이다.😎
먼저, 병렬 라우팅이 무엇인지 간단하게 알아보자 📖
병렬 라우팅은 특정 경로에 들어 왔을때 하나 이상의 페이지를
동시 또는 조건부로 렌더링 할 수 있게 해준다.

이와 같이 컴포넌트 안에 자식 컴포넌트 두개를 두는 방식이 아니라, 병렬 라우팅을 통해 등록한 페이지들이 자동으로 렌더링 되는 방식이다.

각각의 컴포넌트이기 때문에 각자 설정한 error와 loading을 설정할 수 있고 독립적으로 Stream 되어진다.
병렬 라우트는 폴더명 앞에 @를 붙여 만든다. 그리고 같은 레벨의 layout에 props로 전달된다.
디렉토리를 만들었다고 해서 URL 에 포함되는 것은 아니다.

위 폴더구조에서 layout.js 는 @analytics 와 @team 슬롯을 props로 전달받아 children과 함께 렌더링 할 수 있게 된다.
export default function Layout(props: {
children: React.ReactNode;
// 이런식으로 slot들을 전달받는다.
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<>
{props.children}
{props.team}
{props.analytics}
</>
);
}
더 자세한 내용은 Next.js App Router 에서 확인할 수 있다.

@modal 디렉토리 안에 bottomDialog와 buttonDialog 페이지를 생성한다.
App 라우팅으로 /bottomDialog 와 /buttonDialog 경로로 접근이 가능하다.
// app/layout.tsx
export default function RootLayout({
children,
modal,
}: Readonly<{
children: React.ReactNode;
modal: React.ReactNode;
}>) {
return (
<html lang="en">
<body id="container" className="antialiased max-w-tablet mx-auto">
<Suspense fallback={<LoadingScreen />}>
<ToastProvider>
{children}
{modal}
</ToastProvider>
</Suspense>
</body>
</html>
);
}
루트 레이아웃에 생성한 modal 을 연결해 준다.
이제❗️ 기존 페이지들과 modal 내부 페이지들이 병렬로 라우팅 되어진다. 🙌🏻
전역 상태관리 라이브러리인 zustand 로 버튼 팝업에 대한 정보를 관리한다.
☝🏻 팝업 정보를 store에 업데이트 시킨다.
✌🏻 router.push() 로 팝업 페이지를 띄운다.
🤟🏻 store 값을 참조해 팝업을 렌더링 한다.

결과를 먼저 확인해 보면, 기존 localhost:3000 페이지와
localhost:3000/buttonDialog 페이지가 병렬로 라우팅 되어 있다.
// buttonInfoDto.ts
import { z } from "zod";
export const buttonInfoDto = z
.object({
// 팝업 내용
description: z.string().default(""),
// 팝업 버튼 정보
buttonList: z
.array(
z.object({
buttonName: z.string().default(""),
onClick: z.function().default(() => {}),
})
)
.default([]),
})
.default({});
export type buttonInfoType = z.infer<typeof buttonInfoDto>;
export const initialButtonInfo: buttonInfoType = buttonInfoDto.parse({});
// useBottonPopupStore.ts
import { create } from "zustand";
import {buttonInfoType, initialButtonInfo} from '@/shared/model/buttonInfoDto';
interface ButtonPopupState {
buttonInfo: buttonInfoType;
setButtonInfo: (buttonInfo: buttonInfoType) => void;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export const useButtonPopupStore = create<ButtonPopupState>((set) => ({
buttonInfo: initialButtonInfo,
setButtonInfo: (info) => set({buttonInfo:info}),
isOpen: false,
setIsOpen: (isOpen: boolean) => set({isOpen: isOpen}),
}));
store에는 버튼 정보가 들어있는 buttonInfo 와 isOpen 값이 들어 있다.
// /app/page.tsx
export default function MainPage() {
const {setButtonInfo} = useButtonPopupStore()
const { push, back } = useRouter();
const handleOnButtonClick = (num: number) => {
// 팝업 정보 설정
if(num === 1){
setButtonInfo({
description: "버튼 한개 팝업입니다.",
buttonList: [
{
buttonName: "확인",
onClick:()=> back()
}
]
})
}else{
setButtonInfo({
description: "버튼 두개 팝업입니다.",
buttonList: [
{
buttonName: "취소",
onClick:()=> back()
},
{
buttonName: "확인",
onClick:()=> back()
},
]
})
}
// 팝업 띄우기
push("/buttonDialog", { scroll: false });
}
...
return (
<div className="mt-10 mx-auto flex gap-3">
<BottomButton title="One Button Popup" onClick={()=>handleOnButtonClick(1)} />
<BottomButton title="Two Button Popup" onClick={()=>handleOnButtonClick(2)} />
<BottomButton title="Bottom Dialog" onClick={handleOnBottomDialogClick} />
</div>
);
}
// /app/@modal/buttonDialog/page.tsx
export default function ButtonModal() {
const router = useRouter();
const { buttonInfo, isOpen } = useButtonPopupStore();
const [isOneButton, setIsOneButton] = useState(false);
const {setIsOpen} = useButtonPopupStore()
const pathname = usePathname();
const closeModal = () => {
router.back();
};
useEffect(() => {
setIsOneButton(buttonInfo?.buttonList?.length === 1);
}, [buttonInfo?.buttonList?.length]); // 의존성 추가
useEffect(() => {
if (pathname.includes("/buttonDialog")) {
setIsOpen(true);
} else {
setIsOpen(false);
}
}, [pathname]);
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={closeModal}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed left-0 top-0 right-0 bottom-0 bg-black/25" />
</TransitionChild>
<div className="fixed left-0 top-0 right-0 bottom-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-12 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel
className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-4 text-left align-middle shadow-xl transition-all"
aria-labelledby="dialog-title"
aria-describedby="dialog-description"
>
<div id="dialog-description" className="mt-2 whitespace-pre-line text-center">
<p className="text-by01-15-21-400 text-kn-900">{buttonInfo?.description}</p>
</div>
<div className={`flex mt-4 h-[3rem] ${isOneButton ? "justify-center" : ""}`}>
{buttonInfo?.buttonList?.map((button, index) => {
return (
<button
key={button.buttonName}
type="button"
id={button.taggingId}
className={`inline-flex flex-1 justify-center items-center border border-transparent py-2 px-1 text-base font-medium ${
isOneButton
? "bg-ss-800 rounded-md text-white"
: index === 0
? "bg-kg-400 text-kn-1000 w-full rounded-tl-md rounded-bl-md"
: "bg-ss-800 text-white w-full -ml-[2px] rounded-tr-md rounded-br-md"
}
`}
onClick={button.onClick}
>
{button.buttonName}
</button>
);
})}
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
}
전역 데이터로 팝업 데이터를 관리하기 때문에
팝업을 띄우고자 하는 화면에서 store 값을 업데이트 시키고
router.push('/buttonDialog') 해주면 된다. 👍🏻

// /app/@modal/bommomDialog/categoryDetail
export default function ButtonModal() {
const router = useRouter();
const [isOpen, setIsOpen] = useState<boolean>(false);
const [selectedCategoryId, setSelectedCategoryId] = useState(undefined);
const [detailCategoryList, setDetailCategoryList] = useState([{categoryDetailId:1, categoryDetailName:"카테고리1"},{categoryDetailId:2, categoryDetailName:"카테고리2"}, {categoryDetailId:3, categoryDetailName:"카테고리3"}, {categoryDetailId:4, categoryDetailName:"카테고리4"}, {categoryDetailId:5, categoryDetailName:"카테고리5"}]);
useEffect(() => {
setIsOpen(true);
}, []);
const closeModal = () => {
setIsOpen(false);
setTimeout(() => {
router.back();
}, 200);
};
const handleOnSelect = () => {
setIsOpen(false);
setTimeout(() => {
router.back();
}, 200);
};
return (
<BottomDialog isOpen={isOpen} onClose={closeModal} title="바텀업 다이얼로그">
<>
<div className="flex flex-col gap-5 mt-4 max-h-[200px] pb-3 overflow-y-auto noneScrollBar dialog-body">
{/* 카테고리 항목 렌더링 */}
{detailCategoryList?.map((item) => {
return (
<div
key={item.categoryDetailId}
className="flex justify-between px-1"
onClick={() => setSelectedCategoryId(item.categoryDetailId)}
>
<span className="text-sb01-16-21-400 text-kn-1000 font-normal">{item.categoryDetailName}</span>
{selectedCategoryId === item.categoryDetailId && <NextImage src={CHECKED} alt="Checked" width={18} height={18} />}
</div>
);
})}
</div>
<BottomButton onClick={() => handleOnSelect()} title={CONFIRM} className="mb-[10px] mt-4" />
</>
</BottomDialog>
);
}
버튼 팝업과 마찬가지로, router.push('/bottomDialog/categoryDetail');
로 이동하면 병렬라우팅 되어진다.