커머스 결제 백엔드 시스템 구현 프로젝트 - 결제 도메인 담당
결제창 일반/정기결제 V2 선택INIpayTest (이니시스 결제창 일반결제 및 API 일반결제)INIBillTst (구독/도전과제용, 별도 채널 추가)| 값 | 설명 | 형태 |
|---|---|---|
storeId | PortOne이 상점(계정) 전체를 식별하는 ID | store-xxxxxxxx-... |
channelKey | 생성한 채널 하나를 식별하는 키 | channel-key-xxxxxxxx-... |
API Secret | 서버가 PortOne REST API 호출 시 신원 증명 | 콘솔 > 식별코드·API Keys |
⚠️ API Secret이 노출되면 공격자가 결제 취소 API를 임의로 호출할 수 있습니다. 반드시
.env에 저장하고.gitignore에 추가하세요!
클라이언트 서버 PortOne(PG)
| | |
|── (1) 주문 생성 요청 ──→| |
|←── 주문ID 반환 ─────────| |
| | |
|── (2) 결제 시도 기록 ──→| PENDING 상태로 DB 저장 |
|←── OK ──────────────────| |
| | |
|── (3) 결제창 호출 ────────────────────────────────────→ |
|←── paymentId 반환 ─────────────────────────────────────|
| | |
|── (4-A) 결제 확정 요청 →| |
| |── (5) 결제 조회 ─────────────→|
| |←── 결제 상태/금액 ─────────────|
| |───── (6) 검증 + 재고차감 |
|←── (7) 결제 완료 응답 ──| + 상태 변경 |
"결제 확정의 유일한 근거는 PortOne 결제 조회 API 결과"
클라이언트가 보내온 금액(request.payAmount())은 신뢰할 수 없습니다!
spring-boot-starter-web에 RestClient 포함 ✅# 공통 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}
PORTONE_API_SECRET=실제값
PORTONE_STORE_ID=실제값
PORTONE_CHANNEL_KG=실제값
PORTONE_CHANNEL_TOSS=실제값
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 <- 팀원이 구현
@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;
}
}
@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 인증 방식
public record PortOnePaymentResponse(
String id,
PortOnePayStatus status, // String이 아닌 Enum으로 → Jackson이 자동 역직렬화
Amount amount,
OffsetDateTime paidAt // RFC 3339 → OffsetDateTime (시간대 정보 보존!)
) {
public record Amount(int total) {}
}
@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);
}
}
@Getter
@RequiredArgsConstructor
public enum PaidStatus {
PENDING("결제 대기"),
PROCESSING("결제 중"),
SUCCESS("결제 성공"),
FAILED("결제 실패"),
REFUNDED("환불 완료");
private final String title;
}
public enum PortOnePayStatus {
READY,
IN_PROGRESS,
PAID,
FAILED,
CANCELLED,
PARTIAL_CANCELLED
}
⚠️ 두 Enum을 반드시 분리해야 하는 이유
PaidStatus는 DB에 저장되는 값- 나중에 PG사를 교체하면 PortOne 상태값이 달라질 수 있음
- 하나로 합치면 DB에 저장된 값을 전부 마이그레이션해야 하는 상황 발생
@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;
}
}
SUCCESS 상태인데 paidAt이 null인 레코드가 생기는 것을 방지payment.confirmPayment() 호출만으로 의도가 명확public interface PaymentRepository extends JpaRepository<Payment, Long> {
// 결제 확정 요청 시 결제 레코드 조회
Optional<Payment> findByOrder(Order order);
// 멱등성 검증용 - 이미 SUCCESS인 결제가 있는지 확인
boolean existsByPortOneIdAndPaidStatus(String portOneId, PaidStatus paidStatus);
}
💡
order_idUnique 제약조건이 있기 때문에 같은 주문으로 두 번째 결제 레코드 INSERT 시 DB 레벨에서 차단됩니다!
@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());
}
// PortOne 결제는 성공했지만 내부 처리(재고 차감 등)가 실패한 경우
// try-catch로 감싸서 PortOne 결제 취소 API 호출 필요
try {
// ... 결제 확정 로직
} catch (Exception e) {
// PortOne 결제 취소 API 호출 (보상 트랜잭션)
// 결제 상태 → FAILED
// 주문 상태 → 결제 대기 유지
}
동일한 요청이 여러 번 들어와도 결과는 딱 한 번만 처리되어야 한다는 원칙
portOneId기준으로 이미SUCCESS인 결제가 있으면 중복 요청 차단- 웹훅과 Client Confirm이 동시에 도착해도 한 번만 처리
Dirty Checking: @Transactional 안에서 엔티티 변경 시 save() 없이도 UPDATE 쿼리 실행| Rich Domain Model | Anemic Domain Model | |
|---|---|---|
| 엔티티 | 상태 변경 로직 포함 | getter/setter만 존재 |
| 서비스 | "언제" 변경할지 결정 | 모든 비즈니스 로직 담당 |
| 권장 | ✅ | ❌ |
ORDINAL → DB에 숫자(0, 1, 2...)로 저장✅ PortOne 콘솔 설정
✅ application.yml 설정
✅ PortOneProperties
✅ PortOneConfig
✅ PortOneClient
✅ Payment 엔티티 설계
✅ PaymentRepository
✅ 결제 확정 서비스 (해피 패스)
⬜ 결제 시도 기록 API
⬜ 결제 확정 컨트롤러 (소유권 검증 포함)
⬜ 웹훅 엔드포인트
⬜ 보상 트랜잭션
⬜ 환불 로직
FAILED 상태를 반환해도 서버가 그냥 확정해버리는 상황 발생status == PAID 검증 후 진행!paidAt을 BaseEntity의 modifiedAt으로 대체하면?modifiedAt은 마지막 수정 시각 → 결제 확정 후 상태가 변경되면 덮어써짐paidAt으로 관리!2024-01-15T09:30:00+09:00) 사용LocalDateTime은 시간대 정보 소실OffsetDateTime 사용 권장!웹훅, 보상 트랜잭션, 환불, 포인트 연동 중심
JWT 토큰 → email 추출 → CustomUserDetails 생성 → SecurityContext 저장
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에 넣어야 합니다.
가장 많이 헷갈렸던 부분!
우리 서버가 paymentUuid 생성 (PMN-20240115-A1B2C3D4)
↓
클라이언트가 PortOne 결제창 호출 시 이 값을 paymentId로 전달
↓
PortOne이 결제 완료 후 동일한 값을 그대로 돌려줌
↓
Client Confirm → 클라이언트가 서버로 전달
Webhook → PortOne이 서버로 전달
| 값 | 생성 주체 | 시점 |
|---|---|---|
paymentUuid | 우리 서버 | 결제 시도 기록 시 |
PortOne의 paymentId | PortOne이 그대로 돌려줌 | 결제 완료 후 |
즉 둘은 같은 값입니다!
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 조회, 금액 검증, 재고 차감, 최종 확정
}
⚠️
@Transactional은private메서드에 적용 불가!
반드시public메서드인confirmPayment()와confirmPaymentByWebhook()에 붙여야 합니다.
사용자 브라우저 강제 종료 💥
↓
Client Confirm 미도달
↓
PortOne → 웹훅으로 결제 완료 알림
↓
서버가 웹훅으로 결제 확정 처리 ✅
@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이 재전송해줬을 때 다시 시도할 기회를 주는 것이 사용자에게 유리합니다!
@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();
}
}
}
@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가 아니면 웹훅을 무한 재전송합니다.
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)→ 응답 바디 수신
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 결제가 아직 안됐는데 취소 시도 → 새로운 오류 발생!
💡 "실제로 실행된 작업만 되돌린다" 는 원칙!
public Refund(Payment payment, String refundReason) {
this.payment = payment;
this.refundAmount = payment.getPaidAmount(); // 전액 환불이므로 자동 설정!
this.refundReason = refundReason;
this.refundStatus = RefundStatus.REQUEST;
}
💡 전액 환불만 구현하므로 외부에서 금액을 받지 않고
엔티티 스스로 결제 금액을 참조하는 것이 더 깔끔합니다!
// ❌ 잘못된 방식
@Service
public class WebhookService {
private Webhook webhook = null; // 동시 요청 시 덮어써짐! 💥
}
// ✅ 올바른 방식
public void handleWebhook(...) {
Webhook webhook = null; // 요청마다 독립적인 변수 ✅
}
Spring Bean은 싱글톤이므로 여러 요청이 동시에 들어오면
클래스 필드를 공유합니다!
// ❌ 잘못된 방식
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 void processPaymentConfirm(...) { }
// ✅ public 메서드에 적용
@Transactional
public void confirmPayment(...) {
processPaymentConfirm(...); // private 메서드 호출
}
✅ PortOne 콘솔 설정
✅ PortOneProperties / Config / Client (취소 API 포함)
✅ Payment 엔티티 설계
✅ 결제 시도 기록 API
✅ 결제 확정 서비스 + 컨트롤러
✅ 웹훅 구현
✅ 보상 트랜잭션 설계
⬜ 소유권 검증 완성
⬜ 보상 트랜잭션 코드 완성
⬜ 환불 서비스/컨트롤러
⬜ 포인트 연동 완성
⬜ 프론트엔드 연동 스펙 맞춰 DTO 수정
보상 트랜잭션 완성, 코드 점검, 포인트 전액 결제 처리 중심
@Transactional confirmPayment() 실행 중
↓
재고 차감 실패 💥
↓
catch 블록에서 payment.failPayment() 호출
↓
예외 재던짐 → @Transactional 롤백!
↓
payment.failPayment()도 롤백됨! 💥 DB에 여전히 PENDING 상태
// 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 | 기존 트랜잭션 중단, 새 트랜잭션 생성 |
// ❌ 잘못된 방식 - 같은 클래스 내부 호출
@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이 무시됩니다.
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)만 보상 트랜잭션 필요!
// ❌ 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(); // ✅ 접근 가능!
}
}
// ❌ 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); // 결과 반환!
}
// ❌ 잘못된 방식 - 원래 예외 정보 사라짐
} 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);
}
@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);
// 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(attributePaths = {
"order", // Payment → Order 한번에!
"order.user", // Order → User 한번에!
"order.orderItems", // Order → OrderItems 한번에!
"order.orderItems.product" // OrderItem → Product 한번에!
})
Optional<Payment> findByPortOneId(String portOneId);
// 단 1번의 JOIN 쿼리로 모든 연관 엔티티 조회! ✅
주문금액: 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(), "포인트로 결제가 완료되었습니다.");
}
// ❌ portOneId가 nullable = false인데 null 설정!
public <void preparePendingAttempt(int paidAmount) {
this.portOneId = null; // DB 저장 시 에러 발생! 💥
}
// ❌ 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));
// ❌ 프론트엔드 스펙은 paymentId인데
String portOneId;
// ✅ 스펙에 맞게
String paymentId;
| 필드 | 의미 |
|---|---|
total | 실제 결제된 금액 (포인트 할인 후 최종 금액) |
discount | 할인된 금액 (포인트 사용액 등) |
원래 주문 금액 = total + discount
실제 결제 금액 = total ← 이 값과 DB의 paidAmount를 비교해야 함!
💡
paidAmount에는 반드시 포인트 차감 후 실제 결제 금액을 저장해야 합니다!
그래야 PortOne 금액 검증이 정확하게 동작합니다.
결제 시스템 구현 과정에서 놓쳤거나 답변하지 못한 질문들을 정리합니다.
각 질문에 대한 답변과 핵심 개념을 함께 정리했습니다.
PUT은 리소스를 "수정"할 때, POST는 "생성" 또는 "행위(Action)"를 표현할 때 사용합니다.
결제 확정은 단순한 데이터 수정일까요, 아니면 하나의 "비즈니스 행위"일까요?
결제 확정은 비즈니스 행위(Action) 입니다.
단순히 DB의 상태 컬럼 하나를 수정하는 것이 아니라,
이 복합적인 작업이 동시에 일어나기 때문에 POST를 사용합니다.
POST /api/v1/payments/confirm ✅
PUT /api/v1/payments/confirm ❌
💡 실무에서도 복합적인 비즈니스 행위는 관례적으로
POST를 사용합니다.
@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에서 필수입니다!
@ConfigurationProperties 활성화 방법
@ConfigurationProperties(prefix = "portone")를 사용하면 yml의 값이 자동으로 매핑됩니다.
이 클래스를 Bean으로 활성화하려면 어떤 어노테이션이 추가로 필요할까요?
세 가지 방법이 있습니다.
| 방법 | 위치 | 설명 |
|---|---|---|
@Component | @ConfigurationProperties 클래스 위 | 가장 간단 ✅ |
@EnableConfigurationProperties(클래스.class) | @Configuration 클래스 위 | 명시적 등록 |
@ConfigurationPropertiesScan | 메인 클래스 위 | 자동 스캔 |
// 가장 간단한 방법
@Component
@ConfigurationProperties(prefix = "portone")
public class PortOneProperties { ... }
PortOne이
FAILED상태인 결제를 조회해서 반환했는데,
우리 서버가 상태 확인 없이 그냥 확정해버리면 어떻게 될까요?
결제가 실제로 실패했는데도 서버가 주문 완료 + 재고 차감을 처리해버리는 최악의 상황이 발생합니다.
// 반드시 추가해야 하는 검증!
if (portOnePaymentResponse.status() != PortOnePayStatus.PAID) {
throw new PaymentException(ErrorCode.PAYMENT_PORTONE_ERROR);
}
💡 PortOne 조회 API 호출 후 반드시 상태가 PAID인지 확인하고 진행해야 합니다!
save() vs Dirty Checking
paymentRepository.save()를 명시적으로 쓰는 것과
@TransactionalDirty Checking에 맡기는 것 중 어느 쪽이 더 좋을까요?
둘 다 동작하지만 상황에 따라 선택합니다.
| 방식 | 장점 | 단점 |
|---|---|---|
save() 명시 | 가독성 좋음, 의도가 명확 | 코드가 약간 길어짐 |
| Dirty Checking | 코드 간결 | 저장 여부가 암묵적 |
// payment는 명시적 save (가독성)
paymentRepository.save(payment);
// order는 Dirty Checking에 맡김 (도메인 경계 침범 방지)
// orderRepository.save(order) ← 호출하지 않아도 자동 저장됨
💡 도메인 경계:
PaymentService가orderRepository.save()를 직접 호출하면
Order 도메인을 침범하는 느낌이 생깁니다. 이런 경우 Dirty Checking이 더 자연스럽습니다.
@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);
}
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(...);
PortOne API를 호출하는 코드는 어느 계층에 두는 것이 좋을까요?
PaymentService안에 직접 작성하는 것과, 별도의 클래스로 분리하는 것의 차이는?
별도의 PortOneClient 클래스로 분리하는 것이 올바릅니다.
| 방식 | 문제점 |
|---|---|
| Service에 직접 작성 | PG사 교체 시 Service 코드 전체 수정 필요 |
PortOneClient로 분리 | PG사 교체 시 Client 클래스만 교체 ✅ |
@Component // Controller/Service/Repository가 아닌 경우 @Component 사용
public class PortOneClient {
// 외부 API 호출만 전담
}
LocalDateTime vs OffsetDateTimePortOne이 반환하는
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을 사용해야 합니다!
PortOne 상태값으로 내부 Enum을 통일하면 어떤 문제가 생길까요?
내부 PaidStatus는 DB에 저장되는 값입니다.
현재: DB에 "PAID", "CANCELLED" 저장 (PortOne 기준)
↓
나중에 토스페이먼츠로 교체 시 상태값이 다를 수 있음
↓
DB에 저장된 수백만 건의 데이터 마이그레이션 필요! 💥
올바른 분리
// 우리 시스템 내부 (DB 저장용)
PaidStatus: PENDING, SUCCESS, FAILED, REFUNDED
// PortOne 전용 (API 응답 역직렬화용)
PortOnePayStatus: READY, PAID, FAILED, CANCELLED, PARTIAL_CANCELLED
11번부터 시작 (1~10번은 Day 1 파일 참고)
@AuthenticationPrincipal의 타입 불일치 문제
JwtAuthenticationFilter에서 principal에email(String)을 넣고 있는데,
@AuthenticationPrincipal CustomUserDetails userDetails로 받으면 어떤 문제가 생길까요?
Spring이 String을 CustomUserDetails로 캐스팅하려다 런타임 에러가 발생합니다!
// JwtAuthenticationFilter에서 principal에 넣은 값
new UsernamePasswordAuthenticationToken(
email, // String을 넣었음
...
)
// 컨트롤러에서 받으려는 타입
@AuthenticationPrincipal CustomUserDetails userDetails // 💥 ClassCastException!
해결책: JwtAuthenticationFilter에서 CustomUserDetails 객체를 principal에 넣어야 합니다!
CustomUserDetails customUserDetails = new CustomUserDetails(user);
new UsernamePasswordAuthenticationToken(
customUserDetails, // CustomUserDetails 객체를 넣어야 함
...
)
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-");
반면 상태(필드)를 갖는 경우는 인스턴스 메서드가 적합합니다.
FAILED상태인 웹훅이 재전송됐을 때,
재처리를 허용하는 것이 좋을까요, 차단하는 것이 좋을까요?
재처리를 허용하는 것이 좋습니다!
PROCESSED → 재처리 차단 ✅ (이미 성공적으로 완료됨)
FAILED → 재처리 허용 ✅ (내부 오류로 실패했으므로 재시도 기회 제공)
// PROCESSED만 차단, FAILED는 통과
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
return;
}
💡
FAILED는 우리 서버 내부 오류로 처리에 실패한 상태입니다.
PortOne이 친절하게 재전송해줬을 때 다시 시도할 기회를 주는 것이
사용자 입장에서 더 유리합니다!
existsByWebhookIdAndStatus()는boolean을 반환하는데,
결과를 사용하지 않으면 어떻게 될까요?
결과를 무시하면 중복 검증이 전혀 동작하지 않습니다!
// ❌ 잘못된 방식 - 결과를 무시
webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED);
// 조회만 하고 아무 처리 없이 다음 줄로 진행!
// ✅ 올바른 방식 - 결과를 if문으로 처리
if (webhookRepository.existsByWebhookIdAndStatus(webhookId, WebhookStatus.PROCESSED)) {
return; // 이미 처리된 웹훅 → 종료
}
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은 애플리케이션 전체에서 단 하나의 인스턴스만 존재합니다.
따라서 클래스 필드는 모든 요청이 공유하므로 상태를 저장하면 안 됩니다!
RestClient에서body()를 요청 바디 전송과 응답 바디 수신에 모두 사용할 수 있는데,
어떻게 구분해서 사용해야 할까요?
body()는 위치에 따라 역할이 다릅니다!
portOneRestClient
.post()
.uri("/payments/{paymentId}/cancel", paymentUuid)
.body(cancelRequest) // 1. retrieve() 전 → 요청 바디 전송
.retrieve()
.onStatus(...)
.body(PortOneCancelPaymentResponse.class); // 2. retrieve() 후 → 응답 바디 수신
| 위치 | 역할 | 파라미터 |
|---|---|---|
retrieve() 앞 | 요청 바디 전송 | 객체 직접 전달 |
retrieve() 뒤 | 응답 바디 수신 | .class 타입 전달 |
보상 트랜잭션에서
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(...);
}
}
💡 "실제로 실행된 작업만 되돌린다" 는 보상 트랜잭션의 핵심 원칙!
nullable한 필드에
Optional을 사용하면 어떤 문제가 생길까요?
Optional은 메서드 반환 타입으로만 사용하도록 Java에서 설계되었습니다.
// ❌ 잘못된 방식 - record 필드에 Optional
public record PortOneCancelPaymentResponse(
Optional<OffsetDateTime> cancelledAt // Jackson 직렬화/역직렬화 문제! 💥
) {}
// ✅ 올바른 방식 - 그냥 nullable 타입으로 선언
public record PortOneCancelPaymentResponse(
OffsetDateTime cancelledAt // null이 올 수 있음, Jackson이 자동으로 null 처리
) {}
문제점:
Optional 필드를 올바르게 역직렬화하지 못할 수 있음{"cancelledAt": {"present": true, "value": "..."}} 처럼 의도치 않은 형태로 출력될 수 있음
@Transactional을private메서드에 붙이면 동작할까요?
동작하지 않습니다!
Spring의 @Transactional은 AOP 프록시 방식으로 동작합니다.
프록시는 외부에서 호출 가능한 public 메서드만 가로챌 수 있습니다.
// ❌ 동작 안 함
@Transactional
private void processPaymentConfirm(...) { }
// ✅ 올바른 방식
@Transactactional
public void confirmPayment(...) { // public 메서드에 적용
processPaymentConfirm(...); // private 메서드는 트랜잭션 안에서 실행됨
}
💡
processPaymentConfirm()은@Transactional이 붙은confirmPayment()안에서
호출되므로 같은 트랜잭션 컨텍스트 안에서 실행됩니다!
Q: @Transactional이 try-catch를 감싸고 있을 때 롤백은 언제 일어나나요?
try-catch 전체가 @Transactional 안에 있음
↓
catch 블록 먼저 실행 (보상 트랜잭션 처리)
↓
예외 재던짐
↓
@Transactional이 예외 감지 → 롤백!
catch 블록은 롤백 전에 실행됩니다! ✅
Q: PaymentFailureHandler handler = new PaymentFailureHandler()처럼 직접 생성하면 어떤 문제가 생기나요?
// ❌ new로 직접 생성
PaymentFailureHandler handler = new PaymentFailureHandler();
// portOneClient, paymentRollback 등 의존성이 주입되지 않음!
// → 사용 시 NullPointerException 💥
Spring Bean은 Spring이 직접 생성하고 의존성을 주입합니다.
반드시@RequiredArgsConstructor로 주입받아서 사용해야 합니다!
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 + private | private 메서드에는 적용 불가, public에 붙여야 함 |