🎅 오너먼트 프로젝트

카카오 결제 : 신 API 이용한 결제 코드 분석해보기 - 1

보배 진 2026. 1. 7. 17:04

 

 

 

 

 

 

🌻 각 파일의 역할 정리 🌻

KakaoPayConfig파일         : Admin Key, CID 등 설정 관리 
KakaoPayReadyAction     : 구매 요청을 생성하고 카카오페이 구매 페이지로 이동
KakaoPaySuccessAction : 구매 완료 후 승인 처리를 시작하는 역할
KakaoPayApproveUtil       : 카카오페이 구매 승인 API를 실제로 호출
KakaoPayFailAction           : 구매 실패 시 페이지 이동

 

 


 

 

 

🌻 KakaoPayConfig.java  🌻

package controller.kakaopay;

// 카카오페이 연동에 필요한 설정 정보 관리 : Admin Key, CID 등 
public class KakaoPayConfig {
	// 우리 서버의 기본 주소
	public static final String BASE_URL = "http://localhost:8088";
	
	// 테스트용 상점 코드 : 테스트 CID (1회성 결제)
    public static final String CID = [ 상점 코드 ];
	
    // Secret Key (개발용)
	public static final String SECRET_KEY_DEV = [ Secret Key ];
	
	// 결제 준비(Ready) API 주소
    // 결제 요청 정보를 카카오페이에 전달하여
    // TID와 결제 페이지 URL을 발급받기 위한 API
    public static final String READY_URL = "https://kapi.kakaopay.com/v1/payment/ready";
    
    // 결제 성공 시 카카오페이가 리다이렉트하는 우리 서버의 URL
    // 사용자가 결제 완료/실패/취소 시 이동하는 주소
    public static final String SUCCESS_URL = BASE_URL + "/BugSandwichOrnamentMall/kakaoPaySuccess.do";
    public static final String FAIL_URL    = BASE_URL + "/BugSandwichOrnamentMall/kakaoPayFail.do";
    public static final String CANCEL_URL  = BASE_URL + "/BugSandwichOrnamentMall/kakaoPayCancel.do";
}

 

Admin Key, CID 등 설정 관리를 하는 파일

 

 


 

🌻 KakaoPayReadyAction.java 🌻

package controller.kakaopay;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.util.ArrayList;

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 javax.servlet.http.HttpSession;
import javax.websocket.Session;

import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

import controller.common.Action;
import controller.common.ActionForward;
import model.dao.CartDAO;
import model.dao.ItemDAO;
import model.dao.OrderDAO;
import model.dto.CartDTO;
import model.dto.ItemDTO;
import model.dto.OrderDTO;

// 결제 준비 (ready API 호출)
// 카카오페이 API 호출 (결제 준비)
// 성공적인 응답을 받으면 TID와 함께 리다이렉트 URL 반환
// 클라이언트에게 결제 페이지로 리다이렉션
//// 필요한 정보: Admin Key, CID, 결제 금액, 주문번호, 상품명 등


public class KakaoPayReadyAction implements Action {
	@Override
	public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
    	// 카카오페이 API 호출 코드
        // 결제 준비 요청
        // 응답에서 TID 받아서 리다이렉트 URL 생성
    	System.out.println("[로그] KakaoPayReadyAction 시작");
    	HttpSession session = request.getSession();
    	ActionForward forward = new ActionForward();
    	
    	try {
    		// 세션과 요청에서 정보 가져오기
    		int accountPk = (Integer) session.getAttribute("accountPk"); 
			int addressPk = Integer.parseInt(request.getParameter("addressPk"));
			int totalAmount = Integer.parseInt(request.getParameter("totalAmount"));
            System.out.println("accountPk" + accountPk);
            System.out.println("addressPk" + addressPk);
            System.out.println("totalAmount" + totalAmount);
            
            
            if (totalAmount <= 0) {
                throw new RuntimeException("결제 금액 오류");
            }
            ItemDAO itemDAO = new ItemDAO();
            CartDAO cartDAO = new CartDAO();
            
            // 즉시 구매 여부
            CartDTO tempCartDTO = (CartDTO) session.getAttribute("tempCartDTO");
            
            // 상품명 설정
            String itemName = "";
            
            // =========================
            // 즉시 구매
            // =========================
            if (tempCartDTO != null) {
	            int itemPk = tempCartDTO.getItemPk();
	            int count  = tempCartDTO.getCount();

	            // 1. 재고 체크
	            ItemDTO itemDTO = new ItemDTO();
	            itemDTO.setItemPk(itemPk);
	            itemDTO.setItemCount(count);
	            itemDTO.setCondition("ITEM_STOCK_ENOUGH");
	
	            ItemDTO itemData = itemDAO.selectOne(itemDTO);
	            if (itemDAO.selectOne(itemDTO) == null) {
	            	System.out.println("[로그] 즉시 구매 재고 부족");
	            	
	            	request.setAttribute("message", "해당 상품의 재고가 부족합니다.");
					request.setAttribute("location", "ornamentDetailPage.do?itemPk=" + itemDTO.getItemPk());
					forward.setPath("message.jsp");
					forward.setRedirect(false);
					return forward;
	            }
	            System.out.println("[로그] 즉시 구매 재고 감소 성공");
	            itemName = itemData.getItemName(); // itemName

	            // 2. 재고 임시 감소
	            itemDTO.setCondition("BUY_ITEM");
	            if (!itemDAO.update(itemDTO)) {
	                throw new RuntimeException("재고 선점 실패");
	            }

	            // 3. 선점 정보 세션 저장 (복구용)
	            session.setAttribute("reservedItemPk", itemPk);
	            session.setAttribute("reservedCount", count);
	        }
            // =========================
            // 장바구니 구매
            // =========================
            else {            	
	            CartDTO cartDTO = new CartDTO();
	            cartDTO.setAccountPk(accountPk);
	            cartDTO.setCondition("SELECT_ALL_ACCOUNT_CART");
	
	            // 장바구니 상품
	            ArrayList<CartDTO> cartDatas = cartDAO.selectAll(cartDTO);
	            
	            // 장바구니에 재고가 부족한 상품
	            ArrayList<String> outOfStockItems = new ArrayList<>();
	            
	            // 1. 재고 체크
	            for (CartDTO cart : cartDatas) {
	            	ItemDTO itemDTO = new ItemDTO();
	                itemDTO.setItemPk(cart.getItemPk());
	                itemDTO.setItemCount(cart.getCount());
	                itemDTO.setCondition("ITEM_STOCK_ENOUGH");
	
	                if (itemDAO.selectOne(itemDTO) == null) {
	                    // 재고 부족이면 리스트에 상품명 추가
	                    outOfStockItems.add(cart.getItemName()); // CartDTO에 itemName이 있어야 함
	                    continue; // 재고 부족 상품은 스킵
	                }
	            }
	            
	            // 2. 재고 부족 체크
	            if (!outOfStockItems.isEmpty()) {
	                itemName = outOfStockItems.get(0);
	                if (outOfStockItems.size() > 1) {
	                    itemName += " 외 " + (outOfStockItems.size() - 1) + "건";
	                }
	                request.setAttribute("outOfStockItems", outOfStockItems);
	                forward.setPath("paymentStockFail.jsp"); // 재고 부족 모달 표시용 JSP
	                forward.setRedirect(false);
	                return forward;
	            } 	            
	            
	           // itemName 설정
	           if (!cartDatas.isEmpty()) {
	            	// 장바구니 상품이 1개면 그냥 상품명
	                itemName = cartDatas.get(0).getItemName();

	                // 장바구니 상품이 2개 이상이면 "첫번째 상품명 외 N건"
	                if (cartDatas.size() > 1) {
                        itemName += " 외 " + (cartDatas.size() - 1) + "건";
                    }
	            }
	            System.out.println("[로그] 출력 상품명 확인 : " + itemName);
	            
	            // 3. 재고 충분하면 감소
	            for (CartDTO cart : cartDatas) {
	                ItemDTO itemDTO = new ItemDTO();
	                itemDTO.setItemPk(cart.getItemPk());
	                itemDTO.setItemCount(cart.getCount());
	                itemDTO.setCondition("BUY_ITEM");
	                itemDAO.update(itemDTO);
	                
	                // 재고 감소 실패
	                if (!itemDAO.update(itemDTO)) {
	                	throw new RuntimeException("재고 감소 실패");
	                }
	            }
	            // 복구용 저장
	            session.setAttribute("reservedCartDatas", cartDatas);	           
            }
            
            // =========================
            // 주문 생성
            // =========================
    		OrderDTO orderDTO = new OrderDTO();
    		orderDTO.setAccountPk(accountPk);
    		orderDTO.setAddressPk(addressPk);
    		orderDTO.setCondition("PREPARING");

    		OrderDAO orderDAO = new OrderDAO();
    		orderDAO.insert(orderDTO);

    		// 방금 생성된 orderPk 가져오기
    		orderDTO.setCondition("SELECT_ONE_ORDER_PK");
    		int orderPk = orderDAO.selectOne(orderDTO).getOrderPk();
    		session.setAttribute("orderPk", orderPk); // 세션 저장

    		System.out.println("[로그] orderPk 값 확인 : " + orderPk );
    		System.out.println("[로그] CID 값 확인 : " + KakaoPayConfig.CID );
    		System.out.println("[로그] SECRET_KEY_DEV 값 확인 : " + KakaoPayConfig.SECRET_KEY_DEV);
    		
    		
    		
    		
    		// 카카오페이 API 준비
            JsonObject body = new JsonObject();
            body.addProperty("cid", KakaoPayConfig.CID);
            body.addProperty("partner_order_id", orderPk);
            body.addProperty("partner_user_id", accountPk);
            body.addProperty("item_name", itemName);
            body.addProperty("quantity", 1);
            body.addProperty("total_amount", totalAmount);
            body.addProperty("tax_free_amount", 0);
            body.addProperty("approval_url", KakaoPayConfig.SUCCESS_URL);
            body.addProperty("cancel_url", KakaoPayConfig.CANCEL_URL);
            body.addProperty("fail_url", KakaoPayConfig.FAIL_URL);


            // Open API 호출
            URL url = new URL("https://open-api.kakaopay.com/online/v1/payment/ready");
            HttpURLConnection conn = (HttpURLConnection) url.openConnection();
            conn.setRequestMethod("POST");
            conn.setRequestProperty("Authorization", "SECRET_KEY " + KakaoPayConfig.SECRET_KEY_DEV);
            conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
            conn.setDoOutput(true);
            
            OutputStream os = conn.getOutputStream();
            os.write(body.toString().getBytes("UTF-8"));
            os.flush();
            os.close();
            
            // 응답 처리
            BufferedReader br;
            if (conn.getResponseCode() == 200) {
                br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"));
            } else {
                br = new BufferedReader(new InputStreamReader(conn.getErrorStream(), "UTF-8"));
            }

            StringBuilder sb = new StringBuilder();
            String line;
            while((line = br.readLine()) != null) {
                sb.append(line);  // 줄바꿈 없이 연결
            }
            br.close();
            System.out.println("응답 JSON: " + sb.toString());

            JsonObject json = JsonParser.parseString(sb.toString()).getAsJsonObject();

            if (json.has("tid") && json.has("next_redirect_pc_url")) {
                String tid = json.get("tid").getAsString();	// tid 발급
                String redirectUrl = json.get("next_redirect_pc_url").getAsString();
                session.setAttribute("tid", tid);			// 세션에 저장

                forward.setPath(redirectUrl);
                forward.setRedirect(true);
                
                System.out.println("<<로그>> tid : " + tid);
                System.out.println("<<로그>> redirectUrl : " + redirectUrl);
            } else {
                System.out.println("<<로그>> Ready API 호출 실패: " + json.toString());
                forward.setPath(KakaoPayConfig.BASE_URL + "/kakaoPayFail.do");
                forward.setRedirect(true);
            }
       } catch (Exception e) {
            e.printStackTrace();
            forward.setPath(KakaoPayConfig.FAIL_URL);
            forward.setRedirect(true);
        }
        return forward;
    }
}

 

KakaoPayReadyAction 클래스는 카카오페이 결제 준비(Ready API) 를 담당하는 컨트롤러

사용자가 결제 버튼을 눌렀을 때 실행되어 재고 선점 ➡ 주문 생성 ➡ 카카오페이 결제 페이지로 리다이렉트까지 하는 역할

 

이 액션이 시작되면 가장 먼저 세션과 요청 파라미터에서 결제에 필요한 핵심 정보를 가져온다

accountPk : 로그인한 사용자 식별자

addressPk : 배송지

totalAmount : 최종 결제 금액

결제 금액이 0 이하인 경우는 정상적인 결제가 아니기 때문에 바로 에외를 발생시켜 흐름을 중단한다

이후 상품 및 장바구니 처리를 위해 ItemDAO, CartDAO를 중비하고

즉시 구매인지/장바구니 구매인지를 구분하기 위해 세션에 저장된 tampCart를 확인한다

이 분기점이 이 코드의 핵심이다

 

 

즉시 구매인 경우 tempCartDAO != null

단일 상품에 대해서만 처리한다

상품 PK의 수량을 가져온 뒤,

ITEM_STOCK_ENOUGH 조건으로 재고가 충분한지 먼저 조회한다

여기서 재고가 부족하면 메시지 JSP로 포워딩하면서 결제를 중단한다

 

재고가 충분하다면 상품명을 itemName으로 설정하고,

BUY_ITEM 조건으로 재고를 임시 감소 시킨다

이 단계는 "결제 중 재고 선점" 역할을 하며,

결제 도중 사용자가 이탕하거나 실패했을 때를 대비해

reservedItemPk,  reservedCount를 세션에 저장해 복구 가능하도록 설계되어 있다

이 부분은 실무 기준으로도 좋은 흐름이다

 

 

 

즉시 구매가 아니라면 장바구니 구매 로직으로 들어간다

현재 로그인한 계정의 장바구니 전체를 조회한 뒤

각 상품마다 ITEM_STOCK_ENOUGH 조건으로 재고를 하나씩 확인한다

 

이때 재고가 부족한 상품이 하나라도 있으면

그 상품명들을 outOfStockItems 리스트에 담고,

결제를 진행하지 않고 paymentStockFail.jsp로 포워딩한다

여기서 첫 번째 상품명 + "외 N건" 형태로 표시한다

 

모든 상품의 재고가 충분한 경우에만

카카오페이에 전달할 itemName을

1개이면 : 상품명 그대로

2개 이상이면 : "첫 상품명 외 N건"

으로 표시한다

 

그 다음 장바구니에 들어 있는 모든 상품에 대해 BUY_ITEM 조건으로 재고를 감소시키고,

감소한 장바구니 목록 전체를 세션에 reservedCartDatas로 저장한다

이 역시 결제 실패 시 재고 롤백을 염두에 둔 구조다

 

 


 

재고 선점이 끝나면 이제 주문 생성 단계로 넘어간다.
OrderDTO에 계정, 주소를 세팅하고 상태를 PREPARING으로 둔 채 주문을 insert 한다.
이후 SELECT_ONE_ORDER_PK 조건으로 방금 생성된 주문의 PK를 다시 조회해
세션에 orderPk로 저장한다.

이 orderPk는 이후 카카오페이 승인 단계에서
partner_order_id로 재사용되기 때문에 매우 중요한 값이다.

 

 

 

 

이제 실제 카카오페이 Ready API 호출을 준비한다.
JSON Body에는 다음 정보들이 들어간다.

  • CID: 가맹점 식별자
  • partner_order_id: 주문 PK
  • partner_user_id: 사용자 PK
  • item_name: 앞에서 구성한 상품명
  • quantity: 1 (실제 수량이 아닌 결제 단위 개념)
  • total_amount: 총 결제 금액
  • tax_free_amount: 0
  • success / cancel / fail URL

이후 HttpURLConnection을 사용해
https://open-api.kakaopay.com/online/v1/payment/ready로 POST 요청을 보내며,
Authorization 헤더에 SECRET_KEY를 포함시킨다.

응답은 HTTP 상태 코드에 따라 InputStream 또는 ErrorStream에서 읽고,
JSON으로 파싱한다.

 

응답에 tid와 next_redirect_pc_url이 모두 존재하면

  • tid를 세션에 저장하고
  • 사용자를 카카오페이 결제 페이지로 redirect 시킨다.

이 단계까지 오면 결제 준비는 성공이다.

반대로 응답에 필요한 값이 없거나 예외가 발생하면
실패 URL로 리다이렉트되며,
catch 블록에서도 동일하게 실패 URL로 보내도록 되어 있다.