누군가가 이미 여러분의 문제를 해결해 놓았습니다
1장에서는 다른 개발자가 여러분과 똑같은 문제를 경험하고 해결하면서 익혔던 지혜와 교훈을 살펴보고, 그것을 어떻게 활용할 수 있는지를 배웁니다.
우선 디자인 패턴의 활용 분야와 디자인 패턴으로 얻을 수 있는 장점을 알아봅니다. 그리고 몇 가지 핵심적인 객체지향 디자인 원칙을 살펴본 후, 한 가지 패턴을 정해 디자인 원칙이 어떤 식으로 작동하는지 알아보겠습니다.
패턴을 잘 사용하려면 패턴을 머릿속에 집어넣은 다음 애플리케이션에 어떻게 적용할지를 파악해야 합니다. 디자인 패턴은 코드가 아닌 경험을 재사용하는 것이니까요.
이 게임에는 헤엄도 치고 괙꽥 소리도 내는 매우 다양한 오리가 등장한다.
이 시스템을 처음 디자인한 사람은 표준 객체지향 기법을 사용하여 Duck
이라는 슈퍼클래스를 만든 다음, 그 클래스를 확장해서 서로 다른 종류의 오리를 만들었다.
Duck
이라는 슈퍼클래스는 quack()
, swim()
, display()
를 포함한 다양한 오리 관련 메소드를 포함하고 있다.
다른 오리 시뮬레이션 게임과는 차별화를 두고 싶어요!
오리를 날 수 있게 해주세요!
그런데 갑자기 위와 같은 요구사항이 추가되었다. (두둥-)
그렇지만 객체지향 개발자라면 쫄 필요(?)없다. Duck
클래스에 fly()
메소드 하나만 추가해주면 다른 모든 오리가 이 메소드를 상속받을테니까!
그런데 문제가 하나 생겼어요. 상속받는 오리중에
RubberDuck
도 있는데, 갑자기 고무 오리들이 날아다녀요!
그래서 결국 RubberDuck
클래스에서는 fly()
메소드를 오버라이드하여 아무것도 하지 않도록 써주기로 했다.
근데 만약 이 게임의 제품 규격이 계속 바뀐다면..?
매번 Duck
의 서브클래스를 일일이 살펴보고 상황에 따라 다르게 오버라이드해줘야 한다.
코드를 재사용한다는 점에서 상속을 기가 막히게 활용했다고 생각했는데.. 유지보수를 생각하면 별로 안 좋아보이네..?
fly()
를 Duck
슈퍼클래스에서 빼고 fly()
메소드가 들어있는 Flyable
인터페이스를 만들 수도 있다.
이렇게 하면 날 수 있는 오리에게만 그 인터페이스를 구현해서 fly()
메소드를 넣을 수 있다. 마찬가지로 모든 오리가 꽥꽥거리는 건 아니니까 Quackable
이라는 인터페이스도 만들 수 있을 것이다.
그러나 메소드 몇개를 오버라이드하기 싫어서 이런 로직을 생각했다면 그야말로 바보같은 아이디어다!
날아가는 동작을 조금 바꾸기 위해 Duck
의 서브클래스에서 날아다닐 수 있는 모든 코드를 전부 고쳐야 한다.
모든 서브클래스에 날거나 꽥꽥거리는 기능이 있어야 하는 것은 아니므로 상속이 올바른 방법은 아니다.
서브클래스에서 Flyable
, Quackable
과 같은 인터페이스를 구현해서 고무 오리가 날아다니는 것과 같은 일부 문제점은 해결할 수 있지만, 코드를 재사용하지 않으므로 코드 관리에 큰 문제가 생긴다.
게다가 날 수 있는 오리 중에서도 날아다니는 방식이 다를 수도 있다.
결국 우리는 소프트웨어를 고칠 때 기존 코드에 미치는 영향을 최소한으로 줄이면서 작업할 수 있는 방법을 찾아야 한다!
소프트웨어 개발에서 절대로 바뀌지 않는 진리는 변화
다.
아무리 디자인을 잘한 애플리케이션이라도 시간이 지남에 따라 변화하고 성장해야 한다. 그렇지 않으면 그 애플리케이션은 죽는다.
상속
의 경우 서브클래스마다 오리의 행동이 바뀔 수 있는데도 모든 서브클래스에서 한 가지 행동만 사용하도록 하므로 그리 성공적인 해결책은 아니다.
인터페이스
의 경우는 자바 인터페이스에 구현된 코드가 없으므로 코드를 재사용할 수 없다는 문제점이 있었다. 한 가지 행동을 바꿀 때마다 그 행동이 정의되어 있는 서로 다른 서브 클래스를 전부 찾아서 코드를 일일이 고쳐야 하는 것이다.
이러한 문제 상황에 딱 어울리는 디자인 원칙이 있다.
애플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분과 분리한다.
다시 말해 코드에 새로운 요구 사항이 있을 때마다 바뀌는 부분이 있다면 분리해야 한다. 이는 아래와 같이 말할 수도 있다.
바뀌는 부분은 따로 뽑아서 캡슐화한다. 그러면 나중에 바뀌지 않는 부분에는 영향을 미치지 않고 그 부분만 고치거나 확장할 수 있다.
이 개념은 매우 간단하지만 다른 모든 디자인 패턴의 기반을 이루는 원칙이다. 모든 패턴은 시스템의 일부분을 다른 부분과 독립적으로 변화시킬 수 있는
방법을 제공하기 때문이다.
이제부터는 디자인 원칙을 적용하여 오리 시뮬레이션 게임에서의 문제를 해결해보자.
fly()
와 quack()
문제를 제외하면 Duck
클래스는 잘 작동하고 있으며, 나머지 부분은 자주 달라지거나 바뀌지 않는다. 따라서 Duck
클래스는 그대로 두는 것이 좋다.
변화하는 부분
과 그대로 있는 부분
을 분리하려면 2개의 클래스 집합을 만들어야 한다. 하나는 나는 것과 관련된 부분이고, 다른 하나는 꽥꽥거리는 것과 관련된 부분이다.
각 클래스 집합에는 각각의 행동을 구현한 것을 전부 집어넣는다. 예를 들어 꽥꽥거리는 것과 관련된 부분에는 꽥꽥 소리, 빽빽 소리, 삑삑 소리, 아무 소리도 내지 않는 등 다양한 행동을 구현하는 클래스를 만들어 넣을 수 있다.
그렇다면 나는 행동과 꽥괙거리는 행동을 구현하는 클래스 집합은 어떻게 디자인해야 할까?
Duck
인스턴스에 행동을 할당할 수 있어야 한다.이 두 가지 목표를 정해두고 두 번재 디자인 원칙을 살펴보자.
구현보다는 인터페이스에 맞춰서 프로그래밍한다.
Duck
클래스에서 구체적으로 구현하거나 서브클래스 자체에서 별도로 구현-> 항상 특정 구현에 의존했기 때문에 코드를 추가하지 않는 이상 행동을 변경할 여지가 없다.
FlyBehavior
, QuackBehavior
) 로 표현하고 이러한 인터페이스를 사용해서 행동을 구현Duck
클래스에서 구현하지 않고, 특정 행동을 목적으로하는 클래스의 집합으로 만든다. 행동 인터페이스는 Duck
클래스가 아니라 행동 클래스에서 구현한다.)-> 새로운 디자인을 사용하면 Duck
서브클래스는 인터페이스(FlyBehavior
, QuackBehavior
) 로 표현되는 행동을 사용한다.
따라서 새 방법을 사용하면 실제 행동 구현(인터페이스를 구현하는 클래스에 코딩되어 있는 구체적인 특정 행동)은 Duck
서브클래스에 국한되지 않는다.
앞서 말한 "인터페이스에 맞춰서 프로그래밍한다"라는 말은 "상위 형식에 맞춰서 프로그래밍한다"는 의미이기도 하다.
여기서 인터페이스는 중의적인 의미로 쓰였다.
따라서 인터페이스에 맞춰서 프로그래밍하라고 했다고 반드시 자바의 인터페이스를 사용하라는 뜻은 아니다.
위 두 문장은 다시 이렇게 설명될 수 있다.
간단한 코드로 예시를 들어보자.
Animal
이라는 추상 클래스가 있고, 그 밑에 Dog
과 Cat
이라는 구상 클래스가 있다.
구현에 맞춰서 프로그래밍하면 아래와 같다.
Dog d = new Dog();
d.bark();
변수 d
를 Dog
형식(Animal
을 확장한 구상 클래스)으로 선언하면 구체적인 구현에 맞춰서 코딩해야 한다.
하지만 인터페이스와 상위 형식에 맞춰서 프로그래밍하면 아래와 같이 할 수 있다.
Animal animal = new Dog();
animal.makeSound();
Dog
라는 걸 알고 있긴 하지만 다형성을 활용해서 Animal
의 레퍼렌스를 써도 된다.
더 바람직한 방법은 상위 형식의 인스턴스를 만드는 과정을 (new Dog()
같은 식으로) 직접 코드로 만드는 대신 구체적으로 구현된 객체를 실행 시에 대입하는 것이다.
a = getAnimal();
a.makeSound();
Animal
의 하위 형식 가운데 어떤 형식인지는 모른다. 단지 makeSound()
에 올바른 반응만 할 수 있으면 된다.
FlyBehavior
와 QuackBehavior
라는 2개의 인터페이스를 사용하여 최종적으로 구현한 모습을 표현하면 아래와 같다.
이런 식으로 디자인하면 다른 형식의 객체에서도 나는 행동과 꽥꽥거리는 행동을 재사용할 수 있다. 이러한 행동들이 더이상 Duck
클래스 안에 숨겨져 있지 않기 때문이다.
또한 기존의 행동 클래스를 수정하거나 날아다니는 행동을 사용하는 Duck
클래스를 전혀 건드리지 않고도 새로운 행동을 추가할 수 있다.
따라서 상속을 쓸 때 떠안게 되는 부담을 전부 버리고도 재사용의 장점을 그대로 누릴 수 있다.
오리들은 모두 Duck
을 확장해서 만들고, 나는 행동은 FlyBehavior
를, 꽥꽥거리는 행동은 QuackBehavior
를 구현해서 만든다.
여기서 나는 행동과 꽥꽥거리는 행동은 모두 캡슐화된 알고리즘으로 구현되어 있다. 각 오리에는 FlyBehavior
와 QuackBehavior
가 있으며, 각각 나는 행동과 꽥꽥거리는 행동을 위임
받는다.
이런 식으로 두 클래스를 합치는 것을 구성
을 이용한다고 한다. Duck
클래스에서는 행동을 상속받는 대신, 올바른 행동 객체로 구성
되어 행동을 부여받는다.
여기서 세 번째 디자인 원칙이 등장한다.
상속보다는 구성을 활용한다.
구성을 활용해서 시스템을 만들면 아래와 같이 유연성을 크게 향상시킬 수 있다.
앞서 살펴본 디자인 원칙의 맥락이 바로 우리가 첫 번째 배울 디자인 패턴인 전략 패턴 이다.
전략 패턴(Strategy Pattern)
은 알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 쓸 수 있게 해준다.
전략 패턴을 사용하면 클라이언트로부터 알고리즘을 분리해서 독립적으로 변경할 수 있다.
본 포스팅에 쓰인 이미지와 내용의 모든 출처는 책 '헤드 퍼스트 디자인 패턴' 에 있습니다.