PaymentEvent 이벤트 소싱과 PaymentDetail 다형성
실전 결제 시스템 구현기
- 01임시 주문 → 실주문 2단계 결제 모델 설계하기
- 02Domain Model과 JPA Entity를 분리한 이유
- 03Toss Payments 연동: 3계층 구조와 에러 처리 전략
- 04PaymentEvent 이벤트 소싱과 PaymentDetail 다형성← 현재
- 05주문/결제 이중 상태 머신과 트랜잭션 분리 전략
결제 시스템을 운영하면서 가장 곤란했던 순간은 고객 문의가 왔을 때였다. "결제했는데 주문이 안 보여요." Payment 테이블을 열어보면 status는 FAILED인데, 왜 실패했는지 아무 정보도 없었다.
상태만 저장하면 벌어지는 일
처음에는 PaymentEntity에 status 필드만 두었다. PENDING → PAID 또는 PENDING → FAILED. 단순하고 깔끔했다.
// 처음 구조
@Entity
public class PaymentEntity {
@Enumerated(EnumType.STRING)
private PaymentStatus status; // PENDING, PAID, FAILED
private String pgPaymentKey;
private BigDecimal totalAmount;
}고객 문의가 올 때까지는 문제가 없었다. "결제가 왜 실패했나요?"라는 질문에 답할 수 없게 되면서 깨달았다.
- FAILED인데 왜 실패했는지 모른다 — 카드 한도? 잔액 부족? PG 서버 장애?
- 결제가 언제 시도되고 언제 실패했는지 정확한 시간을 모른다 —
updatedAt은 마지막 수정 시간일 뿐 - 재시도 후 성공한 경우 몇 번 시도했는지 모른다 — 이전 실패 기록이 덮어씌워진다
- 취소 건은 원래 얼마를 결제했고 얼마를 환불했는지 추적이 안 된다
특히 CS팀에서 "이 결제가 3시에 실패한 건지, 3시 5분에 재시도해서 성공한 건지 알 수 있나요?"라고 물었을 때, 답할 수 없었다. 그게 결정적이었다.
모든 상태 변화를 이벤트로 기록하자
결제의 현재 상태가 아니라 어떤 과정을 거쳐 이 상태가 됐는지를 기록하기로 했다.
@Getter
@Builder(access = AccessLevel.PRIVATE)
public class PaymentEvent {
private final Long id;
private final PaymentEventType type;
private final PaymentMethod method;
private final BigDecimal amount;
private final String pgPaymentKey;
private final String pgErrorCode;
private final String pgErrorMessage;
private final PaymentDetail detail;
private final LocalDateTime eventAt;
// 각 이벤트 타입마다 팩토리 메서드를 둔다
public static PaymentEvent requested(
final PaymentMethod method, final BigDecimal amount
) {
return PaymentEvent.builder()
.type(PaymentEventType.REQUESTED)
.method(method)
.amount(amount)
.eventAt(LocalDateTime.now())
.build();
}
public static PaymentEvent completed(
final PaymentMethod method,
final BigDecimal amount,
final String pgPaymentKey,
final PaymentDetail detail
) {
return PaymentEvent.builder()
.type(PaymentEventType.COMPLETED)
.method(method)
.amount(amount)
.pgPaymentKey(pgPaymentKey)
.detail(detail)
.eventAt(LocalDateTime.now())
.build();
}
public static PaymentEvent failed(
final String errorCode, final String errorMessage
) {
return PaymentEvent.builder()
.type(PaymentEventType.FAILED)
.pgErrorCode(errorCode)
.pgErrorMessage(errorMessage)
.eventAt(LocalDateTime.now())
.build();
}
}COMPLETED 이벤트에는 반드시 pgPaymentKey가 있어야 하고, FAILED에는 반드시 errorCode가 있어야 한다. 팩토리 메서드로 나누면 타입별로 필수 데이터를 컴파일 타임에 강제할 수 있다. PaymentEvent.completed(method, amount, null, null)같은 실수를 방지한다.
public enum PaymentEventType {
REQUESTED, // 결제 요청됨
COMPLETED, // 결제 완료
FAILED, // 결제 실패
CANCEL_COMPLETED, // 취소 완료
CANCEL_FAILED // 취소 실패
}이벤트 타임라인으로 보면 이렇다
실패 → 재시도 → 성공 시나리오의 이벤트 기록
이 기록이 있으면 CS팀의 질문에 바로 답할 수 있다. "15시 정각에 잔액 부족으로 실패했고, 2분 뒤에 다른 카드로 성공했습니다."
PG 호출 전에 먼저 기록하는 이유
PG API를 호출하기 전에 Payment 레코드와 REQUESTED 이벤트를 먼저 저장한다. 이것도 뒤늦게 배운 교훈이다.
@Service
@RequiredArgsConstructor
public class PaymentInitService {
private final PaymentRepository paymentRepository;
private final PaymentEventRepository paymentEventRepository;
@Transactional
public Payment createPending(final CreatePaymentCommand command) {
final Payment payment = Payment.createPending(
command.getMethod(), command.getAmount(), command.getOrderNumber()
);
final Payment saved = paymentRepository.save(payment);
paymentEventRepository.save(
PaymentEvent.requested(command.getMethod(), command.getAmount()),
saved.getId()
);
return saved;
}
}왜 PG 호출 전에 저장하느냐? 네트워크 타임아웃 때문이다. Toss API 호출 후 응답을 못 받으면 결제가 실제로 승인됐는지 알 수 없다. Payment 레코드가 있으면 나중에 pgPaymentKey로 Toss에 조회해서 보정할 수 있다. 레코드 자체가 없으면 보정할 단서가 없다.
실제로 한 번 겪었다. 네트워크 불안정으로 타임아웃이 났는데, Toss에서는 결제가 승인된 상태였다. 고객 카드에서 돈은 빠져나갔는데 우리 시스템에는 결제 기록이 없었다. REQUESTED 이벤트라도 있었으면 "아, 이 시간에 이 금액으로 시도한 건이 있구나"를 알 수 있었을 텐데.
PaymentDetail — 결제 수단마다 저장해야 할 정보가 다르다
결제 수단에 따라 기록해야 하는 정보가 다르다는 걸 뒤늦게 알았다.
카드 결제면 카드사 이름, 카드 번호 뒷자리, 승인번호가 필요하다. 계좌이체면 은행 코드, 은행 이름이 필요하다. 처음에는 PaymentEntity에 모든 필드를 다 넣었는데, 카드 결제에서 bankCode는 null이고, 계좌이체에서 approveNo는 null이 되는 게 보기 싫었다.
Java 17의 sealed interface와 record로 해결했다.
public sealed interface PaymentDetail
permits CardPaymentDetail, TransferPaymentDetail {
}
public record CardPaymentDetail(
String issuerCode,
String issuerName,
String number, // 마스킹된 카드번호 (**** **** **** 1234)
String cardType,
String approveNo
) implements PaymentDetail {}
public record TransferPaymentDetail(
String bankCode,
String bankName
) implements PaymentDetail {}DB에는 JSON으로 저장한다. 역직렬화할 때 어떤 구현체인지 판별하는 로직이 필요한데, approveNo 필드 유무로 구분했다. 카드 결제에만 승인번호가 있으니까.
@Converter
public class PaymentDetailConverter implements AttributeConverter<PaymentDetail, String> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public PaymentDetail convertToEntityAttribute(final String json) {
if (json == null || json.isBlank()) return null;
try {
final JsonNode node = MAPPER.readTree(json);
if (node.has("approveNo")) {
return MAPPER.treeToValue(node, CardPaymentDetail.class);
}
return MAPPER.treeToValue(node, TransferPaymentDetail.class);
} catch (final JsonProcessingException e) {
throw new IllegalArgumentException("Failed to deserialize PaymentDetail", e);
}
}
}나중에 간편결제(카카오페이, 네이버페이)를 추가해야 할 수도 있다. sealed interface의 permits에 새 record만 추가하면 된다. 그리고 컴파일러가 switch 문에서 새 타입을 처리하지 않은 곳을 잡아준다. enum처럼 "모든 케이스를 다 처리했는가?"를 강제할 수 있다.
Toss 응답 → PaymentDetail 변환
Toss API 응답에서 결제 수단을 판별해 적절한 구현체를 만드는 매퍼다.
@Component
public class TossPaymentDetailMapperImpl implements TossPaymentDetailMapper {
@Override
public PaymentDetail toPaymentDetail(final TossPaymentsResponse response) {
return switch (response.getMethod()) {
case "카드" -> new CardPaymentDetail(
response.getCard().getIssuerCode(),
response.getCard().getIssuerName(),
response.getCard().getNumber(),
response.getCard().getCardType(),
response.getCard().getApproveNo()
);
case "계좌이체" -> new TransferPaymentDetail(
response.getTransfer().getBankCode(),
response.getTransfer().getBankName()
);
default -> throw new IllegalArgumentException(
"Unsupported payment method: " + response.getMethod()
);
};
}
}default에서 예외를 던지는 건 의도적이다. 지원하지 않는 결제 수단으로 결제가 들어오면 빨리 실패해서 알아차리는 게 낫다. null을 반환하면 나중에 어딘가에서 NullPointerException이 터지고, 그때는 원인을 찾기 훨씬 어렵다.
이벤트 소싱 도입 후 달라진 것
- CS 대응: "15시 3분에 카드 한도 초과로 실패, 15시 5분에 다른 카드로 성공" — 즉시 답변 가능
- 장애 보정: REQUESTED만 있고 COMPLETED/FAILED가 없으면 → PG 조회로 보정
- 통계: 결제 수단별 실패율, 시간대별 결제량 등 분석 가능
- 감사(Audit): 결제 생명주기의 완전한 기록
다음 편(마지막)에서는 OrderStatus와 PaymentStatus의 이중 상태 머신, 비관적 락, 그리고 환불 흐름까지 포함한 전체 결제 생명주기를 다룬다.