Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

31장. 캐시와 비동기 처리 — Redis · Queue · Worker · DLQ

이 장의 두 주제에는 공통점이 있다.

추가하기는 쉽고 되돌리기는 어렵다.

캐시를 붙이는 코드는 세 줄이고,
그 캐시가 만든 정합성 문제는 몇 달 뒤에 나타난다.

Agent는 세 줄을 아주 잘 쓴다.


캐시는 정합성 부채다

@Cacheable("product")
fun getProduct(id: Long): Product = productRepository.findById(id)

Agent에게 “이 조회가 느려” 라고 하면
이 코드가 나온다.

즉시 빨라진다.

⚠️ 그리고 상품 정보를 수정해도 반영되지 않는다.

캐시를 추가할 때 함께 결정해야 하는 것이 넷이다.

결정이 예시의 답
product:{id}
TTL5분
무효화 시점상품 수정·삭제 시
정합성 허용 범위최대 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는 메시지를 조용히 버리는 곳이다
  • 중복 수신과 순서 역전 테스트가 없으면 멱등성 코드는 미검증 코드다
  • 비동기 전환은 성능 개선이 아니라 결합도와 정합성의 교환이다