삽질블로그

NestJS 예외 핸들링 본문

개발

NestJS 예외 핸들링

삽질블로그 2024. 7. 22. 20:44

Nodejs로 개발 스택을 바꾸면서 동시에 개인 프로젝트를 진행하게 되었는데,

버전1부터 버전3까지 개발 과정을 거치면서 예외 처리를 어떻게 하는게 좋을까 하는 생각이 들곤했다.

각 계층에 대한 예외처리를 하는게 맞지만, 한 계층에서 다른 계층의 에러를 핸들링을 하는게 맞을까 하는 고민이 있었다.

 

예를 들어 Express.js로 개발한 개인 프로젝트 버전 2에선 다음과 같은 예외 처리가 많았다. 

다음 코드는 Service에서 던져진 에러를 Controller에서 식별해 http에러를 던지는 동작이다.

 

이는 서비스와 컨트롤러간의 결합도가 낮고 SRP원칙을 준수한다는 장점이 있지만 

그 외엔 단점이 너무 많은 전략이다.

// PostService.ts

public async getPost(postId : number){
        const post = await this.postRepository.findOne({
            where: { id: postId },
            relations: ['postHashtags.hashtag']
        })

        if(!post){
            logger.error('존재하지 않는 게시글입니다.');
            throw new Error('존재하지 않는 게시글입니다.');
        }
        return post;
}

 

// PostController.ts

public async getPost(req : Request, res : Response){
        const postId : number = parseInt(req.params.postId, 10);
        try{
            const post = await postService.getPost(postId);
            return res.status(200).json({message : '게시글 상세조회 성공', post});
        }
        catch(err : any){
            if(err.message === '존재하지 않는 게시글입니다.'){
                return res.status(404).json({message : '존재하지 않는 게시글입니다.'});
            }
            res.status(err.statusCode || 500).send({message : '게시글 조회에 실패하였습니다.'});
        }
    }

 

현재는 서비스의 로직이 매우 간단하기 때문에 괜찮지 않을까? 싶기도 하지만

만약 throw해야할게 3개만 있어도 코드 중복도가 많아지고 예외 핸들링이 힘들어진다.

다음 코드를 통해 느껴보자.

코드가 이전 보다 많이 더러워지고 같은 컨트롤러에서도 중복된 로직이 많아질 수 있는 상황에 놓이게 된다.

 

//PostController.ts

public async getPost(req : Request, res : Response){
        const postId : number = parseInt(req.params.postId, 10);
        try{
            const post = await postService.getPost(postId);
            return res.status(200).json({message : '게시글 상세조회 성공', post});
        }
        catch(err : any){
            if(err.message === '존재하지 않는 게시글입니다.'){
                return res.status(404).json({message : '존재하지 않는 게시글입니다.'});
            }
            else if(err.message === '~~~~~'){
                return res.status(409).json({message : '~~~~~~~~~~'});
            }
            else if(err.message === '~~~~~~~~~~'){
                return res.status(503).json({message : '~~~~~~~~~~~'});
            }
            res.status(err.statusCode || 500).send({message : '게시글 조회에 실패하였습니다.'});
        }
    }

 

 

 

 

 

 

그럼 지금까지가 버전 2에서의 예외 핸들링 방식이었다면

현재 버전 3인 NestJS에서의 예외 핸들링은 다음과 같이 NestJS의 내장 필터를 사용해왔다.

그럼 다음 코드를 봐보자.

//member.controller.ts

@Version('3')
@Post('/signup')
@SignUpDescription()
async signUp(@Body() body: CreateMemberDto): Promise<CreatedTimeResponse> {
    return this.memberService.signUp(body);
}
  

//member.service.ts

async signUp(body: CreateMemberDto): Promise<CreatedTimeResponse> {
    const member : Member = await this.memberRepository.findOneBy({ email: body.email });
    if (member) {
      throw new AlreadyExistedException(member.email);
    }

    if (body.password !== body.passwordCheck) {
      throw new BadRequestException('패스워드 불일치');
    }

    const hashedPassword: string = await bcrypt.hash(
      body.password,
      parseInt(process.env.SALT_OR_ROUNDS, 10),
    );

 

 

이 코드에서 회원가입 요청이 들어왔을 때 이미 존재하는 회원이라면

throw new AlreadyExistedException(member.email)을 통해서 http 에러를 던져주고 있다.

이 코드는 어느 부분에서 문제가 있는걸까?

 

코드를 보면 상황에 맞는 http에러를 던져주고 있지만 service로직에서 http예외 처리를 하게 된다.

따라서 컨트롤러 계층에서 해야 할 일을 서비스 계층이 하게 됨으로써

컨트롤러가 서비스에 의존하는데 서비스는 컨트롤러에 종속된 양방향(?)관계가 형성된다.

 

보통은 레포지토리 <- 서비스 <- 컨트롤러 순으로 의존하는 단방향 관계가 형성되어야 

각 레이어의 구현체를 쉽게 변경할 수 있는 구조가 되게 된다.

그래서 만약 현재 채팅 서비스가 없지만 웹소켓 같은 다른 종류의 프로토콜을 사용하게 된다면

서비스에 작성된 HTTP 예외 처리 로직은 전부 수정해야 하는 불편한 상황이 발생하게 된다.

따라서 다음과 같은 양방향(?)관계가 형성되면 각 계층의 결합도가 높아지면서 SRP원칙을 준수하지 못하게 된다고 볼 수 있다.

 

그럼 어떻게 수정해야 SRP원칙을 지켜 각 계층간의 결합도를 낮추면서 예외를 핸들링 할 수 있을까?

-> 커스텀 예외 필터를 사용하여 예외를 핸들링하여 해결

우리가 만들 커스텀 예외 필터는 다음과 같이 동작한다.

1. Service에서 ServiceException을 컨트롤러로 던진다.

2. 컨트롤러에서 핸들링하지 못한 예외가 다시 던져진다.(try-catch를 사용하지 않는 한 자동으로 상위로 전파되어 예외 필터에 도달함)

3. 예외 필터가 ServiceException 타입 예외를 캐치한 후 HTTP 컨텍스트 에러로 변환해 응답한다.

 

그 과정은 다음 사진과 같이 이루어지게 됩니다.

 

 

 

그럼 예외 핸들링을 천천히 해봅시다.

 

첫 번째 : 에러 정보를 담은 에러 코드 만들기

1. ErrorCodeVo 클래스를 만든다.

2. ErrorCode로 ErrorCodeVo의 타입을 정의한다.

3. 생성자를 통해 에러코드 값 객체 인스턴스를 선언한다.

 

다음은 제가 정의한 ErrorCodeVo입니다.

// src/common/exception/error-code/error-code.ts

class ErrorCodeVo{
  readonly status : number;
  readonly code : string;
  readonly message : string;

  constructor(status : number, code : string, message : string){
    this.status = status;
    this.code = code;
    this.message = message;
  }
}

export type ErrorCode = ErrorCodeVo;

// Common Error Codes
export const ENTITY_NOT_FOUND : ErrorCodeVo = new ErrorCodeVo(404, 'C001', 'Entity Not Found');
export const ALREADY_EXISTED : ErrorCodeVo= new ErrorCodeVo(409, 'C002', 'Already Existed');

// Auth Error Codes

// Notification Error Codes

// Member Error Codes

// Post Error Codes
export const POST_NOT_FOUND : ErrorCode = new ErrorCodeVo(404, 'P001', 'Post Not Found');

 

에러 코드가 전체적으로 흩어져있을 경우 코드, 메시지의 중복을 방지하기 어렵고 전체적으로 관리하는 것이 매우 어렵기 때문에

다음과 같이 에러코드는 한 곳에서 정의해주는게 좋다고 합니다.

저는 공통 에러코드와 다른 엔티티별 에러코드를 만들어서 사용할 예정입니다.

 

이제 만든 에러코드를 모듈화해서 내보낼 index 파일을 작성해야 합니다.

 

// src/common/exception/error-code/index.ts
export * from './error-code';

 

 

두 번째 : 예외 만들기

서비스 예외 클래스 생성

에러 클래스를 상속받은 ServiceException 클래스를 만듭시다.

 

ServiceException의 역할은 다음과 같다.

1. 커스텀 예외 필터의 대상

2. ServiceException타입 인스턴스 생성 메서드가 사용하는 생성자 제공

 

다음과 같이 ServiceException 클래스를 만들었다.

이제 이 ServiceException을 NestJS의 예외 계층에서 감지하기 위해 커스텀 예외 필터를 만들어보자.

// src/common/exception/service.exception.ts

import { ErrorCode } from "./error-code";

export class ServiceException extends Error {

  readonly errorCode: ErrorCode;

  constructor(errorCode : ErrorCode, message : string){
    if(!message){
      message = errorCode.message;
    }
    super(message);

    this.errorCode = errorCode;
  }
}

 

세 번째 :  커스텀 예외 필터 만들기

던져진 ServiceException을 이 필터를 통해 감지하여 HttpResponse로 변환하게 된다.

이제 만들어진 ServiceException을 리턴하는 함수를 만들어서 사용하면 예외 핸들링이 완성된다!

import { ArgumentsHost, Catch, ExceptionFilter } from "@nestjs/common";
import { ServiceException } from "../service.exception";
import { Request, Response } from "express";

// src/exception/exception-filter/service-exception-to-http-exception-filter.ts

@Catch(ServiceException)
export class ServiceExceptionToHttpExceptionFilter implements ExceptionFilter{
  catch(exception: ServiceException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.errorCode.status;


    response
      .status(status)
      .json({
        statusCode: status,
        message: exception.message,
        code : exception.errorCode.code,
        path: request.url,
      });

  }
}

 

 

 

나는 PostNotFoundException이라는 게시글을 찾지못했을 때 예외를 만들어서 사용하려고 한다.

함수를 만들었으니 이제 진짜 사용하러 가보자ㅎㅎㅎㅎ

import { POST_NOT_FOUND } from "../../../common/exception/error-code";
import { ServiceException } from "../../../common/exception/service.exception";

// src/post/exception/post.exception.ts

export const PostNotFoundException = (message? : string): ServiceException =>{
    return new ServiceException(POST_NOT_FOUND, message)
}

 

 

다음과 같이 Service 레이어에서 던져주던 Http 에러를

async getPost(postId : string) : Promise<GetPostDetailDto>{
    const post = await this.postRepository
      .createQueryBuilder('post')
      .leftJoinAndSelect('post.postHashtags', 'postHashtags')
      .leftJoinAndSelect('post.member', 'member')
      .leftJoinAndSelect('post.image', 'image')
      .leftJoinAndSelect('postHashtags.tag', 'tag')
      .leftJoinAndSelect('post.comments', 'comments')
      .leftJoinAndSelect('comments.children', 'children')
      .where('post.id = :postId', { postId })
      .orderBy('comments.createdAt', 'ASC')  // 부모 댓글 작성일 순 정렬
      .addOrderBy('children.createdAt', 'ASC')  // 자식 댓글 작성일 순 정렬
      .getOne();

    if(!post){
      throw new NotFoundException('존재하지 않는 게시글입니다.');
    }

    return plainToClass(GetPostDetailDto, {
      id: post.id,
      memberId : post.member.id,
      email : post.member.email,
      title: post.title,
      problem_number: post.problem_number,
      problem_link: post.problem_link,
      image : post.image.image_link,
      rate : post.rate,
      content: post.content,
      alarm: post.alarm,
      createdAt: post.createdAt,
      updatedAt: post.updatedAt,
      hashtag: post.postHashtags.map(hashtag => plainToClass(hashtagDto, {
        name: hashtag.tag.name,
      })),
    });
  }

 

 

다음과 같이 갈아끼우기만 하면 끝!

async getPost(postId : string) : Promise<GetPostDetailDto>{

    const post = await this.postRepository
      .createQueryBuilder('post')
      .leftJoinAndSelect('post.postHashtags', 'postHashtags')
      .leftJoinAndSelect('post.member', 'member')
      .leftJoinAndSelect('post.image', 'image')
      .leftJoinAndSelect('postHashtags.tag', 'tag')
      .leftJoinAndSelect('post.comments', 'comments')
      .leftJoinAndSelect('comments.children', 'children')
      .where('post.id = :postId', { postId })
      .orderBy('comments.createdAt', 'ASC')  // 부모 댓글 작성일 순 정렬
      .addOrderBy('children.createdAt', 'ASC')  // 자식 댓글 작성일 순 정렬
      .getOne();

    if(!post){
      throw PostNotFoundException('존재하지 않는 게시글입니다.');
    }

    return plainToClass(GetPostDetailDto, {
      id: post.id,
      memberId : post.member.id,
      email : post.member.email,
      title: post.title,
      problem_number: post.problem_number,
      problem_link: post.problem_link,
      image : post.image.image_link,
      rate : post.rate,
      content: post.content,
      alarm: post.alarm,
      createdAt: post.createdAt,
      updatedAt: post.updatedAt,
      hashtag: post.postHashtags.map(hashtag => plainToClass(hashtagDto, {
        name: hashtag.tag.name,
      })),
    });
  }

 

 

부족한 글 읽어주셔서 감사합니다.

이상한 부분 있으면 언제든 말씀해주세요

 

참고자료 : 

https://velog.io/@intellik/NestJS-%ED%9A%A8%EA%B3%BC%EC%A0%81%EC%9D%B8-%EC%98%88%EC%99%B8-%ED%95%B8%EB%93%A4%EB%A7%81

 

NestJS, 효과적인 예외 핸들링

예외 처리에 대한 고민에서 자유로워지세요 🕊️

velog.io

 

https://cheese10yun.github.io/spring-guide-exception/

 

Spring Guide - Exception 전략 - Yun Blog | 기술 블로그

Spring Guide - Exception 전략 - Yun Blog | 기술 블로그

cheese10yun.github.io

 

Comments