dev.syw

인증 패턴부터 관측·셀프호스팅까지 운영 단계를 설계한다.

실전 운영: 인증·관측·셀프호스팅 배포

입문편에서는 Vercel을 통한 기본 배포와 환경 변수 설정, 서버 액션의 기초를 다뤘습니다. 실제 서비스를 운영하다 보면 그 이후 단계가 훨씬 복잡해집니다. 인증 흐름을 미들웨어와 연결해 일관된 인가 정책을 적용해야 하고, 분산 환경에서 로그와 트레이스를 수집해 장애를 조기에 파악해야 하며, Docker로 독립 실행 빌드를 구성하거나 무중단 배포 전략을 설계해야 할 때도 많습니다.

이 강에서는 프로덕션 환경에서 실제로 맞닥뜨리는 여섯 가지 운영 과제를 깊이 있게 다룹니다. 각 주제를 단순한 사용법이 아니라 "왜 이런 구조가 필요한가"와 "함정은 무엇인가"에 초점을 맞춰 설명합니다.

학습 목표

  • 세션·JWT 인증을 미들웨어와 서버 액션에 연결해 일관된 인가 레이어를 설계할 수 있다.
  • 환경 변수시크릿을 빌드 시점과 런타임으로 분리해 안전하게 관리할 수 있다.
  • OpenTelemetrySentry를 연동해 분산 추적과 에러 모니터링을 구축할 수 있다.
  • Docker standalone 빌드로 셀프호스팅하고 캐시 핸들러를 커스터마이징할 수 있다.
  • 블루-그린 배포기능 플래그를 활용해 무중단 점진적 출시를 설계할 수 있다.
  • 보안 헤더, CSP, rate limiting으로 프로덕션 하드닝을 완성할 수 있다.

1. 세션·JWT 인증과 미들웨어·서버 액션 연계 인가 설계

인증 흐름의 두 축: 세션 vs JWT

세션 방식은 서버(혹은 Redis)에 상태를 저장하고 쿠키로 세션 ID만 주고받습니다. JWT 방식은 상태를 토큰 자체에 인코딩해 서버가 무상태로 동작합니다. Next.js 앱에서는 이 둘을 혼합해 사용하는 경우가 많습니다—예를 들어 iron-session으로 서버 측 암호화 쿠키를 사용하거나, jose 라이브러리로 직접 JWT를 검증합니다.

💡 TIP next-auth(Auth.js) v5는 내부적으로 JWT와 데이터베이스 세션을 모두 지원합니다. 여기서는 원리를 이해하기 위해 라이브러리 없이 직접 구현하는 예시를 다룹니다.

미들웨어에서 인가 처리

middleware.ts는 Edge Runtime에서 실행되므로 Node.js API를 사용할 수 없습니다. jose 라이브러리를 사용해 JWT를 검증하는 것이 표준 패턴입니다.

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";

const SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

const PUBLIC_PATHS = ["/login", "/register", "/api/auth"];

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // 공개 경로는 통과
  if (PUBLIC_PATHS.some((p) => pathname.startsWith(p))) {
    return NextResponse.next();
  }

  const token = req.cookies.get("access_token")?.value;

  if (!token) {
    return NextResponse.redirect(new URL("/login", req.url));
  }

  try {
    const { payload } = await jwtVerify(token, SECRET);

    // 검증된 사용자 정보를 헤더로 전달 (서버 컴포넌트·API 라우트에서 사용)
    const requestHeaders = new Headers(req.headers);
    requestHeaders.set("x-user-id", payload.sub as string);
    requestHeaders.set("x-user-role", payload.role as string);

    return NextResponse.next({ request: { headers: requestHeaders } });
  } catch {
    // 만료·변조된 토큰 처리
    const response = NextResponse.redirect(new URL("/login", req.url));
    response.cookies.delete("access_token");
    return response;
  }
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

서버 액션에서 세밀한 인가

미들웨어는 경로 수준의 1차 방어선입니다. 서버 액션에서는 리소스 소유권 같은 2차 인가를 반드시 별도로 수행해야 합니다.

// lib/auth.ts — 서버 전용 헬퍼
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export async function requireAuth() {
  const headerStore = await headers(); // Next.js 15에서 headers()는 비동기
  const userId = headerStore.get("x-user-id");
  if (!userId) redirect("/login");
  return { userId, role: headerStore.get("x-user-role") ?? "user" };
}

export async function requireRole(role: string) {
  const { role: userRole, userId } = await requireAuth();
  if (userRole !== role) {
    throw new Error("Forbidden"); // ❌ 리다이렉트 대신 에러를 던져 서버 액션 경계에서 처리
  }
  return { userId, role: userRole };
}
// app/posts/actions.ts
"use server";

import { requireAuth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function deletePost(postId: string) {
  const { userId } = await requireAuth(); // ✅ 모든 서버 액션 시작에서 인증 확인

  const post = await db.post.findUnique({ where: { id: postId } });
  if (!post || post.authorId !== userId) {
    throw new Error("Not authorized"); // ✅ 소유권 확인
  }

  await db.post.delete({ where: { id: postId } });
}

⚠️ 주의 서버 액션의 URL은 외부에 노출됩니다. 미들웨어가 특정 경로만 보호한다고 해서 서버 액션이 자동으로 보호되지는 않습니다. 모든 뮤테이션 액션에서 requireAuth()를 호출하는 것을 규칙으로 삼으세요.


2. 환경 변수·시크릿 관리와 빌드/런타임 구성 분리

빌드 시점 vs 런타임 환경 변수

Next.js 환경 변수는 두 생명 주기를 가집니다.

구분접두사번들 포함노출
빌드 시 번들에 인라인NEXT_PUBLIC_O클라이언트에 노출
서버 런타임에서만 읽힘없음X서버 전용

NEXT_PUBLIC_ 변수는 빌드 시 소스에 문자열로 삽입됩니다. 배포 후 변경하려면 재빌드가 필요합니다. API 키나 데이터베이스 URL처럼 민감한 값은 절대 NEXT_PUBLIC_을 붙여선 안 됩니다.

# .env.local (로컬 개발용, git에 포함 금지)
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=super-secret-32-char-minimum-key

# 클라이언트에 노출해도 안전한 값만
NEXT_PUBLIC_API_BASE_URL=https://api.example.com
NEXT_PUBLIC_ANALYTICS_ID=GA-XXXXXXXX

런타임 환경 변수 검증

빌드가 성공했어도 런타임에 필수 환경 변수가 없으면 조용히 실패합니다. zod로 시작 시점에 검증하는 패턴이 권장됩니다.

// lib/env.ts
import { z } from "zod";

const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  REDIS_URL: z.string().url().optional(),
  SENTRY_DSN: z.string().url().optional(),
  NODE_ENV: z.enum(["development", "test", "production"]),
});

// 파싱 실패 시 서버 시작 자체를 중단
export const env = envSchema.parse(process.env);
// next.config.ts — 빌드 시에도 검증 수행
import "./lib/env"; // ✅ import만으로 검증 실행

export default {
  // ...
};

💡 TIP Docker나 Kubernetes 환경에서는 시크릿을 환경 변수 대신 파일(/run/secrets/)로 마운트하는 것이 더 안전합니다. fs.readFileSync로 런타임에 읽은 뒤 env 객체에 병합하세요.


3. 구조적 로깅·OpenTelemetry 계측·Sentry 에러 모니터링

구조적 로깅 (pino)

프로덕션에서 console.log는 검색·집계가 불가능합니다. pino처럼 JSON 로그를 출력하는 라이브러리를 사용해야 Loki, Elasticsearch, CloudWatch 같은 로그 수집기와 연동됩니다.

// lib/logger.ts
import pino from "pino";

export const logger = pino({
  level: process.env.LOG_LEVEL ?? "info",
  formatters: {
    level: (label) => ({ level: label }), // ✅ 레벨을 문자열로 유지
  },
  base: {
    service: "my-next-app",
    env: process.env.NODE_ENV,
  },
});
// app/api/orders/route.ts
import { logger } from "@/lib/logger";

export async function POST(req: Request) {
  const body = await req.json();
  const log = logger.child({ orderId: body.orderId, userId: body.userId });

  log.info("order.create.started");

  try {
    // ... 주문 처리 로직
    log.info({ amount: body.amount }, "order.create.completed");
    return Response.json({ ok: true });
  } catch (err) {
    log.error({ err }, "order.create.failed"); // ✅ 에러 객체를 구조체로 전달
    return Response.json({ error: "Internal error" }, { status: 500 });
  }
}

OpenTelemetry 계측

Next.js 15는 instrumentation.ts 파일을 공식 지원합니다. 이 파일은 서버 프로세스 시작 시 한 번 실행됩니다.

// instrumentation.ts (프로젝트 루트)
export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    const { NodeSDK } = await import("@opentelemetry/sdk-node");
    const { OTLPTraceExporter } = await import(
      "@opentelemetry/exporter-trace-otlp-http"
    );
    const { resourceFromAttributes } = await import("@opentelemetry/resources");
    const { ATTR_SERVICE_NAME } = await import(
      "@opentelemetry/semantic-conventions"
    );

    const sdk = new NodeSDK({
      // ✅ resourceFromAttributes() 팩토리 + ATTR_SERVICE_NAME 사용 (Resource 생성자는 deprecated)
      resource: resourceFromAttributes({
        [ATTR_SERVICE_NAME]: "my-next-app",
      }),
      traceExporter: new OTLPTraceExporter({
        url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://localhost:4318/v1/traces",
      }),
    });

    sdk.start();
  }
}

Sentry 에러 모니터링

npx @sentry/wizard@latest -i nextjs

위 명령으로 자동 설정을 마친 뒤, 커스텀 에러 컨텍스트를 추가하는 패턴입니다.

// app/global-error.tsx
"use client";

import * as Sentry from "@sentry/nextjs";
import { useEffect } from "react";

export default function GlobalError({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  useEffect(() => {
    Sentry.captureException(error, {
      tags: { digest: error.digest },
    });
  }, [error]);

  return (
    <html>
      <body>
        <h2>예기치 않은 오류가 발생했습니다.</h2>
        <button onClick={() => reset()}>다시 시도</button>
      </body>
    </html>
  );
}

⚠️ 주의 Sentry의 beforeSend 훅에서 개인정보(이메일, 패스워드, 토큰)가 포함된 이벤트를 필터링하는 것은 GDPR 준수를 위해 필수입니다.


4. Docker standalone 빌드와 캐시 핸들러 커스터마이징

standalone 출력 모드

output: "standalone" 옵션을 사용하면 Next.js가 프로덕션 실행에 필요한 파일만 .next/standalone 폴더에 복사합니다. node_modules 전체를 포함하지 않아도 됩니다.

// next.config.ts
export default {
  output: "standalone",
};
# Dockerfile — 멀티스테이지 빌드
FROM node:22-alpine AS base

# 의존성 설치 스테이지
FROM base AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# 빌드 스테이지
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 빌드 시 필요한 환경 변수만 ARG로 주입
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN npm run build

# 실행 스테이지 — 최소한의 파일만
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production

# 보안: non-root 사용자로 실행
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

Redis 기반 캐시 핸들러

여러 컨테이너가 실행되는 수평 확장 환경에서는 인메모리 캐시를 공유할 수 없습니다. Redis 기반 캐시 핸들러로 공유 캐시를 구성해야 합니다.

// cache-handler.ts
import { createClient } from "redis";
import type { CacheHandler, CacheHandlerValue } from "next/dist/server/lib/incremental-cache";

const client = createClient({ url: process.env.REDIS_URL });
client.connect();

export default class RedisCacheHandler implements CacheHandler {
  async get(key: string): Promise<CacheHandlerValue | null> {
    const data = await client.get(key);
    if (!data) return null;
    return JSON.parse(data) as CacheHandlerValue;
  }

  async set(key: string, data: CacheHandlerValue, ctx: { revalidate?: number | false }) {
    const ttl = typeof ctx.revalidate === "number" ? ctx.revalidate : 3600;
    await client.set(key, JSON.stringify(data), { EX: ttl });
  }

  async revalidateTag(tag: string): Promise<void> {
    // 태그 기반 무효화: 태그→키 매핑을 별도 Set으로 관리
    const keys = await client.sMembers(`tag:${tag}`);
    if (keys.length > 0) {
      await client.del(keys);
    }
  }
}
// next.config.ts
export default {
  output: "standalone",
  cacheHandler: require.resolve("./cache-handler.ts"),
  cacheMaxMemorySize: 0, // 인메모리 캐시 비활성화
};

5. 점진적 마이그레이션·기능 플래그·무중단 배포(블루-그린)

기능 플래그로 점진적 출시

기능 플래그는 코드 배포와 기능 활성화를 분리합니다. 미들웨어에서 플래그를 확인해 트래픽을 분기하는 것이 Next.js에서 가장 자연스러운 패턴입니다.

// lib/flags.ts
type FlagConfig = {
  name: string;
  defaultValue: boolean;
  rolloutPercent?: number; // 0~100
};

const FLAGS: Record<string, FlagConfig> = {
  new_checkout: { name: "new_checkout", defaultValue: false, rolloutPercent: 20 },
  ai_recommendations: { name: "ai_recommendations", defaultValue: false, rolloutPercent: 5 },
};

export function isEnabled(flagName: string, userId: string): boolean {
  const flag = FLAGS[flagName];
  if (!flag) return false;

  if (flag.rolloutPercent === undefined) return flag.defaultValue;

  // 결정적 해시: 같은 userId는 항상 같은 결과
  const hash = userId.split("").reduce((acc, c) => acc + c.charCodeAt(0), 0);
  return hash % 100 < flag.rolloutPercent;
}
// middleware.ts에 플래그 분기 추가
import { isEnabled } from "@/lib/flags";

// ... (기존 인증 코드 이후)
const userId = payload.sub as string;

if (req.nextUrl.pathname === "/checkout" && isEnabled("new_checkout", userId)) {
  return NextResponse.rewrite(new URL("/checkout-v2", req.url));
}

블루-그린 배포 전략

블루-그린 배포는 두 개의 동일한 환경(블루=현재, 그린=신버전)을 유지하고 로드 밸런서에서 트래픽을 전환합니다. Kubernetes를 사용한다면 서비스 셀렉터만 변경하면 됩니다.

# 그린 배포 확인 후 트래픽 전환 (kubectl 예시)
kubectl set image deployment/next-app-green container=my-next-app:v2.0.0

# 헬스체크 통과 확인
kubectl rollout status deployment/next-app-green

# 트래픽 전환: 서비스 셀렉터를 green으로 변경
kubectl patch service next-app-svc -p '{"spec":{"selector":{"version":"green"}}}'

# 롤백이 필요하면
kubectl patch service next-app-svc -p '{"spec":{"selector":{"version":"blue"}}}'

Next.js 앱 자체에서도 /api/healthz 엔드포인트를 제공해 로드 밸런서의 헬스체크를 지원해야 합니다.

// app/api/healthz/route.ts
import { db } from "@/lib/db";

export const dynamic = "force-dynamic";

export async function GET() {
  try {
    await db.$queryRaw`SELECT 1`; // DB 연결 확인
    return Response.json({ status: "ok", version: process.env.APP_VERSION });
  } catch {
    return Response.json({ status: "error" }, { status: 503 });
  }
}

6. 보안 헤더·CSP·Rate Limiting 프로덕션 하드닝

보안 헤더 설정

// next.config.ts
import type { NextConfig } from "next";

const securityHeaders = [
  { key: "X-DNS-Prefetch-Control", value: "on" },
  { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
  { key: "X-Frame-Options", value: "SAMEORIGIN" },
  { key: "X-Content-Type-Options", value: "nosniff" },
  { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
  { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
];

const nextConfig: NextConfig = {
  output: "standalone",
  async headers() {
    return [
      {
        source: "/(.*)",
        headers: securityHeaders,
      },
    ];
  },
};

export default nextConfig;

Content Security Policy (CSP)

CSP는 XSS를 방어하는 가장 강력한 수단이지만, Next.js의 인라인 스크립트(__NEXT_DATA__)와 충돌할 수 있습니다. nonce 기반 CSP가 권장 패턴입니다.

// middleware.ts — nonce 생성 및 CSP 헤더 적용
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

export function middleware(req: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString("base64");

  const cspHeader = [
    `default-src 'self'`,
    // 'strict-dynamic'을 지원하는 브라우저는 'self'/호스트 허용 목록을 무시하고 nonce로 허용된 스크립트와
    // 그 스크립트가 동적으로 로드한 스크립트만 신뢰합니다. 'self'는 비지원 브라우저용 폴백으로만 함께 둡니다.
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' blob: data: https://cdn.example.com`,
    `connect-src 'self' https://api.example.com`,
    `font-src 'self'`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`,
  ].join("; ");

  const requestHeaders = new Headers(req.headers);
  requestHeaders.set("x-nonce", nonce); // 서버 컴포넌트에서 nonce 읽기용

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  response.headers.set("Content-Security-Policy", cspHeader);

  return response;
}
// app/layout.tsx — nonce를 Script 태그에 전달
import { headers } from "next/headers";
import Script from "next/script";

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const nonce = (await headers()).get("x-nonce") ?? ""; // Next.js 15에서 headers()는 비동기

  return (
    <html>
      <head>
        {/* ✅ nonce를 통해 허용된 인라인 스크립트만 실행 */}
        <Script nonce={nonce} src="/analytics.js" strategy="afterInteractive" />
      </head>
      <body>{children}</body>
    </html>
  );
}

Rate Limiting (Upstash Redis 활용)

// lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

export const rateLimiter = new Ratelimit({
  redis: Redis.fromEnv(), // UPSTASH_REDIS_REST_URL, UPSTASH_REDIS_REST_TOKEN
  limiter: Ratelimit.slidingWindow(10, "10 s"), // 10초에 10회
  analytics: true,
});
// middleware.ts — rate limiting 적용
import { rateLimiter } from "@/lib/rate-limit";

export async function middleware(req: NextRequest) {
  // API 경로에만 적용
  if (req.nextUrl.pathname.startsWith("/api/")) {
    const ip = req.headers.get("x-forwarded-for") ?? "127.0.0.1";
    const { success, remaining } = await rateLimiter.limit(ip);

    if (!success) {
      return new NextResponse("Too Many Requests", {
        status: 429,
        headers: {
          "Retry-After": "10",
          "X-RateLimit-Remaining": "0",
        },
      });
    }
  }

  // ... 나머지 미들웨어 로직
  return NextResponse.next();
}

⚠️ 주의 x-forwarded-for 헤더는 클라이언트가 위조할 수 있습니다. Cloudflare나 AWS ALB를 사용한다면 해당 플랫폼이 설정하는 신뢰할 수 있는 헤더(예: cf-connecting-ip)를 사용하세요.


요약

  • 인가는 두 단계로 설계합니다. 미들웨어에서 경로 수준 1차 방어를 하고, 서버 액션에서 리소스 소유권 같은 2차 인가를 반드시 별도로 수행합니다.
  • 환경 변수는 생명 주기로 분리합니다. NEXT_PUBLIC_은 빌드 시 번들에 인라인 되므로 민감 정보는 절대 포함하지 않으며, zod로 런타임 시작 시 검증합니다.
  • 관측성은 세 층으로 구성합니다. 구조적 로그(pino) → 분산 트레이스(OpenTelemetry) → 에러 집계(Sentry).
  • 셀프호스팅output: "standalone"으로 이미지 크기를 최소화하고, 수평 확장 환경에서는 Redis 기반 캐시 핸들러로 캐시를 공유합니다.
  • 무중단 배포는 블루-그린 전략과 기능 플래그를 함께 사용해 코드 배포와 기능 활성화를 분리합니다.
  • 보안 하드닝은 nonce 기반 CSP, 보안 헤더, IP 기반 rate limiting을 미들웨어 레이어에서 일괄 적용합니다.

연습문제

  1. 현재 미들웨어에서 JWT 검증은 하지만 역할(role) 기반 접근 제어(RBAC)는 구현되지 않은 상태입니다. /admin 경로는 role === "admin"인 사용자만 접근할 수 있도록 미들웨어를 수정하세요.

    힌트 jwtVerify의 반환값 payload에서 role 필드를 확인하고, 조건을 충족하지 못할 경우 403 또는 다른 경로로 리다이렉트하세요.

  2. output: "standalone" 모드로 빌드된 Next.js 앱을 Docker 컨테이너로 실행할 때, 여러 컨테이너 간에 revalidateTag()로 캐시를 무효화해야 합니다. 앞서 소개한 Redis 캐시 핸들러에서 set() 메서드를 수정해 태그 정보를 Redis Set에 저장하도록 구현하세요.

    힌트 CacheHandlerValuetags 필드를 순회하며 client.sAdd(tag:${tag}, key) 형태로 저장하면 됩니다.

  3. 기능 플래그 new_dashboard를 추가하고, 이를 미들웨어가 아닌 서버 컴포넌트에서 확인해 조건부로 다른 컴포넌트를 렌더링하는 코드를 작성하세요. 사용자 ID는 lib/auth.tsrequireAuth()로 가져옵니다.

    힌트 서버 컴포넌트는 async 함수이고 requireAuth()도 (Next.js 15의 비동기 headers() 때문에) async이므로 await requireAuth()로 호출하고 결과를 isEnabled()에 전달할 수 있습니다.

  4. next.config.ts에서 CSP 헤더를 headers() 함수로 정적으로 설정하는 방법과 미들웨어에서 nonce를 사용해 동적으로 설정하는 방법의 차이를 설명하고, 정적 CSP가 적합한 경우와 적합하지 않은 경우를 각각 서술하세요.

    힌트 인라인 스크립트(<script> 태그에 직접 작성된 JS) 허용 여부와 Next.js 내부 스크립트 동작 방식을 고려하세요.


💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

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

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