개주 훈련일지/🏋️ 전집중 호흡 훈련

카카오페이 API 결제 준비(READY) & 결제 승인(APPROVE) 구현 정리

lshfood2 2025. 12. 22. 23:47

 



 

카카오페이 API 단건 결제 방법 정리

카카오페이 결제 API는 카카오페이 개발자센터에서 제공된다.해당 링크를 통해 카카오페이 개발자센터로 이동할 수 있다.> https://developers.kakaopay.com/ ※ 주의사항카카오 디벨로퍼 (https://developers.k

lshfood2.tistory.com

지난 포스팅에서 업데이트된

카카오페이 API 단건 결제 방법을 정리했었다.

 

이번엔 위 방법을 토대로 실제 프로젝트에서

사용할 결제 API 코드를 구현해 보았다.


[ JSP/JAVA/Servlet 활용 ]

카카오페이 단건결제는 크게 두 단계로 나뉜다.


1) 결제 준비(READY) 단계에서
“결제 건 생성 + 결제 페이지 URL 받기”를 수행하고,

 

2) 결제 승인(APPROVE) 단계에서
“사용자 결제 완료 후 실제 승인 + 우리 DB 반영”을 수행한다.

 

즉, READY는 “결제 시작”, APPROVE는 “결제 확정” 단계이다.

 

전체 흐름 한 장 요약

  1. 사용자(브라우저) → 우리 서버
    : 충전 금액 선택 후 결제 요청
  2. 우리 서버 → 카카오 서버
    : READY API 호출
  3. 카카오 서버 → 우리 서버
    : tid + next_redirect_pc_url 응답
  4. 우리 서버 → 사용자(브라우저)
    : next_redirect_pc_url로 리다이렉트
  5. 사용자(브라우저) → 카카오 결제 페이지
    : 결제 진행
  6. 카카오 → 사용자(브라우저)
    : 결제 완료 후 approval_url?pg_token=...로 리다이렉트
  7. 사용자(브라우저) → 우리 서버
    : APPROVE Action 진입 (pg_token 수신)
  8. 우리 서버 → 카카오 서버
    : APPROVE API 호출 (tid + pg_token)
  9. 우리 서버
    : 승인 성공 시 DB 캐시 충전 반영 + 성공 페이지 출력

READY에서 핵심으로 알아야 할 값: tid

tid는 카카오페이가 발급하는

거래 ID(Transaction ID) 이다.


READY 단계에서 카카오 서버가

결제건을 만들면서 tid를 내려준다.


이 tid는 APPROVE 단계에서

pg_token과 함께 반드시 필요하다.

  • READY 응답
    : tid, next_redirect_pc_url
  • APPROVE 요청
    : cid, tid, partner_order_id, partner_user_id, pg_token

따라서 READY에서 받은 tid는 세션에 저장해두고,

APPROVE에서 꺼내 사용해야 한다.


[ 결제 준비(READY) Action 구현 ]

READY Action은 다음 흐름으로 구현한다.

 * [KakaoPayReadyAction 흐름]
 * 1) 로그인 사용자 확인(세션 memberId)
 * 2) 충전 금액 수신 + 숫자 변환 + 허용 금액 검증
 * 3) 가맹점 주문번호(partner_order_id) 생성 (UUID 기반)
 * 4) 콜백 URL 3종 생성 (승인/취소/실패)
 * 5) ready API 요청 JSON 바디 구성
 * 6) 카카오 ready API 호출 (서버 → 카카오 서버)
 * 7) 카카오 응답(JSON) 수신 후 tid, next_redirect_pc_url 추출
 * 8) tid 등 결제정보 세션 저장 (Approve 단계에서 사용)
 * 9) 사용자 브라우저를 카카오 결제 페이지(next_redirect_pc_url)로 redirect

 

(1) 충전 금액 검증이 필요한 이유

결제 금액은 신뢰하면 안 된다.
사용자가 프론트에서 1,000원을 눌렀더라도,
개발자도구/조작으로 다른 금액을 보낼 수 있다.

 

따라서 서버에서 허용 금액만 통과시키는 방식으로 막아야 한다.

예시) 1000/5000/10000/50000만 허용한다.

 

(2) 콜백 URL이 왜 필요한가

카카오페이 결제는 “카카오 결제창”에서 진행된다.
결제가 끝난 뒤 다시 우리 서비스로 돌아와야 하므로

READY 요청 바디에 다음 URL을 넣는다.

String path = request.getContextPath();
String approvalUrl = BASE_URL + path + "/KakaoPayApprove.do";
String cancelUrl = BASE_URL + path + "/KakaoPayCancel.do";
String failUrl = BASE_URL + path + "/KakaoPayFail.do";
  • approval_url : 결제 성공 후 돌아올 URL (APPROVE Action)
  • cancel_url : 사용자가 취소했을 때 돌아올 URL
  • fail_url : 결제 실패 시 돌아올 URL

여기에서 BASE_URL + request.getContextPath()로

URL을 만드는 이유는, 로컬/운영 환경에서 도메인이

바뀌더라도 경로가 깨지지 않도록 하기 위함이다.

 

(3) READY 요청 바디(JSON) 구성 핵심

금액은 숫자 필드를 숫자로 보내는 편이 안전하다.

JsonObject body = new JsonObject();
body.addProperty("cid", CID);
body.addProperty("partner_order_id", partnerOrderId);
body.addProperty("partner_user_id", String.valueOf(memberId));
body.addProperty("item_name", "캐시 충전 " + cashCharge + "원");
body.addProperty("quantity", 1);
body.addProperty("total_amount", cashCharge);
body.addProperty("vat_amount", calcVat(cashCharge));
body.addProperty("tax_free_amount", 0);
body.addProperty("approval_url", approvalUrl);
body.addProperty("cancel_url", cancelUrl);
body.addProperty("fail_url", failUrl);

 

(4) READY 호출 + tid 저장 + redirect

APPROVE에서 반드시 필요하거나 검증에

필요한 값들을 세션에 저장한다.

// 카카오 READY API 호출 (서버 → 카카오 서버)
// - 지금까지 만든 body(JSON)를 카카오 READY 엔드포인트로 전송한다.
// - 여기서 실제로 "결제건(거래)"이 카카오 서버에 생성되며,
// - 성공하면 카카오가 tid(거래ID) + 리다이렉트 URL을 응답으로 내려준다.
String responseJson = postJson(READY_URL, body.toString());

// 카카오 응답(JSON) 파싱
// - responseJson은 문자열(JSON) 형태이므로, JsonParser로 파싱해서 객체로 변환한다.
// - READY 성공 응답에는 tid와 next_redirect_pc_url이 포함된다.
JsonObject obj = JsonParser.parseString(responseJson).getAsJsonObject();

// tid 추출
// - tid(Transaction ID)는 카카오가 발급한 "거래 식별자"이다.
// - APPROVE 단계에서 pg_token과 함께 반드시 필요하므로 절대 잃어버리면 안 된다.
String tid = obj.get("tid").getAsString();

// 결제창 이동 URL 추출
// - next_redirect_pc_url은 "사용자를 카카오 결제 페이지로 보내기 위한 주소"이다.
// - 즉, READY 단계의 목적은 (1) tid 확보 + (2) 결제창 URL 확보라고 보면 된다.
String nextRedirectPcUrl = obj.get("next_redirect_pc_url").getAsString();

// APPROVE에서 필요하므로 세션 저장
// - 결제 완료 후 카카오가 approval_url로 돌아올 때는 pg_token만 넘어오는 경우가 일반적이다.
// - 그래서 READY에서 받은 tid/주문번호/사용자/금액을 세션에 저장해두고,
//   APPROVE Action에서 다시 꺼내서 "같은 결제건"임을 확인하며 승인 요청을 수행한다.
session.setAttribute("kakaopay_tid", tid); // 승인 요청에 필수 (tid + pg_token)
session.setAttribute("kakaopay_partner_order_id", partnerOrderId); // 결제건 구분용(중복 승인 방지에도 사용)
session.setAttribute("kakaopay_partner_user_id", String.valueOf(memberId)); // 카카오 요청 스펙의 user_id (로그인 사용자 기반)
session.setAttribute("kakaopay_total_amount", cashCharge); // 승인금액(amount.total) 검증 기준값(위변조/오류 방지)

// 카카오 결제 페이지로 이동 (브라우저 Redirect)
// - 결제는 우리 서버에서 끝나는 것이 아니라, 사용자가 카카오 결제창에서 실제 결제를 진행해야 한다.
// - 따라서 서버는 사용자 브라우저에게 "카카오 결제창으로 가라"라고 302 Redirect를 내려준다.
// - forward.setRedirect(true)로 설정하면 response.sendRedirect(...) 방식으로 동작한다.
forward.setRedirect(true);
forward.setPath(nextRedirectPcUrl);
return forward;

세션에 SET이 필요한 항목

  • kakaopay_tid (필수)
  • kakaopay_partner_order_id (선택)
  • kakaopay_partner_user_id (선택)
  • kakaopay_total_amount (필수)

[ 결제 승인(APPROVE) Action 구현 ]

APPROVE Action은 다음 흐름으로 구현한다.

 * [KakaoPayApproveAction 흐름]
 * 1) pg_token 수신
 * 2) 세션/로그인 사용자 확인
 * 3) 세션에 저장된 tid/order/user/amount 꺼내기
 * 4) 중복 승인 방지(새로고침/뒤로가기 대비)
 * 5) approve 요청 JSON 구성
 * 6) 카카오 approve API 호출 (서버 → 카카오 서버)
 * 7) 응답 파싱(amount.total 등)
 * 8) 승인 금액 강제 검증 (요청 금액 vs 승인 금액)
 * 9) DB 캐시 충전 반영
 * 10) 결과 페이지 이동

 

(1) pg_token은 어디서 검증되는가

pg_token은 우리가 “직접 진짜인지” 검증하는 값이 아니다.

우리 서버는 pg_token을 받아서 카카오 approve API로 보내고,
카카오가 tid + pg_token 조합이 유효한지 판별한다.

즉, “approve API 성공/실패”가 곧 pg_token 검증 결과이다.

 

(2) APPROVE 요청 바디 구성

JsonObject approveBody = new JsonObject();
approveBody.addProperty("cid", CID);
approveBody.addProperty("tid", tid);
approveBody.addProperty("partner_order_id", partnerOrderId);
approveBody.addProperty("partner_user_id", partnerUserId);
approveBody.addProperty("pg_token", pgToken);

 

(3) 승인 금액(amount.total) 파싱 + 강제 검증

승인 성공 후에도 우리 세션의 요청 금액과

카카오 승인 금액이 다르면 절대 충전하면 안 된다.

 

이를 위해 두 변수 비교를 통해 검증해야 한다.

// 카카오 APPROVE 응답(JSON 문자열) 파싱
// - approve API가 성공하면 responseJson에는 승인 결과가 JSON 형태로 들어온다.
// - 이 JSON 안에는 결제 승인 정보(승인 시각, 결제수단 등)와 함께,
//   "실제로 승인된 금액"을 담고 있는 amount 객체가 포함되는 경우가 많다.
JsonObject responseObj = JsonParser.parseString(responseJson).getAsJsonObject();


// 승인된 총 금액(amount.total) 추출
// - approvedTotal = "카카오가 최종 승인한 결제 총액"이다.
// - 이 값은 우리가 캐시를 충전할 때 절대적으로 믿을 수 있는 기준값이다.
// - 반대로, 프론트에서 넘어온 요청 금액(cashCharge)은 조작 가능하므로,
//   승인 금액(approvedTotal)과 반드시 비교 검증해야 안전하다.
Integer approvedTotal = null;

// amount 필드가 존재하고, JSON 객체 형태인지 확인
// - 응답 구조가 바뀌거나 비정상 응답일 때 NPE/파싱 오류를 막기 위한 방어 코드이다.
if (responseObj.has("amount") && responseObj.get("amount").isJsonObject()) {
    // amount 객체 꺼내기
    JsonObject amountObj = responseObj.getAsJsonObject("amount");
    // amount.total 존재 여부 + null 여부 확인
    // - total이 없거나 null이면 getAsInt()에서 예외가 터질 수 있으므로
    //   안전하게 조건을 걸고 파싱한다.
    if (amountObj.has("total") && !amountObj.get("total").isJsonNull()) {
        approvedTotal = amountObj.get("total").getAsInt(); // 승인 총액(int)
    }
}

// 강제 검증: null도 실패, 불일치도 실패
// - cashCharge  : READY에서 세션에 저장한 "우리 쪽이 예상한 요청 금액"
// - approvedTotal: 카카오가 실제로 승인한 "최종 승인 금액"
// - 결제는 금전이므로 다음 중 하나라도 만족하면 즉시 실패 처리해야 한다.
//
//   1) cashCharge가 null  → READY 단계에서 세션 저장이 누락되었거나 세션이 끊긴 경우
//   2) approvedTotal이 null → 카카오 응답에서 승인 금액을 정상적으로 파싱하지 못한 경우
//   3) 두 값이 불일치      → 금액 위변조/오류/다른 결제건 섞임 가능성이 있으므로 충전 금지
if (cashCharge == null || approvedTotal == null || !cashCharge.equals(approvedTotal)) {
    request.setAttribute("payResult", "FAIL");
    forward.setRedirect(false);
    forward.setPath("cashresult.jsp");
    return forward;
}

 

(4) DB 캐시 충전 반영 + 중복처리 플래그

주문/결제내역 테이블이 없는 구조라면,

최소한 새로고침 중복을 막기 위해

세션 플래그라도 두는 편이 안전하다.

// 중복 승인 방지(세션 최소 방어)
String processedOrderId = (String) session.getAttribute("kakaopay_processed_order_id");
if (partnerOrderId.equals(processedOrderId)) {
    request.setAttribute("payResult", "SUCCESS");
    forward.setRedirect(false);
    forward.setPath("cashresult.jsp");
    return forward;
}

// 캐시 충전 UPDATE
MemberDAO memberDAO = new MemberDAO();
MemberDTO memberDTO = new MemberDTO();
memberDTO.setCondition("MEMBER_CASH_CHARGE");
memberDTO.setMemberId(memberId);
memberDTO.setMemberCash(approvedTotal);

boolean updateCash = memberDAO.update(memberDTO);
if (!updateCash) {
    request.setAttribute("payResult", "FAIL");
    forward.setRedirect(false);
    forward.setPath("cashresult.jsp");
    return forward;
}

// 성공 후에만 처리완료 주문번호 저장(중요)
session.setAttribute("kakaopay_processed_order_id", partnerOrderId);

// 성공 결과 전달
request.setAttribute("payResult", "SUCCESS");
forward.setRedirect(false);
forward.setPath("cashresult.jsp");
return forward;

[ 공통 유틸: postJson() 메서드 ]

카카오 API 호출은 서버에서

HttpURLConnection으로 처리하였다.

private String postJson(String url, String jsonBody) throws IOException {
    HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
    conn.setRequestMethod("POST");
    conn.setDoOutput(true);
    conn.setConnectTimeout(10000);
    conn.setReadTimeout(10000);

    conn.setRequestProperty("Authorization", "SECRET_KEY " + SECRET_KEY_DEV);
    conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");

    try (OutputStream os = conn.getOutputStream()) {
        os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
    }

    int code = conn.getResponseCode();
    InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();

    StringBuilder sb = new StringBuilder();
    if (is != null) {
        try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
            String line;
            while ((line = br.readLine()) != null) sb.append(line);
        }
    }

    if (code < 200 || code >= 300) {
        throw new IOException("KakaoPay API 실패: HTTP " + code + " / body=" + sb);
    }
    return sb.toString();
}


중요한 것은 요청 헤더 2개이다.

  • Authorization: SECRET_KEY {키값}
    → 카카오 서버가 “누가 보낸 요청인지” 인증하는 헤더이다.
  • Content-Type: application/json; charset=UTF-8
    → 요청 바디가 JSON이며,
        한글이 깨지지 않도록 UTF-8로 전송한다는 의미이다.

또한 try-with-resources를 사용하면 close()가 자동으로 호출되므로
과거처럼 finally에서 닫는 코드를 별도로 작성하지 않아도 된다.


[ 결제 금액과 VAT에 대한 오해 ]

READY 바디에서 total_amount는 최종 결제 총액이다.
vat_amount는 부가세가 추가로 붙어서

결제금액이 늘어나는 값이 아니라,

 

총액(total_amount)을 세금/면세로

어떻게 나눌지에 대한 breakdown(구성 정보) 이다.

 

즉, total_amount = 3000이면 실제 결제도 보통 3000이 된다.
3300이 되는 구조가 아니라는 점을 구분해야 한다.


[ 실패/취소 처리 방식 ]

  • Cancel
    : 사용자가 의도적으로 취소한 케이스이므로,
    message.jsp로 안내 후 마이페이지로 이동시키는 방식이 깔끔하다.
  • Fail
    : 결제 실패는 결과 페이지(cashresult.jsp)에서
    FAIL UI를 구성해 주는 방식이 좋다.

이때 결과 판단 값은 payResult로 통일하면 View(JSP)가 단순해진다.

  • 성공: payResult = "SUCCESS"
  • 실패: payResult = "FAIL"

[ 마무리 ]

카카오페이 결제는 “READY로 결제 건 생성 + 결제 URL 받기”와
“APPROVE로 결제 확정 + DB 반영”을 분리해서 이해하면

구현이 비교적 단순해진다.

 

구현에서 가장 중요한 포인트는 다음과 같다.

  • READY에서 받은 tid는 반드시 저장하고, APPROVE에서 사용한다.
  • pg_token은 approve API 호출 성공/실패로 검증된다.
  • 승인 응답의 amount.total과 세션의 요청 금액을 비교하여 반드시 검증한다.
  • approve 중복 호출 방지를 반드시 고려한다(최소 세션 플래그라도).

이 구조대로 구현하면 JSP/Servlet 기반에서도

안정적으로 결제 충전 기능을 붙일 수 있다.


[ 최종 코드 - 결제 준비 / KakaoPayReadyAction ]

package controller.member;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.UUID;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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

import controller.common.Action;
import controller.common.ActionForward;

public class KakaoPayReadyAction implements Action {

    // 1) 카카오페이 결제 준비(READY) API 엔드포인트
    private static final String READY_URL = "https://open-api.kakaopay.com/online/v1/payment/ready";

    // 2) 테스트 CID(단건 결제)
    private static final String CID = "TC0ONETIME";

    // 3) 로컬/운영 도메인(approval/cancel/fail 콜백 URL을 만들 때 필요)
    // - 운영 배포 시에는 운영 도메인으로 교체해야 한다.
    private static final String BASE_URL = "http://localhost:8088";

    // 4) Secret key(dev)
    // - 절대 커밋/공유 금지(보안)
    private static final String SECRET_KEY_DEV = "<YOUR_SECRET_KEY_DEV>";

    /*
     * [KakaoPayReadyAction 흐름]
     * 1) 로그인 사용자 확인(세션 memberId)
     * 2) 충전 금액 수신 + 숫자 변환 + 허용 금액 검증
     * 3) 가맹점 주문번호(partner_order_id) 생성 (UUID 기반)
     * 4) 콜백 URL 3종 생성 (승인/취소/실패)
     * 5) ready API 요청 JSON 바디 구성
     * 6) 카카오 ready API 호출 (서버 → 카카오 서버)
     * 7) 카카오 응답(JSON) 수신 후 tid, next_redirect_pc_url 추출
     * 8) tid 세션 저장 (Approve 단계에서 tid + pg_token으로 승인 요청하기 위해 필요)
     * 9) 사용자 브라우저를 카카오 결제 페이지(next_redirect_pc_url)로 redirect
     */
    @Override
    public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
        ActionForward forward = new ActionForward();

        try {
            System.out.println("[카카오페이 READY] 시작");

            // 1) 로그인 체크
            // - 결제는 반드시 “누가 결제했는지”가 필요하므로 로그인 사용자만 허용한다.
            HttpSession session = request.getSession(false);
            if (session == null || session.getAttribute("memberId") == null) {
                forward.setRedirect(true);
                forward.setPath(request.getContextPath() + "/loginPage.do");
                return forward;
            }
            int memberId = (int) session.getAttribute("memberId");

            // 2) 금액 수신 + 검증
            // - 프론트에서 선택한 금액도 개발자도구로 조작 가능하므로 서버에서 반드시 검증한다.
            String selectCash = request.getParameter("selectCash");
            selectCash = (selectCash == null) ? "" : selectCash.trim();

            int cashCharge;
            try {
                cashCharge = Integer.parseInt(selectCash);
            } catch (NumberFormatException e) {
                System.out.println("[카카오페이 READY] 금액 파싱 실패: selectCash=[" + selectCash + "]");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 허용 금액만 통과(화이트리스트)
            if (!(cashCharge == 1000 || cashCharge == 5000 || cashCharge == 10000 || cashCharge == 50000)) {
                System.out.println("[카카오페이 READY] 허용되지 않은 금액: cashCharge=[" + cashCharge + "]");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 3) partner_order_id 생성
            // - 결제건을 구분하는 “가맹점 주문번호”
            // - 정석은 DB 주문테이블을 만들고 해당 PK를 쓰지만, 간단 구현에서는 UUID로 대체 가능하다.
            String partnerOrderId = "CASH_" + UUID.randomUUID();

            // 4) 콜백 URL 생성
            // - 결제는 카카오 결제창에서 진행되므로, 완료 후 다시 우리 서버로 돌아올 경로가 필요하다.
            // - approval_url: 결제 완료 후 pg_token을 포함해 돌아오는 URL (ApproveAction 진입점)
            // - cancel_url  : 사용자가 결제 취소했을 때 돌아오는 URL
            // - fail_url    : 결제 실패 시 돌아오는 URL
            String ctx = request.getContextPath();
            String approvalUrl = BASE_URL + ctx + "/KakaoPayApprove.do";
            String cancelUrl   = BASE_URL + ctx + "/KakaoPayCancel.do";
            String failUrl     = BASE_URL + ctx + "/KakaoPayFail.do";

            // 5) READY 요청 JSON 바디 구성
            // - 카카오 문서의 필드명을 그대로 맞춰야 한다.
            // - 숫자 필드는 숫자로 보내는 것이 안전하다(JSON 파싱 오류/스펙 불일치 방지)
            JsonObject body = new JsonObject();
            body.addProperty("cid", CID);
            body.addProperty("partner_order_id", partnerOrderId);
            body.addProperty("partner_user_id", String.valueOf(memberId));
            body.addProperty("item_name", "캐시 충전 " + cashCharge + "원");
            body.addProperty("quantity", 1);
            body.addProperty("total_amount", cashCharge);
            body.addProperty("vat_amount", calcVat(cashCharge));
            body.addProperty("tax_free_amount", 0);
            body.addProperty("approval_url", approvalUrl);
            body.addProperty("cancel_url", cancelUrl);
            body.addProperty("fail_url", failUrl);

            // 6) 카카오 READY API 호출 (서버 → 카카오 서버)
            // - 여기서 실제로 우리 서버가 카카오 서버로 HTTP POST 요청을 보낸다.
            String responseJson = postJson(READY_URL, body.toString());
            System.out.println("[카카오페이 READY] responseJson=" + responseJson);

            // 7) 응답에서 tid / next_redirect_pc_url 추출
            // - tid: 결제건의 거래ID(Approve에서 필수)
            // - next_redirect_pc_url: 사용자를 카카오 결제창으로 보내기 위한 URL
            JsonObject respObj = JsonParser.parseString(responseJson).getAsJsonObject();
            String tid = respObj.get("tid").getAsString();
            String nextRedirectPcUrl = respObj.get("next_redirect_pc_url").getAsString();

            // 8) APPROVE 단계에서 필요하므로 세션 저장
            // - 결제 완료 후 돌아올 때는 pg_token만 오므로,
            //   READY에서 받은 tid/order/amount를 세션에 저장해두고 APPROVE에서 꺼내쓴다.
            session.setAttribute("kakaopay_tid", tid);
            session.setAttribute("kakaopay_partner_order_id", partnerOrderId);
            session.setAttribute("kakaopay_partner_user_id", String.valueOf(memberId));
            session.setAttribute("kakaopay_total_amount", cashCharge);

            // 9) 사용자 브라우저를 카카오 결제 대기화면으로 redirect
            forward.setRedirect(true);
            forward.setPath(nextRedirectPcUrl);
            return forward;

        } catch (Exception e) {
            e.printStackTrace();
            request.setAttribute("payResult", "FAIL");
            forward.setRedirect(false);
            forward.setPath("cashresult.jsp");
            return forward;
        }
    }

    // 공통: 카카오 API 호출(POST + JSON)
    // - url: 호출할 카카오 API 주소
    // - jsonBody: 요청 바디(JSON 문자열)
    // - return: 카카오 서버 응답(JSON 문자열)
    private String postJson(String url, String jsonBody) throws IOException {

        // Secret Key가 비어 있으면 인증 실패이므로 요청 전에 차단한다.
        if (SECRET_KEY_DEV == null || SECRET_KEY_DEV.trim().isEmpty()) {
            throw new IllegalStateException("SECRET_KEY_DEV 값을 설정하세요.");
        }

        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setRequestMethod("POST");

        // POST 바디(JSON)를 쓰려면 OutputStream이 필요하므로 true로 설정한다.
        conn.setDoOutput(true);

        // 네트워크 지연 대비 타임아웃 설정(너무 길면 스레드가 묶일 수 있음)
        conn.setConnectTimeout(10000);
        conn.setReadTimeout(10000);

        // [핵심 헤더 2개]
        // 1) Authorization: 카카오가 “누가 보낸 요청인지” 인증하는 값
        // 2) Content-Type : 바디가 JSON이며 UTF-8 인코딩임을 명시(한글 깨짐 방지)
        conn.setRequestProperty("Authorization", "SECRET_KEY " + SECRET_KEY_DEV);
        conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");

        // 요청 바디 전송
        // - try-with-resources: 블록 종료 시 close() 자동 호출 → 자원 누수 방지
        try (OutputStream os = conn.getOutputStream()) {
            os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
        }

        // 응답코드 확인
        int code = conn.getResponseCode();

        // 2xx면 정상 스트림, 아니면 에러 스트림을 읽어야 실패 원인이 로그에 남는다.
        InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();

        // 응답 바디를 문자열로 읽기
        StringBuilder sb = new StringBuilder();
        if (is != null) {
            try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
                String line;
                while ((line = br.readLine()) != null) sb.append(line);
            }
        }

        // 2xx가 아니면 예외로 올려서 호출부(READY/APPROVE)에서 FAIL 처리하도록 한다.
        if (code < 200 || code >= 300) {
            throw new IOException("KakaoPay API 실패: HTTP " + code + " / body=" + sb);
        }

        return sb.toString();
    }

    // VAT 계산 예시
    // - total_amount는 “최종 결제 총액”이고,
    // - vat_amount는 그 총액 중 부가세 구성 금액(추가 과금 개념이 아니라 breakdown 정보)이다.
    private int calcVat(int cashCharge) {
        return (int) Math.round(cashCharge / 11.0);
    }
}

[ 최종 코드 - 결제 승인 /  KakaoPayReadyAction ]

package controller.member;

import java.io.*;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

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

import controller.common.Action;
import controller.common.ActionForward;

import model.member.MemberDAO;
import model.member.MemberDTO;

public class KakaoPayApproveAction implements Action {

    // 카카오페이 결제 승인(APPROVE) API 엔드포인트
    private static final String APPROVE_URL = "https://open-api.kakaopay.com/online/v1/payment/approve";

    // 테스트 CID(단건 결제)
    private static final String CID = "TC0ONETIME";

    // Secret key(dev) (절대 커밋/공유 금지)
    private static final String SECRET_KEY_DEV = "<YOUR_SECRET_KEY_DEV>";

    /*
     * [KakaoPayApproveAction 흐름]
     * 1) pg_token 수신
     * 2) 세션/로그인 사용자 확인
     * 3) 세션에 저장된 tid/order/user/amount 꺼내기 (READY에서 저장한 값)
     * 4) 중복 승인 방지(새로고침/뒤로가기 대비)
     * 5) approve 요청 JSON 구성
     * 6) 카카오 approve API 호출 (서버 → 카카오 서버)
     * 7) 응답 파싱(amount.total 등)
     * 8) 승인 금액 강제 검증 (요청 금액 vs 승인 금액)
     * 9) DB 캐시 충전 처리
     * 10) 결과 페이지 이동
     */
    @Override
    public ActionForward execute(HttpServletRequest request, HttpServletResponse response) {
        ActionForward forward = new ActionForward();

        try {
            System.out.println("[카카오페이 APPROVE] 시작");

            // 1) pg_token 수신
            // - 사용자가 카카오 결제창에서 결제를 완료하면,
            //   카카오가 approval_url로 redirect 하면서 pg_token을 쿼리 파라미터로 붙여준다.
            String pgToken = request.getParameter("pg_token");
            if (pgToken == null || pgToken.trim().isEmpty()) {
                System.out.println("[카카오페이 APPROVE] pg_token 없음(잘못된 접근/만료 가능)");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 2) 세션/로그인 확인
            HttpSession session = request.getSession(false);
            if (session == null || session.getAttribute("memberId") == null) {
                System.out.println("[카카오페이 APPROVE] 세션 없음 또는 로그인 정보 없음");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }
            Integer memberId = (Integer) session.getAttribute("memberId");

            // 3) READY에서 저장해둔 값 꺼내기
            // - approve는 pg_token만 돌아오기 때문에,
            //   tid/order/user/amount는 세션에서 가져와야 한다.
            String tid = (String) session.getAttribute("kakaopay_tid");
            String partnerOrderId = (String) session.getAttribute("kakaopay_partner_order_id");
            String partnerUserId = (String) session.getAttribute("kakaopay_partner_user_id");
            Integer cashCharge = (Integer) session.getAttribute("kakaopay_total_amount");

            if (tid == null || partnerOrderId == null || partnerUserId == null || cashCharge == null) {
                System.out.println("[카카오페이 APPROVE] 세션 결제정보 누락(tid/order/user/amount)");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 4) 중복 승인 방지(세션 최소 방어)
            // - approve는 새로고침/뒤로가기 등으로 중복 호출될 수 있다.
            // - DB 결제내역 테이블이 없다면, 최소한 세션 플래그로라도 막아두는 것이 안전하다.
            String processedOrderId = (String) session.getAttribute("kakaopay_processed_order_id");
            if (partnerOrderId.equals(processedOrderId)) {
                System.out.println("[카카오페이 APPROVE] 중복 호출 차단(이미 처리됨): orderId=[" + partnerOrderId + "]");
                request.setAttribute("payResult", "SUCCESS");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 5) approve 요청 바디(JSON) 구성
            // - 카카오 문서 필드 그대로 구성한다.
            JsonObject approveBody = new JsonObject();
            approveBody.addProperty("cid", CID);
            approveBody.addProperty("tid", tid);
            approveBody.addProperty("partner_order_id", partnerOrderId);
            approveBody.addProperty("partner_user_id", partnerUserId);
            approveBody.addProperty("pg_token", pgToken);

            // 6) 카카오 approve API 호출
            // - 여기서 tid + pg_token 조합이 유효한지 “카카오 서버가 검증”한다.
            // - 즉, pg_token 검증의 실질적인 결과는 approve API의 성공/실패로 판단된다.
            String responseJson = postJson(APPROVE_URL, approveBody.toString());
            System.out.println("[카카오페이 APPROVE] responseJson=" + responseJson);

            // 7) 응답 파싱
            JsonObject responseObj = JsonParser.parseString(responseJson).getAsJsonObject();

            // 7-1) 승인된 총 금액(amount.total) 추출
            // - 승인 성공이라도 amount.total 파싱이 실패하면 안전하게 FAIL 처리하는 것이 좋다.
            Integer approvedTotal = null;
            if (responseObj.has("amount") && responseObj.get("amount").isJsonObject()) {
                JsonObject amountObj = responseObj.getAsJsonObject("amount");
                if (amountObj.has("total") && !amountObj.get("total").isJsonNull()) {
                    approvedTotal = amountObj.get("total").getAsInt();
                }
            }

            // 8) 승인 금액 강제 검증
            // - 결제는 돈이므로 “승인된 금액(approvedTotal)”과 “요청 금액(cashCharge)”가 다르면 절대 충전하면 안 된다.
            if (approvedTotal == null) {
                System.out.println("[카카오페이 APPROVE] approvedTotal 파싱 실패");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            if (!cashCharge.equals(approvedTotal)) {
                System.out.println("[카카오페이 APPROVE] 금액 불일치: 요청=[" + cashCharge + "], 승인=[" + approvedTotal + "]");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 9) DB 캐시 충전 반영 (승인 금액 기준)
            // - 여기까지 왔다는 것은 “카카오 승인 성공 + 금액 검증 통과” 상태이다.
            // - 따라서 승인 금액만큼 캐시를 증가시킨다.
            //
            // 정석: 결제내역 테이블 + partner_order_id UNIQUE + 트랜잭션
            // 현재: 결제내역 테이블이 없으므로 캐시 업데이트 + 세션 플래그로 최소 방어
            MemberDAO memberDAO = new MemberDAO();
            MemberDTO memberDTO = new MemberDTO();

            memberDTO.setCondition("MEMBER_CASH_CHARGE");
            memberDTO.setMemberId(memberId);
            memberDTO.setMemberCash(approvedTotal); // 승인된 총 금액만 반영

            boolean updateCash = memberDAO.update(memberDTO);
            if (!updateCash) {
                System.out.println("[카카오페이 APPROVE] 캐시 업데이트 실패: memberId=[" + memberId + "]");
                request.setAttribute("payResult", "FAIL");
                forward.setRedirect(false);
                forward.setPath("cashresult.jsp");
                return forward;
            }

            // 10) 성공 후 “처리 완료 주문번호” 저장
            // - DB 반영이 성공한 후에만 찍어야 한다.
            session.setAttribute("kakaopay_processed_order_id", partnerOrderId);

            // 결과 페이지로 성공 전달
            request.setAttribute("payResult", "SUCCESS");
            forward.setRedirect(false);
            forward.setPath("cashresult.jsp");
            return forward;

        } catch (Exception e) {
            e.printStackTrace();
            request.setAttribute("payResult", "FAIL");
            forward.setRedirect(false);
            forward.setPath("cashresult.jsp");
            return forward;
        }
    }

    // 카카오 API 호출(POST + JSON) 공통 메서드
    private String postJson(String url, String jsonBody) throws IOException {

        if (SECRET_KEY_DEV == null || SECRET_KEY_DEV.trim().isEmpty()) {
            throw new IllegalStateException("SECRET_KEY_DEV 값을 설정하세요.");
        }

        HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
        conn.setRequestMethod("POST");
        conn.setDoOutput(true);
        conn.setConnectTimeout(10000);
        conn.setReadTimeout(10000);

        // 인증 + JSON 바디 전송을 위한 헤더
        conn.setRequestProperty("Authorization", "SECRET_KEY " + SECRET_KEY_DEV);
        conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");

        // 요청 바디 전송(try-with-resources로 close 자동)
        try (OutputStream os = conn.getOutputStream()) {
            os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
        }

        // 응답코드 + 바디 읽기
        int code = conn.getResponseCode();
        InputStream is = (code >= 200 && code < 300) ? conn.getInputStream() : conn.getErrorStream();

        StringBuilder sb = new StringBuilder();
        if (is != null) {
            try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
                String line;
                while ((line = br.readLine()) != null) sb.append(line);
            }
        }

        if (code < 200 || code >= 300) {
            throw new IOException("KakaoPay APPROVE 실패: HTTP " + code + " / body=" + sb);
        }

        return sb.toString();
    }
}