데이터를 변경하는 기능(쓰기)과 데이터를 조회하는 기능(읽기)을 분리하는 패턴
CQRS 방식
쓰기(Command)와 읽기(Query)를 위한 서로 다른 모델을 만듭니다.
종류
| 구분 | Command Model (쓰기 모델) | Query Model (읽기 모델) |
| 목적 | 데이터의 생성, 수정, 삭제 (쓰기) | 데이터의 조회 (읽기) |
| 데이터 모델 | 복잡한 모델 객체지향적이고 풍부한 엔티티 모델 비즈니스 규칙과 로직을 포함 데이터 정합성(Consistency) 유지에 초점 |
단순한 모델 데이터베이스 스키마와 유사한 평면적(flat) 모델 조회에 필요한 데이터만 포함 성능(Performance)에 초점 |
| 주요 작업 | Command 처리 (예: CreateUserCommand, UpdateProductPriceCommand) |
Query 처리 (예: GetUserByIdQuery, GetProductListQuery) |
| 데이터베이스 | 쓰기 작업에 최적화된 DB 관계형 데이터베이스(RDB)가 주로 사용됨 단일 데이터베이스 또는 이벤트 저장소(Event Store) |
읽기 작업에 최적화된 DB RDB, NoSQL(Redis, Elasticsearch 등), 데이터 웨어하우스(DW) 등 다양하게 사용 가능 |
| 데이터 동기화 | 변경 이벤트 발생 | Command Model에서 발생한 이벤트를 비동기적으로 받아 Query Model에 반영 |
| 예상 트래픽 | 상대적으로 적음 | 상대적으로 많음 (쓰기보다 읽기 요청이 많으므로) |
| 주요 기술 | JPA, Hibernate 등 엔티티 기반의 ORM 기술 |
SQL 쿼리 직접 작성, MyBatis, 조회용 API(GraphQL) 등 |
| 장점 | 비즈니스 로직을 명확하게 분리하고 데이터 정합성을 보장하기 용이함 | 복잡한 조인을 피하고 읽기 성능을 극대화할 수 있으며, 독립적으로 확장 가능 |
| 단점 | 복잡도가 증가하고 쓰기/읽기 모델 간의 데이터 동기화 이슈 발생 가능 | 동기화 지연(Eventual Consistency)으로 인해 최신 데이터가 즉시 반영되지 않을 수 있음 |
| 대표적 예시 | 주문 생성, 사용자 등록, 재고 감소 | 상품 목록 조회, 사용자 정보 검색, 대시보드 통계 자료 |
- 쓰기(Command) 모델은 이벤트 소싱을 사용해 모든 변경 이력을 이벤트로 기록합니다.
- 읽기(Query) 모델은 이벤트 저장소에 기록된 이벤트들을 비동기적으로 읽어와서, 조회에 최적화된 형태로 데이터를 구성(Projection)하여 저장합니다.
이렇게 하면 쓰기 모델은 '이벤트 기록'이라는 단순한 작업만 하고, 읽기 모델은 '빠른 조회'라는 목적에만 집중할 수 있어 시스템의 성능과 확장성을 극대화할 수 있습니다.
예시
1. Command Model (쓰기 모델)
// Command Model - 도메인 엔티티 (비즈니스 로직 포함)
public class Employee {
private final String empId;
private String name;
private String department;
private boolean active = true; // 직원 활성 상태
public Employee(String empId, String name, String department) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("직원 이름은 필수입니다.");
}
if (department == null || department.isBlank()) {
throw new IllegalArgumentException("부서는 필수입니다.");
}
this.empId = empId;
this.name = name;
this.department = department;
}
// 부서 변경 (비즈니스 규칙 반영)
public void changeDepartment(String newDepartment) {
if (!this.active) {
throw new IllegalStateException("퇴사한 직원은 부서 변경 불가");
}
this.department = newDepartment;
}
// 퇴사 처리
public void deactivate() {
this.active = false;
}
// Getter
public String getEmpId() { return empId; }
public String getName() { return name; }
public String getDepartment() { return department; }
public boolean isActive() { return active; }
}
2. Query Model (읽기 모델)
// Query Model - 읽기 전용 Projection (RDO: Read Data Object)
public class EmployeeRdo {
private final String empId;
private final String name;
private final String department;
private final boolean active;
public EmployeeRdo(String empId, String name, String department, boolean active) {
this.empId = empId;
this.name = name;
this.department = department;
this.active = active;
}
// Getter (조회 전용)
public String getEmpId() { return empId; }
public String getName() { return name; }
public String getDepartment() { return department; }
public boolean isActive() { return active; }
}
3. Repository 인터페이스
// Command Repository (쓰기 전용)
public interface EmployeeCommandRepository {
void save(Employee employee);
void deleteById(String empId);
}
// Query Repository (읽기 전용)
public interface EmployeeQueryRepository {
EmployeeRdo findById(String empId);
List<EmployeeRdo> findAll();
}
4. Service 계층
public class EmployeeService {
private final EmployeeCommandRepository commandRepo;
private final EmployeeQueryRepository queryRepo;
public EmployeeService(EmployeeCommandRepository commandRepo, EmployeeQueryRepository queryRepo) {
this.commandRepo = commandRepo;
this.queryRepo = queryRepo;
}
// 직원 등록 (Command)
public void registerEmployee(String empId, String name, String department) {
Employee employee = new Employee(empId, name, department);
commandRepo.save(employee);
}
// 부서 변경 (Command)
public void changeDepartment(String empId, String newDept) {
EmployeeRdo empRdo = queryRepo.findById(empId);
if (empRdo == null || !empRdo.isActive()) {
throw new IllegalStateException("직원이 존재하지 않거나 퇴사 상태입니다.");
}
// 도메인 엔티티로 로드 후 변경
Employee employee = new Employee(empRdo.getEmpId(), empRdo.getName(), empRdo.getDepartment());
employee.changeDepartment(newDept);
commandRepo.save(employee);
}
// 직원 조회 (Query)
public EmployeeRdo getEmployee(String empId) {
return queryRepo.findById(empId);
}
}
5. 사용 예시
public class ExampleApp {
public static void main(String[] args) {
// 가짜 구현체 (메모리 DB)
EmployeeCommandRepository commandRepo = new InMemoryEmployeeRepository();
EmployeeQueryRepository queryRepo = new InMemoryEmployeeRepository();
EmployeeService service = new EmployeeService(commandRepo, queryRepo);
// 직원 등록 (Command)
service.registerEmployee("E001", "홍길동", "개발팀");
// 부서 변경 (Command)
service.changeDepartment("E001", "인프라팀");
// 직원 조회 (Query → RDO 반환)
EmployeeRdo rdo = service.getEmployee("E001");
System.out.println("사번:" + rdo.getEmpId() + ", 이름:" + rdo.getName() + ", 부서:" + rdo.getDepartment());
}
}
'BACKEND' 카테고리의 다른 글
| [Architecture]헥사고날 아키텍처 (Hexagonal Architecture (1) | 2025.09.04 |
|---|---|
| [java]리플렉션 (reflection) (0) | 2025.08.22 |
| [java] Spring AOP(Aspect-Oriented Programming) (0) | 2025.08.22 |
| [java]ThreadLocal 사용법 (0) | 2025.08.21 |
| [Architecture]멀티테넌시(Multi-tenancy) -Shared Schema 방식 (0) | 2025.08.21 |