Next.js

Next.js App Router의 인터셉트 라우트(Intercept Route)와 병렬 라우트(Parallel Route) 알아보기

Kir93 2024. 12. 15. 19:43
728x90
반응형

Next.js App Router

 

 

Next.js 13 이후 도입된 App 디렉터리 기반의 라우팅 시스템은 기존 pages 디렉터리 방식에 비해 유연하고 모듈화 된 라우팅 구성 방식을 제공합니다.

 

특히 app 디렉터리 내에서 Layout, Server Components, 그리고 클라이언트 컴포넌트 개념을 통한 세밀한 구조 관리가 가능해졌습니다.

그중 인터셉트 라우트(Intercept Route)와 병렬 라우트(Parallel Route)는 기존에는 구현하기 까다롭던 UX 패턴을 손쉽게 구현할 수 있는 좋은 기능이었습니다.

1. 인터셉트 라우트(Intercept Route) : 요청을 가로채는 새로운 방식

인터셉트 라우트는 특정한 요청이 들어올 때 기존의 라우트 흐름을 가로채어(인터셉트) 원하는 UI나 플로우를 제공하는 기능입니다.

 

예를 들어, 사용자가 특정한 상품 상세 페이지로 진입하려 할 때, 전체 페이지 리로딩 없이 기존 페이지 위에 모달 형태로 상세 정보를 띄우고 싶다고 해봅시다.

과거에는 이러한 UI 패턴을 구현하기 위해 페이지 구조, 상태 관리, 또는 라우팅 트릭들을 상당히 복잡하게 다뤄야 했습니다.

하지만 인터셉트 라우트를 활용하면, 기존 페이지를 유지한 상태로 새로운 경로에 해당하는 뷰를 오버레이(overlay) 형태로 쉽게 뿌릴 수 있습니다.

1.1 작동방식

Intercept Route는 next.config.js에서 라우트 설정과 미들웨어를 활용하거나 app router의 layout-level에서 정의할 수 있습니다.

기본적인 작동 흐름은 다음과 같습니다.

  1. 사용자가 특정 경로로 이동하면 해당 요청이 Intercept Route 설정에 따라 처리됩니다.
  2. 요청은 미리 정의된 컴포넌트로 전달되며, 필요에 따라 새로운 화면이나 팝업 형태로 렌더링 됩니다.
  3. 기존의 브라우저 히스토리와 상태를 유지하면서도, 비동기 데이터 로딩과 사용자 경험 개선이 가능합니다.

1.2 사용 예시 : 상품 상세 모달로의 인터셉트 라우팅

이전
상품 목록 페이지에서 특정 상품을 클릭하면 상세 페이지로 내비게이션 하는 일반적인 패턴을 사용했습니다.

하지만 사용자가 목록을 스크롤해 보며 여러 상품을 살펴보는 경우, 매번 페이지 전환으로 인한 레이아웃 변화가 UX를 저해했습니다.

또한, 상세 정보만 간단히 확인하려면 별도의 탭이나 팝업 처리를 해야 했기에 유지보수가 까다로웠습니다.

 

인터셉트 라우트 적용

app/products/[id]/page.tsx와 같은 경로를 일반 상세 페이지로 유지하되, 동일한 [id]에 대한 "인터셉트 라우트"를 별도 스코프로 정의합니다.

이때 app/products/@modal/[id]/page.tsx와 같은 구조를 활용할 수 있습니다.
사용자가 상품 이미지나 "자세히 보기"를 클릭하면 기존의 상품 목록을 덮는 모달이 인터셉트 라우트를 통해 나타납니다.

이때 주소창에는 /products/[id] 형태로 상세 경로가 반영되지만, 실제로는 메인 콘텐츠(products) 라우트 위에 모달(@modal) 라우트가 겹쳐 렌더링 됩니다.

사용자는 뒤로 가기, 닫기 버튼을 누르면 다시 상품 목록 화면으로 복귀하는데, 이때 전체 페이지 리로드 없이도 상태와 스크롤 위치가 유지되어, 매끄러운 사용자 경험을 제공할 수 있습니다.

// 디렉터리 구조
app/
  products/
    page.tsx              // 상품 목록 페이지
    layout.tsx            // 상품 페이지 상위 레이아웃
    [id]/
      page.tsx            // 상품 상세 일반 페이지 (백업 또는 직접 접근용)
    @modal/
      [id]/
        page.tsx          // 상품 상세 모달 인터셉트 라우트
// app/products/layout.tsx
import React, { ReactNode } from 'react';
import '../globals.css';

interface ProductsLayoutProps {
  children: ReactNode;
}

export default function ProductsLayout({ children }: ProductsLayoutProps) {
  return (
    <div className="products-layout">
      <h1>상품 목록</h1>
      {children}
    </div>
  );
}
// app/products/page.tsx (상품 목록)
import React from 'react';
import Link from 'next/link';

interface Product {
  id: string;
  name: string;
}

const mockProducts: Product[] = [
  { id: '1', name: '상품 A' },
  { id: '2', name: '상품 B' },
  { id: '3', name: '상품 C' },
];

export default function ProductsPage() {
  return (
    <ul>
      {mockProducts.map(product => (
        <li key={product.id}>
          {/* 클릭 시 /products/[id] 경로로 이동하지만, 인터셉트 라우트(@modal)가 적용되어 모달로 열림 */}
          <Link href={`/products/${product.id}`}>
            <span>{product.name}</span>
          </Link>
        </li>
      ))}
    </ul>
  );
}
// app/products/[id]/page.tsx(일반 상세 페이지, 직접 접근 시)
import React from 'react';

interface ProductPageProps {
  params: { id: string };
}

export default function ProductPage({ params }: ProductPageProps) {
  return (
    <div>
      <h2>상품 상세 (일반 페이지)</h2>
      <p>상품 ID: {params.id}</p>
      {/* 여기서는 일반적인 상세 정보 표시 */}
    </div>
  );
}
// app/products/@modal/[id]/page.tsx(인터셉트 모달)
import React from 'react';
import { useRouter } from 'next/navigation';

interface ModalProductPageProps {
  params: { id: string };
}

export default function ModalProductPage({ params }: ModalProductPageProps) {
  const router = useRouter();

  const handleClose = () => {
    router.back();
  };

  return (
    <div className="modal-overlay">
      <div className="modal-content">
        <h2>상품 상세 모달</h2>
        <p>상품 ID: {params.id}</p>
        {/* 상세 정보, 이미지, 가격 등 표시 */}
        <button onClick={handleClose}>닫기</button>
      </div>
    </div>
  );
}

 

이러한 패턴은 모달뿐만 아니라, 로그인/회원가입 폼을 인터셉트 라우트로 처리하여, 어디서든 인증 뷰를 레이어로 오버레이 하는 방식에도 활용할 수 있습니다.

 

결과적으로 인터셉트 라우트를 통해 "페이지 전환"과 유사한 사용자 인지 흐름을 유지하면서도, 비주얼적으로는 오버레이를 통해 맥락 전환 비용을 최소화할 수 있습니다.

2. 병렬 라우트(Parallel Route): 동시적 데이터 로딩과 렌더링

병렬 라우트는 하나의 상위 레이아웃(또는 경로) 내에서 서로 다른 영역을 동시에 렌더링 할 수 있도록 하는 개념입니다.

 

전통적으로 라우팅은 트리 형태로 상위 > 하위 경로로 점진적으로 내려가는 구조를 따릅니다.

하지만 때때로 한 레이아웃에서 좌측 내비게이션 메뉴, 우측 메인 콘텐츠, 그리고 하단 푸터 등 서로 다른 부분을 별도의 라우팅 흐름으로 관리하고 싶을 때가 있습니다.

병렬 라우트는 이러한 컴포넌트들을 독립된 라우팅 스코프로 처리하여, 유저가 한 영역에서 경로를 바꾸더라도 다른 영역은 재렌더링 없이 상태를 유지하거나 다른 로직으로 전환할 수 있도록 합니다.

2.1 작동 방식

Parallel Route는 app router에서 layout 파일과 함께 사용되며, 각 경로를 키-값 형태로 매핑하여 병렬로 렌더링 합니다.

2.2 사용 예시 : 검색 페이지 내 필터 영역 분리와 병렬 라우트 활용

이전

상품 검색 페이지에서 사용자는 카테고리, 가격 범위, 브랜드별 필터링을 적용하며 메인 상품 리스트를 갱신합니다.

기존 구조에서는 모든 필터 적용이 단일 라우트로 묶여 있었고, 필터 조건이 바뀌면 전체 페이지가 리렌더링 되거나, 클라이언트 상태 관리 도구(예: Redux, Zustand 등)를 통해 복잡한 상태 작업을 해야 했습니다.

 

병렬 라우트 적용

예를 들어 app/search 아래에 @filters와 @results라는 병렬 라우트 스코프를 정의합니다.

// 디렉터리 구조
app/
  search/
    layout.tsx
    @filters/
      page.tsx   // 필터 UI
    @results/
      page.tsx   // 결과 리스트 UI
// app/search/layout.tsx
import React, { ReactNode } from 'react';
import '../globals.css';

interface SearchLayoutProps {
  children: ReactNode;
}

export default function SearchLayout({ children }: SearchLayoutProps) {
  return (
    <div className="search-layout">
      <h1>상품 검색</h1>
      <div className="search-container">{children}</div>
    </div>
  );
}
// app/search/@filters/page.tsx(필터 UI)
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function FiltersPage() {
  const [category, setCategory] = useState('all');
  const router = useRouter();

  const handleCategoryChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    setCategory(e.target.value);
    // 쿼리 기반 업데이트 가능, 예: router.push(`/search?category=${e.target.value}`)
    // 여기서는 단순히 상태만 바꿔놓고, 결과 페이지가 이를 참조한다고 가정
  };

  return (
    <aside className="filters-panel">
      <h2>필터</h2>
      <label>
        카테고리:
        <select value={category} onChange={handleCategoryChange}>
          <option value="all">전체</option>
          <option value="electronics">전자제품</option>
          <option value="fashion">패션</option>
        </select>
      </label>
    </aside>
  );
}
// app/search/@results/page.tsx(결과 리스트 UI)
import React, { useEffect, useState } from 'react';

// 이 예시에서는 필터 상태를 간단히 글로벌(상위 레이아웃 또는 서버 액션)에서 받아온다고 가정
function getCurrentCategory() {
  // 실제 구현에서는 URL 쿼리, useSelectedLayoutSegments, 서버 액션 등을 통해 상태를 얻을 수 있음.
  return 'all';
}

interface Product {
  id: string;
  name: string;
}

export default function ResultsPage() {
  const [products, setProducts] = useState<Product[]>([]);
  const category = getCurrentCategory();

  useEffect(() => {
    // category 상태에 따라 해당 데이터 fetch
    fetch(`/api/search?category=${category}`)
      .then(res => res.json())
      .then(data => setProducts(data));
  }, [category]);

  return (
    <main className="results-panel">
      <h2>검색 결과</h2>
      <ul>
        {products.map(p => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </main>
  );
}

 

이러한 구조를 통해 필터 영역과 결과 리스트 영역이 각각 독립된 라우팅 흐름을 가질 수 있습니다.

사용자가 필터를 조작할 때, URL 쿼리나 세그먼트 변화를 통해 @filters 영역 라우트를 업데이트하더라도 @results는 해당 변화에 따라 필요한 데이터 패칭과 UI 업데이트를 수행하지만, 상위 레이아웃이나 다른 병렬 라우트 스코프가 불필요하게 재렌더링되지 않습니다.

이로써 필터 적용 시 불필요한 전역 상태 관리 복잡도를 줄이고, 컴포넌트 분리도 극대화하여 유지보수성과 성능을 향상할 수 있습니다.

 

또한 병렬 라우트를 통해 A/B 테스트를 병렬 스코프에서 실행할 수도 있습니다.

예를 들어 @results 스코프 내에서 일부 사용자는 기존 리스트, 일부는 새로운 디자인의 리스트를 렌더링 하는 경로를 병렬 라우트로 관리함으로써, 동일한 상위 콘텍스트에서 다양한 실험을 유연하게 진행할 수 있었습니다.

3. 인터셉트 라우트(Intercept Route)와 병렬 라우트(Parallel Route)의 조합

가장 큰 강점은 이 두 기능을 조합해 더욱 강력한 UX를 설계할 수 있다는 점입니다.

 

예를 들어, 대시보드 페이지에서 알림 패널을 클릭하면 Intercept Route를 활용해 모달로 알림 상세 정보를 표시하고, 모달이 닫혀도 Parallel Route를 통해 대시보드의 다른 패널은 영향을 받지 않도록 할 수 있습니다.

// 디렉터리 구조
app/
  dashboard/
    layout.tsx
    @main/
      page.tsx   // 대시보드 메인 콘텐츠
    @notifications/
      page.tsx   // 알림 목록
    (modal)/
      notification/
        [id]/
          page.tsx  // 알림 상세 모달 (인터셉트)
// app/dashboard/layout.tsx
import React, { ReactNode } from 'react';
import '../globals.css';

interface DashboardLayoutProps {
  children: ReactNode;
}

export default function DashboardLayout({ children }: DashboardLayoutProps) {
  return (
    <div className="dashboard-layout">
      <h1>내 대시보드</h1>
      <div className="dashboard-container">{children}</div>
    </div>
  );
}
// app/dashboard/@main/page.tsx(메인 콘텐츠)
import React from 'react';

export default function MainDashboardPage() {
  return (
    <section className="main-section">
      <h2>주요 지표</h2>
      {/* 대시보드 주요 콘텐츠 렌더링 */}
    </section>
  );
}
// app/dashboard/@notifications/page.tsx(알림 목록)
import React from 'react';
import Link from 'next/link';

interface Notification {
  id: string;
  message: string;
}

const mockNotifications: Notification[] = [
  { id: '101', message: '새로운 메시지가 도착했습니다.' },
  { id: '102', message: '시스템 점검 예정 안내.' },
];

export default function NotificationsPage() {
  return (
    <aside className="notifications-panel">
      <h2>알림</h2>
      <ul>
        {mockNotifications.map(n => (
          <li key={n.id}>
            {/* 클릭 시 알림 상세 모달 인터셉트 라우트로 이동 */}
            <Link href={`/dashboard/(modal)/notification/${n.id}`}>
              {n.message}
            </Link>
          </li>
        ))}
      </ul>
    </aside>
  );
}
// app/dashboard/(modal)/notification/[id]/page.tsx(알림 상세 모달)
import React from 'react';
import { useRouter } from 'next/navigation';

interface NotificationDetailProps {
  params: { id: string };
}

export default function NotificationDetailModal({ params }: NotificationDetailProps) {
  const router = useRouter();
  
  const handleClose = () => {
    router.back();
  };

  return (
    <div className="modal-overlay">
      <div className="modal-content">
        <h2>알림 상세</h2>
        <p>알림 ID: {params.id}</p>
        <p>여기에 알림 상세 내용 표시</p>
        <button onClick={handleClose}>닫기</button>
      </div>
    </div>
  );
}

/dashboard 페이지에서 왼쪽에 알림(@notifications), 오른쪽에 주요 콘텐츠(@main)가 병렬로 렌더링 되며, 특정 알림 클릭 시 /dashboard/(modal)/notification/[id] 경로로 이동하지만, 전체 페이지 전환 없이 모달이 나타납니다.

모달을 닫으면 /dashboard로 돌아가고, 대시보드의 다른 병렬 슬롯은 그대로 유지됩니다.

 

인터셉트 라우트와 병렬 라우트는 Next.js app router 시대에 들어서며 등장한 새로운 라우팅 패러다임으로, 전통적인 페이지 전환 모델로는 구현하기 어려웠던 UX 패턴을 손쉽게 가능하게 만듭니다.

이 두 가지 라우팅 개념을 전략적으로 도입하면, 사용자 경험 개선, 성능 최적화, 유지보수성 향상을 모두 달성할 수 있습니다.

 

다만 폴더 구조, 상태 관리, SEO 등 고려해야 할 부분도 존재하므로, 초기 도입 단계에서 신중하게 설계하고 실험하는 과정을 거치는 것을 권장합니다.

 

반응형