-
Notifications
You must be signed in to change notification settings - Fork 0
Payment
결제(payment) · 지갑(wallet) · 정산(settlement) 도메인 구조와 API, 그리고 Mock PG를 이용한 결제 확정 부하테스트 기록
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/
├── 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/
├── 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/
├── 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/
├── 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 호출 (⚠ 아래 참고)
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
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/...)는 살아있지만, 그 데이터를 실제로 채워 넣는 계산 파이프라인은 지금 아무것도 실행되지 않는다.
| 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는 구현돼 있지만 이를 호출하는 컨트롤러가 없다.
| 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까지 포함한 전체 흐름을 부하테스트했고, 그 과정에서 하네스 버그 하나와 실제 동시성 결함 하나를 찾았다.
PaymentTossClientAdapter가 호출하는 baseUrl은 설정으로 완전히 외부 주입되므로, 앱 코드를 건드리지 않고 이 값만 로컬 서버로 돌리면 confirm 전체(락 해제 시점, 트랜잭션 분리, 지갑 충전까지)를 부하테스트할 수 있다. 별도 의존성 없이 JDK 내장 HttpServer만으로 MockTossServer.java를 작성했고, 기존 단위테스트(TossPaymentAdapterTest.kt)가 WireMock으로 이미 검증해 둔 실제 요청/응답 포맷을 그대로 따랐다.
JMeter 시나리오는 스레드마다 매 반복 prepare로 새 결제를 만들고, 그 응답을 그대로 confirm에 흘려보내는 체이닝 구조로 만들었다 (confirm은 READY 상태에 대해 단 한 번만 성공하므로).
첫 실행에서 confirm이 97건 전부 500으로 떨어졌다. 응답시간 p95(≈31초)가 HikariCP 기본 커넥션 타임아웃(30초)과 비슷해 처음엔 "풀 부족"으로 추정했지만, 실제 스택트레이스는 전혀 달랐다:
io.grpc.StatusRuntimeException: UNKNOWN: Application error processing RPC
at PaymentWalletGrpcClientAdapter.findWalletByUserId
at PaymentCommandService.lambda$confirm$2
DB엔 97건이 전부 APPROVING에 멈춰 있었다. 추적 결과 부하테스트 스크립트 자신의 버그였다: userId = 900000000000000000 + N을 awk로 계산했는데, 이 자릿수(9×10¹⁷)는 awk가 쓰는 배정밀도 부동소수점의 정확한 정수 표현 한계(2⁵³≈9×10¹⁵)를 넘어 +1이 반올림에 묻혔다. 모든 요청이 지갑이 없는 동일 userId로 confirm을 시도했고, 서버가 던진 WALLET_NOT_FOUND가 gRPC NOT_FOUND로 변환되지 않은 채(변환 코드 자체가 없음) UNKNOWN으로 도착해 500까지 이어졌다.
bash 고유 64bit 정수 연산으로 교체 후 재실행: confirm 143/144(99.3%) 성공, 지갑 충전액도 정확히 일치.
규모를 올리자 confirm 성공률이 58%(80/138)로 떨어졌다. Prometheus로 확인한 5분 구간 지표:
| 지표 | 값 |
|---|---|
hikaricp_connections_active 최대 |
10 (기본 풀 크기) |
hikaricp_connections_pending 최대 |
30 |
| confirm 평균 응답시간 (일부 구간) | 30~60초 |
confirm 1건이 prepare 1회 + confirm 내부 트랜잭션 2회, 총 DB 왕복 3번을 필요로 하는데 30명이 think-time 없이 몰아치자 기본 풀(10)이 그대로 바닥났다 — 여기까지는 합리적인 "용량 부족" 결론이었다.
maximum-pool-size: 20으로 늘리고 재검증하니 예상과 반대로 confirm 성공률이 1.5%(1/66)로 더 나빠졌다. 게다가 DB엔 신규 APPROVED가 9건인데 confirm 200 응답은 1건뿐이었다 — 8건은 서버에서 실제로 처리(지갑 충전까지)됐지만 클라이언트가 60초 넘게 기다리다 먼저 타임아웃으로 포기한 뒤 응답이 도착한 것이다.
부하 구간의 HikariCP 추이:
active: 0, 17, 20, 20, 20, 20, 20, 20, 20, 20, 20, 0, ... ← 최댓값에 고정, 전혀 안 풀림
pending: 0, 0, 30, 30, 30, 30, 30, 30, 30, 30, 30, 0, ... ← 대기열도 고정, 전혀 안 줄어듦
풀이 부족해서 줄을 서는 상황이라면 완료되는 요청이 생길 때마다 값이 오르내려야 한다. 100초 가까이 완전히 평평했다는 건 그 시간 동안 단 하나도 끝나지 못했다는 뜻이다 (부하가 끝나면 곧바로 active=0, idle=20으로 회복되므로 누수는 아니었다).
근본 원인 — 커넥션 풀 자기잠식 데드락: confirm의 두 번째 트랜잭션은 DB 트랜잭션을 연 채로 그 안에서 지갑 서비스를 gRPC로 블로킹 호출한다. 이 앱은 모놀리스라 지갑 gRPC 서버가 같은 JVM, 같은 HikariCP 풀을 쓰는데, 그 핸들러도 자기 쿼리를 실행하려면 같은 풀에서 커넥션을 하나 더 받아야 한다.
sequenceDiagram
participant C as confirm 스레드 (x N)
participant Pool as HikariCP 풀 (size=20)
participant W as Wallet gRPC 핸들러
C->>Pool: ① 커넥션 획득 (Payment Tx2 오픈)
Note over C,Pool: 풀의 커넥션이 전부 ①에서 소진될 때까지 반복
C->>W: ② getWallet/chargePoints (블로킹, 커넥션 쥔 채로 대기)
W->>Pool: ③ 자기 쿼리용 커넥션 요청
Pool--xW: 풀 전부가 이미 ①에 점유돼 있어 배분 불가
Note over C,W: 아무도 끝나지 못함 — 순환 대기
동시 confirm 수가 풀 크기에 근접하면 모든 커넥션이 "①에서 대기 중인 스레드"에게 점유된 채 ③에 필요한 커넥션이 하나도 남지 않는다 — 고전적인 중첩 자원 획득 데드락이다.
Important
풀을 20으로 늘렸을 때 더 나빠진 것도 이걸로 설명된다: 풀이 클수록 더 많은 스레드가 동시에 ①까지 도달해 같은 함정이 더 완전하게 발동한다. 이 문제는 풀 크기로 해결되지 않는다.
흥미로운 점은, 바로 위 Toss PG 호출은 정확히 반대로 짜여 있다는 것이다 — 코드 주석에도 "트랜잭션 종료 시 락이 해제되므로 외부 PG 호출 중 DB 락이 유지되지 않는다"고 명시돼 있다. 개발자는 외부 호출을 트랜잭션 밖에서 해야 한다는 원칙을 이미 알고 Toss 호출엔 적용했지만, 같은 트랜잭션 안의 지갑 gRPC 호출에는 적용하지 못한 것으로 보인다.
Tip
권장 해결 — 지갑 gRPC 호출을 Tx2 밖으로 분리한다: (a) 결제 상태를 APPROVED로 먼저 커밋 → (b) 트랜잭션 밖에서 지갑 gRPC 호출. Toss 호출과 동일한 패턴이다.
cd payment/mock-pg && ./start-mock-pg.sh # Mock PG 기동
# 앱을 local,loadtest 프로파일로 기동 (application-loadtest.yml)
cd payment && ./confirm.sh 30 10 60 # 30명 / ramp 10초 / 유지 60초Grafana RAIO Main - Application Metrics 대시보드의 "DB 커넥션 풀 (HikariCP)" 패널로 active/pending 추이를 실시간으로 볼 수 있다.
결제 도메인 전체(payment/wallet/settlement/donation)를 통틀어 테스트 파일은 단 2개뿐이고, 그마저 하나는 현재 코드와 맞지 않는다.
| 파일 | 대상 | 방식 | 상태 |
|---|---|---|---|
TossPaymentAdapterTest.kt (클래스명 PaymentTossClientAdapterTest) |
PaymentTossClientAdapter |
WireMock으로 Toss 응답을 스텁, 실제 RestClient로 호출 |
✅ 현재 코드와 일치, 정상 |
PaymentCommandServiceTest.kt |
PaymentCommandService |
MockK로 협력 객체 모킹 | ❌ 컴파일 안 됨 |
Warning
PaymentCommandServiceTest.kt는 WalletReadUseCase/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와 생성자 불일치 |
- 🏗️ Hexagonal Architecture
- 🧩 Multi Module
- 🔄 CQRS
- 🌐 gRPC
- ⚙️ Batch Architecture
- ⚙️ Batch Core
- 📡 gRPC Core
- 🗄️ JPA Core
- 🌍 Common
- 🚨 Exception