목록 컨텍스트를 잃지 않는 상세 모달 만들기: URL 동기화부터 뒤로가기까지

커버 이미지

페이지 방식과 모달 방식의 한계

저희가 운영하는 콘텐츠 마켓 서비스의 콘텐츠 목록 화면은 무한 스크롤로 구성되어 있습니다. 사용자는 스티커, 이미지, 음원 같은 콘텐츠 카드를 계속 내려가며 탐색하다가, 마음에 드는 콘텐츠를 클릭해 상세 정보를 확인합니다.

이때 상세 화면을 일반적인 페이지 이동으로 처리하면 한 가지 문제가 생깁니다. 상세를 보고 뒤로가기로 목록에 돌아왔을 때, 무한 스크롤로 쌓아온 데이터와 스크롤 위치가 사라지고 목록이 처음부터 다시 렌더링됩니다. 수십 페이지를 내려가며 탐색하던 사용자 입장에서는 맥락이 통째로 사라지는 셈이라, 탐색 경험에 치명적입니다.

그렇다고 상세를 단순한 모달로 만들면 반대편 문제가 생깁니다. 모달은 URL이 없으므로 특정 콘텐츠를 다른 사람에게 공유할 수 없고 검색 엔진에 상세 정보를 노출할 수도 없습니다. 콘텐츠를 판매하는 서비스에서 공유와 검색 유입을 포기할 수는 없었습니다.

정리하면 요구사항은 다음과 같았습니다.

  • 목록 컨텍스트 유지: 상세를 열었다 닫아도 무한 스크롤 데이터와 스크롤 위치가 보존되어야 합니다.
  • 공유 가능한 URL: 상세가 열릴 때 주소창의 URL이 해당 콘텐츠를 가리켜야 하고, 그 URL로 직접 접근해도 같은 내용이 보여야 합니다.
  • SEO: 상세 콘텐츠의 메타 정보가 검색 엔진과 SNS 공유 미리보기에 정상적으로 노출되어야 합니다.
  • 자연스러운 뒤로가기: 상세가 열린 상태에서 뒤로가기를 누르면 사용자 기대에 맞는 동작이 일어나야 합니다.
상세 모달을 닫아도 목록과 스크롤 위치가 유지되는 동작

즉 페이지의 장점(URL, SEO)과 모달의 장점(컨텍스트 유지)을 동시에 가져야 했습니다.

모달이지만 페이지처럼 보이게

이 문제는 인스타그램이나 핀터레스트 같은 서비스들이 이미 풀어놓은 패턴이 있습니다. 목록에서 콘텐츠를 클릭하면 모달로 띄우되, 주소창의 URL은 상세 페이지 주소로 바꿔주는 방식입니다.

핵심은 같은 콘텐츠가 두 가지 경로로 렌더링된다는 점입니다.

  • 목록에서 클릭한 경우: 목록 페이지 위에 모달로 상세를 띄웁니다. 목록 페이지는 언마운트되지 않으므로 컨텍스트가 그대로 유지됩니다.
  • URL로 직접 접근한 경우: 상세 전용 페이지 라우트(/contents/[type]/detail)가 렌더링됩니다. 공유받은 링크로 들어오거나 검색 엔진이 방문하는 경로입니다.

이중 경로 구조

이 이중 경로 구조가 이후 모든 구현의 전제가 됩니다. 모달의 상태 관리는 Pinia 스토어(detailModalStore)로 중앙화하고 목록 페이지와 모달 컴포넌트가 이 스토어를 통해 협력하도록 했습니다.

URL 동기화: router.push 대신 history.pushState를 택한 이유

모달을 열 때 URL을 바꾸는 가장 자연스러운 방법은 router.push처럼 보입니다. 하지만 라우터 내비게이션을 타면 Nuxt가 페이지 전환으로 인식해서 목록 페이지 컴포넌트가 교체됩니다. 그러면 무한 스크롤 데이터와 스크롤 위치가 사라지므로 이 방식은 첫 번째 요구사항과 충돌합니다.

그래서 라우터를 거치지 않고 브라우저 히스토리 API의 history.pushState로 주소창만 바꾸는 방법을 택했습니다. pushState는 히스토리 항목을 추가하고 URL을 교체할 뿐, 라우터에는 아무 일도 일어나지 않습니다. 목록 페이지는 그대로 살아 있고 그 위에 모달만 열립니다.

// store/detailModalStore.ts
export const useDetailModalStore = defineStore("detailModal", () => {
  const isModalOpen = ref(false);
  const contentId = ref("");
  const contentType = ref("");
  const prevUrl = ref(""); // 모달을 닫을 때 복원할 목록 페이지 URL
 
  function openDetailModal(id: string, type: string, currentUrl: string) {
    const nextUrl = `/contents/${type}/detail?contentId=${id}`;
 
    // router.push 대신 history.pushState로 주소창만 교체
    // 라우터 내비게이션을 타면 목록 페이지가 다시 렌더링되기 때문
    if (process.client) {
      window.history.pushState({ ...window.history.state }, "", nextUrl);
    }
 
    contentId.value = id;
    contentType.value = type;
    prevUrl.value = currentUrl;
    isModalOpen.value = true;
  }
 
  function closeDetailModal() {
    // 모달을 닫으면 목록 페이지 URL로 복원
    window.history.pushState({ ...window.history.state }, "", prevUrl.value);
 
    contentId.value = "";
    contentType.value = "";
    isModalOpen.value = false;
  }
 
  // ...
});

물론 만능은 아닙니다. 라우터를 우회했으므로 라우터가 알고 있는 현재 경로와 주소창의 실제 URL이 달라지는 트레이드오프가 생깁니다. 라우터 입장에서 사용자는 여전히 목록 페이지에 있는데, 주소창은 상세 페이지를 가리키는 상태입니다. 뒤로가기에서 이 어긋남이 문제로 드러납니다.

뒤로가기를 "모달 닫기"로 바꾸기

모달을 열 때 pushState로 히스토리 항목을 추가했으므로, 사용자가 뒤로가기를 누르면 브라우저는 URL을 목록 페이지로 되돌립니다. 이때 Vue Router가 URL 변화를 감지하고 페이지 이동을 시작하는데, 이 이동을 그대로 두면 목록 페이지가 다시 렌더링되면서 컨텍스트를 잃습니다.

그래서 레이아웃에서 onBeforeRouteLeave 가드(라우트를 떠나기 직전에 실행되는 훅)로 이 이동을 가로챘습니다. 모달이 열려 있는 상태라면 페이지 이동을 막고 모달만 닫습니다.

뒤로가기 처리 흐름

// layouts/default.vue
onBeforeRouteLeave((to, from, next) => {
  // 상세 모달이 열린 상태의 뒤로가기: 페이지 이동을 막고 모달만 닫음
  if (contentId.value) {
    closeDetailModal();
    return;
  }
 
  next();
});

여기서 뒤로가기의 동작을 두 가지 방안 중에서 고민했습니다.

  • 방안 1: 뒤로가기를 누르면 모달을 띄우기 전 페이지로 실제 이동합니다. "URL이 바뀌었으니 뒤로가기는 페이지 이동"이라는 히스토리 관점에 충실한 동작입니다.
  • 방안 2: 뒤로가기를 누르면 모달만 닫고 목록 페이지에 머뭅니다. "상세를 닫고 보던 목록으로 돌아간다"는 사용자 관점의 동작입니다.

저희는 방안 2를 택했습니다. 이유는 이 구조를 만든 목적 그 자체였습니다. 방안 1로 실제 페이지 이동이 일어나면 목록 페이지가 다시 렌더링되어 무한 스크롤로 쌓아온 데이터와 스크롤 위치가 사라집니다. 그러면 애초에 모달 구조를 택한 이유가 없어집니다. 사용자 입장에서도 "콘텐츠를 닫으면 방금 보던 자리로 돌아간다"가 자연스러운 기대이므로 뒤로가기를 모달 닫기로 해석하는 쪽이 맞다고 판단했습니다.

참고로 모달 위에서 결제 팝업 같은 별도 창과 통신해야 하는 경우도 있는데, 이때의 postMessage 데이터 검증은 별도 글에서 다룬 적이 있어 여기서는 생략합니다.

모달 안에서의 좌우 탐색과 무한 스크롤 연동

상세 모달에는 좌우 화살표로 이전/다음 콘텐츠를 넘겨볼 수 있는 탐색 기능이 있습니다. 모달을 닫지 않고 목록의 콘텐츠들을 연속으로 살펴보는 기능인데, 여기서 목록과 모달의 연동 문제가 하나 생깁니다. 사용자가 모달 안에서 목록의 마지막 콘텐츠까지 넘겨봤다면, 다음 콘텐츠는 아직 목록이 불러오지 않은 다음 페이지에 있습니다.

이 문제는 목록 페이지가 가진 "다음 페이지 로드 함수"를 모달을 열 때 스토어에 함께 넘기는 방식으로 풀었습니다. 모달에서 마지막 항목에 도달하면 이 함수를 호출해 목록의 다음 페이지를 이어서 불러옵니다. 목록의 무한 스크롤과 모달의 좌우 탐색이 같은 데이터 소스를 공유하므로 모달을 닫았을 때도 그동안 불러온 데이터가 목록에 그대로 반영되어 있습니다.

// store/detailModalStore.ts
async function navigateToContent(direction: "prev" | "next") {
  if (direction === "prev" && currentIndex.value === 0) return;
 
  // 마지막 항목에서 다음으로 이동하려면 목록의 다음 페이지를 먼저 로드
  const isLastItem = currentIndex.value === contentInfoList.value.length - 1;
  if (direction === "next" && isLastItem) {
    if (!hasNext.value) return;
    await loadMoreDataFunc.value?.();
  }
 
  // 좌우 전환은 이전 콘텐츠를 유지한 채 새 콘텐츠로 교체 (다음 섹션 참고)
  isFreshOpen.value = false;
 
  const newIndex =
    direction === "prev" ? currentIndex.value - 1 : currentIndex.value + 1;
  const { id, type } = contentInfoList.value[newIndex];
 
  contentId.value = id;
  contentType.value = type;
  currentIndex.value = newIndex;
 
  // 탐색할 때마다 URL도 현재 콘텐츠에 맞게 갱신
  window.history.pushState(
    { ...window.history.state },
    "",
    `/contents/${type}/detail?contentId=${id}`,
  );
}

탐색할 때마다 pushState로 URL을 갱신하는 부분도 눈여겨볼 만합니다. 모달 안에서 다섯 번째 콘텐츠로 넘어간 상태에서 URL을 복사해 공유하면, 받은 사람은 정확히 그 콘텐츠의 상세 페이지로 들어옵니다.

모달 안에서 좌우로 탐색할 때마다 주소창 URL이 갱신되는 모습

키보드 화살표 키로도 탐색할 수 있게 했는데, 한 가지 가드가 필요했습니다. 모달 안에 검색창 같은 입력 요소가 있을 때 사용자가 텍스트 커서를 움직이려고 누른 화살표 키를 탐색으로 오인하면 안 됩니다. 이벤트 대상이 입력 요소면 무시하도록 처리했습니다.

// components/modal/ContentDetail.vue
function handleKeydown(e: KeyboardEvent) {
  if (!isModalOpen.value || isNavigating.value) return;
 
  // 입력 요소에 포커스가 있으면 화살표 키를 가로채지 않음
  const tag = (e.target as HTMLElement).tagName;
  if (tag === "INPUT" || tag === "TEXTAREA") return;
 
  if (e.key === "ArrowLeft") navigateToContent("prev");
  if (e.key === "ArrowRight") navigateToContent("next");
}

전환 시 이전 콘텐츠가 비치는 문제 해결

모달이 콘텐츠를 갈아끼우는 상황은 두 가지입니다. 목록에서 카드를 클릭해 새로 여는 경우와, 열린 모달 안에서 좌우로 전환하는 경우입니다. 두 상황은 데이터를 불러온다는 면에서는 같지만 이전 콘텐츠를 어떻게 처리해야 하는지가 다릅니다.

  • 신규 오픈: 직전에 봤던 다른 콘텐츠의 상태가 스토어에 남아 있으면, 새 데이터가 도착하기 전까지 이전 콘텐츠가 잠깐 보였다가 교체되는 현상이 생깁니다. 사용자가 클릭한 것과 무관한 콘텐츠가 스쳐 지나가므로 이전 상태를 비우고 로딩 화면을 보여줘야 합니다.
  • 좌우 전환: 반대로 이전 콘텐츠를 비워버리면 전환할 때마다 로딩 화면이 깜빡입니다. 연속으로 넘겨보는 흐름에서는 이전 콘텐츠를 유지한 채 새 콘텐츠가 도착하는 순간 교체되는 쪽이 매끄럽습니다.

같은 "콘텐츠 교체"인데 요구되는 처리가 정반대라서, 두 경우를 구분하는 isFreshOpen 플래그를 스토어에 두었습니다. 목록에서 열 때는 true로 설정하고, 좌우 전환에서는 false로 유지합니다. 모달 컴포넌트의 watcher는 이 플래그를 보고 신규 오픈일 때만 이전 상태를 비웁니다.

// components/modal/ContentDetail.vue
watch(contentId, async () => {
  if (!contentId.value) return;
 
  // 신규 오픈일 때만 이전 상태를 비워 이전 콘텐츠가 비치는 현상을 제거
  // 좌우 전환은 기존 콘텐츠를 유지해 매끄럽게 교체
  if (isFreshOpen.value) {
    contentDetail.value = null;
    isFreshOpen.value = false;
  }
 
  isNavigating.value = true;
  try {
    contentDetail.value = await fetchContentApi({
      type: apiType.value,
      contentId: contentId.value,
    });
  } finally {
    isNavigating.value = false;
  }
});

플래그는 watcher가 소비한 직후 false로 되돌립니다. 이렇게 하지 않으면 신규 오픈 이후의 좌우 전환에서도 상태를 비워버려서, 전환마다 로딩 화면이 깜빡이는 문제가 되살아납니다.

공유하면 페이지처럼 노출되는 SEO 처리

마지막 퍼즐은 SEO입니다. 상세 정보의 메타 태그(og:title, og:image 등)는 SNS 공유 미리보기와 검색 엔진 노출에 필요한데, 이 책임을 어디에 둘지가 문제였습니다.

저희는 모달 컴포넌트 안에 메타 태그를 두었습니다. 모달 컴포넌트가 SEO를 책임진다는 것이 다소 특이하게 들릴 수 있지만 앞서 설명한 이중 경로 구조를 생각하면 자연스러운 선택입니다. 공유받은 URL로 직접 접근하면 상세 전용 페이지 라우트가 서버에서 렌더링되고 이 라우트가 같은 상세 컴포넌트를 사용하므로 메타 태그도 함께 렌더링됩니다. 검색 엔진과 SNS 크롤러가 방문하는 것은 언제나 이 직접 접근 경로이므로, 메타 태그를 상세 컴포넌트에 두면 두 경로 모두에서 일관되게 동작합니다.

한 가지 신경 쓸 부분은 og:url입니다. 공유용 정식 URL은 서버 렌더링 시점의 요청 정보로 확정해야 해서, onServerPrefetch에서 요청 헤더의 호스트와 경로를 조합해 만들었습니다.

<!-- components/modal/ContentDetail.vue -->
<template>
  <Head v-if="contentDetail">
    <Title>{{ seoTitle }}</Title>
    <Meta name="description" :content="seoDescription" />
    <Meta property="og:title" :content="seoTitle" />
    <Meta property="og:description" :content="seoDescription" />
    <Meta property="og:image" :content="contentDetail.mainImageUrl" />
    <Meta property="og:url" :content="seoUrl" />
  </Head>
  <!-- ... -->
</template>
 
<script setup lang="ts">
const urlState = useState("seoUrl", () => "");
 
onServerPrefetch(() => {
  const route = useRoute();
  const event = useRequestEvent()!;
  // 서버 렌더링 시점의 요청 정보로 공유용 정식 URL을 확정
  urlState.value = `https://${event.req.headers.host}${route.fullPath}`;
});
 
const seoUrl = computed(() => urlState.value);
</script>
상세 URL을 메신저에 공유했을 때 표시되는 미리보기 카드

상세 URL을 메신저에 공유하면 og 태그 기반 미리보기 카드가 표시되는 것을 확인할 수 있습니다.

결과 및 회고

이 구조를 적용한 뒤, 목록과 상세를 오가는 탐색 경험이 의도한 대로 동작하게 되었습니다. 무한 스크롤로 수십 페이지를 내려간 상태에서 상세 모달을 열고 닫아도, 쌓아온 목록 데이터와 스크롤 위치가 그대로 유지됩니다. 모달 안에서 좌우 탐색으로 새로 불러온 데이터도 목록에 반영되어 있으므로, 사용자는 어디서 모달을 닫아도 항상 보던 맥락으로 돌아옵니다. 동시에 모든 상세 콘텐츠가 고유 URL을 가지므로 공유와 검색 유입도 일반 페이지와 동일하게 동작합니다.

SEO 측면의 이점도 분명했습니다. 목록과 상세가 모두 SSR로 렌더링되기 때문에, 검색 엔진이 목록 페이지로 들어오든 상세 URL로 직접 들어오든 완성된 HTML을 받습니다. 상세를 모달로 전환하는 선택이 자칫 SEO를 포기하는 결정이 되기 쉬운데, 직접 접근용 페이지 라우트를 남겨두고 그 경로를 SSR로 처리한 덕분에 검색 노출은 일반 페이지 구조와 동일하게 유지되었습니다.

이 패턴의 본질은 라우터의 페이지 전환과 브라우저의 URL을 의도적으로 분리한 것입니다. 화면 전환은 모달로 처리해 목록 컨텍스트를 지키고 URL은 히스토리 API로 직접 관리해 페이지처럼 보이게 했습니다. 그 대가로 라우터와 주소창의 상태가 어긋나는 곳들이 생기는데, 뒤로가기(onBeforeRouteLeave 가드), 신규 오픈과 전환의 구분(isFreshOpen), SEO(onServerPrefetch)가 그 어긋남을 하나씩 메우는 장치들입니다.

다시 한다면 히스토리를 직접 조작하는 코드가 스토어의 여러 함수(열기, 닫기, 좌우 전환)에 흩어져 있는 부분을 한 곳으로 모으고 싶습니다. URL 동기화라는 관심사가 분산되어 있으면, 나중에 이 코드를 만나는 사람이 전체 그림을 파악하기 어렵기 때문입니다.

목록 컨텍스트와 공유 가능한 URL을 모두 지켜야 하는 상황이라면, 화면 전환과 URL 관리를 분리하는 이 접근 방식이 좋은 출발점이 될 수 있습니다.

참고 자료

History API

Vue Router / Nuxt