오늘은 Pre팀과 각자 스프링에 사용되는 디자인 패턴들을 공부하고 발표하는 시간을 가졌다. 옛날에 발표를 녹음했던 거에 비해서 상당히 말도 천천히 말을 할 수 있게 되었음을 알 수 있었다.
발표 이후에 피드백으로 발표 자료에 대한 피드백이 상당히 많이 들어왔는데, 글로 쓰는 형식에 익숙해져 있다보니 글에 대한 가독성이 떨어지는 것 같다는 피드백을 받았다. 발표 자료란 어떤 형식으로 이루어져야 하는가에 대한 생각을 하게 될 수 있는 유익한 시간이었다. 추가적으로 아래는 금일 발표를 진행했던 전략 패턴에 대한 소개이다.
소개
안녕하세요 !
객체지향의 꽃처럼 보이는 이번 전략 패턴을 소개하게 된 문종운이라고 합니다. 😄
개요
헤드 퍼스트 디자인 패턴 책에서도 전략 패턴을 제일 처음 단원으로 구성을 해두었습니다. 그 만큼 디자인 패턴을 배울 때 중요도가 높다고 할 수 있는 부분입니다. 위에서 전략패턴의 꽃처럼 보인다라고 말씀을 드렸는데요. 전략 패턴이 객체지향 설계 기법에 많은 부분을 지킨 케이스이기 때문입니다.
전략 패턴에 대한 소개를 다음과 같이 진행하고자 합니다.
- 전략 패턴이 왜 생기게 되었는가.
- 전략 패턴은 무엇인가.
- 전략 패턴은 어떻게 구현하는가.
- 전략 패턴이 실 사례에서는 어떻게 쓰이는가.
- 비슷한 템플릿 메서드 패턴과의 차이는 무엇인가.
전략 패턴이 생기게 된 이유
전략 패턴이 생기게 된 여러 사례들이 있습니다. 그 중 대표적으로
- 상속으로 기능을 확장하고자 구현했을 때의 문제
- 인터페이스로 기능 확장을 위해 단순히 역할만 부여했을 때의 문제
이 두 개를 살펴보도록 하겠습니다.
상속에 대한 문제
public abstract class Robot {
public abstract void talk();
public abstract void move();
}
class WalkingRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 걸을 수 있습니다.");
}
@Override
public void move() {
System.out.println("걸어서 배달합니다 삐-빅");
}
}
class RunningRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 뛸 수 있습니다.");
}
@Override
public void move() {
System.out.println("뛰어서 배달합니다 삐-빅");
}
}
class Main {
public static void main(String[] args) {
Robot robot1 = new WalkingRobot();
robot1.talk();
robot1.move();
Robot robot2 = new RunningRobot();
robot2.talk();
robot2.move();
}
}
지금 같은 상황으로 봐서는 크게 문제가 없어보입니다. 다만 여기에는 보이지 않는 문제가 있습니다. 바로 유지보수를 할 때의 문제, 기능이 추가되었을 때의 문제입니다.
로봇에게 춤을 추는 기능을 추가했다고 가정해보도록 하겠습니다.
public abstract class Robot {
public abstract void talk();
public abstract void move();
public abstract void dance();
}
class WalkingClassicDanceRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 걸을 수 있습니다.");
}
@Override
public void move() {
System.out.println("걸어서 배달합니다 삐-빅");
}
@Override
public void dance() {
System.out.println("띠리리리");
}
}
class RunningLeseDanceRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 뛸 수 있습니다.");
}
@Override
public void move() {
System.out.println("뛰어서 배달합니다 삐-빅");
}
@Override
public void dance() {
System.out.println("안티티티티");
}
}
class WalkingNewJeansDanceRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 뛸 수 있습니다.");
}
@Override
public void move() {
System.out.println("뛰어서 배달합니다 삐-빅");
}
@Override
public void dance() {
System.out.println("Cause I ~~~");
}
}
class RunningRobot extends Robot {
@Override
public void talk() {
System.out.println("저는 뛸 수 있습니다.");
}
@Override
public void move() {
System.out.println("뛰어서 배달합니다 삐-빅");
}
@Override
public void dance() {
}
}
class Main {
public static void main(String[] args) {
Robot robot1 = new WalkingClassicDanceRobot();
robot1.talk();
robot1.move();
robot1.dance();
Robot robot2 = new RunningLeseDanceRobot();
robot2.talk();
robot2.move();
robot2.dance();
Robot robot3 = new WalkingNewJeansDanceRobot();
robot3.talk();
robot3.move();
robot3.dance();
Robot robot4 = new RunningRobot();
robot4.talk();
robot4.move();
robot4.dance();
}
}
위 처럼 춤추는 기능을 추가했더니 클래스가 굉장히 많아졌습니다! 이를 클래스 폭발 이라고 부르기도 합니다. 추가로 보이는 것은 춤을 추지 않는 로봇임에도 불구하고, 빈 메서드를 구현해주어야 한다는 문제점이 발견된 것입니다.
지금도 단 하나의 행동만을 추가했음에도 이렇게 클래스가 방대해졌는데 기능이 하나, 둘 추가됨에 따라 클래스는 더욱 더 불어난다는 문제는 피할 수 없는 문제로 보여집니다.
인터페이스에 대한 문제
public interface Walkable {
void walk();
}
public interface Runnable {
void run();
}
public interface Speakable {
void talk();
}
class WalkingRobot implements Walkable, Speakable {
@Override
public void walk() {
System.out.println("저는 걸을 수 있습니다.");
}
@Override
public void speak() {
System.out.println("저는 말 할 수 있습니다.");
}
}
class RunningRobot implements Runnable, Speakable {
@Override
public void run() {
System.out.println("저는 뛸 수 있습니다.");
}
@Override
public void speak() {
System.out.println("저는 말 할 수 있습니다.");
}
}
상속을 하게 되면 필요없는 부분을 구현을 해줘야 하니, 인터페이스를 사용해서 필요한 기능만 구현하려고 전략을 바꾼 상황입니다. 이전과 같은 사례와 비슷하게 해당 케이스도 문제가 발생 할 것으로 보여집니다. 계속해서 새로운 기능이 추가되면 새로운 기능이 추가될 때마다 인터페이스를 구현해야하는 양이 많아지게 된다는 점입니다. 따라서 이러한 방식도 좋은 방법이 아닌 것으로 보여집니다.
우리의 목적은 결국 객체 자체에게 어떠한 행동에 대해서 부여를 해야하는 상황입니다. 그리고 이러한 문제를 해결하기 위해 전략패턴을 사용하게 되었습니다.
전략 패턴은 무엇인가
GoF의 디자인 패턴 책에서는 전략 패턴을 다음과 같이 정의하고 있습니다.
동일 계열의 알고리즘 군을 정의하고, 각각의 알고리즘을 캡슐화하여 이들을 상호 교환이 가능하도록 만들어서 알고리즘을 사용하는 클라이언트와 상관없이 독립적으로 알고리즘을 다양하게 변경할 수 있게 한다.
정리를 하자면 전략 패턴은 실행 도중에 알고리즘 전략을 선택하여 객체의 동작을 실시간으로 바뀌도록 할 수 있게 하는 행위 디자인 패턴이다.
전략 알고리즘 객체들
알고리즘, 행위, 동작등으로 객체를 정의한 구현체
전략 인터페이스
모든 전략 구현체에 대한 공용 인터페이스
컨텍스트(Context)
알고리즘을 실행해야 할 때마다 해당 알고리즘과 연결된 전략 객체의 메서드를 호출.
프로그래밍에서의 컨텍스트(Context) 란 콘텐츠(Contents)를 담는 무엇인가를 뜻하고, 어떤 객체를 핸들링 하기 위한 접근 수단입니다.
클라이언트
특정 전략 객체를 컨텍스트에 전달 함으로써 전략을 등록하거나 변경하여 전략 알고리즘을 실행한 결과를 받는다.
전략 패턴의 구현
위에서 상속에 대한 나쁜 사례와 인터페이스를 단순하게 사용했을 때의 대한 나쁜 사례들을 살펴보았고, 이를 개선하기 위한 방법을 알아보도록 하겠습니다.
상속을 사용했을 때 클래스가 매우 많아지는 문제가 생긴 이유는 객체 자체를 사물 혹은 생물 즉 현실에 존재하는 어떠한 실체에 대해서만 생각을 했기 때문이다. 객체는 실체가 아닌 하나의 기능, 행위, 동작으로도 표현이 가능합니다. 따라서 위의 로봇 코드를 리팩토링 한다면 다음과 같이 구성이 가능하게 됩니다.
interface MoveStrategy {
void move();
}
class Walk implements MoveStrategy {
@Override
public void move() {
System.out.println("걸어서 배달합니다 삐-빅");
}
}
class Run implements MoveStrategy {
@Override
public void move() {
System.out.println("뛰어서 배달합니다 삐-빅");
}
}
interface DanceStrategy {
void dance();
}
class Hypeboy implements DanceStrategy {
@Override
public void dance() {
System.out.println("Cause I~~");
}
}
class Antifragile implements DanceStrategy {
@Override
public void dance() {
System.out.println("Antitititi~~");
}
}
public class Robot {
MoveStrategy moveStrategy;
DanceStrategy danceStrategy;
Robot(MoveStrategy moveStrategy, DanceStrategy danceStrategy) {
this.moveStrategy = moveStrategy;
this.danceStrategy = danceStrategy;
}
void move() {
moveStrategy.move();
}
void dance() {
danceStrategy.dance();
}
void changeMove(MoveStrategy moveStrategy) {
this.moveStrategy = moveStrategy;
}
void changeDance(DanceStrategy danceStrategy) {
this.danceStrategy = danceStrategy;
}
}
class User {
public static void main(String[] args) {
Robot robot = new Robot(new Walk(), new Hypeboy());
robot.move(); // 걸어서 배달합니다 삐-빅
robot.dance(); // Cause I~~
// 로봇의 전략(기능)을 run과 Japanese 번역으로 변경
robot.changeMove(new Run());
robot.changeDance(new Antifragile());
robot.move(); // 뛰어서 배달합니다 삐-빅
robot.dance(); // Antitititi~~
}
}
위처럼 합성(Composite)를 사용하여 필요한 알고리즘 군을 바꿔서 사용할 수 있게 되었습니다. 이어서 아까와 같은 질문을 할 수도 있습니다.
그럼 아무런 행동도 하지 않을 경우에는 어떻게 하나요? 움직이지 않거나 춤추지 않는 경우는요?
해당 부분에 대해서는 다음과 같은 방식으로 움직이지 않거나 춤을 추지 않는다는 구성체를 추가하는 방향으로 해결할 수 있습니다.
class Hold implements MoveStrategy {
...
}
class NoDance implements DanceStrategy {
...
}
이어서 전략패턴이 어떻게 구현되었는지 확인했으니, 실제 사례에서는 전략패턴을 어떻게 사용하고 있는지 살펴보도록 하겠습니다.
전략 패턴의 실 사례
자바의 정렬
제일 먼저 자바에서 전략 패턴을 사용한 사례를 확인한다면 sort() 에 해당합니다. sort()는 기본적으로 Comparator를 매개변수로 받고 이를 구현한 형태에 따라 정렬이 전략에 따라 다르게 동작하도록 구성이 되어있습니다.
List<Integer> numbers = new ArrayList<>();
numbers.add(2);
numbers.add(1);
numbers.add(3);
numbers.add(5);
numbers.add(4);
// sort 메서드의 매개변수로 익명 클래스로 Comparator 객체를 인스턴스화하여
// 그 안의 compare 메서드 동작 로직(ConcreteStrategy)를 직접 구현하여 할당하는 것을 볼 수 있다.
Collections.sort(numbers, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o1 - o2;
}
});
거래 전략 선택
이번에는 실 사례를 대상으로 비슷하게 전략패턴을 사용하여 구현할 수 있는 케이스를 살펴보도록 하겠습니다.
인터넷에서 물건을 구매하거나, 아니면 음식을 배달시키거나 할 때에 항상 선택하는 것은 결제 수단입니다. 어떤 결제 수단을 선택하느냐에 따라서 결제 방식이 달라지게 됩니다. 음식을 배달시키는 앱을 가정하여 전략 패턴이 어떻게 구현될 지 한 번 살펴보도록 하겠습니다.
class Food {
private String name;
private int price;
public Item(String name, int price) {
this.name = name;
this.price = cost;
}
public int getPrice() {
return price;
}
}
class Basket {
List<Food> foods;
public Basket() {
foods = new ArrayList<>();
}
public void addFood(Food food) {
this.foods.add(food);
}
public void pay(PaymentStrategy paymentStrategy) {
int amount = 0;
for (Food food : foods) {
amount += food.price;
}
paymentStrategy.pay(amount);
}
}
우선 기본적인 결제 프레임입니다. 장바구니에 음식을 담고 장바구니에 담긴 음식에 총합을 계산하고, 결제 전략에 따라서 다르게 결제되는 형식입니다. 그리고 결제 전략을 다음과 같이 구현 할 수 있습니다.
interface PaymentStrategy {
void pay(int amount);
}
class ProgrammersCard implements PaymentStrategy {
int balance;
public ProgrammersCard(int balance) {
this.balance = balance;
}
@Override
public void pay(int amount) {
// 예외 생략
System.out.println(amount + "원" 결제되었습니다.);
balance -= amount;
}
public void charge(int amount) {
balance += amount;
}
}
class NormalCard implements PaymentStrategy {
private String cardNumber;
private String csv;
private String dateOfExpiry
public NormalCard(String cardNumber, String csv, String dateOfExpiry) [
this.cardNumber = cardNumber;
this.csv = csv;
this.dateOfExpiry = dateOfExpiry;
}
@Override
public void pay(int amount) {
// 예외 생략
결제라이브러리.호출(amount);
}
}
마지막 실제 사용 부분에서는 다음과 같이 사용이 가능합니다.
class Main {
public static void main(String[] args) {
Basket basket = new Basket();
Food a = new Food("황금 올리브", 25000);
Food b = new Food("자메이카 통다리", 22000);
basket.addFood(a);
basket.addFood(b);
basket.pay(new ProgrammersCard(100000));
basket.pay(new NormalCard("1111111111", "324", "26/01/01"));
}
}
이렇게 결제하는 수단을 적절하게 교체하여 사용하는 것이 가능해졌습니다. 이것과도 비슷한 사례로 로그인 기능을 선택하는 경우에도 동일합니다. OAuth2를 사용했을 때 각 플랫폼에 따라 인가를 해주는 방식이 상이합니다. 이럴 경우에 동일한 로그인 전략에 각각 다른 로그인 방식을 구현해주면 해결이 됩니다.
그럼 마지막으로 비슷한 템플릿 메서드 패턴과 비교를 마지막으로 마치겠습니다.
전략 패턴과 템플릿 메서드 패턴의 차이
전략 패턴과 템플릿 메서드 패턴 둘 다 적절한 알고리즘을 선택하여 때에 따라 다르게 동작하도록 한다는 컨셉을 가지고 있고, 해당 패턴 둘 다 OCP를 지켜서 쉽게 확장을 할 수 있다는 장점이 있습니다.
하지만, 전략 패턴은 대체로 합성을 사용한다면 템플릿 메서드 패턴은 상속을 통한 해결책을 제시합니다. 이에 따라 전체적으로 전략 패턴보다는 템플릿 메서드 패턴의 단점이 많이 부각되는데 단점은 다음과 같습니다.
- 전략 패턴은 느슨한 결합인 반면, 템플릿 메서드 패턴은 상대적으로 강한 결합입니다.
- 전략 패턴에서는 전체적인 알고리즘 교체가 가능하지만, 템플릿 메서드 패턴은 일부만 변경이 가능합니다.
- 전략 패턴은 인터페이스를 사용해서 다양한 전략을 implements 할 수 있지만, 템플릿 메서드 패턴은 하나 밖에 안됩니다.
위와 같은 이유로 인해 실제 실무 사례에서는 전략 패턴을 주로 사용한다고 합니다. 하지만 추가적으로 요즘에는 함수형 프로그래밍의 등장으로 인해서 상황에 따라 적절한 함수를 넘겨주는 방식을 택하는 경우도 많다고 합니다.
전략 패턴에서 주의 할 점이 있습니다. 그리고 이는 다른 패턴들에서도 동일하게 보일 수 있다고 생각합니다. 전략 패턴을 구현하기 위해 합성을 자주 사용한다고 해서 합성(Composition)에 집중하면 안된다는 것입니다.
합성은 어떠한 행위 자체를 그저 객체가 의존하여 위임하는 방식을 택하는 것을 결정한 것이지. 전략 패턴이 꼭 합성이여야 한다는 것은 아니라는 것입니다. 전략 패턴의 주된 목적은 런타임 상황에서 알고리즘군을 적절하게 교체하는 것이 목적인 패턴입니다. 따라서 구현 방식보다 목적이 무엇인지에 대해서 사고하는 것이 중요합니다.
참고자료
https://inpa.tistory.com/entry/GOF-💠-전략Strategy-패턴-제대로-배워보자
https://refactoring.guru/ko/design-patterns/strategy
https://devoong2.tistory.com/entry/전략-패턴Strategy-Pattern-은-무엇일까
헤드 퍼스트 디자인 패턴 [https://product.kyobobook.co.kr/detail/S000001810483]
피드백
창현님 : 글이 너무 많은 것 같다. 가독성을 높이는게 좋을 것 같다. 필요한 부분만 적어라.
혁준님 : 코드가 이해하기가 어려웠다. 기능에 대한 간략화를 해도 좋을 것 같다.
민희님 : 코드 예제가 좀 더 간단하고 이해하기 쉬웠으면 좋겠다. 익숙한 예제를 사용하자.
근우님 : 설명을 할 때 왔다 갔다 해서 흐름이 끊기는 것 같다. 흐름대로 이어가는 것이 좋을 것 같다.
이슬님 : 글을 줄이고 가독성을 높여주시는게 좋을 것 같다!
회고
실제로 발표자료를 티스토리에 옮겨보면서 느낀 부분은 확실히 글 내용이 너무 많다. 이 방대한 양을 전부 발표에 담았는데, 내용만 이런식으로 가져가고 발표 내용은 글 줄에 있는 걸 요약해서 말씀을 드렸던 것 같다. 발표 할 때는 오히려 더욱 더 간추리고 말을 더 많이 해서 듣는이들의 눈이 피로하지 않고 보기 쉽게 하는 것이 좋을 것 같다! 포스팅과 발표자료는 다르다는 것을 적절히 활용해보자.
추가적으로 디자인 패턴 스터디도 진행을 했는데, 해당 스터디에서 옵저버 패턴과 데코레이터 패턴에 대해서 좀 더 깊게 이해 할 수 있는 부분이 되었다. 헤드 퍼스트 디자인 패턴 교재와 여러가지 예시를 참조했을 때 과연 데코레이터 패턴이 왜 쓰이는 건지, 단순히 합성을 통해서도 구현 할 수 있는 부분이 아닌가? 라는 생각이 들었지만 얘기를 나누던 도중 팀장인 병곤님께서 좋은 인사이트를 주셨다. 객체를 담는 과정에서 다른 목적으로 구현을 하고 최종 구현체가 나타나게 된다는 것이다. 이렇게 한다면 확실히 데코레이터 패턴을 써야 하는 이유가 명확하다.
오늘도 참 많은 것을 얻어가는 하루였다. 항상 좋은 인사이트를 주는 데브코스 팀원들에게 항상 감사하고, 내일은 처음으로 강남 강의장이 오픈되는데 민희님은 일정 상 참석을 하지 못하게 되어서 아쉽다ㅠㅠ 다른 팀원들은 전부다 참석한다! 뭔가 연예인 보는 느낌일 것 같다는 생각이 들기도 한다.
꾸준히 나아가자 개미처럼!
'프로그래머스 데브코스' 카테고리의 다른 글
프로그래머스 데브코스 8일차 - Null 제거와, Pattern 객체 캐싱 (1) | 2023.06.11 |
---|---|
프로그래머스 데브코스 7일차 - 롬복 트러블 슈팅, Enum 최적화 (1) | 2023.06.10 |
프로그래머스 데브코스 5일차 (0) | 2023.06.08 |
프로그래머스 데브코스 4일차 - 인터페이스 (0) | 2023.06.07 |
프로그래머스 데브코스 3일차 - OOP 이야기 (0) | 2023.06.06 |