[NestJS] HttpExceptionFilter로 응답하기

codeing999·2023년 7월 24일
0

NestJS

목록 보기
8/9

공식 문서 : https://docs.nestjs.com/exception-filters
status code 참고 자료 : https://www.whatap.io/ko/blog/40/

HttpExceptionFilter 만들기

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
}

db 에러

{
    "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를 사용했기 때문에 내가 커스텀한 형태로 응답했다.

profile
코딩 공부 ing..

2개의 댓글

comment-user-thumbnail
2023년 7월 24일

공감하며 읽었습니다. 좋은 글 감사드립니다.

답글 달기
comment-user-thumbnail
2024년 7월 18일

오 좋네요 ,저도 코드 복붙하겠습니다. 혹시 시간이 지난 지금 코드를 개선한게있으신가요?

답글 달기