Study/SpringBoot

[e-commerce 프로젝트] 6. 위시리스트, 장바구니

delay100 2024. 7. 29. 13:12
728x90
반응형

안녕하세요! delay100입니다.

위시리스트, 장바구니는 CRUD 위주로 간단하게 구현해서 사실 별로 할 말이 없습니다..!!ㅎㅎ

사실 이번 포스팅 없이 바로 주문과 관련된 포스팅을 진행하려 했으나,,, 주문을 할 때 장바구니를 통해서 주문을 하기 때문에 가볍게 넣어봤습니다!

쉬어가는 부분이라고 생각하고 가볍게 읽어주시면 좋겠습니다.

 


서론

위시리스트와 장바구니는 자주 변경되므로 DB에 지속적으로 접근하는 것이 적절한지 고민했습니다. 

1. Redis 캐싱

장바구니와 위시리스트 데이터를 Redis에 캐싱하여, 변경이 있을 때만 DB를 업데이트하는 방법입니다. 이를 통해 DB 접근을 최소화하고 서버 부하를 줄일 수 있습니다.

 

2. 로그에 따른 변경

장바구니, 위시리스트의 변경 사항을 로그로 저장하고, 일정 간격으로 DB에 반영하는 방법입니다.

 

3. 쓰기지연(Lazy Write)

장바구니, 위시리스트의 변경 사항을 즉시 DB에 반영하지 않고, 일정 시간 동안 누적한 후 한꺼번에 반영하는 방법입니다.

 

프로젝트를 만들던 시점에서는 Redis로 캐싱하는 방식밖에 떠오르지 않았었습니다. 그럼에도 Redis를 사용하지 않고 DB에 접근하기로 결정내렸던 것은 프로젝트가 Redis에 지나치게 의존하지 않도록 하고 싶었습니다. 꼭 필요한 경우를 제외하고는 DB의 값을 직접 변경하는 방식을 쓰는 게 더 좋다고 생각했습니다. 그러나 지금 와서 생각하니, Redis로 캐싱하는 것이 서버 부하 측면에서 더 좋을 것 같습니다.

 

장바구니, 위시리스트의 ERD는 아래와 같습니다.

위시리스트, 장바구니 ERD

둘 다 회원, 상품에 대해 1:N로 동일한 연관관계를 가지고 있습니다.

두 테이블의 차이점은 위시리스트는 상품 옵션 선택 없이 저장한다는 특징을 가지고 있고, 장바구니는 상품 옵션, 담은 수량을 명시해둔다는 점이 있습니다.


1. 위시리스트


1-1. 동작 방식

카카오 선물하기 - 위시리스트

카카오 선물하기의 위시리스트를 떠올리면 됩니다. 상품 옵션 없이 하나의 상품 자체를 위시리스트에 등록하는 방식입니다.


1-1-1. 위시리스트 단건 추가

위시리스트 단건 추가 순서

 

1-1-2. 위시리스트 단건 삭제

위시리스트 단건 삭제 순서

1-1-3. 위시리스트 상품 리스트

wishlist DB에서 페이지네이션 처리 후 값을 가져옵니다.

 


1-2. 위시리스트 구현

Github 주소

// Wishlist
@Entity
@Table(name = "wishlist")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Wishlist extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long wishlistId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;


    public static Wishlist from(WishlistRequestDto wishlist) {
        return Wishlist.builder()
                .product(wishlist.getProduct())
                .member(wishlist.getMember())
                .build();
    }
}
// WishlistController
@RestController
@RequiredArgsConstructor
public class WishlistController {
    private final WishlistService wishlistService;

    private static final String BASE_WISHLIST = "/wishlist";

    /**
     * 위시리스트 상품 추가
     * @param userDetails security의 회원 정보
     * @param wishlistWishRequestDto 추가할 상품 정보
     * @return 상품 추가 성공 여부(T/F)
     */
    @PostMapping("/wishlist")
    public ApiResponse<Boolean> createWishlistWish(
            @AuthenticationPrincipal UserDetailsImpl userDetails,
            @RequestBody WishlistWishRequestDto wishlistWishRequestDto
    ) {
        return ApiResponse.createSuccess(wishlistService.createWishlistWish(userDetails.getMember(), wishlistWishRequestDto.getProductId()));
    }

    /**
     * DELETE
     * 위시리스트 상품 삭제
     * @param userDetails security의 회원 정보
     * @param wishlistWishRequestDto 삭제할 상품 정보
     * @return 상품 삭제 성공 여부(T/F)
     */
    @DeleteMapping("/wishlist")
    public ApiResponse<Boolean> deleteWishlistWish(
            @AuthenticationPrincipal UserDetailsImpl userDetails,
            @RequestBody WishlistWishRequestDto wishlistWishRequestDto
    ) {
        return ApiResponse.createSuccess(wishlistService.deleteWishlistWish(userDetails.getMember(), wishlistWishRequestDto.getProductId()));
    }

    /**
     * GET
     * 위시리스트 리스트
     * @param userDetails security의 회원 정보
     * @param page 페이지 번호
     * @param size 한 페이지에 띄울 수
     * @return 위시리스트 리스트 DTO
     */
    @GetMapping("/wishlist")
    public ApiResponse<Page<WishlistResponseDto>> getAllWishlist(
            @AuthenticationPrincipal UserDetailsImpl userDetails,
            @RequestParam int page,
            @RequestParam int size
    ) {
            return ApiResponse.createSuccess(wishlistService.getAllWishlist(userDetails.getMember(), page, size));
    }
}
// WishlistService
@Slf4j
@Service
@RequiredArgsConstructor
public class WishlistService {
    private final WishlistRepository wishlistRepository;
    private final ProductRepository productRepository;

    @Transactional
    public boolean createWishlistWish(Member member, Long productId) {
        // 상품 조회
        Product product = productRepository.findByIdForUpdate(productId)
                .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));

        // 위시리스트에 상품이 이미 존재하는지 확인
        if (wishlistRepository.existsByMemberMemberIdAndProductProductId(member.getMemberId(), productId)) {
            throw new IllegalArgumentException("이미 등록되어있는 상품입니다.");
        }
        // Wishlist 생성 및 저장
        Wishlist wishlist = Wishlist.from(WishlistRequestDto.from(member, product));
        wishlistRepository.save(wishlist);

        // product의 wishlistCount 증가
        product.setProductWishlistCount(product.getProductWishlistCount() + 1);
        productRepository.save(product);

        return true;
    }

    @Transactional
    public boolean deleteWishlistWish(Member member, Long productId) {
        Product product = productRepository.findByIdForUpdate(productId)
                .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));

        // wishlist조회 했는데 이미 값이 있는 경우 삭제
        Optional<Wishlist> wishlist = wishlistRepository.findByMemberMemberIdAndProductProductId(member.getMemberId(), productId);
        wishlist.ifPresent(wishlistRepository::delete);

        // product의 wishlistCount 감소
        product.setProductWishlistCount(product.getProductWishlistCount() - 1);
        productRepository.save(product);

        return true;
    }

    @Transactional(readOnly = true)
    public Page<WishlistResponseDto> getAllWishlist(Member member, int page, int size) {
        Pageable pageable = PageRequest.of(page, size);

        return wishlistRepository.findByMemberMemberId(member.getMemberId(), pageable).map(WishlistResponseDto::from);
    }
}

2. 장바구니

위시리스트에서 상품 옵션, 수량이 추가된 것 말고는 딱히 차이점이 없습니다.


2-1. 동작 방식

카카오 선물하기 - 장바구니

동작은 카카오 선물하기의 장바구니를 떠올리면 됩니다. 각 옵션별 따로 장바구니에 저장할 수 있습니다.   


2-1-1. 장바구니 단건 추가

큰 동작은 위시리스트와 동일합니다! 그러나 상품(product)DB에 가지 않아도 됩니다.

이미 장바구니에 있는 상품이면 재고만 추가하고, 카트에 없는 상품이면 장바구니에 상품을 추가합니다.

 

1-1-2. 장바구니 단건 삭제

큰 동작은 위시리스트와 동일합니다! 단건 추가와 동일하게 상품(product)DB에 가지 않아도 됩니다.

 

1-1-3. 장바구니 상품 리스트

장바구니 DB에서 페이지네이션 처리 후 값을 가져옵니다. 가져온 리스트에서 상품의 총 가격을 계산해서 프론트에 전체 상품 정보를 넘겨줍니다. 


2-2. 장바구니 구현

// Cart
@Entity
@Table(name = "cart")
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Cart extends Timestamped {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long cartId;

    @Column(nullable = false)
    private Long cartProductOptionId;

    @Column(nullable = false)
    private int cartProductQuantity; // 카트에 담은 수

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id", nullable = false)
    private Product product;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    public static Cart from(CartRequestDto cart) {
        return Cart.builder()
                .cartProductOptionId(cart.getCartProductOptionId())
                .cartProductQuantity(cart.getCartProductQuantity())
                .member(cart.getMember())
                .product(cart.getProduct())
                .build();
    }

    public void setCartProductQuantity(int cartProductStock) {
        this.cartProductQuantity = cartProductStock;
    }
}
// CartController
@RestController
@RequiredArgsConstructor
public class CartController {
    private final CartService cartService;

    private static final String BASE_CART = "/cart";

    /**
     * POST
     * 장바구니에 넣기
     * @param userDetails security의 회원 정보
     * @param cartInfoRequestDto 장바구니 담는 객체
     * @return 담은 장바구니 DTO
     */
    @PostMapping(BASE_CART)
    public ApiResponse<CartInfoResponseDto> createCart(
            @AuthenticationPrincipal UserDetailsImpl userDetails,
            @RequestBody CartInfoRequestDto cartInfoRequestDto
    ) {
        return ApiResponse.createSuccess(cartService.createCart(userDetails.getMember(), cartInfoRequestDto.getProductId(), cartInfoRequestDto.getProductOptionId(), cartInfoRequestDto.getQuantity()));
    }

    /**
     * DELETE
     * 장바구니에서 상품 삭제
     * @param userDetails security의 회원 정보
     * @param cartSimpleInfoRequestDto 장바구니에서 삭제할 정보
     * @return 상품 삭제 여부(T/F)
     */
    @DeleteMapping(BASE_CART)
    public ApiResponse<Boolean> deleteCart(
            @AuthenticationPrincipal UserDetailsImpl userDetails,
            @RequestBody CartSimpleInfoRequestDto cartSimpleInfoRequestDto
    ) {
        return ApiResponse.createSuccess(cartService.deleteCart(userDetails.getMember(), cartSimpleInfoRequestDto.getProductId(), cartSimpleInfoRequestDto.getProductOptionId()));
    }

    /**
     * GET
     * 장바구니 모든 상품 리스트
     * @param userDetails security의 회원 정보
     * @return 장바구니 모든 상품 리스트 DTO
     */
    @GetMapping(BASE_CART)
    public ApiResponse<CartAllInfoResponseDto> getCartAllInfo(
        @AuthenticationPrincipal UserDetailsImpl userDetails
    )   {
        return ApiResponse.createSuccess(cartService.getCartAllInfo(userDetails.getMember()));
    }
}
// CartService
@Slf4j
@Service
@RequiredArgsConstructor
public class CartService {
    private final CartRepository cartRepository;
    private final ProductRepository productRepository;
    private final ProductOptionRepository productOptionRepository;

    @Transactional
    public CartInfoResponseDto createCart(Member member, Long productId, Long productOptionId, int quantity) {
        // 상품 조회
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new IllegalArgumentException("찾는 상품이 없습니다."));

        ProductOption productOption = productOptionRepository.findById(productOptionId)
                    .orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));

        // 카트에 있는 상품 조회
        Optional<Cart> optionalCart = cartRepository.findByMemberMemberIdAndProductProductIdAndCartProductOptionId(
                member.getMemberId(), productId, productOptionId);

        Cart cart;
        if (optionalCart.isEmpty()) {
            // 카트에 없는 상품이라면 새로 생성
            cart = Cart.from(CartRequestDto.from(productOptionId, quantity, member, product));
            cartRepository.save(cart);
        } else {
            // 카트에 있는 상품이라면 수량을 증가시킴
            cart = optionalCart.get();
            cart.setCartProductQuantity(cart.getCartProductQuantity() + quantity);
        }
        int productTotalPrice = (product.getProductPrice() + productOption.getProductOptionPrice()) * cart.getCartProductQuantity();
        return CartInfoResponseDto.from(productId, product.getProductTitle(), product.getProductPrice(), cart.getCartProductQuantity(), productOption, productTotalPrice);
    }

    @Transactional
    public Boolean deleteCart(Member member, Long productId, Long productOptionId) {
        // 카트에 있는 상품 조회
        Cart cart = cartRepository.findByMemberMemberIdAndProductProductIdAndCartProductOptionId(
                        member.getMemberId(), productId, productOptionId)
                .orElseThrow(() -> new IllegalArgumentException("삭제할 상품이 없습니다."));

        cartRepository.delete(cart);

        return true;
    }

    @Transactional(readOnly = true)
    public CartAllInfoResponseDto getCartAllInfo(Member member) {
        List<Cart> cartList = cartRepository.findByMemberMemberId(member.getMemberId());

        List<CartInfoResponseDto> cartInfoResponseDtoList = cartList.stream().map(cart -> {
            Product product = cart.getProduct();
            ProductOption productOption = productOptionRepository.findById(cart.getCartProductOptionId())
                        .orElseThrow(() -> new IllegalArgumentException("찾는 상품 옵션이 없습니다."));

            int productTotalPrice = (product.getProductPrice() + productOption.getProductOptionPrice()) * cart.getCartProductQuantity();
            return CartInfoResponseDto.from(product.getProductId(), product.getProductTitle(), product.getProductPrice(), cart.getCartProductQuantity(), productOption, productTotalPrice);
        }).collect(Collectors.toList());

        int totalPrice = cartInfoResponseDtoList.stream()
                .mapToInt(CartInfoResponseDto::getProductTotalPrice)
                .sum();

        return CartAllInfoResponseDto.from(cartInfoResponseDtoList, totalPrice);
    }
}

3. 결과 및 예제(API)

아래의 링크에서 더 자세히 보실 수 있습니다.

API(POSTMAN) : https://documenter.getpostman.com/view/23481846/2sA3kSo3ZJ

POSTMAN
위시리스트 - 단건 등록
위시리스트 - 단건 삭제
위시리스트 - 상품 리스트
장바구니 - 단건 추가
장바구니 - 단건 삭제
장바구니 - 상품 리스트


4. 이후 추가하고 싶은 기능

4-1. 장바구니를 Redis에서 관리하기

장바구니 데이터를 Redis에서 관리하여 빠른 접근과 변경을 가능하게 하고, 서버 부하를 더욱 줄이는 방식을 도입하고 싶습니다.

 

4-2. 삭제에 대해 논리적 삭제로 변경하기

장바구니, 위시리스트 테이블 각각에 deleted 컬럼을 추가해서 논리적 삭제로 변경하는 방식이 있습니다. 이 방식으로 데이터를 삭제하면 실제로 데이터베이스에서 삭제하지 않고, 삭제 여부에 따라 사용자에게 보여주는 값을 변경할 수 있습니다.

논리적 삭제를 도입하면, 나중에 사용자 맞춤형 추천 알고리즘 등에서 삭제된 데이터를 활용할 수 있어 유용할 것입니다.

 


긴 글 읽어주셔서 감사합니다! 코드나 동작 방식에 대해 궁금한 점이 있으시면 언제든지 질문해 주세요. 피드백도 환영합니다.

728x90
반응형