![[Spring] CQRS 패턴](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbGK14c%2FbtsOqeaLI7s%2FOE0XmaA1th4J21k5Ul2dsK%2Fimg.png)
[Spring] CQRS 패턴Back-End/Spring2025. 6. 7. 00:05
Table of Contents
CQRS는 CUD(Command)와 R(Query)를 분리하는 것이다.
기존의 CRUD 서비스 구조는 보통 하나의 클래스나 API에서 create, read, update, delete 전부 처리한다.
CQRS는 이를 명확히 나눠서,
- Command (명령): create, update, delete
- Query (조회): read
이 두 가지를 서로 다른 책임으로 나누는 패턴이다.
CQRS 패턴을 도입하는 이유
역할이 다르기 때문
- Command는 상태를 바꾸는 작업.
- Query는 상태를 읽기만 함.
성능 최적화 가능
- 예를 들어 Command는 트랜잭션 안전성과 정합성 위주로, Query는 빠르게 결과만 제공하도록 설계할 수 있다.
코드 유지보수 편리
- 로직이 복잡해질수록 읽기와 쓰기를 분리하는 게 유리하다.
CQRS는 단순히 코드 구조만 나누는 게 아니라, DB 구조도 분리할 수 있다는 점이 핵심이다.
CQRS는 단순히 책임 분리를 넘어서 실제 아키텍처 레벨의 분산 처리를 가능하게 한다.
즉, Command → 쓰기 전용 DB, Query → 읽기 전용 DB로 분리한다.
구현 예시
애플리케이션 코드
도메인 모델 예시
@Entity
public class Post {
@Id @GeneratedValue
private Long id;
private String title;
private String content;
// 생성자, getter 생략
}
Command 서비스 – 쓰기 책임
@Service
@RequiredArgsConstructor
public class PostCommandService {
private final PostRepository postRepository;
public Long createPost(String title, String content) {
Post post = new Post(title, content);
return postRepository.save(post).getId();
}
public void updatePost(Long postId, String newTitle, String newContent) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new RuntimeException("Post not found"));
post.update(newTitle, newContent);
}
public void deletePost(Long postId) {
postRepository.deleteById(postId);
}
}
Query 서비스 – 읽기 책임
@Service
@RequiredArgsConstructor
public class PostQueryService {
private final PostRepository postRepository;
public PostResponse getPost(Long id) {
Post post = postRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Post not found"));
return new PostResponse(post.getId(), post.getTitle(), post.getContent());
}
public List<PostResponse> getAllPosts() {
return postRepository.findAll().stream()
.map(p -> new PostResponse(p.getId(), p.getTitle(), p.getContent()))
.collect(Collectors.toList());
}
}
DTO 예시 (조회 전용)
@Getter
@Builder
@AllArgsConstructor
public class PostResponse {
private Long id;
private String title;
private String content;
}
읽기/ 쓰기 DB 분리
- 쓰기 DB: insert/update/delete 등 CUD 작업 처리 (ex: master DB)
- 읽기 DB: select 등 R 작업 처리 (ex: read-replica, slave DB)
application.yml 예시
spring: datasource: hikari: maximum-pool-size: 10 custom: datasource: write: jdbc-url: jdbc:mysql://master-db:3306/mydb username: root password: rootpass read: jdbc-url: jdbc:mysql://slave-db:3306/mydb username: root password: rootpass |
DataSourceType 정의
public enum DataSourceType {
READ, WRITE
}
RoutingContext (ThreadLocal로 현재 타입 저장)
public class DataSourceContextHolder {
private static final ThreadLocal<DataSourceType> contextHolder = new ThreadLocal<>();
public static void set(DataSourceType type) {
contextHolder.set(type);
}
public static DataSourceType get() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
RoutingDataSource 구현
public class ReplicationRoutingDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return DataSourceContextHolder.get() == null ? DataSourceType.WRITE : DataSourceContextHolder.get();
}
}
DataSourceConfig 설정
@Configuration
public class DataSourceConfig {
@Value("${custom.datasource.write.jdbc-url}")
private String writeUrl;
@Value("${custom.datasource.write.username}")
private String writeUser;
@Value("${custom.datasource.write.password}")
private String writePass;
@Value("${custom.datasource.read.jdbc-url}")
private String readUrl;
@Value("${custom.datasource.read.username}")
private String readUser;
@Value("${custom.datasource.read.password}")
private String readPass;
@Bean
public DataSource writeDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(writeUrl);
dataSource.setUsername(writeUser);
dataSource.setPassword(writePass);
return dataSource;
}
@Bean
public DataSource readDataSource() {
HikariDataSource dataSource = new HikariDataSource();
dataSource.setJdbcUrl(readUrl);
dataSource.setUsername(readUser);
dataSource.setPassword(readPass);
return dataSource;
}
@Bean
public DataSource routingDataSource() {
Map<Object, Object> targetDataSources = new HashMap<>();
targetDataSources.put(DataSourceType.WRITE, writeDataSource());
targetDataSources.put(DataSourceType.READ, readDataSource());
ReplicationRoutingDataSource routingDataSource = new ReplicationRoutingDataSource();
routingDataSource.setTargetDataSources(targetDataSources);
routingDataSource.setDefaultTargetDataSource(writeDataSource());
return routingDataSource;
}
@Primary
@Bean
public DataSource dataSource() {
return new LazyConnectionDataSourceProxy(routingDataSource());
}
}
AOP 기반 readOnly 자동 설정
@Aspect
@Component
public class ReadOnlyRoutingAspect {
@Pointcut("@annotation(org.springframework.transaction.annotation.Transactional)")
public void transactionalMethods() {}
@Before("transactionalMethods() && @annotation(tx)")
public void setRoutingDataSource(Transactional tx) {
if (tx.readOnly()) {
DataSourceContextHolder.set(DataSourceType.READ);
} else {
DataSourceContextHolder.set(DataSourceType.WRITE);
}
}
@After("transactionalMethods()")
public void clearRoutingDataSource() {
DataSourceContextHolder.clear();
}
}
결과
- @Transactional(readOnly = true) → 자동으로 읽기용 DB로 전환
- 그 외에는 기본적으로 쓰기 DB 사용
- QueryService는 readOnly, CommandService는 기본으로 두면 됨
장점
- 쓰기 부하를 최소화하고 읽기 확장성 확보
- CQRS 아키텍처와 연동하기 쉬움
- 코드 수정 없이 트랜잭션 설정만으로 제어 가능
단점
- Replication 지연에 주의해야 함 (쓰기 직후 조회 시, 최신이 아닌 데이터가 조회될 수 있음)
- 트랜잭션 내에서 혼합 사용 시 일관성 깨질 수 있음
'Back-End > Spring' 카테고리의 다른 글
[Spring Boot 핵심원리와 활용] 사용자 정의 메트릭 (0) | 2025.05.11 |
---|---|
[Spring Boot 핵심원리와 활용] 마이크로미터, 프로메테우스, 그라파나 (0) | 2025.05.10 |
[Spring Boot 핵심원리와 활용] 액츄에이터(Actuator) (0) | 2025.05.09 |
[Spring Boot 핵심 원리와 활용] 외부 설정 (0) | 2025.05.03 |
[Spring Boot 핵심 원리와 활용] 자동 구성 라이브러리 제작 및 사용 (0) | 2025.05.02 |