해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요

디자인 패턴 관련해서 공부를 해도 실제 실무에서 어떻게 적용하는지 의문이었던 적이 있었다.
실무 개발을 진행하면서 여러 시행착오를 통해 유지보수 용이한 코드를 작성했던 경험을 공유 해보고자 한다
전략 패턴을 소개하는 용도로 작성한 게시글이라 완성된 코드가 아니다.
전략 패턴을 어떻게 활용했는지 참고하는 용도로 봤으면 좋겠다.

 

개발 요구사항

  • 미리 등록해둔 결제 수단(카드, 페이)을 이용해서 도서를 구매한다.

개발 예제

PaymentMethod, PaymentItem, PaymentCommand, PaymentResult

// 결제수단
public enum PaymentMethod {
    CARD, PAY,
}

// 결제 전략 Not Found 예외
public class PaymentStrategyNotFoundException extends RuntimeException {
    public PaymentStrategyNotFoundException() {
        super("결제 전략을 찾을 수 없습니다.");
    }
}

// 결제 상품
@RequiredArgsConstructor
@Getter
public class PaymentItem {
    private final String name;
    private final int price;
    private final int quantity;
}

// 결제 커맨드
@RequiredArgsConstructor
@Getter
public class PaymentCommand {
    private final Long userId;
    private final Long paymentMethodId;
    private final PaymentMethod paymentMethod;
    private final PaymentItem paymentItem;
}

@Getter
@RequiredArgsConstructor
public class PaymentResult {
    private final Long paymentId;
    private final Long paymentMethodId;
    private final PaymentMethod paymentMethod;
    private final PaymentItem paymentItem;
    private final String message;
    private final boolean success;
}
  • 각각 결제 수단, 결제 상품, 결제 커맨드, 결제결과를 저장하는 클래스이다.
  • 결제 커맨드에는 회원 아이디, 결제 수단 아이디, 결제수단 타입, 결제 상품 정보가 저장되어 있다.
  • 결제 수단은 카드, 페이가 존재하며 중복되지 않는 아이디로 관리되고 있다고 가정한다. 

PaymentStrategy

// 결제 전략 인터페이스
public interface PaymentStrategy {
    PaymentResult pay(PaymentCommand command);
}

// 카드 결제 전략 구현체
@RequiredArgsConstructor
@Service
public class CardPaymentStrategy implements PaymentStrategy {

    private final CardPaymentApi cardPaymentApi; 
    private final CardPaymentMethodRepository cardPaymentMethodRepository;  

    @Override
    public PaymentResult pay(PaymentCommand command) {
        ... 결제 로직
    }
}

// 페이 결제 전략 구현체
@RequiredArgsConstructor
@Service
public class PayPaymentStrategy implements PaymentStrategy {

    private final PayPaymentApi PayPaymentApi;  
    private final PayPaymentMethodRepository payPaymentMethodRepository;  

    @Override
    public PaymentResult pay(PaymentCommand command) {
        ... 결제 로직
    }
}
  • 결제 커맨드를 받아서 결과를 반환하는 전략 패턴 인터페이스이다.
  • 결제 수단은 카드, 페이가 있으므로 각각 결제 전략에 따라서 결제를 수행하는 구현체를 생성한다.
  • 결제 관련 자세한 로직은 생략한다. (전략 패턴 활용에 초점을 맞춰서 해당 내용은 제외하겠습니다.)

BookPaymentUseCase

@RequiredArgsConstructor
@Service
public class BookPaymentUseCase {

    private final CardPaymentStrategy cardPaymentStrategy;
    private final PayPaymentStrategy payPaymentStrategy;

    public PaymentResult pay(PaymentCommand command) {
        PaymentResult paymentResult = getPaymentStrategy(command.getPaymentMethod())
            .pay(command);

        // 결제 이후에 작업들

        return paymentResult;
    }

    private PaymentStrategy getPaymentStrategy(PaymentMethod paymentMethod) {
        switch (paymentMethod) {
            case PAY:
                return payPaymentStrategy;
            case CARD:
                return cardPaymentStrategy;
            default:
                throw new PaymentStrategyNotFoundException();
        }
    }
}
  • 도서를 구매하는 역할을 가진 BookPaymentUseCase 클래스에서 결제 전략에 따라 도서를 구매하고 결과를 반환한다.
  • 중요하게 보아야할 부분은 getPaymentStrategy(...)메소드 인데 paymentMethod 값에 따라서 결제 전략을 반환한다.
  • 결제 전략이 존재하지 않으면 PaymentStrategyNotFoundException 예외가 발생한다.
  • 하지만 BookPaymentUseCase는 OCP(개방 폐쇄 법칙, 확장에는 열려있고 변경에는 닫혀있다.)를 지키지 않는다.  
  • 새로운 전략이 추가될 때마다 BookPaymentUseCase를 수정해주어야 한다.
  • 아래와 같이 OCP를 지키도록 개선해보자

PaymentStrategyProvider

public interface PaymentStrategy {
    PaymentResult pay(PaymentCommand command);

    PaymentMethod getPaymentMethod(); // 메소드 추가
}


@Service
public class PaymentStrategyProvider {
    private final Map<PaymentMethod, PaymentStrategy> provider;

    // PaymentStrategyProvider는 Bean이기 때문에 의존성 주입 시 PaymentStrategy로 구현한 클래스를 List로 주입받을 수 있다.
    public PaymentStrategyProvider(List<PaymentStrategy> paymentStrategies) {
        this.provider = paymentStrategies
            .stream()
            .collect(Collectors.toMap(PaymentStrategy::getPaymentMethod, Function.identity()));
    }
    
    // paymentMethod를 매개변수로 받아서 PaymentStrategy를 반환한다. 
    // PaymentStrategy 구현체가 존재하지 않으면PaymentStrategyNotFoundException 예외를 발생시킨다
    public PaymentStrategy provide(PaymentMethod paymentMethod) {
        return Optional.ofNullable(provider.get(paymentMethod))
            .orElseThrow(PaymentStrategyNotFoundException::new);
    }
}
  • PaymentStratgy 인터페이스에 PaymentMethod getPaymentMethod(); 메소드를 추가하고 각 구현체에 반영한다.
  • PaymentStrategyProvider는 paymentMethod 값에 따라서 PaymentStratgy 구현체를 반환하는 역할을 가진 클래스이다.
  •  PaymentStrategyProvider는 Bean이기 의존성 주입 시 PaymentStrategy로 구현한 클래스를 List로 주입받을 수 있다.
  • 해당 리스트를 Map으로 변환하는데 key 값은 각 구현체의 getPaymentMethod() 값이고, 값으로 결제 전략 구현체가 저장되도록 한다. 
  • provide(...) 메소드를 호출해서 기존에 swtich문을 대체한다.
  • 이로서 새로운 결제 전략이 추가되더라도 코드 수정을 하지 않아도 된다.
  • 이는 스프링의 의존성 주입 특성을 활용하는 것인데, 의존성 주입 시 생성자에 특정 인터페이스를 받도록 선언해두면 그에 따른 구현체를 주입시켜준다. 그리고 리스트 형태로도 받을 수 있다.
  • 스프링이 아니더라도 PaymentStrategyConfig를 만들어 PaymentStrategyProvider를 직접 생성해주는 방식으로 사용해도 된다.  

BookPaymentUseCase 개선

@RequiredArgsConstructor
@Service
public class BookPaymentUseCase {

    private final PaymentStrategyProvider paymentStrategyProvider;

    public PaymentResult pay(PaymentCommand command) {
        PaymentResult paymentResult = getPaymentStrategy(command.getPaymentMethod())
            .pay(command);

        // 결제 이후에 작업들

        return paymentResult;
    }

    private PaymentStrategy getPaymentStrategy(PaymentMethod paymentMethod) {
        return paymentStrategyProvider.provide(paymentMethod);
    }
}
  • PaymentStrategyProvider를 활용하도록 수정하자
  • 고수준 모듈 (BookPaymentUseCase)은 저수준 모듈 (결제 모듈들..)을 직접적으로 의존하지 않고, 새로운 전략이 추가되더라도 고수준 모듈 자체를 수정하지 않아도 된다. (OCP) 

결제 전략 패턴을 적용한 클래스 다이어그램

PaymentStrategyProvider  테스트 코드

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.ArrayList;
import java.util.Arrays;

@DisplayName("PaymentStrategyProvider 테스트 ")
class PaymentStrategyProviderTest {

    @Test
    public void 카드결제_전략조회_성공() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(
            Arrays.asList(new CardPaymentStrategy(), new PayPaymentStrategy()));

        PaymentStrategy paymentStrategy = paymentStrategyProvider.provide(PaymentMethod.CARD);

        Assertions.assertThat(paymentStrategy.getClass()).isEqualTo(CardPaymentStrategy.class);
    }

    @Test
    public void 페이결제_전략조회_성공() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(
            Arrays.asList(new CardPaymentStrategy(), new PayPaymentStrategy()));

        PaymentStrategy paymentStrategy = paymentStrategyProvider.provide(PaymentMethod.PAY);

        Assertions.assertThat(paymentStrategy.getClass()).isEqualTo(PayPaymentStrategy.class);
    }

    @Test
    public void 원하는_결제_전략이_없을경우_예외발생() {
        PaymentStrategyProvider paymentStrategyProvider = new PaymentStrategyProvider(new ArrayList<>());

        Assertions.assertThatThrownBy(() -> paymentStrategyProvider.provide(PaymentMethod.PAY))
            .isInstanceOf(PaymentStrategyNotFoundException.class);
    }
}

 

결론

  • 개발에 정답이 있는 것은 아니지만 내가 생각하는 좋은 코드란 SOLID 원칙을 잘 준수한 코드라고 생각한다. 

1. 전략 패턴이란?

전략 패턴은 실행 중에 알고리즘을 선택할 수 있게 하는 행위 소프트웨어 디자인 패턴이다. - 위키 백과

동일 계열의 알고리즘군을 정의하고, 각 알고리즘을 캡슐화하며, 이들을 상호교환이 가능하도록 만든다. 알고리즘을 사용하는 클라이언트와 상관없이 독립적으로 알고리즘을 다양하게 변경할 수 있게 한다. - GoF

특정 기능이 추가될 때, 전략 객체를 생성 후 주입하는 방식으로 유연하게 기능을 확장할 수 있습니다.

전략 패턴 클래스 다이어그램

 

2. 예제 소스

1) Strategy 인터페이스 및 Strategy 클래스

//전략 인터페이스
public interface PrintStrategy {

    public void print();
}

//프린터로 전달하는 전략
public class PrinterStrategy implements PrintStrategy {

    @Override
    public void print() {
        System.out.println("Printer로 전달");
    }

}

//구글 드라이브로 전달하는 전략
public class GoogleDriveStrategy implements PrintStrategy {

    @Override
    public void print() {
        System.out.println("구글 드라이브로 전달");
    }

}

//팩스로 전달하는 전략
public class FaxStrategy implements PrintStrategy {

    @Override
    public void print() {
        System.out.println("Fax로 전달");
    }

}
각 전략 클래스들은 PrintStrategy 인터페이스를 상속하고 print() 메소드를 오버라이딩합니다.

 

2) Context 클래스

import java.util.Objects;

public class Printing {

    private PrintStrategy printStrategy;

    public void print() {
        if (Objects.nonNull(printStrategy)) {
            printStrategy.print();
        }
    }

    public void setPrintStrategy(PrintStrategy printStrategy) {
        this.printStrategy = printStrategy;
    }

}
인쇄 관련 기능을 직접 구현하지 않고 전략을 주입하는 형식으로 인쇄 기능을 수행합니다.
setPrintStrategy(PrintStrategy printStrategy) 메소드를 통해 인쇄 전략을 주입받고, print() 메소드를 통해 주입받은 전략을 수행합니다.

 

3) Client 

public class ChromeBrower extends Printing {

}
public class Main {

    public static void main(String[] args) {
        ChromeBrower chromeBrower = new ChromeBrower();
        PrintStrategy printerStrategy = new PrinterStrategy();
        PrintStrategy googleDriveStrategy = new GoogleDriveStrategy();

        chromeBrower.setPrintStrategy(printerStrategy);
        chromeBrower.print();

        chromeBrower.setPrintStrategy(googleDriveStrategy);
        chromeBrower.print();

    }

}
ChromeBrower 클래스는 Printing 클래스를 상속 받고, 다양한 전략을 주입받아서 인쇄 기능을 수행합니다.
새로운 기능이 추가되어도 PrintStrategy 전략을 생성하고 주입하는 형식으로 유연하게 기능을 확장할 수 있습니다.

참조

https://ko.wikipedia.org/wiki/%EC%A0%84%EB%9E%B5_%ED%8C%A8%ED%84%B4
https://victorydntmd.tistory.com/292

+ Recent posts