[카테고리:] 미분류

  • Next.js at Scale


    주제: SSR/ISR/RSC, 라우트 핸들러, 이미지·CDN 최적화


    TL;DR (체크리스트)

    • 렌더링 전략을 먼저 고정: Static(SSG) → ISR → SSR(스트리밍) 순서로 우선
    • 데이터 패칭 표준: fetch(..., { next: { revalidate, tags }, cache })
    • 변경 알림 표준: 서버 액션/웹훅 → revalidateTag()
    • 이미지: next/image + sizes 필수, remotePatterns 화이트리스트
    • 클라이언트 JS 다이어트: RSC 기본, use client 최소화, dynamic import
    • 엣지 활용: 인증 제외 읽기 전용 API/경량 페이지는 runtime='edge'
    • 관측성: instrumentation.ts + Server-Timing/traceparent 전파

    1) 성능 목표(웹 지표)

    • TTFB p95 < 300ms(캐시 히트 시 100ms대)
    • LCP p75 < 2.5s, CLS < 0.1, INP p75 < 200ms
    • 페이지 JS < 200KB gz(초기 로드)

    2) 렌더링 전략 맵

    • 정적(SSG/ISR): 변동이 느린 컨텐츠(마케팅/문서/목록)
    • SSR(스트리밍): 사용자별/세션 의존, 빠른 TTFB + 점진 렌더
    • RSC(Server Components): 데이터와 컴포넌트 결합, 클라 JS 감소
    // app/products/[id]/page.tsx (ISR + 태그 캐시)
    export const revalidate = 120; // 초
    export default async function Page({ params:{id} }:{
      params:{id:string}
    }) {
      const data = await fetch(`${process.env.API}/products/${id}`, {
        next: { revalidate, tags: [`product:${id}`, 'products'] }
      }).then(r=>r.json());
      return <ProductView data={data} />;
    }
    

    3) 데이터 패칭 규칙(중요)

    • 읽기: fetchnext: { revalidate, tags } 또는 cache:'no-store'
    • 쓰기 후 무효화: 서버 액션/웹훅 → revalidateTag('product:123')
    • BFF 라우트 핸들러에서 ETag/Cache-Control 헤더 부여
    // app/actions/updateProduct.ts
    'use server'
    import { revalidateTag } from 'next/cache'
    export async function updateProduct(id: string, dto: any) {
      await fetch(`${process.env.API}/products/${id}`, { method:'PUT', body: JSON.stringify(dto) })
      revalidateTag(`product:${id}`)         // 목록/상세 동시 무효화
      revalidateTag('products')
    }
    
    // app/api/public/products/[id]/route.ts (BFF + 캐시 헤더)
    import { NextResponse } from 'next/server'
    export const runtime = 'nodejs' // 인증/서명 필요시 node 런타임
    export async function GET(_:Request,{params}:{params:{id:string}}) {
      const r = await fetch(`${process.env.API}/products/${params.id}`, { cache:'no-store' })
      const data = await r.json()
      const res = NextResponse.json(data)
      res.headers.set('Cache-Control','public, max-age=60, stale-while-revalidate=120')
      return res
    }
    

    4) 스트리밍 SSR UX 패턴

    • loading.tsx첫 스켈레톤
    • 부분적 비싼 영역은 Suspense 경계 분리(데이터 지연 컴포넌트 뒤로)
    // app/app/page.tsx
    import { Suspense } from 'react'
    export default function Page(){
      return (
        <>
          <Hero/>
          <Suspense fallback={<ListSkeleton/>}>
            {/* 비싼 서버 컴포넌트 */}
            <TopProducts/>
          </Suspense>
        </>
      )
    }
    

    5) 이미지 & CDN 최적화

    • next/image반드시 sizes 지정(가로폭 조건식)
    • 큰 리스트는 우선순위 한정(priority는 Fold 상단 1~2개만)
    • 외부 이미지 사용 시 next.config.jsimages.remotePatterns
    import Image from 'next/image'
    export default function Card({img}:{img:string}) {
      return (
        <div className="relative h-48">
          <Image
            src={img}
            alt=""
            fill
            sizes="(max-width:768px) 100vw, (max-width:1200px) 50vw, 33vw"
            placeholder="empty" // blurDataURL 준비 시 'blur'
          />
        </div>
      )
    }
    
    // next.config.js
    module.exports = {
      images: {
        remotePatterns: [{ protocol:'https', hostname:'cdn.example.com' }]
      }
    }
    

    정적 자산은 Cache-Control: max-age=31536000, immutable (빌드 해시 파일).


    6) 엣지 런타임 가이드

    • 사용 추천: 읽기 전용 API, 퍼스널라이제이션 없는 공개 페이지
    • 피해야: 비밀 접근, 대형 종속성, Node 전용 API 필요 시
    export const runtime = 'edge' // 파일 상단
    

    7) 클라이언트 JS 다이어트

    • 기본은 서버 컴포넌트; 이벤트 필요 부분만 use client
    • 무거운 위젯은 dynamic import(ssr:false 가능)
    • 상태관리 최소화(Zustand 등도 client boundary 안으로만)
    // app/(client)/Chart.tsx
    'use client'
    import dynamic from 'next/dynamic'
    const Chart = dynamic(()=>import('./_chart'), { ssr:false })
    export default function ChartWrap(){ return <Chart/> }
    

    8) 라우트 핸들러 운영 팁(BFF)

    • 인증·세션 처리 후 서버→API 호출(서비스 토큰/HMAC)
    • ETag/If-None-Match로 304 응답
    • 레이트리밋 헤더(RFC 9230) 부여(6편 참고)
    // 304 처리 예
    import crypto from 'crypto'
    export async function GET(req:Request) {
      const data = await getData()
      const etag = crypto.createHash('md5').update(JSON.stringify(data)).digest('hex')
      if (req.headers.get('if-none-match')===etag) return new Response(null,{status:304})
      const res = new Response(JSON.stringify(data),{ headers:{ 'ETag': etag, 'Content-Type':'application/json' }})
      return res
    }
    

    9) SEO/메타데이터 & 국제화

    • export const metadata = {...}로 타이틀/OG/Twitter 카드 설정
    • 다국어는 도메인/서브패스 라우팅 + generateMetadata에서 locale별 태그
    export const metadata = {
      title: 'EX | XR/AI Platform',
      openGraph: { title:'EX', type:'website' }
    }
    

    10) 에러/로딩/경계

    • error.tsx(사용자 친화 메시지), not-found.tsx
    • API 실패는 서버 컴포넌트에서 throwerror.tsx로 위임
    • 로그인 요구 페이지middleware.ts로 보호

    11) 관측성(instrumentation.ts)

    • Next.js OTel 지원으로 서버 요청 트레이스/메트릭 노출
    • 응답에 Server-Timing·traceparent 추가하여 백엔드와 연결
    // instrumentation.ts
    import { registerOTel } from '@vercel/otel' // (Vercel 사용 시)
    export function register() {
      registerOTel({ serviceName: 'web' })
    }
    
    // app/api/_lib/withTrace.ts
    export async function withTrace<T>(name:string, f:()=>Promise<T>) {
      const start = Date.now()
      try { return await f() }
      finally {
        const dur = Date.now()-start
        // 헤더 주입 (Route Handler 내에서)
        // res.headers.set('Server-Timing', `app;dur=${dur}`)
      }
    }
    

    12) 보안 헤더 & 미들웨어

    • HSTS, CSP, Referrer-Policy, X-Frame-Options(=DENY)
    • 봇/스크래핑 차단, 업로드 라우트에 바디 크기 제한
    • 인증이 필요한 API는 cache:'no-store' 강제

    13) 빌드/배포 최적화

    • next buildanalyze로 번들 크기 점검(중복 deps 제거)
    • Edge/Node 런타임 혼합 시 라우트 분리로 콜드스타트 감소
    • CI에서 lint/test → build → prerender 결과 확인 → 배포

    14) 2주 액션 플랜

    1. 페이지/라우트 렌더링 전략 표 확정(SSG/ISR/SSR 구분)
    2. 상위 5개 페이지에 revalidate/tags 적용, 서버 액션에서 revalidateTag 연결
    3. next/image 전면 적용 + sizes/remotePatterns 세팅
    4. 클라 JS 다이어트: use client 제거/경계 재배치 + dynamic import
    5. 라우트 핸들러에 ETag/Cache-Control/RateLimit 헤더 도입
    6. instrumentation.ts로 OTEL 연결 + Server-Timing 노출
    7. Lighthouse CI/Playwright로 성능·회귀 테스트 파이프라인 추가

    15) “복붙” 모음

    A. 데이터 패칭 공통 래퍼

    export async function api<T>(path:string, opt?:RequestInit & { revalidate?: number, tags?: string[] }) {
      const { revalidate, tags, ...init } = opt || {}
      const res = await fetch(`${process.env.API}${path}`, {
        ...init,
        next: { revalidate, tags },
        headers: { 'Accept':'application/json', ...(init.headers||{}) },
      })
      if (!res.ok) throw new Error(`API ${res.status}`)
      return res.json() as Promise<T>
    }
    

    B. 전역 이미지 컴포넌트 프리셋

    import Image, { ImageProps } from 'next/image'
    export function Img(props: ImageProps){
      return <Image placeholder="empty" sizes="(max-width:768px) 100vw, 50vw" {...props}/>
    }
    

    C. Edge용 공개 목록 API

    // app/api/public/items/route.ts
    export const runtime = 'edge'
    export async function GET() {
      const r = await fetch(`${process.env.API}/items`, { next:{ revalidate: 60, tags:['items'] } })
      return new Response(await r.text(), {
        headers: { 'Content-Type':'application/json', 'Cache-Control':'public, max-age=60, stale-while-revalidate=120' }
      })
    }
    

    마무리

    Next.js는 캐시·무효화·스트리밍 3가지를 제대로 쓰는 순간 스케일이 달라집니다. 브라우저에는 작은 JS, 엣지에는 빠른 캐시, 서버에는 견고한 BFF—이 조합을 습관화하세요.

    다음은 8편. 가시성(Observability) — OpenTelemetry/로그/메트릭/트레이싱 & SLO로 갑니다.