1편에서 결제 도메인 산출물을 다 뽑았는데, 팀 회의 하다 보니 수정할 게 꽤 생겼다. 카프카 기반 구조로 바뀌면서 API 명세서, 시퀀스 다이어그램, 컨텍스트 맵이 다 바뀌었고, 아웃박스 패턴이랑 인프라 설계까지 진행했다. 이번 편에서 그 내용을 정리해보려 한다.
1. API 명세서 수정 — 결제 요청 흐름 변경
원래는 클라이언트가 직접 POST /api/v1/payments를 호출하는 구조였는데, 팀이랑 얘기하다 보니 예매 도메인에서 Kafka로 결제 요청을 보내는 구조로 바뀌었다.
변경 전
클라이언트가 직접 결제 요청 API 호출
변경 후
예매 확정 → Booking이 payment.requested 이벤트 발행 → Payment가 구독해서 결제 레코드 생성
그래서 POST /api/v1/payments는 외부 API에서 내부 API로 변경했다.
기능method권한URL외부/내부
| 결제 생성 | POST | SYSTEM | /internal/v1/payments | 내부 |
| 결제 승인 | POST | CUSTOM | /api/v1/payments/confirm | 외부 |
| 결제 상세 조회 | GET | ALL+본인 | /api/v1/payments/{paymentId} | 외부 |
| 본인 결제 목록 조회 | GET | 본인 | /api/v1/payments/me | 외부 |
| 환불 | POST | CUSTOM+ADMIN | /api/v1/payments/{paymentId}/refund | 외부 |
| 결제 상태 변경 | PATCH | SYSTEM | /internal/v1/payments/{paymentId}/status | 내부 |
| 전체 결제 목록 조회 | GET | ADMIN | /api/v1/admin/payments | 외부 |
| 프로그램별 결제 목록 | GET | HOST+ADMIN | /api/v1/{programId}/payments | 외부 |
외부/내부를 나누는 이유는 내부 API는 서버 간 통신 전용이라 외부에 노출되면 안 되기 때문이다. 실제로 Spring Gateway나 nginx에서 /internal/** 경로는 외부 접근을 막는 필터를 걸어서 보안 처리한다.
2. 이벤트 명세서 작성
Kafka 기반으로 바뀌면서 이벤트 명세서를 새로 작성했다.
payment.requested
- 발행 도메인: Booking
- 수신 도메인: Payment
- 설명: 예매 확정 시 결제 도메인에 결제 생성 요청
public record PaymentRequestedPayload(
UUID bookingId,
UUID userId,
UUID scheduleId,
Integer finalAmount,
LocalDateTime requestedAt
) {}
payment.completed
- 발행 도메인: Payment
- 수신 도메인: Booking
- 설명: 결제 승인/실패/환불 완료 시 예매 도메인에 결제 결과 전달
public record PaymentCompletedPayload(
UUID paymentId,
UUID bookingId,
UUID userId,
String status, // SUCCESS / FAILED / REFUNDED
Integer finalAmount,
String reason, // 실패/환불 사유 (nullable)
LocalDateTime processedAt
) {}
3. 아웃박스 패턴 (Outbox Pattern)
왜 필요한가
결제 성공 후 Kafka로 이벤트를 발행할 때 이런 문제가 생길 수 있다.
1. DB에 결제 SUCCESS 저장 ← 성공
2. Kafka로 이벤트 발행 ← 서버 죽음
1번은 성공했는데 2번에서 죽으면 DB엔 SUCCESS인데 예매 도메인은 이벤트를 못 받는 데이터 불일치 상황이 생긴다.
해결 방법
이벤트를 Kafka에 바로 쏘는 게 아니라 DB에 먼저 저장한다.
1. DB에 결제 SUCCESS 저장
+ P_OUTBOX_EVENTS에 이벤트 INSERT ← 같은 트랜잭션!
2. 스케줄러가 P_OUTBOX_EVENTS 읽어서 Kafka 발행
3. 발행 성공하면 published_at 업데이트
1번이 같은 트랜잭션이라 둘 다 성공하거나 둘 다 실패한다. 데이터 불일치가 없어진다.
인박스 패턴은 외부 이벤트를 수신할 때 필요한데, 결제 도메인은 이벤트를 발행하는 쪽이라 인박스는 필요 없다.
4. 시퀀스 다이어그램 수정
Kafka 기반으로 흐름이 바뀌었다.
sequenceDiagram
autonumber
actor 사용자
participant Client as 클라이언트
participant Booking as 예매 서버
participant PayServer as 결제 서버
participant DB as 결제 DB
participant Toss as Toss Payments API
participant Kafka as Kafka
rect rgb(240, 248, 255)
Note over Booking, Kafka: ① 결제 생성 (Kafka 트리거)
사용자->>Client: 결제하기 클릭
Client->>Booking: 예매 확정 요청
Booking->>Kafka: payment.requested 발행 {bookingId, userId, finalAmount}
Kafka->>PayServer: payment.requested 수신
PayServer->>DB: INSERT P_PAYMENTS (status=PENDING)
DB-->>PayServer: OK
PayServer-->>Client: 200 {paymentId, orderId, clientKey}
end
rect rgb(255, 250, 240)
Note over Client, Toss: ② 토스 결제창 (클라이언트 SDK)
Client->>Toss: toss.requestPayment(orderId, amount)
Note over Client: 사용자 카드 정보 입력
Toss-->>Client: redirect successUrl?paymentKey&orderId&amount
end
rect rgb(240, 255, 245)
Note over Client, DB: ③ 결제 승인 (Confirm)
Client->>PayServer: POST /api/v1/payments/confirm {paymentKey, orderId, amount}
PayServer->>PayServer: orderId·amount 검증 (위변조 방지)
alt 검증 실패
PayServer-->>Client: 400 Bad Request (금액 불일치)
else 검증 성공
PayServer->>Toss: POST /v1/payments/confirm {paymentKey, orderId, amount}
alt 토스 승인 실패
Toss-->>PayServer: 4xx (잔고부족, 한도초과 등)
PayServer->>DB: UPDATE P_PAYMENTS (status=FAILED)
PayServer->>DB: INSERT P_PAYMENTS_HISTORY (status=FAILED)
PayServer->>Kafka: payment.completed 발행 {status=FAILED, reason}
Kafka->>Booking: payment.completed 수신 → 예매 상태 업데이트
PayServer-->>Client: 500 결제 실패
else 토스 승인 성공
Toss-->>PayServer: 200 {paymentKey, status: DONE, approvedAt}
PayServer->>DB: UPDATE P_PAYMENTS (status=SUCCESS, paymentKey, approvedAt)
PayServer->>DB: INSERT P_PAYMENTS_HISTORY (status=SUCCESS)
PayServer->>Kafka: payment.completed 발행 {status=SUCCESS}
Kafka->>Booking: payment.completed 수신 → 예매 상태 업데이트
PayServer-->>Client: 200 {paymentId, status=SUCCESS}
end
end
end
rect rgb(255, 245, 245)
Note over PayServer, Kafka: ④ 환불
Client->>PayServer: POST /api/v1/payments/{paymentId}/refund {reason}
PayServer->>Toss: 토스 취소 API 호출
Toss-->>PayServer: 200 취소 완료
PayServer->>DB: UPDATE P_PAYMENTS (status=REFUNDED)
PayServer->>DB: INSERT P_PAYMENTS_HISTORY (status=REFUNDED)
PayServer->>Kafka: payment.completed 발행 {status=REFUNDED, reason}
Kafka->>Booking: payment.completed 수신 → 예매 취소 처리
PayServer-->>Client: 200 {status=REFUNDED}
end
기존 대비 달라진 점
- 1단계: 클라이언트 직접 호출 → Booking이 payment.requested 발행 → 결제 서버 구독
- 결제 결과: payment.completed로 Kafka 발행해서 예매 도메인에 전달
5. 테이블 명세서 — payment_key NULL 여부
payment_key가 NULL인 게 맞냐는 의문이 생겼는데 결론은 맞다.
결제 흐름을 보면 알 수 있다.
1. 결제 요청 → P_PAYMENTS INSERT → payment_key = NULL
(이 시점엔 토스가 아직 paymentKey를 안 줬으니까)
2. 토스 결제창 → 카드 입력
3. confirm → 토스가 paymentKey 돌려줌
→ P_PAYMENTS UPDATE → payment_key = "토스가 준 값"
payment_key는 토스가 결제 승인 완료 후에 주는 값이라 요청 시점에는 존재 자체가 없다. NULL로 시작했다가 confirm 단계에서 UPDATE로 채우는 게 정확한 설계다.
6. 도메인 모델 다이어그램 수정
팀 회의에서 두 가지를 삭제하기로 했다.
Auditing 필드 제거 createdAt, createdBy, updatedAt, updatedBy, deletedAt, deletedBy 6개 필드는 도메인 모델 다이어그램에서 너무 세부적인 내용이라 제거했다.
PaymentService 제거 Domain Service는 도메인 모델 다이어그램에서 굳이 표현 안 해도 된다는 방향으로 결정됐다.
최종 다이어그램 구성은 이렇게 됐다.
- Payment (Aggregate Root)
- PaymentHistory (Entity)
- PaymentStatus (Enum)
- TossPaymentsPort (ACL/Interface)
- TossConfirmResult (VO)
- TossCancelResult (VO)
7. 인프라 설계
최종 확정 구성
항목결정
| 서비스 | 9개 (config, eureka, gateway, user, venue, program, booking, payment, queue) |
| 컨테이너 | Docker + ECS (Fargate) |
| 이미지 저장소 | ECR |
| CI/CD | Github Actions → ECR push → ECS 배포 |
| DB | RDS PostgreSQL 단일 인스턴스 (스키마 분리) |
| 캐시 | Redis (EC2 Docker 컨테이너) |
| 메시지 브로커 | Kafka (EC2 Docker 컨테이너) |
| 모니터링 | Prometheus + Grafana |
| 인증/인가 | Keycloak + Spring Security + JWT |
| 공통 모듈 | 외부 Maven 저장소 배포 |
CI/CD 흐름
코드 push
→ Github Actions 빌드
→ Docker 이미지 생성
→ ECR에 이미지 push
→ ECS가 새 이미지로 자동 배포
ECS + Fargate를 선택한 이유
비즈니스 서비스 9개를 Fargate로 띄우면 서버 관리를 AWS가 해주고, 티켓팅 오픈 순간 트래픽이 몰릴 때 자동 스케일링이 된다. Redis랑 Kafka는 EC2에 Docker로 직접 올려서 비용을 절감했다.
이번 편에서 바뀐 핵심은 한 가지다.
"결제 요청을 클라이언트가 직접 하는 게 아니라 Kafka 이벤트로 트리거한다"
이게 바뀌면서 API 명세서, 시퀀스 다이어그램, 컨텍스트 맵이 전부 연달아 수정됐다. MSA에서 도메인 간 흐름 하나를 바꾸면 산출물이 얼마나 많이 영향을 받는지 직접 체감했다.
다음 포스팅에서는 실제 Spring Boot 코드 구현을 다룰 예정이다.
참고
- 토스페이먼츠 개발자 센터
- Spring Boot 3.x + JPA + Kafka 기반 구현
'Spring 개발일지' 카테고리의 다른 글
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 도메인 모델 & 인프라 설계 (0) | 2026.04.24 |
|---|---|
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 레이스 컨디션 발견 및 설계 재검토 (2) | 2026.04.23 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 도메인 설계 및 산출물 도출 (0) | 2026.04.21 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 도메인 설계 및 API 구조 정리 (1) | 2026.04.20 |
| [팀프로젝트] MSA 기반 티켓팅 프로그램 - 결제 도메인 (1) | 2026.04.18 |