Skip to content

3차: 결제 비동기 처리 및 결제 후속 작업 배치 처리 기반 DB 커넥션 감소

bellringstar edited this page Sep 1, 2024 · 9 revisions

🔍 문제 분석

성능 병목점 식별

  1. 동기식 결제 처리
    기존의 코드에서는 티켓을 구매할 때 결제 처리가 동기적으로 이루어지고 있었습니다. 이는 다음과 같은 문제를 일으켰습니다.
  • 결제 처리 시간 동안 스레드가 블로킹되어 다른 요청을 처리하지 못한다.
  • 동시에 많은 결제 요청이 들어올 경우 리소스 부족이 발생
  1. 개별적인 DB 연산
    결제 건에 대해 개별적으로 DB 연산이 수행되고 있었습니다. 이는 다음과 같은 문제를 일으켰습니다.
  • 많은 DB 커넥션 사용으로 인한 커넥션 풀 고갈
  • 잦은 BD I/O로 인한 성능 저하

이러한 병목점들은 순간적인 고부하 상황에서 시스템의 성능을 저하시키고 안정성을 위협할 수 있습니다. 특히 결제 처리와 같은 중요한 비즈니스 로직에서 이러한 문제가 발생하면 사용자 경험에 직접적인 영향을 미칠 수 있습니다.

🛠️ 해결 방안

이러한 병목을 해결하기 위해 두 가지 전력을 채택했습니다. 결제 처리의 비동기화와 결제 후속 작업의 배치 처리입니다.

  1. 결제 처리의 비동기화
    첫 번째 접근 방식은 결제 처리를 비동기적으로 변경하는 것이었습니다. 이를 위해 CompletableFuture를 활용했습니다.
public String processPurchase(PurchaseData purchaseData) {
    validatePurchase(purchaseData);
    String paymentId = UUID.randomUUID().toString();

    paymentService.initiatePayment(paymentId, purchaseData.memberId(), purchaseData.ticketId())
        .thenAcceptAsync(status -> handlePaymentResult(paymentId, status, purchaseData))
        .exceptionally(e -> {
            log.error("결제 처리 중 오류 발생", e);
            compensationService.compensateFailedPurchase(paymentId, purchaseData.ticketId(),
                    purchaseData.ticketStockId());
            return null;
        });

    return paymentId;
}

이 방식의 장점은 다음과 같습니다.

  • 결제 요청을 즉시 반환하여 클라이언트의 대기 시간을 줄입니다.
  • 서버 리소스를 효율적으로 사용할 수 있어, 동시에 더 많은 요청을 처리할 수 있습니다.
  • 결제 처리 실패 시 보상 트랜잭션을 비동기적으로 처리할 수 있습니다.
  1. 결제 후속 작업의 배치 처리
    두 번째 전략은 결제 후속 작업(구매 기록 생성, 재고 업데이트, 체크인 기록 생성 등)을 배치로 처리하는 것이었습니다. 이를 위해 우리는 인메모리 큐와 주기적인 배치 프로세싱을 도입했습니다.
@Scheduled(fixedRate = 5000) // 5초마다 실행
public void processPurchases() {
    List<PurchaseData> batch = queue.pollBatch(calculateOptimalBatchSize());
    if (!batch.isEmpty()) {
        CompletableFuture.runAsync(() -> processBatch(batch), executor)
            .exceptionally(e -> {
                log.error("배치 처리 실패", e);
                handleFailedBatch(batch);
                return null;
            });
    }
}

@Transactional
protected void processBatch(List<PurchaseData> purchases) {
    List<Purchase> successfulPurchases = new ArrayList<>();
    for (PurchaseData purchase : purchases) {
        try {
            Purchase newPurchase = createPurchase(purchase);
            successfulPurchases.add(newPurchase);
        } catch (Exception e) {
            log.error("구매 처리 실패", e);
        }
    }

    if (!successfulPurchases.isEmpty()) {
        batchInsertPurchases(successfulPurchases);
        batchInsertCheckins(successfulPurchases);
        synchronizeTicketStock();
    }
}

이 접근 방식의 주요 이점은 다음과 같습니다.

  • DB 커넥션 사용을 최소화하여 커넥션 풀 고갈 문제를 해결합니다.
  • 배치 인서트를 통해 DB I/O 작업을 줄여 전체적인 처리 속도를 향상시킵니다.
  • 실패한 작업을 재시도 할 수 있는 메커니즘을 제공합니다.
  1. 추가적인 최적화
    위의 주요 전략 외에도 다음과 같은 추가적인 최적화를 수행했습니다.
  • 캐시 활용: 자주 접근하는 데이터(예:티켓정보)를 캐싱하여 DB 부하를 줄였습니다.
  • 커넥션 풀 최적화: HikariCP 설정을 튜닝하여 DB 커넥션 관리를 개선했습니다.
  • 스레드 풀 조정: 비동기 작업을 위한 스레드 풀 크기를 최적화하여 리소스 사용을 효율화했습니다.

이러한 방안들을 통해 시스템의 처리량을 크게 향상시키고, 고부하 상황에서의 안정성을 개선할 수 있었습니다.

🔧 설계 변경

변경된 아키텍처 개요

graph TD
    A[Client] -->|HTTP Request| B[API Gateway]
    B --> C[Purchase Service]
    C --> D[Payment Service]
    D -->|Async| E[Queue]
    C -->|Async| E
    E --> F[Batch Processor]
    F --> G[Database]
    F --> H[Redis Cache]
    I[Scheduler] -->|Trigger| F
    J[Error Handler] --> E
Loading
  1. API Gateway: 클라이언트의 요청을 적절한 서비스로 라우팅합니다.
  2. Purchase Service: 구매 요청을 처리하고 결제를 시작합니다.
  3. Payment Service: 외부 결제 시스템과 통신하여 실제 결제를 처리합니다.
  4. Queue: 결제 완료된 거래를 임시 저장합니다. 안정성을 위해 log를 비동기로 파일로 저장합니다.
  5. Batch Processor: 큐에서 거래를 가져와 일괄 처리합니다.
  6. Shceduler: 주기적으로 배치 프로세서를 트리거합니다.
  7. Error Handler: 실패한 거래를 재처리합니다.

새로 추가된 컴포넌트 및 기능

  1. 인메모리 큐(Queue)
    결제가 완료된 거래를 임시 저장하는 역할을 합니다. Java의 ConcurrentLinkedQueue를 사용하여 구현하였습니다.
public class InMemoryQueue<T> implements CustomQueue<T> {
    private final ConcurrentLinkedQueue<T> queue;
    private final AtomicInteger size;

    // 구현 세부사항...
}
  1. 배치 프로세서
    큐에서 데이터를 가저와 일괄적으로 처리합니다. 이는 DB 연산을 최적화하고 커넥션 사용을 줄이는데 중요한 역할을 합니다.
@Service
public class BatchProcessor {
    @Transactional
    public void processBatch(List<PurchaseData> purchases) {
        List<Purchase> successfulPurchases = new ArrayList<>();
        for (PurchaseData purchase : purchases) {
            try {
                Purchase newPurchase = createPurchase(purchase);
                successfulPurchases.add(newPurchase);
            } catch (Exception e) {
                log.error("구매 처리 실패", e);
            }
        }

        if (!successfulPurchases.isEmpty()) {
            batchInsertPurchases(successfulPurchases);
            batchInsertCheckins(successfulPurchases);
        }
    }
    
    // 기타 메서드...
}
  1. 스케줄러
    주기적으로 배치 프로세서를 실행하여 큐에 쌓이 데이터를 처리합니다. Spring의 @Scheduled 어노테이션을 활용했습니다.
@Service
public class QueueProcessor {
    @Scheduled(fixedRate = 5000) // 5초마다 실행
    public void processPurchases() {
        List<PurchaseData> batch = queue.pollBatch(calculateOptimalBatchSize());
        if (!batch.isEmpty()) {
            batchProcessor.processBatch(batch);
        }
    }
}
  1. 에러 핸들러
    실패한 거래를 관리하고 재처리를 시도합니다. 이를 통해 시스템의 안정성과 데이터 일관성을 향상시켰습니다.
@Service
public class ErrorHandler {
    @Scheduled(fixedRate = 60000) // 1분마다 실행
    public void processErrorQueue() {
        List<PurchaseData> errorBatch = errorQueue.pollBatch(100);
        for (PurchaseData data : errorBatch) {
            try {
                purchaseService.processSinglePurchase(data);
            } catch (Exception e) {
                handleRetry(data);
            }
        }
    }
    
    private void handleRetry(PurchaseData data) {
        // 재시도 로직...
    }
}

이러한 설계를 통해 시스템의 확장성과 성능을 개선할 수 있었습니다. 특히 비동기 처리와 배치 프로세싱의 도입으로 DB 커넥션 사용을 최적화하고, 동시에 더 많은 거래를 처리할 수 있게 되었습니다.

🚀 성능 테스트

테스트 계획 수립

결과 분석