dev.syw

Nitro 프리셋·엣지 배포, 환경 분리, 보안 헤더, 로깅·모니터링 운영.

프로덕션 운영과 관측성

입문편에서 nuxt build 와 기본 배포 절차를 익혔다면, 이번 레슨은 그 다음 단계인 운영 관점에 집중합니다. 실제 프로덕션 환경은 단순 배포 이상의 요구를 합니다. 멀티 환경 시크릿 관리, 엣지·서버리스 선택, 보안 헤더, 캐시 무효화, 그리고 장애가 발생했을 때 빠르게 파악하고 복구하는 체계가 필요합니다.

이 레슨에서는 Nitro 프리셋 선택 기준부터 구조화 로깅·에러 리포팅·헬스체크·무중단 롤백까지, 프로덕션 환경에서 실제로 맞닥뜨리는 문제들을 다룹니다.

학습 목표

  • Nitro 프리셋 별 실행 환경 차이(Node, 서버리스, 엣지)를 이해하고 상황에 맞게 선택할 수 있다.
  • runtimeConfig 와 환경 변수를 멀티 스테이지(dev/staging/production)로 안전하게 분리할 수 있다.
  • 보안 헤더·CSP·rate limitingrouteRules 와 서버 미들웨어로 적용할 수 있다.
  • 구조화 로깅과 Sentry 에러 리포팅을 연동하고, 헬스체크 엔드포인트를 구현할 수 있다.
  • 무중단 배포·롤백 전략과 .output 구조를 이해하고 실제 배포 파이프라인에 적용할 수 있다.

Nitro 프리셋과 배포 타깃 선택

Nitro는 같은 소스 코드를 여러 런타임 환경으로 컴파일합니다. 프리셋은 단순히 출력 파일 형태만 바꾸는 것이 아니라 사용 가능한 Node.js API, 콜드 스타트 전략, 파일 시스템 접근 여부 까지 결정합니다.

주요 프리셋 비교

프리셋런타임콜드 스타트파일 시스템장기 연결대표 플랫폼
node-serverNode.js 프로세스없음(상시 실행)가능가능자체 VPS, Docker
vercel / netlify서버리스 함수있음제한적불가Vercel, Netlify
cloudflare-pagesV8 엔진(엣지)거의 없음없음불가Cloudflare Pages
bunBun 런타임없음가능가능Bun 서버
static정적 HTML없음해당 없음해당 없음GitHub Pages, S3
// nuxt.config.ts — 프리셋 명시 지정 (빌드 환경 변수로도 덮어쓸 수 있음)
export default defineNuxtConfig({
  nitro: {
    preset: 'node-server', // ✅ 자체 서버 운영 시
    // preset: 'cloudflare-pages', // ✅ 엣지 배포 시
  },
})
# CI/CD 파이프라인에서 프리셋을 동적으로 지정
NITRO_PRESET=vercel npx nuxi build

⚠️ 주의 — 엣지 런타임(Cloudflare Workers)은 Node.js 내장 모듈(fs, path, crypto 일부 등)을 지원하지 않습니다. server/ 코드에서 Node 전용 API를 사용하고 있다면 엣지 프리셋으로 빌드할 때 오류가 납니다. nuxt buildnitro.jsonrouteMatcher 를 확인해 예상치 못한 번들 포함을 점검하세요.

엣지 배포의 실제 함정

엣지 환경에서는 Web Crypto API 만 사용할 수 있습니다. Node의 crypto.createHash 대신 globalThis.crypto.subtle 을 써야 합니다.

// server/utils/hash.ts
// ❌ 엣지에서 동작하지 않음
import { createHash } from 'node:crypto'
export const md5 = (s: string) => createHash('md5').update(s).digest('hex')

// ✅ 엣지 호환 방식
export async function sha256(message: string): Promise<string> {
  const msgBuffer = new TextEncoder().encode(message)
  const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', msgBuffer)
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
}

환경별 runtimeConfig 분리와 시크릿 관리

입문편에서 runtimeConfig 기본 사용법을 다뤘습니다. 여기서는 빌드 시점 vs 런타임 시점 값의 차이, 그리고 dev/staging/production 멀티 환경 분리 전략을 살펴봅니다.

빌드 시점 vs 런타임 시점

runtimeConfig 는 런타임에 환경 변수로 덮어쓸 수 있지만, appConfigpublic 아래 일부 값은 빌드 시점에 번들에 인라인됩니다.

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // 런타임 환경 변수 NUXT_DB_URL 로 덮어씀 — 빌드 후에도 변경 가능
    dbUrl: '',
    jwtSecret: '',

    public: {
      // 클라이언트 번들에 포함 — 빌드 후 변경 불가
      apiBase: process.env.NUXT_PUBLIC_API_BASE ?? '/api',
      appVersion: process.env.npm_package_version ?? '0.0.0',
    },
  },
})

💡 TIPruntimeConfig 최상위(서버 전용) 값은 NODE_ENV 와 무관하게 서버 프로세스 환경 변수로 주입되므로, Docker 컨테이너의 환경 변수나 Kubernetes Secret으로 런타임에 바꿀 수 있습니다. 반면 public 아래 값은 클라이언트 번들에 인라인되므로 재빌드 없이 변경할 수 없습니다.

멀티 환경 .env 분리 전략

# .env.development — 로컬 개발
NUXT_DB_URL=postgresql://localhost:5432/myapp_dev
NUXT_JWT_SECRET=dev-secret-change-in-prod
NUXT_PUBLIC_API_BASE=http://localhost:3000/api

# .env.staging — 스테이징 (CI 환경 변수로 주입)
NUXT_DB_URL=postgresql://staging-db:5432/myapp_staging
NUXT_JWT_SECRET=staging-jwt-secret-xxx
NUXT_PUBLIC_API_BASE=https://staging.example.com/api

# .env — 프로덕션 (절대 커밋하지 않음, 플랫폼 시크릿 매니저에서 주입)
NUXT_DB_URL=postgresql://prod-db:5432/myapp
NUXT_JWT_SECRET=prod-very-secure-random-string
NUXT_PUBLIC_API_BASE=https://api.example.com
// server/plugins/db.ts — 서버 시작 시 DB 연결 초기화
export default defineNitroPlugin(async () => {
  const config = useRuntimeConfig()
  if (!config.dbUrl) {
    throw new Error('NUXT_DB_URL 환경 변수가 설정되지 않았습니다.')
  }
  // DB 연결 로직...
})

⚠️ 주의.env 파일은 반드시 .gitignore 에 추가하세요. 시크릿은 AWS Secrets Manager, Vault, 또는 CI/CD 플랫폼의 시크릿 스토어에서 런타임에 주입하는 방식을 권장합니다.

보안 헤더·CSP·Rate Limiting

routeRules 로 보안 헤더 일괄 적용

Nuxt의 routeRules 는 경로별로 응답 헤더를 선언적으로 지정할 수 있는 강력한 도구입니다.

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 모든 경로에 공통 보안 헤더 적용
    '/**': {
      headers: {
        'X-Frame-Options': 'DENY',
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'strict-origin-when-cross-origin',
        'Permissions-Policy': 'camera=(), microphone=(), geolocation=()',
        'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
      },
    },
    // API 경로는 HTML 콘텐츠 보안 정책 제외
    '/api/**': {
      headers: {
        'Content-Security-Policy': '',
      },
    },
  },
})

CSP(Content Security Policy) 설정

CSP는 XSS 공격을 방어하는 핵심 헤더입니다. 서버 미들웨어에서 요청마다 nonce를 생성해 CSP 헤더에 넣고, 같은 nonce를 Nitro가 렌더링한 모든 스크립트/스타일 태그에 일괄 주입하는 방식으로 nonce 기반 CSP를 구현할 수 있습니다.

// server/middleware/csp.ts
export default defineEventHandler((event) => {
  // 요청마다 고유 nonce 생성
  const nonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64')

  // event context에 nonce 저장 (페이지 렌더링 시 참조)
  event.context.cspNonce = nonce

  const csp = [
    "default-src 'self'",
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "font-src 'self'",
    "connect-src 'self' https://api.example.com",
    "frame-ancestors 'none'",
  ].join('; ')

  setResponseHeader(event, 'Content-Security-Policy', csp)
})

useHead({ script: [{ nonce }], style: [{ nonce }] }) 처럼 작성하면 nonce 속성만 가진 <script>/<style> 태그가 새로 추가될 뿐, Nuxt가 실제로 렌더링하는 기존 인라인/번들 스크립트·스타일 태그에는 nonce가 전파되지 않습니다. 그 결과 'nonce-${nonce}' 기반 CSP가 동작하지 않아 대부분의 스크립트가 차단됩니다.

대신 Nitro의 render:html 훅에서 생성된 HTML의 모든 <script>/<style> 태그에 nonce를 후처리로 주입해야 합니다.

// server/plugins/csp-nonce.ts — 렌더된 HTML의 모든 script/style 태그에 nonce 주입
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('render:html', (html, { event }) => {
    const nonce = event?.context.cspNonce as string | undefined
    if (!nonce) return

    const inject = (tags: string[]) =>
      tags.map(tag =>
        // 이미 nonce가 있으면 건드리지 않고, 없으면 여는 태그에 주입
        /<(script|style)(?![^>]*\bnonce=)/i.test(tag)
          ? tag.replace(/<(script|style)\b/gi, `<$1 nonce="${nonce}"`)
          : tag,
      )

    html.head = inject(html.head)
    html.bodyPrepend = inject(html.bodyPrepend)
    html.bodyAppend = inject(html.bodyAppend)
  })
})

Rate Limiting 미들웨어

서버리스·엣지 환경에서는 외부 상태 저장소(Redis, KV) 없이 rate limiting을 구현하기 어렵습니다. 여기서는 Node 서버 환경의 인메모리 구현과 Redis 기반 구현을 함께 보여줍니다.

// server/middleware/rate-limit.ts
const requestCounts = new Map<string, { count: number; resetAt: number }>()

const WINDOW_MS = 60_000 // 1분
const MAX_REQUESTS = 100   // 창 당 최대 요청 수

export default defineEventHandler((event) => {
  // API 경로에만 적용
  if (!event.path.startsWith('/api/')) return

  const ip =
    getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
    ?? getRequestIP(event)
    ?? 'unknown'

  const now = Date.now()
  const record = requestCounts.get(ip)

  if (!record || record.resetAt < now) {
    requestCounts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
    return
  }

  record.count++

  if (record.count > MAX_REQUESTS) {
    setResponseStatus(event, 429)
    setResponseHeader(event, 'Retry-After', String(Math.ceil((record.resetAt - now) / 1000)))
    return sendError(event, createError({ statusCode: 429, message: 'Too Many Requests' }))
  }
})

⚠️ 주의 — 인메모리 rate limiting은 프로세스가 여러 개 뜨는 환경(Kubernetes, 서버리스)에서는 효과가 없습니다. 프로덕션에서는 Redis의 INCR + EXPIRE 또는 Cloudflare Rate Limiting 같은 인프라 레벨 솔루션을 사용하세요.

캐싱·CDN·ISR 운영과 무효화

routeRules 로 ISR 및 캐싱 구성

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // 홈 페이지: 60초마다 서버에서 재생성 (ISR)
    '/': { isr: 60 },

    // 블로그 포스트: 10분 캐시, CDN 에서 1시간 캐시
    '/blog/**': {
      isr: 600,
      headers: {
        'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
      },
    },

    // 관리자 페이지: 캐시 완전 금지
    '/admin/**': {
      headers: {
        'Cache-Control': 'no-store, no-cache, must-revalidate',
      },
    },

    // API 응답: 30초 캐시 (Nitro 레벨)
    '/api/products': { cache: { maxAge: 30 } },
  },
})

캐시 수동 무효화

ISR로 캐시된 페이지를 CMS 업데이트 시 즉시 무효화하려면 Nitro의 캐시 스토리지 API를 활용합니다. 다만 Nitro가 내부적으로 사용하는 캐시 키 스킴은 경로 그대로가 아니라 그룹·이름·해시가 조합된 형태(버전에 따라 nitro:handlers:... 등)이며 ISR은 플랫폼별 메커니즘을 쓰기도 하므로, nitro:routes:${path} 처럼 경로만 끼워 넣은 키로는 무효화되지 않습니다. 정확한 키 스킴은 Nitro 버전마다 달라질 수 있으므로, 키를 직접 추측하기보다 무효화 대상을 명시적으로 제어하는 것이 안전합니다.

가장 확실한 방법은 cachedEventHandler 로 캐시할 때 캐시 그룹/이름을 직접 지정하고, 그 prefix로 키를 순회하며 지우는 것입니다.

// server/api/revalidate.post.ts
export default defineEventHandler(async (event) => {
  // 웹훅 시크릿 검증
  const body = await readBody(event)
  const signature = getRequestHeader(event, 'x-webhook-signature')
  const config = useRuntimeConfig()

  if (signature !== config.webhookSecret) {
    throw createError({ statusCode: 401, message: 'Unauthorized' })
  }

  const { path } = body as { path: string }

  // 캐시 스토리지 핸들 (드라이버는 nitro.storage.cache 설정을 따름)
  const storage = useStorage('cache')

  // ⚠️ `nitro:routes:${path}` 같은 키를 직접 만들지 마세요 — Nitro의 실제 키 스킴과
  // 맞지 않아 무효화되지 않습니다. 대신 직접 제어 가능한 캐시 그룹/prefix를 지정하거나,
  // prefix 기반으로 키를 순회하며 지웁니다.
  if (path === '*') {
    // 캐시 전체 비우기 (가장 단순하지만 광범위)
    await storage.clear()
  } else {
    // 직접 제어하는 캐시 그룹(예: cachedEventHandler 의 { group, name } 으로 지정한 prefix)을
    // 순회하며, 해당 경로와 매칭되는 키만 제거
    const prefix = `pages:${path}`
    const keys = await storage.getKeys(prefix)
    await Promise.all(keys.map(key => storage.removeItem(key)))
  }

  return { revalidated: true, path }
})

위 prefix 순회가 동작하려면, 캐시를 만들 때 cachedEventHandlergroup/name 으로 키 prefix를 직접 지정해 두어야 합니다.

// server/routes/products.ts — 캐시 키 prefix를 명시적으로 제어
export default cachedEventHandler(
  async (event) => {
    // ... 데이터 조회
    return { products: [] }
  },
  {
    // 캐시 키는 cache:pages:products:... 형태로 저장됨 → getKeys('pages:products') 로 순회 가능
    group: 'pages',
    name: 'products',
    maxAge: 60,
  },
)

💡 TIP — Vercel을 사용한다면 @vercel/ogrevalidateTag 대신 Nuxt의 routeRules.isr 과 위의 웹훅 패턴으로 플랫폼 독립적인 무효화를 구현할 수 있습니다. Nitro의 캐시 스토리지는 Redis, Cloudflare KV 등 다양한 드라이버로 교체 가능합니다. 단, ISR 캐시는 플랫폼(Vercel/Cloudflare)별 메커니즘으로 관리될 수 있으므로, 키 기반 무효화가 통하지 않는 환경에서는 해당 플랫폼의 재검증 API를 함께 사용하세요.

Nitro 캐시 스토리지 드라이버 설정

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      // Redis를 캐시 스토리지로 사용
      cache: {
        driver: 'redis',
        url: process.env.REDIS_URL,
        ttl: 3600,
      },
    },
  },
})

여기서 드라이버의 ttl(저장소 레벨에서 항목이 만료·삭제되는 시간)과 routeRulescache.maxAge/swr(라우트 캐시의 신선도를 판단하는 기준)은 서로 다른 계층의 설정이므로, 라우트 캐시의 실제 만료 동작은 routeRules 쪽으로 제어하는 것이 일반적입니다.

구조화 로깅과 에러 리포팅

구조화 로깅 (JSON 포맷)

프로덕션에서 로그는 사람이 읽는 텍스트가 아니라 Elasticsearch, CloudWatch, Datadog 같은 로그 집계 시스템이 파싱할 수 있는 JSON 형태여야 합니다.

// server/utils/logger.ts
interface LogEntry {
  level: 'info' | 'warn' | 'error' | 'debug'
  message: string
  timestamp: string
  requestId?: string
  userId?: string
  [key: string]: unknown
}

export function createLogger(context?: Partial<LogEntry>) {
  function log(level: LogEntry['level'], message: string, extra?: Record<string, unknown>) {
    const entry: LogEntry = {
      level,
      message,
      timestamp: new Date().toISOString(),
      ...context,
      ...extra,
    }
    // 프로덕션에서는 JSON, 개발에서는 가독성 좋은 포맷
    if (process.env.NODE_ENV === 'production') {
      console.log(JSON.stringify(entry))
    } else {
      console.log(`[${entry.level.toUpperCase()}] ${entry.message}`, extra ?? '')
    }
  }

  return {
    info: (message: string, extra?: Record<string, unknown>) => log('info', message, extra),
    warn: (message: string, extra?: Record<string, unknown>) => log('warn', message, extra),
    error: (message: string, extra?: Record<string, unknown>) => log('error', message, extra),
    debug: (message: string, extra?: Record<string, unknown>) => log('debug', message, extra),
  }
}
// server/middleware/request-logger.ts
export default defineEventHandler((event) => {
  const requestId = crypto.randomUUID()
  event.context.requestId = requestId

  const logger = createLogger({ requestId })
  event.context.logger = logger

  const start = Date.now()

  // 응답 후 로그 기록
  event.node.res.on('finish', () => {
    logger.info('request completed', {
      method: event.method,
      path: event.path,
      statusCode: event.node.res.statusCode,
      durationMs: Date.now() - start,
    })
  })
})

Sentry 에러 리포팅 연동

npm install @sentry/nuxt
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@sentry/nuxt/module'],
  sentry: {
    sourceMapsUploadOptions: {
      org: 'your-org',
      project: 'your-project',
      authToken: process.env.SENTRY_AUTH_TOKEN,
    },
  },
  sourcemap: { client: 'hidden' },
})
// sentry.client.config.ts
import * as Sentry from '@sentry/nuxt'

Sentry.init({
  dsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
  replaysSessionSampleRate: 0.1,
  replaysOnErrorSampleRate: 1.0,
  integrations: [Sentry.replayIntegration()],
})
// sentry.server.config.ts
import * as Sentry from '@sentry/nuxt'

Sentry.init({
  dsn: process.env.NUXT_PUBLIC_SENTRY_DSN,
  environment: process.env.NODE_ENV,
  tracesSampleRate: 0.2,
})
// server/middleware/error-reporter.ts
// 서버 에러를 Sentry에 캡처하는 글로벌 에러 훅
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('error', (error, { event }) => {
    // 4xx 에러는 클라이언트 실수이므로 리포팅 제외
    if (error.statusCode && error.statusCode < 500) return

    import('@sentry/nuxt').then(({ captureException }) => {
      captureException(error, {
        extra: {
          path: event?.path,
          requestId: event?.context.requestId,
        },
      })
    })
  })
})

💡 TIPtracesSampleRate 를 프로덕션에서 1.0으로 설정하면 모든 트랜잭션을 추적하여 Sentry 비용이 급증할 수 있습니다. 트래픽 규모에 따라 0.05~0.2 수준으로 시작하고, 에러 전용 replaysOnErrorSampleRate 만 1.0으로 유지하는 방식을 권장합니다.

헬스체크 엔드포인트와 무중단 배포

헬스체크 엔드포인트 구현

로드밸런서나 쿠버네티스의 Liveness/Readiness 프로브가 사용할 엔드포인트입니다.

// server/api/health.get.ts
interface HealthStatus {
  status: 'ok' | 'degraded' | 'down'
  timestamp: string
  version: string
  checks: Record<string, { status: string; latencyMs?: number; error?: string }>
}

export default defineEventHandler(async (event): Promise<HealthStatus> => {
  const config = useRuntimeConfig()
  const checks: HealthStatus['checks'] = {}
  let overallStatus: HealthStatus['status'] = 'ok'

  // DB 연결 확인
  const dbStart = Date.now()
  try {
    // 실제 DB 핑 쿼리 (예시)
    // await db.raw('SELECT 1')
    checks.database = { status: 'ok', latencyMs: Date.now() - dbStart }
  } catch (err) {
    checks.database = { status: 'error', error: (err as Error).message }
    overallStatus = 'degraded'
  }

  // 캐시(Redis) 연결 확인
  const cacheStart = Date.now()
  try {
    const storage = useStorage('cache')
    await storage.setItem('health:ping', 'pong', { ttl: 10 })
    await storage.getItem('health:ping')
    checks.cache = { status: 'ok', latencyMs: Date.now() - cacheStart }
  } catch (err) {
    checks.cache = { status: 'error', error: (err as Error).message }
    // 캐시 장애는 degraded (서비스는 계속 가능)
    overallStatus = overallStatus === 'ok' ? 'degraded' : overallStatus
  }

  // 심각한 장애면 503 반환
  if (overallStatus === 'down') {
    setResponseStatus(event, 503)
  }

  return {
    status: overallStatus,
    timestamp: new Date().toISOString(),
    version: config.public.appVersion,
    checks,
  }
})

.output 구조와 무중단 배포 전략

nuxt build 의 결과물 구조를 이해하면 무중단 배포 스크립트 작성이 쉬워집니다.

.output/
├── public/          # 정적 에셋 (CDN에 업로드)
│   ├── _nuxt/       # 해시된 JS/CSS 번들
│   └── ...
├── server/          # Nitro 서버 번들
│   ├── index.mjs    # 서버 진입점
│   ├── chunks/      # 코드 청크
│   └── node_modules/ # 벤더 번들
└── nitro.json       # 빌드 메타데이터 (프리셋, 경로 등)
#!/bin/bash
# deploy.sh — Node 서버 무중단 배포 스크립트 (PM2 사용)

set -e

APP_NAME="myapp"
DEPLOY_DIR="/srv/myapp"
BUILD_DIR="$(pwd)/.output"

echo "=== 빌드 시작 ==="
npx nuxi build

echo "=== 정적 에셋 CDN 업로드 ==="
aws s3 sync "$BUILD_DIR/public" "s3://my-cdn-bucket/public" \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*.html"

echo "=== 새 릴리스 디렉토리 생성 ==="
RELEASE="$DEPLOY_DIR/releases/$(date +%Y%m%d%H%M%S)"
mkdir -p "$RELEASE"
cp -r "$BUILD_DIR/server" "$RELEASE/"
cp "$BUILD_DIR/nitro.json" "$RELEASE/"

echo "=== 환경 변수 파일 복사 ==="
cp "$DEPLOY_DIR/.env.production" "$RELEASE/.env"

echo "=== 심볼릭 링크 전환 (무중단) ==="
ln -sfn "$RELEASE" "$DEPLOY_DIR/current"

echo "=== PM2 재시작 (graceful reload) ==="
pm2 reload "$APP_NAME" --update-env

echo "=== 헬스체크 ==="
for i in {1..10}; do
  STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/api/health)
  if [ "$STATUS" = "200" ]; then
    echo "헬스체크 통과 (시도 $i)"
    break
  fi
  echo "헬스체크 대기... ($i/10)"
  sleep 3
done

echo "=== 이전 릴리스 정리 (최근 3개 유지) ==="
ls -dt "$DEPLOY_DIR/releases"/* | tail -n +4 | xargs rm -rf

echo "=== 배포 완료 ==="
# 롤백 스크립트 — 이전 릴리스로 즉시 전환
#!/bin/bash
# rollback.sh

DEPLOY_DIR="/srv/myapp"
PREVIOUS=$(ls -dt "$DEPLOY_DIR/releases"/* | sed -n '2p')

if [ -z "$PREVIOUS" ]; then
  echo "롤백할 이전 릴리스가 없습니다."
  exit 1
fi

echo "=== $PREVIOUS 로 롤백 ==="
ln -sfn "$PREVIOUS" "$DEPLOY_DIR/current"
pm2 reload myapp --update-env
echo "=== 롤백 완료 ==="

💡 TIP — PM2의 reload 는 새 프로세스가 준비된 후 기존 프로세스를 교체하므로 다운타임이 없습니다. 반면 restart 는 즉시 종료 후 재시작이므로 짧은 다운타임이 발생합니다. ecosystem.config.jswait_ready: truelisten_timeout 을 설정하면 서버가 실제로 요청을 받을 준비가 될 때까지 대기합니다.

요약

  • Nitro 프리셋은 실행 파일 형태뿐 아니라 사용 가능한 API, 파일 시스템 접근, 콜드 스타트 전략 을 결정하므로 배포 환경에 맞는 프리셋을 신중하게 선택해야 합니다.
  • runtimeConfig 의 서버 전용 값은 런타임에 환경 변수로 주입 가능하지만, public 아래 값은 빌드 시 번들에 인라인되므로 시크릿을 절대 public 에 넣지 않아야 합니다.
  • 보안 헤더와 CSP는 routeRulesheaders 로 선언하고, nonce 기반 CSP는 서버 미들웨어에서 요청마다 nonce를 생성한 뒤 Nitro의 render:html 훅에서 렌더된 모든 스크립트/스타일 태그에 일괄 주입합니다(useHead 로 빈 태그를 추가하는 방식으로는 기존 태그에 nonce가 전파되지 않습니다). Rate limiting은 단일 프로세스라면 인메모리로, 멀티 인스턴스라면 Redis를 사용하세요.
  • 캐시 무효화는 Nitro의 useStorage('cache') API를 웹훅과 함께 사용해 구현하되, 경로를 끼워 넣은 임의의 키가 아니라 cachedEventHandlergroup/name 으로 직접 지정한 prefix를 getKeys()/removeItem() 으로 순회하거나 clear() 로 비웁니다. 실제 키 스킴은 버전마다 다를 수 있으며, ISR은 플랫폼별 메커니즘을 쓸 수 있으므로 키 기반 무효화가 통하지 않으면 플랫폼 재검증 API를 병행합니다.
  • 구조화 로깅(JSON)과 Sentry 연동은 프로덕션 장애 탐지와 원인 분석 속도를 크게 높입니다. 헬스체크 엔드포인트는 의존 서비스별 상태를 포함해야 로드밸런서가 실질적 장애를 감지합니다.
  • 무중단 배포는 릴리스 디렉토리 + 심볼릭 링크 전환 + PM2 graceful reload 패턴으로 구현하고, 롤백은 이전 릴리스의 심볼릭 링크 재설정으로 수행합니다.

연습문제

  1. 현재 Nuxt 프로젝트에 NITRO_PRESET=node-server 로 빌드한 뒤 .output/nitro.json 을 열어 presetrouteMatcher 필드를 확인하세요. 그런 다음 cloudflare-pages 프리셋으로 다시 빌드해 두 산출물의 차이를 비교해 보세요.

  2. 아래 조건을 모두 충족하는 routeRules 설정을 nuxt.config.ts 에 작성하세요.

    • 모든 페이지에 X-Frame-Options: DENY 헤더 적용
    • /blog/** 경로는 ISR 300초, CDN s-maxage=1800
    • /api/** 경로는 Cache-Control: no-store
  3. /api/health 헬스체크 엔드포인트를 구현하되, 외부 의존 서비스로 Redis 핑외부 API 응답 확인 두 가지를 포함하고, 하나라도 실패 시 HTTP 503 을 반환하도록 하세요.

  4. 구조화 로거(createLogger)를 활용해, 모든 API 요청에 requestId, method, path, statusCode, durationMs 를 JSON으로 기록하는 서버 미들웨어를 작성하세요.

힌트defineEventHandlerevent.node.res.on('finish', ...) 이벤트에서 응답 완료 후 로그를 남기면 statusCodedurationMs 를 함께 기록할 수 있습니다.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

Nuxt.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.