Transaction 관리하기(with NestJS, TypeORM)

정비호·2024년 5월 10일

Troubleshooting

목록 보기
4/8

메인 프로젝트할 때의 이야기다.

발단

당시 나는 Transaction 이라는 개념 자체를 모르던 시절이었다. 그러다 같이 사이드 프로젝트를 하는 선배가 Transaction 이라는 키워드를 던져주셔서 이에 대해서 공부를 하고 동아리에서 매 2주마다 하는 발표(당시 영상 자료)의 주제로 사용했었다.

문제

Transaction에 대해서 알아 보고 메인 프로젝트의 코드를 보았다.
문제가 없을리가 없다. 정말 많은 문제가 있었지만 대표적인 예시를 몇개 들어보면

  • 유저 생성과 동시에 부가적인 리소스가 같이 생성되야 함.
    ex) 유저의 행동(글 쓰기 등등)에 대한 각각의 count를 기록하는 테이블에 해당 유저에 대한 로우를 생성해야 함.
    • 추후 해당 유저가 글을 작성하는 등의 활동을 했다면 해당 count를 기록하는 테이블에 해당 유저의 PKFK로 갖고 있는 로우의 데이터를 수정해야 함. 근데 만약 해당 로우가 존재하지 않는다면 서버 에러.
  • 인기 게시글을 선정할 때, 좋아요의 개수를 따져서 인기 게시글로 선정을 하는데 이 과정에서 좋아요만 생성되고 인기 게시글로 선정이 되지 않는 에러가 발생할 수 있음.
    • 어느 한쪽에서 에러가 났다면 양쪽 다 작업이 취소 되어야 함.

이 모든걸 종합해보면 해당 예시에 있는 하나의 큰 작업과 그 뒤에 따라오는 추가적인 처리들은 하나의 작업 단위, 즉 하나의 Transaction으로 묶여야 하는 작업임.

흔히 Transaction이 설명되는 대표적인 예시는 계좌 이체를 하는 상황이다.
아래에서 간단한 예시를 들어 보겠다.

A와 B가 있다.
A는 B에게 3천원을 빌렸었고, 지금 돈을 갚기 위해 계좌 이체를 한다.
둘의 잔고 상황은 다음과 같다.
A 잔고: 10,000
B 잔고: 10,000

  • A --> B 3천원 송금.
    이 때 A 잔고는 -3,000이 되어 7,000원이 남음.
  • A의 잔고는 -3,000이 된 상태. 이제 B의 잔고에 +3,000을 해줘야 한다.
  • 근데 B의 잔고에 +3,000을 하는 과정에서 에러가 발생.
  • 하나의 Transcation으로 묶이지 않았기에 A의 돈만 빠져나가고 B의 계좌에는 해당 돈이 들어오지 않는 대참사 발생.

이 정도면 Transaction의 중요성과 간단한 설명 정도는 된 것 같다. 딱 Transaction의 특징까지만 알아보자.

Transaction의 특징(ACID)

원자성 (Atomicity)

  • 트랜잭션이 데이터베이스에 모두 반영되던가, 아니면 모두 반영되지 않아야 한다는 것.
  • 만약 트랜잭션 단위로 데이터를 처리하지 않는다면 설계한 사람은 데이터 처리 시스템을 이해하기 힘듬.
  • 뿐만 아니라 오작동 했을 시에 원인을 찾기 매우 힘듬.

일관성 (Consistency)

  • 트랜잭션의 작업 처리 결과 이전과 이후 데이터베이스 상태가 항상 일관성이 있어야 한다는 것.
  • ex) 모든 유저는 반드시 이름을 가지고 있어야 한다는 제약이 있음.
    • 이름 없는 새로운 유저를 추가하는 쿼리
    • 기존 유저의 이름을 삭제하는 쿼리
  • 이 예시의 쿼리가 일어난 이후의 데이터베이스는 일관되지 않는 상태를 가지게 됨.

독립성 (Isolation)

  • 둘 이상의 트랜잭션이 동시에 실행되고 있을 때, 어떤 하나의 트랜잭션이라도, 다른 트랜잭션의 연산에 끼어들 수 없다는 점을 가리킴.
  • 하나의 특정 트랜잭션이 완료될때까지, 다른 트랜잭션이 특정 트랜잭션의 결과를 참조할 수 없다.

영구성/지속성 (Durability)

  • 하나의 트랜잭션이 성공적으로 수행되었다면, 해당 트랜잭션에 대한 로그가 남아야 하는 성질.
  • 만약 런타임 오류나 시스템 오류가 발생하더라도, 해당 기록은 영구적이어야 한다는 뜻이다.

이 정도까지만 하고 NestJS에서 TypeORM을 이용해 Transaction을 관리하는 방법을 알아보자.

문제해결

TypeORMTransaction 관리 방법은 크게 두가지가 있다.
1. transaction 메서드를 직접 이용.

  • 이 방법은 EntityManager의 transaction 메서드를 사용하는 방법이다.

    await this.dataSource.manager.transaction(
    async (transactionalEntityManager) => {
      await transactionalEntityManager
        .withRepository(this.repository)
        .save({ userId, ...postProps });
  1. QueryRunner를 통해 트랜잭션 제어
  • QueryRunner를 이용해 단일 DB 커넥션 상태를 생성하고 관리
  • NestJS공식문서에는 QueryRunnerTransaction을 완전하게 제어할 수 있으므로 QueryRunner 사용을 권장한다.

그럼 나는 공식문서의 추천에 따라 QueryRunner를 통해 트랜잭션을 제어하는 방법을 알아보겠다.
괜히 코드블럭으로 설명하는 것보단 발표때 사용된 사진을 사용하는게 더 이해가 쉬울 것 같아서 사진을 통해 설명하겠다.

일단 가장 먼저

  • 생성자 함수에서 TypeORM의 DataSource 객체를 주입한다.
  • 이후 createQueryRunner 메서드를 통해 QueryRunner를 생성.
  • 이때 queryRunner는 RDBMS가 연결 풀링을 지원하는 경우 dataSource의 연결풀에서 단일 연결을 사용함.
  • 이제 보이는 순서대로 connect 함수를 호출해서 새로운 queryRunner를 연결하고 startTransaction 함수를 호출해서 새로운 트랜잭션을 시작하면 된다.
  • try catch 문법을 사용한다.

실제 queryRunner를 통해 트랜잭션을 제어하는 예시 코드 하나를 작성해보겠다.

@Injectable()
export class PostsService {
  constructor(
    // dataSource 객체 주입
    private readonly dataSource: DataSource,
    private readonly postRepository: PostRepository,
    private readonly postHistoryRepository: PostHistoryRepository
  ) {}	
  
  async create(
    userId: number,
    title: string,
    description: string
  ) {
    // queryRunner 생성
    const queryRunner = this.dataSource.createQueryRunner();
    
    // queryRunner 연결 및 transaction 시작
    await queryRunner.connect();
    await queryRunner.startTransaction();
    
    // try catch 문법 사용
    try {
      const entityManager = queryRunner.manager;
      
      // 생성한 queryRunner의 entityManager를 통해 쿼리를 실행.
      // 이 부분이 중요하다. 해당 entityManager를 통해 쿼리를 날려야 함.
      // 그렇지 않으면 한 트랜잭션으로 묶이지 않음.
      const newPost = await entityManager
        .withRepository(this.postRepository)
        .save({
       	  userId,
          title,
          description
      	});
      
      // 마찬가지로 같은 entityManager를 통해 쿼리를 날린다.
      await entityManager
        .withRepository(this.postHistoryRepository)
      	.save({
          postId: newPost.id,
          userId,
          title,
          description
        });
      
      // DB 작업이 성공적으로 수행됐다면 commit으로 영속화 완료.
      await queryRunner.commitTransaction();
  	} catch (err) {
      // 만일 에러가 발생 시, rollback 시켜준다.
      if (queryRunner.isTransactionActive) {
        await queryRunner.rollbackTransaction();
      }
      
      console.error(err);
  	} finally {
      // 모든 작업을 마쳤다면, 반드시 queryRunner를 relase 해줘야 함.
      if (!queryRunner.isReleased) {
        await queryRunner.release();
      }
  	} 
  }  
}

대충 게시글 하나를 생성하고 해당 게시글의 history를 쌓는 로직이다.
주석을 보면 알다시피 중요한 점이 몇 가지 있다.

  • 생성한 queryRunner의 entityManager를 통해 DB 작업을 수행.
  • 작업을 마쳤다면 queryRunner를 release 해준다.

만약 queryRunner를 release 시켜주지 않는다면 다음과 같은 문제가 발생한다.

  • 리소스 정리 - 트랜잭션이 완료되거나 오류가 발생한 경우, queryRunner를 해제하면 데이터베이스 연결이 적절하게 종료되어, 리소스를 해제하고 잠재적인 메모리 누수를 방지한다.
  • 연결 누수 방지 - queryRunner를 해제하지 않으면 연결 누수가 발생할 수 있다. 연결이 적절히 해제되지 않으면 연결 풀이 고갈되어 애플리케이션이 사용 가능한 연결을 소진하고, 성능에 영향을 미치거나 충돌을 일으킬 수 있다.

이 점 숙지하도록 하고, 여기까지만 알아보도록 하자.

마무리

Transaction을 적절히 잘 관리 하는 것은 굉장히 중요하다.

profile
잘하고 싶은 개발자

0개의 댓글