카카오페이 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는 “결제 확정” 단계이다.
전체 흐름 한 장 요약
- 사용자(브라우저) → 우리 서버
: 충전 금액 선택 후 결제 요청 - 우리 서버 → 카카오 서버
: READY API 호출 - 카카오 서버 → 우리 서버
: tid + next_redirect_pc_url 응답 - 우리 서버 → 사용자(브라우저)
: next_redirect_pc_url로 리다이렉트 - 사용자(브라우저) → 카카오 결제 페이지
: 결제 진행 - 카카오 → 사용자(브라우저)
: 결제 완료 후 approval_url?pg_token=...로 리다이렉트 - 사용자(브라우저) → 우리 서버
: APPROVE Action 진입 (pg_token 수신) - 우리 서버 → 카카오 서버
: APPROVE API 호출 (tid + pg_token) - 우리 서버
: 승인 성공 시 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();
}
}'개주 훈련일지 > 🏋️ 전집중 호흡 훈련' 카테고리의 다른 글
| 프로필 이미지 업로드 UX 개선: 비동기 리사이징 미리보기 + 최종 확정 시 캐시 차감 연계 (0) | 2025.12.24 |
|---|---|
| 이미지 업로드 서블릿 만들기(JSP/Servlet + 자동 리사이즈 + 폴더 분기) (0) | 2025.12.24 |
| n8n으로 뉴스 요약 자동화 기능 만들기 (0) | 2025.12.20 |
| Generative AI 시대에서 Agentic Workflow 시대로 (0) | 2025.12.20 |
| 게시글 댓글 정렬(최신순/오래된순) 비동기 서블릿 구현 (0) | 2025.12.19 |