Skip to content

Commit

Permalink
[feat] 결제시 동일한 카트에 대한 중복 결제 문제 (#150)
Browse files Browse the repository at this point in the history
* [feat] redis를 이용하여 cart를 저장/조회/삭제하도록 구현 (#134)

* [feat] Redis 환경설정 파일 생성

- port 와 host를 받아 connection을 생성
- redisTemplate에 직렬화 도구로 key : string, value = GenericJackson2JsonRedisSerializer 로 설정

* [fix] Cart의 생성자를 protected에서 public으로 변경

* [fix] CaartService에서 customerId의 cart를 가져오는 로직을 supplier로 변경

* [feat] Redis전용 CartEntity와 CartItemEntity를 생성

* [feat] Redis를 이용해서 Cart를 영속하는 레포지토리 구현

* [test] Redis를 이용해서 Cart를 영속하는 레포지토리 테스트

* [fix] ContainerSettingTest 삭제

여러개의 test-container를 구동시키면 포트 충돌로 일단 삭제

* [fix] testcode 가 돌아갈 수 있도록 환경변수 추가, inmemory cart repository를 primary로 수정

* [feat] Cart를 DB에 저장한다 (#133)

* [fix] 카트 도메인 엔티티 수정

* [feat] 카트 영속성 엔티티 구현

* [feat] DB를 사용하는 CartRepository 구현체

* [fix] CartService 오류 수정

Transactional 적용
orElse -> orElseGet : 데이터가 존재함에도 불구하고 저장되는 오류 수정

* [fix] 도메인 엔티티로 변환시 id값도 추가

* [refactor] 변수명 변경

* [fix] 생성자 접근제어자 protected

* [test] JpaCartRepository 테스트 코드 작성

* [feat] 구매자는 카트를 조회할 수 있다 (#137)

* [feat] 조회하고자 하는 카트가 없을 때 발생시킬 예외 구현

* [feat] 구매자의 카트를 조회하는 Dao

* [feat] 카트 조회 앤드포인트

* [feat] 카트를 조회할 수 있는 Dao를 Redis를 이용해서 구현 (#135)

* [test] menu 더미를 저장하고 반환하는 메서드 추가 및 상점을 24시간 열려있도록 수정

* [feat] getter 및 생성자 추가

* [feat] redis용 dao를 임시로 inMemoryCartRepository를 이용해서 구현

* [test] RedisCartDao 테스트코드 추가

* [feat] CartItemInfo에도 getter 추가

* [fix] 테스트 이후 데이터를 롤백시키기 위해 @transactional 추가

* [fix] CartResponse의 amount를 Integer로 수정

* [feat] Jpa를 이용한 카트 조회 dao

---------

Co-authored-by: 김현욱 <[email protected]>

* [feat] CartEntity의 CustomerId 변경

String -> UUID

* [fix] CartDao 이중 등록 제거

* [fix] CartItemEntity에 id가 null로 갱신되는 문제 해결

Co-authored-by: Dr-KoKo <[email protected]>

* [fix] 의존 주입할 Bean을 application.yml에서 관리할 수 있도록 수정

Co-authored-by: Dr-KoKo <[email protected]>

* [feat] 중복 주문에 대한 Exception 생성 및 error code 갱신

Co-authored-by: Dr-KoKo <[email protected]>

* [test] 중복 주문 동시성 테스트에 사용할 더미데이터 만드는 메서드 추가

Co-authored-by: Dr-KoKo <[email protected]>

* [test] 중복 주문 방지에 대한 동시성 테스트 시나리오 추가

Co-authored-by: Dr-KoKo <[email protected]>

* [refactor] 검증 부분 주석 추가

* [fix] OneToMany 관계에서 cascade를 사용하기 위해 mappedBy 사용

@joincolumn만 이용하면 CartEntity를 통해CartItemEntity를 영속화시키지 못한다.

* [feat] 마이크로미터 앤드포인트 노출

* [feat] 마이크로미터 앤드포인트 노출

* [test] 마이크로미터 앤드포인트 노출

테스트용 yml 수정

* [fix] APIResponseAdvice에 @EnableConfigurationProperties 추가

* [fix] JpaCartDao 오류 수정

fetchOne()의 결과로 리스트가 반환되어 오류가 발생

* [feat] 주문 시점에 배타락을 적용해서 문제를 해결 (#148)

* [feat] 카트 조회시 X-lock 적용

* [test] TransactionTemplate를 통해서 Cart를 조회

* [feat] 주문 시점에 분산락을 적용해서 문제를 해결한 버전 (#147)

* [feat] cartRepository의 findByCustomerId에 분산락을 적용한 버전 임시

* [feat] 분산락을 잡지 못했을 경우에 던지는 exception을 커스텀해서 던질 수 있도록 수정.

* [fix] wait time 을 0초로 변경

- 중복 결제 요청들이 락을 다시 획득하기 위해서 기다릴 필요가 없음

---------

Co-authored-by: donghar <[email protected]>

* Revert "[feat] 주문 시점에 배타락을 적용해서 문제를 해결 (#148)"

This reverts commit a2b0da5.

* [fix] CartRepository를 트랜잭션 내에서 테스트

* [fix] cart 저장소를 redis로 변경

* [fix] Redisson에 의존하는 configuration 이름을 redis로 변경

* [fix] 생성자 통일

두 개로 분리되어 있던 생성자를 하나로 통일

* [fix] redis 설정 통합

* [fix] 컴파일 오류 해결

디폴트 예외 설정의 누락으로 인한 컴파일 오류 해결

* [fix] 테스트시 in_memory 버전의 CartRepository를 사용하게 설정

* [refactor] build.gradle 정리

---------

Co-authored-by: 김현욱 <[email protected]>
Co-authored-by: Hyeon-Uk <[email protected]>
  • Loading branch information
3 people authored Aug 29, 2024
1 parent 537cdf3 commit a9dd1c3
Show file tree
Hide file tree
Showing 36 changed files with 1,213 additions and 81 deletions.
38 changes: 21 additions & 17 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,30 +24,34 @@ repositories {
}

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

// https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter-data-redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis:3.3.3'
implementation 'org.redisson:redisson-spring-boot-starter:3.35.0'

// https://mvnrepository.com/artifact/com.github.codemonstur/embedded-redis
implementation 'com.github.codemonstur:embedded-redis:1.4.3'

// jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// QueryDsl
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// redisson
implementation 'org.redisson:redisson-spring-boot-starter:3.35.0'
// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// jdbc driver
runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'
// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
// embedded redis
implementation 'com.github.codemonstur:embedded-redis:1.4.3'
// actuator
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
}

tasks.named('test') {
Expand All @@ -72,4 +76,4 @@ compileJava.dependsOn clean
// java source set 에 querydsl QClass 위치 추가
sourceSets {
main.java.srcDirs += "$projectDir/build/generated"
}
}
11 changes: 8 additions & 3 deletions src/main/java/camp/woowak/lab/cart/domain/Cart.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

@Getter
public class Cart {
private Long id;
private final String customerId;
private final List<CartItem> cartItems;

Expand All @@ -29,12 +30,16 @@ public Cart(String customerId) {
}

/**
* 해당 Domain을 사용하는 같은 패키지내의 클래스, 혹은 자식 클래스는 List를 커스텀할 수 있습니다.
*
* @param customerId 장바구니 소유주의 ID값입니다.
* @param cartItems 장바구니에 사용될 List입니다.
*/
protected Cart(String customerId, List<CartItem> cartItems) {
public Cart(String customerId, List<CartItem> cartItems) {
this.customerId = customerId;
this.cartItems = cartItems;
}

public Cart(Long id, String customerId, List<CartItem> cartItems) {
this.id = id;
this.customerId = customerId;
this.cartItems = cartItems;
}
Expand Down
21 changes: 10 additions & 11 deletions src/main/java/camp/woowak/lab/cart/domain/vo/CartItem.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

import java.util.Objects;

import lombok.Getter;

@Getter
public class CartItem {
private Long id;
private final Long menuId;
private final Long storeId;
private final int amount;
Expand All @@ -13,20 +17,15 @@ public CartItem(Long menuId, Long storeId, Integer amount) {
this.amount = amount;
}

public Long getMenuId() {
return menuId;
}

public Long getStoreId() {
return storeId;
}

public int getAmount() {
return amount;
public CartItem(Long id, Long menuId, Long storeId, int amount) {
this.id = id;
this.menuId = menuId;
this.storeId = storeId;
this.amount = amount;
}

public CartItem add(Integer increment) {
return new CartItem(menuId, storeId, amount + increment);
return new CartItem(id, menuId, storeId, amount + increment);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
public enum CartErrorCode implements ErrorCode {
MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "ca_1_1", "해당 메뉴가 존재하지 않습니다."),
OTHER_STORE_MENU(HttpStatus.BAD_REQUEST, "ca_1_2", "다른 매장의 메뉴는 등록할 수 없습니다."),
STORE_NOT_OPEN(HttpStatus.BAD_REQUEST, "ca_1_3", "주문 가능한 시간이 아닙니다.");
STORE_NOT_OPEN(HttpStatus.BAD_REQUEST, "ca_1_3", "주문 가능한 시간이 아닙니다."),
NOT_FOUND(HttpStatus.BAD_REQUEST, "ca_2_1", "카트에 담긴 상품이 없습니다.");

private final int status;
private final String errorCode;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package camp.woowak.lab.cart.exception;

import camp.woowak.lab.common.exception.BadRequestException;

public class NotFoundCartException extends BadRequestException {
public NotFoundCartException(String message) {
super(CartErrorCode.NOT_FOUND, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package camp.woowak.lab.cart.persistence.jpa.entity;

import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

import camp.woowak.lab.cart.domain.Cart;
import camp.woowak.lab.cart.domain.vo.CartItem;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class CartEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private UUID customerId;
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true)
private List<CartItemEntity> cartItems;

public Cart toDomain() {
List<CartItem> cartItems = this.cartItems.stream().map(CartItemEntity::toDomain).collect(Collectors.toList());
return new Cart(id, customerId.toString(), cartItems);
}

public static CartEntity fromDomain(Cart cart) {
CartEntity entity = new CartEntity();
entity.id = cart.getId();
entity.customerId = UUID.fromString(cart.getCustomerId());
entity.cartItems = cart.getCartItems()
.stream()
.map((cartItem) -> CartItemEntity.fromDomain(entity, cartItem))
.collect(Collectors.toList());
return entity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package camp.woowak.lab.cart.persistence.jpa.entity;

import camp.woowak.lab.cart.domain.vo.CartItem;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;

@Entity
public class CartItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long menuId;
private Long storeId;
private int amount;

@ManyToOne
private CartEntity cart;

public CartItem toDomain() {
return new CartItem(id, menuId, storeId, amount);
}

public static CartItemEntity fromDomain(CartEntity cartEntity, CartItem cartItem) {
CartItemEntity entity = new CartItemEntity();
entity.id = cartItem.getId();
entity.menuId = cartItem.getMenuId();
entity.storeId = cartItem.getStoreId();
entity.amount = cartItem.getAmount();
entity.cart = cartEntity;
return entity;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package camp.woowak.lab.cart.persistence.jpa.repository;

import java.util.Optional;
import java.util.UUID;

import org.springframework.data.jpa.repository.JpaRepository;

import camp.woowak.lab.cart.persistence.jpa.entity.CartEntity;

public interface CartEntityRepository extends JpaRepository<CartEntity, Long> {
Optional<CartEntity> findByCustomerId(UUID customerId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package camp.woowak.lab.cart.persistence.jpa.repository;

import java.util.Optional;
import java.util.UUID;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Repository;

import camp.woowak.lab.cart.domain.Cart;
import camp.woowak.lab.cart.persistence.jpa.entity.CartEntity;
import camp.woowak.lab.cart.repository.CartRepository;

@Repository
@ConditionalOnProperty(name="cart.repository", havingValue = "jpa")
public class JpaCartRepository implements CartRepository {
private final CartEntityRepository entityRepository;

public JpaCartRepository(CartEntityRepository entityRepository) {
this.entityRepository = entityRepository;
}

@Override
public Optional<Cart> findByCustomerId(String customerId) {
Optional<CartEntity> entity = entityRepository.findByCustomerId(UUID.fromString(customerId));
if (entity.isEmpty()) {
return Optional.empty();
}
return entity.map(CartEntity::toDomain);
}

@Override
public Cart save(Cart cart) {
CartEntity entity = CartEntity.fromDomain(cart);
CartEntity save = entityRepository.save(entity);
return save.toDomain();
}

@Override
public void delete(Cart cart) {
CartEntity cartEntity = CartEntity.fromDomain(cart);
entityRepository.delete(cartEntity);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package camp.woowak.lab.cart.persistence.redis.entity;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

import org.springframework.data.annotation.Id;
import org.springframework.data.redis.core.RedisHash;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

import camp.woowak.lab.cart.domain.Cart;
import lombok.Getter;

@RedisHash("cart")
@Getter
public class RedisCartEntity implements Serializable {
@Id
private String customerId;

private List<RedisCartItemEntity> cartItems;

@JsonCreator
private RedisCartEntity(@JsonProperty("customerId") String customerId,
@JsonProperty("cartItems") List<RedisCartItemEntity> cartItems) {
this.customerId = customerId;
this.cartItems = cartItems == null ? new ArrayList<>() : cartItems;
}

public static RedisCartEntity fromDomain(Cart cart) {
List<RedisCartItemEntity> list = cart.getCartItems().stream()
.map(RedisCartItemEntity::fromDomain)
.collect(Collectors.toList());
return new RedisCartEntity(cart.getCustomerId(), list);
}

public Cart toDomain() {
return new Cart(customerId, cartItems.stream()
.map(RedisCartItemEntity::toDomain)
.collect(Collectors.toList()));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package camp.woowak.lab.cart.persistence.redis.entity;

import java.io.Serializable;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;

import camp.woowak.lab.cart.domain.vo.CartItem;
import lombok.Getter;

@Getter
public class RedisCartItemEntity implements Serializable {
private Long menuId;
private Long storeId;
private int amount;

@JsonCreator
private RedisCartItemEntity(@JsonProperty("menuId") Long menuId,
@JsonProperty("storeId") Long storeId,
@JsonProperty("amount") int amount) {
this.menuId = menuId;
this.storeId = storeId;
this.amount = amount;
}

protected static RedisCartItemEntity fromDomain(CartItem item) {
return new RedisCartItemEntity(item.getMenuId(), item.getStoreId(), item.getAmount());
}

protected CartItem toDomain() {
return new CartItem(menuId, storeId, amount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package camp.woowak.lab.cart.persistence.redis.repository;

import java.util.Optional;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.data.repository.CrudRepository;

import camp.woowak.lab.cart.domain.Cart;
import camp.woowak.lab.cart.persistence.redis.entity.RedisCartEntity;
import camp.woowak.lab.cart.repository.CartRepository;

@ConditionalOnProperty(name = "cart.repository", havingValue = "redis")
public interface RedisCartRepository extends CrudRepository<RedisCartEntity, String>, CartRepository {
@Override
default Optional<Cart> findByCustomerId(String customerId) {
return findById(customerId).map(RedisCartEntity::toDomain);
}

@Override
default Cart save(Cart cart) {
RedisCartEntity entity = RedisCartEntity.fromDomain(cart);
return save(entity).toDomain();
}

@Override
default void delete(Cart cart) {
deleteById(cart.getCustomerId());
}
}
Loading

0 comments on commit a9dd1c3

Please sign in to comment.