Skip to content

Commit 166319a

Browse files
authored
[25.10.01 / TASK-248] Refactor - 에러 로그 개선 및 액세스 로그 추가 (#45)
* refactor: 에러 로그 개선 및 액세스 로그 추가 * test: 로깅 유틸 함수에 대한 테스트 추가 * refactor: 인증 미들웨어에 중복 로깅 삭제 * fix: 1차 리뷰 반영
1 parent e29d2ba commit 166319a

File tree

10 files changed

+367
-20
lines changed

10 files changed

+367
-20
lines changed

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { options } from '@/configs/swagger.config';
1414
import { getSentryStatus } from '@/configs/sentry.config';
1515
import { getCacheStatus } from '@/configs/cache.config';
1616
import { errorHandlingMiddleware } from '@/middlewares/errorHandling.middleware';
17+
import { accessLogMiddleware } from '@/middlewares/accessLog.middleware';
1718

1819
dotenv.config();
1920

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

2526
const swaggerSpec = swaggerJSDoc(options);
2627

28+
app.use(accessLogMiddleware);
2729
app.use(cookieParser());
2830
app.use(express.json({ limit: '10mb' })); // 파일 업로드 대비
2931
app.use(express.urlencoded({ extended: true, limit: '10mb' }));

src/configs/logger.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ if (!fs.existsSync(errorLogDir)) {
1313
}
1414

1515
const jsonFormat = winston.format.printf((info) => {
16+
const message = typeof info.message === 'object' && info.message !== null ? info.message : { message: info.message };
17+
1618
return JSON.stringify({
1719
timestamp: info.timestamp,
1820
level: info.level.toUpperCase(),
19-
logger: 'default',
20-
message: info.message,
21+
logger: info.logger || 'default',
22+
...message,
2123
});
2224
});
2325

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Request, Response, NextFunction } from 'express';
2+
import { recordRequestStart, logAccess } from '@/utils/logging.util';
3+
4+
/**
5+
* 액세스 로그 미들웨어
6+
* 모든 요청의 시작과 끝을 기록합니다.
7+
*/
8+
export const accessLogMiddleware = (req: Request, res: Response, next: NextFunction): void => {
9+
// 요청 시작 시점 기록
10+
recordRequestStart(req);
11+
12+
// 응답 완료 시 액세스 로그 기록
13+
res.on('finish', () => {
14+
if (res.statusCode < 400) {
15+
// 400 미만만 액세스 로그, 그 외 에러 로깅
16+
logAccess(req, res);
17+
}
18+
});
19+
20+
next();
21+
};

src/middlewares/errorHandling.middleware.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { Request, Response, NextFunction, ErrorRequestHandler } from 'express';
33
import { CustomError } from '@/exception';
44
import * as Sentry from '@sentry/node';
5-
import logger from '@/configs/logger.config';
5+
import { logError } from '@/utils/logging.util';
66

77
export const errorHandlingMiddleware: ErrorRequestHandler = (
88
err: CustomError,
@@ -11,16 +11,20 @@ export const errorHandlingMiddleware: ErrorRequestHandler = (
1111
next: NextFunction,
1212
) => {
1313
if (err instanceof CustomError) {
14-
res
15-
.status(err.statusCode)
16-
.json({ success: false, message: err.message, error: { code: err.code, statusCode: err.statusCode } });
14+
res.status(err.statusCode);
15+
logError(req, res, err, `Custom Error: ${err.message}`);
16+
17+
res.json({ success: false, message: err.message, error: { code: err.code, statusCode: err.statusCode } });
1718
return;
1819
}
1920

21+
// Sentry에 에러 전송
2022
Sentry.captureException(err);
21-
logger.error('Internal Server Error');
2223

23-
res.status(500).json({
24+
res.status(500);
25+
logError(req, res, err as Error, 'Internal Server Error');
26+
27+
res.json({
2428
success: false,
2529
message: '서버 내부 에러가 발생하였습니다.',
2630
error: {

src/middlewares/validation.middleware.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { NextFunction, Request, Response } from 'express';
22
import { plainToInstance } from 'class-transformer';
33
import { validate } from 'class-validator';
4-
import logger from '@/configs/logger.config';
4+
import { BadRequestError } from '@/exception';
55

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

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

1818
if (errors.length > 0) {
19-
logger.error(`API 입력 검증 실패, errors: ${errors}`);
20-
res.status(400).json({
21-
success: false,
22-
message: '검증에 실패하였습니다. 입력값을 다시 확인해주세요.',
23-
errors: errors.map((error) => ({
24-
property: error.property,
25-
constraints: error.constraints,
26-
})),
27-
});
28-
return;
19+
throw new BadRequestError(`API 입력 검증 실패, errors: ${errors}`);
2920
}
3021

3122
req[key] = value as T;
3223
next();
3324
} catch (error) {
34-
logger.error(`${key} Dto 검증 중 오류 발생 : `, error);
3525
next(error);
3626
}
3727
};

src/types/express.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ declare global {
88
accessToken: string;
99
refreshToken: string;
1010
};
11+
requestId: string;
12+
startTime: number;
1113
}
1214
}
1315
}

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,8 @@ export { TotalStatsResponseDto } from '@/types/dto/responses/totalStatsResponse.
4242
export type { SentryIssueStatus } from '@/types/models/Sentry.type';
4343
export type { SentryProject, SentryIssue, SentryWebhookData } from '@/types/models/Sentry.type';
4444

45+
// Logging 관련
46+
export type { LogContext, ErrorLogData, AccessLogData } from '@/types/logging';
47+
4548
// Common
4649
export { EmptyResponseDto } from '@/types/dto/responses/emptyReponse.type';

src/types/logging.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* 기본 로그 컨텍스트 정보
3+
*/
4+
export interface LogContext {
5+
requestId: string;
6+
userId?: number;
7+
method: string;
8+
url: string;
9+
userAgent?: string;
10+
ip?: string;
11+
}
12+
13+
/**
14+
* 에러 로그 데이터
15+
*/
16+
export interface ErrorLogData extends LogContext {
17+
logger: 'error';
18+
message: string;
19+
statusCode: number;
20+
errorCode?: string;
21+
stack?: string;
22+
responseTime?: number;
23+
}
24+
25+
/**
26+
* 액세스 로그 데이터
27+
*/
28+
export interface AccessLogData extends LogContext {
29+
logger: 'access';
30+
statusCode: number;
31+
responseTime: number;
32+
responseSize?: number;
33+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { Request, Response } from 'express';
2+
import { Socket } from 'net';
3+
import {
4+
createLogContext,
5+
logError,
6+
logAccess,
7+
getClientIp,
8+
getLogLevel,
9+
} from '@/utils/logging.util';
10+
import { CustomError } from '@/exception';
11+
import { User } from '@/types';
12+
import logger from '@/configs/logger.config';
13+
14+
// logger 모킹
15+
jest.mock('@/configs/logger.config', () => ({
16+
error: jest.fn(),
17+
warn: jest.fn(),
18+
info: jest.fn(),
19+
}));
20+
21+
describe('Logging Utilities', () => {
22+
let mockRequest: Partial<Request>;
23+
let mockResponse: Partial<Response>;
24+
25+
beforeEach(() => {
26+
mockRequest = {
27+
headers: {'x-forwarded-for': '127.0.0.1'},
28+
method: 'GET',
29+
originalUrl: '/api/test',
30+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
31+
user: { id: 123, velog_uuid: 'user123' } as User,
32+
requestId: 'test-request-id',
33+
startTime: Date.now() - 100,
34+
};
35+
36+
mockResponse = {
37+
statusCode: 200,
38+
get: jest.fn(),
39+
};
40+
});
41+
42+
afterEach(() => {
43+
jest.clearAllMocks();
44+
});
45+
46+
describe('getClientIp', () => {
47+
it('x-forwarded-for 헤더에서 IP를 추출해야 한다', () => {
48+
mockRequest.headers = { 'x-forwarded-for': '192.168.1.1, 10.0.0.1' };
49+
expect(getClientIp(mockRequest as Request)).toBe('192.168.1.1');
50+
});
51+
52+
it('x-real-ip 헤더에서 IP를 추출해야 한다', () => {
53+
mockRequest.headers = { 'x-real-ip': '203.0.113.1' };
54+
expect(getClientIp(mockRequest as Request)).toBe('203.0.113.1');
55+
});
56+
57+
it('헤더가 없으면 unknown을 반환해야 한다', () => {
58+
mockRequest.headers = {};
59+
/* eslint-disable @typescript-eslint/consistent-type-assertions */
60+
mockRequest.socket = { remoteAddress: undefined } as Socket;
61+
expect(getClientIp(mockRequest as Request)).toBe('unknown');
62+
});
63+
});
64+
65+
describe('getLogLevel', () => {
66+
it('200은 info 레벨을 반환해야 한다', () => {
67+
expect(getLogLevel(200)).toBe('info');
68+
});
69+
70+
it('404는 warn 레벨을 반환해야 한다', () => {
71+
expect(getLogLevel(404)).toBe('warn');
72+
});
73+
74+
it('500은 error 레벨을 반환해야 한다', () => {
75+
expect(getLogLevel(500)).toBe('error');
76+
});
77+
});
78+
79+
describe('createLogContext', () => {
80+
it('요청에서 올바른 로그 컨텍스트를 생성해야 한다', () => {
81+
const context = createLogContext(mockRequest as Request);
82+
83+
expect(context.requestId).toBe('test-request-id');
84+
expect(context.userId).toBe(123);
85+
expect(context.method).toBe('GET');
86+
expect(context.url).toBe('/api/test');
87+
expect(context.userAgent).toBeUndefined();
88+
expect(context.ip).toBe('127.0.0.1');
89+
});
90+
});
91+
92+
describe('logError', () => {
93+
it('일반 에러를 올바르게 로깅해야 한다', () => {
94+
const error = new Error('Test error');
95+
mockResponse.statusCode = 500; // error 레벨을 위해 500으로 설정
96+
97+
logError(mockRequest as Request, mockResponse as Response, error);
98+
99+
expect(logger.error).toHaveBeenCalledWith(
100+
expect.objectContaining({
101+
message: expect.objectContaining({
102+
logger: 'error',
103+
message: 'Test error',
104+
statusCode: 500,
105+
requestId: 'test-request-id',
106+
userId: 123,
107+
method: 'GET',
108+
url: '/api/test',
109+
ip: '127.0.0.1',
110+
})
111+
})
112+
);
113+
});
114+
115+
it('CustomError의 경우 에러 코드를 포함해야 한다', () => {
116+
const customError = new CustomError('Custom error', 'CUSTOM_ERROR', 400);
117+
mockResponse.statusCode = 400;
118+
119+
logError(mockRequest as Request, mockResponse as Response, customError);
120+
121+
expect(logger.error).toHaveBeenCalledWith(
122+
expect.objectContaining({
123+
message: expect.objectContaining({
124+
errorCode: 'CUSTOM_ERROR',
125+
})
126+
})
127+
);
128+
});
129+
130+
it('500이거나 예상하지 못한 에러는 스택 트레이스를 포함해야 한다', () => {
131+
const error = new Error('Test error');
132+
mockResponse.statusCode = 500;
133+
134+
logError(mockRequest as Request, mockResponse as Response, error);
135+
136+
expect(logger.error).toHaveBeenCalledWith(
137+
expect.objectContaining({
138+
message: expect.objectContaining({
139+
stack: expect.any(String),
140+
})
141+
})
142+
);
143+
});
144+
});
145+
146+
describe('logAccess', () => {
147+
it('액세스 로그를 올바르게 생성해야 한다', () => {
148+
(mockResponse.get as jest.Mock).mockReturnValue('1024');
149+
150+
logAccess(mockRequest as Request, mockResponse as Response);
151+
152+
expect(logger.info).toHaveBeenCalledWith(
153+
expect.objectContaining({
154+
logger: 'access',
155+
statusCode: 200,
156+
responseTime: expect.any(Number),
157+
responseSize: 1024,
158+
})
159+
);
160+
});
161+
});
162+
});

0 commit comments

Comments
 (0)