에러 처리 코어 모듈 구현 가이드
1. CoreErrorHandlingModule
1.1 개요
애플리케이션 전반의 에러 처리, 로깅, 모니터링을 담당하는 글로벌 모듈입니다.
1.2 프로젝트 구조
CoreErrorHandlingModule은 모든 서비스와 모듈에서 공통으로 사용되는 핵심 기능이므로 libs/ 디렉토리 아래에 위치합니다.
libs/
core/
error-handling/
src/
lib/
errors/
base-error.ts
domain-error.ts
system-error.ts
filters/
global-exception.filter.ts
services/
error-logger.service.ts
error-metrics.service.ts
error-alert.service.ts
core-error-handling.module.ts
index.ts
README.md
tsconfig.json
모든 에러 처리 관련 코드는 libs/core/error-handling 디렉토리에 구현하고, 필요한 서비스에서 import { CoreErrorHandlingModule } from '@libs/core/error-handling';와 같이 임포트하여 사용합니다. 이를 통해 모든 서비스에서 일관된 에러 처리, 로깅, 모니터링 방식을 보장합니다.
1.3 구현 사항
// libs/core/error-handling/src/lib/core-error-handling.module.ts
import { Module, Global } from '@nestjs/common';
import { ErrorLoggerService } from './services/error-logger.service';
import { ErrorMetricsService } from './services/error-metrics.service';
import { ErrorAlertService } from './services/error-alert.service';
import { GlobalExceptionFilter } from './filters/global-exception.filter';
@Global()
@Module({
providers: [
ErrorLoggerService,
ErrorMetricsService,
ErrorAlertService,
GlobalExceptionFilter,
],
exports: [
ErrorLoggerService,
ErrorMetricsService,
ErrorAlertService,
],
})
export class CoreErrorHandlingModule {}
1.4 기본 에러 클래스
// libs/core/error-handling/src/lib/errors/base-error.ts
export interface ErrorMetadata {
[key: string]: any;
}
export abstract class BaseError extends Error {
constructor(
public readonly code: number,
message: string,
public readonly statusCode: number = 500,
public readonly metadata: ErrorMetadata = {},
public readonly errorId: string = randomUUID()
) {
super(message);
this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor);
}
toJSON(): Record<string, any> {
return {
code: this.code,
message: this.message,
statusCode: this.statusCode,
metadata: this.metadata,
errorId: this.errorId
};
}
}
// libs/core/error-handling/src/lib/errors/domain-error.ts
export class DomainError extends BaseError {
constructor(
code: number,
message: string,
metadata?: ErrorMetadata
) {
super(code, message, 400, metadata);
}
}
// libs/core/error-handling/src/lib/errors/system-error.ts
export class SystemError extends BaseError {
constructor(
code: number,
message: string,
metadata?: ErrorMetadata
) {
super(code, message, 500, metadata);
}
}
// 공통 도메인 에러 (추가)
export class NotFoundError extends DomainError {
constructor(
message = '리소스를 찾을 수 없습니다.', // 기본 메시지
metadata?: ErrorMetadata
) {
super(404, message, metadata); // 404 상태 코드에 매핑, 내부 에러 코드는 임의로 404 사용
}
}
export class ForbiddenError extends DomainError {
constructor(
message = '접근 권한이 없습니다.', // 기본 메시지
metadata?: ErrorMetadata
) {
super(403, message, metadata); // 403 상태 코드에 매핑, 내부 에러 코드는 임의로 403 사용
}
}
2. 서비스 구현
2.1 에러 로깅 서비스
// libs/core/error-handling/src/lib/services/error-logger.service.ts
@Injectable()
export class ErrorLoggerService {
constructor(
private readonly logger: LoggerService,
private readonly logPublisher: LogPublisherService
) {}
logError(error: Error, context?: ErrorContext): void {
const errorData = this.formatErrorData(error, context);
// BigQuery의 error_logs 테이블에 저장
this.logPublisher.publish('error', errorData);
// 일반 로깅도 함께 수행
this.logger.error(error.message, error.stack, context);
}
private formatErrorData(error: Error, context?: ErrorContext) {
const baseErrorData = {
message: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
type: 'error_log'
};
if (error instanceof BaseError) {
return {
...baseErrorData,
code: error.code,
statusCode: error.statusCode,
metadata: error.metadata,
errorId: error.errorId,
...context
};
}
return {
...baseErrorData,
...context
};
}
}
2.2 에러 메트릭스 서비스
// libs/core/error-handling/src/lib/services/error-metrics.service.ts
@Injectable()
export class ErrorMetricsService {
private readonly errorCounter: Counter;
constructor() {
this.errorCounter = new Counter({
name: 'app_errors_total',
help: '발생한 에러의 총 개수',
labelNames: ['error_type', 'error_code']
});
}
recordError(error: Error): void {
if (error instanceof BaseError) {
this.errorCounter.inc({
error_type: error.constructor.name,
error_code: error.code
});
}
}
}
2.3 에러 알림 서비스
// libs/core/error-handling/src/lib/services/error-alert.service.ts
@Injectable()
export class ErrorAlertService {
private readonly criticalErrorPatterns = [
'DATABASE_CONNECTION_ERROR',
'PAYMENT_PROCESSING_ERROR',
];
async handleError(error: BaseError): Promise<void> {
if (this.isCriticalError(error)) {
await this.sendAlert({
level: 'critical',
title: `중요 에러 발생: ${error.code}`,
message: error.message,
metadata: error.metadata
});
}
}
private isCriticalError(error: BaseError): boolean {
return this.criticalErrorPatterns.includes(error.code.toString());
}
}
3. 필터 구현
3.1 글로벌 예외 필터
// libs/core/error-handling/src/lib/filters/global-exception.filter.ts
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
Injectable,
} from '@nestjs/common';
import { Request, Response } from 'express';
import { BaseError } from '../errors/base-error';
import { ErrorCode } from '../enums/error-code.enum';
/**
* Standard validation error detail structure
*/
export interface ValidationErrorDetail {
field: string;
message: string;
}
/**
* Structure for validation message objects from class-validator or ValidationPipe
*/
interface ValidationMessageObject {
property?: string;
message?: string;
field?: string;
error?: string;
constraints?: Record<string, string>;
value?: unknown;
}
/**
* Type guard functions for safer type checking
*/
function isValidationMessageObject(value: unknown): value is ValidationMessageObject {
return typeof value === 'object' && value !== null;
}
function hasHttpExceptionResponse(value: unknown): value is { message?: unknown; error?: string } {
return typeof value === 'object' && value !== null;
}
@Catch()
@Injectable()
export class GlobalExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(GlobalExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorResponse: Record<string, any> = {
code: ErrorCode.UNKNOWN_ERROR,
message: 'An unexpected error occurred',
detail: 'Internal Server Error',
};
// Handle different types of exceptions
if (exception instanceof BaseError) {
// Custom domain errors
statusCode = exception.statusCode;
errorResponse = {
code: exception.code,
message: exception.errorNameKey,
detail: exception.message,
};
// Include metadata if present
if (exception.metadata && typeof exception.metadata === 'object') {
errorResponse['metadata'] = exception.metadata;
}
} else if (exception instanceof HttpException) {
// NestJS built-in exceptions
statusCode = exception.getStatus();
const exceptionResponse = exception.getResponse();
// Check specifically for validation errors (BadRequest)
if (
statusCode === HttpStatus.BAD_REQUEST &&
hasHttpExceptionResponse(exceptionResponse) &&
Array.isArray(exceptionResponse.message)
) {
// Handle validation errors with enhanced processing
const errors = this.processValidationMessages(exceptionResponse.message);
errorResponse = {
code: ErrorCode.VALIDATION_ERROR,
message: exceptionResponse.error || 'Validation Failed',
detail: '입력 값이 유효하지 않습니다',
errors,
};
} else {
// Other HttpExceptions
errorResponse = {
code: errorResponse['code'],
message: errorResponse['message'],
detail: typeof exceptionResponse === 'string'
? exceptionResponse
: 'An error occurred',
};
}
} else if (exception instanceof Error) {
// Standard JS Error objects
errorResponse['detail'] = exception.message || 'Unknown error occurred';
}
// Enhanced logging with detailed context
const originalExceptionDetails = exception instanceof BaseError
? exception.toJSON()
: (exception instanceof Error ? { name: exception.name, message: exception.message } : undefined);
this.logger.error(
`[${request.method} ${request.url}] Error ${errorResponse['code']}: ${errorResponse['message']}`,
{
stack: exception instanceof Error ? exception.stack : undefined,
errorResponse,
originalException: originalExceptionDetails,
}
);
response.status(statusCode).json(errorResponse);
}
/**
* Safely processes validation message arrays from various sources
* Supports both Global ValidationPipe and individual ValidationPipe formats
*/
private processValidationMessages(messages: unknown[]): ValidationErrorDetail[] {
if (!Array.isArray(messages)) {
this.logger.warn('Expected array of validation messages, got:', typeof messages);
return [];
}
return messages
.map((msg, index) => {
try {
return this.processValidationMessage(msg);
} catch (error) {
this.logger.warn(
`Failed to process validation message at index ${index}:`,
{ message: msg, error }
);
return {
field: 'unknown',
message: 'Validation error',
};
}
})
.filter((error): error is ValidationErrorDetail =>
error !== null
);
}
/**
* Processes a single validation message from ValidationPipe
* Handles both object and string formats
*/
private processValidationMessage(msg: unknown): ValidationErrorDetail | null {
if (!msg) {
return null;
}
// Handle object-based validation messages
if (isValidationMessageObject(msg)) {
if (msg.property && msg.message) {
return {
field: String(msg.property),
message: String(msg.message),
};
}
// Handle class-validator constraints format
if (msg.property && msg.constraints) {
const constraintMessages = Object.values(msg.constraints);
return {
field: String(msg.property),
message: constraintMessages.join(', '),
};
}
}
// Handle string-based validation messages
if (typeof msg === 'string') {
return {
field: this.extractFieldFromMessage(msg),
message: msg,
};
}
// Safe fallback
return {
field: 'unknown',
message: String(msg),
};
}
/**
* Extracts field name from string validation messages
* Supports various patterns like "email must be valid", "user.name is required"
*/
private extractFieldFromMessage(message: string): string {
if (!message || typeof message !== 'string') {
return 'unknown';
}
try {
const patterns = [
/^(\w+(?:\.\w+)*(?:\[\d+\])*) /, // nested.field[0] format
/^(\w+) /, // basic field format
/property\s+"([^"]+)"/, // property "fieldName" format
/(\w+(?:\.\w+)*)\s+(?:must|should|is)/i, // flexible pattern
];
for (const pattern of patterns) {
const match = message.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return 'unknown';
} catch (error) {
this.logger.warn('Error extracting field from message:', { message, error });
return 'unknown';
}
}
}
3.2 새로운 타입 안전성 기능 (v0.47.0+)
ValidationErrorDetail 인터페이스
Validation 오류 시 클라이언트에게 반환되는 필드별 상세 정보:
export interface ValidationErrorDetail {
field: string; // 오류 발생 필드명
message: string; // 구체적인 오류 메시지
}
지원하는 Validation 형태
1. NestJS ValidationPipe 객체 형태 (main.ts에서 설정)
{
"property": "email",
"message": "must be an email",
"value": "invalid-email"
}
2. class-validator constraints 형태
{
"property": "accessCode",
"constraints": {
"isLength": "must be exactly 16 characters",
"matches": "must be alphanumeric"
},
"value": "invalid"
}
3. 문자열 validation 메시지
"email must be an email"
"user.profile.name should not be empty"
타입 안전성 개선사항
이전 (any 타입 사용):
const msgObj = msg as any; // 타입 안전하지 않음
if (msgObj.property && msgObj.message) { ... }
개선 (타입 가드 사용):
function isValidationMessageObject(value: unknown): value is ValidationMessageObject {
return typeof value === 'object' && value !== null;
}
if (isValidationMessageObject(msg)) {
if (msg.property && msg.message) { ... } // 타입 안전
}
3.3 실제 사용 예시
ValidationPipe 설정과 GlobalExceptionFilter 연동
main.ts에서의 ValidationPipe 설정:
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
exceptionFactory: (errors) => {
const result = errors.map((error) => ({
property: error.property,
message: error.constraints
? error.constraints[Object.keys(error.constraints)[0]]
: 'Invalid value',
value: error.value,
}));
return new BadRequestException(result);
},
})
);
GlobalExceptionFilter에서 자동 처리:
- ValidationPipe가 생성한 객체 배열이 자동으로
ValidationErrorDetail[]로 변환됨 - 타입 안전성으로 런타임 에러 (503 에러) 방지
- 다양한 validation 형태 모두 지원
클라이언트 응답 예시
WCS URI validation 오류 (실제 로그 케이스):
{
"code": 1001,
"message": "VALIDATION_ERROR",
"detail": "Input validation failed",
"errors": [
{
"field": "content",
"message": "Invalid WCS URI format. Expected: wcs://[language]/[version]/[path]"
}
]
}
여러 필드 validation 오류:
{
"code": 1001,
"message": "VALIDATION_ERROR",
"detail": "Input validation failed",
"errors": [
{
"field": "email",
"message": "must be an email"
},
{
"field": "accessCode",
"message": "must be exactly 16 characters, must be alphanumeric"
},
{
"field": "user.profile.name",
"message": "should not be empty"
}
]
}
4. 도메인에서의 사용
4.1 도메인별 에러 정의
// libs/feature/auth/src/lib/errors/auth.errors.ts
import { DomainError } from '@core/error-handling';
export class InvalidCredentialsError extends DomainError {
constructor(metadata?: ErrorMetadata) {
super(
2023,
'이메일 또는 비밀번호가 올바르지 않습니다',
metadata
);
}
}
4.2 서비스에서의 사용
// libs/feature/auth/src/lib/services/auth.service.ts
import { Injectable } from '@nestjs/common';
import { ErrorLoggerService } from '@core/error-handling';
import { InvalidCredentialsError } from '../errors/auth.errors'; // 4.1에서 정의한 에러 임포트
// ... (다른 필요한 import들)
@Injectable()
export class AuthService {
constructor(
private readonly errorLogger: ErrorLoggerService
// ... (다른 필요한 서비스 주입)
) {}
async login(loginDto: LoginDto): Promise<any> { // 실제 반환 타입 명시 필요
try {
// --- 비즈니스 로직 시작 ---
const user = await this.findUserByEmail(loginDto.email); // 사용자 조회
if (!user || !(await this.verifyPassword(loginDto.password, user.passwordHash))) {
// <<--- 여기서 정의된 커스텀 에러를 throw 합니다.
throw new InvalidCredentialsError({ attemptEmail: loginDto.email });
}
// 로그인 성공 처리 (예: 토큰 생성)
const token = this.generateJwtToken(user);
return { accessToken: token };
// --- 비즈니스 로직 끝 ---
} catch (error) {
// InvalidCredentialsError 또는 다른 에러가 여기서 잡힘
this.errorLogger.logError(error, {
context: 'AuthService.login',
data: { email: loginDto.email } // 로깅 시 필요한 컨텍스트 추가
});
// 잡은 에러를 GlobalExceptionFilter가 처리하도록 다시 던짐
throw error;
}
}
...
}
5. 모듈 통합
5.1 App 모듈에서의 통합
// apps/dta-wide-api/src/app/app.module.ts
import { Module } from '@nestjs/common';
import { CoreConfigModule } from '@core/config';
import { CoreDatabaseModule } from '@core/database';
import { CoreCommonModule } from '@core/common';
import { CoreErrorHandlingModule } from '@core/error-handling';
@Module({
imports: [
CoreConfigModule,
CoreDatabaseModule,
CoreCommonModule,
CoreErrorHandlingModule,
],
})
export class AppModule {}
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-03-24 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-03-26 | bok@weltcorp.com | BigQuery 로그 저장 통합 |
| 0.3.0 | 2025-03-30 | bok@weltcorp.com | 에러 처리 모듈 위치 libs/ 경로로 명시 |
| 0.47.0 | 2025-09-09 | jeff@weltcorp.com | GlobalExceptionFilter 타입 안전성 개선, ValidationErrorDetail 인터페이스 추가, validation 처리 로직 고도화, 503 에러 방지 기능 추가 |