Ordinary
About

Strategy Pattern

profileordilov / 2022. 3. 22
Strategy Pattern

알고리즘 그룹을 정의하고, 각 알고리즘을 클래스로 만들어 교체하며 사용하는 Behavior Pattern입니다. 즉 어떤 문제를 해결할 때, 다양한 전략을 교체해가며 해결할 수 있는 패턴입니다.

Behavior Pattern

Behavior Pattern은 객체들 간의 알고리즘이나, 책임 분배에 관한 패턴입니다. 분배를 통해서 객체들 간의 유연성을 높입니다.

Strategy Pattern 가 필요한 이유

전략 패턴은 하나의 문제를 해결할 때 여러 전략 중 골라쓰는 경우 필요할 수 있습니다. 예시로 문자열을 공백 기준으로 분리해서 저장하는 save 메서드가 있다고 가정합니다. 문자열을 분리해서 데이터베이스에 개인정보를 저장해주는 역할을 맡고 있습니다. 이 때 개인정보 데이터로 JSON 형식도 지원되게 하고 싶습니다.

메서드 하나.png

void save(String info){ String split[] = info.split("\\s"); String name = split[0]; String age = split[1]; String address = split[2]; Member member = new Member(name, age, address); repository.save(member); }

분기로 나누기

JSON 형식도 지원하게 하는 가장 간단한 방법은 분기로 나누는 것입니다. 하지만 CSV, 쿼리 형식등도 지원한다고 하면 계속해서 메서드에 분기를 추가하게 됩니다. 즉 기능이 추가될 때마다 save 메서드를 수정해야 하고, 갈수록 변경하기 어려워집니다. 그래서 어떻게 기능 추가, 변경을 쉽게 할 수 있을까 고민하게 됩니다.

void save(String info){ String name, age, address; if(isValidJson(info)){ JSONObject json = new JSONObject(info); name = json.getString("name"); age = json.getString("age"); address = json.getString("address"); } else { String split[] = info.split("\\s"); name = split[0]; age = split[1]; address = split[2]; } Member member = new Member(name, age, address); repository.save(member); }

메서드 분리하기

다른 방법으로 메서드를 분리시킬 수 있습니다. 입력도 문자열로 받고, 데이터 베이스에 저장하는 것도 같아 내부 구현만 다른 메서드가 필요합니다. 따라서 방법만 다를 뿐 같은 일을 하는 메서드를 추가로 만들게 됩니다.

두 방법 모두 객체지향 설계 원칙 중 OCP Open Closed Principle를 위반하게 됩니다. 기능을 확장시키기 어렵고, 기능을 사용하는 코드도 수정해야 합니다.

메서드 변경.png

Open Closed Principle

'소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다’

Open-Closed Principle의 정의를 조금 더 풀어서 정의하면 이렇습니다.

'소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 확장하는 개체를 사용하는 개체의 수정에 대해서는 닫혀 있어야 한다’

클래스 분리하기

그래서 다른 방법으로 OCP를 만족하기 위해 클래스를 추가해서 만들 수 있습니다. 파싱 부분 구현이 다른 클래스가 생성됩니다. 두 클래스는 save 메서드를 포함하는 부모 클래스로 추상화하고 상속 받을 수 있습니다. 하지만 save 메서드 하나의 부분 구현 차이 때문에 전체 클래스를 분리해서 사용하게 됩니다.

분리 클래스.png

해결책

해결책으로 Strategy Pattern은 문제를 해결하는 객체와 해결하는 방법을 분리시킵니다.

  • 문제를 해결하는 객체는 Context로 정의합니다.
  • 문제를 푸는 방법의 인터페이스는 Strategy로 정의합니다.
  • 문제를 푸는 구체적인 구현체는 Concrete Strategy로 정의합니다.

OCP 원칙을 준수하기 위해선 다음과 같은 작업이 필요합니다.

  1. 변하는 것과 변하지 않는 것을 구분합니다.
  2. 변하지 않는 부분 중 사용하는 부분을 인터페이스로 정의합니다.
  3. 변하는 것을 의존하지 않고, 변하지 않는 것을 의존합니다.

이 때 변하는 부분을 어떻게 사용할지가 문제가 되는데 Strategy Patternsetter를 이용합니다. 위의 문제에서 변하지 않는 부분은 문자열을 입력받고 데이터베이스에 저장하는 부분입니다. 변하는 부분은 문자열을 파싱하는 방법이 되겠습니다. 이 과정을 적용시켜보면 다음과 같습니다.

  1. 먼저 파싱을 위한 전략에서 공통적인 부분을 인터페이스로 선언합니다.
  2. 각 파싱 전략들은 공통 인터페이스를 구현한 클래스로 생성합니다.
  3. 파싱 전략을 사용하는 클래스에 setter로 넣어줍니다.
  4. parse 메서드를 호출하면 setter로 설정된 전략에 따라 파싱해서 처리합니다.

전략패턴.png

private SplitStrategy splitStrategy; void save(String info){ String[] split = splitStrategy.parse(info); String name = split[0]; String age = split[1]; String address = split[2]; Member member = new Member(name, age, address); repository.save(member); }

장점

Strategy Pattern의 가장 큰 장점은 OCP 원칙을 지킬 수 있습니다. 전략들이 추가되더라도 사용하는 메서드를 수정할 필요가 없습니다. 또한 위에서 봤듯이 setter로 바꾸면 실행중에도 전략을 바꿀 수 있기에 유연합니다. 그리고 위에서 클래스로 구현했을 때는 상속으로 처리했던 부분을 composition 방식으로 변경되었습니다. 정리해보면 다음과 같은 장점이 있습니다.

  • OCP 원칙 준수
  • 실행 중에 알고리즘을 변경 가능
  • 알고리즘 구현을 사용하는 부분과 고립 가능
  • 상속에서 결합으로 변경 가능

단점

단점으로 여전히 메서드 하나 때문에 인터페이스와 여러 클래스를 만들게 됩니다. 어떤 책임을 갖는 객체가 아니라 행위 때문에 말이죠. 그리고 클라이언트가 전략을 자유롭게 선택하는 만큼 책임도 클라이언트가 갖게 됩니다. 잘못된 결과를 내는 전략이거나, 전략을 주입하지 않으면 제대로 실행되지 않습니다. 이에 대한 부분을 전략을 실행하는 객체는 알지 못합니다.

Strategy Pattern을 개선하려면?

람다 함수로 대체하기

먼저 메서드를 클래스로 생성하는 단점을 개선해봅니다. Strategy Pattern의 대표적인 예로 리스트를 정렬할 때를 보겠습니다. 리스트를 정렬하기 위해 정렬 방법을 클래스로 넘겨주고 있습니다.

List<Integer> list = new ArrayList<>(List.of(5, 3, 4, 1, 2)); // Strategy Pattern list.sort(new Comparator<Integer>() { @Override public int compare(Integer o1, Integer o2) { return 0; } }); // 람다 함수 list.sort((o1, o2) -> 0);

많은 프로그래밍 언어에서는 Strategy Pattern에서 클래스로 넘겨주던 것을 함수로 넘겨줄 수 있습니다. 객체 지향적으로 봤을 때도 이상하다고 느낄 수 있습니다. 객체라면 상태행위를 가지고 있어야 하지만 위의 익명 클래스는 행위 밖에 담고 있지 않습니다. 따라서 행위만 변동적이라면 행위 자체를 넘겨주는 것이 조금 더 직관적입니다.

Java 8 버전부터는 람다 함수를 이용하면 간단한 전략은 클래스 대신 함수로 대체할 수 있습니다. 익명 클래스를 쓴 것보다 람다 함수가 같은 기능을 하면서도 더 이해하기 쉽고 가독성이 좋습니다. 따라서 전략을 클래스 대신 함수를 받도록 처리하는 것으로 클래스를 생성하는 부담이 줄어듭니다. 물론 전략이 개별적인 상태를 가지는 경우 클래스로 처리하는 것이 좋습니다.

의존성 주입받기

또 다른 단점인 클라이언트에게 책임이 크다는 점을 개선해보겠습니다. 기능을 변경하다가 전략을 넣어주는 걸 잊어버릴 수 있습니다. 이 결과는 전략이 실행되기 전까지 잘못되었는지 알 수 없습니다. 이렇게 전략을 넣어주는 부분을 클라이언트가 아닌 외부에서 자동으로 해준다면 어떨까요?

@Autowired private SimpleStrategy simpleStrategy;

Spring 같은 프레임워크를 사용하면 클라이언트가 직접 전략을 넣어주던걸 프레임워크가 대신해줍니다. 이렇게 되면 클라이언트는 매번 직접 전략을 직접 생성해서 넣어주지 않아도 됩니다. 클라이언트가 전략을 선택하고, 생성하는 책임에서 생성에 대한 책임이 줄어들게 됩니다.