주문 생성 트랜잭션 글에서는 주문 생성 트랜잭션의 다섯 단계와 세 가지 취소 경로를 학습했습니다. 그중 결제 실패로 인한 취소는 “Kafka Consumer가 payment.failed 이벤트를 소비해 주문을 취소한다”고만 언급했습니다. 이번 글에서는 그 이벤트가 어떻게 만들어지는지, 즉 Payment 도메인 쪽을 봅니다.

결제는 외부 시스템(Toss Payments)과의 경계에 있습니다. 주문이나 재고처럼 우리가 완전히 통제할 수 있는 도메인이 아닙니다. 외부 API가 실패할 수 있고 응답이 늦을 수 있고 같은 이벤트를 두 번 보낼 수도 있습니다. 그래서 Payment 도메인의 설계에서 반복적으로 등장하는 질문은 “실패했을 때 어떻게 되는가”와 “중복이 들어오면 어떻게 되는가”입니다.

이 연재는 PeekCart를 네 단계로 진행했습니다. Phase 1은 단일 Spring Boot 모놀리스로 도메인을 구현한 단계, Phase 2는 캐시, 분산 락, Kafka와 Outbox로 성능과 정합성을 보강한 단계, Phase 3은 CI, Kubernetes, 관측성, 부하 테스트로 운영 환경을 검증한 단계, Phase 4는 서비스별로 나눈 MSA 단계입니다.

이 글에서 사용하는 용어는 다음 뜻으로 읽으면 됩니다.

용어이 글에서의 의미
paymentKeyToss Payments가 발급하는 결제 고유 키. 결제 승인 요청 시 클라이언트가 전달한다
결제 승인 (confirm)클라이언트가 Toss 위젯에서 결제를 완료한 뒤, 서버가 Toss API에 최종 승인을 요청하는 단계
웹훅 (Webhook)외부 시스템이 이벤트 발생 시 우리 서버의 특정 URL로 HTTP 요청을 보내는 방식
HMAC-SHA256공유 비밀키로 메시지의 무결성과 출처를 검증하는 서명 알고리즘
멱등성 (Idempotency)같은 요청을 여러 번 보내도 결과가 한 번 보낸 것과 같은 성질
Outbox 이벤트DB 트랜잭션과 함께 저장되어 이벤트 유실을 방지하는 패턴. 뒤의 Outbox 글에서 상세히 다룰 예정

이번 학습에서는 다음 질문을 확인하려고 합니다.

  1. Payment는 언제, 누가 생성할까?
  2. 결제 승인 흐름에서 어떤 일이 순서대로 일어날까?
  3. Toss API 호출이 실패하면 Payment와 Order에 어떤 일이 일어날까?
  4. Payment 상태 전이는 Order 상태 전이와 어떻게 연결될까?
  5. 외부 결제 API를 Infrastructure로 격리하면 무엇이 좋아질까?
  6. 웹훅 중복 수신은 어떻게 방어할까?
  7. Phase 4 MSA에서 Payment Service는 어떻게 바뀔까?

Payment는 언제 생성되는가

주문이 생성되면 Outbox 이벤트(order.created)가 저장되고 폴링 스케줄러가 이를 Kafka로 발행한다고 했습니다. Payment의 생성은 이 이벤트를 소비하는 것에서 시작합니다.

sequenceDiagram

    participant OCS as OrderCommandService
    participant DB as Database
    participant Kafka
    participant PEC as PaymentEventConsumer

    OCS->>DB: 주문 저장 + Outbox 이벤트 저장 (단일 트랜잭션)

    Note over DB: order.created 이벤트 대기


    DB-->>Kafka: 폴링 스케줄러가 발행

    Kafka-->>PEC: order.created 소비

    PEC->>DB: Payment(PENDING) 생성

PaymentEventConsumer: Kafka에서 주문 생성 이벤트를 소비한다

@KafkaListener(topics = "order.created", groupId = GROUP_ORDER_CREATED)
@Transactional
public void handleOrderCreated(String message) {
    JsonNode root = kafkaMessageParser.parse(message);
    String eventId = root.get("eventId").asText();
    JsonNode payload = root.get("payload");

    idempotencyChecker.executeIfNew(eventId, GROUP_ORDER_CREATED, () -> {
        Long orderId = payload.get("orderId").asLong();
        long totalAmount = payload.get("totalAmount").asLong();
        Payment payment = Payment.create(orderId, totalAmount);
        paymentRepository.save(payment);
        log.debug("Payment(PENDING) 생성 — orderId={}", orderId);
    });
}

order.created 이벤트에는 orderId와 totalAmount가 들어 있습니다. Consumer는 이 정보로 PENDING 상태의 Payment를 생성합니다. idempotencyChecker.executeIfNew()로 같은 이벤트가 중복 소비되더라도 Payment가 두 개 만들어지는 것을 방지합니다.

Payment를 만드는 주체는 Order 도메인이 아닌 Payment 도메인 자신입니다. Order는 “주문이 생성됐다”는 사실만 이벤트로 알리고 “그러면 결제 레코드를 만들어야 한다”는 결정은 Payment가 독립적으로 내립니다.

PaymentEventListener: Phase 1의 로컬 이벤트 버전

코드베이스에는 Kafka Consumer 외에 로컬 이벤트 리스너도 남아 있습니다.

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void handleOrderCreated(OrderCreatedEvent event) {
    Payment payment = Payment.create(event.orderId(), event.totalAmount());
    paymentRepository.save(payment);
}

Phase 1에서는 @TransactionalEventListener(AFTER_COMMIT)으로 주문 트랜잭션 커밋 이후 Payment를 생성했습니다. Phase 2에서 Kafka + Outbox로 전환하면서 PaymentEventConsumer가 이 역할을 대체했습니다. 로컬 이벤트 방식의 한계는 이후 상세히 다룰 예정입니다. 간단히 말하면 “주문 커밋은 됐는데 Payment 생성이 실패하면 이벤트가 유실된다”는 것입니다. Outbox 패턴은 이벤트 저장을 주문 트랜잭션 안에 포함시켜 이 문제를 해결합니다.


Payment 애그리거트: 상태 전이와 금액 검증

Payment 엔티티

@Entity
@Table(name = "payments")
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "order_id", nullable = false, unique = true)
    private Long orderId;

    @Column(name = "payment_key", nullable = false, unique = true)
    private String paymentKey;

    @Column(nullable = false)
    private long amount;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private PaymentStatus status;

    @Column
    private String method;

    @Column(name = "approved_at")
    private LocalDateTime approvedAt;

    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    private Payment(Long orderId, long amount) {
        this.orderId = orderId;
        this.paymentKey = UUID.randomUUID().toString();
        this.amount = amount;
        this.status = PaymentStatus.PENDING;
        this.createdAt = LocalDateTime.now();
    }
}

Payment의 구조에서 몇 가지 설계 결정이 있었습니다.

order_id는 UK다. 하나의 주문에 하나의 결제만 존재합니다. 부분 결제(한 주문에 여러 결제 수단)는 현재 범위에서 지원하지 않습니다.

paymentKey는 두 번 설정된다. 생성 시에는 UUID로 임시 키를 부여하고 클라이언트가 Toss 위젯에서 결제를 진행하면 Toss가 발급한 실제 paymentKey로 교체합니다.

public void assignPaymentKey(String paymentKey) {
    if (this.status != PaymentStatus.PENDING) {
        throw new PaymentException(ErrorCode.PAY_004);
    }
    this.paymentKey = paymentKey;
}

assignPaymentKey()는 PENDING 상태에서만 호출할 수 있습니다. 이미 승인되었거나 실패한 결제의 키를 바꾸는 것은 의미가 없기 때문입니다.

method와 approvedAt은 승인 후에 채워진다. 결제 수단(카드, 계좌이체 등)과 승인 시각은 Toss API 응답에서 가져옵니다. 생성 시점에는 아직 모르는 정보입니다.

PaymentStatus: 3개 상태, 2개 전이

public enum PaymentStatus {

    PENDING {
        @Override
        public boolean canTransitionTo(PaymentStatus target) {
            return target == APPROVED || target == FAILED;
        }
    },
    APPROVED {
        @Override
        public boolean canTransitionTo(PaymentStatus target) {
            return false;
        }
    },
    FAILED {
        @Override
        public boolean canTransitionTo(PaymentStatus target) {
            return false;
        }
    };

    public abstract boolean canTransitionTo(PaymentStatus target);
}
stateDiagram-v2
    [*] --> PENDING : Payment 생성

    PENDING --> APPROVED : Toss 승인 성공
    PENDING --> FAILED : Toss 승인 실패

    APPROVED --> [*]
    FAILED --> [*]

OrderStatus(8개 상태, 11개 전이)와 비교하면 PaymentStatus는 훨씬 단순합니다. 3개 상태, 2개 전이. APPROVED와 FAILED는 모두 터미널 상태입니다. 한 번 결정되면 되돌릴 수 없습니다.

이 단순함은 의도적입니다. 결제의 복잡한 후속 처리(환불, 부분 취소 등)는 현재 프로젝트 범위에 포함되지 않습니다. 결제가 성공하면 payment.completed 이벤트를, 실패하면 payment.failed 이벤트를 발행하는 것이 Payment 도메인의 책임 범위입니다.

주문 생성 글의 OrderStatus와 같은 패턴(canTransitionTo() 추상 메서드)을 사용하고 있습니다. 프로젝트 전체에서 상태 전이 규칙을 일관된 방식으로 표현합니다.

Payment의 도메인 메서드

public void approve(String method, LocalDateTime approvedAt) {
    if (!this.status.canTransitionTo(PaymentStatus.APPROVED)) {
        throw new PaymentException(ErrorCode.PAY_004);
    }
    this.status = PaymentStatus.APPROVED;
    this.method = method;
    this.approvedAt = approvedAt;
}

public void fail() {
    if (!this.status.canTransitionTo(PaymentStatus.FAILED)) {
        throw new PaymentException(ErrorCode.PAY_004);
    }
    this.status = PaymentStatus.FAILED;
}

public void validateAmount(long requestedAmount) {
    if (this.amount != requestedAmount) {
        throw new PaymentException(ErrorCode.PAY_001);
    }
}

주문 생성 글에서 Order는 범용 transitionTo()와 특화된 cancel()을 함께 제공했습니다. Payment는 approve()와 fail()이라는 도메인 언어에 가까운 메서드만 있습니다. 전이가 2개뿐이므로 범용 메서드가 필요하지 않습니다. Order에서 개선 후보로 남겨둔 “도메인 언어화”가 Payment에서는 이미 적용되어 있습니다.

validateAmount()는 클라이언트가 보낸 금액과 서버에 저장된 금액이 일치하는지 확인합니다. 이 검증은 클라이언트가 금액을 조작해서 더 적은 금액으로 결제를 승인받는 것을 방지하기 위한 것입니다. 금액 검증은 Toss API 호출 전에 서버 측에서 먼저 수행합니다.


결제 승인 흐름: confirmPayment()

결제 승인은 Payment 도메인에서 가장 복잡한 UseCase입니다. 외부 API 호출을 포함하며 성공과 실패 경로가 명확히 갈립니다.

flowchart TD

    Start["클라이언트: 결제 승인 요청"]

    Own["1. 주문 소유권 검증"]

    Find["2. Payment 조회"]

    Amt["3. 금액 검증"]

    Transition["4. Order → PAYMENT_REQUESTED"]
    Key["5. paymentKey 교체"]

    Toss["6. Toss API 호출"]


    Success["approve() + payment.completed Outbox"]
    Fail["fail() + payment.failed Outbox"]
    Start --> Own --> Find --> Amt --> Transition --> Key --> Toss
    Toss -->|성공| Success
    Toss -->|실패| Fail


    style Success fill:#e8f5e9
    style Fail fill:#ffebee

PaymentCommandService.confirmPayment()

public PaymentDetailDto confirmPayment(Long userId, ConfirmPaymentCommand command) {
    // 1. 주문 소유권 검증
    orderPort.verifyOrderOwner(userId, command.orderId());
    // 2. Payment 조회
    Payment payment = paymentRepository.findByOrderId(command.orderId())
            .orElseThrow(() -> new PaymentException(ErrorCode.PAY_003));
    // 3. 금액 검증
    payment.validateAmount(command.amount());
    // 4. Order 상태를 PAYMENT_REQUESTED로 전이
    orderPort.transitionToPaymentRequested(command.orderId());
    // 5. Toss가 발급한 paymentKey로 교체
    payment.assignPaymentKey(command.paymentKey());
    try {
        // 6. Toss API 호출
        TossConfirmResponse response = tossPaymentClient.confirm(
                command.paymentKey(), command.orderId().toString(), command.amount());
        // 성공: approve + payment.completed 이벤트
        payment.approve(response.method(),
                OffsetDateTime.parse(response.approvedAt()).toLocalDateTime());
        outboxEventPublisher.publishPaymentCompleted(payment, userId);
    } catch (Exception e) {
        // 실패: fail + payment.failed 이벤트
        log.error("Toss 결제 승인 실패 — orderId={}, paymentKey={}: {}",
                command.orderId(), command.paymentKey(), e.getMessage());
        payment.fail();
        outboxEventPublisher.publishPaymentFailed(payment, userId);
    }
    return PaymentDetailDto.from(payment);
}

confirmPayment()는 하나의 @Transactional 안에서 Payment 조회, Order 상태 전이, Toss API 호출, Payment 상태 변경, Outbox 이벤트 저장을 모두 수행합니다. 이 범위가 넓다는 점은 한계 절에서 다룹니다.

각 단계를 하나씩 보겠습니다.

1단계: 주문 소유권 검증. orderPort.verifyOrderOwner()로 본인 주문인지 확인합니다. 다른 사용자의 주문에 대해 결제를 시도할 수 없습니다. 인증과 인가 글에서 본 데이터 레벨 인가가 여기서도 적용됩니다.

2단계: Payment 조회. findByOrderId()로 해당 주문의 Payment를 찾습니다. 없으면 PAY-003. 이 시점에서 Payment는 PaymentEventConsumer가 이미 생성해둔 상태입니다.

3단계: 금액 검증. 클라이언트가 보낸 amount와 서버의 payment.amount가 일치하는지 확인합니다. 불일치 시 PAY-001. 이 검증이 Toss API 호출보다 앞에 있습니다. 금액이 맞지 않으면 외부 API를 호출할 필요가 없습니다.

4단계: Order 상태 전이. orderPort.transitionToPaymentRequested()로 Order 상태를 PENDING → PAYMENT_REQUESTED로 바꿉니다. 이 전이가 결제 승인 전에 일어나는 이유는, “결제가 진행 중이다”라는 상태를 먼저 기록하기 위함입니다. 주문 생성 글에서 본 타임아웃 스케줄러는 PAYMENT_REQUESTED 상태를 기준으로 15분 초과 주문을 찾습니다.

5단계: paymentKey 교체. 생성 시 부여한 임시 UUID를 Toss가 발급한 실제 paymentKey로 교체합니다.

6단계: Toss API 호출과 분기. 여기서 흐름이 성공과 실패로 갈립니다.


성공 경로와 실패 경로

성공: approve + payment.completed

Toss API가 성공 응답을 반환하면 payment.approve()가 호출됩니다. 결제 수단(method)과 승인 시각(approvedAt)을 Toss 응답에서 가져와 Payment에 기록합니다. 그리고 payment.completed Outbox 이벤트를 저장합니다.

payment.approve(response.method(),
        OffsetDateTime.parse(response.approvedAt()).toLocalDateTime());
outboxEventPublisher.publishPaymentCompleted(payment, userId);

이 이벤트는 두 Consumer가 소비합니다.

  • Order 도메인: payment.completed를 받아 Order 상태를 PAYMENT_COMPLETED로 전이
  • Notification 도메인: 결제 완료 알림 발송

실패: fail + payment.failed

Toss API 호출이 실패하면(네트워크 오류, 잔액 부족, 카드 한도 초과 등) catch 블록에서 payment.fail()이 호출됩니다. payment.failed Outbox 이벤트를 저장합니다.

payment.fail();
outboxEventPublisher.publishPaymentFailed(payment, userId);

이 이벤트가 바로 주문 생성 글에서 다룬 “결제 실패 취소”의 출발점입니다.

sequenceDiagram

    participant PCS as PaymentCommandService
    participant Toss as Toss API
    participant DB as Database
    participant Kafka
    participant OEC as OrderEventConsumer

    PCS->>Toss: 결제 승인 요청

    Toss-->>PCS: 실패 응답


    PCS->>DB: payment.fail() + payment.failed Outbox 저장

    Note over DB: 트랜잭션 커밋


    DB-->>Kafka: 폴링 스케줄러가 발행

    Kafka-->>OEC: payment.failed 소비

    OEC->>DB: order.cancel() + 재고 복구

Payment 도메인은 “결제가 실패했다”는 사실만 이벤트로 발행하고, 주문 취소와 재고 복구는 Order 도메인이 독립적으로 결정합니다. Payment 생성 때와 같은 구도여서 Payment가 Order의 취소 로직을 알 필요가 없고 Order가 Toss API의 존재를 알 필요도 없습니다.

PaymentStatus와 OrderStatus의 연결

두 상태 전이도를 나란히 놓으면 연결 지점이 보입니다.

flowchart LR

    subgraph Payment["Payment 상태"]

        PP["PENDING"]
        PA["APPROVED"]
        PF["FAILED"]
    end

    subgraph Order["Order 상태"]

        OP["PENDING"]
        OPR["PAYMENT_REQUESTED"]
        OPC["PAYMENT_COMPLETED"]
        OC["CANCELLED"]
    end
    PP -.->|"confirmPayment() 호출 시"| OPR
    PA -.->|"payment.completed 이벤트"| OPC
    PF -.->|"payment.failed 이벤트"| OC
Payment 상태 변경트리거Order 상태 변경연결 방식
(생성 전)에서 PENDING으로order.created Kafka 이벤트없음Kafka Consumer
PENDING 유지 (결제 진행 중)confirmPayment() 호출PENDING에서 PAYMENT_REQUESTED로OrderPort 동기 호출
PENDING에서 APPROVED로Toss API 성공PAYMENT_REQUESTED에서 PAYMENT_COMPLETED로payment.completed Kafka 이벤트
PENDING에서 FAILED로Toss API 실패PAYMENT_REQUESTED에서 CANCELLED로 (order.cancel())payment.failed Kafka 이벤트

Payment와 Order가 동기로 연결되는 지점은 OrderPort.transitionToPaymentRequested() 하나뿐입니다. 나머지는 모두 Kafka 이벤트로 비동기 연결됩니다.

한 가지 확인할 점이 있습니다. OrderStatus enum에는 PAYMENT_FAILED 상태가 정의되어 있고 PAYMENT_REQUESTED에서 전이할 수 있지만 실제 OrderEventConsumer.handlePaymentFailed()는 order.cancel()을 호출해서 CANCELLED로 직접 전이합니다. PAYMENT_FAILED를 거치지 않습니다. enum에 상태가 정의되어 있다고 해서 반드시 사용되는 것은 아닙니다. 현재 구현에서는 결제 실패와 사용자 취소가 모두 같은 cancel() 경로를 사용합니다.

Controller: FAILED 상태면 에러 응답

@PostMapping("/confirm")
public ResponseEntity<ApiResponse<PaymentResponse>> confirmPayment(
        @CurrentUser LoginUser loginUser,
        @Valid @RequestBody ConfirmPaymentRequest request) {
    ConfirmPaymentCommand command = new ConfirmPaymentCommand(
            request.paymentKey(), request.orderId(), request.amount());
    PaymentDetailDto result = paymentCommandService.confirmPayment(loginUser.userId(), command);
    if ("FAILED".equals(result.status())) {
        throw new PaymentException(ErrorCode.PAY_005);
    }
    return ResponseEntity.ok(ApiResponse.of(PaymentResponse.from(result)));
}

PaymentCommandService.confirmPayment()은 Toss API 실패 시 예외를 던지지 않고 FAILED 상태의 DTO를 반환합니다. 예외를 던지면 트랜잭션이 롤백되어 payment.failed Outbox 이벤트도 함께 사라지기 때문입니다. 대신 Controller가 상태를 확인하고 에러 응답을 반환합니다.

이것은 “트랜잭션 안에서 실패를 기록하고, 트랜잭션 밖에서 에러를 응답한다”는 패턴입니다. Application Service는 실패를 상태로 관리하고 Presentation이 그 상태를 HTTP 응답으로 변환합니다.


외부 API 격리: TossPaymentClient

Toss Payments와의 통신은 Infrastructure 레이어의 TossPaymentClient에 격리되어 있습니다.

@Component
public class TossPaymentClient {

    private final RestClient restClient;

    public TossPaymentClient(@Value("${toss.payments.secret-key}") String secretKey) {
        String credentials = Base64.getEncoder()
                .encodeToString((secretKey + ":").getBytes());
        this.restClient = RestClient.builder()
                .baseUrl("https://api.tosspayments.com/v1")
                .defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + credentials)
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }

    public TossConfirmResponse confirm(String paymentKey, String orderId, long amount) {
        return restClient.post()
                .uri("/payments/confirm")
                .body(Map.of(
                        "paymentKey", paymentKey,
                        "orderId", orderId,
                        "amount", amount
                ))
                .retrieve()
                .body(TossConfirmResponse.class);
    }
}
public record TossConfirmResponse(
        String paymentKey,
        String orderId,
        String status,
        String method,
        String approvedAt) {
}

왜 Infrastructure에 두는가

TossPaymentClient가 Application이나 Domain 레이어에 있으면 어떤 문제가 생길까?

위치문제
Domain 레이어도메인이 HTTP, RestClient, Toss API 스펙에 의존. 단위 테스트에서 외부 API를 호출하거나 Mock해야 함
Application 레이어Toss를 다른 PG로 교체 시 Application 코드가 변경됨. UseCase 흐름과 외부 연동 세부사항이 섞임
Infrastructure 레이어 (현재)도메인과 Application은 Toss의 존재를 모름. PG 교체 시 Infrastructure만 교체

PaymentCommandService는 TossPaymentClient.confirm()을 직접 호출하고 있어, 현재는 Application이 Infrastructure 구현체에 의존하고 있습니다. 이 의존을 Port 인터페이스로 분리하면(“결제를 승인해주세요”) 테스트가 더 쉬워지고 PG 교체 시 영향 범위가 줄어듭니다. 지금은 테스트에서 @Mock으로 교체하고 있어 실질적 문제는 없지만, PG를 교체하거나 테스트 환경에서 Fake를 주입하려면 Port 분리가 필요합니다. 상품과 재고 글, 주문 생성 글에서 본 ProductPort, OrderPort와 같은 패턴입니다. 현재 결제 PG가 하나뿐이어서 포트 분리의 실질적 필요성이 낮지만 개선 후보로 남겨둡니다.

TossConfirmResponse도 Infrastructure에 위치합니다. Toss API의 응답 구조가 바뀌어도 영향이 이 클래스에서 멈춥니다. PaymentCommandService는 응답에서 method와 approvedAt만 꺼내 쓰므로, Toss 응답의 다른 필드가 추가되거나 바뀌어도 영향을 받지 않습니다.


Outbox 이벤트 발행: PaymentOutboxEventPublisher

결제 결과는 Outbox 이벤트로 발행됩니다.

@Component
@RequiredArgsConstructor
public class PaymentOutboxEventPublisher {

    private static final String AGGREGATE_TYPE = "PAYMENT";
    private static final String PAYMENT_COMPLETED = "payment.completed";
    private static final String PAYMENT_FAILED = "payment.failed";
    private final OutboxEventRepository outboxEventRepository;
    private final ObjectMapper objectMapper;

    public void publishPaymentCompleted(Payment payment, Long userId) {
        PaymentCompletedPayload payload = new PaymentCompletedPayload(
                payment.getId(), payment.getOrderId(), userId,
                payment.getPaymentKey(), payment.getAmount(),
                payment.getMethod(), payment.getApprovedAt());
        saveOutboxEvent(PAYMENT_COMPLETED, payment.getOrderId().toString(), payload);
    }

    public void publishPaymentFailed(Payment payment, Long userId) {
        PaymentFailedPayload payload = new PaymentFailedPayload(
                payment.getId(), payment.getOrderId(), userId,
                payment.getPaymentKey(), payment.getAmount());
        saveOutboxEvent(PAYMENT_FAILED, payment.getOrderId().toString(), payload);
    }
}

두 이벤트의 payload를 비교하면 차이가 보입니다.

필드payment.completedpayment.failed
paymentIdOO
orderIdOO
userIdOO
paymentKeyOO
amountOO
methodO (카드, 계좌이체 등)X
approvedAtOX

payment.failed에는 method와 approvedAt이 없습니다. 실패한 결제는 결제 수단과 승인 시각이 결정되지 않았기 때문입니다.

aggregate ID로 orderId를 사용하고 있습니다. 설계 문서에서 Kafka 파티션 키를 order_id로 설정한다고 했는데, aggregate ID가 파티션 키로 사용되면 동일 주문에 대한 order.created → payment.completed(또는 payment.failed) → order.cancelled 이벤트가 같은 파티션에 라우팅되어 순서가 보장됩니다.


웹훅 멱등성: 중복 수신을 어떻게 방어하는가

Toss Payments는 결제 상태 변경 시 우리 서버의 /api/v1/payments/webhook으로 HTTP 요청을 보냅니다. 네트워크 이슈로 같은 이벤트가 두 번 이상 올 수 있습니다.

WebhookService: 서명 검증 + 멱등성

@Service
@Transactional
public class WebhookService {

    private final WebhookLogRepository webhookLogRepository;
    private final String webhookSecret;

    public void processWebhook(String signature, String paymentKey, String eventType,
                               String idempotencyKey, String payload) {
        // 1. HMAC-SHA256 서명 검증
        verifySignature(signature, payload);

        // 2. 중복 확인
        if (webhookLogRepository.existsByIdempotencyKey(idempotencyKey)) {
            return;
        }

        // 3. 로그 저장
        WebhookLog log = WebhookLog.create(paymentKey, eventType, idempotencyKey,
                payload, "PROCESSED");
        webhookLogRepository.save(log);
    }
}

웹훅 처리는 세 단계입니다.

1단계: 서명 검증. Toss가 보낸 Toss-Signature 헤더와 payload를 HMAC-SHA256으로 비교합니다. 서명이 없거나 불일치하면 PAY-006을 던집니다. 이 검증은 웹훅 요청이 실제로 Toss에서 온 것인지 확인하는 보안 장치입니다. 공유 비밀키(webhookSecret)를 모르는 공격자가 가짜 웹훅을 보내는 것을 방지합니다.

private void verifySignature(String signature, String payload) {
    if (signature == null) {
        throw new PaymentException(ErrorCode.PAY_006);
    }
    try {
        Mac mac = Mac.getInstance("HmacSHA256");
        mac.init(new SecretKeySpec(
                webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
        String computed = Base64.getEncoder().encodeToString(
                mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)));
        if (!computed.equals(signature)) {
            throw new PaymentException(ErrorCode.PAY_006);
        }
    } catch (PaymentException e) {
        throw e;
    } catch (Exception e) {
        throw new PaymentException(ErrorCode.PAY_006);
    }
}

2단계: 중복 확인. webhookLogRepository.existsByIdempotencyKey()로 이미 처리한 이벤트인지 확인합니다. 같은 idempotencyKey가 있으면 아무것도 하지 않고 리턴합니다.

3단계: 로그 저장. 새로운 이벤트이면 WebhookLog를 생성해 저장합니다.

WebhookLog: 멱등성의 물리적 근거

@Entity
@Table(name = "webhook_logs")
public class WebhookLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "payment_key", nullable = false)
    private String paymentKey;

    @Column(name = "event_type", nullable = false)
    private String eventType;

    @Column(name = "idempotency_key", nullable = false, unique = true)
    private String idempotencyKey;

    @Column(nullable = false, columnDefinition = "TEXT")
    private String payload;

    @Column(nullable = false)
    private String status;

    @Column(name = "received_at", nullable = false, updatable = false)
    private LocalDateTime receivedAt;
}

idempotency_key에 UK(Unique Key)가 걸려 있습니다. existsByIdempotencyKey() 조회와 save() 사이에 동시 요청이 들어와도, UK 제약조건이 중복 INSERT를 막습니다. 이것은 뒤의 Consumer 멱등성 글에서 다룰 processed_events의 (event_id, consumer_group) UK와 같은 패턴입니다.

Controller의 idempotencyKey 처리

@PostMapping("/webhook")
public ResponseEntity<Void> handleWebhook(
        @RequestHeader(value = "Toss-Signature", required = false) String signature,
        @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey,
        @RequestBody String rawPayload) throws Exception {
    JsonNode node = objectMapper.readTree(rawPayload);
    String paymentKey = node.path("paymentKey").asText("");
    String eventType = node.path("eventType").asText("");
    String idempKey = idempotencyKey != null
            ? idempotencyKey
            : paymentKey + "-" + eventType;
    webhookService.processWebhook(signature, paymentKey, eventType, idempKey, rawPayload);
    return ResponseEntity.ok().build();
}

Toss가 Idempotency-Key 헤더를 보내면 그것을 사용하고 없으면 paymentKey + "-" + eventType을 조합해서 만듭니다. 같은 결제의 같은 이벤트 유형이면 같은 키가 생성되므로 중복 방어가 됩니다.

@RequestBody String rawPayload로 원본 JSON을 문자열 그대로 받는 이유는 HMAC 서명 검증 때문입니다. 객체로 역직렬화하면 필드 순서나 공백이 달라져 서명이 불일치할 수 있습니다.

웹훅과 결제 승인의 관계

현재 구현에서 웹훅은 로그 기록만 하고 비즈니스 로직을 실행하지 않습니다. 결제 상태 변경은 confirmPayment()에서 동기적으로 처리합니다. 웹훅은 Toss 측 이벤트를 수신하고 보존합니다.

이 구조가 유효한 이유는, Toss Payments의 결제 플로우에서 결제 승인은 서버가 직접 API를 호출하는 것이고 웹훅은 추가적인 알림이기 때문입니다. 만약 결제 승인 API 호출이 실패했는데 실제로는 Toss에서 승인이 처리된 경우, 웹훅 로그가 그 사실을 확인할 수 있는 감사 기록이 됩니다. 이 구조의 한계는 한계 절의 “웹훅이 비즈니스 로직을 실행하지 않는다”에서 다룹니다.


도메인 간 경계: Port가 만드는 계약

Payment 도메인이 다른 도메인과 소통하는 방식을 정리하면 이렇습니다.

OrderPort: Payment에서 Order로 (동기)

public interface OrderPort {
    void transitionToPaymentRequested(Long orderId);
    void verifyOrderOwner(Long userId, Long orderId);
}

이 Port는 Payment 도메인의 application/port에 정의되고 구현체(OrderPortAdapter)는 Order 도메인의 infrastructure에 위치합니다. 주문 생성 글에서 본 것과 같은 방향입니다.

PaymentOutboxEventPublisher: Payment에서 Kafka로 (비동기)

payment.completed와 payment.failed 이벤트를 Outbox에 저장합니다. Order와 Notification이 각각의 Consumer Group으로 이 이벤트를 소비합니다.

전체 흐름 도식

flowchart LR

    subgraph Order["Order 도메인"]

        OCS["OrderCommandService"]
        OPA["OrderPortAdapter<br/>(impl)"]
        OEC["OrderEventConsumer"]
    end

    subgraph Payment["Payment 도메인"]

        PCS["PaymentCommandService"]
        OP["OrderPort<br/>(interface)"]
        POP["PaymentOutboxEventPublisher"]
        PEC["PaymentEventConsumer"]
    end

    subgraph Infra["Infrastructure"]
        Toss["TossPaymentClient"]
        Kafka["Kafka"]
    end
    OCS -->|"order.created"| Kafka
    Kafka -->|"order.created"| PEC
    PCS --> OP
    OP -.-> OPA
    PCS --> Toss
    PCS --> POP
    POP -->|"payment.completed<br/>payment.failed"| Kafka
    Kafka -->|"payment.failed"| OEC

Payment와 Order 사이에 동기 연결(OrderPort)과 비동기 연결(Kafka)이 공존합니다. 동기 연결은 결제 승인 시 Order 상태를 즉시 전이하기 위함이고 비동기 연결은 결제 결과를 전파하기 위함입니다.


테스트로 검증하기

Payment 도메인 테스트: 상태 전이 규칙

// PaymentStatusTest.java
@Test
@DisplayName("APPROVED에서 모든 전이가 거부된다")
void approved_allTransitions_denied() {
    for (PaymentStatus target : PaymentStatus.values()) {
        assertThat(PaymentStatus.APPROVED.canTransitionTo(target)).isFalse();
    }
}

@Test
@DisplayName("FAILED에서 모든 전이가 거부된다")
void failed_allTransitions_denied() {
    for (PaymentStatus target : PaymentStatus.values()) {
        assertThat(PaymentStatus.FAILED.canTransitionTo(target)).isFalse();
    }
}

주문 생성 글의 OrderStatusTest와 같은 패턴입니다. 터미널 상태에서 모든 전이가 거부되는지를 전수 검사합니다.

PaymentCommandService 테스트: 성공과 실패 분기

@Test
@DisplayName("confirmPayment: 성공 시 APPROVED 상태와 payment.completed가 발행된다")
void confirmPayment_success() {
    Payment payment = PaymentFixture.pendingPaymentWithId();
    ConfirmPaymentCommand command = PaymentFixture.confirmPaymentCommand();
    TossConfirmResponse response = new TossConfirmResponse(
            PaymentFixture.DEFAULT_PAYMENT_KEY, command.orderId().toString(),
            "DONE", "카드", "2026-03-25T14:00:00+09:00");

    given(paymentRepository.findByOrderId(command.orderId())).willReturn(Optional.of(payment));
    given(tossPaymentClient.confirm(command.paymentKey(), command.orderId().toString(), command.amount()))
            .willReturn(response);

    PaymentDetailDto result = paymentCommandService.confirmPayment(
            PaymentFixture.DEFAULT_USER_ID, command);

    assertThat(result.status()).isEqualTo("APPROVED");
    assertThat(result.method()).isEqualTo("카드");
    then(orderPort).should().transitionToPaymentRequested(command.orderId());
    then(outboxEventPublisher).should().publishPaymentCompleted(
            any(Payment.class), eq(PaymentFixture.DEFAULT_USER_ID));
}

@Test
@DisplayName("confirmPayment: Toss API 실패 시 FAILED 상태와 payment.failed가 발행된다")
void confirmPayment_tossFailure_failsPayment() {
    Payment payment = PaymentFixture.pendingPaymentWithId();
    ConfirmPaymentCommand command = PaymentFixture.confirmPaymentCommand();

    given(paymentRepository.findByOrderId(command.orderId())).willReturn(Optional.of(payment));
    given(tossPaymentClient.confirm(any(), any(), eq(command.amount())))
            .willThrow(new RuntimeException("Toss API error"));

    PaymentDetailDto result = paymentCommandService.confirmPayment(
            PaymentFixture.DEFAULT_USER_ID, command);

    assertThat(result.status()).isEqualTo("FAILED");
    then(outboxEventPublisher).should().publishPaymentFailed(
            any(Payment.class), eq(PaymentFixture.DEFAULT_USER_ID));
}

두 테스트가 검증하는 것은 동일한 confirmPayment() 호출에서 Toss API 성공/실패에 따라 결과가 어떻게 갈리는지입니다. 성공하면 상태가 APPROVED가 되고 publishPaymentCompleted가 호출되며, 실패하면 FAILED가 되고 publishPaymentFailed가 호출됩니다. TossPaymentClient를 Mock으로 교체해서 외부 API 없이도 두 경로를 모두 테스트할 수 있습니다.

WebhookService 테스트: 서명 검증과 멱등성

@Test
@DisplayName("processWebhook: 유효한 서명이면 로그를 저장한다")
void processWebhook_validSignature_savesLog() {
    String payload = "{\"paymentKey\":\"pk-123\"}";
    String signature = computeHmac(payload);
    given(webhookLogRepository.existsByIdempotencyKey("idem-1")).willReturn(false);

    assertThatCode(() -> webhookService.processWebhook(
            signature, "pk-123", "PAYMENT_DONE", "idem-1", payload))
            .doesNotThrowAnyException();

    then(webhookLogRepository).should().save(any(WebhookLog.class));
}

@Test
@DisplayName("processWebhook: 중복 idempotencyKey이면 저장을 스킵한다")
void processWebhook_duplicateKey_skips() {
    String payload = "{\"paymentKey\":\"pk-123\"}";
    String signature = computeHmac(payload);
    given(webhookLogRepository.existsByIdempotencyKey("idem-1")).willReturn(true);

    webhookService.processWebhook(signature, "pk-123", "PAYMENT_DONE", "idem-1", payload);

    then(webhookLogRepository).should(never()).save(any());
}

멱등성은 두 번째 테스트가 검증합니다. 같은 idempotencyKey로 두 번 호출해도 save()가 한 번도 호출되지 않는지 봅니다.


한계와 트레이드오프

confirmPayment()의 트랜잭션 범위가 넓다

앞에서 본 것처럼 confirmPayment()는 모든 단계를 하나의 @Transactional 안에서 수행합니다. 그런데 Toss API 호출은 외부 네트워크 I/O이므로 응답 시간이 가변적입니다. API 응답이 느리면 트랜잭션이 길어지고 DB 커넥션이 그만큼 점유됩니다.

이 문제를 완화하려면 Toss API 호출을 트랜잭션 밖으로 빼는 방법이 있습니다. 먼저 Toss API를 호출해 결과를 받고 그 결과를 바탕으로 트랜잭션을 열어 상태를 변경하는 것입니다. 그러나 이 경우 “API는 성공했는데 트랜잭션이 실패했다”는 새로운 불일치 시나리오가 생깁니다. 현재 트래픽 수준에서는 트랜잭션 안에서 처리하는 것이 더 단순합니다.

TossPaymentClient에 Port 인터페이스가 없다

PaymentCommandService와 TossPaymentClient 사이에는 인터페이스 없이 구현체를 직접 주입하고 있습니다. 영향과 개선 방향은 외부 API 격리 절의 “왜 Infrastructure에 두는가”에서 설명한 대로입니다.

웹훅이 비즈니스 로직을 실행하지 않는다

현재 웹훅은 로그만 기록합니다. 만약 confirmPayment() API 호출이 타임아웃되었는데 실제로는 Toss에서 결제가 승인된 경우, 우리 시스템에는 결제 실패로 기록되지만 Toss에는 결제 성공으로 남는 불일치가 발생할 수 있습니다. 이 경우 웹훅이 실제 결제 상태를 동기화하는 역할을 해야 합니다. 현재 프로젝트 범위에서는 이 시나리오를 다루지 않고 있습니다.

의도적으로 안 한 것

  • 환불/부분 취소: APPROVED 이후의 상태 전이. 비즈니스적으로 중요하지만 현재 범위에서 미구현
  • 재시도 정책: Toss API 호출 실패 시 즉시 FAILED로 처리합니다. 일시적 네트워크 오류에 대한 재시도 로직은 없음
  • payment_failures 테이블: 실패 사유를 상세히 기록하는 테이블은 만들지 않았습니다. 도입은 Phase 4 절에서 다룹니다.
  • 웹훅 기반 상태 동기화: 위에서 언급한 API/웹훅 불일치 해소

Phase 4 MSA에서는 어떻게 바뀌는가

Phase 4에서 Payment Service는 독립 배포 단위가 됩니다. 자체 DB를 갖고 Order Service와는 Kafka 이벤트와 HTTP API로만 소통합니다.

flowchart TD

    subgraph PaymentSvc["Payment Service"]
        PCS["PaymentCommandService"]
        TPC["TossPaymentClient"]
        WHS["WebhookService"]
        PayDB["Payment DB<br/>payments · webhook_logs · payment_failures"]
    end

    subgraph OrderSvc["Order Service"]
        OCS["OrderCommandService"]
        OrderDB["Order DB"]
    end

    subgraph Kafka
        OC["order.created"]
        PC["payment.completed"]
        PF["payment.failed"]
    end
    OrderSvc -->|"order.created"| OC
    OC -->|소비| PaymentSvc
    PaymentSvc -->|"payment.completed"| PC
    PaymentSvc -->|"payment.failed"| PF
    PF -->|소비| OrderSvc
    PC -->|소비| OrderSvc

    PCS --> TPC
    PCS -.->|"HTTP: 주문 검증"| OrderSvc

여기서 주요 변화는 다음과 같습니다.

OrderPort가 HTTP API로 바뀐다. 현재 OrderPort.verifyOrderOwner()와 transitionToPaymentRequested()는 같은 JVM 안에서 메서드를 호출합니다. MSA에서는 Order Service의 HTTP API를 호출하게 됩니다. 네트워크 오류와 타임아웃 처리가 추가로 필요합니다.

payment_failures 테이블이 도입된다. 현재는 payments.status = 'FAILED'로 실패를 표현하지만 실패 사유(잔액 부족, 카드 한도, 네트워크 오류 등)를 구분하지 않습니다. Phase 4에서는 별도 테이블에 상세 실패 이력을 기록합니다.

결제 실패 보상이 Choreography Saga가 된다. 현재 모놀리스에서는 payment.failed를 받은 Order Consumer가 주문 취소와 재고 복구를 하나의 서비스 안에서 처리합니다. MSA에서는 이 흐름이 서비스 경계를 넘습니다.

payment.failed 발행 (Payment Service)  → Order Service 소비 → 주문 CANCELLED + order.cancelled 발행
    → Product Service 소비 → 재고 복구

각 단계가 독립 서비스에서 실행되므로, 중간 단계가 실패했을 때의 처리가 더 복잡해집니다. 하지만 이미 Phase 2에서 Outbox + Kafka + 멱등성 처리를 갖추고 있어, Saga의 기반 인프라는 준비되어 있습니다.

웹훅 엔드포인트가 Payment Service로 이동한다. 현재 모놀리스의 /api/v1/payments/webhook이 Payment Service의 엔드포인트가 됩니다. API Gateway를 통하지 않고 Toss에서 직접 Payment Service로 들어올 수도 있고 Gateway를 거칠 수도 있습니다. 이 결정은 보안 요구사항에 따라 달라집니다.


자료는 어떤 질문에 연결해서 읽을까

질문같이 읽을 자료이 글에서 연결되는 지점
PG 연동의 일반적인 플로우는 어떻게 되는가?Toss Payments, 결제 연동 가이드confirm API의 역할과 paymentKey 흐름을 이해할 때
외부 API를 Infrastructure에 격리하는 이유는 무엇인가?Alistair Cockburn, Hexagonal ArchitectureTossPaymentClient의 위치와 Port 패턴을 비교할 때
웹훅 보안은 어떻게 구현하는가?OWASP, Webhook SecurityHMAC-SHA256 서명 검증의 필요성을 이해할 때
멱등성은 왜 필요하고 어떻게 구현하는가?Stripe, Idempotent Requestswebhook_logs.idempotency_key UK의 설계를 볼 때
Saga 보상 트랜잭션은 어떻게 동작하는가?Chris Richardson, Sagaspayment.failed, order.cancelled, inventory.restore로 이어지는 흐름을 볼 때
Outbox 패턴은 왜 필요한가?Chris Richardson, Transactional Outboxpayment.completed/failed Outbox 이벤트의 필요성을 이해할 때