본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 10. 29. · 50 Views
Next.js Parallel Routes와 Intercepting Routes 완벽 가이드
Next.js의 고급 라우팅 기능인 Parallel Routes와 Intercepting Routes를 실무 관점에서 깊이 있게 다룹니다. 모달, 대시보드, 멀티뷰 레이아웃 등 복잡한 UI 패턴을 우아하게 구현하는 방법을 배워보세요.
목차
- Parallel Routes 기본 개념 - 동시에 여러 페이지를 렌더링하는 마법
- Parallel Routes 실전 활용 - 대시보드 구조 설계하기
- Intercepting Routes 기본 개념 - 라우트를 가로채는 우아한 모달
- Intercepting Routes와 Parallel Routes 조합 - 최강의 모달 패턴
- 조건부 렌더링과 동적 슬롯 - 권한 기반 UI 구성하기
- default.tsx의 중요성 - 슬롯의 폴백 처리 마스터하기
- 중첩 레이아웃과 슬롯 - 복잡한 UI 계층 구조 만들기
- workspace/project/[id]/file/[fileId]/page.tsx가 최종 콘텐츠를 제공
- 동적 라우트와 Parallel Routes - 데이터 기반 슬롯 구성하기
- 에러 경계와 슬롯별 에러 처리 - 견고한 애플리케이션 만들기
- 실전 예제 - 소셜 미디어 피드와 모달 구현하기
1. Parallel Routes 기본 개념 - 동시에 여러 페이지를 렌더링하는 마법
시작하며
여러분이 대시보드를 만들 때 이런 상황을 겪어본 적 있나요? 같은 페이지에 사용자 통계, 최근 활동, 알림 목록을 동시에 보여줘야 하는데, 각각 독립적으로 로딩되고 에러 처리도 따로 해야 하는 상황 말이죠.
전통적인 방법으로는 하나의 컴포넌트에 모든 로직을 때려박거나, 복잡한 상태 관리를 구현해야 했습니다. 하나라도 에러가 나면 전체 페이지가 깨지는 문제도 흔했죠.
바로 이럴 때 필요한 것이 Parallel Routes입니다. 하나의 레이아웃 안에서 여러 페이지를 독립적으로 렌더링하고, 각각의 로딩과 에러 상태를 따로 관리할 수 있게 해줍니다.
개요
간단히 말해서, Parallel Routes는 동일한 레이아웃에서 하나 이상의 페이지를 동시에 렌더링할 수 있게 해주는 Next.js App Router의 고급 기능입니다. 실무에서 복잡한 대시보드나 멀티뷰 인터페이스를 만들 때 각 섹션이 독립적으로 동작해야 하는 경우가 많습니다.
예를 들어, 관리자 패널에서 왼쪽에는 사용자 목록, 오른쪽에는 상세 정보, 상단에는 통계를 보여주면서 각각 다른 데이터 소스에서 로딩되어야 하는 경우에 매우 유용합니다. 기존에는 복잡한 상태 관리와 조건부 렌더링으로 처리했다면, 이제는 폴더 구조만으로 이를 우아하게 해결할 수 있습니다.
Parallel Routes의 핵심 특징은 슬롯(slot) 기반 라우팅, 독립적인 에러와 로딩 상태 관리, 그리고 조건부 렌더링 지원입니다. 이러한 특징들이 복잡한 UI를 구조적으로 관리하고, 사용자 경험을 크게 향상시킵니다.
코드 예제
// app/dashboard/layout.tsx
// Parallel Routes는 @폴더명 규칙으로 슬롯을 정의합니다
export default function DashboardLayout({
children,
analytics, // @analytics 폴더와 매칭
team, // @team 폴더와 매칭
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div className="dashboard-container">
{/* 메인 콘텐츠 */}
<main>{children}</main>
{/* 독립적으로 렌더링되는 분석 섹션 */}
<aside className="analytics-panel">{analytics}</aside>
{/* 독립적으로 렌더링되는 팀 섹션 */}
<section className="team-section">{team}</section>
</div>
)
}
설명
이것이 하는 일: Parallel Routes는 복잡한 UI를 여러 개의 독립적인 "슬롯"으로 나누어 각각 별도의 라우팅과 상태 관리를 가능하게 합니다. 첫 번째로, 폴더명 앞에 @를 붙여서 슬롯을 정의합니다.
예를 들어 @analytics 폴더를 만들면, 이는 layout의 analytics prop으로 자동 매칭됩니다. 이렇게 하는 이유는 Next.js가 파일시스템 기반 라우팅을 활용하면서도 컴포넌트 구조를 명확하게 유지하기 위함입니다.
그 다음으로, 각 슬롯은 자체적인 loading.tsx와 error.tsx를 가질 수 있습니다. @analytics/loading.tsx는 분석 데이터가 로딩될 때만 표시되고, @team/loading.tsx는 팀 데이터가 로딩될 때만 표시됩니다.
내부적으로 React Suspense 경계가 자동으로 생성되어 각 슬롯이 독립적으로 스트리밍됩니다. 세 번째 단계로, layout.tsx에서 props로 받은 슬롯들을 원하는 위치에 배치합니다.
마지막으로 각 슬롯은 자신만의 라우팅을 가지므로, /dashboard/analytics와 /dashboard/team이 동시에 렌더링되면서도 독립적으로 동작합니다. 여러분이 이 패턴을 사용하면 복잡한 대시보드를 모듈화하고, 각 섹션의 로딩 성능을 최적화하며, 에러가 발생해도 페이지 전체가 아닌 해당 슬롯만 영향을 받는 견고한 애플리케이션을 만들 수 있습니다.
코드 구조도 훨씬 명확해져서 팀 협업 시 각자 담당 슬롯만 개발할 수 있고, 유지보수도 쉬워집니다.
실전 팁
💡 각 슬롯에는 반드시 default.tsx를 만들어두세요. 라우팅이 매칭되지 않을 때 표시할 폴백 컴포넌트로, 없으면 404 에러가 발생할 수 있습니다.
💡 슬롯 간 데이터 공유가 필요하면 Context API나 서버 컴포넌트의 fetch 캐시를 활용하세요. 각 슬롯이 독립적이라고 해서 완전히 격리된 것은 아닙니다.
💡 조건부 렌더링 시 null을 반환하면 해당 슬롯이 사라집니다. 예를 들어 관리자만 볼 수 있는 슬롯을 role 체크로 제어할 수 있습니다.
💡 성능 최적화를 위해 각 슬롯의 loading.tsx에는 스켈레톤 UI를 구현하세요. 사용자는 전체 페이지가 로딩되는 것보다 부분적으로 로딩되는 것을 선호합니다.
💡 개발자 도구의 React DevTools에서 각 슬롯이 별도의 Suspense 경계로 표시되는 것을 확인할 수 있습니다. 디버깅 시 매우 유용합니다.
2. Parallel Routes 실전 활용 - 대시보드 구조 설계하기
시작하며
여러분이 실제 프로덕션 대시보드를 만들 때, 사용자 통계는 빠르게 로딩되는데 복잡한 차트 데이터는 느리게 로딩되어서 전체 페이지가 멈춰있는 경험을 한 적 있나요? 이런 문제는 사용자 이탈률을 높이는 주요 원인입니다.
모든 데이터가 준비될 때까지 빈 화면만 보여주면 사용자는 페이지가 고장났다고 생각합니다. Parallel Routes를 활용하면 빠른 데이터는 먼저 보여주고, 느린 데이터는 로딩 인디케이터와 함께 점진적으로 표시할 수 있습니다.
개요
간단히 말해서, 실전 Parallel Routes 구조는 폴더 구조, 데이터 페칭 전략, 에러 핸들링을 종합적으로 설계하는 것을 의미합니다. 실무에서는 단순히 레이아웃을 나누는 것을 넘어서 각 슬롯의 데이터 소스, 로딩 우선순위, 에러 복구 전략까지 고려해야 합니다.
예를 들어, 전자상거래 관리자 패널에서 주문 목록은 즉시 보여주고, 복잡한 매출 분석 차트는 천천히 로딩되어도 괜찮습니다. 기존에는 Promise.all로 모든 데이터를 기다리거나, 복잡한 waterfall 로딩을 구현했다면, 이제는 각 슬롯이 자동으로 병렬 로딩되고 독립적으로 스트리밍됩니다.
핵심은 각 슬롯에 적절한 loading.tsx, error.tsx, default.tsx를 배치하고, 데이터 페칭 로직을 서버 컴포넌트로 구현하는 것입니다. 이렇게 하면 SEO, 성능, 사용자 경험 모두를 만족시킬 수 있습니다.
코드 예제
// app/dashboard/@analytics/page.tsx
// 복잡한 분석 데이터를 가져오는 서버 컴포넌트
async function getAnalytics() {
// 의도적으로 느린 API 호출
const res = await fetch('https://api.example.com/analytics', {
next: { revalidate: 60 } // 1분 캐시
})
return res.json()
}
export default async function AnalyticsSlot() {
const data = await getAnalytics()
return (
<div className="analytics-card">
<h2>실시간 분석</h2>
<Chart data={data} />
<Insights metrics={data.metrics} />
</div>
)
}
// app/dashboard/@analytics/loading.tsx
export default function AnalyticsLoading() {
return <SkeletonChart />
}
설명
이것이 하는 일: 각 슬롯을 독립적인 비동기 서버 컴포넌트로 구현하여 병렬 데이터 페칭과 점진적 렌더링을 실현합니다. 첫 번째로, @analytics/page.tsx는 async 함수로 선언되어 서버에서 데이터를 직접 페칭합니다.
Next.js 13+의 서버 컴포넌트는 기본적으로 서버에서 실행되므로, 데이터베이스나 내부 API를 직접 호출해도 클라이언트에 노출되지 않습니다. fetch의 next.revalidate 옵션으로 캐싱 전략도 세밀하게 제어할 수 있습니다.
그 다음으로, loading.tsx가 자동으로 Suspense 폴백으로 작동합니다. 사용자가 /dashboard에 접속하면 즉시 SkeletonChart가 표시되고, 백그라운드에서 getAnalytics()가 실행됩니다.
데이터가 준비되면 React가 자동으로 loading.tsx를 실제 컴포넌트로 교체합니다. 세 번째로, 다른 슬롯들(@team, @orders 등)도 동시에 병렬로 로딩됩니다.
각 슬롯의 데이터 페칭은 서로 blocking되지 않으므로, 가장 느린 슬롯이 전체 페이지를 지연시키지 않습니다. 이는 전통적인 waterfall 로딩보다 훨씬 빠른 체감 성능을 제공합니다.
여러분이 이 패턴을 사용하면 Time to First Byte(TTFB)를 최소화하고, Largest Contentful Paint(LCP)를 개선하며, 사용자가 페이지 이탈 없이 점진적으로 콘텐츠를 소비할 수 있습니다. 또한 각 슬롯의 캐싱 전략을 독립적으로 설정할 수 있어, 자주 변경되는 데이터와 정적 데이터를 효율적으로 관리할 수 있습니다.
실전 팁
💡 데이터 페칭 순서가 중요하다면 React의 use() 훅이나 서버 컴포넌트에서 순차적으로 await하세요. 하지만 대부분의 경우 병렬 로딩이 더 빠릅니다.
💡 각 슬롯의 error.tsx에는 'use client'를 추가하고 재시도 버튼을 구현하세요. 전체 페이지를 새로고침하지 않고도 실패한 슬롯만 다시 로딩할 수 있습니다.
💡 개발 환경에서 느린 네트워크를 시뮬레이션하려면 loading.tsx에 임시로 await new Promise(r => setTimeout(r, 3000))을 추가해보세요. 실제 사용자 경험을 미리 확인할 수 있습니다.
💡 프로덕션에서는 각 슬롯의 로딩 시간을 모니터링하세요. Next.js의 Server Timing API나 Vercel Analytics로 병목 지점을 찾아 최적화할 수 있습니다.
3. Intercepting Routes 기본 개념 - 라우트를 가로채는 우아한 모달
시작하며
여러분이 인스타그램처럼 피드에서 이미지를 클릭하면 모달로 열리고, 직접 URL로 접근하면 전체 페이지로 열리는 UX를 구현해야 한다면 어떻게 하시겠어요? 보통은 복잡한 상태 관리와 조건부 렌더링으로 골머리를 앓게 됩니다.
이런 패턴은 현대 웹 앱에서 표준이 되었지만, 구현은 까다롭습니다. 뒤로가기 버튼, 새로고침, 공유 링크 등 모든 케이스를 처리하려면 수많은 엣지 케이스를 고려해야 하죠.
바로 이럴 때 필요한 것이 Intercepting Routes입니다. URL을 가로채서 다른 컴포넌트를 보여주되, 브라우저 히스토리와 새로고침 동작은 자연스럽게 유지합니다.
개요
간단히 말해서, Intercepting Routes는 특정 라우트로의 네비게이션을 가로채서 다른 UI를 보여주면서도, 해당 라우트는 여전히 독립적으로 접근 가능하게 만드는 기능입니다. 실무에서 가장 흔한 사용 케이스는 모달입니다.
사용자가 앱 내에서 링크를 클릭하면 모달로 열리지만, 같은 URL을 북마크하거나 공유해서 직접 접근하면 전체 페이지로 열립니다. 예를 들어, 쇼핑몰에서 상품을 클릭하면 모달로 빠르게 확인하고, 직접 링크로 접근하면 상세 페이지로 보는 UX입니다.
기존에는 router.push()와 함께 쿼리 파라미터나 상태로 모달 여부를 관리했다면, 이제는 폴더 구조만으로 이 모든 복잡성을 해결할 수 있습니다. Intercepting Routes는 (.) 규칙으로 작동합니다.
(.)는 같은 레벨, (..)는 한 단계 위, (..)(..)는 두 단계 위, (...)는 루트부터 시작을 의미합니다. 이 규칙들이 파일시스템 기반의 직관적인 라우팅 가로채기를 가능하게 합니다.
코드 예제
// app/feed/(..)photo/[id]/page.tsx
// 피드에서 사진 클릭 시 이 컴포넌트가 가로챕니다
'use client'
import { useRouter } from 'next/navigation'
import Modal from '@/components/Modal'
export default function PhotoInterceptPage({
params
}: {
params: { id: string }
}) {
const router = useRouter()
return (
<Modal onClose={() => router.back()}>
{/* 모달 안에서 보여줄 사진 상세 */}
<PhotoDetail id={params.id} />
<p>피드에서 열린 모달입니다</p>
</Modal>
)
}
// app/photo/[id]/page.tsx
// 직접 URL 접근 시 이 컴포넌트가 렌더링됩니다
export default function PhotoPage({ params }: { params: { id: string } }) {
return (
<div className="full-page">
<PhotoDetail id={params.id} />
<p>전체 페이지로 열렸습니다</p>
</div>
)
}
설명
이것이 하는 일: Intercepting Routes는 네비게이션 컨텍스트에 따라 같은 URL을 다르게 렌더링하는 조건부 라우팅을 제공합니다. 첫 번째로, (..)photo 폴더명의 (..)는 "한 단계 위의 photo를 가로채라"는 의미입니다.
/feed 페이지에서 /photo/123으로 네비게이션하면, Next.js는 먼저 (..)photo/[id]/page.tsx가 있는지 확인합니다. 있다면 이 컴포넌트를 모달로 렌더링하고, 실제 photo/[id]로는 이동하지 않습니다.
이렇게 하는 이유는 클라이언트 사이드 네비게이션에서 더 나은 UX를 제공하기 위함입니다. 그 다음으로, router.back()을 호출하면 브라우저 히스토리가 올바르게 작동합니다.
Next.js는 가로챈 라우트도 히스토리 스택에 추가하므로, 뒤로가기 버튼이나 router.back()이 자연스럽게 작동합니다. 내부적으로는 shallow routing이 아닌 실제 히스토리 엔트리가 생성됩니다.
세 번째로, 사용자가 브라우저 주소창에 직접 /photo/123을 입력하거나, 페이지를 새로고침하면 어떻게 될까요? 이 경우에는 가로채기가 작동하지 않고, 원본 라우트인 app/photo/[id]/page.tsx가 렌더링됩니다.
마지막으로 이 두 파일은 완전히 독립적이므로, 모달용 UI와 전체 페이지용 UI를 각각 최적화할 수 있습니다. 여러분이 이 패턴을 사용하면 인스타그램, 트위터, Pinterest와 같은 현대적인 웹 앱의 UX를 손쉽게 구현할 수 있습니다.
복잡한 상태 관리 없이도 모달이 자연스럽게 작동하고, SEO도 유지되며, 공유 링크도 정상적으로 동작합니다. 또한 각 라우트가 독립적인 파일이므로 코드 스플리팅도 자동으로 이루어집니다.
실전 팁
💡 Intercepting Routes는 반드시 클라이언트 컴포넌트('use client')여야 합니다. router.back() 같은 클라이언트 동작이 필요하기 때문입니다.
💡 Modal 컴포넌트는 재사용 가능하게 만들고, dialog 태그나 Radix UI 같은 접근성이 보장된 라이브러리를 사용하세요. ESC 키, 외부 클릭, 포커스 트랩 등이 필수입니다.
💡 새로고침과 가로채기를 구분해야 한다면 usePathname()과 useSearchParams()로 네비게이션 소스를 추적하세요. 하지만 대부분의 경우 불필요합니다.
💡 Parallel Routes와 조합하면 더욱 강력합니다. 예를 들어 @modal 슬롯에 Intercepting Route를 배치하면 레이아웃을 유지하면서 모달을 띄울 수 있습니다.
💡 디버깅 시 Next.js 개발자 도구에서 "Intercepted" 라벨을 확인하세요. 어떤 라우트가 가로채졌는지 명확하게 표시됩니다.
4. Intercepting Routes와 Parallel Routes 조합 - 최강의 모달 패턴
시작하며
여러분이 복잡한 전자상거래 사이트를 만든다고 상상해보세요. 상품 목록에서 상품을 클릭하면 모달로 열리고, 모달을 닫으면 원래 목록으로 돌아가며, 직접 링크로 접근하면 전체 페이지로 열려야 합니다.
더 복잡한 것은, 모달이 떠있는 동안에도 백그라운드의 목록 페이지는 유지되어야 하고, 모달 안에서 다른 상품으로 이동할 수도 있어야 한다는 점입니다. 이 모든 요구사항을 Parallel Routes와 Intercepting Routes를 조합하면 놀랍도록 간단하게 해결할 수 있습니다.
개요
간단히 말해서, Parallel Routes의 @modal 슬롯 안에 Intercepting Route를 배치하면, 모달을 위한 완벽한 라우팅 시스템을 만들 수 있습니다. 실무에서 이 패턴은 "모달은 레이아웃 위에 오버레이로 떠야 하고, 닫으면 사라져야 하며, 새로고침하면 전체 페이지가 되어야 한다"는 요구사항을 완벽하게 충족시킵니다.
예를 들어, 넷플릭스처럼 영화 목록에서 클릭하면 모달로 예고편을 보여주고, 직접 링크로는 전체 상세 페이지를 보여주는 패턴입니다. 기존에는 전역 상태로 모달 관리, 조건부 렌더링, 히스토리 관리 등 수많은 코드가 필요했다면, 이제는 세 개의 폴더 구조만으로 해결됩니다.
핵심은 layout.tsx에 @modal 슬롯을 추가하고, 그 안에 (.)경로 규칙으로 Intercepting Route를 만드는 것입니다. 이렇게 하면 Next.js가 자동으로 모든 복잡성을 처리합니다.
코드 예제
// app/layout.tsx
// 루트 레이아웃에 모달 슬롯 추가
export default function RootLayout({
children,
modal, // @modal 슬롯
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{/* 모달은 children 위에 오버레이로 렌더링 */}
{modal}
</body>
</html>
)
}
// app/@modal/(.)products/[id]/page.tsx
// 상품 클릭 시 가로채서 모달로 표시
'use client'
import { useRouter } from 'next/navigation'
export default function ProductModal({ params }: { params: { id: string } }) {
const router = useRouter()
return (
<dialog open className="modal-overlay">
<div className="modal-content">
<button onClick={() => router.back()}>닫기</button>
<ProductQuickView id={params.id} />
</div>
</dialog>
)
}
// app/@modal/default.tsx
// 모달이 없을 때는 null 반환
export default function DefaultModal() {
return null
}
설명
이것이 하는 일: Parallel Routes의 슬롯 시스템과 Intercepting Routes의 조건부 렌더링을 결합하여 프로덕션급 모달 시스템을 구축합니다. 첫 번째로, layout.tsx의 modal prop은 @modal 슬롯과 연결됩니다.
이 슬롯은 children과는 독립적으로 렌더링되므로, 모달이 떠있어도 백그라운드 페이지는 그대로 유지됩니다. {modal}을 {children} 뒤에 배치하면 DOM 순서상 위에 오버레이로 표시됩니다.
그 다음으로, @modal/(.)products/[id]/page.tsx의 (.) 규칙이 핵심입니다. 사용자가 /products/123로 네비게이션하면, 이 파일이 가로채서 @modal 슬롯에 렌더링됩니다.
하지만 직접 URL 접근이나 새로고침에는 app/products/[id]/page.tsx가 렌더링됩니다. 이 이중 구조가 모든 마법을 가능하게 합니다.
세 번째로, default.tsx가 매우 중요합니다. 모달이 없는 상태(예: 홈 페이지)에서는 @modal 슬롯에 null을 반환해야 합니다.
이 파일이 없으면 슬롯이 매칭되지 않아 404 에러가 발생합니다. default.tsx는 "슬롯의 기본값"을 정의하는 필수 파일입니다.
마지막으로, router.back()을 호출하면 URL이 이전 페이지로 돌아가고, @modal 슬롯은 자동으로 default.tsx(즉, null)로 바뀌어 모달이 사라집니다. 이 모든 과정이 클라이언트 사이드 네비게이션으로 부드럽게 처리되며, 브라우저 뒤로가기 버튼도 완벽하게 작동합니다.
여러분이 이 패턴을 사용하면 복잡한 모달 관리 로직 없이도 Instagram, Twitter, Airbnb와 같은 최상급 UX를 구현할 수 있습니다. 페이지 전환 없이 빠른 인터랙션을 제공하면서도, SEO와 공유 기능은 그대로 유지됩니다.
또한 코드 구조가 명확해서 새로운 모달을 추가할 때도 같은 패턴을 반복하기만 하면 됩니다.
실전 팁
💡 modal prop의 타입을 React.ReactNode로 정의하되, 실제로는 null이 자주 반환됩니다. 조건부 렌더링이 필요하다면 {modal && <div className="overlay">{modal}</div>} 형태로 사용하세요.
💡 모달이 열릴 때 body에 overflow: hidden을 추가하려면 useEffect를 사용하세요. 하지만 Next.js 페이지 전환 시 클린업을 잊지 마세요.
💡 복잡한 모달(예: 다단계 폼)에서는 @modal/(..)path/[id]/@step 같은 중첩 Parallel Routes도 가능합니다. 하지만 너무 복잡해지면 오히려 유지보수가 어려워지므로 신중하게 사용하세요.
💡 Intercepting Route가 작동하지 않는다면 폴더명의 괄호와 점의 개수를 다시 확인하세요. (.)는 같은 레벨, (..)는 한 단계 위입니다.
💡 프로덕션에서는 모달 전환에 Framer Motion이나 CSS 애니메이션을 추가하면 더욱 부드러운 경험을 만들 수 있습니다. router.back()과 조합해도 잘 작동합니다.
5. 조건부 렌더링과 동적 슬롯 - 권한 기반 UI 구성하기
시작하며
여러분이 관리자 대시보드를 만드는데, 일반 사용자에게는 통계만 보여주고, 관리자에게는 통계와 사용자 관리 패널을 함께 보여줘야 한다면 어떻게 하시겠어요? 전통적으로는 거대한 if문과 조건부 렌더링으로 처리했을 겁니다.
이런 접근은 코드가 지저분해지고, 권한이 추가될 때마다 조건문이 늘어나며, 각 패널의 로딩과 에러 처리도 복잡해집니다. Parallel Routes의 조건부 렌더링 기능을 활용하면 권한에 따라 슬롯을 동적으로 표시하거나 숨길 수 있으며, 각 패널은 여전히 독립적으로 관리됩니다.
개요
간단히 말해서, Parallel Routes의 슬롯은 조건부로 렌더링될 수 있으며, null을 반환하거나 권한 체크를 통해 동적으로 UI를 구성할 수 있습니다. 실무에서 권한 기반 UI는 매우 흔한 요구사항입니다.
예를 들어, SaaS 애플리케이션에서 Free 플랜 사용자에게는 기본 기능만, Pro 플랜에는 고급 분석을, Enterprise에는 팀 관리까지 보여주는 경우입니다. 각 패널이 독립적인 데이터 소스와 로딩 상태를 가지므로 복잡도가 매우 높습니다.
기존에는 거대한 컴포넌트 안에 모든 로직을 넣고 조건부로 렌더링했다면, 이제는 각 슬롯을 독립적인 파일로 분리하고 layout에서 권한에 따라 표시 여부만 결정합니다. 핵심은 슬롯 자체는 항상 렌더링되지만, layout에서 조건부로 null을 반환하거나, 슬롯 내부의 page.tsx에서 권한 체크 후 리다이렉트하는 것입니다.
두 가지 접근 방식 모두 유효하며, 상황에 따라 선택할 수 있습니다.
코드 예제
// app/dashboard/layout.tsx
// 권한에 따라 슬롯을 조건부로 렌더링
import { auth } from '@/lib/auth'
export default async function DashboardLayout({
children,
analytics,
admin,
}: {
children: React.ReactNode
analytics: React.ReactNode
admin: React.ReactNode
}) {
// 서버 컴포넌트에서 권한 체크
const session = await auth()
const isAdmin = session?.user?.role === 'admin'
return (
<div className="dashboard">
<main>{children}</main>
{/* 모든 사용자에게 분석 표시 */}
<aside>{analytics}</aside>
{/* 관리자에게만 관리 패널 표시 */}
{isAdmin && <section>{admin}</section>}
</div>
)
}
// app/dashboard/@admin/page.tsx
// 이 슬롯은 관리자 전용이지만 파일은 존재
export default async function AdminSlot() {
// 추가 권한 체크 (이중 안전장치)
const session = await auth()
if (session?.user?.role !== 'admin') {
return null // 또는 redirect('/unauthorized')
}
return <AdminPanel />
}
설명
이것이 하는 일: 서버 컴포넌트의 권한 체크와 Parallel Routes의 조건부 렌더링을 결합하여 보안이 보장된 동적 레이아웃을 구축합니다. 첫 번째로, layout.tsx는 서버 컴포넌트이므로 await auth()를 직접 호출하여 세션 정보를 가져옵니다.
클라이언트에 노출되지 않으므로 안전하며, 서버에서 미리 권한을 체크하므로 클라이언트에서 불필요한 컴포넌트가 렌더링되지 않습니다. 이는 번들 크기 최적화에도 도움이 됩니다.
그 다음으로, {isAdmin && <section>{admin}</section>} 조건부 렌더링이 실행됩니다. isAdmin이 false면 admin 슬롯은 아예 DOM에 렌더링되지 않습니다.
하지만 @admin 폴더의 page.tsx는 여전히 파일로 존재하며, 누군가 직접 URL로 접근하려 할 때를 대비합니다. 세 번째로, @admin/page.tsx 내부에도 권한 체크가 있습니다.
이는 "defense in depth" 전략으로, layout의 조건을 우회하려는 시도를 막습니다. 예를 들어, 클라이언트 사이드 라우팅 버그나 직접 URL 조작 시에도 보안이 유지됩니다.
null을 반환하는 대신 redirect('/unauthorized')를 사용하면 더 명확한 피드백을 제공할 수 있습니다. 마지막으로, 권한이 변경되면(예: 로그아웃) Next.js는 자동으로 layout을 재평가하고, 슬롯의 표시 여부가 업데이트됩니다.
revalidatePath()나 router.refresh()로 수동 재검증도 가능합니다. 여러분이 이 패턴을 사용하면 권한 로직이 명확하게 분리되고, 각 슬롯이 독립적으로 보안 검증되며, 불필요한 코드가 클라이언트에 전송되지 않습니다.
또한 새로운 권한 레벨을 추가할 때도 새로운 슬롯을 만들고 조건문만 추가하면 되므로 확장성이 뛰어납니다. 테스트도 쉬워집니다 - 각 슬롯을 독립적으로 테스트하고, layout의 조건부 로직만 따로 테스트하면 됩니다.
실전 팁
💡 권한 체크는 항상 서버 컴포넌트에서 먼저 하세요. 클라이언트에서 조건부 렌더링하면 코드가 번들에 포함되어 역공학으로 노출될 수 있습니다.
💡 복잡한 권한 로직(예: RBAC, ABAC)은 별도의 authz.ts 파일로 분리하고, can('read', 'analytics') 같은 선언적 API를 만들면 유지보수가 쉽습니다.
💡 개발 중에는 환경변수로 모든 패널을 강제로 보이게 하는 "god mode"를 만들면 UI 개발이 편합니다. process.env.DEV_GOD_MODE === 'true' 같은 식으로요.
💡 슬롯이 표시되지 않는 이유를 디버깅하려면 layout에서 console.log나 Next.js의 Server Actions로 권한 상태를 로깅하세요. 브라우저 콘솔이 아닌 터미널에 출력됩니다.
6. default.tsx의 중요성 - 슬롯의 폴백 처리 마스터하기
시작하며
여러분이 Parallel Routes를 처음 사용할 때 가장 많이 겪는 에러가 무엇일까요? 바로 "404 Not Found"입니다.
슬롯을 만들고 몇 개 페이지를 추가했는데, 특정 라우트에서 갑자기 404가 뜹니다. 이 문제의 원인은 대부분 default.tsx가 없기 때문입니다.
Next.js는 슬롯에 매칭되는 페이지가 없을 때 자동으로 폴백할 방법을 모르면 에러를 던집니다. default.tsx는 Parallel Routes를 안정적으로 만드는 필수 파일이며, 올바르게 사용하면 유연한 라우팅 시스템을 구축할 수 있습니다.
개요
간단히 말해서, default.tsx는 슬롯에 매칭되는 페이지가 없을 때 렌더링되는 폴백 컴포넌트입니다. 실무에서 이 파일의 중요성은 자주 간과됩니다.
예를 들어, @modal 슬롯에 여러 Intercepting Routes가 있지만, 모달이 없는 일반 페이지에서는 슬롯이 비어있어야 합니다. 이때 default.tsx가 없으면 Next.js는 어떤 컴포넌트를 렌더링해야 할지 모르고 에러를 발생시킵니다.
기존 라우팅 시스템에서는 이런 개념이 없었지만, Parallel Routes는 동시에 여러 페이지를 렌더링하므로 "이 슬롯에는 페이지가 없다"는 상태를 명시적으로 표현해야 합니다. 핵심은 모든 슬롯에 default.tsx를 만드는 것을 습관화하는 것입니다.
대부분의 경우 null을 반환하지만, 경우에 따라 기본 UI나 플레이스홀더를 보여줄 수도 있습니다.
코드 예제
// app/@modal/default.tsx
// 모달이 없는 상태의 기본값
export default function ModalDefault() {
// 대부분의 경우 null 반환
return null
}
// app/@sidebar/default.tsx
// 사이드바가 필요 없는 페이지의 기본값
export default function SidebarDefault() {
// 빈 사이드바 대신 null 반환
return null
}
// app/@dashboard/default.tsx
// 대시보드 슬롯의 기본값 - 플레이스홀더 표시
export default function DashboardDefault() {
// 경우에 따라 기본 UI 표시
return (
<div className="empty-state">
<p>대시보드를 선택하세요</p>
</div>
)
}
// app/layout.tsx
// 슬롯들이 default로 폴백될 수 있도록 구성
export default function RootLayout({
children,
modal,
sidebar,
dashboard,
}: {
children: React.ReactNode
modal: React.ReactNode
sidebar: React.ReactNode
dashboard: React.ReactNode
}) {
return (
<html>
<body>
{sidebar}
<main>
{children}
{dashboard}
</main>
{modal}
</body>
</html>
)
}
설명
이것이 하는 일: default.tsx는 Next.js의 슬롯 매칭 알고리즘에서 "안전망" 역할을 하며, 예상치 못한 라우팅 상황을 우아하게 처리합니다. 첫 번째로, Next.js가 라우트를 매칭할 때의 동작을 이해해야 합니다.
사용자가 /dashboard로 이동하면, @modal 슬롯에서 @modal/dashboard/page.tsx를 찾습니다. 없으면 @modal/default.tsx를 찾고, 그마저도 없으면 404 에러를 던집니다.
이는 슬롯이 "필수"로 간주되기 때문입니다. 그 다음으로, null을 반환하는 default.tsx의 의미를 파악해야 합니다.
null은 "이 슬롯은 현재 렌더링할 것이 없다"는 의도를 명확히 표현합니다. layout.tsx에서 {modal}은 결국 null이 되고, React는 null을 아무것도 렌더링하지 않습니다.
이는 에러가 아니라 정상적인 상태입니다. 세 번째로, 경우에 따라 플레이스홀더를 반환하는 default.tsx도 유용합니다.
@dashboard/default.tsx가 "대시보드를 선택하세요" 메시지를 보여주면, 사용자는 빈 화면 대신 명확한 안내를 받습니다. 이는 특히 복잡한 SaaS 애플리케이션에서 UX를 크게 개선합니다.
마지막으로, default.tsx와 page.tsx의 차이를 이해해야 합니다. page.tsx는 특정 라우트에 매칭되지만, default.tsx는 "매칭되는 page.tsx가 없을 때"의 폴백입니다.
예를 들어, @modal/product/[id]/page.tsx는 /product/123에 매칭되고, 다른 모든 라우트에서는 @modal/default.tsx가 사용됩니다. 여러분이 이 패턴을 올바르게 사용하면 Parallel Routes가 훨씬 안정적으로 작동하고, 예상치 못한 404 에러가 사라지며, 라우팅 로직이 더 명확해집니다.
새로운 팀원이 프로젝트에 합류해도 "이 슬롯은 기본적으로 비어있다"는 것을 코드에서 바로 알 수 있습니다. 또한 default.tsx를 활용하면 점진적으로 기능을 추가할 수 있습니다 - 처음에는 null을 반환하다가, 나중에 플레이스홀더나 기본 UI를 추가하는 식으로요.
실전 팁
💡 프로젝트 시작 시 모든 슬롯에 default.tsx를 먼저 만드세요. 나중에 추가하면 이미 발생한 404 에러를 찾느라 시간을 낭비합니다.
💡 TypeScript를 사용한다면 default.tsx도 page.tsx와 같은 타입을 반환해야 합니다. export default function Default(): null | JSX.Element 형태로 명시하세요.
💡 default.tsx가 언제 렌더링되는지 확인하려면 console.log를 추가하세요. 서버 컴포넌트이므로 터미널에 출력됩니다.
💡 복잡한 애플리케이션에서는 각 슬롯의 default.tsx를 문서화하세요. "이 슬롯은 /dashboard 외의 모든 라우트에서 비어있음" 같은 주석이 큰 도움이 됩니다.
7. 중첩 레이아웃과 슬롯 - 복잡한 UI 계층 구조 만들기
시작하며
여러분이 Notion이나 Figma처럼 복잡한 다단계 레이아웃을 만든다고 상상해보세요. 상단에는 글로벌 네비게이션, 왼쪽에는 프로젝트 목록, 오른쪽에는 파일 목록, 중앙에는 에디터, 그리고 때때로 오른쪽 사이드바에는 속성 패널이 나타납니다.
이런 복잡한 UI를 하나의 레이아웃에 모두 넣으면 코드가 엉망이 되고, 각 섹션의 상태와 로딩을 관리하기가 지옥이 됩니다. Parallel Routes는 중첩될 수 있으며, 각 레벨에서 독립적인 슬롯을 정의하여 복잡한 UI 계층을 우아하게 구성할 수 있습니다.
개요
간단히 말해서, 중첩 레이아웃에서도 각 레벨마다 Parallel Routes를 사용할 수 있으며, 상위 슬롯과 하위 슬롯이 독립적으로 작동합니다. 실무에서 이런 패턴은 엔터프라이즈급 애플리케이션에서 필수입니다.
예를 들어, 프로젝트 관리 도구에서 /workspace/[id]는 전체 레이아웃을 정의하고, 그 안에서 /workspace/[id]/project/[pid]는 프로젝트별 레이아웃을 정의하며, 각각이 독립적인 슬롯을 가집니다. 기존에는 Context를 여러 겹 중첩하거나, 복잡한 상태 관리 라이브러리로 처리했다면, 이제는 파일 구조만으로 계층을 명확히 표현할 수 있습니다.
핵심은 각 layout.tsx가 자신의 슬롯만 관리하고, 상위 레이아웃의 슬롯과는 독립적이라는 것입니다. 이는 관심사의 분리와 코드 재사용성을 극대화합니다.
코드 예제
// app/workspace/layout.tsx
// 최상위 워크스페이스 레이아웃
export default function WorkspaceLayout({
children,
sidebar, // @sidebar 슬롯
panel, // @panel 슬롯
}: {
children: React.ReactNode
sidebar: React.ReactNode
panel: React.ReactNode
}) {
return (
<div className="workspace-layout">
<nav>{sidebar}</nav>
<main>{children}</main>
<aside>{panel}</aside>
</div>
)
}
// app/workspace/project/[id]/layout.tsx
// 프로젝트별 중첩 레이아웃 (추가 슬롯 정의)
export default function ProjectLayout({
children,
files, // @files 슬롯 (이 레벨에서만 유효)
preview, // @preview 슬롯
}: {
children: React.ReactNode
files: React.ReactNode
preview: React.ReactNode
}) {
return (
<div className="project-layout">
<div className="file-explorer">{files}</div>
<div className="editor">{children}</div>
<div className="preview-pane">{preview}</div>
</div>
)
}
// app/workspace/project/[id]/@files/page.tsx
// 프로젝트 레벨의 파일 목록 슬롯
export default async function FilesSlot({ params }: { params: { id: string } }) {
const files = await getProjectFiles(params.id)
return <FileTree files={files} />
}
설명
이것이 하는 일: 중첩 레이아웃과 Parallel Routes를 결합하여 계층적 UI 구조를 파일시스템에 매핑하고, 각 레벨의 관심사를 명확히 분리합니다. 첫 번째로, app/workspace/layout.tsx는 전역 워크스페이스 레벨의 레이아웃을 정의합니다.
여기서 정의된 @sidebar와 @panel 슬롯은 모든 하위 라우트에서 유지됩니다. 예를 들어, /workspace/project/123으로 이동해도 sidebar와 panel은 그대로 유지되고, children 부분만 바뀝니다.
이는 SPA의 영구적인 레이아웃과 유사하지만, 서버 사이드 렌더링의 이점을 유지합니다. 그 다음으로, app/workspace/project/[id]/layout.tsx는 추가적인 슬롯을 정의합니다.
중요한 점은 이 슬롯들(@files, @preview)이 상위 레이아웃의 슬롯과 완전히 독립적이라는 것입니다. 상위 레이아웃은 @files가 무엇인지 모르고, 하위 레이아웃은 @sidebar가 무엇인지 모릅니다.
각자의 책임 범위만 관리합니다. 세 번째로, 실제 렌더링 순서를 이해해야 합니다.
/workspace/project/123/file/abc.tsx로 이동하면, Next.js는 다음 순서로 컴포넌트를 조합합니다:
5. workspace/project/[id]/file/[fileId]/page.tsx가 최종 콘텐츠를 제공
실전 팁
💡 중첩 레벨이 3단계를 넘어가면 코드 네비게이션이 어려워집니다. 너무 깊은 중첩보다는 명확한 라우트 구조를 유지하세요.
💡 상위 레이아웃에서 하위 레이아웃으로 데이터를 전달하려면 React Context나 서버 컴포넌트의 props drilling을 사용하세요. 슬롯 간에는 직접 데이터를 주고받을 수 없습니다.
💡 각 레벨의 layout.tsx에 명확한 주석을 추가하세요. "이 레이아웃은 워크스페이스 전체에 적용됨" 같은 설명이 팀 협업에 큰 도움이 됩니다.
💡 개발자 도구의 React Components 탭에서 중첩 구조를 시각화하세요. 각 Layout과 Suspense 경계가 계층적으로 표시되어 디버깅에 유용합니다.
8. 동적 라우트와 Parallel Routes - 데이터 기반 슬롯 구성하기
시작하며
여러분이 전자상거래 사이트를 만드는데, 카테고리마다 다른 사이드바를 보여줘야 한다면 어떻게 하시겠어요? 의류 카테고리에서는 사이즈와 색상 필터, 전자제품에서는 브랜드와 가격 필터를 말이죠.
전통적으로는 카테고리 타입을 읽어서 거대한 switch문으로 다른 컴포넌트를 렌더링했을 겁니다. 새로운 카테고리가 추가될 때마다 코드 수정이 필요했죠.
동적 라우트와 Parallel Routes를 결합하면, 카테고리별로 독립적인 슬롯 파일을 만들어 확장 가능한 구조를 만들 수 있습니다.
개요
간단히 말해서, Parallel Routes의 슬롯은 동적 라우트 세그먼트([id])를 사용할 수 있으며, params를 받아 데이터 기반으로 다른 UI를 렌더링할 수 있습니다. 실무에서 이는 매우 강력한 패턴입니다.
예를 들어, /category/[slug] 라우트에서 @filters 슬롯이 slug에 따라 완전히 다른 필터 UI를 보여주거나, /user/[id] 라우트에서 @activity 슬롯이 사용자 타입에 따라 다른 활동 피드를 보여줄 수 있습니다. 기존에는 조건부 렌더링으로 처리했다면, 이제는 각 케이스를 독립적인 파일로 분리하고, 서버 컴포넌트에서 params를 읽어 적절한 데이터를 페칭합니다.
핵심은 슬롯의 page.tsx가 params를 props로 받을 수 있고, 이를 활용해 동적으로 데이터를 가져오고 UI를 구성한다는 것입니다. 각 슬롯은 독립적으로 데이터를 페칭하므로 waterfall이 발생하지 않습니다.
코드 예제
// app/category/[slug]/layout.tsx
// 카테고리별 레이아웃
export default function CategoryLayout({
children,
filters, // @filters 슬롯
products, // @products 슬롯
}: {
children: React.ReactNode
filters: React.ReactNode
products: React.ReactNode
}) {
return (
<div className="category-layout">
<aside className="filters-sidebar">{filters}</aside>
<main>
{children}
<div className="products-grid">{products}</div>
</main>
</div>
)
}
// app/category/[slug]/@filters/page.tsx
// 동적 slug에 따라 다른 필터 표시
async function getCategoryConfig(slug: string) {
// 카테고리별 설정 가져오기
const res = await fetch(`/api/categories/${slug}/config`)
return res.json()
}
export default async function FiltersSlot({
params
}: {
params: { slug: string }
}) {
const config = await getCategoryConfig(params.slug)
// 카테고리 타입에 따라 다른 필터 컴포넌트 렌더링
if (config.type === 'clothing') {
return <ClothingFilters options={config.filters} />
} else if (config.type === 'electronics') {
return <ElectronicsFilters options={config.filters} />
} else {
return <GenericFilters options={config.filters} />
}
}
// app/category/[slug]/@products/page.tsx
// 동적 제품 목록 (병렬 로딩)
export default async function ProductsSlot({ params }: { params: { slug: string } }) {
const products = await getProducts(params.slug)
return <ProductGrid products={products} />
}
설명
이것이 하는 일: 동적 라우트 세그먼트와 Parallel Routes를 결합하여 URL 파라미터 기반의 맞춤형 다중 패널 UI를 구현합니다. 첫 번째로, layout.tsx는 slug에 관계없이 항상 동일한 구조를 제공합니다.
하지만 각 슬롯의 내용은 params.slug에 따라 완전히 달라질 수 있습니다. 이는 "구조는 일정하되, 내용은 동적"이라는 견고한 UI 패턴을 만듭니다.
레이아웃 시프트 없이 콘텐츠만 교체되므로 부드러운 사용자 경험을 제공합니다. 그 다음으로, @filters/page.tsx가 params.slug를 받아 getCategoryConfig()를 호출합니다.
이는 서버 컴포넌트이므로 데이터베이스나 CMS에서 직접 설정을 가져올 수 있습니다. 예를 들어, 관리자가 CMS에서 새로운 카테고리를 추가하고 필터 타입을 설정하면, 코드 수정 없이 자동으로 반영됩니다.
세 번째로, 조건부 렌더링(if config.type === 'clothing')이 서버에서 실행됩니다. 클라이언트는 최종 HTML만 받으므로, ClothingFilters와 ElectronicsFilters의 코드를 모두 다운로드할 필요가 없습니다.
이는 번들 크기를 크게 줄이고, 각 카테고리별로 최적화된 컴포넌트를 제공할 수 있게 합니다. 마지막으로, @products/page.tsx는 @filters/page.tsx와 병렬로 데이터를 페칭합니다.
getCategoryConfig()와 getProducts()가 동시에 실행되므로, 필터 설정을 기다리는 동안 제품 목록도 로딩됩니다. 사용자는 두 섹션이 거의 동시에 나타나는 것을 경험하며, 총 로딩 시간이 단축됩니다.
여러분이 이 패턴을 사용하면 Amazon, eBay 같은 대규모 쇼핑몰의 카테고리 시스템을 구현할 수 있습니다. 새로운 카테고리를 추가할 때 코드 수정이 최소화되고, 각 카테고리의 UI와 데이터 페칭 로직이 독립적으로 관리되며, 성능도 최적화됩니다.
또한 A/B 테스팅도 쉬워집니다 - 특정 slug에 대해 다른 필터 UI를 실험하고, 전환율을 측정할 수 있습니다.
실전 팁
💡 params를 활용한 조건부 렌더링은 서버 컴포넌트에서만 하세요. 클라이언트에서 하면 모든 케이스의 코드가 번들에 포함됩니다.
💡 동적 슬롯의 loading.tsx는 params에 접근할 수 없으므로, 범용적인 스켈레톤 UI를 사용하세요. 카테고리별 맞춤 로딩 UI가 필요하면 Suspense를 슬롯 내부에 추가하세요.
💡 params 기반 데이터 페칭은 자동으로 캐시됩니다. next.revalidate 옵션으로 카테고리별로 다른 캐싱 전략을 설정할 수 있습니다.
💡 generateStaticParams()를 사용하면 빌드 타임에 주요 카테고리를 미리 렌더링할 수 있습니다. 동적이면서도 정적 생성의 이점을 누릴 수 있습니다.
9. 에러 경계와 슬롯별 에러 처리 - 견고한 애플리케이션 만들기
시작하며
여러분의 대시보드에서 통계 API가 다운되었을 때, 전체 페이지가 에러 화면으로 바뀌어버린다면 얼마나 답답할까요? 사용자는 다른 정상 작동하는 기능들도 사용할 수 없게 됩니다.
전통적인 에러 경계는 전체 컴포넌트 트리를 잡아버리므로, 부분적인 에러 처리가 어렵습니다. 하나의 섹션이 실패하면 전체가 무너지는 "all or nothing" 구조였죠.
Parallel Routes는 각 슬롯마다 독립적인 error.tsx를 가질 수 있어, 부분적인 에러 처리와 우아한 성능 저하(graceful degradation)를 자연스럽게 구현할 수 있습니다.
개요
간단히 말해서, 각 슬롯은 자체 error.tsx를 가질 수 있으며, 한 슬롯의 에러가 다른 슬롯에 영향을 주지 않습니다. 실무에서 이는 애플리케이션의 신뢰성을 크게 향상시킵니다.
예를 들어, SaaS 대시보드에서 외부 API를 호출하는 위젯이 실패해도, 내부 데이터베이스 기반의 다른 위젯들은 정상 작동합니다. 사용자는 일부 기능에 문제가 있다는 것을 알지만, 여전히 대부분의 기능을 사용할 수 있습니다.
기존에는 try-catch로 개별 컴포넌트를 감싸거나, 복잡한 에러 상태 관리를 했다면, 이제는 파일 구조만으로 에러 격리가 자동으로 이루어집니다. 핵심은 각 슬롯의 error.tsx가 해당 슬롯의 React Error Boundary로 작동하며, 에러가 발생하면 자동으로 error.tsx가 렌더링된다는 것입니다.
다른 슬롯은 영향을 받지 않습니다.
코드 예제
// app/dashboard/@analytics/error.tsx
// 분석 슬롯의 에러 처리
'use client'
import { useEffect } from 'react'
export default function AnalyticsError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// 에러 로깅 서비스로 전송
console.error('Analytics error:', error)
logErrorToService(error)
}, [error])
return (
<div className="error-card">
<h3>분석 데이터를 불러올 수 없습니다</h3>
<p>{error.message}</p>
<button onClick={reset}>다시 시도</button>
<p className="footnote">
다른 대시보드 기능은 정상 작동합니다
</p>
</div>
)
}
// app/dashboard/@team/error.tsx
// 팀 슬롯의 독립적인 에러 처리
'use client'
export default function TeamError({ error, reset }: { error: Error, reset: () => void }) {
return (
<div className="error-card">
<h3>팀 정보 로딩 실패</h3>
<details>
<summary>에러 상세</summary>
<pre>{error.stack}</pre>
</details>
<button onClick={reset}>재시도</button>
</div>
)
}
// app/dashboard/@analytics/page.tsx
// 에러를 던질 수 있는 서버 컴포넌트
export default async function AnalyticsSlot() {
const data = await fetchAnalytics() // 여기서 에러 발생 가능
if (!data) {
throw new Error('분석 데이터를 가져올 수 없습니다')
}
return <AnalyticsChart data={data} />
}
설명
이것이 하는 일: Parallel Routes의 자동 에러 격리를 활용하여 부분적 에러 처리와 resilient한 사용자 경험을 구현합니다. 첫 번째로, @analytics/page.tsx에서 에러가 발생하면, Next.js는 자동으로 가장 가까운 error.tsx를 찾습니다.
이 경우 @analytics/error.tsx가 매칭되고, 해당 슬롯만 에러 UI로 교체됩니다. 내부적으로 React Error Boundary가 각 슬롯을 감싸고 있기 때문입니다.
이는 명시적인 코드 없이도 자동으로 이루어집니다. 그 다음으로, error.tsx는 반드시 'use client'여야 합니다.
reset() 함수나 이벤트 핸들러 같은 클라이언트 동작이 필요하기 때문입니다. error와 reset을 props로 받으며, error 객체에는 에러 메시지, 스택 트레이스, 그리고 Next.js가 자동 생성하는 digest(에러 식별자)가 포함됩니다.
세 번째로, reset() 함수의 동작을 이해해야 합니다. 사용자가 "다시 시도" 버튼을 클릭하면, Next.js는 해당 슬롯만 재렌더링을 시도합니다.
전체 페이지를 새로고침하지 않으므로, 다른 슬롯의 상태는 유지됩니다. 만약 에러가 일시적(예: 네트워크 타임아웃)이었다면, 재시도로 성공할 수 있습니다.
마지막으로, 에러가 발생하지 않은 슬롯들(@team, @orders 등)은 완전히 정상적으로 작동합니다. 사용자는 "분석 데이터는 안 보이지만, 팀 정보와 주문 목록은 볼 수 있다"는 부분적인 기능 사용이 가능합니다.
이는 Netflix의 "일부 콘텐츠를 불러올 수 없습니다" 패턴과 유사한 UX입니다. 여러분이 이 패턴을 사용하면 외부 API 의존성이 많은 애플리케이션에서도 안정적인 서비스를 제공할 수 있습니다.
한 API가 다운되어도 전체 서비스는 계속 작동하고, 사용자는 대부분의 기능을 사용할 수 있습니다. 또한 각 슬롯의 에러를 독립적으로 모니터링하고, 어떤 서비스가 자주 실패하는지 추적할 수 있습니다.
에러 로깅 시 슬롯 이름을 태그로 추가하면 디버깅도 훨씬 쉬워집니다.
실전 팁
💡 error.tsx에서 에러를 로깅할 때 슬롯 이름을 컨텍스트로 추가하세요. Sentry나 LogRocket 같은 서비스에서 "어떤 슬롯이 자주 실패하는가"를 추적할 수 있습니다.
💡 reset() 함수는 일시적 에러(네트워크, 타임아웃)에는 유용하지만, 데이터 에러(잘못된 응답 형식)에는 무의미합니다. 에러 타입에 따라 "다시 시도" 버튼을 조건부로 표시하세요.
💡 프로덕션에서는 사용자에게 친절한 메시지를 보여주되, 개발 환경에서는 상세한 스택 트레이스를 표시하세요. process.env.NODE_ENV로 구분할 수 있습니다.
💡 전역 에러(예: 인증 실패)는 루트 레벨의 error.tsx에서 처리하고, 슬롯별 에러는 개별 error.tsx에서 처리하는 계층적 에러 전략을 사용하세요.
💡 error.digest는 서버 로그와 클라이언트 에러를 연결하는 데 유용합니다. 서버에서 에러가 발생하면 같은 digest가 로그에 기록되므로, 사용자가 보고한 에러를 쉽게 찾을 수 있습니다.
10. 실전 예제 - 소셜 미디어 피드와 모달 구현하기
시작하며
여러분이 Instagram이나 Twitter 같은 소셜 미디어를 만든다고 상상해보세요. 피드에서 게시물을 클릭하면 모달로 열리고, URL도 변경되며, 직접 링크로 공유할 수도 있어야 합니다.
이런 UX는 사용자에게는 자연스럽지만, 구현은 매우 복잡합니다. 모달 상태 관리, 브라우저 히스토리, 새로고침 처리, 공유 링크 등 수많은 엣지 케이스를 고려해야 합니다.
Parallel Routes와 Intercepting Routes를 조합하면 이 모든 것을 우아하게 해결하는 완벽한 소셜 미디어 패턴을 만들 수 있습니다.
개요
간단히 말해서, @modal 슬롯에 (.)post/[id] Intercepting Route를 배치하고, post/[id]에는 전체 페이지를 만들면, Instagram 스타일의 완벽한 UX가 완성됩니다. 실무에서 이 패턴은 소셜 미디어, 포트폴리오 사이트, 갤러리 앱 등에서 표준이 되었습니다.
사용자가 앱 내에서 탐색할 때는 빠른 모달 경험을, 직접 링크로 접근할 때는 완전한 페이지를 제공합니다. 기존에는 쿼리 파라미터(?modal=true)나 복잡한 상태 관리로 처리했다면, 이제는 세 개의 파일만으로 해결됩니다: layout.tsx, @modal/(.)post/[id]/page.tsx, post/[id]/page.tsx.
핵심은 네비게이션 컨텍스트를 Next.js가 자동으로 추적하고, 같은 URL을 상황에 따라 다르게 렌더링한다는 것입니다. 개발자는 복잡한 로직 없이 두 개의 UI 파일만 만들면 됩니다.
코드 예제
// app/layout.tsx
// 루트 레이아웃에 모달 슬롯 추가
export default function RootLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<html>
<body>
{children}
{modal}
</body>
</html>
)
}
// app/@modal/(.)post/[id]/page.tsx
// 피드에서 게시물 클릭 시 모달로 표시
'use client'
import { useRouter } from 'next/navigation'
import Modal from '@/components/Modal'
import PostContent from '@/components/PostContent'
export default function PostModal({ params }: { params: { id: string } }) {
const router = useRouter()
return (
<Modal
onClose={() => router.back()}
className="post-modal"
>
<PostContent postId={params.id} />
</Modal>
)
}
// app/post/[id]/page.tsx
// 직접 URL 접근 시 전체 페이지로 표시
import PostContent from '@/components/PostContent'
export default function PostPage({ params }: { params: { id: string } }) {
return (
<div className="post-page">
<header>
<nav>홈으로 돌아가기</nav>
</header>
<main>
<PostContent postId={params.id} />
</main>
<aside>
<RecommendedPosts />
</aside>
</div>
)
}
// app/@modal/default.tsx
export default function DefaultModal() {
return null
}
// components/PostContent.tsx
// 모달과 전체 페이지에서 재사용되는 컴포넌트
'use client'
export default function PostContent({ postId }: { postId: string }) {
const { data: post } = useSWR(`/api/posts/${postId}`)
if (!post) return <PostSkeleton />
return (
<article>
<img src={post.image} alt={post.title} />
<h1>{post.title}</h1>
<p>{post.content}</p>
<CommentSection postId={postId} />
</article>
)
}
설명
이것이 하는 일: Parallel Routes, Intercepting Routes, 그리고 컴포넌트 재사용을 결합하여 프로덕션급 소셜 미디어 피드를 구축합니다. 첫 번째로, 폴더 구조를 이해해야 합니다.
@modal/(.)post/[id]의 (.)는 "루트 레벨의 post를 가로채라"는 의미입니다. 사용자가 피드(/feed)에서 /post/123으로 Link를 클릭하면, Next.js는 먼저 @modal/(.)post/[id]를 찾고, 있다면 이를 모달로 렌더링합니다.
백그라운드의 /feed 페이지는 그대로 유지되고, 그 위에 모달이 오버레이로 떠서 부드러운 전환을 제공합니다. 그 다음으로, 사용자가 브라우저 주소창에 직접 /post/123을 입력하거나, 외부에서 공유된 링크를 클릭하면 어떻게 될까요?
이 경우에는 네비게이션 컨텍스트가 없으므로 Intercepting Route가 작동하지 않고, app/post/[id]/page.tsx가 렌더링됩니다. 이 페이지는 헤더, 사이드바, 추천 게시물 등 완전한 페이지 레이아웃을 제공합니다.
같은 URL이지만 컨텍스트에 따라 완전히 다른 UI가 표시되는 것입니다. 세 번째로, PostContent 컴포넌트를 재사용하는 것이 핵심입니다.
모달과 전체 페이지 모두 같은 PostContent를 사용하므로, 게시물 렌더링 로직, 댓글 시스템, 좋아요 기능 등이 중복되지 않습니다. useSWR 같은 데이터 페칭 라이브러리를 사용하면 두 경로 모두에서 캐싱이 공유되어 효율적입니다.
마지막으로, router.back()의 동작을 살펴봅시다. 사용자가 피드에서 게시물을 클릭하면 히스토리 스택에 /post/123이 추가됩니다.
모달을 닫으면 router.back()이 히스토리를 pop하고, 원래 페이지(/feed)로 돌아갑니다. 브라우저의 뒤로가기 버튼도 똑같이 작동하며, 모달 애니메이션과도 자연스럽게 통합됩니다.
여러분이 이 패턴을 사용하면 세계적인 소셜 미디어 플랫폼과 동일한 수준의 UX를 제공할 수 있습니다. 빠른 인터랙션, 공유 가능한 링크, SEO 최적화, 그리고 깔끔한 코드 구조를 모두 얻을 수 있습니다.
또한 PostContent를 재사용하므로 버그 수정이나 기능 추가 시 한 곳만 수정하면 모달과 전체 페이지에 동시에 반영됩니다. 이는 장기적인 유지보수 비용을 크게 절감합니다.
실전 팁
💡 Modal 컴포넌트는 접근성을 반드시 고려하세요. dialog 태그, aria-modal="true", 포커스 트랩, ESC 키 핸들링은 필수입니다. Radix UI나 Headless UI 같은 라이브러리를 추천합니다.
💡 모달 애니메이션을 추가하려면 Framer Motion의 AnimatePresence를 사용하세요. modal prop이 변경될 때 자동으로 애니메이션이 적용됩니다.
💡 모달이 떠있을 때 body 스크롤을 막으려면 useEffect로 document.body.style.overflow = 'hidden'을 설정하세요. 클린업에서 'auto'로 되돌리는 것을 잊지 마세요.
💡 깊은 링크를 지원하려면 모달 안에서도 <Link>를 사용하세요. 예를 들어, 모달 안에서 사용자 프로필을 클릭하면 /user/[id] 모달로 전환되는 식입니다.
💡 프로덕션에서는 모달 전환을 추적하세요. Google Analytics나 Mixpanel에 "modal_opened" 이벤트를 전송하면 어떤 게시물이 인기 있는지 알 수 있습니다.
댓글 (0)
함께 보면 좋은 카드 뉴스
마이크로서비스 배포 완벽 가이드
Kubernetes를 활용한 마이크로서비스 배포의 핵심 개념부터 실전 운영까지, 초급 개발자도 쉽게 따라할 수 있는 완벽 가이드입니다. 실무에서 바로 적용 가능한 배포 전략과 노하우를 담았습니다.
Application Load Balancer 완벽 가이드
AWS의 Application Load Balancer를 처음 배우는 개발자를 위한 실전 가이드입니다. ALB 생성부터 ECS 연동, 헬스 체크, HTTPS 설정까지 실무에 필요한 모든 내용을 다룹니다. 초급 개발자도 쉽게 따라할 수 있도록 단계별로 설명합니다.
고객 상담 AI 시스템 완벽 구축 가이드
AWS Bedrock Agent와 Knowledge Base를 활용하여 실시간 고객 상담 AI 시스템을 구축하는 방법을 단계별로 학습합니다. RAG 기반 지식 검색부터 Guardrails 안전 장치, 프론트엔드 연동까지 실무에 바로 적용 가능한 완전한 시스템을 만들어봅니다.
에러 처리와 폴백 완벽 가이드
AWS API 호출 시 발생하는 에러를 처리하고 폴백 전략을 구현하는 방법을 다룹니다. ThrottlingException부터 서킷 브레이커 패턴까지, 실전에서 바로 활용할 수 있는 안정적인 에러 처리 기법을 배웁니다.
AWS Bedrock 인용과 출처 표시 완벽 가이드
AWS Bedrock의 Citation 기능을 활용하여 AI 응답의 신뢰도를 높이는 방법을 배웁니다. 출처 추출부터 UI 표시, 검증까지 실무에서 바로 사용할 수 있는 완전한 가이드입니다.