개요
문제 | 새로운 백엔드 팀원들이 합류하면서, 기존 코드를 수정할 때 의도치 않게 기능이 변경되는 문제가 발생했습니다. 새로운 기능 추가 후, 문서가 부족하여 기존 기능을 테스트하는 데에 어려움이 있었습니다. |
해결방안 | 수동으로 테스트하던 작업을 자동화하기 위해, 테스트 코드 작성을 통해 회귀 버그를 검증했습니다. 또한 테스트 격리성을 확보하기 위해 QueryRunner를 사용하여 각 테스트에서 독립된 세션에서 실행되도록 하고, 테스트 종료 후에는 롤백하는 환 경을 구축했습니다. |
서론
- 1부는 이론편, 2부는 실습편입니다.
- 트랜잭션을 통해 테스트 격리성을 가져가고 싶었습니다.
- 단, 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 환경에서 트랜잭션 전략을 사용하고 있다고 알고 있는데, 개인적인 마음으로는 관련 글을 작성해주셨으면 좋겠습니다.
'트러블 슈팅' 카테고리의 다른 글
이미지 로드 속도 향상하기 (0) | 2024.11.17 |
---|---|
[Nest.js] HTTP query에 따라 Service가 동적으로 할당되어야 한다. (0) | 2024.05.07 |
html에서 React로 마이그레이션 시 겪었던 문제점 (0) | 2024.02.05 |
[MySQL 복구 2] binlog를 통해 데이터베이스 복구하기 (0) | 2023.09.08 |
[MySQL 복구 1] .ibd 파일을 이용하여 데이터 복구하기 (0) | 2023.09.08 |
글 내용 중 잘못되거나 이해되지 않는 부분은 댓글을 달아주세요! 감사합니다! 문의: puleugo@gmail.com