본문 바로가기

[Project] 프로젝트 삽질기58 (feat Transactional)

어가며

NestJS와 PostgreSQL, TypeORM을 활용하여 프로덕트를 만들고 있습니다. 저번 글에서 커넥션 풀 누수 문제가 발생한 장애를 해결하는 과정을 공유했는데요. 장애를 해결한 후, 팀에서 커넥션 풀 누수 문제를 더 이상 겪지 않으려면 어떻게 해야 할까 고민했습니다. 이 글을 통해 팀 내에서 트랜잭션 제어 방식을 다르게 구성한 방법을 공유하고자 합니다. 

 

 

 

 

 

 

 

 

 


 

 

문제 원인

TypeORM을 활용하면서 API를 구성할 때마다 매번 반복적으로 아래의 코드를 반복해서 작성했습니다. 

 

constructor(private readonly dataSource?: DataSource) {}

const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();

try {
    //...
    await queryRunner.commitTransaction();
  } catch (e) {
    await queryRunner.rollbackTransaction();
  } finally {
    await queryRunner.release();
}

 

 

이 과정에서

 

finally {
    await queryRunner.release();
}

 

 

위 형태의 코드를 개발자의 실수로 빼먹고 작성하지 않아서 커넥션 풀 부족 문제가 발생했습니다. API 개수가 적으면 빠르게 문제를 해결할 수 있겠지만, API 개수가 많아질수록 어디서 release 코드를 빼먹고 작성하지 않았는지 파악하기란 쉬운 일이 아니었습니다. 그리고 이렇게 반복되는 코드를 작성하면서, 아래와 같은 세 가지 불편함을 느꼈습니다.

 

1. 매번 반복되는 코드를 계속해서 작성해야 한다.
2. 개발자의 실수로 코드 한 줄 작성하지 않으면 서버에 장애가 발생할 수 있다.
3. 비즈니스 로직이 담겨 있어야 할 공간에 트랜잭션을 다루는 코드가 혼합되어 사용됨으로써 코드의 복잡도가 올라간다.

 

 

이 세 가지 문제를 더 이상 겪지 않도록 코드 구성 방식을 변경하고자 했습니다. 문제 해결을 위해 크게 3가지 형태의 해결 방법을 고민했습니다. 

 

 

 

 

 

 

 

 


 

 

 

1. transaction 객체 활용

transaction 메서드를 활용해서 코드를 작성하는 방법이 있었습니다.

 

await this.connection.transaction<Users>(async (em: EntityManager) => { // 1
    // ...
  });

 

 

이 방법을 활용하면 반복되는 코드를 많이 제거할 수 있다는 장점이 있었습니다.

 

await queryRunner.release();

 

 

그리고 위의 코드를 작성하지 않아도 되기 때문에 개발자의 실수를 방지할 수 있는 장점도 있습니다. 그렇지만 트랜잭션을 관리하려면 매번 transaction이라는 메서드를 반복해서 활용해야 했기에, 비즈니스 로직이 담겨 있어야 할 곳에 트랜잭션을 다루는 코드가 함께 사용된다는 점이 비효율이라 생각했습니다. 그래서 아직까지는 문제 해결을 위한 가장 효율적인 해결책이 아니라고 판단했습니다. 

 

 

 

 

 

 

 


 

 

 

 

 

 

 

2. 인터셉터 활용

NestJS에서는 interceptor를 활용해서 개발할 수 있습니다. interceptor를 활용해서 트랜잭션을 제어한다면 문제를 해결할 수 있지 않을까 생각했습니다. controller 실행 전 interceptor에서 트랜잭션을 시작하고 응답을 보낼 때 interceptor에서 트랜잭션을 종료시키는 코드를 구성할 수 있다면 문제를 해결할 수 있을 것이라 판단했습니다. 

 

 

 

@Injectable()
export class TransactionInterceptor implements NestInterceptor {
  constructor(private readonly dataSource: DataSource) {}
 
  async intercept(
    context: ExecutionContext,
    next: CallHandler,
  ): Promise<Observable<any>> {
    const queryRunner = this.dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
 
    const request = context.switchToHttp().getRequest();
    request.queryRunnerManager = queryRunner.manager;
 
    return next.handle().pipe(
      catchError(async (error) => {
        await queryRunner.rollbackTransaction();
        await queryRunner.release();
 
        if (error instanceof HttpException) {
          throw new HttpException(error.getResponse(), error.getStatus());
        }
        throw new InternalServerErrorException();
      }),
      tap(async () => {
        await queryRunner.commitTransaction();
        await queryRunner.release();
      }),
    );
  }
}

 

 

 

인터셉터는 위와 같이 구성했습니다.

 

 

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

 

 

 

그리고 인터셉터에서 request 객체의 프로퍼티 값으로 추가한 queryRunnerManager 객체를 데코레이터에서 바로 가져올 수 있도록 구성했습니다.

 

 

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}
 
  @Post()
  @UseInterceptors(TransactionInterceptor)
  async test(
    @TransactionManager() transactionManager: EntityManager,
  ) {
    return await this.usersService.test(transactionManager);
  }
}

 

 

 

그 후 controller에서 @TransactionManager 커스텀 데코레이터를 활용해서 트랜잭션을 처리하는 객체를 주입받아 사용했습니다. 이렇게 처리한 결과, service 계층에서 entityManager를 곧바로 활용할 수 있게 됨으로써 이전보다 반복되는 코드를 줄일 수 있었습니다.

하지만 인터셉터를 활용하려면 controller에서 transaction을 관리하는 객체를 주입받아야만 했습니다. 이 부분이 큰 문제라고 생각했습니다.

 

인터셉터를 활용하면, transaction을 명시적으로 컨트롤하는 객체를 controller에서부터 주입받아서 활용하는 것이기 때문에 controller, service, repository 모두에서 ORM 코드를 강력하게 의존해야 하는 문제가 있었습니다. controller에서 ORM 코드에 의존할 필요가 없다고 생각했습니다. 이 방식이 그나마 합리적인 문제 해결 방식이었지만, 문제를 근본적으로 해결하는 좋은 방법은 아니라고 생각했습니다. 그래서 다른 방법을 찾아봤습니다.

 

 

 

 

 

 

 


3. 커스텀 데코레이터 활용

컨트롤러가 아닌 서비스 계층에서 트랜잭션을 관리하는 객체를 주입받을 수 있다면 문제가 해결되는 것이 아닐까 생각했습니다. service 계층에서 트랜잭션 데코레이터를 바로 주입받아서 활용할 수 있다면, 트랜잭션 관리 코드를 제거하면서 동시에 효율적으로 트랜잭션을 제어할 수 있다고 판단했습니다. 그럼 트랜잭션을 관리하는 데코레이터를 어떻게 주입받을 수 있는지 살펴봤을 때 transactional이라는 데코레이터를 활용하는 방법을 알 수 있었습니다.

 

 

 

NestJs Transaction Decorator 만들기

@Transactional 데코레이터를 만들어보자 (with TypeORM)

velog.io

 

 

 

위 링크를 참고하며, AOP라는 개념을 활용하면 트랜잭션 관리를 보다 효율적으로 할 수 있다는 것을 배웠습니다.

 

 

 

출처: https://toss.tech/article/nestjs-custom-decorator

 

 

AOP

로깅, 캐시, 트랜잭션 같은 여러 클래스에서 반복적으로 사용되는 코드가 있을 때, 데코레이터를 사용하면 중복된 코드를 줄이면서 코드를 모듈 단위로 관리하는 효과를 거둘 수 있습니다. 즉 트랜잭션이라는 공통 관심사를 처리하기 위해 매번 중복되는 코드를 생성하는 것이 아니라, 공통 관심사를 하나로 묶어서 처리할 수만 있다면, 중복되는 코드를 줄일 수 있다고 생각했습니다.

 

 

 

NestJS 환경에 맞는 Custom Decorator 만들기

NestJS에서 데코레이터를 만들기 위해서는 NestJS의 DI와 메타 프로그래밍 환경 등을 고려해야 합니다. 어떻게 하면 이러한 NestJS 환경에 맞는 데코레이터를 만들 수 있을지 고민해보았습니다.

toss.tech

 

 

문제 해결을 위해 위 링크를 참고하여 직접 AOP 개념을 활용해서 데코레이터를 생성하려 했지만, ‘바퀴를 다시 발명하지 마라’라는 격언처럼, 굳이 다시 발명하지 않고 누군가가 만든 도구를 빠르게 활용할 수 있다면 팀의 개발 생산성에 더 도움이 되겠다고 생각했습니다. 그래서 사용할 바퀴를 찾아보면서 typeorm-transactional 라이브러리를 알게 됐습니다. 이 라이브러리를 활용하면 트랜잭션을 관리하는 방법을 효율적으로 구성할 수 있으리라 판단했습니다. 

 

 

 

typeorm-transactional

A Transactional Method Decorator for typeorm that uses cls-hooked to handle and propagate transactions between different repositories and service methods. Inpired by Spring Trasnactional Annotation and Sequelize CLS. Latest version: 0.5.0, last published:

www.npmjs.com

 

 

 

typeorm-transactional 활용

라이브러리를 활용해서 트랜잭션을 관리한 결과 아래처럼 변경됐습니다.

 

 

class UserService {

constructor(private readonly dataSource?: DataSource) {}

async test() {
		const queryRunner = this.dataSource.createQueryRunner();
        await queryRunner.connect();
        await queryRunner.startTransaction();
	
        try {
            //...
            await queryRunner.commitTransaction();
          } catch (e) {
            await queryRunner.rollbackTransaction();
          } finally {
            await queryRunner.release();
        }
}

}

 

 

적용 전에는 위와 같은 형식의 코드를 반복해서 작성해야 했다면

 

class UserService {
	constructor(private readonly dataSource: DataSource)

    @Transactional()
    async test() {
        ...
    }

}

 

 

typeorm-transactional 라이브러리를 적용 후에는 위와 같이, UserService 클래스에서 주입받은 dataSource 객체를 활용해서 트랜잭션을 제어할 수 있게 됨으로써 Service 클래스에서 반복적으로 작성하는 트랜잭션 관리 코드를 모두 줄일 수 있었고, service 계층에서는 비즈니스 로직만 작성하면 되도록 환경을 개선했습니다. 

 

 

기대효과 

transactional 데코레이터를 활용하는 방법으로 트랜잭션을 처리한 결과 아래의 성과를 낼 수 있었습니다.

 

1. 트랜잭션을 관리하는 코드를 매번 반복해서 작성할 필요가 없음.
2. 개발자가 실수하지 않는 환경을 구축함으로써 서버의 장애 원인을 제거함.
3. 복잡한 코드를 간단하게 작성할 수 있게 됨으로써 코드 가독성을 올림.

 

 

하지만 transactional 데코레이터를 사용한다고 모든 문제가 해결되는 것은 아니었습니다. 데코레이터를 활용해서 트랜잭션을 제어하면 어떤 문제를 겪을 수 있는지 살펴보겠습니다. 

 

 

 

문제점

만약 비즈니스 로직을 처리하기 위한 트랜잭션은 성공했지만 이벤트 발행에 실패할 경우, 트랜잭션은 어떻게 처리되어야 할까요?

 

@Transactional()
async test() {
	await userRepository.save();
   	Event.sendPush();
}

 

 

예를 들어 test 메서드를 실행하면 특정 데이터를 저장하고, 푸시를 보내는 이벤트가 발행된다고 가정하겠습니다. 만약 데이터는 정상적으로 잘 저장됐는데, 푸시 이벤트를 제대로 발행하지 못했다면 트랜잭션은 어떻게 처리될까요?

 

transactional 데코레이터를 활용하면 이벤트가 발행되지 않았다는 이유로 트랜잭션이 rollback 될 것입니다. 만약 결제 과정에서 결제 데이터는 제대로 저장됐는데, 이벤트 로직이 제대로 처리되지 않았다면 결제를 취소해야 하는 상황이 온다면 어떻게 대처해야 할지에 대한 의문이 들었습니다. 이 문제를 해결하기 위해 어떻게 해야 할까 알아보면서 'Transactional outbox 패턴'이라는 것을 알 수 있었습니다. 이 패턴이 무엇이며, 이 패턴을 서비스에 어떻게 적용하려 했는지, 적용하는 과정에서 어떤 어려움이 있었는지 다음 글에서 공유하려 합니다.

 

 

 

 

 

 

 


 

 

 

 

 

 

 

 

 

 

 

마치며

팀 내에서 여러 문제를 해결하고 있습니다. 문제를 하나둘씩 해결하면서, 어떤 고민을 했는지, 어떤 것을 배웠는지 등을 꾸준히 공유하겠습니다. 

 

 

 

 

 


 

 

 

출처

 

NestJs Transaction Decorator 만들기

@Transactional 데코레이터를 만들어보자 (with TypeORM)

velog.io

 

NestJS 환경에 맞는 Custom Decorator 만들기

NestJS에서 데코레이터를 만들기 위해서는 NestJS의 DI와 메타 프로그래밍 환경 등을 고려해야 합니다. 어떻게 하면 이러한 NestJS 환경에 맞는 데코레이터를 만들 수 있을지 고민해보았습니다.

toss.tech

 

typeorm-transactional

A Transactional Method Decorator for typeorm that uses cls-hooked to handle and propagate transactions between different repositories and service methods. Inpired by Spring Trasnactional Annotation and Sequelize CLS. Latest version: 0.5.0, last published:

www.npmjs.com