공식 문서 : https://docs.nestjs.com/exception-filters
status code 참고 자료 : https://www.whatap.io/ko/blog/40/
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
} from '@nestjs/common';
import { Request, Response } from 'express';
import moment from 'moment';
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();
const error = exception.getResponse() as
| string
| { error: string; statusCode: number; message: string | string[] };
if (typeof error === 'string') {
response.status(status).json({
timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
path: request.url,
error: error,
});
} else {
response.status(status).json({
timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
path: request.url,
...error,
});
}
}
}
나는 일단 이렇게 커스텀하였다.
timestamp와 path를 넣어놓았고.
status는 무조건 throw할 때 error가 아닌 httpException으로 던질 예정이므로 저 error에 들어가게 되어 있다.
error를 string 또는 객체로 분기해서 처리하는 이유는
throw new HttpException('나쁜 요청!!!', 50000);
이런 식으로 HttpException을 사용하여서 첫번째 인자에 string을 넣은 경우에는 위에 것으로 처리하고 그렇지 않고 BadRequestException 같은 내장된 httpException을 사용한 경우에는 error가 객체이므로 아래처럼 처리하려는 것이다.
사용하는 법은
@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
throw new ForbiddenException();
}
익셉션필터를 적용할 api에다가 UserFilters 데코레이터를 일일이 달아도 되고
전체에 공통적인 필터를 쓸거라면
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new HttpExceptionFilter());
await app.listen(3000);
}
bootstrap();
이런 식으로 bootstrap에 useGlobalFilters를 사용하여 넣어주면 된다.
async registerCounseling(info: CounselingCreateInfo): Promise<Counseling> {
// 해당 반려동물 있는지 검증
const pet = await this.PetDB.findOne({
where: { id: info.petId },
});
if (pet === null) throw new BadRequestException('잘못된 반려동물입니다.');
// 해당 의사 있는지 검증
const doctor = await this.DoctorDB.findOne({
where: { id: info.doctorId },
});
if (doctor === null) throw new BadRequestException('잘못된 의사입니다.');
const entity = this.mapper.mapCreateDomainToEntity(info);
//db에 등록
let result;
try {
result = await this.CounselingDB.insert(entity);
} catch (error) {
logger.error('registerCounseling - database error');
throw new InternalServerErrorException();
}
return await this.getOneCounseling(result.identifiers[0].id);
}
여기는 진료 예약하는 repository의 method인데
일단 등록하려는 반려동물과 의사가 있는 데이터인지 검증하는 부분에서는
BadRequestException을 사용하여 throw 하였다.
이 때 따로 로깅을 하거나 하진 않았는데, 이는 코드 상의 에러가 아니라 유저의 사용 상의 에러이기 때문이다.
그리고 '잘못된 반려동물입니다.' 와 같이 클라이언트에 응답할 메시지를 넣어준다.
지금은 반려동물이 잘못된 것과, 의사가 잘못된 것 모두 400이라는 같은 status code를 쓰는데
협업을 어떻게 하냐에 따라 응답 코드를 커스텀하여 더 분화하여 써도 된다.
code는 분리 안했지만 메시지로 어떤 필드가 잘못되었는지 나타내었으니 충분하다 생각하면 이대로 해도 된다.
그리고 아래에서 실제로 db에 insert한 곳에는 try catch로 감싼 후에 그곳에서 에러가 난다면
InternalServerErrorException 을 통해 throw하였다.
그러나 이 경우에는 에러 원인을 백엔드 개발자는 알아야 하기 때문에 던지기 전에 에러에 대한 log를 남겼다.
이 때 InternalServerError를 쓴 이유는
db 쿼리 에러이든, 외부에 요청하다가 timeout 에러가 난 것이든
서버 내부적인 무언가로 에러가 났긴 하지만 그 에러 원인을 클라이언트에게 굳이 보여줘야할 필요는 없으므로 InternalServerError로 퉁쳐서 보여주려는 것이다.
에러 원인을 외부에 자세히 알려주는 것은 보안상 좋지 않다.
//예약 등록
async registerCounseling(info: CounselingCreateInfo): Promise<Counseling> {
// 등록날짜는 현재 시각보단 작으면 안됨
if (new Date(info.dateTime).getTime() <= Date.now()) {
throw new BadRequestException('잘못된 날짜입니다.');
}
let result: Counseling;
try {
result = await this.repository.registerCounseling(info);
} catch (error) {
throw error;
}
return result;
}
여기는 진료 예약하는 서비스 부분인데,
db와 비교가 필요없는 날짜의 유효성 검사부분은 service에서 해주고 아까 repository에서와 마찬가지로 날짜 값이 잘못 됐을 때 BadRequest로 throw 하였다.
호출한 repository부분은 try catch로 감싸고 그대로 controller로 던진다.
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: '진료 예약' })
@Post()
async registerCounseling(@Body() counselingData: CreateCounselingDto) {
try {
const counselingInfo = this.mapper.mapCreateDtoToDomain(counselingData);
const result = await this.counselingService.registerCounseling(
counselingInfo,
);
return this.response.success(result);
} catch (error) {
throw error;
}
}
컨트롤러에서는 마찬가지로 service의 메소드 호출 부분을 try catch로 감쌌고, 바로 throw해서
내가 커스텀한 HttpExceptionFilter에서 이 예외에 대한 응답을 처리하게 한다.
에러가 나지 않은 경우는
return this.response.success(result);
부분을 통해 응답하게 하였는데 이 부분도 내가 만든 성공 시 응답 방법이다.
import moment from 'moment';
export class Response {
success(result?: any) {
return {
statusCode: 200,
timestamp: moment().format('YYYY-MM-DD HH:mm:ss'),
message: '성공',
result: result,
};
}
}
이런 식으로 class를 만들었고,
@Controller('counseling')
export class CounselingController {
private response: Response;
constructor(private readonly counselingService: CounselingService) {
this.mapper = new CounselingMapper();
this.response = new Response();
}
컨트롤러의 생성자 부분에서 이런 식으로 객체를 만든 후
성공 응답 시에 statusCode, timestamp, message 이외에 다른 필드가 필요하다면
return this.response.success(result);
이런 식으로 인자로 넣어서 호출하면 되고 필요 없다면 안 넣어줘도 된다.
위 처럼 구현했을 때 응답 예시들이다
{
"timestamp": "2023-07-25 01:20:01",
"path": "/counseling",
"message": "잘못된 날짜입니다.",
"error": "Bad Request",
"statusCode": 400
}
{
"timestamp": "2023-07-25 00:32:08",
"path": "/counseling",
"message": "잘못된 반려동물입니다.",
"error": "Bad Request",
"statusCode": 400
}
{
"timestamp": "2023-07-25 00:38:35",
"path": "/counseling",
"message": "Internal Server Error",
"statusCode": 500
}
만약에 db 에러 시에 exception이 아닌 error로 throw 하였다면
{
"statusCode": 500,
"message": "Internal server error"
}
이런 식으로 커스텀하지 않은 응답을 하게 된다.
{
"statusCode": 200,
"timestamp": "2023-07-25 00:06:14",
"message": "성공",
"result": {
"id": 2,
"userName": "찰스",
"petName": "똥강아지",
"doctorName": "허준",
"hospitalName": "항해병원",
"dateTime": "2023-08-07T03:30:10.000Z",
"status": "Reserved",
"expense": 0,
"content": null
}
}
{
"timestamp": "2023-07-25 00:29:59",
"path": "/counseling%E3%85%87%E3%84%B4%E3%84%B9%E3%85%87",
"message": "Cannot GET /counseling%E3%85%87%E3%84%B4%E3%84%B9%E3%85%87",
"error": "Not Found",
"statusCode": 404
}
이는 nestjs에서 없는 경로로 요청했을 시 알아서 응답해준 것인데, 이것 조차도 useGlobalFilters를 사용했기 때문에 내가 커스텀한 형태로 응답했다.
공감하며 읽었습니다. 좋은 글 감사드립니다.