들어가며
사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. 개발한 API를 살펴보면서, 비효율적인 부분들이 눈에 보였습니다. 같은 조회 쿼리를 하나의 API에 두 번씩 도는 로직이 있었고, 처리 순서가 중요하지 않은 조회 쿼리를 동기적으로 처리하고 있었습니다. 마지막으로 데이터를 저장할 때 하나의 트랜잭션으로 처리하는 것이 아니라, 별도의 트랜잭션으로 처리하고 있었고, 만약 에러가 발생할 경우에도 별도의 트랜잭션으로 데이터를 저장하기에, 롤백되는 것이 아닌 커밋될 위험이 다분했습니다. 이 문제를 해결하기 위해 트랜잭션과 Promise.all을 활용했습니다. 이 글은 트랜잭션과 Promise.all을 어떻게 활용했는지에 관한 글입니다.
트랜잭션?
API를 개발하면서, 에러가 났음에도 데이터가 저장된 것을 보면서, 트랜잭션을 제대로 적용해봐야겠다고 생각했습니다. 트랜잭션을 외우기만 했지, 적용해본 적이 없기에 이번 기회에 트랜잭션이란 무엇이며, 트랜잭션에 고민해야 할 것은 무엇인지, lock은 무엇이며, 트랜잭션 고립 레벨 별로 lock은 어떻게 동작하는지 알아야겠다고 생각했습니다.
위와 같이 공부하고서, 프로젝트에 어떻게 트랜잭션을 적용했는지 살펴보겠습니다.
API 리팩터링
기존 작업한 API는 다음과 같았습니다.
async createFavoriteSummoner (
userDto: UserReq,
favoriteSummonerDto: FavoriteSummonerReq,
): Promise<void> {
await this.checkLimitFavoriteSummoner(userDto.userId);
await this.redisClient.saveRedisSummonerRecord(favoriteSummonerDto);
await this.saveSummonerRecord(favoriteSummonerDto);
await this.saveFavoriteSummoner(favoriteSummonerDto, userDto);
await this.restoreFavoriteSummoner(favoriteSummonerDto, userDto);
}
private async saveSummonerRecord(
favoriteSummonerDto: FavoriteSummonerReq,
): Promise<void> {
const isFindSummonerRecordId = await this.isSummonerRecordBySummonerId(
favoriteSummonerDto.summonerId,
);
if (!isFindSummonerRecordId) {
await this.summonerRecordRepository.save(
await favoriteSummonerDto.toSummonerRecordEntity(),
);
}
}
private async saveFavoriteSummoner(
favoriteSummonerDto: FavoriteSummonerReq,
userDto: UserReq,
): Promise<void> {
if (
!(await this.findFavoriteSummonerIdWithSoftDelete(
userDto.userId,
favoriteSummonerDto.summonerId,
))
)
await this.favoriteSummonerRepository.save(
await favoriteSummonerDto.toFavoriteSummonerEntity(userDto.userId),
);
}
private async restoreFavoriteSummoner(
favoriteSummonerDto: FavoriteSummonerReq,
userDto: UserReq,
): Promise<void> {
const favoriteSummonerId = await this.findFavoriteSummonerIdWithSoftDelete(
userDto.userId,
favoriteSummonerDto.summonerId,
);
if (favoriteSummonerId)
await this.favoriteSummonerRepository.restore(favoriteSummonerId.id);
}
위 코드에서 아래와 같이 중복된 조회 쿼리를 이용하는 코드가 보입니다.
await this.findFavoriteSummonerIdWithSoftDelete(
userDto.userId,
favoriteSummonerDto.summonerId,
)
위의 코드를 saveFavoriteSummoner, restoreFavoriteSummoner 메서드에서 활용하고 있었습니다. 기존의 코드라면, 두 번의 조회 쿼리를 수행해야 하므로 상당히 비효율적입니다. 또한 하나의 트랜잭션으로 묶여 있지 않고, 각각의 트랜잭션에서 save 또는 restore가 수행되기에, 갑작스러운 문제로 API 호출에 실패할 경우에도 save 또는 restore 쿼리가 커밋될 수 있는 문제가 발생합니다. 이 문제를 해결해보겠습니다.
먼저 조회 쿼리를 분리해보겠습니다.
async createFavoriteSummoner(
userDto: UserReq,
favoriteSummonerDto: FavoriteSummonerReq,
): Promise<void> {
await this.checkLimitFavoriteSummoner(userDto.userId);
await this.redisClient.saveRedisSummonerRecord(favoriteSummonerDto);
const [isFindSummonerRecordId, favoriteSummonerId] = await Promise.all([
await this.isSummonerRecordBySummonerId(favoriteSummonerDto.summonerId),
await this.findFavoriteSummonerIdWithSoftDelete(
userDto.userId,
favoriteSummonerDto.summonerId,
),
]);
await this.saveOrRestoreTransaction(
isFindSummonerRecordId,
favoriteSummonerId,
favoriteSummonerDto,
userDto,
}
}
각 메서드에 있던 쿼리를 밖으로 꺼내어 saveOrRestoreTransaction 메서드에 주입했습니다. 이를 통해 동일한 조회 쿼리를 두 번 활용하지 않아도 되게끔 설정했습니다. 또한 조회 쿼리의 경우 처리의 순서를 신경 쓸 필요 없으니, Promise.all을 활용하여 병렬적으로 처리하도록 설정했습니다.
private async saveOrRestoreTransaction(
isFindSummonerRecordId: boolean,
favoriteSummonerId: FavoriteSummonerId,
favoriteSummonerDto: FavoriteSummonerReq,
userDto: UserReq,
) {
const queryRunner: QueryRunner = this.connection.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
await this.saveSummonerRecord(
isFindSummonerRecordId,
favoriteSummonerDto,
queryRunner,
);
await this.saveFavoriteSummoner(
favoriteSummonerId,
favoriteSummonerDto,
userDto,
queryRunner,
);
await this.restoreFavoriteSummoner(favoriteSummonerId, queryRunner);
await queryRunner.commitTransaction();
} catch (error) {
await queryRunner.rollbackTransaction();
throw error;
} finally {
await queryRunner.release();
}
}
또한 save 또는 restore 쿼리를 하나의 트랜잭션으로 관리하기 위해 QueryRunner를 활용했습니다. TypeORM의 Connection은 기본적으로 커넥션 풀을 활용하는데, 이때 실제 단일 데이터베이스 커넥션을 별도로 생성하여 세부적으로 트랜잭션을 관리하고 싶다면 QueryRunner를 사용할 수 있습니다. connection으로부터 createQueryRunner 함수를 통해 QueryRunner 인스턴스를 생성할 수 있습니다. 해당 인스턴스를 connect 함수를 통해 데이터베이스에 연결한 뒤 쿼리를 날리거나 트랜잭션을 수행할 수 있습니다.
- startTransaction(isolationLevel) 트랜잭션 시작
- commitTransaction() 트랜잭션 커밋
- rollbackTransaction() 트랜잭션 롤백
QueryRunner를 다 사용했다면 release를 통해 인스턴스를 해제해 줘야 합니다. 위와 같이 설정하여 트랜잭션을 제어할 수 있었습니다. 결과적으로 API를 보다 더 효율적으로 동작할 수 있도록 설정했습니다.
마치며
앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다.
참고 및 출처
https://typeorm.io/transactions
https://typeorm.io/query-runner#
'Project > 서버 개발' 카테고리의 다른 글
[Project] 프로젝트 삽질기27 (feat Redis 활용) (1) | 2022.05.11 |
---|---|
[Project] 프로젝트 삽질기26 (feat Redis 설정) (0) | 2022.05.08 |
[Project] 프로젝트 삽질기24 (feat Sentry Slack 연동) (0) | 2022.04.29 |
[Project] 프로젝트 삽질기23 (feat Enum) (0) | 2022.04.22 |
[Project] 프로젝트 삽질기22 (feat Exception) (0) | 2022.04.21 |