CQRS 읽기 모델과 일관성
세션 목록을 매번 전부 읽어 과목별 학습 횟수와 시간을 합산하면 데이터가 늘수록 조회 비용도 커집니다. CQRS는 쓰기 모델을 그대로 조회에 재사용하지 않고, 자주 묻는 질문에 맞춘 작은 projection을 별도로 유지하는 선택입니다.
대신 쓰기 성공과 집계 반영 사이에 시간이 생깁니다. API와 화면은 “방금 완료한 세션이 집계에 즉시 보인다”고 약속하지 말고, 지연 가능성과 재구축 방법을 함께 설계해야 합니다.
읽기와 쓰기는 다른 모델이 필요할 수 있다
command는 원본 도메인의 불변식을 지키며 상태를 바꾸고, commit된 SessionCompleted 이벤트가 projection updater의 입력이 됩니다. projection은 원본의 축약 사본이 아니라 “과목별 횟수와 총 분”이라는 조회 질문에 맞춘 독립 모델입니다.
완료 이벤트로 집계 projection을 갱신한다
앞 절에서 이벤트에 minutes를 포함했으므로 updater가 완료 시점의 분량을 다시 조회할 필요가 없습니다. 이벤트 처리 한 번이 횟수 1과 총 분량을 함께 증가시키고, JPA version은 동시에 같은 과목을 갱신한 경쟁을 조용한 유실 대신 충돌로 드러냅니다.
package com.andongmin.studylog.report;
import org.springframework.data.jpa.repository.JpaRepository;
public interface SubjectProjectionRepository
extends JpaRepository<SubjectProjection, String> {
}package com.andongmin.studylog.report;
import java.util.UUID;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "subject_projection")
public class SubjectProjection {
@Id
private String subject;
private long sessions;
private long minutes;
private UUID lastEventId;
@Version
private long version;
protected SubjectProjection() {
}
public SubjectProjection(String subject) {
this.subject = subject;
}
public void apply(UUID eventId, int addedMinutes) {
if (eventId.equals(lastEventId)) {
return;
}
sessions += 1;
minutes += addedMinutes;
lastEventId = eventId;
}
}package com.andongmin.studylog.report;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.andongmin.studylog.session.SessionCompleted;
@Service
public class SubjectProjectionUpdater {
private final SubjectProjectionRepository projections;
public SubjectProjectionUpdater(SubjectProjectionRepository projections) {
this.projections = projections;
}
@Transactional
public void apply(SessionCompleted event) {
SubjectProjection projection = projections.findById(event.subject())
.orElseGet(() -> new SubjectProjection(event.subject()));
projection.apply(event.eventId(), event.minutes());
projections.save(projection);
}
}현재 lastEventId 검사는 같은 이벤트가 바로 연속 재전달되는 경우만 막습니다. A, B, A 순서의 재처리까지 정확히 막으려면 적용한 event ID를 별도 테이블에 UNIQUE로 기록하거나, projection을 비우고 전체 event stream을 처음부터 한 번씩 재생해야 합니다.
재처리와 rebuild 결과를 비교한다
이 문서에는 아래 이름의 테스트 파일을 제공하지 않습니다. 따라서 존재하지 않는 테스트를 성공했다고 가정하지 않고 먼저 컴파일을 확인합니다. 통합 테스트를 작성할 때는 연속 중복 이벤트, 서로 다른 두 이벤트, 동일 과목 동시 갱신, projection 전체 rebuild를 나누어 검증합니다.
./mvnw -q compile같은 eventId를 바로 두 번 적용하면 한 번만 증가해야 합니다. 임의 순서 재전달까지 허용할 경우에는 `lastEventId`만으로 부족하며, durable dedup 테이블을 추가한 뒤 원본 event replay와 현재 projection의 횟수·분량을 비교해야 합니다.projection 지연도 외부 계약에 포함한다
command 응답이 성공해도 projection consumer가 아직 처리 중일 수 있습니다. 화면에 마지막 갱신 시각을 보여 주거나, 강한 일관성이 필요한 단건 화면은 쓰기 모델을 읽고 집계 화면만 projection을 쓰는 식으로 기대 수준을 나눕니다.
읽기·쓰기 모델의 책임을 나눴다면 마지막으로 그 책임이 Spring과 JPA 세부 구현에 다시 엉키지 않도록 포트와 어댑터 경계를 정리합니다.