이전 게시글인 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는 대용량 환경에서 꼭 필요한 안전장치가 됩니다.
실제로 대용량 주문 처리 구조를 고민해볼 때
한 번쯤 직접 구성해보면 좋은 패턴들이라고 느꼈습니다.
'개발지식 > Backend Engineering' 카테고리의 다른 글
| Kafka Outbox 패턴으로 비동기 이벤트 발행하기 (0) | 2026.01.31 |
|---|---|
| 비동기 처리란 무엇인가?(MQ / Kafka) (0) | 2026.01.23 |
| 전략 패턴 + AOP로 정책 로직을 분리한 설계 (0) | 2025.12.28 |
| 🌿 Spring Data JPA – 반환 타입 정리 (0) | 2025.11.13 |
| 📚 Spring Data JPA Repository 제대로 써보기 (0) | 2025.11.12 |