스프링부트 Spring Boot + JPA 쇼핑몰 상품 목록, 장바구니 기능 구현
my code archive
article thumbnail
반응형

백엔드 과제 제출로 오랜만에 처음부터 세팅해서 만져본 스프링부트 + JPA

퇴사하고 약 4달만에 코딩해보는거라 처음부터 약간 버벅거렸지만..

기록해보겠습니당.

 

- 개발 환경

  • Java 21
  • Spring Boot 3.4.1
  • thymeleaf
  • MariaDB 10.10

- build.gradle 의존성

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.projectlombok:lombok'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
implementation 'nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect'
implementation 'com.googlecode.json-simple:json-simple:1.1'

testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'

implementation group: 'org.springframework', name: 'spring-jdbc', version: '6.1.12'
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-thymeleaf', version: '3.3.5'

 

- DB 

장바구니, 장바구니 상품, 상품, 고객, 주문(결제) 테이블로 구성.

 


상품 목록 조회

1. 프론트는 부트스트랩 쇼핑몰 템플릿을 사용했습니다.

https://themewagon.com/themes/foodmart/

 

FoodMart - Free Bootstrap 5 eCommerce Website Template

FoodMart is a free Bootstrap 5 eCommerce website template for grocery store websites. The theme is responsive, search engine optimized, and user-friendly.

themewagon.com

 

2. 상품 Entity

@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Product extends BaseEntity{

	@Id
	@GeneratedValue(strategy =  GenerationType.IDENTITY)
	@Column(name = "product_id")
	private Long id;	//상품 ID
	
	@Column(nullable = false, length = 255)
	private String name;	//상품 이름 
	
	@Column(length = 1000)
	private String description;	//상품 설명 
	
	@Column(nullable = false)
	private Long price;	//상품 가격 
	
	@Column(nullable = false)
	private int stock;	//재고 수량 
	
	@Column(nullable = false)
	private String imgUrl;	//이미지 경로
	
	public static Product toProduct(ProductDto productDto) {
		
		Product product = new Product();
		product.setId(productDto.getId());
		product.setName(productDto.getName());
		product.setDescription(productDto.getDescription());
		product.setPrice(productDto.getPrice());
		product.setStock(productDto.getStock());
		product.setImgUrl(productDto.getImgUrl());
		
		return product;
	}
	
	public static Product uptProduct(ProductDto productDto) {
		
		// 생략
	}
	
	
	
}

 

3. 상품 Dto

@Getter
@Setter
@NoArgsConstructor
@ToString
public class ProductDto {

	private Long id;
	
	private String name;
	
	private String description;
	
	private Long price;
	
	private int stock;
	
	private String imgUrl;
	
	public ProductDto(Long id, String name, String description, Long price, int stock, String imgUrl) {
		
		this.id = id;
		this.name = name;
		this.description = description;
		this.price = price;
		this.stock = stock;
		this.imgUrl = imgUrl;
	}
	
	public static ProductDto toProductDto(Product product) {
		
		ProductDto productDto = new ProductDto();
		productDto.setId(product.getId());
		productDto.setName(product.getName());
		productDto.setDescription(product.getDescription());
		productDto.setPrice(product.getPrice());
		productDto.setStock(product.getStock());
		productDto.setImgUrl(product.getImgUrl());
		
		return productDto;
		
	}
}

 

4. 전체 상품 조회

Repository

public interface ProductRepository extends JpaRepository<Product, Long>{
	
	Product findProductById(Long id);

}

 

Service

/*
 * DTO -> Entity
 * Entity -> DTO
 */

@Service
@RequiredArgsConstructor
public class ProductService {
	
	private final ProductRepository productRepository;
	
	public List<ProductDto> getAllProduct() {
		
		List<Product> productList = productRepository.findAll();
		List<ProductDto> dtoList = new ArrayList<>();
		
		for (Product product : productList) {
			dtoList.add(ProductDto.toProductDto(product));
		}
		
		return dtoList;
	}
	
	public ProductDto findByProductId(Long id) {
		
		// 생략
	}
	
	
}

 

5. 전체 상품 조회 후 메인 화면에 전달

Controller

@Controller
@RequiredArgsConstructor
public class MainController {

	private final ProductService productService;
	
	@GetMapping("/")
	public String main(Model model) {
		
		List<ProductDto> products = productService.getAllProduct();
		model.addAttribute("products", products);
		
		return "index";
	}
}

 

6. 메인 화면 index.html

<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
	  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
	  layout:decorate="~{layout/layout}">
  
	<th:block layout:fragment="script">
 		<script th:inline="javascript">
			// 생략
		 </script>
  	</th:block>

	<th:block layout:fragment="content">
	    <section class="py-5">
	      <div class="container-fluid">
	        
	        <div class="row">
	          <div class="col-md-12">
	
	            <div class="bootstrap-tabs product-tabs">
	              <div class="tabs-header d-flex justify-content-between border-bottom my-5">
	                <h3>상품목록</h3>
	                <nav>
	                  <div class="nav nav-tabs" id="nav-tab" role="tablist">
	                    <a href="#" class="nav-link text-uppercase fs-6 active" id="nav-all-tab" data-bs-toggle="tab" data-bs-target="#nav-all">All</a>
	                    <!-- <a href="#" class="nav-link text-uppercase fs-6" id="nav-fruits-tab" data-bs-toggle="tab" data-bs-target="#nav-fruits">Fruits & Veges</a>
	                    <a href="#" class="nav-link text-uppercase fs-6" id="nav-juices-tab" data-bs-toggle="tab" data-bs-target="#nav-juices">Juices</a> -->
	                  </div>
	                </nav>
	              </div>
	              <div class="tab-content" id="nav-tabContent">
	                <div class="tab-pane fade show active" id="nav-all" role="tabpanel" aria-labelledby="nav-all-tab">
	
	                  <div class="product-grid row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5">
	                  
	                  	<th:block th:each="products : ${products}">
							<input type="hidden" id="productId" th:value="${products.id}">
							<div class="col">
		                      <div class="product-item">
		                        <figure>
		                          <a href="index.html" title="Product Title">
		                            <img th:src="${products.imgUrl}"  class="tab-image">
		                          </a>
		                        </figure>
		                        <h3>[[${products.name}]]</h3>
								<!-- <span class="qty" id="stock">재고 : [[${products.stock}]]개</span> -->
		                        <span class="price">[[${products.price}]]</span>
		                        <div class="d-flex align-items-center justify-content-between">
		                          <div class="input-group product-qty">
		                              <span class="input-group-btn">
		                                  <button type="button" class="quantity-left-minus btn btn-danger btn-number" data-type="minus">
		                                    <!-- <svg width="16" height="16"><use xlink:href="#minus"></use></svg> -->
		                                    <i class="fa-solid fa-minus"></i>
		                                  </button>
		                              </span>
		                              <input type="text" id="quantity" name="quantity" class="form-control input-number" value="1">
		                              <span class="input-group-btn">
		                                  <button type="button" class="quantity-right-plus btn btn-success btn-number" data-type="plus">
		                                      <!-- <svg width="16" height="16"><use xlink:href="#plus"></use></svg> -->
		                                      <i class="fa-solid fa-plus"></i>
		                                  </button>
		                              </span>
		                          </div>
								  <button type="button" class="btn btn-light border border-primary btn-lg btn-cart-font" th:attr="onclick=|addCart(${products.id})|">장바구니 담기 <i class="fa-solid fa-cart-arrow-down"></i></button>
		                        </div>
		                      </div>
		                    </div>
						</th:block>
	                    
	                  </div>
	                  <!-- / product-grid -->
	                  
	                </div>          
	              </div>
	            </div>
	
	          </div>
	        </div>
	      </div>
	    </section>
	</th:block>
</html>

 

 

장바구니 담기 기능

1. 장바구니, 장바구니 상품 Entity

회원 1명당 1개의 장바구니를 갖는다.

@Entity
@Getter
@Setter
@ToString
public class Cart extends BaseEntity{

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "cart_id")
    private Long id;

    private int count; // 카트 상품 개수

    @OneToOne(fetch = FetchType.EAGER)
    @JoinColumn(name="customer_id")
    private Customer customer;
    
    @OneToMany(mappedBy = "cart", cascade = CascadeType.PERSIST)
    private List<CartProduct> cartList = new ArrayList<>();

    
    public static Cart createCart(Customer customer) {
    	Cart cart = new Cart();
    	cart.setCount(0);
    	cart.setCustomer(customer);
    	
    	return cart;
    }
}
@Entity
@Getter
@Setter
public class CartProduct extends BaseEntity{

	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
	@Column(name = "cart_product_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="cart_id")
    private Cart cart;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name="product_id")
    private Product product;

    private int count; // 카트 상품 개수
    
    public static CartProduct createCartProduct(Cart cart, Product product, int count) {
    	// 생략
    }
    
    
    public void addCount(int count) {
    	this.count += count;
    }
    
}

 

2. Dto

@Getter
@Setter
public class CartProductDto {

	@NotNull
	private Long productId;
	
	@Min(value = 1, message = "최소 1개 이상 담아주세요.")
	private int count;
}
@Getter
@Setter
public class CartDetailDto {

	private Long cartProductId;
	
	private String name;
	
	private Long price;
	
	private int count;
	
	private String imgUrl;

	public CartDetailDto(Long cartProductId, String name, Long price, int count, String imgUrl) {
		
		this.cartProductId = cartProductId;
		this.name = name;
		this.price = price;
		this.count = count;
		this.imgUrl = imgUrl;
		
	}
}

 

3. 장바구니 담기

Service

@Service
@RequiredArgsConstructor
@Transactional
public class CartService {
	
	private final CartRepository cartRepository;
	private final ProductRepository productRepository;
	private final CartProductRepository cartProductRepository;
	private final CustomerRepository customerRepository;
	
	public Long addCart(CartProductDto dto, String email) {
		
		// 회원의 장바구니 조회 
		Product product = productRepository.findById(dto.getProductId())
				.orElseThrow(EntityNotFoundException::new);
		
		Customer customer = customerRepository.findByEmail(email);
		
		Cart cart = cartRepository.findByCustomerId(customer.getId());
		
		// 장바구니에 상품을 처음 담을 경우 장바구니 엔티티 생성 
		if (cart == null) {
			cart = Cart.createCart(customer);
			cartRepository.save(cart);
		}
		
		// 현재 상품이 이미 장바구니에 들어가 있는지 조회 
		CartProduct savedProduct = cartProductRepository.findByCartIdAndProductId(cart.getId(), product.getId());
		
		// 상품이 장바구니에 없다면 카트 상품 생성 후 추가
		if (savedProduct != null) {
			savedProduct.addCount(dto.getCount());
			return savedProduct.getId();
		} else {
			CartProduct cartProduct = CartProduct.createCartProduct(cart, product, dto.getCount());
			cartProductRepository.save(cartProduct);
			return cartProduct.getId();
		}
		
	}
	
	@Transactional(readOnly = true)
	public List<CartDetailDto> getCartList(@Param("email") String email) {
		
		// 생략
	}
	
	@Transactional(readOnly = true)
	public boolean validateCart(Long cartProductId, String email) {
		
		// 생략
	}
	
	public void updateCartCount(Long cartProductId, int count) {
		
		// 생략
	}
	
	public void deleteCart(Long cartProductId) {
		
		// 생략
	}

}

 

Controller

@Controller
@RequiredArgsConstructor
public class CartController {
	
	private final CartService cartService;
	
	private final CustomerService customerService;
	
	@PostMapping(value = "/cart")
	public @ResponseBody ResponseEntity addCart(@RequestBody @Valid CartProductDto dto, BindingResult bindingResult, Principal principal){
		
		if (bindingResult.hasErrors()) {
			StringBuilder sb = new StringBuilder();
			List<FieldError> fieldErrors = bindingResult.getFieldErrors();
			
			for (FieldError fieldError : fieldErrors) {
				sb.append(fieldError.getDefaultMessage());
			}
			
			return new ResponseEntity<String>(sb.toString(), HttpStatus.BAD_REQUEST);
		}
		
		String email = principal.getName();
		Long cartProductId;
		
		try {
			
			cartProductId = cartService.addCart(dto, email);
			
		} catch(Exception e){
			
            return new ResponseEntity<String>(e.getMessage(), HttpStatus.BAD_REQUEST);
        }
		
		return new ResponseEntity<Long>(cartProductId, HttpStatus.OK);

    }
	
	@GetMapping("/cart")
	public String getCartList(Principal principal, Model model) {
		
		// 생략
	}
	
	@DeleteMapping(value = "/cart/{cartProductId}")
	public @ResponseBody ResponseEntity deleteCart(@PathVariable("cartProductId") Long cartProductId, Principal principal) {
		
        // 생략
	}
}

 

4. 프론트단

function addCart(productId) {
				
    var url = "/cart";

    var paramData = {
        productId :productId,
        count : $("#quantity").val(),
    };

    var param = JSON.stringify(paramData);

    $.ajax({
        url: url,
        type : "POST",
        contentType : "application/json",
        data: param,
        dataType: "json",
        cache: false,
        success: function(status) {
            alert("장바구니에 상품을 담았습니다.");
            location.href="/";
        },
        error: function(jqXHR, status, error) {

            if (jqXHR.status == '401') {
                alert('로그인 후 이용해주세요.');
                location.href="/customer/login";
            } else {

            }
        }
    });
}

 

장바구니 리스트, 장바구니 삭제 기능

1. 장바구니 리스트 조회, 삭제

Repository

public interface CartProductRepository extends JpaRepository<CartProduct, Long>{
	
	CartProduct findByCartIdAndProductId(Long cartId, Long productId);
	
	@Query("""
			select cp 
            from CartProduct cp join fetch cp.product p
            where cp.cart.id = :cartId
            order by cp.createdAt desc
	        """)
	List<CartProduct> findCartDetailDtoList(@Param("cartId") Long cartId);

}
public interface CartRepository extends JpaRepository<Cart, Long>{
	
	Cart findByCustomerId(Long customerId);

}

 

Service

@Transactional(readOnly = true)
public List<CartDetailDto> getCartList(@Param("email") String email) {

    List<CartDetailDto> cartDetailDtoList = new ArrayList<>();

    Customer customer = customerRepository.findByEmail(email);

    Cart cart = cartRepository.findByCustomerId(customer.getId());

    if (cart == null) {

        return cartDetailDtoList;
    }

    List<CartProduct> cartProductList = cartProductRepository.findCartDetailDtoList(cart.getId());

    cartDetailDtoList = cartProductList.stream()
            .map(m -> new CartDetailDto(m.getId(), 
                                        m.getProduct().getName(),
                                        m.getProduct().getPrice(),
                                        m.getCount(),
                                        m.getProduct().getImgUrl()))
            .collect(Collectors.toList());

    return cartDetailDtoList;
}

public void deleteCart(Long cartProductId) {

    CartProduct cartProduct = cartProductRepository.findById(cartProductId)
            .orElseThrow(EntityNotFoundException::new);

    cartProductRepository.delete(cartProduct);
}

 

Controller

@GetMapping("/cart")
public String getCartList(Principal principal, Model model) {

    // 현재 로그인한 사용자의 이메일 정보를 이용하여 장바구니에 담겨있는 상품 조회
    List<CartDetailDto> cartList = cartService.getCartList(principal.getName());

    Customer customer = customerService.getCustomerInfo(principal.getName());

    model.addAttribute("cartList", cartList);
    model.addAttribute("customerName", customer.getName());
    model.addAttribute("customerEmail", customer.getEmail());

    return "/product/cart";
}

@DeleteMapping(value = "/cart/{cartProductId}")
public @ResponseBody ResponseEntity deleteCart(@PathVariable("cartProductId") Long cartProductId, Principal principal) {

    if (!cartService.validateCart(cartProductId, principal.getName())) {
        return new ResponseEntity<String>("삭제 권한이 없습니다.", HttpStatus.FORBIDDEN);
    }

    cartService.deleteCart(cartProductId);

    return new ResponseEntity<Long>(cartProductId, HttpStatus.OK);
}

 

2. 프론트단 cartList.html

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org"
	  xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
	  xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
	  layout:decorate="~{layout/layout}">
	  
	<th:block layout:fragment="script">
 		// 생략
 	</th:block>
 	
 	<th:block layout:fragment="css">
 		<style>
 			// 생략
 		</style>
 	</th:block>
	<th:block layout:fragment="content">

				<h3>장바구니 목록</h3>
				<div>
					<table class="table">
						<colgroup>
							<col width="15%"/>
							<col width="70%"/>
							<col width="15%"/>
						</colgroup>
						<thead>
							<tr class="text-center">
								<td><input type="checkbox" id="checkall" onclick="chkAll()"/>전체 선택</td>
								<td>상품명</td>
								<td>금액</td>
							</tr>
						</thead>
						<tbody>
							<tr th:each="cartList : ${cartList}">
								<td>
									<input type="checkbox" name="cartChkbox" th:value="${cartList.cartProductId}" >
								</td>
								<td>
									<div class="proImgDiv imgItem">
				                        <img th:src="${cartList.imgUrl}" class = "rounded proImg" th:alt="${cartList.name}">
				                    </div>
				                    <div class="imgItem">
				                        <span th:text="${cartList.name}" id="productName" class="fs17 font-weight-bold"></span>
				                        <div class="fs17 font-weight-light">
				                            <span class="input-group mt-2">
				                                <span th:id="'price_' + ${cartList.cartProductId}"
				                                      th:data-price="${cartList.price}"
				                                      th:text="${cartList.price} + '원'" class="align-self-center mr-2">
				                                </span>
				                                <input type="number" name="count" th:id="'count_' + ${cartList.cartProductId}"
				                                       th:value="${cartList.count}" min="1"
				                                       onchange="changeCount(this)" class="form-control mr-2" >
				                                <button type="button" class="close" aria-label="Close">
				                                    <span aria-hidden="true" th:data-id="${cartList.cartProductId}" onclick="deleteCart(this)">&times;</span>
				                                </button>
				                            </span>
				                        </div>
				                    </div>
								</td>
								<td class="text-center align-middle">
				                    <span th:id="'totalPrice_' + ${cartList.cartProductId}"
				                          name="totalPrice" th:text="${cartList.price * cartList.count} + '원'">
				                    </span>
				                </td>
							</tr>
						</tbody>
					</table>
					
					<h2 class="text-center">
						총 주문 금액 : <span id="orderTotal" class="text-danger">0원</span>
					</h2>
					<div class="text-center">
						<button type="button" class="btn btn-primary btn-lg" onclick="requestPayment()">토스로 결제하기</button>
					</div>
				</div>
	</th:block>
</html>

 

deleteCart

<script th:inline="javascript">
    $(document).ready(function() {
        // 생략
    });


    function deleteCart(obj){

        var cartProductId = obj.dataset.id;
        var url = "/cart/" + cartProductId;

        $.ajax({
            url      : url,
            type     : "DELETE",
            dataType : "json",
            cache   : false,
            success  : function(result, status){
                location.href='/cart';
            },
            error : function(jqXHR, status, error){

                if(jqXHR.status == '401'){
                    alert('로그인 후 이용해주세요');
                    location.href='/customer/login';
                } else{
                    alert('시스템 오류입니다.');
                }

            }
        });
    }
</script>

 

 

반응형
profile

my code archive

@얼레벌레 개발자👩‍💻

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!

반응형