본문 바로가기

Spring

Spring / CQRS, 코드로 알아보기

CQRS를 검색해 보면 관련된 글들이 많이 나온다.

Query와 Command를 나눈다는 이야기인데 이것만으로는 실제 CQRS를 적용하기 위해서는 더 구체적으로 알아야 할 필요가 있다.

이번 포스팅에서는 CQRS가 필요한 구체적인 예시와 더불어 어떻게 구현하는지에 대한 간단한 예제를 다뤄보려 한다.

 

목차

1. 프로젝트 구조 설정

2. 문제점 및 한계

3. CQRS 적용
  3-1. Query 모델 만들기

  3-2. Query 모델과 Command 모델 동기화

 

프로젝트 구조 설정

'학생기록부' 시스템이 있다고 가정해 보자.

Entity는 학생, 부모(각각 아버지, 어머니)가 있고 영어 성적, 수학 성적으로 구성되어 있다.

 

그리고 학생의 아래 정보들을 요구하는 api 있다고 가정해 보자

@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class StudentDetailResponse {
    private Long studentId;
    private String studentName;
    private Long familyIncome; // 부모님의 수입
    private Double averageEnglishScore; //현재까지 영어 평균 점수
    private Double averageMathScore; // 현재까지 수학 평균 점수
}

 

위 정보를 조회하는 Service를 대략적으로 구현해 보면 아래와 같이 구현할 수 있을 것이다.

@RequiredArgsConstructor
@Service
public class StudentService {
    private final StudentRepository studentRepository;
    private final FatherRepository fatherRepository;
    private final MatherRepository matherRepository;
    private final MathGradeRepository mathGradeRepository;
    private final EnglishGradeRepository englishGradeRepository;

    public StudentDetailResponse findStudentDetail(Long studentId) {
        Student student = studentRepository.findById(studentId).orElseThrow();

        Father father = fatherRepository.findByStudentId(studentId).orElseThrow();
        Mather mather = matherRepository.findByStudentId(studentId).orElseThrow();
        long familyIncome = father.getIncome() + mather.getIncome();

        double mathAverage = mathGradeRepository.findAllByStudentId(studentId).stream()
                .mapToLong(MathGrade::getScore)
                .average()
                .orElse(0);

        double englishAverage = englishGradeRepository.findAllByStudentId(studentId).stream()
                .mapToLong(EnglishGrade::getScore)
                .average()
                .orElse(0);

        return new StudentDetailResponse(
                student.getId(),
                student.getName(),
                familyIncome,
                englishAverage,
                mathAverage
        );
    }

}

 

문제점 및 한계

'StudentDetailResponse'를 조회하기 위해서는 여러 번의 쿼리가 발생한다.
join 해서 성능을 개선할 수 있긴 하지만 위 예시는 간단하게 구현된 예시일 뿐 실제 서비스에서는 더욱 많은 도메인이 얽혀있을 수 있고 한계가 존재할 수밖에 없는 경우가 있다.(상상력을 발휘하여 연관관계가 더 얽혀있다고 생각해 보자)

 

사실 단건 조회에서는 여러 번의 쿼리가 발생하는 게 그리 치명적이지는 않다.

그러나 만약 List<StudentDetailResponse>로 수십 수백 건을 조회하는 경우에는 상당히 문제가 될 수 있다.

 

이때, CQRS를 적용해서 문제를 해결할 수 있다.

 

CQRS 적용

Query 모델 만들기

먼저 쿼리를 위한 모델(StudentView)을 만든다.

@Entity
@Getter
public class StudentView {
    @Id
    private Long studentId;
    private String studentName;
    private Long fatherId;
    private Long fatherIncome;
    private Long matherId;
    private Long matherIncome;
    private Long familyIncome;
    private Double averageEnglishScore;
    private Double averageMathScore;
}

그러면 StudentQueryService를 다음과 같이 구성할 수 있다.

@RequiredArgsConstructor
@Service
public class StudentQueryService {
    private final StudentViewRepository studentViewRepository;

    public List<StudentDetailResponse> findAllStudentDetail() {
        return studentViewRepository.findAll().stream()
                .map(studentView -> new StudentDetailResponse(
                        studentView.getStudentId(),
                        studentView.getStudentName(),
                        studentView.getFamilyIncome(),
                        studentView.getAverageEnglishScore(),
                        studentView.getAverageMathScore()
                ))
                .collect(Collectors.toList());
    }
}

StudentQueryService에서는 join과 같은 어떠한 중간 단계가 필요 없이 바로 데이터를 조회할 수 있다.

 

Query 모델과 Command 모델 동기화

만약 학생 데이터가 생성되거나 관련 정보가 수정이 되면 Query 모델에 변경사항을 적용해줘야 한다.

여러 방법이 있겠지만 Hibernate의 EventListner를 활용해서 간단하게 구현할 수 있다.

 

먼저 코드를 소개하자면 아래와 같다.

package com.example.cqrs_example.hibernaterlistener;

import com.example.cqrs_example.entity.model.Student;
import org.hibernate.event.spi.*;
import org.hibernate.persister.entity.EntityPersister;
import org.springframework.stereotype.Component;

@Component
public class StudentHibernateEventListener implements PostInsertEventListener, PostUpdateEventListener, PostDeleteEventListener {

    @Override
    public boolean requiresPostCommitHanding(EntityPersister persister) {
        return true;
    }

    @Override
    public void onPostDelete(PostDeleteEvent event) {
        System.out.println("onPostDelete");
        Object entity = event.getEntity();
        handleEntity(entity);
    }

    @Override
    public void onPostInsert(PostInsertEvent event) {
        System.out.println("onPostInsert");
        Object entity = event.getEntity();
        handleEntity(entity);
    }

    @Override
    public void onPostUpdate(PostUpdateEvent event) {
        System.out.println("onPostUpdate");
        Object entity = event.getEntity();
        handleEntity(entity);
    }

    private void handleEntity(Object entity) {
        if (entity instanceof Student) {
            //todo StudentView 데이터 업데이트
        }
    }
}

위와 같이 StudentHibernateEVentListener를 만들고 config에 등록해주자.

@RequiredArgsConstructor
@Configuration
public class StudentConfiguration {
    private final EntityManagerFactory entityManagerFactory;
    private final StudentHibernateEventListener studentHibernateEventListener;

    @PostConstruct
    private void init() {
        SessionFactoryImpl sessionFactory = entityManagerFactory.unwrap(SessionFactoryImpl.class);
        EventListenerRegistry registry = sessionFactory.getServiceRegistry().getService(EventListenerRegistry.class);

        registry.getEventListenerGroup(EventType.POST_INSERT).appendListener(studentHibernateEventListener);
        registry.getEventListenerGroup(EventType.POST_UPDATE).appendListener(studentHibernateEventListener);
        registry.getEventListenerGroup(EventType.POST_DELETE).appendListener(studentHibernateEventListener);
    }
}

위와 같이 하면 Hibernate 쿼리가 발생할때마다 이를 감지하여 StudentView를 업데이트 시켜준다거나 혹은 삭제 등 커스텀한 동작을 수행할 수 있다.

 

*관련 코드는 여기서 확인할 수 있습니다.

반응형