BACKEND

[Architectural Pattern] CQRS (Command Query Responsibility Segregation)

handcraft 2025. 8. 29. 09:13

데이터를 변경하는 기능(쓰기)과 데이터를 조회하는 기능(읽기)을 분리하는 패턴

 

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());
    }
}