티스토리 뷰
이번 글에서 정리할 전략 패턴 또한 템플릿 메소드 패턴과 같이 알고리즘의 전체적인 구조는 고정되고 일부 로직의 구현만이 변경될 경우 사용할 수 있는 패턴이다. 이 두 패턴의 가장 큰 차이점은 패턴을 구현하는 방법으로, 템플릿 메소드 패턴은 상속을, 전략 패턴은 위임을 이용한다. 그럼 왜 굳이 비슷한 기능을 하는 패턴을 구현 방법에 따라 2개로 나눠놨을까? 의문이 들 것이다.
템플릿 메소드 패턴에서 알고리즘의 구체적인 구현은 상속을 통해 자식 클래스에서 이뤄진다. 이때 템플릿 즉 알고리즘의 전체적인 틀을 정의하는 부모 클래스의 메소드들까지도 자식 클래스에게 상속된다. 즉 자식 클래스는 자신이 실제로 사용하지 않는 부모 클래스의 기능까지도 상속받게 되고 이로 인해 부모 클래스의 변경이 불필요하게 자식 클래스에까지 영향을 미칠 수 있다. 다시 말해 알고리즘의 상세 구현이 템플릿에 의존하게 되는 꼴이 될 수 있다는 것이다. 이런 불필요한 의존성을 끊어버리기 위해 전략 패턴이 등장했고, 해당 패턴에서는 알고리즘의 상세 구현이 템플릿으로부터 완전히 독립적으로 관리된다.
이번 글에서는 전략 패턴은 무엇인지, 어떻게 구현하는지 자세히 알아보고 더불어 전략 패턴의 구현 방식 중 하나인 템플릿 콜백 패턴에 대해서도 간단히 알아보고자 한다.
Strategy Pattern
디자인 패턴은 어느날 갑자기 하늘에서 뚝 떨어진 것이 아니다. 실제 어떤 문제 상황을 효율적으로 해결하기 위한 방법을 열심히 고안한 끝에 도출된 것이다. 그럼 이 패턴을 어떤 상황에 적용할 수 있을지는 패턴의 의도를 통해 알 수 있는데, 전략 패턴의 설계 의도는 다음과 같다.
특정 알고리즘의 여러 구현을 별도의 클래스로 분리, 즉 캡슐화하여 상호 교환 가능하게 만듦으로써 해당 알고리즘을 사용하는 객체의 동작을 런타임에 동적으로 변경할 수 있다.
즉 전략 패턴에서 특정 작업을 처리하기 위한 알고리즘의 실제 실행은 다른 객체에게 위임하며 이때 위임 받은 객체는 해당 알고리즘의 여러 구현 중 하나로, 이러한 알고리즘 구현체들을 동적으로 변경하면서 해당 작업은 여러 방식으로 처리될 수 있다.
단어들이 조금은 추상적이라 전략 패턴의 개념이 한번에 와닿지 않을 수 있다. 전략 패턴의 구성 요소들에는 무엇이 있는지, 각 요소는 무슨 역할을 하는지 패턴의 실체를 살펴봄으로써 이해해보자.
Context | - 작업의 전체적인 구조(템플릿)를 정의하며 여러 Concrete Strategy 중 하나에게 알고리즘의 실행을 위임 - Strategy 구현 객체에 대한 참조를 가짐 - Context는 작업을 수행하기 위한 적절한 Concrete Strategy를 선택할 책임이 없으며, 실제 Concrete Strategy의 구현 내용은 알지 못하고 단순히 실행을 위임 - 일반적으로 생성자를 통해 Concrete Strategy를 주입받지만, 런타임에 동적으로 Concrete Strategy를 변경하기 위해 setter를 제공 |
Strategy | - Concreate Strategy에 대한 공통된 동작을 정의하는 인터페이스 - Context가 Concrete Strategy를 실행하는데 사용하는 하나의 메소드를 선언 |
Concrete Strategy | - Strategy 인터페이스를 구현한 클래스 - 각 클래스는 하나의 알고리즘에 대한 다른 구현을 정의 |
Client | - Context에게 적절한 Concrete Strategy 객체를 생성해서 전달하는 역할 - Context와 달리 모든 Concrete Strategy에 대해 알고 있어야 함 |
각 요소 간 관계는 다음과 같다. 중요한 것은 Context는 Strategy 인터페이스에 대한 참조를 가지며, ConcreteStrategy는 Strategy 인터페이스를 구현한다는 것과 Client가 적절한 ConcreteStrategy 객체를 Context에게 주입해준다는 것이다. 따라서 ConcreteStrategy, 즉 알고리즘의 상세 구현은 템플릿이라 할 수 있는 Context로부터 완전히 독립되고 Context 클래스에서도 전략의 구체가 아닌 추상, 즉 Strategy 인터페이스에 의존함으로써 변경에 더욱 유연한 구조가 되었다. 이러한 전략 패턴을 통해 Context나 기존 Strategy 관련 코드를 변경할 필요 없이 새로운 전략을 추가하거나 변경하는 것이 가능해진다.
전략 패턴은 복잡하지 않은 구조로 이만하면 이해가 됐을거라 생각한다. 이제 실제 문제 상황에 전략 패턴을 적용해보자. 아래는 아주 간단한 할인 정책 요구사항으로 지난 템플릿 메소드 패턴 관련 글에서 작성했던 것과 같다. 전략 패턴을 적용하지 않고 하나의 클래스에서 요구사항을 구현한 코드는 다음과 같다.
- 등급 별로 다른 할인 정책을 적용하는 서비스
- BRONZE, SILVER, GOLD 등급이 존재
- BRONZE는 2만원 이상 구매 시 3천원 할인
- SILVER는 1만원 이상 구매 시 3천원 할인
- GOLD는 구매 금액 상관 없이 50% 할인
public class DiscountPolicy {
int discount(int amount, Grade grade) {
if (grade == Grade.BRONZE) {
if (amount >= 20000) return amount - 3000; //3000원 할인
}
else if (grade == Grade.SILVER) {
if (amount >= 10000) return amount - 3000; //3000원 할인
}
else if (grade == Grade.GOLD) {
if (true) return amount - (amount / 2); //기존 금액의 50% 할인
}
return amount;
}
}
public enum Grade {
BRONZE, SILVER, GOLD
}
위 코드의 가장 큰 문제점은 변경에 유연하지 않다는 것이다. BRONZE, SILVER, GOLD 등급의 할인 정책이 변경되거나 새로운 등급이 추가되었을 때 모두 DiscountPolicy
클래스 코드를 변경해야 한다. 만약 할인 정책이 복잡해짐에 따라 DiscountPolicy
클래스가 비대해진다면 이런 변경 사항을 적용하기도 어려워질 것이다.
위 요구사항은 '할인'이라는 공통의 알고리즘이 등급에 따라 다르게 구현된 것이라고 볼 수 있다. 즉 알고리즘의 전체적인 구조는 고정되고 일부 로직만이 등급에 따라 변하기 때문에 전략 패턴을 적용할 수 있으며 이를 통해 좀더 변경에 유연한 구조를 만들 수 있다. 그럼 이제 다음 순서에 따라 전략 패턴을 적용해보자.
1. 캡슐화를 적용할 알고리즘을 식별
한 클래스 내에 동일한 알고리즘의 여러 구현으로 분기하는 거대한 조건문이 있을 때, 각 구현을 캡슐화하고 추상화함으로써 리팩토링할 수 있다. 위의 DiscountPolicy
클래스에는 할인 정책 알고리즘을 등급에 따라 분기 처리하기 위해 거대한 if문이 포함되어 있는데(등급이 많아질 수록 더 거대해질 것이다.) 이는 전략 패턴을 적용해야 한다는 일종의 신호이다.
2. 해당 알고리즘의 공통 동작을 정의하는 Strategy 인터페이스를 선언
할인 정책에서 공통된 동작은 파라미터로 전달된 금액에 대한 등급별 최종 할인 금액을 산출해내는 것이다.(해당 금액에 대해 얼만큼 할인되는지) 이러한 공통 동작을 수행하는 discount
메소드 1개를 DiscountStrategy
인터페이스 내에 선언해주었다. 실제 discount
메소드의 구현은 등급별로 달라질 것이다.
public interface DiscountStrategy {
int discount(int amount);
}
3. Strategy 인터페이스를 구현하여 각 알고리즘의 상세 구현을 클래스로 정의DiscountStrategy
인터페이스를 구현한 클래스를 생성하고 각 클래스에서는 등급에 따른 할인 정책 내용을 정의해준다. 현재는 3개의 등급만이 존재하므로 BronzeDiscountStrategy
, SilverDiscountStrategy
, GoldDiscountStrategy
클래스를 정의했다.
public class BronzeDiscountStrategy implements DiscountStrategy {
@Override
public int discount(int amount) {
if (amount >= 20000) return 3000; //2만원 이상 구매 시 3천원 할인
return 0;
}
}
public class SilverDiscountStrategy implements DiscountStrategy {
@Override
public int discount(int amount) {
if (amount >= 10000) return 3000; //1만원 이상 구매 시 3천원 할인
return 0;
}
}
public class GoldDiscountStrategy implements DiscountStrategy {
@Override
public int discount(int amount) {
return amount / 2; //구매 금액 상관 없이 50% 할인
}
}
4. Strategy 인터페이스에 대한 참조를 가진 Context 클래스를 정의하고, 알고리즘의 실행을 위임하기 위해 Strategy의 메소드를 호출 (런타임에 Strategy 구현 객체를 변경하기 위해 setter를 정의) DiscountStrategy
인터페이스에 대한 참조를 가진 DiscountContext
클래스를 정의해주었다. 해당 클래스는 등급별 할인 정책의 적용은 DiscountStrategy
구현 객체에게 위임하며 위임 결과 최종 할인 금액을 반환 받아 최종 결제 금액을 산출한다. 실행 시 동적으로 할인 정책의 구현을 변경하기 위해 setter 메소드를 추가로 정의했다.
public class DiscountContext {
private DiscountStrategy strategy;
//생성자 주입
public DiscountContext(DiscountStrategy strategy) {
this.strategy = strategy;
}
//setter 주입
public void setStrategy(DiscountStrategy strategy) {
this.strategy = strategy;
}
//최종 결제 금액 산출
public int applyDiscount(int amount) {
int discountAmount = strategy.discount(amount); //할인 정책 적용
if (amount < discountAmount) return 0;
return amount - discountAmount;
}
}
5. Context에 적절한 Strategy 구현 객체를 주입해주는 Client 클래스를 정의DiscountContext
에 적절한 DiscountStrategy
구현 객체를 주입하고 주어진 금액에 할인을 적용한 최종 금액을 반환받는 DiscountApplication
클래스를 정의했다. setStrategy
메소드를 통해 할인 정책의 구현을 동적으로 변경해주었다.
public class DiscountApplication {
public static void main(String[] args) {
BronzeDiscountStrategy bronze = new BronzeDiscountStrategy();
SilverDiscountStrategy silver = new SilverDiscountStrategy();
GoldDiscountStrategy gold = new GoldDiscountStrategy();
DiscountContext context = new DiscountContext(bronze);
int bronzeResult = context.applyDiscount(10000);
context.setStrategy(silver);
int silverResult = context.applyDiscount(10000);
context.setStrategy(gold);
int goldResult = context.applyDiscount(10000);
System.out.println("bronzeResult = " + bronzeResult);
System.out.println("silverResult = " + silverResult);
System.out.println("goldResult = " + goldResult);
}
}
이렇게 전략 패턴을 적용한 결과 전체적인 클래스의 수는 증가했지만 이전에는 하나의 클래스가 모든 책임을 가진 것과는 달리 각 클래스가 하나의 책임만을 가져 SRP(단일 책임 원칙)에 더 부합하게 되었다. 따라서 원래의 DiscountPolicy
클래스 내의 거대한 if문이 제거되었으며 DiscountContext
클래스는 모든 등급의 할인 정책 내용을 포함할 필요 없이 단순히 DiscountStrategy
구현체 중 하나에게 실행을 위임할 수 있게 되었다. 또한 특정 등급의 할인 정책 변경 시 DiscountStrategy
를 구현한 클래스 1개에만 영향을 미치며 새로운 등급 추가 시에는 DiscountStrategy
를 구현한 클래스를 생성하면 되므로 변경에 더욱 유연하게 대처할 수 있게 되었다.
전략 패턴을 적용함으로써 얻을 수 있는 장점은 다음과 같다.
- 유연성(Flexibility): 알고리즘의 다른 구현을 선택함으로써 런타임에 객체의 동작을 동적으로 변경 가능
- 모듈화(Modularity): 알고리즘의 구현을 별도의 클래스로 캡슐화함으로써 다른 코드에 영향을 주지 않으면서 알고리즘의 구현을 쉽게 추가하거나 제거 가능 (알고리즘의 구현 디테일을 해당 알고리즘을 사용하는 코드로부터 격리)
- 테스트 용이성(Testability): 각 알고리즘의 구현을 클래스로 분리하였기 때문에 테스트가 용이
- OCP(개방-폐쇄 원칙): '확장에는 열려 있어야 하고 변경에는 닫혀 있어야 한다.'는 OCP를 준수, 즉 Context의 코드 변경 없이 Strategy의 구현을 변경하거나 새로운 Strategy 도입 가능
그러나 전략 패턴에서는 여러 클래스와 인터페이스가 등장하기 때문에 어플리케이션의 전체적인 복잡도가 증가할 수 있다. 따라서 알고리즘의 구현이 많지 않으면서 거의 변하지 않는다면 위의 DiscountPolicy
클래스처럼 단순히 하나의 클래스에서 모두 처리하도록 하는 것이 좋다.
위에서는 Context 클래스가 Strategy 인터페이스를 참조하는 방식으로 전략 패턴을 구현하였다. 이는 Context와 Strategy를 실행 전에 원하는 모양으로 조립해두고, 그 다음에 Context를 실행하는 '선 조립, 후 실행' 방식이다. 따라서 Context와 Strategy를 한번 조립하고 나면 이후로는 Context를 실행하기만 하면 된다. 이 방식은 스프링으로 애플리케이션을 개발할 때 애플리케이션 로딩 시점에 의존관계 주입을 통해 필요한 의존관계를 모두 맺어두고 실행 시점에는 주입 받은 객체에게 요청 처리를 위임하는 것과 같은 원리이다.
헤당 방식에서는 동적으로 전략을 변경하기 위해 setter를 열어두게 되는데 이때 Context를 싱글톤으로 관리하게 되면 동시성 문제가 발생할 수 있다. 즉 여러 쓰레드가 동시에 setter를 통해 전략 필드를 수정하게 되어 의도한 전략이 수행되지 않을 수 있다. 이는 쓰레드 별로 Context를 생성하고 이곳에 다른 Strategy를 주입하는 방식으로 해결될 수 있으나 여전히 Context 객체를 매번 생성해야 하는 문제가 남는다.
결론적으로 전략을 실시간으로 자주 변경해야 한다면 Context가 Strategy에 대한 참조 필드를 가지는 것이 아닌 Context의 실행 메소드의 파라미터로 직접 Strategy를 전달해주는 방식을 택하는 것이 좋다. 이 방식에서는 전략을 Context 내에서 객체 필드로 관리하지 않아 동시성 문제가 발생하지 않으며, 전략을 동적으로 변경하기 위해 매번 Context 객체를 생성해줄 필요도 없다. 이렇게 Context의 실행 메소드 파라미터로 전략을 주입함으로써 실행 시 마다 전략을 유연하게 변경할 수 있도록 하는 전략 패턴의 구현 방식을 별도로 템플릿 콜백 패턴이라고 하는데 아래에서 이에 대해 간단히 알아보자.
Template Callback Pattern
먼저 콜백(callback)이란 다른 코드의 인수로서 넘겨주는 실행 가능한 코드로, 자세한 정의는 다음과 같다.
프로그래밍에서 콜백(callback) 또는 콜애프터 함수(call-after function)는 다른 코드의 인수로서 넘겨주는 실행 가능한 코드를 말한다. 콜백을 넘겨받는 코드는 이 콜백을 필요에 따라 즉시 실행할 수도 있고, 아니면 나중에 실행할 수도 있다.
즉 템플릿 콜백 패턴에서는 전략 패턴의 Context가 템플릿 역할을 하고, Strategy는 콜백으로서 전달된다 생각하면 된다. 참고로 템플릿 콜백 패턴은 GOF 패턴은 아니고, 스프링 내부에서 이런 방식을 자주 사용하기 때문에 스프링에서 정의한 패턴이다. 정리하자면 전략 패턴에서 템플릿과 콜백 부분이 강조된 패턴이라 할 수 있다.
*GOF 패턴: 에릭 감마, 리처드 헬름, 랄프 존슨, 존 블리시데스가 제안한 GoF(Gang of Four)의 디자인 패턴으로, 객체지향 개념에 따른 설계 중 재사용할 경우 유용한 설계를 패턴으로 정립한 것
자바에서는 실행 가능한 코드(콜백)를 인수로 넘기려면 객체가 필요한데, Strategy 인터페이스를 구현한 별도의 클래스를 정의하거나 익명 내부 클래스를 사용할 수 있다. 자바 8부터는 람다를 사용할 수 있기 때문에 해당 인터페이스가 하나의 추상 메소드만을 가진 함수형 인터페이스인 경우에는 간단하게 코드 조각(전략)을 파라미터로 넘길 수 있다.
(위의 전략 패턴에서도 람다 적용 가능)
그럼 이번에는 할인 정책 요구사항에 템플릿 콜백 패턴을 적용해보자. DiscountStrategy
인터페이스를 구현한 별도의 클래스를 정의하지 않고 람다를 사용할 것이므로 DiscountStrategy
인터페이스와 DiscountContext
, DiscountApplication
클래스만이 필요하다.
public interface DiscountStrategy {
int discount(int amount);
}
public class DiscountContext {
//DiscountStrategy에 대한 참조 제거
//실행 시 전략을 파라미터로 주입
public int applyDiscount(int amount, DiscountStrategy strategy) {
int discountAmount = strategy.discount(amount);
if (amount < discountAmount) return 0;
return amount - discountAmount;
}
}
public class DiscountApplication {
public static void main(String[] args) {
DiscountContext context = new DiscountContext();
//람다로 구현된 전략
int bronzeResult = context.applyDiscount(10000, amount -> {
if (amount >= 20000) return 3000;
return 0;
});
int silverResult = context.applyDiscount(10000, amount -> {
if (amount >= 10000) return 3000;
return 0;
});
int goldResult = context.applyDiscount(10000, amount -> amount / 2);
System.out.println("bronzeResult = " + bronzeResult);
System.out.println("silverResult = " + silverResult);
System.out.println("goldResult = " + goldResult);
}
}
위 코드를 보면 context
의 appliyDiscount
메소드의 파라미터로 코드 조각, 즉 콜백을 넘겨준다. appliyDiscount
메소드는 전달된 콜백을 호출하고 호출 결과를 적절히 처리하는 역할을 한다. 이렇게 Context 실행 시마다 새로운 전략을 전달하기 때문에 할인 정책 알고리즘의 구현을 보다 유연하게 변경할 수 있게 되었다. 또한 DiscountStrategy
인터페이스를 구현한 새로운 클래스를 별도로 정의하지 않고 람다를 사용하여 프로그램의 전체적인 복잡도가 증가하지 않았다. 그러나 Context 실행 시마다 항상 전략을 파라미터로 전달해주어야 한다는 불편함과 람다를 사용했기 때문에 전략의 재사용성이 떨어진다는 문제점이 남아있긴 한다. 만약 다른 클래스에서 해당 전략을 재사용하거나 구현된 전략이 매우 복잡하다면 별도의 클래스로 정의해주는 것이 좋다.
스프링에서는 JdbcTemplate
, RestTemplate
, TransactionTemplate
, RedisTemplate
처럼 템플릿 콜백 패턴이 많이 사용된다. 실제 JdbcTemplate
의 execute
메소드의 파라미터로는 ConnectionCallback
인터페이스 구현체가, TransactionTemplate
의 execute
메소드의 파라미터로도 TransactionCallback
인터페이스 구현체가 전달되는 것을 확인할 수 있다. 일반적으로 스프링에서 이름에 XxxTemplate
이 있다면 템플릿 콜백 패턴이 적용된 것으로 생각하면 된다.
이외에도 우리가 흔히 사용하는 자바 Collections
에도 전략 패턴이 적용되어 있는데, 바로 Collections
의 sort
메소드의 파라미터로 Comparator
인터페이스 구현체를 전달받는 부분이다. Comparator
는 두 객체를 비교하는 방법, 즉 비교 알고리즘의 구현을 정의한 것으로 sort
메서드는 이 전략을 사용하여 컬렉션을 정렬한다. 참고로 Comparator
인터페이스는 추상 메소드로 compare
메소드만이 존재하는 함수형 인터페이스이기 때문에 람다를 사용할 수 있다.
앞서 말했듯 특정 작업의 전체적인 흐름, 구조는 동일한 반면 일부 로직의 상세 구현이 달라지는 경우, 변경에 보다 유연한 구조를 만들기 위해 템플릿 메소드 또는 전략 패턴을 사용할 수 있다. 둘은 각각 상속과 위임이라는 방법을 통해 구현되는데 이러한 구현 방식의 차이를 염두에 두고 상황에 따라 각 패턴을 적절히 선택하면 될 것 같다.
마지막으로 이 두 패턴의 차이를 정의하는 글을 보고 의문이 들어 가지고 와봤다.
Template Method works at the class level, so it’s static.
Strategy works on the object level, letting you switch behaviors at runtime.
전략 패턴에 대한 설명은 이해가 갔으나 템플릿 메소드 패턴에서의 static, 정적이라는 단어가 잘 와닿지 않았다. 그 이유는 템플릿 메소드 패턴 또한 다른 자식 클래스를 선택함으로써 알고리즘의 구현을 동적으로 변경할 수 있기 때문이다. 열심히 생각해본 결과, 내가 내린 결론은 다음과 같다.
'템플릿 메소드 패턴의 경우 어떤 알고리즘의 구현을 선택할지는 여러 자식 클래스들 중 하나를 선택함으로써, 전략 패턴의 경우에는 여러 전략 객체들 중 하나를 컨텍스트에 전달함으로써 이뤄지기 때문에 각각 class level, object level에서 동작하며, 템플릿 메소드 패턴에서는 작업 실행의 주체가 부모 클래스를 상속한 자식 클래스로, 자식 클래스 자체는 동작이 고정되어 정적인 반면, 전략 패턴에서는 컨텍스트가 실행의 주체로 전달된 전략 객체에 따라 동작이 변하므로 동적이다.'
아마 이러한 의도로 저런 단어를 사용하지 않았을까 한다. 아무튼 끝.
끝.
참고
https://refactoring.guru/design-patterns/strategy
https://www.geeksforgeeks.org/strategy-pattern-set-1/
https://www.freecodecamp.org/news/a-beginners-guide-to-the-strategy-design-pattern/
'자바 > 개념' 카테고리의 다른 글
[디자인 패턴] Facade 패턴 (1) | 2023.12.17 |
---|---|
[디자인 패턴] Template Method Pattern (0) | 2023.09.19 |
[개념] FrontController 패턴 (with 자바) (0) | 2023.08.27 |
[개념] Builder Pattern과 자바의 @Builder 어노테이션2 (0) | 2023.02.13 |
[개념] Builder Pattern과 자바의 @Builder 어노테이션1 (0) | 2023.02.13 |
- Total
- Today
- Yesterday
- junit5
- Linux
- Gitflow
- 서블릿 컨테이너
- facade 패턴
- FrontController
- SSE
- 단위 테스트
- Transaction
- 템플릿 콜백 패턴
- C++
- Spring Security
- spring boot
- 디자인 패턴
- JPA
- Front Controller
- Git
- vscode
- spring aop
- mockito
- 전략 패턴
- Java
- Assertions
- 모두의 리눅스
- github
- spring
- QueryDSL
- servlet filter
- ParameterizedTest
- rest api
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |