CQRS - CQRS Study 2

 

CQRS 방법론 정리2 (2025.05 ~ 2025.12)

목차

  1. 다중 데이터 저장소 전략
  2. 실습 코드 분석
  3. 실습 환경 구성
  4. Kafka 개발 환경 이슈 해결
  5. JPA 실무 가이드
  6. 종합 정리


6. 다중 데이터 저장소 전략

6.1 다중 데이터 저장소의 목적

성능 최적화를 위한 전략

  • 같은 네트워크 내에서는 I/O 지연이 적음
  • 각 DB의 특성에 맞는 데이터 저장

조회 시간 기준

  • 전체 조회 시간: 150~200ms 이내 권장
  • Elasticsearch + MongoDB + Redis 조합 활용

사용 예시

  • 재고 데이터: Redis 캐싱으로 수십 ms 내 조회 가능
  • 검색 필터링: Elasticsearch
  • 원본 문서 조회: MongoDB



6.2 백엔드 개발 트렌드

DB 선택 시 고려사항

  • DB의 자료구조 이해 필수
  • 해당 DB 특성상 최적화 과정 파악
  • 해결하고자 하는 문제에 적합한 오퍼레이션 확인



6.3 벡터 DB 고려사항

Elasticsearch의 한계

  • 대량의 벡터 데이터 인덱싱 시 안정성 부족
  • 벡터 기반 검색에는 전용 DB 사용 권장 (예: Qdrant)

벡터 DB 특성

  • 차원 간 전환 시 부하 발생
  • 성능 최적화가 중요



6.4 Elasticsearch vs MongoDB

Elasticsearch 특성

작동 원리

  • 역색인(Inverted Index) 개념 사용
  • 문서 ID를 여러 개 엮어서 조건에 맞는 연산 수행
  • 결과 도출은 매우 빠름

장단점

작업 유형 속도
필터링 및 검색 매우 빠름 ⚡
문서 ID 찾기 매우 빠름 ⚡
개별 문서 원본 조회 느림 🐢


사용 권장 케이스

  • 초기 시스템에서 원본 데이터가 적은 경우
  • 필터링이 주된 목적인 경우



MongoDB 특성

작동 원리

  • B+Tree 구조
  • 원본 문서를 찾는 속도가 매우 빠름

장점

  • 개별 문서 조회 성능 우수
  • 문서 지향 데이터베이스



6.5 I/O 최적화 전략

네트워크 라운드 트립 고려

  • 데이터가 적은 경우: Elasticsearch 단일 조회 (1번의 라운드 트립)
  • 데이터가 많은 경우: Elasticsearch + MongoDB 조합 (2번의 라운드 트립)

결론

  • 비즈니스 특성에 맞는 다중 저장소 설계가 중요




7. 실습 코드 분석

7.1 모델 부분

스키마 관리

  • inStock과 같은 비즈니스 필드 변경 시 스키마 업데이트
  • 이벤트 스키마 무중단 교체 수행


성능 최적화 원칙

  • ❌ 쿼리 시점에 계산 금지
  • ✅ 커맨드 실행 시 계산하여 미리 DB에 저장


필드 설계 예시

  • inStock: 재고 확인용 불린 필드
  • 상세 수량 체크가 필요한 경우 별도 처리


데이터 모델링 전략

  • Product와 직접 관련 있는 내용은 반정규화
  • 참조 전략으로 JOIN 사용



7.2 비즈니스 및 서비스 부분

주요 컴포넌트

ProductDocumentOperations

  • MongoDB 문서 연산 처리


QueryService

  • Elasticsearch와 MongoDB 검색 구현
  • 캐싱 전략 적용


캐싱 전략

  • 단일 검색 결과 캐싱
  • getProduct와 같은 단일 객체 조회 시 캐싱



CDC 이벤트 처리

cdcEvent 구조

  • before: 변경 전 데이터
  • after: 변경 후 데이터
  • source: 테이블 정보


핸들러 구조

Document 핸들러

  • MongoDB용 이벤트 핸들러 모음

Search 핸들러

  • Elasticsearch용 이벤트 핸들러 모음
  • SME (SearchModelEvent) 처리

ProductSearchModelSyncer

  • 이벤트 핸들러 통합 관리
  • 큐에서 이벤트 처리 가능 여부 체크
  • 모델 업데이트 수행



7.3 디버깅 방법

브레이크 포인트 활용

  • 개별 이벤트 처리 과정 파악
  • 데이터 흐름 추적

시스템 도식화

  • 손으로 직접 시스템 구조 그리기
  • 참고 URL: https://app.excalidraw.com/l/zZ6L7Mz24A/8DJ2xINRMER




8. 실습 환경 구성

8.1 초기 서버 설정 순서

권장 브랜치

  • 2번째 브랜치 사용 권장
  • 브랜치 이동 시 설정 변경으로 에러 발생 가능
  • 각 폴더에서 개별 클론 권장


실행 순서

  1. DDL 파일 실행 (테이블 생성 및 데이터 INSERT)
    • docker-compose.yml에서 자동 실행 설정 가능
  2. docker-compose.yml 실행
  3. register.sh 실행 (Debezium CDC 커넥터 등록)
  4. 톰캣 서버 시작
  5. Kafka UI에서 이벤트 라이브 모드 확인 (선택)
  6. API 테스트


포트 정보

  • HTTP API: localhost:8080
  • Kafka UI: localhost:8989



8.2 성능 특성

병렬 처리 효과

  • INSERT 시 관련 테이블 개수만큼 이벤트 큐 생성
  • 병렬 처리로 처리 시간 감소


CQRS의 성능 이점

  • 조회 쿼리: 성능 향상 효과 제한적
  • 상태 변경(INSERT, DELETE, UPDATE): 병렬 처리로 성능 향상
  • DB를 요구사항에 따라 분리할 때 Kafka/CDC 필요


아키텍처 원칙

개념 설명
CQRS Command와 Query 책임 분리
이벤트 기반 아키텍처 비동기 이벤트 처리
Polyglot Persistence 다중 DB 전략
CDC 변경 데이터 캡처


result_and_final.png




9. Kafka 개발 환경 이슈 해결

9.1 Cluster ID 불일치 에러

에러 메시지

Invalid cluster.id in: /var/lib/kafka/data/meta.properties
Expected E-G5GZXETG2XFSGwGaxGKQ, but read CX4OIZCKQ9-v0eXstX4ubA


발생 원인

  1. Zookeeper에 등록된 Cluster ID: E-G5GZXETG2XFSGwGaxGKQ
  2. Kafka 볼륨의 Cluster ID: CX4OIZCKQ9-v0eXstX4ubA
  3. 두 ID가 불일치하여 Kafka가 시작 거부


왜 발생하는가?

  • Kafka는 브로커 초기화 시 cluster.id 고정
  • Zookeeper와 Kafka 데이터 디렉터리의 cluster.id 비교
  • 불일치 시 데이터 보호를 위해 즉시 종료



9.2 해결 방안

1️⃣ 로컬 개발 환경 (권장)

데이터 완전 초기화

# 컨테이너 중지
docker-compose down

# Kafka 데이터 볼륨 삭제
docker volume rm <kafka-volume-name>

# 바인드 마운트인 경우
rm -rf ./kafka-data

# 재실행
docker-compose up -d


2️⃣ 데이터 보존이 필요한 경우

확인 절차

# Kafka 데이터 확인
cat /var/lib/kafka/data/meta.properties
# 출력: cluster.id=CX4OIZCKQ9-v0eXstX4ubA

# Zookeeper 확인
zookeeper-shell zookeeper:2181 get /cluster/id

선택지

  • ✅ Zookeeper를 Kafka 데이터에 맞게 초기화
  • ❌ Kafka 데이터에 수동으로 cluster.id 수정 (비권장)


3️⃣ meta.properties 수동 수정 (비권장)

vi /var/lib/kafka/data/meta.properties
cluster.id=E-G5GZXETG2XFSGwGaxGKQ

⚠️ 주의: 운영 환경에서는 절대 비권장


4️⃣ 운영 환경 권장 아키텍처

[ Zookeeper (3 nodes) ]
        |
        |
[ Kafka Broker 1 ] — [ Kafka Broker 2 ] — [ Kafka Broker 3 ]

볼륨 매핑

volumes:
  - /data/kafka/broker-1:/var/lib/kafka/data
  • 운영에서는 docker volume ❌ / host bind mount ✅ 권장


5️⃣ 안전한 Kafka 재기동 (Rolling Restart)

올바른 절차

  1. Broker 1 중지
  2. Broker 1 기동
  3. ISR 정상 복귀 확인
  4. Broker 2 반복
  5. Broker 3 반복

상태 확인

kafka-topics.sh --bootstrap-server localhost:9092 --describe
# ISR에 모든 replica가 있어야 다음 브로커 진행


6️⃣ Kafka 버전 업그레이드 전략

단계 설명
1 기존 브로커 중 1대 중지
2 새 이미지로 교체
3 동일 data 디렉터리로 기동
4 정상 join 확인
5 다음 브로커 진행

절대 금지 사항

  • ❌ 기존 데이터 디렉터리에 다른 cluster.id Kafka 실행
  • ❌ 전체 브로커 동시 종료


7️⃣ Zookeeper 재구성 전략

절대 금지

  • ❌ Zookeeper 전체 삭제
  • ❌ Zookeeper 단일 노드 운영
  • ❌ Kafka보다 먼저 삭제

안전한 방식

  1. Zookeeper quorum 유지
  2. Rolling restart
  3. dataDir 유지


8️⃣ KRaft 모드 (차세대 권장)

장점

항목 ZK KRaft
cluster.id ZK 의존 자체 관리
구성 복잡도 높음 낮음
장애 포인트 많음 적음
운영 안정성 보통 높음

운영 기준

  • Kafka 3.5+ → KRaft 적극 권장
  • 신규 운영 환경이면 무조건 KRaft



9.3 분산 시스템 개발 방법론

로컬 ↔ 운영 환경 이슈

전형적인 시나리오

① 로컬 A 컴퓨터
   - docker-compose up
   - Kafka + Zookeeper 실행
   - data 볼륨 생성 (cluster.id = A)

② docker-compose down
   - 컨테이너만 종료
   - 볼륨은 그대로 남음 (중요!)

③ 다른 컴퓨터 B
   - docker-compose.yml 수정
     (DB 추가, 네트워크 구조 변경 등)
   - git commit & push

④ 다시 로컬 A 컴퓨터
   - git pull
   - docker-compose up
   
👉 이 시점에서 에러 발생


Kafka 관점에서의 동작

단계 Kafka 내부 동작
최초 실행 Zookeeper에 cluster.id 등록
Kafka data 볼륨 meta.properties에 cluster.id 저장
compose 변경 서비스 구조 재조정
재기동 Zookeeper가 “새 클러스터”로 인식
결과 기존 Kafka 데이터 ≠ 현재 Zookeeper


가장 좋은 로컬 개발 전략

docker-compose 예시

services:
  zookeeper:
    volumes:
      - zookeeper-data:/data

  kafka:
    volumes:
      - kafka-data:/var/lib/kafka/data

volumes:
  zookeeper-data:
  kafka-data:

구조 변경 시

docker-compose down -v
docker-compose up -d


실무 워크플로우

추천 루틴

1. git pull
2. docker-compose down -v  # 중요!
3. docker-compose up -d


상태 기반 분산 시스템의 공통 특성

시스템 동일 이슈
Elasticsearch cluster UUID mismatch
MongoDB ReplicaSet replicaSetId mismatch
Redis Cluster node-id conflict
etcd member ID mismatch


정리

로컬 개발

git pull
docker-compose down -v
docker-compose up -d


운영

  • 절대 down -v 금지
  • Rolling restart만 허용
  • 볼륨 수명 = 클러스터 수명




10. JPA 실무 가이드

10.1 JPA 연관관계 설정

단방향 설정 권장

기본 원칙

  • 웬만하면 JPA 단방향 설정 사용
  • @xToOne 영속성 관계에서 LAZY 설정 기본
  • 양방향 설정보다 단방향 설정 권장 (복잡성 감소)

결론

  • 실무에서는 단방향 설정 우선
  • 불가피한 경우에만 양방향 사용 및 신중한 관리


10.2 JPA FK 설정

설정 위치

  • 주인이 아닌 자식 객체에서 FK 설정

단방향 CASCADE 설정

@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "product_id")
private Product product;

@ManyToOne(fetch = FetchType.LAZY)
@OnDelete(action = OnDeleteAction.CASCADE)
@JoinColumn(name = "tag_id")
private Tag tag;


10.3 IntelliJ Ultimate 기능

JPA Entity 자동 생성

  1. DB 연결
  2. 테이블 우클릭
  3. Create JPA Entities From DB 선택

Entity Attributes 추가

  • Create JPA Entities Attributes From DB
  • 추가된 칼럼이나 FK 칼럼 자동 생성


10.4 Fetch Join

수정 전 (N+1 문제)

private List<ProductLineEntity> getProductLineEntitiesByCategory(
    Long categoryId, Pageable pageable, String keyword) {
    
    List<OrderSpecifier<?>> orderSpecifiers = getOrderSpecifiers(pageable.getSort());
    
    // ProductLine 3개 조회 시 Product 쿼리도 3번 추가 실행
    return jpaQueryFactory
        .selectDistinct(qProductLine)
        .from(qProductLine)
        .where(qProductLine.category.categoryId.eq(categoryId)
            .and(qProductLine.deletedAt.isNull())
            .and(getSearchCondition(keyword)))
        .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize() + 1)
        .fetch();
}

문제점

  • ProductLine 조회: 1개 쿼리
  • 각 ProductLine의 Product 조회: N개 쿼리
  • 총 1 + N개 쿼리 실행


수정 후 (Fetch Join 적용)

private List<ProductLineEntity> getProductLineEntitiesByCategory(
    Long categoryId, Pageable pageable, String keyword) {
    
    List<OrderSpecifier<?>> orderSpecifiers = getOrderSpecifiers(pageable.getSort());
    
    return jpaQueryFactory
        .selectDistinct(qProductLine)
        .from(qProductLine)
        .leftJoin(qProductLine.products, qProduct).fetchJoin()
        .where(qProductLine.category.categoryId.eq(categoryId)
            .and(qProductLine.deletedAt.isNull())
            .and(getSearchCondition(keyword)))
        .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0]))
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize() + 1)
        .fetch();
}

개선점

  • 쿼리 4개 → 1개로 감소
  • ProductLine과 Product를 한 번에 조회



10.5 Fetch Join 사용 시 문제점

A. 메모리 문제

증상

  • SQL LIMIT 구문 미적용
  • firstResult/maxResults가 메모리 내에서 적용됨
  • ⚠️ 경고: 컬렉션 페치와 페이지네이션 동시 사용

원인: Hibernate의 컬렉션 페치 페이지네이션

  • OneToMany, ManyToMany 관계는 조인 시 데이터 수 변경
  • 메모리 내에서 페이지네이션 적용
  • 대용량 데이터 처리 시 성능 문제

예시

ProductLine 3개 × 각각 Comment 7개 = 21개 DB Row
→ 모든 데이터를 메모리로 가져와 JPA가 페이지네이션 계산
→ OutOfMemoryError 발생 가능


B. 응답 시간 문제

성능 저하

  • 해당 카테고리의 전체 ProductLine과 Product를 메모리에 로드
  • 약 100만 건 데이터 기준: 35~40초 소요
  • 사용자 입장에서는 서버 다운으로 인식


C. 2개 이상의 1:N 관계 Fetch Join 불가

MultipleBagFetchException

  • 2개 이상의 1:N 관계 컬렉션 Fetch Join 시 발생
  • 카테시안 곱(Cartesian Product)으로 데이터 급증
  • JPA에서 의도적으로 차단

Trade-off

  • N+1 쿼리 + DB 커넥션 증가
  • vs
  • N+1 해결 + DB 커넥션 감소


D. 해결 방안: Batch Size 설정

글로벌 설정

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 1000

효과

  • N+1 문제 해결
  • 메모리 문제 방지
  • 페이지네이션 정상 작동


10.6 Projection 활용

생성자 방식

  • DTO에 쿼리 파라미터와 동일한 생성자 필요
  • @RequestParam 없이 DTO로 파라미터 수신 가능


10.7 JPAQuery 분리

Content vs Count

// Content 조회
JPAQuery<Entity> contentQuery = ...
List<Entity> content = contentQuery.fetch();

// Count 조회
JPAQuery<Long> countQuery = ...
Long total = countQuery.fetchOne();

N+1 문제 해결

  • default_batch_fetch_size 글로벌 설정 필수


10.8 Page 인터페이스 사용

PageImpl 활용

return new PageImpl<>(content, pageable, total);



11. 종합 정리

11.1 CQRS 핵심 요약

주요 개념

  1. Command와 Query 책임 분리
  2. 각 모델에 최적화된 DB 선택
  3. 이벤트 기반 비동기 동기화

성능 최적화

  • 읽기 부하 감소: 100 → 90
  • 쓰기 작업 병렬 처리
  • 다중 DB 전략으로 특화된 성능


11.2 실무 적용 체크리스트

기술 스택

  • Kafka / Zookeeper (또는 KRaft)
  • Debezium (CDC)
  • Elasticsearch (검색/필터링)
  • MongoDB (문서 저장)
  • Redis (캐싱)
  • PostgreSQL (커맨드 모델)

개발 환경

  • Docker Compose 설정
  • 로컬 개발 워크플로우 정립
  • 모니터링 도구 구성

코드 품질

  • JPA N+1 문제 해결
  • Batch Size 설정
  • 단방향 연관관계 우선
  • Fetch Join 신중하게 사용


11.3 운영 고려사항

모니터링

  • Kafka 오프셋 지연 확인
  • 쿼리 모델 동기화 시간 측정
  • 응답 시간 목표: 100~200ms

장애 대응

  • 이벤트 큐에 의한 장애 격리
  • Dead Letter Queue 구성
  • 멱등성 보장

확장성

  • 파티션 전략 수립
  • 컨슈머 그룹 관리
  • Rolling Restart 절차