Skip to content

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

김현준 edited this page Sep 2, 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 커넥션 사용을 최적화하고, 동시에 더 많은 거래를 처리할 수 있게 되었습니다.

🚀 성능 테스트

테스트 계획 수립

티켓팅 시나리오

  • 3000, 5000명 기준
  1. 상세 페이지 조회
  2. 티켓 목록 조회
  3. 티켓 결제 가능 여부 조회
  4. 결제 정보 미리보기 페이지 조회
  5. 티켓 결제

결과 분석

3000명

3000 Users Comparison

5000명

5000 Users Comparison