๋ฉ”์ธ ์ฝ˜ํ…์ธ ๋กœ ๊ฑด๋„ˆ๋›ฐ๊ธฐ
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. ํ”„๋กœ๋•์…˜์—์„œ๋Š” ์ƒ์„ธ ์ •๋ณด ์ œํ•œ

๋‹ค์Œ ๋‹จ๊ณ„