Table of contents

헤드퍼스트 디자인패턴에 대한 내용을 정리하기에 앞서 각 패턴이 등장하게 된 원칙에 대해 정리한다.

원칙1

애플리케이션에서 달라지는 부분을 찾아 내고, 달라지지 않는 부분으로부터 분리시킨다.

첫번째 원칙은 스트래티지 패턴에 해당하는 장에서 등장했다. 캡슐화와 관련된 이 원칙은 요구사항이 추가되거나 요구사항이 변경됐을 때를 대비한다. 보통 이럴 때는 클래스의 메소드나 생성자를 고치고 추가하는 작업을 한다. 그러나 이렇게 공개된 API가 변경되면 이를 사용하는 클라이언트 코드도 변경해야한다. 이러한 상황에 대비하여 바뀌는 부분을 분리하라는 원칙이 등장했다.

코드가 추후에 변경될 여지가 있다면, 변경되는 부분을 기존 코드에서 분리시켜야한다. 추후 분리시킨 코드를 변경해도 변경되지 않은 코드에는 영향을 미치지 않게된다.

class A {
	public methodA() {
	// 바뀌지 않는 부분
	// 바뀌는부분 -> 이 부분을 다른 클래스로 캡슐화한다.(분리)
	// 바뀌지 않는 부분
	}
}

원칙2

상속보다는 구성을 활용한다.

두번째 원칙도 첫번째 원칙과 같이 스트래티지 패턴에서 등장했다. 처음 이 원칙을 알게 되었을 때는 다음과 같은 의문이 들었다.

  • 객체지향에서는 무엇보다 재사용성, 확장성을 중요하게 여기는거 아닌가?
  • 어떤 클래스를 상속받아서 확장하면 부모 클래스의 모든 것들을 재사용할 수 있으니까 상속이 좋은거 아니야?

책의 저자도 이러한 내용에 대해 언급했으며, 사부와 제자의 대화를 예시로 들어 이유를 설명한다. 대화 내용의 일부는 다음과 같다.

사부: 개발이 끝나기 전과 개발이 끝난 후 어느 쪽 코드에 시간을 더 많이 쓰게 되느냐?

이 포스트를 작성할 당시 나는 프로젝트 유지보수 경험이 없었고, 신입으로 입사하여 첫 개발 프로젝트를 진행하고 있었다. 경험부족의 탓인가, 나는 짧게는 3개월 ~ 길게는 1, 2년의 개발 기간이 결코 짧다고 생각하지 않았기 때문에, 이 질문에 대한 정답은 당연히 개발이 끝나기 전이라고 생각했다. 헌데 사부와 제자의 대화는 달랐다. 개발이 끝난 후 개발기간보다 개발이 완료된 소프트웨어를 유지보수 하는데 더 많은 시간과 고민이 필요하다는 것이다. 때문에 사부는 관리의 용이성과 확장성을 고려해야한다고 답헀다.

제자: 상속을 통해서 개발시간을 단축할 수 있지 않나요?

제자의 생각이 당시 나의 생각과 비슷했는데, 상속을 통해서 개발 시간을 단축할 수 있다는것이다. 유지보수에서는 관리의 용이성과 확장성이 중요하다. 하지만 이를 위해 상속을 사용할 경우 다음과 같은 단점이 있다.

  1. 서브클래스에서 코드가 중복되는 경우가 생길 수 있다.
  2. 실행시에 특징(행동, 인스턴스)을 바꾸기가 힘들다.
  3. 코드를 변경했을 때 다른 클래스에 의도치 않은 영향을 끼칠 수 있다.

가장 주의해야할 점은 코드의 일부를 변경했을 뿐인데 오류나 예상치 못한 결과를 가져올 수 있다는 점이다.

위의 도면을 보면서 어떻게 잘못된 결과가 나오는지 살펴보자. 꽥 소리를 내는 quack() 메소드를 갖는 Duck 클래스와 이를 상속받는 3개의 클래스가 등장한다. 개발이 완료된 후, Duck클래스에 날아다니는 기능을 추가하기 위해 fly()라는 메소드를 추가한다. 여기서 문제가 생긴다. RubberDuck, 즉 고무로된 오리 인형은 날 수 없는 특징을 갖는다. 하지만 Duck 클래스에 fly()라는 메소드를 추가했기 때문에 서브클래스인 RubberDuck도 날아다닐 수 있는 이상한 결과가 나오게된다.

RubberDuck에서 fly()메소드를 오버라이드해서 아무것도 하지 않거나 예외를 던지면 되지 않을까?

만약, 오리의 종류가 잦은 주기로 추가된다면 예외를 던지거나 하는 일은 귀찮고 복잡한 작업이 될 수 있다. 나무로 된 오리, 오리인척하는 거위, 오리알처럼 오리가 아닌것 같지만 오리로 취급해야될 종류가 추가될수록 오리의 규격은 계속 변경될 것이다. 그럴 때마다 개발자는 Duck의 메소드와 서브클래스들의 메소드를 일일히 살펴보고 상황에 따라 Duck을 상속할지 서브클래스의 서브클래스로 만들지를 결정하고 어떤 메소드를 오버라이드할지 정해야한다.

개발자들은 이런 무의미하면서 반복적인 행위를 없애기 위해 노력해야한다. 일부 형식의 오리만 날거나 꽥꽥거릴 수 있도록 미리 정의하는 더 깔끔한 방법이 없을까? 그 방법은 3번째 원칙과 스트래티지 패턴에서 등장한다.

원칙3

구현이 아닌 인터페이스 맞춰서 프로그래밍한다.

세번째 원칙도 스트래티지 패턴에서 등장했다. 이번에도 스타크래프트의 테란 종족에 대한 예시를 살펴보자.

  • 모든 유닛을 의미하는 Unit
  • 지상 유닛을 의미하는 GroundUnit
  • 공중 유닛을 의미하는 AirUnit

편의상 움직이는 부분만 기능만 정의했다. SCV, 탱크, 마린과 같은 지상유닛이 있으며 배틀크루저, 스카우터와 같은 공중유닛이 있다.

인터페이스와 추상클래스

위의 포스트에서도 사용한 예시인데, SCV라는 유닛은 다른 유닛을 수리할수 있는 특징이 있다. 이 기능은 기계유닛에게만 사용할 수 있다는 조건이 있다. 그렇다면 위 클래스 다이어그램에서 어떻게 기계 유닛을 구분할 수 있을까? 가장 단순하게는 오버로딩을 이용해 기계유닛의 수만큼 메서드를 만들면된다. 하지만 유닛의 수만큼 메소드를 만들어아하는 단점이 있다. 그렇다고 RepairableUnit이라는 클래스를 만들어 상속하기엔 이미 GroundUnit, AirUnit 중 하나를 상속하기 때문에 불가능하다. 이러한 문제는 marker interface를 사용하면 해결이 가능하다.

마커 인터페이스(marker interface): 아무것도 정의하지않고 타입을 이용할 목적으로 사용하는 인터페이스

자바에는 Comparable 과 같은 인터페이스가 있다. 이 인터페이스는 구현클래스에 정렬이 가능한 타입이라는 특징을 부여하기 위한 인터페이스다. 수리가 가능한 유닛이라는 특징을 부여하기 위해 Repairable라는 마커 인터페이스를 만들고 기계 유닛이 이를 구현하게 하면된다.

Repairable이라는 마커 인터페이스를 만들고 기계유닛인 탱크, 배틀크루저와 스카우터에서 해당 인터페이스를 구현한다. Repariable은 아무것도 정의되어 있지 않은 마커 인터페이스이며 단순히 implements 키워드를 이용하면 된다.. 이제 SCV클래스의 repair라는 메소드의 파라미터를 제한할 수 있다.

SCV scv = new SCV();
scv.repair(마린) // 예외!
scv.repair(배틀크루저) // 성공

이는 repair라는 메소드를 인터페이스에 맞춰서 프로그래밍한것이다. public repair(탱크); public repair(배틀크루저); public repair(스카우터) 처럼 같이 특정 구현에 맞추게 되면 repair 메소드를 변경하거나 유닛을 추가하게되면 각 메소드를 다 변경하거나 유닛의 수 만큼 추가해야한다. 반면 인터페이스에 초점을 맞춰 정의하면 하나의 메소드만 수정하거나 Repairable 인터페이스를 구현하는 유닛을 추가하면 된다.