31장. 캐시와 비동기 처리 — Redis · Queue · Worker · DLQ
이 장의 두 주제에는 공통점이 있다.
추가하기는 쉽고 되돌리기는 어렵다.
캐시를 붙이는 코드는 세 줄이고,
그 캐시가 만든 정합성 문제는 몇 달 뒤에 나타난다.
Agent는 세 줄을 아주 잘 쓴다.
캐시는 정합성 부채다
@Cacheable("product")
fun getProduct(id: Long): Product = productRepository.findById(id)
Agent에게 “이 조회가 느려” 라고 하면
이 코드가 나온다.
즉시 빨라진다.
⚠️ 그리고 상품 정보를 수정해도 반영되지 않는다.
캐시를 추가할 때 함께 결정해야 하는 것이 넷이다.
| 결정 | 이 예시의 답 |
|---|---|
| 키 | product:{id} |
| TTL | 5분 |
| 무효화 시점 | 상품 수정·삭제 시 |
| 정합성 허용 범위 | 최대 5분 지연 허용 |
네 번째가 도메인 판단이다.
상품 이름은 5분 늦어도 되지만
재고와 가격은 그렇지 않다.
이 데이터를 캐시하려고 해.
먼저 물어볼게:
- 이 값이 바뀌는 경로를 전부 찾아줘
- 각 경로에서 무효화가 필요한지 판단해줘
- 몇 초까지 옛 값이 보여도 되는지는 내가 정할게
첫 번째 요구가 핵심이다.
🔥 캐시 사고의 대부분은
무효화를 안 한 경로가 하나 남아서 생긴다.
Agent는 그 경로를 전부 찾는 일을 잘한다.
로컬 캐시는 분산 환경에서 위험하다
28장의 전제가 여기서도 작동한다.
private val cache = ConcurrentHashMap<Long, Product>()
인스턴스가 2대면 캐시도 2벌이다.
한쪽에서 무효화해도 다른 쪽은 옛 값을 준다.
# CLAUDE.md
- 캐시는 Redis 를 쓴다. 애플리케이션 로컬 캐시를 새로 만들지 않는다
(인스턴스가 여러 대이므로 무효화가 전파되지 않는다)
캐시 스탬피드
TTL이 만료되는 순간 요청이 몰리면
전부 DB로 간다.
캐시 만료 → 100개 요청 동시 도착 → DB에 100개 쿼리
Agent는 이 문제를 묻기 전에는 다루지 않는다.
트래픽이 큰 키에서만 신경 쓰면 된다.
이 캐시 키에 요청이 몰릴 가능성이 있어.
만료 시점에 DB로 몰리지 않게 하려면 어떤 방법이 있어?
각각의 복잡도와 함께 알려줘.
메시지는 세 가지를 보장하지 않는다
비동기 처리로 넘어간다.
Agent가 만드는 Consumer는 대개 이렇게 생겼다.
@KafkaListener(topics = ["order-cancelled"])
fun handle(event: OrderCancelledEvent) {
pointService.refund(event.orderId, event.point)
}
논리적으로 맞다.
그리고 메시지 시스템은 세 가지를 보장하지 않는다.
| 보장 안 되는 것 | 결과 |
|---|---|
| 한 번만 도착 | 포인트가 두 번 환급된다 |
| 순서대로 도착 | 취소가 결제보다 먼저 온다 |
| 반드시 도착 | 환급이 누락된다 |
1장부터 따라온 그 버그가
여기서 또 다른 형태로 나타난다.
Consumer는 멱등해야 한다
30장의 원칙이 그대로 적용된다.
@KafkaListener(topics = ["order-cancelled"])
fun handle(event: OrderCancelledEvent) {
if (pointHistoryRepository.existsByEventId(event.id)) return
pointService.refund(event.orderId, event.point, event.id)
}
처리 이력을 남기고 중복을 걸러낸다.
- 모든 Consumer 는 멱등해야 한다
이벤트 ID 또는 비즈니스 키로 중복 처리를 방어한다
- 이벤트 처리는 재시도를 전제로 작성한다
⚠️ 이 규칙이 없으면 Agent는
“메시지는 한 번 온다” 를 전제로 코드를 쓴다.
로컬 테스트에서는 실제로 한 번 온다.
순서와 유실
나머지 두 가지도 처리 방법이 있다.
| 문제 | 대처 |
|---|---|
| 순서 뒤바뀜 | 버전·타임스탬프 비교 후 과거 이벤트 무시 |
| 유실 | DLQ + 재처리, 정합성 배치 |
DLQ를 만들 때 함께 정할 것이 있다.
DLQ에 쌓인 메시지는 누가 언제 보는가.
🔥 이 질문에 답이 없는 DLQ는
메시지를 조용히 버리는 곳이다.
Agent에게 DLQ를 만들라고 하면 만든다.
모니터링과 재처리 절차까지는 만들지 않는다.
비동기는 테스트하기 어렵다
그래서 테스트 전략을 미리 정해둔다.
| 대상 | 방법 |
|---|---|
| 이벤트 발행 | 발행됐는지만 검증 |
| Consumer 로직 | 이벤트 객체를 직접 넣어 호출 |
| 중복 처리 | 같은 이벤트를 두 번 넣기 |
| 순서 역전 | 나중 이벤트를 먼저 넣기 |
| 전체 흐름 | Embedded Kafka 또는 Testcontainers |
세 번째와 네 번째가 이 장의 핵심 테스트다.
@Test
fun `같은 취소 이벤트가 두 번 와도 환급은 한 번만 발생한다`() {
val event = OrderCancelledEvent(id = "evt-1", ...)
handler.handle(event)
handler.handle(event) // 중복 수신
assertThat(pointHistories).hasSize(1)
}
⚠️ 이 테스트가 없으면
멱등성 코드는 검증된 적 없는 코드다.
30장과 같은 결론이다.
동기와 비동기의 선택
마지막으로 짚을 것이 하나 있다.
Agent에게 “이 처리를 비동기로 바꿔줘” 라고 하면
아주 잘 바꿔준다.
그런데 비동기로 바꾸는 순간
이 장의 모든 문제가 따라온다.
비동기로 바꾸기 전에 확인하자.
- 이 처리가 실패하면 사용자가 알아야 하는가
- 순서가 중요한가
- 결과를 언제까지 보장해야 하는가
셋 다 아니면 비동기가 맞고,
하나라도 해당하면 동기로 두는 게 낫지 않을까?
비동기는 성능 개선이 아니라
결합도와 정합성의 교환이다.
이 판단은 사람이 한다.
이 장의 핵심
- 캐시와 비동기는 추가하기 쉽고 되돌리기 어렵다
- 캐시는 키·TTL·무효화 시점·허용 지연 넷을 함께 정해야 한다
- 캐시 사고의 대부분은 무효화가 안 된 경로 하나가 남아서 생긴다
- 값이 바뀌는 경로를 전부 찾는 일은 Agent가 잘한다
- 로컬 캐시는 인스턴스가 여러 대면 무효화가 전파되지 않는다
- 메시지는 한 번만·순서대로·반드시 도착하지 않는다
- Consumer는 멱등해야 한다 — 로컬 테스트에서는 메시지가 한 번 온다
- 누가 언제 보는지 정하지 않은 DLQ는 메시지를 조용히 버리는 곳이다
- 중복 수신과 순서 역전 테스트가 없으면 멱등성 코드는 미검증 코드다
- 비동기 전환은 성능 개선이 아니라 결합도와 정합성의 교환이다