본문 바로가기
backend/NestJS

[Prisma] Promise.all과 transaction 동시 사용 시 Transaction API error: Unable to start a transaction in the given time. 에러 발생.

by BK0625 2024. 12. 8.
반응형

 

외주 작업을 하면서 추가 의뢰를 받은 기능을 개발하던 중이였다.

 

해당 기능은 주문이 들어온지 두 달이 지났는데 아직 연결이 안된 접수 건을 조회 해서 취소된 항목 변경해달라는 것.

 

나는 cron으로 이미 스케줄링 작업을 돌리고 있었기 때문에 cron을 사용하기로 했고 매일 정해진 시간에 조회를 해 업데이트를 하면 된다고 생각했다. 또 이미 주문 취소 함수가 구현이 되어 있었기 때문에 해당 함수를 사용하면 된다고 생각했다.

 

그리고 문제의 그 코드...

 

 @Cron('0 50 23 * * * ', { timeZone: 'Asia/Seoul' })
    async deleteOldOrder() {
        //예전 데이터 삭제

        // 기준 날짜: 현재 날짜에서 2개월 전
        const twoMonthsAgo = new Date();
        twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2);

		// 두 달 지난 데이터 조회
        const oldList = await this.tasksRepository.getOldOrders(twoMonthsAgo);
        if (!oldList.success) {
            this.logger.error('예전 데이터 조회 오류');
        }

        const oldOrders = oldList.list;
        // Promise.all로 병렬 삭제
        await Promise.all(
            oldOrders.map((oldOrder) => {
                const cancelOrderDto: CancelOrderDto = {
                    orderId: oldOrder.id,
                    isFirst: oldOrder.isFirst,
                    patientId: oldOrder.patientId,
                };
                this.erpService.cancelOrder(cancelOrderDto);
            }),
        );
    }

 

뭐 자세한 비즈니스 로직은 말할 수 없지만 대충 두 달 지난 데이터를 조회해 취소 시키는 로직이다. 처음에 데이터를 몇 개만 넣어서 테스트를 진행 했을 때는 별 문제 없었다.

 

그리고 배포 전 마지막 테스트로 데이트를 몇 십 개 정도 넣고 테스트를 해봤는데...

 

Transaction API error: Unable to start a transaction in the given time.

 

 

Transaction API error: Unable to start a transaction in the given time.

 

라는 에러가 터지면서 몇 개는 되고 대부분은 취소가 안되는 대참사가 터졌다. 에러 메세지를 서치를 해보니 에러가 발생한 이유는 다음과 같았다.

 

Promise.all에서 실행 되는 cancelOrder 함수는 저 안에서 트랜잭션을 실행해 4,5개의 테이블의 데이터를 수정하게 된다. 그런데 이런 함수를 Promise.all로 병렬 실행을 하게 되면 트랜잭션 충돌이 일어나거나 연결 풀이 한계에 달할 수 있다. 에러 메세지를 보면 트랜잭션이 주어진 시간 안에 시작되지 못해 에러가 발생한 걸 알 수 있고 아마도 여러 개의 트랜잭션을 동시에 걸다 보니 연결 풀이 한계에 도달하여 발생한 문제로 보인다. (참고로 디폴트로 주어지는 시간은 5초이다. 그래서 몇 개만 취소되고 대부분은 취소가 안됐던 거 같다.)

 

기존에 단순 create나 update를 Promise.all로 처리한 적이 있는데 이런 단순 개별 쿼리들은 트랜잭션으로 래핑되지 않고 독립적인 단일 쿼리로 처리된다고 한다. 따라서 단순 데이터의 수정이나 삭제, 생성 등은 병렬적으로 실행해도 일반적으로 문제가 되지 않는다.

하지만 여러 데이터 베이스 연산을 묶어서 실행하거나 .$transaction을 명시적으로 사용해 트랜잭션을 시작하는 경우에는 트랜잭션과 연결 풀이 관리 대상이 되기 때문에 이런 상황에서는 병렬로 실행할 때 연결 풀의 크기 제한에 의해 오류가 발생할 가능성이 있다고 한다.

 

prisma에서 timeout이랑 maxWait 등의 기능으로 트랜잭션 생성을 조정 할 수 있다고는 하는데 해당 방법을 쓰게 되면 쿼리 생성 시간이 지나치게 늘어나 성능 이슈가 발생할 수 있다.

 

연결 풀을 늘리는 건 근본적인 해결책은 아닌거 같았고...

 

 

아예 직렬로 처리를 하거나 다른 방법을 찾아야 했는데 내가 선택한 방법은 다음과 같다.

 

 

  // 기준 날짜: 현재 날짜에서 2개월 전
        const twoMonthsAgo = new Date();
        twoMonthsAgo.setMonth(twoMonthsAgo.getMonth() - 2);

        const oldList = await this.tasksRepository.getOldOrders(twoMonthsAgo);
        if (!oldList.success) {
            this.logger.error('예전 데이터 조회 오류');
        }

        const oldOrders = oldList.list;

        const BATCH_SIZE = 5;

        for (let i = 0; i < oldOrders.length; i += BATCH_SIZE) {
            const batch = oldOrders.slice(i, i + BATCH_SIZE);

            await Promise.all(
                batch.map(async (oldOrder) => {
                    const cancelOrderDto: CancelOrderDto = {
                        orderId: oldOrder.id,
                        isFirst: oldOrder.isFirst,
                        patientId: oldOrder.patientId,
                    };
                    await this.erpService.cancelOrder(cancelOrderDto);
                    
                }),
            );
        }

 

 

코드를 보면 BATCH_SIZE라는 변수를 확인 할 수 있다. 즉 배치 사이즈를 조절하여 부분적으로 병렬 처리 하는 방법을 선택 했다.

 

만약 직렬 처리 방법을 선택했다면...

 

  • 모든 작업이 완전히 직렬로 진행됨
  • 데이터베이스 및 네트워크에 대한 부하가 낮음
  • 트랜잭션 충돌 및 연결 풀 부족 문제를 방지
  • 한 번에 하나의 작업만 실행되기 때문에 트랜잭션 충돌, 연결 풀 문제, 데이터 일관성 이슈를 방지
  • 다만 성능 처리 시간이 느리고
  • 네트워크 지연, I/O 대기 시간이 모든 작업에 누적

반면 한 번에 일정 수의 작업을 병렬로 처리하고 다음 배치를 실행하는 방법은

 

  • 작업은 부분 병렬적으로 진행
  • 데이터베이스와 네트워크 자원을 효율적으로 사용할 수 있음
  • 배치 크기 조정으로 안정성과 성능을 균형 있게 조정.
  • 직렬 실행보다 훨씬 빠름. 병렬 처리로 네트워크 대기 시간을 줄이고 데이터베이스 처리 속도를 향상 시킴.
  • 배치 크기를 조정하여 부하를 제어할 수 있음. 너무 큰 크기를 선택하지 않으면 트랜잭션 충돌과 연결 풀 문제를 최소화 할 수 있음
  • 다만 에러 핸들링이 복잡할 수 있음. 개별 작업 실패 시 특정 작업만 재시도하려면 추가 처리 필요
  • 데이터베이스나 네트워크의 용량을 초과하면 성능이 저하될 수 있음

 

이런 장단점이 있는데 어차피 매일 실행되는 특성 상 배치 실행 방법의 단점은 크게 상쇄 될 것이라고 봤기 때문에 성능이 더 나은 배치 실행 방법을 선택하였다.

 

 

공부하면서 정리한 내용입니다. 모든 지적 감사히 받겠습니다:)

반응형