Skip to content

✍️ 필사 모드: TypeScript & Next.js 실전 가이드 — 타입 안전한 풀스택 개발

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

목차

  1. TypeScript 타입 시스템 심화
  2. 유틸리티 타입 완전 정복
  3. 타입 가드와 타입 좁히기
  4. Next.js 15 App Router
  5. Server Components vs Client Components
  6. Server Actions
  7. 데이터 페칭 전략
  8. 미들웨어
  9. 배포 전략
  10. 성능 최적화

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 --from=deps /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 --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /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를 개선하세요

이 가이드에서 다룬 내용을 바탕으로 타입 안전하고 성능이 뛰어난 풀스택 애플리케이션을 구축해 보세요.

현재 단락 (1/925)

1. [TypeScript 타입 시스템 심화](#1-typescript-타입-시스템-심화)

작성 글자: 0원문 글자: 21,594작성 단락: 0/925