세 줄 요약

  • 에러 코드는 한 곳에서 관리하자.
  • 서비스 예외를 따로 만들어 서비스가 컨트롤러에 종속되지 않게 하자.
  • 에러를 컨트롤러에서 캐치하지 말고 커스텀 예외 필터를 통해 캐치하자.

아래 링크를 클릭하면 각 단계에 맞는 깃허브 리포지토리로 이동합니다 🚀

개요
전략 1
전략 2
솔루션
부록

포스트 작성 동기

서버 개발에서 에러를 핸들링할 때 항상 고민하던 것이 있습니다.

고민의 유형은 아래와 같습니다.

  • “서비스 계층에서 HTTP 에러를 발생시키는 행위가 과연 단일 책임 원칙(SRP)을 지킨 것이라고 할 수 있을까?”
  • “만약 위 문제를 회피하려고 서비스 계층에서 Error 객체를 던지고 컨트롤러에서 Error를 HTTP 예외로 변환한다면 Error를 어떻게 식별하지?”
  • “어찌어찌해서 에러를 식별한다고 해도 서비스 레이어의 메서드에서 던질 수 있는 예외가 여러 개면 컨트롤러에서 try-catch 로직이 지저분하게 붙을텐데? 코드 중복은?”

이런 고민을 따라가며 해결책을 찾기 위해 다양한 방법을 찾아보았고, 우연히 Spring Guide - Exception 전략 글에서 영감을 얻어 포스트를 작성하게 되었습니다.
(이 글을 만난 건 정말 행운이었어요🍀)

우선 예외 필터가 필요한 이유부터 알아보아요!

예외 필터가 필요한 이유

예외 필터가 없다면, 런타임시 서버는 적절하게 예외를 처리할 수 없고 응답을 포맷팅되지 않은 형태로 출력하거나 예외 로그를 출력하는 과정에서 민감한 정보를 외부에 노출할 수도 있습니다.

NestJS에서 소개하고 있는 요청(request)의 생명 주기는 아래와 같습니다.

  1. 요청
  2. Globally bound middleware
  3. Module bound middleware
  4. Global guards
  5. Controller guards
  6. Route guards
  7. Global interceptors (pre-controller)
  8. Controller interceptors (pre-controller)
  9. Route interceptors (pre-controller)
  10. Global pipes
  11. Controller pipes
  12. Route pipes
  13. Route parameter pipes
  14. Controller (method handler)
  15. Service (if exists)
  16. Route interceptor (post-request)
  17. Controller interceptor (post-request)
  18. Global interceptor (post-request)
  19. Exception filters (route, then controller, then global)
  20. 응답

우리가 어떤 처리를 하지 않더라도 NestJS에는 내장 필터가 있으며, 핸들링하지 않는 예외는 ‘19번(Exception filters)’에서 JSON 타입으로 변환되어 출력됩니다. 아래처럼요!

{
  "statusCode": 500,
  "message": "Internal server error"
}

아래에서 소개된 전략 1, 전략 2와 솔루션은 모두 예외 필터를 사용합니다. 결과는 적절한 HTTP 에러를 응답하는 거지만 어느 계층에서 어떻게 예외를 처리하는지에 차이가 있다는 점이 중요합니다.

시나리오

유저 서비스가 있고 유저 컨트롤러가 있다고 가정합시다.

  1. 유저 컨트롤러에서 GET: /users/:id 를 통해 해당하는 id 값을 가진 user의 정보를 받아오는 요청을 보냅니다.
  2. 유저 서비스에서 컨트롤러의 usersService.findUser(+id) 호출을 받고 해당하는 유저 id로 데이터베이스에서 해당 id를 가진 유저를 찾습니다.
  3. 만약 유저가
    • 있다면 찾은 유저 entity를 dto에 담아 반환합니다.
    • 없다면 컨트롤러 또는 서비스에서 NotFoundError를 던집니다.

우리가 주목해야 할 부분은 3-b 입니다.

우선 기본적인 코드 구조는 아래와 같습니다.

// src/users/users.controller.ts
@Controller('users')
export class UsersController {
  constructor(private userService: UsersService) {}

  @Get('/:id')
  public async findUser(@Param('id') id: string) {
    return await this.userService.findUser(+id);
  }

  ...
}

// src/users/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>,
  ) {}

  ...

  public async findUser(id: number) {
    const user = await this.userRepository.findOneBy({ id });

    return new ReadUserBasicDto(user);
  }
}

// src/users/dto/read-user.dto.ts
export class ReadUserBasicDto {
  id: number;
  username: string;

  constructor(user: User) {
    this.id = user.id;
    this.username = user.username;
    Object.seal(this);
  }
}

전략 1과 전략 2 그리고 솔루션에 대한 요약은 아래와 같습니다.

[전략 1]
서비스에서 HTTP 예외 throw
[전략 2]
서비스에서 에러를 메시지와 함께 throw 컨트롤러에서 HTTP 예외로 변환, throw
솔루션
결합도높음낮음낮음
단일 책임 원칙 준수 (Single Responsibility Principle)XOO
복잡성낮음높음중간 (예외 처리 많을수록 이점 많음)

전략1부터 살펴볼까요?

전략 1

아마 가장 간단하게 HTTP 예외를 던질 수 있는 방법이 아닐까 싶습니다.

user가 존재하지 않는다면 HTTP 404 예외를 발생시키는 로직을 UsersService 클래스의 findUser 메서드 안에 집어넣기만 하면 됩니다!

// src/users/users.service.ts
@Injectable()
export class UsersService {
  ...

  public async findUser(id: number) {
    const user = await this.userRepository.findOneBy({ id });

		// 해당 id 값의 user가 존재하지 않는다면 
		//  HTTP 404 Exception throw
		if (!user) {
      throw new NotFoundException('user not found');
    }

    return new ReadUserBasicDto(user);
  }
}

이 전략은 가장 단순하다는 장점이 있지만, 컨트롤러 계층에서 해야 할 일을 서비스 계층이 함으로써 컨트롤러가 서비스에 의존하는데 서비스는 컨트롤러에 종속된 이상한 관계가 형성됩니다.

보통은 컨트롤러가 서비스에 의존하고 서비스는 리포지토리에 의존하는 단방향 관계가 형성돼야 각 레이어의 구현체가 변경되더라도 쉽게 구현체를 교체할 수 있는 구조가 되기 때문에 이상한 관계라고 표현했습니다.

예를 들어 컨트롤러가 지금은 HTTP 프로토콜을 사용하고 있지만, 다른 프로토콜(gRPC, 웹소켓, MQTT, AMQP 등)을 사용하면 서비스에 작성된 HTTP 예외 처리 로직은 전부 수정해야 합니다.

따라서, 전략 1은 결합도가 높은 예외 처리 방식이라고 할 수 있습니다.

전략 2

두번째 전략은 SRP 원칙을 준수하고 결합도가 낮아진다는 장점이 있지만, 그 외 모든 것이 단점인 전략입니다.

저 같으면 이 전략 쓸 바에는 전략 1을 쓰는 게 나아보이네요😂 

코드를 보면서 그 이유를 파악해봅시다.

우선 서비스 로직에서는 user가 없을 경우 “user not found”라는 메시지와 함께 Error를 던집니다.

// src/users/users.service.ts
@Injectable()
export class UsersService {
  ...
  public async findUser(id: number) {
    const user = await this.userRepository.findOneBy({ id });

    if (!user) {
      throw new Error('user not found');
    }

    return new UserBasicInfoDto(user);
  }
}

그 다음 서비스의 findUser를 호출한 컨트롤러는 에러 객체를 HTTP 예외로 변환해서 throw해야 ExceptionFilter가 제대로 처리됩니다. 에러 객체를 제대로 변환하지 않는다면 500 에러(사실상 서버)가 터지니까요.

아래 컨트롤러의 findUser는 에러(e)를 캐치해서 에러 메시지가 “user not found”인지 확인하고 맞다면 NotFoundException을 throw하고 있습니다.

// src/users/users.controller.ts
@Controller('users')
export class UsersController {
  ...
  @Get('/:id')
  public async findUser(@Param('id') id: string) {
    try {
      return await this.userService.findUser(+id);
    } catch (e) {
      if (e.message === 'user not found') {
        throw new NotFoundException(e.message);
      }
    }
  }
  ...
}

뭐가 문제인 걸까요?

  1. 우선, 에러 메시지 (리터럴 문자열)를 매번 작성해 캐치하다 보면 한번쯤 실수할 수도 있지 않을까요?
    • 실수하면 런타임시 500 에러가 발생하고
    • 이런 캐치 방식이 여러 컨트롤러에서 분산된 상태로 반복적으로 사용된다면 잡기 어려운 에러가 됩니다.
  2. 두번째로, 서비스 메서드가 throw할 수 있는 에러 수가 많아진다면 컨트롤러 메서드 내부도 if-else문으로 도배돼야 합니다. 아래처럼요. 이런 컨트롤러 메서드가 많아진다고 생각하면… 코드 중복도 많아지고 예외 핸들링이 굉장히 힘들어집니다.
    // src/users/users.controller.ts
    @Controller('users')
    export class UsersController {
      ...	
      @Get('/:id')
      public async findUser(@Param('id') id: string) {
        try {
          return await this.userService.findUser(+id);
        } catch (e) {
          if (e.message === 'user not found') {
            throw new NotFoundException(e.message);
          } /*
          else if (e.message=== '다른 에러 메시지 1'){
            throw new 다른예외1(e.message);
          } else if (e.message=== '다른 에러 메시지 2'){
            throw new 다른예외2(e.message);
          } else if (e.message=== '다른 에러 메시지 3'){
            throw new 다른예외3(e.message);
          } else{
            throw new 다른예외4(e.message);
          */
        }
      }
      ...
    }

그렇다면 SRP 원칙을 지켜 결합도를 낮춘 아키텍처를 가져가면서도 전략 2의 단점을 보완할 수 있는 해결법은 뭘까요?

솔루션: 커스텀 예외 필터를 사용한 예외 핸들링

우리가 만들 커스텀 예외 필터(ServiceExceptionToHttpExceptionFilter)는 아래와 같이 동작합니다.

  1. Service에서 ServiceException을 컨트롤러로 던진다.
  2. 컨트롤러에서 핸들링하지 못한 예외가 다시 던져진다.
  3. 예외 필터가 ServiceException 타입 예외를 캐치한 후 HTTP 컨텍스트 에러로 변환해 응답한다.

다음은 ServiceException의 상속 다이어그램입니다. Error 객체를 상속받아야 예외를 던질 수 있기 때문에 상속받아 클래스를 만들었습니다.

단계 1: 에러 정보를 담은 에러 코드 만들기

  • ErroCodeVo 클래스를 만들고
  • ErrorCode로 ErrorCodeVo의 타입을 정의한 다음
  • 생성자를 사용해 에러코드 값 객체(VO) 인스턴스를 선언합니다.

에러 코드는 서비스 레이어에서 던질 예외에 사용될 예정이니 프로토콜에 맞는 응답 코드와 적절한 디폴트 메시지를 작성해주시면 되겠습니다~!

// src/common/exception/error-code/error.code.ts
class ErrorCodeVo {
  readonly status;
  readonly message;

  constructor(status, message) {
    this.status = status;
    this.message = message;
  }
}

export type ErrorCode = ErrorCodeVo;

// 아래에 에러코드 값 객체를 생성
// Create an error code instance below.
export const ENTITY_NOT_FOUND = new ErrorCodeVo(404, 'Entity Not Found');

위에서 ErrorCodeVo 클래스를 export하면 타입 체킹도 될텐데 왜 그 타입을 굳이 ErrorCode로 따로 선언했는지 궁금하실 수 있습니다.

그 이유는 ErrorCodeVo 클래스의 생성자를 통해서 값 객체 인스턴스를 생성할 수 있는 범위를 error.code.ts 파일 범위 내로 한정하고 싶었기 때문입니다.

이렇게 범위를 한정하면 모든 에러 코드를 한 파일 내에서 관리할 수 있습니다.

같은 용도로 사용되는 에러 코드를

  • 실수로 중복해서 다른 이름으로 정의하거나
  • 같은 이름으로 다른 디렉토리에서 정의하거나

하는 등의 불상사를 막을 수 있다 이 말이죠~! 😎

이제 error-code를 모듈화해서 내보낼 index 파일을 작성합니다.

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

단계 2: 예외 만들기

서비스 예외 클래스 생성

Error 클래스를 상속받은 ServiceException 클래스를 만들었습니다.

이 ServiceException 클래스의 역할은

  • 커스텀 예외 필터의 대상
  • ServiceExeption 타입 인스턴스 생성 메서드가 사용하는 생성자 제공

만약 인스턴스 생성 메서드가 아니라 하위 클래스(subClass) 형식으로 ServiceException을 상속받은 예외 클래스를 만들고 싶으시다면 클래스로 구현하시면 됩니다.

(만약 프로토콜별로 서비스 예외 클래스를 만들고 싶다면, ServiceException을 상속받은 ServiceHttpException과 같은 class를 만들고 그 하위 클래스를 만들거나 인스턴스 생성 메서드를 만들면 커스텀 예외 필터에서 ServiceHttpException를 캐치하게 만들 수도 있습니다. 단, 타입 식별이 제대로 가능하게 ServiceHttpException에 리터럴 타입과 같은 식별 코드가 추가되어야 합니다)

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

// ENTITY_NOT_FOUND 값 객체(status, default-message)를 가진
//  ServiceException 인스턴스 생성 메서드
export const EntityNotFoundException = (message?: string): ServiceException => {
  return new ServiceException(ENTITY_NOT_FOUND, message);
};

export class ServiceException extends Error {
  readonly errorCode: ErrorCode;

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

    super(message);

    this.errorCode = errorCode;
  }
}

단계 3: 예외 필터 생성과 사용 설정

이제 ServiceException을 캐치해서 원하는 프로토콜 컨텍스트의 에러로 변환해 응답시킬 커스텀 예외 필터를 만들어 봅시다.

아래 커스텀 필터 클래스에서는 호스트를 HTTP 컨텍스트로 바꾸어 그에 맞게 응답함수를 실행하고 있습니다. 추가적인 작업을 원하신다면 캐치 메서드 내부에 코드를 더 작성하시면 됩니다😊

// src/common/exception-filter/index.ts
export * from './service.exception.to.http.exception.filter';

// src/common/exception-filter/service.exception.to.http.exception.filter.ts
@Catch(ServiceException)
export class ServiceExceptionToHttpExceptionFilter implements ExceptionFilter {
  catch(exception: ServiceException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
    const status = exception.errorCode.status;

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

다음은 전역 레벨에서의 필터 사용 선언입니다. NestJS의 공식문서 설명에 따르면 두 가지 방식으로 전역 레벨에서 필터 사용을 적용할 수 있습니다.

// 방법 1: main.ts 글로벌 필터 사용선언
// src/main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  ...
  // 전역 레벨에서 ServiceExceptionToHttpExceptionFilter 사용
  app.useGlobalFilters(new ServiceExceptionToHttpExceptionFilter());
  await app.listen(3000);
}

bootstrap();

// 방법 2: APP_FILTER 토큰 값에 클래스 주입 방식
// src/app.module.ts
@Module({
  imports: [UsersModule, DatabaseModule],
  providers: [
    {
      provide: APP_FILTER,
      useClass: ServiceExceptionToHttpExceptionFilter,
    },
  ],
})
export class AppModule {}

긴 여정의 끝이 보입니다. 이제 서비스에서 ServiceException을 던져봅시다✌️

단계 4: 준비하시고~쏘세요(Be Ready and Throw)🏹

이제 서비스와 컨트롤러에서 에러 처리 로직은 굉장히 심플해졌습니다.

Service에서는 ServiceException 타입의 인스턴스 생성 메서드(EntityNotFoundException)를 던지기만 하면 됩니다.


// src/users/users.controller.ts
@Controller('users')
export class UsersController {
  ...
  @Get('/:id')
  public async findUser(@Param('id') id: string) {
    return await this.userService.findOneUser(+id);
  }
}

// src/users/users.service.ts
@Injectable()
export class UsersService {
  ...
  public async findOneUser(id: number) {
    const user = await this.userRepository.findOneBy({ id });

    if (!user) {
      throw EntityNotFoundException(id + ' is not found');
    }

    return new ReadUserInfoDto(user);
  }
}

이렇게 함으로써 컨트롤러와 서비스의 결합도를 낮추고 내부 코드를 간결하게 가져갈 수 있게 되었습니다.

긴 글 읽어주셔서 감사합니다 💙

부록: Service에서 논리 로직 떼어내기

(아직 한발 남았다...)
음.. 지금은 단지 유저 서비스 클래스에 작성된 메서드가 많지 않아서 논리 로직이 별로 돋보이지 않습니다.

하지만, 서비스 클래스에 논리 로직(주석 아래 부분)이 많아지게 되면 클래스가 지저분해지고 코드 중복도 많이 생길 것입니다.

Service 클래스가 검증하는 책임까지 갖는 게 과도한 책임을 가진 게 아닌가 싶기도 하구요.

@Injectable()
export class UsersService {
  ...
  public async findOneUser(id: number) {
    ...
    // 정확히 이 부분이 거슬립니다,,
    if (!user) {
      throw EntityNotFoundException(id + ' is not found');
    }
    ...
  }
}

논리 로직을 따로 떼어내어 관리할 수는 없는 걸까요? 당연히 가능합니다 😀

UsersService에 UsersManager를 주입해 논리 로직과 관련된 작업 처리는 UsersManager에게 위임합시다.

// src/users/users.manager.ts
@Injectable()
export class UsersManager {
  public validateUser = (id: number, user: User): void => {
    if (!user) {
      throw EntityNotFoundException(id + ' is not found');
    }
  };
}
// src/users/users.module.ts
@Module({
  ...
  // UsersManager 주입 설정(프로바이더 등록)
  providers: [UsersService, UsersManager],
})
export class UsersModule {}
// src/users/users.service.ts
@Injectable()
export class UsersService {
  constructor(
    ...
    // usersManager를 생성자 방식으로 주입
    private usersManager: UsersManager,
  ) {}

  public async findOneUser(id: number) {
    const user = await this.userRepository.findOneBy({ id });

    // usersManager는
    //  이제 유저와 관련된 논리 로직을 처리하는 책임을 가지고
    //  usersService와 협력한다.!
    this.usersManager.validateUser(id, user);

    return new ReadUserInfoDto(user);
  }
}

이로써, 논리 로직은 따로 떼어내어 UsersManager에서 관리할 수 있게 되었고 UsersService 코드베이스를 더 간결하게 유지할 수 있게 되었습니다.

참고자료

Request lifecycle

Spring Guide - Exception 전략

Exception filters

profile
계속 읽고 싶은 글을 쓰고 싶어요 ☺

8개의 댓글

comment-user-thumbnail
2023년 3월 5일

👍👍

1개의 답글
comment-user-thumbnail
2023년 6월 6일

글 너무 잘읽었습니다! 덕분에 효율적인 에러 핸들링을 할 수 있을 것 같습니다 :)
도움이 많이 되어서 그런데 제 벨로그에도 정리를 하고 싶은데 참고해도 괜찮을까요?

1개의 답글
comment-user-thumbnail
2024년 1월 9일

개인 프로젝트를 하던 중 에러 핸들링에 고민이 많았는데 큰 도움이 되었습니다 감사합니다 🥰

1개의 답글
comment-user-thumbnail
2024년 1월 15일

글 잘 읽었습니다. 몇달간 고민하던거였는데 그냥 계속 전략 1로 사용해오다가
코드가 점점 지져분해지는걸 느끼고 방법을 찾다가 이 글을 봤는데 도움이 많이 됩니다.

1개의 답글