[Architecture] CQRS 겉핥기
CQRS 겉핥기
어쩌다 CQRS
우아한테크코스4기 팀 프로젝트 초기 당시 설계에 관한 이야기를 나눴습니다. JPA를 사용하되 간접 참조를 사용하고 조회 쿼리와 명령 쿼리를 분리하자는 결론이 나왔습니다. 아직 두 개념이 생소하였고 추천받은 자료들을 보았습니다. 그중 하나가 최범균님의 CQRS 아는 척하기입니다.
아직 CQRS를 깊게 아는 상태도 아니고 조회와 명령을 분리해 본 경험으로 작성한 글입니다. 이런 경험을 해봤구나 정도로봐주시면 좋을 것 같아요.
간접 참조는 뭐지?
JPA의 엔티티 매핑을 사용하지 않고 객체의 id만 참조하는 상태를 의미합니다. JPA를 학습한 저로써는 이해가 되지 않는 내용이었죠. ‘엔티티 매핑을 사용해서 객체를 참조하고 편하게 개발하는 게 아닌가?’ ‘엔티티 매핑을 사용하는 게 객체지향 아닌가?’라는 생각이 들었습니다.
팀에선 간접 참조로 먼저 설계를 하고 엔티티 참조가 더 필요해지는 상황에선 직접 참조를 하자는 결론이 나왔습니다. 우선 경험해 보지 못했으므로 경험을 해보면서 장단점을 느껴보고자 해당 제안을 동의하였습니다.
추후 땡쿠(우아한테크코스4기 팀 프로젝트 서비스 이름) 팀은 간접 참조의 불편한 점을 느끼고 간접 참조와 직접 참조를 적절히 사용하면서 개발을 진행하였습니다. 간접 참조를 하게 되면서 도메인 간의 의존성을 끊어낼 수 있고 조회 시 JPA의 여러 문제들을 고민하지 않고 조회할 수 있다는 점을 경험했어요. 해당 경험에 관해 따로 포스팅을 해볼게요.
CQRS 패턴은 뭐지?
Command and Query Responsibility Segregation의 약자로 명령과 조회의 책임 분리를 말합니다. 문제를 해결하기 위한 하나의 패턴이죠. 주문을 하거나 변경, 취소와 같이 상태를 변경하는 작업이 명령이고 주문 조회같이 상태를 반환하는 작업이 조회를 의미합니다.
즉 명령 역할을 수행하는 구성요소와 쿼리 역할을 수행하는 구성요소를 나누는 것을 말합니다.-
분리하면 뭐가 좋은가요?
CQRS를 알아볼수록 제가 모르는 영역만 나오더라고요. 그래서 제가 느낀 장점에 대해서 적어보려고 합니다.
단순한 비즈니스가 아니라면 조회용 모델과 명령형 모델은 비슷하기 힘듭니다. 명령형 모델의 경우 한 영역의 데이터를 주로 다루지만 쿼리형 모델의 경우 여러 영역의 데이터를 다룹니다. 예약 정보를 가져오기 위해 쿠폰과 회원의 정보들을 가져오는 상황처럼 말이죠. 쿼리의 경우 변경도 자주 일어납니다. 프로젝트를 진행하면서 조회 쪽에서 수정되는 일이 훨씬 많았습니다.
따라서 명령과 조회가 함께 있다면 서로 다른 이유로 코드가 변경되고 이는 책임의 크기가 적당하지 않다는 것을 의미합니다. 객체의 책임으로만 바라봐도 쿼리와 명령을 분리하는 이유가 충분하다고 생각했습니다.
조금 더 나아가면 조회용 데이터베이스로 분리할 수 있고 상황에 맞게 튜닝을 할 수 있는 이점이 생깁니다. 이벤트 소싱에 대해서는 모르겠습니다. 😂
한 가지 더 느낀 장점으로는 예약 or 미팅 → 쿠폰 → 회원으로 방향이 흘러가는 땡쿠의 프로젝트에서 예약이나 미팅에서 회원까지 조회해올 경우 JPA에서 발생할 수 있는 여러 문제점들을 고려하지 않고 jdbc를 통해 비교적 학습곡선 없이 개발을 할 수 있었어요. JPA 관련 기술이 필요해진다면 그때 전환하기로 했습니다.
코드 살짝 맛보기
명령과 관련된 로직은 CouponService에서 수행하고 @Transactional
어노테이션을 클래스 단계에 사용하여 상태를 변화시키는 책임을 수행합니다.
@Service
@Transactional
@RequiredArgsConstructor
public class CouponService { // Coupon Command Service
private final CouponRepository couponRepository;
private final MemberRepository memberRepository;
private final AlarmSender alarmSender;
// 쿠폰 전체 저장
public void saveAll(final Long senderId, final CouponRequest couponRequest) {
validateMember(senderId, couponRequest.getReceiverIds());
Coupons coupons = new Coupons(couponRepository.saveAll(couponRequest.toEntities(senderId)));
Members members = new Members(memberRepository.findByIdIn(coupons.getReceiverIds()));
sendMessage(senderId, members.getEmails(), couponRequest.toCouponContent());
}
...
}
쿼리와 관련된 로직은 CouponQueryService에서 수행하고 @Transactional(readOnly = true)
어노테이션을 클래스단계에 사용하여 상태를 반환하는 책임을 수행합니다.
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CouponQueryService { // Coupon Query Service
private final CouponQueryRepository couponQueryRepository;
private final ReservationProvider reservationProvider;
private final MeetingProvider meetingProvider;
// 받은 쿠폰 조회
public List<CouponResponse> getReceivedCoupons(final Long receiverId, final String status) {
List<String> statusNames = CouponStatusGroup.findStatusNames(status);
return couponQueryRepository.findByReceiverIdAndStatus(receiverId, statusNames)
.stream()
.map(CouponResponse::of)
.collect(Collectors.toList());
}
// 보낸 쿠폰 조회
public List<CouponResponse> getSentCoupons(final Long senderId) {
return couponQueryRepository.findBySenderId(senderId)
.stream()
.map(CouponResponse::of)
.collect(Collectors.toList());
}
// 쿠폰 상세 조회
public CouponDetailResponse getCouponDetail(final Long memberId, final Long couponId) {
MemberCoupon memberCoupon = getMemberCoupon(couponId);
validateCouponOwner(memberId, memberCoupon);
CouponStatus couponStatus = CouponStatus.of(memberCoupon.getStatus());
if (couponStatus.isInReserveOrUsed()) {
return getCouponDetailResponse(couponId, memberCoupon, couponStatus);
}
return CouponDetailResponse.of(memberCoupon);
}
...
}
참고 : https://bluayer.com/37