Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
a47b1ad
fix #98 : 응답이 없을때 response type을 void로 수정
hyeonji91 Oct 23, 2025
4f3ebab
feature #166 : 주문하기 controller단
hyeonji91 Oct 23, 2025
e719184
feature #166 : 주문하기 - 상품 확인
hyeonji91 Oct 23, 2025
f676c3e
feature #116 : 주문하기 - 재고 차감
hyeonji91 Oct 23, 2025
3ac8140
feature #166 : 쿠폰 유효성 검증 및 할인 금액 계산 로직 추가
hyeonji91 Oct 23, 2025
e2f9700
feature #166 : 쿠폰 사용 처리 로직
hyeonji91 Oct 23, 2025
b7bdf24
feature #166 : tosspayments 결제 승인 API
hyeonji91 Oct 23, 2025
adf7302
feature #166 : 결제하기 API
hyeonji91 Oct 23, 2025
cd46b94
feature #166 : spring cloud version수정
hyeonji91 Oct 23, 2025
e95abbb
feature #166: 결제 실패시 재고 되돌리기
hyeonji91 Oct 23, 2025
ec9fc60
feature #166: 결제 완료 후 payment 업데이트
hyeonji91 Oct 23, 2025
5f614ac
Merge branch 'feature/#166-customer-order-api' of https://github.com/…
hyeonji91 Oct 24, 2025
3ec78a2
style #166 : pessimisticLock이름 변경
hyeonji91 Oct 24, 2025
20e471e
feature #166: 주문하기
hyeonji91 Oct 24, 2025
4080205
feature #166 : 쿠폰 롤백
hyeonji91 Oct 24, 2025
bd8f432
feature #166 : response에 가격 세분화 (상품 총 가격, 실 결제 금액, 할인 금액)
hyeonji91 Oct 24, 2025
e3adbc9
style #166 : spotless적용
hyeonji91 Oct 24, 2025
b041776
feature #166 : tosspaymentkey, tossorderId
hyeonji91 Oct 24, 2025
1d25dfa
feature #166 : payment method추가
hyeonji91 Oct 24, 2025
9dada18
Merge branch 'develop' into feature/#166-customer-order-api
hyeonji91 Oct 24, 2025
a955730
fix #187 : 기본 배송비 배송비 정책에서 가져오기
hyeonji91 Oct 24, 2025
f2a0308
feature #166 : 환경변수 주입
hyeonji91 Oct 24, 2025
4205911
fix #187 : 코드 리뷰 반영
hyeonji91 Oct 25, 2025
275499e
refactor #187 : 재고 보상트랜젝션 중 N+1 이슈 해결
hyeonji91 Oct 25, 2025
ab70c78
refactor #187 : 주문하기 API - 쿼리 조회 횟수 감소
hyeonji91 Oct 25, 2025
8e737ff
refactor #166 : tosspayment 호출 로직 비즈니스 로직과 분리
hyeonji91 Oct 25, 2025
ae40ee7
style #187 : spotless적용
hyeonji91 Oct 25, 2025
829143e
feature #166 : preparePayment 호출 전 결제 취소 시나리오 대응 - 스케줄러
hyeonji91 Oct 25, 2025
c407e7c
feature #187 : 물품 수량 음수 방지 - request validaton 추가
hyeonji91 Oct 27, 2025
b162bd0
feature #187 : 물품 옵션 값 repository - @param추가
hyeonji91 Oct 27, 2025
eea163a
style : spotless 적용
hyeonji91 Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ clean {
delete querydslDirProperty
}

//feign client를 위한 springcloud
ext {
set('springCloudVersion', "2025.0.0")
}

dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
}
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'

Expand All @@ -76,6 +87,10 @@ dependencies {
annotationProcessor "com.querydsl:querydsl-apt::jakarta"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"

//feign client
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
implementation group: 'io.github.openfeign', name: 'feign-gson', version: '11.0'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/irum/come2us/Come2usApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableFeignClients
public class Come2usApplication {

public static void main(String[] args) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.irum.come2us.domain.coupon.application.service;

import com.irum.come2us.domain.coupon.domain.entity.AppliedCoupon;
import com.irum.come2us.domain.coupon.domain.entity.Coupon;
import com.irum.come2us.domain.coupon.domain.repository.AppliedCouponRepository;
import com.irum.come2us.domain.coupon.domain.repository.CouponRepository;
import com.irum.come2us.domain.payment.domain.entity.Payment;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class AppliedCouponService {
private final AppliedCouponRepository appliedCouponRepository;
private final CouponRepository couponRepository;

/** 쿠폰 사용 처리 */
public void createAppliedCouponList(Payment payment, List<UUID> couponIdList) {
List<Coupon> couponList = couponRepository.findAllById(couponIdList);

List<AppliedCoupon> appliedCouponList =
couponList.stream()
.map(
coupon ->
AppliedCoupon.builder()
.payment(payment)
.coupon(coupon)
.build())
.toList();

appliedCouponRepository.saveAll(appliedCouponList);
}

/** 롤백 */
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void rollbackAppliedCouponList(Payment payment) {
appliedCouponRepository.deleteByPayment(payment);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.irum.come2us.domain.coupon.application.service;

import com.irum.come2us.domain.coupon.domain.entity.Coupon;
import com.irum.come2us.domain.coupon.domain.repository.AppliedCouponRepository;
import com.irum.come2us.domain.coupon.domain.repository.CouponRepository;
import com.irum.come2us.domain.coupon.presentation.dto.request.CouponGenerateRequest;
import com.irum.come2us.domain.member.domain.entity.Member;
import com.irum.come2us.domain.member.domain.repository.MemberRepository;
import com.irum.come2us.global.presentation.advice.exception.CommonException;
import com.irum.come2us.global.presentation.advice.exception.errorcode.CouponErrorCode;
import com.irum.come2us.global.presentation.advice.exception.errorcode.MemberErrorCode;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,6 +22,7 @@
public class CouponService {
private final CouponRepository couponRepository;
private final MemberRepository memberRepository;
private final AppliedCouponRepository appliedCouponRepository;

public void createCoupon(CouponGenerateRequest request, Long memberId) {

Expand Down Expand Up @@ -51,4 +54,36 @@ public void deleteCoupon(UUID couponId, Long memberId) {
}
couponRepository.delete(coupon);
}

/** 쿠폰 유효성 검증 및 할인 금액 계산 */
public int validAndCalCoupon(List<UUID> couponIdList, int calculatedTotalPrice, Member member) {
if (couponIdList.isEmpty()) {
return 0;
}

int totalDiscount = 0;
List<Coupon> couponList = couponRepository.findAllById(couponIdList);

for (Coupon coupon : couponList) {
// 권한 검사
if (!coupon.getMember().getMemberId().equals(member.getMemberId())) {
throw new CommonException(CouponErrorCode.COUPON_NO_PERMISSION);
}
// 만료일 검사
if (coupon.getExpiration().isBefore(LocalDateTime.now())) {
throw new CommonException(CouponErrorCode.COUPON_EXPIRATION);
}
// 사용 여부 검사
if (appliedCouponRepository.existsByCouponId(coupon.getId())) {
throw new CommonException(CouponErrorCode.COUPON_ALREADY_USED);
}
totalDiscount += coupon.getDiscountAmount();
}

if (totalDiscount > calculatedTotalPrice) {
totalDiscount = calculatedTotalPrice;
}

return totalDiscount;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import jakarta.persistence.*;
import java.util.UUID;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
Expand All @@ -16,6 +18,8 @@
@Entity
@Table(name = "p_applied_coupon")
@Getter
@Builder
@AllArgsConstructor
@SQLDelete(sql = "UPDATE p_applied_coupon SET deleted_at = NOW() WHERE applied_coupon_id = ?")
@Where(clause = "deleted_at IS NULL")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package com.irum.come2us.domain.coupon.domain.repository;

import com.irum.come2us.domain.coupon.domain.entity.AppliedCoupon;
import com.irum.come2us.domain.payment.domain.entity.Payment;
import java.util.List;
import java.util.UUID;
import org.springframework.data.jpa.repository.JpaRepository;

public interface AppliedCouponRepository extends JpaRepository<AppliedCoupon, UUID> {
List<AppliedCoupon> findByCouponId(UUID couponId);

boolean existsByCouponId(UUID couponId);

void deleteByPayment(Payment payment);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.irum.come2us.domain.order.application.mapper;

import com.irum.come2us.domain.order.domain.entity.Order;
import com.irum.come2us.domain.order.domain.entity.OrderDetail;
import com.irum.come2us.domain.order.presentation.dto.response.AddressResponse;
import com.irum.come2us.domain.order.presentation.dto.response.CustomerOrderResponse;
import java.util.List;
import org.springframework.stereotype.Component;

@Component
public class CustomerOrderMapper {

public static CustomerOrderResponse toCustomerOrderResponse(
Order order, List<OrderDetail> orderDetailList) {
List<CustomerOrderResponse.ProductSummary> productSummaryList =
orderDetailList.stream().map(CustomerOrderMapper::toProductSummary).toList();

return CustomerOrderResponse.builder()
.orderId(order.getOrderId())
.address(AddressResponse.from(order.getDeliveryAddress().getAddress()))
.totalProductPrice(order.getTotalPrice())
.totalDiscountAmount(order.getPayment().getTotalDiscountAmount())
.totalPaymentAmount(order.getPayment().getAmount())
.prodcutList(productSummaryList)
Comment thread
hyeonji91 marked this conversation as resolved.
Outdated
.build();
}

private static CustomerOrderResponse.ProductSummary toProductSummary(OrderDetail orderDetail) {
return CustomerOrderResponse.ProductSummary.builder()
.orderDetailId(orderDetail.getOrderDetailId())
.productName(orderDetail.getProductName())
.price(orderDetail.getPrice())
.quantity(orderDetail.getQuantity())
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.irum.come2us.domain.order.application.service;

import com.irum.come2us.domain.coupon.application.service.AppliedCouponService;
import com.irum.come2us.domain.coupon.application.service.CouponService;
import com.irum.come2us.domain.deliveryaddress.domain.entity.DeliveryAddress;
import com.irum.come2us.domain.deliveryaddress.domain.repository.DeliveryAddressRepository;
import com.irum.come2us.domain.member.domain.entity.Member;
import com.irum.come2us.domain.order.application.mapper.CustomerOrderMapper;
import com.irum.come2us.domain.order.domain.entity.Order;
import com.irum.come2us.domain.order.domain.entity.OrderDetail;
import com.irum.come2us.domain.order.domain.entity.enums.OrderStatus;
import com.irum.come2us.domain.order.domain.repository.OrderDetailRepository;
import com.irum.come2us.domain.order.domain.repository.OrderRepository;
import com.irum.come2us.domain.order.presentation.dto.request.CustomerOrderRequest;
import com.irum.come2us.domain.order.presentation.dto.response.CustomerOrderResponse;
import com.irum.come2us.domain.payment.application.service.PaymentService;
import com.irum.come2us.domain.payment.domain.entity.Payment;
import com.irum.come2us.domain.payment.domain.entity.enums.PaymentCorp;
import com.irum.come2us.domain.product.domain.entity.Product;
import com.irum.come2us.domain.product.domain.entity.ProductOptionValue;
import com.irum.come2us.domain.product.domain.repository.ProductOptionValueRepository;
import com.irum.come2us.domain.product.domain.repository.ProductRepository;
import com.irum.come2us.domain.store.domain.entity.Store;
import com.irum.come2us.domain.store.domain.repository.StoreRepository;
import com.irum.come2us.global.presentation.advice.exception.CommonException;
import com.irum.come2us.global.presentation.advice.exception.errorcode.DeliveryAddressErrorCode;
import com.irum.come2us.global.presentation.advice.exception.errorcode.OrderErrorCode;
import com.irum.come2us.global.presentation.advice.exception.errorcode.ProductErrorCode;
import com.irum.come2us.global.presentation.advice.exception.errorcode.StoreErrorCode;
import com.irum.come2us.global.util.MemberUtil;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
@Slf4j
@RequiredArgsConstructor
public class CustomerOrderService {
private final MemberUtil memberUtil;
private final ProductOptionValueRepository productOptionValueRepository;
private final ProductRepository productRepository;
private final StoreRepository storeRepository;
private final DeliveryAddressRepository deliveryAddressRepository;
private final OrderDetailRepository orderDetailRepository;
private final CouponService couponService;
private final AppliedCouponService appliedCouponService;
private final PaymentService paymentService;
private final OrderRepository orderRepository;

public CustomerOrderResponse prepareOrder(CustomerOrderRequest request) {
Member member = memberUtil.getCurrentMember();
Store store =
storeRepository
.findById(request.storeId())
.orElseThrow(() -> new CommonException(StoreErrorCode.STORE_NOT_FOUND));
DeliveryAddress deliveryAddress =
deliveryAddressRepository
.findById(request.deliveryAddressId())
.orElseThrow(
() ->
new CommonException(
DeliveryAddressErrorCode
.DELIVERY_ADDRESS_NOT_FOUND));
log.info("[주문준비] 멤버 {} {} , 상점, 주소 검색", member.getMemberId(), member.getName());

// 상품 확인, 재고확인, 상품 정보 조회 , 가격 계산, 주문 상세 엔티티 생성 준비
int calculatedTotalPrice = 0;
int productCount = 0;
List<OrderDetail> orderDetails = new ArrayList<>();
for (CustomerOrderRequest.ProductSummary productReq : request.productList()) {
Comment thread
hyeonji91 marked this conversation as resolved.
// 상품이 해당 상점의 상품인지 확인
Product product =
productRepository
.findById(productReq.productId())
.orElseThrow(
() -> new CommonException(ProductErrorCode.PRODUCT_NOT_FOUND));
if (!product.getStore().getId().equals(store.getId())) {
throw new CommonException(OrderErrorCode.INVALID_ORDER);
}

// 재고 확인
ProductOptionValue productOptionValue =
productOptionValueRepository
.findByIdWithLock(productReq.optionValueId())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: 재고 동시성 문제에 대해 많이 고민하신 것 같습니다. 👍
다만 한가지 우려스러운 점은, JPA 레벨의 Lock 이 Transaction단위로 처리된다는 것입니다.
락이 유지되는 동안 타 유저들은 해당 상품 옵션에 대한 접근 자체가 불가능해진다는 것인데, 이 부분은 고려가 좀 더 필요하지 않나 싶습니다. 특히 2차 스프린트에서 서비스가 분리되면, 결제나 쿠폰 관련 서비스로부터의 응답에 생기는 지연 만큼 락의 유지 시간도 함께 연장되어 문제가 더 클 것 같습니다. 혹시 이와 관련하여 찾아보신 부분이 있다면 공유 부탁드립니다!
@Bal1oon

-> Repository 단에서 lock 유지 시간 3초로 제한해 두신 것 확인했습니다! 추후 분산 환경 처리 성능 따라 시간 조정은 검토해볼만 한 것 같네요:)

.orElseThrow(
() ->
new CommonException(
ProductErrorCode
.PRODUCT_OPTION_VALUE_NOT_FOUND));
if (productOptionValue.getStockQuantity() < productReq.quantity()) {
throw new CommonException(ProductErrorCode.PRODUCT_OUT_OF_STOCK);
}

// 제품 가격 계산
int productPrice =
(product.getPrice() + productOptionValue.getExtraPrice())
* productReq.quantity();
calculatedTotalPrice += productPrice;
productCount += productReq.quantity();

// 재고 미리 차감
productOptionValue.decreaseStock(productReq.quantity());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

P3: 현재 구매를 진행중인 사용자의 재고 선점을 위해 구현하신 로직인 것 같습니다.
만약 주문을 통해 Pending 상태로 생성된 결제가 사용자의 서비스 중도 이탈, 결제 서비스 오류 등으로 상태 변경이 이루어지지 않는 경우, 해당 재고에 대한 보상 로직 등이 구현되어 있을까요?
만약 구현되어 있지 않다면, Pending 걸려있는 재고에 대한 주문들을 스케줄링으로 취소시키는 등의 로직이 필요할 것 같습니다.
추후 Kafka가 사용될 부분일 것 같네요


OrderDetail orderDetail =
OrderDetail.builder()
.product(product)
.price(productPrice)
.quantity(productReq.quantity())
.orderStatusIndi(OrderStatus.PENDING)
.optionName(productOptionValue.getName())
.productName(product.getName())
.productOptionValue(productOptionValue)
.build();
orderDetails.add(orderDetail);
}
log.info("상품 확인, 재고 확인, 재고 차감, 가격 계산 완료");

/** 배송비 적용* */
int deliveryFee = store.getDeliveryPolicy().getDefaultDeliveryFee();
int deliveryMinAmount = store.getDeliveryPolicy().getMinAmount();
int deliveryMinQuantity = store.getDeliveryPolicy().getMinQuantity();
if (calculatedTotalPrice > deliveryMinAmount || productCount > deliveryMinQuantity) {
deliveryFee = 0;
}
log.info("배송비 {}", deliveryFee);

/** 할인 적용 */
int discountAmount =
couponService.validAndCalCoupon(
request.couponIdList(), calculatedTotalPrice, member);
int finalPaymentAmount = calculatedTotalPrice - discountAmount;
log.info("할인 {}, 할인 후 가격 {}", discountAmount, finalPaymentAmount);

/** 결재 생성 PENDING 상태* */
Payment payment =
paymentService.preparePayment(
member, finalPaymentAmount, discountAmount, PaymentCorp.TOSS);
// 쿠폰 미리 차감
appliedCouponService.createAppliedCouponList(payment, request.couponIdList());
Comment thread
hyeonji91 marked this conversation as resolved.

// 주문 엔티티 생성 PENDING 상태
String orderId = "ORD-" + (int) ((Math.random() * 10000000));
Comment thread
hyeonji91 marked this conversation as resolved.
Outdated

Order order =
Order.builder()
.orderNum(orderId)
.totalPrice(calculatedTotalPrice)
.deliveryFee(deliveryFee)
.deliveryRequest(request.deliveryRequest())
.orderStatusAll(OrderStatus.PENDING)
.member(member)
.store(store)
.payment(payment)
.deliveryAddress(deliveryAddress)
.build();
orderRepository.save(order);

/** 주문 상세 저장* */
for (OrderDetail orderDetail : orderDetails) {
orderDetail.updateOrder(order);
orderDetailRepository.save(orderDetail);
}

return CustomerOrderMapper.toCustomerOrderResponse(order, orderDetails);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
@RequiredArgsConstructor
@Slf4j
@Transactional
public class OrderService {
public class OwnerOrderService {
private final OrderDetailRepository orderDetailRepository;
private final OrderRepository orderRepository;
private final OrderRepositoryCustom orderRepositoryCustom;
Expand Down
Loading