저는 개인적으로 트랜잭션이라는 개념을 원자성이라는 이유로 사용합니다. 데이터베이스 CRUD에 대한 여러 작업이 하나의 메서드에 존재할 때 해당 작업들이 부분적으로 성공 및 실패하지 않도록 하기 위함입니다. 즉 모든 데이터베이스 작업들이 성공하거나 모든 데이터베이스 작업들을 실패시키기 위해 트랜잭션이라는 개념을 사용합니다. 제가 직접 트랜잭션을 사용한 상황을 통해 관련 내용을 공유드리겠습니다.
문제 상황
async subscribe(reqUser: JwtPayload, publisherUserId: number) {
try {
await this.userRepository.update(reqUser.id, {
subscribeCount: () => 'subscribeCount + 1',
});
await this.userRepository.update(publisherUserId, {
subscriberCount: () => 'subscriberCount + 1',
});
return await this.subscribeInfoRepository.insert({
subscriber: { id: reqUser.id },
publisher: { id: publisherUserId },
});
} catch (error) {
throw error;
}
}
위 코드는 제가 NestJS를 이용해 개발 중인 서비스 코드 중 일부입니다. 위 메서드는 '구독'을 위한 기능입니다. 현재 데이터베이스에서 사용자 테이블에는 내가 구독한 사람 숫자, 나를 구독한 사람 숫자를 의미하는 컬럼이 존재합니다. 또한 사용자간 구독 현황을 알려주는 구독 테이블이 존재합니다. 이렇게 구독 행위에는 총 3개의 업데이트가 이루어져하므로 위와 같은 코드를 작성했습니다.
이때 문제가 발생할 수 있는 상황이 여러 가지 존재하는데요. 그 중 하나는 바로 "return await this.subscribeInfoRepository.insert" 이 부분에서 에러가 날 경우입니다. 이 경우에는 해당 코드 위 부분은 모두 성공적으로 적용되지만 해당 부분만 실패하게 되면서 데이터 정합성에 문제가 생길 수 있습니다.
async subscribe(reqUser: JwtPayload, publisherUserId: number) {
try {
// 성공 - 내가 구독한 사람 수 +1
await this.userRepository.update(reqUser.id, {
subscribeCount: () => 'subscribeCount + 1',
});
// 성공 - 나를 구독한 사람 수 +1
await this.userRepository.update(publisherUserId, {
subscriberCount: () => 'subscriberCount + 1',
});
// 실패!!! - 그러면 나를 구독한 사람 수, 내가 구독한 사람 수는 업데이트가 이미 되었는데 어떡하지?...
return await this.subscribeInfoRepository.insert({
subscriber: { id: reqUser.id },
publisher: { id: publisherUserId },
});
} catch (error) {
throw error;
}
}
위와 같은 경우 실제 구독 정보는 존재하지 않지만( subscribeInfoRepository.insert가 실패했으므로 구독 정보 추가 안 됨 ) 각 사용자의 구독 관련 숫자는 이미 +1 이 적용되버린 상황인 것입니다.
트랜잭션을 활용한 문제 해결
이를 위해 필요한 것이 바로 트랜잭션입니다. 중간에 특정 로직이 실패하면 다른 로직들도 모두 롤백처리하여 원자성을 보장해야 합니다. TypeORM에서는 DataSource와 QueryRunner를 이용해 이런 트랜잭션 구현이 가능합니다. 다음은 위 코드를 DataSource와 QueryRunner를 활용해 수정한 예제입니다.
constructor(
...
private readonly dataSource: DataSource,
) {}
async subscribe(reqUser: JwtPayload, publisherUserId: number) {
const queryRunner: QueryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 요청자 구독 수 증가
await queryRunner.manager.update(User, reqUser.id, {
subscribeCount: () => 'subscribeCount + 1',
});
// 게시자 구독자 수 증가
await queryRunner.manager.update(User, publisherUserId, {
subscriberCount: () => 'subscriberCount + 1',
});
// 구독 정보 저장
await queryRunner.manager.insert(SubscribeInfo, {
subscriber: { id: reqUser.id },
publisher: { id: publisherUserId },
});
// 트랜잭션 커밋
await queryRunner.commitTransaction();
return;
} catch (error) {
throw error;
} finally {
// 쿼리 러너 릴리스 필수!!!
await queryRunner.release();
}
}
사용법은 간단합니다. DataSource를 활용해 queryRunner를 만들고 트랜잭션을 시작했습니다. 이후 해당 객체를 이용해 데이터베이스 CRUD 작업을 진행했습니다. 그리고 모든 작업 이후에는 트랜잭션을 종료하고 해당 queryRunner를 release해주었습니다.
❗️조심해야할 부분
- 람다 방식
사실 위 코드를 테스트하는 과정에서 트랜잭션이 제대로 적용되지 않음을 확인할 수 있었는데요. 그 이유가 무엇일까요? 바로 'subscribeCount + 1'과 같은 람다 방식 업데이트로 인함입니다. 이런 람다 방식은 트랜잭션 관리 하에 이루어지지 않을 수 있다고 합니다. 그래서 해당 트랜잭션이 실패하여 롤백되어도 트랜잭션 하에 이루어진 업데이트가 아니다보니 원상복구가 되지 않는 모습을 확인할 수 있었습니다.
이를 해결하기 위해서는 쿼리 빌더를 이용하는 방식으로 수정해 롤백이 제대로 적용되게 수정했습니다. 예를 들면 다음과 같은 방식으로 수정했습니다.
// 요청자 구독 수 증가
await queryRunner.manager
.createQueryBuilder()
.update(User)
.set({ subscribeCount: () => '`subscribeCount` + 1' })
.where('id = :id', { id: reqUser.id })
.execute();
'데이터베이스, ORM > TypeORM' 카테고리의 다른 글
[TypeORM] connection pool size 조정해서 데이터베이스 작업 효율 높이기 (0) | 2025.04.20 |
---|---|
[TypeORM] 골치 아픈 Distinct 쿼리 피해 소요 시간 줄이기 (0) | 2025.03.22 |
[TypeORM] SQL 쿼리 튜닝 - 2. 외래키 정보 join없이 조회하기 (0) | 2024.12.02 |
[TypeORM] SQL 쿼리 튜닝 - 1. findBy VS findOneBy 뭐가 더 좋을까? (0) | 2024.11.25 |
[TypeORM] @OneToMany, @ManyToOne - 옵션: onDelete / onUpdate (0) | 2024.11.23 |