SMAIVNN
article thumbnail

'부끄럽지만 나는 지금까지 'try-catch'가 없으면 혹시 모를 이유로 에러가 발생하면 서버가 crash되지 않을까?'라는 생각으로 냅다 코드블럭을 'try-catch'로 감싸는 습관이 있었다. 

 

하지만 이번 배포를 진행하고, 클린 코드 스터디를 진행하며 내가 작성한 코드를 돌아보는 시간을 가졌다. 내 코드는 특히 'try-catch'와 에러처리 때문에 명백하게 '좋은 코드'는 아니라고 생각했다.

 

또한, 해당 글 에 남겨준 함께 스마일게이트 캠프를 진행한 형의 아래 댓글을 읽고 이참에 확실히 정리하고, 나도 제대로 알고 코드 작성하자는 마음가짐으로 글을 쓴다.

땡큐형님

 

NestJS의 에러

결론적으로 앞서 내가 한 서버 crash의 고민은 조금 덜어놔도 된다. NestJS의 공식 홈페이지에는 아래와 같이 써있다.

 


Nest comes with a built-in exceptions layer which is responsible for processing all unhandled exceptions across an application. When an exception is not handled by your application code, it is caught by this layer, which then automatically sends an appropriate user-friendly response.

 

NestJS에서는 에러가 발생하면 기본적으로 예외(Exception)로 처리된다. 그리고 NestJS는 모든 예외를 처리하는 예외 계층이 있다. 이는 기본적으로 글로벌 예외 필터에 의해 수행된다. NestJS는 예외 필터를 통해 애플리케이션 전역에서 발생하는 예외를 포착하고, 이를 적정하게 변환하여 반환한다.

 

예외 처리 흐름:

1. 만약 에러가 발생할 경우 에러가 try-catch로 잡히지 않으면 상위 호출 스택으로 전파된다.

즉, repository > service > controller까지 전파되는 것이다.

 

2. 최종적으로 NestJS의 내장된 예외 필터나 글로벌 필터에 의해 예외를 처리한다.

 

3. 또한 이 광정에서 NestJS는 내부적으로 비동기 함수에서 발생하는 예외를 잡아 적정한 HTTP 응답으로 변환한다. 

 

4. 따라서 예외가 발생하더라도 서버 프로세스가 멈추지 않는다. (동기적인 코드에서의 예외를 잡지 못했을 경우 제외)

 

물론 데이터베이스 연결과 같은 특정 상황도 존재하긴 하지만, 연결 문제는 논외로 취급하고, 비즈니스 로직에서의 에러만 바라보자.

 

에러 처리 전략

그렇다면 우리는 어떤 에러 처리 전략을 작성해야할까 고민해보아야 한다. 이 고민에는 위에 사진의 댓글에서 말하는 바와 같이 어떤 레이어에서 에러를 처리하느냐도 포함된다.

 

당연한 말이지만, 서비스 레이어는 비즈니스 로직이 존재한다. 비즈니스 로직에서 발생하는 예외를 명시적으로 처리하고, 나머지 예외는 필터에서 일관되게 처리한다.

 

이 방법은 NestJS의 철학과 기본 제공 기능을 최대한 활용하는 방법이다. 또한 필요한 경우에 커스텀 기능을 추가할 수 있다. 우리는 이 방법으로 한 곳에서 관리되는, 종속성이 낮은 에러처리를 진행할 것이다. 예외 필터를 적극 활용한다.

아래 코드를 보자.

// service.ts
async deactivateUser(user:IUser, id: number, body: UserDeactiveDto) {
  const { password, deleteReason } = body;

  if (user.id !== id) {
    throw new ForbiddenException('다른 사용자의 계정은 비활성화할 수 없습니다.');
  }

  const isPasswordMatching = await bcrypt.compare(password, user.password);
  if (!isPasswordMatching) {
    throw new UnauthorizedException('비밀번호가 일치하지 않습니다.');
  }

  await this.userRepository.deactivateUser(user, deleteReason);
}
// repository.ts
async deactivateUser(user: UserEntity, deleteReason: string) {
  user.deleteReason = deleteReason;
  user.deletedAt = new Date();
  user.isDeleted = true;

  await this.userDbAccess.save(user);
}

 

현재 코드에서는 repository에서 발생할 수 있는 예상치 못한 예외는 상위 레이어나 글로벌 필터로 전파한다. 또한 명확하고 구체적인 예외(Forbidden, Unauthorized)를 던진다.

 

정리하자면 다음과 같다.

- 서비스 레이어: 비즈니스 로직과 관련된 예외를 명시적으로 처리, DB오류 등 예기치 않은 것은 상위로 전파

 

- 레포지토리 레이어: 일반적으로 try-catch를 사용하지 않고 상위로 전파. DB예외를 도메인 예외로 변환해야 할 경우만 try-catch사용

 

- 글로벌 예외 필터 활용: 모든 예외를 일관되게 처리하기 위한 필터 활용

 

그렇다면 중요한 것은 글로벌 필터가 될 것이다.

글로벌 예외 필터는 다음과 같이 만든다.

 

// http-exception-filter.ts
@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  private readonly logger = new Logger(HttpExceptionFilter.name);

  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const statusCode = exception.getStatus
      ? exception.getStatus()
      : HttpStatus.INTERNAL_SERVER_ERROR;

    let errorMessage: string;
    let errorCode: string | undefined;

    /**
     * CustomHttpException을 상속한 예외라면 errorCode와 message를 사용
     */
    if (exception instanceof CustomHttpException) {
      errorCode = exception.errorCode;
      errorMessage = exception.message;
    } else {
      /**
       * 기본 HttpException을 사용한다면 getResponse()를 통해 예외 응답을 가져온다.
       */
      const errorResponse = exception.getResponse() as
        | string
        | { errorCode: string; message: string | string[] };

      if (typeof errorResponse === 'string') {
        errorMessage = errorResponse;
      } else {
        errorCode = errorResponse.errorCode || '-';
        errorMessage = Array.isArray(errorResponse.message)
          ? errorResponse.message.join(' ')
          : errorResponse.message;
      }
    }

    // 환경별 로그 처리 (개발 환경이라면 추가 정보를 로그에 포함)
    const logMessage = this.createLogMessage(
      errorMessage,
      errorCode,
      statusCode,
      request,
    );
    this.logger.error(logMessage, exception.stack);

    const responseBody = this.createResponseBody(
      errorMessage,
      statusCode,
      errorCode,
      request, // 클라이언트에게 추가 정보를 제공
    );
    response.status(statusCode).json(responseBody);
  }

  private createLogMessage(
    errorMessage: string,
    errorCode: string | undefined,
    statusCode: number,
    request: Request,
  ) {
    return {
      ...
  }

  private createResponseBody(
    message: string,
    statusCode: number,
    error?: string,
    request?: Request,
  ) {
    return {
...
    };
  }
}

 

예외 필터 코드를 보면 CustomHttpException부분이 보이는데, 이 부분은 NestJS에서 제공하는 기본 http에러 클래스가 아닌, 커스텀 클래스를 위한 부분이다.

 

// custom-http-exception.ts
import { HttpException, HttpStatus } from '@nestjs/common';

export class CustomHttpException extends HttpException {
  readonly errorCode: string;

  constructor(
    errorCode: string,
    message: string,
    status: number = HttpStatus.BAD_REQUEST,
  ) {
    super(message, status);
    this.errorCode = errorCode;
  }
}

 

위와 같이 HttpException을 상속받는 클래스를 만들어준다.

이 클래스에 errorCode부분을 추가하여 프로젝트마다 필요한 자체적인 코드를 정의하여 넣을 수 있다.

 

// custom-errors.ts
import { HttpStatus } from '@nestjs/common';
import { CustomHttpException } from './custom-http-exception';
import { ErrorCodes } from './error.codes';

export class DifferentUserException extends CustomHttpException {
  constructor() {
    super(
      ErrorCodes.DIFFERENT_USER,
      '다른 사용자의 계정은 비활성화할 수 없습니다.',
      HttpStatus.FORBIDDEN,
    );
  }
}

export class PasswordNotMatchingException extends CustomHttpException {
  constructor() {
    super(
      ErrorCodes.PASSWORD_NOT_MATCHING,
      '비밀번호가 일치하지 않습니다.',
      HttpStatus.UNAUTHORIZED,
    );
  }
}

 

이후에는 custom-errors파일을 만들어 해당 파일 내에서 CustomHttpException을 상속받는 클래스를 작성한다.

  async test() {
    throw new DifferentUserException();
  }

 

그러면 위와 같이 에러를 처리하는 것이다. 

 

결국, 이 방법은 대부분의 개발자들이 더 쉽게 적응하고 가독성도 좋은 방법이다.

 

try-catch는?

그러면 try-catch는 언제 사용할까? 일단, 내가 하던 것처럼 try-catch를 포괄적으로 작성하진 않는다. 그럴 필요도 없다..

 

일단 대표적으로, 예상 가능한 특정 예외를 처리할 때. 즉, 이전 글에서 나타나듯 checked exception에 대해서 처리할 때 감싸는 편이다. 예를 들어, 예외가 발생할 가능성이 높은 코드 블록이나 외부 모듈을 호출하는 부분이다.

 

예상치 못한 예외는 글로벌 필터로 던지면 된다. 결국 try-catch는 말 그대로 에러를 catch해서 추가적인 조치를 취하려고 하는 것이기 때문이다. 오히려 catch 블록의 예외를 처리하지 않으면 문제를 추적하기 어렵다.

 

무엇보다, 불필요한 try-catch는 코드의 복잡성을 증가시킨다. 이는 코드의 가독성 저하로 이어진다. 결론적으로, try-catch는 필요한 경우에만 사용한다는 것이다.

 


 

위와 같은 방법을 통해, 컨트롤러는 요청의 수신과 응답의 반환에 집중하고, 서비스는 비즈니스 로직에 집중한다. 에러 처리를 서비스 레이어에서 담당함으로써 각 계층의 역할을 명확히 분리하여 관심사를 분리한다.

 

요즘 가독성 높은 코드, 협업에 좋은 코드부터 시작해서, 내가 정확히 알고 작성하는 코드의 중요성을 새삼 느끼고 있다. 이렇게 좋은 습관을 들여가면서 해피 코딩 해야겠다. 

 

팩트는 내 개발과 개발 습관이 건강해지고 있다는 것임~!

profile

SMAIVNN

@SMAIVNN

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