전략 패턴을 활용한 다형성 결제 시스템 설계

비즈니스 요구사항

이커머스 시스템에서 주문(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 동작
공통 정보만ordersOrderEntity만 업데이트
공통 + 상세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는 그대로 유지하면서 새 버전 추가 가능!

각 속성의 역할

속성의미효과
useJsonTypeInfo.Id.NAME문자열/숫자로 타입 식별@JsonSubTypes의 name과 매칭
includeEXISTING_PROPERTY기존 필드를 타입 식별자로 사용별도의 @type 필드가 생기지 않음
property“paymentType”어떤 필드로 타입을 구분할지paymentType 값으로 클래스 결정
visibletrueJSON에 타입 정보 포함응답 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> {
    // ...
}