코드의 개선 사항이 필요해보인다면 언제든 댓글로 지적해주세요 :)
구현하려는 장바구니 기능의 요구사항은 다음과 같다.
- 사용자는 두 종류의 장바구니를 가진다.
- IsLotto(true : 금액 제한 장바구니, false : 무제한 장바구니)
- 장바구니 사용자는 현재 인증된 토큰을 기반으로 찾는다.
- 장바구니 상품 추가 시엔 상품 id, 상품 수량, IsLotto 값, 상품 종류(enum) 값이 필요하다.
- 장바구니 물품 id를 반환해준다.
- isLotto가 true 일 경우, 담은 금액이 제한 금액(최신 회차 로또 실수령액)을 넘길 경우 에러를 반환한다.
- 장바구니 조회 시엔 IsLotto 값이 필요하다. (default : true)
- 조회할 장바구니가 없다면 새로 생성한다.
- 장바구니 상품 제거 시엔 장바구니 물품 id 값이 필요하다.
- 장바구니 상품 수정 시엔 장바구니 물품 id, 변경할 수량 값이 필요하다.
- 사용자가 삭제되면 장바구니도 모두 삭제되어야 한다.
- 장바구니가 삭제되면 장바구니 내에 물품도 모두 삭제되어야 한다.
Entity
@Getter
@Setter
@Entity(name = "bucketlist")
@Table(name = "bucketlist", indexes = {
@Index(name = "idx_account", columnList = "account_oid")
})
public class BucketListEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bucketListOid;
private Boolean isLotto;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "account_oid", nullable = false)
private AccountEntity account;
@Column(nullable = false, columnDefinition = "BIGINT DEFAULT 0")
private Long totalPrice = 0L;
@Column
private Integer lottoRound;
@Column
private Long lottoPrize;
public static BucketListEntity create(AccountEntity account, Boolean isLotto, Integer lottoRound, Long lottoPrize) {
BucketListEntity bucketList = new BucketListEntity();
bucketList.setAccount(account);
bucketList.setIsLotto(isLotto);
if (Boolean.TRUE.equals(isLotto)) {
bucketList.setLottoRound(lottoRound);
bucketList.setLottoPrize(lottoPrize);
}
return bucketList;
}
}
@Getter
@Setter
@Entity(name = "cartItem")
@Table(name = "cartItem", indexes = {
@Index(name = "idx_bucket_list_oid", columnList = "bucket_list_oid"),
})
public class CartItemEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long cartItemId;
@Enumerated(EnumType.STRING)
private TargetType targetType;
@Column(nullable = false)
private String targetId;
@Column(nullable = false)
private Integer amount; // 수량
@ManyToOne
@JoinColumn(name = "bucket_list_oid", nullable = false)
private BucketListEntity bucketList;
public static CartItemEntity create(TargetType targetType, String targetId, Integer amount, BucketListEntity bucketList){
CartItemEntity cartItem = new CartItemEntity();
cartItem.setTargetType(targetType);
cartItem.setTargetId(targetId);
cartItem.setAmount(amount);
cartItem.setBucketList(bucketList);
return cartItem;
}
}
- @Entity : 해당 어노테이션을 붙여야 JPA에서 해당 클래스를 엔티티로 지정해준다.
- @Table : 매핑할 테이블을 지정해주는 어노테이션이다.
- 회원별 버킷리스트 조회와, 버킷리스트에 저장된 카트아이템 조회 성능을 높이기 위해 인덱싱을 진행하였다.
- @GeneratedValue : Oid는 기본 키로 자동 생성 되게끔 설정
- 버킷리스트 - 회원 연결 : 버킷리스트 두개가 하나의 계정과 연결된다. 이때 계정을 필요할 때만 연결해 조회하는 LAZY 타입으로 설정해 성능을 높였다.
- 상품 - 버킷리스트 연결: 상품 여러개가 하나의 버킷리스트에 저장되는 일대다 관계이다. 원래는 양방향 연결로 구현하였었으나, 사이클이 돌거나 데이터 동기화 문제가 발생할 것을 우려하여 단방향 연결로 수정하였다. 일대다 관계에선 다수에 속한 쪽에서 연결을 진행하는 것이 일반적이기 때문에 카트아이템 엔티티에 연결 코드를 작성하였다.
DTO
@Data
@AllArgsConstructor
public class BucketListDto {
private Long bucketListOid;
private Boolean isLotto;
private Long totalPrice;
private Long lottoPrize;
private List<CartItemDto> cartItems;
public static BucketListDto fromEntity(BucketListEntity entity, List<CartItemEntity> cartItems) {
return new BucketListDto(
entity.getBucketListOid(),
entity.getIsLotto(),
entity.getTotalPrice(),
entity.getLottoPrize(),
cartItems.stream().map(CartItemDto::fromEntity).collect(Collectors.toList())
);
}
}
@Data
@AllArgsConstructor
public class CartItemDto {
private Long cartItemId;
private TargetType targetType;
private String targetId;
private Integer amount;
public static CartItemDto fromEntity(CartItemEntity entity) {
return new CartItemDto(
entity.getCartItemId(),
entity.getTargetType(),
entity.getTargetId(),
entity.getAmount()
);
}
}
카트 아이템에서 버킷 리스트로의 단방향 연결을 해놓았기 때문에, 버킷리스트를 조회하게 되면 담긴 카트 아이템을 바로 볼 수 없다.
현재 버킷리스트를 조회하면 버킷리스트 객체로 검색 쿼리를 돌려 담긴 물품도 같이 출력되게 서비스 로직을 작성해둔 상태이다. 한번의 출력으로 원하는 모든 객체를 출력하기 위해 버킷리스트 DTO에 cartItem 리스트를 담는 로직을 추가하였다.
Controller
@RestController
@RequestMapping("/api/cart")
@RequiredArgsConstructor
public class CartController {
private final BucketListService bucketListService;
@PostMapping
public ResponseEntity<?> addItemToBucketList(
@RequestParam Boolean isLotto,
@RequestParam TargetType targetType,
@RequestParam String targetId,
@RequestParam @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") Integer amount) {
Long cartItemId = bucketListService.addItemToBucketList(isLotto, targetType, targetId, amount);
return ResponseEntity.ok(Map.of("cartItemId", cartItemId));
}
@GetMapping
public ResponseEntity<BucketListDto> getCurrentBucketList(
@RequestParam(defaultValue = "true") Boolean isLotto) {
BucketListDto bucketList = bucketListService.getBucketListForCurrentUser(isLotto);
return ResponseEntity.ok(bucketList);
}
@DeleteMapping("/{cartItemId}")
public ResponseEntity<?> removeCartItem(
@PathVariable Long cartItemId) {
bucketListService.removeCartItem(cartItemId);
return ResponseEntity.ok(Map.of("message", "Item successfully removed."));
}
@PutMapping("/{cartItemId}")
public ResponseEntity<?> updateItem(
@PathVariable Long cartItemId,
@RequestParam @Min(value = 1, message = "수량은 1개 이상이어야 합니다.") Integer newAmount ) {
bucketListService.updateCartItem(cartItemId, newAmount);
return ResponseEntity.ok(Map.of("message", "Item successfully updated."));
}
}
원래 HTTP 메서드 매핑을 할때 URL을 굉장히 명시적으로 지었었다. (ex. api/cart/update/{cartItem}..)
해당 방식으로 하게 되면 한눈에 무슨 일을 하는지 알 수 있긴하지만 알고보니 추천되지 않는 방식이라고 한다. 본래 API는 HTTP 메서드와 URL 주소의 조합을 통해서 그 역할을 파악할 수 있는것이 정석이라길래 후딱 고쳤다.
요청 파라미터를 받을 때 기본적인 입력값 유효성 검증을 진행하였고, 로직과 관련된 검증은 서비스 계층으로 미루었다.
Service
@Service
@RequiredArgsConstructor
public class BucketListService {
private final BucketListRepository bucketListRepository;
private final CartItemRepository cartItemRepository;
private final LottoService lottoService;
private final CurrentUserService currentUserService;
private final ProductService productService;
public BucketListDto getBucketListForCurrentUser(Boolean isLotto) {
AccountEntity currentUser = currentUserService.getCurrentUser();
BucketListEntity bucketList = getOrCreateBucketList(currentUser, isLotto);
List<CartItemEntity> cartItems = cartItemRepository.findByBucketList(bucketList);
return BucketListDto.fromEntity(bucketList, cartItems);
}
private BucketListEntity getOrCreateBucketList(AccountEntity account, Boolean isLotto) {
return bucketListRepository.findByAccountAndIsLotto(account, isLotto)
.orElseGet(() -> {
LottoDto latestLottoInfo = isLotto ? lottoService.getLatestLotto() : null;
BucketListEntity newBucketList = BucketListEntity.create(
account, isLotto,
latestLottoInfo != null ? latestLottoInfo.getRound() : null,
latestLottoInfo != null ? latestLottoInfo.getActualWinnings() : null
);
return bucketListRepository.save(newBucketList);
});
}
@Transactional
public Long addItemToBucketList(Boolean isLotto, TargetType targetType, String targetId, Integer amount) {
AccountEntity currentUser = currentUserService.getCurrentUser();
BucketListEntity bucketList = getOrCreateBucketList(currentUser, isLotto);
Long price = productService.getProductDetail(targetId,targetType.toString()).getPrice();
validateLottoLimit(bucketList, price * amount);
updateTotalPrice(bucketList, price * amount);
CartItemEntity cartItem = CartItemEntity.create(targetType, targetId, amount, bucketList);
cartItemRepository.save(cartItem);
return cartItem.getCartItemId();
}
@Transactional
public void removeCartItem(Long cartItemId) {
CartItemEntity cartItem = findCartItemById(cartItemId);
Long price = productService.getProductDetail(cartItem.getTargetId(), cartItem.getTargetType().toString()).getPrice();
updateTotalPrice(cartItem.getBucketList(), -(price * cartItem.getAmount()));
cartItemRepository.delete(cartItem);
}
@Transactional
public void updateCartItem(Long cartItemId, Integer newAmount) {
CartItemEntity cartItem = findCartItemById(cartItemId);
int quantityDifference = newAmount - cartItem.getAmount();
Long price = productService.getProductDetail(cartItem.getTargetId(), cartItem.getTargetType().toString()).getPrice();
updateTotalPrice(cartItem.getBucketList(), price * quantityDifference);
cartItem.setAmount(newAmount);
cartItemRepository.save(cartItem);
}
private CartItemEntity findCartItemById(Long cartItemId) {
return cartItemRepository.findById(cartItemId)
.orElseThrow(() -> new AppException(ExceptionCode.NON_EXISTENT_ITEM));
}
private void validateLottoLimit(BucketListEntity bucketList, Long priceToAdd) {
if (bucketList.getLottoPrize() != null &&
(bucketList.getTotalPrice() + priceToAdd > bucketList.getLottoPrize())) {
throw new AppException(ExceptionCode.LOTTO_LIMIT_EXCEEDED);
}
}
private void updateTotalPrice(BucketListEntity bucketList, Long priceDifference) {
bucketList.setTotalPrice(bucketList.getTotalPrice() + priceDifference);
}
}
컨틀롤러 계층과 서비스 계층 사이에서 발생하는 데이터 전송은 DTO로 변환하여 진행하였다. 모든 계층에서의 데이터 전송을 변환하려 하였으나 데이터 변환에 적지 않은 리소스가 소모되는것이 염려되었다.
그후 검색을 해보니 서비스 계층과 레포지토리 계층 사이에서의 데이터 이동은 엔티티를 통해 이루어져도 괜찮다는 글을 보아서 해당 계층 사이에서의 데이터 전송은 엔티티를 통해 진행하였다.
하나의 로직이 하나의 역할만을 수행할 수 있게끔 가격 검증, 비교, 상품 검색, 장바구니 조회, 생성 등 메서드를 나누어 작성하였다.
또한, 수정/삭제/생성 등의 과정에서 성공 및 실패시의 일관성을 유지하기 위해서 @Transactional 어노테이션을 붙여주었다.
'Dev Tool > Spring boot' 카테고리의 다른 글
[Spring] 리팩토링 - 객체 생성 방식 (0) | 2025.02.03 |
---|---|
[Spring] @Scheduled와 JavaMailSender를 이용한 자동 메일 발송 기능 (0) | 2025.01.31 |
[Spring] 로또 API 단위 테스트 코드 작성 (0) | 2025.01.23 |
[Spring] 테스트 코드 기본 개념 (0) | 2025.01.22 |
[Spring] 로또 API 구현(2) & 예외 처리(@validate) (0) | 2025.01.22 |