참고: Head First Design Patterns
여러 종류의 오리를 시뮬레이션하는 프로그램이 존재한다고 가정해보자. 오리들은 모두 꽥꽥 울며, 수영을 한다.
Duck
을 만들고, 모든 종류의 오리들은 Duck
을 상속하도록 구현하였다.class Duck {
quack()
swim()
display()
}
class MallardDuck extends Duck {
// ...
}
class RedheadDuck extends Duck {
// ...
}
이제, 오리들에게 "날기" 동작을 추가해야 한다고 생각해보자. 이를 어떻게 구현할 수 있을까?
fly()
메소드를 추가하면, 이를 상속한 모든 오리들은 자연스럽게 날 수 있게 되겠다!class Duck {
quack()
swim()
fly() // 메소드 추가
display()
}
수퍼클래스 Duck
을 수정한 덕분에 모든 종류의 오리들에게 "날기" 기능이 추가되었다.
그러나, 아뿔사! 날지 못하는 오리들도 있다는 것을 미처 몰랐던 개발자가 수퍼클래스에 fly()
메소드를 추가해버리는 바람에 러버덕이 날아다니는 참사가 발생하고 말았다.
러버덕의 코드를 잘 살펴보니, 이미 러버덕은 quack()
메소드를 오버라이딩해서 꽥꽥 울지 않고 삑삑 소리를 내도록 하고 있다. 그렇다면, fly()
메소드 역시 오버라이딩해서 날지 못하도록 해보자.
class RubberDuck extends Duck {
@Override
fly() { 아무것도 안하기 }
quack() { 꽥꽥 우는 대신 삑삑 소리 내기 }
}
자, 이제 러버덕은 날지 못 할 것이다. 그런데 과연 이것으로 문제가 해결된걸까? 만약 프로그램의 다음 요구사항으로 나무 미끼오리(Wooden Decoy Duck)를 추가해야 한다면? 이 오리는 나무이기 때문에 날지 못 할 뿐더러 소리도 내지 말아야한다.
객체지향 프로그래밍이 제공하는 강력한 기능인 '상속'의 장점 중 하나가 바로 '코드의 재사용성'이다. 위의 러버덕처럼 날지 못해야 하는 오리가 추가될 때 마다 메소드를 오버라이딩 하여 똑같은 로직의 코드를 재작성하는 것이야말로 코드의 재사용성을 정면으로 해치고 있다.
그러나, 보다 근본적인 문제는 코드의 재사용을 위해 사용한 상속이 유지보수 측면에서 역효과를 낳아버렸다는 점이다. 수퍼클래스 Duck
의 코드 변화가 건들지 말아야 할 서브클래스에게까지 영향을 끼쳐버렸다.
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()
메소드를 사용하지 않는다. 다만, Duck
이 FlyBehavior
와 QuackBehavior
타입의 객체를 인스턴스로 가지고, 각각 .fly()
, .quack()
메소드를 호출 할 뿐이다. 이 것이 캡슐화의 핵심이다.
Duck
은 QuackBehavior
의 구현체가 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-A가 IS-A보다 낫다.
위에서 보인 예제처럼 상속대신 Behavior 객체를 구성요소로 사용한다면 캡슐화를 할 수 있을 뿐만 아니라 행동을 런타임에 바꿀 수 있게 해준다.
'컴포지션'은 다른 여러 디자인 패턴에서도 사용되기에, 공부하면서 여러 장단점을 느끼게 될 것이다.