diff --git a/.dockerignore b/.dockerignore index 21106d2..0f6b750 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,7 +4,7 @@ .env.local .gitignore README.md -.gitmessage +.gitmessage.txt LICENSE SunStyle_edited.xml codecov.yml diff --git a/.gitmessage b/.gitmessage.txt similarity index 100% rename from .gitmessage rename to .gitmessage.txt diff --git a/README.md b/README.md index 3f720fb..ce76161 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,95 @@ ![Codecov](https://img.shields.io/badge/Codecov-F01F7A?logo=codecov&logoColor=white) ![GitHub Actions](https://img.shields.io/badge/GitHub%20Actions-2088FF?logo=githubactions&logoColor=white) -# 관심사 +# 주문 서버 관심사 +### 주문 및 결제 과정 -# 정보 +1. 클라이언트는 주문 서버에 주문 요청 +2. 주문 서버는 클라이언트 요청의 유효성을 검사 + 1. `PENDING_ORDER` 상태의 유형제품을 선택 + 2. 유형제품의 상태를 `PROCESSING`로 변경 + 3. 주문에 유형 제품을 추가 + 4. 만약 주문 요청한 수량만큼 `PENDING_ORDER` 상태의 유형제품이 없다면, `재고가 부족합니다. ` 문구를 클라이언트에게 응답 +3. 주문 서버가 결제 서버에 결제 요청 +4. 결제 서버가 결제 요청 받았다고 응답 +5. 주문 서버는 결제 요청이 들어간 것을 클라이언트에게 전달 +6. 결제 서버가 결제 결과를 주문 서버에 전달 + 1. 결제가 정상적으로 완료되면, + 1. 유형제품의 상태를 `SHIPPING`으로 변경 + 2. 핵심제품의 재고를 차감 + 2. 결제가 실패되면, + 1. 재고 유지 + 2. 주문의 유형제품 상태를 `PENDING_ORDER`로 변경 +7. 주문 서버는 받는 결과를 클라이언트에게 전달 + +### 클라이언트 주문 요청 형태 + +```json +{ + "core_products" : { + "1" : "30", + "2" : "10" + }, + "client_type" : "InexperiencedCustomer", + "payment_method" : "CREDIT_CARD" +} +``` + +### 결제 요청 형태 +```json +{ + "buyer": { + "name": "John Doe", + "email": "a1@ex.com" + }, + "seller": { + "name": "Jane Doe", + "email": "a2@ex.com" + }, + "payment": "CREDIT_CARD", + "price": 100, + "redirect": "http://localhost:8080/payment/confirm" +} +``` + +### 상품의 상태에 대한 설명 + +`PENDING_ORDER` : 유형제품이 주문에 포함되지 않는 기본적인 상태를 의미. 여러 주문에서 동시에 접근하면 한 주문에만 들어간다. + +`PROCESSING` : 유형제품이 주문에 포함되며, 결제 결과를 기다리는 상태를 의미. + +`SHIPPING` : 결제가 정상적으로 종료되고, 해당 유형제품이 온전히 고객의 소유가 되는 상태. + +`DELIVERED` : 상품이 고객에게 배송 완료된 상태. 현재 프로젝트에서는 배송까지는 관심사가 아니기 때문에 사용하지 않는다. + +### 클라이언트 요청 유효성 검사 리스트 + +- [ ] 핵심제품 id가 존재하는가? +- [ ] 핵심제품의 재고가 충분한가? +- [ ] `PENDING_ORDER` 상태의 유형제품이 충분한가? +- [ ] 클라이언트가 상품 구매 권한이 있는가? +- [ ] 결제 방식이 유효한 방식인가? + +## 시퀀스 다이어그램 + +![count-sequence.png](./count-sequence.png) + +**Transaction 1** + +- 사용자 권한 확인 +- 재고 확인 +- 주문 생성 +- 상품을 결제 중으로 상태 변환 + +**Transaction 2** + +- 결제 결과가 성공일 때, + - 상품 상태를 SHIPMENT 로 변경 + - 주문의 상태를 FINISH 로 변경 +- 에러가 발생하거나, 결제가 실패했을 때, + - 상품의 상태를 PENDING_ORDER 으로 변경 + - 상품 재고를 원복 + - 주문의 상태를 FAIL 로 변경 + +**Transaction 3** +- 리디렉션 정보에 따라 주문을 읽기 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 024de9f..78b5a77 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,16 @@ repositories { } @Suppress("SpellCheckingInspection") dependencies { + // spring-distribute-lock + implementation("org.springframework.integration:spring-integration-jdbc") + implementation("org.springframework.boot:spring-boot-starter-jdbc") + implementation("org.springframework.boot:spring-boot-starter-integration") + + // spring-web-payment + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.5.0") + runtimeOnly("io.netty:netty-resolver-dns-native-macos:4.1.94.Final:osx-aarch_64") + // spring-web-jpa-concurrency implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/count-sequence.png b/count-sequence.png new file mode 100644 index 0000000..874db3a Binary files /dev/null and b/count-sequence.png differ diff --git a/docker-compose.yaml b/docker-compose.yaml index 3db6d65..bc89d3a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -21,8 +21,8 @@ services: depends_on: mysql: condition: service_healthy - networks: - - private-subnet +# networks: +# - private-subnet mysql: <<: *mysql-template @@ -30,13 +30,13 @@ services: container_name: mysql-dev ports: - "3306:3306" - networks: - - private-subnet - -networks: - private-subnet: # private: 172.19.0.x - driver: bridge - ipam: - driver: default - config: - - subnet: 172.19.0.0/24 +# networks: +# - private-subnet +# +#networks: +# private-subnet: # private: 172.19.0.x +# driver: bridge +# ipam: +# driver: default +# config: +# - subnet: 172.19.0.0/24 diff --git a/src/main/java/com/concurrency/config/EnumMappingConfig.java b/src/main/java/com/concurrency/config/EnumMappingConfig.java new file mode 100644 index 0000000..5ab3258 --- /dev/null +++ b/src/main/java/com/concurrency/config/EnumMappingConfig.java @@ -0,0 +1,14 @@ +package com.concurrency.config; + +import org.springframework.boot.convert.ApplicationConversionService; +import org.springframework.context.annotation.Configuration; +import org.springframework.format.FormatterRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class EnumMappingConfig implements WebMvcConfigurer { + @Override + public void addFormatters(FormatterRegistry registry) { + ApplicationConversionService.configure(registry); + } +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/config/JDBCConfig.java b/src/main/java/com/concurrency/config/JDBCConfig.java new file mode 100644 index 0000000..2ff0b5a --- /dev/null +++ b/src/main/java/com/concurrency/config/JDBCConfig.java @@ -0,0 +1,21 @@ +package com.concurrency.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.integration.jdbc.lock.DefaultLockRepository; +import org.springframework.integration.jdbc.lock.JdbcLockRegistry; +import org.springframework.integration.jdbc.lock.LockRepository; + +import javax.sql.DataSource; + +@Configuration +public class JDBCConfig { + @Bean + public DefaultLockRepository DefaultLockRepository(DataSource dataSource){ + return new DefaultLockRepository(dataSource); + } + @Bean + public JdbcLockRegistry jdbcLockRegistry(LockRepository lockRepository){ + return new JdbcLockRegistry(lockRepository); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/ActualProduct.java b/src/main/java/com/concurrency/jpa/customer/Product/ActualProduct.java deleted file mode 100644 index b07f3af..0000000 --- a/src/main/java/com/concurrency/jpa/customer/Product/ActualProduct.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.concurrency.jpa.customer.Product; - - -import com.concurrency.jpa.customer.order.Order; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@NoArgsConstructor -@AllArgsConstructor -@Builder -@Table(name = "actual_product") -public class ActualProduct { - @Id - @Column(name = "actual_product_id") - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Enumerated(EnumType.STRING) - @Column(name = "actual_status", columnDefinition = "varchar(255)") - private OrderStatus actualStatus; - - @ManyToOne(targetEntity = CoreProduct.class) - @JoinColumn(name = "core_product_id", nullable = false) - private CoreProduct coreProduct; - - @ManyToOne(targetEntity = Order.class) - @JoinColumn(name = "order_id") - private Order order; - - @Getter - @Column(name = "actual_price") - private Long actualPrice; - - @Column(name = "discount_rate") - private float discountRate; -} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/ActualProductRepository.java b/src/main/java/com/concurrency/jpa/customer/Product/ActualProductRepository.java index 053b3e6..a6f7391 100644 --- a/src/main/java/com/concurrency/jpa/customer/Product/ActualProductRepository.java +++ b/src/main/java/com/concurrency/jpa/customer/Product/ActualProductRepository.java @@ -1,8 +1,29 @@ package com.concurrency.jpa.customer.Product; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import jakarta.persistence.LockModeType; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; @Repository public interface ActualProductRepository extends JpaRepository{ + @Query(value = "SELECT count(a.id) FROM ActualProduct a " + + "WHERE a.coreProduct.id = :coreId AND a.actualStatus = :actualStatus") + Long countByCoreProductIdANDActualStatus(@Param("coreId") Long k, @Param("actualStatus") ActualStatus actualStatus); + + @Lock(LockModeType.PESSIMISTIC_READ) + @Query(value = "SELECT a FROM ActualProduct a " + + "WHERE a.coreProduct.id = :coreProductId AND a.actualStatus = :reqStatus") + List findByCoreProduct_IdAndActualStatus(@Param("coreProductId") Long coreProductId, @Param("reqStatus")ActualStatus reqStatus, Pageable pageable); + List findByOrder_Id(Long orderId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT a FROM ActualProduct a join fetch a.coreProduct c WHERE a.order.id = :orderId") + List findByOrder_IdPessimistic(@Param("orderId") Long orderId); } diff --git a/src/main/java/com/concurrency/jpa/customer/Product/CoreProductRepository.java b/src/main/java/com/concurrency/jpa/customer/Product/CoreProductRepository.java index 950ed81..7451598 100644 --- a/src/main/java/com/concurrency/jpa/customer/Product/CoreProductRepository.java +++ b/src/main/java/com/concurrency/jpa/customer/Product/CoreProductRepository.java @@ -1,8 +1,20 @@ package com.concurrency.jpa.customer.Product; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface CoreProductRepository extends JpaRepository { + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select c from CoreProduct c where c.id = :id" ) + Optional findByIdPessimistic(@Param("id") Long id); } diff --git a/src/main/java/com/concurrency/jpa/customer/Product/OrderStatus.java b/src/main/java/com/concurrency/jpa/customer/Product/OrderStatus.java deleted file mode 100644 index b7ea0da..0000000 --- a/src/main/java/com/concurrency/jpa/customer/Product/OrderStatus.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.concurrency.jpa.customer.Product; - -public enum OrderStatus { - PENDING_ORDER, - PENDING_PAYMENT, - PROCESSING, - SHIPPED, - DELIVERED -} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/ProductService.java b/src/main/java/com/concurrency/jpa/customer/Product/ProductService.java new file mode 100644 index 0000000..ad35940 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/ProductService.java @@ -0,0 +1,18 @@ +package com.concurrency.jpa.customer.Product; + +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; + +import java.util.List; +import java.util.Map; + +public interface ProductService { + List findActualProductsByOrder(Long orderId); + List findActualProducts(Long coreProductId, ActualStatus actualStatus, Long stock); + + List concatActualProductList(Map coreProducts); + void updateCoreProductsStock(Map requireProducts); + long subtractCoreProductStock(Long coreProductId, Long reqStock); + long subtractCoreProductStockPessimistic(Long coreProductId, Long reqStock); + long subtractCoreProductStockOptimistic(Long coreProductId, Long reqStock); +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/ProductServiceImpl.java b/src/main/java/com/concurrency/jpa/customer/Product/ProductServiceImpl.java new file mode 100644 index 0000000..95f73d9 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/ProductServiceImpl.java @@ -0,0 +1,119 @@ +package com.concurrency.jpa.customer.Product; + +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.common.BaseResponseStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ProductServiceImpl implements ProductService{ + private final ActualProductRepository actualProductRepository; + private final CoreProductRepository coreProductRepository; + + /** + * 요청한 상품의 유형제고가 충분한지 확인 + * @param requireProducts + */ + @Override + @Transactional + public void updateCoreProductsStock(Map requireProducts) { + requireProducts.forEach(this::subtractCoreProductStockPessimistic); + } + + @Override + @Transactional + public List concatActualProductList(Map coreProducts) { + List actualProducts = new ArrayList<>(); + coreProducts.forEach((coreProductId, stock) ->{ + actualProducts.addAll( + findActualProducts( + coreProductId, + ActualStatus.PENDING_ORDER, + stock)); + } + ); + return actualProducts; + } + + @Override + @Transactional + public List findActualProducts(Long coreProductId, ActualStatus actualStatus, Long stock){ + List actualProducts = actualProductRepository.findByCoreProduct_IdAndActualStatus( + coreProductId, + actualStatus, + PageRequest.of(0, Math.toIntExact(stock))); + return actualProducts; + } + + @Override + @Transactional + public List findActualProductsByOrder(Long orderId){ + return actualProductRepository.findByOrder_IdPessimistic(orderId); + } + + + + ///////////////////////// 재고량 감소 + + @Override + @Transactional + public long subtractCoreProductStock(Long coreProductId, Long reqStock){ + CoreProduct coreProduct = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + if (reqStock > coreProduct.getStock()) { + throw new BaseException(BaseResponseStatus.NOT_ENOUGH_STOCK); + } + return coreProduct.addStrock(-reqStock); + } + + @Transactional + public long subtractCoreProductStockPessimistic(Long coreProductId, Long reqStock){ + System.out.println("핵심 상품 쿼리"); + CoreProduct coreProduct = coreProductRepository.findByIdPessimistic(coreProductId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + long stock = coreProduct.getStock(); + if(reqStock > stock){ + throw new BaseException(BaseResponseStatus.NOT_ENOUGH_STOCK); + } + return coreProduct.addStrock(-reqStock); + } + + @Transactional(isolation = Isolation.REPEATABLE_READ) + public long subtractCoreProductStockOptimistic(Long coreProductId, Long reqStock) { + int patience = 0; + while(true){ + try{ + try{ + CoreProduct coreProduct = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + if(reqStock > coreProduct.getStock()){ + throw new BaseException(BaseResponseStatus.NOT_ENOUGH_STOCK); + } + return coreProduct.addStrock(-reqStock); + } + catch(Exception oe){ + if(patience == 10){ + throw new BaseException(BaseResponseStatus.OPTIMISTIC_FAILURE); + } + patience++; + Thread.sleep(500); + } + } + catch(Exception e){ + throw new RuntimeException(e); + } + + } + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/dto/OrderCoreProductStockDto.java b/src/main/java/com/concurrency/jpa/customer/Product/dto/OrderCoreProductStockDto.java new file mode 100644 index 0000000..2d398fb --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/dto/OrderCoreProductStockDto.java @@ -0,0 +1,9 @@ +package com.concurrency.jpa.customer.Product.dto; + +import lombok.Value; + +@Value +public class OrderCoreProductStockDto { + Long coreProductId; + Long reduceStock; +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/dto/StockDto.java b/src/main/java/com/concurrency/jpa/customer/Product/dto/StockDto.java new file mode 100644 index 0000000..d29153e --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/dto/StockDto.java @@ -0,0 +1,10 @@ +package com.concurrency.jpa.customer.Product.dto; + +import lombok.Getter; +import lombok.Value; + +@Value +@Getter +public class StockDto { + Long stock; +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/entity/ActualProduct.java b/src/main/java/com/concurrency/jpa/customer/Product/entity/ActualProduct.java new file mode 100644 index 0000000..278b691 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/entity/ActualProduct.java @@ -0,0 +1,61 @@ +package com.concurrency.jpa.customer.Product.entity; + + +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.dto.ActualProductDto; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "actual_product") +public class ActualProduct { + @Id + @Getter + @Column(name = "actual_product_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Getter + @Enumerated(EnumType.STRING) + @Column(name = "actual_status", columnDefinition = "varchar(255)") + private ActualStatus actualStatus; + + @Getter + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "core_product_id", nullable = false) + private CoreProduct coreProduct; + + @ManyToOne + @Setter + @JoinColumn(name = "order_id") + private Order order; + + @Getter + @Column(name = "actual_price") + private Long actualPrice; + + @Column(name = "discount_rate") + private float discountRate; + + public void updateActualProductStatus(ActualStatus actualStatus){ + this.actualStatus = actualStatus; + } + + public ActualProductDto toDto(){ + return ActualProductDto.builder() + .actualProductId(id) + .actualStatus(actualStatus) + .coreProductId(coreProduct.getId()) + .actualPrice(actualPrice) + .discountRate(discountRate) + .build(); + } + + public Long getCoreProductId(){ + return coreProduct.getId(); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/Product/CoreProduct.java b/src/main/java/com/concurrency/jpa/customer/Product/entity/CoreProduct.java similarity index 66% rename from src/main/java/com/concurrency/jpa/customer/Product/CoreProduct.java rename to src/main/java/com/concurrency/jpa/customer/Product/entity/CoreProduct.java index f56c49d..303d41c 100644 --- a/src/main/java/com/concurrency/jpa/customer/Product/CoreProduct.java +++ b/src/main/java/com/concurrency/jpa/customer/Product/entity/CoreProduct.java @@ -1,8 +1,9 @@ -package com.concurrency.jpa.customer.Product; +package com.concurrency.jpa.customer.Product.entity; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; +import lombok.Getter; import lombok.NoArgsConstructor; @@ -13,11 +14,20 @@ @Table(name = "core_product") public class CoreProduct { @Id + @Getter @Column(name = "core_product_id") @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private Long price; +// @Version +// private Long version; + @Getter private Long stock; @Column(name = "seller_id") private Long sellerId; + + public long addStrock(Long change){ + stock += change; + return stock; + } } diff --git a/src/main/java/com/concurrency/jpa/customer/Product/enums/ActualStatus.java b/src/main/java/com/concurrency/jpa/customer/Product/enums/ActualStatus.java new file mode 100644 index 0000000..b40f243 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/Product/enums/ActualStatus.java @@ -0,0 +1,9 @@ +package com.concurrency.jpa.customer.Product.enums; + +public enum ActualStatus { + PENDING_ORDER, + PROCESSING, + SHIPPING, + DELIVERED, + FAILED +} diff --git a/src/main/java/com/concurrency/jpa/customer/common/BaseException.java b/src/main/java/com/concurrency/jpa/customer/common/BaseException.java new file mode 100644 index 0000000..70496bf --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/common/BaseException.java @@ -0,0 +1,16 @@ +package com.concurrency.jpa.customer.common; + +import lombok.Getter; + +@Getter +public class BaseException extends RuntimeException{ + + private final BaseResponseStatus status; + + public BaseException(BaseResponseStatus status) { + super(status.getMessage()); + this.printStackTrace(); + this.status = status; + } + +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/common/BaseResponse.java b/src/main/java/com/concurrency/jpa/customer/common/BaseResponse.java new file mode 100644 index 0000000..93d23be --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/common/BaseResponse.java @@ -0,0 +1,53 @@ +package com.concurrency.jpa.customer.common; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +@JsonPropertyOrder({"isSuccess", "code", "message", "waitUntilFinish"}) +@Getter +public class BaseResponse extends ResponseEntity { + // Http Response의 일관성을 높이자 + + @JsonProperty("isSuccess") // json 객체 내의 Key 값 설정 + private final Boolean isSuccess; + private final String message; +// private final HttpStatus code; + @JsonInclude(JsonInclude.Include.NON_NULL) // json을 만들 때 null인 객체는 제외한다. + private T result; + @JsonInclude(JsonInclude.Include.NON_NULL) + private HttpHeaders headers; + + public BaseResponse(T result) { + super(BaseResponseStatus.SUCCESS.getCode()); + // 응답에 성공하고 컨텐츠가 있는 경우, create, update, read 경우 + this.isSuccess = BaseResponseStatus.SUCCESS.isSuccess(); + this.message = BaseResponseStatus.SUCCESS.getMessage(); + this.result = result; + } + public BaseResponse(T result, HttpHeaders headers) { + super(headers, BaseResponseStatus.SUCCESS.getCode()); + // 응답에 성공하고 컨텐츠가 있는 경우, create, update, read 경우 + this.isSuccess = BaseResponseStatus.SUCCESS.isSuccess(); + this.message = BaseResponseStatus.SUCCESS.getMessage(); + this.result = result; + this.headers = headers; + } + + public BaseResponse(BaseResponseStatus status) { + super(status.getCode()); + // 예외 발생한 경우 + this.isSuccess = status.isSuccess(); + this.message = status.getMessage(); + } + public BaseResponse(RuntimeException e) { + super(BaseResponseStatus.FAIL.getCode()); + // 예외 발생한 경우 + this.isSuccess = BaseResponseStatus.FAIL.isSuccess(); + this.message = e.getMessage(); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/common/BaseResponseStatus.java b/src/main/java/com/concurrency/jpa/customer/common/BaseResponseStatus.java new file mode 100644 index 0000000..95ff73d --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/common/BaseResponseStatus.java @@ -0,0 +1,32 @@ +package com.concurrency.jpa.customer.common; + + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum BaseResponseStatus { + // httpstatus는 code 대신 HttpsStatus 열거형 쓰는게 더 표준적 + + SUCCESS(true, HttpStatus.OK, "요청에 성공하였습니다."), + FAIL(false, HttpStatus.BAD_REQUEST, "요청에 실패했습니다."), + OPTIMISTIC_FAILURE(false, HttpStatus.REQUEST_TIMEOUT, "낙관적으로 너무 오랬 동안 대기했습니다."), + NOT_ENOUGH_STOCK(false, HttpStatus.BAD_REQUEST, "재고가 충분하지 않습니다."), + PRODUCT_NOT_FOUND(false, HttpStatus.NOT_FOUND, "요청한 상품이 없습니다."), + NOT_AUTHORITY(false, HttpStatus.FORBIDDEN, "상품 주문 권한이 없습니다."), + PAYMENT_FAILED(false, HttpStatus.INTERNAL_SERVER_ERROR, "결제가 실패했습니다."), + LOCK_ACQUISITION_FAILED(false, HttpStatus.INTERNAL_SERVER_ERROR, "분산락을 얻기 실패했습니다."), + ORDER_COMPLETION_YET(false, HttpStatus.BAD_REQUEST, "아직 주문 완료되지 않았습니다."), + PAYMENT_COMPLETION_DELAYED(false, HttpStatus.INTERNAL_SERVER_ERROR, "결제 완료가 너무 늦어집니다."); + + + private final boolean isSuccess; + private final HttpStatus code; + private final String message; + + BaseResponseStatus(boolean isSuccess, HttpStatus code, String message) { + this.isSuccess = isSuccess; + this.code = code; + this.message = message; + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/common/CustomExceptionHandler.java b/src/main/java/com/concurrency/jpa/customer/common/CustomExceptionHandler.java new file mode 100644 index 0000000..6f67d19 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/common/CustomExceptionHandler.java @@ -0,0 +1,22 @@ +package com.concurrency.jpa.customer.common; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Slf4j +public class CustomExceptionHandler { + @ExceptionHandler(value = {BaseException.class, NullPointerException.class}) + public ResponseEntity handlerException( BaseException b){ + b.printStackTrace(); + return ResponseEntity.badRequest().body(new BaseResponse<>(b)); + } + + @ExceptionHandler(value = {RuntimeException.class, InterruptedException.class}) + public ResponseEntity runtimeHandlerException( RuntimeException b){ + b.printStackTrace(); + return ResponseEntity.badRequest().body(new BaseResponse<>(b)); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/lock/LockService.java b/src/main/java/com/concurrency/jpa/customer/lock/LockService.java new file mode 100644 index 0000000..b234d15 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/lock/LockService.java @@ -0,0 +1,12 @@ +package com.concurrency.jpa.customer.lock; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +public interface LockService { + T executeWithLock(String email, + int timeoutSeconds, + Supplier supplier); + + void executeWithLock(String email, int timeoutSeconds, T dto, Consumer consumer); +} diff --git a/src/main/java/com/concurrency/jpa/customer/lock/LockServiceImpl.java b/src/main/java/com/concurrency/jpa/customer/lock/LockServiceImpl.java new file mode 100644 index 0000000..2e20e29 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/lock/LockServiceImpl.java @@ -0,0 +1,66 @@ +package com.concurrency.jpa.customer.lock; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.integration.support.locks.LockRegistry; +import org.springframework.stereotype.Service; + +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Supplier; + +@Service +public class LockServiceImpl implements LockService{ + + private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); + private static final String EXCEPTION_MESSAGE = "LOCK 을 수행하는 중에 오류가 발생하였습니다."; + + private final LockRegistry lockRegistry; + + public LockServiceImpl(LockRegistry lockRegistry) { + this.lockRegistry = lockRegistry; + } + + @Override + public T executeWithLock(String email, + int timeoutSeconds, + Supplier supplier) { + var lock = lockRegistry.obtain(email); + boolean lockAcquired = lock.tryLock(); + if(lockAcquired){ + try{ + log.info("lock taken"); + return supplier.get(); + } finally { + lock.unlock(); + log.info("finally unlock"); + } + } + else{ + throw new RuntimeException(EXCEPTION_MESSAGE); + } + } + + @Override + public void executeWithLock(String email, + int timeoutSeconds, + T dto, + Consumer consumer) { + var lock = lockRegistry.obtain(email); + boolean lockAcquired = lock.tryLock(); + if(lockAcquired){ + try{ + log.info("lock taken"); + consumer.accept(dto); + } + finally { + lock.unlock(); + } + } + else{ + throw new RuntimeException(EXCEPTION_MESSAGE); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/order/Actors.java b/src/main/java/com/concurrency/jpa/customer/order/Actors.java deleted file mode 100644 index 90a1f41..0000000 --- a/src/main/java/com/concurrency/jpa/customer/order/Actors.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.concurrency.jpa.customer.order; - -public enum Actors { - Guest(Integer.MAX_VALUE, Integer.MAX_VALUE), - InexperiencedCustomer(1000, 50), - LoyalCustomer(50, 50), - ElderlyCustomer(1000, 1000), - ; - - private int waitOrderMillisec; - private int waitPaymentMillisec; - - Actors(int waitOrderMillisec, int waitPaymentMillisec) { - this.waitOrderMillisec = waitOrderMillisec; - this.waitPaymentMillisec = waitPaymentMillisec; - } -} diff --git a/src/main/java/com/concurrency/jpa/customer/order/Order.java b/src/main/java/com/concurrency/jpa/customer/order/Order.java index 8f6b5f7..b744fcf 100644 --- a/src/main/java/com/concurrency/jpa/customer/order/Order.java +++ b/src/main/java/com/concurrency/jpa/customer/order/Order.java @@ -1,14 +1,17 @@ package com.concurrency.jpa.customer.order; -import com.concurrency.jpa.customer.Product.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; @Entity @NoArgsConstructor @@ -21,20 +24,62 @@ public class Order { @Getter @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Enumerated(EnumType.STRING) @Column(columnDefinition = "varchar(255)") private Actors actor; + + @Getter private Long totalPrice; - private Long totalCount; + @Getter + @Column(name = "payment_id") + private Long paymentId; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(255)") + private PaymentMethods paymentMethod; + + @Getter + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "varchar(255)") + private OrderStatus orderStatus; + + @Getter @OneToMany @JoinColumn(name = "order_id") private List actualProducts = new ArrayList<>(); // 연관관계 메서드 - public void addActualProduct(ActualProduct actualProduct){ - actualProducts.add(actualProduct); - totalCount++; - totalPrice += actualProduct.getActualPrice(); + public void addActualProducts(List actualProducts){ + List newActualProducts = new ArrayList<>(actualProducts); + newActualProducts.forEach(a -> { + a.updateActualProductStatus(ActualStatus.PROCESSING); + this.actualProducts.add(a); + this.totalPrice += a.getActualPrice(); + }); + } + + public OrderDto toDto(){ + return OrderDto.builder() + .id(id) + .clientType(actor) + .totalPrice(totalPrice) + .paymentMethod(paymentMethod) + .paymentId(paymentId) + .orderStatus(orderStatus) + .build(); + } + + public void setOrderStatus(OrderStatus status){ + this.orderStatus = status; + } + + public void setPaymentId(Long paymentId) { + this.paymentId = paymentId; + } + + public void clearActualProducts() { + this.actualProducts.clear(); } } \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/order/OrderController.java b/src/main/java/com/concurrency/jpa/customer/order/OrderController.java new file mode 100644 index 0000000..02e0362 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/OrderController.java @@ -0,0 +1,59 @@ +package com.concurrency.jpa.customer.order; + +import com.concurrency.jpa.customer.order.dto.CreateOrderRequestDto; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.order.service.OrderService; +import com.concurrency.jpa.customer.payment.dto.PaymentInitialRequestDto; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; +import com.concurrency.jpa.customer.payment.service.PaymentService; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; + +@RestController +@RequiredArgsConstructor +public class OrderController { + @Autowired + OrderService orderService; + + + @PostMapping("/order") + public ResponseEntity postOrder(@RequestBody CreateOrderRequestDto createOrderRequestDto) { + + PaymentStatusDto paymentRequest = orderService.createOrder(createOrderRequestDto); + + // 사용자가 결제 결과 화면 볼 수 있도록 리다이렉트 + URI redirectUri = UriComponentsBuilder.fromUriString("/payments/result") + .queryParam("paymentId", paymentRequest.paymentId()) + .queryParam("status", paymentRequest.status()) + .queryParam("userEmail", paymentRequest.buyer().email()) + .queryParam("userName", paymentRequest.buyer().name()) + .build() + .toUri(); + HttpHeaders headers = new HttpHeaders(); + System.out.println(paymentRequest); + // 사용자가 결제 결과 화면 볼 수 있도록 리다이렉트 + headers.setLocation(redirectUri); + return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); + } + + @PutMapping("/confirm") + public ResponseEntity redirect() { + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create("/")); + return new ResponseEntity<>(headers, HttpStatus.MOVED_PERMANENTLY); + } + + @GetMapping("/payment/id") + public ResponseEntity getPayment() { + OrderDto dto = orderService.findByPaymentId(3L); + return ResponseEntity.ok(dto); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/OrderRepository.java b/src/main/java/com/concurrency/jpa/customer/order/OrderRepository.java index e95c537..cb2c20a 100644 --- a/src/main/java/com/concurrency/jpa/customer/order/OrderRepository.java +++ b/src/main/java/com/concurrency/jpa/customer/order/OrderRepository.java @@ -1,9 +1,26 @@ package com.concurrency.jpa.customer.order; +import com.concurrency.jpa.customer.Product.dto.OrderCoreProductStockDto; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Optional; + @Repository public interface OrderRepository extends JpaRepository { + + @Query("select m from Order m join m.actualProducts a where m.paymentId = :id") + Optional findByPaymentIdWithFetch(Long id); + + @Query("select new com.concurrency.jpa.customer.Product.dto.OrderCoreProductStockDto(c.id, count(a.id)) " + + "from Order o join o.actualProducts a join a.coreProduct c where o.id = :id " + + "group by c.id") + List findCoreProductStockByOrderId(Long id); + Optional findByPaymentId(Long id); } \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/order/dto/ActualProductDto.java b/src/main/java/com/concurrency/jpa/customer/order/dto/ActualProductDto.java new file mode 100644 index 0000000..2d472f6 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/dto/ActualProductDto.java @@ -0,0 +1,19 @@ +package com.concurrency.jpa.customer.order.dto; + +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class ActualProductDto { + private Long actualProductId; + private ActualStatus actualStatus; + private Long coreProductId; + private Long actualPrice; + private float discountRate; +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/dto/CreateOrderRequestDto.java b/src/main/java/com/concurrency/jpa/customer/order/dto/CreateOrderRequestDto.java new file mode 100644 index 0000000..2cb1c84 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/dto/CreateOrderRequestDto.java @@ -0,0 +1,45 @@ +package com.concurrency.jpa.customer.order.dto; + +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; +import com.concurrency.jpa.customer.payment.dto.CustomerRequestDto; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class CreateOrderRequestDto { + + @JsonProperty("core_products") + private Map coreProducts = new HashMap<>(); + @JsonProperty("client_type") + private Actors clientType; + @JsonProperty("buyer") + private CustomerRequestDto buyer; + @JsonProperty("payment_method") + private PaymentMethods paymentMethod; + + public Order toEntity(){ + return Order.builder() + .actor(clientType) + .paymentMethod(paymentMethod) + .orderStatus(OrderStatus.PENDING) + .totalPrice(0L) + .actualProducts(new ArrayList<>()) + .build(); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/dto/OrderDto.java b/src/main/java/com/concurrency/jpa/customer/order/dto/OrderDto.java new file mode 100644 index 0000000..0372a97 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/dto/OrderDto.java @@ -0,0 +1,29 @@ +package com.concurrency.jpa.customer.order.dto; + +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderDto { + private Long id; + @JsonProperty("client_type") + private Actors clientType; + private Long totalPrice; + private Long paymentId; + private PaymentMethods paymentMethod; + private OrderStatus orderStatus; +// @JsonProperty("payment_method") +// private PaymentMethods paymentMethod; +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/enums/Actors.java b/src/main/java/com/concurrency/jpa/customer/order/enums/Actors.java new file mode 100644 index 0000000..a0987f5 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/enums/Actors.java @@ -0,0 +1,19 @@ +package com.concurrency.jpa.customer.order.enums; + +public enum Actors { + Guest(Integer.MAX_VALUE, Integer.MAX_VALUE, false), + InexperiencedCustomer(1000, 50, true), + LoyalCustomer(50, 50, true), + ElderlyCustomer(1000, 1000, true), + ; + + private int waitOrderMillisec; + private int waitPaymentMillisec; + private boolean authority; + + Actors(int waitOrderMillisec, int waitPaymentMillisec, boolean authority) { + this.waitOrderMillisec = waitOrderMillisec; + this.waitPaymentMillisec = waitPaymentMillisec; + this.authority = authority; + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/enums/OrderStatus.java b/src/main/java/com/concurrency/jpa/customer/order/enums/OrderStatus.java new file mode 100644 index 0000000..597e0a3 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/enums/OrderStatus.java @@ -0,0 +1,7 @@ +package com.concurrency.jpa.customer.order.enums; + +public enum OrderStatus { + PENDING, + FINISH, + FAIL +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/enums/PaymentMethods.java b/src/main/java/com/concurrency/jpa/customer/order/enums/PaymentMethods.java new file mode 100644 index 0000000..3e8e9d8 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/enums/PaymentMethods.java @@ -0,0 +1,7 @@ +package com.concurrency.jpa.customer.order.enums; + +public enum PaymentMethods { + CREDIT_CARD, + DepositWOPassbook, + Transfer, +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/service/OrderService.java b/src/main/java/com/concurrency/jpa/customer/order/service/OrderService.java new file mode 100644 index 0000000..b9e5482 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/service/OrderService.java @@ -0,0 +1,25 @@ +package com.concurrency.jpa.customer.order.service; + +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.dto.CreateOrderRequestDto; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; +import org.springframework.http.ResponseEntity; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; + +public interface OrderService { + PaymentStatusDto createOrder(CreateOrderRequestDto createOrderRequestDto); + Order getOrder(CreateOrderRequestDto createOrderRequestDto, List actualProducts); + + void rollback(Long paymentId); + + void changeActualProductStatus(Long paymentId); + + OrderDto findByPaymentId(long l); + +} diff --git a/src/main/java/com/concurrency/jpa/customer/order/service/OrderServiceImpl.java b/src/main/java/com/concurrency/jpa/customer/order/service/OrderServiceImpl.java new file mode 100644 index 0000000..4c11179 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/order/service/OrderServiceImpl.java @@ -0,0 +1,146 @@ +package com.concurrency.jpa.customer.order.service; + + +import com.concurrency.jpa.customer.Product.ActualProductRepository; +import com.concurrency.jpa.customer.Product.CoreProductRepository; +import com.concurrency.jpa.customer.Product.ProductService; +import com.concurrency.jpa.customer.Product.dto.OrderCoreProductStockDto; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.common.BaseResponseStatus; +import com.concurrency.jpa.customer.lock.LockService; +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.OrderRepository; +import com.concurrency.jpa.customer.order.dto.CreateOrderRequestDto; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.payment.dto.AbstractPayment; +import com.concurrency.jpa.customer.payment.dto.PaymentInitialRequestDto; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.*; + + +@Service +@RequiredArgsConstructor +public class OrderServiceImpl implements OrderService { + private final OrderRepository orderRepository; + private final ProductService productService; + private final LockService lockService; + @Value("${payment.server.url}") + private String paymentURI; + + @Override + @Transactional + public PaymentStatusDto createOrder(CreateOrderRequestDto createOrderRequestDto){ + // 유저 권한 확인하기 + checkUserAuthority(createOrderRequestDto.getClientType()); + return lockService.executeWithLock(createOrderRequestDto.getBuyer().email(), + 1, () -> { + // 재고 확인하고 감소시키기 + productService.updateCoreProductsStock(createOrderRequestDto.getCoreProducts()); + // 유형제품 찾기 + List actualProducts = productService.concatActualProductList(createOrderRequestDto.getCoreProducts()); + // 주문 생성 + // 주문과 유형제품 연결 & 유형제품 상태 업데이트 + Order savedOrder = getOrder(createOrderRequestDto, actualProducts); + PaymentStatusDto payPending = pay(new PaymentInitialRequestDto( + AbstractPayment.valueOf(createOrderRequestDto.getPaymentMethod().name()), + savedOrder.getTotalPrice(), + createOrderRequestDto.getBuyer())); + savedOrder.setPaymentId(payPending.paymentId()); + orderRepository.save(savedOrder); + return payPending; + }); + } + private PaymentStatusDto pay(PaymentInitialRequestDto dto) { + Mono mono = WebClient.create() + .post() + .uri(paymentURI+"/payments") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(dto) + .retrieve() + .bodyToMono(PaymentStatusDto.class); + return mono.block(); + } + + private void checkUserAuthority(Actors clientType) { + if(clientType.equals(Actors.Guest)){ + throw new BaseException(BaseResponseStatus.NOT_AUTHORITY); + } + } + + @Override + @Transactional + public Order getOrder(CreateOrderRequestDto createOrderRequestDto, List actualProducts) { + Order order = createOrderRequestDto.toEntity(); + order.addActualProducts(actualProducts); + return order; + } + + @Override + @Transactional + public void changeActualProductStatus(Long paymentId) { + Order order = orderRepository.findByPaymentIdWithFetch(paymentId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + order.setOrderStatus(OrderStatus.FINISH); +// List actualStatusList = findActualProductsByOrder(order.getId()); + order.getActualProducts().forEach( + a -> a.updateActualProductStatus(ActualStatus.SHIPPING) + ); + } + + /** + * 결제가 실패했기 때문에 해당 결제 id를 가진 주문의 상품들을 이전 상태로 돌려야한다. + * 주문, 유형, 핵심 모두 fetch join으로 한번에 가져옴 + 비관적 락 + * => 유형 상품 데이터는 사용하지 않을 건데 패치되어 가져오는건 비효율적이다. + * DTO Projection을 이용해 필요한 데이터만 가져와서 처리할 수 있다. + * + * @param paymentId + */ + @Override + @Transactional + public void rollback(Long paymentId) { + Order order = orderRepository.findByPaymentId(paymentId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + order.setOrderStatus(OrderStatus.FAIL); + order.getActualProducts().forEach( + a -> { + a.updateActualProductStatus(ActualStatus.PENDING_ORDER); + } + ); + Map coreCntMap = new HashMap<>(); + List coreMap = orderRepository.findCoreProductStockByOrderId(order.getId()); + for(OrderCoreProductStockDto o : coreMap){ + System.out.println(o.getCoreProductId()+" : "+o.getReduceStock()); + coreCntMap.put(o.getCoreProductId(), -1*o.getReduceStock()); + } + productService.updateCoreProductsStock(coreCntMap); + order.clearActualProducts(); + } + + @Override + public OrderDto findByPaymentId(long l) { + Optional order = orderRepository.findByPaymentIdWithFetch(l); + if(order.isEmpty()){ + return null; + } + else{ + return order.get().toDto(); + } + } + +} diff --git a/src/main/java/com/concurrency/jpa/customer/payment/PaymentController.java b/src/main/java/com/concurrency/jpa/customer/payment/PaymentController.java new file mode 100644 index 0000000..46bea53 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/PaymentController.java @@ -0,0 +1,59 @@ +package com.concurrency.jpa.customer.payment; + +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.payment.dto.CustomerRequestDto; +import com.concurrency.jpa.customer.payment.dto.PaymentStatus; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; +import com.concurrency.jpa.customer.payment.service.PaymentService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/payments") +public class PaymentController { + + @Autowired + private PaymentService paymentService; + + /** + * 주문 서버가 결제 서버에게 결제 결과를 요청함 + * @param dto + * @return + */ + @PutMapping("/confirm") + public ResponseEntity confirm(@RequestBody PaymentStatusDto dto){ + System.out.println("결제 서버로 부터 받은 정보 : "+dto); + PaymentStatusDto result = null; + try{ + result = paymentService.confirm(dto); + } + catch (BaseException | InterruptedException | ObjectOptimisticLockingFailureException e){ + throw new RuntimeException(e); + } + + System.out.println("결제 서버로 보낼 정보 : "+result); + return ResponseEntity.ok(result); + } + + @GetMapping("/result") + public ResponseEntity result(@RequestParam("paymentId") Long paymentId, + @RequestParam("status") PaymentStatus status, + @RequestParam("userEmail") String userEmail, + @RequestParam("userName") String userName){ + PaymentStatusDto dto = new PaymentStatusDto(paymentId, status, + new CustomerRequestDto(userEmail, userName)); + try{ + OrderDto result = paymentService.waitUntilFinish(dto); + return ResponseEntity.ok(result); + } + catch (BaseException | InterruptedException e){ + throw new RuntimeException(e); + } + + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/payment/dto/AbstractPayment.java b/src/main/java/com/concurrency/jpa/customer/payment/dto/AbstractPayment.java new file mode 100644 index 0000000..f880a03 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/dto/AbstractPayment.java @@ -0,0 +1,16 @@ +package com.concurrency.jpa.customer.payment.dto; + +public enum AbstractPayment { + /** + * Payment by credit card. + */ + @SuppressWarnings("unused") CREDIT_CARD, + /** + * Payment by PayPal. + */ + @SuppressWarnings("unused") PAYPAL, + /** + * Payment by iDEAL. + */ + @SuppressWarnings("unused") IDEAL +} diff --git a/src/main/java/com/concurrency/jpa/customer/payment/dto/CustomerRequestDto.java b/src/main/java/com/concurrency/jpa/customer/payment/dto/CustomerRequestDto.java new file mode 100644 index 0000000..6761690 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/dto/CustomerRequestDto.java @@ -0,0 +1,8 @@ +package com.concurrency.jpa.customer.payment.dto; + +public record CustomerRequestDto( + String email, + String name +) { + +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentInitialRequestDto.java b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentInitialRequestDto.java new file mode 100644 index 0000000..f8cb106 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentInitialRequestDto.java @@ -0,0 +1,25 @@ +package com.concurrency.jpa.customer.payment.dto; + +import com.concurrency.jpa.customer.order.dto.OrderDto; +import org.springframework.beans.factory.annotation.Value; + +import java.net.URI; + +public record PaymentInitialRequestDto( + CustomerRequestDto seller, + CustomerRequestDto buyer, + AbstractPayment payment, + Long price, + // The URL to return to after the payment is completed. + URI redirect +) { + + + public PaymentInitialRequestDto(AbstractPayment abstractPayment, Long price, CustomerRequestDto buyer){ + this(new CustomerRequestDto("abcSeller@gmail.com", "sol sol"), + buyer, + abstractPayment, + price, + URI.create("http://localhost:8080/payments/confirm")); + } +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatus.java b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatus.java new file mode 100644 index 0000000..b779755 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatus.java @@ -0,0 +1,40 @@ +package com.concurrency.jpa.customer.payment.dto; + +public enum PaymentStatus { + /** + * Initial payment status. + */ + PENDING, + /** + * Payment is cancelled. + */ + CANCELLED, + /** + * Payment succeeded via confirmation process. + */ + SUCCESS, + /** + * Payment failed to initialize or confirm. + */ + @SuppressWarnings("unused") FAILED; + + /** + * Check if payment is successful. + * + * @param result payment status + * @return true if payment is successful + */ + public static boolean isSuccess(final PaymentStatusDto result) { + return SUCCESS.equals(result.status()); + } + + /** + * Check if payment is cancelled. + * + * @param result payment status + * @return true if payment is cancelled + */ + public static boolean isCancelled(final PaymentStatusDto result) { + return CANCELLED.equals(result.status()); + } +} diff --git a/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatusDto.java b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatusDto.java new file mode 100644 index 0000000..1c24917 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/dto/PaymentStatusDto.java @@ -0,0 +1,9 @@ +package com.concurrency.jpa.customer.payment.dto; + +public record PaymentStatusDto( + Long paymentId, + PaymentStatus status, + CustomerRequestDto buyer +) { + +} \ No newline at end of file diff --git a/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentService.java b/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentService.java new file mode 100644 index 0000000..9e26961 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentService.java @@ -0,0 +1,13 @@ +package com.concurrency.jpa.customer.payment.service; + +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; + +public interface PaymentService { + + PaymentStatusDto confirm(PaymentStatusDto dto) throws InterruptedException; + + OrderDto waitUntilFinish(PaymentStatusDto dto) throws InterruptedException; + + PaymentStatusDto cancel(PaymentStatusDto dto); +} diff --git a/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentServiceImpl.java b/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentServiceImpl.java new file mode 100644 index 0000000..13737c4 --- /dev/null +++ b/src/main/java/com/concurrency/jpa/customer/payment/service/PaymentServiceImpl.java @@ -0,0 +1,144 @@ +package com.concurrency.jpa.customer.payment.service; + +import com.concurrency.jpa.customer.Product.ActualProductRepository; +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.common.BaseResponseStatus; +import com.concurrency.jpa.customer.lock.LockService; +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.OrderRepository; +import com.concurrency.jpa.customer.order.dto.OrderDto; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.service.OrderService; +import com.concurrency.jpa.customer.payment.dto.PaymentStatus; +import com.concurrency.jpa.customer.payment.dto.PaymentStatusDto; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import java.net.URI; + +/** + * 외부 결제 서버에 결제 요청, 결과 확인, 결제 취소를 요청하는 부분 + */ +@Service +@RequiredArgsConstructor +public class PaymentServiceImpl implements PaymentService{ + + @Value("${payment.server.url}") + private String paymentURI; + + private final OrderRepository orderRepository; + private final ActualProductRepository actualProductRepository; + private final OrderService orderService; + private final LockService lockService; + private URI getUri(String uri) { + return URI.create(paymentURI+uri); + } + + /** + * 결제 성공 시 수행하는 로직 + * 1. 결제 번호로 주문 찾기 + * 2. 주문에 포함된 상품의 상태를 배송 중으로 수정 + * 3. + * @param dto + * @return + */ + @Override +// @Transactional + public PaymentStatusDto confirm(PaymentStatusDto dto) throws InterruptedException { + // 결제 결과를 API로 가져오기 + PaymentStatusDto payResult = getPaymentResult(dto); + int patience = 4; + while(patience > 0){ + try{ + return lockService.executeWithLock(dto.buyer().email(), + 1, () -> { + if(payResult.status().equals(PaymentStatus.FAILED)){ + // 결제 실패한 경우 + orderService.rollback(dto.paymentId()); + return payResult; + } + try{ + // 결제 성공한 경우 + // 결제 id를 가진 주문을 찾기 + // 해당 주문에 들어간 상품의 상태를 바꾸기 + orderService.changeActualProductStatus(dto.paymentId()); + return payResult; + } + catch (RuntimeException e){ + cancel(payResult); + orderService.rollback(dto.paymentId()); + throw e; + } + }); + } + catch (RuntimeException e){ + e.printStackTrace(); + Thread.sleep(500); + patience--; + } + } + System.out.println("참을 수 없습니다!!"); + throw new BaseException(BaseResponseStatus.FAIL); + } + + @Override + public OrderDto waitUntilFinish(PaymentStatusDto dto) throws InterruptedException { + int patience = 10; + while(patience > 0){ + System.out.println("결과 확인 결제 정보 : "+dto); + try{ + Order order = lockService.executeWithLock(dto.buyer().email(), 1, + () -> + { + Order o = orderRepository.findByPaymentId(dto.paymentId()) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + System.out.println(o.getId()+" : "+o.getOrderStatus()); + return o; + } + ); + if(order.getOrderStatus() != OrderStatus.PENDING){ + return order.toDto(); // lazy loading으로 유형 제품 find + } + else{ + throw new BaseException(BaseResponseStatus.ORDER_COMPLETION_YET); + } + }catch (RuntimeException e){ + e.printStackTrace(); + Thread.sleep(1000); + patience--; + } + } + System.out.println("참을 수 없습니다!!"); + throw new BaseException(BaseResponseStatus.FAIL); + } + + private PaymentStatusDto getPaymentResult(PaymentStatusDto dto) { + Mono mono = WebClient.create() + .put() + .uri(getUri("/payments/confirm")) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(dto) + .retrieve() + .bodyToMono(PaymentStatusDto.class); + return mono.block(); + } + + + + @Override + public PaymentStatusDto cancel(PaymentStatusDto dto) { + Mono mono = WebClient.create() + .post() + .uri(getUri("/payments/cancel")) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(dto) + .retrieve() + .bodyToMono(PaymentStatusDto.class); + return mono.block(); + } +} diff --git a/src/main/resources/application-default.yml b/src/main/resources/application-default.yml index 97f7694..b3ce05c 100644 --- a/src/main/resources/application-default.yml +++ b/src/main/resources/application-default.yml @@ -8,10 +8,10 @@ # JPA configuration spring.jpa: open-in-view: false - show-sql: false + show-sql: true ## Hibernate native properties properties.hibernate: - format-sql: false + format-sql: true use_sql_comments: false generate_statistics: false id.new_generator_mappings: false diff --git a/src/main/resources/application-init-sql.yml b/src/main/resources/application-init-sql.yml index aad3edc..98b8ca5 100644 --- a/src/main/resources/application-init-sql.yml +++ b/src/main/resources/application-init-sql.yml @@ -9,4 +9,4 @@ spring.jpa.hibernate.ddl-auto: validate spring.sql.init: mode: always schema-locations: classpath:/schema.sql - data-locations: classpath:/data.sql +# data-locations: classpath:/data.sql diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6854630..ba4d7bc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,11 +7,21 @@ # Core configuration spring.application.name: spring-web-jpa-concurrency -spring.profiles.active: default, dev, init-sql +spring.profiles.active: default, dev #, init-sql # Database configuration spring.datasource: - url: jdbc:mysql://${MYSQL_SERVER}:3306/${MYSQL_DATABASE} - username: ${MYSQL_USER} - password: ${MYSQL_PASSWORD} + url: jdbc:mysql://localhost:3306/jpa-concurrency + username: chansol + password: solsol driver-class-name: com.mysql.cj.jdbc.Driver + +# Hibernate Cache hit statistics +hibernate: + cache: + use_query_cache=true: + +# Payment Server url +payment.server.url: http://localhost:8081 # http://13.125.145.9:8080/ +# My Public ip +my.public.ip: http://localhost:8080 \ No newline at end of file diff --git a/src/main/resources/data.sql b/src/main/resources/data.sql index 4578182..6737a7c 100644 --- a/src/main/resources/data.sql +++ b/src/main/resources/data.sql @@ -1,2 +1,213 @@ +INSERT INTO core_product( price, seller_id, stock) +VALUES (1000, 1, 64); + +-- INSERT INTO core_product( price, seller_id, stock, version) +-- VALUES (1000, 1, 64, 1); + INSERT INTO customer(email_id, email_provider, first_name, last_name) + VALUES ('user', 'example.com', 'First', 'Last'); + +INSERT INTO core_product( price, seller_id, stock) +VALUES (5000, 1, 2); + +-- INSERT INTO core_product( price, seller_id, stock, version) +-- VALUES (5000, 1, 2, 1); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 1000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (1, 'PENDING_ORDER', 900, 10, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (2, 'PENDING_ORDER', 5000, 0, null); + +INSERT INTO actual_product(core_product_id, actual_status, actual_price, discount_rate, order_id) +VALUES (2, 'PENDING_ORDER', 2500, 50, null); \ No newline at end of file diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql index 846a9ca..a45506f 100644 --- a/src/main/resources/schema.sql +++ b/src/main/resources/schema.sql @@ -1,3 +1,11 @@ +CREATE TABLE IF NOT EXISTS `INT_LOCK` ( + `LOCK_KEY` char(36) NOT NULL, + `REGION` varchar(100) NOT NULL, + `CLIENT_ID` char(36) DEFAULT NULL, + `CREATED_DATE` timestamp NOT NULL, + PRIMARY KEY (`LOCK_KEY`,`REGION`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + DROP TABLE IF EXISTS customer; CREATE TABLE customer ( diff --git a/src/test/java/com/concurrency/jpa/order/ConcurrentOrderServiceTest.java b/src/test/java/com/concurrency/jpa/order/ConcurrentOrderServiceTest.java new file mode 100644 index 0000000..89af2ea --- /dev/null +++ b/src/test/java/com/concurrency/jpa/order/ConcurrentOrderServiceTest.java @@ -0,0 +1,179 @@ +package com.concurrency.jpa.order; + +import com.concurrency.jpa.customer.Product.CoreProductRepository; +import com.concurrency.jpa.customer.Product.ProductService; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.lock.LockService; +import com.concurrency.jpa.customer.order.dto.CreateOrderRequestDto; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; +import com.concurrency.jpa.customer.order.service.OrderService; +import com.concurrency.jpa.customer.order.service.OrderServiceImpl; +import org.aspectj.lang.annotation.RequiredTypes; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class ConcurrentOrderServiceTest { + private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); + @Autowired + OrderService orderService; + @Autowired + ProductService productService; + @Autowired + CoreProductRepository coreProductRepository; + @Autowired + LockService lockService; + static int ACTUAL_STOCK = 30; + static int threadCount = 10; // 멀티 스레드 개수 + static int requestCount = 30; // 요청 개수 + + @BeforeAll + void setup() { + CoreProduct coreProduct1 = CoreProduct.builder() + .id((long)1) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct1); + CoreProduct coreProduct2 = CoreProduct.builder() + .id((long)2) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct2); + CoreProduct coreProduct3 = CoreProduct.builder() + .id((long)3) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct3); + CoreProduct coreProduct4 = CoreProduct.builder() + .id((long)4) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct4); + } + + + @Test + @DisplayName("싱글스레드로 재고 감소 후 체크") + public void Update_CoreStock_Success(){ + Long coreProductId = 1L; + // given + CoreProduct coreProduct1 = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("시작 상품 재고 : "+coreProduct1.getStock()); + + // when + productService.subtractCoreProductStock(coreProductId, 1L); + + // then + CoreProduct coreProduct2 = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("나중 상품 재고 : "+coreProduct2.getStock()); + Assertions.assertEquals(ACTUAL_STOCK - 1L, coreProduct2.getStock()); + } + + @Test + @DisplayName("멀티스레드로 재고 감소, 여러 transaction이 경쟁 상태 발생") + public void givenMultiThreadAndTransaction_whenUpdated_thenFAIL() throws InterruptedException { + Long coreProductId = 2L; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(requestCount); + CoreProduct coreProduct = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("초기 상품 재고량 : "+coreProduct.getStock()); + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + productService.subtractCoreProductStock(coreProductId, 1L); + } catch (Exception e){ + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } }); + } + countDownLatch.await(); + + Long result = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + System.out.println("티켓 수량 : "+result); + Assertions.assertNotEquals(ACTUAL_STOCK - requestCount, result); + } + + @Test + @DisplayName("멀티스레드로 재고 감소, 낙관적 락으로 경쟁 상태 관리.") + public void givenMultiThreadAndTransaction_whenUpdated_thenOptic_Success() throws InterruptedException { + Long coreProductId = 3L; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(requestCount); + CoreProduct coreProduct = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("초기 상품 재고량 : "+coreProduct.getStock()); + + for (int i = 0; i < requestCount; i++) { + executorService.submit(() -> { + try { + productService.subtractCoreProductStockOptimistic(coreProductId, 1L); + } catch (Exception e){ + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } }); + } + countDownLatch.await(); + + Long result = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + System.out.println("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK - requestCount, result); + } + + @Test + @DisplayName("멀티스레드로 재고 증가, 비관적 락으로 경쟁 상태 제거") + public void givenMultiThreadAndTransaction_whenUpdated_thenPessimistic_Success() throws InterruptedException { + Long coreProductId = 4L; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + // 스레드는 countDown을 호출해서 requestCount를 하나씩 감소시킴 + CountDownLatch countDownLatch = new CountDownLatch(requestCount); + CoreProduct coreProduct = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("초기 상품 재고량 : "+coreProduct.getStock()); + + for (int i = 0; i < requestCount; i++) { // wating time일 수 있다. + executorService.submit(() -> { // submit 안에 함수는 스레드가 실행시킴 + try { + productService.subtractCoreProductStockPessimistic(coreProductId, -1L); // 티켓 수량 증가 + } catch (Exception e){ + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } }); + } + // 메인 스레드는 requestCount가 0이 될때까지 blocked된다. + countDownLatch.await(); + + Long result = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + System.out.println("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK + requestCount, result); + } +} diff --git a/src/test/java/com/concurrency/jpa/order/DistributeLockOrderServiceTest.java b/src/test/java/com/concurrency/jpa/order/DistributeLockOrderServiceTest.java new file mode 100644 index 0000000..98789bb --- /dev/null +++ b/src/test/java/com/concurrency/jpa/order/DistributeLockOrderServiceTest.java @@ -0,0 +1,309 @@ +package com.concurrency.jpa.order; + +import com.concurrency.jpa.customer.Product.CoreProductRepository; +import com.concurrency.jpa.customer.Product.ProductService; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.lock.LockService; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.service.OrderService; +import com.concurrency.jpa.customer.order.service.OrderServiceImpl; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class DistributeLockOrderServiceTest { + private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); + @Autowired + OrderService orderService; + @Autowired + ProductService productService; + @Autowired + CoreProductRepository coreProductRepository; + @Autowired + LockService lockService; + static int ACTUAL_STOCK = 30; + static int threadCount = 10; // 멀티 스레드 개수 + static int requestCount = 30; // 요청 개수 + + @BeforeAll + void setup() { + CoreProduct coreProduct1 = CoreProduct.builder() + .id((long)1) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct1); + CoreProduct coreProduct2 = CoreProduct.builder() + .id((long)2) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct2); + CoreProduct coreProduct3 = CoreProduct.builder() + .id((long)3) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct3); + CoreProduct coreProduct4 = CoreProduct.builder() + .id((long)4) + .price((long) 10000) + .stock((long) ACTUAL_STOCK) + .sellerId((long) 1) +// .version((long) 1) + .build(); + coreProductRepository.save(coreProduct4); + } + + + @Test + @DisplayName("싱글스레드로 재고 감소 후 체크") + public void Update_CoreStock_Success(){ + Long coreProductId = 1L; + // given + CoreProduct coreProduct1 = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("시작 상품 재고 : "+coreProduct1.getStock()); + + // when + productService.subtractCoreProductStock(coreProductId, 1L); + + // then + CoreProduct coreProduct2 = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + System.out.println("나중 상품 재고 : "+coreProduct2.getStock()); + Assertions.assertEquals(ACTUAL_STOCK - 1L, coreProduct2.getStock()); + } + + @Test + @DisplayName("멀티스레드로 재고 감소 후 체크, 분산락을 이용해서 동시성 제어") + public void GivenDistributeLock_Update_CoreStock_Success() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch = new CountDownLatch(2); + Long coreProductId = 1L; + Runnable lockThreadOne = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> productService.subtractCoreProductStock(coreProductId, 1L)); + } + catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + } + finally { + latch.countDown(); + } + }; + + Runnable lockThreadTwo = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> productService.subtractCoreProductStock(coreProductId, 1L)); + }catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + }finally { + latch.countDown(); + } + }; + executorService.submit(lockThreadOne); + executorService.submit(lockThreadTwo); + latch.await(); + Long result = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + log.info("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK-1, result); + } + + @Test + @DisplayName("같은 분산락을 서로 다른 쓰레드가 획득할 수 있는지 체크") + public void GivenDistributeLock_Share_Other_Success() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch1 = new CountDownLatch(1); + Long coreProductId = 1L; + Runnable lockThreadOne = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> productService.subtractCoreProductStock(coreProductId, 1L)); + } + catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + } + finally { + latch1.countDown(); + } + }; + executorService.submit(lockThreadOne); + latch1.await(); + + CountDownLatch latch2 = new CountDownLatch(1); + Runnable lockThreadTwo = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> productService.subtractCoreProductStock(coreProductId, 1L)); + }catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + }finally { + latch2.countDown(); + } + }; + executorService.submit(lockThreadTwo); + latch2.await(); + Long result = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + log.info("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK-2, result); + } + + @Test + @DisplayName("다른 분산락이 같은 레코드에 적용될 때 비관적락을 써야 동시성 문제 해결?") + public void Given_Difference_DistributeLock_Share_Other_PESSIMISTIC_SUCESS() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch1 = new CountDownLatch(1); + Long coreProductId = 1L; + Runnable lockThreadOne = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user1@naver.com", + 1, + ()-> productService.subtractCoreProductStockPessimistic(coreProductId, 1L)); + } + catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + } + finally { + latch1.countDown(); + } + }; + executorService.submit(lockThreadOne); + + + CountDownLatch latch2 = new CountDownLatch(1); + Runnable lockThreadTwo = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user2@naver.com", + 1, + ()-> productService.subtractCoreProductStockPessimistic(coreProductId, 1L)); + }catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + }finally { + latch2.countDown(); + } + }; + executorService.submit(lockThreadTwo); + latch1.await(); + latch2.await(); + Long result = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + log.info("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK-2, result); + } + + + @Test + @DisplayName("트랜젝션이 끝나고 분산락을 해제하기 전에 예외가 발생한다면 트랜젝션도 롤백되지 않음") + public void GivenDistributeLock_Tx_Finished_But_exception() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch1 = new CountDownLatch(1); + Long coreProductId = 1L; + Runnable lockThreadOne = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> { + productService.subtractCoreProductStock(coreProductId, 1L); + throw new RuntimeException("강제 예외 발생"); + }); + } + catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + } + finally { + latch1.countDown(); + } + }; + executorService.submit(lockThreadOne); + latch1.await(); + Long result = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + log.info("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK-1, result); + } + + @Test + @DisplayName("연관관계를 업데이트하고 결과를 읽기") + public void Relation_Update() throws InterruptedException { + ExecutorService executorService = Executors.newFixedThreadPool(2); + CountDownLatch latch1 = new CountDownLatch(1); + Long coreProductId = 1L; + Runnable lockThreadOne = () -> { + UUID uuid = UUID.randomUUID(); + log.info("task start thread: " + uuid); + try { + lockService.executeWithLock("user@naver.com", + 1, + ()-> { + productService.subtractCoreProductStock(coreProductId, 1L); + throw new RuntimeException("강제 예외 발생"); + }); + } + catch (Exception e0) { + e0.printStackTrace(); + log.info("exception thrown with thread: " + uuid); + throw e0; + } + finally { + latch1.countDown(); + } + }; + executorService.submit(lockThreadOne); + latch1.await(); + Long result = coreProductRepository.findById(coreProductId) + .orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + log.info("티켓 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK-1, result); + } +} diff --git a/src/test/java/com/concurrency/jpa/order/OrderControllerTest.java b/src/test/java/com/concurrency/jpa/order/OrderControllerTest.java new file mode 100644 index 0000000..939a225 --- /dev/null +++ b/src/test/java/com/concurrency/jpa/order/OrderControllerTest.java @@ -0,0 +1,54 @@ +package com.concurrency.jpa.order; + +import com.concurrency.jpa.customer.common.BaseResponse; +import com.concurrency.jpa.customer.order.OrderController; +import com.concurrency.jpa.customer.order.dto.CreateOrderRequestDto; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; +import com.concurrency.jpa.customer.payment.dto.CustomerRequestDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +public class OrderControllerTest { + + @Autowired + private MockMvc mockMvc; + @Autowired + private ObjectMapper objectMapper; + + @Test + void shouldReturnRequestData() throws Exception { + Map coreProduct = new HashMap<>(); + coreProduct.put((long) 1,(long) 2); + CreateOrderRequestDto createOrderRequestDto = new CreateOrderRequestDto(coreProduct, Actors.InexperiencedCustomer, + new CustomerRequestDto("user1@naver.com", "user1"),PaymentMethods.CREDIT_CARD); + String content = objectMapper.writeValueAsString(createOrderRequestDto); + System.out.println(content); + mockMvc.perform(post("/order") + .content(content) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + } + +} diff --git a/src/test/java/com/concurrency/jpa/order/OrderRepositoryTest.java b/src/test/java/com/concurrency/jpa/order/OrderRepositoryTest.java index e9764ee..dd6ff75 100644 --- a/src/test/java/com/concurrency/jpa/order/OrderRepositoryTest.java +++ b/src/test/java/com/concurrency/jpa/order/OrderRepositoryTest.java @@ -1,22 +1,63 @@ package com.concurrency.jpa.order; -import com.concurrency.jpa.customer.Product.ActualProduct; -import com.concurrency.jpa.customer.Product.CoreProduct; -import com.concurrency.jpa.customer.Product.OrderStatus; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.common.BaseResponseStatus; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; import org.junit.jupiter.api.Assertions; -import com.concurrency.jpa.customer.order.Actors; +import com.concurrency.jpa.customer.order.enums.Actors; import com.concurrency.jpa.customer.order.Order; import com.concurrency.jpa.customer.order.OrderRepository; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; @SpringBootTest public class OrderRepositoryTest { @Autowired private OrderRepository orderRepository; - + private List actualProducts = new ArrayList<>(); + private CoreProduct coreProduct; + @BeforeEach + void setup() { + coreProduct = CoreProduct.builder() + .id((long)1) + .price((long) 10000) + .stock((long) 3) + .sellerId((long) 1) + .build(); + ActualProduct ap1 = ActualProduct.builder() + .id((long) 1) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 9000) + .discountRate(10) + .coreProduct(coreProduct) + .build(); + ActualProduct ap2 = ActualProduct.builder() + .id((long) 2) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 8000) + .discountRate(20) + .coreProduct(coreProduct) + .build(); + ActualProduct ap3 = ActualProduct.builder() + .id((long) 3) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 7000) + .discountRate(30) + .coreProduct(coreProduct) + .build(); + actualProducts = List.of(ap1, ap2, ap3); + } @Test @DisplayName("주문을 생성하고 생성한 주문을 확인하는 테스트") public void Order_Create_AND_SELECT_Test(){ @@ -24,20 +65,26 @@ public void Order_Create_AND_SELECT_Test(){ .actor(Actors.Guest) .build(); Order createdOrder = orderRepository.save(order); -// CoreProduct cp1 = CoreProduct.builder() -// .price((long) 100000) -// .stock((long) 100) -// .sellerId((long) 1) -// .build(); -// ActualProduct ap1 = ActualProduct.builder() -// .actualStatus(OrderStatus.PENDING_ORDER) -// .actualPrice((long) 99900) -// .discountRate(10) -// .coreProduct(cp1) -// .build(); -// -// - Assertions.assertEquals(order, orderRepository.findById(createdOrder.getId()) - .orElseThrow(RuntimeException::new)); + Assertions.assertEquals(order.getId(), orderRepository.findById(createdOrder.getId()).get().getId()); + } + + @Test + @Transactional + @DisplayName("주문을 생성하고 생성한 주문에 결제를 넣는 테스트") + public void Order_Create_AND_Payment_test(){ + Long paymendId = 35L; + Order order = Order.builder() + .actor(Actors.Guest) + .actualProducts(new ArrayList<>()) + .totalPrice(0L) + .orderStatus(OrderStatus.PENDING) + .paymentMethod(PaymentMethods.CREDIT_CARD) + .build(); + order.addActualProducts(actualProducts); + order.setPaymentId(paymendId); + Order createdOrder = orderRepository.save(order); + Order findOrder = orderRepository.findByPaymentIdWithFetch(paymendId) + .orElseThrow(() -> new BaseException(BaseResponseStatus.FAIL)); + Assertions.assertEquals(createdOrder.getId(), findOrder.getId()); } } diff --git a/src/test/java/com/concurrency/jpa/order/OrderServiceRollbackTest.java b/src/test/java/com/concurrency/jpa/order/OrderServiceRollbackTest.java new file mode 100644 index 0000000..3143e27 --- /dev/null +++ b/src/test/java/com/concurrency/jpa/order/OrderServiceRollbackTest.java @@ -0,0 +1,168 @@ +package com.concurrency.jpa.order; + +import com.concurrency.jpa.customer.Product.ActualProductRepository; +import com.concurrency.jpa.customer.Product.CoreProductRepository; +import com.concurrency.jpa.customer.Product.ProductService; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.lock.LockService; +import com.concurrency.jpa.customer.order.Order; +import com.concurrency.jpa.customer.order.OrderRepository; +import com.concurrency.jpa.customer.order.enums.Actors; +import com.concurrency.jpa.customer.order.enums.OrderStatus; +import com.concurrency.jpa.customer.order.enums.PaymentMethods; +import com.concurrency.jpa.customer.order.service.OrderServiceImpl; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public class OrderServiceRollbackTest { + private final Logger log = LoggerFactory.getLogger(this.getClass().getSimpleName()); + @Autowired + OrderServiceImpl orderService; + @Autowired + ProductService productService; + @Autowired + CoreProductRepository coreProductRepository; + @Autowired + ActualProductRepository actualProductRepository; + @Autowired + OrderRepository orderRepository; + @Autowired + LockService lockService; + static long ACTUAL_STOCK = 0; + static int threadCount = 10; // 멀티 스레드 개수 + static int requestCount = 3; // 요청 개수 + static long coreProductId = 1L; + + void setup() { + CoreProduct coreProduct = CoreProduct.builder() + .id(coreProductId) + .price((long) 10000) + .stock(ACTUAL_STOCK) + .sellerId((long) 1) + .build(); + CoreProduct savedCoreProduct = coreProductRepository.save(coreProduct); + List orders = new ArrayList<>(); + for(int i=1; i<4; i++){ + Order order = Order.builder() + .id((long) i) + .actor(Actors.InexperiencedCustomer) + .paymentId((long) i) + .actualProducts(new ArrayList<>()) + .paymentMethod(PaymentMethods.CREDIT_CARD) + .orderStatus(OrderStatus.PENDING) + .totalPrice(9000L) + .build(); + orders.add(order); + } + List actualProducts = new ArrayList<>(); + for(int i=1; i<4; i++){ + ActualProduct actualProduct = ActualProduct.builder() + .id((long) i) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 9000) + .discountRate(10) + .coreProduct(savedCoreProduct) + .build(); + actualProducts.add(actualProduct); + } + actualProductRepository.saveAll(actualProducts); + for(int i=1; i<=3; i++){ + orders.get(i-1).addActualProducts(List.of(actualProducts.get(i-1))); + actualProducts.get(i-1).setOrder(orders.get(i-1)); + } + orderRepository.saveAll(orders); + } + + /** + * 결제 실패를 상정하고 각 주문을 롤백함 + * 1. 주문의 상태를 실패로 수정 + * 2. 유형 제품의 상태를 대기로 수정, 주문 레코드와 + * 3. 핵심 제품의 재고를 늘리기 + * @throws InterruptedException + */ + @Test + @DisplayName("멀티스레드로 롤백 -> 핵심 상품 재고 롤백 성공") + public void givenMultiThreadAndTransaction_whenUpdated_thenSucess() throws InterruptedException { + setup(); + Long coreProductId = 1L; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(requestCount); + CoreProduct coreProduct = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + log.info("초기 상품 재고량 : "+coreProduct.getStock()); + + for (long i = 1; i <= requestCount; i++) { + long finalI = i; + executorService.submit(() -> { + try { + orderService.rollback(finalI); + } catch (Exception e){ + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } }); + } + countDownLatch.await(); + + Long result = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + List orders = orderRepository.findAll(); +// log.info("핵심 상품 재고 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK + requestCount, result); + for(int i=0; i<3; i++){ + Assertions.assertEquals(OrderStatus.FAIL, orders.get(i).getOrderStatus()); + List actualProducts = actualProductRepository.findByOrder_Id(orders.get(i).getId()); + for(ActualProduct a : actualProducts){ + Assertions.assertEquals(ActualStatus.PENDING_ORDER, a.getActualStatus()); + } + } + } + + @Test + @DisplayName("멀티스레드로 롤백 -> 핵심 상품 재고 롤백 성공") + public void givenMultiThread_Add_coreProduct_thenSucess() throws InterruptedException { + setup(); + Long coreProductId = 1L; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch countDownLatch = new CountDownLatch(requestCount); + CoreProduct coreProduct = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")); + log.info("초기 상품 재고량 : "+coreProduct.getStock()); + + Map coreMap = new HashMap<>(); + coreMap.put(1L, -1L); + for (long i = 1; i <= requestCount; i++) { + long finalI = i; + executorService.submit(() -> { + try { + productService.updateCoreProductsStock(coreMap); + } catch (Exception e){ + System.out.println(e.getMessage()); + } + finally { + countDownLatch.countDown(); + } }); + } + countDownLatch.await(); + + Long result = coreProductRepository.findById(coreProductId).orElseThrow(() -> new RuntimeException("존재하지 않는 상품입니다.")).getStock(); + List orders = orderRepository.findAll(); + log.info("핵심 상품 재고 수량 : "+result); + Assertions.assertEquals(ACTUAL_STOCK + requestCount, result); + } +} diff --git a/src/test/java/com/concurrency/jpa/order/OrderServiceTest.java b/src/test/java/com/concurrency/jpa/order/OrderServiceTest.java new file mode 100644 index 0000000..b1e136b --- /dev/null +++ b/src/test/java/com/concurrency/jpa/order/OrderServiceTest.java @@ -0,0 +1,118 @@ +package com.concurrency.jpa.order; + +import com.concurrency.jpa.customer.Product.ActualProductRepository; +import com.concurrency.jpa.customer.Product.CoreProductRepository; +import com.concurrency.jpa.customer.Product.ProductService; +import com.concurrency.jpa.customer.Product.entity.ActualProduct; +import com.concurrency.jpa.customer.Product.entity.CoreProduct; +import com.concurrency.jpa.customer.Product.enums.ActualStatus; +import com.concurrency.jpa.customer.common.BaseException; +import com.concurrency.jpa.customer.order.OrderRepository; +import com.concurrency.jpa.customer.order.service.OrderService; +import com.concurrency.jpa.customer.order.service.OrderServiceImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.shadow.com.univocity.parsers.annotations.Nested; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class OrderServiceTest { + + @InjectMocks + ProductService productService; + @Mock + ActualProductRepository actualProductRepository; + @Mock + CoreProductRepository coreProductRepository; + private List actualProducts; + private CoreProduct coreProduct; + @BeforeEach + void setup() { + coreProduct = CoreProduct.builder() + .id((long)1) + .price((long) 10000) + .stock((long) 3) + .sellerId((long) 1) + .build(); + ActualProduct ap1 = ActualProduct.builder() + .id((long) 1) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 9000) + .discountRate(10) + .coreProduct(coreProduct) + .build(); + ActualProduct ap2 = ActualProduct.builder() + .id((long) 2) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 8000) + .discountRate(20) + .coreProduct(coreProduct) + .build(); + ActualProduct ap3 = ActualProduct.builder() + .id((long) 3) + .actualStatus(ActualStatus.PENDING_ORDER) + .actualPrice((long) 7000) + .discountRate(30) + .coreProduct(coreProduct) + .build(); + actualProducts = List.of(ap1, ap2, ap3); + } + + @Test + public void Update_CoreStock_Success(){ + when(coreProductRepository.findById((long) 1)) + .thenReturn(Optional.of(coreProduct)); + Assertions.assertEquals(1, productService.subtractCoreProductStock((long) 1, (long) 2)); + } + + @Test + public void Update_CoreStock_Fail(){ + when(coreProductRepository.findById((long) 1)) + .thenReturn(Optional.of(coreProduct)); + Exception exception = Assertions.assertThrows(BaseException.class, + () -> productService.subtractCoreProductStock((long) 1, (long) 4)); + + Assertions.assertTrue(exception.getMessage().contains("재고가 충분하지 않습니다.")); + } + + @Test + public void Find_ProductStock_Success(){ + when(actualProductRepository.findByCoreProduct_IdAndActualStatus( + (long) 1, + ActualStatus.PENDING_ORDER, + PageRequest.of(0, Math.toIntExact(3)))) + .thenReturn(actualProducts); + Map requireStock = new HashMap<>(); + requireStock.put((long) 1, (long) 3); + Assertions.assertEquals(3, productService.findActualProducts( + (long) 1, + ActualStatus.PENDING_ORDER, + (long) 3 + ).size()); + } + + @Test + public void shouldProductStockException(){ + Map requireStock = new HashMap<>(); + requireStock.put((long) 1, (long) 10); + Assertions.assertThrows(BaseException.class, () -> productService.updateCoreProductsStock(requireStock)); + } + + + +} diff --git a/src/test/java/com/concurrency/jpa/product/ProductServiceTest.java b/src/test/java/com/concurrency/jpa/product/ProductServiceTest.java new file mode 100644 index 0000000..3cc088e --- /dev/null +++ b/src/test/java/com/concurrency/jpa/product/ProductServiceTest.java @@ -0,0 +1,15 @@ +package com.concurrency.jpa.product; + +import com.concurrency.jpa.customer.order.service.OrderService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import java.util.HashMap; +import java.util.Map; + +@SpringBootTest +public class ProductServiceTest { + +}