본문 바로가기

Spring

[Spring] Kafka 기반 예매 시스템 성능 개선기: MySQL 병목, Redis 재고 차감, 그리고 정합성 트레이드오프

1. 문제 상황: API는 빠른데 Consumer가 밀렸다

 

이번 프로젝트에서는 Kafka를 활용해 비동기 기반 예매 시스템을 구현했다.

초기 목표는 사용자의 예매 요청을 API 서버에서 직접 모두 처리하지 않고, Kafka 이벤트로 분리해 각 Consumer가 역할별로 처리하도록 만드는 것이었다.

초기 흐름은 다음과 같았다.

  1. Client가 예매 요청
  2. Producer API가 reservation_requested 이벤트 발행
  3. ReservationHistoryConsumer가 예약 내역을 MySQL에 저장
  4. StockConsumer가 상품 재고를 확인하고 차감
  5. StockConsumer가 stock-result 이벤트 발행
  6. ReservationHistoryConsumer가 예약 상태를 CONFIRMED 또는 FAILED로 변경

API 서버는 요청을 받은 뒤 Kafka에 이벤트를 발행하고 바로 202 Accepted를 반환했다.

덕분에 HTTP 응답 자체는 빠르게 끝났다.

하지만 k6로 부하 테스트를 해보니 실제 문제는 API 응답 시간이 아니었다.

평균 총 요청 수: 약 98,892건
평균 API RPS: 약 979.45 RPS
평균 p95 응답 시간: 약 36.49ms
평균 전체 처리 시간: 약 301.96초
평균 추가 처리 시간: 약 200.99초
평균 전체 처리 TPS: 약 327.64 TPS
 

API 응답은 빠르게 끝났지만, Kafka 뒤쪽에서 예약 내역 저장, 재고 차감, 상태 변경까지 완료되는 데 시간이 오래 걸렸다.

즉 처음에는 “API 응답 속도를 어떻게 줄일까?”라고 생각했지만, 실제 문제는 Consumer 처리 지연이었다.

결국 최적화의 목표는 다음과 같이 바뀌었다.

API 응답 시간 최적화가 아니라, Kafka Consumer가 밀리지 않도록 전체 처리량을 개선하자.

 

 

2. 1차 개선: MySQL 재고 차감을 원자 UPDATE로 변경

 

초기 구조에서 예매 요청 1건은 다음과 같은 DB 작업을 발생시켰다.

  1. 예약 내역 INSERT
  2. 상품 재고 SELECT
  3. 상품 재고 UPDATE
  4. 예약 상태 UPDATE

특히 재고 차감은 동시성 문제가 중요한 영역이었다.

이번 테스트에서는 하나의 상품에 모든 요청이 몰리는 구조였기 때문에, 하나의 상품 row에 대한 경합이 발생할 수밖에 없었다.

초기 재고 감소 로직은 다음과 같은 흐름이었다.

  1. Product 엔티티 조회
  2. 애플리케이션에서 재고 수량 확인
  3. 엔티티 필드 변경
  4. 트랜잭션 커밋 시 Dirty Checking으로 UPDATE 실행
@Transactional
public boolean stockService(Reservation event) {
    Product product = productRepository.findById(event.getProductId()).orElse(null);

    if (product == null) {
        return false;
    }

    if (product.getStock() < event.getQuantity() || event.getQuantity() <= 0) {
        return false;
    }

    product.decreaseStock(event.getQuantity());

    if (product.getStock() == 0) {
        product.setStatus(ProductStatus.CLOSED);
    }

    return true;
}

 

이 방식은 JPA를 사용할 때 자연스러운 방식이지만, 작업이 많은 상황에서는 비용이 컸다.

  • SELECT 발생
  • 엔티티 변환 비용
  • 영속성 컨텍스트 관리 비용
  • 스냅샷 저장 비용
  • Dirty Checking 비용
  • UPDATE 발생

그래서 재고 확인과 차감을 애플리케이션에서 따로 처리하지 않고, DB에서 하나의 UPDATE 쿼리로 처리하도록 변경했다.

@Override
public boolean decreaseStock(Long productId, Integer quantity, ProductStatus selling, ProductStatus closed) {

    long updateCount = jpaQueryFactory
            .update(product)
            .set(product.stock, product.stock.subtract(quantity))
            .where(
                    product.id.eq(productId),
                    product.status.eq(selling),
                    product.stock.goe(quantity)
            )
            .execute();

    return updateCount == 1L;
}

이 쿼리는 다음 조건을 모두 만족할 때만 재고를 차감한다.

  • 상품 ID가 일치한다.
  • 상품 상태가 판매 중이다.
  • 현재 재고가 주문 수량 이상이다.

기존에는 애플리케이션에서 재고를 조회하고 판단한 뒤 수정했다면, 개선 후에는 DB가 조건 검사와 재고 차감을 한 번에 원자 처리하게 했다.

기존 방식은 다음과 같았다.

기존

→ SELECT

→ 엔티티 변환

→ 영속성 컨텍스트 저장

→ 애플리케이션에서 재고 비교

→ 필드 변경

→ Dirty Checking

→ UPDATE

개선

→ UPDATE 실행

→ DB가 조건 검사

→ DB가 재고 차감

→ updateCount 반환

 

부하 테스트 결과는 다음과 같았다.

평균 총 요청 수: 약 99,000건
평균 API RPS: 약 980.65 RPS
평균 p95 응답 시간: 약 27.47ms
평균 테스트 종료 후 추가 처리 시간: 약 172.94초
평균 실제 전체 처리 시간: 약 273.87초
평균 전체 처리 TPS: 약 361.79 TPS
 

개선 전과 비교하면 다음과 같다.

항목
원자 처리 전
재고 원자 UPDATE 적용 후
평균 API RPS
약 979.45 RPS
약 980.65 RPS
평균 p95 응답 시간
약 36.49ms
약 27.47ms
테스트 종료 후 추가 처리 시간
약 200.99초
약 172.94초
실제 전체 처리 시간
약 301.96초
약 273.87초
전체 처리 TPS
약 327.64 TPS
약 361.79 TPS

실제 전체 처리 시간은 약 301.96초 → 273.87초로 줄었고, 전체 처리 TPS는 약 327.64 TPS → 361.79 TPS로 증가했다.

이를 통해 재고 차감 구간이 실제 병목 중 하나였다는 것을 확인할 수 있었다.

하지만 여전히 Consumer 처리 지연은 남아 있었다.

 

 

3. 추가 실험: 파티션 증가와 상태 UPDATE 최적화

 

재고 차감 로직을 개선한 뒤에도 전체 처리 시간이 충분히 줄지는 않았다.

그래서 Kafka 설정과 다른 DB 작업도 추가로 개선해보았다.

첫 번째 시도는 stock-result 토픽의 파티션 수를 늘리는 것이었다.

Kafka는 같은 파티션 안에서는 순서를 보장하지만, 서로 다른 파티션 간에는 병렬 처리가 가능하다.

현재 시스템에서는 예매 요청 흐름의 순서가 중요하다고 판단했기 때문에 모든 토픽의 파티션을 늘리지는 않았다.

대신 최종 결과 이벤트인 stock-result 토픽만 파티션을 3개로 늘려 테스트했다.

docker exec -it dockercompose-kafka-1 kafka-topics \
  --bootstrap-server localhost:9092 \
  --alter \
  --topic stock-result \
  --partitions 3

 

 

그리고 Consumer도 파티션 수에 맞춰 병렬 처리되도록 concurrency = "3"을 설정했다.

@KafkaListener(
        topics = "stock-result",
        groupId = "reservation-result-group",
        containerFactory = "stockResultKafkaListenerContainerFactory",
        concurrency = "3"
)
public void stockResult(KafkaEventStockResult event) {
    reservationService.stockResultV2(event);
}
 

두 번째 시도는 예약 상태 변경도 엔티티 조회 후 Dirty Checking으로 처리하지 않고, 원자 UPDATE 방식으로 변경하는 것이었다.

@Override
public boolean setReservationStatus(Long id, ReservationStatus reservationStatus) {
    long updateCount = jpaQueryFactory
            .update(reservation)
            .set(reservation.reservationStatus, reservationStatus)
            .where(reservation.id.eq(id))
            .execute();

    return updateCount == 1L;
}

 

하지만 결과는 기대만큼 좋지 않았다.

테스트
평균 전체 TPS
초기 구조
약 327.64 TPS
재고 원자 UPDATE 적용
약 361.79 TPS
stock-result 파티션 3 적용
약 332.25 TPS
예약 상태 원자 UPDATE 적용
약 340.64 TPS
모두 적용
약 356.61 TPS

가장 좋은 결과는 여러 개선을 모두 적용한 경우가 아니라, 재고 원자 UPDATE만 적용한 경우였다.

이를 통해 알 수 있었던 점은 명확했다.

병목이 아닌 구간을 병렬화하거나 최적화해도 전체 처리량은 크게 개선되지 않는다.

이번 테스트는 하나의 상품에 모든 요청이 몰리는 구조였다.

결국 전체 처리량은 Kafka 파티션 수보다는 하나의 상품 row에 대한 재고 차감 경합과 MySQL 처리량의 영향을 더 크게 받았다.

 

 

4. 2차 개선: Redis + Lua Script 도입

Kafka UI를 통해 Consumer Lag을 확인해보니 reservation-history-group에서 Lag이 증가하고 있었다.

문제가 된 Consumer는 다음과 같은 일을 수행하고 있었다.

@KafkaListener(
        topics = "reservation_requested",
        groupId = "reservation-history-group",
        containerFactory = "reservationKafkaListenerContainerFactory"
)
public void createReservation(KafkaEventReservation event) {

    Reservation reservation = new Reservation(event);

    Reservation save = reservationService.saveReservation(reservation);

    reservationKafkaTemplate.send(CREATE_TOPIC, save);
}
 

이 Consumer는 메시지 하나를 처리할 때마다 다음 작업을 수행했다.

  1. KafkaEventReservation을 Reservation 엔티티로 변환
  2. Reservation 데이터를 MySQL에 INSERT
  3. 저장된 Reservation 데이터를 다시 Kafka로 발행

핵심 비용은 MySQL INSERT였다.

초당 약 1000건의 요청이 들어오면 MySQL에도 초당 약 1000건의 INSERT가 발생했고, MySQL 저장 속도가 Kafka 메시지 유입 속도를 따라가지 못하면서 Lag이 쌓였다.

따라서 실시간 재고 처리와 MySQL 영속 저장을 분리하는 방향으로 구조를 바꾸기로 했다.

 

재고처럼 빠르게 판단해야 하는 데이터는 Redis에 두고, MySQL은 최종 예약 내역과 원본 데이터 저장 용도로 사용하는 방향이었다.

2차 구조에서는 재고 수량을 MySQL이 아닌 Redis에 저장하도록 변경했다.

기존 구조는 다음과 같았다.

  1. Client가 예매 요청
  2. Backend 8081이 reservation_requested 이벤트 발행
  3. Backend 8082가 예약 내역을 MySQL에 저장
  4. Backend 8083이 MySQL에서 재고 조회 및 차감
  5. Backend 8083이 stock-result 이벤트 발행
  6. Backend 8082가 최종 예약 상태 저장

Redis 도입 후 구조는 다음과 같았다.

  1. Client가 예매 요청
  2. Backend 8081이 Redis에서 재고 조회
  3. 재고가 있으면 reservation_requested 이벤트 발행
  4. Backend 8083이 Redis Lua Script로 재고 차감
  5. Backend 8083이 stock-result 이벤트 발행
  6. Backend 8082가 최종 예약 상태를 MySQL에 저장

재고 차감은 Redis Lua Script를 사용했다.

@Bean
public RedisScript<Long> decreaseStockScript() {
    String lua = """
            local stock = redis.call('GET', KEYS[1])
            
            if not stock then
                return -1
            end
            
            stock = tonumber(stock)
            local quantity = tonumber(ARGV[1])
            
            if stock < quantity then
                return 0
            end
            
            redis.call('DECRBY', KEYS[1], quantity)
            return 1
        """;

    DefaultRedisScript<Long> script = new DefaultRedisScript<>();
    script.setScriptText(lua);
    script.setResultType(Long.class);
    return script;
}
 

Lua Script는 다음 흐름으로 동작한다.

  1. Redis에서 현재 재고를 조회한다.
  2. 재고 정보가 없으면 -1 을 반환한다.
  3. 재고가 주문 수량보다 적으면 0을 반환한다.
  4. 재고가 충분하면 DECRBY로 차감하고 1을 반환한다.

따라서 Redis 안에서 재고 확인과 차감을 하나의 스크립트로 처리했다.

public void reservationSuccessV3(KafkaEventReservation event) {

    Long result = redisTemplate.execute(
            decreaseStockScript,
            List.of(PRODUCT_STOCK_PREFIX + event.getProductId()),
            event.getQuantity()
    );

    Reservation reservation;

    if (result == -1L) {
        reservation = new Reservation(event, ReservationStatus.PURCHASE_FAILED);
    } else if (result == 0L) {
        reservation = new Reservation(event, ReservationStatus.PURCHASE_FAILED);
    } else {
        reservation = new Reservation(event, ReservationStatus.PURCHASE_CONFIRMED);
    }

    kafkaTemplate.send(STOCK_TOPIC, reservation);
}
 

Redis 도입 결과는 다음과 같다.

항목
MySQL 기반
Redis 적용 후
평균 API RPS
약 980.65/s
약 982.39/s
평균 응답 시간
약 14.38ms
약 9.84ms
평균 p95 응답 시간
약 27.47ms
약 14.43ms
테스트 종료 후 추가 처리 시간
약 172.94초
약 61.43초
실제 전체 처리 시간
약 273.87초
약 162.43초
전체 처리 TPS
약 361.79/s
약 611.01/s

Redis 도입 후 실제 전체 처리 시간은 약 273.87초 → 162.43초로 줄었고, 전체 처리 TPS는 약 361.79/s → 611.01/s로 증가했다.

처리량만 보면 가장 큰 개선이었다.

하지만 이 구조에도 문제가 있었다.

 

 

5. Redis 구조의 한계: 조회와 차감 시점 분리

 

2차 구조에서 Redis Lua Script 자체는 원자적으로 동작했다.

Redis 재고가 음수가 되거나 초과 차감되는 문제는 막을 수 있었다.

하지만 문제는 다른 곳에 있었다.

최초 요청을 받는 Backend 8081은 Redis 재고를 단순 조회하고, 재고가 있다고 판단하면 reservation_requested 이벤트를 발행했다.

실제 재고 차감은 이후 Backend 8083 Stock Consumer가 이벤트를 소비한 뒤 Lua Script로 수행했다.

다음 두 시점이 분리되어 있었다.

  1. API 서버가 “예약 가능”하다고 판단하는 시점
  2. Stock Consumer가 실제 Redis 재고를 차감하는 시점

예를 들어 Redis 재고가 1개 남아있다고 가정하자.

현재 Redis 재고: 1개

요청 A → Redis 재고 조회: 1개 있음 → reservation_requested 발행
요청 B → Redis 재고 조회: 1개 있음 → reservation_requested 발행
요청 C → Redis 재고 조회: 1개 있음 → reservation_requested 발행
 

이 시점에서는 아직 실제 차감이 일어나지 않았기 때문에 여러 요청이 모두 “재고 있음”으로 판단될 수 있다.

이후 Stock Consumer가 이벤트를 처리하면 실제로는 하나만 성공하고 나머지는 실패한다.

요청 A → Redis 재고 차감 성공
요청 B → Redis 재고 차감 실패
요청 C → Redis 재고 차감 실패
 

Redis Lua Script는 초과 차감을 막아주지만, API 서버가 사용자의 요청을 받아들이는 시점에는 아직 최종 성공 여부가 확정되지 않았다.

따라서 2차 구조의 문제는 Redis의 원자성 문제가 아니었다.

문제는 예약 가능 판단 시점과 실제 재고 차감 시점이 분리되어 있다는 것이었다.

재고처럼 동시성 제어가 중요한 데이터는 다음 작업이 하나의 흐름으로 처리되어야 한다.

  1. 현재 재고 확인
  2. 재고 충분 여부 판단
  3. 재고 차감
  4. 성공 또는 실패 결과 반환

그래서 3차 개선에서는 최초 요청을 받는 Backend 8081에서 Redis 재고를 단순 조회하지 않고, Lua Script를 통해 재고 확인과 차감을 동시에 수행하도록 변경했다.

 

 

6. 3차 개선: Redis 빠른 차감 + MySQL 원본 재고 반영

 

3차 구조에서는 서버 역할을 더 명확히 나누었다.

기존 구조는 다음과 같았다.

  • Backend 8081: 재고 조회 후 이벤트 발행
  • Backend 8083: 재고 차감 후 결과 이벤트 발행
  • Backend 8082: 결과 이벤트를 통해 예매 내역 저장

개선 후에는 다음과 같이 나누었다.

  • Backend 8081: Redis 재고 차감과 예약 내역 관리
  • Backend 8082: MySQL 원본 재고 관리

최종 흐름은 다음과 같다.

  1. Client가 예매 신청
  2. Backend 8081이 Redis Lua Script로 재고 확인 및 차감
  3. Redis 차감 성공 시 예매 내역을 MySQL에 저장
  4. reservation_requested 이벤트 발행
  5. Client에게 예매 결과 반환
  6. Backend 8082가 reservation_requested 이벤트 구독
  7. MySQL 원본 재고 감소
  8. stock-result 이벤트 발행
  9. Backend 8081이 stock-result 이벤트 구독
  10. 예매 내역 최종 상태 업데이트

초기 구조와 달라진 점은 API 서버에서 단순히 이벤트만 발행하지 않는다는 것이다.

초기 구조는 빠른 응답을 위해 이벤트 발행 후 202 Accepted를 반환했다.

반면 최종 구조에서는 사용자에게 더 명확한 성공/실패 결과를 제공하기 위해, API 서버에서 Redis 재고 차감과 예약 내역 저장까지 수행한 뒤 응답하도록 변경했다.

즉, 초기 구조는 응답 속도 중심의 비동기 구조였고, 최종 구조는 일부 동기 처리를 포함하더라도 재고 정합성과 사용자 응답의 정확성을 우선한 구조이다.

다만 MySQL 원본 재고 반영까지 고려하면서 새로운 문제가 발생했다.

 

 

7. 트러블슈팅: Virtual Thread + @Async와 HikariCP 고갈

 

3차 구조에서 MySQL 원본 재고 감소는 Redis 재고 차감보다 상대적으로 느린 작업이라고 판단했다.

Redis에서 이미 재고 차감은 완료되었기 때문에, MySQL 원본 재고 감소는 뒤에서 비동기로 처리해도 괜찮을 것이라고 생각했다.

그래서 @Async를 사용해 비동기 처리를 시도했다.

@KafkaListener(
        topics = "reservation_requested",
        groupId = "stock-group"
)
public void reservationSuccessV4(KafkaEventReservationRequest event) {

    productService.stockServiceV4(event);

    KafkaEventStockResult result =
            new KafkaEventStockResult(
                    event.getReservationId(),
                    ReservationStatus.PURCHASE_CONFIRMED
            );

    kafkaTemplateV2.send(STOCK_TOPIC, result);
}
@Async
@Transactional
public void stockServiceV4(KafkaEventReservationRequest event) {

    boolean decreaseStockCheck = productRepository.decreaseStock(
            event.getProductId(),
            event.getQuantity(),
            ProductStatus.SELLING,
            ProductStatus.CLOSED
    );

    if (!decreaseStockCheck) {
        KafkaEventStockResult result =
                new KafkaEventStockResult(
                        event.getReservationId(),
                        ReservationStatus.PURCHASE_FAILED
                );

        kafkaTemplateV2.send(STOCK_TOPIC, result);
    }
}

 

하지만 부하 테스트 중 다음 에러가 발생했다.

Caused by: java.sql.SQLTransientConnectionException:
HikariPool-1 - Connection is not available,
request timed out after 30003ms
(total=10, active=10, idle=0, waiting=17807)

 

  • total = 10 → HikariCP 커넥션 풀의 전체 커넥션 수가 10개
  • active = 10 → 10개 커넥션이 모두 사용 중
  • idle = 0 → 사용 가능한 유휴 커넥션이 없음
  • waiting = 17807 → 커넥션을 얻기 위해 대기 중인 요청이 17,807개

@Async와 Virtual Thread를 사용하면 작업 자체는 많이 만들 수 있다.

따라서 DB 커넥션 수가 함께 늘어나는 것은 아니다.

 

결국 상황은 다음과 같았다.

비동기 작업 수: 매우 많음 DB 커넥션 수: 10개 결과: 커넥션 풀 고갈

병목이 Thread가 아니라 DB 커넥션 풀이라면 Virtual Thread는 해결책이 될 수 없었다.

 

또 다른 문제도 있었다.

비동기로 MySQL 원본 재고를 감소시키면서, 원본 재고 감소 결과가 확정되기 전에 성공 이벤트가 먼저 발행될 수 있었다.

즉 다음 문제가 발생했다.

  1. @Async 작업이 과도하게 생성되어 DB 커넥션 풀이 고갈됨
  2. MySQL 원본 재고 감소 성공 여부가 확정되기 전에 성공 이벤트가 발행됨

예매 시스템에서는 처리 속도보다 재고 정합성과 이벤트 순서 보장이 더 중요했다.

그래서 @Async를 제거하고, MySQL 원본 재고 감소 결과를 확인한 뒤 stock-result 이벤트를 발행하도록 변경했다.

 

 

8. 최종 결과와 회고

 

최종 구조의 부하 테스트 결과는 다음과 같았다.

항목
평균
총 요청 수
약 97,755건
API RPS
약 967.86/s
평균 응답 시간
약 26.86ms
p95 응답 시간
약 98.37ms
테스트 종료 후 추가 처리 시간
약 155.66초
실제 전체 처리 시간
약 256.66초
전체 처리 TPS
약 380.88/s

물론 2차 Redis 구조와 비교하면 최종 구조의 TPS는 낮아졌다.

2차 Redis 구조: 약 611.01 TPS

3차 최종 구조: 약 380.88 TPS

순수 처리량만 보면 약 37.7% 감소했다.

 

현재 2차 구조는 예약 가능 판단 시점과 실제 재고 차감 시점이 분리되어 있었다.

반면 최종 구조는 Redis 재고 차감, MySQL 원본 재고 반영, 예약 상태 이벤트 발행의 순서를 더 명확히 했다.

전체 결과를 정리하면 다음과 같다.

단계
핵심 변경
전체 처리 TPS
결과
초기 구조
Kafka 기반 비동기 처리, MySQL 조회/저장/수정 반복
약 327.64/s
API 응답은 빠르지만 Consumer 처리 지연 발생
1차 개선
재고 차감을 DB 원자 UPDATE로 변경
약 361.79/s
전체 처리 TPS 약 10.4% 증가
추가 실험
stock-result 파티션 증가
약 332.25/s
병목 구간이 아니라 큰 효과 없음
추가 실험
예약 상태 원자 UPDATE
약 340.64/s
일부 개선됐지만 핵심 병목은 아님
2차 개선
Redis 도입, Lua Script로 재고 원자 차감
약 611.01/s
가장 높은 처리량을 보임
3차 개선
Redis 빠른 차감 + MySQL 원본 재고 반영
약 380.88/s
성능은 감소했지만 정합성과 이벤트 순서 강화
트러블슈팅
Virtual Thread + @Async 시도
-
HikariCP 커넥션 풀 고갈 발생
최종 해결
@Async 제거, 원본 재고 감소 결과 확인 후 이벤트 발행
약 380.88/s
재고 정합성과 이벤트 순서 보장

이번 개선 과정에서 가장 크게 배운 점은 병목은 추측이 아니라 측정으로 찾아야 한다는 점이었다.

처음에는 API 응답 시간이 문제라고 생각했지만, 실제 병목은 Kafka 뒤쪽 Consumer와 MySQL 처리량에 있었다.

또한 가장 빠른 구조가 항상 가장 좋은 구조는 아니었다.

 

Redis만 사용한 2차 구조가 가장 높은 TPS를 보였지만, 예매 시스템에서는 초과 예약 방지와 재고 정합성이 더 중요했다.

결국 최종 구조는 최고 성능을 내는 구조가 아니라, 충분한 성능을 유지하면서도 데이터 정합성을 더 잘 보장하는 구조를 선택한 결과였다.

 

이번 프로젝트를 통해 Kafka, Redis, Virtual Thread 같은 기술은 각각 강력한 장점을 가지고 있지만, 기술 자체가 시스템의 안정성과 성능을 자동으로 보장해주지는 않는다는 것을 배웠다.

빠른 시스템을 만드는 것만큼 중요한 것은, 빠르게 처리해도 데이터가 틀어지지 않는 구조를 만드는 것이었다.


GitHub