본문 바로가기

Spring

[Spring] 충전 기능 구현 with. TossPayments

개요

 

  •  목적
    • 사용자들이 카드, 계좌이체로 금액을 충전해 서비스 내부 지갑을 통해 개인 간 거래가 목적이다.
    • 현 서비스에서 내부 화폐 처럼 동작하도록 설계하고, 중고거래 등 개인 ↔ 개인의 결제 흐름 전체를 스스로 설계하고 운영해 보는 것을 목표로 했다.

 

  • 설계 고민
    • 현실감 있는 결제 경험
      • 단순히 금액만 더해 주는 충전 보다는, 실제 결제 서비스와 최대한 비슷한 UI/UX와 승인 흐름 구현을 원했다.
      • 결제 요청 → 인증(카드/간편결제 선택) → 승인/실패 콜백까지 실제 상용 결제처럼 동작하도록 설계했다.
    • 도메인 모델(엔티티, Enum)과 PG 응답의 정합성
      • 결제수단, 간편결제 종류 등 필드를 서비스의 Entity와 Enum에 어떻게 똑같이 녹여낼지 고민했다. 단순 문자열 저장이 아닌, 결제수단, 간편결제 타입, PG상태 값 등을 Enum으로 정제해서 저장함으로써 재사용하기 쉬운 구조를 목표로 했다.
    • 트랜잭션과 데이터 정합성
      • 결제 승인 과정 중간에 실패할 수 있는 지점이 많기 때문에 결제 승인 실패 시 충전이 되면 안 되고 어떻게 트랜잭션으로 관리할지 고민이었다.

 

  • 설계 방법
    • PG 사 선택 - TossPayments (오픈 API)
      • 실제 결제와 매우 유사한 UI/UX를 제공함
      • 실제 계좌에서 돈이 빠져나가지 않는 테스트 환경 지원
      • 사업자 등록 없이도 연동 가능함
      • 카드, 계좌, 간편 결제 등 다양한 결제 수단 제공
        이라는 이유로 TossPayments 오픈 API를 선택했다. 이 덕분에 실제 상용 결제 흐름을 그대로 사용하면서도, 개발 단계에서는 안전하게 테스트가 가능했다.
    • Entity / Enum 설계
      • TossPayments 문서에서 제공하는 필드들을 참고하여 내부 도메인 모데을 설계했다. (최하단 링크 참고)
    • 트랜잭션 처리와 롤백 전략
      • 결제 승인 서비스 메서드에는 @Transactional을 적용하여
        • TradePayment 생성 (진행중 상태)
        • Toss /confirm 으로 토스페이먼츠와 검증
        • 결제 이력 업데이트
        • 사용자 지갑 잔액 업데이트
        • 지갑 장부 기록
      • 까지를 하나의 트랜잭션으로 묶어서 설계했다.
        이를 통해 Toss 승인 실패나 네트워크 예외가 발생하면 예외를 그대로 던지도록 설계했고, 그 결과 해당 트랜잭션 전체가 롤백하여 잔액의 오류, 결제 내력이 애매하게 남는 문제를 방지했다. 이렇게 돈은 안 빠져나가고 잔액만 충전되는 정합성 문제를 예방하고 결제 로직과 트랜잭션 경계에 대해 설계했다.

 


 

TossPayments 결제 흐름 이해하기

 

  • 요청, 인증, 승인 결제 흐름

 

  • 구매자 & Client
    • 결제 요청 버튼으로 SDK 의 결제 요청 메서드 호출
    • 결제 요청 메서드의 orderId, 성공URL, 실패URL 함께 보내기
const tossPayments = await loadTossPayments(clientKey);

await tossPayments.requestPayment("카드", {
    amount,
    orderId: nanoid(),
    orderName: `무한루프 페이 충전 (${amount.toLocaleString()}원)`,
    successUrl: `${window.location.origin}/wallet/topup/success`,
    failUrl: `${window.location.origin}/wallet/topup/fail`,
    customerName: "테스트유저",
    customerEmail: "",
}

 

  • 토스페이먼츠
    • 클라이언트 → 토스페이먼츠 결제 요청 인증 시 메서드에서 입력받은 (성공 / 실패 ) URL 로 연결
successUrl: `${window.location.origin}/wallet/topup/success`,
failUrl: `${window.location.origin}/wallet/topup/fail`,

 

  • 구매자 & Client
    • 성공 페이지 → API POST 이때 window.location.search에 쿼리스트링 확인 paymentKey, orderId, amount 함께 보냄
http://localhost:3000/wallet/topup/success?orderId=m7GvW7seGVKb0n1UzfZIZ&paymentKey=tviva20251129162101Af8y0&amount=20000
const searchParams = useMemo(
    () => new URLSearchParams(window.location.search),
    []
);
const res = await fetch("/api/wallet/topup/confirm", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify({
        paymentKey,
        orderId,
        amount: Number(amount),
    }),
});

 

  •  Server
    • 성공 URL 의 파라미터 ( patmentKey, orderId, amount ) 값 == 결제 요청에 보낸 값
      일치 하는지 확인 후 결제 승인 API 실행

 


 

결제 승인 API 구현

 

 

  • Client
    1. 결제 승인 요청 

 

  • Controller
    1. @RequestBody DTO ( paymentKey, orderId, amount ) → TossConfirmRequest 자동 매핑
    2. @SessionAttribute("USER") 에서 userId 꺼냄
    3. walletPatmentService.confirmTopup(sessionId, paymentKey, orderId, amount) 실행

 

  • Service 
    1. userRepository.findById 결제한 유저 조회 - 없을 시 예외던지기
    2. tradePaymentRepositoy.save 결제 기록 (PENDING) 저장
    3. confirmWithToss 실제 토스 서버와 유저가 보낸 정보 비교 실행
      • (setDoOutput, setRequestMethod, setRequestProperty) 토스 서버 검증 요청
      • getOutputStream, getResponseCode → 성공여부 (200) + JSON 받기
      • return( status, method, easypay, easyPayProvider, resp('원문 JSON') )
    4. PaymentKey, OrderId, Pg_status, Pg_raw_response, Methode_type, EasyPayType 값 업데이트
    5. Wallet wallet = walletRepository.findByUser(user) 유저의 현재 잔액 가져오고 + amount(충전금액) 더하고 set
    6. walletLedgerRepository.save - 지갑 잔액 바뀔 때마다 기록 저장 (모든 입, 출금 내역)
    7. treadePaymentRepository.setStatus ( PENDING → SUCCEEDED ) 성공으로 변환 후 업데이트

 

  • ENUM 설계
  • PaymentMethodType
case "CARD", "카드" -> CARD;
case "EASY_PAY", "간편결제" -> EASY_PAY;
case "VIRTUAL_ACCOUNT", "가상계좌" -> VIRTUAL_ACCOUNT;
case "MOBILE_PHONE", "휴대폰" -> MOBILE_PHONE;
case "TRANSFER", "계좌이체" -> TRANSFER;
case "CULTURE_GIFT_CERTIFICATE", "문화상품권" -> CULTURE_GIFT_CERTIFICATE;
case "BOOK_GIFT_CERTIFICATE", "도서문화상품권" -> BOOK_GIFT_CERTIFICATE;
case "GAME_GIFT_CERTIFICATE", "게임문화상품권" -> GAME_GIFT_CERTIFICATE;

  

  • EasyPayType
case "TOSSPAY", "토스페이", "토스결제" -> TOSSPAY;
case "NAVERPAY", "네이버페이" -> NAVERPAY;
case "SAMSUNGPAY", "삼성페이" -> SAMSUNGPAY;
case "LPAY", "엘페이" -> LPAY;
case "KAKAOPAY", "카카오페이" -> KAKAOPAY;
case "PAYCO", "페이코" -> PAYCO;
case "SSG", "SSG페이" -> SSG;
case "APPLEPAY", "애플페이" -> APPLEPAY;
case "PINPAY", "핀페이" -> PINPAY;

 

 

 


 

트러블슈팅

 

  • 상황 1. 엔티티-DB 스키마 불일치로 인한 SQLSyntaxErrorException
    • 기존 데이터베이스 설계와 다르게 토스페이먼츠를 사용하게 됨으로 더 많은 엔티티 추가 (paymentKey, orderId, method_type, pg_raw_reponse .. 등)
    • 수정 이후 confirmTopup 에서 tradPaymentRepository.save 실행 시점에서 오류 발생

 

  • 문제 분석
    • 로그 확인 해보니 SQLSyntaxErrorException: Unknown column 'pg_raw_response' in 'field list' 확인
      insert into `trade_payment`
      (`amount`, `created_at`, `easy_pay_type`, `fee_amount`, `method_type`,
       `order_id`, `payment_key`, `payment_method_id`, `pg_raw_response`,
       `pg_status`, `provider_tx_id`, `purpose`, `status`, `trade_id`, `user_id`)
      values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
      Hibernate 가 생성한 SQL 을 확인 한 결과 엔티티에는 pg_raw_response가 있지만, 실제 MySQL 테이블에는 해당 컬럼이 없었기 때문에 해당 컬럼이 없다고 확인

    • Spring.jpa.hibernate.ddl-auto = update 를 설정해 주었지만 일부는 이전에 수동으로 ALTER ADD COLUMN 으로 몇개는 미리 해두었다. 그때 pg_raw_response를 빠뜨렸던 것이 문제

 

  • 해결 방법
    • MySQL에 켜서 필요한 컬럼 등을 직접 추가해줌
      ALTER TABLE trade_payment
        ADD COLUMN payment_key      VARCHAR(100),
        ADD COLUMN order_id         VARCHAR(100),
        ADD COLUMN method_type      VARCHAR(20),,
        ADD COLUMN pg_raw_response  TEXT;  등..

 

  • 결과
    • SQLSyntaxErrorException이 모두 제거 후 정상적으로 confirmTopup 에서 tradPaymentRepository.save 동작
    • 앞으로 엔티티를 수정하는 경우에는 엔티티 변경 → DB마이그레이션 → 테스트 라는 흐름을 세우기로함

 


 

  • 상황2. Toss 응답 값과 Enum 매핑 불일치 문제
    • 결제 내역에 "무슨 방식으로 결제했는지"를 남기기 위해 아래 두 개의 Enum 을 추가
      • PaymentMethodType, EasyPayType
    • 그리고 Toss /confirm 응답을 통해 받는 method, easyPay.provider를 받아 엔티티 저장하게끔 설계
      • tradePayment.setMethod_type(PaymentMethodType.valueOf(tossResult.method()));
      • tradePayment.setEasyPayType( EasyPayType.valueOf(tossResult.easyPayProvider()));

 

  • 문제 분석
    • 단순히 valueOf()로 매핑 시도 → 대소문자, 문자열 1글자라도 다르면 무조건 예외
      즉, IllegalArgumentException 혹은 NullPointerException 으로 그렇게 예외로 던짐

 

  • 해결 방법
    • valueOf와 같은 일을 하고, 예외가 나더라도 핸들링 가능한 함수 생성
public static EasyPayType fromCode(String code) {
    if (code == null) return null;
    return switch (code) {
        case "TOSSPAY", "토스페이", "토스결제" -> TOSSPAY;
        case "NAVERPAY", "네이버페이" -> NAVERPAY;
        case "SAMSUNGPAY", "삼성페이" -> SAMSUNGPAY;
        case "LPAY", "엘페이" -> LPAY;
        case "KAKAOPAY", "카카오페이" -> KAKAOPAY;
        case "PAYCO", "페이코" -> PAYCO;
        case "SSG", "SSG페이" -> SSG;
        case "APPLEPAY", "애플페이" -> APPLEPAY;
        case "PINPAY", "핀페이" -> PINPAY;
        default -> null;
    };
}

 

  •  결과
    • Toss 응답이 영문/한글/별칭 형태로 섞여 있어도 서비스 코드에서는 항상 정제된 Enum 값 저장
    • 특정 PG/간편 결제가 새로 추가 되더라도 fromCode() 내부에 case만 추가로 유지보수 좋아짐

 


 

  • 상황3. Toss 승인 실패 시 트랜잭션 롤백 동작 정리
    • 결제 승인 흐름은 하나의 API 작업 진행
      1. TradePayment 레코드 생성 (PENDING)
      2. TOSS /confirm API 호출로 결제 검증
      3. Toss 응답을 기반으로 TradePayment 업데이트
      4. 사용자 지갑(Wallet) 잔액 업데이트
      5. 지갑 장부(WalletLedger) 기록 추가
    • 위에 API 는 모두 confirmTopup() 서비스 레이어 단계에서 묶여있다. 이때 Toss 승인 실패나
      네트워크 예외 등 예외가 발생한 경우 데이터가 어느 상태까지 남고, 어디까지 롤백 되는지 명확한 이해 원했음

 

  • 문제 분석
    • 시나리오는 대략 두 가지로 고려함
      1. tradePayment 레코드를 save 까지는 함
      2. 그 이후 /confirm API 호출로 결제 검증 하다 예외로 터짐
        → 이때 지갑 잔액은 올라가면 안되고, 결제 이력도 PENDING으로 남거나 하면 안됨
        즉, 결제 승인 로직 전체 하나가 하나의 트랜잭션으로 Commit 이 되거나, 어느 지점에서 실패한다면
        Rollback 되기를 원했다.

 

  • 해결 방법
    • 해당 서비스 계층에서 confirmTopup 함수에 @Transactional 적용
      이때 Toss 결제 검증 실패 시, 네트워크 에러 시 예외 던지기로 인한 해당 트랜잭션 Rollback 적용
@Transactional
public void confirmTopup(Long userId, String paymentKey, String orderId, Long amount) {

 

  • 결과
    • Toss 승인에 실패한다면 TradePayment, Wallet, WalletLedger 이 모두 Rollback 확인함
    • 이렇게 트랜잭션에 대한 Rollback 에 대한 개념을 다시 한번 잡고 가게 되었고
      "ex) 돈은 안 빠져나감 → 잔액 충전 " 처럼 정합성 문제도 해결 가능했다.

 


 

구현 결과

  

 


 

참고 자료

 

데이터 흐름 - https://docs.tosspayments.com/guides/v2/get-started/payment-flow

 

결제 흐름 이해하기 | 토스페이먼츠 개발자센터

카드 결제 과정의 세 가지 핵심 단계인 요청, 인증, 승인을 이해하고 결제 정보를 검증하는 방법을 알아보세요.

docs.tosspayments.com

 

ENUM 코드 -https://docs.tosspayments.com/codes/enum-codes

 

ENUM 코드 | 토스페이먼츠 개발자센터

토스페이먼츠 API/SDK에서 사용하는 ENUM 코드입니다.

docs.tosspayments.com

 

결제 연동 방법 - https://docs.tosspayments.com/guides/v2/payment-widget/integration

 

연동하기 | 토스페이먼츠 개발자센터

토스페이먼츠의 간편한 결제 연동 과정을 한눈에 볼 수 있습니다. 각 단계별 설명과 함께 달라지는 UI와 코드를 확인해보세요.

docs.tosspayments.com

 

 


GitHub