비즈니스 요구사항
이커머스 시스템에서 주문(Order)은 다양한 결제 수단을 지원해야 한다
- 카드 결제 (Card Payment)
- 계좌 이체 (Bank Transfer)
- 가상 계좌 (Virtual Account)
- 카카오페이 (Kakao Pay)
- 네이버페이 (Naver Pay)
- 토스 (Toss)
각 결제 수단마다 필요한 데이터가 다르고, 별도의 테이블로 관리된다
설계 목표
조회를 하나의 API로, 저장/수정도 하나의 API로 통합하되, 내부 구현은 결제 타입별로 분리
Domain Model & Enum
JPA가 아닌 jOOQ를 사용
PaymentType
package com.shop.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@Getter
@RequiredArgsConstructor
public enum PaymentType {
CARD(1, "카드 결제"),
BANK_TRANSFER(2, "계좌 이체"),
VIRTUAL_ACCOUNT(3, "가상 계좌"),
KAKAO_PAY(4, "카카오페이"),
NAVER_PAY(5, "네이버페이"),
PAYPAL(6, "페이팔"),
TOSS(7, "토스");
private final int code;
private final String description;
public static PaymentType fromCode(int code) {
for (PaymentType type : values()) {
if (type.code == code) {
return type;
}
}
throw new IllegalArgumentException("Invalid payment type: " + code);
}
}
ResponseDTO(조회용)
OrderPaymentResponse (부모 클래스)
package com.shop.admin.model.payment;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "paymentType",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderPaymentCardRequest.class, name = "CARD"),
@JsonSubTypes.Type(value = OrderPaymentBankTransferRequest.class, name = "BANK_TRANSFER"),
@JsonSubTypes.Type(value = OrderPaymentVirtualAccountRequest.class, name = "VIRTUAL_ACCOUNT"),
@JsonSubTypes.Type(value = OrderPaymentKakaoPayRequest.class, name = "KAKAO_PAY"),
@JsonSubTypes.Type(value = OrderPaymentNaverPayRequest.class, name = "NAVER_PAY"),
@JsonSubTypes.Type(value = OrderPaymentTossRequest.class, name = "TOSS")
})
@Getter
@ToString
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "결제 정보 기본 클래스")
public abstract class OrderPaymentResponse<T> {
@Schema(description = "결제 타입 ("CARD":카드, "BANK_TRANSFER":계좌이체, "VIRTUAL_ACCOUNT":가상계좌, "KAKAO_PAY":카카오페이, "NAVER_PAY":네이버페이, "TOSS":토스)",
required = true)
private PaymentType paymentType;
@Schema(description = "주문 식별번호", required = true)
private Long orderId;
@Schema(description = "결제 상세 정보 (타입에 따라 다름)")
private T paymentDetail;
}
OrderPaymentCardResponse
package com.shop.admin.model.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@Getter
@ToString(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "카드 결제 정보")
public class OrderPaymentCardResponse extends OrderPaymentResponse<CardPaymentDetailResponse> {
@Schema(description = "자동 결제 사용 여부 (0: 미사용, 1: 사용)")
private int useAutoPayment;
@Schema(description = "카드사 코드")
private String cardCompanyCode;
public static OrderPaymentCardResponse of(OrderRecord orderRecord,
CardPaymentRecord cardPaymentRecord,
int paymentType) {
return OrderPaymentCardResponse.builder()
.paymentType(paymentType)
.orderId(orderRecord.getId())
.useAutoPayment(orderRecord.getUseAutoPayment())
.cardCompanyCode(orderRecord.getCardCompanyCode())
.paymentDetail(CardPaymentDetailResponse.from(cardPaymentRecord))
.build();
}
}
CardPaymentDetailResponse
package com.shop.admin.model.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "카드 결제 상세 정보")
public class CardPaymentDetailResponse {
@Schema(description = "카드 결제 식별번호")
private Long cardPaymentId;
@Schema(description = "카드 번호 (마스킹)")
private String cardNumber;
@Schema(description = "할부 개월 수")
private Integer installmentMonths;
@Schema(description = "카드 승인 번호")
private String approvalNumber;
@Schema(description = "PG사 거래 번호")
private String pgTransactionId;
public static CardPaymentDetailResponse from(CardPaymentRecord record) {
if (record == null) {
return null;
}
return CardPaymentDetailResponse.builder()
.cardPaymentId(record.getId())
.cardNumber(record.getCardNumber())
.installmentMonths(record.getInstallmentMonths())
.approvalNumber(record.getApprovalNumber())
.pgTransactionId(record.getPgTransactionId())
.build();
}
}
OrderPaymentVirtualAccountResponse
package com.shop.admin.model.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@Getter
@ToString(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "가상계좌 결제 정보")
public class OrderPaymentVirtualAccountResponse extends OrderPaymentResponse<VirtualAccountDetailResponse> {
@Schema(description = "입금 기한")
private String depositDeadline;
public static OrderPaymentVirtualAccountResponse of(OrderRecord orderRecord,
VirtualAccountRecord virtualAccountRecord,
int paymentType) {
return OrderPaymentVirtualAccountResponse.builder()
.paymentType(paymentType)
.orderId(orderRecord.getId())
.depositDeadline(orderRecord.getDepositDeadline())
.paymentDetail(VirtualAccountDetailResponse.from(virtualAccountRecord))
.build();
}
}
VirtualAccountDetailResponse
package com.shop.admin.model.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "가상계좌 상세 정보")
public class VirtualAccountDetailResponse {
@Schema(description = "가상계좌 식별번호")
private Long virtualAccountId;
@Schema(description = "은행 코드")
private String bankCode;
@Schema(description = "가상계좌 번호")
private String accountNumber;
@Schema(description = "예금주명")
private String accountHolder;
public static VirtualAccountDetailResponse from(VirtualAccountRecord record) {
if (record == null) {
return null;
}
return VirtualAccountDetailResponse.builder()
.virtualAccountId(record.getId())
.bankCode(record.getBankCode())
.accountNumber(record.getAccountNumber())
.accountHolder(record.getAccountHolder())
.build();
}
}
Request (저장/수정용)
Request에서 insert와 update는 비즈니스 로직상 dto를 나누지 않고 null로 구분지어 분기처리하였다
package com.shop.admin.request.payment;
import com.fasterxml.jackson.annotation.JsonSubTypes;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.shop.domain.enums.PaymentType;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "paymentType",
visible = true
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderPaymentCardRequest.class, name = "CARD"),
@JsonSubTypes.Type(value = OrderPaymentBankTransferRequest.class, name = "BANK_TRANSFER"),
@JsonSubTypes.Type(value = OrderPaymentVirtualAccountRequest.class, name = "VIRTUAL_ACCOUNT"),
@JsonSubTypes.Type(value = OrderPaymentKakaoPayRequest.class, name = "KAKAO_PAY"),
@JsonSubTypes.Type(value = OrderPaymentNaverPayRequest.class, name = "NAVER_PAY"),
@JsonSubTypes.Type(value = OrderPaymentTossRequest.class, name = "TOSS")
})
@Getter
@ToString
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "결제 정보 저장/수정 요청")
public abstract class OrderPaymentRequest<T> {
@Schema(description = "결제 타입", example = "CARD")
@NotNull(message = "결제 타입은 필수입니다")
private PaymentType paymentType;
@Schema(description = "주문 식별번호", example = "1001")
@NotNull(message = "주문 식별번호는 필수입니다")
private Long orderId;
@Valid
@Schema(description = "결제 상세 정보")
private T paymentDetail;
}
OrderPaymentCardRequest
package com.shop.admin.request.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import kr.co.tablero.admin.validation.annotation.BooleanFlag;
import com.shop.jpa.enums.YesNo;
import com.shop.jpa.request.admin.payment.OrderCardPaymentRequest;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@Getter
@ToString(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "카드 결제 정보 저장/수정 요청")
public class OrderPaymentCardRequest extends OrderPaymentRequest<CardPaymentDetailRequest> {
@Schema(description = "자동 결제 사용 여부 (0: 미사용, 1: 사용)")
@NotNull(message = "자동 결제 사용 여부는 필수입니다")
@BooleanFlag(message = "자동 결제 사용 여부는 0 또는 1이어야 합니다")
private Integer useAutoPayment;
@Schema(description = "카드사 코드")
@NotBlank(message = "카드사 코드는 필수입니다")
private String cardCompanyCode;
public OrderCardPaymentRequest toOrderCardPaymentRequest() {
return OrderCardPaymentRequest.builder()
.cardCompanyCode(this.cardCompanyCode)
.useAutoPayment(YesNo.fromCode(this.useAutoPayment))
.build();
}
}
CardPaymentDetailRequest
package com.shop.admin.request.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
import com.shop.jpa.request.admin.payment.CardPaymentRequest;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "카드 결제 상세 정보 저장/수정 요청")
public class CardPaymentDetailRequest {
@Schema(description = "카드 결제 식별번호 (수정 시 필수, 생성 시 null)")
private Long cardPaymentId;
@Schema(description = "카드 번호", example = "1234-5678-****-****")
@NotBlank(message = "카드 번호는 필수입니다")
@Pattern(regexp = "^\\d{4}-\\d{4}-\\*{4}-\\*{4}$",
message = "카드 번호 형식이 올바르지 않습니다")
private String cardNumber;
@Schema(description = "할부 개월 수 (0: 일시불)", example = "0")
@NotNull(message = "할부 개월 수는 필수입니다")
@Min(value = 0, message = "할부 개월 수는 0 이상이어야 합니다")
@Max(value = 36, message = "할부 개월 수는 36 이하여야 합니다")
private Integer installmentMonths;
@Schema(description = "카드 승인 번호", example = "12345678")
@NotBlank(message = "카드 승인 번호는 필수입니다")
private String approvalNumber;
@Schema(description = "PG사 거래 번호", example = "PG202501091234567890")
@NotBlank(message = "PG사 거래 번호는 필수입니다")
private String pgTransactionId;
public CardPaymentRequest toCardPaymentRequest() {
return CardPaymentRequest.builder()
.cardPaymentId(this.cardPaymentId)
.cardNumber(this.cardNumber)
.installmentMonths(this.installmentMonths)
.approvalNumber(this.approvalNumber)
.pgTransactionId(this.pgTransactionId)
.build();
}
}
OrderPaymentVirtualAccountRequest
package com.shop.admin.request.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
import lombok.experimental.SuperBuilder;
@Getter
@ToString(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "가상계좌 결제 정보 저장/수정 요청")
public class OrderPaymentVirtualAccountRequest extends OrderPaymentRequest<VirtualAccountDetailRequest> {
@Schema(description = "입금 기한", example = "2025-01-15 23:59:59")
@NotBlank(message = "입금 기한은 필수입니다")
private String depositDeadline;
}
VirtualAccountDetailRequest
package com.shop.admin.request.payment;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import com.shop.jpa.request.admin.payment.VirtualAccountRequest;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Getter
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "가상계좌 상세 정보 저장/수정 요청")
public class VirtualAccountDetailRequest {
@Schema(description = "가상계좌 식별번호 (수정 시 필수, 생성 시 null)")
private Long virtualAccountId;
@Schema(description = "은행 코드", example = "004")
@NotBlank(message = "은행 코드는 필수입니다")
private String bankCode;
@Schema(description = "가상계좌 번호", example = "123456789012")
@NotBlank(message = "가상계좌 번호는 필수입니다")
private String accountNumber;
@Schema(description = "예금주명", example = "쇼핑몰")
@NotBlank(message = "예금주명은 필수입니다")
private String accountHolder;
public VirtualAccountRequest toVirtualAccountRequest() {
return VirtualAccountRequest.builder()
.virtualAccountId(this.virtualAccountId)
.bankCode(this.bankCode)
.accountNumber(this.accountNumber)
.accountHolder(this.accountHolder)
.build();
}
}
Handler Interface & Registry
PaymentHandler
package com.shop.admin.handler.payment;
import com.shop.admin.model.payment.OrderPaymentView;
import com.shop.admin.request.payment.OrderPaymentRequest;
public interface PaymentHandler<V extends OrderPaymentView<?>, R extends OrderPaymentRequest<?>> {
/**
* 지원하는 결제 타입 코드
*/
int getSupportedPaymentType();
/**
* 결제 정보 조회
*/
V getOrderPaymentView(Long orderId);
/**
* 결제 정보 저장/수정
*/
void saveOrderPayment(R request);
}
PaymentHandlerRegistry
package com.shop.admin.handler.payment;
import com.shop.admin.model.payment.OrderPaymentView;
import com.shop.admin.request.payment.OrderPaymentRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
@Slf4j
@Component
public class PaymentHandlerRegistry {
private final Map<Integer, PaymentHandler<? extends OrderPaymentView<?>, ? extends OrderPaymentRequest<?>>> handlers;
public PaymentHandlerRegistry(
List<PaymentHandler<? extends OrderPaymentView<?>, ? extends OrderPaymentRequest<?>>> handlerList) {
this.handlers = handlerList.stream()
.collect(Collectors.toMap(
PaymentHandler::getSupportedPaymentType,
Function.identity()
));
log.info("Registered Payment handlers: {}", handlers.keySet());
}
@SuppressWarnings("unchecked")
public <V extends OrderPaymentView<?>, R extends OrderPaymentRequest<?>>
PaymentHandler<V, R> getHandler(int paymentType) {
PaymentHandler<? extends OrderPaymentView<?>, ? extends OrderPaymentRequest<?>> handler =
handlers.get(paymentType);
if (handler == null) {
throw new IllegalArgumentException("Unsupported payment type: " + paymentType);
}
return (PaymentHandler<V, R>) handler;
}
}
Handler Implementations
CardPaymentHandler
package com.shop.admin.handler.payment;
import com.shop.admin.exception.CustomException;
import com.shop.admin.exception.enums.OrderError;
import com.shop.admin.exception.enums.PaymentError;
import com.shop.admin.model.payment.OrderPaymentCardView;
import com.shop.admin.query.payment.OrderPaymentCardRepository;
import com.shop.admin.request.payment.OrderPaymentCardRequest;
import com.shop.admin.request.payment.CardPaymentDetailRequest;
import com.shop.jooq.tables.records.OrderRecord;
import com.shop.jooq.tables.records.CardPaymentRecord;
import com.shop.jpa.entity.order.OrderEntity;
import com.shop.jpa.entity.payment.CardPaymentEntity;
import com.shop.jpa.enums.YesNo;
import com.shop.jpa.repository.order.OrderRepository;
import com.shop.jpa.repository.payment.CardPaymentRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class CardPaymentHandler implements PaymentHandler<OrderPaymentCardView, OrderPaymentCardRequest> {
private final OrderPaymentCardRepository orderPaymentCardRepository;
private final OrderRepository orderRepository;
private final CardPaymentRepository cardPaymentRepository;
@Override
public int getSupportedPaymentType() {
return 1; // CARD
}
@Override
public OrderPaymentCardView getOrderPaymentView(Long orderId) {
log.debug("Getting Card Payment data for orderId: {}", orderId);
int supportedPaymentType = getSupportedPaymentType();
OrderRecord orderRecord = orderPaymentCardRepository.findByIdCard(orderId);
if (orderRecord == null) {
throw new CustomException(OrderError.MISMATCH_PAYMENT_TYPE);
}
CardPaymentRecord cardPaymentRecord =
orderPaymentCardRepository.findByOrderIdCardPayment(orderId);
return OrderPaymentCardView.of(orderRecord, cardPaymentRecord, supportedPaymentType);
}
@Override
@Transactional
public void saveOrderPayment(OrderPaymentCardRequest request) {
if (request.getPaymentDetail() == null) {
throw new CustomException(PaymentError.CARD_PAYMENT_DETAIL_REQUIRED);
}
Long orderId = request.getOrderId();
if (!orderRepository.existsByIdAndPaymentType(orderId, request.getPaymentType())) {
throw new CustomException(OrderError.MISMATCH_PAYMENT_TYPE);
}
// 1. Order 테이블 업데이트 (공통 정보)
OrderEntity orderEntity = orderRepository.findById(orderId)
.orElseThrow(() -> new CustomException(OrderError.NOT_FOUND_ORDER));
orderEntity.updateCardPaymentInfo(request.toOrderCardPaymentRequest());
// 2. CardPayment 테이블 저장/수정 (전용 정보)
CardPaymentDetailRequest paymentDetail = request.getPaymentDetail();
if (paymentDetail.getCardPaymentId() == null) {
insertCardPayment(orderId, paymentDetail);
} else {
updateCardPayment(orderId, paymentDetail);
}
}
private void insertCardPayment(Long orderId, CardPaymentDetailRequest request) {
if (cardPaymentRepository.existsByOrderId(orderId)) {
throw new CustomException(PaymentError.CARD_PAYMENT_ALREADY_EXISTS);
}
CardPaymentEntity cardPaymentEntity = CardPaymentEntity.builder()
.orderId(orderId)
.cardNumber(request.getCardNumber())
.installmentMonths(request.getInstallmentMonths())
.approvalNumber(request.getApprovalNumber())
.pgTransactionId(request.getPgTransactionId())
.build();
cardPaymentRepository.save(cardPaymentEntity);
}
private void updateCardPayment(Long orderId, CardPaymentDetailRequest request) {
CardPaymentEntity cardPaymentEntity =
cardPaymentRepository.findByIdAndOrderId(request.getCardPaymentId(), orderId)
.orElseThrow(() -> new CustomException(PaymentError.CARD_PAYMENT_NOT_FOUND));
cardPaymentEntity.updateCardPayment(request.toCardPaymentRequest());
}
}
VirtualAccountPaymentHandler
package com.shop.admin.handler.payment;
import com.shop.admin.exception.CustomException;
import com.shop.admin.exception.enums.OrderError;
import com.shop.admin.exception.enums.PaymentError;
import com.shop.admin.model.payment.OrderPaymentVirtualAccountView;
import com.shop.admin.query.payment.OrderPaymentVirtualAccountRepository;
import com.shop.admin.request.payment.OrderPaymentVirtualAccountRequest;
import com.shop.admin.request.payment.VirtualAccountDetailRequest;
import com.shop.jooq.tables.records.OrderRecord;
import com.shop.jooq.tables.records.VirtualAccountRecord;
import com.shop.jpa.entity.order.OrderEntity;
import com.shop.jpa.entity.payment.VirtualAccountEntity;
import com.shop.jpa.repository.order.OrderRepository;
import com.shop.jpa.repository.payment.VirtualAccountRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class VirtualAccountPaymentHandler
implements PaymentHandler<OrderPaymentVirtualAccountView, OrderPaymentVirtualAccountRequest> {
private final OrderPaymentVirtualAccountRepository orderPaymentVirtualAccountRepository;
private final OrderRepository orderRepository;
private final VirtualAccountRepository virtualAccountRepository;
@Override
public int getSupportedPaymentType() {
return 3; // VIRTUAL_ACCOUNT
}
@Override
public OrderPaymentVirtualAccountView getOrderPaymentView(Long orderId) {
log.debug("Getting Virtual Account Payment data for orderId: {}", orderId);
int supportedPaymentType = getSupportedPaymentType();
OrderRecord orderRecord = orderPaymentVirtualAccountRepository.findByIdVirtualAccount(orderId);
if (orderRecord == null) {
throw new CustomException(OrderError.MISMATCH_PAYMENT_TYPE);
}
VirtualAccountRecord virtualAccountRecord =
orderPaymentVirtualAccountRepository.findByOrderIdVirtualAccount(orderId);
return OrderPaymentVirtualAccountView.of(
orderRecord, virtualAccountRecord, supportedPaymentType);
}
@Override
@Transactional
public void saveOrderPayment(OrderPaymentVirtualAccountRequest request) {
if (request.getPaymentDetail() == null) {
throw new CustomException(PaymentError.VIRTUAL_ACCOUNT_DETAIL_REQUIRED);
}
Long orderId = request.getOrderId();
if (!orderRepository.existsByIdAndPaymentType(orderId, request.getPaymentType())) {
throw new CustomException(OrderError.MISMATCH_PAYMENT_TYPE);
}
// Order 테이블 업데이트
OrderEntity orderEntity = orderRepository.findById(orderId)
.orElseThrow(() -> new CustomException(OrderError.NOT_FOUND_ORDER));
orderEntity.updateDepositDeadline(request.getDepositDeadline());
// VirtualAccount 테이블 저장/수정
VirtualAccountDetailRequest paymentDetail = request.getPaymentDetail();
if (paymentDetail.getVirtualAccountId() == null) {
insertVirtualAccount(orderId, paymentDetail);
} else {
updateVirtualAccount(orderId, paymentDetail);
}
}
private void insertVirtualAccount(Long orderId, VirtualAccountDetailRequest request) {
if (virtualAccountRepository.existsByOrderId(orderId)) {
throw new CustomException(PaymentError.VIRTUAL_ACCOUNT_ALREADY_EXISTS);
}
VirtualAccountEntity virtualAccountEntity = VirtualAccountEntity.builder()
.orderId(orderId)
.bankCode(request.getBankCode())
.accountNumber(request.getAccountNumber())
.accountHolder(request.getAccountHolder())
.build();
virtualAccountRepository.save(virtualAccountEntity);
}
private void updateVirtualAccount(Long orderId, VirtualAccountDetailRequest request) {
VirtualAccountEntity virtualAccountEntity =
virtualAccountRepository.findByIdAndOrderId(
request.getVirtualAccountId(), orderId)
.orElseThrow(() -> new CustomException(
PaymentError.VIRTUAL_ACCOUNT_NOT_FOUND));
virtualAccountEntity.updateVirtualAccount(request.toVirtualAccountRequest());
}
}
Controller & Service
OrderPaymentController
package com.shop.admin.controller;
import com.shop.admin.model.payment.OrderPaymentView;
import com.shop.admin.request.payment.OrderPaymentRequest;
import com.shop.admin.service.OrderPaymentService;
import com.shop.common.response.DataResponse;
import com.shop.common.response.MessageResponse;
import com.shop.domain.enums.PaymentType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Order Payment", description = "주문 결제 정보 API")
@RestController
@RequestMapping("/api/admin/orders")
@RequiredArgsConstructor
public class OrderPaymentController {
private final OrderPaymentService orderPaymentService;
@Operation(summary = "주문 결제 정보 조회")
@GetMapping("/{orderId}/payment/{paymentType}")
public DataResponse<OrderPaymentView<?>> getOrderPayment(
@Parameter(description = "주문 식별번호")
@PathVariable("orderId") Long orderId,
@Parameter(description = "결제 타입")
@PathVariable("paymentType") PaymentType paymentType) {
OrderPaymentView<?> orderPaymentView =
orderPaymentService.getOrderPayment(orderId, paymentType);
return new DataResponse<>(orderPaymentView);
}
@Operation(summary = "주문 결제 정보 저장/수정")
@PutMapping("/{orderId}/payment")
public MessageResponse saveOrderPayment(
@Parameter(description = "주문 식별번호")
@PathVariable("orderId") Long orderId,
@Valid @RequestBody OrderPaymentRequest<?> request) {
orderPaymentService.saveOrderPayment(orderId, request);
return new MessageResponse("결제 정보가 성공적으로 저장되었습니다");
}
}
OrderPaymentService
package com.shop.admin.service;
import com.shop.admin.exception.CustomException;
import com.shop.admin.exception.enums.OrderError;
import com.shop.admin.handler.payment.PaymentHandler;
import com.shop.admin.handler.payment.PaymentHandlerRegistry;
import com.shop.admin.model.payment.OrderPaymentView;
import com.shop.admin.query.order.OrderQueryRepository;
import com.shop.admin.request.payment.OrderPaymentRequest;
import com.shop.domain.enums.PaymentType;
import com.shop.jooq.tables.records.OrderRecord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderPaymentService {
private final OrderQueryRepository orderQueryRepository;
private final PaymentHandlerRegistry paymentHandlerRegistry;
@Transactional(readOnly = true)
public OrderPaymentView<?> getOrderPayment(Long orderId, PaymentType paymentType) {
int paymentTypeCode = paymentType.getCode();
OrderRecord orderRecord = orderQueryRepository.findByIdAndPaymentType(orderId, paymentTypeCode);
if (orderRecord == null) {
throw new CustomException(OrderError.MISMATCH_PAYMENT_TYPE);
}
PaymentHandler<? extends OrderPaymentView<?>, ? extends OrderPaymentRequest<?>> handler =
paymentHandlerRegistry.getHandler(paymentTypeCode);
return handler.getOrderPaymentView(orderId);
}
@Transactional
public void saveOrderPayment(Long orderId, OrderPaymentRequest<?> request) {
// 요청된 orderId와 DTO의 orderId 일치 검증
if (!orderId.equals(request.getOrderId())) {
throw new CustomException(OrderError.MISMATCH_ORDER_ID);
}
int paymentTypeCode = request.getPaymentType().getCode();
PaymentHandler<? extends OrderPaymentView<?>, ? extends OrderPaymentRequest<?>> handler =
paymentHandlerRegistry.getHandler(paymentTypeCode);
handler.saveOrderPayment(request);
}
}
핵심 설계 원칙 정리
- API 일관성: 클라이언트는 결제 타입만 변경하면 동일한 방식으로 호출이 가능하다
- 개방-패쇄 원칙: 새로운 결제 수단 추가 시 기존 코드를 수정할 필요가 없다
- 단일 책임 원칙: 각 Handler는 하나의 결제 타입만 담당한다
- 테스트 용이성: 결제 타입별로 독립적인 단위 테스트가 가능하다
- 도메인 캡슐화: 내부 테이블 구조를 외부에 노출하지 않아도 된다
시나리오별 처리
| 시나리오 | 테이블 | Hanlder 동작 |
| 공통 정보만 | orders | OrderEntity만 업데이트 |
| 공통 + 상세 | orders + card_payment | @Transactional로 테이블 순차 처리 |
| 상세 정보만 | card_payment | 결제 상세 테이블만 업데이트 |
트랜잭션 관리
@Transactional
public void saveOrderPayment(OrderPaymentCardRequest request) {
// 1. Order 공통 정보 업데이트
OrderEntity orderEntity = orderRepository.findById(orderId)
.orElseThrow();
orderEntity.updateCardPaymentInfo(request.toOrderCardPaymentRequest());
// 2. CardPayment 상세 정보 저장/수정
if (request.getPaymentDetail().getCardPaymentId() == null) {
insertCardPayment(orderId, request.getPaymentDetail());
} else {
updateCardPayment(orderId, request.getPaymentDetail());
}
// 하나라도 실패하면 전체 롤백
}
API 사용 예시
조회 API
GET /api/admin/orders/1001/payment/CARD
# Response
{
"data": {
"paymentType": 1,
"orderId": 1001,
"useAutoPayment": 1,
"cardCompanyCode": "KB",
"paymentDetail": {
"cardPaymentId": 501,
"cardNumber": "1234-5678-****-****",
"installmentMonths": 3,
"approvalNumber": "12345678",
"pgTransactionId": "PG202501091234567890"
}
}
}
저장/수정 API
PUT /api/admin/orders/1001/payment
# Request Body
{
"paymentType": "CARD",
"orderId": 1001,
"useAutoPayment": 1,
"cardCompanyCode": "KB",
"paymentDetail": {
"cardPaymentId": null, // null이면 INSERT
"cardNumber": "1234-5678-****-****",
"installmentMonths": 3,
"approvalNumber": "12345678",
"pgTransactionId": "PG202501091234567890"
}
}
Jackson 다형성 어노테이션과 Lombok
@JsonTypeInfo & @JsonSubTypes의 이점
핵심 개념
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME, // 1. 타입을 이름(name)으로 식별
include = JsonTypeInfo.As.EXISTING_PROPERTY, // 2. 기존 속성을 타입 식별자로 사용
property = "paymentType", // 3. 어떤 필드를 타입 식별자로 쓸지 지정
visible = true // 4. JSON에 타입 정보를 노출
)
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderPaymentCardView.class, name = "CARD"),
@JsonSubTypes.Type(value = OrderPaymentVirtualAccountView.class, name = "VIRTUAL_ACCOUNT"),
// ... 각 타입별 매핑
})
실무에서의 핵심 이점
Swagger 자동 문서화 – 협업
이 어노테이션 조합을 사용하면 Swagger(OpenAPI)가 자동으로 타입별 스키마를 분리해서 보여준다
without @JsonTypeInfo (일반적인 경우)
# Swagger Schema - 협업자가 혼란스러워함
OrderPaymentView:
type: object
properties:
paymentType: integer
orderId: integer
paymentDetail: object # 어떤 구조인지 알 수 없음
With @JsonTypeInfo (다형성 적용)
# Swagger Schema - 명확한 타입별 구조
OrderPaymentView:
oneOf:
- $ref: '#/components/schemas/OrderPaymentCardView'
- $ref: '#/components/schemas/OrderPaymentVirtualAccountView'
- $ref: '#/components/schemas/OrderPaymentKakaoPayView'
discriminator:
propertyName: paymentType
mapping:
"1": '#/components/schemas/OrderPaymentCardView'
"3": '#/components/schemas/OrderPaymentVirtualAccountView'
"4": '#/components/schemas/OrderPaymentKakaoPayView'
OrderPaymentCardView:
allOf:
- $ref: '#/components/schemas/OrderPaymentView'
- type: object
properties:
useAutoPayment:
type: integer
description: "자동 결제 사용 여부"
cardCompanyCode:
type: string
description: "카드사 코드"
paymentDetail:
$ref: '#/components/schemas/CardPaymentDetailView'
CardPaymentDetailView:
type: object
properties:
cardPaymentId:
type: integer
description: "카드 결제 식별번호"
cardNumber:
type: string
description: "카드 번호 (마스킹)"
example: "1234-5678-****-****"
installmentMonths:
type: integer
description: "할부 개월 수"
# ... 나머지 필드들
프론트엔드 개발자 입장에서의 장점
// Swagger에서 자동 생성된 TypeScript 타입
interface OrderPaymentCardView {
paymentType: 1;
orderId: number;
useAutoPayment: 0 | 1;
cardCompanyCode: string;
paymentDetail: {
cardPaymentId: number;
cardNumber: string; // 명확하게 어떤 필드가 있는지 알 수 있음!
installmentMonths: number;
approvalNumber: string;
pgTransactionId: string;
};
}
interface OrderPaymentVirtualAccountView {
paymentType: 3;
orderId: number;
depositDeadline: string;
paymentDetail: {
virtualAccountId: number;
bankCode: string;
accountNumber: string;
accountHolder: string;
};
}
// IDE가 자동완성을 정확하게 제공!
function renderPayment(payment: OrderPaymentCardView | OrderPaymentVirtualAccountView) {
if (payment.paymentType === 1) {
// TypeScript가 자동으로 타입을 좁혀줌 (Type Narrowing)
console.log(payment.paymentDetail.cardNumber); // OK
console.log(payment.depositDeadline); // 컴파일 에러 - 타입 안전!
}
}
자동 직렬화/역직렬화 – 코드 간결성
Without @JsonTypeInfo
// 수동으로 타입별 변환 로직 작성 필요
@PutMapping("/payment")
public void savePayment(@RequestBody Map<String, Object> rawData) {
int paymentType = (int) rawData.get("paymentType");
OrderPaymentRequest<?> request;
if (paymentType == 1) {
request = objectMapper.convertValue(rawData, OrderPaymentCardRequest.class);
} else if (paymentType == 3) {
request = objectMapper.convertValue(rawData, OrderPaymentVirtualAccountRequest.class);
} else if (paymentType == 4) {
request = objectMapper.convertValue(rawData, OrderPaymentKakaoPayRequest.class);
}
// ... 7개 타입 전부 if-else로 처리해야 함
}
With @JsonTypeInfo
// Jackson이 자동으로 올바른 타입으로 변환
@PutMapping("/payment")
public void savePayment(@RequestBody OrderPaymentRequest<?> request) {
// paymentType 값에 따라 자동으로
// OrderPaymentCardRequest, OrderPaymentVirtualAccountRequest 등으로 변환됨
paymentService.save(request);
}
타입 안전성 보장
// JSON 요청
{
"paymentType": "CARD",
"orderId": 1001,
"cardCompanyCode": "KB",
"paymentDetail": {
"bankCode": "004" // 카드 결제인데 은행 코드?
}
}
// Jackson이 자동으로 검증
// JsonMappingException 발생
// "Unrecognized field 'bankCode' (class OrderPaymentCardRequest)"
API 버저관리와 하위 호환성
@JsonSubTypes({
@JsonSubTypes.Type(value = OrderPaymentCardView.class, name = "1"),
@JsonSubTypes.Type(value = OrderPaymentCardV2View.class, name = "1_v2"), // 새 버전 추가
@JsonSubTypes.Type(value = OrderPaymentVirtualAccountView.class, name = "3"),
})
// 기존 API는 그대로 유지하면서 새 버전 추가 가능!
각 속성의 역할
| 속성 | 값 | 의미 | 효과 |
| use | JsonTypeInfo.Id.NAME | 문자열/숫자로 타입 식별 | @JsonSubTypes의 name과 매칭 |
| include | EXISTING_PROPERTY | 기존 필드를 타입 식별자로 사용 | 별도의 @type 필드가 생기지 않음 |
| property | “paymentType” | 어떤 필드로 타입을 구분할지 | paymentType 값으로 클래스 결정 |
| visible | true | JSON에 타입 정보 포함 | 응답 JSON에 paymentType 노출 |
visible = true의 중요성
// visible = true (권장)
{
"paymentType": 1, // 타입 정보가 보임
"orderId": 1001,
"cardCompanyCode": "KB"
}
// visible = false
{
"orderId": 1001, // paymentType이 사라져서
"cardCompanyCode": "KB" // 프론트엔드가 타입을 알 수 없음
}
@SuperBuilder의 필요성
문제 상황
일반 @Builder는 상속 구조에서 부모 필드를 포함한 빌더를 만들 수 없다
// @Builder만 사용한 경우
@Getter
@Builder
public abstract class OrderPaymentView<T> {
private int paymentType;
private Long orderId;
private T paymentDetail;
}
@Getter
@Builder // 문제
public class OrderPaymentCardView extends OrderPaymentView<CardPaymentDetailView> {
private int useAutoPayment;
private String cardCompanyCode;
}
// 사용 시
OrderPaymentCardView view = OrderPaymentCardView.builder()
.useAutoPayment(1)
.cardCompanyCode("KB")
.build();
// 컴파일 에러: paymentType, orderId를 설정할 수 없음
@SuperBuilder의 해결책
// @SuperBuilder 사용
@Getter
@SuperBuilder // 부모
public abstract class OrderPaymentView<T> {
private int paymentType;
private Long orderId;
private T paymentDetail;
}
@Getter
@SuperBuilder // 자식
public class OrderPaymentCardView extends OrderPaymentView<CardPaymentDetailView> {
private int useAutoPayment;
private String cardCompanyCode;
}
// 부모와 자식 필드 모두 설정 가능
OrderPaymentCardView view = OrderPaymentCardView.builder()
.paymentType(1) // 부모 필드
.orderId(1001L) // 부모 필드
.useAutoPayment(1) // 자식 필드
.cardCompanyCode("KB") // 자식 필드
.paymentDetail(detail) // 부모 필드
.build();
실무 활용 예시
public static OrderPaymentCardView of(OrderRecord orderRecord,
CardPaymentRecord cardPaymentRecord,
int paymentType) {
return OrderPaymentCardView.builder()
// 부모 필드 (OrderPaymentView)
.paymentType(paymentType)
.orderId(orderRecord.getId())
.paymentDetail(CardPaymentDetailView.from(cardPaymentRecord))
// 자식 필드 (OrderPaymentCardView)
.useAutoPayment(orderRecord.getUseAutoPayment())
.cardCompanyCode(orderRecord.getCardCompanyCode())
.build();
}
@SuperBuilder와 @Builder 비교
| 특징 | @Builder | @SuperBuilder |
| 단일 클래스 | 완벽 지원 | 완벽 지원 |
| 상속 구조 | 부모 필드 설정 불가 | 부모 + 자식 필드 모두 설정 가능 |
| 빌더 체이닝 | 지원 | 지원 |
| IDE 자동완성 | 지원 | 지원 |
| 추가 의존성 | 없음 | 없음 (Lombok 내장) |
@ToString(callSuper = true)의 중요성
문제 상황
@Getter
@ToString // callSuper = false (기본값)
public class OrderPaymentCardView extends OrderPaymentView<CardPaymentDetailView> {
private int useAutoPayment;
private String cardCompanyCode;
}
OrderPaymentCardView view = /* ... */;
System.out.println(view);
// 출력: OrderPaymentCardView(useAutoPayment=1, cardCompanyCode=KB)
// 부모 필드(paymentType, orderId)가 출력되지 않음
callSuper = true의 해결책
@Getter
@ToString(callSuper = true) // 부모의 toString()도 호출
public class OrderPaymentCardView extends OrderPaymentView<CardPaymentDetailView> {
private int useAutoPayment;
private String cardCompanyCode;
}
OrderPaymentCardView view = /* ... */;
System.out.println(view);
// 출력: OrderPaymentCardView(
// super=OrderPaymentView(paymentType=1, orderId=1001, paymentDetail=...),
// useAutoPayment=1,
// cardCompanyCode=KB
// )
실무에서 중요한 이유
디버깅 효과
@Slf4j
@Service
public class OrderPaymentService {
public void processPayment(OrderPaymentRequest<?> request) {
log.info("Processing payment request: {}", request);
// callSuper = false면 부모 필드가 안 보여서 디버깅 어려움!
// callSuper = true
// INFO: Processing payment request: OrderPaymentCardRequest(
// super=OrderPaymentRequest(paymentType=CARD, orderId=1001),
// useAutoPayment=1, cardCompanyCode=KB, paymentDetail=...
// )
// callSuper = false
// INFO: Processing payment request: OrderPaymentCardRequest(
// useAutoPayment=1, cardCompanyCode=KB, paymentDetail=...
// ) // orderId가 뭔지 모름!
}
}
테스트 코드 가독성
@Test
void testPaymentCreation() {
OrderPaymentCardView actual = paymentService.getPayment(1001L);
// callSuper = true일 때 실패 메시지가 명확함
assertThat(actual).isEqualTo(expected);
// 실패 시 출력:
// Expected: OrderPaymentCardView(
// super=OrderPaymentView(paymentType=1, orderId=1001, ...),
// useAutoPayment=1, cardCompanyCode=KB
// )
// Actual: OrderPaymentCardView(
// super=OrderPaymentView(paymentType=1, orderId=1002, ...), // ← orderId가 다름!
// useAutoPayment=1, cardCompanyCode=KB
// )
}
로그 모니터링
@Component
@Aspect
public class PaymentLoggingAspect {
@AfterReturning(pointcut = "execution(* *..*PaymentService.get*(..))",
returning = "result")
public void logPaymentAccess(JoinPoint joinPoint, Object result) {
log.info("Payment accessed: {}", result);
// callSuper = true가 없으면 orderId 같은 중요 정보를 놓침
}
}
상속 깊이가 깊을 때의 효과
@ToString
public abstract class BaseEntity {
private Long id;
private LocalDateTime createdAt;
}
@ToString(callSuper = true)
public abstract class OrderPaymentView<T> extends BaseEntity {
private int paymentType;
private Long orderId;
}
@ToString(callSuper = true) // 2단계 상속
public class OrderPaymentCardView extends OrderPaymentView<CardPaymentDetailView> {
private int useAutoPayment;
}
// 출력: 3단계 모두 포함
// OrderPaymentCardView(
// super=OrderPaymentView(
// super=BaseEntity(id=1, createdAt=2025-01-12T10:00:00),
// paymentType=1, orderId=1001
// ),
// useAutoPayment=1
// )
어노테이션 종합 비교표
| 시나리오 | 어노테이션 조합 | 결과 |
| 기본 빌더 | @Builder | 단일 클래스에서만 사용 가능 |
| 상속 빌더 | @SuperBuilder | 부모 + 자식 필드 모두 빌더로 설정 가능 |
| 기본 toString | @ToString | 자식 필드만 출력 |
| 상속 toString | @ToString(callSuper = true) | 부모 + 자식 필드 모두 출력 |
| 다형성 JSON | @JsonTypeInfo + @JsonSubTypes | 타입별 자동 변환, Swagger 문서화 |
실무 권장 조합
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.EXISTING_PROPERTY,
property = "paymentType",
visible = true)
@JsonSubTypes({/* ... */})
@Getter
@ToString(callSuper = true) // 디버깅용
@SuperBuilder // 빌더 패턴용
@NoArgsConstructor // Jackson 역직렬화용
@AllArgsConstructor // SuperBuilder 동작용
@Schema(description = "결제 정보") // Swagger 문서화용
public abstract class OrderPaymentView<T> {
// ...
}