NestJS

NestJS + TypeORM 트랜잭션(Transaction) 사용에 대한 고민

iKay 2023. 7. 23. 13:48
반응형

과거 그리고 현재에도 웹 애플리케이션을 개발할 때 Spring Boot(https://spring.io/projects/spring-boot)가 많이 사용되고 있습니다.

 

하지만 요즘 TypeScript 생태계가 점점 강력해지면서 NestJS의 점유율도 조금씩 증가하는 것 같습니다.

그림1. 구글트렌드 Spring Boot vs NestJS

 

NestJS는 Spring을 모방하면서 발전한다는 생각이 드는데 이유는 사용법과 철학이 매우 유사하게 느껴지기 때문입니다. Spring에서 주요하다고 여겨지는 개념인 DI, IoC, AOP 등의 개념이 NestJS 에서도 그대로 보여지구요.

 

하지만 아직은 Spring Boot에 비해 다른 기능들이 빈약한 편이긴 합니다. 한 가지 예로, 트랜잭션(Transaction) 사용법입니다. Spring에서 표준이다 시피한 JPA를 사용한다면 @Transactional 어노테이션을 반드시 사용해 봤을 것입니다. 이에 Spring 사용자라면 Service Layer에서 트랜잭션 사용에 대한 불편함을 크게 경험해 본적이 없을 것 같습니다. 

 

여기서 말하는 불편함이란, 트랜잭션 커넥션을 열고 예외가 발생하면 롤백하거나 성공하면 커밋 하는 등의 애플리케이션 로직을 직접  Service Layer에서 해야 한다는 점을 말하는 것입니다.

 

NestJS에는 Spring에서의 JPA와 같이 표준 ORM이 없는 것 같습니다. 현재는 TypeORM이 가장 많이 사용되지만 가장 많이 사용된다고 표준이라 할 수는 없고 앞으로 현재 인기를 얻고 있는 Prisma(https://www.prisma.io/) 같은 것이 더 많이 사용될 수도 있을 것 같습니다. 

 

그래도 현재 NestJS + TypeORM 의 조합이 가장 많이 사용되는 것 같으니 TypeORM 에 대한 이야기를 당분간 해보려 합니다. 

 

 

이제까지 NestJS + TypeORM 조합으로 애플리케이션을 개발할 때 트랜잭션 처리에 대한 불편함을 많이 느껴왔는데 이것을 어떻게 해결하면 좋을지 고민해본 내용을 정리하고자 합니다. 

 

반응형

 

쇼핑몰에서 다수 계정의 상태를 일괄적으로 비활성화 시키는 기능을 추가하고자 하는 예제입니다.

 

User Entity는 다음과 같습니다. beInactive() 메소드를 추가해서 user를 비활성화 시키는 메소드를 추가하였습니다.

@Entity()
export class User {
  @PrimaryGeneratedColumn({ type: 'int', unsigned: true })
  id: number;

  @Column({ type: 'varchar', length: 20 })
  name: string;

  @Column({ default: true })
  isActive: boolean;

  constructor(args?: { name: string; isActive?: boolean }) {
    if (args) {
      this.name = args.name;
      this.isActive = args.isActive ?? true;
    }
  }

  /**
   * 비활성화 시킨다
   */
  beInactive() {
    this.isActive = false
  }
}

 

UserRepository는 다음과 같이 custom repository를 구성하였습니다. findByIds()와 save()를 하는 메소드를 UserRepository에 정의한 후 구현하였습니다.

@Injectable()
export class UsersRepository {
    constructor(
        @InjectRepository(User)
        private usersRepository: Repository<User>,
    ) {}

    async findByIds(ids: number[]): Promise<User[]> {
        const users = await this.usersRepository.findBy({ id: In(ids) })
        return users
    }

    async save(users: User[]): Promise<User[]> {
        const savedUsers = await this.usersRepository.save(users, { transaction: false })
        return savedUsers
    }
}

 

이제 실제로 로직을 구성해봅시다.

 

여러 계정의 상태를 일괄적으로 비활성화 시키는 기능을 추가하고자 합니다. 목적을 달성하기 위해 다음과 같이 Service layer에 코드를 작성할 수 있습니다.

@Injectable()
export class UsersService {
 constructor(
        private usersRepository: UsersRepository,
    ) {}

    async makeUsersBeInactive(userNos: number[]): Promise<void> {
        const users = await this.usersRepository.findByIds(userNos)
        users.forEach((user) => user.beInactive())
        await this.usersRepository.save(users)
    }
}

 

잘 동작하는 것 처럼 보이겠지만 중간 실패시 롤백처리를 하기 위해 트랜잭션 기능을 넣고 싶은 경우 어떻게 하면 좋을까요? 

 

1. QueryRunner를 통해 트랜잭션을 제어

가장 쉽게는 DataSource로 부터 QueryRunner를 얻어 트랜잭션을 직접 제어하는 방식입니다. 이 방법은 NestJS 공식문서(https://docs.nestjs.com/techniques/database#typeorm-transactions)에서도 소개하고 있는 방법이고 합니다. 그리고 Node.js를 사용해온 사람이라면 가장 흔하게 사용할 방법일 것 같네요.

 

export class UsersService {
    constructor(
        private dataSource: DataSource,
    ) {}

    async makeUsersBeInactive(userNos: number[]): Promise<void> {
        const queryRunner = this.dataSource.createQueryRunner()

        await queryRunner.connect()
        await queryRunner.startTransaction()
        try {
            const users = await queryRunner.manager.findBy(User, { id: In(userNos) })
            users.forEach((user) => user.beInactive())
            await queryRunner.manager.save(users)

            await queryRunner.commitTransaction()
        } catch (err) {
            await queryRunner.rollbackTransaction()
        } finally {
            await queryRunner.release()
        }
    }
}

 

물론 잘 동작합니다. 그러나 두 가지 문제가 있습니다.

 

첫 째, userRepository custom repository를 사용할 수 없습니다

 

둘 째, user를 비활성화 시키는 비즈니스 로직과 트랜잭션을 제어하는 애플리케이션 로직이 혼재돼 있어 관심사 분리 측면에서 서로 분리가 필요해 보입니다.

 

이 두 가지 문제를 해결해 보고자 합니다.

 

2. custom repository와 dataSource를 공유

UsersService.makeUsersBeInActive는 dataSource를 통해 queryRunner를 얻었고 이것을 custom repository인 UsersRepository와 queryRunner를 공유하면 custom repository를 이용하면서 트랜잭션을 사용할 수 있을 것 같습니다. 그러면 다음과 같이 Repository와 Service 코드를 변경할 수 있을 것 같습니다

 

@Injectable()
export class UsersRepository {
    constructor(
        @InjectRepository(User)
        private usersRepository: Repository<User>,
    ) {}
    
     async findByIds(ids: number[], queryRunner?: QueryRunner): Promise<User[]> {
        if (!queryRunner) {
            const users = await this.usersRepository.findBy({ id: In(ids) })
            return users
        } else {
            const users = await queryRunner.manager.findBy(User, { id: In(ids) })
            return users
        }
    }

    async save(users: User[], queryRunner?: QueryRunner): Promise<User[]> {
        if (!queryRunner) {
            const savedUsers = await this.usersRepository.save(users, { transaction: false })
            return savedUsers
        } else {
            const savedUsers = await queryRunner.manager.save(users, { transaction: false })
            return savedUsers
        }
    }
}

 

export class UsersService {
    constructor(
      private usersRepository: UsersRepository,
      private dataSource: DataSource,
    ) {}
  
    async makeUsersBeInactive(userNos: number[]): Promise<void> {
      const queryRunner = this.dataSource.createQueryRunner();
      await queryRunner.startTransaction();
      try {
        const users = await this.usersRepository.findByIds(userNos, queryRunner);
        users.forEach((user) => user.beInactive());
        await this.usersRepository.save(users, queryRunner);

        await queryRunner.commitTransaction();
      } catch (err) {
        await queryRunner.rollbackTransaction();
      } finally {
        await queryRunner.release();
      }
    }
  }

 

첫 번째 문제였던 custom repository를 사용하는 목적은 달성하였습니다. 하지만 repository가 괴상해졌습니다. 각 메소드마다 queryRunner 파라미터를 검사해서 있으면 넘겨받은 queryRunner를 사용하고 없으면 inject된 repository를 사용하는게 괴상하지 않나요? 그리고 일일이 service에서 queryRunner를 만들어서 넘기는 것도 괴상하지 않나요? 

 

이를 해결하기 위해 repository에서 생성자로 queryRunner를 넘겨받아서 queryRunner를 통해 manager로 usersRepositry를 생성해서 사용하면 될 것 같습니다. 

export class UsersRepository {
  private usersRepository: Repository<User>;

  constructor(private queryRunner?: QueryRunner) {
    if (queryRunner) {
      this.usersRepository = queryRunner.manager.getRepository(User);
    }
  }

  async findByIds(ids: number[]): Promise<User[]> {
    const users = await this.usersRepository.findBy({ id: In(ids) });
    return users;
  }

  async save(users: User[]): Promise<User[]> {
    const savedUsers = await this.usersRepository.save(users, {
      transaction: false,
    });
    return savedUsers;
  }
}

 

@Injectable()
export class UsersService {
  constructor(
    private dataSource: DataSource,
  ) {}
  
  async makeUsersBeInactive(userNos: number[]): Promise<void> {
    const queryRunner = this.dataSource.createQueryRunner();
    const usersRepository = new UsersRepository(queryRunner)
    await queryRunner.startTransaction();
    try {
      const users = await usersRepository.findByIds(userNos);
      users.forEach((user) => user.beInactive());
      await usersRepository.save(users);

      await queryRunner.commitTransaction();
    } catch (err) {
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }
}

 

하지만 또 다른 문제를 낳았어요. UsersService에서 UsersRepository를 inject 할 수 없고 필요한 곳에서 객체를 직접 생성해야만 하는 점이 말이에요. 이렇게 되면 모킹이 어려워 테스트 하기가 어려운 코드가 되어 좋지 않습니다. 

 

또한 현재로서는 dataSource를 UsersService가 생성해서 UsersRepository에 넘겨줘야하기 때문에 트랜잭션에 관한 애플리케이션 로직도 제거하기 힘들어 보입니다. 

 

이쯤에서 고민해 봤을 때 문제는 dataSource를 생성하고 접근하는 부분인 것 같습니다. 근본적인 해결을 위해서는 dataSource를 전역에서 접근할 수 있는 곳에 저장해야 맞을 것 같습니다. 그리고 dataSource는 각 요청(request)마다 격리되어 생성되어야 하며, 응답(response)이 완료됐을 때 dataSource에 관한 resouce가 반환 되어야 할 것 같습니다. 

 

3. Request 마다 전역적으로 접근 가능한 QueryRunner 만들기

request 마다 전역적으로 접근 가능한 무언가를 만들어 주기 위해 우선 Injection scopes를 알아야 합니다. 

https://docs.nestjs.com/fundamentals/injection-scopes

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

각 request 마다 생성되는 request 객체에 db connection을 넣을 것인데 싱글 쓰레드로 동작하는 Node.js 환경에서 각 request 마다 독립된 한경을 갖기 위해서는 매번 각 요청마다 객체를 생성하는 방법이 있습니다. 이를 이용할 것입니다. 

 

이를 위해 UsersRepository의 scope를 REQUEST로 설정합니다. 

 

@Injectable({ scope: Scope.REQUEST })
export class UsersRepository {

 

그리고 각 request 마다 독립된 queryRunner를 만들기 위해 QueryRunnerMiddleware라는 것을 만듭니다. 

@Injectable()
export class QueryRunnerMiddleware implements NestMiddleware {
  constructor(private dataSource: DataSource) {}
  async use(req: Request, res: Response, next: NextFunction) {
    const queryRunner = this.dataSource.createQueryRunner()
    await queryRunner.connect()
    // @ts-expect-error
    req.queryRunner = queryRunner
    next();
  }
}

QueryRunnerMiddleware에서는 QueryRunner를 만들어 request 객체에 넣습니다. 

 

이제 전역적으로 QueryRunner에 접근가능합니다. 그래서 UsersRepository와 UsersService를 다음과 같이 수정할 수 있습니다. 

@Injectable({ scope: Scope.REQUEST })
export class UsersRepository {
  usersRepository: Repository<User>;

  constructor(
    @Inject(REQUEST) private request: Request,
  ) {
    // @ts-expect-error
    const queryRunner: QueryRunner = this.request.queryRunner
    queryRunner.manager.getRepository(User)
    this.usersRepository = queryRunner.manager.getRepository(User)
  }

  async findByIds(ids: number[]): Promise<User[]> {
    const users = await this.usersRepository.findBy({ id: In(ids) });
    return users;
  }

  async save(users: User[]): Promise<User[]> {
    const savedUsers = await this.usersRepository.save(users, {
      transaction: false,
    });
    return savedUsers;
  }
}

 

@Injectable()
export class UsersService {
  constructor(
    private usersRepository: UsersRepository,
    @Inject(REQUEST) private request: Request,
  ) {}

  async makeUsersBeInactive(userNos: number[]): Promise<void> {
    // @ts-expect-error
    const queryRunner: QueryRunner = this.request.queryRunner
    await queryRunner.startTransaction()
    try {
      const users = await this.usersRepository.findByIds(userNos);
      users.forEach((user) => user.beInactive());
      await this.usersRepository.save(users);
      throw new Error() // 예외를 던져 commit 되지 않는 것을 확인합니다.

      await queryRunner.commitTransaction();
    } catch (err) {
        console.log(err)
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }

 

UsersService에서 볼 수 있듯이 중간에 throw Error 를 해서 데이터의 변경이 롤백되는 것을 확인할 수 있었습니다. 

 

이제 대략 첫 번째 문제였던 Custorm Repository를 사용할 수 없던 것을 해결한 것 같습니다. 

 

이제 두 번째 문제였던 UsersService에서 애플리케이션 로직과 비즈니스 로직을 분리하는 방법에 대해 고민해봅시다. 

 

4. 트랜잭션(Transaction)을 데코레이터(Decorator)로 분리하기

UsersService 메소드를 다시 살펴보면 트랜잭션을 제어하는 애플리케이션 로직과 유저를 비활성화 시키는 비즈니스 로직은 서로 연관이 거의 없음을 알 수 있습니다. 심지어 서로 파라미터를 주고 받지도 않습니다. 

 

  async makeUsersBeInactive(userNos: number[]): Promise<void> {
    // @ts-expect-error
    const queryRunner: QueryRunner = this.request.queryRunner
    await queryRunner.startTransaction()
    try {
      const users = await this.usersRepository.findByIds(userNos);
      users.forEach((user) => user.beInactive());
      await this.usersRepository.save(users);

      await queryRunner.commitTransaction();
    } catch (err) {
        console.log(err)
      await queryRunner.rollbackTransaction();
    } finally {
      await queryRunner.release();
    }
  }

 

이렇게 메소드를 실행시킬 때 로직 전 후 서로 영향을 미치지 않는 어떤 로직을 실행시키고 싶은 경우 여러 가지 방법이 있겠으나 프록시를 이용해 데코레이터를 사용할 수 있습니다. 

 

export function Transactional() {
    return function(target: any, key: string, description: PropertyDescriptor) {
        const originalMethod = description.value

        description.value = new Proxy(originalMethod, {
            async apply(target, thisArgs, args) {
                // originalMethod 실행 전 애플리케이션 로직 실행 

                const result = await originalMethod.apply(thisArgs, args)
                
                // originalMethod 실행 전 애플리케이션 로직 실행
                return result
            }
        })
    }
}

이해하기 어려울 것 같아 코드 완성 전 모습을 보여드리자면, 위 주석으로 설명한대로 originalMethod가 실행되기 전후에 트랜잭션을 생성하고 커밋, 롤백하는 등의 로직을 넣을 예정입니다. 

 

완성하면 다음과 같습니다.

 

import type { QueryRunner } from 'typeorm';

export function Transactional() {
  return function (target: any, key: string, description: PropertyDescriptor) {
    const originalMethod = description.value;

    description.value = new Proxy(originalMethod, {
      async apply(target, thisArgs, args) {
        const queryRunner: QueryRunner = thisArgs.request.queryRunner;
        await queryRunner.startTransaction();
        try {
          const result = await originalMethod.apply(thisArgs, args);
          await queryRunner.commitTransaction();
          return result;
        } catch (err) {
          await queryRunner.rollbackTransaction();
        } finally {
          await queryRunner.release();
        }
      },
    });
  };
}

 

@Injectable()
export class UsersService {
  constructor(
    private usersRepository: UsersRepository,
  ) {}

  @Transactional()
  async makeUsersBeInactive1(userNos: number[]): Promise<void> {
      const users = await this.usersRepository.findByIds(userNos);
      users.forEach((user) => user.beInactive());
      await this.usersRepository.save(users);
  }
}

 

이제 두 번째 목적도 드디어 달성했습니다. 제가 원하던 코드가 이런 것이었습니다! UsersService에 비즈니스 로직과 애플리케이션 로직이 분리되어 훨씬 깔끔해졌네요..!

 

하지만 scope를 Request를 선언했다는 점에서 매 요청마다 객체를 계속 만들기 때문에 자원을 비효율적으로 사용할 것 같다는 느낌이 남습니다. 

 

5. Typeorm Transactional 사용하기

더 쉬운 방법이 있습니다. Typeorm Transactional을 사용하는 것입니다. https://www.npmjs.com/package/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.4.1, last published:

www.npmjs.com

 

본래 https://github.com/odavid/typeorm-transactional-cls-hooked 였는데 TypeORM이 0.3 버전이 나오면서 호환되로록 folk 된 버전입니다. 

 

GitHub - odavid/typeorm-transactional-cls-hooked: A Transactional Method Decorator for typeorm that uses cls-hooked to handle an

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...

github.com

 

 

원리는 cls-hooked(https://www.npmjs.com/package/cls-hooked)를 사용해서 request 마다 context를 만들어서 거기에 connection을 저장해서 사용하는 것으로 보입니다. 

 

cls-hooked

CLS using AsynWrap instead of async-listener - Node >= 4.7.0. Latest version: 4.2.2, last published: 6 years ago. Start using cls-hooked in your project by running `npm i cls-hooked`. There are 719 other projects in the npm registry using cls-hooked.

www.npmjs.com

3년 전에 이와 관련된 내용을 다룬 적이 있는데 아마 동작 원리가 비슷할 것 같습니다. https://kay0426.tistory.com/60

 

AsyncHooks로 context에 DB connection 저장하기

서론 Node 13.X 버전부터 AsyncHooks라는 것이 도입되었다. AsyncHook이란, 쉽게 설명하자면, 실행 context마다 고유한 id를 줘서 callback후에도 callback 전의 context를 알 수 있게 해주는 것이다. 더 자세한 내

kay0426.tistory.com

 

라이브러리화 했기 때문인지라 사용법은 정말 쉽습니다. 설명대로, 

 

필요한 라이브러리를 설치하고

yarn add typeorm-transactional
yarn add typeorm reflect-metadata

 

main.ts에 initializeTransactionalContext()를 추가해줍니다.

import { NestFactory } from '@nestjs/core';
import { initializeTransactionalContext } from 'typeorm-transactional';

import { AppModule } from './app';

const bootstrap = async () => {
  initializeTransactionalContext();

  const app = await NestFactory.create(AppModule);

  await app.listen(3000);
};

bootstrap();

 

그리고 app.module.ts 에 다음과 같이 dataSourceFactory에 addTransactionalDataSource()를 추가해줍니다. 

import { DataSource } from 'typeorm';
import { addTransactionalDataSource } from 'typeorm-transactional';

@Module({
  imports: [
    UsersModule,
    TypeOrmModule.forRootAsync({
      useFactory() {
        return {
          type: 'mysql',
          host: 'localhost',
          port: 3306,
          username: 'root',
          password: 'root',
          database: 'store',
          logging: true,
          entities: [User],
          synchronize: true,
        }
      },
      async dataSourceFactory(options) {
        if (!options) {
          throw new Error('Invalid options passed')
        }
        
        return addTransactionalDataSource(new DataSource(options));
      }
    }),
  ],
  controllers: [...],
  providers: [...],
})

 

그러면 별다른 수정없이 트랜잭션이 잘 동작합니다; 너무 쉽습니다;

import { Transactional } from 'typeorm-transactional'

@Injectable()
export class UsersService {
  constructor(
    private usersRepository: UsersRepository,
  ) {}
  
  @Transactional()
  async makeUsersBeInactive(userNos: number[]): Promise<void> {
      const users = await this.usersRepository.findByIds(userNos);
      users.forEach((user) => user.beInactive());
      await this.usersRepository.save(users);
      throw new Error() // 예외를 던지면 transaction이 rollback
  }
}

 

별 다른 이유가 없다면 현재로서는 typeorm-transactional을 사용하는 것이 베스트 프랙티스가 아닌가 싶습니다.

 


 

오늘은 예전 부터 NestJS에서 typeorm transaction을 잘 사용하는 방법에 대해 고민해본 점을 정리해보았습니다.

 

우선 Service layer에서 트랜잭션과 관련된 애플리케이션 로직과 User를 다루는 비즈니스 로직이 혼재되어 있는 부분이 좋지 못하다는 것을 밝혔습니다. 이를 해결하기 위해 query runner를 어떻게 다룰지를 긴 여정을 통해 살펴봤고 결국에는 UsersRepository scope를 Request로 두면서 request 객체에 query runner를 저장하여 이를 해결하였습니다. 또한, decorator를 이용해 트랜잭과 관련된 애플리케이션 로직을 감추는데 성공했습니다. 하지만 이 경우 Request 마다 객체를 생성하는 방식이라 리소르를 낭비할 수 있다는 한계점이 존재합니다.

 

더 좋은 방법은 이미 있는 오픈소스를 사용하는 것입니다. 이미 이 문제를 해결하기 위해 많은 사람들이 노력해온것 같고 그 방법으로는 typeorm-transactional(https://www.npmjs.com/package/typeorm-transactional)을 사용하는 것입니다. typeorm-transctional은 cls-hooked 기반으로 동작하여 db connection을 async local storage에 저장하여 동작할 것 같습니다.

 

앞으로 실제 프로덕트에서 NestJS가 더 흥했으면 하는 바람입니다.  

 

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

반응형