Next.js 병렬 라우팅으로 안드로이드 Back 버튼 UX 개선하기

김재한·2026년 1월 12일

문제의 인식

상황 📝

모바일 웹 또는 WebView 환경에서 팝업이나 모달을 띄운 상태로 안드로이드 Back 버튼을 누르면 사용자가 기대하는 동작은 대부부 아래와 같다.

"현재 화면에 떠 있는 팝업이나 모달이 닫힌다."

하지만 실제로는 팝업이 닫히지 않고, 이전 페이지로 이동하는 현상이 발생했다. 🚨

이는 단순한 UX 불편을 넘어

  • 사용자가 작업하던 맥락이 끊긴다 (데이터가 초기화됨)
  • 다시 페이지에 진입해야 하는 상황이 발생한다.

원인 분석 🧐

1. 팝업 & 모달은 히스토리에 쌓이지 않는다.

대부분의 경우, 팝업이나 모달은 다음과 같이 구현한다.

  • useState 를 이용한 조건부 렌더링
  • CSS(z-index, position)로 화면 위에 오버레이

이 경우, 라우팅과 관계가 없으므로 브라우저 히스토리에 아무것도 쌓이지 않는다. 🙅🏻‍♂️

2. 안드로이드 Back 버튼의 동작

안드로이드 시스템에서 Back 버튼을 실행하면 (버튼 터치 or 모션)
웹 관점에서는 window.history.back()이 수행된다.

즉, 히스토리에 쌓여있는 스택을 기준으로 이전 엔트리로 이동한다. 🔙

해결방안 💡

Next.js의 병렬 라우팅을 활용해서 팝업과 모달을 페이지로 만들어 준다.

페이지로 렌더링되기 때문에 히스토리에 쌓여 window.history.back() 으로 제어가 가능하다.

이번 포스팅에서는 버튼 팝업과, 바텀업 다이얼로그를 구현해 볼 예정이다.😎

먼저, 병렬 라우팅이 무엇인지 간단하게 알아보자 📖

병렬 라우팅 (Parallel Routes) 이란?

병렬 라우팅은 특정 경로에 들어 왔을때 하나 이상의 페이지를

동시 또는 조건부로 렌더링 할 수 있게 해준다.

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


각각의 컴포넌트이기 때문에 각자 설정한 error와 loading을 설정할 수 있고 독립적으로 Stream 되어진다.

Convention 🧑🏻‍💻

병렬 라우트는 폴더명 앞에 @를 붙여 만든다. 그리고 같은 레벨의 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 디렉토리 안에 bottomDialogbuttonDialog 페이지를 생성한다.

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 페이지가 병렬로 라우팅 되어 있다.

  • store 생성 하기
// 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에는 버튼 정보가 들어있는 buttonInfoisOpen 값이 들어 있다.

  • 팝업 정보 업데이트 하기
// /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');
로 이동하면 병렬라우팅 되어진다.

0개의 댓글