Reader/Writer 패턴으로 가볍게 시작하는 CQRS
ATS 프로젝트 초기에 Service 클래스에 JPA Repository를 직접 주입해서 쓰고 있었다. 어느 날 동료가 코드 리뷰에서 물었다. "이 Service 메서드, 데이터 읽기만 하는 거야 수정도 하는 거야?" 그때 처음 깨달았다. 나도 모르겠다는 걸.
처음에는 이렇게 했다
Service 하나에 Repository를 주입하고, 조회도 저장도 같은 클래스에서 했다.
// 초기 구조 — 모든 게 Service 하나에
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public OrderEntity getById(final Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new CommerceException(CommerceErrorType.NOT_FOUND));
}
@Transactional
public OrderEntity save(final OrderEntity entity) {
return orderRepository.save(entity);
}
public List<OrderEntity> findByOfficeId(final String officeId) {
return orderRepository.findByOfficeId(officeId);
}
}문제는 @Transactional 어노테이션이 제멋대로였다는 것이다. 어떤 조회 메서드에는 @Transactional이 붙어 있고, 어떤 메서드에는 아예 없었다. readOnly = true? 그런 옵션이 있다는 걸 알면서도 일관되게 적용하지 못했다.
문제가 터진 순간
공고 목록 페이지가 느려졌다. 공고 하나에 달린 지원자 수, 면접 일정 수, 전형 단계 정보까지 한 번에 가져오는 화면이었는데, 로딩이 3초를 넘기기 시작했다.
원인을 추적해보니 두 가지였다.
그때 고민했다. 이걸 단순히 @Transactional(readOnly = true)만 붙여서 해결할 것인가? 아니면 구조적으로 "조회"와 "변경"을 분리할 것인가?
결정: Reader와 Writer로 나누자
CQRS를 검색하면 이벤트 소싱, 별도 읽기 DB 같은 무거운 개념이 먼저 나온다. 우리에게 필요한 건 그런 게 아니었다. "이 메서드가 읽기인지 쓰기인지 코드만 보고 알 수 있으면 된다." 그래서 Repository를 감싸는 Reader/Writer 클래스를 만들었다.
바뀐 코드: OrderReader
@Repository
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class OrderReader {
private final OrderRepository orderRepository;
private final OrderSearchCustomRepository orderSearchCustomRepository;
public OrderEntity getById(final Long id) {
return orderRepository.findById(id)
.orElseThrow(() -> new CommerceException(CommerceErrorType.NOT_FOUND));
}
public OrderEntity getByOrderNumber(final String orderNumber) {
return orderRepository.findByOrderNumber(orderNumber)
.orElseThrow(() -> new CommerceException(CommerceErrorType.NOT_FOUND));
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
public OrderEntity getByOrderNumberWithLock(final String orderNumber) {
return orderRepository.findByOrderNumberWithLock(orderNumber)
.orElseThrow(() -> new CommerceException(CommerceErrorType.NOT_FOUND));
}
// SearchView 프로젝션 — 목록 조회에 최적화
public PageData<OrderSearchView> searchByOfficeId(
final String officeId,
final List<OrderSearchQuery> queries,
final Pageable pageable
) {
return orderSearchCustomRepository.searchPaymentCompletedByOfficeId(
officeId, queries, pageable
);
}
}클래스 레벨에 @Transactional(readOnly = true)를 걸었다. 이 클래스 안의 모든 메서드는 읽기 전용이라는 선언이다. Hibernate가 스냅샷을 안 찍으니 대량 조회 성능이 올라갔다.
바뀐 코드: OrderWriter
@Repository
@Transactional
@RequiredArgsConstructor
public class OrderWriter {
private final OrderRepository orderRepository;
public OrderEntity save(final OrderEntity orderEntity) {
return orderRepository.save(orderEntity);
}
}Writer는 단순하다. 저장과 수정만 담당한다. @Transactional은 기본값(readOnly = false)이므로 변경 감지가 정상 동작한다.
N+1 문제 해결: SearchView 프로젝션
Reader를 분리하면서 목록 조회에는 Entity 대신 SearchView를 반환하기로 했다. Entity를 반환하면 연관 객체의 지연 로딩이 N+1을 일으키지만, SearchView는 필요한 필드만 한 방 쿼리로 가져온다.
@Getter
@AllArgsConstructor
public class OrderSearchView {
private final Long id;
private final String orderNumber;
private final String memberName;
private final BigDecimal totalAmount;
private final OrderStatus status;
private final LocalDateTime createdAt;
// Entity의 모든 필드가 아닌, 목록에 필요한 필드만
}@RequiredArgsConstructor
public class OrderSearchCustomRepositoryImpl implements OrderSearchCustomRepository {
private final JPAQueryFactory queryFactory;
@Override
public PageData<OrderSearchView> searchPaymentCompletedByOfficeId(
final String officeId,
final List<OrderSearchQuery> queries,
final Pageable pageable
) {
final List<OrderSearchView> content = queryFactory
.select(Projections.constructor(OrderSearchView.class,
order.id,
order.orderNumber,
member.name,
order.totalAmount,
order.status,
order.createdAt
))
.from(order)
.join(member).on(order.memberId.eq(member.id))
.where(
order.officeId.eq(officeId),
order.status.eq(OrderStatus.PAID),
buildConditions(queries)
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.orderBy(order.createdAt.desc())
.fetch();
return PageData.of(content, pageable, totalCount);
}
}공고 목록 페이지의 쿼리가 151개에서 2개(데이터 조회 + 카운트)로 줄었다. 로딩 시간이 3초에서 200ms로 내려갔다. Entity를 직접 반환하지 않는 것만으로 이 차이가 난다.
프로젝트 전체에 적용
효과를 체감한 뒤 모든 Aggregate에 Reader/Writer를 만들었다.
UseCase에서 의존성만 봐도 의도가 보인다. ApplicantReader만 주입받은 UseCase는 조회용이고, ApplicantWriter를 주입받았다면 상태를 바꾸는 것이다. 코드 리뷰에서 "이 메서드 뭐 하는 건데?"라는 질문이 사라졌다.
이 패턴과 "진짜" CQRS의 차이
| 항목 | Reader/Writer 패턴 | Full CQRS |
|---|---|---|
| 같은 DB 사용 | O | X (별도 읽기 DB) |
| 같은 Entity 모델 공유 | O | X (별도 Read Model) |
| 이벤트 소싱 필요 | X | O (주로) |
| 최종 일관성 | X (즉시 일관성) | O |
| 도입 복잡도 | 낮음 | 높음 |
| 얻는 이점 | 의도 분리, 트랜잭션 최적화 | 독립적 스케일링, 읽기 최적화 |
솔직히 말하면 이건 "진짜 CQRS"가 아니다. 같은 DB, 같은 Entity를 쓰고 있으니까. 하지만 CQRS의 핵심 아이디어인 **"읽기와 쓰기의 관심사를 분리한다"**는 충분히 달성했다. 80%의 이점을 20%의 노력으로 가져간 셈이다.
Reader가 별도 Read DB를 바라보게 하거나, SearchView를 Elasticsearch에서 가져오도록 바꾸는 것도 가능하다. Reader/Writer 분리를 해두었기 때문에 확장 지점이 이미 존재한다. 하지만 지금은 필요 없으니 하지 않는다. 그게 YAGNI다.
돌이켜보면 처음부터 Reader/Writer로 시작하지 않은 게 오히려 좋았다. 문제를 직접 겪어봐야 패턴의 가치를 체감할 수 있고, 팀원들도 "왜 이렇게 바꿔야 하는지" 납득이 된다. 느려진 화면이라는 구체적인 문제가 있었기에 패턴 도입이 자연스러웠다.