2장 인터페이스로 프로그래밍하기 그리고 몇 개의 생성패턴
간략히 인상적인 문구들만 정리한다.
- 디자인 패턴은 크게 보면 구현 상속(extends)을 인터페이스 상속(implements)으로 바꾸는 방법을 설명하고 있다.
- 구현 상속보다는 인터페이스 상속이 훨씬 바람직하다.
- OO 원리를 충실히 지키면 결합도를 상당히 낮출 수 있다. 데이터와 구현을 은닉하고, 가능하면 인터페이스를 사용하여 프로그래밍하라
- extends 는 깨지기 쉬운 기반 클래스 문제를 야기한다. 이는 부모 클래스와 자식 클래스 간의 강한 결합 관계 때문이다.
- 파생 클래스가 상속을 원치 않는 기반 클래스의 메서드에서 예외를 던지는 방법은 나쁜 방법이다. 이는 LSP 를 어기는 것이며 결과적으로 OCP 까지 지킬 수 없게 된다.
- 상속 대신 합성을 사용하라
- 인터페이스 상속을 이용하면 상속된 기능이 없기 때문에 잘못될 일도 없다.
- 인터페이스 관점에서 프로그래밍하라. 의존 관계 역전의 원칙(DIP)을 준수하라
- 추상화를 잘하면 유연해진다. 하지만 복잡성도 증가한다. 유연성과 복잡성 사이에서 적절한 트레이드 오프를 하라
올바른 OO 디자인 과정
- ‘도메인 모델’ 에 대해 학습한다.
- 고객이 해결하려는 것이 무엇인지를, 즉 요구 사항을 유도한다
- 모든 문제에 대한 유스케이스를 규명한다. 이 때, 유스케이스란 사용자가 수행하여 유의미한 결과를 내놓는 단일 작업을 의미한다
- 유스케이스에 기술된 목적을 성취하기 위해 어떤 액티비티가 필요한지를 생각한다
- 전 단계에서 규명된 액티비티를 수행하기 위해 런타임에 객체들이 서로 어떻게 메시지를 보내는지를 보여주는 ‘동적 모델’을 생성한다.
동적 모델링을 하는 동안 객체들이 어떻게 상호 작용을 하는지를 보여주는 클래스 다이어그램을 생성한다.
동적 모델링으로부터 클래스 다이어그램을 도출하라. 이렇게 하면 실제 필요한 연산과 관계만이 포함되기 때문에 정적 모델이 현실적이 되고 가벼워진다.
동적 모델링을 충분히 수행한 후 특정 객체들이 모두 사용하고 있는 공통 연산 집합이 식별되었다면, 공통 연산 집합을 기반 클래스에 구현한 후 구현 상속(extends)을 통해 재사용할 수 있다.
만약 두 클래스가 동일한 연산을 다른 방식으로 수행한다면 각 클래스가 공통의 인터페이스를 구현해야 한다. 예를 들어 Employee 인터페이스를 Manager 와 Engineer 클래스가 다른 방식으로 구현할 수 있다. 그리고 두 클래스에 공통적인 연산이 없다면 별도의 클래스를 통해 구현하며 extends 나 implements 관계는 필요 없다. 마지막으로 한 클래스가 다른 클래스에 약간의 연산을 추가한다면 extends 관계가 적절하다. (Manager 는 Employee 가 하는 모든 일을 하고, 여기에 추가적인 일도 한다)
정리하면
- Employee 와 Manager 가 동일한 연산을 동일한 방식으로 수행한다.
- -> 같은 클래스로 구현한다.
- Employee 와 Manager 가 동일한 연산을 다른 방식으로 수행한다.
- -> 두 클래스가 공통의 인터페이스를 구현한다
- Employee 와 Manager 가 공통의 연산이 없다.
- -> 별도의 클래스로 구현한다.
- Manager 가 Employee 에 약간의 연산을 추가한다.
- -> Manager 가 Employee 를 extends 하도록 한다.
extends 제거하기
- 현재 클래스와 동일한 이름을 갖는 인터페이스를 생성한다.
- 현재 클래스의 이름을 변경하고 인터페이스를 구현하도록 한다.
- new 를 사용하여 Employee 를 생성했던 모든 부분을 바꾸어 준다.
허나 new 연산은 객체를 생성하는 클라이언트와 생성되는 객체를 결합시키기 때문에 애써 인터페이스를 도입한 효과가 없다. new 를 제거하거나 최소한 감추어야 한다.
모든 new 호출을 고쳐야만 하는 문제를 해결할 수 있는 좋은 전략 중 하나는 Abstract Factory 패턴을 사용하는 것이다. 많은 패턴들이 구현 시 Abstract Factory 패턴을 사용하는 경우가 많기 때문에 Abstract Factory 는 일종의 빌딩 블록이 되는 패턴이라 할 수 있다.
Abstract Factory 의 모든 실체화에서 공통되는 주제는 팩토리를 사용하여 정확한 타입을 모르는 객체를 생성한다는 것이다. 사용자(클라이언트)는 생성하려는 객체가 구현하고 있는 인터페이스만을 알 뿐, 생성 객체의 실제 타입은 알지 못한다. 예를 들어 EmployeeFactory 를 사용하여 new Employee() 를 EmployeeFactory.create() 로 대체할 수 있다. 이 때 create() 메서드는 Employee 인터페이스를 구현하는 어떤 클래스든 반환할 수 있기 때문에 클라이언트 코드는 구현 클래스와 격리된다. 그러므로 클라이언트 코드에 영향을 끼치지 않으면서 Employee 를 구현한 클래스를 마음대로 바꿀 수 있다.
또한 Singleton 을 사용하여 Abstract Factory 를 얻어 오고, Abstract Factory 를 사용하여 실제 클래스가 알려지지 않은 객체를 얻어 오는 것은 많이 사용되는 패턴이다.
‘하나의 객체’ 와 ‘전역 접근’ 이라는 Singleton 의 요구 사항을 만족시키는 가장 쉬운 방법은 모든 것을 static 으로 선언하는 것이다. 하지만 컴파일시에 모든 정보를 알 수 없거나 상속을 통한 커스터마이징이 필요한 경우에는 static 을 사용할 수 없다. 이런 경우에는 전형적인 GoF 식 Singleton 실체화가 필요하다.
Abstract Factory 패턴
Abstract Factory 패턴은 인터페이스(Employee)를 통해 생성하려는 객체의 실체 타입을 은닉시켜 준다. 생성된 객체를 사용하는 클라이언트는 인터페이스만을 알고 있으므로 인터페이스를 구현하고 있는 구체 클래스의 변화로부터 자유롭다. 구체 클래스는 인터페이스보다 변하기 쉽기 때문에 가능하면 인터페이스를 사용하라는 ‘의존 역전의 원칙’(DIP) 을 생각해보자.
Java Collection 을 예로 들면 - Collection 인터페이스는 ArrayList, LinkedList 등의 구현체를 은닉 혹은 격리시켜 주며, Iterator 인터페이스는 각 Collection 구현체가 생성한 Iterator 구현체를 은닉시켜 준다. Collection 인터페이스를 사용하는 클라이언트는 자신이 어떤 Collection 구현체를 사용하고 있는지 알 필요가 없으며, Collection 구현체를 통해 생성한 Iterator 구현체의 경우도 마찬가지다. Collection 인터페이스와 Iterator 인터페이스만 알면 된다. 이와 같은 구조는 인터페이스를 통한 프로그래밍을 가능케 해주며 뛰어난 유연성을 보장해준다.
Command 패턴과 Strategy 패턴
Command 패턴은 빌딩 블록 패턴이며, Strategy 패턴은 Command 패턴의 특별한 경우이다. Command 패턴의 기본 아이디어는 무엇을 어떻게 해야 한다는 지식을 객체에 캡슐화하여 전달한다는 것이다. 자바 스레드는 전형적인 Command 패턴의 구현체이다.
1 | class CommandObj implements Runnable { |
Command 패턴의 주요 특징 중 하나는 Command 객체를 사용하는 클라이언트 클래스가 Command 객체가 무엇을 할지에 대해 아무것도 모른다는 것이다. 또한 Command 패턴 자체를 좀 더 세련된 방식으로 사용하여 ‘되돌리기(undo)’ 시스템과 같은 복잡한 문제를 해결할 수도 있다.
Strategy 패턴은 특정 연산을 ‘어떻게’ 수행할 것인지에 대한 전략을 캡슐화한 Strategy 객체를 전달한다. 이 때 Strategy 객체는 무엇을 수행해야 하는가가 좀 더 명확한 Command 객체라 할 수 있다.
요약
- Singleton: 제한된 수의 객체를 생성한다.
- Abstract Factory: 관련된 객체 군(family) 중 하나를 생성하도록 해주는 ‘팩토리’. 이 때 생성되는 객체의 구체 타입은 인터페이스를 통해 가려진다. 구체 타입이 아닌 인터페이스를 통해 프로그래밍하기 때문이다.
- Template Method: 기반 클래스에서 일반 알고리즘을 정의하고, 파생 클래스에서 알고리즘이 사용하는 추상 메서드를 오버라이딩한다.
- Factory Method: 구체 클래스가 알려지지 않은 객체를 생성하는 Template Method 이다. Factory Method 는 객체를 생성하는 Factory Method 일 뿐이다.
- Command: 알려지지 않은 알고리즘을 캡슐화하는 객체이다.
- Strategy: 알려진 문제를 해결하는 전략을 캡슐화하는 객체이다. 어떤 알고리즘을 다양한 전략을 통해 해결할 수 있도록 해준다.
- 캡슐화
- 캡슐화는 데이터와 연산을 한데 묶는 것을 의미한다. 프로그램에서 흘러 다니는 데이터와 결합도는 보통 비례 관계에 있다. 캡슐화는 이와 같은 데이터 흐름으로 인한 결합도 증가를 막아주는 언어적 장치이다.
- 은닉이란 내부 데이터, 내부 연산을 외부에서 접근하지 못하도록 은닉(hiding) 혹은 격리(isolation)시키는 것을 의미한다. 객체는 서비스 제공자(Service Provider)이어야 한다. 그러므로 ‘어떻게’ 연산을 수행하는가는 철저히 은닉되어야 하며, 외부로 공개된 인터페이스 혹은 계약을 통해서만 프로그래밍해야 한다.
- 캡슐화와 은닉이 잘 되려면 객체에 책임을 적절히 분배해 주어야 한다. 단일 책임 원칙(SRP)은 좋은 기준이 된다.
- getter/setter 는 가능한 사용을 자제해야 한다. 이는 ‘데이터를 요청하지 말고 도움을 요청하라’ 라는 OO 금언과 관련 있다. 구현이 잘 은닉되어 있고 책임이 제대로 분배되어 있다면 getter/setter 는 그리 필요치 않다.
- 구체 클래스는 인터페이스보다 변하기 쉽다. 그러므로 인터페이스를 이용하라는 의존 관계 역전의 원칙(DIP)을 생각하자. 인터페이스를 통해 구체 클래스를 은닉하도록 하자.
- 하나의 클래스가 전체적으로는 하나의 역할만을 맡고 있지만 관점에 따라서는 2개 이상의 인터페이스를 구현하고 있을 수 있다. 이런 경우에는 하나의 클래스가 여러 타입이 된다.
- 캡슐화
- 상속에는 구현 상속(extends)과 인터페이스 상속(implements)이 있다.
- 구현 상속에는 재사용과 다형성 획득이라는 두 가지 기능이 있다.
- 인터페이스 상속에는 다형성 획득과 인터페이스를 통한 은닉이라는 기능이 있다.
- 구현 상속에는 ‘깨지기 쉬운 기반 클래스 문제’가 있기 때문에 다형성 획득이라는 측면에서 보자면 인터페이스 상속이 안전하고 낫다
- 재사용 측면에서 보자면 보통 상속보다는 합성이 좋다. 상속은 불필요한 결합도를 증가시키기 때문이다.
- 그러므로 다형성을 위해서는 인터페이스 상속을 사용하고, 재사용을 위해서는 합성을 사용하자. 구현 상속은 언제? 여러 클래스가 공통의 연산 집합을 공유하고 있을 때 정규화를 위해 사용하면 된다.
- 상속을 할 때는 상속한 메서드에서 예외를 던지면 안 된다. 예외를 던지면 다형성을 이용하기 어렵기 때문이다. LSP 를 지키자
- 다형성
- 캡슐화와 은닉은 ‘무엇을’ 과 ‘어떻게’ 를 분리시켜 준다. 상속은 ‘어떻게’를 다양하게 정의하도록 해준다. 다형성은 이 둘을 조합하여 런타임에 ‘무엇을’ ‘어떻게’ 실행시킬 것인지를 동적으로 정하게 된다. 이는 OCP 를 지킬 수 있도록 해준다.
- 예를 들어 로그 메시지를 다양한 방식 (콘솔, 하나의 파일, 날짜별 파일, DB, Mail, TCP/IP) 으로 출력할 수 있을 것이다. 이곳이 바로 다형성이 등장할 곳이다. 로깅 라이브러리는 ‘무엇’(what) 에 해당하는 로그 찍기 메서드만 제공하면 된다. 그리고 설정 파일을 통해 ‘어떻게’(how)에 해당하는 로그 출력 방식을 다양하게 변경할 수 있도록 한다.
- 정리
- 변하는 부분과 변하지 않는 부분을 분리하여 변화하는 부분에 다형성을 통해 확장과 수정의 가능성을 열어두는 것이 중요하다
- 변화가 예상되는 장소에 인터페이스를 삽입해 놓는 것은 복잡함을 크게 증가시키지 않는다. 인터페이스를 적극 도입하여 사용하라