데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      [Node.js] 트랜잭션을 활용한 테스트 격리 환경 구현하기(1/2)

      puleugo 24.09.04
      1,261 7 0
      DEVOTEE 요약
      트랜잭션을 통해 테스트를 격리하려고 했지만 TypeORM에서는 각 테스트가 다른 세션을 사용해 문제가 발생했습니다. 이를 해결하기 위해 하나의 세션만 사용하는 방법을 제안하며 자세한 구현 방법을 설명했습니다. 이 과정에서 @Transactional 어노테이션과 queryRunner를 활용한 트랜잭션 관리를 통해 효과적인 테스트 격리를 구현했습니다.

      서론

      • 트랜잭션을 통해 테스트 격리성을 가져가고 싶었습니다.

      • 단, TypeORM에는 세션을 관리해주지 않아, 하나의 테스트만 실행해도 여러개의 세션이 연결됩니다.

      • 위 제약사항에 대한 접근방식 + 해결방법을 공유하겠습니다.


      트랜잭션으로 테스트 격리성을 확보한다는게 무슨말이야?

      각 테스트 간의 영향을 끼치지 않도록 하는 테스트 방법입니다. 크게 두가지 전략이 있습니다.

      • 트랜잭션 전략: 테스트 시작과 끝에 트랜잭션을 시작하고 롤백하는 전략

      • 클린업 전략: 테스트 종료 후 DB의 모든 행을 제거하는 전략

      둘다 처음 들어본다면 향로님의 재미있는 글이 있으니 추천드립니다. (테스트 데이터 초기화에 @Transactional 사용하는 것에 대한 생각, 향로, 2023. 11. 26.)

      beforeEach(async () => {
      	await queryRunner.startTransaction();
      });
      
      afterEach(async () => {
      	await queryRunner.rollbackTransaction();
      });

      그렇다면 위 코드만으로 해결되지 않을까요? 테스트를 실행해봤습니다.

      it(`변경된 게임을 수정한다`, async () => {
      	// given
      	await gameFactory.save(GameMother.createGame({ edited: true })); // 1️⃣ DB에 게임 삽입
      
      	// when
      	await service.updateEditedGame(); // ❌ DB에 변경된 게임이 없음!
      
      	// then
      	expect(gameFactory.findBy({ edited: true })).toHaveLength(0); // ❌ 1개 존재함!
      	},
      );

      분명히 given절에서 삽입해줬을 게임을 찾을 수 없다고 합니다.

      MySQL의 트랜잭션 격리수준이 REPEATABLE READ이기 때문이라고 예상할 수 있지만, 여기서 알 수 있는 점이 한가지 더 있습니다. 

       

      트랜잭션의 삽입된 데이터를 못 읽는다는 것은 서로 세션이 다르다는 것입니다.

      it(`변경된 게임을 수정한다`, async () => {
      	// given
      	await gameFactory.save(GameMother.createGame({ edited: true })); // 🅰️ DB 세션 A
      
      	// when
      	await service.updateEditedGame(); // 🅱️ DB 세션 B
      
      	// then
      	expect(gameFactory.findBy({ edited: true })).toHaveLength(0);
      	},
      );

      따라서 Fixture와 Service가 서로 다른 세션을 가지고 있다고 가정할 수 있습니다.

       

      바로 검증을 위한 테스트코드를 작성해보았습니다.

      it(`Service와 Fixture의 DB 세션이 동일하다`, async () => {
      	const factorySession = await gameFactory.query(
      		'SELECT * '+
      		'FROM information_schema.PROCESSLIST '+
      		'WHERE ID = CONNECTION_ID()'
      	); // 
      	const serviceSession = await service.getCurrentDbSessionInfo();
      	
      	expect(serviceSession).toStrictEqual.(factorySession); // ❌ 실패! 서로 다른 세션!
          
      	// 결과:
      	// Object {
      	// "COMMAND": "Query",
      	// "DB": "test_wtgames",
      	// - "HOST": "192.168.65.1:53751",
      	// - "ID": "35", // 🅰️ 서비스의 세션
      	// + "HOST": "192.168.65.1:53752",
      	// + "ID": "36", // 🅱️ 팩터리의 세션
      	// "INFO": "SELECT ID , USER, HOST, DB, COMMAND, TIME, STATE, INFO
      	// FROM information_schema.PROCESSLIST WHERE ID = CONNECTION_ID()",
      	// "STATE": "executing",
      	// "TIME": 0,
      	// "USER": "user",
      	// "EXCUTE_ENGINE": "PRIMARY",
      });

      가정대로 세션이 다릅니다.

      그럼 이 문제를 해결하기 위해 어떻게 접근하면 좋을까요?


      격리수준만 낮추면 되는거 아닐까?


      (진짜 모름)


      트랜잭션 내의 INSERT된 데이터를 읽기만 하면 되니까 두 트랜잭션을 모두 READ UNCOMMITTED로 변경해보겠습니다.

      • READ UNCOMMITTED: 커밋되지 않은 데이터를 읽을 수 있는 격리수준

      -- TEST_DB
      SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED; -- 격리수준 변경
      SELECT @@GLOBAL.transaction_isolation, @@GLOBAL.transaction_read_only; -- 격리수준 조회
      -- 격리수준 조회결과
      +--------------------------------+--------------------------------+
      | @@GLOBAL.transaction_isolation | @@GLOBAL.transaction_read_only |
      +--------------------------------+--------------------------------+
      | READ-UNCOMMITTED               |                              0 |
      +--------------------------------+--------------------------------+


      READ UNCOMMITTED으로 변경한다면 삽입된 값을 읽어올 수는 있습니다. 그런데 데드락이 발생하네요.

      it(`변경된 게임을 수정한다`, async () => {
      	// given
      	await gameFactory.save(GameMother.createGame({ edited: true }));
      
      	// when
      	await service.updateEditedGame(); // ❌ 데드락 발생! 타임 아웃 실패!
      
      	// then
      	expect(gameFactory.findBy({ edited: true })).toHaveLength(0);
          
      	// ❌ 결과:
      	// Error: Lock wait timeout exceeded; try restarting transaction
      	},
      );

      .updateEditedGame 메서드는 당연하게 update 쿼리를 사용합니다. 그러면 다음과 같은 순서로 진행됩니다.


      서로가 종료되기를 기다리고 있다..


      그럼 어떻게 해야할까요? 바로 하나의 세션만을 사용하여 테스트를 진행하면 됩니다.

      Spring의 JPA 경우에도 @Transactional AOP가 @Transactional 어노테이션을 만날 때 기존 세션을 사용합니다. 자세한건 트랜잭션 전파를 검색해봅시다.

       

      우선 여기까지가 제가 작성한 테스트가 실패하는 이유였으며, 이제 해결해봅시다.


      어떻게 한 테스트에서 하나의 세션만을 사용할까?

      TypeORM에는 DataSource라는 커넥션을 관리할 수 있는 객체가 존재합니다. 이를 사용하여 문제를 해결해봅시다.

      (ConnectionManager와 관련 Hook은 deprecated되었습니다.)

      export async function getDbConnection() {
      	return await new DataSource({
      		...options,
      		name: 'default',
      	}).initialize();
      }

      DB와 연결하는 부분에 'default' 세션을 생성하도록 했습니다. Fixture를 위한 Repository 인스턴스를 생성해야합니다.

       

      이렇게 하면 해결될 것 같지만 해결해야할 문제가 하나 더 있습니다.

      영속성 레이어가 'default' 세션을 사용하도록 해야합니다.

      @Injectable()
      export class GameRepo {
      	constructor(
      		@InjectRepository(GameEntity)
      		private readonly repo: Repository<GameEntity>, // ← @InjectRepository()를 통해 DI중
      	) {}
          
          // ...
      }

      DI는 이번 이슈의 주목할 부분이 아니므로 그냥 아래처럼 해결했습니다. (이런 자잘한건 다음편에서..)

      beforeAll(async () => {
      	queryRunner = (await getDataSource()).createQueryRunner(); // 1️⃣ 커넥션 가져오기
      
      	const module: TestingModule = await Test.createTestingModule({
      		imports: [
      			getDbModule(),
      			TypeOrmModule.forFeature([...]),
      			],
      		providers: [
      			SheetService,
      			{
      				provide: GameRepoToken,
      				useValue: new GameRepo(
      					new Repository(GameEntity, queryRunner.manager, queryRunner),
      				), // 2️⃣ Repository 인스턴스 생성
      			},
      			],
      	}).complie();
              
      	gameFactory = new Repository(GameEntity, queryRunner.manager, queryRunner); // 2️⃣ Fixture 클래스 default 커넥션
      	});
          
      beforeEach(async () => {
      	await queryRunner.startTransaction();
      })
      
      afterEach(async () => {
      	await queryRunner.rollbackTransaction();
      	await queryRunner.release();
      })

      이렇게하면 하나의 테스트가 하나의 세션을 통해 처리됩니다.

       

      테스트 결과는 다음과 같습니다.

       // ✅
      it(`서비스와 팩터리의 DB 세션이 동일하다`, async () => {
      	const factorySession = await gameFactory.query('SELECT CONNECTION_ID()');
      
      	const serviceSession = await gameService.getCurrentDbSession();
      
      	expect(serviceSession).toStrictEqual(factorySession);
      });
      
       // ✅
      it(`팩터리에서 생성한 데이터를 서비스에서 수정한다`, async () => {
      	await gameFactory.save(GameMother.createGame({ edited: true }));
      
      	const result = sheetService.updateEdited();
      
      	expect(result).resolves.not.toThrow();
      });

      성공적으로 테스트가 수행됩니다.

      QueryRunner vs DataSource vs Repository

      번외로 싱글 커넥션만 가질 수 있는 queryRunner라는 클래스가 존재합니다. DataSource에서 .createQueryRunner()를 사용해서 인스턴스를 얻을 수 있습니다.

       // ❌ 복수의 세션
      it(`DataSource에서 얻은 쿼리러너와 팩터리의 DB 세션이 동일하다`, async () => {
      	const dataSource = getDataSource();
      	const queryRunner = dataSource.createQueryRunner();
      
      	const factorySession = await factory.query('SELECT CONNECTION_ID()');
      	const queryRunnerSession = await queryRunner.query('SELECT CONNECTION_ID()');
      
      	expect(queryRunnerSession).toStrictEqual(factorySession); // ❌ 다른 세션
      });
      
      // ✅ ❗ 암묵적인 이슈 존재
      it(`쿼리러너와 dataSource의 세션이 동일하다`, async () => {
      	const dataSource = getDataSource();
      	const factory = dataSource.getRepository(GameEntity);
      
      	const dataSourceSession = await dataSource.query('SELECT CONNECTION_ID()');
      	const factorySession = await factory.query('SELECT CONNECTION_ID()');
      
      	expect(dataSourceSession).toStrictEqual(factorySession); // ⭕ 같은 세션
      });
      
      
      // ❌ 지맘대로 커밋
      it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
      	const dataSource = getDataSource();
      	const factory = dataSource.getRepository(GameEntity)
          
      	await dataSource.query('START TRANSACTION');
      	await factory.save(GameMother.createGame()); // ❗ 지맘대로 트랜잭션 생성하고 커밋
      	await dataSource.qeury('ROLLBACK');
      
      	expect(await factory.find()).toHaveLength(0); // ❌ 이미 커밋되서 롤백안됨.
      });
      
       // ✅ Best Practice.
      it(`쿼리러너를 주입한 팩터리와 쿼리러너의 DB 세션이 동일하다`, async () => {
      	const queryRunner = getDataSource().createQueryRunner();
      	const factory = new Repository(
      		GameEntity, 
      		queryRunner.manager, 
      		queryRunner, // ⭐ 쿼리러너 주입
      	);
      
      	const factorySession = await factory.query('SELECT CONNECTION_ID()');
      	const queryRunnerSession = await queryRunner.query('SELECT CONNECTION_ID()');
      
      	expect(queryRunnerSession).toStrictEqual(factorySession); // ⭕ 같은 세션
      });

      단, .createQueryRunner 메서드는 무조건 익명 세션을 생성하는 메서드이므로 사용하실 때 참고하셔야합니다.

       

      3번째 테스트 '팩터리가 지맘대로 커밋하지 않는다'는 왜 실패할까요?

      // ❌ 롤백 실패
      it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
      	const dataSource = getDataSource();
      	const factory = dataSource.getRepository(GameEntity);
      
      	await dataSource.query('START TRANSACTION');
      	await factory.save(GameMother.createGame()); // ❗ 지맘대로 트랜잭션 생성하고 커밋
      	await dataSource.query('ROLLBACK');
      
      	expect(await factory.find()).toHaveLength(0); // ❌ 이미 커밋되서 롤백 실패
      });
      
      // ✅ Best Practice. 롤백 성공
      // queryRunner 주입방식
      it(`팩터리가 지맘대로 커밋하지 않는다`, async () => {
      	const queryRunner = dataSource.createQueryRunner();
      	const factory = new Repository(
      		GameEntity,
      		queryRunner.manager,
      		queryRunner, // ⭐ 쿼리러너 주입
      	);
      
      	await queryRunner.startTransaction();
      	await factory.save(GameMother.createGame());
      	await queryRunner.rollbackTransaction();
      
      	expect(await factory.find()).toHaveLength(0); // ⭕ 롤백 성공
      });
      
      // ⭕ 트랜잭션 여부를 알고있다.
      it(`queryRunner가 트랜잭션 활성화 여부를 알고 있다..`, async () => {
      	const queryRunner = dataSource.createQueryRunner();
          
      	await queryRunner.startTransaction();
          
      	expect(queryRunner.isTransactionActive).toBeTruthy(); // 트랜잭션이 활성화 여부가 true
      });

      다만, 1번 방식은 repository가 트랜잭션의 활성화 여부를 모르기 때문에 repository.save 같은 몇몇 메서드에서 의도치 않은 커밋을 발생시킵니다.

      자세한건 (typeorm/src/persistence /EntityPersistExecutor.ts)


      여기까지가 원하는 트랜잭션만 사용해서 테스트를 수행하는 방법이었습니다.

      다음편에서는 주제인 테스트 환경을 구축해보겠습니다.

       

      인프런팀도 TypeORM 환경에서 트랜잭션 전략을 사용하고 있다고 알고 있는데, 개인적으로 관련 글을 작성해주셨으면 좋겠습니다.


      [출처] https://puleugo.tistory.com/201

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      puleugo 님의 최신 블로그

      더보기
      동영상 기고하기