본문 바로가기
Spring/MSA

펀딩 API 성능 개선기: TPS 61에서 100으로

by JuNo_12 2025. 12. 10.

들어가며

음악 크라우드펀딩 플랫폼을 개발하면서 가장 중요한 기능은 단연 '후원하기'였습니다. 사용자가 프로젝트에 후원하는 이 핵심 기능의 성능 테스트를 진행했을 때, 결과는 예상보다 좋지 않았습니다. TPS 61.6, 평균 응답 시간 3850ms. 이 글에서는 이를 TPS 100, 평균 응답 46ms까지 개선한 과정을 공유하려 합니다.

 

초기 성능, 그리고 문제의 시작

처음 부하 테스트를 돌렸을 때의 결과입니다.

요청 수: 1000건
TPS: 61.6/sec
평균 응답 시간: 3850ms
최소: 167ms
최대: 9462ms
표준편차: 1705ms

평균 응답 시간이 거의 4초에 가깝습니다. 더 심각한 것은 최대 응답 시간이 9초를 넘어간다는 점이었습니다. 저희는 AWS ECS에 배포할 계획이었고, 제한된 예산 내에서 인프라를 운영해야 했기에 애플리케이션 레벨에서의 최적화가 절실했습니다.


첫 번째 개선: 결제를 비동기로 분리하다

기존 코드가 가진 근본적인 문제

문제를 파악하기 위해 코드를 다시 살펴봤습니다. 후원하기 API는 하나의 트랜잭션 안에서 모든 것을 처리하고 있었습니다.

초기 설계에서는 펀딩 생성부터 결제 완료까지 모든 과정을 하나의 트랜잭션으로 처리했습니다.

@Transactional
public CreateFundingResult createFunding(CreateFundingCommand command) {
    // 1. Funding 생성
    Funding funding = fundingRepository.save(...);
    
    // 2. Reward 서비스 동기 호출
    StockReserveResponse response = rewardClient.reserveStock(request);
    
    // 3. Payment 서비스 동기 호출
    ProcessPaymentResponse paymentResponse = paymentClient.processPayment(request);
    
    // 4. 완료 처리
    funding.completeFunding(paymentResponse.paymentId());
    fundingProjectStatisticsService.increaseFundingStatusRate(...);
    
    return CreateFundingResult.success(funding.getId());
}

 

이 코드의 가장 큰 문제는 트랜잭션이 너무 길다는 것이었습니다. Reward 서비스와 Payment 서비스를 동기로 호출하면서, 특히 Payment는 실제 PG 연동까지 해야 했기에 응답을 기다리는 시간이 길어질 수밖에 없었습니다. 하나의 트랜잭션이 최대 15초 이상 유지되는 상황이 발생했고, DB 커넥션은 그동안 계속 점유되어 있었습니다. 더 심각한 것은 다른 도메인의 DB 작업까지 우리 트랜잭션에 포함된다는 점이었습니다. MSA 구조를 채택하고 도메인을 분리했다면, 각 서비스는 독립적으로 동작해야 합니다. 하지만 저희는 '후원'이라는 기능이 무조건 '결제'까지 완료되어야 한다고 생각했고, 그 결과 Funding 도메인이 Payment 도메인에 강하게 결합되어 버렸습니다.


도메인 경계를 다시 생각하다

문제의 본질은 도메인 경계 설계에 있었습니다.

초기 설계에서 Funding 도메인이 담당하던 책임을 정리해보니,

Funding Domain이 하던 일:

├─ 펀딩 생성 (맞음)

├─ 중복 체크 (맞음)

├─ 리워드 재고 차감

(Reward Domain 책임)

├─ 결제 처리 (Payment Domain 책임)

└─ 통계 업데이트 (맞음)

 

Funding 도메인이 너무 많은 책임을 지고 있었습니다. 각 도메인은 자신의 책임만 가져야 합니다. 저희가 목표로 했어야 할 설계는 이것이었습니다.

Funding Domain:

- 펀딩 생성 및 상태 관리

- 중복 체크

- 통계 업데이트

Reward Domain:

- 리워드 재고 관리

- 티켓 예약/취소

Payment Domain:

- 결제 처리

- 환불 처리


비동기 처리로의 전환 결제 처리를 비동기로 분리하기로 결정

사용자 관점에서 생각해보면, 후원 요청과 결제하기는 다른 플로우입니다. 새로운 플로우는 이렇게 설계했습니다.

1. POST /fundings

2. Funding 생성 (PENDING 상태) - Reward 재고 예약 - FUNDING_PAYMENT_PROCESS 이벤트 발행

3. Payment Consumer - 이벤트 소비 및 결제 처리

4-A. 성공 시 - funding-payment-success 발행 - Funding 상태: PENDING → COMPLETED

4-B. 실패 시 - funding-payment-failure 발행 - Funding 상태: PENDING → FAILED - 재고 복구 이벤트 발행

 

Kafka를 사용해서 Payment 서비스와 통신하기로 했고, 이벤트 발행 보장을 위해 Outbox 패턴을 적용했습니다.

 

Outbox 패턴을 사용한 이유는 간단합니다. 단순히 Kafka로 이벤트를 발행하면, DB 커밋은 실패했는데 이벤트는 이미 발행되는 상황이 생길 수 있습니다. Outbox에 이벤트를 같은 트랜잭션으로 저장하면, DB 커밋과 이벤트 발행의 원자성을 보장할 수 있습니다.


결제 결과는 어떻게 처리하나

Payment 서비스에서 결제를 완료하면, 결과를 다시 이벤트로 발행합니다.

@KafkaListener(topics = FUNDING_PAYMENT_SUCCESS)
public void onPaymentSuccess(String message, Acknowledgment ack) throws Exception {
    PaymentSuccessEvent event = objectMapper.readValue(message, PaymentSuccessEvent.class);
    
    // Funding 완료 처리 (PENDING → COMPLETED)
    Funding funding = fundingService.completeFunding(event.fundingId(), event.paymentId());
    
    // 리워드가 있을 때만 QR 생성 이벤트 발행
    if (funding.hasReservation()) {
        publishFundingCompletedEvent(event, funding);
    }
    
    ack.acknowledge();
}

결제 실패 시에도 마찬가지입니다. 실패 이벤트를 받으면 Funding 상태를 FAILED로 변경하고, 리워드가 있었다면 재고 복구 이벤트를 발행합니다.

비동기 처리의 효과 :
부하 테스트 결과 (1000건):

 

응답 시간이 절반으로 줄었고, TPS도 39% 향상되었습니다. Payment 서비스를 기다리지 않게 되면서 트랜잭션 시간이 대폭 줄어든 효과였습니다. 하지만 여전히 평균 응답 시간이 1.8초나 되는 것이 마음에 걸렸습니다. 저희가 목표로 한 TPS도 아직 달성하지 못했습니다. Reward 재고 예약을 여전히 동기로 처리하고 있었고, 이 부분이 전체 응답 시간의 대부분을 차지하고 있었습니다.

Reward를 왜 동기로 남겨뒀냐고요? 재고는 즉시 확인해야 하기 때문입니다. 재고가 없으면 후원 자체가 불가능하고, 사용자에게 바로 "매진" 피드백을 줘야 합니다. 만약 비동기로 처리하면, 사용자가 후원 요청을 하고 10초 뒤에 "재고 없음"을 확인하게 됩니다. 선착순 경쟁에서 이는 불공정할 수 있습니다. 하지만 재고 역시 저희가 목표한 TPS를 만족하지 못한다면 과감하게 비동기로 전환할 생각입니다.

 

 

지표 AS-IS TO-BE 개선율
평균 응답 3850ms 1839ms 52% ↓
TPS 61.6 85.6 39% ↑
표준편차 1705ms 1123ms 34% ↓ (안정성 향상)
최대 응답 9462ms 8131ms 14% ↓

두 번째 개선: 인덱스로 쿼리 최적화

병목을 찾아서

비동기 처리로 많이 개선되었지만, 여전히 목표에는 못 미쳤습니다. 다음 병목을 찾기 위해 로그를 자세히 분석했습니다. 그리고 한 가지 쿼리가 눈에 띄었습니다.

boolean alreadyFunded = fundingRepository.existsByUserIdAndProjectIdAndStatus(
    command.userId(),
    command.projectId(),
    FundingStatus.COMPLETED
);

중복 후원을 체크하는 이 쿼리가 무려 384ms나 걸리고 있었습니다. 실행된 SQL을 확인해보니 이랬습니다.

 
SELECT COUNT(*) 
FROM p_fundings 
WHERE user_id = ? 
  AND project_id = ? 
  AND status = 'COMPLETED'

인덱스가 없어서 풀 테이블 스캔을 하고 있었습니다.

 

복합 인덱스 설계

 

p_fundings 테이블에 복합 인덱스를 추가했습니다.

CREATE INDEX idx_fundings_duplicate_check 
ON p_fundings (user_id, project_id, status);

 

적용 후 성능 측정

단순한 인덱스 하나로 응답 시간이 1839ms에서 46ms로 줄었습니다. 쿼리 한 번을 최적화하는 것이 복잡한 아키텍처 변경보다 훨씬 큰 효과를 낸 순간이었습니다.


시행착오: 낙관적 락에서 비관적 락으로

낙관적 락을 시도했던 이유

프로젝트 통계를 업데이트할 때 동시성 제어가 필요했습니다. 처음에는 낙관적 락을 적용해봤습니다.

@Entity
public class FundingProjectStatistics {
    
    @Version
    private Long version;
    
    public void increaseFunding(Long amount) {
        this.currentAmount += amount;
        this.participantCount += 1;
    }
}

 

낙관적 락은 충돌이 적을 때 효율적이라고 알고 있었습니다. 하지만 실제로는 어땠을까요?

3분의 1이 충돌로 실패했습니다. 재시도 로직을 추가해도 사용자 경험이 너무 나빴습니다. 한 프로젝트에 여러 명이 동시에 후원하는 상황에서는 충돌이 빈번할 수밖에 없었습니다.

비관적 락으로 회귀

결국 비관적 락으로 돌아왔습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT fps FROM FundingProjectStatistics fps WHERE fps.projectId = :projectId")
Optional<FundingProjectStatistics> findByProjectIdWithLock(@Param("projectId") UUID projectId);

비관적 락은 조회할 때부터 락을 걸어서 다른 트랜잭션이 대기하게 만듭니다. 충돌이 빈번한 상황에서는 오히려 이쪽이 더 효율적이었습니다.

 


시행착오: HikariCP 설정 삽질기

커넥션 풀을 너무 크게 잡았던 실수

성능을 더 끌어올리고 싶어서 HikariCP 설정을 건드려봤습니다.

hikari:
  maximum-pool-size: 500  # PostgreSQL max_connections: 100

PostgreSQL의 max_connections는 100개인데, 커넥션 풀을 500개로 설정했습니다. 당연히 "too many clients already" 오류가 폭발했습니다.

적정 값을 찾는 과정

히카리 풀 설정 후 성능 측정

 

 

여기서 저는 DB 커넥션을 300개로 증가하고, 히카리풀도 그에 맞게 수정을 해서 테스트를 진행했는데요,

 

PostgreSQL max_connections를 300으로 늘리고, HikariCP도 그에 맞춰 조정했습니다. 하지만 오히려 성능이 더 떨어졌습니다. DB 커넥션이 너무 많으면 컨텍스트 스위칭 비용이 증가한다는 것을 깨달았습니다.

결국 DB 커넥션은 기본 100개로 두고, HikariCP는 80개 정도로 설정하는 것이 가장 좋았습니다. 데이터베이스 max_connections의 80% 정도를 사용하고, 나머지 20%는 여유분으로 남겨두는 것이 안정적이었습니다.


배운 점들

측정부터 하자

"추측하지 말고 측정하라"는 말이 정말 맞았습니다. 처음에는 트랜잭션이 길어서 문제라고 생각했지만, 실제로 가장 큰 효과를 낸 것은 인덱스였습니다. 로그를 보고, 프로파일링을 하고, 실제 데이터로 확인하는 과정이 중요했습니다.

간단한 것부터 시도하자

인덱스 하나 추가하는 것이 아키텍처를 변경하는 것보다 훨씬 쉽고 효과도 컸습니다. 물론 비동기 처리도 중요했지만, 순서가 있다는 것을 배웠습니다. 먼저 간단한 최적화를 시도하고, 그래도 안 되면 구조적인 변경을 고민해도 늦지 않습니다.

도구는 상황에 맞게

낙관적 락과 비관적 락, 어느 것이 더 좋다고 단정할 수 없습니다. 충돌이 적으면 낙관적 락이, 충돌이 빈번하면 비관적 락이 유리합니다. HikariCP 설정도 마찬가지입니다. 무조건 크게 잡는다고 좋은 것이 아니라, 실제 워크로드에 맞춰 조정해야 합니다.

트레이드오프를 이해하자

비동기 처리로 성능은 크게 개선되었지만, 최종 일관성과 복잡도 증가라는 대가를 치렀습니다. 사용자는 이제 후원 상태를 확인해야 하고, 저희는 분산 시스템의 복잡도를 관리해야 합니다. 하지만 이것이 저희 서비스에 맞는 선택이었다고 생각합니다.


앞으로의 개선 방향

아직 남은 병목

현재 가장 큰 병목은 Reward 재고 예약입니다. 여전히 동기로 처리하고 있고, 전체 응답 시간의 대부분을 차지합니다. Reward 서비스 자체의 성능 개선이나, Redis 캐싱을 통한 재고 확인 최적화를 고려하고 있습니다.

모니터링 강화

분산 시스템이 되면서 전체 플로우를 추적하기 어려워졌습니다. 분산 추적(Distributed Tracing)을 도입해서, 한 요청이 여러 서비스를 거치는 과정을 시각화할 필요가 있습니다. Kafka 메시지 지연도 모니터링해야 하고, Outbox 재시도가 발생하면 알림을 받아야 합니다.


마치며

펀딩 API 성능 개선은 단순히 TPS를 높이는 것을 넘어, 시스템의 병목을 이해하고 적절한 도구를 선택하는 과정이었습니다. 처음에는 막연하게 "느리다"고만 생각했지만, 하나씩 측정하고 개선하면서 어디가 문제인지 명확히 알 수 있었습니다.

가장 중요한 것은 측정이었습니다. 추측으로 코드를 고치지 말고, 데이터를 보고 판단해야 합니다. 그리고 간단한 해결책부터 시도해야 한다는 걸 느꼈습니다.