전략 패턴

반응형

지금까지 패턴을 왜 사용하는지에 초점을 맞춰서 얘기를 했다면 이번에는 조금은 다를겁니다. 어쩌면 패턴들중에서 가장 실용적인 패턴이 아닌가 싶습니다. 저는 현재 구독 서비스를 개발중에 있습니다. 구독하는 방식에는 카드, 계좌, 토스페이로 결제하는 방법들이죠. 

public PaymentTossResult toss(PaymentTossCommand command) {...}
public PaymentCardResult card(PaymentCardCommand command) {...}
public PaymentBankTransferResult bankTransfer(PaymentBankTransferCommand command) {...}

이 코드들은 전부 구독에 대한 결제 부분입니다. 다만 내용물이 조금씩은 다르죠.
toss같은 경우는 외부 api에서 받은 정보를 토대로 success or fail에서 구독을 결정하죠.
그리고 card는 현재 pg사가 존재하지 않기 때문에 아무런 상태 변경을 하지않고 구독이 되어지죠.
마지막으로 bankTransfer은 계좌이체로 api에서는 대기상태로 들어가지게 되어집니다. 그리고 스케쥴링을 이용해서 입금여부를 확인하고 구독이 되어지죠. (입금은 수동으로 db에서 변경을 하는것으로 하고 있습니다. 개발할 수는 있는데 저는 구독 서비스를 개발하는거지 계좌 시스템을 개발한것인 아니기 때문에 과감히 생략하였습니다.)

그림을 그려보면 대략적으로 이렇습니다. 이것이 말하고자하는건 하고자하는것은 비슷하지만(구독) 방향이 서로 다르다는것을 알 수 있죠. 이럴때 사용할 수 있는 패턴이 전략 패턴입니다. 

시작 부터 문제네.. 

전략패턴으로 바꿀려고 인터페이스를 만들려고 하니 문제가 발생하였습니다. 
dto의 내용이 조금씩 달랐습니다. 

public record PaymentCardResult(     public record PaymentTossResult(
    String cardNumber,               String orderId,
    String cardHolderName,           long amount,
    long amount,                     String orderName,
    String planType                  String customerName, 
) {}                                 String planType
                                     ) {}

public record PaymentBankTransferResult(
    String bankCode,
    String accountNumber,
    String depositorName,
    long amount,
    String planType
) {}

왜냐하면 전략패턴으로 만들기 위해서는 위에서 소개한 3개의 메소드들을 추상화를 시킬 필요가 있다고 생각합니다.
추상화를 해야 전략패턴을 사용할 수 있기 때문이죠.

public interface PaymentService {
  PaymentResult request(PaymentCommand command);
}

그래서 대략 요렇게 만들었는데 DTO의 형식이 조금씩 다르니 어떻게 추상화를 해야 할지 고민이 되어지는건 사실입니다.
제가 선택한 방법은 최상위 DTO를 만들어서 관리하는 방법입니다.
record는 Interface를 통해 주입을 받을 수 있습니다. 

public record PaymentCardResult(
    String cardNumber,
    String cardHolderName,
    long amount,
    String planType
) implements PaymentResult {}

요렇게 하면, 다음 코드처럼 사용이 가능해집니다.

PaymentResult request();

이렇게 하게되면 전략의 반환타입을 고정해서 호출자는 결제 결과만 신경을 쓸 수 있다는 장점을 가지고 있습니다. 
하지만 interface를 그냥 만들게 되면 모든 곳에서 implements가 사용이 가능할겁니다.
그걸 방지하고자 sealed 처리하게 되면 아래 클래스들만 하위 클래스로 지정할 수 있습니다.

public sealed interface PaymentResult
    permits PaymentCardResult,
    PaymentBankTransferResult,
    PaymentTossResult {
}

커멘드도 함께 진행해보겠습니다.

PaymentResult request(PaymentCommand command);

DTO는 요런식으로 처리하면 되지 않을까 싶습니다.

이제 Service를 사용하려고 보니

이제 결제 전략을 캡슐화한 Service를 실제 요청 흐름에서 사용해볼 차례입니다.
이를 위해 Controller에서 해당 Service를 호출하는 구조를 살펴봅시다.
하지만 컴파일예외가 발생하는 군요.

첫 번째 이유는 PaymentService 인터페이스를 구현한 구체 클래스가 스프링 빈으로 등록되어 있지 않았기 때문입니다.
interface 자체는 인스턴스화될 수 없기 때문에, 반드시 이를 구현한 Service가 컨테이너에 존재해야 합니다.

두 번째 이유는 추상 타입인 PaymentResult를 요청 DTO로 그대로 사용했기 때문입니다. JSON 요청을 객체로 변환하는
과정에서 스프링은 어떤 구현체를 생성해야 할지 알 수 없고, 이로 인해 요청 바인딩 단계에서 문제가 발생하게 됩니다.

생각해보니 서비스에 대한 구현체를 만들지 않았군요.

@Service
public class CardPaymentService implements PaymentService{
  @Override
  public PaymentResult request(PaymentCommand command) {
    return null;
  }
}

열심히 등록 했는데 빈이 여러개라고 등록이 되지는 않군요. 알고보니 service는 하나고 전략을 여러개를 만들어야 했었습니다.
그래서 만든 결과물은

요렇게 만들 수 있었습니다. 

그러면 어떻게 사용할 수 있을까?

이제 이것들을 사용하려고 보니, 각각의 전략들은 모두 빈으로 등록이 되어있었습니다. 그렇다는건 PaymentStrategy자체를 바로 사용할 수 없다는 뜻입니다. 그 이유는 주입해야되는 빈이 여러개면 찾을 수 없는 문제가 있었습니다.
그래서 저는 외부에서 어떤 전략인지 체크를 해주는 부분이 필요하다고 생각했습니다.

@Component
public class PaymentStrategyResolver {
  private final Map<PaymentType, PaymentStrategy> strategies;

  public PaymentStrategyResolver(List<PaymentStrategy> strategies) {
    this.strategies = strategies.stream()
        .collect(Collectors.toMap(
            PaymentStrategy::supports,
            Function.identity()
        ));
  }

  public PaymentStrategy resolve(PaymentCommand command) {
    return strategies.get(command.type());
  }
}

단일이 아닌 리스트 또는 맵으로 등록하게 되면 필요한 타입으로 변환을 시켜서 적용시킬 수 있습니다.
즉, PaymentStrategy 하위 에는 card, bankTransfer, pg이렇게 존재합니다. 그리고 나서 resolve를 이용해서 해당하는 전략들이 주입이 되어지는 형식입니다. 

마지막으로 PaymentService에서 생성해 놓은 resolver을 사용해서 연결을 시킬 수 있습니다.

@Service
@RequiredArgsConstructor
public class PaymentServiceImpl implements PaymentService {
  private final PaymentStrategyResolver resolver;

  @Override
  public PaymentResult request(PaymentCommand command) {
    return resolver.resolve(command.type()).pay(command);
}

 

결국, controller에서 paymentService를 이용해서 카드, 계좌 이체, 토스에 대한 구독 요청을 추상화를 시킬 수 있었습니다.

var response = paymentService.request(command);

외부에서는 내부의 구현을 몰라도 선택할 수 있다는 장점도 있는거 같습니다.
하지만 전략패턴을 만들면서 굉장히 많은 클래스들이 등장하였습니다.

DTO포함 클래스: 7개 -> 16개 
2배가 넘게 증가가 되었다는 사실도 알게 되었습니다.
공통적인 부분을 하나로 합치기 위해 시작한 작업이 이었지만, 만들고 보니 오히려 클래스가 더 증가가 되었다는 사실을 발견하였습니다.
겉보기에는 예뻐보이는것도 그 내부를 보면 약간의 희생을 감수해야 된다는 사실을 배운거 같습니다.

결론

하나의 입구에서 여러 전략을 선택해서 만드는 방법인 전략패턴을 사용해 봤습니다. 원래 시작 지점은 card, bank, toss이렇게 되있었는데 이 부분을 request로 통일을 시켰습니다. request로 통일을 시키기 위해서는 같은 파일에 존재하면 안되었습니다. 그래서 card, bank, pg로 서비스를 분리하고 인터페이스를 통해 주입을 받으려고 시도했습니다. 하지만 사용해야할 빈이 여러개라 이 방법은 불가능했습니다. 이것을 가능하기위해서는 List<>나 Map<,>또는 단일로 등록을 시켜줘야 컴파일에러는 발생하지는 않았습니다. 하지만 List나 Map으로 등록하게 되면 어떤것을 가져와야 하는지에 대한 코드가 존재하지 않더라구요. 그래서 Resovler를 통해 각각의 전략들을 가져오는 방법을 선택하였습니다. 각각의 전략을 만들지 않았기 때문에 아까전에 만들었던 service들을 전략들로 변환을 시키고, 이들을 대표하는 interface로 만들었습니다. 이제 Resolver를 통해 각각의 전략을 각각의 타입과 매핑을 시켜줄 수 있었습니다. 이렇게 해보니 controller쪽은 가독성이 좋아졌습니다. method가 전부 request로 통일이 되었기 때문에 읽기편했습니다. 그리고 내부 구현을 신경을 쓰지 않아도되었기 때문에 그에 대한 부담도 줄었습니다. 하지만 이를 만들기 위해 생각보다 많은 수의 클래스를 만들게 되었습니다. 
이번 경험을 통해 전략 패턴은 코드를 단순히 줄이기 위한 패턴이 아니라, 변화하는 로직을 감당하기 위해 구조를 선택하는 패턴이라는 점을 체감할 수 있었습니다. 깔끔한 진입점과 낮은 결합도를 얻는 대신, 그에 따른 복잡성과 비용을 감수해야 한다는 것도 함께 배웠습니다.

반응형

'개발 > 디자인패턴' 카테고리의 다른 글

옵저버 패턴  (1) 2026.01.12
퍼사드 패턴  (0) 2026.01.05
빌더 패턴  (0) 2025.12.29
디자인패턴과 안티 패턴  (1) 2025.12.25
팩토리 메소드 패턴  (0) 2025.12.22

댓글

Designed by JB FACTORY