결제 모듈 구현을 시작하며 가장 먼저 마주한 고민은 단순히 "결제 승인 API를 어떻게 호출할 것인가"가 아니었습니다. 핵심은 "앞으로 어떤 결제 대행사(PG)가 추가되더라도 서비스의 결제 흐름을 흔들지 않고 일관된 결제 경험을 제공할 수 있는가"였습니다.
초기 MVP 목표는 토* 페이먼츠(T***Payments) 연동이었으나, 향후 결제 수단, 정산 조건, 해외 결제 지원(Stripe 등), 고객사 요구사항에 따라 여러 PG를 동시에 지원해야 하는 상황이 발생할 수 있습니다. 따라서 단순한 API 연동을 넘어 다음과 같은 목표를 설정했습니다.
-
다양한 PG 연동 코드를 하나로 모아 파편화를 방지합니다.
-
새로운 PG를 쉽게 추가할 수 있는 구조적 확장성을 확보합니다.
-
결제 데이터를 내부 DB에 저장하여 추적 및 디버깅 용이성을 제공합니다.
1. if-else 분기 처리의 한계와 전략 패턴 도입
다양한 PG 연동을 결제 서비스 로직 안에 직접 구현할 수도 있습니다. 하지만 Stripe, K****Pay, 해외 PG 등이 추가될 때마다 결제 플로우는 다음과 같이 조건문으로 가득 차게 됩니다.
if (pgProvider.equals("T***Payments")) {
// T*** 승인 로직
} else if (pgProvider.equals("Stripe")) {
// Stripe 승인 로직
} else if (pgProvider.equals("SomeOtherPg")) {
// 다른 PG 승인 로직}
이 방식은 PG가 추가될수록 결제의 핵심 비즈니스 흐름과 외부 연동 세부사항이 강하게 결합된다는 치명적인 단점이 있습니다. 결제 플로우는 "금액 검증 → PG 승인 → 성공/실패 처리"라는 큰 흐름에만 집중해야 하며, 각 PG의 인증 헤더나 응답 파싱 방식까지 알 필요는 없습니다. 이를 분리하기 위해 전략 패턴(Strategy Pattern)을 적용했습니다.
2. 공통 인터페이스(PgProxy)와 구현체 설계
먼저 모든 PG사가 공통으로 구현해야 할 PgProxy 인터페이스를 정의했습니다. 결제 플로우 입장에서는 PG사의 종류와 무관하게 "결제를 승인해달라"는 동일한 메시지만 던지면 됩니다.
public interface PgProxy {
String getPgProviderType();
WebClient getWebClient();
PgPaymentResponse approvePayment(String paymentKey, String orderId, Money amount);
PgPaymentResponse getPgPaymentResponse(String orderId);
}
토* 페이먼츠 구현체는 위 인터페이스를 상속받아 다음과 같이 구현합니다.
@Configuration
@ConditionalOnProperty(value = "pg.t***.enabled", havingValue = "true")
public class T***PgProxy implements PgProxy {
@Value("${pg.t***.secret-key}")
private String secretKey;
@Override
public PgPaymentResponse approvePayment(String paymentKey, String orderId, Money amount) {
T***Payment response = confirmPayment(paymentKey, orderId, amount);
return new PgPaymentResponse(response);
}
@Override
public String getPgProviderType() {
return "T***Payments";
}
}
여기서 @ConditionalOnProperty 어노테이션을 활용하여, 설정값(pg.t***.enabled=true)에 따라 환경별로 필요한 PG 구현체만 스프링 빈(bean)으로 동적 등록되도록 제어했습니다. 향후 추가될 StripePgProxy 역시 동일한 인터페이스를 구현하여 준비해 둘 수 있습니다.
3. 알맞은 전략을 찾아주는 라우팅 팩토리(PgProxyFactory)
이제 요청된 PG 타입에 맞는 구현체를 찾아주는 PgProxyFactory를 구성합니다.
@Component
public class PgProxyFactory {
private final Map<String, PgProxy> pgProxies;
public PgProxyFactory(List<PgProxy> pgProxies) {
this.pgProxies = pgProxies.stream()
.collect(Collectors.toMap(PgProxy::getPgProviderType, Function.identity()));
}
public PgProxy getPgProxy(String pgProvider) {
PgProxy pgProxy = pgProxies.get(pgProvider);
if (pgProxy == null) {
throw new NoSuchElementException("지원하지 않는 결제 대행사입니다: " + pgProvider); }
return pgProxy;
}
}
스프링 컨테이너가 PgProxy 타입의 모든 빈을 리스트로 주입하면, 팩토리는 이를 Map 형태로 변환하여 메모리에 쥐고 있습니다. 이후 비즈니스 로직에서 PG 식별자(Enum 등)를 넘기면 즉시 알맞은 구현체를 반환합니다. 클래스명은 Factorykaka이지만, 여기서는 객체의 '생성'보다는 런타임 환경에 맞는 '전략 객체'를 선택해 라우팅해 주는 전략 매니저의 역할을 수행합니다.
4. 핵심 비즈니스 로직의 단순화
전략 패턴을 적용한 실제 결제 서비스 로직은 외부 통신 세부사항에서 완전히 해방됩니다.
@Service
@Transactional
@RequiredArgsConstructor
public class PaymentNeutFlow {
private final PaymentTask paymentTask;
private final PgProxyFactory pgProxyFactory;
private final PaymentLogic paymentLogic;
public String requestPayment(String paymentId, BigDecimal amount, String pgPaymentKey) {
paymentTask.updatePgPaymentKey(paymentId, pgPaymentKey);
try {
Payment payment = paymentLogic.findPayment(paymentId);
paymentTask.validateAmount(paymentId, amount);
// 외부 PG 통신: 팩토리에서 구현체를 가져와 승인 요청만 위임
PgPaymentResponse pgPaymentResponse = pgProxyFactory
.getPgProxy(payment.getPgProvider())
.approvePayment(pgPaymentKey, payment.getId(), payment.getAmount());
paymentTask.markSuccess(pgPaymentResponse.getOrderId());
return pgPaymentResponse.getOrderId();
} catch (Exception e) {
paymentTask.markFailed(paymentId);
throw e;
}
}
}
이제 PaymentNeutFlow는 특정 PG의 API URL이나 응답 객체 형식을 알지 못합니다. 오직 결제 키 저장, 금액 검증, 팩토리를 통한 승인 위임, 성공/실패 상태 처리라는 본연의 비즈니스 책임에만 집중합니다.
5. 확장에 열려있는 구조 (OCP 원칙 달성)
이 구조의 진가는 새로운 PG(예: N***Pay)를 추가할 때 발휘됩니다. 필요한 작업은 단 두 가지뿐입니다.
-
PgProvider Enum에 N***Pay 상수를 추가합니다.
-
PgProxy를 상속받는 N***PgProxy 클래스를 신규 생성합니다.
기존의 결제 플로우(PaymentNeutFlow)나 팩토리(PgProxyFactory) 코드는 단 한 줄도 수정할 필요가 없습니다. 스프링이 새 구현체를 자동으로 감지하여 주입하기 때문입니다. 변경 범위가 극도로 최소화되므로 기존 기능에 버그가 발생할 확률(회귀 버그) 역시 크게 줄어듭니다.
6. 구현 효과 및 마무리
결제 모듈에 전략 패턴과 어댑터 개념을 결합하여 얻은 이점은 다음과 같습니다.
-
결제 플로우 단순화: 서비스 계층은 PG사별 세부사항을 몰라도 되며, 결제 비즈니스 흐름 자체에만 집중합니다.
-
높은 응집도: 통신 타임아웃, 헤더 설정, 응답 파싱 등 특정 PG에 종속된 코드는 각각의 Proxy 객체 내부에 안전하게 격리됩니다.
-
유연한 운영 설정: @ConditionalOnProperty를 통해 로컬, 개발, 운영 등 환경에 맞춰 특정 PG 구현체를 자유롭게 켜고 끌 수 있습니다.
-
테스트 용이성 확보: 외부 통신부를 Mocking하기 쉬워져 결제 흐름 자체에 대한 단위 테스트 작성이 매우 간편해집니다.
결제 시스템은 필연적으로 실패와 변경이 잦은 외부 세계와 맞닿아 있습니다. 견고한 결제 모듈은 외부의 변화가 시스템 내부의 핵심 로직으로 번지지 않도록 막아내는 방파제가 있어야 합니다. 전략 패턴은 그 단단한 경계를 구축하기 위한 가장 효과적이고 실용적인 설계였습니다.
chnsik