[디자인 패턴] 1. the Strategy Pattern

StandingAsh·2024년 10월 14일
3

참고: Head First Design Patterns

가정


여러 종류의 오리를 시뮬레이션하는 프로그램이 존재한다고 가정해보자. 오리들은 모두 꽥꽥 울며, 수영을 한다.

  • 따라서, 아래와 같이 수퍼클래스 Duck을 만들고, 모든 종류의 오리들은 Duck을 상속하도록 구현하였다.
class Duck {
	quack()
    swim()
    display()
}
class MallardDuck extends Duck {
	// ...
}

class RedheadDuck extends Duck {
	// ...
}

이제, 오리들에게 "날기" 동작을 추가해야 한다고 생각해보자. 이를 어떻게 구현할 수 있을까?

  • 수퍼클래스 Duck에 fly() 메소드를 추가하면, 이를 상속한 모든 오리들은 자연스럽게 날 수 있게 되겠다!
class Duck {
	quack()
    swim()
    fly() // 메소드 추가
    display()
}

문제 발생


수퍼클래스 Duck을 수정한 덕분에 모든 종류의 오리들에게 "날기" 기능이 추가되었다.
그러나, 아뿔사! 날지 못하는 오리들도 있다는 것을 미처 몰랐던 개발자가 수퍼클래스에 fly() 메소드를 추가해버리는 바람에 러버덕이 날아다니는 참사가 발생하고 말았다.

  • 이를 어떻게 해결해야 할까?

해결책?


1. Overriding?

러버덕의 코드를 잘 살펴보니, 이미 러버덕은 quack() 메소드를 오버라이딩해서 꽥꽥 울지 않고 삑삑 소리를 내도록 하고 있다. 그렇다면, fly() 메소드 역시 오버라이딩해서 날지 못하도록 해보자.

class RubberDuck extends Duck {
	@Override
    fly() { 아무것도 안하기 }
    quack() { 꽥꽥 우는 대신 삑삑 소리 내기 }
}

자, 이제 러버덕은 날지 못 할 것이다. 그런데 과연 이것으로 문제가 해결된걸까? 만약 프로그램의 다음 요구사항으로 나무 미끼오리(Wooden Decoy Duck)를 추가해야 한다면? 이 오리는 나무이기 때문에 날지 못 할 뿐더러 소리도 내지 말아야한다.

  • 새로운 요구사항마다 메소드를 오버라이딩하여 구현하는 것이 과연 최선의 해결책일까?

이 방법의 문제가 무엇일까?

객체지향 프로그래밍이 제공하는 강력한 기능인 '상속'의 장점 중 하나가 바로 '코드의 재사용성'이다. 위의 러버덕처럼 날지 못해야 하는 오리가 추가될 때 마다 메소드를 오버라이딩 하여 똑같은 로직의 코드를 재작성하는 것이야말로 코드의 재사용성을 정면으로 해치고 있다.

그러나, 보다 근본적인 문제는 코드의 재사용을 위해 사용한 상속이 유지보수 측면에서 역효과를 낳아버렸다는 점이다. 수퍼클래스 Duck의 코드 변화가 건들지 말아야 할 서브클래스에게까지 영향을 끼쳐버렸다.

  • 즉, 구조 자체에 개선이 필요하다.

2. Interface?

fly() 메소드와 quack() 메소드가 수퍼클래스에 있는 것이 문제구나! 그렇다면, 이 둘을 인터페이스로 분리해보자.

interface Flyable {	fly() }
interface Quackable { quack() }

class MalladDuck implements Flyable, Quackable extends Duck {
	fly() { 날기 }
    quack() { 꽥꽥 울기 }
    swim, display, ...
}

class RubberDuck implements Quackable extends Duck {
    quack() { 삑삑 소리 내기 }
    swim, display, ...
}

class DecoyDuck extends Duck {
	swim, display, ...
}

자, 이제 날지 못하거나 울지 못하는 오리들마다 오버라이딩하지 않아도 된다. 그렇다, 이제 매번 메소드를 오버라이딩 하는 수고 덜었다. 이제 매번 메소드를 '구현'해줘야 한다. 코드의 재사용성을 오히려 더 악화시켜버리고 만 것이다.

  • 이러한 상황을 위한 디자인 원칙이 있다.

Encapsulation (캡슐화) :
'변화를 줘야 하는 요소들'을 찾아 분리하라.

새로운 요구사항이 생길 때마다 달라져야 하는 부분이 있다면, 그 부분은 나머지 코드로부터 분리해줘야 한다는 원칙이다. 이를 '캡슐화'라고 한다.

캡슐화


우리는 인터페이스를 사용하여 fly()quack()Duck으로부터 분리했다. 아니, 정확히는 분리하지 못했다. 메소드의 구현이 여전히 Duck의 서브클래스안에 있기 때문이다.

  • 그렇다면, fly(), quack()등의 '행동'들을 구현할 Behavior 객체를 만들자!
interface FlyBehavior { fly() }
interface QuackBehavior { quack() }

class FlyWithWings implements FlyBehavior {
	fly() { 날개로 날기 }
}

class FlyNoWay implements FlyBehavior {
	fly() { 날지 않기 }
}

class Quack implements QuackBehavior {
	quack() { 꽥꽥 울기 }
}

class Squeak implements QuackBehavior {
	quack() { 삑삑 소리 내기 }
}

class MuteQuack implements QuackBehavior {
	quack() { 소리 내지 않기 }
}

자, 이제 각 행동들의 구현체도 Duck으로부터 분리하는데 성공했다. 그렇다면, 이 행동들을 어떻게 Duck이 사용할 것인가? 역시 상속인가?

class RubberDuck extends Duck, FlyNoWay, Squeak {
	...
}

우리가 캡슐화를 하기로 한 이유가 무엇인가? 요구사항에 따라 변하는 부분이 그렇지 않은 부분에게 종속되는 것을 막기 위함이지 않나? Behavior 구현체를 상속한다면 코드 재사용성에 있어서는 Duck이 직접 implements 하는 것 보다 낫겠지만, 여전히 특정 행동이 Duck 서브클래스들에게 종속된다. 그보다 애초에 Java는 부모 클래스의 다중상속을 지원하지도 않는다...

  • 여기서 또 다른 디자인 원칙이 등장한다.

구현체 대신 인터페이스에 대해 프로그래밍하라.

Java는 객체를 선언할 때, 타입 선언에 인터페이스를 사용할 수 있다.
다시 말해, 인터페이스에 대해 구현하라는 말은

Quack quack = new Quack();

보다는

QuackBehavior quack = new Quack();

쪽을 더 선호하라는 의미라고 할 수 있다.
더 나아가

QuackBehavior quackBehavior;
quackBehavior = getQuackBehavior();
quackBehavior.quack();

이런 식으로 QuackBehavior의 구현체를 동적으로, 다시 말해 런타임에 결정되도록 할 수 있다.
그렇다면, 위 코드가 갖는 의미가 무엇일까?

Duck은 더 이상 종속된 fly(), quack() 메소드를 사용하지 않는다. 다만, DuckFlyBehaviorQuackBehavior 타입의 객체를 인스턴스로 가지고, 각각 .fly(), .quack() 메소드를 호출 할 뿐이다. 이 것이 캡슐화핵심이다.

DuckQuackBehavior의 구현체Quack인지 Squeak인지 관심이 없다. Duck은 각 기능들의 구현 로직에 직접 접근하지 않는다. 따라서, 이제 행동에 변화를 요구받더라도 Behavior 객체의 구현체들만 수정하면 되며, Duck과 그 서브클래스들의 코드를 뜯어 고쳐야 하는 불상사는 발생하지 않을 것이다.

추가로, 각 Behavior의 구현체는 런타임에 결정되기 때문에 프로그램 실행 중에도 언제든지 Duck 서브클래스의 수정 없이 행동을 바꿀 수도 있다.

적용

class Duck {
	FlyBehavior flyBehavior;
    QuackBehavior quackBehavior;
    
    swim()
    performFly()
    performQuack()
    display()
}
void performFly() {
	flyBehavior.fly();
}

void performQuack() {
	quackBehavior.quack();
}

앞서 설명한대로 fly() 메소드와 quack() 메소드를 perform 메소드로 교체 후, 위와 같이 Behavior 인스턴스의 .fly(), .quack() 메소드를 호출하는 방식으로 변경하였다.

이제 Duck 서브클래스들의 Behavior들을 각각 어떻게 지정해줘야 할 지만 고민하면 된다. 아래와 같이 생성자를 통해 초기화해줄 수 있다.

class MallardDuck extends Duck {
	public MallardDuck() {
    	flyBehavior = new FlyWithWings();
        quackBehavior = new Quack();
    }
}
  • 잠깐, 분명 Behavior동적으로 결정한다고 하지 않았던가? 위 코드는 결국 구현체에 대해 프로그래밍 한 것이지 않나?

생성자를 통해 Behavior을 '고정'하는 방법은 분명 최선은 아니다. 하지만, 이 방식도 여전히 기존 방식보단 훨씬 변화에 대한 유연성이 뛰어나다. 아래 코드와 같이 런타임에 Behavior을 바꿔 줄 수 있기 때문이다. 더 좋은 해결 방안은 후에 더 많은 디자인 패턴을 통해 배울 수 있다.

void setFlyBehavior(FlyBehavior fb) {
	flyBehavior = fb;
}

void setQuackBehavior(QuackBehavior qb) {
	quackBehavior = qb;
}

정리


상속(Inheritance)보다는 컴포지션(Composition)을 선호하라

  • 컴포지션이란?

컴포지션은 직역하면 '구성'이라는 의미를 가지고있다.

프로그램의 객체들을 클래스 다이어그램으로 나타낼 때, 상속은 IS-A 관계로 나타낼 수 있다. RubberDuck 객체는 Duck 객체를 상속하기 때문에 "RubberDuck is a Duck" 이라고 표현할 수 있다.

반면, Duck 클래스는 FlyBehavior을 가지고 있기 때문에, "Duck has a FlyBehavior" 이라고 표현할 수 있다. 즉, 구성(Composition)에 Behavior 객체를 참조하고 있다는 의미이며, HAS-A 관계라고 한다.

때로는 HAS-AIS-A보다 낫다.

위에서 보인 예제처럼 상속대신 Behavior 객체를 구성요소로 사용한다면 캡슐화를 할 수 있을 뿐만 아니라 행동을 런타임에 바꿀 수 있게 해준다.
'컴포지션'은 다른 여러 디자인 패턴에서도 사용되기에, 공부하면서 여러 장단점을 느끼게 될 것이다.

  • 정리하자면, Strategy Pattern은 분리시켜야 할 로직을 각각 캡슐화한다. 따라서, 이 로직을 사용하는 객체와 독립적으로 변화를 줄 수 있다.
profile
우당탕탕 백엔드 생존기

0개의 댓글