이전 게시글인 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 |