-
CH01. 디자인 패턴 소개와 전략 패턴DevBook/헤드퍼스트 디자인패턴 2024. 3. 31. 20:37
여기서 학습할 내용
- 디자인 패턴의 활용 분야와 디자인 패턴으로 얻을 수 있는 장점
- 몇 가지 핵심적인 객체지향 디자인 원칙을 살펴본 후, 한 가지 패턴을 정해 디자인 원칙이 어떤 식으로 작동하는지 알아보기
패턴을 잘 사용하려면 패턴을 머릿속에 집어넣은 다음 어플리케이션에 어떻게 적용할지 파악해야 한다.
디자인 패턴은 코드가 아닌 경험을 재사용하는 것이기 때문이다.
## 예시로 문제 상황 살펴보기
매숑이는 오리 시뮬레이션 게임을 만드는 회사에 다니고 있다.
이 게임에는 헤엄도 치고 꽥꽥 소리도 내는 매우 다양한 오리가 등장한다.
이 시스템을 처음 디자인한 사람은 표준 객체지향 기법을 사용해 Duck 이라는 슈퍼클래스를 만든 다음, 그 클래스를 확장해 서로 다른 종류의 오리를 만들었다.
시간이 흘러 오리가 나는 기능을 추가해달라는 요청이 들어왔다.
위의 구조에서 쉽게 떠올리는 방법은 Duck 클래스에 fly()를 추가하는 것이다.
하지만 여기에 조건이 하나 추가되었다.
Duck의 몇몇 서브클래스만 날아야 한다는 것이다.
위와 같이 Duck이라는 슈퍼클래스에 fly() 메서드를 추가하면 일부 서브클래스에 적합하지 않은 행동이 추가되게 된다.
어떻게 하면 좋을까..?
상속을 생각해보자!
위와 같이 필요하지 않은 기능은 하위 클래스에서 오버라이드한다.
하지만 상속을 계속 활용한다면 새로운 오리가 추가될 때마다 필요하지 않은 기능들은 모두 오버라이드 해줘야 한다.
그럼 인터페이스를 생각해보자!
위와 같이 fly()와 quack()을 Duck 슈퍼클래스에서 빼고 각각 인터페이스로 만든다.
날 수 있는 오리와 소리낼 수 있는 오리에서만 각 인터페이스를 구현한다.
하지만 해당 기능이 필요한 모든 하위 클래스에서 인터페이스를 구현해야 하므로 코드의 중복이 발생한다.
### 문제 상황 요약
- 상황 : 주기적으로 기능이 추가된다. 경우에 따라 해당 기능이 필요할 수도 있고 필요하지 않을 수도 있다.
- 방법
- 해당 상황에 적용할 수 있는 간단한 방법을 생각해보면, 상속과 인터페이스이다.
- 1) 상속 : 추가되는 기능에 대해 상속 구조를 사용해 상위 클래스에 메서드를 추가한다.
- 상위 클래스에 선언한 메서드가 하위 클래스에 모두 영향을 끼침
- 해당 기능이 불필요한 하위 클래스에서는 상위 클래스의 메서드를 오버라이딩 해줘야 함
- 2) 인터페이스 : 추가되는 기능에 대해 인터페이스를 설계한다.
- 해당 기능이 필요한 모든 서브클래스에서 인터페이스를 구현해야 하므로 코드의 중복 발생함
- 인터페이스 형태가 변경될 때마다 그 행동이 구현되어 있는 서로 다른 서브클래스를 전부 찾아 코드를 수정해야 하고, 그 과정에서 새로운 버그가 발생 할 수 있음
이제 3가지 디자인 원칙을 통해 개선해보자!
## 문제를 명확하게 파악하기
첫 번째 원칙 : 어플리케이션에서 달라지는 부분을 찾아내고, 달라지지 않는 부분과 분리한다.
- 코드에 새로운 요구 사항이 있을 때마다 바뀌는 부분이 있다면 분리해야 한다.
- 즉, 바뀌는 부분은 따로 뽑아서 캡슐화한다.
- 나중에 바뀌지 않는 부분에는 영향을 미치지 않고 그 부분만 고치거나 확장할 수 있다.
- 따라서 코드를 변경하는 과정에서 의도치 않게 발생하는 일을 줄이며 시스템의 유연성을 향상시킬 수 있다.
- 해당 개념은 매우 간단하지만 다른 모든 디자인 패턴의 기반을 이루는 원칙이다.
### 바뀌는 부분과 그렇지 않은 부분 분리하기 --> 변화하는 부분을 뽑아내자!
- 나는 행동과 꽥꽥거리는 행동은 Duck 클래스에 있는 오리 종류에 따라 달라지는 부분이므로, 별도의 클래스 집합으로 분리함
- 각 클래스 집합에 각각의 행동을 구현한 것을 전부 집어넣음
- 그러면 Duck 클래스에서는 그 행동을 구체적으로 구현할 필요가 없어 실제 행동 구현이 Duck 서브클래스에 국한되지 않음
그러면 나는 행동과 꽥꽥거리는 행동을 구현하는 클래스 집합은 어떻게 디자인해야 할까?
## 오리의 행동을 디자인하는 방법
두 번째 원칙 : 구현보다는 인터페이스에 맞춰서 프로그래밍한다.
- 각 행동은 인터페이스(ex. FlyBehavior, QuackBehavior)로 표현하고 이런 인터페이스를 사용해 행동을 구현함
- 행동(behavior) 인터페이스는 Duck 클래스가 아니라 행동 클래스(ex. FlyWithWings, FlyNoWay)에서 별도로 구현함
'인터페이스에 맞춰 프로그래밍한다' 의미?
- 상위 형식에 맞춰서 프로그래밍한다는 것을 의미하고 이는 변수를 선언할 때 추상 클래스나 인터페이스 같은 상위 형식으로 선언해야 한다는 것
- 실제 실행 시에 쓰이는 객체가 코드에 고정되지 않도록 상위 형식에 맞춰 프로그래밍하여 다형성을 활용해야 한다는 것
FlyBehavior flyBehavior = new FlyWithWings(); flyBehavior.fly();
### 오리의 행동을 구현하는 방법 --> 인터페이스에 맞춰 프로그래밍하기!
- FlyBehavior과 QuackBehavior라는 2개의 인터페이스를 사용하고, 구체적인 행동을 구현하는 클래스들이 있음
- 이런 식으로 디자인하면 다른 형식의 객체에서도 나는 행동과 꽥꽥거리는 행동을 재사용할 수 있음 (행동이 Duck 클래스 안에 숨겨져 있지 않으니까)
- Duck 클래스를 수정하지 않고 새로운 행동 추가가 가능함
Duck 클래스와 별도로 분리한 오리의 행동(나는 행동, 꽥꽥거리는 행동)을 어떻게 합칠까?
## 오리 행동 통합하기
- 가장 중요한 점은 나는 행동과 꽥꽥거리는 행동을 Duck 클래스(또는 서브클래스)에서 정의한 메서드를 써서 구현하지 않고 다른 클래스에 위임한다는 것
1) 행동 변수 선언 및 행동 메서드 추가
- Duck 클래스에 flyBehavior와 quackBehavior라는 인터페이스 형식의 인스턴스 변수를 추가함 (특정 구상 클래스 형식으로 선언X)
- Duck 클래스에 fly()와 quack() 대신 performFly()와 performQuack()이라는 메서드를 넣음
2) 행동 위임
public abstract class Duck { QuackBehavior quackBehavior; // 기타 코드 public void performQuack() { quackBehavior.quack(); // 행동 위임 } }
- 꽥꽥거리는 행동을 직접 처리하는 대신, quackBehavior로 참조되는 객체에 행동을 위임함
3) 인스턴스 변수 설정
public class MallardDuck extends Duck { public MallardDuck() { quackBehavior = new Quack(); // 꽥꽥거리는 행동을 Quack 객체에 위임 flyBehavior = new FlyWithWings(); // 나는 행동을 FlyWithWings 객체에 위임 } public void display() { System.out.println("안녕 난 물오리"); } }
- MallardDuck의 생성자에서 Duck으로부터 상속받은 quackBehavior 인스턴스 변수에 Quack(QuackBehavior 구현체) 형식의 새로운 인스턴스를 대입함 (flyBehavior도 동일)
- performQuack()이 호출되면 꽥꽥거리는 행동은 Quack 객체에 위임되고, performFly()가 호출되면 나는 행동은 FlyWithWings 객체에 위임됨
### 동적으로 행동 지정하기
- 오리의 행동 형식을 생성자에서 인스턴스를 만드는 방법이 아닌 Duck의 서브클래스에서 세터 메서드(setter method)를 호출하는 방법으로 설정한다.
- 실행 중에 오리의 행동을 바꾸고 싶으면 원하는 행동에 해당하는 Duck의 setter method를 호출한다.
setter 메서드 추가
public void setFlyBehavior(FlyBehavior fb) { flyBehavior = fb; } public void setQuackBehavior(QuackBehavior qb) { quackBehavior = qb; }
- Duck 서브클래스에서 위의 setter 메서드를 호출하여 오리의 행동을 변경할 수 있게 됨
이제 정리해보자!
## 캡슐화된 행동 살펴보기
전체 구조는 다음과 같다.
- 오리들은 모두 Duck을 확장해서 만들고, 나는 행동은 FlyBehavior를, 꽥꽥거리는 행동은 QuackBehavior를 구현해서 만듦
- '오리의 행동'들을 일련의 행동 대신 '알고리즘군'으로 생각하면, 위의 형태를 바탕으로 상황에 따라 다양한 알고리즘군을 구성하여 활용할 수 있음
## 두 클래스를 합치는 방법
위에서 분리한 클라이언트 클래스와 행동 클래스는 아래 원칙에 따라 합쳐졌다.
세 번째 원칙 : 상속보다는 구성을 활용한다.
- 각 오리에는 FlyBehavior와 QuackBehavior가 있고, 각각 나는 행동과 꽥꽥거리는 행동을 위임받음
- 이런 식으로 두 클래스를 합치는 것을 '구성(composition)을 이용한다'라고 부름
- 위의 오리 클래스에서는 행동을 상속받는 대신, 별도의 객체로 구성된 행동을 부여받음
- 구성을 활용해서 시스템을 만들면 유연성을 크게 향상시킬 수 있음
- 알고리즘군을 별도의 클래스 집합으로 캡슐화 할 수 있음
- 구성 요소로 사용하는 객체에서 올바른 행동 인터페이스를 구현하기만 하면 실행 시에 행동을 바꿀 수도 있음
지금까지 해온 게 바로 전략 패턴을 활용한 것이었다..!
## 전략 패턴
문장으로 정리하자면, 전략 패턴은
- 알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 쓸 수 있게 해준다.
- 전략 패턴을 사용하면 클라이언트로부터 알고리즘을 분리해서 독립적으로 변경할 수 있다.