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

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

μΌκ΄€λœ 응닡

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

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

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

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

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

λ‘œκΉ…

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

κΈ°λ³Έ μ—λŸ¬ 처리

throw Error

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) {
      // κΈ°λ³Έ μ—λŸ¬ (HTTP 500)
      throw new Error("User not found");
    }
    
    return user;
  }
}

Context둜 μƒνƒœ μ½”λ“œ μ„€μ •

class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");
    
    const user = await rdb
      .table("users")
      .where("id", id)
      .first();
    
    if (!user) {
      // 404 Not Found
      context.reply.status(404);
      throw new Error("User not found");
    }
    
    return user;
  }
}

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

μ£Όμš” μƒνƒœ μ½”λ“œ

class ApiModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async handleRequest(params: any): Promise<any> {
    const context = Sonamu.getContext();
    
    // 400 Bad Request - 잘λͺ»λœ μš”μ²­
    if (!params.required) {
      context.reply.status(400);
      throw new Error("Required field missing");
    }
    
    // 401 Unauthorized - 인증 ν•„μš”
    if (!context.user) {
      context.reply.status(401);
      throw new Error("Authentication required");
    }
    
    // 403 Forbidden - κΆŒν•œ μ—†μŒ
    if (context.user.role !== "admin") {
      context.reply.status(403);
      throw new Error("Admin permission required");
    }
    
    // 404 Not Found - λ¦¬μ†ŒμŠ€ μ—†μŒ
    const resource = await this.findResource(params.id);
    if (!resource) {
      context.reply.status(404);
      throw new Error("Resource not found");
    }
    
    // 409 Conflict - 좩돌
    const existing = await this.checkDuplicate(params.email);
    if (existing) {
      context.reply.status(409);
      throw new Error("Email already exists");
    }
    
    // 429 Too Many Requests - 속도 μ œν•œ
    if (await this.isRateLimited(context.user.id)) {
      context.reply.status(429);
      throw new Error("Too many requests");
    }
    
    // 422 Unprocessable Entity - 검증 μ‹€νŒ¨
    const validationError = this.validate(params);
    if (validationError) {
      context.reply.status(422);
      throw new Error(`Validation failed: ${validationError}`);
    }
    
    // 500 Internal Server Error - μ„œλ²„ μ—λŸ¬ (κΈ°λ³Έκ°’)
    try {
      return await this.process(params);
    } catch (error) {
      context.reply.status(500);
      throw new Error("Internal server error");
    }
  }
}

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

μ½”λ“œμ΄λ¦„μ‚¬μš© μ‹œκΈ°
200OK성곡 (GET, PUT)
201Createdλ¦¬μ†ŒμŠ€ 생성 성곡 (POST)
204No Content성곡, 응닡 μ—†μŒ (DELETE)
400Bad Request잘λͺ»λœ μš”μ²­ ν˜•μ‹
401Unauthorized인증 ν•„μš”
403ForbiddenκΆŒν•œ μ—†μŒ
404Not Foundλ¦¬μ†ŒμŠ€ μ—†μŒ
409Conflict쀑볡, 좩돌
422Unprocessable Entity검증 μ‹€νŒ¨
429Too Many Requests속도 μ œν•œ
500Internal Server Errorμ„œλ²„ λ‚΄λΆ€ μ—λŸ¬

κ΅¬μ‘°ν™”λœ μ—λŸ¬ 응닡

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

interface ErrorResponse {
  error: {
    code: string;
    message: string;
    details?: any;
  };
  timestamp: Date;
  requestId?: string;
}

μ»€μŠ€ν…€ μ—λŸ¬ 클래슀

// errors/api-error.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public code: string,
    message: string,
    public details?: any
  ) {
    super(message);
    this.name = "ApiError";
  }
  
  toJSON(): ErrorResponse {
    return {
      error: {
        code: this.code,
        message: this.message,
        details: this.details,
      },
      timestamp: new Date(),
    };
  }
}

// 편의 λ©”μ„œλ“œ
export class BadRequestError extends ApiError {
  constructor(message: string, details?: any) {
    super(400, "BAD_REQUEST", message, details);
  }
}

export class UnauthorizedError extends ApiError {
  constructor(message: string = "Authentication required") {
    super(401, "UNAUTHORIZED", message);
  }
}

export class ForbiddenError extends ApiError {
  constructor(message: string = "Permission denied") {
    super(403, "FORBIDDEN", message);
  }
}

export class NotFoundError extends ApiError {
  constructor(resource: string) {
    super(404, "NOT_FOUND", `${resource} not found`);
  }
}

export class ConflictError extends ApiError {
  constructor(message: string, details?: any) {
    super(409, "CONFLICT", message, details);
  }
}

export class ValidationError extends ApiError {
  constructor(message: string, details?: any) {
    super(422, "VALIDATION_ERROR", message, details);
  }
}

μ‚¬μš© 예제

import {
  NotFoundError,
  UnauthorizedError,
  ForbiddenError,
  ConflictError,
  ValidationError,
} from "./errors/api-error";

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 NotFoundError("User");
    }
    
    return user;
  }
  
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    const context = Sonamu.getContext();
    
    // 인증 확인
    if (!context.user) {
      throw new UnauthorizedError();
    }
    
    // κΆŒν•œ 확인
    if (context.user.role !== "admin") {
      throw new ForbiddenError("Admin permission required");
    }
    
    const rdb = this.getPuri("r");
    
    // 쀑볡 확인
    const existing = await rdb
      .table("users")
      .where("email", params.email)
      .first();
    
    if (existing) {
      throw new ConflictError("Email already exists", {
        field: "email",
        value: params.email,
      });
    }
    
    // 검증
    if (params.password.length < 8) {
      throw new ValidationError("Password too short", {
        field: "password",
        minLength: 8,
      });
    }
    
    const wdb = this.getPuri("w");
    const [user] = await wdb
      .table("users")
      .insert(params)
      .returning({ id: "id" });
    
    return { userId: user.id };
  }
}

Zod 검증 μ—λŸ¬ 처리

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

import { z } from "zod";
import { ValidationError } from "./errors/api-error";

function handleZodError(error: z.ZodError): never {
  const details = error.errors.map((e) => ({
    field: e.path.join("."),
    message: e.message,
    code: e.code,
  }));
  
  throw new ValidationError(
    "Validation failed",
    details
  );
}

// μ‚¬μš©
class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    try {
      const validated = CreateUserSchema.parse(params);
      
      // 생성 둜직
      // ...
    } catch (error) {
      if (error instanceof z.ZodError) {
        handleZodError(error);
      }
      throw error;
    }
  }
}

safeParse μ‚¬μš©

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const result = CreateUserSchema.safeParse(params);
    
    if (!result.success) {
      const details = result.error.errors.map((e) => ({
        field: e.path.join("."),
        message: e.message,
      }));
      
      throw new ValidationError("Validation failed", details);
    }
    
    const validated = result.data;
    
    // 생성 둜직
    // ...
  }
}

μ—λŸ¬ ν•Έλ“€λŸ¬ 미듀웨어

μ „μ—­ μ—λŸ¬ ν•Έλ“€λŸ¬

// middleware/error-handler.ts
import { FastifyError, FastifyRequest, FastifyReply } from "fastify";
import { ApiError } from "../errors/api-error";
import { ZodError } from "zod";

export async function errorHandler(
  error: FastifyError | Error,
  request: FastifyRequest,
  reply: FastifyReply
) {
  console.error("Error:", error);
  
  // ApiError
  if (error instanceof ApiError) {
    return reply.status(error.statusCode).send(error.toJSON());
  }
  
  // Zod Validation Error
  if (error instanceof ZodError) {
    const details = error.errors.map((e) => ({
      field: e.path.join("."),
      message: e.message,
    }));
    
    return reply.status(422).send({
      error: {
        code: "VALIDATION_ERROR",
        message: "Validation failed",
        details,
      },
      timestamp: new Date(),
    });
  }
  
  // Database Error
  if (error.message.includes("violates foreign key constraint")) {
    return reply.status(400).send({
      error: {
        code: "REFERENCE_ERROR",
        message: "Referenced resource does not exist",
      },
      timestamp: new Date(),
    });
  }
  
  if (error.message.includes("duplicate key value")) {
    return reply.status(409).send({
      error: {
        code: "DUPLICATE_ERROR",
        message: "Resource already exists",
      },
      timestamp: new Date(),
    });
  }
  
  // κΈ°λ³Έ μ—λŸ¬
  const statusCode = (error as any).statusCode || 500;
  
  return reply.status(statusCode).send({
    error: {
      code: "INTERNAL_ERROR",
      message:
        process.env.NODE_ENV === "production"
          ? "Internal server error"
          : error.message,
    },
    timestamp: new Date(),
  });
}

// server.ts
import fastify from "fastify";
import { errorHandler } from "./middleware/error-handler";

const app = fastify();

app.setErrorHandler(errorHandler);

μ—λŸ¬ λ‘œκΉ…

λ‘œκΉ… μ„œλΉ„μŠ€

// services/logger.service.ts
export class Logger {
  static error(
    error: Error,
    context?: {
      userId?: number;
      requestId?: string;
      endpoint?: string;
      params?: any;
    }
  ): void {
    console.error({
      timestamp: new Date(),
      level: "ERROR",
      message: error.message,
      stack: error.stack,
      context,
    });
    
    // μ™ΈλΆ€ λ‘œκΉ… μ„œλΉ„μŠ€λ‘œ 전솑 (선택)
    // Sentry, CloudWatch, etc.
  }
  
  static warn(message: string, context?: any): void {
    console.warn({
      timestamp: new Date(),
      level: "WARN",
      message,
      context,
    });
  }
  
  static info(message: string, context?: any): void {
    console.log({
      timestamp: new Date(),
      level: "INFO",
      message,
      context,
    });
  }
}

// μ‚¬μš©
class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: CreateUserParams): Promise<{ userId: number }> {
    try {
      const wdb = this.getPuri("w");
      const [user] = await wdb
        .table("users")
        .insert(params)
        .returning({ id: "id" });
      
      Logger.info("User created", { userId: user.id });
      
      return { userId: user.id };
    } catch (error) {
      const context = Sonamu.getContext();
      
      Logger.error(error as Error, {
        userId: context.user?.id,
        requestId: context.requestId,
        endpoint: context.request.url,
        params,
      });
      
      throw error;
    }
  }
}

μž¬μ‹œλ„ κ°€λŠ₯ν•œ μ—λŸ¬

export class RetryableError extends ApiError {
  constructor(message: string, public retryAfter?: number) {
    super(503, "SERVICE_UNAVAILABLE", message);
  }
}

class ExternalApiModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async fetchData(): Promise<any> {
    try {
      const response = await axios.get("https://external-api.com/data", {
        timeout: 5000,
      });
      
      return response.data;
    } catch (error) {
      if (axios.isAxiosError(error)) {
        if (error.code === "ECONNABORTED") {
          throw new RetryableError("Request timeout", 10);
        }
        
        if (error.response?.status === 429) {
          const retryAfter = parseInt(
            error.response.headers["retry-after"] || "60"
          );
          throw new RetryableError("Rate limited", retryAfter);
        }
      }
      
      throw new ApiError(
        502,
        "BAD_GATEWAY",
        "External service unavailable"
      );
    }
  }
}

λ‹€κ΅­μ–΄ μ—λŸ¬ λ©”μ‹œμ§€

// i18n/errors.ts
const errorMessages: Record<string, Record<string, string>> = {
  ko: {
    USER_NOT_FOUND: "μ‚¬μš©μžλ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€",
    EMAIL_EXISTS: "이미 μ‚¬μš© 쀑인 μ΄λ©”μΌμž…λ‹ˆλ‹€",
    INVALID_PASSWORD: "λΉ„λ°€λ²ˆν˜Έκ°€ μ˜¬λ°”λ₯΄μ§€ μ•ŠμŠ΅λ‹ˆλ‹€",
    PERMISSION_DENIED: "κΆŒν•œμ΄ μ—†μŠ΅λ‹ˆλ‹€",
  },
  en: {
    USER_NOT_FOUND: "User not found",
    EMAIL_EXISTS: "Email already exists",
    INVALID_PASSWORD: "Invalid password",
    PERMISSION_DENIED: "Permission denied",
  },
};

export function getErrorMessage(code: string, locale: string = "ko"): string {
  return errorMessages[locale]?.[code] || code;
}

// μ‚¬μš©
class UserModel extends BaseModelClass {
  @api({ httpMethod: "GET" })
  async get(id: number): Promise<User> {
    const context = Sonamu.getContext();
    const rdb = this.getPuri("r");
    
    const user = await rdb.table("users").where("id", id).first();
    
    if (!user) {
      const locale = context.locale || "ko";
      throw new NotFoundError(
        getErrorMessage("USER_NOT_FOUND", locale)
      );
    }
    
    return user;
  }
}

μ‹€μ „ νŒ¨ν„΄

class UserModel extends BaseModelClass {
  @api({ httpMethod: "POST" })
  async create(params: unknown): Promise<{ userId: number }> {
    const context = Sonamu.getContext();
    
    try {
      // 1. 인증 확인
      if (!context.user) {
        throw new UnauthorizedError();
      }
      
      // 2. Zod 검증
      const validated = CreateUserSchema.safeParse(params);
      if (!validated.success) {
        const details = validated.error.errors.map((e) => ({
          field: e.path.join("."),
          message: e.message,
        }));
        throw new ValidationError("Validation failed", details);
      }
      
      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 ConflictError("Email already exists", {
          field: "email",
        });
      }
      
      // 4. 생성
      const wdb = this.getPuri("w");
      const [user] = await wdb
        .table("users")
        .insert(data)
        .returning({ id: "id" });
      
      // 5. λ‘œκΉ…
      Logger.info("User created", {
        userId: user.id,
        email: data.email,
      });
      
      return { userId: user.id };
    } catch (error) {
      // μ—λŸ¬ λ‘œκΉ…
      Logger.error(error as Error, {
        userId: context.user?.id,
        endpoint: context.request.url,
      });
      
      throw error;
    }
  }
}

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

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

λ‹€μŒ 단계