์๋ฌ ์ฒ๋ฆฌ ๊ฐ์
์ผ๊ด๋ ์๋ต
ํ์คํ๋ ์๋ฌ ํ์ํด๋ผ์ด์ธํธ ์นํ์
์ํ ์ฝ๋
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 ์ํ ์ฝ๋ ์ฌ์ฉ
- ๋ช ํํ๊ณ ์ผ๊ด๋ ์๋ฌ ๋ฉ์์ง
- ์๋ฌ๋ ํญ์ ๋ก๊น
- ํ๋ก๋์ ์์๋ ์์ธ ์ ๋ณด ์ ํ