Redis를 사용한 카카오페이 결제기능 구현!

SIK407·2025년 1월 21일
0

기술(STACK)

목록 보기
6/6
post-thumbnail

빛보다 빠른건 뭐?
내 통장 잔고 다이어트 속도!
아오!!


현재 부트캠프에서 우리 팀은, CTC 기반으로 홈 서비스를 제공하는 프로젝트를 진행하고 있다

주변에 큰 세탁소 말고, 그냥 동내 세탁소와 고객을 중계해주는 그런 서비스인데, 그럼 돈이 왔다갔다 해야되니 결제가 필요하지 않겠는가?

Redis에 RefreshToken을 넣고 관리하면서 요즘 Redis에 관심이 조금 많다 보니 카카오페이 결제 기능과 접목을 시켜봤다.

그래서! 카카오페이 단건 결제를 구현을 해봤다.

📌 Flow

일단 코드를 작성하기 전에, 난 이 결제 흐름을 정확하게 알고 가야 된다고 생각한다.
https://developers.kakaopay.com/ → 여길 참조해보면 된다
(여기선 코드와 흐름만 보여줄 예정이다. 저 공식 문서에서 등록 과정은 생략하겠다.)

프런트는 Thymeleaf (HTML) → 리액트로 진행해도 무방하다. 별개의 서버라고 생각하고 제작했다.
백엔드는 Spring + Java

일단 단계가 두가지가 있다.

결제 준비 단계, 결제 완료 단계

  1. Spring에서 카카오 서버에게 결제 요청 상세 정보를 전달
    → 카카오페이 서버에서 정보를 받아 인증 (Pay Ready)
  2. Spring에서 사용자 인증 후, 카카오 서버에게 결제 승인 상세 정보를 전달
    → 카카오페이 서버에서 최종 결제 처리 완료 (Pay Complete)

일단 단계별로 코드와 설명을 하겠다.
Redis랑 저 결제 방식을 어떻게 엮었는지도 단계별로 설명하겠다.

📌 결제 준비 (Ready)

// order-detail.html 일부 JS 코드

function kakaoPay() {
        let orderItem = "";
        const orderItemsSize = orderItems.length;

        if (orderItemsSize > 1) {
            orderItem = orderItems[0].name + " 외 " + (orderItems.length - 1) + "건";
        } else {
            orderItem = orderItems[0].name;
        }

        const kakaoPayReqDTO = {
            name: orderItem,             // 카카오페이에 보낼 대표 상품명
            totalPrice: totalPrice,      // 총 결제금액
            quantity : orderItemsSize,   // 상품 수량 (목록 수량인듯)
            paymentId : paymentId        // 결제 ID
        };

        axios.post('/api/orders/kakaopay/ready', kakaoPayReqDTO, {
            headers: {
                Authorization: 'Bearer ' + token
            }
        }).then((response) => {
            // 성공 시, 카카오페이 결제 URL로 이동
            const parsedData = JSON.parse(response.headers['readykakaopayres']);

            if(!isMobile()) {
                window.location.href = parsedData.next_redirect_pc_url;
            } else {
                window.location.href = parsedData.next_redirect_mobile_url;
            }
        }).catch((error) => {
            // 에러 처리
            console.error('결제 준비 중 오류:', error);
        });
    }

DTO는 당연히 정하기 나름이다.

상품명, 총 가격, 수량, 결제ID를 묶어서 스프링에게 /api/orders/kakaopay/ready로 Post 요청을 해서 결제 준비를 요청한다.

public ResponseEntity<?> payReady(int userId , KakaoPayReqDTO kakaoPayReqDTO) {
        // HTTP Header 생성
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        headers.add("Authorization", "SECRET_KEY " + secretDev);

        // HTTP Body 생성
        Map<String, Object> body = new HashMap<>();
        body.put("cid", cid);
        body.put("cid_secret", clientSecret);
        body.put("partner_order_id", String.valueOf(kakaoPayReqDTO.getPaymentId())); // String으로 변환
        body.put("partner_user_id", String.valueOf(userId)); // String으로 변환
        body.put("item_name", kakaoPayReqDTO.getName());
        body.put("quantity", kakaoPayReqDTO.getQuantity());
        body.put("total_amount", kakaoPayReqDTO.getTotalPrice());
        body.put("tax_free_amount", 0); // 숫자 값 그대로 사용
        body.put("approval_url", /customer/order/completed");
        body.put("cancel_url", /customer/order/cancel");
        body.put("fail_url", "customer/order/fail");

        // HTTP 요청 (https://open-api.kakaopay.com/online/v1/payment/ready 으로)
        HttpEntity<Map<String, Object>> requestEntity = new HttpEntity<>(body, headers);
        RestTemplate template = new RestTemplate();
        ResponseEntity<ReadyKakakoPayRes> res = null;
        ReadyKakakoPayRes readyKakakoPayRes = null;

        try {
            String url = "https://open-api.kakaopay.com/online/v1/payment/ready";
            res = template.postForEntity(url, requestEntity, ReadyKakakoPayRes.class);
            readyKakakoPayRes = res.getBody(); // Body 값을 DTO로 변환
        } catch (HttpClientErrorException e) {
            System.out.println("[kakao Login HTTP API 오류] " + e.getMessage());
            return ResponseEntity
                    .badRequest()
                    .body("카카오페이 오류 발생");
        }

        // 객체를 JSON 문자열로 변환
        try {
            String readyKakakoPayResJson = new ObjectMapper().writeValueAsString(readyKakakoPayRes);

            HttpHeaders responseHeaders = new HttpHeaders();
            responseHeaders.add("ReadyKakaoPayRes", readyKakakoPayResJson);

            // Redis Key & Value 생성
            String redisKey = cid + ":" + userId;
            KakaoPaymentInfo paymentInfo = new KakaoPaymentInfo(redisKey, kakaoPayReqDTO.getPaymentId(), readyKakakoPayRes.getTid());

            // Redis에 데이터 저장
            kakaopaymentInfoRepository.save(paymentInfo);

            // ResponseEntity에 헤더 포함하여 응답
            return ResponseEntity.ok()
                    .headers(responseHeaders)
                    .body("결제 준비 완료");
        } catch (JsonProcessingException e) {
            System.out.println("[Json 파싱 오류] Kakaopay Ready 파싱 과정에서 오류가 발생했습니다: " + e.getMessage());
            return ResponseEntity
                    .badRequest()
                    .body("카카오페이 오류 발생");
        }
    }

(주의!!!! Map<String, Object> body = new HashMap<>(); 무조건 이 방식으로 보내야 인식한다.)

자 이게 무슨 코드냐면!

출처: 카카오페이 공식 문서

카카오페이에서 결제 준비에 필요한 정보들을 보여주는 목록이다.

cid는 TC0ONETIME 을 넣어주면 테스트결제가 활성화가 된다.

나머지는 저 O라고 표시되어 있는 정보들만 넣어줘도 결제 준비가 완료되었다고 next_redirect_pc_urltid 값이 넘어온다.

다른 블로그를 보니까 세션방식으로 사용해서 Tid 값을 클라이언트 측으로 넘겨줬다. (다음 단계에 Tid 값이 필요)

근데… 뭐랄까 굳이 이 값을 클라이언트한테 넘겨줄 필요가 있나?
그냥 서버가 가지고 있다가 파기하면 되는거 아닌가?

// Redis Key & Value 생성
String redisKey = cid + ":" + userId;
KakaoPaymentInfo paymentInfo = new KakaoPaymentInfo(redisKey, kakaoPayReqDTO.getPaymentId(), readyKakakoPayRes.getTid());

// Redis에 데이터 저장
kakaopaymentInfoRepository.save(paymentInfo);

그래서 Redis에 cid:userId 라는 Key를 만들었다. (예, “TC0ONETIME:1”)

그리고 Value를 tid 값을 넣어줬다.

Redis는 물론 캐시서버로도 활용이 되지만, 굳이 DB에 넣을 필요가 없는데 잠깐 데이터는 저장하고 싶을 때 유용하다.

Key, Value 형식인 Map 혹은 Dict 자료구조 형태이면서, TTL을 걸어놓은 만큼 데이터가 있다가 알아서 삭제가 된다.

클라이언트 측에 최소한의 정보만 넘기고 싶어 서버에서 tid 값을 관리하는게 좋겠구나 라고 판단했고, 그리고 그 데이터를 Redis가 관리하게 된다.

최종적으로 클라이언트는 next_redirect_pc_url 페이지로 이동하게 된다.

📌 결제 완료 (Complete)

1단계에서 next_redirect_pc_url 페이지로 이동해서 QR로 카카오페이 결제를 진행한 뒤, 결제가 정상적으로 진행이 되면 위에 카카오 서버로 요청을 보낼 때, Body에 담은 approval_url?pg_token=111123 이런 url로 리다이렉트가 된다.

// order-wait.html 일부 JS 코드
const token = sessionStorage.getItem("accessToken");
    const url = 'http://localhost:8080';
    const queryParams = new URLSearchParams(window.location.search);
    const pgToken = queryParams.get('pg_token');

    // 카카오페이 결제 approve
    function approvePayment() {
        axios.post(url + '/api/orders/kakaopay/approve',
            { token: pgToken },
            {
                headers: {
                    Authorization: 'Bearer ' + token
                }
            }
        ).then(res => {
            const aid = res.data.aid;
            const approvedAt = res.data.approvedAt;

            location.href = "/customer/order/success?aid=" + aid + "&approvedAt=" + approvedAt;
        }).catch(error => {
            alert("결제가 실패했습니다. 다시 시도해주세요.");
            location.href = '/customer/main';
        });
    }

그럼 그 pg_token 값을 body에 담은 뒤, /api/orders/kakaopay/approve로 Post 요청을 보내 결제 완료가 되었다고 처리해줘! 라고 Spring에 요청을 보낸다.

그럼 Spring은

@Transactional
public ResponseEntity<?> payCompleted(int userId, String token) {
    System.out.println(token);
    // HTTP Header 생성
    HttpHeaders headers = new HttpHeaders();
    headers.setContentType(MediaType.APPLICATION_JSON);
    headers.add("Authorization", "SECRET_KEY " + secretDev);

   // Redis에서 tid, partner_order_id 가져오기
   KakaoPaymentInfo payInfo = kakaopaymentInfoRepository.findById(cid + ":" + userId)
            .orElseThrow(() -> new RuntimeException("Redis에서 KakaoPaymentInfo를 찾을 수 없습니다: " + cid + ":" + userId));
    String tid = payInfo.getTid();
    Integer partnerOrderId = payInfo.getPartnerOrderId();
    kakaopaymentInfoRepository.deleteById(cid + ":" + userId);  // Redis에서 삭제

    // HTTP Body 생성
    Map<String, String> body = new HashMap<>();
    body.put("cid", cid);                                         // 가맹점 코드(테스트용)
    body.put("tid", tid);                                         // 결제 고유번호
    body.put("partner_order_id", String.valueOf(partnerOrderId)); // 주문번호
    body.put("partner_user_id", String.valueOf(userId));          // 회원 아이디
    body.put("pg_token", token);                                  // 결제승인 요청을 인증하는 토큰

    HttpEntity<Map<String, String>> requestEntity = new HttpEntity<>(body, headers);

    // KakaoPay API 호출
    RestTemplate template = new RestTemplate();
    String url = "https://open-api.kakaopay.com/online/v1/payment/approve";
    KakaoPayApproveRes kakaoPayApproveRes = null;
    try {
        kakaoPayApproveRes = template.postForObject(url, requestEntity, KakaoPayApproveRes.class);
    } catch (RestClientException e) {
        return ResponseEntity
                .badRequest()
                .body("카카오페이 결제 승인 요청에 실패했습니다: " + e.getMessage());
    }

    // Redis에 pgToken 저장
    try {
        KakaoPayPgToken pgToken = new KakaoPayPgToken("pgToken:" + token, partnerOrderId);
        kakaoPayPgTokenRepository.save(pgToken);
    } catch (Exception e) {
        System.out.println("Redis에 pgToken 저장 중 오류 발생: " + e.getMessage());
    }

    // Payment 엔티티에 KakaoPayApproveRes 정보 업데이트
    Payment payment = paymentRepository.findById(partnerOrderId)
            .orElseThrow(() -> new RuntimeException("해당 ID로 Payment를 찾을 수 없습니다: " + partnerOrderId));
    payment.setKakaoPayData(kakaoPayApproveRes);

    // KakaoPayApproveRes 객체에서 aid와 approvedAt 값 추출
    String aid = kakaoPayApproveRes.getAid();
    String approvedAt = kakaoPayApproveRes.getApprovedAt();

    // 결제 완료로 상태 변경
    orderService.updatePaymentStatusComplete(token);

    // Map에 값을 담기
    Map<String, String> responseMap = new HashMap<>();
    responseMap.put("aid", aid);
    responseMap.put("approvedAt", approvedAt);

    // Map을 JSON 형태로 보냄
    String jsonResponse = null;
      try {
        ObjectMapper objectMapper = new ObjectMapper();
        jsonResponse = objectMapper.writeValueAsString(responseMap);
        return ResponseEntity.ok().body(jsonResponse);
    } catch (JsonProcessingException e) {
        System.out.println("JSON 변환 중 오류 발생: " + e.getMessage());
        return ResponseEntity.badRequest().body("결제 완료 처리 중 오류 발생");
    }
}

1단계에서 Redis에 저장한 cid:userId가 Key 값인 Value를 가져온다 (Tid)

그럼 방금 클라이언트 측에서 보낸 pg_token 과 함께 역시 O라고 표시된 정보만 Body에 담아서 보내줘도 결제는 완료가 된다.

(주의!!!! 여기도 Map<String, Object> body = new HashMap<>() 무조건 이 방식으로 보내야 인식한다.)

그럼 각종 결제 승인 정보를 카카오페이 서버에서 우리 Spring 서버로 응답하게 되는데..

뭐 이 정보들 중, 어떤 값들을 DB에 담고 클라이언트에게 보여줄 지는 당연히 개발자나 팀에서 선택하면 된다.

우리 같은 경우는,

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>결제 성공</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            background-color: #ffffff;
        }
        .container {
            text-align: center;
        }
        .success-icon {
            width: 100px;
            height: 100px;
            background-color: #4AC7D5;
            border-radius: 50%;
            display: flex;
            justify-content: center;
            align-items: center;
            margin: 0 auto 20px auto;
        }
        .success-icon svg {
            width: 60px;
            height: 60px;
            fill: #ffffff;
        }
        .message {
            font-size: 20px;
            font-weight: bold;
            margin-bottom: 20px;
        }
        .details {
            margin-bottom: 20px;
            font-size: 14px;
            color: #555;
        }
        .details span {
            display: block;
            margin-bottom: 5px;
        }
        .back-button {
            display: inline-block;
            padding: 10px 20px;
            font-size: 16px;
            color: #4AC7D5;
            border: 1px solid #4AC7D5;
            border-radius: 5px;
            text-decoration: none;
            margin-top: 20px;
        }
        .back-button:hover {
            background-color: #4AC7D5;
            color: #ffffff;
        }
    </style>

    <script type="text/javascript">
        function getQueryParameter(param) {
            const urlParams = new URLSearchParams(window.location.search);
            return urlParams.get(param);
        }

        window.onload = () => {
            const aid = getQueryParameter('aid');
            const approvedAt = getQueryParameter('approvedAt');

            // DOM에 값을 동적으로 설정
            document.getElementById('order-id').innerText = aid || '없음';
            document.getElementById('approved-at').innerText = approvedAt || '없음';
        };
    </script>
</head>
<body>
<div class="container">
    <div class="success-icon">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
            <path d="M9 16.2l-4.2-4.2 1.4-1.4 2.8 2.8 6.8-6.8 1.4 1.4z"/>
        </svg>
    </div>
    <div class="message">결제에 성공하였습니다</div>
    <div class="details">
        <!-- URL 파라미터로 전달된 aid와 approvedAt을 표시 -->
        <span>주문번호: <strong id="order-id"></strong></span>
        <span>결제일시: <strong id="approved-at"></strong></span>
    </div>
    <a href="/customer/orderHistory" class="back-button">돌아가기</a>
</div>
</body>
</html>

이런식으로 간단하게 정보를 보여주고, 메인 페이지로 돌아가게 코드를 작성했다.

📒 트러블 슈팅~

카카오로 소셜 로그인을 구현한 적이 있다.
소셜로그인 구현 방법!

한번 구현한 적이 있어서 금방 구현했다.
다만…. 우리 공식 문서를 꼭 읽어보면서 제작하자 ㅠㅠㅠㅠ

new LinkedHashMap<>() 으로 계속 카카오 서버에 보내니까 400 에러가 반환이 되어서,

야이씨 왜 자꾸 내 데이터 거절해!!!

라고 계속 화를 냈는데, 이상하게 PostMan으로 하면 잘 보내지는 기이한 현상이였다
GPT를 돌려도 같은 말만 하고 ㅡㅡ

각종 블로그와 문서들를 뚫어져라 본 결과….
지원하는 Map 종류가 달라졌다고 한다 ㅠㅠ

그래서 new HashMap<>으로 초기화를 진행하니 바로 Tid 값이 응답되었다.

GPT를 너무 믿지 말자
때로는 무식한 방법이 정답일 경우도 있다.

profile
Spring 백엔드!

0개의 댓글

관련 채용 정보