728x90

NestJS + Prisma :: 예외를 두려워 마라! NestJS AllExceptionFilter의 활용 전략, 공통 응답 구조, Exception 생성해서 사용하기

서론

현대 응용 프로그램에서는 사용자에게 관련성 있는 명확한 에러 메시지를 전달하기 위해 구조화된 에러 처리가 중요한 역할을 합니다. NestJS와 Prisma 같은 프레임워크를 사용하면 이를 원활하게 효율적으로 구현할 수 있습니다. 사용자 정의 예외, 응답 DTO, 그리고 NestJS와 Prisma의 강력한 기능을 어떻게 활용하는지 자세히 알아봅시다.

1. ExceptionCode 구조

사용자 정의 예외에 들어가기 전에 우리의 에러 코드에 대한 구조화된 형식을 만드는 것이 중요합니다. 코드에서 제공하는 내용은 다음과 같습니다:

 

type ExceptionCode = {
  status: number;
  code: string;
  message: string;
};

 

status: HTTP 상태 코드
code: 내부에서 사용하는 사용자 정의 에러 코드
message: 에러를 간단하게 설명하는 사용자 친화적인 메시지.

이를 바탕으로 애플리케이션 내의 모든 가능한 에러를 저장하는 중앙 집중화된 예외 코드 열거형(ExceptionCodeEnum)을 생성합니다.

2. ExceptionCodeEnum 구조

export const ExceptionCodeEnum: Record<string, ExceptionCode> = {
  Unknown: createExceptionCode(500, 'E000', 'Unknown'),
  EmailNotFound: createExceptionCode(404, 'E001', 'Email not found'),
  NotAuthenticated: createExceptionCode(401, 'E002', 'Not authenticated'),
  EmailExists: createExceptionCode(409, 'E003', 'Email exists'),
  PasswordNotMatch: createExceptionCode(401, 'E004', 'Password not match'),
  JwtInvalidToken: createExceptionCode(401, 'E004', 'JWT Invalid Token'),
  JwtUserNotFound: createExceptionCode(401, 'E005', 'JWT User not found'),
  JwtExpired: createExceptionCode(401, 'E006', 'JWT Expired'),
  JwtInvalidSignature: createExceptionCode(
    401,
    'E007',
    'JWT Invalid Signature',
  ),
  FileUpload: createExceptionCode(400, 'E008', 'File upload exception'),
  PrismaClientKnownRequestError: createExceptionCode(
    400,
    'E009',
    'Prisma client known request error',
  ),
  PrismaClientUnknownRequestError: createExceptionCode(
    500,
    'E010',
    'Prisma client unknown request error',
  ),

  // BaseException
  UserNotFound: createExceptionCode(404, 'E100', 'User not found'),
  BadRequest: createExceptionCode(400, 'E101', 'Bad request'),
  Unauthorized: createExceptionCode(401, 'E102', 'Unauthorized'),
  Forbidden: createExceptionCode(403, 'E103', 'Forbidden')
  // etc ..
}

 

3. 사용자 정의 예외 생성


일반적인 에러인 Error("Message")를 던지는 대신, 서비스 계층에서는 특정한 사전 정의된 예외 클래스를 던집니다. 이를 통해 에러의 원인을 더 명확하게 알 수 있고, 디버깅이 쉬워지며, 일관된 에러 처리가 가능합니다.

예를 들어, 시스템에서 이메일을 찾지 못하면 EmailNotFoundException을 던집니다

import { ExceptionCodeEnum } from 'src/exception/exception_code_enum';
import { BaseException } from './base_exception';

// Unknown
export class UnknownException extends BaseException {
  constructor() {
    super(ExceptionCodeEnum.Unknown.code, ExceptionCodeEnum.Unknown.message);
  }
}

// EmailNotFound
export class EmailNotFoundException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.EmailNotFound.code,
      ExceptionCodeEnum.EmailNotFound.message,
    );
  }
}

// NotAuthenticated
export class NotAuthenticatedException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.NotAuthenticated.code,
      ExceptionCodeEnum.NotAuthenticated.message,
    );
  }
}
// EmailExists
export class EmailExistsException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.EmailExists.code,
      ExceptionCodeEnum.EmailExists.message,
    );
  }
}
// PasswordNotMatch
export class PasswordNotMatchException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.PasswordNotMatch.code,
      ExceptionCodeEnum.PasswordNotMatch.message,
    );
  }
}

// JwtInvalidToken
export class JwtInvalidTokenException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.JwtInvalidToken.code,
      ExceptionCodeEnum.JwtInvalidToken.message,
    );
  }
}
// JwtUserNotFound
export class JwtUserNotFoundException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.JwtUserNotFound.code,
      ExceptionCodeEnum.JwtUserNotFound.message,
    );
  }
}
// JwtExpired
export class JwtExpiredException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.JwtExpired.code,
      ExceptionCodeEnum.JwtExpired.message,
    );
  }
}
// JwtInvalidSignature
export class JwtInvalidSignatureException extends BaseException {
  constructor() {
    super(
      ExceptionCodeEnum.JwtInvalidSignature.code,
      ExceptionCodeEnum.JwtInvalidSignature.message,
    );
  }
}

// etc ...

 

4. Service layer에서 Exception 예외 상황 로직


사용자가 로그인을 시도할 때, 서비스 계층의 validateUser 함수는 사용자의 존재 여부를 데이터베이스에서 확인합니다. 만약 찾지 못하거나 비밀번호가 일치하지 않으면 해당 예외를 던집니다

  async validateUser(loginUserDto: LoginUserDto): Promise<User> {
    let user: User | null;

    if (loginUserDto.oauthType === null) {
      user = await this.prisma.user.findUnique({
        where: { email: loginUserDto.email },
      });
    } else {
      user = await this.prisma.user.findFirst({
        where: {
          oauthType: loginUserDto.oauthType,
          oauthEmail: loginUserDto.oauthEmail,
        },
      });
    }

    if (!user) {
      throw new UserNotFoundException();
    }

    if (!(await bcrypt.compare(loginUserDto.password, user.password))) {
      throw new PasswordNotMatchException();
    }

    return user;
  }

 

 

5. Controller layer의 공통 응답 구조와 ResponseDto.ts


응답의 일관성은 프론트엔드가 결과를 올바르게 해석하고 처리하는 데 중요합니다. ResponseDto<T>는 표준화된 응답 형식을 제공합니다


code: 성공 또는 실패 코드.
message: 설명적인 메시지.
data: 실제 응답 데이터.


API 호출이 성공하면 ResponseDto를 사용하여 데이터를 감싸서 일관성을 보장합니다:

export interface ResponseDto<T> {
  code: string;
  message: string;
  data: T;
}

// success response dto wrapper
export function ResponseDto<T>(data: T): ResponseDto<T> {
  return {
    code: 'S001',
    message: 'Success',
    data: data,
  } as const;
}

 

@ApiTags('UserController')
@ApiBasicAuth('access-token')
// @UseGuards(JwtGuard)
@UseFilters(AllExceptionFilter)
@Controller('api/v1/user')
export class UserController {
  constructor(
    private readonly userService: UserService,
    private readonly authService: AuthService,
  ) {}

  @ApiBody({ description: '유저가 로그인한다', type: CreateUserDto })
  @Post('login')
  async validateUser(
    @Body() loginUserDto: LoginUserDto,
  ): Promise<ResponseDto<string>> {
    const user = await this.userService.validateUser(loginUserDto);
    return ResponseDto<string>(await this.getUserAccessToken(user));
  }
  
  private async getUserAccessToken(user: User): Promise<string> {
    return this.authService.getTokenFromUserRole(user);
  }
  // etc ...
}

 

6. AllExceptionFilter - 모든 에러 포착:

NestJS의 AllExceptionFilter를 사용하면 애플리케이션에서 던진 모든 에러, 사용자 정의 예외를 포함하여 포착할 수 있습니다. 이는 일관된 에러 응답 구조를 보장하고, 중복 코드를 줄이며, 에러 처리 메커니즘을 강화합니다.

 

AllExceptionFilter를 활용하면 NestJS 애플리케이션 내에서 발생하는 모든 예외를 효과적으로 포착하고 관리할 수 있다. 이를 통해 사용자에게 일관된 에러 응답을 제공하며, 개발자는 디버깅을 쉽게 할 수 있다. 모든 애플리케이션에서 이러한 중앙 집중식의 에러 처리 방식을 도입하는 것은 좋은 방법이다.

이 AllExceptionFilter에서는 여러 가지 예외 유형, 예를 들면 사용자 정의 예외, HTTP 예외, Prisma 에러 등을 분기처리하여 각각의 상황에 맞는 응답을 반환한다.

BaseException: 사용자 정의 예외로서, 해당 예외에 할당된 상태 코드, 내부 코드 및 메시지를 반환한다.

HttpException: NestJS에서 제공하는 기본 HTTP 예외. 상태 코드와 응답 내용을 반환한다.

PrismaClientKnownRequestError & PrismaClientUnknownRequestError: Prisma ORM에서 발생하는 알려진 및 알려지지 않은 에러. 각각에 맞는 코드와 메시지를 반환한다.

Error: 기타 일반적인 JavaScript 에러. 기본적인 정보를 반환한다.

 

 

import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
} from '@nestjs/common';
import { BaseException } from './base_exception';
import {
  PrismaClientKnownRequestError,
  PrismaClientUnknownRequestError,
} from '@prisma/client/runtime/library';
import { ExceptionCodeEnum } from 'src/exception/exception_code_enum';

/*
  @Catch(HttpException)은
  http 통신의 예외를 캐치하겠다는 뜻입니다. 
  만약 모든 예외를 캐치하고 싶다면
  
  @Catch()로 적용하시면 됩니다.
*/
@Catch()
export class AllExceptionFilter implements ExceptionFilter {
  async catch(
    exception: BaseException | HttpException | Error,
    host: ArgumentsHost,
  ) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse();
    // const request = ctx.getRequest();
    let status: number = ExceptionCodeEnum.Unknown.status;
    let code: string = ExceptionCodeEnum.Unknown.code;
    let message: string = ExceptionCodeEnum.Unknown.message;
    if (exception instanceof BaseException) {
      status = exception.getStatus();
      code = exception.code;
      message = exception.message;
    } else if (exception instanceof HttpException) {
      status = exception.getStatus();
      code = exception.getStatus().toString();
      message = exception.getResponse().toString();
    } else if (exception instanceof PrismaClientKnownRequestError) {
      status = ExceptionCodeEnum.PrismaClientKnownRequestError.status;
      code = exception.code;
      message = exception.message;
    } else if (exception instanceof PrismaClientUnknownRequestError) {
      status = ExceptionCodeEnum.PrismaClientUnknownRequestError.status;
      code = ExceptionCodeEnum.PrismaClientUnknownRequestError.code;
      message = ExceptionCodeEnum.PrismaClientUnknownRequestError.message;
    } else if (exception instanceof Error) {
      status = ExceptionCodeEnum.Unknown.status;
      code = exception.name;
      message = exception.message;
    }
    response.status(status).json({
      code: code,
      message: message,
      data: null,
    });
  }
}



7. NestJS와 Prisma의 장점:

NestJS: 데코레이터, 가드, 인터셉터를 제공하는 다양한 프레임워크로 개발이 간편하고 유지 관리가 쉽습니다.
Prisma: 차세대 ORM으로, 타입 안전한 데이터베이스 쿼리를 제공하여 런타임 에러를 줄이고 개발자의 생산성을 향상시킵니다.
두 도구를 결합하면, 강력하고 유지 관리가 쉽며 효율적인 애플리케이션 구조를 만들 수 있습니다.


8. 결론

위에 제시된 도구와 방법론을 사용함으로써 효과적인 에러 처리는 더 이상 어려운 작업이 아닙니다. 사용자 정의 예외, 통일된 응답 DTO, 그리고 NestJS와 Prisma의 힘을 활용하여 개발자는 유연하고 사용자 친화적인 응용 프로그램을 만들 수 있습니다.

에러가 명확할수록 해결도 더 빠릅니다!

 

+ Recent posts