결제 수단의 확장과 축소를 고려하여 전략 패턴과 팩토리 패턴 사용 - Genie-Uss/genieus GitHub Wiki
[PaymentCommandService]
|
|
v
[PaymentStrategyFactory] ---+--> [TossPayStrategy]
+--> [KakaoPayStrategy]
+--> [...]
package shop.genieus.payment.application.out.strategy;
@PostMapping("/process")
Object processPayment(@RequestBody ProcessPaymentRequest processPaymentRequest) {
PaymentProcessorResult paymentProcessorResult =
paymentCommandService.processPayment(ProcessPaymentRequest.toCommand(processPaymentRequest));
return switch (paymentProcessorResult.resultType()) {
case JSON -> ResponseEntity.ok(ApiResponse.ok(paymentProcessorResult.payload()));
case REDIRECT -> new RedirectView(paymentProcessorResult.payload().toString());
};
}
- 우리 서비스엔 프론트가 없기 때문에 결제 API 연동 테스트를 위해 SSR 도 필요하였음
- API 를 분리하는 게 일반적이겠지만, 단일 엔드포인트로 유연한 처리를 위하여 전략 패턴 도입
- 전략에서 결과 타입을 명시해줬기 때문에, 호출하는 컨트롤러는 switch로 결과만 처리함
package shop.genieus.payment.application.out.strategy;
import shop.genieus.payment.domain.model.entity.Payment;
public interface PaymentStrategy {
PaymentProcessorResult process(Payment payment);
}
- 결제 방식별 로직의 공통 인터페이스
- 각 결제 방식(TossPay, KakaoPay 등)이 이 인터페이스를 구현함
package shop.genieus.payment.application.out.strategy;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import shop.genieus.payment.domain.model.vo.PaymentMethod;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class PaymentStrategyFactory {
private final Map<String, PaymentStrategy> strategies;
public PaymentStrategy getStrategy(PaymentMethod paymentMethod) {
PaymentStrategy paymentStrategy = strategies.get(paymentMethod.name());
if (paymentStrategy == null) {
throw new IllegalArgumentException(paymentMethod + "는 지원하지 않는 결제 수단입니다.");
}
return paymentStrategy;
}
}
- Map<String, PaymentStrategy>를 통해 스프링이 DI(의존성 주입)으로 자동 구성해줌
- key는 Bean 이름인데, 보통 @Component("KAKAO_PAY") 이런 식으로 명시하거나,
클래스명이 그대로 사용됨
- 클라이언트 코드가 전략 구현체에 직접 의존하지 않고, PaymentMethod enum 값을 기반으로
전략을 선택할 수 있게 해줌
package shop.genieus.payment.application.out.strategy;
public record PaymentProcessorResult(
ResultType resultType,
Object payload) {
public static PaymentProcessorResult returnRedirect(String url) {
return new PaymentProcessorResult(ResultType.REDIRECT, url);
}
public static PaymentProcessorResult returnJson(Object body) {
return new PaymentProcessorResult(ResultType.JSON, body);
}
public enum ResultType {
REDIRECT,
JSON
}
}
- 결과를 JSON 또는 REDIRECT로 명시적으로 표현
- 단순 Object가 아니라 결과 타입과 페이로드를 함께 반환해서, 결과 처리 분기를 쉽게 만들어줌
📝 적용 이유
Strategy Pattern (전략 패턴) |
- 예: TossPay, KakaoPay, NaverPay 등 - 모든 결제 수단을 하나의 if-else나 switch에 몰아넣으면 유지보수가 어려움- 새로운 결제 방식 추가 시 코드 변경 없이 클래스만 추가하면 됨 |
Factory Pattern (전략 팩토리) |
- 클라이언트(Controller)는 내부 구현을 몰라도 됨.- PaymentMethod라는 enum 값만 주면, 해당 전략이 자동으로 매핑됨- 전략과 클라이언트 간의 결합도 최소화 |
Map<String, PaymentStrategy> + Spring DI |
- @Component 또는 @Service로 등록된 구현체들이 자동으로 주입됨- 새로운 전략을 추가해도 팩토리 코드를 건드릴 필요 없음 |
PaymentProcessorResult + ResultType |
- 모든 전략이 JSON 혹은 REDIRECT로 반환값을 표준화- Controller에서는 ResultType을 기준으로 일관되게 처리- 결과 처리 책임을 컨트롤러로 이동시켜 역할 분담이 명확해짐 |
OCP(개방 폐쇄 원칙) 준수 |
- 새 결제 수단이 추가되어도 기존 코드(Controller, Factory)는 변경되지 않음- SRP와 함께 유지보수와 테스트가 쉬운 구조가 될 수 있음 |
💡 개선된 점
OCP 원칙 |
새로운 결제 방식이 추가되어도 기존 코드 수정 없이 클래스만 추가하면 됨 |
SRP 원칙 |
컨트롤러는 단순 요청 처리, 전략은 결제 처리, 팩토리는 전략 선택 등으로 관심사 분리됨 |
테스트 용이성 |
각 전략은 독립적으로 테스트 가능. 팩토리도 MockMap으로 테스트 가능 |
유연성 |
Enum이나 문자열로 전략을 선택하는 구조라 다양한 입력에 대응 가능 |
표현력 |
PaymentProcessorResult로 결과를 명확히 구분할 수 있어, 프론트 대응이 쉬워짐 |
⚠️ 단점 및 향후 개선 방향
- 전략 Bean 명시화 (@Component("TOSS_PAY"))
- 현재는 Map<String, PaymentStrategy> 사용 시 Bean 이름이 key가 되는데,
@Component("TOSS_PAY")
같이 명시하면 enum과의 맵핑이 명확해짐
- Enum 기반 맵핑 개선
- 현재는 .name()으로 문자열 맵핑하는데, 실수 가능성 있음
Map<PaymentMethod, PaymentStrategy>
으로 바꾸면 더 안전함
- 결과 타입을 제네릭으로 개선
- Object payload는 타입 안정성이 없으므로,
PaymentProcessorResult<T>(ResultType resultType, T payload)
이런 식으로 제네릭을 쓸 수도 있음