From 0f91d62635506ce1c4a6a89b761790c2fb06905d Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 1 Oct 2025 05:01:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.ts | 2 + src/configs/logger.config.ts | 10 ++ src/middlewares/accessLog.middleware.ts | 21 ++++ src/middlewares/errorHandling.middleware.ts | 16 ++- src/middlewares/validation.middleware.ts | 12 +- src/types/express.d.ts | 2 + src/types/index.ts | 3 + src/types/logging.ts | 33 +++++ src/utils/logging.util.ts | 128 ++++++++++++++++++++ 9 files changed, 211 insertions(+), 16 deletions(-) create mode 100644 src/middlewares/accessLog.middleware.ts create mode 100644 src/types/logging.ts create mode 100644 src/utils/logging.util.ts 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..789d058 100644 --- a/src/configs/logger.config.ts +++ b/src/configs/logger.config.ts @@ -13,6 +13,16 @@ if (!fs.existsSync(errorLogDir)) { } const jsonFormat = winston.format.printf((info) => { + // info.message가 객체인 경우 + if (typeof info.message === 'object' && info.message !== null) { + return JSON.stringify({ + timestamp: info.timestamp, + level: info.level.toUpperCase(), + logger: info.logger || 'default', + ...info.message, // 로그 데이터 평탄화 + }); + } + return JSON.stringify({ timestamp: info.timestamp, level: info.level.toUpperCase(), diff --git a/src/middlewares/accessLog.middleware.ts b/src/middlewares/accessLog.middleware.ts new file mode 100644 index 0000000..5e45d66 --- /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..eb2a511 100644 --- a/src/middlewares/validation.middleware.ts +++ b/src/middlewares/validation.middleware.ts @@ -2,6 +2,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,16 +17,7 @@ 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; 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..1bd985e --- /dev/null +++ b/src/types/logging.ts @@ -0,0 +1,33 @@ +/** + * 기본 로그 컨텍스트 정보 + */ +export interface LogContext { + requestId: string; + userId?: string; + method: string; + url: string; + userAgent?: string; + ip?: string; +} + +/** + * 에러 로그 데이터 + */ +export interface ErrorLogData extends LogContext { + logger: string; + message: string; + statusCode: number; + errorCode?: string; + stack?: string; + responseTime?: number; +} + +/** + * 액세스 로그 데이터 + */ +export interface AccessLogData extends LogContext { + logger: string; + statusCode: number; + responseTime: number; + responseSize?: number; +} diff --git a/src/utils/logging.util.ts b/src/utils/logging.util.ts new file mode 100644 index 0000000..8913982 --- /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?.velog_uuid, + 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(); +}; From eab3accc694c406807b06d2ad1862a36560208b5 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 1 Oct 2025 05:23:06 +0900 Subject: [PATCH 2/4] =?UTF-8?q?test:=20=EB=A1=9C=EA=B9=85=20=EC=9C=A0?= =?UTF-8?q?=ED=8B=B8=20=ED=95=A8=EC=88=98=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/logging.ts | 2 +- src/utils/__test__/logging.util.test.ts | 162 ++++++++++++++++++++++++ src/utils/logging.util.ts | 2 +- 3 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/utils/__test__/logging.util.test.ts diff --git a/src/types/logging.ts b/src/types/logging.ts index 1bd985e..be30cc1 100644 --- a/src/types/logging.ts +++ b/src/types/logging.ts @@ -3,7 +3,7 @@ */ export interface LogContext { requestId: string; - userId?: string; + userId?: number; method: string; url: string; userAgent?: string; 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 index 8913982..e8ca5c9 100644 --- a/src/utils/logging.util.ts +++ b/src/utils/logging.util.ts @@ -22,7 +22,7 @@ export const getClientIp = (req: Request): string => { export const createLogContext = (req: Request): LogContext => { return { requestId: req.requestId || randomUUID(), - userId: req.user?.velog_uuid, + userId: req.user?.id, method: req.method, url: req.originalUrl || req.url, userAgent: req.headers['user-agent'], From 5164ed256d916767c1b201dba35a20beb39178bb Mon Sep 17 00:00:00 2001 From: ooheunda Date: Wed, 1 Oct 2025 05:27:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?refactor:=20=EC=9D=B8=EC=A6=9D=20=EB=AF=B8?= =?UTF-8?q?=EB=93=A4=EC=9B=A8=EC=96=B4=EC=97=90=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/middlewares/validation.middleware.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/middlewares/validation.middleware.ts b/src/middlewares/validation.middleware.ts index eb2a511..f909184 100644 --- a/src/middlewares/validation.middleware.ts +++ b/src/middlewares/validation.middleware.ts @@ -1,7 +1,6 @@ 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'; @@ -23,7 +22,6 @@ export const validateRequestDto = ( req[key] = value as T; next(); } catch (error) { - logger.error(`${key} Dto 검증 중 오류 발생 : `, error); next(error); } }; From 0a2a82e83d1a3d2c892f64db3d59dac31f0f8cc8 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Mon, 6 Oct 2025 01:28:10 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=201=EC=B0=A8=20=EB=A6=AC=EB=B7=B0=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/configs/logger.config.ts | 14 +++----------- src/middlewares/accessLog.middleware.ts | 2 +- src/types/logging.ts | 4 ++-- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/configs/logger.config.ts b/src/configs/logger.config.ts index 789d058..7dd395a 100644 --- a/src/configs/logger.config.ts +++ b/src/configs/logger.config.ts @@ -13,21 +13,13 @@ if (!fs.existsSync(errorLogDir)) { } const jsonFormat = winston.format.printf((info) => { - // info.message가 객체인 경우 - if (typeof info.message === 'object' && info.message !== null) { - return JSON.stringify({ - timestamp: info.timestamp, - level: info.level.toUpperCase(), - logger: info.logger || 'default', - ...info.message, // 로그 데이터 평탄화 - }); - } + 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 index 5e45d66..3f316f2 100644 --- a/src/middlewares/accessLog.middleware.ts +++ b/src/middlewares/accessLog.middleware.ts @@ -12,7 +12,7 @@ export const accessLogMiddleware = (req: Request, res: Response, next: NextFunct // 응답 완료 시 액세스 로그 기록 res.on('finish', () => { if (res.statusCode < 400) { - // 400 이상은 에러 로그로 처리 + // 400 미만만 액세스 로그, 그 외 에러 로깅 logAccess(req, res); } }); diff --git a/src/types/logging.ts b/src/types/logging.ts index be30cc1..5eceb04 100644 --- a/src/types/logging.ts +++ b/src/types/logging.ts @@ -14,7 +14,7 @@ export interface LogContext { * 에러 로그 데이터 */ export interface ErrorLogData extends LogContext { - logger: string; + logger: 'error'; message: string; statusCode: number; errorCode?: string; @@ -26,7 +26,7 @@ export interface ErrorLogData extends LogContext { * 액세스 로그 데이터 */ export interface AccessLogData extends LogContext { - logger: string; + logger: 'access'; statusCode: number; responseTime: number; responseSize?: number;