콘텐츠로 이동

결제 (Toss V2)

PortOne V1 통합을 Toss Payments V2로 마이그레이션한 결제 도메인 (Issue #109). 1회성 결제는 Widget(SDK) → 사후 confirm 패턴으로, 정기 결제는 빌링키(billing key) 기반으로 처리한다. 가상계좌 입금 확인은 Toss webhook + Toss API 재조회로 멱등 처리한다.

사용자 여정

1회성 결제 (카드 / 가상계좌, Widget SDK)

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (AccountController)
    participant SVC as AccountService
    participant DB as MySQL
    participant TOSS as Toss Payments

    U->>FE: 결제 버튼 클릭 (STANDARD/PREMIUM/TOKEN_PACK)
    FE->>API: POST /accounts/payments
{ productType, paymentType } API->>SVC: registerPayment(...) SVC->>DB: Payment(status=PENDING, tossOrderId="ORDER_${paymentId}") SVC-->>FE: { paymentId, orderId, amount, clientKey, ... } FE->>TOSS: Toss Widget 띄우고 결제 진행 TOSS-->>FE: success redirect (paymentKey, orderId, amount) FE->>API: POST /accounts/payments/confirm
{ orderId, paymentKey, amount } API->>SVC: confirmPayment(...) Note over SVC: 1) accountId 검증
2) status==PENDING 검증 (멱등)
3) amount 검증 (redirect tampering 방지) SVC->>TOSS: POST /v1/payments/confirm TOSS-->>SVC: { paymentKey, method, approvedAt } SVC->>DB: payment.status=DONE SVC->>DB: account.initMembershipByPayment(payment)
또는 TokenPack.ofPayment(payment) API-->>FE: 200 OK

자동결제 빌링키 발급 + 첫 결제

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (AccountController)
    participant SVC as BillingService
    participant DB as MySQL
    participant TOSS as Toss Billing API

    U->>FE: 자동결제 등록 흐름 → Toss SDK
    TOSS-->>FE: authKey, customerKey
    FE->>API: POST /accounts/billing/issue
{ authKey, customerKey } API->>SVC: issueBillingKey(...) SVC->>TOSS: POST /v1/billing/authorizations/issue
{ authKey, customerKey } TOSS-->>SVC: { billingKey, card.number, card.issuerCode } SVC->>DB: 기존 PaymentMethod deactivate("새 빌링키 발급으로 교체") SVC->>DB: 새 PaymentMethod.activateWithBilling(billingKey, ...) Note over SVC: 첫 결제 즉시 charge (chargeBillingInternal) SVC->>DB: Payment(status=PENDING, tossOrderId="BILLING_${UUID}") SVC->>TOSS: POST /v1/billing/{billingKey}
{ customerKey, amount, orderId, orderName } TOSS-->>SVC: { paymentKey, method, approvedAt } SVC->>DB: payment.confirmTossPayment(...) → status=DONE SVC->>DB: account.initMembershipByPayment(payment) → 새 Membership API-->>FE: { cardName, cardNumber, membershipType, startDate, endDate }

가상계좌 입금 확인 (Webhook)

sequenceDiagram
    autonumber
    participant TOSS as Toss Payments
    participant API as Backend (TossWebhookController)
    participant SVC as AccountService
    participant DB as MySQL

    Note over TOSS: 사용자가 가상계좌에 입금 완료
    TOSS->>API: POST /webhooks/tosspayments
{ eventType: "DEPOSIT_CALLBACK", data.paymentKey } API->>SVC: confirmVirtualAccountDeposit(paymentKey) Note over SVC: SECURITY: Toss V2는 webhook signature 미제공
→ 항상 Toss API 재조회 (authoritative source) SVC->>TOSS: GET /v1/payments/{paymentKey} TOSS-->>SVC: { orderId, status, method, approvedAt } SVC->>DB: paymentRepository.findByTossOrderId(orderId) alt 이미 DONE SVC-->>API: 멱등 — skip else WAITING_DEPOSIT && Toss DONE SVC->>DB: payment.confirmTossPayment(...) → DONE SVC->>DB: Membership 또는 TokenPack 생성 end API-->>TOSS: 200 "OK"

백엔드 구현

계층 클래스 / 파일 역할
Controller AccountController (apps/backend/.../controller/AccountController.kt) /accounts/payments, /accounts/billing/** HTTP endpoint
Controller TossWebhookController (apps/backend/.../controller/TossWebhookController.kt) /webhooks/tosspayments — 가상계좌 입금 callback
Service AccountService (apps/backend/.../application/service/account/AccountService.kt) 1회성 결제 register / confirm / cancel / virtual-account 처리
Service BillingService (apps/backend/.../application/service/account/BillingService.kt) 빌링키 발급, 정기 charge, 구독 해지
Domain (Entity) Payment (apps/backend/.../application/domain/product/Payment.kt) 결제 상태 + tossOrderId/paymentKey/method/approvedAt 보유
Domain (Entity) PaymentMethod (apps/backend/.../application/domain/product/PaymentMethod.kt) 카드 정보 + billingKey + customerKey + status (ACTIVATED/DEACTIVATED)
Domain (Enum) PaymentStatus PENDING / DONE / WAITING_DEPOSIT / FAILED + 레거시 PortOne 상태
Domain (Enum) PaymentType CARD / BANK / VIRTUAL_ACCOUNT / TRANSFER / EASY_PAY
Outbound TossPaymentsFeignClient (apps/backend/.../common/feign/tosspayments/TossPaymentsFeignClient.kt) 1회성 결제 confirm / get / cancel
Outbound TossBillingFeignClient (apps/backend/.../common/feign/tosspayments/TossBillingFeignClient.kt) 빌링키 issue / charge / delete
Outbound TossPaymentsErrorDecoder Toss 에러 응답을 도메인 예외로 변환
Test 지원 BillingTestMocks (apps/backend/.../application/service/account/BillingTestMocks.kt) Toss API 호출 없이 빌링/charge 응답 mock

도메인 규칙

규칙 위치 값 / 설명
결제 ID 형식 Payment (paymentId) UUID
Toss orderId 형식 (1회성) AccountService.registerPayment() ORDER_${paymentId}
Toss orderId 형식 (정기) BillingService.chargeBillingInternal() BILLING_${UUID}
멱등 처리 (confirm) AccountService.confirmPayment() status != PENDING이면 기존 응답 반환 (double-submit 차단)
금액 검증 AccountService.confirmPayment() payment.price != amountInvalidPaymentException (redirect tampering 방지)
권한 검증 AccountService.confirmPayment() payment.account.id != accountId면 거부
Webhook 신뢰 정책 AccountService.confirmVirtualAccountDeposit() Toss API에서 paymentKey 재조회 — payload 단독 신뢰 금지 (Toss V2 signature 부재)
Webhook 멱등 AccountService.confirmVirtualAccountDeposit() 이미 DONE이면 skip
빌링키 교체 시 기존 처리 BillingService.applyBillingAuthorization() 기존 ACTIVATED PaymentMethod를 deactivate("새 빌링키 발급으로 교체")
정기결제 실패 시 Payment.failureScheduledPayment() PAYMENT_FAIL + paymentMethod.deactivate() + 멤버십 isSubscriptionPaused=true
카드사 코드 매핑 BillingService.resolveCardName() Toss issuerCode (3K/46/71/...) → 한글 카드사명
결제 취소 (사용자 환불) AccountService.cancelTossPayment() Toss /v1/payments/{paymentKey}/cancel 호출 + status=WITHDRAWAL
구독 해지 BillingService.cancelSubscription() Toss /v1/billing/{billingKey} DELETE + PaymentMethod deactivate + 현재 멤버십 withdrawal()

API 엔드포인트

Method Path 설명
POST /accounts/payments 1회성 결제 등록 — orderId 생성 후 클라이언트가 Toss Widget으로 결제
POST /accounts/payments/confirm 결제 확정 — Toss confirm API 호출 + Membership/TokenPack 발급
POST /accounts/payments/cancel 결제 취소 (환불) — Toss cancel API 호출
POST /accounts/billing/issue 빌링키 발급 + 첫 결제 즉시 진행
POST /accounts/billing/cancel 자동결제 해지 + 빌링키 삭제
GET /accounts/billing/status 자동결제 활성 여부 + 카드 정보 + 다음 결제일
POST /webhooks/tosspayments Toss webhook (가상계좌 입금, 결제 상태 변경) — 인증 없음, Toss API 재조회로 검증

스펙 상세(request/response schema)는 API 레퍼런스 / OpenAPI 페이지에서 endpoint별 검색.

데이터 모델

erDiagram
    PAYMENT {
        string payment_id PK "UUID"
        string account_id FK
        string product_type "STANDARD / PREMIUM / TOKEN_PACK"
        string payment_type "CARD / BANK / VIRTUAL_ACCOUNT ..."
        int price "결제 금액 (원)"
        int amount_to_be_paid
        string status "PENDING / DONE / WAITING_DEPOSIT / FAILED ..."
        string toss_order_id "ORDER_xxx 또는 BILLING_xxx"
        string payment_key "Toss 발급, confirm 후 채워짐"
        string method "card / virtualAccount / transfer ..."
        datetime approved_at "Toss approval timestamp"
        string payment_method_id FK "nullable — 정기결제 시 매핑"
        datetime scheduled_time "정기결제 예약 시각"
        string recommender "추천인 코드, nullable"
    }
    PAYMENT_METHOD {
        string payment_method_id PK "UUID"
        string account_id FK
        string status "ON_STANDBY / ACTIVATED / DEACTIVATED / DELETED"
        string billing_key "Toss billingKey"
        string customer_key "발급 시 클라이언트가 생성한 식별자"
        string card_name "한글 카드사명"
        string card_number "마스킹된 카드번호"
        int duration "정기결제 누적 횟수"
        string deactivated_reason
    }
    MEMBERSHIP {
        int membership_id PK
        string payment_id FK "nullable — 결제 발급 시"
    }
    TOKEN_PACK {
        int token_pack_id PK
        string payment_id FK "결제와 1:1"
        int tokens_granted
        datetime expires_at "purchasedAt + 90일"
    }
    ACCOUNT ||--o{ PAYMENT : "결제 이력"
    ACCOUNT ||--o{ PAYMENT_METHOD : "활성 1건 + 과거 deactivated"
    PAYMENT_METHOD ||--o{ PAYMENT : "정기결제 시 (1:N)"
    PAYMENT ||--o| MEMBERSHIP : "구독 결제 시 (1:1)"
    PAYMENT ||--o| TOKEN_PACK : "TOKEN_PACK 결제 시 (1:1)"

설정

항목 위치 비고
Toss base-url application.yml:125-126 https://api.tosspayments.com (전 환경 동일)
Toss 1회성 client/secret application-prod.yml:62-63 ${TOSS_CLIENT_KEY} / ${TOSS_SECRET_KEY} 환경변수
Toss 정기결제 client/secret application-prod.yml:64-66 ${TOSS_BILLING_CLIENT_KEY} / ${TOSS_BILLING_SECRET_KEY}
Local/dev 키 기본값 application-local.yml, application.yml 빈 문자열 — 로컬에서 Toss 호출 시 401 (Test mock 경로 권장)
Webhook URL (prod) Toss 콘솔에서 등록 https://api-new.semugpt.co.kr/webhooks/tosspayments (Issue #151 cutover 후) — 현재 미등록 상태
Webhook 인증 TossWebhookController.handleWebhook() 없음 — Toss V2가 signature 미제공. 방어책: paymentKey로 Toss API 재조회
결제 금액 결정 MembershipPlanProperties.getPrice(productType) YML membership.plans.*.price + membership.token-pack.price

알려진 이슈 / 개선 예정

  • Toss 웹훅 prod URL 미등록 (Issue #151 미해결 항목): cutover 시점에 Toss 콘솔에서 https://api-new.semugpt.co.kr/webhooks/tosspayments 등록 필요
  • Webhook signature 부재: Toss V2가 서명을 제공하지 않음. 외부 위조 요청은 paymentKey로 Toss API를 재조회해서 1차 차단하지만, 존재하지 않는 paymentKey는 무시하므로 DoS 가능성 있음 (TossWebhookController 주석 참조)
  • PortOne V1 잔재: PaymentStatusPRE_VERIFICATION, POST_VERIFICATION, ADDITIONAL_VERIFICATION 등 PortOne 시절 상태값이 남아 있음. verifyCardPaymentPre/Post/Additional 메서드도 미사용 가능성. 정리 필요
  • Toss live 키 미반영 (Issue #151 미해결): 현재 prod env-var는 user 제공 대기 상태. 빌링키 발급/charge가 실키로 동작하려면 prod 시크릿 주입 필요
  • registerPayment recommender 검증 없음: 추천인 코드가 free-form string으로 들어감 — 실재하는 계정인지·중복 사용인지 미검증
  • 결제 WAITING_DEPOSIT 정리 cron 부재: 가상계좌 입금 안 한 채 만료된 PENDING/WAITING_DEPOSIT payment가 누적될 수 있음 — 별도 정리 job 없음
  • 단일 활성 결제수단 가정: paymentMethodRepository.findActivatedAndDeactivatedByAccountId(accountId)single-row 반환 가정. 멀티 카드 등록 시 비정상 동작 가능 — switchTierForTest가 매번 PaymentMethod를 deleteAll하는 이유
  • 취소 시 잔여 일수 비례 환불 미구현: cancelTossPayment는 Toss에 전액 취소 요청. 멤버십 잔여 일수에 따른 부분 환불 로직 없음

관련 문서