보안 우선 SaaS 보일러플레이트를 100% 테스트 커버리지로 구축한 방법

발행: (2025년 12월 18일 오후 07:55 GMT+9)
12 min read
원문: Dev.to

Source: Dev.to

우리는 나중에 보안을 추가할 거야.

이 말을 해본 적이 있다면 혼자가 아닙니다. 저도 그랬습니다. 하지만 수많은 데이터 유출 사건, 새벽 2시의 긴급 보안 패치를 목격하고, 4,450,000 달러라는 평균 유출 비용을 알게 된 뒤 저는 다른 접근 방식을 택하기로 했습니다.

이 이야기는 ShipSecure— 보안이 사후에 고려되는 것이 아니라 기반이 되는 Next.js 보일러플레이트를 제가 어떻게 만들었는지에 대한 이야기입니다.

The Problem: Security as a TODO Item

Most developers approach security like this:

  • ✅ Build landing page
  • ✅ Add authentication
  • ✅ Integrate payments
  • Security (we’ll get to it later)

Sound familiar? The issue is that later often means after the first incident. By then you’ve already:

  • Lost customer trust
  • Faced potential regulatory fines (GDPR, CCPA, SOC 2)
  • Spent weeks retrofitting security into an architecture that wasn’t designed for it

해결책: 보안‑우선 개발

보안을 기능이 아니라 아키텍처로 다루었습니다. 모든 코드 라인은 보안을 염두에 두고 작성되었으며, 모든 보안 기능은 테스트로 검증됩니다.

핵심 개념을 하나씩 살펴보겠습니다.

1. 보안 헤더 – 첫 번째 방어선

대부분의 개발자는 Content‑Security‑Policy에 대해 알고 있지만, 실제로 올바르게 구현하는 사람은 얼마나 될까요? 단순히 헤더 하나를 추가하는 것이 아니라 완전한 보안‑헤더 전략이 필요합니다.

Header방지하는 위협
Content‑Security‑PolicyXSS 공격, 코드 인젝션
Strict‑Transport‑Security프로토콜 다운그레이드 공격
X‑Content‑Type‑OptionsMIME‑스니핑 취약점
X‑Frame‑Options클릭재킹 공격
Referrer‑Policy정보 누출
Permissions‑Policy무단 브라우저 기능 접근

어려운 점은? 이 헤더들을 서로 충돌 없이 동작하도록 구성하는 것입니다. CSP만 해도 수십 개의 디렉티브가 있어 세심한 설정이 필요합니다—하 하나라도 잘못되면 OAuth 흐름이 깨지거나, 분석 도구가 작동하지 않거나, 스타일이 로드되지 않을 수 있습니다.

내 접근법: 모든 라우트에 일관되게 적용되는 중앙화된 getSecurityHeaders() 함수를 만들어, 적절히 구성된 헤더 객체를 반환합니다.

// lib/securityHeaders.ts
export function getSecurityHeaders() {
  return {
    "Content-Security-Policy":
      "default-src 'self'; script-src 'self' https://trusted.cdn.com",
    "Strict-Transport-Security":
      "max-age=63072000; includeSubDomains; preload",
    "X-Content-Type-Options": "nosniff",
    "X-Frame-Options": "DENY",
    "Referrer-Policy": "no-referrer",
    "Permissions-Policy": "geolocation=(), microphone=()",
  };
}

2. Rate Limiting – “천천히” 라는 말의 예술

Rate limiting이 없으면 API는 다음과 같은 공격에 노출됩니다:

  • 🔓 로그인에 대한 무차별 대입 공격
  • 💥 DDoS 시도
  • 🎭 Credential stuffing

프로덕션 vs. 개발
프로덕션에서는 서버리스 인스턴스 간에 제한이 적용되도록 분산 레이트 리밋(예: Upstash Redis)이 필요합니다. 개발 환경에서는 Redis 서버가 필요하지 않아야 합니다.

내 해결책: 자동으로 폴백되는 하이브리드 아키텍처.

// lib/rateLimiter.ts
import { Redis } from "@upstash/redis";

let store: RateLimitStore;

if (process.env.UPSTASH_REDIS_URL) {
  const redis = new Redis({
    url: process.env.UPSTASH_REDIS_URL,
    token: process.env.UPSTASH_REDIS_TOKEN,
  });
  store = new RedisStore(redis);
} else {
  store = new MemoryStore(); // 간단한 인‑메모리 맵
}

// 슬라이딩‑윈도우 알고리즘을 사용하는 레이트‑리밋 미들웨어를 내보냅니다
export const rateLimiter = createRateLimiter({
  store,
  window: 60, // 초
  limit: 100, // 윈도우당 요청 수
  algorithm: "sliding",
});

왜 슬라이딩‑윈도우인가?
고정 윈도우에서는 공격자가 경계 시점에 “버스트”를 일으킬 수 있습니다(예: :59에 10개, :01에 또 10개). 슬라이딩‑윈도우는 이러한 엣지 케이스를 제거합니다.

3. 테스트‑주도 보안 – 게임 체인저

모든 보안 기능은 테스트로 뒷받침됩니다.

왜 중요한가

  • 테스트는 주장을 증명한다. “우리는 CSP를 사용한다”는 말은 누구나 할 수 있지만, 테스트가 이를 검증합니다.
  • 테스트는 회귀를 방지한다. 인턴이 실수로 헤더를 제거해도 테스트가 잡아냅니다.
  • 테스트는 동작을 문서화한다. 보안 요구사항이 실행 가능한 사양이 됩니다.

3계층 테스트 전략

Layer 1: Unit Tests (Vitest)
  • 개별 보안 함수
  • 검증 로직
  • 엣지 케이스

Layer 2: Integration Tests
  • 미들웨어 동작
  • 인증 흐름
  • API 보호

Layer 3: E2E Tests (Playwright)
  • 실제 브라우저 동작
  • 헤더 검증
  • 사용자 여정 보안

그 결과? 75개 이상의 테스트가 모든 보안 메커니즘을 커버합니다. CI/CD 파이프라인은 모든 PR에서 테스트를 실행하므로, 보안 기능이 커버리지 없이 배포되는 일은 없습니다.

4. I

Source:

입력 검증 – 대부분의 침해가 시작되는 지점

SQL 인젝션, XSS, 명령어 인젝션— 모두 검증되지 않은 입력에서 비롯됩니다.

전형적인 (취약한) 코드

// ❌ This is asking for trouble
const { email, name } = await req.json();
await db.users.create({ email, name });

검증 없음, 타입 체크 없음, 보호 조치 없음.

보안‑우선 접근법

  1. 모든 입력에 대해 스키마 정의
  2. 경계에서 검증 (API 라우트, 폼 제출)
  3. 명확한 오류 메시지와 함께 빠르게 실패
  4. 절대 신뢰하지 않기 — 인증된 사용자라도 악의적인 데이터를 보낼 수 있습니다.

저는 Zod를 사용합니다. 컴파일 타임 타입 안전성과 런타임 검증을 제공하지만, 도구보다 모든 곳에서 모든 것을 검증하는 습관이 더 중요합니다.

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

export const createUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
});
// pages/api/users.ts
import { createUserSchema } from "@/lib/schemas";

export async function POST(req: Request) {
  const data = await req.json();
  const parsed = createUserSchema.safeParse(data);
  if (!parsed.success) {
    return new Response(JSON.stringify(parsed.error), { status: 400 });
  }
  await db.users.create(parsed.data);
  return new Response(null, { status: 201 });
}

5. 올바른 인증 구현

인증은 대부분의 보안 취약점이 존재하는 영역입니다: 비밀번호 저장, 세션 관리, CSRF 방어, OAuth 흐름 등.

내 접근법: Auth.js v5(이전 명칭 NextAuth)를 사용합니다. 다음을 기본 제공하기 때문입니다:

  • ✅ 기본적으로 HttpOnly, Secure 쿠키
  • ✅ 내장된 CSRF 방어
  • ✅ 검증된 OAuth 구현체
  • ✅ 안정적인 세션 관리

핵심 인사이트: 인증은 단순히 로그인/로그아웃이 아니라 다음을 포함합니다:

  • 세션이 어떻게 저장되고 검증되는가
  • 쿠키 설정 (SameSite, Secure, HttpOnly)
  • 토큰 처리 및 회전
  • 세션을 실제로 무효화하는 올바른 로그아웃

Auth.js는 이러한 모든 항목을 바로 사용할 수 있게 제공하여, 치명적인 실수를 저지를 가능성을 크게 줄여줍니다.

힘들게 얻은 교훈

이것을 만든 뒤, 내가 더 일찍 알았으면 좋았을 것들:

1. 보안은 설계 단계에서 10배 저렴해진다

기존 코드베이스에 보안을 뒤늦게 적용하는 것은 고통스럽다. 보안을 염두에 두지 않고 내린 모든 결정은 나중에 비용이 많이 드는 수정이 된다.

(…나머지 교훈은 원본 내용에 따라 이어집니다.)

2. 테스트가 최고의 보안 문서다

누군가 “X를 어떻게 방지하나요?” 라고 물으면 테스트 파일을 가리키라. 그것은 약속이 아니라 증거다.

3. 개발자 경험이 중요하다

보안 조치가 번거롭다면, 개발자는 우회 방법을 찾는다.

  • 레이트 리밋을 위한 인‑메모리 폴백? 그것은 DX(개발자 경험) 때문이다.
  • 중앙 집중형 헤더 함수? 그것도 DX 때문이다.

보안은 코드를 사용하는 개발자에게 보이지 않아야 한다.

4. 모든 것을 자동화하라

CI/CD에서 보안 테스트를 실행하면 누구도 실수로(또는 의도적으로) 보안이 취약한 코드를 배포할 수 없다. 이는 신뢰 문제라기보다 시스템 문제다.

결론

현실은 이렇다:

  • **60 %**의 스타트업이 보안 침해 후 6 개월 이내에 문을 닫는다
  • **73 %**의 기업 고객이 보안 인증을 요구한다
  • SOC 2 준수는 영업 주기를 40 % 단축할 수 있다

보안은 기능이 아니라 경쟁력이다.

전체 구현을 원하십니까?

저는 배운 모든 것을 ShipSecure 로 패키징했습니다 — 처음부터 보안이 적용된 Next.js 15 보일러플레이트:

  • 🛡️ 모든 7개의 보안 헤더가 사전 구성되어 작동합니다
  • ⚡ Redis + 인메모리 폴백을 이용한 속도 제한
  • 🔐 보안 기본값이 적용된 Auth.js v5
  • ✅ 100 % 보안 커버리지를 위한 75개 이상의 테스트
  • 📦 Stripe 통합 포함
  • 📚 완전한 문서
  • 🔄 평생 업데이트

일회성 구매. 보안 연구를 건너뛰고 바로 개발을 시작하세요.

당신은 개발하고, 우리는 보안을 책임집니다.

👉 shipsecure.dev

Next.js 보안에 대해 질문이 있나요? 댓글을 남겨 주세요 — 모든 댓글을 읽습니다.

Back to Blog

관련 글

더 보기 »

one-shot AI에서 발생한 오류

one-shot AI 1에서 발생한 오류: @glimmer/application.json을(를) 해결할 수 없습니다 ✘ 오류: '@glimmer/application.json' 플러그인 embroider-esbui를 해결할 수 없습니다