Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { options } from '@/configs/swagger.config';
import { getSentryStatus } from '@/configs/sentry.config';
import { getCacheStatus } from '@/configs/cache.config';
import { errorHandlingMiddleware } from '@/middlewares/errorHandling.middleware';
import { accessLogMiddleware } from '@/middlewares/accessLog.middleware';

dotenv.config();

Expand All @@ -24,6 +25,7 @@ app.set('trust proxy', process.env.NODE_ENV === 'production');

const swaggerSpec = swaggerJSDoc(options);

app.use(accessLogMiddleware);
app.use(cookieParser());
app.use(express.json({ limit: '10mb' })); // 파일 업로드 대비
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
Expand Down
6 changes: 4 additions & 2 deletions src/configs/logger.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ if (!fs.existsSync(errorLogDir)) {
}

const jsonFormat = winston.format.printf((info) => {
const message = typeof info.message === 'object' && info.message !== null ? info.message : { message: info.message };

return JSON.stringify({
timestamp: info.timestamp,
level: info.level.toUpperCase(),
logger: 'default',
message: info.message,
logger: info.logger || 'default',
...message,
});
});

Expand Down
21 changes: 21 additions & 0 deletions src/middlewares/accessLog.middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from 'express';
import { recordRequestStart, logAccess } from '@/utils/logging.util';

/**
* 액세스 로그 미들웨어
* 모든 요청의 시작과 끝을 기록합니다.
*/
export const accessLogMiddleware = (req: Request, res: Response, next: NextFunction): void => {
// 요청 시작 시점 기록
recordRequestStart(req);

// 응답 완료 시 액세스 로그 기록
res.on('finish', () => {
if (res.statusCode < 400) {
// 400 미만만 액세스 로그, 그 외 에러 로깅
logAccess(req, res);
}
});

next();
};
16 changes: 10 additions & 6 deletions src/middlewares/errorHandling.middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
import { CustomError } from '@/exception';
import * as Sentry from '@sentry/node';
import logger from '@/configs/logger.config';
import { logError } from '@/utils/logging.util';

export const errorHandlingMiddleware: ErrorRequestHandler = (
err: CustomError,
Expand All @@ -11,16 +11,20 @@ export const errorHandlingMiddleware: ErrorRequestHandler = (
next: NextFunction,
) => {
if (err instanceof CustomError) {
res
.status(err.statusCode)
.json({ success: false, message: err.message, error: { code: err.code, statusCode: err.statusCode } });
res.status(err.statusCode);
logError(req, res, err, `Custom Error: ${err.message}`);

res.json({ success: false, message: err.message, error: { code: err.code, statusCode: err.statusCode } });
return;
}

// Sentry에 에러 전송
Sentry.captureException(err);
logger.error('Internal Server Error');

res.status(500).json({
res.status(500);
logError(req, res, err as Error, 'Internal Server Error');

res.json({
success: false,
message: '서버 내부 에러가 발생하였습니다.',
error: {
Expand Down
14 changes: 2 additions & 12 deletions src/middlewares/validation.middleware.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextFunction, Request, Response } from 'express';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import logger from '@/configs/logger.config';
import { BadRequestError } from '@/exception';

type RequestKey = 'body' | 'user' | 'query';

Expand All @@ -16,22 +16,12 @@ export const validateRequestDto = <T extends object>(
const errors = await validate(value);

if (errors.length > 0) {
logger.error(`API 입력 검증 실패, errors: ${errors}`);
res.status(400).json({
success: false,
message: '검증에 실패하였습니다. 입력값을 다시 확인해주세요.',
errors: errors.map((error) => ({
property: error.property,
constraints: error.constraints,
})),
});
return;
throw new BadRequestError(`API 입력 검증 실패, errors: ${errors}`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

exception 분리하신 부분이 좋은 것 같습니다!👏

}

req[key] = value as T;
next();
} catch (error) {
logger.error(`${key} Dto 검증 중 오류 발생 : `, error);
next(error);
}
};
Expand Down
2 changes: 2 additions & 0 deletions src/types/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ declare global {
accessToken: string;
refreshToken: string;
};
requestId: string;
startTime: number;
Comment on lines +11 to +12
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이제 모든 request 가 이 값을 middleware 에서 Injection 받고 시작하는거죠?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 맞습니다!

}
}
}
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,5 +42,8 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.
export type { SentryIssueStatus } from '@/types/models/Sentry.type';
export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type';

// Logging 관련
export type { LogContext, ErrorLogData, AccessLogData } from '@/types/logging';

// Common
export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type';
33 changes: 33 additions & 0 deletions src/types/logging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* 기본 로그 컨텍스트 정보
*/
export interface LogContext {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요건 어떤 object 를 보고 따라 만드신 것 일까요?!

requestId: string;
userId?: number;
method: string;
url: string;
userAgent?: string;
ip?: string;
}

/**
* 에러 로그 데이터
*/
export interface ErrorLogData extends LogContext {
logger: 'error';
message: string;
statusCode: number;
errorCode?: string;
stack?: string;
responseTime?: number;
}

/**
* 액세스 로그 데이터
*/
export interface AccessLogData extends LogContext {
logger: 'access';
statusCode: number;
responseTime: number;
responseSize?: number;
}
162 changes: 162 additions & 0 deletions src/utils/__test__/logging.util.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Request, Response } from 'express';
import { Socket } from 'net';
import {
createLogContext,
logError,
logAccess,
getClientIp,
getLogLevel,
} from '@/utils/logging.util';
import { CustomError } from '@/exception';
import { User } from '@/types';
import logger from '@/configs/logger.config';

// logger 모킹
jest.mock('@/configs/logger.config', () => ({
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
}));

describe('Logging Utilities', () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;

beforeEach(() => {
mockRequest = {
headers: {'x-forwarded-for': '127.0.0.1'},
method: 'GET',
originalUrl: '/api/test',
/* eslint-disable @typescript-eslint/consistent-type-assertions */
user: { id: 123, velog_uuid: 'user123' } as User,
requestId: 'test-request-id',
startTime: Date.now() - 100,
};

mockResponse = {
statusCode: 200,
get: jest.fn(),
};
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getClientIp', () => {
it('x-forwarded-for 헤더에서 IP를 추출해야 한다', () => {
mockRequest.headers = { 'x-forwarded-for': '192.168.1.1, 10.0.0.1' };
expect(getClientIp(mockRequest as Request)).toBe('192.168.1.1');
});

it('x-real-ip 헤더에서 IP를 추출해야 한다', () => {
mockRequest.headers = { 'x-real-ip': '203.0.113.1' };
expect(getClientIp(mockRequest as Request)).toBe('203.0.113.1');
});

it('헤더가 없으면 unknown을 반환해야 한다', () => {
mockRequest.headers = {};
/* eslint-disable @typescript-eslint/consistent-type-assertions */
mockRequest.socket = { remoteAddress: undefined } as Socket;
expect(getClientIp(mockRequest as Request)).toBe('unknown');
});
});

describe('getLogLevel', () => {
it('200은 info 레벨을 반환해야 한다', () => {
expect(getLogLevel(200)).toBe('info');
});

it('404는 warn 레벨을 반환해야 한다', () => {
expect(getLogLevel(404)).toBe('warn');
});

it('500은 error 레벨을 반환해야 한다', () => {
expect(getLogLevel(500)).toBe('error');
});
});

describe('createLogContext', () => {
it('요청에서 올바른 로그 컨텍스트를 생성해야 한다', () => {
const context = createLogContext(mockRequest as Request);

expect(context.requestId).toBe('test-request-id');
expect(context.userId).toBe(123);
expect(context.method).toBe('GET');
expect(context.url).toBe('/api/test');
expect(context.userAgent).toBeUndefined();
expect(context.ip).toBe('127.0.0.1');
});
});

describe('logError', () => {
it('일반 에러를 올바르게 로깅해야 한다', () => {
const error = new Error('Test error');
mockResponse.statusCode = 500; // error 레벨을 위해 500으로 설정

logError(mockRequest as Request, mockResponse as Response, error);

expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.objectContaining({
logger: 'error',
message: 'Test error',
statusCode: 500,
requestId: 'test-request-id',
userId: 123,
method: 'GET',
url: '/api/test',
ip: '127.0.0.1',
})
})
);
});

it('CustomError의 경우 에러 코드를 포함해야 한다', () => {
const customError = new CustomError('Custom error', 'CUSTOM_ERROR', 400);
mockResponse.statusCode = 400;

logError(mockRequest as Request, mockResponse as Response, customError);

expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.objectContaining({
errorCode: 'CUSTOM_ERROR',
})
})
);
});

it('500이거나 예상하지 못한 에러는 스택 트레이스를 포함해야 한다', () => {
const error = new Error('Test error');
mockResponse.statusCode = 500;

logError(mockRequest as Request, mockResponse as Response, error);

expect(logger.error).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.objectContaining({
stack: expect.any(String),
})
})
);
});
});

describe('logAccess', () => {
it('액세스 로그를 올바르게 생성해야 한다', () => {
(mockResponse.get as jest.Mock).mockReturnValue('1024');

logAccess(mockRequest as Request, mockResponse as Response);

expect(logger.info).toHaveBeenCalledWith(
expect.objectContaining({
logger: 'access',
statusCode: 200,
responseTime: expect.any(Number),
responseSize: 1024,
})
);
});
});
});
Loading