Java/Spring

스프링 쇼핑몰 프로젝트 장바구니(cart) 기능 구현

SeungbeomKim 2022. 9. 14. 17:47
반응형

오늘은 멘토 분과 함께 장바구니 기능을 구현해봤습니다.

우선적으로 생각해야 할 부분이 있습니다. 

Cart(장바구니)는 CartItem(장바구니에 있는 아이템 목록)을 따로 만들어줘야 합니다. 그 이유가 뭐냐면 DB의 속성과도 연관이 있습니다. DB는 각 테이블을 쪼개면 쪼갤수록 안전하기 때문입니다. 이렇게 구현하지 않으면 서비스 코드 엄청 길어지고 쿼리 조회도 비효율적입니다.

그래서 cart, cartItem을 따로 만들어주었습니다.

 

<코드>

Cart, CartItem Entity

package com.example.shoppingmall.entity.cart;

import com.example.shoppingmall.entity.common.EntityDate;
import com.example.shoppingmall.entity.member.Member;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;

@Data
@AllArgsConstructor
@NoArgsConstructor
@Entity
public class Cart extends EntityDate {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Member member;

    public Cart(Member member) {
        this.member = member;
    }
}
package com.example.shoppingmall.entity.cartItem;

import com.example.shoppingmall.entity.cart.Cart;
import com.example.shoppingmall.entity.common.EntityDate;
import com.example.shoppingmall.entity.product.Product;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;

@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class CartItem extends EntityDate {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cart_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Cart cart;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "product_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Product product;

    @Column(nullable = false)
    private int quantity;

    public CartItem(Cart cart, Product product, int quantity) {
        this.cart = cart;
        this.product = product;
        this.quantity = quantity;
    }
}

 

 

다음 코드를 확인해봤을때

Cart와 Member를 n:1 연관관계를 매핑시켜주었고,

CartItem과 cart, product를 n:1 연관관계를 매핑시켜주었습니다.

한 명의 사람이 여러 개의 카트를 가질 수 있으며,

장바구니에 있는 아이템 목록에서 다양한 제품과 다양한 카트가 있을 수 있습니다.

장바구니의 있는 아이템 목록 => A카트(물건 1, 물건 2, 물건 3) B카트(물건 4, 물건 5, 물건 6) C카트(물건 7, 물건 8, 물건 9)

 

이에 더하여 Entity에 각 변수와 객체에 대한 생성자를 만들어주었습니다.

그 이유는 클래스의 필드에 접근하기 위한 getter, setter를 대신해주는 역할을 한다고 생각하시면 좋을 것 같습니다.

 

CartController

<코드>

package com.example.shoppingmall.controller.cart;

import com.example.shoppingmall.dto.cart.CartCreateRequestDto;
import com.example.shoppingmall.entity.member.Member;
import com.example.shoppingmall.exception.MemberNotFoundException;
import com.example.shoppingmall.repository.member.MemberRepository;
import com.example.shoppingmall.response.Response;
import com.example.shoppingmall.service.cart.CartService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;

import javax.validation.Valid;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
public class CartController {

    private final CartService cartService;
    private final MemberRepository memberRepository;

    // 장바구니 담기
    @PostMapping("/carts")
    @ResponseStatus(HttpStatus.CREATED)
    public Response create(@Valid @RequestBody CartCreateRequestDto req) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Member member = memberRepository.findByUsername(authentication.getName()).orElseThrow(MemberNotFoundException::new);
        cartService.create(req, member);
        return Response.success();
    }

    // 장바구니 조회
    @GetMapping("/carts")
    @ResponseStatus(HttpStatus.OK)
    public Response findAll() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Member member = memberRepository.findByUsername(authentication.getName()).orElseThrow(MemberNotFoundException::new);
        return Response.success(cartService.findAll(member));
    }

    // 장바구니 품목 단건 삭제
    @DeleteMapping("/carts/{cartItemId}")
    @ResponseStatus(HttpStatus.OK)
    public Response deleteById(@PathVariable("cartItemId") Long id) {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Member member = memberRepository.findByUsername(authentication.getName()).orElseThrow(MemberNotFoundException::new);
        cartService.deleteById(id, member);
        return Response.success();
    }

    // 장바구니 물건 전체 구매
    @PostMapping("/carts/buying")
    @ResponseStatus(HttpStatus.OK)
    public Response buyingAll() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        Member member = memberRepository.findByUsername(authentication.getName()).orElseThrow(MemberNotFoundException::new);
        cartService.buyingAll(member);
        return Response.success();
    }
}

CartService

<코드>

package com.example.shoppingmall.service.cart;

import com.example.shoppingmall.dto.cart.CartCreateRequestDto;
import com.example.shoppingmall.dto.cart.CartItemResponseDto;
import com.example.shoppingmall.entity.cart.Cart;
import com.example.shoppingmall.entity.cartItem.CartItem;
import com.example.shoppingmall.entity.member.Member;
import com.example.shoppingmall.entity.product.Product;
import com.example.shoppingmall.exception.*;
import com.example.shoppingmall.repository.cart.CartItemRepository;
import com.example.shoppingmall.repository.cart.CartRepository;
import com.example.shoppingmall.repository.member.MemberRepository;
import com.example.shoppingmall.repository.product.ProductRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.ArrayList;
import java.util.List;

@Service
@RequiredArgsConstructor
public class CartService {

    private final CartRepository cartRepository;
    private final CartItemRepository cartItemRepository;
    private final MemberRepository memberRepository;
    private final ProductRepository productRepository;

    @Transactional
    public void create(CartCreateRequestDto req, Member member) {
        Product product = productRepository.findById(req.getProduct_id()).orElseThrow(ProductNotFoundException::new);

        if (product.getQuantity() < req.getQuantity()) {
            throw new LakingOfProductQuantity();
        }

        // 3. 장바구니 만들어줘야한다 사용자한테

        if (cartRepository.findCartByMember(member).isEmpty()) {
            // 장바구니가 없다면 생성
            Cart cart = new Cart(member);
            cartRepository.save(cart);
        }

        Cart cart = cartRepository.findCartByMember(member).get();

        CartItem cartItem = new CartItem(cart, product, req.getQuantity());
        cartItemRepository.save(cartItem);
    }

    @Transactional(readOnly = true)
    public List<CartItemResponseDto> findAll(Member member) {
        Cart cart = cartRepository.findCartByMember(member).orElseThrow(CartNotFoundException::new);

        List<CartItem> items = cartItemRepository.findAllByCart(cart);
        List<CartItemResponseDto> result = new ArrayList<>();

//        items.stream().forEach(cartItem -> {
//            result.add(new CartItemResponseDto().toDto(cartItem, cartItem.getProduct()));
//        };

        for(CartItem item : items) {
            Product product = item.getProduct();
            result.add(new CartItemResponseDto().toDto(item, product.getName(), product.getPrice()));
        }
        return result;
    }

    @Transactional
    public void deleteById(Long id, Member member) {
        CartItem cartItem = cartItemRepository.findById(id).orElseThrow(CartItemNotFoundException::new);
        Cart cart = cartItem.getCart();

        if (!cart.getMember().equals(member)) {
            throw new MemberNotEqualsException();
        }

        cartItemRepository.delete(cartItem);
    }

    @Transactional
    public void buyingAll(Member member) {
        Cart cart = cartRepository.findCartByMember(member).orElseThrow(CartNotFoundException::new);
        List<CartItem> cartItems = cartItemRepository.findAllByCart(cart);

        cartItems.stream().forEach(cartItem -> {
            Product product = cartItem.getProduct();

            checkMemberCanBuyCartItemForEach(product, member, cartItem);

            product.setQuantity(product.getQuantity() - cartItem.getQuantity());
            member.setMoney(member.getMoney() - product.getPrice() * cartItem.getQuantity());
            product.getSeller().setMoney(product.getSeller().getMoney() + (product.getPrice() * cartItem.getQuantity()));
        });

        checkMemberCanBuyCartItemAll(member);
        cartRepository.delete(cart);
    }

    public boolean checkMemberCanBuyCartItemForEach(Product product, Member member, CartItem cartItem) {
        // 1. 구매 수량 체크
        if (cartItem.getQuantity() > product.getQuantity()) {
            throw new LakingOfProductQuantity();
        }

        // 2. 사용자 돈 있는지 체크
        if (member.getMoney() < product.getPrice() * cartItem.getQuantity()) {
            throw new UserLackOfMoneyException();
        }

        return true;
    }

    public boolean checkMemberCanBuyCartItemAll(Member member) {
        if (member.getMoney() < 0) {
            throw new UserLackOfMoneyException();
        }

        return true;
    }
}

 

우선적으로 service는 repository에 있는 데이터를 가져와 기능을 구현하기 때문에 cart, cartitem, member, product의 레포지토리 객체를 선언해주었습니다. 

반면 controller는 요청을 담당하기에 service 객체를 선언해주었고, member 레포지토리를 참고하였습니다.

 

리뷰와는 다르게 장바구니 구현은 생각할 부분이 되게 많았습니다.

사용자가 임의로 장바구니에 담은 아이템의 개수 > 실제 아이템의 개수이면 물건을 살 수 없으므로,

거기에 해당하는 예외처리를 적용시켜줘야 합니다.

그리고 한 유저에 대한 장바구니가 없다면, 유저에 장바구니를 추가해줘야 합니다.

최종적으로  장바구니에 담긴 아이템에 장바구니와 아이템 목록, 개수를 저장해줍니다.

  이제 장바구니 조회 부분입니다.

한 유저에 대한 장바구니를 조회하면, 

아이템, 아이템 이름, 개수가 나와야 합니다.

List<CartItem> items = cartItemRepository.findAllByCart(cart);
List<CartItemResponseDto> result = new ArrayList<>();

//        items.stream().forEach(cartItem -> {
//            result.add(new CartItemResponseDto().toDto(cartItem, cartItem.getProduct()));
//        };

        for(CartItem item : items) {
            Product product = item.getProduct();
            result.add(new CartItemResponseDto().toDto(item, product.getName(), product.getPrice()));
        }

 cartItem 레포지토리에서 모든 장바구니를 끌고 옵니다. 그리고 entity에 있는 민감정보들을 dto로 감싸 todto메소드를 통해 일일이 ResponseDto에 하나씩 item에 대한 정보들을 추가해주었습니다.

CartItem cartItem = cartItemRepository.findById(id).orElseThrow(CartItemNotFoundException::new);
Cart cart = cartItem.getCart();

if (!cart.getMember().equals(member)) {
     throw new MemberNotEqualsException();
}

cartItemRepository.delete(cartItem);

삭제 부분은 비교적 간단히 구현할 수 있습니다.

장바구니 품목 단건 삭제 부분인데, 서버가 요청 보낸 user정보의 토큰과 cart에 있는 유저 정보가 일치하지 않으면 예외처리(401 UNAUTHORIZED)를 해주면 됩니다. 

일치하다면 아이템이 담겨 있는 장바구니를 삭제해주면 됩니다.

 

가장 어려운 구매 부분입니다.

    @Transactional
    public void buyingAll(Member member) {
        Cart cart = cartRepository.findCartByMember(member).orElseThrow(CartNotFoundException::new);
        List<CartItem> cartItems = cartItemRepository.findAllByCart(cart);

        cartItems.stream().forEach(cartItem -> {
            Product product = cartItem.getProduct();

            checkMemberCanBuyCartItemForEach(product, member, cartItem);

            product.setQuantity(product.getQuantity() - cartItem.getQuantity());
            member.setMoney(member.getMoney() - product.getPrice() * cartItem.getQuantity());
            product.getSeller().setMoney(product.getSeller().getMoney() + (product.getPrice() * cartItem.getQuantity()));
        });

        checkMemberCanBuyCartItemAll(member);
        cartRepository.delete(cart);
    }

    public boolean checkMemberCanBuyCartItemForEach(Product product, Member member, CartItem cartItem) {
        // 1. 구매 수량 체크
        if (cartItem.getQuantity() > product.getQuantity()) {
            throw new LakingOfProductQuantity();
        }

        // 2. 사용자 돈 있는지 체크
        if (member.getMoney() < product.getPrice() * cartItem.getQuantity()) {
            throw new UserLackOfMoneyException();
        }

        return true;
    }

    public boolean checkMemberCanBuyCartItemAll(Member member) {
        if (member.getMoney() < 0) {
            throw new UserLackOfMoneyException();
        }

        return true;
    }

구매 부분에서는 가장 고려해야 할 부분이 많습니다.

1. 사용자가 돈이 없는지 check

2. 장바구니에 담겨있는 아이템의 개수와 남은 아이템의 개수 고려

3. 구매했다면, Product에서 아이템의 개수를 줄여주고 카트를 없애줘야 합니다.

4. 사용자가 가지고 있는 돈의 금액을 줄여줘야 합니다.

Dto

<코드>

CartItemResponseDto

package com.example.shoppingmall.dto.cart;

import com.example.shoppingmall.entity.cartItem.CartItem;
import com.example.shoppingmall.entity.product.Product;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@AllArgsConstructor
@NoArgsConstructor
@Data
public class CartItemResponseDto {

    private Long cartItemId;
    private String name;
    private Integer InsertQuantity;
    private Integer price;

    public static CartItemResponseDto toDto(CartItem cartItem, String name, int price) {
        return new CartItemResponseDto(cartItem.getId(), name, cartItem.getQuantity(), price);
    }
}

 여기에서는 장바구니 아이템 목록에서의 아이템의 번호, 이름, 수량, 가격을 받아서 toDto를 통해 cartItem의 정보들을 ResponseDto로 넘겨줘야 합니다.

CartRequestDto

<코드>

package com.example.shoppingmall.dto.cart;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.validation.constraints.NotNull;

@Data
@AllArgsConstructor
@NoArgsConstructor
public class CartCreateRequestDto {

    @NotNull(message = "상품 번호를 입력하세요.")
    private Long product_id;

    @NotNull(message = "구매 수량을 입력하세요.")
    private Integer quantity;
}

 

이제 다음 시간에는 Postman을 통해 API들을 검증해보고, Cart기능에 대한 Jnit5 Test를 직접 작성해보겠습니다.

더불어 각 entity 간의 접근을 줄이기 위해(안정성을 위해) Link(중재자 역할)를 따로 만들어서 코트를 리팩토링 해보겠습니다.

Member <-> Link(history) <-> Cart <-> Link(history) <-> Product 

 

반응형