๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
SoException์€ Sonamu์˜ ๋ชจ๋“  ์˜ˆ์™ธ ํด๋ž˜์Šค๊ฐ€ ์ƒ์†ํ•˜๋Š” ์ถ”์ƒ ๊ธฐ๋ณธ ํด๋ž˜์Šค์ž…๋‹ˆ๋‹ค. HTTP ์ƒํƒœ ์ฝ”๋“œ์™€ ๋ฉ”์‹œ์ง€, ์ถ”๊ฐ€ ํŽ˜์ด๋กœ๋“œ๋ฅผ ํฌํ•จํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

ํƒ€์ž… ์ •์˜

abstract class SoException extends Error {
  constructor(
    public readonly statusCode: number,
    public message: string,
    public payload?: unknown,
  );
}

์†์„ฑ

statusCode

readonly statusCode: number
HTTP ์‘๋‹ต ์ƒํƒœ ์ฝ”๋“œ์ž…๋‹ˆ๋‹ค. ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉด Fastify๊ฐ€ ์ด ์ฝ”๋“œ๋กœ ์‘๋‹ตํ•ฉ๋‹ˆ๋‹ค.

message

message: string
์˜ˆ์™ธ ๋ฉ”์‹œ์ง€์ž…๋‹ˆ๋‹ค. ํด๋ผ์ด์–ธํŠธ์—๊ฒŒ ์ „๋‹ฌ๋˜๋Š” ์˜ค๋ฅ˜ ์„ค๋ช…์ž…๋‹ˆ๋‹ค.

payload

payload?: unknown
์„ ํƒ์  ์ถ”๊ฐ€ ๋ฐ์ดํ„ฐ์ž…๋‹ˆ๋‹ค. ์˜ˆ์™ธ์™€ ๊ด€๋ จ๋œ ์ƒ์„ธ ์ •๋ณด๋ฅผ ์ „๋‹ฌํ•  ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์‚ฌ์šฉ ์˜ˆ์‹œ:
throw new BadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค", {
  field: "email",
  providedValue: "invalid-email",
  expectedFormat: "[email protected]"
});

์œ ํ‹ธ๋ฆฌํ‹ฐ ํ•จ์ˆ˜

isSoException()

์ฃผ์–ด์ง„ ๊ฐ’์ด SoException ์ธ์Šคํ„ด์Šค์ธ์ง€ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.
function isSoException(err: unknown): err is SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
try {
  // ์–ด๋–ค ์ž‘์—…
} catch (err) {
  if (isSoException(err)) {
    console.log(`Status: ${err.statusCode}`);
    console.log(`Message: ${err.message}`);
    console.log(`Payload:`, err.payload);
  } else {
    // ๋‹ค๋ฅธ ํƒ€์ž…์˜ ์—๋Ÿฌ ์ฒ˜๋ฆฌ
  }
}

๋‚ด์žฅ ์˜ˆ์™ธ ํด๋ž˜์Šค

Sonamu๋Š” ์ผ๋ฐ˜์ ์ธ HTTP ์˜ค๋ฅ˜ ์ƒํ™ฉ์„ ์œ„ํ•œ ์—ฌ๋Ÿฌ ๋‚ด์žฅ ์˜ˆ์™ธ ํด๋ž˜์Šค๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค.

BadRequestException (400)

์ž˜๋ชป๋œ ๋งค๊ฐœ๋ณ€์ˆ˜ ๋“ฑ ์š”์ฒญ์‚ฌํ•ญ์— ๋ฌธ์ œ๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class BadRequestException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async createUser(email: string, password: string) {
  if (!email.includes("@")) {
    throw new BadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ์ด๋ฉ”์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค");
  }
  
  if (password.length < 8) {
    throw new BadRequestException("๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ์ตœ์†Œ 8์ž ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค", {
      minLength: 8,
      providedLength: password.length
    });
  }
  
  // ์‚ฌ์šฉ์ž ์ƒ์„ฑ ๋กœ์ง
}

UnauthorizedException (401)

๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•œ ๊ฒฝ์šฐ ๋กœ๊ทธ์•„์›ƒ ์ƒํƒœ์ด๊ฑฐ๋‚˜ ์ ‘๊ทผ ๊ถŒํ•œ์ด ์—†๋Š” ์š”์ฒญ ์‹œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class UnauthorizedException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async getMyProfile(ctx: Context) {
  if (!ctx.user) {
    throw new UnauthorizedException("๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
  }
  
  return this.findById(ctx.user.id);
}

NotFoundException (404)

์กด์žฌํ•˜์ง€ ์•Š๋Š” ๋ ˆ์ฝ”๋“œ์— ์ ‘๊ทผ ์‹œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class NotFoundException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async getUserById(id: number) {
  const user = await this.findById(id);
  
  if (!user) {
    throw new NotFoundException(`์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค (ID: ${id})`);
  }
  
  return user;
}

InternalServerErrorException (500)

๋‚ด๋ถ€ ์ฒ˜๋ฆฌ ๋กœ์ง(์™ธ๋ถ€ API ํ˜ธ์ถœ ํฌํ•จ) ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class InternalServerErrorException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async processPayment(orderId: number) {
  try {
    const result = await externalPaymentAPI.charge(orderId);
    return result;
  } catch (err) {
    throw new InternalServerErrorException(
      "๊ฒฐ์ œ ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค",
      { orderId, originalError: err }
    );
  }
}

ServiceUnavailableException (503)

ํ˜„์žฌ ์ƒํƒœ์—์„œ ์ฒ˜๋ฆฌ๊ฐ€ ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class ServiceUnavailableException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async getRecommendations() {
  if (!this.mlServiceAvailable) {
    throw new ServiceUnavailableException(
      "์ถ”์ฒœ ์„œ๋น„์Šค๊ฐ€ ์ผ์‹œ์ ์œผ๋กœ ์‚ฌ์šฉ ๋ถˆ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค"
    );
  }
  
  return this.fetchRecommendations();
}

TargetNotFoundException (520)

์ž‘์—… ๋Œ€์ƒ์ด ์—†๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class TargetNotFoundException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async deleteUserPosts(userId: number) {
  const posts = await this.findByUserId(userId);
  
  if (posts.length === 0) {
    throw new TargetNotFoundException(
      `์‚ญ์ œํ•  ๊ฒŒ์‹œ๋ฌผ์ด ์—†์Šต๋‹ˆ๋‹ค (์‚ฌ์šฉ์ž ID: ${userId})`
    );
  }
  
  await this.deleteMany(posts.map(p => p.id));
}

AlreadyProcessedException (541)

์ด๋ฏธ ์ฒ˜๋ฆฌ๋œ ์š”์ฒญ์— ๋Œ€ํ•œ ์ค‘๋ณต ์ฒ˜๋ฆฌ ์‹œ๋„ ์‹œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class AlreadyProcessedException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async processOrder(orderId: number) {
  const order = await this.findById(orderId);
  
  if (order.status === "processed") {
    throw new AlreadyProcessedException(
      `์ฃผ๋ฌธ์ด ์ด๋ฏธ ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค (ID: ${orderId})`,
      { processedAt: order.processedAt }
    );
  }
  
  // ์ฃผ๋ฌธ ์ฒ˜๋ฆฌ ๋กœ์ง
}

DuplicateRowException (542)

์ค‘๋ณต์„ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ ์ค‘๋ณต ์š”์ฒญ ์‹œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค.
class DuplicateRowException extends SoException
์‚ฌ์šฉ ์˜ˆ์‹œ:
@api()
async createUser(email: string, username: string) {
  const existingUser = await this.findByEmail(email);
  
  if (existingUser) {
    throw new DuplicateRowException(
      "์ด๋ฏธ ๋“ฑ๋ก๋œ ์ด๋ฉ”์ผ์ž…๋‹ˆ๋‹ค",
      { field: "email", value: email }
    );
  }
  
  // ์‚ฌ์šฉ์ž ์ƒ์„ฑ ๋กœ์ง
}

์˜ˆ์™ธ ์ฒ˜๋ฆฌ ํ๋ฆ„

Sonamu๋Š” ๋ฐœ์ƒํ•œ ์˜ˆ์™ธ๋ฅผ ์ž๋™์œผ๋กœ ์ฒ˜๋ฆฌํ•˜์—ฌ ์ ์ ˆํ•œ HTTP ์‘๋‹ต์œผ๋กœ ๋ณ€ํ™˜ํ•ฉ๋‹ˆ๋‹ค:
// API ๋ฉ”์„œ๋“œ์—์„œ ์˜ˆ์™ธ ๋ฐœ์ƒ
@api()
async createPost(title: string) {
  if (!title) {
    throw new BadRequestException("์ œ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค");
  }
  // ...
}

// ํด๋ผ์ด์–ธํŠธ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์€ ์‘๋‹ต์„ ๋ฐ›์Œ:
// HTTP 400 Bad Request
// {
//   "statusCode": 400,
//   "message": "์ œ๋ชฉ์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค"
// }
payload๊ฐ€ ์žˆ๋Š” ๊ฒฝ์šฐ:
throw new BadRequestException("์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฐ์ดํ„ฐ", {
  errors: [
    { field: "email", message: "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค" },
    { field: "age", message: "๋‚˜์ด๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค" }
  ]
});

// ํด๋ผ์ด์–ธํŠธ ์‘๋‹ต:
// HTTP 400 Bad Request
// {
//   "statusCode": 400,
//   "message": "์œ ํšจํ•˜์ง€ ์•Š์€ ๋ฐ์ดํ„ฐ",
//   "payload": {
//     "errors": [
//       { "field": "email", "message": "์ด๋ฉ”์ผ ํ˜•์‹์ด ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค" },
//       { "field": "age", "message": "๋‚˜์ด๋Š” 0๋ณด๋‹ค ์ปค์•ผ ํ•ฉ๋‹ˆ๋‹ค" }
//     ]
//   }
// }

์˜ˆ์™ธ vs Guards

๊ฐ„๋‹จํ•œ ์ธ์ฆ ์ฒดํฌ์˜ ๊ฒฝ์šฐ Guards๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค:
// Guards ์‚ฌ์šฉ (๊ถŒ์žฅ)
@api({ guards: ["user"] })
async getMyData(ctx: Context) {
  // ctx.user๊ฐ€ ๋ณด์žฅ๋จ
  return this.findById(ctx.user!.id);
}

// ์ˆ˜๋™ ์ฒดํฌ (๋” ๋ณต์žกํ•œ ๋กœ์ง์— ์‚ฌ์šฉ)
@api()
async updateProfile(ctx: Context, data: ProfileData) {
  if (!ctx.user) {
    throw new UnauthorizedException("๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค");
  }
  
  if (ctx.user.id !== data.userId) {
    throw new UnauthorizedException("๋ณธ์ธ์˜ ํ”„๋กœํ•„๋งŒ ์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค");
  }
  
  // ํ”„๋กœํ•„ ์—…๋ฐ์ดํŠธ ๋กœ์ง
}

๊ด€๋ จ ๋ฌธ์„œ