CQRS (Command Query Responsibility Segregation)는 **명령(Command)**과 **조회(Query)**의 책임을 분리하는 소프트웨어 아키텍처 패턴입니다. 이 패턴은 읽기와 쓰기의 모델을 분리함으로써 확장성과 유지보수성을 향상시키는 데 목적이 있습니다.
🔹 핵심 개념
-
Command (명령)
- 데이터를 변경하는 요청 (예: 생성, 수정, 삭제)
- 예:
createUser(),updateOrderStatus()
-
Query (조회)
- 데이터를 조회하는 요청 (단순히 읽기만 함, 변경 X)
- 예:
getUserById(),getOrderListByUser()
-
책임 분리
- 쓰기 모델(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 프레임워크 활용