개요
- 목적
- 사용자들이 카드, 계좌이체로 금액을 충전해 서비스 내부 지갑을 통해 개인 간 거래가 목적이다.
- 현 서비스에서 내부 화폐 처럼 동작하도록 설계하고, 중고거래 등 개인 ↔ 개인의 결제 흐름 전체를 스스로 설계하고 운영해 보는 것을 목표로 했다.
- 설계 고민
- 현실감 있는 결제 경험
- 단순히 금액만 더해 주는 충전 보다는, 실제 결제 서비스와 최대한 비슷한 UI/UX와 승인 흐름 구현을 원했다.
- 결제 요청 → 인증(카드/간편결제 선택) → 승인/실패 콜백까지 실제 상용 결제처럼 동작하도록 설계했다.
- 도메인 모델(엔티티, Enum)과 PG 응답의 정합성
- 결제수단, 간편결제 종류 등 필드를 서비스의 Entity와 Enum에 어떻게 똑같이 녹여낼지 고민했다. 단순 문자열 저장이 아닌, 결제수단, 간편결제 타입, PG상태 값 등을 Enum으로 정제해서 저장함으로써 재사용하기 쉬운 구조를 목표로 했다.
- 트랜잭션과 데이터 정합성
- 결제 승인 과정 중간에 실패할 수 있는 지점이 많기 때문에 결제 승인 실패 시 충전이 되면 안 되고 어떻게 트랜잭션으로 관리할지 고민이었다.
- 현실감 있는 결제 경험
- 설계 방법
- PG 사 선택 - TossPayments (오픈 API)
- 실제 결제와 매우 유사한 UI/UX를 제공함
- 실제 계좌에서 돈이 빠져나가지 않는 테스트 환경 지원
- 사업자 등록 없이도 연동 가능함
- 카드, 계좌, 간편 결제 등 다양한 결제 수단 제공
이라는 이유로 TossPayments 오픈 API를 선택했다. 이 덕분에 실제 상용 결제 흐름을 그대로 사용하면서도, 개발 단계에서는 안전하게 테스트가 가능했다.
- Entity / Enum 설계
- TossPayments 문서에서 제공하는 필드들을 참고하여 내부 도메인 모데을 설계했다. (최하단 링크 참고)
- 트랜잭션 처리와 롤백 전략
- 결제 승인 서비스 메서드에는 @Transactional을 적용하여
- TradePayment 생성 (진행중 상태)
- Toss /confirm 으로 토스페이먼츠와 검증
- 결제 이력 업데이트
- 사용자 지갑 잔액 업데이트
- 지갑 장부 기록
- 까지를 하나의 트랜잭션으로 묶어서 설계했다.
이를 통해 Toss 승인 실패나 네트워크 예외가 발생하면 예외를 그대로 던지도록 설계했고, 그 결과 해당 트랜잭션 전체가 롤백하여 잔액의 오류, 결제 내력이 애매하게 남는 문제를 방지했다. 이렇게 돈은 안 빠져나가고 잔액만 충전되는 정합성 문제를 예방하고 결제 로직과 트랜잭션 경계에 대해 설계했다.
- 결제 승인 서비스 메서드에는 @Transactional을 적용하여
- PG 사 선택 - TossPayments (오픈 API)
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 실행
- 성공 URL 의 파라미터 ( patmentKey, orderId, amount ) 값 == 결제 요청에 보낸 값
결제 승인 API 구현

- Client
- 결제 승인 요청
- Controller
- @RequestBody DTO ( paymentKey, orderId, amount ) → TossConfirmRequest 자동 매핑
- @SessionAttribute("USER") 에서 userId 꺼냄
- walletPatmentService.confirmTopup(sessionId, paymentKey, orderId, amount) 실행
- Service
- userRepository.findById 결제한 유저 조회 - 없을 시 예외던지기
- tradePaymentRepositoy.save 결제 기록 (PENDING) 저장
- confirmWithToss 실제 토스 서버와 유저가 보낸 정보 비교 실행
- (setDoOutput, setRequestMethod, setRequestProperty) 토스 서버 검증 요청
- getOutputStream, getResponseCode → 성공여부 (200) + JSON 받기
- return( status, method, easypay, easyPayProvider, resp('원문 JSON') )
- PaymentKey, OrderId, Pg_status, Pg_raw_response, Methode_type, EasyPayType 값 업데이트
- Wallet wallet = walletRepository.findByUser(user) 유저의 현재 잔액 가져오고 + amount(충전금액) 더하고 set
- walletLedgerRepository.save - 지갑 잔액 바뀔 때마다 기록 저장 (모든 입, 출금 내역)
- 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를 빠뜨렸던 것이 문제
- 로그 확인 해보니 SQLSyntaxErrorException: Unknown column 'pg_raw_response' in 'field list' 확인
- 해결 방법
- 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; 등..
- MySQL에 켜서 필요한 컬럼 등을 직접 추가해줌
- 결과
- 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()));
- 결제 내역에 "무슨 방식으로 결제했는지"를 남기기 위해 아래 두 개의 Enum 을 추가
- 문제 분석
- 단순히 valueOf()로 매핑 시도 → 대소문자, 문자열 1글자라도 다르면 무조건 예외
즉, IllegalArgumentException 혹은 NullPointerException 으로 그렇게 예외로 던짐
- 단순히 valueOf()로 매핑 시도 → 대소문자, 문자열 1글자라도 다르면 무조건 예외
- 해결 방법
- 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 작업 진행
- TradePayment 레코드 생성 (PENDING)
- TOSS /confirm API 호출로 결제 검증
- Toss 응답을 기반으로 TradePayment 업데이트
- 사용자 지갑(Wallet) 잔액 업데이트
- 지갑 장부(WalletLedger) 기록 추가
- 위에 API 는 모두 confirmTopup() 서비스 레이어 단계에서 묶여있다. 이때 Toss 승인 실패나
네트워크 예외 등 예외가 발생한 경우 데이터가 어느 상태까지 남고, 어디까지 롤백 되는지 명확한 이해 원했음
- 결제 승인 흐름은 하나의 API 작업 진행
- 문제 분석
- 시나리오는 대략 두 가지로 고려함
- tradePayment 레코드를 save 까지는 함
- 그 이후 /confirm API 호출로 결제 검증 하다 예외로 터짐
→ 이때 지갑 잔액은 올라가면 안되고, 결제 이력도 PENDING으로 남거나 하면 안됨
즉, 결제 승인 로직 전체 하나가 하나의 트랜잭션으로 Commit 이 되거나, 어느 지점에서 실패한다면
Rollback 되기를 원했다.
- 시나리오는 대략 두 가지로 고려함
- 해결 방법
- 해당 서비스 계층에서 confirmTopup 함수에 @Transactional 적용
이때 Toss 결제 검증 실패 시, 네트워크 에러 시 예외 던지기로 인한 해당 트랜잭션 Rollback 적용
- 해당 서비스 계층에서 confirmTopup 함수에 @Transactional 적용
@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
'Spring' 카테고리의 다른 글
| [Spring] CaffeineCache TTL 설정 오류로 인한 NPE 발생 사례 (0) | 2026.04.29 |
|---|---|
| [Spring] 예외 처리 종류 & 상태 코드 (0) | 2025.12.28 |
| [Spring] 출금 및 장부 시스템 구현 (2) | 2025.12.08 |
| [Spring] 웹소켓(STOMP) 을 이용한 채팅 구현 (0) | 2025.09.04 |
| [Spring] 카카오 로그인 API 연결 해보기 (0) | 2025.04.12 |