NestJS AOP를 활용한 Prisma 에러 처리 리팩토링

miinhho·2025년 7월 30일
post-thumbnail

NestJS 와 Prisma 를 사용하여 개발을 하다 보면, 데이터베이스 작업에서 발생하는 다양한 에러들을 처리해야 해요. 하지만 매번 비슷한 에러 처리 로직을 반복 작성하게 되면서 코드의 중복과 가독성 저하 문제가 발생하게 되죠.

저는 AOP(Aspect-Oriented Programming) 개념을 활용하여 Prisma 에러 처리 로직을 효과적으로 분리하고, 코드의 재사용성과 가독성을 크게 개선해봤어요.


AOP 적용 이전

async createUser({
  email,
  password,
  name,
}: {
  email: string;
  password: string;
  name: string;
}) {
  try {
    return this.prisma.user.create({
      data: {
        email,
        name,
        password,
      },
      select: {
        id: true,
        role: true,
      },
    });
  } catch (err) {
    if (err.code === PrismaError.UniqueConstraintViolation) {
      throw new BadRequestException('이미 사용 중인 이메일입니다.');
    }
    this.logger.error('유저 생성 중 오류 발생', err.stack, { email, name });
    throw new PrismaDBError('유저 생성에 실패했습니다', err.code);
  }
}

위 코드에선 이런 문제점이 있어요.

1. 비즈니스 로직과 에러 처리 로직의 혼재

실제 유저 생성 로직보다 에러 처리 코드가 더 많은 비중을 차지해서 핵심 비즈니스 로직을 파악하기 어려워졌어요.

2. 코드 중복 문제

다른 CRUD 메서드들에서도 동일한 에러 처리 패턴이 반복되고, Prisma 에러 코드에 따른 분기 처리가 매번 필요해졌어요.

3. 유지보수의 어려움

에러 메시지를 변경하거나 새로운 에러 타입을 추가할 때마다 모든 메서드를 수정해야 하고, 로깅 방식이나 모니터링 방식을 변경하려면 모든 곳을 찾아서 수정해야 해요.

4. 테스트 복잡성 증가

유닛 테스트 시 비즈니스 로직과 에러 처리 로직을 함께 테스트해야 해요.


AOP를 통한 해결

Interceptor 구현

@Injectable()
export class PrismaErrorInterceptor implements NestInterceptor {
  private readonly logger = new Logger(PrismaErrorInterceptor.name);
  
  constructor(private reflector: Reflector) {}

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    return next.handle().pipe(
      catchError((error) => {
        const methodName = context.getHandler().name;
        const className = context.getClass().name;
        const errorMessage = this.getPrismaErrorMessage(context, error.code);

        if (error.code === PrismaError.RecordsNotFound) {
          this.logger.warn(`Record not found in ${className}.${methodName}`, error.meta);
          return throwError(() => new NotFoundException(errorMessage));
        }

        if (error.code === PrismaError.UniqueConstraintViolation) {
          this.logger.warn(
            `Unique constraint violation in ${className}.${methodName}`,
            error.meta,
          );
          return throwError(() => new ConflictException(errorMessage));
        }

        // 기타 Prisma 에러
        if (error.code?.startsWith('P')) {
          this.logger.error(`Prisma error in ${className}.${methodName}`, error.stack);
          return throwError(() => new InternalServerErrorException(errorMessage));
        }

        return throwError(() => error);
      }),
    );
  }
}

Interceptor 에 대한 자세한 내용은 NestJS Interceptor 공식 문서 에서 확인할 수 있어요.

getPrismaErrorMessage 메서드로 에러 메시지를 동적으로 가져와요:

private getPrismaErrorMessage(context: ExecutionContext, errorCode: string) {
  const extractPrismaErrorInfo = () => {
    const handler = context.getHandler();
    return this.reflector.get<PrismaErrorInfoType>(PRISMA_ERROR_INFO_KEY, handler);
  };

  const getPrismaErrorKey = () => {
    for (const errorKey in PrismaError) {
      if (PrismaError[errorKey] === errorCode) {
        return errorCode;
      }
    }
  };

  const errorInfo = extractPrismaErrorInfo();
  const errorKey = getPrismaErrorKey()!;

  const errorMessage: string =
        errorInfo[errorKey] ||
        defaultErrorMessage[errorKey] ||
        errorInfo['Default'] ||
        defaultErrorMessage['Default'];

  return errorMessage;
}

Reflector 를 통해 데코레이터의 메타데이터를 받아와서 각 메서드별 커스텀 에러 메시지를 처리할 수 있어요.

Decorator 구현

const PrismaErrorInfo = (info: PrismaErrorInfoType) =>
  SetMetadata(PRISMA_ERROR_INFO_KEY, info);

export const PrismaErrorHandler = (info: PrismaErrorInfoType) =>
  applyDecorators(UseInterceptors(PrismaErrorInterceptor), PrismaErrorInfo(info));

applyDecorators 를 통해 UseInterceptorsPrismaErrorInfo 를 한번에 쓸 수 있도록 했어요.


AOP 적용 이후

@PrismaErrorHandler({
  UniqueConstraintViolation: '이미 사용 중인 이메일입니다.',
  Default: '유저 생성에 실패했습니다.',
})
async createUser({
  email,
  password,
  name,
}: {
  email: string;
  password: string;
  name: string;
}) {
  return this.prisma.user.create({
    data: {
      email,
      name,
      password,
    },
    select: {
      id: true,
      role: true,
    },
  });
}

개선 효과

1. 코드 간결성 향상

  • Before: 20여 줄의 복잡한 에러 처리 로직
  • After: 핵심 비즈니스 로직만 남은 10줄 미만의 깔끔한 코드

2. 관심사의 분리

  • 비즈니스 로직: 유저 생성이라는 핵심 기능에만 집중
  • 에러 처리: Interceptor 에서 일관된 방식으로 처리
  • 로깅: 자동화되어 별도 관리 불필요
  • 테스트: 메서드의 비즈니스 로직만 테스트하고 에러 로직은 해당 Interceptor 만

3. 유지보수성 및 재사용성 개선

  • 에러 처리 로직 변경: Interceptor 한 곳만 수정하면 전체 적용
  • 로깅 방식 변경: 모든 메서드에 자동 적용

4. 다른 메서드에서의 활용

@PrismaErrorHandler({
  RecordsNotFound: '사용자를 찾을 수 없습니다.',
  Default: '사용자 조회에 실패했습니다.',
})
async findUser(id: string) {
  return this.prisma.user.findUniqueOrThrow({ where: { id } });
}

@PrismaErrorHandler({
  RecordsNotFound: '삭제할 사용자가 없습니다.',
  Default: '사용자 삭제에 실패했습니다.',
})
async deleteUser(id: string) {
  return this.prisma.user.delete({ where: { id } });
}

동일한 패턴으로 모든 Prisma 작업에 일관된 에러 처리를 적용할 수 있어요.

결론

AOP를 활용한 Prisma 에러 처리 리팩토링을 통해 다음과 같은 성과를 얻을 수 있었어요:

  • 코드 가독성 향상: 핵심 비즈니스 로직만 남겨 한눈에 파악 가능
  • 중복 코드 제거: 반복되던 에러 처리 로직을 한 곳으로 통합
  • 유지보수성 증대: 에러 처리 방식 변경 시 한 곳만 수정하면 전체 적용
  • 테스트 효율성 향상: 관심사별로 분리된 테스트 작성 가능

NestJS 의 강력한 AOP 기능을 활용하면 이처럼 횡단 관심사를 효과적으로 분리하여 더 깔끔하고 유지보수하기 쉬운 코드를 작성할 수 있어요.



예제 코드의 PrismaErrorprisma-error-enum 라이브러리에서 나온 enum이에요.

profile
재미있는 걸 좋아합니다

1개의 댓글

comment-user-thumbnail
2025년 7월 30일

로그메시지 커스터마이제이션이 편리해보이네요 👍
코드도 깔끔해지고 좋아보여요

답글 달기