테마 전환

Next.js API 인증과 보안: JWT부터 속도 제한까지 완벽 실전 가이드

Easton editorial illustration: cache waterfall instrument

휴대전화가 울렸습니다. 클라우드 서비스 업체가 보낸 청구 알림이었습니다. 금액은 7,800달러였습니다.

눈을 비비고 다시 봤습니다. 잘못 본 줄 알았습니다. 지난달 청구액은 120달러에 불과했기 때문입니다. 상세 내역을 열어 보니 API 호출 횟수가 1,800만 회였습니다. 평소 개인 프로젝트의 하루 호출량은 고작 수백 회였습니다.

알고 보니 어떤 크롤러가 아무런 보호 조치도 하지 않은 제 API 엔드포인트를 발견해 사흘 내내 요청을 보낸 것이었습니다. 그 순간 API 보안이 선택 사항이 아니라는 말의 의미를 깨달았습니다.

이 글에서는 지난 몇 년 동안 직접 겪은 문제와 조사한 해결책을 체계적으로 정리합니다. JWT 인증부터 CORS 설정, 속도 제한, 입력 검증까지 다룹니다. 이론만 늘어놓는 대신 프로젝트에 바로 적용할 수 있는 실전 코드를 제공합니다.

API 보안이 중요한 이유

API 보안의 일반적인 위협

10.0
CVSS 점수
2025년 12월 React Server Components 심각한 취약점(CVE-2025-55182) 점수, 공격자가 임의 코드를 실행할 수 있음

지난해 12월 React는 CVE-2025-55182라는 심각한 보안 공지를 발표했습니다. CVSS 점수는 만점인 10.0이었습니다.

이게 어느 정도냐고요? 말 그대로 만점입니다. 공격자가 특수하게 조작한 HTTP 요청 하나만 보내도 서버에서 임의의 코드를 실행할 수 있습니다. React Server Components를 사용하면서 제때 업데이트하지 않았다면 사실상 아무런 방어 없이 노출된 셈입니다.

이것만이 아닙니다. 올해 3월에는 점수 9.1의 인증 우회 취약점(CVE-2025-29927)도 발견됐습니다. 공격자가 요청 헤더 하나를 위조하면 Middleware 인증을 우회할 수 있었습니다. 인증을 설정했으니 안전하다고 생각했나요? 공격자는 그 단계를 그대로 건너뜁니다.

이런 고위험 취약점 외에도 일상적인 위협은 훨씬 많습니다.

악성 크롤러와 DDoS 공격. 속도 제한이 없는 API는 순식간에 마비될 수 있습니다. 로그인 엔드포인트에 초당 3,000회의 요청이 들어와 비밀번호를 무차별 대입하고, 결국 서버가 다운된 사례도 봤습니다.

데이터 유출. 권한 제어가 부실하면 사용자 A가 사용자 B의 주문 정보를 볼 수 있습니다. 이런 사고가 뉴스에 보도되면 브랜드 평판은 치명적인 타격을 입습니다.

인젝션 공격. SQL 인젝션, XSS, 명령어 인젝션은 오래된 이야기처럼 들리지만 해마다 수많은 프로젝트가 여전히 피해를 봅니다. React가 자동으로 이스케이프하니 괜찮다고 생각해도 API에서 입력을 검증하지 않으면 소용없습니다.

Next.js API Routes의 특징

Next.js API Routes는 전통적인 백엔드와 조금 다릅니다. 다음 특징에 주의해야 합니다.

Serverless 우선. Vercel에 배포하면 각 API 요청은 독립된 Serverless 함수에서 처리됩니다. 자동 확장이 장점이지만 상태가 없다는 단점도 있습니다. 기존의 메모리 기반 Session 대신 JWT나 데이터베이스 Session을 사용해야 합니다.

프런트엔드와 한곳에 있음. 코드가 모두 한 저장소에 있어 환경 변수를 잘못 다루면 클라이언트에 노출될 수 있습니다. 누군가 .envDATABASE_URL을 넣었다가 빌드 과정에서 프런트엔드 bundle에 포함되어 GitHub에 그대로 공개된 사례도 봤습니다.

엣지 컴퓨팅의 제약. Edge Runtime을 사용하면 일부 Node.js API를 쓸 수 없어 암호화 라이브러리와 데이터베이스 연결 방식을 다시 선택해야 합니다. 보안 방안도 그에 맞춰 조정해야 합니다.

한마디로 Next.js API는 백엔드이지만 더 가볍고 유연하며, 그만큼 문제가 생기기도 쉽습니다.

API 인증 실전

인증 방식 선택 가이드

먼저 가장 현실적인 질문부터 살펴보겠습니다. JWT와 Session 중 무엇을 사용해야 할까요?

저도 처음 Next.js 프로젝트를 시작했을 때 같은 고민을 했습니다. 온라인에는 JWT가 반드시 써야 하는 현대적인 방식이라는 주장도 있고, Session이 더 안전하다는 주장도 있습니다. 결국 상황에 따라 선택해야 한다는 결론을 내렸습니다.

JWT가 적합한 경우:

  • 애플리케이션을 여러 서버에 배포해야 할 때(Serverless, 엣지 노드)
  • 프런트엔드는 app.com, API는 api.com에 있는 식으로 교차 출처 인증이 필요할 때
  • Session 저장소를 관리하지 않고 최대한 단순하게 운영하고 싶을 때

JWT의 본질은 사용자 정보를 token으로 인코딩하는 것입니다. 서버는 상태를 저장하지 않고 요청마다 token만 받으면 됩니다. 수평 확장이 매우 쉽습니다.

Session이 적합한 경우:

  • 사용자를 강제로 로그아웃시키거나 권한을 실시간으로 바꾸는 등 서버의 능동적 제어가 필요할 때
  • 보안 요구 수준이 매우 높아 클라이언트가 사용자 정보를 보유하지 않게 하고 싶을 때
  • 이미 Redis나 데이터베이스가 있어 Session 관리가 어렵지 않을 때

Session은 상태를 서버에 저장하고 클라이언트에는 Session ID만 둡니다. 특정 사용자를 무효화하고 싶다면 해당 Session만 삭제하면 됩니다. JWT로는 이를 바로 처리할 수 없습니다.

제 선택 기준은 간단합니다. 소규모 또는 개인 프로젝트에는 편리한 JWT를 쓰고, 세밀한 제어가 필요한 엔터프라이즈 프로젝트에는 Session을 씁니다. 너무 오래 고민하지 말고 하나를 먼저 적용한 뒤 실제 문제가 생기면 바꿔도 늦지 않습니다.

JWT 인증 전체 구현

JWT를 사용하기로 했다면 Next.js에서 어떻게 구현해야 할까요?

1단계: Token 생성 및 검증

먼저 라이브러리를 설치합니다.

npm install jose

jsonwebtoken을 쓰지 않을까요? 이 라이브러리는 Edge Runtime을 지원하지 않기 때문입니다. jose는 웹 표준 구현이라 어떤 환경에서도 실행할 수 있습니다.

lib/auth.ts를 만듭니다.

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-at-least-32-characters'
);

export async function createToken(payload: { userId: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m') // 15분 후 만료
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

여기서 15m 만료 시간에 주목하세요. 편하다는 이유로 7일이나 30일로 설정하는 경우가 많지만, token이 유출되면 공격자가 오랫동안 사용할 수 있습니다. 수명이 짧은 Access Token과 수명이 긴 Refresh Token을 함께 사용하는 것이 올바른 방식입니다.

2단계: Token 저장—localStorage를 사용하지 마세요

중요한 부분입니다. 온라인 튜토리얼 중에는 token을 localStorage에 저장하라고 안내하는 것이 많습니다. 하지만 XSS 공격이 발생하면 token이 모두 탈취됩니다.

올바른 방법은 HttpOnly Cookie입니다.

로그인 API가 token을 반환할 때 다음과 같이 설정합니다.

// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';

export async function POST(request: Request) {
  // 사용자 이름과 비밀번호 검증...

  const token = await createToken({ userId: user.id });

  const response = NextResponse.json({ success: true });
  response.cookies.set('token', token, {
    httpOnly: true,    // JS에서 읽을 수 없어 XSS 방지
    secure: true,      // HTTPS로만 전송
    sameSite: 'lax',   // CSRF 방지
    maxAge: 900,       // 15분, token 만료 시간과 동일
  });

  return response;
}

HttpOnly가 핵심입니다. JavaScript는 이 Cookie를 전혀 읽을 수 없으므로 XSS 공격으로도 가져갈 수 없습니다.

3단계: Middleware로 API 보호

token을 만들었으니 이제 일부 API를 로그인한 사용자만 호출할 수 있게 해야 합니다. Next.js Middleware를 사용합니다.

middleware.ts를 만듭니다.

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.json(
      { error: 'Invalid token' },
      { status: 401 }
    );
  }

  // 검증에 성공하면 사용자 정보를 API에 전달
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.userId as string);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: '/api/protected/:path*',
};

이제 모든 /api/protected/* 엔드포인트가 자동으로 보호됩니다. API에서는 다음과 같이 사용자 정보를 가져옵니다.

// app/api/protected/profile/route.ts
import { headers } from 'next/headers';

export async function GET() {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');

  // 데이터베이스에서 사용자 정보 조회...
}

4단계: Token 갱신 메커니즘

15분마다 token이 만료된다면 사용자가 계속 로그인해야 할까요? 이때 Refresh Token을 도입합니다.

Access Token은 15분으로 짧게, Refresh Token은 30일로 길게 설정합니다. Access Token이 만료되면 Refresh Token으로 새 token을 받아 다시 로그인하지 않아도 됩니다.

구체적인 구현은 조금 복잡하지만 기본 원리는 이렇습니다. next-auth처럼 이 로직을 내장한 기존 솔루션도 많습니다.

NextAuth.js로 빠르게 통합하기

솔직히 위 기능을 직접 구현하면 원리를 이해하는 데 도움이 되지만 작업량이 적지 않습니다. 빠르게 시작하려면 NextAuth.js(현재 이름은 Auth.js)를 바로 사용하세요.

이 라이브러리의 장점은 안전한 기본 설정을 바로 제공한다는 것입니다.

  • CSRF 보호 자동 처리
  • Session 서명 및 암호화
  • JWT와 데이터베이스 Session 지원
  • Google, GitHub 등 소셜 로그인 기본 제공

설치합니다.

npm install next-auth

app/api/auth/[...nextauth]/route.ts를 만듭니다.

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        // 사용자 이름과 비밀번호 검증...
        if (user) {
          return { id: user.id, email: user.email };
        }
        return null;
      }
    })
  ],
  session: {
    strategy: 'jwt',  // JWT 사용, Serverless에 적합
    maxAge: 30 * 24 * 60 * 60, // 30일
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.userId = token.userId;
      return session;
    }
  }
});

export { handler as GET, handler as POST };

그런 다음 API에서 로그인 상태를 확인합니다.

import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // 로그인된 상태이므로 계속 처리...
}

이것으로 끝입니다. NextAuth가 token 관리와 Session 갱신을 모두 처리합니다.

CORS 설정 자세히 알아보기

CORS의 본질과 일반적인 문제

CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유)는 많은 개발자를 괴롭히는 문제입니다. 개발 환경에서는 잘 작동하다가 배포 후 다음 오류가 나타나곤 합니다.

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

간단히 말해 브라우저에는 웹페이지 A가 사이트 B의 리소스에 마음대로 접근하지 못하게 하는 보안 정책이 있습니다. 예를 들어 app.com 페이지에서 api.com API를 호출하면 브라우저는 먼저 api.com에 이 요청이 app.com에서 왔는데 허용하는지 묻습니다. API가 명시적으로 허용해야 요청이 통과합니다.

개발 환경에서는 왜 오류가 없을까요?

Next.js 개발 환경에서는 프런트엔드와 API가 모두 localhost:3000에 있으므로 동일 출처이며 교차 출처 문제가 없습니다. 배포 후 프런트엔드는 Vercel에, API는 다른 서버에 놓이면 교차 출처가 됩니다.

Preflight 요청이란 무엇인가요?

사용자 지정 요청 헤더(예: Authorization)와 함께 POST 요청을 보내면 브라우저는 먼저 OPTIONS 요청으로 가능 여부를 확인합니다. 이것이 Preflight입니다. API가 OPTIONS를 처리하지 않아 404를 반환하면 CORS도 실패합니다.

저도 이 문제를 겪었습니다. POST 엔드포인트는 잘 만들었지만 OPTIONS 처리를 잊어 프런트엔드에서 계속 CORS 오류가 났습니다. 한참을 찾은 뒤에야 원인을 발견했습니다.

Next.js에서 CORS를 설정하는 세 가지 방법

방법 1: next.config.js 전역 설정

모든 API에 같은 교차 출처를 허용할 때 적합합니다.

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

장점은 한 번 설정하면 전역에 적용된다는 것입니다. 단점은 유연성이 부족해 API별 설정을 하기 어렵다는 것입니다.

방법 2: Middleware 설정

동적으로 판단하거나 한곳에서 통합 처리해야 할 때 적합합니다.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Preflight 요청 처리
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  // 일반 요청에 CORS 헤더 추가
  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

이 방식은 request 객체를 사용할 수 있어 출처에 따라 허용 여부를 동적으로 결정할 수 있습니다.

방법 3: API Route 내부 설정

특정 API에 별도 요구 사항이 있을 때 적합합니다.

// app/api/public/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const data = { message: 'Hello' };

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // 공개 API, 모든 출처 허용
    },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

각 API에 OPTIONS 처리를 작성하지 않으면 Preflight가 통과하지 못한다는 점에 유의하세요.

CORS 보안 모범 사례

1. 와일드카드를 함부로 사용하지 마세요

다음과 같은 설정을 자주 볼 수 있습니다.

'Access-Control-Allow-Origin': '*'

이렇게 하면 모든 웹사이트가 API를 호출할 수 있습니다. 공개 데이터라면 괜찮지만 사용자 정보나 민감한 작업을 다루는 API에서는 보호 장치가 없는 것과 같습니다.

올바른 방법은 허용할 도메인을 명시하는 것입니다.

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

2. 자격 증명 전송에 주의하세요

API에서 Cookie를 읽어야 한다면(예: Session 인증) 프런트엔드 코드는 다음과 같이 작성합니다.

fetch('https://api.example.com', {
  credentials: 'include',
});

백엔드에도 다음 설정이 필요합니다.

response.headers.set('Access-Control-Allow-Credentials', 'true');

단, Access-Control-Allow-Origin*일 수 없습니다. 브라우저는 이 조합을 바로 거부하므로 구체적인 도메인을 지정해야 합니다.

3. Preflight 요청을 처리하세요

기억하세요. Authorization 헤더나 Content-Type: application/json을 포함한 요청은 대부분 Preflight를 일으킵니다. API는 반드시 OPTIONS 메서드에 응답해야 합니다.

공통 함수를 만들 수 있습니다.

export function corsHeaders(origin?: string) {
  return {
    'Access-Control-Allow-Origin': origin || 'https://app.example.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // Preflight 결과를 24시간 캐시
  };
}

이제 각 API에서 재사용하면 됩니다.

API 속도 제한

속도 제한이 필요한 이유

서두의 이야기로 돌아가 보겠습니다. 당시 API에 IP당 분당 최대 100회라는 제한을 두었다면 1,800만 회의 요청으로 7,800달러를 잃는 대신 손실이 수십 달러에 그쳤을 것입니다.

속도 제한(Rate Limiting)은 사용자가 일정 시간 동안 API를 호출할 수 있는 횟수를 제한합니다. 단순해 보이지만 다음과 같은 중요한 역할을 합니다.

DDoS 공격 방어. 공격자가 대량의 요청으로 서버를 마비시키려고 하나요? 제한을 켜면 초당 임계값을 넘는 요청을 바로 거부해 서버를 안정적으로 유지할 수 있습니다.

무차별 대입 방어. 로그인 엔드포인트에 제한이 없으면 해커는 스크립트로 초당 10,000개의 비밀번호를 시험할 수 있습니다. IP당 분당 5회로 제한하면 공격 난도가 기하급수적으로 올라갑니다.

리소스 보호. 데이터베이스와 외부 API 호출에는 모두 비용이 듭니다. 속도 제한은 특정 사용자가 리소스를 고갈시키는 것을 막고 모든 사용자에게 공정한 서비스를 제공합니다.

속도 제한 방식 비교

Next.js에서 속도 제한을 구현할 때 주로 다음 방식을 사용합니다.

방식 1: @upstash/ratelimit + Vercel KV

제가 현재 가장 자주 쓰는 방식입니다. Upstash는 Serverless Redis이며 Vercel 공식 파트너라 통합이 매우 간단합니다.

장점:

  • Serverless 친화적이며 Redis 서버를 직접 관리할 필요가 없음
  • 고정 창, 슬라이딩 창, 토큰 버킷 등 여러 알고리즘 지원
  • 무료 한도로 개인 프로젝트에 충분함

단점:

  • 트래픽이 많은 프로젝트는 비용이 발생함
  • 외부 서비스에 의존함

방식 2: 자체 호스팅 Redis

이미 Redis가 있거나 외부 서비스에 의존하고 싶지 않다면 직접 구현할 수 있습니다.

장점:

  • 완전한 제어가 가능하고 추가 비용이 없음
  • 복잡한 로직을 자유롭게 구성할 수 있음

단점:

  • Redis 서버를 직접 유지 관리해야 함
  • Serverless 환경에서는 설정이 복잡함

방식 3: 메모리 기반 제한

Redis를 설치하고 싶지 않다면 순수 메모리로 간단한 제한을 구현할 수도 있습니다.

장점:

  • 의존성이 없고 몇 줄의 코드로 구현 가능
  • 개발 환경과 소규모 프로젝트에 적합함

단점:

  • Serverless에서는 요청마다 새 인스턴스가 생성되고 메모리를 공유하지 않을 수 있어 제한이 무효화됨
  • 서버를 재시작하면 제한 데이터가 모두 사라짐

제 권장 사항은 개인 프로젝트와 Serverless 배포에는 Upstash, 자체 서버를 운영하는 엔터프라이즈 프로젝트에는 Redis, 데모나 로컬 개발에는 메모리 방식을 사용하는 것입니다.

실전 코드 예제

Upstash를 예로 들어 단계별로 구현해 보겠습니다.

1단계: 설치 및 설정

npm install @upstash/ratelimit @upstash/redis

Upstash 웹사이트에서 Redis 데이터베이스를 만들고 UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN을 발급받아 .env에 넣습니다.

UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

2단계: 속도 제한기 생성

lib/rate-limit.ts를 만듭니다.

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Redis 클라이언트 생성
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// 속도 제한기 생성: 슬라이딩 창, 10초 동안 최대 10회 요청
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
});

slidingWindow(10, '10 s')는 10초 동안 최대 10회의 요청을 허용한다는 뜻입니다. 슬라이딩 창은 고정 창보다 부드럽게 작동하고 창 경계에서 요청이 몰리는 현상을 줄입니다.

3단계: API에서 사용

// app/api/protected/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  // 사용자 IP 가져오기
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  // 속도 제한 확인
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many requests',
        limit,
        remaining,
        reset: new Date(reset),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  // 제한을 통과하면 정상 처리
  return NextResponse.json({ data: 'Success' });
}

여기서는 IP를 제한 식별자로 사용합니다. 사용자 로그인이 있다면 userId를 사용할 수 있습니다.

const identifier = session?.userId || ip;
const { success } = await ratelimit.limit(identifier);

이렇게 하면 로그인 사용자는 사용자별로, 로그인하지 않은 사용자는 IP별로 제한할 수 있어 더 정확합니다.

4단계: Middleware에서 전역 제한

모든 API에 같은 코드를 작성하고 싶지 않다면 Middleware에서 한 번에 처리합니다.

// middleware.ts
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  const ip = request.ip || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

이제 모든 API가 자동으로 보호됩니다.

입력 검증과 방어

입력 검증이 첫 번째 방어선인 이유

“사용자 입력을 절대 신뢰하지 마라.” 보안 분야의 철칙입니다.

프런트엔드에 여러 폼 검증을 넣었나요? 그것만으로는 부족합니다. 개발자 도구를 열고 코드를 바꾸면 검증을 바로 우회할 수 있습니다. 진짜 방어는 서버에서 이루어집니다.

SQL 인젝션. 사용자가 입력란에 '; DROP TABLE users; --를 넣었는데 SQL 문자열을 그대로 조합하면 데이터베이스가 망가집니다. 지금은 대부분 ORM을 사용하지만 원시 SQL을 쓰는 경우도 여전히 많습니다.

XSS 공격. 사용자가 <script>alert('hacked')</script>를 제출해 데이터베이스에 저장했고 다른 사용자가 페이지를 열면 스크립트가 실행되어 Cookie가 탈취됩니다. React가 자동으로 이스케이프하더라도 dangerouslySetInnerHTML을 사용하면 똑같이 공격받을 수 있습니다.

DoS 공격. 사용자가 10MB 크기의 JSON을 보내면 Serverless 함수의 메모리가 바로 소진될 수 있습니다. 지나치게 긴 문자열을 보내 정규식 백트래킹을 끝없이 일으킬 수도 있습니다.

입력 검증만으로도 대부분의 단순 공격을 막을 수 있습니다. 검증하지 않으면 다른 방어 수단을 아무리 많이 두어도 구멍 난 체와 같습니다.

Zod를 사용한 타입 안전성 검증

Zod는 제가 현재 가장 선호하는 검증 라이브러리입니다. TypeScript로 작성되어 타입 시스템과 완벽하게 맞습니다.

설치합니다.

npm install zod

기본 사용법

schema를 정의합니다.

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().int().min(18).max(120),
});

API에서 검증합니다.

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(request: Request) {
  const body = await request.json();

  // 데이터 검증
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.format(),
      },
      { status: 400 }
    );
  }

  // 검증을 통과한 타입 안전 데이터 가져오기
  const { email, password, age } = result.data;

  // 계속 처리...
}

safeParse는 예외를 던지지 않습니다. parse는 예외를 던지므로 try-catch로 처리해야 합니다.

직접 검증하는 것보다 나은 이유는 무엇일까요?

직접 작성하면 다음과 같습니다.

if (!body.email || typeof body.email !== 'string') {
  return error;
}
if (!body.email.includes('@')) {
  return error;
}
// 끝도 없이 이어짐...

Zod에서는 다음 한 줄이면 됩니다.

z.string().email()

검증이 한 줄로 끝나고 타입도 자동으로 추론됩니다.

완전한 입력 검증 방안

입력 검증은 필드 타입만 확인하는 것이 아닙니다. 비즈니스 로직과 경계 조건도 고려해야 합니다.

1. 요청 본문(body) 검증

const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().max(10000), // 크기를 제한해 지나치게 큰 입력 방지
  tags: z.array(z.string()).max(10), // 배열 길이 제한
  publishedAt: z.string().datetime().optional(),
});

2. 쿼리 매개변수(query) 검증

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  const querySchema = z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    sort: z.enum(['asc', 'desc']).default('desc'),
  });

  const params = querySchema.parse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    sort: searchParams.get('sort'),
  });

  // params.page는 항상 숫자이며 타입 안전성이 보장됨
}

z.coerce.number()는 문자열을 숫자로 자동 변환하므로 매우 편리합니다.

3. 사용자 지정 검증 규칙

const passwordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
  .refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
  .refine((val) => /[0-9]/.test(val), 'Must contain number');

비동기 검증도 가능합니다.

const emailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'Email already taken'
);

4. 오류 처리

Zod의 기본 오류 메시지도 친절하지만 원하는 형태로 바꿀 수 있습니다.

if (!result.success) {
  const errors = result.error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));

  return NextResponse.json({ errors }, { status: 400 });
}

구조화된 오류를 반환하면 프런트엔드에서 표시하기 쉽습니다.

그 밖의 보안 조치

입력 검증 외에도 다음과 같은 보안 조치가 중요합니다.

1. CSRF 보호

Next.js Server Actions에는 CSRF 보호 기능이 내장되어 있습니다. 요청의 Origin 헤더와 Host 헤더를 비교해 일치하지 않으면 거부합니다.

API Routes에서는 직접 처리해야 합니다. NextAuth를 사용하면 자동으로 처리됩니다. 직접 구현한다면 CSRF token을 사용할 수 있습니다.

// token을 생성해 Cookie에 넣고, 프런트엔드 요청에 포함한 뒤 서버에서 비교

2. Content Security Policy(CSP)

next.config.js에서 CSP를 설정해 페이지가 불러올 수 있는 리소스를 제한합니다.

{
  headers: [
    {
      key: 'Content-Security-Policy',
      value: "default-src 'self'; script-src 'self'; style-src 'self';",
    },
  ],
}

이렇게 하면 XSS 취약점이 있어도 악성 스크립트를 불러올 수 없습니다.

3. 환경 변수 보안

Next.js 환경 변수는 두 종류로 나뉩니다.

  • NEXT_PUBLIC_*로 시작하면 프런트엔드에 노출됨
  • NEXT_PUBLIC_ 없이 작성하면 서버에서만 사용 가능함

절대로 비밀 키를 NEXT_PUBLIC_ 변수에 넣지 마세요. API 키를 NEXT_PUBLIC_API_KEY에 넣었다가 그대로 유출된 사례도 봤습니다.

4. SQL 인젝션 방어

Prisma나 Drizzle 같은 ORM은 쿼리를 자동으로 매개변수화하므로 대부분 문제가 없습니다.

원시 SQL을 꼭 작성해야 한다면 매개변수화하세요.

// ❌ 위험
db.query(`SELECT * FROM users WHERE id = ${userId}`);

// ✅ 안전
db.query('SELECT * FROM users WHERE id = ?', [userId]);

5. 의존성 정기 업데이트

보안 취약점은 의존성 라이브러리에서 자주 발생합니다. 다음 명령을 정기적으로 실행하세요.

npm audit
npm update

올해 2월에 발견된 React 취약점도 최신 버전으로 업데이트하면 해결됐습니다. 번거롭더라도 업데이트 한 번으로 큰 피해를 막을 수 있습니다.

전체 보안 점검표

지금까지 설명한 내용을 다음 점검표로 정리했습니다. 프로젝트와 하나씩 비교해 보세요.

인증 보안 점검표

  • ✅ Token은 localStorage가 아닌 HttpOnly Cookie에 저장
  • ✅ Access Token 유효 기간은 30분 이하
  • ✅ Refresh Token 메커니즘 구현
  • ✅ JWT 비밀 키는 최소 32자이며 환경 변수에 저장
  • ✅ Cookie의 securesameSite 속성 활성화
  • ✅ Middleware로 민감한 API 보호

CORS 설정 점검표

  • ✅ 민감한 API에 Access-Control-Allow-Origin: *를 사용하지 않음
  • ✅ 허용할 도메인 목록을 명시
  • ✅ OPTIONS Preflight 요청을 올바르게 처리
  • ✅ 자격 증명을 전송해야 할 때 Access-Control-Allow-Credentials: true 설정
  • ✅ 프로덕션 환경에서 CORS 설정 적용 여부 확인

속도 제한 점검표

  • ✅ 주요 엔드포인트(로그인, 회원가입, 비밀번호 재설정)에 엄격한 속도 제한 적용
  • ✅ 인증 사용자와 미인증 사용자의 제한 정책 구분
  • ✅ 429 상태 코드와 Retry-After 헤더 반환
  • ✅ Redis나 Upstash 같은 영구 저장소를 사용해 Serverless에서 제한이 무효화되지 않도록 함
  • ✅ 제한 발생 현황을 모니터링하고 임계값을 적절히 조정

입력 검증 점검표

  • ✅ 모든 사용자 입력을 서버에서 검증
  • ✅ Zod 또는 유사 도구로 타입 안전성 검증
  • ✅ 문자열, 배열, 객체의 최대 길이 제한
  • ✅ 이메일, URL, 날짜 등 데이터 형식 검증
  • ✅ 명확한 검증 오류 메시지 반환

정기 감사 점검표

  • ✅ 매월 최소 한 번 npm audit를 실행하고 고위험 취약점 수정
  • ✅ Next.js와 React를 최신 안정 버전으로 신속히 업데이트
  • ✅ Next.js 보안 공지를 구독하고 새 취약점을 확인
  • ✅ 환경 변수를 점검해 비밀 키가 프런트엔드로 유출되지 않게 함
  • ✅ Code Review에서 인증과 권한 로직을 중점적으로 확인

이 점검표를 출력해 책상에 붙여 두고 새 프로젝트를 시작하기 전 한 번, 배포하기 전 다시 한 번 확인하세요.

결론

긴 내용을 한 문장으로 요약하면 API 보안은 한 번 설정하고 끝나는 일이 아니라 시스템 전체의 지속적인 과제입니다.

인증, CORS, 속도 제한, 검증을 각각 설정하는 일이 번거롭게 느껴질 수 있습니다. 하지만 데이터가 유출되고 서버가 마비되거나 엄청난 청구서를 받은 뒤에 대응하면 대가는 훨씬 커집니다.

제 경험상 처음부터 이 모든 설정을 구성하는 데는 반나절에서 하루 정도 걸립니다. 한 번 구성한 뒤에는 코드를 복사하고 매개변수만 조금 바꾸면 되므로 새 프로젝트에서도 10여 분이면 적용할 수 있습니다. 무엇보다 언젠가 공격받을까 걱정하지 않고 편히 잘 수 있습니다.

실행 권장 사항:

  1. 기존 프로젝트를 즉시 점검하세요. 점검표와 비교해 빠진 부분을 찾습니다.
  2. 가장 중요한 것부터 시작하세요. 인증과 속도 제한을 먼저 적용하고 나머지를 보완합니다.
  3. 보안 소식을 구독하세요. Next.js 공식 블로그와 GitHub Security Advisories를 확인합니다.
  4. 팀에 공유하세요. 보안은 한 사람만의 일이 아니라 모두의 책임입니다.

마지막으로 다시 강조하겠습니다. 2025년 12월에 발견된 CVSS 10.0 React 취약점은 영향 범위가 매우 넓었습니다. 아직 업데이트하지 않았다면 즉시 최신 버전으로 업그레이드하세요. 보안 업데이트는 정말 미루면 안 됩니다.

API 보안은 긴 여정이지만 모든 단계에 충분한 가치가 있습니다. 이 글이 시행착오를 줄이고 필요한 보안 조치를 더 일찍 갖추는 데 도움이 되기를 바랍니다.

FAQ

Next.js API에는 JWT 인증과 Session 인증 중 무엇을 사용해야 하나요?
상황에 따라 다릅니다. Serverless 배포나 교차 출처 인증에는 상태가 없고 확장하기 쉬운 JWT가 적합합니다. 서버에서 사용자를 강제로 로그아웃시키는 등 능동적으로 제어해야 하거나 보안 요구 수준이 매우 높다면 Session이 적합합니다. 개인 프로젝트에는 JWT, 엔터프라이즈 애플리케이션에는 Session을 권장합니다.
JWT token을 localStorage에 저장하면 안 되는 이유는 무엇인가요?
localStorage는 JavaScript로 읽을 수 있어 XSS 공격이 성공하면 token이 탈취될 수 있기 때문입니다. JavaScript에서 전혀 읽을 수 없는 HttpOnly Cookie에 저장하면 XSS 취약점이 있어도 token 탈취를 막을 수 있습니다.
개발 환경에서는 CORS 오류가 없는데 프로덕션에서는 왜 발생하나요?
개발할 때는 프런트엔드와 API가 모두 localhost:3000에 있어 동일 출처이므로 교차 출처 문제가 없습니다. 프로덕션에서는 프런트엔드와 API가 서로 다른 도메인에 있을 수 있어 브라우저가 CORS를 검사합니다. API에서 Access-Control-Allow-Origin 헤더를 올바르게 설정해야 합니다.
Serverless 환경에서 속도 제한을 어떻게 구현하나요?
@upstash/ratelimit과 Vercel KV 조합을 권장합니다. Serverless 함수는 요청마다 새 인스턴스에서 실행될 수 있고 메모리를 공유하지 않으므로 순수 메모리 기반 제한은 무효화될 수 있습니다. Upstash Redis의 영구 저장소를 사용하면 모든 인스턴스가 제한 데이터를 공유하므로 Serverless에 잘 맞습니다.
API 비밀번호 무차별 대입 공격을 어떻게 막을 수 있나요?
다층 방어를 적용하세요. 1) 로그인 엔드포인트에 IP당 분당 최대 5회처럼 엄격한 속도 제한을 설정하고, 2) Zod로 비밀번호 강도를 검증하며, 3) 5회 연속 실패 시 30분간 잠그는 계정 잠금 기능을 구현하고, 4) CAPTCHA를 추가해 공격 비용을 높입니다.

7분 읽기 · 게시일: 2026년 1월 5일 · 수정일: 2026년 9월 4일

댓글

GitHub로 로그인하여 댓글을 남기세요

Easton BlogEaston Blog