diff --git a/src/app.ts b/src/app.ts index d7d8548..e5f3d0c 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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(); @@ -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' })); diff --git a/src/configs/logger.config.ts b/src/configs/logger.config.ts index 9276e09..7dd395a 100644 --- a/src/configs/logger.config.ts +++ b/src/configs/logger.config.ts @@ -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, }); }); diff --git a/src/middlewares/accessLog.middleware.ts b/src/middlewares/accessLog.middleware.ts new file mode 100644 index 0000000..3f316f2 --- /dev/null +++ b/src/middlewares/accessLog.middleware.ts @@ -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(); +}; diff --git a/src/middlewares/errorHandling.middleware.ts b/src/middlewares/errorHandling.middleware.ts index f17edeb..a737ff5 100644 --- a/src/middlewares/errorHandling.middleware.ts +++ b/src/middlewares/errorHandling.middleware.ts @@ -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, @@ -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: { diff --git a/src/middlewares/validation.middleware.ts b/src/middlewares/validation.middleware.ts index 3191d8a..f909184 100644 --- a/src/middlewares/validation.middleware.ts +++ b/src/middlewares/validation.middleware.ts @@ -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'; @@ -16,22 +16,12 @@ export const validateRequestDto = ( 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}`); } req[key] = value as T; next(); } catch (error) { - logger.error(`${key} Dto 검증 중 오류 발생 : `, error); next(error); } }; diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 8ac4345..33ab9c9 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -8,6 +8,8 @@ declare global { accessToken: string; refreshToken: string; }; + requestId: string; + startTime: number; } } } diff --git a/src/types/index.ts b/src/types/index.ts index 068ba34..5fae7a7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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'; diff --git a/src/types/logging.ts b/src/types/logging.ts new file mode 100644 index 0000000..5eceb04 --- /dev/null +++ b/src/types/logging.ts @@ -0,0 +1,33 @@ +/** + * 기본 로그 컨텍스트 정보 + */ +export interface LogContext { + 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; +} diff --git a/src/utils/__test__/logging.util.test.ts b/src/utils/__test__/logging.util.test.ts new file mode 100644 index 0000000..8f351c9 --- /dev/null +++ b/src/utils/__test__/logging.util.test.ts @@ -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; + let mockResponse: Partial; + + 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, + }) + ); + }); + }); +}); \ No newline at end of file diff --git a/src/utils/logging.util.ts b/src/utils/logging.util.ts new file mode 100644 index 0000000..e8ca5c9 --- /dev/null +++ b/src/utils/logging.util.ts @@ -0,0 +1,128 @@ +import { Request, Response } from 'express'; +import { randomUUID } from 'crypto'; +import logger from '@/configs/logger.config'; +import { LogContext, ErrorLogData, AccessLogData } from '@/types/logging'; +import { CustomError } from '@/exception'; + +/** + * 클라이언트 IP 주소 추출 + */ +export const getClientIp = (req: Request): string => { + return ( + (req.headers['x-forwarded-for'] as string)?.split(',')[0] || + (req.headers['x-real-ip'] as string) || + req.socket.remoteAddress || + 'unknown' + ); +}; + +/** + * 요청에서 기본 로그 컨텍스트 생성 + */ +export const createLogContext = (req: Request): LogContext => { + return { + requestId: req.requestId || randomUUID(), + userId: req.user?.id, + method: req.method, + url: req.originalUrl || req.url, + userAgent: req.headers['user-agent'], + ip: getClientIp(req), + }; +}; + +/** + * 로그 레벨과 로거 이름 결정 + */ +export const getLogLevel = (statusCode: number): 'info' | 'warn' | 'error' => { + if (statusCode < 400) return 'info'; + if (statusCode === 404) return 'warn'; + return 'error'; +}; + +/** + * 에러 로그 생성 및 출력 + * + * @param req Express Request 객체 + * @param res Express Response 객체 + * @param error Error 객체 + * @param customMessage 커스텀 에러 메시지 (선택) + * @param additionalData 추가 로그 데이터 (선택) + */ +export const logError = ( + req: Request, + res: Response, + error: Error, + customMessage?: string, + additionalData?: Record, +): void => { + const statusCode = res.statusCode || 500; + const level = getLogLevel(statusCode); + + const context = createLogContext(req); + const responseTime = req.startTime ? Date.now() - req.startTime : undefined; + + // 스택 트레이스 포함 여부 결정 + const includeStack = error instanceof CustomError && error.statusCode < 500 ? false : true; + + // 기본 에러 로그 데이터 생성 (winston 기본 필드 제외) + const errorLogData: ErrorLogData = { + logger: 'error', + requestId: context.requestId, + userId: context.userId, + method: context.method, + url: context.url, + userAgent: context.userAgent, + ip: context.ip, + message: customMessage || error.message, + statusCode, + errorCode: error instanceof CustomError ? error.code : undefined, + ...(includeStack && { stack: error.stack }), + responseTime, + ...additionalData, + }; + + logger[level]({ message: errorLogData }); +}; + +/** + * 액세스 로그 생성 및 출력 + * + * @param req Express Request 객체 + * @param res Express Response 객체 + * @param additionalData 추가 로그 데이터 (선택) + */ +export const logAccess = (req: Request, res: Response, additionalData?: Record): void => { + const statusCode = res.statusCode; + const level = getLogLevel(statusCode); + + const context = createLogContext(req); + const responseTime = req.startTime ? Date.now() - req.startTime : 0; + + // 응답 크기 추정 (정확하지 않을 수 있음) + const contentLength = res.get('content-length'); + const responseSize = contentLength ? parseInt(contentLength, 10) : undefined; + + const accessLogData: AccessLogData = { + logger: 'access', + requestId: context.requestId, + userId: context.userId, + method: context.method, + url: context.url, + userAgent: context.userAgent, + ip: context.ip, + statusCode, + responseTime, + responseSize, + ...additionalData, + }; + + logger[level](accessLogData); +}; + +/** + * 요청 시작 시점 기록을 위한 미들웨어 헬퍼 + */ +export const recordRequestStart = (req: Request): void => { + req.requestId = req.requestId || randomUUID(); + req.startTime = Date.now(); +};