1편 :https://sorryisme.tistory.com/98
새로운 결제 시스템이 도입되었다. 중복 결제를 막는 방법으로 비관적 락을 사용하였고 QA 및 실제 배포 후 문제는 없었다. 하지만 현업은 다음과 같은 요건이 던져졌다.
요건 및 추가 제한사항으로는 간편결제 한도가 있다는 것이다. 객단가가 높은 비즈니스 상황상 한 번의 Request에 분할결제를 진행 해야했으며 요건이 변경됨에 따라 정책과 동시에 개발 또한 수정이 필요했다. 한도가 정해진 결제에 대해서 여러 번 결제해서 처리해달라는 요청이다. 여러가지 제한점과 고려해야할 사항들이 발생되었다
** 참고로 환불에 대한 PG사의 API가 존재하지 않아 부분 환불 및 전체 환불은 불가하다 (은행계좌를 통한 간편결제 시스템)
기존 로직
async processPayment(paymentId: number, paymentDetails: string): Promise<Payment> {
const queryRunner = this.connection.createQueryRunner();
// 트랜잭션 시작
await queryRunner.connect();
await queryRunner.startTransaction();
try {
// 1. 결제 여부 비관적 락 조회
const payment = await queryRunner.manager.findOne(Payment, paymentId, {
lock: { mode: 'pessimistic_write' },
});
if (!payment) {
throw new ConflictException('결제 정보를 찾을 수 없습니다.');
}
if (payment.isPaid) {
throw new ConflictException('이미 결제가 완료된 상태입니다.');
}
// 2. 결제 수행 (여기서는 단순히 상태 변경으로 가정)
payment.isPaid = true;
payment.paymentDetails = paymentDetails;
// 3. 최종 결제 내용 저장
await queryRunner.manager.save(payment);
// 트랜잭션 커밋
await queryRunner.commitTransaction();
return payment;
} catch (error) {
// 트랜잭션 롤백
await queryRunner.rollbackTransaction();
throw error;
} finally {
// 트랜잭션 종료
await queryRunner.release();
}
}
실제로 현업 로직은 이것보다 훨씬 복잡하나 간단히 표현했다.
1. 결제를 수행 전 비관적 락 사용
2. 결제를 수행
3. 결제 수행 후 결제 상태 업데이트
기존 정책은 단일 결제였기에 간단한 로직으로도 충분히 문제는 해결되었다
하지만 분할 결제 처리 방식은 다음과 같은 문제가 발생한다
문제점
1. 트랜잭션의 단위
만약 3,000,000원의 결제 금액이 있고 한도가 2,000,000원을 예를 들자면 한도 금액만큼 잘라서 결제해야한다
실제 결제 수행은 2,000,000원 + 1,000,000원을 수행한다.
2,000,000 금액을 결제하고 1,000,000원을 결제하려고 했을 때 은행계좌에 금액이 부족하면 어떻게 해야할까?
결제사로 이미 API를 발송한 것은 취소할 수 없기에 이를 고려해야 한다. 결제사를 통해 결제된 금액은 저장되어야 하며, 그렇기에 트랜잭션은 한 번의 결제 단위로서 묶여야 하는 점이 있다.
async processTotalPayment(
totalAmount: number,
paymentDetails: string,
limitAmount: number = 2000000
): Promise<Payment[]> {
// 결제 금액을 한도에 따라 분할
const paymentUnits: PaymentUnit[] = [];
let remainingAmount = totalAmount;
while (remainingAmount > 0) {
const currentAmount = remainingAmount >= limitAmount ? limitAmount : remainingAmount;
paymentUnits.push({
amount: currentAmount,
paymentDetails,
});
remainingAmount -= currentAmount;
}
const processedPayments: Payment[] = [];
for (const unit of paymentUnits) {
try {
// 트랜잭션을 수행
const payment = await this.processSinglePayment(unit.amount, unit.paymentDetails);
processedPayments.push(payment);
} catch (error) {
throw new ConflictException(
`결제 금액 ${unit.amount.toLocaleString()}원 처리 중 오류가 발생했습니다: ${error.message}`
);
}
}
return processedPayments;
}
2. 동시성 문제
위의 결제가 수행되는 도중에 동일한 요청이 발생되면 어떻게 될까? 락에 대한 제어 및 유효성 검토를 트랜잭션 단위로 하지 않으면 다음과 같은 문제가 발생될 수 있다.
const findPayment: Payment[] = await this.entityManager.find(Order, {
where: { id: orderId },
});
// [유효성 검사] findPayment 합계가 실제 결제 금액보다 많으면 에러 발생
if (calculateTotal(findPayment) >= 3_000_000) throw new ConflictException("이미 결제된 내역입니다")
for (const unit of paymentUnits) {
try {
// 트랜잭션을 수행
const payment = await this.processSinglePayment(unit.amount, unit.paymentDetails);
processedPayments.push(payment);
} catch (error) {
throw new ConflictException(
`결제 금액 ${unit.amount.toLocaleString()}원 처리 중 오류가 발생했습니다: ${error.message}`
);
}
}
동시에 두 개의 Request가 왔을 때 위와 같은 로직이 수행된다고 가정했을 때 계산된 로직은 이전의 값을 가지고 있어 정상적으로 유효성 검사를 하지 않는다.
또한 루프를 수행하는 단계에서 동시에 발생된 Request가 processSinglePayment내의 락이 풀리는 시점에서 로직을 수행하여 과대하게 결제를 수행하는 문제를 막아야한다.
예를 들어 요청이 동시에 2개가 들어왔다고 가정했을 때 2,000,000원 결제가 수행되는 동안 비관적 락을 통한 방식은 다른 스레드에서 2,000,000원 결제를 대기하고 있을 것이다. 2,000,000원이 결제되고 커밋되는 시점에 다른 스레드에서 2,000,000원이 결제되어 총 4,000,000원이 결제될 수 있다
이런 케이스를 막기 위해 루프 내 트랜잭션이 수행되는 동안 실시간으로 금액에 대한 검증을 수행해야한다
// 트랜잭션 시작
const findPayment = await transactionalEntityManager.find(PaymentOrder, {
relations: { payment: true },
where: { orderId },
lock: { mode: 'pessimistic_write' }
})
const paidPrice = calculate(findPayment); // 결제 내역에서 이미 결제된 금액
const totalPrice = paymentPrice - paidPrice;
const estimatedPrice = totalPrice - splitPrice; // 남은 차액 - 결제요청 금액
if (totalPrice <= 0) throw new BadRequestException('[결제중복] 이미 결제된 금액입니다');
if (estimatedPrice < 0) throw new BadRequestException('[결제에러] 결제 금액이 차액보다 많습니다');
... 중략
// 결제 수행
// 트랜잭션 종료
3. 레코드 락과 인덱스
또 하나 반드시 체크해야하는 사항은 쿼리 수행 시 락이 정확히 원하는 범위만큼 걸리는지 체크해야한다는 점이다. 쿼리 수행 시 실행계획과 동시에 인덱스를 통한 비관적 락을 수행하는지 체크하지 않으면 테이블 전체 락이 걸려 성능상의 이슈가 발생될 수 있다
테스트 데이터
- payment 10개
- payment_order 10개
인덱스가 없을 때 락 조회
set autocommit = false;
select * from payment p
inner join payment_order po on (p.id = po.paymentId)
where po.orderId = 1 for update;
select * from performance_schema.data_locks;
테스트 결과
위 쿼리를 수행한다면 orderId = 1인 하나의 레코드에만 레코드 락이 수행되어야하는데 테스트 데이터 모두 락이 걸리는 문제가 발생했다. 위 테이블에는 인덱스가 존재하지 않아 테이블 풀 스캔이 발생되었고 이로 인해 10건에 대한 레코드 전체가 락이 걸리는 문제가 발생한다. 이로 인해 락이 걸린 관련 레코드들은 어떠한 작업도 수행할 수 없게 된다.
인덱스가 정상적일 때
인덱스를 추가한 후 테스트 해본 결과 필요한 레코드에만 락이 수행되는 것을 확인 할 수 있었다.
단순히 조회 성능 향상을 위해서 인덱스를 확인하는 것이 아니라 비관적 락 사용 시 락에 대한 이슈가 없는지 반드시 확인해봐야한다
결론
위 내용은 일반적인 결제보다 훨씬 특수한 요건이였지만 동시성 문제에 대한 고려는 어떠한 백엔드 개발환경에서든 반드시 고려되어야하는 사항이라고 생각이 든다. 또한 단순히 동시성 문제 뿐 아니라 비관적 락을 사용할 때 실행계획 및 인덱스가 정상적으로 수행되는지 또 락의 범위로 인해 어플리케이션에 부정적인 영향이 없는지 반드시 체크해봐야하는 사항이라고 생각이 든다
'BackEnd > Node' 카테고리의 다른 글
신규 결제 시스템 도입기 (중복결제 방지하기) (0) | 2025.01.15 |
---|