이번 시간에는, 상품후기 기능
을 기존 시스템에 추가해보도록 하겠습니다.
1. 상품후기 설계
1.1 요구사항
- 후기에는 별점과 후기 내용이 포함된다.
- 구매한 상품에 대해서만 후기가 작성이 가능하다.
- 별점은 집계되어 카탈로그에서 상품과 함께 보여진다.
- 내가 작성한 후기 목록을 볼 수 있다.
- 상품이 별점을 각각 몇개를 받았는지를 볼 수 있다.
1.2 설계
기능 추출하기
요구사항을 통해서 별점 집계 기능이 필요한것을 알 수 있습니다. 하지만, Item의 별점을 집계 하기위해서는 전체 상품 후기를 보고 집계할 수 있어야합니다.
또한, 카탈로그에서 여러 상품들이 보여지게 되는데요, 이때마다 한 상품마다 모든 후기 목록을 통해서 별점을 집계하는 것 보다는, 상품의 후기가 등록될 때 마다 별점을 집계하도록 하는 것이 좋을 것 같습니다.
xxxxxxxxxx
public class ReviewProductEntity {
...
public void rate(Rating rating) { ... }
}
저는 이 기능을 위한 별도의 ReviewProductEntity를 사용하기로 했습니다.
속성 추출하기
ReviewEntity외에 ReviewProductEntity가 필요하게 되었으므로 두 엔티티들의 속성들을 각각 추출해야 합니다.
ReviewEntity
xxxxxxxxxx
public class ReviewEntity {
private Rating rating;
private String review;
}
별점과, 후기가 등록되므로 ReviewEntity
는 위와 같은 속성들을 가지게 될 것 입니다.
xxxxxxxxxx
public enum Rating {
ONE(1), TWO(2), THREE(3), FOUR(4), FIVE(5);
private int value;
Rating(int value) {
this.value = value;
}
}
Rating은 참고로, 위와 같이 별점을 1~5점 까지만 줄 수 있도록 하기 위한 Enum
클래스입니다.
ReviewProductEntity
xxxxxxxxxx
public class ReviewProductEntity {
// 별점을 각각 몇개 받았는지 알기 위해 필요한 필드들
private int oneCount;
private int twoCount;
private int threeCount;
private int fourCount;
private int fiveCount;
// 평균 별점을 알기 위해 필요한 필드들
private int totalCount;
private int totalRating;
private double ratingAverage;
}
하나의 상품이 1~5점까지의 별점 각각 몇개 받았는지를 알 수 있어야 하며, 별점 평균점수를 알기 위해서는 위와 같은 속성들이 필요할 것 입니다.
도메인 규칙 추출하기
- 구매한 상품에 대해서만 후기를 작성할 수 있다.
위의 요구사항은 Review를 작성하기 위한 도메인 규칙인데요, 이 규칙은 Review 애그리거트로만 처리가 어려운 규칙입니다. 따라서 아래에서 Domain Service
를 이용해 구현하도록 할 것 입니다.
2. 상품 후기 기능 구현
2.1 도메인 계층 구현
ReviewProductEntity
이 엔티티는, 상품에 대한 별점을 집계하기 위해 필요한 것 입니다.
이를 통해 알 수 있는 것은, Review가 작성되기 이전에 이미 생성되어 있어야 한다는 것입니다. 그렇다면 언제 생성해야 할까요? 이는 이전 장바구니 기능
을 추가할 때와 상황이 비슷함을 알 수 있습니다.
장바구니는 사용자가 회원가입을 하는 시점에 사용자를 위한 장바구니가 생성되어야 했습니다. 그렇다면 ReviewProductEntity
또한, 상품이 생성되는 시점에 생성하도록 구현하면 되지 않을까요?
문제점
물론 위와 같이 생성할 수도 있지만, 아래와 같은 문제점들이 발생합니다.
- 아이템 생성시, 상품 별점 집계 엔티티를 생성하기 위해서, 클라이언트가 기다려야 합니다.
- 트랜잭션 처리가 애매해 집니다. 상품 생성 성공후, 상품별점엔티티 생성시 에러가 발생하면 트랜잭션을 어떻게 처리해야 할까요?
- 상품 별점 엔티티 생성로직에 의존성이 생깁니다. 아이템 생성로직은 상품 별점 생성과 연관이 없음에도, 상품별점집계 엔티티 생성방식이 변한다면, 아이템 생성로직을 수정해야 합니다.
이벤트와 AOP를 이용한 모듈간 결합도 낮추기
저는 이러한 문제를 이벤트와 AOP를 이용해서 해결했습니다.
public class ItemRegisteredEventPublishAop {
private final ApplicationContext context;
pointcut = "execution(* com.galid.commerce.domains.catalog.service.ItemService.saveItem(..))", returning ="returnValue") (
public void publishItemRegisteredEvent(Object returnValue) {
ItemRegisteredEvent event = new ItemRegisteredEvent((Long)returnValue);
context.publishEvent(event);
}
}
위의 AOP클래스는, ItemService에서 saveItem() 메소드가 동작한 후, ItemRegisteredEvent
를 발생시키도록 했습니다.
xxxxxxxxxx
public class ItemService {
...
public Long saveItem(AddItemRequest request) {
ItemEntity newItem = createItem(request);
ItemEntity savedItem = itemRepository.save(newItem);
return savedItem.getItemId();
}
}
AOP를 이용하면, ItemService에서 Event를 발생시키기 위한 로직을 추가할 필요도 없습니다.
public class ItemRegisteredEventHandler {
private final ReviewProductRepository reviewProductRepository;
public void handleEvent(ItemRegisteredEvent event) {
reviewProductRepository.save(new ReviewProductEntity(event.getItemId()));
}
}
마지막으로 ItemRegistered
이벤트 발생시 이를 처리하기 위한 핸들러를 구현합니다.
ReviewEntity
x
name = "review") (
public class ReviewEntity extends BaseEntity {
private Long reviewId;
private Long reviewerId;
private Product product;
private Review review;
public ReviewEntity(Long reviewerId, Product product, Review review) {
this.reviewerId = reviewerId;
setProduct(product);
setReview(review);
}
private void setProduct(Product reviewProduct) {
if (reviewProduct == null)
throw new IllegalArgumentException("no reviewproduct");
this.product = reviewProduct;
}
private void setReview(Review review) {
if (review == null)
throw new IllegalArgumentException("no review");
this.review = review;
}
}
ReviewEntity는 요구사항을 통해서 필요한 기능이 없는 것으로 판단이 되었습니다. 따라서 위와 같이 상품후기를 표현하기 위한 데이터만을 가지고 있습니다.
public class Product {
private Long productId;
private String productName;
}
public class Review {
EnumType.STRING) (
private Rating rating;
private String comment;
}
ReviewEntity안의 Product, Review는 ValueObject
로 하나의 논리적인 개념을 표현하기 위해서 사용되는 것들입니다. Product는 productId와 productName을 포함하고 있는 후기 작성 대상 상품을 표현하는 ValueObject입니다. Review는 rating(별점)과 comment(후기)를 포함하고 있는 후기 자체를 표현하는 ValueObject입니다.
의미없는 Setter를 생성하면 안되는걸로 아는데요?
위의 setter들은, 특별한 의미를 이름으로 가진 Setter들이 아니지만, 접근 제한자가 private
이므로 외부에서 악용될 수가 없으며, 올바른 값이 전달 되어졌는지 확인하기 위한 메소드입니다.
2.2 서비스 계층 구현
x
public class ReviewService {
private final ReviewRepository reviewRepository;
private final ReviewProductRepository reviewProductRepository;
private final CheckOrderedProductService checkOrderedProductService;
private final MyOrderedItemDao orderedProductDao;
public Long review(Long reviewerId, ReviewRequest reviewRequest) {
// 사용자가 주문한 상품목록에 리뷰대상이 존재하는지 확인
Set<Long> orderedItemIdSet = getOrderedItemIdSet(orderedProductDao.myOrderedListFromLastMonth(reviewerId));
checkOrderedProductService.checkOrderedProduct(reviewRequest.getProductId(), orderedItemIdSet);
// 리뷰 생성
ReviewEntity reviewEntity = createReview(reviewerId, reviewRequest);
ReviewEntity savedReview = reviewRepository.save(reviewEntity);
// 리뷰 수 관리
reviewProductRepository.findByProductId(reviewRequest.getProductId())
.rate(Rating.valueOf(reviewRequest.getRating()));
return savedReview.getReviewId();
}
private Set<Long> getOrderedItemIdSet(List<OrderEntity> myOrderedItemIdListFromLastMonth) { ... }
private ReviewEntity createReview(Long reviewerId, ReviewRequest reviewRequest) { ... }
}
리뷰 작성을 위한 Service 입니다. 동작은 간단합니다.
- 사용자가 리뷰 작성을 요청한 상품이 주문내역에 존재하는지 확인합니다.
- 리뷰를 생성합니다.
- 상품의 평균 별점을 업데이트합니다.
CheckOrderedProductService
- 구매한 상품에 대해서만 후기를 작성할 수 있다.
앞서 위의 요구사항은 도메인 규칙임을 찾아내었고, 이는 Review 애그리거트 내에서 처리하기 어렵기 때문에, DomainService로 해결할 것이라고 말씀드렸습니다.
xxxxxxxxxx
public class CheckOrderedProductService {
public void checkOrderedProduct(Long reviewTargetProductId, Set<Long> usersOrderedProductIdList) {
if (! usersOrderedProductIdList.contains(reviewTargetProductId))
throw new NotOrderedProductReviewException("구매한 상품만 리뷰작성이 가능합니다.");
}
}
이 도메인서비스는 매우 간단합니다. 리뷰할 상품의 id
가 사용자가 주문한 내역에 포함된 상품 Set
에 포함되었는지 확인하고, 포함되어 있지 않다면 Exception을 발생시킵니다.
MyOrderedItemDao
이 dao는, 사용자가 주문했던 목록을 가져오기 위한 Dao입니다. 이미 사용자별 주문목록을 조회하기위한 MyOrderDao
를 작성한 적이 있지만, 용도도 다르고, 결정적으로 특별한 조건을 부여해 가져오기 때문에 별도로 구현을 했습니다. 위의 빨간 사각형으로 표시한것이 바로 그것입니다.
이는 바로 조회 성능
때문입니다. 사용자가 후기를 작성할 때 마다, 사용자가 주문한 모든 내역을 순회하며, 주문한 상품에 포함되어 있는지 확인하는 것은 매우 무거운 연산이 될 수 있습니다. 예를 들어 약 5년간 사용한 사용자가 수만명이 된다면, 5년동안 주문한 상품들이 엄청날 수 있습니다.
이 때문에 성능을 위해서, 상품을 구매한지 1달이 지나지 않은
상품들에 대해서만 후기 작성이 가능하도록 하기 위해서 위의 조건을 추가했습니다.
private Set<Long> getOrderedItemIdSet(List<OrderEntity> myOrderedItemIdListFromLastMonth) {
Set<Long> collect = myOrderedItemIdListFromLastMonth
.stream()
.map(o -> o.getOrderItemList())
.flatMap(olList -> olList.stream().map(ol -> ol.getItem().getItemId()))
.collect(Collectors.toSet());
return collect;
}
CheckOrderedProductService
에 넘겨지는 주문한 상품 Id Set
은 위의 메소드를 통해서 만들어집니다.
2.3 표현 계층 구현
xxxxxxxxxx
public class ReviewController {
private final ItemService itemService;
"/reviews/review") (
public String getReviewPage( (value = "productId", required = true) Long productId,
Model model) {
model.addAttribute("product", itemService.findItem(productId));
return "reviews/review";
}
}
우선 Review 작성을 위한 페이지 요청 핸들러를 구현합니다. Review 작성 대상 상품을 보여주기 위한 데이터를 model에 세팅합니다.
x
public class ReviewRestController {
private final AuthenticationConverter authenticationConverter;
private final ReviewService reviewService;
"/reviews/review") (
public Long writeReview(Authentication authentication,
ReviewRequest reviewRequest) {
MemberEntity member = authenticationConverter.getMemberFromAuthentication(authentication);
return reviewService.review(member.getMemberId(), reviewRequest);
}
}
public class ReviewRequest {
private Long productId;
private String productName;
private int rating;
private String review;
}
Review 작성 요청을 처리하는 핸들러 입니다. RestController로 작성하였으며, 사용자의 요청은 HTTP Request Body에 담겨서 전송되어, 핸들러의 매개변수인 ReviewRequest
에 맵핑됩니다.
x
<script>
$(function() {
$("#reviewBtn").click(function() {
let body = createBody();
validation(body);
$.ajax({
type: "POST",
url: "/reviews/review",
data: JSON.stringify(body),
success: function(data) {
alert("리뷰 작성 완료 !");
location.href = "/catalog";
},
error: function(data) {
alert(data.responseText);
},
contentType: "application/json"
});
});
// 리뷰 작성에 필요값 만들기
function createBody() {
let productId = $("#productId").text();
let productName = $("#productName").text();
let review = $("#review").val().trim();
return {
productId: productId,
productName: productName,
rating: rating,
review: review
};
}
}
</script>
Review 작성 페이지는 https://gitlab.com/galid1/jpa-commerce/-/blob/master/src/main/resources/templates/reviews/review.html 에서 확인해주세요. 여기서는 Review 작성을 위한 ajax 로직만 간단히 살펴보겠습니다.
Review작성 요청은 다음의 순서로 진행됩니다.
- createBody() 메소드를 통해서, 사용자가 Review를 작성한 값들을 가져와 Http Request Body를 만듭니다.
- $.ajax({ ...}) 를 통해서, POST 요청으로
/reviews/review
경로로 요청을 전달하여 Review 작성 요청을 합니다.
success: function(data) { alert("리뷰 작성 완료 !"); location.href = "/catalog"; }, error: function(data) { alert(data.responseText); },
리뷰 작성 후 200 상태가 반환되면, /catalog 페이지로 이동됩니다. 리뷰 작성에 실패하면, alert()로 에러 메시지를 사용자가 보게 됩니다.
3. 최종 도메인 모델
상품후기가 추가된 최종 도메인 모델은 위와 같습니다.
'FrameWork > Spring JPA' 카테고리의 다른 글
Spring JPA - @OneToOne 관계에서 Lazy로딩이 동작하지 않는 경우 (0) | 2022.03.01 |
---|---|
Spring JPA - JPA N+1 문제 완전 정리 (0) | 2021.08.16 |
Spring JPA - JPA를 이용해 Commerce App 만들기 - 9.2 (카테고리 기능 기존 시스템과 결합) (1) | 2020.11.09 |
Spring JPA - JPA를 이용해 Commerce App 만들기 - 9.1 (무한카테고리 구현 및 Redis를 이용한 캐싱) (8) | 2020.11.09 |
Spring JPA - JPA를 이용해 Commerce App 만들기 - 8 (무한스크롤, 페이지네이션, 컬렉션 조회 최적화, N+1 문제해결) (0) | 2020.11.08 |