개발지식/Backend Engineering

Kafka, Elasticsearch, Redis 으로 대용량 주문 처리하기

우루쾅 2026. 2. 3. 17:00
728x90
반응형
SMALL

이전 게시글인 Kafka Outbox 패턴으로 비동기 이벤트 발행하기 에서는
데이터를 Outbox 테이블에 적재하고, 이를 기반으로 Kafka 이벤트를 안전하게 발행하는 구조를 정리해봤습니다.

 

이번에는 그 다음 단계로,
Kafka로 발행된 이벤트를 어떻게 소비하면 좋을지,
그리고 대용량 주문 환경에서도 안정적으로 조회하려면 어떤 구조가 좋을지를 정리해보려고 합니다.

 

이번 글에서는
Elasticsearch, Redis, 그리고 Idempotency Lock을 활용해서
주문 처리 흐름을 한 단계 더 확장해봤습니다!

.


전체 흐름 먼저 살펴보기

이번에 정리해본 전체 데이터 흐름은 아래와 같습니다.

 
PostgreSQL
 → Outbox Table
   → Kafka (order.created)
     → Kafka Consumer
       → Elasticsearch (검색 모델)
         → Redis (조회 캐시)
  • 주문 생성은 정합성이 중요한 영역이라 RDB 중심으로 처리하고
  • 조회는 성능이 중요한 영역이라 Elasticsearch와 Redis로 분리해봤습니다.
  • 중복 요청에 대해서는 Redis를 활용해 한 번 더 안전장치를 두었습니다.

1. Kafka 이벤트를 Elasticsearch에서 소비

Kafka는 이벤트를 전달하는 역할까지만 책임지고,
그 이후에 어디에 저장하고 어떻게 조회할지는 Consumer의 선택입니다.

주문 데이터를 조회하는 상황을 생각해보면,

  • 사용자별 주문 목록
  • 기간별 주문 조회
  • 상태별 필터링
  • 검색 조건 확장 가능성

같은 요구사항이 자연스럽게 생기게 됩니다.

이런 조회는 RDB 조인보다는
검색 전용 엔진인 Elasticsearch가 더 잘 어울린다고 판단해서
Kafka Consumer에서 Elasticsearch로 색인하는 구조를 사용해봤습니다.


2. Kafka → Elasticsearch 색인, Bulk 방식

Kafka Consumer는 order.created 이벤트를 받아서
Elasticsearch의 orders index에 문서를 저장합니다.

여기서 중요한 포인트는 Bulk 색인 방식입니다.

처음에는 단건 색인도 가능하지만,
이벤트가 많아질수록 다음과 같은 문제가 생길 수 있습니다.

  • 이벤트 1건당 HTTP 요청 1번
  • 네트워크 비용 증가
  • Consumer 처리 속도 저하

그래서 여러 이벤트를 묶어서 한 번에 보내는
Bulk 색인 방식을 사용해봤습니다.

@KafkaListener(topics = ["order.created"])
fun consume(events: List<OrderCreatedEvent>) {
    val documents = events.map {
        OrderDocument(
            orderId = it.orderId,
            skuId = it.skuId,
            createdAt = OffsetDateTime.now().toString()
        )
    }

    orderSearchRepository.saveAll(documents) // Bulk 색인
}
 

Kafka Consumer가 배치로 이벤트를 받고,
Elasticsearch도 배치로 처리하면서
대량 이벤트 처리에 훨씬 잘 맞는 구조가 됐습니다.


3. 조회 성능을 위해 Redis 캐시 사용

Elasticsearch는 검색에 강력하지만,
같은 조회가 반복되면 그만큼 불필요한 비용도 생깁니다.

그래서 조회 쪽에는 Cache-Aside 패턴으로
Redis를 한 번 더 붙여봤습니다.

조회 흐름은 단순합니다.

Client
 → GET /search/orders
   → Redis 조회
     - HIT  → 바로 응답
     - MISS → Elasticsearch 조회
              → Redis에 TTL 저장
              → 응답
 

간단한 예시는 아래와 같습니다.

fun searchOrders(userId: Long): List<OrderDto> {
    val cacheKey = "cache:orders:user:$userId"

    redisTemplate.opsForValue().get(cacheKey)?.let {
        return objectMapper.readValue(it)
    }

    val result = searchRepository.findByUserId(userId)

    redisTemplate.opsForValue()
        .set(cacheKey, objectMapper.writeValueAsString(result), Duration.ofSeconds(30))

    return result
}
  • Redis가 반복 조회를 흡수해주고
  • Elasticsearch는 Cache MISS일 때만 조회하게 됩니다.
  • TTL을 두어서 데이터도 너무 오래 남지 않게 해봤습니다.

4. Idempotency Lock 으로 중복 주문 방지

대용량 환경에서는
중복 요청을 완전히 피하기가 어렵습니다.

  • 네트워크 타임아웃 후 재요청
  • 사용자 더블 클릭
  • 클라이언트 재시도 로직

이런 상황에서 같은 주문이 여러 번 생성되지 않도록
Redis 기반 Idempotency Lock을 적용해봤습니다.

개념은 단순합니다.

Client
 → POST /orders (idempotencyKey)
   → Redis SETNX
     - 이미 존재 → 기존 orderId 반환
     - 새 요청 → 주문 생성 진행
 

DB 충돌에 의존하기보다는
요청 단계에서 한 번 더 걸러주는 방식이라
안정성이 훨씬 좋아졌습니다.


5. 지표로 확인해보기

이번 구조에서는
“잘 되는 것 같다”가 아니라 “숫자로 확인해보자”를 목표로 했습니다.

Spring Actuator와 Micrometer를 붙여서
다음 지표들을 직접 확인해봤습니다.

  • 주문 생성 성공 수
  • Kafka 발행 성공 수
  • Elasticsearch 색인 성공 수
  • Redis cache hit / miss
  • Outbox backlog 상태

이를 통해,

  • 주문 수 ≒ Kafka 발행 수 ≒ ES 색인 수 인지
  • 캐시 적용 후 Redis HIT 비율이 늘어났는지
  • 부하 테스트 이후에도 Outbox backlog가 정상적으로 소진되는지

를 직접 눈으로 확인할 수 있었습니다.


 

마무리하며

이전 글에서 정리했던 Outbox 패턴 + Kafka
“이벤트를 안전하게 발행하는 방법”이었다면,

이번에 정리해본 Elasticsearch, Redis, Idempotency Lock
그 이벤트를 어떻게 소비하고, 어떻게 안정적으로 제공할지에 대한 이야기였습니다.

정리해보면,

  • Kafka는 시스템을 느슨하게 분리해주고
  • Elasticsearch는 조회 전용 모델로 역할을 나누고
  • Redis는 트래픽과 중복 요청을 한 번 더 흡수해주고
  • Idempotency는 대용량 환경에서 꼭 필요한 안전장치가 됩니다.

실제로 대용량 주문 처리 구조를 고민해볼 때
한 번쯤 직접 구성해보면 좋은 패턴들이라고 느꼈습니다.

728x90
반응형
LIST