메인 μ½˜ν…μΈ λ‘œ κ±΄λ„ˆλ›°κΈ°
APIμ—μ„œ λ°œμƒν•˜λŠ” μ—λŸ¬λ₯Ό μΌκ΄€λ˜κ²Œ μ²˜λ¦¬ν•˜κ³  λͺ…ν™•ν•œ 응닡을 μ œκ³΅ν•˜λŠ” 방법을 μ•Œμ•„λ΄…λ‹ˆλ‹€.

μ—λŸ¬ 처리 κ°œμš”

μΌκ΄€λœ 응닡

ν‘œμ€€ν™”λœ μ—λŸ¬ ν˜•μ‹ ν΄λΌμ΄μ–ΈνŠΈ μΉœν™”μ 

μƒνƒœ μ½”λ“œ

HTTP μƒνƒœ μ½”λ“œ RESTful ν‘œμ€€

λͺ…ν™•ν•œ λ©”μ‹œμ§€

개발자 μΉœν™”μ  μ‚¬μš©μž μΉœν™”μ 

λ‘œκΉ…

μ—λŸ¬ 좔적 디버깅 지원

SoException

SonamuλŠ” SoException 기반의 μ—λŸ¬ 클래슀λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. SoException을 throwν•˜λ©΄ ν”„λ ˆμž„μ›Œν¬κ°€ μžλ™μœΌλ‘œ μ μ ˆν•œ HTTP μƒνƒœ μ½”λ“œμ™€ κ΅¬μ‘°ν™”λœ μ—λŸ¬ 응닡을 μ „μ†‘ν•©λ‹ˆλ‹€.

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

import { NotFoundException } from "sonamu";

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const rdb = this.getPuri("r");

    const user = await rdb.table("users").where("id", id).first();

    if (!user) {
      throw new NotFoundException("μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
    }

    return user;
  }
}

μ œκ³΅λ˜λŠ” μ˜ˆμ™Έ 클래슀

Sonamuκ°€ 기본으둜 μ œκ³΅ν•˜λŠ” μ˜ˆμ™Έ ν΄λž˜μŠ€μž…λ‹ˆλ‹€. λͺ¨λ‘ SoException을 μƒμ†ν•©λ‹ˆλ‹€.
ν΄λž˜μŠ€μƒνƒœ μ½”λ“œμ‚¬μš© μ‹œκΈ°
BadRequestException400잘λͺ»λœ λ§€κ°œλ³€μˆ˜ λ“± μš”μ²­μ— λ¬Έμ œκ°€ μžˆλŠ” 경우
UnauthorizedException401둜그인이 ν•„μš”ν•˜κ±°λ‚˜ μ ‘κ·Ό κΆŒν•œμ΄ μ—†λŠ” 경우
NotFoundException404μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” λ ˆμ½”λ“œμ— μ ‘κ·Όν•˜λŠ” 경우
InternalServerErrorException500λ‚΄λΆ€ 처리 둜직 λ˜λŠ” μ™ΈλΆ€ API 호좜 였λ₯˜
ServiceUnavailableException503ν˜„μž¬ μƒνƒœμ—μ„œ μ²˜λ¦¬κ°€ λΆˆκ°€λŠ₯ν•œ 경우
TargetNotFoundException520처리 λŒ€μƒμ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠλŠ” 경우
AlreadyProcessedException541이미 처리된 μš”μ²­μΈ 경우
DuplicateRowException542쀑볡을 ν—ˆμš©ν•˜μ§€ μ•ŠλŠ” μΌ€μ΄μŠ€μ— 쀑볡 μš”μ²­

SoException μƒμ„±μž

λͺ¨λ“  μ˜ˆμ™Έ ν΄λž˜μŠ€λŠ” λ™μΌν•œ μƒμ„±μž μ‹œκ·Έλ‹ˆμ²˜λ₯Ό κ°€μ§‘λ‹ˆλ‹€.
constructor(message: LocalizedString, payload?: unknown)
  • message: μ—λŸ¬ λ©”μ‹œμ§€. λ‹€κ΅­μ–΄ λ¬Έμžμ—΄(LocalizedString)을 μ§€μ›ν•©λ‹ˆλ‹€.
  • payload: μΆ”κ°€ 정보. Zod 검증 이슈 λ°°μ—΄ 등을 전달할 수 μžˆμŠ΅λ‹ˆλ‹€.

μ‚¬μš© 예제

λ‹€μ–‘ν•œ μ—λŸ¬ 상황

import {
  BadRequestException,
  UnauthorizedException,
  NotFoundException,
  DuplicateRowException,
} from "sonamu";

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const rdb = this.getPuri("r");

    const user = await rdb.table("users").where("id", id).first();

    if (!user) {
      throw new NotFoundException("μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€");
    }

    return user;
  }

  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    const context = Sonamu.getContext();

    // 인증 확인
    if (!context.user) {
      throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
    }

    // μž…λ ₯ 검증
    if (!params.email) {
      throw new BadRequestException("이메일은 ν•„μˆ˜μž…λ‹ˆλ‹€");
    }

    const rdb = this.getPuri("r");

    // 쀑볡 확인
    const existing = await rdb.table("users").where("email", params.email).first();

    if (existing) {
      throw new DuplicateRowException("이미 μ‚¬μš© 쀑인 μ΄λ©”μΌμž…λ‹ˆλ‹€");
    }

    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(params).returning({ id: "id" });

    return { userId: user.id };
  }
}

payloadλ₯Ό ν™œμš©ν•œ 상세 정보 전달

import { BadRequestException } from "sonamu";

class OrderModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateOrderParams): Promise<{ orderId: number }> {
    // payload둜 μ—λŸ¬ 상세 정보 전달
    if (params.quantity <= 0) {
      throw new BadRequestException("μ£Όλ¬Έ μˆ˜λŸ‰μ΄ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€", {
        field: "quantity",
        value: params.quantity,
        constraint: "must be > 0",
      });
    }

    // ...
  }
}

isSoException νƒ€μž… κ°€λ“œ

import { isSoException, AlreadyProcessedException } from "sonamu";

class PaymentModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async process(paymentId: number): Promise<void> {
    try {
      await this.executePayment(paymentId);
    } catch (error) {
      if (isSoException(error) && error.statusCode === 541) {
        // 이미 처리된 결제 - λ¬΄μ‹œ
        return;
      }
      throw error;
    }
  }
}

Zod 검증 μ—λŸ¬ 처리

BadRequestException의 payload에 Zod 이슈 배열을 μ „λ‹¬ν•˜λ©΄, Sonamu의 μ—λŸ¬ ν•Έλ“€λŸ¬κ°€ μžλ™μœΌλ‘œ 검증 μ—λŸ¬ 상세 정보λ₯Ό 응닡에 ν¬ν•¨ν•©λ‹ˆλ‹€.

Zod μ—λŸ¬ λ³€ν™˜

import { z } from "zod";
import { BadRequestException } from "sonamu";

const CreateUserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(2),
  password: z.string().min(8),
});

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const result = CreateUserSchema.safeParse(params);

    if (!result.success) {
      throw new BadRequestException("검증 μ‹€νŒ¨", result.error.issues);
    }

    const validated = result.data;

    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(validated).returning({ id: "id" });

    return { userId: user.id };
  }
}

μ—λŸ¬ 응닡 ν˜•μ‹

Sonamu의 λ‚΄μž₯ μ—λŸ¬ ν•Έλ“€λŸ¬λŠ” λ‹€μŒ ν˜•μ‹μœΌλ‘œ μ‘λ‹΅ν•©λ‹ˆλ‹€.

κΈ°λ³Έ μ—λŸ¬ 응닡

{
  "name": "NotFoundException",
  "code": null,
  "message": "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"
}

Zod 검증 μ—λŸ¬ 응닡 (payload에 이슈 λ°°μ—΄ 전달 μ‹œ)

{
  "name": "BadRequestException",
  "code": null,
  "message": "검증 μ‹€νŒ¨ (email)",
  "issues": [
    {
      "code": "invalid_type",
      "expected": "string",
      "received": "undefined",
      "path": ["email"],
      "message": "Required"
    }
  ]
}

μ—λŸ¬ ν•Έλ“€λŸ¬ μ»€μŠ€ν„°λ§ˆμ΄μ§•

SonamuλŠ” κΈ°λ³Έ μ—λŸ¬ ν•Έλ“€λŸ¬λ₯Ό λ‚΄μž₯ν•˜κ³  μžˆμ§€λ§Œ, ν•„μš” μ‹œ startServer의 lifecycle.onError μ˜΅μ…˜μœΌλ‘œ μ»€μŠ€ν…€ μ—λŸ¬ ν•Έλ“€λŸ¬λ₯Ό μ„€μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
import { Sonamu, isSoException } from "sonamu";

Sonamu.startServer({
  lifecycle: {
    onError: (error, request, reply) => {
      // μ»€μŠ€ν…€ μ—λŸ¬ 처리 둜직
      if (isSoException(error)) {
        reply.status(error.statusCode).send({
          error: error.message,
          payload: error.payload,
        });
      } else {
        reply.status(500).send({
          error: "Internal server error",
        });
      }
    },
  },
});

μƒνƒœ μ½”λ“œ μ°Έμ‘°ν‘œ

ν‘œμ€€ HTTP μƒνƒœ μ½”λ“œ

μ½”λ“œμ΄λ¦„μ‚¬μš© μ‹œκΈ°
200OK성곡 (GET, PUT)
201Createdλ¦¬μ†ŒμŠ€ 생성 성곡 (POST)
204No Content성곡, 응닡 μ—†μŒ (DELETE)
400Bad Request잘λͺ»λœ μš”μ²­ ν˜•μ‹
401Unauthorized인증 ν•„μš”
404Not Foundλ¦¬μ†ŒμŠ€ μ—†μŒ
500Internal Server Errorμ„œλ²„ λ‚΄λΆ€ μ—λŸ¬
503Service Unavailableμ„œλΉ„μŠ€ 이용 λΆˆκ°€

Sonamu μ»€μŠ€ν…€ μƒνƒœ μ½”λ“œ

μ½”λ“œμ˜ˆμ™Έ ν΄λž˜μŠ€μ‚¬μš© μ‹œκΈ°
520TargetNotFoundException처리 λŒ€μƒμ΄ μ‘΄μž¬ν•˜μ§€ μ•ŠμŒ
541AlreadyProcessedException이미 처리된 μš”μ²­
542DuplicateRowException쀑볡 ν—ˆμš©ν•˜μ§€ μ•ŠλŠ” 곳에 쀑볡

μ‹€μ „ νŒ¨ν„΄

import {
  UnauthorizedException,
  BadRequestException,
  DuplicateRowException,
} from "sonamu";

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const context = Sonamu.getContext();

    // 1. 인증 확인
    if (!context.user) {
      throw new UnauthorizedException("둜그인이 ν•„μš”ν•©λ‹ˆλ‹€");
    }

    // 2. Zod 검증
    const validated = CreateUserSchema.safeParse(params);
    if (!validated.success) {
      throw new BadRequestException("검증 μ‹€νŒ¨", validated.error.issues);
    }

    const data = validated.data;

    // 3. λΉ„μ¦ˆλ‹ˆμŠ€ κ·œμΉ™ 검증
    const rdb = this.getPuri("r");
    const existing = await rdb.table("users").where("email", data.email).first();

    if (existing) {
      throw new DuplicateRowException("이미 μ‚¬μš© 쀑인 μ΄λ©”μΌμž…λ‹ˆλ‹€");
    }

    // 4. 생성
    const wdb = this.getPuri("w");
    const [user] = await wdb.table("users").insert(data).returning({ id: "id" });

    return { userId: user.id };
  }
}

μ£Όμ˜μ‚¬ν•­

μ—λŸ¬ 처리 μ‹œ μ£Όμ˜μ‚¬ν•­: 1. λ―Όκ°ν•œ 정보 λ…ΈμΆœ κΈˆμ§€ (μŠ€νƒ 트레이슀, DB μ—λŸ¬ λ“±) 2. μ μ ˆν•œ HTTP μƒνƒœ μ½”λ“œ μ‚¬μš© 3. λͺ…ν™•ν•˜κ³  μΌκ΄€λœ μ—λŸ¬ λ©”μ‹œμ§€ 4. μ—λŸ¬λŠ” 항상 λ‘œκΉ… 5. ν”„λ‘œλ•μ…˜μ—μ„œλŠ” 상세 정보 μ œν•œ

λ‹€μŒ 단계

μžλ™ 검증

Zod 기반 검증

μ»€μŠ€ν…€ 검증

μ»€μŠ€ν…€ 검증 둜직

@api λ°μ½”λ ˆμ΄ν„°

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

Context

SonamuContext