메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
BadRequestException은 ν΄λΌμ΄μ–ΈνŠΈμ˜ μš”μ²­μ΄ 잘λͺ»λœ 경우 μ‚¬μš©ν•˜λŠ” μ˜ˆμ™Έμž…λ‹ˆλ‹€. HTTP 400 μƒνƒœ μ½”λ“œλ₯Ό λ°˜ν™˜ν•˜λ©°, μœ νš¨μ„± 검증 μ‹€νŒ¨, 잘λͺ»λœ λ§€κ°œλ³€μˆ˜, μš”μ²­ ν˜•μ‹ 였λ₯˜ 등에 μ‚¬μš©λ©λ‹ˆλ‹€.

κΈ°λ³Έ μ‚¬μš©λ²•

class BadRequestException extends SoException {
  constructor(message: LocalizedString, payload?: unknown);
}
κ°„λ‹¨ν•œ μ˜ˆμ‹œ:
@api()
async createPost(title: string, content: string) {
  if (!title || title.trim().length === 0) {
    throw new BadRequestException("제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€");
  }

  if (content.length > 10000) {
    throw new BadRequestException("본문은 10,000자λ₯Ό μ΄ˆκ³Όν•  수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  return this.create({ title, content });
}

μ‹€μš© 예제

μœ νš¨μ„± 검증

@api()
async updateUser(
  userId: number,
  email?: string,
  age?: number,
  phoneNumber?: string
) {
  const errors: Array<{ field: string; message: string }> = [];

  // 이메일 검증
  if (email && !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
    errors.push({
      field: "email",
      message: "μœ νš¨ν•˜μ§€ μ•Šμ€ 이메일 ν˜•μ‹μž…λ‹ˆλ‹€"
    });
  }

  // λ‚˜μ΄ 검증
  if (age !== undefined && (age < 0 || age > 150)) {
    errors.push({
      field: "age",
      message: "λ‚˜μ΄λŠ” 0~150 사이여야 ν•©λ‹ˆλ‹€"
    });
  }

  // μ „ν™”λ²ˆν˜Έ 검증
  if (phoneNumber && !phoneNumber.match(/^\d{3}-\d{3,4}-\d{4}$/)) {
    errors.push({
      field: "phoneNumber",
      message: "μ „ν™”λ²ˆν˜Έ ν˜•μ‹μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€ (예: 010-1234-5678)"
    });
  }

  if (errors.length > 0) {
    throw new BadRequestException("μž…λ ₯ 데이터가 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€", {
      errors
    });
  }

  return this.update(userId, { email, age, phoneNumber });
}

λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ 검증

@api()
async transferMoney(
  ctx: Context,
  fromAccountId: number,
  toAccountId: number,
  amount: number
) {
  // κΈˆμ•‘ 검증
  if (amount <= 0) {
    throw new BadRequestException(
      "이체 κΈˆμ•‘μ€ 0보닀 컀야 ν•©λ‹ˆλ‹€",
      { amount }
    );
  }

  if (amount > 10000000) {
    throw new BadRequestException(
      "1회 이체 ν•œλ„λŠ” μ²œλ§Œμ›μž…λ‹ˆλ‹€",
      { amount, limit: 10000000 }
    );
  }

  // κ³„μ’Œ 검증
  if (fromAccountId === toAccountId) {
    throw new BadRequestException("λ™μΌν•œ κ³„μ’Œλ‘œλŠ” 이체할 수 μ—†μŠ΅λ‹ˆλ‹€");
  }

  const fromAccount = await AccountModel.findById(fromAccountId);

  if (fromAccount.userId !== ctx.user!.id) {
    throw new BadRequestException("본인 κ³„μ’Œμ—μ„œλ§Œ 이체할 수 μžˆμŠ΅λ‹ˆλ‹€");
  }

  if (fromAccount.balance < amount) {
    throw new BadRequestException(
      "μž”μ•‘μ΄ λΆ€μ‘±ν•©λ‹ˆλ‹€",
      {
        balance: fromAccount.balance,
        requested: amount,
        shortage: amount - fromAccount.balance
      }
    );
  }

  // 이체 μ‹€ν–‰
  return this.executeTransfer(fromAccountId, toAccountId, amount);
}

파일 μ—…λ‘œλ“œ 검증

@upload()
async uploadProfileImage(ctx: Context) {
  const { bufferedFiles } = Sonamu.getContext();
  const file = bufferedFiles?.[0]; // 첫 번째 파일 μ‚¬μš©

  if (!file) {
    throw new BadRequestException("파일이 ν•„μš”ν•©λ‹ˆλ‹€");
  }

  // 파일 크기 검증 (5MB)
  const maxSize = 5 * 1024 * 1024;
  if (file.size > maxSize) {
    throw new BadRequestException(
      "파일 ν¬κΈ°λŠ” 5MBλ₯Ό μ΄ˆκ³Όν•  수 μ—†μŠ΅λ‹ˆλ‹€",
      {
        size: file.size,
        maxSize,
        sizeMB: (file.size / 1024 / 1024).toFixed(2),
        maxSizeMB: 5
      }
    );
  }

  // 파일 ν˜•μ‹ 검증
  const allowedTypes = ["image/jpeg", "image/png", "image/gif", "image/webp"];
  if (!allowedTypes.includes(file.mimetype)) {
    throw new BadRequestException(
      "μ§€μ›ν•˜μ§€ μ•ŠλŠ” 이미지 ν˜•μ‹μž…λ‹ˆλ‹€",
      {
        provided: file.mimetype,
        allowed: allowedTypes
      }
    );
  }

  // 이미지 μ €μž₯
  return Sonamu.storage.save({
    file: file.buffer,
    filename: file.filename,
    bucket: "profile-images"
  });
}

λ‚ μ§œ/μ‹œκ°„ 검증

@api()
async createReservation(
  userId: number,
  startDate: Date,
  endDate: Date,
  guestCount: number
) {
  const now = new Date();

  // κ³Όκ±° λ‚ μ§œ 체크
  if (startDate < now) {
    throw new BadRequestException(
      "μ˜ˆμ•½ μ‹œμž‘μΌμ€ ν˜„μž¬ μ‹œκ° 이후여야 ν•©λ‹ˆλ‹€",
      { startDate, now }
    );
  }

  // μ‹œμž‘μΌ/μ’…λ£ŒμΌ μˆœμ„œ 체크
  if (startDate >= endDate) {
    throw new BadRequestException(
      "μ’…λ£ŒμΌμ€ μ‹œμž‘μΌλ³΄λ‹€ 이후여야 ν•©λ‹ˆλ‹€",
      { startDate, endDate }
    );
  }

  // μ΅œλŒ€ μ˜ˆμ•½ κΈ°κ°„ 체크 (30일)
  const maxDays = 30;
  const daysDiff = Math.ceil(
    (endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)
  );

  if (daysDiff > maxDays) {
    throw new BadRequestException(
      `μ˜ˆμ•½ 기간은 μ΅œλŒ€ ${maxDays}μΌκΉŒμ§€ κ°€λŠ₯ν•©λ‹ˆλ‹€`,
      { requestedDays: daysDiff, maxDays }
    );
  }

  // 인원 체크
  if (guestCount < 1 || guestCount > 10) {
    throw new BadRequestException(
      "μ˜ˆμ•½ 인원은 1~10λͺ… 사이여야 ν•©λ‹ˆλ‹€",
      { guestCount }
    );
  }

  return this.create({ userId, startDate, endDate, guestCount });
}

λ°°μ—΄/객체 검증

@api()
async createOrder(
  userId: number,
  items: Array<{ productId: number; quantity: number }>
) {
  // 빈 주문 체크
  if (!items || items.length === 0) {
    throw new BadRequestException("μ΅œμ†Œ 1개 μ΄μƒμ˜ μƒν’ˆμ΄ ν•„μš”ν•©λ‹ˆλ‹€");
  }

  // μ΅œλŒ€ μ£Όλ¬Έ μˆ˜λŸ‰ 체크
  if (items.length > 50) {
    throw new BadRequestException(
      "ν•œ λ²ˆμ— μ΅œλŒ€ 50개 μƒν’ˆκΉŒμ§€ μ£Όλ¬Έν•  수 μžˆμŠ΅λ‹ˆλ‹€",
      { itemCount: items.length, maxItems: 50 }
    );
  }

  // 각 ν•­λͺ© 검증
  const errors: Array<{ index: number; message: string }> = [];

  items.forEach((item, index) => {
    if (!item.productId || item.productId <= 0) {
      errors.push({
        index,
        message: "μœ νš¨ν•˜μ§€ μ•Šμ€ μƒν’ˆ IDμž…λ‹ˆλ‹€"
      });
    }

    if (!item.quantity || item.quantity <= 0) {
      errors.push({
        index,
        message: "μˆ˜λŸ‰μ€ 1개 이상이어야 ν•©λ‹ˆλ‹€"
      });
    }

    if (item.quantity > 999) {
      errors.push({
        index,
        message: "μƒν’ˆλ‹Ή μ΅œλŒ€ 999κ°œκΉŒμ§€ μ£Όλ¬Έν•  수 μžˆμŠ΅λ‹ˆλ‹€"
      });
    }
  });

  if (errors.length > 0) {
    throw new BadRequestException("μ£Όλ¬Έ ν•­λͺ©μ΄ μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€", {
      errors
    });
  }

  return this.createOrder(userId, items);
}

Zod κ²€μ¦κ³Όμ˜ μ‘°ν•©

SonamuλŠ” API νŒŒλΌλ―Έν„°λ₯Ό μžλ™μœΌλ‘œ Zod μŠ€ν‚€λ§ˆλ‘œ κ²€μ¦ν•˜μ§€λ§Œ, μΆ”κ°€ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 κ²€μ¦μ—λŠ” BadRequestException을 μ‚¬μš©ν•©λ‹ˆλ‹€:
@api()
async createProduct(
  name: string,        // Zod둜 μžλ™ 검증: λ¬Έμžμ—΄ νƒ€μž…
  price: number,       // Zod둜 μžλ™ 검증: 숫자 νƒ€μž…
  categoryId: number   // Zod둜 μžλ™ 검증: 숫자 νƒ€μž…
) {
  // μΆ”κ°€ λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 검증
  if (price < 0) {
    throw new BadRequestException("가격은 0 이상이어야 ν•©λ‹ˆλ‹€");
  }

  const category = await CategoryModel.findById(categoryId);
  if (!category) {
    throw new BadRequestException(
      "μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” μΉ΄ν…Œκ³ λ¦¬μž…λ‹ˆλ‹€",
      { categoryId }
    );
  }

  if (!category.isActive) {
    throw new BadRequestException(
      "λΉ„ν™œμ„±ν™”λœ μΉ΄ν…Œκ³ λ¦¬μ—λŠ” μƒν’ˆμ„ μΆ”κ°€ν•  수 μ—†μŠ΅λ‹ˆλ‹€",
      { categoryId, categoryName: category.name }
    );
  }

  return this.create({ name, price, categoryId });
}

payload ν™œμš© νŒ¨ν„΄

ν•„λ“œλ³„ 였λ₯˜ λͺ©λ‘

throw new BadRequestException("μž…λ ₯ 데이터 검증 μ‹€νŒ¨", {
  errors: [
    { field: "email", code: "INVALID_FORMAT" },
    { field: "password", code: "TOO_SHORT", minLength: 8 },
  ],
});

μ œν•œ 사항 정보

throw new BadRequestException("파일 크기 초과", {
  size: file.size,
  limit: 5242880, // 5MB in bytes
  unit: "bytes",
});

μž¬μ‹œλ„ κ°€λŠ₯ 정보

throw new BadRequestException("일일 μš”μ²­ ν•œλ„ 초과", {
  limit: 100,
  used: 100,
  resetsAt: new Date("2024-01-01T00:00:00Z"),
});

ν΄λΌμ΄μ–ΈνŠΈ 응닡 μ˜ˆμ‹œ

κΈ°λ³Έ 응닡

{
  "statusCode": 400,
  "message": "제λͺ©μ€ ν•„μˆ˜μž…λ‹ˆλ‹€"
}

payload 포함 응닡

{
  "statusCode": 400,
  "message": "μž…λ ₯ 데이터가 μœ νš¨ν•˜μ§€ μ•ŠμŠ΅λ‹ˆλ‹€",
  "payload": {
    "errors": [
      {
        "field": "email",
        "message": "μœ νš¨ν•˜μ§€ μ•Šμ€ 이메일 ν˜•μ‹μž…λ‹ˆλ‹€"
      },
      {
        "field": "age",
        "message": "λ‚˜μ΄λŠ” 0~150 사이여야 ν•©λ‹ˆλ‹€"
      }
    ]
  }
}

κ΄€λ ¨ λ¬Έμ„œ