Lsiron
AIR PANG(ts, express, mysql)- 4(유효성 검사 미들웨어와 커스텀에러 클래스 적용) 본문
현재 폴더 구조
data_project/
├── back # 백엔드 프로젝트 루트 디렉토리
│ ├── logs # 로그 파일들이 위치하는 디렉토리
│ ├── node_modules # 의존성 모듈들이 위치하는 디렉토리
│ └── src # 소스 파일들이 위치하는 디렉토리
│ ├── config # 환경설정 관련 파일들이 위치하는 디렉토리
│ │ └── db.config.ts # 데이터베이스 설정 파일 (MySQL 연결 설정)
│ ├── controllers # 컨트롤러 파일들이 위치하는 디렉토리
│ │ └── challengeController.ts # 챌린지 관련 컨트롤러 파일
│ ├── dto # 데이터 전송 객체들이 위치하는 디렉토리
│ │ └── challenge.dto.ts # 챌린지 관련 DTO 파일
│ ├── middlewares # 미들웨어 파일들이 위치하는 디렉토리
│ │ └── validateDto.ts # DTO 유효성 검사 미들웨어
│ ├── repositories # 데이터 저장소 파일들이 위치하는 디렉토리
│ │ └── challengeRepository.ts # 챌린지 관련 데이터 저장소 파일
│ ├── routes # 라우트 정의 파일들이 위치하는 디렉토리
│ ├── services # 서비스 파일들이 위치하는 디렉토리
│ │ └── challengeService.ts # 챌린지 관련 비즈니스 로직 파일
│ ├── types # 타입 정의 파일들이 위치하는 디렉토리
│ ├── utils # 유틸리티 함수들이 위치하는 디렉토리
│ │ ├── customError.ts # 커스텀 에러 클래스 정의 파일
│ │ └── logger.ts # 로깅 유틸리티 (Winston 설정)
│ ├── app.ts # 애플리케이션 진입점 파일 (Express 설정 및 미들웨어 구성)
│ └── index.ts # 서버 시작 파일 (Express 서버 실행)
│ ├── .env # 환경변수 설정 파일 (개발 환경)
│ ├── .env.production # 프로덕션 환경변수 설정 파일
│ ├── .gitignore # Git에서 무시할 파일을 지정하는 파일
│ ├── nodemon.json # Nodemon 설정 파일 (개발 중 서버 자동 재시작 설정)
│ ├── package-lock.json # 정확한 버전의 패키지를 설치하기 위한 파일
│ ├── package.json # 프로젝트 메타데이터 및 의존성 목록
│ └── tsconfig.json # TypeScript 설정 파일 (컴파일러 옵션 등)
challengeController와 challengeService에 커스텀 에러 클래스를 적용하고, DTO의 유효성 검사 미들웨어를 구현 해 보자.
1. 커스텀 에러 클래스란 무엇인가?
커스텀 에러 클래스(Custom Error Class)는 기본적으로 자바스크립트나 타입스크립트에서 제공하는 표준 Error 클래스를 확장(extends)하여 특정 상황에 맞는 에러를 정의하는 사용자 정의 클래스이다.
특히, 커스텀 에러 클래스는 각기 다른 상황에서 발생하는 에러를 구분할 수 있도록, 각 유형의 에러를 명확하게 정의할 수 있다.
예를 들어, 인증 오류는 AuthenticationError, 권한 오류는 AuthorizationError, 유효성 검사 오류는 ValidationError 등으로 정의할 수 있다.
또한 커스텀 에러 클래스는 중앙 집중식 에러 처리와 결합하여 모든 에러를 일관되게 처리할 수 있다.
즉, 각 에러마다 별도의 로직을 작성할 필요 없이, 공통된 처리 방식으로 관리할 수 있다는 것.
특히 커스텀 에러 클래스를 사용함으로 인해 편리했던 점은 디버깅이 전 보다 훨씬 수월한 점이였다.
기본 Error 클래스는 에러를 표현하기 위한 최소한의 정보만 제공하지만, 커스텀 에러 클래스는 이 정보를 확장하여 추가적인 속성(예: HTTP 상태 코드, 유효성 검사 오류 등)을 제공하고, 특정 에러 유형을 구체적으로 관리할 수 있도록 해준다.
기본 Error와 커스텀 에러 클래스의 차이점
기본 'Error' : 기본적으로 자바스크립트/타입스크립트에서는 아래와 같은 방식으로 에러를 던진다.
throw new Error("Something went wrong!");
그러나 이렇게 던진 에러는 단순한 메시지만 포함할 수 있고, 상황에 대한 추가적인 정보(HTTP 상태 코드, 관련 데이터 등)를 포함할 수 없다.
커스텀 에러 클래스 : HTTP 응답을 처리할 때 사용할 수 있는 커스텀 에러 클래스의 예시이다.
class HttpError extends Error {
public statusCode: number;
constructor(message: string, statusCode: number) {
super(message); // 부모 클래스 Error의 생성자 호출
this.statusCode = statusCode; // 추가적인 상태 코드 저장
}
}
이 클래스는 statusCode라는 추가 속성을 가지며, 이를 통해 HTTP 상태 코드를 에러와 함께 처리할 수 있다.
참고로 super는 부모 클래스의 생성자를 호출하는 데 사용된다. 여기서는 Error 클래스의 생성자를 호출하여 Error 객체에 메시지를 전달하고 있다.
부모 클래스인 Error는 메시지에 대한 기본적인 처리(에러 메시지를 Error 객체로 등록)를 수행한다.
쉽게 말해서, 자식이 부모가 물려준 가게를 운영하기 위해서는 부모가 전수해준 기본적인 기술(예: 빵 만드는 법)이 반드시 필요하다. 하지만 자식은 부모의 방식에 자신만의 변화를 추가할 수 있다.
- 부모 클래스(Error): 기본적인 빵집 운영 방식(기본 에러 처리).
- 자식 클래스(CustomError): 부모에게서 배운 빵집 운영 방식에 자신의 특별한 메뉴나 기능(추가적인 속성, 예를 들어 상태 코드와 같은)을 더함.
2. 커스텀 에러 클래스 구현
자, 이제 AIRPANG 프로젝트에 사용할 커스텀에러를 제작 해 보자.
export class CustomError extends Error {
public statusCode: number;
public validationErrors?: object[];
constructor(message: string, statusCode: number, validationErrors?: object[]) {
super(message);
this.statusCode = statusCode;
this.validationErrors = validationErrors;
}
static handleError(err: Error): { statusCode: number; message: string; validationErrors?: object[] } {
if (err instanceof CustomError) {
return {
statusCode: err.statusCode,
message: err.message,
validationErrors: err.validationErrors,
};
}
return {
statusCode: 500,
message: 'Internal Server Error',
};
}
}
export class ValidationError extends CustomError {
constructor(errors: object[]) {
super('Validation failed', 400, errors);
}
}
export class AuthenticationError extends CustomError {
constructor(message: string = 'Authentication failed') {
super(message, 401);
}
}
export class AuthorizationError extends CustomError {
constructor(message: string = 'You do not have permission to perform this action') {
super(message, 403);
}
}
export class NotFoundError extends CustomError {
constructor(entity: string, identifier: string) {
super(`${entity} with identifier ${identifier} was not found`, 404);
}
}
CustomError 클래스는 기본 Error 클래스에서 에러 메시지 처리를 하도록 super(message)로 전달한다.
추가로 statusCode와 validationErrors를 직접 관리하도록 설계했으며, 자식 클래스가 이 두 가지 속성을 사용할 수 있도록 만들었다.
CustomError.handleError(err) 함수는 에러가 CustomError의 인스턴스인지 확인하고, 맞다면 해당 에러의 상태 코드와 메시지, 유효성 검사 오류를 반환한다.
만약 CustomError의 인스턴스가 아니라면? 500 상태 코드와 "Internal Server Error"라는 메시지가 반환한다.
마지막으로 각 자식 클래스는 super() 를 통해 자신에게 맞는 기본 메시지와 상태 코드를 CustomError로 전달하여 처리하도록 설계했다. ( 402 에러의 경우 결제 필요 에러 이므로, AIRPANG 프로젝트에는 필요없기 때문에 제외 )
특히, ValidationError는 400 상태 코드와 특정한 유효성 검사 오류를 다루기 위해 만들었다.
이는 클라이언트 측에서 수정할 수 있는 유효성 검사 오류를 객체로 전달하기 위함이다.
이제 커스텀에러를 만들었으니, DTO를 검사하는 validateDto 미들웨어를 구현 해 보자.
3. 유효성 검사 미들웨어 구현
validateDto 미들웨어는 요청 데이터가 서버에 도달하기 전에 유효성 검사를 수행하는 역할을 한다.
주로 클라이언트로부터 전달된 데이터가 올바른지 확인하고, 그 데이터가 서버의 기대에 맞지 않는 경우 이를 미리 감지하여 처리 해준다.
클라이언트에서 전달된 데이터가 올바르지 않으면 서버 로직에 문제가 발생할 수 있다.
예를 들어서 숫자가 들어가야 할 필드에 문자열이 들어가거나 필수 필드가 누락되는 등, 이러한 경우를 사전에 방지하기 위해 서버 안정성 확보 측면에서 유효성 검사가 필요하다.
또한 악의적인 데이터나 잘못된 입력을 사전에 차단하여 SQL 인젝션이나 XSS 같은 공격을 예방하는 보안 강화 측면에서도 유효성 검사가 필요하다.
이를 통해 DTO는 서버가 클라이언트에게 기대하는 데이터 구조를 명확히 정의하며, 유효성 검사는 그 계약을 확실하게 지키도록 해준다.
특히, 서비스나 컨트롤러 계층에서 데이터가 유효한지 확인하는 로직을 반복해서 작성할 필요가 없도록 해 주기 때문에 실제 비즈니스 로직에만 집중할 수 있도록 코드를 간결화 시켜준다.
미들웨어가 검사하는 것은 네 가지가 있다.
1. 필드 존재 여부 : 필수 필드가 요청에 포함 돼 있는지 확인한다.
2. 데이터 유형: 각 필드가 데이터 유형과 일치하는지 확인한다.
( 문자열, 숫자, 배열 등 )
3. 비즈니스 규칙: 비즈니스에 따라 요구되는 추가적인 규칙이 지켜지는지 확인한다.
( 특정 범위 내의 값, 문자열의 길이 제한 등 )
4. 중첩된 객체의 유효성 검사: 객체 내에 중첩된 데이터 구조가 올바른지 확인한다.
자, 이제 AIRPANG 프로젝트에 사용할 validateDto 미들웨어를 제작 해 보자.
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ValidationError } from '@_utils/customError';
import logger from '@_utils/logger';
export const validateDto = async <T extends object>(dtoClass: new () => T, data: object): Promise<void> => {
const dto = plainToInstance(dtoClass, data);
const errors = await validate(dto);
if (errors.length > 0) {
logger.warn('Validation failed:', {validationErrors: errors.map(e => e.constraints)});
throw new ValidationError(errors);
}
};
먼저 클라이언트에서 온 순수 객체 데이터를 DTO 클래스의 인스턴스로 변환 시켜준다.
이 변환은 class-transformer 라이브러리를 통해 이루어지고, 이렇게 함으로써 해당 객체가 DTO 클래스에 정의된 규칙에 맞게 형식이 변환된다.
DTO 클래스에 설정된 유효성 검사 규칙, 예를 들어 @IsString(), @IsNumber() 등을 기반으로, 클라이언트에서 온 데이터가 규칙을 지키고 있는지 확인한다. 이는 class-validator 라이브러리를 사용하여 수행된다.
즉, 내가 앞서 설정한 challenge.dto.ts 파일대로 데이터들이 알맞게 들어왔는지 검사하는 것.
만약 유효성 검사가 실패하면, customError 클래스로 제작한 ValidationError를 발생시켜 클라이언트에게 어떤 데이터가 잘못되었는지 명확히 전달해 준다.
특히, error 객체중에 다양한 정보가 들어있을 때, 이 중 에러가 발생한 필드만 반환하도록 map 함수로 감싸주었다.
4. 컨트롤러에 커스텀 에러와 유효성 검사 적용
이제 challaengeController.ts 파일로 가서 위에서 구현한 customError 클래스와 유효성 검사 미들웨어를 실제 controller에 적용시켜 보자.
import { Request, Response, NextFunction } from 'express';
import { plainToInstance } from 'class-transformer';
import { ChallengeService } from '@_services/challengeService';
import { GetAllChallengesDto, GetChallengeByIdDto, CreateChallengeDto, UpdateChallengeDto } from '@_dto/challenge.dto';
import { validateDto } from '@_middlewares/validateDto';
import { NotFoundError, AuthorizationError } from '@_utils/customError';
import logger from '@_utils/logger';
export class ChallengeController {
private challengeService: ChallengeService;
constructor() {
this.challengeService = new ChallengeService();
}
// 모든 챌린지 가져오기
public getAllChallengesController = async (req: Request, res: Response, next: NextFunction) => {
try {
const getAllChallengesDto = plainToInstance(GetAllChallengesDto, req.query);
await validateDto(GetAllChallengesDto, getAllChallengesDto);
const search = getAllChallengesDto.search || '';
const page = getAllChallengesDto.page ?? 1;
const limit = getAllChallengesDto.limit ?? 4;
const { challenges, total } = await this.challengeService.getAllChallenges(search, page, limit);
return res.status(200).json({ challenges, total });
} catch (error) {
logger.error('Failed to retrieve challenges.', { error });
next(error);
}
};
// ID로 챌린지 가져오기
public getChallengeByIdController = async (req: Request, res: Response, next: NextFunction) => {
try {
const getChallengeByIdDto = plainToInstance(GetChallengeByIdDto, req.params);
await validateDto(GetChallengeByIdDto, getChallengeByIdDto);
const { id } = getChallengeByIdDto;
const { challenge, tasks } = await this.challengeService.getChallengeById(id);
if (!challenge) throw new NotFoundError('Challenge', id);
return res.status(200).json({ challenge, tasks, userId: req.user!.id });
} catch (error) {
logger.error(`Failed to retrieve challenge for user ${req.params.id}.`, { error });
next(error);
}
};
// 챌린지 생성
public createChallengeController = async (req: Request, res: Response, next: NextFunction) => {
try {
const createChallengeDto = plainToInstance(CreateChallengeDto, req.body);
await validateDto(CreateChallengeDto, createChallengeDto);
const newChallenge = await this.challengeService.createChallenge(req.user!.id, createChallengeDto);
return res.status(201).json(newChallenge);
} catch (error) {
logger.error('Failed to create challenge.', { error });
next(error);
}
};
// 챌린지 업데이트
public updateChallengeController = async (req: Request, res: Response, next: NextFunction) => {
const { id } = req.params;
try {
const updateChallengeDto = plainToInstance(UpdateChallengeDto, req.body);
await validateDto(UpdateChallengeDto, updateChallengeDto);
const { challenge } = await this.challengeService.getChallengeById(id);
if (!challenge) throw new NotFoundError('Challenge', id);
if (challenge.user_id !== req.user!.id) {
throw new AuthorizationError('You do not have permission to update this challenge');
}
const updatedChallenge = await this.challengeService.updateChallenge(id, updateChallengeDto);
return res.status(200).json(updatedChallenge);
} catch (error) {
logger.error(`Failed to update challenge ${id}.`, { error });
next(error);
}
};
// 챌린지 삭제
public deleteChallengeController = async (req: Request, res: Response, next: NextFunction) => {
const { id } = req.params;
try {
const { challenge } = await this.challengeService.getChallengeById(id);
if (!challenge) throw new NotFoundError('Challenge', id);
if (challenge.user_id !== req.user!.id) {
throw new AuthorizationError('You do not have permission to delete this challenge');
}
await this.challengeService.deleteChallenge(id);
return res.status(204).send();
} catch (error) {
logger.error(`Failed to delete challenge ${id}.`, { error });
next(error);
}
};
}
모든 컨트롤러 메서드에 유효성 검사와 에러 처리를 적용 해 주었다.
각 메서드에서 유효성 검사가 실패하면 ValidationError가 발생하고, 유효하지 않은 데이터가 서비스 레이어에 도달하지 않도록 방지 해준다.
5. 에러 처리 미들웨어 설정
마지막으로, app.ts 파일로 가서 에러 처리 미들웨어를 설정 해주자.
이 미들웨어는 CustomError.handleError 메서드를 통해 에러를 처리하고, 적절한 HTTP 응답을 클라이언트에 반환 해준다.
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
const { statusCode, message, validationErrors } = CustomError.handleError(err);
logger[statusCode >= 500 ? 'error' : 'warn'](`Error: ${message} - Status: ${statusCode}`, {
error: err,
path: req.path,
});
res.status(statusCode).json({
status: statusCode,
message,
...(validationErrors && { errors: validationErrors }),
});
});
CustomError.handleError(err)는 전달된 에러가 CustomError의 인스턴스인지 확인하고, 그렇다면 해당 에러에 설정된 statusCode, message, validationErrors를 추출 해준다.
하지만 만약 err가 CustomError의 인스턴스가 아니면, handleError 메서드는 기본적으로 500 상태 코드와 "Internal Server Error" 메시지를 반환 해준다.
이 방법을 통해 에러 유형에 따라 다른 상태 코드와 메시지를 동적으로 결정할 수 있다.
logger[statusCode >= 500 ? 'error' : 'warn'](`Error: ${message} - Status: ${statusCode}`, {
error: err,
path: req.path,
});
이 부분에서는 에러를 기록하는 로깅 유틸리티(logger)를 사용하여 에러를 기록한다. 로그의 심각도를 statusCode에 따라 결정 해준다.
- statusCode >= 500: 서버 측 에러이므로 심각한 에러(error)로 로그를 남긴다.
- statusCode < 500: 클라이언트 측에서 발생한 에러로 경고(warn) 수준으로 로그를 남긴다.
로그에 기록되는 정보는 에러 메시지, 상태 코드, 에러 객체(err), 요청 경로(req.path) 등이다.
이렇게 해주니, 디버깅이나 모니터링 시 에러 발생 상황을 자세히 추적할 수 있었다.
res.status(statusCode).json({
status: statusCode,
message,
...(validationErrors && { errors: validationErrors }),
});
이 부분에서는 에러를 클라이언트에게 응답으로 반환 해준다. 응답은 JSON 형식으로 구성되며, 상태 코드와 에러 메시지를 포함한다.
- status(statusCode): HTTP 상태 코드를 설정한다. 예를 들어, 404는 "Not Found", 400은 "Bad Request" 등의 의미를 가진다.
- json({...}): 클라이언트에게 JSON 형식의 데이터를 반환 해준다.
반환되는 데이터는 다음과 같다.
- status: 에러의 HTTP 상태 코드.
- message: 에러에 대한 설명.
- errors: validationErrors가 존재하는 경우(예: DTO 유효성 검사 실패 시), 그 내용을 함께 반환한다. 이는 클라이언트 측에서 데이터를 수정할 수 있도록 유효성 검사에 대한 구체적인 오류 정보를 제공하기 위함이다.
마지막으로, ...(validationErrors && { errors: validationErrors })는 조건부로 객체를 확장하는 구문이다.
즉, validationErrors가 존재할 때만 errors라는 속성을 추가해준다. 이는 에러가 유효성 검사 실패와 관련이 있을 때만 추가적인 정보를 클라이언트에 제공하려는 의도이다.
이제 모든 에러가 일관되게 처리되고, 유효성 검사 오류와 같은 클라이언트 측에서 수정할 수 있는 정보는 명확히 전달된다.
이와 같이 커스텀 에러 클래스와 유효성 검사 미들웨어를 통해 서버의 안정성을 높이고 코드의 가독성을 향상 시켜주었다.
이 방식은 중앙 집중식 에러 처리와 코드의 유지보수성을 크게 개선시켜 주었다!