- Authors

- Name
- Youngju Kim
- @fjvbn20031
목차
- TypeScript 타입 시스템 심화
- 유틸리티 타입 완전 정복
- 타입 가드와 타입 좁히기
- Next.js 15 App Router
- Server Components vs Client Components
- Server Actions
- 데이터 페칭 전략
- 미들웨어
- 배포 전략
- 성능 최적화
1. TypeScript 타입 시스템 심화
TypeScript는 단순히 JavaScript에 타입을 추가한 것을 넘어, 강력한 타입 레벨 프로그래밍 언어입니다. 이 장에서는 실무에서 자주 쓰이는 고급 타입 패턴을 다룹니다.
1.1 Union 타입과 Intersection 타입
Union 타입은 여러 타입 중 하나를 나타내며, Intersection 타입은 여러 타입을 모두 만족하는 타입입니다.
// Union 타입: 여러 타입 중 하나
type Status = 'idle' | 'loading' | 'success' | 'error'
type ApiResponse =
| { status: 'success'; data: unknown }
| { status: 'error'; message: string }
// Intersection 타입: 여러 타입을 결합
type Timestamped = { createdAt: Date; updatedAt: Date }
type SoftDeletable = { deletedAt: Date | null }
type BaseEntity = Timestamped & SoftDeletable
interface User extends BaseEntity {
id: string
name: string
email: string
}
Discriminated Union은 공통 필드(판별자)를 이용해 타입을 구분하는 패턴으로, 복잡한 상태 관리에 매우 유용합니다.
// Discriminated Union 패턴
type Action =
| { type: 'SET_USER'; payload: User }
| { type: 'SET_ERROR'; payload: string }
| { type: 'RESET' }
function reducer(state: State, action: Action): State {
switch (action.type) {
case 'SET_USER':
// action.payload는 자동으로 User 타입
return { ...state, user: action.payload, error: null }
case 'SET_ERROR':
// action.payload는 자동으로 string 타입
return { ...state, error: action.payload }
case 'RESET':
return initialState
}
}
1.2 제네릭 (Generics)
제네릭은 타입을 매개변수화하여 재사용 가능한 컴포넌트를 만드는 핵심 도구입니다.
// 기본 제네릭 함수
function identity<T>(value: T): T {
return value
}
// 제네릭 제약 조건
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key]
}
// 제네릭 인터페이스
interface Repository<T extends { id: string }> {
findById(id: string): Promise<T | null>
findAll(): Promise<T[]>
create(data: Omit<T, 'id'>): Promise<T>
update(id: string, data: Partial<T>): Promise<T>
delete(id: string): Promise<void>
}
// 제네릭 클래스
class ApiClient<TResponse> {
constructor(private baseUrl: string) {}
async get(path: string): Promise<TResponse> {
const response = await fetch(`${this.baseUrl}${path}`)
return response.json() as Promise<TResponse>
}
}
1.3 조건부 타입 (Conditional Types)
조건부 타입은 입력 타입에 따라 출력 타입이 결정되는 강력한 패턴입니다.
// 기본 조건부 타입
type IsString<T> = T extends string ? true : false
type A = IsString<string> // true
type B = IsString<number> // false
// infer 키워드로 타입 추출
type UnpackPromise<T> = T extends Promise<infer U> ? U : T
type Resolved = UnpackPromise<Promise<string>> // string
// 배열 요소 타입 추출
type ElementOf<T> = T extends (infer E)[] ? E : never
type Item = ElementOf<string[]> // string
// 함수 반환 타입 추출
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never
1.4 매핑된 타입 (Mapped Types)
기존 타입을 변환하여 새로운 타입을 만듭니다.
// 모든 속성을 읽기 전용으로
type Readonly<T> = {
readonly [P in keyof T]: T[P]
}
// 모든 속성을 선택적으로
type MyPartial<T> = {
[P in keyof T]?: T[P]
}
// 특정 키만 선택
type MyPick<T, K extends keyof T> = {
[P in K]: T[P]
}
// 실전 예제: Form 상태 타입 자동 생성
type FormFields<T> = {
[K in keyof T]: {
value: T[K]
error: string | null
touched: boolean
}
}
interface LoginData {
email: string
password: string
}
type LoginForm = FormFields<LoginData>
// 결과:
// {
// email: { value: string; error: string | null; touched: boolean }
// password: { value: string; error: string | null; touched: boolean }
// }
1.5 템플릿 리터럴 타입 (Template Literal Types)
문자열 리터럴 타입을 조합하여 새로운 문자열 타입을 생성합니다.
// 기본 템플릿 리터럴 타입
type EventName = `on${Capitalize<string>}`
// 구체적인 조합 생성
type Color = 'red' | 'blue' | 'green'
type Size = 'sm' | 'md' | 'lg'
type ClassName = `${Color}-${Size}`
// 'red-sm' | 'red-md' | 'red-lg' | 'blue-sm' | ...
// CSS 속성 자동 생성
type CSSProperty = 'margin' | 'padding'
type Direction = 'top' | 'right' | 'bottom' | 'left'
type SpacingProp = `${CSSProperty}-${Direction}`
// 'margin-top' | 'margin-right' | ... | 'padding-left'
// API 경로 타입 안전성
type ApiRoutes = `/api/${'users' | 'posts' | 'comments'}`
type ApiRouteWithId = `${ApiRoutes}/${string}`
2. 유틸리티 타입 완전 정복
TypeScript는 자주 쓰이는 타입 변환을 위한 내장 유틸리티 타입을 제공합니다.
2.1 기본 유틸리티 타입
interface User {
id: string
name: string
email: string
role: 'admin' | 'user'
createdAt: Date
}
// Partial: 모든 속성을 선택적으로
type UpdateUserDto = Partial<User>
// Required: 모든 속성을 필수로
type StrictUser = Required<User>
// Pick: 특정 속성만 선택
type UserPreview = Pick<User, 'id' | 'name'>
// Omit: 특정 속성 제외
type CreateUserDto = Omit<User, 'id' | 'createdAt'>
// Record: 키-값 쌍의 타입
type UserMap = Record<string, User>
type RolePermissions = Record<User['role'], string[]>
2.2 고급 유틸리티 타입
// ReturnType: 함수 반환 타입 추출
function createUser(name: string, email: string) {
return { id: crypto.randomUUID(), name, email, createdAt: new Date() }
}
type CreatedUser = ReturnType<typeof createUser>
// Parameters: 함수 매개변수 타입 추출
type CreateUserParams = Parameters<typeof createUser>
// [name: string, email: string]
// Awaited: Promise 언래핑
async function fetchUser(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
type FetchedUser = Awaited<ReturnType<typeof fetchUser>>
// User
// Extract & Exclude
type Status = 'active' | 'inactive' | 'pending' | 'banned'
type ActiveStatus = Extract<Status, 'active' | 'pending'>
// 'active' | 'pending'
type InactiveStatus = Exclude<Status, 'active' | 'pending'>
// 'inactive' | 'banned'
// NonNullable
type MaybeUser = User | null | undefined
type DefiniteUser = NonNullable<MaybeUser>
// User
2.3 커스텀 유틸리티 타입 만들기
// DeepPartial: 중첩 객체까지 모두 선택적으로
type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
// DeepReadonly: 중첩 객체까지 모두 읽기 전용
type DeepReadonly<T> = {
readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P]
}
// RequiredKeys: 특정 키만 필수로
type RequiredKeys<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
type UserWithRequiredEmail = RequiredKeys<Partial<User>, 'email'>
// Prettify: 타입을 펼쳐서 읽기 쉽게
type Prettify<T> = {
[K in keyof T]: T[K]
} & {}
3. 타입 가드와 타입 좁히기
런타임에서 타입을 안전하게 좁히는 방법입니다.
3.1 typeof 가드
function formatValue(value: string | number | boolean): string {
if (typeof value === 'string') {
return value.toUpperCase()
}
if (typeof value === 'number') {
return value.toFixed(2)
}
return value ? 'Yes' : 'No'
}
3.2 instanceof 가드
class ApiError extends Error {
constructor(
message: string,
public statusCode: number
) {
super(message)
}
}
class ValidationError extends Error {
constructor(
message: string,
public fields: Record<string, string>
) {
super(message)
}
}
function handleError(error: unknown): string {
if (error instanceof ApiError) {
return `API Error ${error.statusCode}: ${error.message}`
}
if (error instanceof ValidationError) {
return `Validation Error: ${Object.values(error.fields).join(', ')}`
}
if (error instanceof Error) {
return error.message
}
return 'Unknown error'
}
3.3 in 연산자 가드
interface Dog {
bark(): void
breed: string
}
interface Cat {
meow(): void
color: string
}
function makeSound(animal: Dog | Cat): void {
if ('bark' in animal) {
animal.bark()
} else {
animal.meow()
}
}
3.4 사용자 정의 타입 가드 (is 키워드)
// 커스텀 타입 가드
function isUser(value: unknown): value is User {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
'name' in value &&
'email' in value
)
}
// 배열 필터링에서 타입 가드 활용
const items: (User | null)[] = [user1, null, user2, null]
const users: User[] = items.filter((item): item is User => item !== null)
3.5 satisfies 연산자
TypeScript 4.9에서 도입된 satisfies는 타입 체크와 타입 추론을 동시에 지원합니다.
type Route = {
path: string
element: React.ReactNode
}
// satisfies를 사용하면 타입 체크를 하면서도 리터럴 타입을 유지
const routes = {
home: { path: '/', element: '<Home />' },
about: { path: '/about', element: '<About />' },
blog: { path: '/blog', element: '<Blog />' },
} satisfies Record<string, Route>
// routes.home.path의 타입은 '/' (리터럴)
// 만약 `as const`를 쓰면 readonly가 되어 수정이 불가
type ColorConfig = Record<string, string | string[]>
const palette = {
primary: '#007bff',
secondary: ['#6c757d', '#adb5bd'],
danger: '#dc3545',
} satisfies ColorConfig
// palette.primary는 string 타입
// palette.secondary는 string[] 타입 (배열 메서드 사용 가능)
4. Next.js 15 App Router
Next.js 15의 App Router는 React Server Components를 기반으로 한 새로운 라우팅 시스템입니다.
4.1 app 디렉토리 구조
App Router는 파일 시스템 기반 라우팅을 사용합니다. 각 폴더는 URL 세그먼트에 대응하며, 특수한 파일 이름이 각각의 역할을 담당합니다.
app/
layout.tsx # 루트 레이아웃 (필수)
page.tsx # 홈페이지 (/)
loading.tsx # 로딩 UI
error.tsx # 에러 UI
not-found.tsx # 404 UI
global-error.tsx # 전역 에러 UI
blog/
page.tsx # /blog
[slug]/
page.tsx # /blog/some-post
loading.tsx # 이 라우트의 로딩
dashboard/
layout.tsx # 대시보드 레이아웃
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
(marketing)/ # 라우트 그룹 (URL에 영향 없음)
about/
page.tsx # /about
contact/
page.tsx # /contact
api/
users/
route.ts # API 라우트
4.2 Layout과 Page
// app/layout.tsx - 루트 레이아웃
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App',
},
description: 'A modern full-stack application',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="ko">
<body>
<header>
<nav>{/* 네비게이션 */}</nav>
</header>
<main>{children}</main>
<footer>{/* 푸터 */}</footer>
</body>
</html>
)
}
// app/blog/[slug]/page.tsx - 동적 라우트
import { notFound } from 'next/navigation'
interface PageProps {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.summary,
}
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
notFound()
}
return (
<article>
<h1>{post.title}</h1>
<time dateTime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
// 정적 생성할 경로 지정
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
4.3 Loading, Error, Not Found
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
)
}
// app/blog/error.tsx
'use client' // Error 컴포넌트는 반드시 클라이언트 컴포넌트
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>문제가 발생했습니다</h2>
<button onClick={() => reset()}>다시 시도</button>
</div>
)
}
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div>
<h2>페이지를 찾을 수 없습니다</h2>
<p>요청하신 페이지가 존재하지 않습니다.</p>
<Link href="/">홈으로 돌아가기</Link>
</div>
)
}
5. Server Components vs Client Components
Next.js 15에서 가장 중요한 개념 중 하나는 서버 컴포넌트와 클라이언트 컴포넌트의 구분입니다.
5.1 Server Components (기본값)
App Router에서 모든 컴포넌트는 기본적으로 서버 컴포넌트입니다. 서버에서만 실행되므로 번들 크기에 포함되지 않습니다.
// 서버 컴포넌트 - 기본값
// async 함수로 직접 데이터를 페칭할 수 있음
import { db } from '@/lib/db'
export default async function UserList() {
// 직접 DB에 접근 가능 (서버에서만 실행)
const users = await db.user.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
})
return (
<ul>
{users.map((user) => (
<li key={user.id}>
<span>{user.name}</span>
<span>{user.email}</span>
</li>
))}
</ul>
)
}
5.2 Client Components
상호작용이 필요한 경우 파일 상단에 'use client'를 선언합니다.
'use client'
import { useState, useTransition } from 'react'
interface SearchProps {
onSearch: (query: string) => Promise<void>
}
export default function SearchBar({ onSearch }: SearchProps) {
const [query, setQuery] = useState('')
const [isPending, startTransition] = useTransition()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
startTransition(() => {
onSearch(query)
})
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="검색어를 입력하세요..."
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? '검색 중...' : '검색'}
</button>
</form>
)
}
5.3 경계 설정 패턴
서버 컴포넌트와 클라이언트 컴포넌트의 조합 패턴입니다.
// 서버 컴포넌트가 클라이언트 컴포넌트를 감싸는 패턴
// app/dashboard/page.tsx (서버 컴포넌트)
import { db } from '@/lib/db'
import DashboardClient from './DashboardClient'
export default async function DashboardPage() {
const data = await db.analytics.getOverview()
return <DashboardClient initialData={data} />
}
// app/dashboard/DashboardClient.tsx (클라이언트 컴포넌트)
'use client'
import { useState } from 'react'
interface Props {
initialData: AnalyticsOverview
}
export default function DashboardClient({ initialData }: Props) {
const [data, setData] = useState(initialData)
const [timeRange, setTimeRange] = useState('7d')
return (
<div>
<select
value={timeRange}
onChange={(e) => setTimeRange(e.target.value)}
>
<option value="7d">7일</option>
<option value="30d">30일</option>
<option value="90d">90일</option>
</select>
<Chart data={data} />
</div>
)
}
서버 컴포넌트와 클라이언트 컴포넌트를 선택하는 기준은 다음과 같습니다.
서버 컴포넌트를 사용하는 경우:
- 데이터를 페칭할 때
- 백엔드 리소스에 직접 접근할 때
- 민감한 정보(API 키, 토큰)를 사용할 때
- 큰 의존성을 서버에 유지하고 싶을 때
클라이언트 컴포넌트를 사용하는 경우:
- 상호작용(이벤트 리스너)이 필요할 때
- useState, useEffect 등 훅을 사용할 때
- 브라우저 전용 API를 사용할 때
- 커스텀 훅에 상태가 포함될 때
6. Server Actions
Server Actions는 서버에서 실행되는 비동기 함수로, 폼 제출과 데이터 변경을 처리합니다.
6.1 기본 Server Action
// app/actions/user.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
import { db } from '@/lib/db'
const CreateUserSchema = z.object({
name: z.string().min(2, '이름은 2자 이상이어야 합니다'),
email: z.string().email('유효한 이메일을 입력하세요'),
role: z.enum(['admin', 'user']),
})
export async function createUser(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
role: formData.get('role'),
}
const validatedData = CreateUserSchema.safeParse(rawData)
if (!validatedData.success) {
return {
errors: validatedData.error.flatten().fieldErrors,
}
}
await db.user.create({ data: validatedData.data })
revalidatePath('/users')
redirect('/users')
}
6.2 폼과 함께 사용하기
// app/users/new/page.tsx
import { createUser } from '@/app/actions/user'
export default function NewUserPage() {
return (
<form action={createUser}>
<div>
<label htmlFor="name">이름</label>
<input type="text" id="name" name="name" required />
</div>
<div>
<label htmlFor="email">이메일</label>
<input type="email" id="email" name="email" required />
</div>
<div>
<label htmlFor="role">역할</label>
<select id="role" name="role">
<option value="user">사용자</option>
<option value="admin">관리자</option>
</select>
</div>
<button type="submit">사용자 생성</button>
</form>
)
}
6.3 클라이언트 컴포넌트에서 Server Action 사용
'use client'
import { useActionState } from 'react'
import { createUser } from '@/app/actions/user'
export default function UserForm() {
const [state, formAction, isPending] = useActionState(createUser, null)
return (
<form action={formAction}>
<input type="text" name="name" placeholder="이름" />
{state?.errors?.name && (
<p className="text-red-500">{state.errors.name}</p>
)}
<input type="email" name="email" placeholder="이메일" />
{state?.errors?.email && (
<p className="text-red-500">{state.errors.email}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? '처리 중...' : '생성'}
</button>
</form>
)
}
6.4 보안 고려사항
Server Actions 사용 시 반드시 지켜야 할 보안 사항입니다.
'use server'
import { auth } from '@/lib/auth'
import { rateLimit } from '@/lib/rate-limit'
export async function deletePost(postId: string) {
// 1. 인증 확인
const session = await auth()
if (!session?.user) {
throw new Error('인증이 필요합니다')
}
// 2. 권한 확인
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.authorId !== session.user.id) {
throw new Error('권한이 없습니다')
}
// 3. Rate limiting
const limiter = await rateLimit(session.user.id)
if (!limiter.success) {
throw new Error('요청이 너무 많습니다. 잠시 후 다시 시도하세요.')
}
// 4. 입력 검증
const validatedId = z.string().uuid().parse(postId)
// 5. 실제 작업 수행
await db.post.delete({ where: { id: validatedId } })
revalidatePath('/posts')
}
7. 데이터 페칭 전략
Next.js 15에서의 데이터 페칭은 서버 컴포넌트와 함께 더욱 강력해졌습니다.
7.1 서버 컴포넌트에서 fetch
// 기본 fetch - 기본적으로 캐시됨 (Next.js 14)
// Next.js 15부터는 기본이 no-store
async function getPost(slug: string) {
const res = await fetch(
`https://api.example.com/posts/${slug}`,
{
next: {
revalidate: 3600, // 1시간마다 재검증
tags: ['posts'], // 캐시 태그
},
}
)
if (!res.ok) throw new Error('Failed to fetch post')
return res.json()
}
// 캐시하지 않는 요청
async function getCurrentUser() {
const res = await fetch('https://api.example.com/me', {
cache: 'no-store',
headers: {
Authorization: `Bearer ${getToken()}`,
},
})
return res.json()
}
7.2 unstable_cache (서버 전용 캐시)
import { unstable_cache } from 'next/cache'
import { db } from '@/lib/db'
// DB 쿼리 결과를 캐시
const getCachedPosts = unstable_cache(
async (category: string) => {
return db.post.findMany({
where: { category, published: true },
orderBy: { createdAt: 'desc' },
take: 20,
})
},
['posts-by-category'], // 캐시 키
{
revalidate: 600, // 10분마다 재검증
tags: ['posts'], // 수동 재검증을 위한 태그
}
)
// 사용
export default async function BlogPage() {
const posts = await getCachedPosts('technology')
return <PostList posts={posts} />
}
7.3 캐시 무효화
'use server'
import { revalidatePath, revalidateTag } from 'next/cache'
// 특정 경로의 캐시 무효화
export async function publishPost(postId: string) {
await db.post.update({
where: { id: postId },
data: { published: true },
})
// 특정 경로 재검증
revalidatePath('/blog')
revalidatePath(`/blog/${postId}`)
// 또는 태그 기반 재검증
revalidateTag('posts')
}
7.4 병렬 데이터 페칭
// 순차적 (느림)
export default async function Dashboard() {
const user = await getUser()
const posts = await getPosts() // user 완료 후 시작
const analytics = await getAnalytics() // posts 완료 후 시작
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// 병렬 (빠름)
export default async function Dashboard() {
const [user, posts, analytics] = await Promise.all([
getUser(),
getPosts(),
getAnalytics(),
])
return <DashboardView user={user} posts={posts} analytics={analytics} />
}
// Suspense를 활용한 스트리밍
import { Suspense } from 'react'
export default async function Dashboard() {
const user = await getUser() // 필수 데이터
return (
<div>
<UserProfile user={user} />
<Suspense fallback={<PostsSkeleton />}>
<PostsSection />
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<AnalyticsSection />
</Suspense>
</div>
)
}
8. 미들웨어
미들웨어는 요청이 완료되기 전에 코드를 실행하여 인증, 리다이렉트, 국제화 등을 처리합니다.
8.1 기본 미들웨어
// middleware.ts (프로젝트 루트)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 인증 토큰 확인
const token = request.cookies.get('auth-token')?.value
// 보호된 경로 확인
if (request.nextUrl.pathname.startsWith('/dashboard')) {
if (!token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('callbackUrl', request.nextUrl.pathname)
return NextResponse.redirect(loginUrl)
}
}
// 응답 헤더 추가
const response = NextResponse.next()
response.headers.set('x-request-id', crypto.randomUUID())
return response
}
export const config = {
matcher: [
// 정적 파일과 API 라우트 제외
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}
8.2 국제화 미들웨어
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { match } from '@formatjs/intl-localematcher'
import Negotiator from 'negotiator'
const locales = ['ko', 'en', 'ja']
const defaultLocale = 'ko'
function getLocale(request: NextRequest): string {
const negotiatorHeaders: Record<string, string> = {}
request.headers.forEach((value, key) => {
negotiatorHeaders[key] = value
})
const languages = new Negotiator({
headers: negotiatorHeaders,
}).languages()
return match(languages, locales, defaultLocale)
}
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// 이미 로케일이 있는지 확인
const pathnameHasLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
)
if (pathnameHasLocale) return
// 적절한 로케일로 리다이렉트
const locale = getLocale(request)
request.nextUrl.pathname = `/${locale}${pathname}`
return NextResponse.redirect(request.nextUrl)
}
8.3 Rate Limiting 미들웨어
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const rateLimit = new Map<string, { count: number; resetTime: number }>()
export function middleware(request: NextRequest) {
if (request.nextUrl.pathname.startsWith('/api')) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const windowMs = 60 * 1000 // 1분
const maxRequests = 60
const current = rateLimit.get(ip)
if (!current || now > current.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs })
} else if (current.count >= maxRequests) {
return NextResponse.json(
{ error: 'Too Many Requests' },
{ status: 429 }
)
} else {
current.count++
}
}
return NextResponse.next()
}
9. 배포 전략
9.1 Vercel 배포
Vercel은 Next.js의 제작사이며 가장 최적화된 배포 환경을 제공합니다.
# Vercel CLI 설치 및 배포
npm i -g vercel
vercel
# 프로덕션 배포
vercel --prod
# 환경 변수 설정
vercel env add DATABASE_URL production
환경 변수 관리를 위한 설정 파일 예시입니다.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 환경 변수 검증
env: {
CUSTOM_KEY: process.env.CUSTOM_KEY,
},
// 이미지 최적화
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'images.example.com',
},
],
},
}
export default nextConfig
9.2 Docker 배포
# Dockerfile
FROM node:20-alpine AS base
# 의존성 설치
FROM base AS deps
WORKDIR /app
COPY package.json yarn.lock* ./
RUN yarn install --frozen-lockfile
# 빌드
FROM base AS builder
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN yarn build
# 프로덕션
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY /app/public ./public
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
// next.config.ts - standalone 출력 설정
const nextConfig: NextConfig = {
output: 'standalone',
}
9.3 셀프호스팅
# PM2를 사용한 프로세스 관리
npm install -g pm2
# 빌드
npm run build
# PM2로 실행
pm2 start npm --name "nextjs-app" -- start
# 클러스터 모드 (CPU 코어 수만큼)
pm2 start npm --name "nextjs-app" -i max -- start
# 자동 재시작 설정
pm2 startup
pm2 save
Nginx 리버스 프록시 설정:
# /etc/nginx/sites-available/nextjs
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# 정적 파일 캐싱
location /_next/static {
proxy_pass http://localhost:3000;
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
10. 성능 최적화
10.1 Image 최적화
import Image from 'next/image'
// 기본 이미지 최적화
export function HeroImage() {
return (
<Image
src="/hero.jpg"
alt="Hero image"
width={1200}
height={630}
priority // LCP 이미지에 사용
placeholder="blur"
blurDataURL="data:image/jpeg;base64,..."
/>
)
}
// 반응형 이미지
export function ResponsiveImage() {
return (
<Image
src="/photo.jpg"
alt="Responsive photo"
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
)
}
10.2 Font 최적화
// app/layout.tsx
import { Inter, Noto_Sans_KR } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter',
})
const notoSansKR = Noto_Sans_KR({
subsets: ['latin'],
weight: ['400', '500', '700'],
display: 'swap',
variable: '--font-noto-sans-kr',
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html
lang="ko"
className={`${inter.variable} ${notoSansKR.variable}`}
>
<body>{children}</body>
</html>
)
}
10.3 Bundle 분석
# @next/bundle-analyzer 설치
npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer'
const nextConfig: NextConfig = {
// 기타 설정
}
export default withBundleAnalyzer({
enabled: process.env.ANALYZE === 'true',
})(nextConfig)
# 번들 분석 실행
ANALYZE=true npm run build
10.4 Lazy Loading
import dynamic from 'next/dynamic'
// 컴포넌트 지연 로딩
const DynamicChart = dynamic(() => import('@/components/Chart'), {
loading: () => <p>차트 로딩 중...</p>,
ssr: false, // 클라이언트에서만 렌더링
})
// 조건부 로딩
const DynamicEditor = dynamic(
() => import('@/components/RichTextEditor'),
{ ssr: false }
)
export default function PostEditor() {
const [showEditor, setShowEditor] = useState(false)
return (
<div>
<button onClick={() => setShowEditor(true)}>
에디터 열기
</button>
{showEditor && <DynamicEditor />}
</div>
)
}
10.5 종합 성능 체크리스트
프로덕션 배포 전에 확인해야 할 성능 최적화 항목입니다.
이미지:
- next/image 컴포넌트 사용
- LCP 이미지에 priority 속성 추가
- 적절한 sizes 속성 설정
- WebP/AVIF 포맷 자동 변환 활용
코드 분할:
- dynamic import로 큰 컴포넌트 지연 로딩
- 라우트별 자동 코드 분할 활용
- 불필요한 의존성 제거
캐싱:
- 적절한 revalidate 값 설정
- 캐시 태그를 활용한 세밀한 무효화
- CDN 캐싱 활용
서버 컴포넌트:
- 가능한 한 서버 컴포넌트 활용
- 클라이언트 컴포넌트 경계 최소화
- Suspense로 스트리밍 렌더링 구현
번들 최적화:
- bundle-analyzer로 번들 크기 분석
- tree-shaking 최적화
- barrel export 패턴 주의 (필요한 것만 import)
마무리
TypeScript와 Next.js는 현대 웹 개발의 표준이 되었습니다. TypeScript의 강력한 타입 시스템은 런타임 에러를 컴파일 타임에 잡아주고, Next.js의 App Router와 Server Components는 최적의 사용자 경험과 개발자 경험을 동시에 제공합니다.
핵심 정리:
- TypeScript의 제네릭, 조건부 타입, 매핑된 타입을 활용하면 타입 안전하면서도 유연한 코드를 작성할 수 있습니다
- Server Components를 기본으로 사용하고, 상호작용이 필요한 부분만 Client Components로 분리하세요
- Server Actions는 폼 처리와 데이터 변경의 새로운 표준입니다 (항상 인증과 입력 검증을 잊지 마세요)
- 데이터 페칭은 병렬화하고, Suspense를 활용해 점진적 렌더링을 구현하세요
- 이미지, 폰트, 번들 최적화로 Core Web Vitals를 개선하세요
이 가이드에서 다룬 내용을 바탕으로 타입 안전하고 성능이 뛰어난 풀스택 애플리케이션을 구축해 보세요.