이커머스 API 성능 개선기 (2편) - CQRS와 Kafka로 근본적인 성능 개선하기

H_M·2026년 1월 11일

성능 개선기

목록 보기
2/2

들어가며

1편에서는 Redis 캐싱을 통해 홈 API 응답 시간을 500ms에서 30ms로 단축시킨 과정을 공유했습니다. 하지만 글 마지막에서 언급했듯이, 캐싱은 어디까지나 단기 해결책이었습니다.

캐싱을 운영하면서 다음과 같은 한계를 마주했습니다.

  • 캐시 무효화 로직의 복잡도 증가: 상품 업데이트 경로가 늘어날수록 무효화 로직도 복잡해짐
  • 캐시 미스 시 여전히 느린 응답: 캐시가 없는 첫 요청은 여전히 500ms
  • 데이터 정합성 리스크: 개발자가 무효화 시점을 놓치면 잘못된 데이터 노출
  • 근본적 문제 미해결: 여러 테이블 조인이라는 구조적 문제는 여전히 존재

이번 글에서는 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 패턴 도입 결정

CQRS란?

CQRS(Command Query Responsibility Segregation)는 명령(쓰기)과 조회(읽기)의 책임을 분리하는 패턴입니다.

전통적인 방식

사용자 → API → 동일한 DB 테이블 (읽기/쓰기)

CQRS 방식

[읽기] 사용자 조회 → API → 조회 전용 테이블 (읽기 최적화)
[쓰기] 관리자 수정 → API → 원본 테이블 → 이벤트 → 조회 테이블 동기화

우리 서비스에 CQRS가 적합한 이유

  1. 읽기와 쓰기 비율이 극단적으로 다름

  2. 조회 요구사항이 명확함

    • 홈 화면에서 필요한 데이터가 정해져 있음
    • 복잡한 동적 필터나 정렬 불필요
  3. 약간의 지연은 허용 가능

    • 상품 정보 수정 후 홈 화면 반영까지 수 초 지연은 비즈니스적으로 문제없음

View 테이블 설계

설계 원칙

조회 성능을 최우선으로 하되, 국가별로 독립된 데이터를 효율적으로 관리할 수 있는 구조를 목표로 했습니다.

핵심 설계 포인트

  1. 반정규화 전략

    • 조인을 제거하기 위해 필요한 모든 데이터를 한 테이블에 저장
    • 상품명, 카테고리명, 가격, 재고, 이미지 정보, 태그, 리뷰 집계 정보 등을 모두 포함
    • 중복 데이터가 발생하지만 조회 성능이 우선순위
  2. 국가별 데이터 관리

    • 상품ID와 국가코드를 조합한 복합 키 사용
    • 각 국가마다 독립된 레코드로 관리
    • 국가 코드 기반 인덱스로 빠른 조회 보장
  3. 집계 정보 사전 계산

    • 리뷰 수, 평균 평점 등 집계 정보를 미리 계산하여 저장
    • 조회 시 COUNT나 AVG 같은 연산 불필요

조회 성능 비교

기존 (여러 테이블 조인)
- 실행 시간: ~200ms

CQRS 적용 후 (단일 테이블)
- 실행 시간: ~5ms

Kafka 이벤트 기반 동기화 전략

아키텍처 개요

[원본 테이블 수정]
    ↓
[Kafka Producer - 이벤트 발행]
    ↓
[Kafka Topic: product_updated]
    ↓
[Kafka Consumer - 이벤트 처리]
    ↓
[현재 DB 조회 → View 테이블 업데이트]

이벤트 설계

이벤트에는 최소한의 정보만 포함합니다.

  • 무엇이 변경되었는지 (product_ids)

DB 기준 싱크 방식으로 설계하여 실제 데이터는 Consumer가 DB에서 직접 조회하도록 했습니다.

왜 ID만 담는 방식을 선택했는가?

이벤트 페이로드 전략 비교

방식 1: 이벤트에 전체 데이터 포함

event = {
    'product_id': 123,
    'product_name': '상품명',
    'category_name': '카테고리',
    'price_th': 1000,
    'price_vn': 50000,
    'price_cn': 100,
    'images': [...],
    'tags': [...],
    # ... 모든 국가의 모든 데이터
}

장점

  • Consumer가 DB 조회 불필요
  • 이벤트만으로 동기화 가능

단점

  • 이벤트 크기가 매우 커짐 (특히 국가가 많을 경우)
  • 일부만 변경되어도 전체 데이터 전송
  • 동기화 로직 변경 시 Producer도 함께 수정 필요

방식 2: 이벤트에 ID만 포함 (우리의 선택)

event = {
    'product_ids': [123]
}

장점
1. 단순성 - 이벤트는 최소 정보만 포함
2. 효율성 - 이벤트 크기가 작음
3. 데이터 정합성 - Consumer가 항상 최신 DB 상태 기준으로 동기화
4. 유연성 - 동기화 로직 변경 시 Consumer만 수정

단점

  • Consumer가 DB 조회 필요
  • DB 부하 발생 가능

우리 서비스에서 ID만 담는 방식을 선택한 이유

  1. 상품-국가 관계의 복잡성

    • 한 상품이 수십 개 국가와 연결
    • 전체 데이터를 담으면 이벤트 크기가 수십 KB 이상으로 커짐
    • 어떤 국가가 영향받는지 Producer가 판단해야 함
  2. 동기화 빈도가 낮음

    • 하루 수백 건 정도의 업데이트
    • DB 조회로 인한 부하가 미미함
  3. 유연한 확장

    • 새로운 필드 추가 시 Consumer만 수정
    • Producer는 변경 불필요

단점 대응

  • DB 조회 부하 → 동기화 빈도가 낮아 실제 부하는 미미
  • 이벤트 순서 보장 필요 → Kafka 파티션 키(product_id)로 순서 보장
  • 타이밍 이슈 가능 → 재시도 로직과 멱등성 보장으로 해결

중요한 문제점과 해결 - Replica 사용 이슈

초기에는 Consumer에서 데이터를 조회할 때 Read Replica를 사용하려 했습니다.

왜 Replica를 사용하려 했는가?

  • Primary DB 부하 분산
  • 읽기 작업이므로 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에서 직접 데이터를 조회하도록 변경했습니다.

트레이드오프

  • Primary DB 부하 증가
  • 하지만 데이터 정합성이 더 중요했음
  • 동기화 빈도가 높지 않아 (하루 수백 건) 실제 부하는 미미함

이 경험을 통해 배운 점은 다음과 같습니다.

이벤트 기반 시스템에서는 Replication Lag를 항상 고려해야 한다.
특히 Write 직후 발행된 이벤트를 처리할 때는 Primary DB 조회가 안전하다.

동기화 로직

Consumer는 이벤트를 받으면 다음과 같이 동작합니다.

  1. 이벤트에서 product_ids 추출
  2. 각 상품에 연결된 모든 국가 조회
  3. 각 국가별로 Primary DB에서 최신 데이터 조회
    • 상황에 따라 필요한 데이터만 조회
  4. View 테이블에 upsert (update or create)
  5. 만약 상품이 삭제된 경우 View 테이블에서도 삭제

이 방식은 멱등성을 보장합니다. 같은 이벤트를 여러 번 처리해도 결과는 동일합니다.

실제 동작 시나리오

시나리오 1 - 상품 이름 변경

1. 관리자가 상품 이름 수정
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 이벤트 수신
4. 해당 상품의 모든 연결된 국가 조회 (TH, VN, CN 등)
5. 각 국가별로 Primary DB 조회 후 View 테이블 업데이트
6. 홈 API는 View 테이블만 조회 → 빠른 응답

시나리오 2 - 특정 국가의 가격만 변경

1. 태국 가격만 수정
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 모든 국가 데이터 동기화 (TH, VN, CN 등)
4. 태국은 새 가격, 다른 국가는 기존 가격 유지

시나리오 3 - 상품 삭제

1. 상품 삭제
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 데이터 조회 시도 → 상품 없음 확인
4. View 테이블에서 해당 상품의 모든 국가 데이터 삭제
5. 홈 화면에서 자동으로 노출 제외

시나리오 4 - 리뷰 작성 (캐싱에서 문제가 되었던 케이스)

1. 사용자가 리뷰 작성
2. 이벤트 발행: {product_ids: [100]}
3. Consumer가 이벤트 수신
4. 해당 상품의 모든 국가 조회 후 각각 동기화
5. 리뷰 집계 정보 재계산 후 View 테이블 업데이트
6. 홈 화면에 최신 리뷰 수 자동 반영 (캐시 무효화 걱정 불필요!)

결과

성능 개선

[캐싱 이전]
- 평균: 200ms
- P99: 500ms

[Redis 캐싱 적용]
- 평균: 30ms (캐시 히트)
- 캐시 미스: 200ms (여전히 느림)

[CQRS 적용 후]
- 평균: 5ms (항상 빠름!)
- P99: 10ms
- 캐시 불필요

복잡도 개선

[이전] 태그 기반 캐시 무효화
- 업데이트 경로마다 무효화 로직 구현 필요
- 하나라도 누락하면 잘못된 데이터 노출

[이후] 단순한 이벤트 발행
- 어디서 수정하든 이벤트만 발행
- Consumer가 알아서 동기화

안정성 개선

  • 캐시 무효화 누락 위험 제거
  • View 테이블이 항상 최신 상태 유지
  • 개발자가 무효화 시점 고민 불필요
  • 여러 테이블 수정 시에도 자동으로 동기화
  • 더 이상 새벽에 급하게 캐시를 수동으로 날릴 일 없음
  • QA 단계에서 발견되던 무효화 누락 버그 완전 제거

마치며

Redis 캐싱에서 CQRS + Kafka로의 전환은 단순한 성능 개선을 넘어, 아키텍처의 근본적인 개선이었습니다.

주요 성과

  • 응답 시간 - 200ms → 5ms (97.5% 개선)
  • 안정적 성능 - 트래픽 증가와 무관하게 일정한 응답 시간
  • 단순한 코드 - 복잡한 캐시 무효화 로직 제거
  • 확장성 - 새로운 국가 추가 시에도 구조 변경 불필요

핵심 설계 결정

  • 이벤트에 ID만 포함하는 DB 기준 싱크 방식 선택
  • Replica 대신 Primary DB 조회 (Replication Lag 회피)
  • 멱등성 보장으로 안정적인 동기화 구현

트레이드오프

  • 초기 구축 비용 발생
  • 운영 복잡도 증가 (Kafka, Consumer 관리)
  • 결과적 일관성 허용 (수 초 지연)

이 개선 과정을 통해 얻은 가장 큰 교훈은 "빠른 해결책(캐싱)과 근본적 해결책(CQRS)을 상황에 맞게 선택하는 것"의 중요성이었습니다.

0개의 댓글