(.) - 같은 레벨 인터셉팅
(..) - 한 단계 위 인터셉팅
(..)(..) - 두 단계 위 인터셉팅
(...) - 루트부터 인터셉팅
app/
├── feed/
│ ├── page.tsx # /feed
│ └── (..)photo/
│ └── [id]/
│ └── page.tsx # 모달
└── photo/
└── [id]/
└── page.tsx # 전체 페이지
// app/products/page.tsx - 상품 목록
import Link from 'next/link'
export default function ProductsPage() {
const products = [
{ id: 1, name: '노트북', price: 1500000, image: '/products/1.jpg' },
{ id: 2, name: '마우스', price: 50000, image: '/products/2.jpg' },
{ id: 3, name: '키보드', price: 120000, image: '/products/3.jpg' },
]
return (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-8">상품 목록</h1>
<div className="grid grid-cols-3 gap-6">
{products.map(product => (
<Link
key={product.id}
href={`/products/${product.id}`}
className="border rounded-lg overflow-hidden hover:shadow-xl transition"
>
<img
src={product.image}
alt={product.name}
className="w-full h-48 object-cover"
/>
<div className="p-4">
<h2 className="font-semibold text-lg">{product.name}</h2>
<p className="text-blue-600 font-bold">
{product.price.toLocaleString()}원
</p>
</div>
</Link>
))}
</div>
</div>
)
}
// components/ProductDetail.tsx - 공통 컴포넌트
export default async function ProductDetail({ id }) {
const product = await fetch(`/api/products/${id}`).then(r => r.json())
return (
<div className="space-y-6">
<div className="grid grid-cols-2 gap-6">
<img
src={product.image}
alt={product.name}
className="w-full rounded-lg"
/>
<div className="space-y-4">
<h1 className="text-2xl font-bold">{product.name}</h1>
<p className="text-3xl font-bold text-blue-600">
{product.price.toLocaleString()}원
</p>
<p className="text-gray-700">{product.description}</p>
<div className="space-y-2">
<button className="w-full bg-blue-500 text-white py-3 rounded-lg hover:bg-blue-600">
장바구니 담기
</button>
<button className="w-full border-2 border-blue-500 text-blue-500 py-3 rounded-lg hover:bg-blue-50">
바로 구매
</button>
</div>
</div>
</div>
{/* 리뷰 섹션 */}
<div className="border-t pt-6">
<h2 className="text-xl font-bold mb-4">리뷰 ({product.reviews.length})</h2>
<div className="space-y-4">
{product.reviews.slice(0, 3).map(review => (
<div key={review.id} className="border-b pb-4">
<div className="flex items-center gap-2 mb-2">
<div className="text-yellow-500">{'★'.repeat(review.rating)}</div>
<span className="text-sm text-gray-600">{review.author}</span>
</div>
<p className="text-gray-700">{review.content}</p>
</div>
))}
</div>
</div>
</div>
)
}
// app/products/(.).[id]/page.tsx - 모달 (인터셉팅)
'use client'
import { useRouter } from 'next/navigation'
import ProductDetail from '@/components/ProductDetail'
import Link from 'next/link'
export default function ProductModal({ params }) {
const router = useRouter()
return (
<>
{/* 배경 오버레이 */}
<div
className="fixed inset-0 bg-black/60 z-50"
onClick={() => router.back()}
/>
{/* 모달 컨테이너 */}
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<div
className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 모달 헤더 */}
<div className="sticky top-0 bg-white border-b px-6 py-4 flex justify-between items-center">
<h2 className="text-lg font-semibold">상품 상세</h2>
<div className="flex gap-3">
<Link
href={`/products/${params.id}`}
className="text-sm text-blue-500 hover:underline"
>
전체 페이지로 보기 →
</Link>
<button
onClick={() => router.back()}
className="text-2xl text-gray-400 hover:text-gray-600"
>
×
</button>
</div>
</div>
{/* 모달 본문 */}
<div className="p-6">
<ProductDetail id={params.id} />
</div>
</div>
</div>
</div>
</>
)
}
// app/products/[id]/page.tsx - 전체 페이지
import ProductDetail from '@/components/ProductDetail'
import Link from 'next/link'
export default function ProductPage({ params }) {
return (
<div className="container mx-auto p-6 max-w-6xl">
<nav className="mb-6">
<Link href="/products" className="text-blue-500 hover:underline">
← 상품 목록으로
</Link>
</nav>
<ProductDetail id={params.id} />
{/* 전체 페이지만의 추가 정보 */}
<div className="mt-12 space-y-12">
<section>
<h2 className="text-2xl font-bold mb-6">상세 정보</h2>
<div className="prose max-w-none">
{/* 상세 설명 */}
</div>
</section>
<section>
<h2 className="text-2xl font-bold mb-6">배송 정보</h2>
{/* 배송 정보 */}
</section>
<section>
<h2 className="text-2xl font-bold mb-6">추천 상품</h2>
{/* 추천 상품 */}
</section>
</div>
</div>
)
}
// ❌ index를 key로 사용
function BadList({ items }) {
return (
<ul>
{items.map((item, index) => (
<li key={index}>{item.name}</li>
))}
</ul>
)
}
// 문제 상황:
// 초기: ['A', 'B', 'C']
// 맨 앞에 'D' 추가: ['D', 'A', 'B', 'C']
// React는 모든 항목을 업데이트함!
// index 0: A → D (업데이트)
// index 1: B → A (업데이트)
// index 2: C → B (업데이트)
// index 3: 없음 → C (생성)
// ✅ 고유한 ID를 key로 사용
function GoodList({ items }) {
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
)
}
// 동작:
// 초기: [{ id: 'a', name: 'A' }, { id: 'b', name: 'B' }]
// 'D' 추가: [{ id: 'd', name: 'D' }, { id: 'a', name: 'A' }]
// React는 'd'만 새로 생성하고 나머지는 재사용!
'use client'
import { useState } from 'react'
export default function SortableList() {
const [items, setItems] = useState([
{ id: '1', name: '사과', price: 2000 },
{ id: '2', name: '바나나', price: 3000 },
{ id: '3', name: '오렌지', price: 2500 },
])
const [sortBy, setSortBy] = useState('name')
const sortedItems = [...items].sort((a, b) => {
if (sortBy === 'name') {
return a.name.localeCompare(b.name)
}
return a.price - b.price
})
return (
<div className="p-6">
<div className="mb-4 space-x-2">
<button
onClick={() => setSortBy('name')}
className={`px-4 py-2 rounded ${
sortBy === 'name' ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
이름순
</button>
<button
onClick={() => setSortBy('price')}
className={`px-4 py-2 rounded ${
sortBy === 'price' ? 'bg-blue-500 text-white' : 'bg-gray-200'
}`}
>
가격순
</button>
</div>
<ul className="space-y-2">
{sortedItems.map(item => (
<li
key={item.id} // ✅ 고유한 ID 사용
className="border p-4 rounded-lg flex justify-between"
>
<span>{item.name}</span>
<span className="font-bold">{item.price}원</span>
</li>
))}
</ul>
</div>
)
}
// ✅ 좋은 Key
- 데이터베이스 ID
- UUID
- 안정적인 고유 식별자
// ⚠️ 주의해서 사용
- 배열 인덱스 (항목이 추가/삭제/재정렬되지 않을 때만)
// ❌ 절대 사용 금지
- Math.random()
- Date.now()
- crypto.randomUUID() (매 렌더링마다 새로 생성)
app/
├── dashboard/
│ ├── page.tsx
│ └── (..)settings/
│ ├── page.tsx # 설정 모달
│ └── (..)profile/
│ └── page.tsx # 프로필 모달 (모달 위의 모달)
app/
├── shop/
│ ├── layout.tsx
│ ├── @modal/
│ │ └── (..)product/
│ │ └── [id]/
│ │ └── page.tsx
│ └── products/
│ └── page.tsx
// app/shop/layout.tsx
export default function ShopLayout({ children, modal }) {
return (
<>
{children}
{modal}
</>
)
}
'use client'
import { useSearchParams, useRouter } from 'next/navigation'
export default function ConditionalModal() {
const searchParams = useSearchParams()
const router = useRouter()
const showModal = searchParams.get('modal') === 'true'
if (!showModal) return null
return (
<div className="fixed inset-0 bg-black/50 z-50">
<div className="bg-white p-6 rounded-lg">
<button onClick={() => router.back()}>닫기</button>
<h2>조건부 모달</h2>
</div>
</div>
)
}
app/
├── (marketing)/ # URL에 포함 안됨
│ ├── about/
│ │ └── page.tsx # /about
│ └── contact/
│ └── page.tsx # /contact
├── (shop)/
│ ├── products/
│ │ └── page.tsx # /products
│ └── cart/
│ └── page.tsx # /cart
└── (dashboard)/
└── analytics/
└── page.tsx # /analytics
특징:
() 안의 이름은 URL에 포함되지 않음app/
├── (auth)/
│ ├── layout.tsx # 인증 레이아웃
│ ├── login/
│ │ └── page.tsx # /login
│ └── signup/
│ └── page.tsx # /signup
├── (dashboard)/
│ ├── layout.tsx # 대시보드 레이아웃
│ ├── page.tsx # /dashboard
│ └── analytics/
│ └── page.tsx # /analytics
└── (marketing)/
├── layout.tsx # 마케팅 레이아웃
├── page.tsx # /
├── about/
│ └── page.tsx # /about
└── pricing/
└── page.tsx # /pricing
// app/(auth)/layout.tsx - 인증 페이지 레이아웃
export default function AuthLayout({ children }) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-500 to-purple-600">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="bg-white rounded-lg shadow-xl p-8 w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold">My App</h1>
</div>
{children}
</div>
</div>
</div>
)
}
// app/(dashboard)/layout.tsx - 대시보드 레이아웃
export default function DashboardLayout({ children }) {
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
<div className="container mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<h1 className="text-xl font-bold">Dashboard</h1>
<div className="flex gap-4">
<a href="/analytics">Analytics</a>
<a href="/settings">Settings</a>
</div>
</div>
</div>
</nav>
<main className="container mx-auto p-6">
{children}
</main>
</div>
)
}
// app/(marketing)/layout.tsx - 마케팅 레이아웃
export default function MarketingLayout({ children }) {
return (
<div>
<header className="bg-white shadow">
<nav className="container mx-auto px-6 py-4">
<div className="flex justify-between items-center">
<h1 className="text-xl font-bold">My App</h1>
<div className="flex gap-6">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/pricing">Pricing</a>
<a href="/login">Login</a>
</div>
</div>
</nav>
</header>
<main>{children}</main>
<footer className="bg-gray-800 text-white py-8 mt-12">
<div className="container mx-auto px-6 text-center">
© 2024 My App. All rights reserved.
</div>
</footer>
</div>
)
}
app/
├── (shop)/
│ ├── layout.tsx
│ ├── @modal/
│ │ └── default.tsx
│ └── products/
│ ├── page.tsx
│ └── (.).[id]/
│ └── page.tsx
// ❌ 얕은 비교로는 감지 못함
const MemoComponent = React.memo(function Component({ user, onClick }) {
return (
<div onClick={onClick}>
{user.name}
</div>
)
})
function Parent() {
const [count, setCount] = useState(0)
// 문제: 매번 새로운 객체/함수 생성
const user = { name: 'John', age: 30 }
const handleClick = () => console.log('clicked')
return (
<>
<button onClick={() => setCount(count + 1)}>{count}</button>
<MemoComponent user={user} onClick={handleClick} />
</>
)
}
// ✅ 해결: 커스텀 비교 함수
const MemoComponent = React.memo(
function Component({ user, onClick }) {
return <div onClick={onClick}>{user.name}</div>
},
(prevProps, nextProps) => {
// true: 리렌더링 스킵
// false: 리렌더링
return prevProps.user.id === nextProps.user.id
}
)
// ❌ 모든 것이 함께 리렌더링
function BadComponent() {
const [input, setInput] = useState('')
const [data] = useState(expensiveData)
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<ExpensiveList data={data} /> {/* input 변경시마다 리렌더링! */}
</div>
)
}
// ✅ 컴포넌트 분리
function GoodComponent() {
const [data] = useState(expensiveData)
return (
<div>
<SearchInput />
<ExpensiveList data={data} /> {/* 독립적 */}
</div>
)
}
function SearchInput() {
const [input, setInput] = useState('')
return <input value={input} onChange={e => setInput(e.target.value)} />
}
// ❌ 상태 변경시 children도 리렌더링
function Wrapper({ children }) {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
{children}
</div>
)
}
// 사용
<Wrapper>
<ExpensiveComponent /> {/* count 변경시 리렌더링! */}
</Wrapper>
// ✅ children은 부모에서 생성되므로 리렌더링 안됨
function Parent() {
return (
<Wrapper>
<ExpensiveComponent /> {/* 리렌더링 안됨! */}
</Wrapper>
)
}
// useMemo: 값 메모이제이션
function Component({ items }) {
const total = useMemo(() => {
return items.reduce((sum, item) => sum + item.price, 0)
}, [items])
return <div>{total}</div>
}
// React.memo: 컴포넌트 메모이제이션
const MemoizedComponent = React.memo(Component)
// 선택 기준:
// - 값 재사용 → useMemo
// - 컴포넌트 리렌더링 방지 → React.memo
app/
├── (auth)/ # 인증 관련
│ ├── login/
│ ├── signup/
│ └── forgot-password/
│
├── (marketing)/ # 마케팅 페이지
│ ├── page.tsx # 홈
│ ├── about/
│ ├── pricing/
│ └── contact/
│
├── (app)/ # 메인 앱
│ ├── dashboard/
│ ├── profile/
│ └── settings/
│
├── api/ # API 라우트
│ ├── auth/
│ ├── users/
│ └── products/
│
└── (admin)/ # 관리자
├── users/
├── orders/
└── analytics/
// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
console.log('Middleware 실행:', request.nextUrl.pathname)
// 요청 계속 진행
return NextResponse.next()
}
// 특정 경로에만 적용
export const config = {
matcher: [
'/dashboard/:path*',
'/api/:path*',
]
}
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value
const { pathname } = request.nextUrl
// 보호된 경로
const protectedPaths = ['/dashboard', '/profile', '/settings']
const isProtectedPath = protectedPaths.some(path =>
pathname.startsWith(path)
)
// 인증 필요한 경로인데 토큰 없음
if (isProtectedPath && !token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('from', pathname)
return NextResponse.redirect(loginUrl)
}
// 이미 로그인했는데 로그인 페이지 접근
if (pathname === '/login' && token) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: [
'/dashboard/:path*',
'/profile/:path*',
'/settings/:path*',
'/login',
]
}
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { verifyToken } from './lib/auth'
export async function middleware(request: NextRequest) {
const token = request.cookies.get('token')?.value
const { pathname } = request.nextUrl
// 토큰 검증
if (!token) {
return NextResponse.redirect(new URL('/login', request.url))
}
try {
const user = await verifyToken(token)
// 관리자 전용 경로
if (pathname.startsWith('/admin') && user.role !== 'admin') {
return NextResponse.redirect(new URL('/unauthorized', request.url))
}
// 헤더에 사용자 정보 추가 (서버 컴포넌트에서 사용)
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-user-id', user.id)
requestHeaders.set('x-user-role', user.role)
return NextResponse.next({
request: {
headers: requestHeaders,
}
})
} catch (error) {
return NextResponse.redirect(new URL('/login', request.url))
}
}
export const config = {
matcher: ['/dashboard/:path*', '/admin/:path*']
}
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const ALLOWED_IPS = ['127.0.0.1', '192.168.1.1']
const BLOCKED_IPS = ['1.2.3.4']
export function middleware(request: NextRequest) {
const ip = request.ip || request.headers.get('x-forwarded-for')
// IP 차단
if (BLOCKED_IPS.includes(ip)) {
return new NextResponse('Blocked', { status: 403 })
}
// 관리자 페이지는 특정 IP만
if (request.nextUrl.pathname.startsWith('/admin')) {
if (!ALLOWED_IPS.includes(ip)) {
return new NextResponse('Unauthorized', { status: 401 })
}
}
return NextResponse.next()
}
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 쿠키 확인
let bucket = request.cookies.get('bucket')?.value
// 쿠키 없으면 랜덤 할당
if (!bucket) {
bucket = Math.random() < 0.5 ? 'A' : 'B'
}
// 버전별 라우팅
const url = request.nextUrl.clone()
url.pathname = `/experiments/${bucket}${url.pathname}`
const response = NextResponse.rewrite(url)
// 쿠키 설정
response.cookies.set('bucket', bucket)
return response
}
export const config = {
matcher: '/landing-page'
}
// app/layout.tsx
export default function RootLayout({ children }) {
return (
<html>
<body>
{process.env.NODE_ENV === 'development' ? (
<React.StrictMode>
{children}
</React.StrictMode>
) : (
children
)}
</body>
</html>
)
}
1. 예상치 못한 부작용
// ❌ StrictMode가 경고
function Component() {
// 렌더링 중 외부 상태 변경
someGlobalVariable = 'changed'
return <div>Component</div>
}
// ✅ 올바른 방법
function Component() {
useEffect(() => {
someGlobalVariable = 'changed'
}, [])
return <div>Component</div>
}
2. 레거시 생명주기
// ❌ 더 이상 사용 안 함
class Component extends React.Component {
componentWillMount() {
// 경고 발생!
}
componentWillReceiveProps() {
// 경고 발생!
}
}
// ✅ 권장
function Component() {
useEffect(() => {
// 초기화 로직
}, [])
}
3. 이중 렌더링
// StrictMode에서는 컴포넌트가 2번 렌더링됨
function Component() {
console.log('렌더링!') // 개발 모드: 2번 출력
return <div>Component</div>
}
// 이유: 순수성 체크
// 프로덕션에서는 1번만 실행됨
// app/dashboard/page.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifyToken } from '@/lib/auth'
export default async function DashboardPage() {
const cookieStore = cookies()
const token = cookieStore.get('token')?.value
if (!token) {
redirect('/login')
}
try {
const user = await verifyToken(token)
return (
<div>
<h1>환영합니다, {user.name}님!</h1>
</div>
)
} catch (error) {
redirect('/login')
}
}
// components/withAuth.tsx
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { verifyToken } from '@/lib/auth'
export function withAuth(Component, options = {}) {
return async function AuthenticatedComponent(props) {
const cookieStore = cookies()
const token = cookieStore.get('token')?.value
if (!token) {
redirect('/login')
}
const user = await verifyToken(token)
// 권한 체크
if (options.requiredRole && user.role !== options.requiredRole) {
redirect('/unauthorized')
}
return <Component {...props} user={user} />
}
}
// 사용
export default withAuth(DashboardPage, { requiredRole: 'admin' })
// components/RoleGuard.tsx
import { cookies } from 'next/headers'
import { verifyToken } from '@/lib/auth'
export async function RoleGuard({
children,
allowedRoles,
fallback = null
}) {
const cookieStore = cookies()
const token = cookieStore.get('token')?.value
if (!token) return fallback
try {
const user = await verifyToken(token)
if (!allowedRoles.includes(user.role)) {
return fallback
}
return children
} catch {
return fallback
}
}
// 사용
export default function Page() {
return (
<div>
<h1>대시보드</h1>
<RoleGuard allowedRoles={['admin']}>
<AdminPanel />
</RoleGuard>
<RoleGuard
allowedRoles={['admin', 'editor']}
fallback={<p>권한이 없습니다</p>}
>
<EditorPanel />
</RoleGuard>
</div>
)
}
10-12일차 완료! 🚀