Skip to content

Payment

silberbullet edited this page Aug 24, 2026 · 3 revisions

💰 Payment

결제(payment) · 지갑(wallet) · 정산(settlement) 도메인 구조, 프로세스, 제공 API, 그리고 테스트 현황


목차

  1. 개요
  2. 모듈 구조
  3. 결제 프로세스
  4. 제공 API
  5. 부하테스트
  6. 단위테스트
  7. 정리

개요

payment-service는 헥사고날 아키텍처 + 멀티모듈로 구성되며, payment(결제) / wallet(지갑·포인트) / settlement(정산) 세 서브모듈로 나뉜다. 후원(donation) 서비스는 별도 모듈이지만 지갑을 gRPC로 호출하므로 이 문서에 함께 포함한다.

아키텍처 Hexagonal (api / application / driving / driven), 모듈당 독립 Gradle 프로젝트
서비스 간 통신 gRPC (동기, in-process — 모놀리스로 배포되어 같은 JVM·같은 DB 커넥션 풀을 공유)
DB PostgreSQL, 스키마 payment (wallets, payments, point_histories, settlements, settlement_settings)
잔액 변경 방식 락이 아닌 원자적 조건부 UPDATE (WHERE balance >= :amount), 필요시에만 쓰라고 주석 달린 비관적 락 메서드가 별도로 존재
멱등성 point_histories(wallet_id, type, source_id) 유니크 제약 + "이미 있으면 스킵" 체크

모듈 구조

payment — 결제 준비/확정

payment/
├── api/domain/          Payment (id, orderId, userId, amount, status, method, pgProvider, externalTid)
│                         type/ PaymentMethod · PaymentStatus(READY→APPROVING→APPROVED|FAILED|CANCELED) · PgProvider
├── application/
│   ├── port/             PaymentClientPort(PG 승인 호출) · PaymentCommandRepositoryPort(findByIdForUpdate 등)
│   │                     PaymentQueryRepositoryPort · WalletCommandPort(지갑 gRPC)
│   ├── usecase/          PaymentPrepareUseCase · PaymentConfirmUseCase · PaymentRecoveryUseCase
│   └── service/          PaymentCommandService(prepare/confirm) · PaymentRecoveryService(멈춘 결제 복구)
├── driving/
│   ├── web-mvc/          PaymentCommandApi
│   └── batch/            PaymentTaskletConfiguration → Job "paymentRecoveryJob"
└── driven/
    ├── rdb/              PaymentCommandAdapter · PaymentQueryAdapter · PaymentEntity(Snowflake PK)
    ├── client/            PaymentTossClientAdapter — 외부 PG(Toss) REST 호출
    └── grpc-client/       PaymentWalletGrpcClientAdapter — 지갑 서비스 호출

wallet — 잔액·포인트 이력

wallet/
├── api/domain/           Wallet(1 user : 1 wallet) · PointHistory(append-only 원장, type: CHARGE|DONATION|REFUND)
├── application/
│   ├── port/              WalletCommandRepositoryPort(increaseBalance/decreaseBalance) · PointHistoryCommandRepositoryPort
│   │                      SettlementCommandPort — 지갑 생성 시 정산 설정도 같이 만들어달라고 요청
│   ├── usecase/           WalletCreateUseCase · PointChargeUseCase · PointDonateUseCase · PointRefundUseCase · PointHistoryReadUseCase
│   └── service/           WalletCommandService(create/charge/refund/donate) · WalletQueryService · PointHistoryQueryService
├── driving/
│   ├── web-mvc/           WalletCommandApi · WalletQueryApi · PointHistoryQueryApi
│   └── grpc-server/       WalletGrpcServerAdapter — createWallet/donatePoint/getWallet/chargePoints/refundPoints
└── driven/
    ├── rdb/               WalletJpaRepository(조건부 UPDATE) · PointHistoryJpaRepository
    └── grpc-client/        WalletSettlementGrpcClientAdapter — 정산 서비스 호출

settlement — 스트리머 정산

settlement/
├── api/domain/           Settlement(정산 스냅샷) · SettlementItem(후원 건별 스냅샷) · SettlementSetting(주기 설정)
│                         policy/SettlementFeePolicy — 수수료율 정책 (Strategy)
├── application/
│   ├── port/              SettlementDonationQueryPort — 후원 서비스에서 미정산 후원 조회 (⚠ 현재 스텁, 아래 참고)
│   ├── usecase/           SettlementCalculateUseCase · SettlementConfirmUseCase · SettlementCancelUseCase
│   ├── service/           SettlementCommandService · SettlementSettingCommandService
│   └── strategy/          SettlementDefaultFeeStrategy — DAILY 15% / WEEKLY 10% / MONTHLY 5%
├── driving/
│   ├── web-mvc/           SettlementQueryApi · SettlementSettingCommandApi · SettlementSettingQueryApi
│   ├── batch/             SettlementChunkConfiguration → Job "settlementCalculateJob" (⚠ 아래 참고)
│   └── grpc-server/        SettlementGrpcServerAdapter — createSettlementSetting
└── driven/
    ├── rdb/               SettlementCommandAdapter · SettlementSettingCommandAdapter
    └── grpc-client/        SettlementDonationGrpcClientAdapter (⚠ 하드코딩 스텁)

donation — 후원 (지갑을 gRPC로 호출)

donation/
├── api/domain/           Donations(streamId, senderId, receiverId, amount, status)
├── application/service/  DonationCommandService — ① 포인트 차감 → ② 후원 저장 → ③ 실시간 알림 (보상 트랜잭션 TODO 명시)
├── driving/web-mvc/       DonationApi — POST /donations (senderId는 JWT에서만 추출)
└── driven/grpc-client/    DonationGrpcClientAdapter — wallet의 donatePoint 호출 (⚠ 아래 참고)

결제 프로세스

결제 확정(prepare → confirm) 흐름

sequenceDiagram
    participant Client
    participant Api as PaymentCommandApi
    participant Svc as PaymentCommandService
    participant DB as payment.payments
    participant Toss as Toss PG
    participant Wallet as Wallet 서비스 (gRPC, 같은 프로세스)

    Client->>Api: POST /payment/payments/prepare
    Api->>Svc: prepare(userId, amount, method, pgProvider)
    Svc->>DB: INSERT status=READY, orderId 채번
    Svc-->>Client: paymentId, orderId, amount

    Client->>Api: POST /payment/payments/confirm (paymentKey 포함)
    Api->>Svc: confirm(paymentId, paymentKey, orderId, amount)
    rect rgba(120,120,120,0.12)
    Note over Svc,DB: Tx1 — 비관적 락으로 조회 후 검증 → APPROVING
    Svc->>DB: findByIdForUpdate, 상태/금액/주문번호 검증
    end
    Svc->>Toss: POST /v1/payments/confirm (트랜잭션 밖에서 호출)
    Toss-->>Svc: 200 APPROVED
    rect rgba(120,120,120,0.12)
    Note over Svc,Wallet: Tx2 — ⚠ 이 안에서 gRPC 블로킹 호출 (부하테스트 섹션 참고)
    Svc->>DB: status=APPROVED
    Svc->>Wallet: findWalletByUserId → increaseWalletBalance
    Wallet->>DB: 잔액 UPDATE + PointHistory(CHARGE) 기록
    end
    Svc-->>Client: paymentId, orderId, status=APPROVED
Loading

prepare는 순수 DB 쓰기(외부 호출 없음)라 실패 지점이 거의 없다. confirm은 트랜잭션을 두 번 나눠서 PG 호출 중에는 DB 락을 들고 있지 않도록 설계되어 있다 — 다만 지갑 gRPC 호출은 이 원칙에서 빠져 있고, 이게 부하테스트에서 찾은 문제의 핵심이다.

지갑 충전 · 환불 · 후원 흐름

셋 다 "이미 처리한 sourceId면 스킵" 패턴으로 멱등성을 노리지만, 실제로 그렇게 동작하는 건 둘뿐이다.

흐름 트리거 잔액 연산 sourceId 검증 실제 멱등 동작
충전(charge) 결제 confirm 승인 시 (gRPC chargePoints, sourceId=paymentId) balance + amount O ✅ 정상
환불(refund) POST /payment/wallets/{id}/refund balance - amount (조건부) O ✅ 정상
후원(donate) donation 서비스가 gRPC donatePoint 호출 balance - amount (조건부) 체크는 O, 저장은 항상 null 깨져 있음

Warning

WalletCommandService.donate()는 중복 체크에는 실제 sourceId를 쓰지만, savePointHistory(..., sourceId) 호출에서 sourceId 자리에 null을 하드코딩해서 넘긴다. 그래서 "이미 있는지" 조회가 항상 빈 결과만 보게 되어 중복 방지가 실질적으로 동작하지 않는다 (같은 후원 요청이 재시도되면 이중 차감 가능). 여기에 더해 호출 쪽(donation 서비스)도 sourceId를 아예 채우지 않고 보내고, 서버도 donatePoint만 다른 RPC와 달리 userId → walletId 변환 단계를 건너뛰어 실제로는 이 경로 자체가 정상 동작하지 않는 상태다.

지갑·정산·후원 도메인 전체에 테스트가 하나도 없다 보니(단위테스트 참고) 이런 차이가 컴파일도, 테스트도 통과한 채로 남아있었던 것으로 보인다.

정산 흐름

스트리머별 SettlementSetting(정산 주기: DAILY/WEEKLY/MONTHLY)을 기준으로, 기간 내 후원을 모아 수수료를 뗀 Settlement 스냅샷을 만드는 배치 흐름으로 설계되어 있다.

Warning

후원 서비스에서 미정산 후원을 조회하는 SettlementDonationQueryPort는 아직 List.of()만 반환하는 스텁이고, 이 계산을 실행하는 배치 Job(settlementCalculateJob)도 어떤 Gradle 모듈에서도 실제로 의존하지 않아 현재 어떤 실행 앱에도 포함되어 있지 않다. 정산 조회 API(GET /payment/settlements/...)는 살아있지만, 그 데이터를 실제로 채워 넣는 계산 파이프라인은 지금 아무것도 실행되지 않는다.


제공 API

REST

Method + Path 설명
POST /payment/payments/prepare 결제 준비 (READY 생성, orderId 채번)
POST /payment/payments/confirm 결제 확정 (PG 승인 + 지갑 충전)
POST /payment/wallets 지갑 생성 (balance=0, 정산 설정도 같이 시도)
POST /payment/wallets/{walletId}/refund 포인트 환불 (잔액 차감)
GET /payment/wallets/{userId} 지갑 단건 조회
GET /payment/point-histories/wallets/{walletId} 지갑별 포인트 이력 (페이지)
GET /payment/point-histories/{id} 포인트 이력 단건
GET /payment/settlements/{id} 정산 단건 조회
GET /payment/settlements/streamers/{streamerId} 스트리머별 정산 목록 (페이지)
POST / DELETE /payment/settlement-settings/streamers/{streamerId}/cycle-change 정산 주기 변경 예약 / 취소
GET /payment/settlement-settings/streamers/{streamerId} 정산 설정 조회
GET /payment/settlement-settings/due 정산 실행 대상 조회
POST /donations 후원 생성 (senderId는 JWT에서만 추출)

결제 단건/이력을 조회하는 REST 엔드포인트는 없다 — PaymentQueryRepositoryPort/PaymentQueryAdapter는 구현돼 있지만 이를 호출하는 컨트롤러가 없다.

gRPC — WalletCommandService

RPC Request Response
CreateWallet userId walletId, userId, balance
GetWallet userId walletId, userId, balance
ChargePoints userId, amount, sourceId userId, balance
RefundPoints userId, amount, sourceId userId, balance
DonatePoint userId, amount, sourceId userId, balance

SettlementCommandService.CreateSettlementSetting(streamerId)도 별도로 존재하며, 지갑이 생성될 때마다 내부적으로 호출된다.


부하테스트

POST /payment/payments/confirm은 외부 PG(Toss)를 실제로 호출하기 때문에 그동안 부하테스트 대상에서 제외되어 있었다. Mock PG를 만들어 confirm까지 포함한 전체 흐름을 부하테스트했고, 풀 크기로는 해결되지 않는 커넥션 풀 순환대기 데드락을 찾았다 — 조사 과정과 Grafana/Prometheus 증적은 별도 페이지에 정리했다.

➡️ 자세한 내용: Payment Load Test


단위테스트

결제 도메인 전체(payment/wallet/settlement/donation)를 통틀어 테스트 파일은 단 2개뿐이고, 그마저 하나는 현재 코드와 맞지 않는다.

파일 대상 방식 상태
TossPaymentAdapterTest.kt (클래스명 PaymentTossClientAdapterTest) PaymentTossClientAdapter WireMock으로 Toss 응답을 스텁, 실제 RestClient로 호출 ✅ 현재 코드와 일치, 정상
PaymentCommandServiceTest.kt PaymentCommandService MockK로 협력 객체 모킹 컴파일 안 됨

Warning

PaymentCommandServiceTest.ktWalletReadUseCase/PointChargeUseCase를 직접 모킹하는 4-argument 생성자를 기준으로 작성돼 있는데, 실제 PaymentCommandService는 gRPC 기반 WalletCommandPort 하나만 받는 3-argument 생성자로 리팩터링된 상태다. payment-application 모듈은 애초에 wallet-application에 대한 Gradle 의존성도 없어서, 이 테스트는 작성된 시점부터 지금까지 테스트 컴파일 단계에서 이미 실패하는 상태였던 것으로 보인다. CI(deploy-railway.yml)도 ./gradlew test를 실행하지 않아 이 상태가 그대로 지나갔다.

커버리지 갭

  • wallet / settlement / donation 세 모듈은 테스트가 0개 — 이번에 발견한 donate 멱등성 버그, 정산 파이프라인 미배선 등은 전부 이 사각지대에 있던 것들이다.
  • payment 쪽도 REST 컨트롤러(PaymentCommandApi), RDB 어댑터, gRPC 클라이언트 어댑터는 테스트가 없다.
  • 테스트 인프라(Kotest + MockK) 자체는 루트 build.gradle.kts에서 전 모듈에 이미 깔려 있다 — "도구가 없어서"가 아니라 "아직 안 쓴" 상태에 가깝다.

정리

구분 항목
✅ 검증됨 지갑 잔액 원자적 UPDATE (충전/환불), Toss 어댑터 계약, confirm 부하 143/144 성공(하네스 버그 수정 후)
⚠️ 실제 결함 confirm Tx2의 gRPC 블로킹 호출로 인한 커넥션 풀 순환대기 데드락 (풀 크기로 해결 불가)
⚠️ 실제 결함 후원(donate) 포인트 차감 멱등성 미동작 — 체크와 저장이 다른 sourceId를 봄
🚧 미배선 정산 계산 배치(settlementCalculateJob)가 어떤 실행 앱에도 포함되어 있지 않음
🚧 스텁 SettlementDonationQueryPort — 후원 서비스에 실제로 미정산 내역을 물어보지 않고 항상 빈 목록 반환
❌ 테스트 없음 wallet / settlement / donation 전체, payment의 컨트롤러·RDB·gRPC 클라이언트 계층
❌ 컴파일 안 됨 PaymentCommandServiceTest.kt — 현재 PaymentCommandService와 생성자 불일치

🚀 RAIO Backend

Home


📖 Getting Started

🏛️ Architecture

🧱 Core Modules

💳 Domains

📚 Guides

Clone this wiki locally