codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
에듀›웹 보안의 기초 — JWT · OAuth · OWASP›4단계

4단계

입력 검증 + 길이 상한

0회 조회

입력 검증 + 길이 상한

클라이언트가 보낸 모든 값은 의심. zod · Valibot 같은 스키마 라이브러리로 서버에서 다시 검증.

1. 왜 서버에서 또 검증?

  • 브라우저 검증은 우회 가능 (DevTools · curl)
  • 프론트 버전 불일치로 검증 누락 가능
  • 악의적 클라이언트는 정상 UI 를 쓰지 않음

프론트 검증은 UX, 서버 검증은 보안 경계.

2. zod 최소 예

pnpm add zod
import { z } from "zod";

const CreatePostSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1).max(10000),
  tags: z.array(z.string().max(30)).max(10).optional(),
  published: z.boolean().default(false),
});

export async function POST(req: Request) {
  const json = await req.json();
  const parsed = CreatePostSchema.safeParse(json);
  if (!parsed.success) {
    return NextResponse.json(
      { error: "invalid", issues: parsed.error.flatten() },
      { status: 400 }
    );
  }
  const post = parsed.data;
  // 여기서부터 타입 안전 · 내용 신뢰
}

3. 길이 상한이 중요한 이유

  • DoS 방어 — 메가바이트 문자열 수천 개가 DB 메모리 날림
  • 저장 비용 — 자릿수 제한이 없으면 한 사용자가 GB 단위 기록
  • 인덱스 성능 — PostgreSQL text 인덱스는 길어질수록 느려짐

모든 string 필드에 .max(...) 습관화.

4. 공통 스키마 재사용

// schemas/common.ts
export const EmailSchema = z.string().email().max(120).toLowerCase();
export const SlugSchema = z.string().regex(/^[a-z0-9-]+$/).min(1).max(80);
export const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);

// schemas/user.ts
export const CreateUserSchema = z.object({
  email: EmailSchema,
  nickname: z.string().min(2).max(30),
  birthdate: IsoDateSchema.optional(),
});

재사용으로 "email 정규식을 여러 곳에서 다르게" 문제 방지.

5. 중첩 · 유니온

const NotificationSchema = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("comment"),
    postId: z.number().int().positive(),
    commentId: z.number().int().positive(),
  }),
  z.object({
    type: z.literal("like"),
    postId: z.number().int().positive(),
  }),
  z.object({
    type: z.literal("follow"),
    userId: z.string().uuid(),
  }),
]);

type 에 따라 다른 필드 요구. 런타임에 typescript-safe.

6. transform — 파싱 + 정규화

const TrimmedStringSchema = z.string().transform((s) => s.trim()).pipe(z.string().min(1));

const PhoneSchema = z.string()
  .transform((s) => s.replace(/[\s-]/g, ""))
  .pipe(z.string().regex(/^010\d{8}$/));

사용자 입력 다양성을 흡수한 뒤 정규 패턴 검증.

7. 에러 응답 포맷

if (!parsed.success) {
  return NextResponse.json({
    error: "validation_failed",
    issues: parsed.error.issues.map(i => ({
      path: i.path.join("."),
      message: i.message,
      code: i.code,
    })),
  }, { status: 400 });
}

프론트가 필드별 에러 표시 가능. 일관된 에러 스키마 SSOT 유지.

8. Query string · URL params

const QuerySchema = z.object({
  page: z.coerce.number().int().min(1).max(10000).default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  q: z.string().max(100).optional(),
});

const params = QuerySchema.parse(Object.fromEntries(url.searchParams));

z.coerce.number() 로 string → number 자동 변환.

9. 파일 업로드

const MAX_SIZE = 10 * 1024 * 1024;       // 10 MB
const ALLOWED_MIME = new Set(["image/jpeg", "image/png", "image/webp"]);

if (file.size > MAX_SIZE) return NextResponse.json({ error: "too_large" }, { status: 413 });
if (!ALLOWED_MIME.has(file.type)) return NextResponse.json({ error: "mime" }, { status: 415 });

// MIME sniffing — 실제 매직 바이트도 확인
const header = new Uint8Array(await file.slice(0, 4).arrayBuffer());
// ... 실제 시그니처 검증

클라이언트 file.type 은 변조 가능. 매직 바이트 · file-type 라이브러리 권장.

10. 자주 걸리는 자리

  • max 누락 — string z.string() 만 쓰면 무한 길이
  • coerce 없이 query parsing — string vs number 타입 혼동
  • 에러 메시지에 내부 정보 포함 — DB 구조 힌트가 공격자에게
  • 검증 후 재변형 — parse 결과를 그대로 써야 안전

하고픈 말

z.string().min(1).max(N) 한 줄이 XSS · DoS · 메모리 공격을 동시에 줄입니다. 스키마 파일을 만들고 초반부터 훈련하는 편이 쉬움.

Next

  • 05-rate-limit-cors

← 3단계

OAuth + state · PKCE

5단계 →

Rate limit + CORS + 보안 헤더