-
Notifications
You must be signed in to change notification settings - Fork 0
[25.10.01 / TASK-248] Refactor - 에러 로그 개선 및 액세스 로그 추가 #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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(); | ||
}; |
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'; | ||
|
||
|
@@ -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}`); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||
} | ||
}; | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -8,6 +8,8 @@ declare global { | |
accessToken: string; | ||
refreshToken: string; | ||
}; | ||
requestId: string; | ||
startTime: number; | ||
Comment on lines
+11
to
+12
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이제 모든 request 가 이 값을 middleware 에서 Injection 받고 시작하는거죠? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 넵 맞습니다! |
||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,33 @@ | ||
/** | ||
* 기본 로그 컨텍스트 정보 | ||
*/ | ||
export interface LogContext { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
} |
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, | ||
}) | ||
); | ||
}); | ||
}); | ||
}); |
Uh oh!
There was an error while loading. Please reload this page.