Contents

Node.js와 TypeORM에서 겪은 트랜잭션 롤백 문제 - 원인 분석과 해결 방법

<표지 사진출처: Unsplash>

 

Project Tech Stack Overview
  • Language: TypeScript

  • Platform: Node.js

  • Web Framework: Express.js

  • Database: MySQL

  • ORM: TypeORM

  • Cloud Storage: AWS S3

  • Server: AWS EC2

  • Development Tool: WebStorm

  • Collaboration Tool: Slack

  • Version Control and Issue Tracking: GitHub Issue

 

문제 상황

프로젝트에서 발견된 주요 문제는 TypeORM을 사용하는 Node.js 환경에서 트랜잭션 관리와 롤백이 제대로 이루어지지 않는 것이었다. 이는 게시글을 등록하는 로직에서 발견되었으며, 예기치 않은 오류가 발생했을 경우 데이터베이스에 불필요한 데이터가 잔존하는 결과를 초래했다.

좀 더 자세히 풀어보자면, 현재 게시글을 등록하는 로직은 다음과 같다.

  1. 요청을 받으면 트랜잭션을 시작한다.
  2. DB는 새로운 게시글 데이터를 생성한다.
  3. 전달받은 파일 링크의 유효성 검사를 실행한다.
  4. 파일링크의 유효성 검사가 끝나면, 해당 파일 링크를 1번에서 생성한 게시글에 정상적으로 연결한다.
  5. 이후 나머지 로직을 실행한다.
  6. 2번부터 5번까지는 트랜잭션 내부에서 처리되며 에러가 발생하지 않는다면 트랜잭션을 커밋한다.
  7. 6번의 과정 중 에러가 발생한다면 2번부터 5번까지의 모든 작업을 취소하고 롤백하여 DB의 상태를 되돌린다.

이 중, 3번 과정에서 에러를 발생시켰더니 status code, error message와 함께 에러는 정상적으로 반환되었지만, 1번에서 생성한 게시글의 데이터가 롤백되지 않고 그대로 DB에 잔존해있었다.
즉, 트랜잭션 롤백이 제대로 작동하고 있지 않는 문제였다.
 

일러두기

사실, 이 문제는 typeORM transaction에서 repository 사용하기 - inchan.dev 이 글에서 이미 동일하게 발생했던 이슈이고 해결했었다.
그런데 왜 똑같은 문제가 다시 발생했을까?

이전과 지금의 로직에서 바뀐 점은 바로 해당 레포지토리의 성격 변화이다. 이전 글에서는 해당 레포지토리가 함수적 확장 레포지토리로 선언되어있었지만 OOP 리팩토링 과정에서 클래스 기반 레포지토리로 수정되었다. 때문에 함수가 사용되는 맥락이 아닌 새로운 인스턴스에서 트랜잭션이 실행되고 있기에 다시 한번 문제가 생긴 것이다.

함수적 확장 레포지토리에서 queryRunner를 사용할 때, 이것은 이미 시작된 트랜잭션 내에서의 작업을 의미한다. 즉, queryRunner.startTransaction()을 호출한 후, queryRunner의 컨텍스트 내에서 따로이 다른 레포지토리의 save, update, delete 같은 메소드를 호출하면, 이 메소드들은 queryRunner에 의해 시작된 트랜잭션 내에서 실행되었다.

하지만 클래스 기반 레포지토리에서는 save, update, delete 같은 메소드가 다르게 작동한다.

  • 클래스를 구성하는 코드 안에서는 super(Feed, dataSource.createEntityManager())를 호출하여 각 인스턴스가 고유의 엔티티 매니저를 가지도록 구성되었고,
  • 이렇게 사용자 정의 클래스 레포지토리를 사용함으로써, 해당 레포지토리는 더 이상 기본 dataSource의 연결과 컨텍스트를 직접 사용하지 않고, 대신 각 인스턴스별로 독립된 엔티티 매니저를 통해 작업을 수행하게 되면서 별개로 트랜잭션 범위 관리를 해줘야 하는 상황이 된 것이다.

문제상황의 서비스 코드

private executeTransactionWithRetry = async (
    attempt: number,
    feedInfo: TempFeedDto | FeedDto,
    fileLinks: string[],
    options: FeedOption
  ): Promise<Feed> => {
    const queryRunner = dataSource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();

    try {
  	const newFeedInstance = plainToInstance(Feed, feedInfo);
  	const newFeed = await queryRunner.manager
  	  .withRepository(this.feedRepository)
  	  .createFeed(newFeedInstance); 
  	// ^^^^^^^^^^^  이 메서드가 롤백되지 않고 그대로 커밋되어버리는 문제 발생
  	
  	  await this.uploadFileService.updateFileLinks(
  		queryRunner,
  		newFeed,
  		fileLinks
  	  );
  	// 여기서 에러를 발생시키고 롤백을 작동시켰다.


  	// ... 이후 코드
  	} catch (err: any) {
  	await queryRunner.rollbackTransaction();
  	// ... 이후 에러 코드
  	}
  }

 

수정 이전의 메서드 코드

// 위 문제상황의 서비스코드에서 문제되고 있는 createFeed 메소드의 코드를 살펴본다.
// await queryRunner.manager
//     .withRepository(this.feedRepository)
//     .createFeed(newFeedInstance); 

  async createFeed(feedInfo: Feed) {
    const feed = this.create(feedInfo);
    await this.save(feed);              // <= 1차 수정
    return await this.findOne({         // <= 2차 수정
  	loadRelationIds: true,
  	where: { user: { id: feedInfo.user.id } },
  	order: { id: 'DESC' },
    });
  }

 

트랜잭션 중첩 문제

트랜잭션을 관리하는 해당 서비스를 요청하고 터미널에서 mySQL의 흐름을 살펴보니, START TRANSACTION두번 연속으로 호출되고 있었다. 그리고 문제의 메서드 코드부분은 트랜잭션 과정이 끝나기 전에 먼저 커밋해버리고 이후 따로이 트랜잭션 롤백이 일어나고 있었다. START TRANSACTION 이후 트랜잭션 내부 로직에서는 커밋과 롤백, 둘 중 하나만 이루어져야 한다. 그런데 이상하게도 커밋과 롤백이 연이어 발생하고 있었다.
 

문제가 되고있는 typeORM의 save 메서드 문서를 찾아보았다.

save - Saves a given entity or array of entities. If the entity already exist in the database, it is updated. If the entity does not exist in the database, it is inserted. It saves all given entities in a single transaction (in the case of entity, manager is not transactional). Also supports partial updating since all undefined properties are skipped. Returns the saved entity/entities.

TypeORM의 saveupdate 메서드는 내부적으로 자체 트랜잭션을 생성하고 커밋한다.1 이는 별도의 트랜잭션 관리 없이 사용될 때는 문제가 되지 않지만, 따로이 트랜잭션을 관리하는 경우 문제를 일으킬 수 있다.
특히, createQueryRunner를 사용하여 시작된 트랜잭션 내에서 save 메서드를 호출하면 트랜잭션이 중첩되어, 기대했던 롤백 동작이 정상적으로 이루어지지 않게 된다.

1차 수정된 메서드 코드

  async createFeed(feedInfo: Feed, queryRunner: QueryRunner) {
    const feed = queryRunner.manager.create(Feed, feedInfo);
    await queryRunner.manager.save(feed); // <= parameter에 queryRunner를 추가하고 함께 수정한 코드

    const result = await this.findOne({
  	loadRelationIds: true,
  	where: { user: { id: feedInfo.user.id } },
  	order: { id: 'DESC' },
    });

    return result;
  }

 

Isolation Level의 중요성

1차 수정 이후 새로운 문제가 생겼다. 해당 함수는 새로이 생성된 게시글 데이터의 정보를 반환해야하는데, 새로 생긴 데이터가 아닌 그 이전의 데이터를 반환하는 문제가 발생된 것이다.
 
트랜잭션의 고립 수준(Isolation Level)은 다수의 트랜잭션이 동시에 실행될 때 각각의 트랜잭션이 서로에게 미치는 영향을 정의한다. TypeORM에서는 이 고립 수준을 설정하여, 트랜잭션 간의 간섭을 최소화하고, 데이터의 일관성과 무결성을 유지할 수 있다.
이번 문제에서는 findOne 메서드가 트랜잭션 외부의 데이터에 접근하고 있었기에, 트랜잭션 내에서 생성된 데이터를 올바르게 찾지 못한 것이었다.

데이터 트랜잭션의 ACID
  • Atomicity(원자성): 트랜잭션의 모든 단계가 완료되지 않으면 트랜잭션이 끝나지 않는다. 이는 데이터가 항상 올바른 상태를 유지하도록 돕는다. 예를 들어, 계좌 이체의 경우 돈을 보내는 계좌에서 돈을 빼기만 하고 받는 계좌에 돈을 더하지 않으면 데이터 무결성이 깨진다.
  • Consistency(일관성): 트랜잭션은 데이터베이스의 상태를 일관된 상태에서 다른 일관된 상태로 이동하도록 한다. 만약 트랜잭션 중간에 문제가 발생하면 트랜잭션은 롤백되어 데이터베이스를 이전 일관된 상태로 되돌린다.
  • Isolation(고립성 또는 독립성): 동시에 여러 트랜잭션이 수행되더라도 각 트랜잭션은 서로에게 영향을 주지 않는다. 이는 각 트랜잭션이 독립적으로 수행되도록 보장하며, 이는 병렬 처리를 가능하게 한다.
  • Durability(내구성 또는 지속성): 트랜잭션이 성공적으로 완료되면 그 결과는 영구적으로 데이터베이스에 저장된다. 시스템이 중단되더라도 트랜잭션으로 인한 변경사항은 손실되지 않는다.

참조링크 - ACID - 위키백과, 우리 모두의 백과사전

2차 수정된 메서드 코드

	async createFeed(feedInfo: Feed, queryRunner: QueryRunner) {
	  const feed = queryRunner.manager.create(Feed, feedInfo);
	  await queryRunner.manager.save(feed);
  
	  const result = await queryRunner.manager.findOne(Feed, {
		loadRelationIds: true,
		where: { user: { id: feedInfo.user.id } },
		order: { id: 'DESC' },
	  });
	// findOne 메소드 역시 dataSource 레포지토리에 직접 연결이 아닌 queryRunner를 끌어와 해당 트랜잭션 내에서 수행될 수 있도록 한다. 
  
	  return result;
	}

 

수정된 코드에서는 queryRunner.manager.savequeryRunner.manager.findOne을 사용하여 해당 기능을 트랜잭션 내부에서 동작할 수 있도록 확실히 제어한다. 이를 통해, 트랜잭션 중첩 문제를 해결하고, 트랜잭션의 고립성을 보장하고 있다.

롤백되지 않는 MySQL의 AUTO_INCREMENT 필드

특이점을 발견했다.
트랜잭션 롤백 후에도 MySQL의 AUTO_INCREMENT 필드(특히 PK ID)는 증가된 상태를 유지하고 있다는 사실이다.
예를 들자면,

  1. 현재 가장 최근 데이터의 ID는 10번이다.
  2. 트랜잭션 로직 내부에서 데이터가 생성되었다가(11번 ID 부여) 에러발생으로 인해 롤백하며 데이터베이스는 새로운 데이터 생성 이전의 상태로 되돌아갔다.
  3. 그런데 이후 정상적으로 생성된 데이터의 ID는 11번이 아닌 12번이 되었다.
     

이는 동시성 제어 및 성능 최적화를 위한 설계 특성인듯 싶다.
대규모 웹서비스의 상황으로 예를 넓혀보자면,

  • (상황) 여러 사용자가 동시에 데이터 생성 로직을 요청하고 있을 때,
  • (조건) 그 중 한 트랜잭션이 롤백된 후,AUTO_INCREMENT 필드가 이전 값으로 되돌아간다면,
  • (결과) 동시 실행중인 다른 로직에서는 ID 충돌의 위험이 발생할 것이다.

즉, 여러 트랜잭션이 동시에 실행되고 있을 때 한 트랜잭션의 롤백이 다른 트랜잭션에 영향을 주면서 다른 여러 트랜잭션에서의 ID 할당에 혼란이 야기되는 상황이 그려진다. 이에 AUTO_INCREMENT 값의 연속성을 유지함으로써, 트랜잭션 간의 독립성을 보장하고, 데이터베이스의 일관성 및 무결성을 유지할 수 있음을 알 수 있다.
 

github 해당 커밋 보기 - github-inchan code CHANGED | 2.0.8
 

참고문서

관련 글 - typeORM transaction에서 repository 사용하기 - inchan.dev


  1. TypeORM에서 자체 트랜잭션을 진행하는 메소드는 save, remove, insert, update, delete 등이 있다. ↩︎