🎅 오너먼트 프로젝트

장바구니 AJAX 비동기처리 코드 분석

보배 진 2026. 1. 3. 23:11

🍄 장바구니 페이지가 하는 일 🍄

장바구니 페이지에서는 AJAX를 활용해 상품 수향 변경 및 삭제 시 

페이지 새로 고침없이 즉시 반영되도록 수현하였고,

총 금액이 0원일 경우 구매 버튼을 비활성화하여

잘못된 결제 흐름을 사전에 차단하였다

 

🍄 script 전체 구조 🍄

1. DOM 요소 캐싱

2. + / - 버튼 수량 변경

3. 집적 입력 수량 변경

4. AJAX 업데이트 함수

5. 상품 삭제 AJAX

6. 총 금액 계산 + 구매 버튼 제어

 

 


 

 

[ 변수 ]

const buyBtn = $(".cart-buy-btn");
const cartbody = $("#cart-tbody");
const totalPriceElem = $(".cart-summary .total-price");

buyBtn : 구매 버튼 DOM 요소을 갖고 있음 | 구매 버튼의 활성/비활성 상태를 제어

cartbody : 장바구니 상품들이 들어있는 <tbody> | 장바구니 상품 목록을 담는 컨테이너

totalPriceElem : 장바구니 총금액을 표시하는 DOM 요소 | 장바구니 전체 금액을 화면에 출력하는 표시 담당

 

 

 

[ 버튼으로 개수 변경 ]

$(document).on("click", ".btn-minus, .btn-plus", function(){
    const tr = $(this).closest("tr");
    const cartPk = tr.data("cartpk");
    const input = tr.find("input");
    let count = parseInt(input.val()) || 1;

    if($(this).hasClass("btn-minus")) count -= 1;
    else count += 1;

    // 개수 변경 함수 호출
    updateCartCount(cartPk, count, tr, input);
});

tr : 클릭된 버튼이 속한 상품 한 줄(tr) 찾기 ➡ 어떤 상품의 버튼을 눌렀는지 식별

cartPk : 서버에서 해당 상품을 식별하는 고유 ID, AJAX로 요청 시 전달됨

input : 해당 상품의 수량 읽기, 이 상품의 상태값 역할

count : 현재 수량을 숫자로 변환


 

[ 장바구니 안의 개수 변경 ]

// AJAX : 개수변경, 총금액
function updateCartCount(cartPk, count, tr, input){
    // 최소/최대 범위 제한
    if(count < 1) count = 1;
    if(count > 99) count = 99;

    $.ajax({
        url: "${pageContext.request.contextPath}/UpdateCartItemCountServlet",
        type: "POST",
        data: { cartPk: cartPk, newCount: count },
        success: function(res){
            if(res === "true"){
                input.val(count);

                // 항목 합계 업데이트
                const price = parseInt(tr.find(".item-price").text().replace(/,/g,"").replace("원",""));
                const total = price * count;
                tr.find(".item-total p").text(total.toLocaleString("ko-KR") + "원");


                // 촘 금액 함수 호출
                updateTotalPrice();
            } else {
                alert("수량 변경 실패");
            }
        }
    });
}

 

수량 변경은 여러 이벤트에서 발생하기 때문에

공통함수로 분리하여 처리.

서버에서 변경 성공 응답을 받은 경우에만

수량과 금액 UI를 갱신하도록 설계했습니다

이를 통해 서버 상태와 화면 상태의 불일치를 방지

 

 

[ 각 매개변수 역할 ]

cartPk 서버에서 장바구니 항목을 식별하는 PK
count 변경하려는 수량
tr 해당 상품의 행(<tr>)
input 수량 입력창 DOM

 

 

[ 수량 범위 제한 ]

if 조건문으로 하는데 여기서 하는 이유

버튼 이벤트, 직접 입력 이벤트로 진입 경로가 2가지이기 때문에

서버로 보내기 전에 프론트에서 한 번 더 정제

 

 

[ AJAX ]

비동기 처리를 하는 부분을 보면 페이지를 새로고침하지 않고 UX를 즉시 반영한다

요청 URL 

data : 전송 데이터 (서버에 전달되는 값은 cartPk, newCount )

sussess : 서버 응답 처리

성공했을 때만 UI 변경

서버 성공 ➡ 화면 변경

서버 실패 ➡ 화면 유지

 

input.val(count); : 수량 input값 동기화

다시 세팅하는 이유는 직접 입력했을 수도 있고, 버튼으로 변경했을 수도 있음

 

price : 상품별 합계 계산, 화면에 표시된 가격에서 ,과 원 제거 후 숫자로 변환

total : 현재 상품의 합계 계산

 

updateTotalPrice : 전체 총 금액 재계산

 

 

"total은 해당 상품 한줄의 합계" ➡ 부분 업데이트

"updateTotalPrice"는 장바구니 전체 상태를 다시 계산 ➡ 전체 상태 재계산

 

 


 

 

[ 총 금액 계산 ]

// 총 금액 계산
function updateTotalPrice(){
    let total = 0;
    $("#cart-tbody tr").each(function(){
        // 가격이 없는 행(빈 장바구니 메시지 등)은 스킵
        if($(this).find(".item-price").length === 0) return;

        const priceText = $(this).find(".item-price").text().replace("원","").replace(/,/g,"");
        const count = parseInt($(this).find("input").val()) || 1;
        total += parseInt(priceText) * count;
    });

    // 총 금액 계산 후
    if (isNaN(total) || total <= 0) {
        total = 0;
        buyBtn.prop("disabled", true).addClass("disabled");
    } else {
        buyBtn.prop("disabled", false).removeClass("disabled");
    }
    totalPriceElem.text(total.toLocaleString("ko-KR") + "원");
}

장바구니 모든 상품 상태를 다시 읽어서

총 금액 + 구매 가능 여부를 한 번에 정리

 

total : 장바구니 전체 금액 누적 변수

 

 

[ 장바구니 모든 행 순회 ]

$("#cart-tbody tr").each(function(){

의미 : 현재 화면에 존재하는 모든 상품 행(tr)  검사

장바구니가 동적으로 변해도 ➡ 현재 상태 기준으로 계산

 

 

[ 빈 바구니 행 제외 ]

if($(this).find(".item-price").length === 0) return;

"빈 장바구니" 행 제외

빈 장바구니일때 출력하는 멘트가 있기 때문에

일단 그리고 가격이나 input이 없어서 NaN 이 발생할 수 있음

 

 

priceText : 상품 가격 추출을 하는데 화면에 표시된 가격에서 콤마와 원 제거 ➡ 계산은 숫자 타입

count : 상품 수량 추출, 숫자 아닐 경우 기본값 1

total : 전체 금액 누적

 

 

[ 총금액 계산 후 버튼 비활성화/활성화 ]

만약 isNaN 이거나 total이 0보다 작거나 같으면 구매 버튼 비활성화

정상 상태일 경우에는 버튼 활성화 (결제 가능 상태)

 

 

[ 총 금액 화면 출력 ]

totalPriceElem.text(total.toLocaleString("ko-KR") + "원");

계산된 총 금액을 사용자에게 시작적으로 표시

 

 


 

 

 

cart.jsp 전체코드

<%@ page language="java" contentType="text/html; charset=UTF-8"
	pageEncoding="UTF-8"%>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c"%>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt"%>
<%@ taglib tagdir="/WEB-INF/tags" prefix="ornably"%>
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="utf-8">
<title>오너블리 - 함꼐 크리스마스를 즐겨요</title>
<link rel="icon" href="images/ORNABLY.jpg">
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<meta content="" name="keywords">
<meta content="" name="description">
<!-- Google Web Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
	href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
	rel="stylesheet">
<!-- Icon Font Stylesheet -->
<link rel="stylesheet"
	href="https://use.fontawesome.com/releases/v5.15.4/css/all.css" />
<link
	href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.4.1/font/bootstrap-icons.css"
	rel="stylesheet">
<!-- Libraries Stylesheet -->
<link href="lib/animate/animate.min.css" rel="stylesheet">
<link href="lib/owlcarousel/assets/owl.carousel.min.css"
	rel="stylesheet">
<!-- Customized Bootstrap Stylesheet -->
<link href="css/bootstrap.min.css" rel="stylesheet">

<!-- Template Stylesheet -->
<link href="css/style.css" rel="stylesheet">
<!-- Template Stylesheet -->


<link rel="stylesheet"
	href="${pageContext.request.contextPath}/customResource/style.css">
	
	<style>
	/* 기본 버튼 색상 (비활성화 및 활성화 상태 공통) */
	.cart-buy-btn {
	    background-color: #6F4F3A;
	    border-color: #6F4F3A;
	    opacity: 1; /* 기본적으로 불투명도 1로 설정 */
	    cursor: pointer;
	    color: white;
	}
	
	/* 비활성화 상태 */
	.cart-buy-btn.disabled {
	    opacity: 0.5; /* 비활성화 시 투명도 설정 */
	    cursor: not-allowed; /* 마우스 커서 변경 */
	}
	
	/* 활성화 상태 */
	.cart-buy-btn:not(.disabled) {
	    opacity: 1; /* 활성화 시 opacity 1로 설정 */
	}
	</style>
</head>
<body>
	<ornably:header />

	<!-- Single Page Header start -->
	<div class="container-fluid page-header py-5">
		<h1 class="text-center text-white display-6 wow fadeInUp"
			data-wow-delay="0.1s">장바구니</h1>
	</div>
	<!-- Single Page Header End -->


	<!-- 장바구니 품목 Start -->
	<div class="container-fluid py-5 cart-section">
		<div class="container py-5 content item-card rounded">
			<div class="row">
				<!-- 왼쪽 장바구니 테이블 -->
				<div class="col-lg-8 col-md-7 col-sm-12 mb-5">
					<div class="table-responsive card cart-table">
						<table class="table">
							<thead>
								<tr>
									<th scope="col"><span style="color:black">상품명</span></th>
									<th scope="col"><span style="color:black">가격</span></th>
									<th scope="col"><span style="color:black">수량</span></th>
									<th scope="col"><span style="color:black">합계</span></th>
									<th scope="col"><span style="color:black">삭제</span></th>
								</tr>
							</thead>
							<tbody id="cart-tbody">
								<!-- 상품 출력 -->
								<c:if test="${empty cartDatas }">
									<tr>
										<td colspan="5" class="text-center py-5 text-muted"><i
											class="bi bi-cart-fill" style="font-size: 40px;"></i>
											<p class="mt-3 mb-0">장바구니가 비어있습니다..</p></td>
									</tr>
								</c:if>
								<c:forEach var="cart" items="${cartDatas}">

									<tr data-cartpk="${cart.cartPk}">
										<th scope="row">
											<p class="mb-0 py-4">${cart.itemName}</p>
										</th>

										<!-- 가격 -->
										<td class="item-price">
											<p class="mb-0 py-4">
												<fmt:formatNumber value="${cart.itemPrice}" type="number" />
												원
											</p>
										</td>

										<!-- 수량 -->
										<td>
											<div class="input-group py-4 cart-quantity"
												style="width: 100px;">
												<button type="button"
													class="btn btn-sm btn-minus rounded-circle bg-light border">-</button>
												<input type="text"
													class="form-control form-control-sm text-center border-0 item-count"
													value="${cart.count != 0 ? cart.count : 1}"
													data-cartpk="${cart.cartPk}">
												<button type="button"
													class="btn btn-sm btn-plus rounded-circle bg-light border">+</button>
											</div>
										</td>

										<!-- 합계 -->
										<td class="item-total">
											<p class="mb-0 py-4">
												<fmt:formatNumber
													value="${cart.itemPrice * (cart.count != 0 ? cart.count : 1)}"
													type="number" />
												원
											</p>
										</td>

										<!-- 삭제 -->
										<td class="py-4">
											<button
												class="btn btn-md rounded-circle bg-light border cart-remove-btn">
												<i class="fa fa-times text-danger"></i>
											</button>
										</td>
									</tr>
								</c:forEach>
							</tbody>
						</table>
					</div>
				</div>

				<!-- 오른쪽 결제요약 박스 -->
				<div class="col-lg-4 col-md-5 col-sm-12">
					<div class="rounded cart-summary content item-card">
						<div class="p-4">
							<h2 class="display-6 mb-4">
								장바구니 <span class="fw-normal">총액</span>
							</h2>
						</div>

						<div
							class="py-4 mb-4 border-top border-bottom d-flex justify-content-between">
							<h5 class="mb-0 ps-4 me-4">총 합계</h5>
							<p class="mb-0 pe-4 total-price">${totalPrice}원</p>
						</div>

						<form action="${pageContext.request.contextPath}/paymentPage.do"
							method="get">
							<button
								class="btn rounded-pill px-4 py-3 text-uppercase mb-4 ms-4 cart-buy-btn"
								style="background-color: #6F4F3A; border-color: #6F4F3A;"
								type="submit">구매</button>
						</form>
					</div>
				</div>
			</div>
		</div>
	</div>
	<!-- 장바구니 품목 End -->

	<!-- footer 태그 호출 -->
	<ornably:footer />

	<!-- jQuery -->
	<script>
	console.log('${cartDatas}');

	const buyBtn = $(".cart-buy-btn");
	const cartbody = $("#cart-tbody");
	const totalPriceElem = $(".cart-summary .total-price"); // 총액 변수

	// 버튼으로 개수 변경
	$(document).on("click", ".btn-minus, .btn-plus", function(){
	    const tr = $(this).closest("tr");
	    const cartPk = tr.data("cartpk");
	    const input = tr.find("input");
	    let count = parseInt(input.val()) || 1;

	    if($(this).hasClass("btn-minus")) count -= 1;
	    else count += 1;

	 	// 개수 변경 함수 호출
	    updateCartCount(cartPk, count, tr, input);
	});
	
	
	// 직접 입력으로 개수 변경
    $(document).on("blur", ".item-count", function(){
        const input = $(this);
        const tr = input.closest("tr");
        const cartPk = input.data("cartpk");
        let count = parseInt(input.val()) || 1;
        if(count < 1) count = 1; // 최소 1
        
        // 개수 변경 함수 호출
        updateCartCount(cartPk, count, tr, input);
    });
	
 	// AJAX : 개수변경, 총금액
    function updateCartCount(cartPk, count, tr, input){
    	// 최소/최대 범위 제한
        if(count < 1) count = 1;
        if(count > 99) count = 99;
        
        $.ajax({
            url: "${pageContext.request.contextPath}/UpdateCartItemCountServlet",
            type: "POST",
            data: { cartPk: cartPk, newCount: count },
            success: function(res){
                if(res === "true"){
                    input.val(count);

                    // 항목 합계 업데이트
                    const price = parseInt(tr.find(".item-price").text().replace(/,/g,"").replace("원",""));
			        const total = price * count;
			        tr.find(".item-total p").text(total.toLocaleString("ko-KR") + "원");


                    // 촘 금액 함수 호출
                    updateTotalPrice();
                } else {
                    alert("수량 변경 실패");
                }
            }
        });
    }

	// 삭제 버튼
	$(document).on("click", ".cart-remove-btn", function(){
	    const tr = $(this).closest("tr");
	    const cartPk = tr.data("cartpk");

	    $.ajax({
	        url: "${pageContext.request.contextPath}/DeleteCartServlet",
	        type: "POST",
	        data: { cartPk: cartPk },
	        success: function(res){
	            if(res === "true"){
	                tr.remove();		// 선택한 상품 삭제
	                
	                if ($("#cart-tbody tr").length === 0) {
	                	  $("#cart-tbody").append(`
	                	    <tr>
	                	      <td colspan="5" class="text-center py-5 text-muted">
	                	        <i class="bi bi-cart-fill" style="font-size: 40px;"></i>
	                	        <p class="mt-3 mb-0">장바구니가 비어있습니다..</p>
	                	      </td>
	                	    </tr>
	                	  `);
	                	}
	                
	                updateTotalPrice();	// 총 금액 업데이트
	            } else {
	                alert("삭제 실패");
	            }
	        }
	    });
	});

	// 총 금액 계산
	function updateTotalPrice(){
	    let total = 0;
	    $("#cart-tbody tr").each(function(){
	        // 가격이 없는 행(빈 장바구니 메시지 등)은 스킵
	        if($(this).find(".item-price").length === 0) return;
	        
	        const priceText = $(this).find(".item-price").text().replace("원","").replace(/,/g,"");
	        const count = parseInt($(this).find("input").val()) || 1;
	        total += parseInt(priceText) * count;
	    });
	
	    // 총 금액 계산 후
	    if (isNaN(total) || total <= 0) {
	        total = 0;
	        buyBtn.prop("disabled", true).addClass("disabled");
	    } else {
	        buyBtn.prop("disabled", false).removeClass("disabled");
	    }
	    totalPriceElem.text(total.toLocaleString("ko-KR") + "원");
	}
		
	$(document).ready(function(){
		// 페이지 로딩 시 총 금액 계산
	    updateTotalPrice();
	});
	</script>
</body>
</html>

 

 

 

UpdateCartItemCountServlet.java

package controller.servlet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import model.dao.CartDAO;
import model.dto.CartDTO;

@WebServlet("/UpdateCartItemCountServlet")
public class UpdateCartItemCountServlet extends HttpServlet {
	private static final long serialVersionUID = 1L;
       
    public UpdateCartItemCountServlet() {
        super();
    }

	protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [doGet] - 잘못된 요청 방식 **POST방식 요청**");
		doPost(request, response);
	}

	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
		System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [doPost] - 시작");
		
		String stringCartPk = request.getParameter("cartPk");
		String stringNewCount = request.getParameter("newCount");
		
		System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [데이터 받기] - cartPk:["+stringCartPk+"] newCount:["+stringNewCount+"]");
		
		int cartPk = Integer.parseInt(stringCartPk);
		int newCount = Integer.parseInt(stringNewCount);
		
		CartDAO cartDAO = new CartDAO();
		CartDTO cartDTO = new CartDTO();
		cartDTO.setCartPk(cartPk);
		cartDTO.setNewCount(newCount);
		cartDTO.setCondition("UPDATE_CART_ITEM_COUNT");
		
		System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [cartDAO.update(cartDTO)] - cartDTO:["+cartDTO+"]");
		if(cartDAO.update(cartDTO)) {
			System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [장바구니 아이템 개수 변경] - 성공");
			response.getWriter().print(true);
		}
		else {
			response.getWriter().print(false);
			System.out.println("[로그] controller.servlet.UpdateCartItemCountServlet | [장바구니 아이템 개수 변경] - 실패");
		}
	}
}