Day53

강태훈·2026년 3월 17일

nbcamp TIL

목록 보기
53/58

PortOne 기반 결제 시스템 구현 학습 정리

커머스 결제 백엔드 시스템 구현 프로젝트 - 결제 도메인 담당


Day 1


1. PortOne 콘솔 설정

채널 설정

  • 연동 모드: 테스트 / 실연동 분리
  • 결제 모듈: 결제창 일반/정기결제 V2 선택
  • MID 선택 기준:
    • 일반 결제 → INIpayTest (이니시스 결제창 일반결제 및 API 일반결제)
    • 정기 결제 → INIBillTst (구독/도전과제용, 별도 채널 추가)

필수 발급 값

설명형태
storeIdPortOne이 상점(계정) 전체를 식별하는 IDstore-xxxxxxxx-...
channelKey생성한 채널 하나를 식별하는 키channel-key-xxxxxxxx-...
API Secret서버가 PortOne REST API 호출 시 신원 증명콘솔 > 식별코드·API Keys

⚠️ API Secret이 노출되면 공격자가 결제 취소 API를 임의로 호출할 수 있습니다. 반드시 .env에 저장하고 .gitignore에 추가하세요!


2. 전체 결제 흐름 이해

클라이언트                   서버                      PortOne(PG)
    |                       |                            |
    |── (1) 주문 생성 요청 ──→|                            |
    |←── 주문ID 반환 ─────────|                            |
    |                       |                            |
    |── (2) 결제 시도 기록 ──→|   PENDING 상태로 DB 저장     |
    |←── OK ──────────────────|                            |
    |                       |                            |
    |── (3) 결제창 호출 ────────────────────────────────────→ |
    |←── paymentId 반환 ─────────────────────────────────────|
    |                       |                            |
    |── (4-A) 결제 확정 요청 →|                            |
    |                       |── (5) 결제 조회 ─────────────→|
    |                       |←── 결제 상태/금액 ─────────────|
    |                       |───── (6) 검증 + 재고차감      |
    |←── (7) 결제 완료 응답 ──|      + 상태 변경             |

핵심 원칙

"결제 확정의 유일한 근거는 PortOne 결제 조회 API 결과"
클라이언트가 보내온 금액(request.payAmount())은 신뢰할 수 없습니다!


3. 기술 스택 설정

build.gradle

  • PortOne Java SDK는 별도 의존성 없음
  • spring-boot-starter-webRestClient 포함 ✅

application.yml 설정

# 공통 yml
portone:
  api:
    base-url: https://api.portone.io

# prod 프로필 yml
portone:
  api:
    secret: ${PORTONE_API_SECRET}
  store:
    id: ${PORTONE_STORE_ID}
  channel:
    kg-inicis: ${PORTONE_CHANNEL_KG}
    toss: ${PORTONE_CHANNEL_TOSS}

.env 파일

PORTONE_API_SECRET=실제값
PORTONE_STORE_ID=실제값
PORTONE_CHANNEL_KG=실제값
PORTONE_CHANNEL_TOSS=실제값

4. 패키지 구조

domain/
└── payments/
    ├── payment/
    │   ├── controller/
    │   ├── service/
    │   ├── repository/
    │   ├── dto/
    │   │   ├── request
    │   │   │   └── PaymentConfirmRequest.java
    │   │   └── response
    │   │       └── PaymentConfirmResponse.java
    │   ├── entity/
    │   └── enums/
    │       └── PaidStatus.java
    ├── portone/                     ← PortOne 통신 전담
    │   ├── dto/
    │   │   └── PortOnePaymentResponse.java
    │   ├── enums/
    │   │   └── PortOnePayStatus.java   ← PortOne 전용 Enum (분리!)
    │   ├── PortOneClient.java
    │   └── PortOneConfig.java
    └── refund/
        ├── controller/
        ├── service/
        ├── repository/
        └── entity/

config/
└── PortOneProperties.java <- 팀원이 구현

5. PortOne 연동 클래스

PortOneProperties.java

@Component
@ConfigurationProperties(prefix = "portone")
public class PortOneProperties {
    private Api api;
    private Store store;
    private Map<String, String> channel;

    @Data
    public static class Api {
        private String baseUrl;
        private String secret;
    }

    @Data
    public static class Store {
        private String id;
    }
}

PortOneConfig.java

@Configuration
@RequiredArgsConstructor
public class PortOneConfig {
    private final PortOneProperties portOneProperties;

    @Bean
    public RestClient portOneRestClient() {
        return RestClient.builder()
                .baseUrl(portOneProperties.getApi().getBaseUrl())
                .defaultHeader("Authorization", "PortOne " + portOneProperties.getApi().getSecret())
                .build();
    }
}

💡 Authorization 헤더 형식: PortOne {apiSecret} - PortOne V2 인증 방식

PortOnePaymentResponse.java

public record PortOnePaymentResponse(
    String id,
    PortOnePayStatus status,   // String이 아닌 Enum으로 → Jackson이 자동 역직렬화
    Amount amount,
    OffsetDateTime paidAt      // RFC 3339 → OffsetDateTime (시간대 정보 보존!)
) {
    public record Amount(int total) {}
}

PortOneClient.java

@Component
@RequiredArgsConstructor
public class PortOneClient {

    private final RestClient portOneRestClient;

    public PortOnePaymentResponse getPayment(String portOneId) {
        return portOneRestClient
                .get()
                .uri("/payments/{paymentId}", portOneId)
                .retrieve()
                .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                    throw new PaymentException(ErrorCode.PAYMENT_NOT_FOUND);
                })
                .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                    throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
                })
                .body(PortOnePaymentResponse.class);
    }
}

6. Enum 설계

PaidStatus.java (우리 시스템 내부용)

@Getter
@RequiredArgsConstructor
public enum PaidStatus {
    PENDING("결제 대기"),
    PROCESSING("결제 중"),
    SUCCESS("결제 성공"),
    FAILED("결제 실패"),
    REFUNDED("환불 완료");

    private final String title;
}

PortOnePayStatus.java (PortOne 응답 전용)

public enum PortOnePayStatus {
    READY,
    IN_PROGRESS,
    PAID,
    FAILED,
    CANCELLED,
    PARTIAL_CANCELLED
}

⚠️ 두 Enum을 반드시 분리해야 하는 이유

  • PaidStatus는 DB에 저장되는 값
  • 나중에 PG사를 교체하면 PortOne 상태값이 달라질 수 있음
  • 하나로 합치면 DB에 저장된 값을 전부 마이그레이션해야 하는 상황 발생

7. Payment 엔티티 설계

핵심 설계 결정사항

@Getter
@Entity
@Table(
    name = "payments",
    uniqueConstraints = {
        @UniqueConstraint(
            name = "uk_payments_order_id",
            columnNames = {"order_id"}
        )
    }
)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Payment extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private String portOneId;      // PortOne 결제 ID는 String! (UUID 형태)
    private Long payAmount;        // 금액은 Long! (int 최대값 약 21억 초과 가능)

    @Enumerated(EnumType.STRING)   // 반드시 STRING으로! (숫자로 저장 시 유지보수 불가)
    private PaidStatus paidStatus;

    private OffsetDateTime paidAt; // BaseEntity의 createdAt과 다름! (PG 실제 승인 시각)

    // 도메인 메서드 (Setter 대신 의미있는 메서드로!)
    public void confirmPayment(String portOneId, OffsetDateTime paidAt) {
        this.portOneId = portOneId;
        this.paidAt = paidAt;
        this.paidStatus = PaidStatus.SUCCESS;
    }

    public void failPayment() {
        this.paidStatus = PaidStatus.FAILED;
    }

    public void refundPayment() {
        this.paidStatus = PaidStatus.REFUNDED;
    }
}

왜 Setter 대신 도메인 메서드?

  • 불변식(Invariant) 보호: SUCCESS 상태인데 paidAtnull인 레코드가 생기는 것을 방지
  • Rich Domain Model: 엔티티가 자신의 상태를 스스로 변경하는 책임을 가짐
  • 가독성: payment.confirmPayment() 호출만으로 의도가 명확

8. PaymentRepository

public interface PaymentRepository extends JpaRepository<Payment, Long> {

    // 결제 확정 요청 시 결제 레코드 조회
    Optional<Payment> findByOrder(Order order);

    // 멱등성 검증용 - 이미 SUCCESS인 결제가 있는지 확인
    boolean existsByPortOneIdAndPaidStatus(String portOneId, PaidStatus paidStatus);
}

💡 order_id Unique 제약조건이 있기 때문에 같은 주문으로 두 번째 결제 레코드 INSERT 시 DB 레벨에서 차단됩니다!


9. 결제 확정 서비스 로직 (해피 패스)

@Transactional
public PaymentConfirmResponse confirmPayment(PaymentConfirmRequest request) {

    // 1. orderId로 Order, Payment 조회
    Order order = orderRepository.findById(request.orderId())
            .orElseThrow(() -> new PaymentException(ErrorCode.ORDER_NOT_FOUND));

    Payment payment = paymentRepository.findByOrder(order)
            .orElseThrow(() -> new PaymentException(ErrorCode.PAYMENT_NOT_FOUND));

    // 2. 중복 요청 검증 (멱등성)
    if (paymentRepository.existsByPortOneIdAndPaidStatus(request.portOneId(), PaidStatus.SUCCESS)) {
        throw new PaymentException(ErrorCode.DUPLICATE_PAYMENT_REQUEST);
    }

    // 3. PortOne 조회 API 호출
    PortOnePaymentResponse portOnePaymentResponse = portOneClient.getPayment(request.portOneId());

    // 3-1. PortOne 결제 상태 검증 (PAID 상태인지 확인!)
    if (portOnePaymentResponse.status() != PortOnePayStatus.PAID) {
        throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
    }

    // 4. 금액 검증 (클라이언트 값이 아닌 PortOne 응답값과 비교!)
    if (portOnePaymentResponse.amount().total() != payment.getPayAmount()) {
        throw new PaymentException(ErrorCode.PAYMENT_AMOUNT_MISMATCH);
    }

    // 5. 재고 차감
    for (OrderItem orderItem : order.getOrderItems()) {
        orderItem.getProduct().decreaseStock(orderItem.getQuantity());
    }

    // 6. 최종 확정
    payment.confirmPayment(request.portOneId(), portOnePaymentResponse.paidAt());
    order.updateOrderStatus(OrderStatus.PAID); // 주문 상태 변경
    paymentRepository.save(payment);           // 명시적 save (가독성)
    // order는 @Transactional Dirty Checking으로 자동 저장

    return new PaymentConfirmResponse(order.getOrderNumber());
}

10. 보상 트랜잭션 (미완성 - 추후 구현)

// PortOne 결제는 성공했지만 내부 처리(재고 차감 등)가 실패한 경우
// try-catch로 감싸서 PortOne 결제 취소 API 호출 필요

try {
    // ... 결제 확정 로직
} catch (Exception e) {
    // PortOne 결제 취소 API 호출 (보상 트랜잭션)
    // 결제 상태 → FAILED
    // 주문 상태 → 결제 대기 유지
}

11. 주요 개념 정리

멱등성 (Idempotency)

동일한 요청이 여러 번 들어와도 결과는 딱 한 번만 처리되어야 한다는 원칙

  • portOneId 기준으로 이미 SUCCESS인 결제가 있으면 중복 요청 차단
  • 웹훅과 Client Confirm이 동시에 도착해도 한 번만 처리

@Transactional의 역할

  • 재고 차감 중 하나라도 실패하면 전체 DB 작업 롤백
  • 단, 외부 API(PortOne)는 롤백 불가 → 보상 트랜잭션 필요
  • Dirty Checking: @Transactional 안에서 엔티티 변경 시 save() 없이도 UPDATE 쿼리 실행

Rich Domain Model vs Anemic Domain Model

Rich Domain ModelAnemic Domain Model
엔티티상태 변경 로직 포함getter/setter만 존재
서비스"언제" 변경할지 결정모든 비즈니스 로직 담당
권장

@Enumerated(EnumType.STRING) 반드시 필요한 이유

  • 기본값은 ORDINAL → DB에 숫자(0, 1, 2...)로 저장
  • Enum 순서가 바뀌면 기존 데이터 의미가 뒤바뀌는 대참사 발생!

12. 다음 구현 목록

✅ PortOne 콘솔 설정
✅ application.yml 설정
✅ PortOneProperties
✅ PortOneConfig
✅ PortOneClient
✅ Payment 엔티티 설계
✅ PaymentRepository
✅ 결제 확정 서비스 (해피 패스)
⬜ 결제 시도 기록 API
⬜ 결제 확정 컨트롤러 (소유권 검증 포함)
⬜ 웹훅 엔드포인트
⬜ 보상 트랜잭션
⬜ 환불 로직

13. 내가 놓쳤던 포인트 모음

PortOne 결제 상태 검증을 빠뜨리면?

  • PortOne이 FAILED 상태를 반환해도 서버가 그냥 확정해버리는 상황 발생
  • 반드시 status == PAID 검증 후 진행!

paidAt을 BaseEntity의 modifiedAt으로 대체하면?

  • modifiedAt은 마지막 수정 시각 → 결제 확정 후 상태가 변경되면 덮어써짐
  • 결제 승인 시각과 환불 시각을 동시에 추적 불가
  • PG 실제 승인 시각은 별도 컬럼 paidAt으로 관리!

금액 비교 시 클라이언트 값을 쓰면?

  • 악의적인 사용자가 금액을 조작해서 보낼 수 있음
  • 반드시 PortOne 조회 API 응답값과 DB에 저장된 금액을 비교!

LocalDateTime vs OffsetDateTime

  • PortOne은 RFC 3339 형식(2024-01-15T09:30:00+09:00) 사용
  • LocalDateTime은 시간대 정보 소실
  • 결제 도메인에서는 OffsetDateTime 사용 권장!


Day 2

웹훅, 보상 트랜잭션, 환불, 포인트 연동 중심


1. 인증 구조 파악 (CustomUserDetails)

JwtAuthenticationFilter 인증 흐름

JWT 토큰 → email 추출 → CustomUserDetails 생성 → SecurityContext 저장

CustomUserDetails 핵심 메서드

public Long getId() { return user.getId(); }         // userId 추출
public String getEmail() { return user.getEmail(); } // email 추출

컨트롤러에서 사용법

@AuthenticationPrincipal CustomUserDetails userDetails
// userId 꺼내기
userDetails.getId()

💡 principal에 String email이 들어있으면 CustomUserDetails로 캐스팅 시 런타임 에러 발생!
반드시 JwtAuthenticationFilter에서 CustomUserDetails 객체를 principal에 넣어야 합니다.


2. paymentId vs portOneId 개념 정리

가장 많이 헷갈렸던 부분!

우리 서버가 paymentUuid 생성 (PMN-20240115-A1B2C3D4)
        ↓
클라이언트가 PortOne 결제창 호출 시 이 값을 paymentId로 전달
        ↓
PortOne이 결제 완료 후 동일한 값을 그대로 돌려줌
        ↓
Client Confirm → 클라이언트가 서버로 전달
Webhook → PortOne이 서버로 전달
생성 주체시점
paymentUuid우리 서버결제 시도 기록 시
PortOne의 paymentIdPortOne이 그대로 돌려줌결제 완료 후

둘은 같은 값입니다!


3. 결제 확정 로직 리팩토링 (공유 로직 분리)

분리 이유

  • Client Confirm과 Webhook이 동일한 결제 확정 로직 공유
  • Client Confirm은 orderId로 조회, Webhook은 paymentUuid로 조회

구조

// Client Confirm 전용
@Transactional
public PaymentConfirmResponse confirmPayment(PaymentConfirmRequest request) {
    Order order = orderRepository.findById(request.orderId())...
    Payment payment = paymentRepository.findByOrder(order)...
    processPaymentConfirm(payment, order, request.portOneId(), ...);
    return new PaymentConfirmResponse(...);
}

// Webhook 전용
@Transactional
public void confirmPaymentByWebhook(String paymentUuid) {
    Payment payment = paymentRepository.findByPaymentUuid(paymentUuid)...
    Order order = payment.getOrder();
    processPaymentConfirm(payment, order, paymentUuid, ...);
}

// 공유 핵심 로직 (private)
private void processPaymentConfirm(Payment payment, Order order, String portOneId, ...) {
    // 중복 검증, PortOne 조회, 금액 검증, 재고 차감, 최종 확정
}

⚠️ @Transactionalprivate 메서드에 적용 불가!
반드시 public 메서드인 confirmPayment()confirmPaymentByWebhook()에 붙여야 합니다.


4. 웹훅(Webhook) 구현

웹훅이란?

  • 일반 API: 우리가 먼저 PortOne에 요청
  • 웹훅: PortOne이 먼저 우리 서버에 알림

왜 필요한가?

사용자 브라우저 강제 종료 💥
        ↓
Client Confirm 미도달
        ↓
PortOne → 웹훅으로 결제 완료 알림
        ↓
서버가 웹훅으로 결제 확정 처리 ✅

Webhook 엔티티

@Table(
    name = "webhook_events",
    uniqueConstraints = {
        @UniqueConstraint(name = "uk_webhook_events_webhook_id", columnNames = {"webhook_id"})
    }
)
public class Webhook extends BaseEntity {
    @Column(name = "webhook_id")
    String webhookId;        // 헤더에서 추출, UNIQUE!

    String paymentUuid;      // 바디의 payment_id
    String eventStatus;      // 바디의 status

    @Enumerated(EnumType.STRING)
    WebhookStatus status;    // RECEIVED / PROCESSED / FAILED

    // createdAt = 수신 시각 (BaseEntity)
    // modifiedAt = 처리 완료 시각 (BaseEntity)
}

이벤트 단위 멱등 처리

// PROCESSED만 차단, FAILED는 재처리 허용!
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
    return;
}

💡 FAILED 재처리를 허용하는 이유
FAILED는 우리 서버 내부 오류로 처리 실패한 상태입니다.
PortOne이 재전송해줬을 때 다시 시도할 기회를 주는 것이 사용자에게 유리합니다!

WebhookService 핵심

@Transactional
public void handleWebhook(String webhookId, String paymentUuid, String eventStatus) {
    Webhook webhook = null; // 반드시 메서드 지역변수로! (싱글톤 동시성 문제)
    try {
        if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
            return;
        }
        webhook = new Webhook(webhookId, paymentUuid, eventStatus);
        webhookRepository.save(webhook);
        paymentService.confirmPaymentByWebhook(paymentUuid);
        webhook.processedWebhook();
    } catch (Exception e) {
        if (webhook != null) {
            webhook.failedWebhook();
        }
    }
}

WebhookController

@PostMapping
public ResponseEntity<Void> handleWebhook(
        @RequestHeader("webhook-id") String webhookId,  // 헤더에서 추출!
        @RequestBody WebhookRequest request
) {
    try {
        webhookService.handleWebhook(webhookId, request.paymentId(), request.status());
    } catch (Exception e) {
        log.error("웹훅 처리 실패 webhookId: {}, error: {}", webhookId, e.getMessage());
    }
    return ResponseEntity.ok().build(); // 항상 200 OK!
}

⚠️ 웹훅은 항상 200 OK 반환!
PortOne은 200 OK가 아니면 웹훅을 무한 재전송합니다.


5. PortOneClient 결제 취소 API

public PortOneCancelPaymentResponse cancelPayment(
        String paymentUuid, PortOneCancelPaymentRequest cancelRequest
) {
    return portOneRestClient
            .post()
            .uri("/payments/{paymentId}/cancel", paymentUuid)
            .body(cancelRequest)          // 요청 바디 전송
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
                throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
            })
            .body(PortOneCancelPaymentResponse.class); // 응답 바디 수신
}

💡 body()의 두 가지 역할

  • .body(cancelRequest) → 요청 바디 전송
  • .body(PortOneCancelPaymentResponse.class) → 응답 바디 수신

6. 보상 트랜잭션 구현

왜 필요한가?

PortOne 결제 승인 ✅ (돈이 빠져나감)
포인트 차감 ✅
재고 차감 💥 실패!
        ↓
@Transactional → DB 롤백
        ↓
❗ 돈은 빠져나갔는데 포인트도 차감된 상태!

플래그 방식으로 구현

private void processPaymentConfirm(...) {
    boolean pointUsed = false;
    boolean portOneConfirmed = false;

    try {
        if (pointsToUse > 0) {
            pointService.validateAndUse(userId, pointsToUse);
            pointUsed = true;  // 포인트 차감 성공 기록
        }

        // 검증 로직...

        for (OrderItem orderItem : order.getOrderItems()) {
            orderItem.getProduct().decreaseStock(orderItem.getQuantity());
        }
        portOneConfirmed = true;  // 재고 차감 성공 기록

        payment.confirmPayment(portOneId, portOnePaymentResponse.paidAt());
        order.updateOrderStatus(OrderStatus.PAID);
        paymentRepository.save(payment);

        pointService.earnPoint(userId, order.getId(), earnAmount);

    } catch (Exception e) {
        // 보상 트랜잭션!
        if (portOneConfirmed) {
            portOneClient.cancelPayment(portOneId,
                new PortOneCancelPaymentRequest("내부 오류로 인한 자동 취소"));
        }
        if (pointUsed) {
            pointService.restorePoint(userId, order.getId(), pointsToUse);
        }
        payment.failPayment();
        throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
    }
}

플래그가 필요한 이유

포인트 차감 💥 실패 (portOneConfirmed = false)
        ↓
catch에서 무조건 cancelPayment() 호출?
        ↓
❗ PortOne 결제가 아직 안됐는데 취소 시도 → 새로운 오류 발생!

💡 "실제로 실행된 작업만 되돌린다" 는 원칙!


7. 환불 엔티티 설계

public Refund(Payment payment, String refundReason) {
    this.payment = payment;
    this.refundAmount = payment.getPaidAmount(); // 전액 환불이므로 자동 설정!
    this.refundReason = refundReason;
    this.refundStatus = RefundStatus.REQUEST;
}

💡 전액 환불만 구현하므로 외부에서 금액을 받지 않고
엔티티 스스로 결제 금액을 참조하는 것이 더 깔끔합니다!


8. 오늘 놓쳤던 포인트 모음

싱글톤 Bean의 필드 사용 위험성

// ❌ 잘못된 방식
@Service
public class WebhookService {
    private Webhook webhook = null; // 동시 요청 시 덮어써짐! 💥
}

// ✅ 올바른 방식
public void handleWebhook(...) {
    Webhook webhook = null; // 요청마다 독립적인 변수 ✅
}

Spring Bean은 싱글톤이므로 여러 요청이 동시에 들어오면
클래스 필드를 공유합니다!

Optional을 record 필드로 사용 금지

// ❌ 잘못된 방식
public record PortOneCancelPaymentResponse(
    Optional<OffsetDateTime> cancelledAt  // Jackson 직렬화 문제 발생!
) {}

// ✅ 올바른 방식
public record PortOneCancelPaymentResponse(
    OffsetDateTime cancelledAt  // null 허용, Jackson이 자동으로 null 처리
) {}

웹훅 중복 검증 결과 사용 누락

// ❌ 잘못된 방식
webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED);
// 결과를 무시!

// ✅ 올바른 방식
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
    return;
}

@Transactional은 private 메서드에 적용 불가

// ❌ 동작 안 함
@Transactional
private void processPaymentConfirm(...) { }

// ✅ public 메서드에 적용
@Transactional
public void confirmPayment(...) {
    processPaymentConfirm(...); // private 메서드 호출
}

9. 다음 구현 목록

✅ PortOne 콘솔 설정
✅ PortOneProperties / Config / Client (취소 API 포함)
✅ Payment 엔티티 설계
✅ 결제 시도 기록 API
✅ 결제 확정 서비스 + 컨트롤러
✅ 웹훅 구현
✅ 보상 트랜잭션 설계
⬜ 소유권 검증 완성
⬜ 보상 트랜잭션 코드 완성
⬜ 환불 서비스/컨트롤러
⬜ 포인트 연동 완성
⬜ 프론트엔드 연동 스펙 맞춰 DTO 수정

Day 3

보상 트랜잭션 완성, 코드 점검, 포인트 전액 결제 처리 중심


1. 트랜잭션 전파 (Propagation)

왜 필요한가?

@Transactional confirmPayment() 실행 중
        ↓
재고 차감 실패 💥
        ↓
catch 블록에서 payment.failPayment() 호출
        ↓
예외 재던짐 → @Transactional 롤백!
        ↓
payment.failPayment()도 롤백됨! 💥 DB에 여전히 PENDING 상태

해결: REQUIRES_NEW로 별도 트랜잭션 분리

// PaymentRollback.java (별도 클래스!)
@Component
@RequiredArgsConstructor
public class PaymentRollback {
    private final PaymentRepository paymentRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void failPayment(Payment payment) {
        payment.failPayment();
        paymentRepository.save(payment);
        // 메인 트랜잭션이 롤백돼도 이건 이미 커밋됨! ✅
    }
}

트랜잭션 전파 종류

옵션동작
REQUIRED (기본값)기존 트랜잭션 참여, 없으면 새로 생성
REQUIRES_NEW기존 트랜잭션 중단, 새 트랜잭션 생성

2. 같은 클래스 내부 호출 - REQUIRES_NEW 적용 불가 ⚠️

// ❌ 잘못된 방식 - 같은 클래스 내부 호출
@Service
public class PaymentService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void failPayment(...) { } // 프록시를 거치지 않아 REQUIRES_NEW 무시!

    public void handleFailure(...) {
        failPayment(...); // 같은 클래스 내부 호출 → REQUIRES_NEW 적용 안 됨!
    }
}

// ✅ 올바른 방식 - 별도 클래스로 분리
@Component
public class PaymentRollback {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void failPayment(...) { } // 외부에서 호출 → 프록시 거침 → REQUIRES_NEW 적용! ✅
}

💡 AOP 프록시는 외부에서 호출할 때만 동작합니다!
같은 클래스 안에서 호출하면 프록시를 거치지 않아 @Transactional이 무시됩니다.


3. 보상 트랜잭션 최종 구조

PaymentService.confirmPayment()  [트랜잭션 A]
        ↓
processPaymentConfirm() 실행
        ↓
실패! catch 블록
        ↓
PaymentFailureHandler.handlePaymentFailure()
    ├── portOneConfirmed → portOneClient.cancelPayment() (PortOne 취소)
    └── PaymentRollback.failPayment() [트랜잭션 B - REQUIRES_NEW]
            → FAILED 상태 별도 커밋 ✅
        ↓
트랜잭션 A 롤백 (포인트, 재고 원복)
        ↓
결과: DB에 FAILED 상태 저장됨 ✅, 포인트/재고 롤백됨 ✅

보상 트랜잭션에서 포인트 복구가 필요 없는 이유

포인트 차감 (DB 작업)
        ↓
이후 실패
        ↓
@Transactional 롤백 → 포인트 차감도 자동 롤백! ✅
        ↓
catch에서 restorePoint() 추가로 호출하면 포인트가 오히려 증가! 💥

💡 DB 작업은 @Transactional이 롤백, 외부 API(PortOne)만 보상 트랜잭션 필요!


4. 변수 스코프(Scope) - try-catch에서 변수 접근

// ❌ try 블록 안에서 선언 → catch에서 접근 불가
try {
    Payment payment = ...; // try 블록 끝나면 사라짐
} catch (Exception e) {
    payment.failPayment(); // 💥 컴파일 에러!
}

// ✅ try 밖에서 선언, 안에서 할당
Payment payment = null;  // 선언
try {
    payment = paymentRepository.findByPortOneId(portOneId)...; // 할당
} catch (Exception e) {
    if (payment != null) {  // null 체크 후 사용
        payment.failPayment(); // ✅ 접근 가능!
    }
}

5. Java 값 전달(Pass by Value) - 플래그 문제

// ❌ boolean 파라미터로 넘기면 메서드 안에서 바뀐 값이 밖에 반영 안 됨
boolean portOneConfirmed = false;
processPayment(portOneConfirmed); // false 복사본이 전달됨
// processPayment 안에서 true로 바꿔도 여기선 여전히 false!

// ✅ 해결: 결과 객체로 반환
public record PaymentProcessResult(
    boolean portOneConfirmed,
    String portOneId
) {}

private PaymentProcessResult processPaymentConfirm(...) {
    boolean portOneConfirmed = false;
    // ...
    portOneConfirmed = true;
    return new PaymentProcessResult(portOneConfirmed, portOneId); // 결과 반환!
}

6. 예외 정보 손실 문제

// ❌ 잘못된 방식 - 원래 예외 정보 사라짐
} catch (Exception e) {
    paymentFailureHandler.handlePaymentFailure(...);
    throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR<); // 멱등성 에러도 이걸로 덮어씀!
}

// ✅ 올바른 방식 - 예외 종류에 따라 분리
} catch (PaymentException e) {
    paymentFailureHandler.handlePaymentFailure(...);
    throw e; // 원래 예외 그대로 던지기!
} catch (Exception e) {
    paymentFailureHandler.handlePaymentFailure(...);
    throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
}

7. 소유권 검증 + 웹훅 통합

userId를 null로 구분

@Transactional
public PaymentConfirmResponse confirmPayment(String portOneId, Long userId) {
    payment = paymentRepository.findByPortOneId(portOneId).orElseThrow(...);

    if (userId != null) {
        // Client Confirm → 소유권 검증
        if (!payment.getOrder().getUser().getId().equals(userId)) {
            throw new PaymentException(ErrorCode.USER_FORBIDDEN);
        }
    } else {
        // Webhook → userId를 payment에서 꺼내기
        userId = payment.getOrder().getUser().getId();
    }
}

호출부

// Client Confirm 컨트롤러
paymentService.confirmPayment(portOneId, userDetails.getId());

// Webhook 서비스
paymentService.confirmPayment(portOneId, null);

8. @EntityGraph - N+1 문제 해결

N+1 문제란?

// Payment 조회 1번
Payment payment = paymentRepository.findByPortOneId(portOneId);
// order 접근 시 추가 쿼리!
Order order = payment.getOrder();         // 쿼리 2번
User user = order.getUser();              // 쿼리 3번
List<OrderItem> items = order.getOrderItems(); // 쿼리 4번
items.get(0).getProduct();               // 쿼리 5번...N번

@EntityGraph로 해결

@EntityGraph(attributePaths = {
    "order",                    // Payment → Order 한번에!
    "order.user",               // Order → User 한번에!
    "order.orderItems",         // Order → OrderItems 한번에!
    "order.orderItems.product"  // OrderItem → Product 한번에!
})
Optional<Payment> findByPortOneId(String portOneId);
// 단 1번의 JOIN 쿼리로 모든 연관 엔티티 조회! ✅

9. 포인트 전액 결제 처리

문제 상황

주문금액: 10,000원
포인트: 10,000P
actualAmount = 0원
        ↓
PortOne SDK에 totalAmount: 0 전달
        ↓
PortOne: "0보다 큰 금액만 허용!" 💥

해결 방법 - 분기 처리

// 프론트엔드
if (finalAmount > 0) {
    // 기존 흐름: PortOne 결제창 호출
    const paymentResult = await openPortOnePaymentWithPoints(paymentData);
    await confirmPaymentTemplate(paymentResult.paymentId);
} else {
    // 포인트 전액 결제: PortOne 없이 서버에서 바로 확정
    await pointOnlyPayment(orderToProcess.orderId);
}
// 서버 - 포인트 전액 결제 전용 엔드포인트
@Transactional
public PaymentConfirmResponse confirmPointOnlyPayment(Long orderId, Long userId) {
    Order order = orderRepository.findByIdAndUser_Id(orderId, userId).orElseThrow(...);
    Payment payment = paymentRepository.findByOrder(order).orElseThrow(...);

    // PortOne 없이 바로 포인트 차감 + 확정
    pointService.validateAndUse(userId, payment.getPointsToUse());
    payment.confirmPayment(OffsetDateTime.now());
    order.updateOrderStatus(OrderStatus.COMPLETED);
    paymentRepository.save(payment);

    return new PaymentConfirmResponse(order.getOrderNumber(), "포인트로 결제가 완료되었습니다.");
}

10. 코드 점검에서 발견된 주요 문제들

Payment 엔티티 - portOneId null 설정 위험

// ❌ portOneId가 nullable = false인데 null 설정!
public <void preparePendingAttempt(int paidAmount) {
    this.portOneId = null; // DB 저장 시 에러 발생! 💥
}

request.pointsToUse() 언박싱 NPE

// ❌ Integer → int 언박싱 시 null이면 NPE!
new Payment(order, actualPayAmount, portOneId, request.pointsToUse()); // 💥

// ✅ pointsToUseOrZero() 사용
new Payment(order, actualPayAmount, portOneId, request.pointsToUseOrZero());

에러코드 부적절

// ❌ 유저/멤버십 조회 실패인데 PAYMENT_NOT_FOUND?
userRepository.findWithLockById(...)
    .orElseThrow(() -> new PaymentException(ErrorCode.PAYMENT_NOT_FOUND));

// ✅ 올바른 에러코드
.orElseThrow(() -> new PaymentException(ErrorCode.USER_NOT_FOUND));

PaymentTryResponse 필드명 불일치

// ❌ 프론트엔드 스펙은 paymentId인데
String portOneId;

// ✅ 스펙에 맞게
String paymentId;

11. PortOne amount.total 의미

필드의미
total실제 결제된 금액 (포인트 할인 후 최종 금액)
discount할인된 금액 (포인트 사용액 등)
원래 주문 금액 = total + discount
실제 결제 금액 = total  ← 이 값과 DB의 paidAmount를 비교해야 함!

💡 paidAmount에는 반드시 포인트 차감 후 실제 결제 금액을 저장해야 합니다!
그래야 PortOne 금액 검증이 정확하게 동작합니다.



📚 내가 답하지 못했던 질문 모음

결제 시스템 구현 과정에서 놓쳤거나 답변하지 못한 질문들을 정리합니다.
각 질문에 대한 답변과 핵심 개념을 함께 정리했습니다.


1. REST API 메서드 선택

❓ 질문

PUT은 리소스를 "수정"할 때, POST는 "생성" 또는 "행위(Action)"를 표현할 때 사용합니다.
결제 확정은 단순한 데이터 수정일까요, 아니면 하나의 "비즈니스 행위"일까요?

✅ 답변

결제 확정은 비즈니스 행위(Action) 입니다.

단순히 DB의 상태 컬럼 하나를 수정하는 것이 아니라,

  • PortOne API 호출
  • 재고 차감
  • 주문 상태 변경
  • 결제 상태 변경

이 복합적인 작업이 동시에 일어나기 때문에 POST를 사용합니다.

POST /api/v1/payments/confirm  ✅
PUT  /api/v1/payments/confirm  ❌

💡 실무에서도 복합적인 비즈니스 행위는 관례적으로 POST를 사용합니다.


2. @AuthenticationPrincipal의 활용

❓ 질문

@AuthenticationPrincipal로 받아온 사용자 정보를 결제 확정 로직에서 어떻게 활용할 수 있을까요?
다른 사람의 주문을 결제 확정하려는 시도를 막으려면 어떻게 해야 할까요?

✅ 답변

컨트롤러에서 현재 로그인한 사용자의 ID를 꺼내서, 서비스에서 주문의 소유자와 비교합니다.

// 컨트롤러
@PostMapping("/confirm")
public ResponseEntity<?> confirmPayment(
    @AuthenticationPrincipal UserDetails userDetails,
    @RequestBody PaymentConfirmRequest request
) {
    // userDetails에서 userId 추출 후 서비스로 전달
}

// 서비스
if (!order.getUserId().equals(currentUserId)) {
    throw new PaymentException(ErrorCode.USER_FORBIDDEN);
}

💡 소유권 검증은 결제/환불/포인트 등 "내 것"이 중요한 모든 API에서 필수입니다!


3. @ConfigurationProperties 활성화 방법

❓ 질문

@ConfigurationProperties(prefix = "portone")를 사용하면 yml의 값이 자동으로 매핑됩니다.
이 클래스를 Bean으로 활성화하려면 어떤 어노테이션이 추가로 필요할까요?

✅ 답변

세 가지 방법이 있습니다.

방법위치설명
@Component@ConfigurationProperties 클래스 위가장 간단 ✅
@EnableConfigurationProperties(클래스.class)@Configuration 클래스 위명시적 등록
@ConfigurationPropertiesScan메인 클래스 위자동 스캔
// 가장 간단한 방법
@Component
@ConfigurationProperties(prefix = "portone")
public class PortOneProperties { ... }

4. PortOne 결제 상태 검증의 필요성

❓ 질문

PortOne이 FAILED 상태인 결제를 조회해서 반환했는데,
우리 서버가 상태 확인 없이 그냥 확정해버리면 어떻게 될까요?

✅ 답변

결제가 실제로 실패했는데도 서버가 주문 완료 + 재고 차감을 처리해버리는 최악의 상황이 발생합니다.

// 반드시 추가해야 하는 검증!
if (portOnePaymentResponse.status() != PortOnePayStatus.PAID) {
    throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
}

💡 PortOne 조회 API 호출 후 반드시 상태가 PAID인지 확인하고 진행해야 합니다!


5. save() vs Dirty Checking

❓ 질문

paymentRepository.save()를 명시적으로 쓰는 것과
@Transactional Dirty Checking에 맡기는 것 중 어느 쪽이 더 좋을까요?

✅ 답변

둘 다 동작하지만 상황에 따라 선택합니다.

방식장점단점
save() 명시가독성 좋음, 의도가 명확코드가 약간 길어짐
Dirty Checking코드 간결저장 여부가 암묵적
// payment는 명시적 save (가독성)
paymentRepository.save(payment);

// order는 Dirty Checking에 맡김 (도메인 경계 침범 방지)
// orderRepository.save(order) ← 호출하지 않아도 자동 저장됨

💡 도메인 경계: PaymentServiceorderRepository.save()를 직접 호출하면
Order 도메인을 침범하는 느낌이 생깁니다. 이런 경우 Dirty Checking이 더 자연스럽습니다.


6. 보상 트랜잭션의 필요성

❓ 질문

@Transactional 롤백은 어디까지 영향을 미칠까요?
PortOne 결제가 이미 승인된 상태에서 재고 차감이 실패하면 어떻게 될까요?

✅ 답변

@Transactional 롤백은 DB 작업만 되돌립니다. 이미 승인된 외부 결제는 롤백되지 않습니다!

PortOne 결제 승인 완료 ✅ (돈이 빠져나감)
        ↓
재고 차감 실패 ❌
        ↓
@Transactional → DB 롤백 ↩️ (주문/결제 상태 원복)
        ↓
❗ 돈은 빠져나갔는데 주문은 안된 상태!

해결책: 보상 트랜잭션

try {
    // 결제 확정 로직
} catch (Exception e) {
    // PortOne 결제 취소 API 직접 호출
    portOneClient.cancelPayment(portOneId);
    payment.failPayment();
    throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
}

7. findByOrder vs findByOrderId

❓ 질문

findByOrder(Order order)처럼 Order 객체 전체를 넘기는 것과
findByOrderId(Long orderId)처럼 ID만 넘기는 것 중 어떤 방식이 더 적합할까요?

✅ 답변

서비스 로직에서 어차피 Order 객체를 먼저 조회한 뒤 Payment를 찾는 흐름이므로
findByOrder(Order order)가 더 자연스럽습니다.

// 흐름
Order order = orderRepository.findById(request.orderId())
        .orElseThrow(...);

Payment payment = paymentRepository.findByOrder(order) // Order 객체 재사용
        .orElseThrow(...);

8. 외부 API 호출 클래스의 위치

❓ 질문

PortOne API를 호출하는 코드는 어느 계층에 두는 것이 좋을까요?
PaymentService 안에 직접 작성하는 것과, 별도의 클래스로 분리하는 것의 차이는?

✅ 답변

별도의 PortOneClient 클래스로 분리하는 것이 올바릅니다.

방식문제점
Service에 직접 작성PG사 교체 시 Service 코드 전체 수정 필요
PortOneClient로 분리PG사 교체 시 Client 클래스만 교체 ✅
@Component  // Controller/Service/Repository가 아닌 경우 @Component 사용
public class PortOneClient {
    // 외부 API 호출만 전담
}

9. LocalDateTime vs OffsetDateTime

❓ 질문

PortOne이 반환하는 paidAt은 RFC 3339 형식(2024-01-15T09:30:00+09:00)입니다.
LocalDateTime으로 변환하면 어떤 문제가 생길까요?

✅ 답변

LocalDateTime시간대(TimeZone) 정보가 없습니다.

RFC 3339: 2024-01-15T09:30:00+09:00  ← 한국 시간임을 명시
LocalDateTime: 2024-01-15T09:30:00   ← 시간대 정보 소실!
OffsetDateTime: 2024-01-15T09:30:00+09:00  ← 시간대 정보 보존 ✅

💡 결제 도메인에서는 국제 결제, 서버 시간대 변경 등의 상황을 대비해
반드시 OffsetDateTime을 사용해야 합니다!


10. 두 Enum을 분리해야 하는 이유

❓ 질문

PortOne 상태값으로 내부 Enum을 통일하면 어떤 문제가 생길까요?

✅ 답변

내부 PaidStatusDB에 저장되는 값입니다.

현재: DB에 "PAID", "CANCELLED" 저장 (PortOne 기준)
      ↓
나중에 토스페이먼츠로 교체 시 상태값이 다를 수 있음
      ↓
DB에 저장된 수백만 건의 데이터 마이그레이션 필요! 💥

올바른 분리

// 우리 시스템 내부 (DB 저장용)
PaidStatus: PENDING, SUCCESS, FAILED, REFUNDED

// PortOne 전용 (API 응답 역직렬화용)
PortOnePayStatus: READY, PAID, FAILED, CANCELLED, PARTIAL_CANCELLED

📚 내가 답하지 못했던 질문 모음 - Day 2

11번부터 시작 (1~10번은 Day 1 파일 참고)


11. @AuthenticationPrincipal의 타입 불일치 문제

❓ 질문

JwtAuthenticationFilter에서 principal에 email(String)을 넣고 있는데,
@AuthenticationPrincipal CustomUserDetails userDetails로 받으면 어떤 문제가 생길까요?

✅ 답변

Spring이 StringCustomUserDetails캐스팅하려다 런타임 에러가 발생합니다!

// JwtAuthenticationFilter에서 principal에 넣은 값
new UsernamePasswordAuthenticationToken(
    email,  // String을 넣었음
    ...
)

// 컨트롤러에서 받으려는 타입
@AuthenticationPrincipal CustomUserDetails userDetails  // 💥 ClassCastException!

해결책: JwtAuthenticationFilter에서 CustomUserDetails 객체를 principal에 넣어야 합니다!

CustomUserDetails customUserDetails = new CustomUserDetails(user);
new UsernamePasswordAuthenticationToken(
    customUserDetails,  // CustomUserDetails 객체를 넣어야 함
    ...
)

12. static 메서드가 적합한 경우

❓ 질문

static 메서드는 언제 사용하는 것이 적합할까요?
GenerateCodeUuid처럼 상태(필드)를 갖지 않고 입력값만으로 결과를 반환하는 경우가
static에 적합한 이유가 무엇일까요?

✅ 답변

static 메서드는 객체의 상태(필드)에 의존하지 않고, 입력값만으로 결과를 만들어내는 순수한 함수에 적합합니다.

// static이 적합한 경우 - 필드 없이 입력값만으로 결과 반환
public class GenerateCodeUuid {
    public static String generateCodeUuid(String code) {
        return code + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
                + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 10).toUpperCase();
    }
}

// 인스턴스 생성 없이 바로 호출 가능
GenerateCodeUuid.generateCodeUuid("PMN-");

반면 상태(필드)를 갖는 경우는 인스턴스 메서드가 적합합니다.


13. 웹훅 FAILED 상태 재처리 허용 여부

❓ 질문

FAILED 상태인 웹훅이 재전송됐을 때,
재처리를 허용하는 것이 좋을까요, 차단하는 것이 좋을까요?

✅ 답변

재처리를 허용하는 것이 좋습니다!

PROCESSED → 재처리 차단 ✅ (이미 성공적으로 완료됨)
FAILED → 재처리 허용 ✅ (내부 오류로 실패했으므로 재시도 기회 제공)
// PROCESSED만 차단, FAILED는 통과
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
    return;
}

💡 FAILED는 우리 서버 내부 오류로 처리에 실패한 상태입니다.
PortOne이 친절하게 재전송해줬을 때 다시 시도할 기회를 주는 것이
사용자 입장에서 더 유리합니다!


14. 웹훅 중복 검증 결과를 사용하지 않은 문제

❓ 질문

existsByWebhookIdAndStatus()boolean을 반환하는데,
결과를 사용하지 않으면 어떻게 될까요?

✅ 답변

결과를 무시하면 중복 검증이 전혀 동작하지 않습니다!

// ❌ 잘못된 방식 - 결과를 무시
webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED);
// 조회만 하고 아무 처리 없이 다음 줄로 진행!

// ✅ 올바른 방식 - 결과를 if문으로 처리
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
    return; // 이미 처리된 웹훅 → 종료
}

15. 싱글톤 Bean에서 클래스 필드 사용 위험성

❓ 질문

WebhookService는 Spring Bean으로 싱글톤입니다.
webhook을 클래스 필드로 선언하면 여러 요청이 동시에 들어올 때 어떤 일이 생길까요?

✅ 답변

여러 요청이 같은 필드를 공유하므로 덮어쓰기 문제가 발생합니다!

사용자 A의 웹훅 처리 중 → webhook = A의 Webhook 객체
동시에 사용자 B의 웹훅 처리 → webhook = B의 Webhook 객체 (덮어씀!) 💥
사용자 A의 catch 블록 → webhook이 B의 객체로 바뀌어 있음!
// ❌ 잘못된 방식 - 클래스 필드
@Service
public class WebhookService {
    private Webhook webhook = null; // 모든 요청이 공유! 💥
}

// ✅ 올바른 방식 - 메서드 지역변수
public void handleWebhook(...) {
    Webhook webhook = null; // 요청마다 독립적인 변수 ✅
}

💡 Spring Bean 싱글톤 원칙
Spring Bean은 애플리케이션 전체에서 단 하나의 인스턴스만 존재합니다.
따라서 클래스 필드는 모든 요청이 공유하므로 상태를 저장하면 안 됩니다!


16. body()의 두 가지 역할 혼동

❓ 질문

RestClient에서 body()를 요청 바디 전송과 응답 바디 수신에 모두 사용할 수 있는데,
어떻게 구분해서 사용해야 할까요?

✅ 답변

body()위치에 따라 역할이 다릅니다!

portOneRestClient
    .post()
    .uri("/payments/{paymentId}/cancel", paymentUuid)
    .body(cancelRequest)                              // 1. retrieve() 전 → 요청 바디 전송
    .retrieve()
    .onStatus(...)
    .body(PortOneCancelPaymentResponse.class);        // 2. retrieve() 후 → 응답 바디 수신
위치역할파라미터
retrieve() 요청 바디 전송객체 직접 전달
retrieve() 응답 바디 수신.class 타입 전달

17. 보상 트랜잭션에서 플래그가 필요한 이유

❓ 질문

보상 트랜잭션에서 boolean pointUsed, boolean portOneConfirmed 플래그를
사용하는 이유가 무엇일까요? 왜 무조건 취소하지 않고 플래그로 체크할까요?

✅ 답변

아직 실행되지 않은 작업을 되돌리려 하면 새로운 오류가 발생하기 때문입니다!

시나리오: 포인트 차감 단계에서 실패 (잔액 부족)
        ↓
catch 블록 실행
        ↓
portOneConfirmed = false → cancelPayment() 호출 안 함 ✅
(PortOne 결제가 아직 안됐으므로 취소할 것도 없음!)
// ❌ 잘못된 방식 - 무조건 취소
} catch (Exception e) {
    portOneClient.cancelPayment(...);  // PortOne 결제가 안됐는데 취소 시도! 💥
    pointService.restorePoint(...);    // 포인트 차감도 안됐는데 복구 시도! 💥
}

// ✅ 올바른 방식 - 플래그로 체크
} catch (Exception e) {
    if (portOneConfirmed) {           // 실제로 PortOne 결제가 됐을 때만
        portOneClient.cancelPayment(...);
    }
    if (pointUsed) {                  // 실제로 포인트가 차감됐을 때만
        pointService.restorePoint(...);
    }
}

💡 "실제로 실행된 작업만 되돌린다" 는 보상 트랜잭션의 핵심 원칙!


18. Optional을 record 필드로 사용하면 안 되는 이유

❓ 질문

nullable한 필드에 Optional을 사용하면 어떤 문제가 생길까요?

✅ 답변

Optional메서드 반환 타입으로만 사용하도록 Java에서 설계되었습니다.

// ❌ 잘못된 방식 - record 필드에 Optional
public record PortOneCancelPaymentResponse(
    Optional<OffsetDateTime> cancelledAt  // Jackson 직렬화/역직렬화 문제! 💥
) {}

// ✅ 올바른 방식 - 그냥 nullable 타입으로 선언
public record PortOneCancelPaymentResponse(
    OffsetDateTime cancelledAt  // null이 올 수 있음, Jackson이 자동으로 null 처리
) {}

문제점:

  • Jackson이 Optional 필드를 올바르게 역직렬화하지 못할 수 있음
  • 직렬화 시 {"cancelledAt": {"present": true, "value": "..."}} 처럼 의도치 않은 형태로 출력될 수 있음

19. @Transactional이 private 메서드에 적용되지 않는 이유

❓ 질문

@Transactionalprivate 메서드에 붙이면 동작할까요?

✅ 답변

동작하지 않습니다!

Spring의 @TransactionalAOP 프록시 방식으로 동작합니다.
프록시는 외부에서 호출 가능한 public 메서드만 가로챌 수 있습니다.

// ❌ 동작 안 함
@Transactional
private void processPaymentConfirm(...) { }

// ✅ 올바른 방식
@Transactactional
public void confirmPayment(...) {       // public 메서드에 적용
    processPaymentConfirm(...);         // private 메서드는 트랜잭션 안에서 실행됨
}

💡 processPaymentConfirm()@Transactional이 붙은 confirmPayment() 안에서
호출되므로 같은 트랜잭션 컨텍스트 안에서 실행됩니다!


12. 오늘 놓쳤던 질문 모음 (20번~)

20. 트랜잭션 롤백 시점과 catch 블록 실행 순서

Q: @Transactionaltry-catch를 감싸고 있을 때 롤백은 언제 일어나나요?

try-catch 전체가 @Transactional 안에 있음
        ↓
catch 블록 먼저 실행 (보상 트랜잭션 처리)
        ↓
예외 재던짐
        ↓
@Transactional이 예외 감지 → 롤백!

catch 블록은 롤백 전에 실행됩니다! ✅


21. Spring Bean을 new로 생성하면 안 되는 이유

Q: PaymentFailureHandler handler = new PaymentFailureHandler()처럼 직접 생성하면 어떤 문제가 생기나요?

// ❌ new로 직접 생성
PaymentFailureHandler handler = new PaymentFailureHandler();
// portOneClient, paymentRollback 등 의존성이 주입되지 않음!
// → 사용 시 NullPointerException 💥

Spring Bean은 Spring이 직접 생성하고 의존성을 주입합니다.
반드시 @RequiredArgsConstructor로 주입받아서 사용해야 합니다!


22. 포인트 복구가 필요 없는 이유

Q: 결제 실패 시 포인트 복구(restorePoint)가 필요한가요?

포인트 차감은 DB 작업이므로 @Transactional 롤백 시 자동으로 원복됩니다!
catch 블록에서 추가로 restorePoint()를 호출하면 포인트가 오히려 증가합니다! 💥

보상 트랜잭션이 필요한 것: 외부 API(PortOne) 결제 승인
보상 트랜잭션이 불필요한 것: DB 작업 (포인트, 재고 차감)



✍️ 마무리 - 핵심 키워드 정리

키워드한 줄 요약
멱등성같은 요청이 여러 번 와도 결과는 한 번만
보상 트랜잭션외부 API는 DB 롤백이 안 되므로 직접 취소 API 호출
Rich Domain Model엔티티가 자신의 상태를 스스로 변경
도메인 경계다른 도메인의 Repository를 직접 호출하지 않음
OffsetDateTime결제 도메인에서 시간대 정보 보존 필수
Enum 분리외부 시스템 Enum과 내부 Enum은 반드시 분리
------
싱글톤 필드 위험Spring Bean 필드는 모든 요청이 공유 → 지역변수 사용
보상 트랜잭션 플래그실제로 실행된 작업만 되돌림
웹훅 200 OK실패해도 200 OK → PortOne 무한 재전송 방지
FAILED 재처리 허용FAILED는 재시도 기회 부여, PROCESSED만 차단
body() 위치retrieve() 앞 = 요청 전송, 뒤 = 응답 수신
Optional 필드 금지Optional은 메서드 반환 타입 전용
@Transactional + privateprivate 메서드에는 적용 불가, public에 붙여야 함

0개의 댓글