๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
BadRequestException์€ ํด๋ผ์ด์–ธํŠธ์˜ ์š”์ฒญ์ด ์ž˜๋ชป๋œ ๊ฒฝ์šฐ ์‚ฌ์šฉํ•˜๋Š” ์˜ˆ์™ธ์ž…๋‹ˆ๋‹ค. HTTP 400 ์ƒํƒœ ์ฝ”๋“œ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋ฉฐ, ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์‹คํŒจ, ์ž˜๋ชป๋œ ๋งค๊ฐœ๋ณ€์ˆ˜, ์š”์ฒญ ํ˜•์‹ ์˜ค๋ฅ˜ ๋“ฑ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.

๊ธฐ๋ณธ ์‚ฌ์šฉ๋ฒ•

class BadRequestException extends SoException {
  constructor(
    public message = "Bad Request",
    public 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);
}

ํŒŒ์ผ ์—…๋กœ๋“œ ๊ฒ€์ฆ

@api()
@upload({ mode: "single" })
async uploadProfileImage(ctx: Context) {
  const { file } = Sonamu.getUploadContext();

  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 ์‚ฌ์ด์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค"
      }
    ]
  }
}

๊ด€๋ จ ๋ฌธ์„œ