1편에서는 Redis 캐싱을 통해 홈 API 응답 시간을 500ms에서 30ms로 단축시킨 과정을 공유했습니다. 하지만 글 마지막에서 언급했듯이, 캐싱은 어디까지나 단기 해결책이었습니다.
캐싱을 운영하면서 다음과 같은 한계를 마주했습니다.
이번 글에서는 CQRS 패턴과 Kafka 기반 이벤트 드리븐 아키텍처를 도입하여 이 문제를 근본적으로 해결한 과정을 정리하고자 합니다.
기존 홈에서 상품 리스트 조회 방식은 상품, 국가별 상품, 리뷰 정보 등 여러 테이블을 조인하는 구조였습니다
이 구조의 문제점은 아래와 같습니다.
1. 무효화 시점 파악의 어려움
1편에서 소개한 태그 기반 무효화는 효율적이었지만, 언제 무효화해야 하는지 파악하는 것이 점점 어려워졌습니다.
그 이유 중 하나는 상품 업데이트 경로가 다양하다는 점입니다.
각 경로마다 올바른 태그를 선택해서 무효화해야 하는데, 이 로직을 빠뜨리면 잘못된 데이터가 노출됩니다.
2. 여러 테이블 수정 시 캐시 무효화 누락 위험
홈 화면에는 여러 테이블의 정보가 함께 노출됩니다. 문제는 각 테이블이 서로 다른 곳에서 수정될 수 있다는 점입니다.
# 예시 1: 상품 테이블 수정
def update_product_name(product_id, new_name):
Product.objects.filter(id=product_id).update(name=new_name)
# ✅ 캐시 무효화
CacheManager.invalidate_by_tags(["ALL"])
# 예시 2: 리뷰 작성
def create_review(product_id, user_id, content, rating):
Review.objects.create(
product_id=product_id,
user_id=user_id,
content=content,
rating=rating
)
# ❌ 무효화 누락!
# → 홈 화면에 리뷰 수가 증가하지 않음
어느 하나라도 수정되면 홈 캐시를 무효화해야 하는데, 실제로는 개발자가 놓치기 쉽습니다.
실제로 겪었던 버그 상황 - QA에서 발견된 리뷰 수 문제
리뷰 기능을 새로 추가하면서, 리뷰를 작성하는 API에서 캐시 무효화를 누락했습니다. 결국 급하게 Redis CLI로 직접 접속해서 수동으로 캐시를 전체 삭제해야 했습니다.
CQRS(Command Query Responsibility Segregation)는 명령(쓰기)과 조회(읽기)의 책임을 분리하는 패턴입니다.
전통적인 방식
사용자 → API → 동일한 DB 테이블 (읽기/쓰기)
CQRS 방식
[읽기] 사용자 조회 → API → 조회 전용 테이블 (읽기 최적화)
[쓰기] 관리자 수정 → API → 원본 테이블 → 이벤트 → 조회 테이블 동기화
읽기와 쓰기 비율이 극단적으로 다름
조회 요구사항이 명확함
약간의 지연은 허용 가능
조회 성능을 최우선으로 하되, 국가별로 독립된 데이터를 효율적으로 관리할 수 있는 구조를 목표로 했습니다.
핵심 설계 포인트
반정규화 전략
국가별 데이터 관리
집계 정보 사전 계산
기존 (여러 테이블 조인)
- 실행 시간: ~200ms
CQRS 적용 후 (단일 테이블)
- 실행 시간: ~5ms
[원본 테이블 수정]
↓
[Kafka Producer - 이벤트 발행]
↓
[Kafka Topic: product_updated]
↓
[Kafka Consumer - 이벤트 처리]
↓
[현재 DB 조회 → View 테이블 업데이트]
이벤트에는 최소한의 정보만 포함합니다.
DB 기준 싱크 방식으로 설계하여 실제 데이터는 Consumer가 DB에서 직접 조회하도록 했습니다.
방식 1: 이벤트에 전체 데이터 포함
event = {
'product_id': 123,
'product_name': '상품명',
'category_name': '카테고리',
'price_th': 1000,
'price_vn': 50000,
'price_cn': 100,
'images': [...],
'tags': [...],
# ... 모든 국가의 모든 데이터
}
장점
단점
방식 2: 이벤트에 ID만 포함 (우리의 선택)
event = {
'product_ids': [123]
}
장점
1. 단순성 - 이벤트는 최소 정보만 포함
2. 효율성 - 이벤트 크기가 작음
3. 데이터 정합성 - Consumer가 항상 최신 DB 상태 기준으로 동기화
4. 유연성 - 동기화 로직 변경 시 Consumer만 수정
단점
우리 서비스에서 ID만 담는 방식을 선택한 이유
상품-국가 관계의 복잡성
동기화 빈도가 낮음
유연한 확장
단점 대응
초기에는 Consumer에서 데이터를 조회할 때 Read Replica를 사용하려 했습니다.
왜 Replica를 사용하려 했는가?
문제 발생 - Replication Lag
Timeline:
10:00:00.000 - Primary DB에 상품 가격 수정 (10,000원 → 15,000원)
10:00:00.001 - 트랜잭션 커밋
10:00:00.002 - Kafka 이벤트 발행
10:00:00.005 - Consumer가 이벤트 수신
10:00:00.006 - Consumer가 Replica DB 조회 → 아직 10,000원! (Replication Lag)
10:00:00.010 - Replica에 반영됨 (15,000원)
결과적으로 View 테이블에 이전 데이터(10,000원)가 저장되는 문제가 발생했습니다.
해결 방법 - Primary DB 직접 조회
Consumer가 Primary DB에서 직접 데이터를 조회하도록 변경했습니다.
트레이드오프
이 경험을 통해 배운 점은 다음과 같습니다.
이벤트 기반 시스템에서는 Replication Lag를 항상 고려해야 한다.
특히 Write 직후 발행된 이벤트를 처리할 때는 Primary DB 조회가 안전하다.
Consumer는 이벤트를 받으면 다음과 같이 동작합니다.
이 방식은 멱등성을 보장합니다. 같은 이벤트를 여러 번 처리해도 결과는 동일합니다.
1. 관리자가 상품 이름 수정
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 이벤트 수신
4. 해당 상품의 모든 연결된 국가 조회 (TH, VN, CN 등)
5. 각 국가별로 Primary DB 조회 후 View 테이블 업데이트
6. 홈 API는 View 테이블만 조회 → 빠른 응답
1. 태국 가격만 수정
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 모든 국가 데이터 동기화 (TH, VN, CN 등)
4. 태국은 새 가격, 다른 국가는 기존 가격 유지
1. 상품 삭제
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 데이터 조회 시도 → 상품 없음 확인
4. View 테이블에서 해당 상품의 모든 국가 데이터 삭제
5. 홈 화면에서 자동으로 노출 제외
1. 사용자가 리뷰 작성
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 이벤트 수신
4. 해당 상품의 모든 국가 조회 후 각각 동기화
5. 리뷰 집계 정보 재계산 후 View 테이블 업데이트
6. 홈 화면에 최신 리뷰 수 자동 반영 (캐시 무효화 걱정 불필요!)
[캐싱 이전]
- 평균: 200ms
- P99: 500ms
[Redis 캐싱 적용]
- 평균: 30ms (캐시 히트)
- 캐시 미스: 200ms (여전히 느림)
[CQRS 적용 후]
- 평균: 5ms (항상 빠름!)
- P99: 10ms
- 캐시 불필요
[이전] 태그 기반 캐시 무효화
- 업데이트 경로마다 무효화 로직 구현 필요
- 하나라도 누락하면 잘못된 데이터 노출
[이후] 단순한 이벤트 발행
- 어디서 수정하든 이벤트만 발행
- Consumer가 알아서 동기화
Redis 캐싱에서 CQRS + Kafka로의 전환은 단순한 성능 개선을 넘어, 아키텍처의 근본적인 개선이었습니다.
주요 성과
핵심 설계 결정
트레이드오프
이 개선 과정을 통해 얻은 가장 큰 교훈은 "빠른 해결책(캐싱)과 근본적 해결책(CQRS)을 상황에 맞게 선택하는 것"의 중요성이었습니다.