SMAIVNN
article thumbnail
Published 2024. 4. 30. 21:28
[Nest.JS] 에러 처리 더 잘하기 Web

 

최근 프로젝트를 진행하며 http status code와 에러처리에 대해서 나름대로의 규칙을 정하는게 좋겠다는 생각이 들었습니다.

특히, try-catch문에 대해서는 왜 쓰는지보다는 그냥 무작정 쓰고 본다는 느낌으로 코드를 작성했는데 오늘 자세히 정리해보고자 합니다.

 

try-catch 의 작동

try-catch는 아래의 작동 알고리즘을 갖습니다.

  1. 먼저, try {...} 안의 코드가 실행됩니다.
  2. 에러가 없다면, try 안의 마지막 줄까지 실행되고, catch 블록은 건너뜁니다.
  3. 에러가 있다면, try 안 코드의 실행이 중단되고, catch(err) 블록으로 제어 흐름이 넘어갑니다. 변수 err(아무 이름이나 사용 가능)는 무슨 일이 일어났는지에 대한 설명이 담긴 에러 객체를 포함합니다.

만약 try {…} 블록 안에서 에러가 발생해도 catch에서 에러를 처리하기 때문에 스크립트는 죽지 않습니다.

다만 에러가 발생한 아래쪽의 코드는 절대 도달하지 않습니다.

 

그렇다면 아래 코드처럼, 실제로 서비스 레이어, 레포지토리 레이어에서 사용할 때 코드 컨텍스트를 모두 try-catch로 감싸는건 타당해 보입니다. 왜냐하면, 서비스가 죽지 않는 것이 더 중요하기 때문이죠.

async tryFunction() {
 try {
 ...
 } catch (error) {
   ...
 } 
}

 

하지만 정말 모든 메서드에 try-catch를 감싸는게 무작정 좋을까를 고민해봤습니다.

 

현업에서 try-catch?

실제 서비스를 진행하는 친구에게 물어봤을 때는 다음과 같이 말했었습니다.

 

😡 : " 그런데 서비스를 하면 진짜 생각도 못한 말도 안되는 곳에서 에러 많이 나던데 진짜 빡세게 잡아도 에러가 나요. 혹시 모르니 안정감 느낄려면 try-catch는 필수인듯요. "

 

사실 이 안정감이라는 말이 핵심인 것 같습니다. 대부분의 에러는 request 데이터의 검증과 db 입출력에서 많이 발생하지만, 검증이나 작업 외에도 외부 API호출, 파일 입력 등 다양한 부분에서 에러는 발생합니다.

 

반면 아래와 같이 말하는 분도 있었습니다.

 

🤔 : " try-catch는 최대한 지양해야 한다. 예외가 발생하면 로직은 종료되어야 한다. "

😤 : " 값의 입출력이 고정적이지 않은 데이터에 한해서 try-catch문을 사용한다. "

혹은 이렇게 구체적인 상황을 정해서 하는 분도 있죠.

 

결국 사람마다 다르고 팀마다 다른 것이지만 로그와 예외처리가 중요한 만큼 꼼꼼하게 안정성을 생각해서 작성해야 하는 것이 당연합니다.

 

에러핸들링

생각의 흐름은 결국 어떻게 처리하던, 안정성을 위해서 에러에 대해서 더 잘 처리해야겠다로 이어졌습니다.

저는 현재 아래 코드와 같이 레포지토리 레이어에서 에러 메세지를 처리하고 서비스 레이어에서 http exception을 처리하고 있습니다.

// service
async TxSoftDelete(request, id: number) {
      ...code...
    const foundContents = await ...code...
    if (!foundContents) {
      throw new HttpException(`id ${id} does not exists or is not yours`, HttpStatus.BAD_REQUEST);
    }
    try {
        ...code...
    } catch (error) {
      throw new HttpException(error.message, HttpStatus.BAD_REQUEST);
    } finally {
      await queryRunner.release();
    }
  }
// repository
  async TxSoftDeleteById(
    Id: number,
    queryRunner: QueryRunner,
  ): Promise<void> {
    try {
      await queryRunner.manager.softDelete(UserEntity, Id);
    } catch (error) {
      throw new Error('Failed to soft delete User');
    }
  }

 

이러한 방법이 에러를 처리하거나 혹은 동작별 http status code를 전달하는 과정에서 다양한 단점이 존재할 수 있겠다고 생각을 하였고 대표적인 단점들을 gpt에서 물어봤습니다.

 

대표적으로 일관성과 복잡성, 의존성에 대한 문제가 있는데 내용이 길어 접은글로 첨부합니다.

더보기

Q : 서비스 레이어에서 http 에러를 던지고, 레포지토리 레이어에서는 에러만 만들고 있는데, 이러한 방법의 단점이 뭐야?
  
A : 서비스 계층에서 HTTP 오류를 던지고 레포지토리 계층에서는 에러 메시지만 생성하는 방법은 일반적으로 생각할 수 있는 몇 가지 단점이 있습니다:    
    
1. 계층 간의 과도한 의존성: 레포지토리 계층이 오로지 에러 메시지를 생성하는 것에 그친다면, 이는 상위 계층(서비스 계층)이 하위 계층(레포지토리 계층)의 에러 처리 방식에 의존하고 있음을 의미합니다. 이는 계층 간의 역할 분리 원칙을 약화시키며, 추후 시스템 확장성 및 유지보수성에 영향을 줄 수 있습니다.    
        
2. 에러 처리의 일관성 부족: 레포지토리 계층에서 발생하는 예외 상황이 다양한 경우, 서비스 계층에서 모든 예외를 적절히 분류하고 처리하는 것은 복잡해질 수 있습니다. 예외 상황마다 적절한 HTTP 오류 코드를 선택하는 것이 어려워지며, 코드가 더 불투명해질 수 있습니다.    
        
3. 테스트의 복잡성 증가: 서비스 계층에서 레포지토리 계층의 에러를 해석하고 처리해야 하므로, 단위 테스트 작성이 복잡해질 수 있습니다. 각 계층을 독립적으로 테스트하기보다는, 계층 간의 상호작용을 포함한 통합 테스트에 더 많이 의존할 수 있으며, 이로 인해 테스트가 더 느리고 비효율적이 될 수 있습니다.    
        
4. 에러 처리 로직의 중복: 다른 서비스나 컨트롤러에서 비슷한 레포지토리 메서드를 사용할 경우, 각 서비스나 컨트롤러에서 유사한 에러 처리 로직을 반복적으로 구현해야 할 수 있습니다. 이는 코드 중복을 초래하며, 기능 변경 시 여러 곳에서 수정해야 하는 번거로움이 발생할 수 있습니다.    
        
5. 관심사의 분리 원칙 위배: 많은 경우, 데이터 베이스나 다른 외부 시스템과의 소통은 레포지토리 계층의 책무입니다. 이 계층에서 발생하는 에러를 직접 다루지 않고 서비스 계층으로 전가시킨다면, 관심사의 분리(Separation of Concerns) 원칙이 침해될 수 있으며, 각 계층의 책임이 모호해질 수 있습니다.

 

결국 우리는 다음과 같은 것에 대해서 고민을 해보아야 합니다.

  • 복잡성이 낮아야한다
  • 단일 책임 원칙을 준수하여야 한다
  • 결합도가 낮아야 한다

현재의 방법은 이 세가지 모두에 해당하지 않습니다.

 

어떻게 수정할 수 있을까?

Nest.JS의 공식 홈페이지에서는 @nestjs/common 의 HttpException을 제공합니다. 또한 이를 상속받는 아래와 같은 예외 목록들이 있습니다.

더보기

- 400 BadRequestException
- 401 UnauthorizedException
- 404 NotFoundException
- 403 ForbiddenException
- 406 NotAcceptableException
- 408 RequestTimeoutException
- 409 ConflictException
- 410 GoneException
- 505 HttpVersionNotSupportedException
- 413 PayloadTooLargeException
- 415 UnsupportedMediaTypeException
- 422 UnprocessableEntityException
- 500 InternalServerErrorException
- 501 NotImplementedException
- 418 ImATeapotException
- 405 MethodNotAllowedException
- 502 BadGatewayException
- 503 ServiceUnavailableException
- 504 GatewayTimeoutException
- 412 PreconditionFailedException

 

또한, 예외 레이어에 대한 완전한 제어를 위해서, 예를 들어, 로깅을 추가하거나 일부 동적 요소를 기반으로 다른 JSON스키마를사용할 수 있도록 Http-Exception-Filter를 제공하고있습니다. Http-Exception-Filter를 작성하는 코드는 공식 홈페이지를 참고해주세요.

 

홈페이지에서는 다음과 같이 사용하는 방법을 소개합니다.

@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

new HttpExceptionFilter() 대신 HttpExceptionFilter와 같이 사용하는 방법은 NestJS가 필요에 따라 HttpExceptionFilter 클래스의 인스턴스를 재사용할 수 있으므로 메모리 사용량을 줄일 수 있습니다.
전자와 같이 인스턴스를 직접 생성하면, 각 요청마다 새로운 인스턴스가 생성되므로 메모리 사용량이 늘어날 수 있습니다.

 

이제, 서비스 혹은 레포지토리 레이어에서 발생한 에러는 컨트롤러로 전파 되었고, HttpExceptionFilter가 이를 캐치하여 처리합니다.

 

결론적으로

try-catch는 개인의 취향입니다. 하지만 확실하게 고려되어야 할 것은 서비스의 안정성입니다.

서비스가 종료되지 않고 잘 돌아갈 수 있도록 예외처리를 하여야 합니다.

 

에러를 어디서 던지느냐에 대해서, 저는 기존 방식이 아닌 서비스와 레포지토리 각 레이어에서 해당하는 에러를 던지는 것으로 생각을 바꿨습니다.근거는 다음과 같습니다.

 

각 레이어에서 발생하는 예외는 그 레이어의 특성과 관련이 있습니다.

 

예를 들어, 레포지토리 레이어에서는 데이터베이스 관련 예외가, 서비스 레이어에서는 비즈니스 로직 관련 예외가 발생할 수 있습니다. 이런 예외들을 각 레이어에서 적절하게 처리하면, 에러 메시지를 더욱 명확하게 만들고, 디버깅을 더욱 쉽게 할 수 있습니다.

 

결론적으로 필터를 사용해서 컨트롤러에서 이런 예외를 캐치하고 처리 해주기 때문에 일관성과 복잡성, 의존성에 대한 문제가 비교적 내려가게 됩니다.

 

 

혹시 다른 좋은 방법이 있다면 소개해주시면 감사하겠습니다. 많은 의견 부탁드립니다.

 


 

레퍼런스
https://ko.javascript.info/try-catch

https://www.codeit.kr/community/questions/UXVlc3Rpb246NjA2OWQ1M2EyZDdmYzcxZGUzZTQ3MGRj

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

profile

SMAIVNN

@SMAIVNN

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!