이번 프로젝트에서는 유료 스터디 결제 기능이 필요했다.
단순히 PG 연동만 붙이면 끝나는 작업처럼 보일 수 있지만, 실제로는 결제가 성공한 뒤 어떤 상태로 저장할지, 환불은 어디까지 허용할지, 정산이 시작된 뒤에는 어떤 요청을 막아야 할지까지 함께 정리해야 했다.
토스 결제를 구현하기 전에 먼저 어떤 PG를 선택할지부터 정리했다.
비교 기준은 크게 네 가지였다.
- 구현 난이도와 개발 속도
- 유지보수성과 확장성
- 웹훅 기반 정합성 관리가 가능한지
- 수수료
결론부터 말하면 현재 프로젝트 단계에서는 토스페이먼츠가 가장 현실적인 선택이었다.
PG 선택 기준
1. 구현 난이도와 개발 속도
프로젝트에서 결제를 당장 혼자 구현해야 하는 상황이었다.
이 조건에서는 수수료 몇 퍼센트 차이보다도, 자료가 충분한지, 예제가 많은지, 빠르게 붙일 수 있는지가 더 중요했다.
토스는 국내에서 자료가 많은 편이고, Spring 기반 예제나 구현 경험도 비교적 쉽게 찾을 수 있다.
반대로 KG이니시스나 나이스페이 같은 전통 PG는 대규모 운영 경험과 안정성 측면에서는 강점이 있지만, 혼자 빠르게 구현하기에는 진입 장벽이 더 높다고 봤다.
Webhook 구조나 운영 처리까지 감안하면 구현 복잡도가 확실히 올라간다.
2. 유지보수성과 확장성
지금 구현하는 사람과 나중에 이어받는 사람이 같지 않을 가능성도 고려했다.
이 경우 중요한 것은 구현이 얼마나 깔끔한지가 아닌, 다음 사람이 얼마나 빨리 문맥을 따라올 수 있는지다.
토스는 문서, 사례, 커뮤니티 자료가 많아서 유지보수 진입 장벽이 상대적으로 낮다.
반면 수수료가 저렴한 PG 중에는 결제 자체는 붙이기 쉬워 보여도, 요구사항이 복잡해졌을 때 구조를 계속 유지하기 어려운 경우가 있다.
현재 구현이 된다고 해서 장기적으로도 같은 선택이 맞는 것은 아니기 때문에, 처음부터 갈아탈 가능성이 높은 구조를 택하고 싶지는 않았다.
3. 정합성 관리 기능 여부
이 프로젝트에서는 단순 결제 완료 여부보다 정합성 관리가 더 중요했다.
굿즈 판매나 디지털 파일 다운로드처럼 결제가 끝나면 즉시 물건을 전달하고 끝나는 구조라면, 상대적으로 단순한 결제 구조로도 운영할 수 있다.
하지만 스터디 서비스는 결제 이후에도 상태를 계속 관리해야 했다.
- 결제 완료
- 가상계좌 입금 대기
- 실제 입금 완료
- 환불 요청
- 부분 환불 또는 전액 환불
- 정산
이 흐름에서는 프론트 redirect만 보고 결제 완료를 확정하면 불안하다.
토스는 웹훅 이벤트 구조를 중심으로 서버에서 최종 상태를 확정하는 쪽으로 가져가기 좋았다.
반면 일부 저수수료 PG는 결제 자체는 붙이기 쉬워 보여도, 웹훅 구조나 상태 추적 측면에서는 한계가 있었다.
부분 취소, 부분 환불, 이후 보증금 같은 구조까지 생각하면 요구사항이 복잡해질수록 유지하기 어려워질 가능성이 높다고 봤다.
4. 수수료
수수료만 보면 토스를 선택할 수 없다.
토스페이먼츠는 카드 기준 3.4% 수준이어서, 표면적인 숫자만 보면 더 저렴한 선택지가 분명히 있다.
예를 들면 다음과 같은 후보가 있었다.
- Prosell: 1.9% ~ 3.4%
- BootPay: 2.9%
- payApp: 1.9% ~ 2.85%
또 KG이니시스, 나이스페이 같은 전통 PG는 매출이나 트래픽이 충분히 커지면 수수료 협상 여지도 있다고 한다.
다만 현재 단계에서는 매출과 트래픽이 얼마나 나올지 모르는 상황이었다.
이 상황에서 수수료를 조금 더 아끼겠다는 이유로 구현 일정이 불확실해지고, 도메인 정합성을 약하게 가져가는 선택이 정말 이득인지에 대해서는 회의적이었다.
토스도 카드 결제만 있는 것이 아니라 계좌이체나 간편계좌 결제를 UX에서 더 잘 노출해 평균 수수료를 조절할 수 있다.
즉, 당장의 의사결정에서는 수수료 최저치보다 구현 안정성과 운영 가능성을 더 높게 봤다.
토스를 선택한 이유
정리하면 이번 프로젝트에서는 토스를 선택한 이유가 명확했다.
- 혼자서 구현 가능한 난이도여야 했다.
- 나중에 다른 사람이 이어받아도 문서를 찾고 맥락을 따라가기 쉬워야 했다.
- 프론트 redirect가 아니라 서버 기준으로 결제 상태를 확정할 수 있어야 했다.
- 환불과 정산까지 이어지는 복잡한 흐름을 무리 없이 수용할 수 있어야 했다.
수수료만 보면 더 싼 선택지가 있었지만, 지금 단계에서 더 중요했던 것은 구현 속도와 정합성, 그리고 유지보수 가능성이었다.
나중에 매출과 트래픽이 유의미하게 커지고, 수수료 절감이 실제 과제가 되는 시점이 오면 그때는 다른 선택도 충분히 고려할 수 있다.
그 시점에는 PortOne + 저수수료 PG 조합처럼 수수료 최적화를 목표로 한 전환이 더 의미 있을 것이라고 봤다.
PG사를 선택하고 결제 구현 전 기획안을 보고 아래와 같이 정리했다.
- 결제 요청은 사용자가 여러 번 눌러도 같은 결제 건으로 관리할 것
- 프론트에서 넘어온 금액을 그대로 믿지 않을 것
- 카드 결제와 가상계좌 결제를 같은 흐름으로 억지로 처리하지 않을 것
- 웹훅이 중복 호출되어도 상태가 꼬이지 않을 것
- 환불과 정산은 결제와 분리된 별도 도메인으로 관리할 것
- 사용자가 나중에 자신의 결제 이력을 확인할 수 있어야 할 것
이 기준을 바탕으로 결제, 환불, 정산을 각각 엔티티로 분리했다.
StudyPayment: 결제 원본StudyRefund: 환불 요청과 처리 결과StudySettlement: 스터디 단위 정산PaymentHistory: 사용자와 관리자 액션 이력TossWebhookEvent: 웹훅 멱등 처리용 이벤트 로그
구조 파악
결제 흐름은 크게 다섯 단계로 나눴다.
- 사용자가 결제 페이지에 진입하면 서버에서 결제 준비 데이터를 만든다.
- 프론트는 그 정보를 이용해 토스 결제창을 띄운다.
- 결제 성공 후 프론트가
confirmAPI를 호출해 서버 승인 처리를 한다. - 이후 토스 웹훅이 들어오면 비동기 상태 변경을 반영한다.
- 결제가 완료된 건은 환불과 정산 도메인으로 이어진다.
즉, 결제 승인 한 번으로 끝나는 구조가 아니라, 결제 이후 상태를 계속 관리해야 하는 구조였다.
구현하면서 가장 먼저 고려한 것
1. 결제 건을 언제 생성할 것인가
처음에는 토스 결제가 성공한 뒤에만 DB에 결제 건을 저장하는 방식도 생각할 수 있었다.
하지만 이 방식은 문제가 있었다.
- 프론트가 어떤 결제 건을 승인하려는지 서버가 미리 알 수 없다.
- 결제 금액 검증을 서버 기준으로 하기가 어렵다.
- 결제창 진입 후 취소, 재시도, 중복 요청을 추적하기 어렵다.
그래서 결제 페이지 진입 시점에 먼저 StudyPayment를 생성하는 구조로 잡았다.
preparePayment()에서는 다음을 검증한다.
- 유료 스터디인지
- 이미 성공한 결제가 있는지
- 해당 스터디에 신청한 사용자인지
- 클라이언트가 보낸 금액과 서버 금액이 일치하는지
그 뒤 REQUESTED 상태의 결제 건을 만들고 paymentCode, tossOrderId를 발급한다.
이렇게 해두면 프론트가 결제창을 여러 번 열어도 서버는 같은 결제 건을 기준으로 승인 처리를 이어갈 수 있다.
문제 분석
1. 결제 금액을 클라이언트 기준으로 처리하면 안 되는 문제
결제 연동에서 가장 먼저 막아야 하는 것은 금액 변조다.
프론트에서 10만원짜리 스터디를 1원으로 내려 보내고 승인 요청을 보낼 수도 있기 때문이다.
그래서 결제 준비 단계와 승인 단계에서 모두 금액을 다시 검증했다.
결제 준비 단계에서는 스터디 가격을 기준으로 검증했다.
1
2
3
4
5
6
7
8
private Long validateAndGetAmount(GroupStudy groupStudy, Long clientAmount) {
Long serverAmount = groupStudy.getPrice();
if (clientAmount != null && !clientAmount.equals(serverAmount)) {
throw new PaymentException(PaymentErrorCode.PAYMENT_AMOUNT_MISMATCH);
}
return serverAmount;
}
승인 단계에서는 한 번 더 검증한다.
- 요청의
paymentId가 서버에 저장된 결제 건인지 orderId가 서버가 발급한 값과 같은지- 요청 금액이 DB 금액과 같은지
- 토스 응답의
totalAmount가 다시 같은지
즉, 클라이언트 요청 한 번만 믿는 것이 아니라, 서버 저장값과 PG 응답값을 모두 맞춰 본 뒤 성공 처리하도록 했다.
2. 가상계좌 처리
카드 결제처럼 즉시 승인되는 결제는 confirm 응답만으로도 성공 처리할 수 있다.
하지만 가상계좌는 다르다.
토스 결제창에서 성공 콜백이 왔다고 바로 결제가 끝난 것이 아니라, 입금 대기 상태가 될 수 있다.
이걸 카드 결제와 같은 방식으로 처리하면 아직 입금되지 않은 결제를 성공으로 저장하게 된다.
그래서 토스 응답 상태를 그대로 분기해서 처리했다.
DONE이면 즉시SUCCESSWAITING_FOR_DEPOSIT이면WAITING_FOR_DEPOSIT- 그 외는 실패 처리
가상계좌인 경우에는 계좌번호, 은행코드, 예금주, 입금기한을 함께 저장했다.
이렇게 해두면 사용자는 서버가 내려 준 가상계좌 정보를 기준으로 입금할 수 있고, 나중에 웹훅으로 실제 입금 완료가 들어왔을 때만 SUCCESS로 전이할 수 있다.
3. 결제 승인 API와 웹훅이 동시에 상태를 바꾸는 문제
결제는 프론트의 승인 API와 토스 웹훅이 모두 같은 건을 건드릴 수 있다.
이 경우 가장 조심해야 하는 부분은 중복 처리다.
예를 들어 다음과 같은 상황이 가능하다.
- 사용자가 결제 성공 후 프론트가
confirmAPI를 호출한다. - 거의 동시에 토스 웹훅이 도착한다.
- 둘 다 같은 결제 건을 성공 처리하려고 한다.
이 문제를 막기 위해 웹훅 처리 쪽에서는 두 가지를 넣었다.
첫째는 상태 전이 규칙이다.
1
2
3
4
5
6
private static final Map<PaymentStatus, Set<PaymentStatus>> VALID_TRANSITIONS = Map.of(
PaymentStatus.PENDING, Set.of(PaymentStatus.SUCCESS, PaymentStatus.WAITING_FOR_DEPOSIT, PaymentStatus.CANCELED, PaymentStatus.FAILED),
PaymentStatus.WAITING_FOR_DEPOSIT, Set.of(PaymentStatus.SUCCESS, PaymentStatus.CANCELED, PaymentStatus.FAILED),
PaymentStatus.REQUESTED, Set.of(PaymentStatus.SUCCESS, PaymentStatus.WAITING_FOR_DEPOSIT, PaymentStatus.CANCELED, PaymentStatus.FAILED),
PaymentStatus.SUCCESS, Set.of(PaymentStatus.CANCELED)
);
이미 SUCCESS인 결제를 다시 SUCCESS로 바꾸거나, 종료 상태에서 이상한 상태 전이가 일어나지 않도록 막았다.
둘째는 웹훅 멱등 처리다.
토스는 재시도나 네트워크 상황에 따라 동일 웹훅이 여러 번 들어올 수 있다.
그래서 paymentKey + createdAt + transactionKey 조합으로 멱등 키를 만들고, toss_webhook_event 테이블에 unique constraint를 걸어 중복 이벤트를 막았다.
1
2
3
4
5
String idempotencyKey = TossWebhookEvent.generateIdempotencyKey(
data.getPaymentKey(),
payload.getCreatedAt(),
transactionKey
);
그리고 처리 방식도 조회 후 저장이 아니라 저장을 먼저했다.
- 먼저 이벤트를 저장 시도한다.
- unique constraint에 걸리면 이미 처리된 이벤트로 보고 바로 종료한다.
이 방식은 동시 요청 상황에서도 DB가 최종 방어선이 되기 때문에 안전하다.
4. 웹훅 오류처리 문제
일반 사용자 API는 OAuth 인증을 거치지만, 웹훅은 외부 시스템이 호출하는 엔드포인트라 같은 필터 체인을 쓰면 안 된다.
그래서 /api/v1/webhooks/**는 별도 시큐리티 체인으로 분리하고 인증 없이 열어 두었다.
대신 결제 엔티티에 저장한 tossSecret과 웹훅 payload의 secret을 비교해서 검증하도록 했다.
1
2
3
4
5
private boolean verifySecret(StudyPayment payment, String webhookSecret) {
String storedSecret = payment.getTossSecret();
...
return Objects.equals(storedSecret, webhookSecret);
}
또 하나 중요한 부분은 응답 정책이었다.
웹훅에서 항상 예외를 그대로 500으로 던지면, 이미 처리할 수 없는 비즈니스 오류에도 토스가 계속 재시도하게 된다.
그래서 다음처럼 나눴다.
- 비즈니스 오류는 200 OK 반환
- 인프라 오류만 500 반환
예를 들면 이런 경우는 200으로 응답한다.
- 해당 주문의 결제를 찾지 못한 경우
- secret이 일치하지 않는 경우
- 이미 처리된 상태 전이인 경우
- 잘못된 비즈니스 요청인 경우
반대로 DB 장애처럼 실제 재시도가 의미 있는 경우만 500으로 남겼다.
잘못된 요청에도 500을 반환하면 PG가 계속 재시도하면서 장애가 확대될 수 있다.
5. 취소와 환불 구분
구현하면서 가장 헷갈리기 쉬운 부분이 이 부분이었다.
사용자가 결제창을 닫거나 승인 전에 취소한 것과, 이미 결제가 끝난 뒤 환불하는 것은 전혀 다른 흐름이다.
그래서 cancelPayment()는 성공 결제에 대해서는 허용하지 않았다.
1
2
3
if (payment.getStatus() == PaymentStatus.SUCCESS) {
throw new PaymentException(PaymentErrorCode.CANNOT_CANCEL_SUCCESS_PAYMENT);
}
이미 성공한 결제는 결제 취소가 아니라 환불 도메인으로 보내야 한다.
6. 환불 관리
처음에는 결제 엔티티의 상태만 REFUNDED처럼 두고 처리할 수도 있어 보였다.
하지만 실제로는 다음 정보가 별도로 필요했다.
- 환불 요청 시점
- 사용자 요청 사유
- 관리자 승인 여부
- 승인된 환불 금액
- PG 환불 호출 실패 여부
- 환불 완료 시점
이 정보는 결제 엔티티 하나에 억지로 넣기보다 StudyRefund로 분리하는 편이 맞다고 판단했다.
그래서 환불은 다음 단계로 나눴다.
- 사용자가 환불 요청
- 관리자가 승인 또는 거절
- 관리자가 실제 PG 환불 수행
- 완료 또는 실패 처리
여기서도 중요한 조건이 있었다.
- 이미 환불 진행 중인 건은 다시 요청할 수 없을 것
- 정산이 시작된 스터디는 환불을 막을 것
- 아무 경우나 환불이 가능하면 안 될 것
현재 구현에서는 사용자가 스터디를 나가거나 강퇴된 경우에만 환불 요청이 가능하도록 했다.
7. 환불 금액 계산 분리
환불 금액을 서비스 코드 안에서 if 문으로 바로 계산하기 시작하면 나중에 규칙이 바뀔 때 유지보수가 어려워진다.
그래서 RefundPolicyCalculator로 분리했다.
정책은 다음 기준으로 잡았다.
- 결제 후 7일이 지나면 환불 불가
- 스터디 시작 전이면 전액 환불
- 진행 중이면 사용 비율에 따라 차감
- 사용 비율이 50% 이상이면 환불 불가
이 로직을 별도 클래스로 분리해 두면 정책 변경이 생겨도 결제, 환불 서비스 코드를 크게 건드리지 않아도 된다.
8. 환불 완료와 결제 상태 관리 문제
환불도 두 종류가 있었다.
- 관리자가 결제를 강제 취소하는 전액 환불
- 일반 환불 요청을 승인해서 처리하는 환불
전액 강제 취소는 결제 자체를 무효화하는 성격이 강해서 payment.markCanceled()를 함께 호출했다.
반면 일반 환불은 부분 환불 가능성을 열어 두고 있었기 때문에, 결제 상태를 곧바로 CANCELED로 바꾸지 않았다.
이 차이를 두지 않으면 부분 환불 한 건이 생겼다는 이유만으로 원 결제 전체를 취소 상태로 바꾸는 문제가 생긴다.
그래서 일반 환불 완료 시에는 StudyRefund만 COMPLETED로 관리하고, 결제 원본은 그대로 두었다.
9. 정산과 결제 문제
정산은 결제 합계만 더해서 만들 수 있는 작업이 아니다.
다음 조건을 모두 만족해야 했다.
- 스터디 상태가
COMPLETED일 것 - 성공 결제가 하나 이상 있을 것
- 진행 중인 환불이 없을 것
- 이미 생성되거나 완료된 정산이 없을 것
- 환불을 반영한 남은 금액이 0보다 클 것
정산 금액도 단순 합계가 아니라, 성공 결제 총액에서 완료된 환불 금액을 빼고 플랫폼 수수료와 세율을 반영해 계산했다.
1
2
3
BigDecimal remaining = sales.subtract(refund);
BigDecimal afterFee = remaining.multiply(BigDecimal.ONE.subtract(feeRate));
BigDecimal settlement = afterFee.multiply(BigDecimal.ONE.subtract(taxRate));
이 과정을 분리해 둔 이유는 정산이 결제의 부속 기능이 아니기 때문이다.
해결 방향
1. 결제는 상태 중심으로 관리.
결제 성공 여부만 저장하는 것이 아니라, 실제 운영에서 필요한 상태를 먼저 정의했다.
REQUESTEDPENDINGWAITING_FOR_DEPOSITSUCCESSFAILEDCANCELED
이 상태를 기준으로 각 API와 웹훅이 어떤 전이를 허용할지 제한했다.
그 결과 승인 API, 웹훅, 환불, 정산이 서로 다른 시점에 결제 건을 건드려도 어느 정도 일관성을 유지할 수 있었다.
2. 결제, 환불, 정산을 분리.
한 엔티티에 모든 상태를 몰아넣지 않고 결제 원본, 환불, 정산을 따로 관리했다.
이렇게 나누니 각 도메인 책임이 분명해졌다.
- 결제는 승인과 결제 수단 상태 관리
- 환불은 요청과 승인, PG 취소 처리
- 정산은 운영 단위 집계와 정산 금액 계산
처음에는 엔티티가 많아지는 것처럼 보였지만, 실제로는 상태가 훨씬 읽기 쉬워졌다.
3. 외부 연동 실패를 도메인 오류와 분리.
토스 confirm, refund API 호출은 전용 클라이언트로 분리하고, 응답 실패를 PaymentException과 PaymentErrorCode로 매핑했다.
이렇게 해두니 컨트롤러에서는 어떤 오류를 사용자에게 내려 줄지, 웹훅에서는 어떤 오류를 200으로 삼킬지 판단하기 쉬웠다.
4. 사용자 액션과 관리자 액션을 이력으로 남길 수 있도록.
결제 기능은 나중에 꼭 이력이 필요하다.
사용자가 보는 거래 내역도 필요하고, 운영 중 문제가 생겼을 때 누가 어떤 상태를 바꿨는지도 남아 있어야 한다.
그래서 결제, 입금대기, 성공, 실패, 환불 요청, 환불 승인, 환불 완료, 관리자 취소 등을 PaymentHistory로 남기도록 했다.
이 구조 덕분에 결제 도메인이 단순히 현재 상태만 가진 것이 아니라, 상태가 바뀐 과정을 추적할 수 있게 했다.
정리
결제 기능은 연동 자체보다 상태 설계가 더 중요했다.
토스 API를 호출하는 코드를 짜는 시간보다, 어떤 시점에 어떤 상태를 저장하고 어떤 요청을 막을지 정리하는 데 시간이 더 많이 들었다.
이번 구현에서 가장 크게 느낀 점은 결제를 단일 API로 보면 안 된다는 것이었다.
결제 준비, 승인, 웹훅, 환불, 정산은 서로 이어져 있지만 각각 다른 규칙과 실패 조건을 가지기 때문에 정리를 해놓고 구현을 해야했다.
토스 결제 연동 자체보다, 결제 도메인을 프로젝트 안에서 어떻게 상태를 관리할지 정리한 과정이 유의미했던 것 같다.