Pink Spider/CQRS

Created Fri, 09 May 2025 09:20:45 +0900 Modified Mon, 08 Dec 2025 08:41:47 +0900
1656 Words 8 min

CQRS (Command Query Responsibility Segregation)는 **명령(Command)**과 **조회(Query)**의 책임을 분리하는 소프트웨어 아키텍처 패턴입니다. 이 패턴은 읽기와 쓰기의 모델을 분리함으로써 확장성과 유지보수성을 향상시키는 데 목적이 있습니다.


🔹 핵심 개념

  1. Command (명령)

    • 데이터를 변경하는 요청 (예: 생성, 수정, 삭제)
    • 예: createUser(), updateOrderStatus()
  2. Query (조회)

    • 데이터를 조회하는 요청 (단순히 읽기만 함, 변경 X)
    • 예: getUserById(), getOrderListByUser()
  3. 책임 분리

    • 쓰기 모델(Command Model): 도메인 로직과 유효성 검사를 포함
    • 읽기 모델(Query Model): 주로 DTO로 구성되며, 성능 최적화를 위한 전용 구조를 가짐 (예: denormalized view)

🔹 전통적인 CRUD 방식과의 차이점

항목 전통적 CRUD CQRS
모델 공유 읽기/쓰기 동일한 모델 사용 읽기와 쓰기 모델 분리
확장성 제한적 읽기/쓰기 각각 확장 가능
복잡도 낮음 상대적으로 높음
성능 최적화 어렵다 읽기에 최적화된 구조 가능

🔹 사용 예시

  • 이커머스 시스템: 주문은 복잡한 비즈니스 로직이 많고, 주문 내역 조회는 빠른 응답이 중요할 때
  • 마이크로서비스: 서비스별 책임 분리를 할 때 Command/Query 모델을 각각 다른 서비스로 분리 가능

🔹 장점

  • 확장성 향상: 읽기와 쓰기를 독립적으로 확장 가능
  • 성능 최적화 용이: 조회 전용 모델을 캐시나 비정규화된 DB로 구성 가능
  • 복잡한 도메인 모델의 단순화: Command 모델에만 복잡한 도메인 규칙을 적용 가능

🔹 단점

  • 복잡도 증가: 코드 구조와 운영 환경이 복잡해짐
  • 데이터 일관성 처리 필요: Command/Query 모델 간 동기화 필요 (이벤트 기반 처리 등)
  • 러닝 커브: 도입 시 개발자의 이해도 요구

🔹 CQRS와 Event Sourcing의 조합

CQRS는 종종 Event Sourcing과 함께 사용됩니다.

  • Event Sourcing: 상태 변경을 이벤트로 저장 → 상태 재구성 가능
  • 함께 사용 시 변경 사항 추적, 감사 로그, 재현 등에 강력

여기서는 Java Spring Boot 기반의 간단한 CQRS 예제를 보여드리겠습니다. 예제 시나리오는 “사용자(User) 등록 및 조회” 입니다.


💡 구조 개요

  • UserCommandService → 사용자 등록 (Command)
  • UserQueryService → 사용자 조회 (Query)
  • User 엔티티는 Command 모델에만 존재
  • 조회용 UserView는 별도 모델

1. 📦 도메인 및 Command 모델

User.java (엔티티)

@Entity
public class User {
    @Id
    @GeneratedValue
    private Long id;

    private String name;
    private String email;
}

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {}

UserCommandService.java

@Service
public class UserCommandService {
    private final UserRepository userRepository;

    public UserCommandService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public Long registerUser(String name, String email) {
        User user = new User();
        user.setName(name);
        user.setEmail(email);
        userRepository.save(user);
        return user.getId();
    }
}

2. 📦 Query 모델

UserView.java (조회용 DTO)

public record UserView(Long id, String name, String email) {}

UserQueryService.java

@Service
public class UserQueryService {
    private final UserRepository userRepository;

    public UserQueryService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public List<UserView> getAllUsers() {
        return userRepository.findAll().stream()
                .map(user -> new UserView(user.getId(), user.getName(), user.getEmail()))
                .toList();
    }
}

3. 📦 Controller (API 진입점)

@RestController
@RequestMapping("/users")
public class UserController {

    private final UserCommandService commandService;
    private final UserQueryService queryService;

    public UserController(UserCommandService commandService, UserQueryService queryService) {
        this.commandService = commandService;
        this.queryService = queryService;
    }

    @PostMapping
    public ResponseEntity<Long> createUser(@RequestBody Map<String, String> payload) {
        Long id = commandService.registerUser(payload.get("name"), payload.get("email"));
        return ResponseEntity.ok(id);
    }

    @GetMapping
    public ResponseEntity<List<UserView>> getUsers() {
        return ResponseEntity.ok(queryService.getAllUsers());
    }
}

📌 요약

  • 쓰기 요청UserCommandService를 통해 처리
  • 읽기 요청UserQueryService를 통해 조회 전용 DTO로 처리
  • 두 책임을 분리하여 변경 시 영향 최소화

Kafka 기반 CQRS 예제는 Command 처리 결과를 이벤트로 Kafka에 발행하고, Query 측에서는 Kafka 이벤트를 수신해 읽기 모델(Read DB)을 갱신하는 구조입니다.


🎯 예제 시나리오

  • 사용자를 등록하면 UserCreatedEvent가 Kafka로 발행됨
  • 조회 서비스는 Kafka에서 해당 이벤트를 수신해 읽기용 DB를 업데이트
  • Command/Query는 서비스 또는 DB도 분리 가능

🏗️ 구조도 (CQRS + Kafka)

[POST /users] ──▶ Command API ──▶ Write DB (User)
                          │
                          ▼
                 Kafka Producer (UserCreatedEvent)
                          │
                          ▼
                Kafka Topic: "user-events"
                          │
                          ▼
                Kafka Consumer (Query Service)
                          │
                          ▼
                  Read DB (UserView Table)
                          │
                          ▼
                [GET /users] ◀── Query API

⚙️ Command 서비스 (Spring Boot)

1. UserCreatedEvent.java

public record UserCreatedEvent(Long id, String name, String email) {}

2. Kafka 설정 (application.yml)

spring:
  kafka:
    bootstrap-servers: localhost:9092
    producer:
      key-serializer: org.apache.kafka.common.serialization.StringSerializer
      value-serializer: org.springframework.kafka.support.serializer.JsonSerializer

3. UserCommandService.java

@Service
public class UserCommandService {
    private final UserRepository userRepository;
    private final KafkaTemplate<String, UserCreatedEvent> kafkaTemplate;

    public UserCommandService(UserRepository userRepository, KafkaTemplate<String, UserCreatedEvent> kafkaTemplate) {
        this.userRepository = userRepository;
        this.kafkaTemplate = kafkaTemplate;
    }

    public Long createUser(String name, String email) {
        User user = new User();
        user.setName(name);
        user.setEmail(email);
        userRepository.save(user);

        // Kafka 이벤트 발행
        UserCreatedEvent event = new UserCreatedEvent(user.getId(), name, email);
        kafkaTemplate.send("user-events", event);

        return user.getId();
    }
}

🔍 Query 서비스 (Kafka Consumer + Read DB)

1. Kafka Consumer 설정 (application.yml)

spring:
  kafka:
    bootstrap-servers: localhost:9092
    consumer:
      group-id: user-view-service
      key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
      value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
      properties:
        spring.json.trusted.packages: '*'

2. Read Model Entity (UserView.java)

@Entity
public class UserView {
    @Id
    private Long id;
    private String name;
    private String email;
}

3. Consumer + Read DB 저장

@Service
public class UserEventConsumer {

    private final UserViewRepository userViewRepository;

    public UserEventConsumer(UserViewRepository userViewRepository) {
        this.userViewRepository = userViewRepository;
    }

    @KafkaListener(topics = "user-events", groupId = "user-view-service")
    public void consume(UserCreatedEvent event) {
        UserView userView = new UserView();
        userView.setId(event.id());
        userView.setName(event.name());
        userView.setEmail(event.email());
        userViewRepository.save(userView);
    }
}

4. UserViewController.java (조회 API)

@RestController
@RequestMapping("/users")
public class UserViewController {
    private final UserViewRepository repository;

    public UserViewController(UserViewRepository repository) {
        this.repository = repository;
    }

    @GetMapping
    public List<UserView> findAll() {
        return repository.findAll();
    }
}

✅ 장점

  • 읽기/쓰기 완전 분리 (마이크로서비스로도 분리 가능)
  • 비동기 이벤트 기반 확장성
  • Read DB는 조회 최적화 구조로 설계 가능

⚠️ 주의사항

  • 이벤트 중복 수신 대비 (idempotency) 로직 필요
  • Read DB eventual consistency (최종 일관성) 수용
  • Kafka 장애 처리/재시도/오류 큐 등 운영 고려 필요

🔧 확장 아이디어

  • Kafka 대신 NATS 또는 RabbitMQ 사용
  • Outbox Pattern + Debezium으로 변경 이벤트 감지
  • Elasticsearch 기반 Read View
  • Axon Framework / Eventuate 등 CQRS 프레임워크 활용