μλ¬ μ²λ¦¬ κ°μ
μΌκ΄λ μλ΅
νμ€νλ μλ¬ νμν΄λΌμ΄μΈνΈ μΉνμ
μν μ½λ
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");
}
}
}
μν μ½λ μ°Έμ‘°ν
| μ½λ | μ΄λ¦ | μ¬μ© μκΈ° |
|---|---|---|
| 200 | OK | μ±κ³΅ (GET, PUT) |
| 201 | Created | 리μμ€ μμ± μ±κ³΅ (POST) |
| 204 | No Content | μ±κ³΅, μλ΅ μμ (DELETE) |
| 400 | Bad Request | μλͺ»λ μμ² νμ |
| 401 | Unauthorized | μΈμ¦ νμ |
| 403 | Forbidden | κΆν μμ |
| 404 | Not Found | 리μμ€ μμ |
| 409 | Conflict | μ€λ³΅, μΆ©λ |
| 422 | Unprocessable Entity | κ²μ¦ μ€ν¨ |
| 429 | Too Many Requests | μλ μ ν |
| 500 | Internal 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;
}
}
}
μ£Όμμ¬ν
μλ¬ μ²λ¦¬ μ μ£Όμμ¬ν:
- λ―Όκ°ν μ 보 λ ΈμΆ κΈμ§ (μ€ν νΈλ μ΄μ€, DB μλ¬ λ±)
- μ μ ν HTTP μν μ½λ μ¬μ©
- λͺ ννκ³ μΌκ΄λ μλ¬ λ©μμ§
- μλ¬λ νμ λ‘κΉ
- νλ‘λμ μμλ μμΈ μ 보 μ ν