Skip to content

Commit 0f91d62

Browse files
committed
refactor: 에러 로그 개선 및 액세스 로그 추가
1 parent e29d2ba commit 0f91d62

File tree

9 files changed

+211
-16
lines changed

9 files changed

+211
-16
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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ if (!fs.existsSync(errorLogDir)) {
1313
}
1414

1515
const jsonFormat = winston.format.printf((info) => {
16+
// info.message가 객체인 경우
17+
if (typeof info.message === 'object' && info.message !== null) {
18+
return JSON.stringify({
19+
timestamp: info.timestamp,
20+
level: info.level.toUpperCase(),
21+
logger: info.logger || 'default',
22+
...info.message, // 로그 데이터 평탄화
23+
});
24+
}
25+
1626
return JSON.stringify({
1727
timestamp: info.timestamp,
1828
level: info.level.toUpperCase(),
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 & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextFunction, Request, Response } from 'express';
22
import { plainToInstance } from 'class-transformer';
33
import { validate } from 'class-validator';
44
import logger from '@/configs/logger.config';
5+
import { BadRequestError } from '@/exception';
56

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

@@ -16,16 +17,7 @@ export const validateRequestDto = <T extends object>(
1617
const errors = await validate(value);
1718

1819
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;
20+
throw new BadRequestError(`API 입력 검증 실패, errors: ${errors}`);
2921
}
3022

3123
req[key] = value as T;

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?: string;
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: string;
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: string;
30+
statusCode: number;
31+
responseTime: number;
32+
responseSize?: number;
33+
}

src/utils/logging.util.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Request, Response } from 'express';
2+
import { randomUUID } from 'crypto';
3+
import logger from '@/configs/logger.config';
4+
import { LogContext, ErrorLogData, AccessLogData } from '@/types/logging';
5+
import { CustomError } from '@/exception';
6+
7+
/**
8+
* 클라이언트 IP 주소 추출
9+
*/
10+
export const getClientIp = (req: Request): string => {
11+
return (
12+
(req.headers['x-forwarded-for'] as string)?.split(',')[0] ||
13+
(req.headers['x-real-ip'] as string) ||
14+
req.socket.remoteAddress ||
15+
'unknown'
16+
);
17+
};
18+
19+
/**
20+
* 요청에서 기본 로그 컨텍스트 생성
21+
*/
22+
export const createLogContext = (req: Request): LogContext => {
23+
return {
24+
requestId: req.requestId || randomUUID(),
25+
userId: req.user?.velog_uuid,
26+
method: req.method,
27+
url: req.originalUrl || req.url,
28+
userAgent: req.headers['user-agent'],
29+
ip: getClientIp(req),
30+
};
31+
};
32+
33+
/**
34+
* 로그 레벨과 로거 이름 결정
35+
*/
36+
export const getLogLevel = (statusCode: number): 'info' | 'warn' | 'error' => {
37+
if (statusCode < 400) return 'info';
38+
if (statusCode === 404) return 'warn';
39+
return 'error';
40+
};
41+
42+
/**
43+
* 에러 로그 생성 및 출력
44+
*
45+
* @param req Express Request 객체
46+
* @param res Express Response 객체
47+
* @param error Error 객체
48+
* @param customMessage 커스텀 에러 메시지 (선택)
49+
* @param additionalData 추가 로그 데이터 (선택)
50+
*/
51+
export const logError = (
52+
req: Request,
53+
res: Response,
54+
error: Error,
55+
customMessage?: string,
56+
additionalData?: Record<string, unknown>,
57+
): void => {
58+
const statusCode = res.statusCode || 500;
59+
const level = getLogLevel(statusCode);
60+
61+
const context = createLogContext(req);
62+
const responseTime = req.startTime ? Date.now() - req.startTime : undefined;
63+
64+
// 스택 트레이스 포함 여부 결정
65+
const includeStack = error instanceof CustomError && error.statusCode < 500 ? false : true;
66+
67+
// 기본 에러 로그 데이터 생성 (winston 기본 필드 제외)
68+
const errorLogData: ErrorLogData = {
69+
logger: 'error',
70+
requestId: context.requestId,
71+
userId: context.userId,
72+
method: context.method,
73+
url: context.url,
74+
userAgent: context.userAgent,
75+
ip: context.ip,
76+
message: customMessage || error.message,
77+
statusCode,
78+
errorCode: error instanceof CustomError ? error.code : undefined,
79+
...(includeStack && { stack: error.stack }),
80+
responseTime,
81+
...additionalData,
82+
};
83+
84+
logger[level]({ message: errorLogData });
85+
};
86+
87+
/**
88+
* 액세스 로그 생성 및 출력
89+
*
90+
* @param req Express Request 객체
91+
* @param res Express Response 객체
92+
* @param additionalData 추가 로그 데이터 (선택)
93+
*/
94+
export const logAccess = (req: Request, res: Response, additionalData?: Record<string, unknown>): void => {
95+
const statusCode = res.statusCode;
96+
const level = getLogLevel(statusCode);
97+
98+
const context = createLogContext(req);
99+
const responseTime = req.startTime ? Date.now() - req.startTime : 0;
100+
101+
// 응답 크기 추정 (정확하지 않을 수 있음)
102+
const contentLength = res.get('content-length');
103+
const responseSize = contentLength ? parseInt(contentLength, 10) : undefined;
104+
105+
const accessLogData: AccessLogData = {
106+
logger: 'access',
107+
requestId: context.requestId,
108+
userId: context.userId,
109+
method: context.method,
110+
url: context.url,
111+
userAgent: context.userAgent,
112+
ip: context.ip,
113+
statusCode,
114+
responseTime,
115+
responseSize,
116+
...additionalData,
117+
};
118+
119+
logger[level](accessLogData);
120+
};
121+
122+
/**
123+
* 요청 시작 시점 기록을 위한 미들웨어 헬퍼
124+
*/
125+
export const recordRequestStart = (req: Request): void => {
126+
req.requestId = req.requestId || randomUUID();
127+
req.startTime = Date.now();
128+
};

0 commit comments

Comments
 (0)