Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#15] 마이페이지 쿠폰 조회 & 사용 가능한 쿠폰 조회 & 쿠폰 사용 #16

Merged
merged 29 commits into from
Apr 17, 2024

Conversation

codesejin
Copy link
Collaborator

@codesejin codesejin commented Apr 8, 2024

테이블 증설

쿠폰 사용 로직에 주문 비즈니스 로직이 포함되어야 할것 같아서 테이블 3개를 추가했습니다.

image

궁금한 점

쿠폰을 사용한 주문 API에서 DB 조회가 많이 일어나고 있는것 같은데, 관련 데이터는 한번에 받아서 DTO에 담아야 할지,
아니면 간단한 SELECT쿼리는 DB 부하에 영향을 크게 주지 않기 때문에 현재로서도 충분한지 궁금합니다.

@codesejin codesejin self-assigned this Apr 8, 2024
@codesejin codesejin added the enhancement New feature or request label Apr 9, 2024
Copy link
Collaborator

@f-lab-maverick f-lab-maverick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다. 리뷰 확인해주시고, 수정하신 후 다시 요청해주세요~

private final MyPageService myPageService;
@ResponseStatus(HttpStatus.OK)
@GetMapping("/coupons")
public ResponseEntity<ResponseDTO<List<AllCouponsByMemberIdResponse>>> getAllCoupons(@RequestParam final long memberId) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

자기자신의 쿠폰 리스트를 확인하는데, 파라미터로 memberId를 받을 필요가 있을까요?
이 유저가 다른 유저의 id를 파라미터로 입력해서 데이터를 조회한다면 어떻게 될까요?

별도의 권한체크가 없으니 parameter manipulation 공격이 성립하겠네요. 이 키워드를 한번 찾아보시고, 취약점을 방어해주세요.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 이번 PR에서 수정하지 않고 미루기로했으니, 우선 해당 URL이 오픈되지 않도록 핸들러매핑이 연결되지 않게 @GetMapping("/coupons")을 지워주세요~~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네엡 다음번 PR에서 수정하도록하겠습니다!

Comment on lines 29 to 32
@ResponseStatus(HttpStatus.OK)
@GetMapping("/available-coupons")
public ResponseEntity<ResponseDTO<List<AvailableCouponsByMemberIdResponse>>> getAvailableCoupons(@RequestParam final long memberId,
@RequestParam final long productId) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 마찬가지로, 자기자신의 쿠폰 목록을 조회하는 것인데 memberId를 파라미터로 받을 필요가 있을까요?

* @param productId 상품 ID
* @return 사용 가능한 쿠폰 목록
*/
@ResponseStatus(HttpStatus.OK)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ResponseStatus는 사용에 주의해야 합니다. Return type이 ResponseEntity이며, 어노테이션 ResponseStatus를 동시에 쓰는 상황에서 두가지 반환값이 서로 다르다면 어떤 status가 반환될까요?

Copy link
Collaborator Author

@codesejin codesejin Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controller에서 ResponseEntity와 @ResponseStatus를 동시에 사용할때에는 ResponseEntity에서 명시된 상태코드가 우선됩니다. 즉, ResponseEntity에서 명시된 상태코드가 HTTP 응답으로 반환됩니다.

하지만 응답이 2번 발생할 수 있기 때문에 동시에 2개를 사용하는 것은 권장되지 않고, 아래와 같이 (제가 한 실수,,) 응답코드를 Created와 Ok를 둘 다 사용한다면 다른 개발자가 코드를 읽을때 혼동을 줄 수도 있습니다. 따라서 휴먼 에러를 방지하기 위해 왠만하면 Controller에서 @ResponseStatus를 사용하지 않도록 수정하겠습니다!
image

private final String title;
private final String description;
private final long originalPrice; // 원래 가격
private final Long salePrice; // null일 경우 전체 할인이 적용되지 않은 것으로 간주
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

null로 비즈니스 로직을 표현하는 것은 NPE의 가능성을 높입니다.
이런 경우 salePrice를 0으로 기본값을 표기해준다면 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

정밀도 향상을 위해 BigDecimal타입으로 변경했고, default 0으로 설정했습니다!

Comment on lines 51 to 52
List<AvailableCouponsByMemberIdResponse> responseList = new ArrayList<>();
filterAvailableCouponsWithMinPrice(productId, availableCoupons, responseList);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Collection을 생성한 후 메서드 내부에서 컬랙션의 상태를 변경시키고 있네요.
이렇게 내부에서 상태를 변경시키는 메서드는 코드를 읽을 때 해당 변수에 대해서 계속해서 트래킹하게 만들기 때문에 피로감을 느끼게 만들고, 메서드의 결과값을 예측할 수 없게 만듭니다.

그렇기 때문에 상태를 변경시키는 것이 아닌, 행위 그 자체를 메서드화한다면 좀 더 깔끔하고 읽기 쉬운 직관적인 코드로 만들 수 있습니다.

Suggested change
List<AvailableCouponsByMemberIdResponse> responseList = new ArrayList<>();
filterAvailableCouponsWithMinPrice(productId, availableCoupons, responseList);
List<AvailableCouponsByMemberIdResponse> responseList = availableCoupons.stream
.filter(coupon -> isOverThenMinPrice(productId))
...

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 filter를 사용해보려고 했는데, 할인 가격이 minOrderPrice를 초과하지 않는 범위까지만 누적하는거라 filter 사용이 어려울것 같습니답...

Comment on lines 45 to 54
/**
* 상품의 총 가격을 계산하여 반환하는 메소드입니다.
*
* @param product 상품 정보
* @param quantity 상품 수량
* @return 상품의 총 가격
*/
public long calculateTotalPrice(Product product, long quantity) {
return getProductPricePerUnit(product) * quantity;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 로직또한 비즈니스 로직으로, 유틸리티가 아니기 때문에 Service / Domain Layer에 존재해야하는 로직입니다.

Copy link
Collaborator Author

@codesejin codesejin Apr 15, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

계산하는 로직이 코드 중복될 가능성이 있다고 생각해서 별도의 클래스에 분리하려고 시도했는데, 제가 유틸리티 라는 개념에 대해 미숙했던것 같습니다 수정하겠습니다!

long totalDiscountPrice = 0;
for (Coupon coupon : couponList) {
if (coupon.getDiscountType() == DiscountType.PERCENT) {
totalDiscountPrice += getProductPricePerUnit(product) * ((double) coupon.getDiscountRate() / 100);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

값을 double type으로 직접 계산하고 있네요. 숫자를 이렇게 원시 타입으로 계산하면 어떤 일이 발생할까요? 원하는 값이 정확하게 나올까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

double형태로 계산하면 overflow 와 부동 소수점 이슈가 발생합니다. BigDecimal로 변경해서 수정하겠습니다!

Comment on lines 84 to 93
ROUND(CASE
WHEN p.sale_price IS NULL THEN
p.original_price -
IF(c.discount_type = 'PERCENT', p.original_price * (c.discount_rate / 100),
c.discount_price)
ELSE
p.sale_price - IF(c.discount_type = 'PERCENT', p.sale_price * (c.discount_rate / 100),
c.discount_price)
END, 0)
AS discounted_price
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

query에 로직이 있네요~~
정말 불가피한 경우가 아니라면 아래 케이스는 모두 지양해야 합니다.

  • query에서 분기를 태우는 case-when-then
  • query에서 값을 계산하는 경우
  • query에서 비즈니스 로직이 있는 경우

왜냐하면, query는 디버깅하기가 어려우며 수정사항이 발생하였을 경우 유동적으로 변경할 수 없고, 테스트도 짜기 어렵기 때문입니다. 데이터 조회는 심플하게 조회하고, 모든 가공은 어플리케이션에서 진행해주세요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와! 제가 항상 고민했지만 확신이 없던 내용인데, 이렇게 원인과 케이스별로 정리해주셔서 감사합니다! 애플리케이션 내에서 가공하도록 수정하겠습니다!

Comment on lines 99 to 100
AND c.validate_start_date <= #{currentDateTime}
AND c.validate_end_date >= #{currentDateTime}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BETWEEN 문을 쓸 수 있엤네요.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네엡 변경했습니다 감사합니다~

Comment on lines 86 to 88
select id as couponId,
if(validate_start_date <= #{currentDateTime} and validate_end_date >= #{currentDateTime}, true,
false) as isBetweenValidatePeriod,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분도 쿼리에 로직이 들어간 케이스로 보이네요~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

애플리케이션에서 처리하도록 수정했습니다!

쿼리에서 로직 삭제 후 애플리케이션에서 데이터 가공
기존 구현에서 람다 표현식이 외부 변수 accumulatedDiscountPrice를 수정하여 "Variable used in lambda expression should be final or effectively final" 오류가 발생했습니다. AtomicReference 래퍼를 사용하여 accumulatedDiscountPrice 변수를 안전하게 업데이트하도록 수정했습니다.
Copy link
Collaborator Author

@codesejin codesejin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

시큐리티 이슈를 아직 해결하지 못해서 parameter manipulation 공격에 대한 내용 제외하고 수정했습니다!

Comment on lines 103 to 104
// 5. 쿠폰 사용 처리
couponIssueRepository.updateCouponStatus(request.getCouponIssueId());
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아직 메소드 명이 뭐가 적절한지 판단하는게 미숙한것 같습니다! useCoupon 으로 변경하겠습니다 감사합니다!

Comment on lines 84 to 93
ROUND(CASE
WHEN p.sale_price IS NULL THEN
p.original_price -
IF(c.discount_type = 'PERCENT', p.original_price * (c.discount_rate / 100),
c.discount_price)
ELSE
p.sale_price - IF(c.discount_type = 'PERCENT', p.sale_price * (c.discount_rate / 100),
c.discount_price)
END, 0)
AS discounted_price
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

와! 제가 항상 고민했지만 확신이 없던 내용인데, 이렇게 원인과 케이스별로 정리해주셔서 감사합니다! 애플리케이션 내에서 가공하도록 수정하겠습니다!

Comment on lines 99 to 100
AND c.validate_start_date <= #{currentDateTime}
AND c.validate_end_date >= #{currentDateTime}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네엡 변경했습니다 감사합니다~

Comment on lines 51 to 52
List<AvailableCouponsByMemberIdResponse> responseList = new ArrayList<>();
filterAvailableCouponsWithMinPrice(productId, availableCoupons, responseList);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀해주신 filter를 사용해보려고 했는데, 할인 가격이 minOrderPrice를 초과하지 않는 범위까지만 누적하는거라 filter 사용이 어려울것 같습니답...

Comment on lines 86 to 88
select id as couponId,
if(validate_start_date <= #{currentDateTime} and validate_end_date >= #{currentDateTime}, true,
false) as isBetweenValidatePeriod,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

애플리케이션에서 처리하도록 수정했습니다!

Comment on lines 63 to 78
private void filterAvailableCouponsWithMinPrice(long productId, List<AvailableCouponsByMemberIdVo> availableCoupons, List<AvailableCouponsByMemberIdResponse> responseList) {
// min_price(상품의 최소 주문 금액)과 비교하여 조건에 맞는 쿠폰만 결과 리스트에 추가합니다.
if (!availableCoupons.isEmpty()) {
long accumulatedDiscountPrice = 0;
for (AvailableCouponsByMemberIdVo availableCoupon : availableCoupons) {
// 최소 주문 금액까지 할인 가능한 금액 누적
accumulatedDiscountPrice += availableCoupon.discountedPrice();
// 할인 금액이 min_price보다 작거나 같으면 결과 리스트에 추가
if (accumulatedDiscountPrice <= productRepository.getProductMinOrderPriceById(productId)) {
AvailableCouponsByMemberIdResponse response = new AvailableCouponsByMemberIdResponse(availableCoupon);
responseList.add(response);
}
}
}
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

인덴트를 줄이기 위해 람다표현식과 AtomicReference를 사용하는걸로 변경했습니다!

Copy link
Collaborator

@f-lab-maverick f-lab-maverick left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다~ 코멘트 확인해서 수정 후 머지해주세요.

private final MyPageService myPageService;
@ResponseStatus(HttpStatus.OK)
@GetMapping("/coupons")
public ResponseEntity<ResponseDTO<List<AllCouponsByMemberIdResponse>>> getAllCoupons(@RequestParam final long memberId) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 이번 PR에서 수정하지 않고 미루기로했으니, 우선 해당 URL이 오픈되지 않도록 핸들러매핑이 연결되지 않게 @GetMapping("/coupons")을 지워주세요~~

Comment on lines 81 to 83
} else {
return calculateDiscountPrice(vo.discountType(), vo.originalPrice(), discountRate, discountPrice);
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

else문 없이도 정상 동작 할 것으로 보이네요~ else, else if문은 읽을 때 개발자의 피로감을 늘릴 수 있기 때문에 안쓰는 방법을 연습하는게 좋습니다.

Suggested change
} else {
return calculateDiscountPrice(vo.discountType(), vo.originalPrice(), discountRate, discountPrice);
}
}
return calculateDiscountPrice(vo.discountType(), vo.originalPrice(), discountRate, discountPrice);

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 early return으로 변경했습니다!

Comment on lines 168 to 173
for (CouponValidationPeriodVo validationPeriod : isBetweenValidatePeriodVo) {
if (!isNowBetweenValidatePeriod(now, validationPeriod)) {
throw new CouponUsageInvalidPeriodException(COUPON_USAGE_INVALID_PERIOD
.formatted(validationPeriod.couponId(), validationPeriod.validateStartDate(), validationPeriod.validateEndDate()));
}
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이런 부분이 stream의 anymatch를 적용하면 훨씬 깔끔하게 코드가 변경됩니다~

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 stream API사용하는걸로 변경했습니다!

Copy link
Collaborator Author

@codesejin codesejin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

피드백 반영해서 수정했습니다~

Comment on lines 81 to 83
} else {
return calculateDiscountPrice(vo.discountType(), vo.originalPrice(), discountRate, discountPrice);
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 early return으로 변경했습니다!

Comment on lines 168 to 173
for (CouponValidationPeriodVo validationPeriod : isBetweenValidatePeriodVo) {
if (!isNowBetweenValidatePeriod(now, validationPeriod)) {
throw new CouponUsageInvalidPeriodException(COUPON_USAGE_INVALID_PERIOD
.formatted(validationPeriod.couponId(), validationPeriod.validateStartDate(), validationPeriod.validateEndDate()));
}
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 stream API사용하는걸로 변경했습니다!

private final MyPageService myPageService;
@ResponseStatus(HttpStatus.OK)
@GetMapping("/coupons")
public ResponseEntity<ResponseDTO<List<AllCouponsByMemberIdResponse>>> getAllCoupons(@RequestParam final long memberId) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네엡 다음번 PR에서 수정하도록하겠습니다!

Copy link

@codesejin codesejin merged commit bc8e470 into develop Apr 17, 2024
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants