BE/Spring Boot
[Spring Boot] CQRS
셰욘
2025. 3. 29. 19:22
728x90
CQRS (Command Query Responsibility Segregation)
명령(Command)과 조회(Query)를 명확히 분리하는 아키텍처 패턴
Command
데이터를 생성(Create), 수정(Update), 삭제(Delete) 하는 작업
Query
데이터를 조회(Select)하는 작업
✅ 장점
부하분산
- 조회 요청이 많을 경우, 쿼리 서비스만 스케일업/스케일아웃하면 됨
- 커맨드와 쿼리를 같이 둔 경우, 불필요하게 커맨드 로직도 함께 리소스를 차지
장애 분리
- 쿼리에 장애가 발생했을 때도 커맨드는 계속 서비스 가능
CQRS 적용 방법
단일 프로젝트 내에서 분리
- BoardCommandService: 게시글 등록, 수정, 삭제
- BoardQueryService: 게시글 목록 및 상세 조회
프로젝트를 완전히 분리
- board-command-service: 커맨드 전용 마이크로서비스
- board-query-service: 조회 전용 마이크로서비스
Spring Boot에서 CQRS 적용 (멀티 모듈, 카프카)
board-command-service와 board-query-service 모듈을 생성한다.
💡 Board 생성 시나리오
1. 클라이언트가 HTTP로 Board 생성 요
2. Command 모듈의 Controller가 요청을 받아 Service 호출
3. Service가 Command DB에 Board 저장 후, Kafka로 "Board 생성 이벤트" 발행
4. Query 모듈이 Kafka 이벤트 수신
5. 이벤트 기반으로 Query DB에 Board 저장
💡 Board 조회 시나리오
1. 클라이언트가 HTTP로 Board 조회 요청
2. Query 모듈의 Controller가 요청을 받아 Service 호출
3. Service가 Query DB에서 Board 조회 후 반환
root project의 settings.gradle
command 서비스와 query 서비스를 등록해준다.
rootProject.name = 'cqrs'
include 'board-query-service'
include 'board-command-service'
Command, Query 모듈의 build.gradle
kafka로 통신할 거기 때문에 kafka를 추가해준다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.mariadb.jdbc:mariadb-java-client'
implementation 'org.springframework.kafka:spring-kafka'
}
Command, Query 모듈의 application.yml
카프카 서버를 등록해주고, 그룹 id와 컨슈머, 프로듀서 설정을 추가해준다.
포트번호는 서버마다 바꿔서 사용한다.
server:
port: 8082 # 포트번호 바꿔서 사용
spring:
kafka:
bootstrap-servers: [브로커 서버 IP 주소]:9092
consumer:
group-id: build-group
key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
datasource:
url: ${DB_URL}
driver-class-name: org.mariadb.jdbc.Driver
username: ${DB_USER}
password: ${DB_PASSWORD}
jpa:
database-platform: org.hibernate.dialect.MariaDBDialect
hibernate:
ddl-auto: update
properties:
hibernate:
format_sql: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
Command 모듈
Controller
생성, 수정, 삭제하는 작업에 대해 작성한다. (수정, 삭제 생략)
@RequiredArgsConstructor
@RestController
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
@PostMapping("/create")
public ResponseEntity<BoardDto.BoardRes> create(@RequestBody BoardDto.CreateReq dto) {
BoardDto.BoardRes response = boardService.create(dto);
return ResponseEntity.ok(response);
}
}
Service
카프카로 데이터 저장 이벤트를 발행한다.
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
private final KafkaTemplate<String, BoardCreatedEvent> kafkaTemplate;
public BoardDto.BoardRes create(BoardDto.CreateReq dto) {
Board board = boardRepository.save(dto.toEntity());
// 카프카로 저장 이벤트를 발행
kafkaTemplate.send("board-created", BoardCreatedEvent.of(board));
return BoardDto.BoardRes.of(board);
}
}
Event
Board 생성 시 발행되는 이벤트 (entity -> event class)
entity에 들어있는 값을 넣어서 event 클래스로 반환해준다.
@AllArgsConstructor
@Getter
@Setter
@Builder
public class BoardCreatedEvent {
private Long idx;
private String title;
private String contents;
public static BoardCreatedEvent of(Board entity) {
return BoardCreatedEvent.builder()
.idx(entity.getIdx())
.title(entity.getTitle())
.contents(entity.getContents())
.build();
}
}
Query 모듈
Controller
전체 목록 조회와 상세 조회 기능
@RequiredArgsConstructor
@RestController
@RequestMapping("/board")
public class BoardController {
private final BoardService boardService;
@GetMapping("/list")
public ResponseEntity<List<BoardDto.BoardRes>> list() {
List<BoardDto.BoardRes> response = boardService.getList();
return ResponseEntity.ok(response);
}
@GetMapping("/read/{boardIdx}")
public ResponseEntity<BoardDto.BoardRes> read(@PathVariable Long boardIdx) {
BoardDto.BoardRes response = boardService.get(boardIdx);
return ResponseEntity.ok(response);
}
}
Service
카프카로부터 저장 이벤트를 구독하고, 이벤트가 발행되면 query쪽 DB에 Board 데이터를 저장한다.
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository boardRepository;
// 카프카로부터 저장 이벤트를 구독
@Transactional
@KafkaListener(topics = "board-created", groupId = "board-group", properties = {
"spring.json.value.default.type:com.example.boardqueryservice.board.event.BoardCreatedEvent",
"spring.json.use.type.headers:false"
})
public void getBoardCreatedEvent(BoardCreatedEvent event) throws Exception {
boardRepository.save(event.toEntity());
System.out.println("게시글 생성됨");
}
public List<BoardDto.BoardRes> getList() {
List<Board> boardList = boardRepository.findAll();
return boardList.stream().map(BoardDto.BoardRes::of).toList();
}
public BoardDto.BoardRes get(Long boardIdx) {
Board board = boardRepository.findById(boardIdx).orElseThrow();
return BoardDto.BoardRes.of(board);
}
}
Event
event로 들어온 객체를 entity로 변환한다.
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
@Builder
public class BoardCreatedEvent {
private Long idx;
private String title;
private String contents;
public Board toEntity() {
return Board.builder()
.idx(idx)
.title(title)
.contents(contents)
.build();
}
}
728x90