Enjoy My Posts

오브젝트 정리

Posted on By Geunwon Lim

이 포스트에서는 조영호님의 Object를 정리하겠습니다. 책 내용 중 핵심이라고 생각하는 부분을 옮겨보았습니다.


[목차]

0_프로그래밍 패러다임

1_객체, 설계

2_객체지향 프로그래밍

3_역할, 책임, 협력

4_설계 품질과 트레이드오프

5_책임 할당하기

6_메시지와 인터페이스

7_객체 분해

8_의존성 관리하기

9_유연한 설계

10_상속과 코드 재사용

11_합성과 유연한 설계

12_다형성

13_서브클래싱과 서프타이핑

14_일관성 있는 협력

15_디자인 패턴과 프레임워크

부록 A_계약에 의한 설계

부록 B_타입 계층의 구현

부록 C_동적인 협력, 정적인 코드

0_프로그래밍 패러다임

프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 함으로써 불필요한 부분에 대한 의견 충돌을 방지한다. 또한 프로그래밍 패러다임을 교육시킴으로써 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있다.

객체지향 패러다임이라는 용어를 사용하는 것도 개발자들 간 완벽하게 동일하지는 않더라도 어느 정도 유사한 그림을 머릿속에 그릴 수 있는 기반이 될 수 있다. 또한 객체지향에 대한 오해를 제거함으로써 객체지향 프로그래밍을 하는 개발자들이 동일한 규칙과 표준에 따라 프로그램을 작성할 수 있게 한다.

패러다임과 그 패러다임을 채용하는 언어는 특정한 종류의 문제를 해결하는 데 필요한 일련의 개념들을 지원하기 때문에, 프로그래밍 언어와 프로그래밍 패러다임을 분리해서 설명하기 어렵다.

프로그래밍 패러다임에서는 각 패러다임이 완전히 다른 패러다임을 대체한다고 하기는 어려운데, 예를 들어 절차형 패러다임에서 객체지향 패러다임으로 전환됐다고 해서 두 패러다임이 함께 존재할 수 없는 것은 아니다. 오히려 프로그래밍 패러다임은 과거에 있던 패러다임의 단점을 보완하는 발전적인 과정을 거친다고 할 수 있다.

1_객체, 설계

모든 소프트웨어 모듈(크기와 상관 없이 클래스나 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소를 의미)에는 세 가지 목적이 있다. 첫 번째는 실행 중 제대로 동작하는 것이다. 두 번째는 변경을 위해 존재하는 것이다. 세 번째는 코드를 읽는 사람과 의사사통 하는 것이다.

1_1_문제

기존에 보여준 예제에서 문제 중 하나는 객체가 수동적이라는 것이다. 현실 세계와 객체를 매칭시켜봤을 때 상식적이지 않다. 이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다. 즉, 상식적으로 이해가 돼야 코드를 읽는 사람과 제대로 의사소통할 수 있다.

또한 기존 코드는 코드를 이해하기 위해서 여러 세부적인 내용들을 한꺼번에 기억하고 있어야 한다는 점에서도 문제(이해하기 어려움)다. 하나의 클래스나 메서드에서 너무 많은 세부사항을 다루기 때문에 코드를 작성하는 사람 뿐 아니라 코드를 읽고 이해해야 하는 사람 모두에게 부담을 준다.

가장 심각한 문제는 한 객체를 변경했을 때 다른 객체도 변경해야 한다는 것이다.

코드는 변경될 수 있고, 기존 코드는 변경해야할 때 모든 코드가 일시에 흔들리게 된다. 이것은 의존성과 관련된 문제다. 의존성이라는 말 속에는 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포돼 있다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것이기 때문에 의존성 자체가 나쁜 것은 아니다.

객체 사이의 의존성이 과한 경우를 가리켜 결합도가 높다고 말한다. 설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 설계를 만드는 것이다.

1_2_개선

변경과 의사소통이라는 문제는 서로 엮여 있다. 기존의 문제를 해결은 한 객체가 다른 객체를 너무 세세한 부분까지 알지 못하도록 차단하여, 객체들을 자율적인 존재로 만들면 된다.

캡슐화

개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화라고 부른다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것이다. 캡슐화를 하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 좀 더 쉽게 변경할 수 있다.

객체를 인터페이스와 구현으로 나누고 인터페이스만을 공개하는 것은 객체 사이의 결합도를 낮추고 변경하기 쉬운 코드를 작성하기 위해 따라야 하는 가장 기본적인 설계 원칙이다.

응집도

핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다. 밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도가 높다고 한다. 객체의 응집도를 높이기 위해서는 객체 스스로 자신의 데이터를 책임져야 한다. 즉 객체는 자신의 데이터를 스스로 처리하는 자율적인 존재여야 한다.

절차지향과 객체지향

프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍이라고 부른다. 일반적으로 절차적 프로그래밍은 우리의 직관에 위배되고, 따라서 코드를 읽는 사람과 원활하게 의사소통하지 못한다. 더 큰 문제는 절차적 프로그래밍의 세상에서는 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것이다. 한 클래스 내 구현을 변경하면 다른 클래스도 변경해야한다. 변경은 버그를 발생시킬 가능성이 있기 때문에, 코드 변경을 어렵게 하고 따라서 절차적 프로그래밍의 세상은 변경하기 어려운 코드를 양산하는 경향이 있다.

변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 그러려면 클래스가 자신의 데이터를 스스로 처리하도록 해야한다. 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체지향 프로그래밍이라고 부른다. 훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다.

책임

‘책임’을 기능을 가리키는 객체지향 세계의 용어로 생각해도 무방하다. 객체지향 설계에서는 각 객체에 책임이 적절히 분배되고, 객체는 자신을 스스로 책임진다. 이러한 점에서 객체지향 프로그래밍을 흔히 데이터와 프로세스를 하나의 단위로 통합해 놓는 방식으로 표현하기도 한다. 덕분에 변경에 탄력적으로 대응할 수 있는 견고한 설계를 얻을 수 있다. 나아가 코드를 이해하기 쉬워진다.

설계를 어렵게 만드는 것은 의존성이다. 해결 방법은 불필요한 의존성을 제거함으로써 객체 사이의 결합도를 낮추는 것이다. 예제에서는 결합도를 낮추기 위해 캡슐화했다. 캡슐화를 통해 객체의 자율성을 높이고 응집도 높은 객체들의 공동체를 창조할 수 있게 됐다.

설계시 유의할 점

두 가지를 기억해야 한다. 첫째, 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다. 둘째, 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물이다.

의인화

개선한 코드를 보면 비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다는 것을 알 수 있다. 이렇게 능동적이고 자율적인 존재로 설계하는 원칙을 가리켜 의인화라고 부른다. 훌륭한 객체지향 설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다. 따라서 이해하기 쉽고 변경하기 쉬운 코드를 작성하고 싶다면 차라리 한 편의 애니메이션을 만든다고 생각하여, 무생물이 생물인 것처럼 행동해도 당황하지 말자.

1_3_객체지향 설계

설계란 코드를 배치하는 것이다. 좋은 설계의 조건은 오늘 완성해야 하는 기능을 구현하는 코드를 짜야 하는 동시에 내일 쉽게 변경할 수 있는 코드를 짜는 것이다. 변경을 수용할 수 있는 설계가 중요한 이유는 요구사항이 항상 변경되기 때문이다. 요구사항 변경은 필연적으로 코드 수정을 초래하고, 코드 수정은 버그가 발생할 가능성을 높인다. 코드 변경으로 버그가 발생할지도 모른다는 불안감 때문에 코드 수정을 회피하려는 경향이 있는데, 이러한 점을 극복하기 위해서 변경을 고려한 설계를 해야한다.

객체지향은 과거 다른 방법보다 코드 변경이라는 측면에서는 안정감을 준다. 변경 가능한 코드는 이해하기 쉬운 코드다. 훌륭한 객체지향 설계란 협력하는 객체 사이의 의존성을 적절하게 관리하는 설계다.

2_객체지향 프로그래밍

2_2 객체지향 프로그래밍을 향해

협력, 객체, 클래스

객체지향의 의미를 되뇌일 필요가 있다. 객체지향 프로그래밍을 할 때 대부분 클래스를 결정한 후 클래스에 어떤 속성과 메서드가 필요한 지 고민한다. 이것은 객체지향의 본질과 거리가 멀다. 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때 얻을 수 있다. 이를 위해 두 가지에 집중해야 한다.

  1. 어떤 클래스가 필요한 지 고민하기 전에 어떤 객체들이 필요한 지 고민하라. 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다. 따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.
  2. 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다. 객체를 협력자로 바라보고, 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라.

객체지향 패러다임이 강력한 이유

요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다. 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.

접근제어자의 가치

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다. 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출 지를 결정하는 것이다. 클래스의 내부와 외부를 구분해야 하는 이유는 두 가지이다.

  1. 경계의 명확성이 객체의 자율성을 보장하기 때문이다.

    객체는 상태와 행동을 함께 가지는 복합적인 존재이다. 그리고 객체는 스스로 판단하고 행동하는 자율적인 존재이다. 두 사실은 서로 연관돼 있다. 객체지향 이전의 패러다임에서는 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성했다. 이와 달리 객체 지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. 이처럼 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 부른다.

    객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다. 객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다. 객체가 자율적인 존재로 우뚝 서기 위해서는 외부의 간섭을 최소화해야 한다. 일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 한다.

  2. 프로그래머에게 구현의 자유를 제공하기 때문이다.

    클래스 작성자는 클래스 사용자에게 필요한 부분만 공개하고 나머지는 숨김으로써, 클래스 사용자가 숨겨놓은 부분에 마음대로 접근할 수 없도록 방지한다. 덕분에 클래스 작성자는 클래스 사용자에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 이를 구현 은닉이라고 부른다. 구현은닉은 클래스 작성자, 클래스 사용자 모두에게 유용하다. 클래스 사용자는 내부의 구현을 고려하지 않아도 클래스를 사용할 수 있기 때문에 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있다. 클래스 작성자는 인터페이스를 바꾸지 않는 한(public 영역을 변경하지 않는다면) 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. 즉 객체의 외부외 내부를 구분하면 클래스 사용자가 알아야 할 지식의 양은 줄어들고, 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭은 넓어진다. 따라서 클래스를 개발할 때 인터페이스와 구현을 깔끔하게 분리하기 위해 노력해야 한다. 변경에 기민하기 대처하는 것이 객체 지향 설계의 가치였는데, 접근제어가 객체의 변경을 관리할 수 있는 가장 대표적인 기법이다.

원시타입 포장의 가치

객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다. 따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현하라.

협력

시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력이라고 부른다. 객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다. 객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청하고, 요청을 받은 객체가 자율적인 방법에 따라 요청을 처리한 후 응답하는 과정을 거쳐 협력한다.

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것뿐이다. 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신했다고 얘기한다. 메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정한다. 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 부른다. 메시지와 메서드의 구분에서부터 다형성의 개념이 출발하기 때문에, 메시지와 메서드를 구분하는 것은 중요하다.

2_4 상속과 다형성

컴파일 시간 의존성과 실행 시간 의존성

어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체의 메서드를 호출할 경우 두 클래스 사이에 의존성이 존재한다고 말한다. 그런데 코드의 의존성과 실행 시점의 의존성은 서로 다를 수 있다. 유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다.

코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다. 코드를 이해하기 위해 코드뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다. 반면 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다. 이러한 트레이드오프를 고려하여 의사결정 해야한다.

상속

상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다. 부모 클래스를 상속 후 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 부른다.

대부분 상속의 목적이 메서드나 인스턴스 변수를 재사용하는 것이라고 생각한다. 하지만 상속이 가치는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있다는 것이다. 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다. 즉 자식 클래스는 부모 클래스 대신 사용될 수 있다. 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 부른다.

구현 상속과 인터페이스 상속

순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것을 구현 상속이라고 부르고, 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것을 인터페이스 상속이라고 부른다. 상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다. 코드 재사용을 상속의 주된 목적이라고 생각하는 것은 오해다. 인터페이스를 재사용할 목적이 아니라 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확률이 크다.

다형성

한 객체가 다른 객체(협력하는 객체)에 메시지를 전송할 때, 실행되는 메서드는 실행 시간 의존 객체에 의해 결정된다. 즉 동일한 메시지를 전송하더라도 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다. 이를 다형성이라고 부른다. 다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.

정리하자면, 다형성이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다. 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. 다시 말해 인터페이스가 동일해야 한다. 예제에서는 인터페이스를 통일하기 위해 상속이라는 구현 방법을 사용했다. 다형성을 구현하는 방법은 매우 당야하지만 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다. 다시 말해 메시지와 메서드를 실행 시점에 바인딩한다는 것이고, 이를 지연 바인딩 또는 동적 바인딩이라고 부른다. 객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고, 하나의 메시지를 선택적으로 서로 다른 메서드에 연결할 수 있는 이유가 바로 지연 바인딩이라는 메커니즘을 사용하기 때문이다.

2_5 추상화와 유연성

추상화의 힘

예제에서 구현체에 직접 의존하는 대신 추상화된 객체에 의존하는 코드를 보여줬다. 추상화된 객체가 “더 추상적이다”고 하는 이유는 인터페이스에 초점을 맞추기 때문이다. 추상화된 객체는 같은 계층에 속하는 클래스들의 공통적으로 가질 수 있는 인터페이스를 정의하고 구현의 일부(추상클래스의 경우) 또는 전체(자바 인터페이스의 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임한다.

높은 수준에서 요구사항 서술

추상화의 계층만 따로 떼어놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다. 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다. 이런 특징은 세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다. 추상화를 이용한 설계는 필요에 따라 표현의 수준을 조정하는 것을 가능하게 해준다. 추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술한다는 것을 의미한다. 추상화된 것을 구체화하는 클래스들은 추상화를 이용해서 이미 정해놓은, 상위의 협력 흐름을 그대로 따르게 된다. 재사용 가능한 설계의 기본을 이루는 디자인 패턴, 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용한다.

유연한 설계

책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 특정한 케이스를 예외로 취급하기 때문에 일관성 있던 협력 방식이 무너지게 되기 때문이다. 항상 예외 케이스를 최소화하고 일관성을 유지할 수 있는 방법을 선택하라. 추상화를 적절히 활용하면 인터페이스를 수정하지 않아도 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장할 수 있다. 즉, 추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다. 추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. 이렇게 특정한 구현체에 묶이지 않는 것을 컨텍스트 독립성이라고 부른다. 유연성이 필요한 곳에 추상화를 사용하라.

추상클래스와 인터페이스 트레이드오프

추상클래스를 상속하여 구현하는 것은 부모 클래스와 자식 클래스를 개념적으로 결합시킨다. 이러면 캡슐화가 깨진다는 문제가 생긴다. 이 문제를 해결하기 위해서는 추상클래스 대신 인터페이스를 활용하는 것이다. 그러면 개념적인 혼란과 결합을 제거할 수 있다. 어떤 설계가 더 좋은가? 이상적으로는 인터페이스를 사용하도록 변경한 것이 더 좋다,. 현실적으로는 인터페이스를 추가하는 것이 과하다고 생각이 들 수 있다. 구현과 관련된 모든 것들은 트레이드오프의 대상이 될 수 있다.

상속의 단점

상속의 단점은 첫째로 상속이 캡슐화를 위반한다는 것이고 둘째로 상속이 설계를 유연하지 못하게 만든다는 것이다. 캡슐화 위반은 다른 포스트에서 많이 이야기해서 생략한다. 상속이 설계를 유연하지 못하게 만드는 이유는 상속이 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정하기 때문이다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다. 실행 시점에 자식 클래스를 변경한다고 가정하자. 이미 생성된 객체의 캘르스를 변경하는 것은 불가능하기 때문에, 최선의 방법은 다른 구현체를 생성 후 기존 구현체의 상태를 복사하는 것뿐이다. 부모 클래스와 자식 클래스가 강하게 결합돼 있기 때문에 문제가 생겼다. 한편, 전략을 인스턴스 변수로 두면 실행 시점에 전략을 쉽게 바꿀 수 있다. 이를 통해 상속보다 인스턴스 변수로 관계를 연결하는 설계가 더 유연하다는 사실을 알 수 있다.

합성

상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 데 비해 합성은 그렇지 않다. 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 부른다. 합성은 상속의 두 문제점을 모두 해결한다. 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다. 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다. 따라서 코드 재사용을 위해서는 상속보다는 합성이 더 좋은 방법이다. 그렇다고 상속을 절대 사용하지 말라는 것은 아니다. 코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수밖에 없다.

정리

대부분 객체지향 프로그래밍 과정을 클래스 안에 속성과 메서드를 채워넣는 작업이나 상속을 이용해 코드를 재사용하는 방법 정도로 생각한다. 프로그래밍 관점에서 클래스, 상속은 중요하지만 프로그래밍 관점에 너무 치우쳐서 객체지향을 바라볼 경우 객체지향의 본질을 놓치기 쉽다. 객체지향에서 가장 중요한 것은 애플리케이션의 기능을 구현하기 위해 협력에 참여하는 객체들 사이의 상호작용이다. 객체들은 협력에 참여하기 위해 역할을 부여받고 역할에 적합한 책임을 수행한다. 즉 객체지향 설계의 핵심은 적절한 협력을 식별하고 협력에 필요한 역할을 정의한 후에 역할을 수행할 수 있는 적절한 객체에게 적절한 책임을 할당하는 것이다.

3_역할, 책임, 협력

객체지향 패러다임의 관점에서 핵심은 역할, 책임, 협력이다. 그 중 가장 중요한 것은 책임이다. 객체지향의 본질은 협력하는 객체들의 공동체를 창조하는 것이다. 객체지향 설계의 핵심은 협력을 구성하기 위해 적절한 객체를 찾고 적절한 책임을 할당하는 과정에서 드러난다. 클래스와 상속은 객체들의 책임과 협력이 어느 정도 자리를 잡은 후 사용할 수 있는 구현 방식일 뿐이다. 애플리케이션의 기능을 구현하기 위해 어떤 협력이 필요하고 협력을 위해 어떤 역할과 책임이 필요한지를 고민하지 않은 채 너무 이른 시기에 구현에 초점을 맞추는 것은 변경하기 어렵고 유연하지 못한 코드를 낳는 원인이 된다.

3_1 협력

객체지향 원칙을 따르는 애플리케이션의 제어 흐름은 어떤 하나의 객체에 의해 통제되지 않고 다양한 객체들 사이에 균형 있게 분배되는 것이 일반적이다. 다양한 객체들은 기능을 구현하기 위해 메시지를 주고받으며 상호작용하는데, 이러한 상호작용을 협력이라고 한다. 객체가 협력에 참여하기 위해 수행하는 로직은 책임이라고 부른다. 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성한다.

협력은 객체지향의 세계에서 기능을 구현할 수 있는 유일한 방법이다. 메시지 전송은 객체 사이의 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단이다. 협력이란 어떤 객체가 다른 객체에게 무엇인가를 요청하는 것이다. 한 객체는 어떤 것이 필요할 때 다른 객체에게 전적으로 위임하거나 서로 협력한다. 즉, 두 객체가 상호작용을 통해 더 큰 책임을 수행하는 것이다. 객체 사이의 협력을 설계할 때는 객체를 서로 분리된 인스턴스가 아닌 협력하는 파트너로 인식해야 한다.

메시지를 수신한 객체는 메서드를 실행해 요청에 응답한다. 객체가 메시지를 처리할 방법을 스스로 선택한다는 점이 중요하다. 외부의 객체는 오직 메시지만 전송할 수 있을 뿐이며 메시지를 어떻게 처리할지는 메시지를 수신한 객체가 직접 결정한다. 이것은 객체가 지신의 일을 스스로 처리할 수 있는 자율적인 존재라는 것을 의미한다.

객체를 자율적으로 만드는 가장 기본적인 방법은 내부 구현을 캡슐화하는 것이다. 캡슐화를 통해 변경에 대한 파급효과를 제한할 수 있기 때문에 자율적인 객체는 변경하기도 쉬워진다. 자율적인 객체는 자신에게 할당된 책임을 수행하던 중, 필요한 정보를 알지 못하거나 외부의 도움이 필요한 경우 적절한 객체에게 메시지를 전송해서 협력을 요청한다.

애플리케이션 안에 어떤 객체가 필요하다면 그 이유는 단 하나여야 한다. 그 객체가 어떤 협력에 참여하고 있기 때문이다. 그리고 객체가 협력에 참여할 수 있는 이유는 협력에 필요한 적절한 행동을 보유하고 있기 때문이다. 결론적으로 객체의 행동을 결정하는 것은 객체가 참여하고 있는 협력이다. 협력이 바뀌면 객체가 제공해야 하는 행동 역시 바뀌어야 한다.

객체의 상태는 그 객체가 행동을 수행하는 데 필요한 정보가 무엇인지로 결정된다. 객체는 자신의 상태를 스스로 결정하고 관리하는 자율적인 존재이기 때문에 객체가 수행하는 행동에 필요한 상태도 함께 가지고 있어야 한다. 결국, 객체가 참여하는 협력이 객체를 구성하는 행동과 상태 모두를 결정한다. 따라서 협력은 객체를 설계하는 데 필요한 일종의 문맥(맥락)을 제공한다.

3_2 책임

협력을 갖춘 후 다음 할 일은 협력에 필요한 행동을 수행할 수 있는 적절한 객체를 찾는 것이다. 협력에 참여하기 위해 객체가 수행하는 행동을 책임이라고 부른다. 책임이란 객체에 의해 정의되는 응집도 있는 행위의 집합으로, 객체가 유지해야 하는 정보와 수행할 수 있는 행동에 대해 개략적으로 서술한 문장이다. 책임이 외부의 인터페이스와 내부의 속성을 결정한다.

책임은 객체가 수행할 수 있는 행동을 족합적이고 간략하게 서술하기 때문에 메시지보다 추상적이고 개념적으로도 더 크다.

책임의 관점에서 ‘아는 것’과 ‘하는 것’이 밀접하게 연관돼 있다. 어떤 책임을 수행하기 위해서는 그 책임을 수행하는 데 필요한 정보도 함께 알아야 하기 때문이다.

협력이 중요한 이유를 “객체에게 할당할 책임을 결정할 수 있는 문맥을 제공하기 때문”이라고 할 만큼, 책임이 핵심이고 중요하다. 객체에게 얼마나 적절한 책임을 할당하느냐가 설계의 전체 품질을 결정한다.

객체에게 책임을 할당하기 위해서는 먼저 협력이라는 문맥을 정의해야 한다. 협력을 설계하는 출발점은 시스템이 사용자에게 제공하는 기능을 시스템에 담당할 하나의 책임으로 바라보는 것이다. 객체가 책임을 수행하는 유일한 방법은 메시지를 전송하는 것이므로 책임을 할당하는 것은 메시지 이름을 결정하는 것과 같다. 객체지향 설계는 협력에 필요한 메시지를 찾고 메시지에 적절한 객체를 선택하는 반복적인 과정을 통해 이뤄진다. 이렇게 결정된 메시지가 객체의 퍼블릭 인터페이스를 구성한다. 상황에 따라 정보전문가가 아닌 다른 객체에게 책임을 할당하는 것이 더 적절한 경우도 있지만, 일반적으로 책임과 관련된 정보를 가장 많이 알고 있는 정보 전문가에게 할당하는 것이 기본적으로 좋다.

책임을 찾고 책임을 수행할 적절한 객체를 찾아 책임을 할당하는 방식으로 협력을 설계하는 방법을 책임 주도 설계라고 한다. 책임을 할당할 때 고려해야 하는 두 가지 요소는 “메시지가 객체를 결정한다”와 “행동이 상태를 결정한다”이다.

메시지가 객체를 결정한다.

객체에게 책임을 할당하는 데 필요한 메시지를 먼저 식별하고 메시지를 처리할 객체를 나중에 선택하는 것이 중요하다. 즉, 객체가 메시지를 선택하는 것이 아니라 메시지가 객체를 선택하게 해야한다. 왜냐하면, 첫째로 객체가 최소한의 인터페이스를 가질 수 있게 하기 때문이다. 둘째로 객체가 충분히 추상적인 인터페이스를 가질 수 있게 되기 때문이다.

행동이 상태를 결정한다.

객체의 행동은 객체가 협력에 참여할 수 있는 유일한 방법이다. 객체가 협력에 적합한지를 결정하는 것은 그 객체의 상태가 아니라 행동이다. 행동과 대치되는 개념인 상태는 단지 객체가 행동을 정상적으로 수행하기 위해 필요한 재료일 뿐이다. 협력이 객체의 행동을 결정하고, 행동이 상태를 결정한다. 행동은 객체의 책임이 된다.

책임과 협력의 윤곽은 캡슐화, 크기, 의존성, 유연성, 성능, 확장 가능성, 재사용성 등의 다양한 요소들의 트레이드오프를 통해 결정된다.

3_3 역할

객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합을 역할이라고 부른다. 책임을 객체에 할당할 때, 그 내면에는 두 절차가 있다. 첫째로 기능을 위한 역할을 찾는 것이고 둘째로 역할을 수행할 객체를 선택하는 것이다. 역할에 특별한 이름을 부여하지 않더라도 실제로는 익명의 역할을 찾고 그 역할을 수행할 수 있는 객체를 선택하는 방식으로 설계가 진행된다고 생각하는 것이 자연스럽다. 협력은 역할들의 상호작용으로 구성되고, 협력을 구성하기 위해 역할에 적합한 객체가 선택되며, 객체는 클래스를 이용해 구현되고 생성된다.

역할이 중요한 이유는 역할을 통해 유연하고 재상용 가능한 협력을 얻을 수 있기 때문이다. 책에 이렇게 쓰인 건 아니지만, 역할을 “책임들의 추상화”라고 생각해도 될 것 같다. 책임들을 역할이라고 추상화하고, 협력 안에서 역할을 다양한 책임을 바꿔 낄 수 있다. 즉 역할은 다양한 구체적 객체들을 포괄하는 추상화이다. 요점은 동일한 책임을 수행하는 역할을 기반으로 두 개의 협력을 하나로 통합할 수 있다는 것이다. 따라서 역할을 이용하면 불필요한 중복 코드를 제거할 수 있을뿐 아니라, 협력이 더 유연해진다.

역할을 구현하는 가장 일반적인 방법은 추상클래스와 인터페이스를 사용하는 것이다.

그런데 오직 한 종류의 객체만 협력에 참여하는 상황에서는 역할을 뭐라고 생각하면 될까? 협력에 참여하는 후보가 여러 종류의 객체에 의해 수행될 필요가 있다면 그 후보는 역할이 되지만 단지 한 종류의 객체만이 협력에 참여할 필요가 있다면 후보는 객체가 된다. 만약 동일한 종류의 객체가 하나의 역할을 항상 수행한다면 객체와 역할은 동일한 것이다. 하지만 어떤 협력에서 하나 이상의 객체가 동일한 책임을 수행할 수 있다면 역할은 서로 다른 방법으로 실행할 수 있는 책임의 집합이 된다. 대부분의 경우 어떤 것이 역할이고 어떤 것이 객체인지 또렷하게 드러나지 않는다. 도메인 모델 안에는 개념과 객체와 역할이 어지럽게 뒤섞여 있다. 도메인 모델이 불완전한 사람들이 세상을 바라보는 모델에 기반하기 때문이다. 따라서 필자는 설계 초반에는 적절한 책임과 협력의 큰 그림을 탐색하는 것이 가장 중요한 목표여야 하고, 역할과 객체를 명확하게 구분하는 것은 그렇게 중요하지 않다고 주장한다. 즉 객체, 역할이 애매할 때 단순히 객체로 시작하고 반복적으로 책임과 협력을 정제해가면서 필요한 순간에 객체로부터 역할을 분리해내는 것이 좋다고 한다. 책임을 수행하는 주체인 후보(식별한 책임을 구분해서 담을 수 있는 일종의 빈자리를 메꿔주는 것이라고 생각해도 됨)는 객체가 될 수도 있고 역할이 될 수도 있고 클래스가 될 수도 있다. 후보가 정확히 무엇인지는 설계 초반에서 그다지 중요하지 않다. 설계 초반에 중요한 것은 협력을 위해 어떤 책임이 필요한지를 이해하는 것이다. 처음에 특정 시나리오에 대한 협력을 구상할 때는 아마도 도메인 모델에 있는 개념들을 후보로 선택해 직접 책임을 할당할 것이다. 다양한 시나리오를 설계로 옮기면서 협력을 지속적으로 정제하다 보면 두 협력이 거의 유사한 구조를 보인다는 것을 발견하게 될 것이다. 이 경우 두 협력을 하나로 합치면서 두 객체를 포괄할 수 있는 역할을 고려해서 객체를 역할로 대체할 수 있다. 즉, 처음엔 후보를 객체로 생각했다가 나중에 동일한 책임을 서로 다른 방식으로 수행할 수 있는 객체들이 필요해질 때가 왔을 때 역할의 도입을 고려해도 늦지 않다. 따라서 가장 중요한 것은 책임이다. 다양한 객체들이 협력에 참여한다는 것이 확실하다면 역할로 시작하라. 하지만 모든 것이 안개 속에 둘러싸여 있고 정확한 결정을 내리기 어려운 상황이라면 구체적인 객체로 시작하라. 다양한 시나리오를 탐색하고 유사한 협력들을 단순화하고 합치다 보면 자연스럽게 역할이 그 모습을 드러낼 것이다. 중요한 것은 협력을 구체적인 객체가 아니라 추상적인 역할의 관점에서 설계하면 협력이 유연하고 재사용 가능해진다는 것이다. 따라서 역할의 가장 큰 장점은 설계의 구성 요소를 추상화할 수 있다는 것이다.

역할과 추상화

역할은 공통의 책임을 바탕으로 객체의 종류를 숨기기 때문에 이런 관점에서 역할을 객체의 추상화로 볼 수 있다. 따라서 추상화가 가지는 두 장점은 협력의 관점에서 역할에도 동일하게 적용될 수 있다. 이전 장에서 추상화의 장점은 언급했지만, 추상화의 첫 번째 장점은 세부 사항에 억눌리지 않고도 상위 수준의 정책을 쉽고 간단하게 표현할 수 있다는 것이다. 두 번째 장점은 설계를 유연하게 만들 수 있다는 것이다.

배우와 배역

배역은 연극 배우가 특정 연극에서 연기하는 역할이다. 배역은 연극이 상영되는 동안에만 존재하는 일시적인 개념이다. 연극이 끝나면 연극 배우는 배역이라는 역할을 벗어 버리고 원래의 배우로 돌아온다. 서로 다른 배우들이 동일한 배역을 연기할 수 있다. 하나의 배우가 다양한 연극 안에서 서로 다른 배역을 연기할 수 있다.

위 특성을 보았을 때 배우, 프로그래밍 내 개념들을 배우, 배역, 연극에 비유할 수 있다. 협력은 연극과 동일하고 코드는 극본과 동일하다. 객체 등 책임을 수행하는 주체는 배우이고, 역할은 배역이다.

객체는 여러 역할을 가질 수 있지만 특정한 협력 안에서는 일시적으로 오직 하나의 역할만이 보여진다는 점에 주의하라. 즉, 객체는 다수의 역할을 보유할 수 있지만 객체가 참여하는 특정 협력은 객체의 한 가지 역할만 바라볼 수 있다.

4_설계 품질과 트레이드오프

객체지향 설계란 올바른 객체에게 올바른 책임을 할당하면서 낮은 결합도와 높은 응집도를 가진 구조를 창조하는 활동이다. 이 정의에는 두 관점이 섞여있는데, 첫 번째 관점은 객체지향 설계의 핵심이 책임이란느 것이다. 두 번째 관점은 책임을 할당하는 작업이 응집도와 결합도 같은 설계 품질과 깊이 연관돼 있다는 것이다.

훌ㅇ한 설계는 합리적 비용 안에서 변경을 수용할 수 있는 것인데, 이러한 설계는 응집도가 높고 서로 느슨하게 결합돼 있는 요소로 구성된다. 결합도와 응집도를 합리적인 수준으로 유지할 수 있는 원칙은 객체의 상태가 아니라 객체의 행동에 초점을 맞추는 것이다. 객체를 단순히 데이터의 집합으로 바라보는 시각은 객체의 내부 구현을 퍼블릭 인터페이스에 노출시키는 결과를 낳기 때문에 결과적으로 설계가 변경에 취약해진다.

4_1 데이터 중심의 영화 예매 시스템

데이터 중심의 관점은 객체의 상태에 초점을 맞추고 책임 중심의 관점은 객체의 행동에 초점을 맞춘다. 객체의 상태는 구현에 속한다. 구현은 불안정하기 때문에 변하기 쉽다. 상태를 객체 분할의 중심축ㅇ로 삼으면 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다. 결과적으로 상태 변경은 인터페이스의 변경을 초래하며 이 인터페이스에 의존하는 모든 객체에게 변경의 영향이 퍼지게 된다. 따라서 데이터에 초점을 맞춘느 설계는 변경에 취약할 수밖에 없다. 그에 비해 객체의 책임은 인터페이스에 속한다. 객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다. 따라서 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있다.

4_2 설계 트레이드오프

설계 품질을 판단할 수 있는 기준으로 캡슐화, 응집도, 결합도를 사용한다.

캡슐화

상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서다. 여기서 구현이란 나중에 변경될 가능성이 높은 어떤 것을 말한다. 객체지향이 강력한 이유는 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 적절하게 조절할 수 있는 장치를 제공하기 때문이다. 객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있다. 변경될 가능성이 높은 부분을 구현이라고 부르고 상대적으로 안정적인 부분을 인터페이스라고 부른다.

객체지향에서 가장 중요한 원리는 캡슐화다. 캡슐화는 외부에서 알 필요가 없는 부분을 감춤으로써 대상을 단순화하는 추상화의 한 종류다. 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는 것이다. 설계가 필요한 이유는 요구사항이 변경되기 때문이고, 캡슐화가 중요한 이유는 불안정한 부분과 안정적인 부분을 분리해서 변경의 영향을 통제할 수 있기 때문이다. 정리하면 캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이다.

객체지향의 목표 중 하나는 유지보수성이다. 유지보수성이란 두려움 없이, 주저함 없이, 저항감 없이 코드를 변경할 수 있는 능력을 말한다.

응집도와 결합도

응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다. 결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도다.

변경의 관점에서 응집도란 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다. 간단히 말해 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 응집도가 높은 것이고 모듈의 일부만 변경된다면 응집도가 낮은 것이다. 또한 하나의 변경에 대해 하나의 모듈만 변경된다면 응집도가 높지만 다수의 모듈이 함께 변경돼야 한다면 응집도가 낮은 것이다. 응집도가 높을수록 변경의 대상과 범위가 명확해지기 때문에 코드를 변경하기 쉬워진다.

변경의 관점에서 결합도란 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다. 즉 하나의 모듈을 수정할 때 얼마나 많은 모듈을 함께 수정해야 하는지를 나타낸다. 내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우에는 두 모듈 사이의 결합도가 높다고 표현한다. 반면 퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다고 표현한다.

캡슐화의 정도가 응집도와 결합도에 영향을 미친다.

4_3 데이터 중심의 영화 예매 시스템의 문제점

캡슐화 위반

게터, 세터는 객체 내부에 특정 타입의, 특정 이름의 인스턴스 변수가 존재한다는 사실을 퍼블릭 인터페이스에 노골적으로 드러낸다. 객체가 캡슐화의 원칙을 어기게 된 근본적 원인은 객체가 수행할 책임이 아니라 내부에 저장할 데이터에 초점을 맞췄기 때문이다. 데이터 중심으로 생각하면 개발자가 어떤 상황에서도 해당 객체가 사용될 수 있게 하기 위해 최대한 많은 접근자, 수정자를 두게 되는 문제가 생긴다. 이러한 방식은 객체가 사용될 협력을 고려하지 않고 객체가 다양한 상황에서 사용될 수 있을 것이라는 막연한 추측을 기반으로 설계를 진행하는 것이다. 따라서 프로그래머는 내부 상태를 드러내는 메서드를 최대한 많이 추가해야 한다는 압박에 시달리게 되고, 결과적으로 대부분의 내부 구현이 퍼블릭 인터페이스에 그대로 노출된다. 그 결과 캡슐화를 위반하게 된다.

높은 결합도

게터는 해당 인스턴스 타입 변경으로 인해 협력하는 클래스가 변경되기 때문에 게터는 인스턴스 변수(구현)을 제대로 캡슐화하지 못한다. 결합도 측면에서 데이터 중심 설계가 가지는 단점은 여러 데이터 객체들을 사용하는 제어 로직이 특정 객체 안에 집중되기 때문에 하나의 제어 객체가 다수의 데이터 객체에 강하게 결합된다는 것이다. 예제에서 대부분의 제어 로직을 가지고 있는 객체는 어플리케이션 내 대부분의 객체가 수정될 때 함께 수정돼야 한다는 걸 보여준다. 데이터 중심의 설계는 전체 시스템을 하나의 거대한 의존성 덩어리로 만들어 버리기 때문에 어떤 변경이라도 일단 발생하고 나면 시스템 전체가 요동칠 수밖에 없다.

낮은 응집도

낮은 응집도는 두 가지 측면에서 설계에 문제를 일으킨다. 첫째, 변경의 이유가 서로 다른 코드들을 하나의 모듈 안에 뭉쳐놓았기 때문에 변경과 아무 상관이 없는 코드들이 영향을 받게 된다. 둘째, 하나의 요구사항 변경을 반영하기 위해 동시에 여러 모듈을 수정해야 한다.

단일 책임 원칙

단일 책임 원칙은 클래스가 단 한 가지의 변경 이유만 가져야 한다는 것이다. 단일 책임 원칙은 클래스의 응집도를 높일 수 있는 설계 원칙이다.

4_4 자율적인 객체

캡슐화를 지켜라

예제가 보여주는 캡슐화를 지키지 않은 객체는 많은 두 가지 문제가 있다. 첫째, 코드 중복이 발생할 확률이 높다는 것이다. 둘째, 변경에 취약하다는 것이다. 해결방법은 캡슐화를 강화시키는 것이다. 예제는 객체가 스스로를 책임질 수 있게 책임을 이동시킴으로써 캡슐화를 강화시키는 것을 보여준다.

스스로 자신의 데이터를 책임지는 객체

객체를 데이터 중심으로 설계할 때, “이 객체가 어떤 데이터를 포함해야 하는가?”라는 질문은 두 개의 개별적인 질문으로 분리해야 한다. “이 객체가 어떤 데이터를 포함해야 하는가?”와 “이 객체가 데이터에 대해 수행해야 하는 오퍼레이션은 무엇인가?” 이다. 두 질문을 통해 객체의 내부 상태를 저장하는 방식과 저장된 상태에 대해 호출할 수 있는 오퍼레이션의 집합을 얻을 수 있다. 다시 말해 새로운 데이터 타입을 만들 수 있다. 이러한 생각으로 의존성이 몰려있던 것을 해소하여 예제를 조금 개선할 수 있다. 이것은 전보다 내부 구현을 더 면밀하게 캡슐화하기 때문이다. 기존에 비해 데이터를 처리하는 데 필요한 메서드를 데이터를 가지고 있는 객체 스스로 구현하게 만들었다. 즉, 객체들이 스스로를 책임지게 만들었다.

4_5 하지만 여전히 부족하다

개선한 코드도 데이터 중심의 설계 방식이다. 개선했다고 하더라도 개선 전에 있던 대부분의 문제가 여전히 발생한다. 그 이유를 살펴보자.

캡슐화 위반

수정한 코드를 보면, 메소드에 파라미터로 인스턴스 변수가 포함되어있다. 즉, 인터페이스를 통해 내부 구현을 외부에 노출하고 있는 것이다. 따라서 인스턴스 변수의 속성을 변경한다면, 메소드를 수정할 뿐아니라 클라이언트 코드도 함께 수정해야 한다. 내부 구현의 변경이 외부로 퍼져나가는 파급효과는 캡슐화가 부족하다는 명백한 증거다. 파라미터뿐 아니라 메소드 명에서도 내부 구현을 노출시킬 수 있다.

캡슐화의 진정한 의미

캡슐화는 단순히 객체 내부의 데이터를 외부로부터 감추는 것 이상의 의미를 가진다. 사실 캡슐화는 변경될 수 있는 어떤 것이라도 감추는 것을 의미한다. 즉, 인스턴스 변수의 타입이건, 인스턴스 변수의 종류건 상관 없이 내부 구현의 변경으로 인해 외부 객체가 영향을 받는다면 캡슐화를 위반한 것이다.

높은 결합도

예제를 통해, 한 객체의 인터페이스가 아니라 구현을 변경하는 경우에도 그것에 의존하는 객체가 변경해야 한다는 것을 볼 수 있다. 이것은 객체 사이의 결합도가 높다는 것을 의미한다. 이 문제는 두 객체간의 문제로 한정되는 것이 아니라, 변경의 여파가 시스템을 구성하는 모든 객체들에게 퍼진다. 모든 문제의 원인은 캡슐화를 지키지 않았기 때문이다. 제대로 캡슐화하지 못했기 때문에 결합도가 높아진 것이다.

낮은 응집도

예제에서는 하나의 객체를 변경하기 위해 여러 객체를 함께 수정해야 하는 걸 보여준다. 하나의 변경을 수용하기 위해 코드의 여러 곳을 동시에 변경해야 한다는 것은 설계의 응집도가 낮다는 증거다. 응집도가 낮은 이유는 캡슐화를 위반했기 때문이다.

4_6 데이터 중심 설계의 문제점

데이터 중심의 설계가 변경에 취약한 이유는 두 가지다.

  1. 데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
  2. 데이터 중심의 설계에서는 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.

데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다

데이터는 구현의 일부이다. 데이터 주도 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에 너무 이른 시기에 내부 구현에 초점을 맞추게 된다. 데이터 중심 설계는 일반적으로 데이터와 기능을 분리하는 절차적 프로그래밍 방식을 따른다. 데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다. 이 방식은 캡슐화를 완전히 무너뜨린다. 이것이 첫번째 예제가 좋지 않은 이유다.

비록 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다. 결과적으로 객체의 인터페이스는 구현을 캡슐화하는 데 실패하고 코드는 변경에 취약해진다. 이것이 두번째 예제가 좋지 않은 이유다.

결론적으로 데이터 중심 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 실패한다.

데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다

데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. 실행 문맥에 대한 깊이 있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스를 억지로 끼워맞출 수밖에 없다. 이것은 객체가 다른 객체와 협력하는 방법을 중요시하는 객체지향 설계의 핵심을 위반한다. 두번째 예제가 변경에 유연하게 대처하지 못한 이유가 여기에 있다. 객체의 인터페이스에 구현이 노출돼 있었기 때문에 협력이 구현 세부사항에 종속돼있고 그에 따라 객체의 내부 구현이 변경됐을 때 협력하는 객체 모두가 영향을 받을 수밖에 없었던 것이다.

5_책임 할당하기

책임에 초점을 맞춰 설계할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기 쉽지 않다는 것이다. 올바른 책임을 할당하기 위해서는 다양한 관점에서 설계를 평가할 수 있어야 한다.

5_1 책임 주도 설계를 향해

데이터보다 행동을 먼저 결정하라

객체를 설계하기 위한 질문의 순서를 바꿔라. “객체가 포함해야 하는 데이터가 무엇인가”를 결정한 후에 “데이터를 처리하는 데 필요한 오퍼레이션은 무엇인가”를 결정하는 게 아니라, “객체가 수행해야 하는 책임은 무엇인가”를 결정한 후에 “이 책임을 수행하는 데 필요한 데이터는 무엇인가”를 결정허라.

협력이라는 문맥 안에서 책임을 결정하라

객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다. 책임은 객체 입장이 아니라 객체가 참여하는 협력에 적합해야 한다. 협력을 시작하는 주체는 메시지 전송자이기 때문에 협력에 적합한 책임이란 메시지 수신자가 아니라 메시지 전송자에게 적합한 책임을 의미한다. 메시지는 클라이언트의 의도를 표현한다.

정리하자면, 객체에게 적절한 책임을 할당하기 위해서는 협력이라는 문맥을 고려해야 한다. 협력이라는 문맥에서 적절한 책임이란 곧 클라이언트의 관점에서 적절한 책임을 의미한다.

책임 주도 설계

  1. 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.
  2. 시스템 책임을 더 작은 책임으로 분할한다.
  3. 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
  4. 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
  5. 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 한다.

5_2 책임 할당을 위한 GRASP 패턴

도메인 개념에서 출발하기

도메인 안에는 무수히 많은 개념들이 존재하며 이 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다. 따라서 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 유력한 후보는 바로 도메인 개념이다.

도메인 모델은 도메인을 개념적으로 표현한 것이지만 그 안에 포함된 개념과 관계는 구현이 기반이 돼야 한다. 유연성이나 재사용성 등과 같이 실제 코드를 구현하면서 얻게 되는 통찰이 역으로 도메인에 대한 개념을 바꾸기도 한다. 이것은 올바른 도메인 모델이란 존재하지 않는다는 사실을 보여준다. 필요한 것은 도메인을 그대로 투영한 모델이 아니라 구현에 도움이 되는 모델이다.

정보 전문가에게 책임을 할당하라

객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. 정보를 알고 있는 객체만이 책임을 어떻게 수행할지 스스로 결정할 수 있다. 정보와 행동을 최대한 가까운 곳에 위치시키면 캡슐화를 유지할 수 있다 정보는 데이터와 다르다는 사실에 주의하라. 책임을 수행하는 객체가 정보를 ‘알고’있다고 해서 그 정보를 ‘저장’하고 있을 필요는 없다. 만약 스스로 처리할 수 없는 작업이 있다면 외부에 도움을 요청해야 한다. 이 요청이 외부로 전송해야 하는 새로운 메시지가 되고, 최종적으로 이 메시지가 새로운 객체의 책임으로 할당된다.

높은 응집도와 낮은 결합도

다양한 협력 패턴 중 높은 응집도와 낮은 결합도를 얻을 수 있는 설계가 있다면 그 설계를 선택하라.

Low Coupling 패턴

낮은 결합도 패턴은 어떻게 하면 의존성을 낮추고 변화의 영향을 줄이며 재사용성을 증가시킬 수 있을 지 고민한다. 예제에서 이미 결합된 객체들을 협력하게 하면 설계 전체적으로 결합도를 추가하지 않고도 협력을 완성할 수 있다.

High Cohesion 패턴

높은 응집도 패턴은 어떻게하면 복잡성을 관리할 수 있는 수준으로 유지할 수 있는 지 고민한다. 다른 객체와 협력하기 위해 기존과는 다른 이유로 변경되는 책임을 새롭게 짊어지게 되면 응집도가 낮아진다.

창조자에게 생성 책임을 할당하라. - Creator 패턴

객체 A를 생성해야 할 때 어떤 객체에게 객체 생성 책임을 할당해야 하는가? 아래 조건을 최대한 많이 만족하는 B에게 객체 생성 책임을 할당하라.

  1. B가 A객체를 포함하거나 참조한다.
  2. B가 A객체를 기록한다.
  3. B가 A객체를 긴밀하게 사용한다.
  4. B가 A객체를 초기화하는 데 필요한 데이터를 가지고 있다(이 경우 B는 A에 대한 정보 전문가다).

Creator 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에 해당 객체를 생성할 책임을 맡기는 것이다. 이미 결합된 객체에게 생성 책임을 맡기라고 이해해도 된다. 이미 결합돼있으면 생성 책임을 할당해도 설계의 전체적인 결합도에 영향을 미치지 않기 때문이다.

5_3 구현을 통한 검증

질문 - calculateMovieFee(Screening screening)에서 왜 파라미터로 Screening을 넣은거지?

변경에 취약한 클래스 개선

변경에 취약한 클래스란 코드를 수정해야 하는 이유를 하나 이상 가지는 클래스다. 하나 이상의 변경 이유를 가지기 때문에 응집도가 낮다. 낮은 응집도가 초래하는 문제를 해결하기 위해서는 변경의 이유에 따라 클래스를 분리해야 한다.

코드를 통해 변경의 이유를 파악할 수 있는 첫 번째 방법은 인스턴스 변수가 초기화되는 시점을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다. 반면 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화되지 않은 상태로 남겨진다. 함께 초기화되는 속성을 기준으로 코드를 분리해야 한다.

두 번째 방법은 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것이다. 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다. 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다. 속성 그룹과 해당 그룹에 접근하는 메서드 그룹을 기준으로 코드를 분리해야 한다.

타입 분리하기

예제의 큰 문제는 두 개의 독립적인 타입이 하나의 클래스 안에 공존하고 있다는 점이다. 그래서 타입을 분리하고 나니, 분리된 객체들의 입장에서 보면 응집도가 높아졌지만 변경과 캡슐화라는 관점에서 보면 전체적으로 설계의 품질이 나빠지고 말았다.

다형성을 통해 분리하기 - Polymorphism(다형성) 패턴

역할은 협력 안에서 대체 가능성을 의미하기 때문에 역할의 개념을 적용하면 클라이언트 클래스가 서비스 클래스를 구체적으로 알지 못한 채 오직 역할에 대해서만 결합되도록 의존성을 제한할 수 있다. 역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상 클래스를 사용하면 된다. 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다면 인터페이스를 사용하면 된다.

객체의 암시적인 타입에 따라 행동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결할 수 있다. 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다. 이를 Polymorphism(다형성) 패턴이라고 한다.

변경으로부터 보호하기 - Protected Variations(변경보호) 패턴

추상화는 구체적인 타입을 캡슐화한다. 이것은 새로운 타입을 추가하더라도 클라이언트가 영향을 받지 않는다는 것을 의미한다. 변경을 캡슐화하도록 책임을 할당하는 것을 Protected Variations(변경 보호) 패턴이라고 부른다.

어떻게 설계해야 변화와 불안정성이 다른 요소에 나쁜 영향을 미치지 않도록 방지할 수 있을까? 변화가 예상되는 불안정한 지점들을 식별하고 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당하라. 변경 보호 패턴은 책임 할당의 관점에서 캡슐화를 설명한 것이다. 설계에서 변하는 것이 무엇인지 고려하고 변하는 개녑을 캡슐화하라. 우리가 캡슐화해야 하는 것은 변경이다. 변경 될 가능성이 높은가? 캡슐화하라.

클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계의 결합도와 응집도를 향상시키는 강력한 방법이다. 하나의 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분해하고 다형성 패턴에 따라 책임을 분산시켜라. 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 변경 보호 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라.

도메인의 구조가 코드의 구조를 이끈다

도메인 모델은 단순히 설계에 필요한 용어를 제공하는 것을 넘어 코드의 구조에도 영향을 미친다. 변경 역시 도메인 모델의 일부이다. 도메인 모델에는 도메인 안에서 변하는 개념과 이들 사이의 관계가 투영돼 있어야 한다.

구현을 가이드할 수 있는 도메인 모델을 선택하라. 객체지향은 도메인의 개념과 구조를 반영한 코드를 가능하게 만들기 때문에 도메인의 구조가 코드의 구조를 이끌어 내는 것은 자연스러울뿐만 아니라 바람직한 것이다.

변경과 유연성

설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다. 하나는 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것이다. 다른 하나는 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다. 대부분의 경우에 전자가 더 좋은 방법이지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 더 좋다.

5_4 책임 주도 설계의 대안

책임 주도 설계가 어렵다면 그 대안은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성하는 것이다.

메서드 응집도

긴 메서드는 다양한 측면에서 코드의 유지보수에 부정적인 영향을 미친다.

  1. 어떤 일을 수행하는지 한눈에 파악하기 어렵기 때문에 코드를 전체적으로 이해하는 데 너무 많은 시간이 걸린다.
  2. 하나의 메서드 안에서 너무 많은 작업을 처리하기 때문에 변경이 필요할 때 수정해야 할 부분을 찾기 어렵다.
  3. 메서드 내부의 일부 로직만 수정하더라도 메서드의 나머지 부분에서 버그가 발생할 확률이 높다.
  4. 로직의 일부만 재사용하는 것이 불가능하다.
  5. 코드를 재사용하는 유일한 방법은 원하는 코드를 복사해서 붙여넣는 것뿐이므로 코드 중복을 초래하기 쉽다.

한마디로 긴 메서드는 응집도가낮기 때문에 이해하기도 어렵고 재사용하기도 어려우며 변경하기도 어렵다. 클래스의 응집도와 마찬가지로 메서드의 응집도를 높이는 이유도 변경과 관련이 깊다. 메서드가 잘게 나눠져 있을 때 다른 메서드에서 사용될 확률이 높아진다. 또한 고수준의 메서드를 볼 때 일련의 주석을 읽는 것 같은 느낌이 들게 할 수 있다. 그리고 오버라이딩하는 것도 훨씬 쉽다. 사실 메서드의 길이 자체가 중요한 것은 아니다. 중요한 것은 메서드의 이름과 메서드 몸체의 의미적 차이다. 길어지더라도 명확해진다면 나눌 필요가 있다.

객체를 자율적으로 만들자

자신이 소유하고 있는 데이터를 자기 스스로 처리하도록 만드는 것이 자율적인 객체를 만드는 지름길이다. 어떤 데이터를 사용하는지를 가장 쉽게 알 수 있는 방법은 메서드 안에서 어떤 클래스의 접근자 메서드를 사용하는지 파악하는 것이다. 예제를 보면, 메서드를 다른 클래스로 이동시킬 때는 인자에 정의된 클래스 중 하나로 이동하는 경우가 일반적이라는 것을 알 수 있다.

책임 주도 설계 방법에 익숙하지 않다면 일단 데이터 중심으로 구현한 후 이를 리팩터링하더라도 유사한 결과를 얻을 수 있다.

6_메시지와 인터페이스

클래스는 개발자가 직접 만지고, 실험하고, 고쳐볼 수 있는 실제적이면서도 구체적인 도구다. 클래스라는 구현 도구에 지나치게 집착하면 경직되고 유연하지 못한 설계에 이를 확률이 높아진다. 책임에 초점을 맞춰야 하는데, 책임은 객체가 수신할 수 있는 메시지의 기반이 된다. 애플리케이션은 클래스로 구성되지만 메시지를 통해 정의된다. 객체가 수신하는 메시지들이 객체의 퍼블릭 인터페이스를 구성한다. 훌륭한 퍼블릭 인터페이스를 얻기 위해서는 책임 주도 설계 방법을 따르는 것만으로는 부족하다. 유연하고 재사용 가능한 퍼블릭 인터페이스를 만드는 데 도움이 되는 설계 원칙과 기법을 익히고 적용해야 한다.

6_1 협력과 메시지

클라이언트-서버 모델

협력 안에서 메시지를 전송하는 객체를 클라이언트, 메시지를 수신하는 객체를 서버라고 부른다. 협력은 클라이언트가 서버의 서비스를 요청하는 단방향 상호작용이다. 대부분 객체가 수신하는 메시지의 집합에만 초점을 맞추지만 협력에 적합한 객체를 설계하기 위해서는 외부에 전송하는 메시지의 집합도 함께 고려하는 것이 바람직하다.

메시지와 메시지 전송

메시지는 오퍼레이션명과 인자로 구성되며 메시지 전송은 여기에 메시지 수신자를 추가한 것이다. 즉 메시지 전송은 메시지 수신자, 오퍼레이션명, 인자의 조합이다.

메시지와 메서드

메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇인가에 달려 있다. 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다. 메시지 전송을 코드 상에 표기하는 시점에는 어떤 코드가 실행될 것인지를 정확하게 알 수 없다.

**질문: **

메시지 수신자는 메시지를 처리하기 위해 필요한 메서드를 스스로 결정할 수 있는 자율권을 누린다 - 객체가 적절한 메서드를 선택한다는 말을 이해 못하겠음.

메시지를 보내는 시점에 오퍼레이션명, 인자는 정해져있으니 수신자는 그에 맞는 메서드를 호출할 수밖에 없는 것 아닌가? 어떻게 선택한다는 것인지…

메시지와 메서드의 구분은 메시지 전송자와 메시지 수신자가 느슨하게 결합될 수 있게 한다. 실행 시점에 메시지와 메서드를 바인딩하는 메커니즘은 두 객체 사이의 결합도를 낮춤으로써 유연하고 확장 가능한 코드를 작성할 수 있게 만든다.

퍼블릭 인터페이스와 오퍼레이션

객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 인터페이스라고 부른다. 프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션이라고 부른다. 오퍼레이션은 수행 가능한 어떤 행동에 대한 추상화다. 오퍼레이션이란 실행하기 위해 객체가 호출될 수 있는 변화니나 정의에 관한 명세다. UML용어로 말하자면, 인터페이스의 각 요소는 오퍼레이션이다. 반면 메시지를 수신했을 때 실제로 실행되는 코드는 메서드라고 부른다. UML의 메서드는 오퍼레이션을 구현한 것이다.

시그니처

오퍼레이션(혹은 메서드)의 이름과 파라미터 목록을 합쳐 시그니처라고 부른다. 오퍼레이션은 실행 코드 없이 시그니처만을 정의한 것이다. 메서드는 이 시그니처에 구현을 더한 것이다. 오퍼레이션의 관점에서 다형성이란 동일한 오퍼레이션 호출에 대해 서로 다른 메서드들이 실행되는 것이라고 정의할 수 있다.

질문:

일반적으로 메시지를 수신하면 오퍼레이션의 시그니처와 동일한 메서드가 실행된다 - 그렇지 않은 경우도 있다는 건가?

용어 정리

  • 메시지: 객체가 다른 객체와 협력하기 위해 사용하는 의사소통 메커니즘. 일반적으로 객체의 오퍼레이션이 실행되도록 요청하는 것을 “메시지 전송”이라고 부른다.
  • 오퍼레이션: 객체가 다른 객체에게 제공하는 추상적인 서비스다. 메시지가 전송자와 수신자 사이의 협력 관계를 강조하는 데 비해 오퍼레이션은 메시지를 수신하는 객체의 인터페이스를 강조한다. 즉 메시지 수신자의 관점만을 다룬다. 메시지 수신이란 메시지에 대응되는 객체의 오퍼레이션을 호출하는 것을 의미한다.
  • 메서드: 메시지에 응답하기 위해 실행되는 코드 블록을 메서드라고 부른다. 메서드는 오퍼레이션의 구현이다.
  • 퍼블릭 인터페이스: 객체가 협력에 참여하기 위해 외부에서 수신할 수 있는 메시지의 묶음. 클래스의 퍼블릭 메서드들의 집합이나 메시지의 집합을 가리키는 데 사용된다.
  • 시그니처: 시그니처는 오퍼레이션이나 메서드의 명세를 나타낸 것으로, 이름과 인자의 목록을 포함한다.

중요한 것은 객체가 수신할 수 있는 메시지가 객체의 퍼블릭 인터페이스와 그 안에 포함될 오퍼레이션을 결정한다는 것이다. 객체의 퍼블릭 인터페이스가 객체의 품질을 결정하기 때문에 결국 메시지가 객체의 품질을 결정한다고 할 수 있다.

6_2 인터페이스와 설계 품질

좋은 인터페이스는 최소한의 인터페이스와 추상적인 인터페이스라는 조건을 만족해야 한다. 최소한의 인터페이스는 꼭 필요한 오퍼레이션만을 인터페이스에 포함한다. 추상적인 인터페이스는 어떻게 수행하는지가 아니라 무엇을 하는지를 표현한다.

퍼블릭 인터페이스의 품질에 영향을 미치는 원칙과 기법: 디미터 법칙, 묻지 말고 시켜라, 의도를 드러내는 인터페이스, 명령-쿼리 분리

디미터 법칙

디미터법칙은 협력하는 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안되었다. 디미터 법칙을 간단히 요약하면 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것이다. 디미티 법칙을 따르기 위해서는 클래스가 특정한 조건을 만족하는 대상에게만 메시지를 전송하도록 프로그래밍해야 한다. 디미터 법칙을 통해 불필요한 어떤 것도 다른 객체에게 보여주지 않으며, 다른 객체의 구현에 의존하지 않는 코드를 작성할 수 있다. 디미터 법칙을 따르는 코드는 메시지 수신자의 내부 구조가 전송자에게 노출되지 않으며, 메시지 전송자는 수신자의 내부 구현에 결합되지 않는다. 따라서 클라이언트와 서버 사이에 낮은 결합도를 유지할 수 있다. 디미터 법칙은 클래스를 캡슐화하기 위해 따라야 하는 구체적인 지침을 제공한다. 캡슐화 원칙이 클래스 내부의 구현을 감춰야 한다는 사실을 강조한다면, 디미터 법칙은 협력하는 클래스의 캡슐화를 지키기 위해 접근해야 하는 요소를 제한한다. 디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조한다. 디미터 법칙은 객체의 내부 구조를 묻는 메시지가 아니라 수신자에게 무언가를 시키는 메시지가 더 좋은 메시지라고 말한다. 이러한 점에서 “묻지 말고 시켜라” 원칙과 일맥상통한다.

묻지 말고 시켜라

메시지 전송자는 메시지 수신자의 상태를 기반으로 결정을 내린 후 메시지 수신자의 상태를 바꿔서는 안된다. 묻지 말고 시켜라 원칙을 따르면 밀접하게 연관된 정보와 행동을 함께 가지는 객체를 만들 수 있다. 객체지향의 기본은 함께 변경될 확률이 높은 정보와 행동을 하나의 단위로 통합하는 것이다. 묻지 말고 시켜라 원칙을 따르면 객체의 정보를 이용하는 행동을 객체의 외부가 아닌 내부에 위치시키기 때문에 자연스럽게 정보와 행동을 동일한 클래스 안에 두게 된다. 자연스럽게 정보 전문가에게 책임을 할당하게 되고 높은 응집도를 가진 클래스를 얻을 확률이 높아진다.

클라이언트는 이웃 객체가 수행하는 역할을 사용해 무엇을 우너하는지를 서술해야 하고, 호출되는 객체(서버)가 어떻게 해야 하는지를 스스로 결정하게 해야한다. 이렇게 하면 동일한 역할을 수행하는 객체로 교체하는 것이 쉽기 때문에 유연한 코드를 얻을 수 있다.

묻지 않고 시킨다고 모든 문제가 해결되진 않는다. 훌륭한 인터페이스를 얻기 위해서는 객체가 어떻게 작업을 수행하는지를 노출해서는 안 된다. 인터페이스는 객체가 어떻게 하는지가 아니라 무엇을 하는지를 서술해야 한다.

의도를 드러내는 인터페이스

메서드를 명명하는 첫 번째 방법은 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것이다. 이 스타일은 좋지 않은데, 그 이유는 다음과 같다. 첫째, 메서드에 대해 제대로 커뮤니케이션하지 못한다. 이 스타일로 메서드명을 정하면 클라이언트 관점에서 여러 메서드는 동일한 작업을 수행한다. 하지만 메서드의 이름이 다르기 때문에 두 메서드의 내부 구현을 정확하게 이해하지 못한다면 두 메서드가 동일한 작업을 수행한다는 사실을 알아채기 어렵다. 둘째, 메서드 수준에서 캡슐화를 위반한다. 이 메서드들은 클라이언트로 하여금 협력하는 객체의 종류를 알도록 강요한다. 변화가 있을 때 메서드 이름을 변경해야 할 것이고, 메서드 이름을 변경한다는 것은 메시지를 전송하는 클라이언트의 코드도 함께 변경해야 한다는 것을 의미한다. 따라서 책임을 수행하는 방법을 드러내는 메서드를 사용한 설계는 변경에 취약할 수밖에 없다.

메서드를 명명하는 두 번째 방법은 “어떻게”가 아니라 “무엇”을 하는지를 드러내는 것이다. 무엇을 하는지를 드러내는 이름은 코드를 읽고 이해하기 쉽게 만들뿐만 아니라 유연한 코드를 낳는 지름길이다. 어떻게 수행하는지를 드러내는 이름은 메서드의 내부 구현을 설명하는 이름이다. 결과적으로 협력을 설계하기 시작하는 이른 시기부터 클래스의 내부 구현에 관해 고민할 수밖에 없다. 반면 무엇을 하는지를 드러내도록 메서드의 이름을 짓기 위해서는 객체가 협력 안에서 수행해야 하는 책임에 관해 고민해야 한다. 이것은 외부의 객체가 메시지를 전송하는 목적을 먼저 생각하도록 만들며, 결과적으로 협력하는 클라이언트의 의도에 부합하도록 메서드의 이름을 짓게 된다. 어떻게 하느냐가 아니라 무엇을 하느냐에 따라 메서드의 이름을 짓는 패턴을 “의도를 드러내는 선택자”라고 부른다. 메서드에 의도를 드러낼 수 있는 이름을 붙이기 위한 팁은 매우 다른 두 번째 구현을 상상하는 것이다. 그리고 해당 메서드에 동일한 이름을 붙인다고 상상하면, 아마도 그 순간 추상적인 이름을 메서드에 붙일 것이다. DDD에서는 의도를 드러내는 선택자를 인터페이스 레벨로 확장한 “의도를 드러내는 인터페이스”를 제시했다. 한 마디로 요약하면 구현과 관련된 모든 정보를 캡슐화하고 객체의 퍼블릭 인터페이스에는 협력과 관련된 의도만을 표현해야 한다는 것이다. 여기에서는 수행 방법에 관해서는 언급하지 말고 결과와 목적만을 포함하도록 클래스와 오퍼레이션의 이름을 부여하라고 제안한다.

오퍼레이션의 이름은 협력이라는 문맥을 반영해야 한다. 오퍼레이션은 클라이언트가 객체에게 무엇을 원하는 지를 표현해야 한다. 즉, 객체 자신이 아닌 클라이언트의 의도를 표현하는 이름을 가져야 한다.

정리하자면

디미터 법칙은 객체 간의 협력을 설계할 때 캡슐화를 위반하는 메시지가 인터페이스에 포함되지 않도록 제한한다. 묻지 말고 시켜라 원칙은 디미터 법칙을 준수하는 협력을 만들기 위한 스타일을 제시한다. 의도를 드러내는 인터페이스 원칙은 객체의 퍼블릭 인터페이스에 어떤 이름이 드러나야 하는지에 대한 지침을 제공함으로써 코드의 목적을 명확하게 커뮤니케이션할 수 있게 해준다.

6_3 원칙의 함정

원칙이 현재 상황에 부적합하다고 판단된다면 과감히 원칙을 무시하라. 원칙을 아는 것보다 더 중요한 것은 언제 원칙이 유용하고 언제 유용하지 않은지를 판단할 수 있는 능력을 기르는 것이다.

디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아니다

스트림을 보면 디미터 법칙을 위반한 것처럼 보인다. 하지만 아니다. 디미터 법칙은 결합도와 관련된 것이며, 이 결합도가 문제가 되는 것은 객체의 내부 구조가 외부로 노출되는 경우로 한정된다. 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것이다.

결합도와 응집도의 충돌

결합도를 낮추고 응집도를 높이기 위해 위임 메서드를 추가하는 것이 효과적이다. 하지만 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 상관 없는 책임들을 한꺼번에 떠안게 되기 때문에 결과적으로 응집도가 낮아진다. 클래스는 하나의 변경 원인만을 가져야 한다. 서로 상관없는 책임들이 함께 뭉쳐있는 클래스는 응집도가 낮으며 작은 변경으로도 쉽게 무너질 수 있다. 예제를 통해 한 객체의 캡슐화를 향상시키는 것보다 그 객체의 응집도를 높이고 다른 객체와의 결합도를 낮추는 것이 전체적인 관점에서 더 좋은 방법일 수 있다는 걸 알 수 있다.

묻지 말라고 했지만, 물으려는 객체가 정말로 데이터인 경우도 있다. 디미터 법칙의 위반 여부는 묻는 대상이 객체인지, 자료 구조인지에 달려있다. 객체는 내부 구조를 숨겨야 하므로 디미터 법칙을 따르는 것이 좋지만 자료구조라면 당연히 내부를 노출해야 하므로 디미터 법칙을 적용할 필요가 없다. 객체에게 시키는 것이 항상 가능한 것은 아니라. 가끔씩은 물어야 한다.

6_4 명령-쿼리 분리 원칙

어떤 절차를 묶어 호출 가능하도록 이름을 부여한 기능 모듈을 루틴이라고 부른다. 루틴은 다시 프로시저와 함수로 구분할 수 있다. 프로시저는 정해진 절차에 따라 내부의 상태를 변경하는 루틴의 한 종류다. 반면 함수는 어떤 절차에 따라 필요한 값을 계산해서 반환하는 루틴의 한 종류다. 프로시저는 부수효과를 발생시킬 수 있지만 값을 반환할 수 없다. 함수는 값을 반환할 수 있지만 부수효과를 발생시킬 수 없다. 명령과 쿼리는 객체의 인터페이스 측면에서 프로시저와 함수를 부르는 또 다른 이름이다. 객체의 상태를 수정하는 오퍼레이션을 명령이라고 부르고 객체와 관련된 정보를 반환하는 오퍼레이션을 쿼리라고 부른다. 명령-쿼리 분리 원칙의 요지는 오퍼레이션은 부수효과를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야 한다는 것이다. 어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안된다. 따라서, 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다. 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다. 명령은 상태를 변경할 수 있지만 상태를 반환해서는 안 된다. 쿼리는 객체의 상태를 반환할 수 있지만 상태를 변경해서는 안 된다.

질문

객체의 상태를 변경하는 명령은 반환값을 가질 수 없다 - 그럼 명령에서는 다 리턴 타입이 void여야 한다는 것인가?

명령-쿼리 분리와 참조 투명성

명령과 쿼리를 분리함으로써 명령형 언어의 틀 안에서 참조 투명성의 장점을 제한적이나마 누릴 수 있게 된다. 참조 투명성이란 “어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성”을 의미한다. 즉, 값이 변하지 않는다는 것이다. 이처럼 어떤 값이 변하지 않는 성질을 불변성이라고 부른다. 어떤 값이 불변한다는 말은 부수 효과가 발생하지 않는다는 말과 동일하다. 수학에서의 함수는 어떤 값도 변경하지 않기 때문에 부수효과가 존재하지 않는다. 그리고 부수효과가 없는 불변의 세상에서는 모든 로직이 참조 투명성을 만족시킨다. 참조 투명성을 만족화면, 첫째로 모든 함수를 이미 알고 있는 하나의 결괏값으로 대체할 수 있기 때문에 식을 쉽게 계산할 수 있다. 둘째로 모든 곳에서의 함수의 결괏값이 동일하기 때문에 식의 순서를 변경하더라도 각 식의 결과는 달라지지 않는다. 객체지향 패러다임이 객체의 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다. 하지만 명령-쿼리 분리 원칙을 지키면 부수효과를 가지는 명령으로부터 부수효과를 가지지 않는 쿼리를 명백하게 분리함으로써 제한적이나마 참조 투명성의 혜택을 누릴 수 있다.

명령형 프로그래밍과 함수형 프로그래밍

명령형 프로그래밍은 상태를 변경시키는 연산들을 적절한 순서대로 나열함으로써 프로그램을 작성한다. 대부분의 객체지향 프로그래밍 언어들은 메시지에 의한 객체의 상태 변경에 집중하기 때문에 명령형 프로그래밍 언어로 분류된다. 함수형 프로그래밍은 부수효과가 존재하지 않는 수학적인 함수에 기반한다. 따라서 함수형 프로그래밍에서는 참조 투명성의 장점을 극대화할 수 있으며 명령형 프로그래밍에 비해 프로그래밍의 실행 결과를 이해하고 예측하기 더 쉽다.

책임에 초점을 맞춰라

메시지를 먼저 선택하면 다양한 원칙을 준수할 수 있게 된다. 메시지를 먼저 선택하는 것은 다양한 원칙에 다음과 같은 긍정적인 영향을 끼친다.

  1. 디미터 법칙: 메시지를 먼저 결정하면 두 객체 사이의 구조적인 결합도를 낮출 수 있다. 수신할 객체를 알지 못한 상태에서 메시지를 선택하기 때문에 객체의 내부 구조에 대해 고민핳 필요가 없어진다.
  2. 묻지 말고 시켜라: 클라이언트의 관점에서 메시지를 선택하기 때문에 필요한 정보를 물을 필요 없이 원하는 것을 표현한 메시지를 전송하면 된다.
  3. 의도를 드러내는 인터페이스: 메시지를 먼저 선택하는 것은 클라이언트의 관점에서 메시지 이름을 정하는 것이기 때문에, 당연히 그 이름에 클라이언트가 무엇을 원하는 지 그 의도가 분명하게 드러날 수밖에 없다.
  4. 명령-쿼리 분리 원칙: 메시지를 먼저 선택하면 객체가 단순히 어떤 일을 해야 하는지뿐만 아니라 협력 속에서 객체의 상태를 예측하고 이해하기 쉽게 만들기 위한 방법에 관해 고민하게 된다. 따라서 예측 가능한 협력을 만들기 위해 명령과 쿼리를 분리하게 될 것이다.

계약에 의한 설계

지금까지 살펴본 원칙들은 구현과 부수효과를 캡슐화하고, 높은 응집도와 낮은 결합도를 가진 인터페이스를 만들 수 있는 지침을 제공하지만 실제로 실행 시점에 필요한 구체적인 제약이나 조건을 명확하게 표현하지는 못한다. 협력을 위해 두 객체가 보장해야 하는 실행 시점의 제약을 인터페이스에 명시할 수 있는 방법이 존재하지 않는다. 이런 문제를 해결하기 위해 계약에 의한 설계라는 개념이 있다. 계약에 의한 설계는 협력을 위해 클라이언트와 서버가 준수해야 하는 제약을 코드 상에 명시적으로 표현하고 강제할 수 있는 방법이다.

7_객체 분해

불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업을 추상화라고 부른다. 큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해라고 부른다.

7_1 프로시저 추상화와 데이터 추상화

추상화 메커니즘을 크게 프로시저 추상화와 데이터 추상화로 나눌 수 있다. 프로시저 추상화는 소프트웨어가 무엇을 해야 하는지를 추상화한다. 데이터 추상화는 소프트웨어가 무엇을 알아야 하는지를 추상화한다. 소프트웨어는 데이터를 이용해 정보를 표현하고 프로시저를 이용해 데이터를 조작한다. 프로그래밍 패러다임은 적절한 추상화의 윤곽을 따라 시스템을 어떤 식으로 나눌 것인지를 결정하는 원칙과 방법의 집합이기 때문에 추상화 메커니즘에 따라 시스템의 분해 방법을 설명한다.

프로시저 추상화를 중심으로 시스템을 분해하기로 결정했다면 기능분해의 길로 들어서는 것이다. 데이터 추상화를 중심으로 시스템을 분해하기로 결정했다면 다시 두 가지 중 하나를 선택해야 한다. 하나는 데이터를 중심으로 타입을 추상화하는 것이고 다른 하나는 데이터를 중심으로 프로시저를 추상화하는 것이다. 전자를 추상 데이터 타입, 후자를 객체지향이라고 부른다.

“역할과 책임을 수행하는 객체”가 바로 객체지향 패러다임이 이용하는 추상화다. 기능을 “협력하는 공동체”를 구성하도록 객체들로 나누는 과정이 바로 객체지향 패러다임에서의 분해를 의미한다. 프로그래밍 언어의 관점에서 객체지향이란 데이터를 중심으로 데이터 추상화와 프로시저 추상화를 통합한 객체를 이용해 시스템을 분해하는 방법이다. 같은 말로, 프로그래밍 언어적인 관점에서 객체지향을 바라보는 일반적인 관점은 데이터 추상화와 프로시저 추상화를 함께 포함한 클래스를 이용해 시스템을 분해하는 것이다.

7_2 프로시저 추상화와 기능 분해

메인 함수로서의 시스템

프로시저는 반복적으로 실행되거나 거의 유사하게 실행되는 작업들을 하나의 장소에 모아놓음으로써 로직을 재사용하고 중복을 방지할 수 있는 추상화 방법이다. 프로시저를 추상화라고 부르는 이유는 내부의 상세한 구현 내용을 모르더라도 인터페이스만 알면 프로시저를 사용할 수 있기 때문이다.

프로시저 중심의 기능 분해 관점에서 시스템은 입력 값을 계산해서 출력 값을 반환하는 수학의 함수와 동일하다. 시스템은 필요한 더 작은 작업으로 분해될 수 있는 하나의 커다란 메인함수다.

전통적으로 하향식 접근법을 따르는데, 하향식 접근법이란 시스템을 구성하는 가장 최상위 기능을 정의하고, 이 최상위 기능을 좀 더 작은 단계의 하위 기능으로 분해해 나가는 방법을 말한다. 여기에서 기능 분해라는 무대의 주연은 기능이며, 데이터는 기능을 보조하는 조연의 역할에 머무른다. 하향식 기능 분해는 논리적이고 체계적인 시스템 개발 절차를 제시한다. 문제는 우리가 사는 세계가 그렇게 체계적이지도, 이상적이지도 않다는 점이다. 체계적이고 이상적인 방법이 불규칙하고 불완전한 인간과 만나는 지점에서 혼란이 발생한다.

하향식 기능 분해의 문제점

  1. 시스템은 하나의 메인 함수로 구성돼 있지 않다.
  2. 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다.
  3. 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다.
  4. 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다.
  5. 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다.

1. 하나의 메인 함수라는 비현실적인 아이디어

시스템은 시간이 자나면서 새로운 기능을 추가하게 된다. 이것은 시스템이 오직 하나의 메인함수만으로 구현된다는 개념과 모순된다. 대부분의 경우 추가되는 기능은 최초에 배포된 메인함수의 일부가 아닐 것이다. 결국 처음에는 가장 중요하게 생각됐던 메인 함수는 동등하게 중요한 여러 함수들 중 하나로 전락하고 만다. 모든 기능들은 규모라는 측면에서 차이가 있을 수는 있겠지만 기능성의 측면에서는 동등하게 독립적이고 완결된 하나의 기능을 표현한다. 현대적인 시스템은 동등한 수준의 다양한 기능으로 구성된다. 실제 시스템에 정상(top)은 없다.

2. 메인 함수의 빈번한 재설계

하향식 기능 분해에서는 새로운 기능을 추가할 때마다 매번 메인 함수를 수정해야 한다. 기존 로직과는 아무 상관이 없는, 개념적으로 동등한 수준의 새로운 함수의 적절한 위치를 확보해야 하기 때문에 메인 함수의 구조를 급격하게 변경할 수밖에 없는 것이다. 즉 새로운 정상을 추가할 때마다 하나의 정상이라고 간주했던 main 함수의 내부 구현을 수정할 수밖에 없다. 결과적으로 기존 코드의 빈번한 수정으로 인한 버그 발생 확률이 높아지기 때문에 시스템은 변경에 취약해질 수밖에 없다.

3. 비즈니스 로직과 사용자 인터페이스의 결합

하향식 접근법은 비즈니스 로직을 설계하는 초기 단계부터 입력 방법과 출력 양식을 함께 고민하도록 강요한다. 비즈니스 로직 관심사와 사용자 인터페이스의 관심사가 한데 섞여 있게되기 때문에 코드 안에서 비즈니스 로직과 사용자 인터페이스 로직이 밀접하게 결합된다.

문제는 비즈니스 로직과 사용자 인터페이스가 변경되는 빈도가 다르다는 것이다. 자주 변경되는 사용자 인터페이스를 변경하는 경우 비즈니스 로직까지 변경에 영향을 받게 된다. 관심사가 섞여있기 때문에 사용자 인터페이스를 변경하는 유일한 방법은 전체 구조를 재설계하는 것뿐이다.

4. 성급하게 결정된 실행 순서

하향식으로 기능을 분해하는 과정은 하나의 함수를 더 작은 함수로 분해하고, 분해된 함수들의 실행 순서를 결정하는 작업으로 요약할 수 있다. 이것은 설계를 시작하는 시점부터 시스템이 무엇을 해야하는지가 아니라 어떻게 동작해야 하는지에 집중하도록 만든다. 하향식 접근법은 처음부터 구현을 염두에 두기 때문에 자연스럽게 함수들의 실행 순서를 정의하는 시간 제약을 강조한다. 메인 함수를 작은 함수들로 분해하기 위해서는 우선 함수들의 순서를 결정해야 한다. 실행 순서나 조건, 반복과 같은 제어 구조를 미리 결정하지 않고는 분해를 진행할 수 없기 때문에 기능 분해 방식은 중앙집중 제어 스타일의 형태를 띨 수밖에 없다. 결과적으로 모든 중요한 제어 흐름의 결정이 상위 함수에서 이뤄지고 하위 함수는 상위 함수의 흐름에 따라 적절한 시점에 호출된다.

문제는 중요한 설계 결정사항인 함수의 제어 구조가 빈번한 변경의 대상이라는 점이다. 기능을 추가하거나 변경하는 작업은 매번 기존에 결정된 함수의 제어구조를 변경하도록 만든다.

이를 해결하는 방법은 자주 변경되는 시간적인 제약에 대한 미련을 버리고 좀 더 안정적인 논리적 제약을 설계의 기준으로 삼는 것이다. 객체지향은 함수 간의 호출 순서가 아니라 객체 사이의 논리적인 관계를 중심으로 설계를 이끌어 나간다. 결과적으로 전체적인 시스템은 어떤 한 구성요소로 제어가 집중되지 않고 여러 객체들 사이로 제어 주체가 분산된다.

하향식 접근법을 통해 분해한 함수들은 재사용하기도 어렵다. 모든 함수가 상위 함수를 분해하는 과정에서 식별되기 때문에, 상위 함수가 강요하는 문맥 안에서만 의미를 가지기 때문이다. 함수가 재사용 가능하려면 상위 함수보다 더 일반적이어야 한다. 하향식 접근법에서 하위 함수는 항상 상위 함수보다 문맥에 더 종속적이다.

하향식 설계와 관련된 모든 문제의 원인은 결합도다. 함수의 상위 함수가 강요하는 문맥 안에서 강하게 결합된다. 함수는 함께 절차를 구성하는 다른 함수들과 시간적으로 강하게 결합돼 있다. 강한 결합도는 시스템을 변경에 취약하게 만들고 이해하기 어렵게 만든다. 강하게 결합된 시스템은 아주 사소한 변경만으로도 전체 시스템을 크게 요동치게 만들 수 있다. 현재 문맥에 강하게 결합된 시스템은 현재 문맥을 떠나 다른 문맥으로 옮겨갔을 때 자사용하기 어렵다. 가장 큰 문제는 전체 시스템의 핵심적인 구조를 결정하는 함수들이 데이터와 강하게 결합된다는 것이다.

5. 데이터 변경으로 인한 파급효과

하향식 기능 분해의 가장 큰 문제점은 어떤 데이터를 어떤 함수가 사용하고 있는지를 추적하기 어렵다는 것이다. 따라서 데이터 변경으로 인해 어떤 함수가 영향을 받을지 예상하기 어렵다. 개별 함수 입장에서 사용하는 데이터를 파악하는 것은 어렵지 않다. 하지만 어떤 데이터가 어떤 함수에 의존하고 있는 지를 파악하는 것은 어렵다. 모든 함수를 열어 데이터를 사용하고 있는지를 모두 확인해봐야 하기 때문이다.

이것은 의존상과 결합도의 문제다. 데이터의 변경으로 인한 영향은 데이터를 직접 참조하는 모든 함수로 퍼져나간다. 전역 데이터를 수정했을 때, 그 수정에 대응하기 위해서는 시스템 안에 구현된 모든 함수를 분석해서 영향도를 파악해야 한다는 것을 의미한다. 전역 변수에 의존하는 함수를 찾는 것은 심하게 얽힌 실타래를 푸는 것처럼 인내와 끈기를 요구하는 작업이다.

예제에서는 새로운 기능을 추가했고, 그에 대응하기 위해 데이터를 추가하고 그 기능과 관련되어 보이는 함수를 수정했다. 하지만 관련이 없어 보이는 함수가 영향 받는다는 사실을 알지 못했기 때문에 버그가 발생했다. 버그는 예상하기 어려웠는데, 버그 발생 함수는 에러도 발생하지 않았고 값을 정상적으로 반환했기 때문이다. 문제는 그 값이 잘못된 값이라는 것이다. 코드가 성장하고 라인 수가 증가할수록 전역 데이터를 변경하는 것은 악몽으로 변해간다.

데이터 변경으로 인한 영향을 최소화하려면 데이터와 함께 변경되는 부분과 그렇지 않은 부분을 명확하게 분리해야 한다. 잘 정의된 퍼블릭 인터페이스를 통해 데이터에 대한 접근을 통제해야 한다. 변경에 대한 영향을 최소화하기 위해 영향을 받는 부분과 받지 않는 부분을 명확하게 분리하고 잘 정의된 퍼블릭 인터페이스를 통해 변경되는 부분에 대한 접근을 통제하는 것이 의존성 관리의 핵심이다.

언제 하향식 분해가 유용한가?

하향식 아이디어가 매력적인 이유는 설계가 어느 정도 안정화된 후에는 설계의 다양한 측면을 논리적으로 설명하고 문서화하기에 용이하기 때문이다. 하향식은 이미 완전히 이해된 사실을 서술하기에 적합하다.

정리

하향식 방식으로 설계된 소프트웨어는 하나의 함수에 제어가 집중되기 때문에 확장이 어렵다. 하향식 분해는 프로젝트 초기에 설계의 본질적인 측면을 무시하고 사용자 인터페이스 같은 비본질적인 측면에 집중하게 만든다. 과도하게 함수에 집중하게 함으로써 소프트웨어의 중요한 다른 측면인 데이터에 대한 영향도를 파악하기 어렵게 만든다. 또한 하향식 분해를 적용한 설계는 근본적으로 재사용하기 어렵다.

7_3 모듈

정보 은닉과 모듈

정보 은닉은 시스템을 모듈 단위로 분해하기 위한 기본 원리로 시스템에서 자주 변경되는 부분을 상대적으로 덜 변경되는 안정적인 인터페이스 뒤로 감춰야 한다는 것이 핵심이다. 정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리다. 모듈은 변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 한다.

모듈과 기능 분해는 상호 배타적인 관계가 아니다. 시스템을 모듈로 분해한 후에는 각 모듈 내부를 구현하기 위해 기능 분해를 적용할 수 있다. 기능 분해가 하나의 기능을 구현하기 위해 필요한 기능들을 순차적으로 찾아가는 탐색의 과정이라면 모듈 분해는 감춰야 하는 비밀을 선택하고 비밀 주변에 안정적인 보호막을 설치하는 보존의 과정이다. 모듈은 다음 두 가지 비밀을 감춰야 한다.

  1. 복잡성: 외부에 모듈을 추상화할 수 있는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.
  2. 변경 가능성: 변경 발생 시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다.

시스템의 가장 일반적인 비밀이 데이터이지만, 비밀이 반드시 데이터일 필요는 없고 복잡한 로직이나 변경 가능성이 큰 자료 구조일 수도 있다.

모듈의 장점

  1. 모듈 내부의 변수가 변경되더라도 모듈 내부에만 영향을 미친다. 어떤 데이터가 변경됐을 때 영향을 받는 함수를 찾기 위해 해당 데이터를 정의한 모듈만 검색하면 된다.
  2. 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다. 사용자 인터페이스가 변경되더라도 비즈니스 로직은 영향을 받지 않고 변경되지 않는다.
  3. 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다. 모듈의 한 가지 용도는 네임스페이스를 제공하는 것이다. 변수와 함수를 모듈 내부에 포함시키기 때문에 다른 모듈에서도 동일한 이름을 사용할 수 있게 된다.

각 모듈은 외부에 감춰야 하는 비밀과 관련성 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지한다. 모듈과 모듈 사이에는 퍼블릭 인터페이스를 통해서만 통신해야 한다. 따라서 낮은 결합도를 유지한다. 모듈은 정보 은닉이라는 개념을 통해 데이터라는 존재를 설계의 중심 요소로 부각시켰다. 모듈에 있어서 핵심은 데이터다. 즉 기능이 아니라 데이터를 중심으로 시스템을 분해한다.

모듈의 한계

모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 것이다. 이를 개선하기 위해 등장한 개념이 추상화 데이터 타입이다.

7_4 데이터 추상화와 추상 데이터 타입

추상 데이터 타입

프로그래밍 언어에서 타입이란 변수에 저장할 수 있는 내용물의 종류와 변수에 적용될 수 있는 연산의 가짓수를 의미한다. 리스코프는 프로시저 추상화를 보완하기 위해 데이터 추상화의 개념을 제안했다. 추상 데이터 타입은 추상 객체의 클래스를 정의한 것으로 추상 객체에 사용할 수 있는 오퍼레이션을 이용해 규정된다. 이것은 오퍼레이션을 이용해 추상 데이터 타입을 정의할 수 있음을 의미한다. 추상 데이터 객체를 사용할 때 프로그래머는 오직 객체가 외부에 제공하는 행위에만 관심을 가지며 행위가 구현되는 세부적인 사항에 대해서는 무시한다. 추상 데이터 타입은 사람들이 세상을 바라보는 방식에 좀 더 근접해지도록 추상화 수준을 향상시킨다.

비록 추상 데이터 타입 정의를 기반으로 객체를 생성하는 것은 가능하지만 여전히 데이터와 기능을 분리해서 바라본다는 점에 주의하라. 추상 데이터 타입은 말 그대로 시스템의 상태를 저장할 데이터를 표현하고, 기능을 구현하는 핵심 로직은 추상 데이터 타입 외부에 존재한다. 추상 데이터 타입은 데이터에 대한 관점을 설계의 표면으로 끌어올리기는 하지만 여전히 데이터와 기능을 분리하는 절차적인 설계의 틀에 갇혀 있다.

추상 데이터 타입의 기본 의도는 프로그래밍 언어가 제공하는 타입처럼 동작하는 사용자 정의 타입을 추가할 수 있게 하는 것이다. 프로그래밍 언어의 관점에서 추상 데이터 타입은 프로그래밍 언어의 내장 데이터 타입과 동일하다. 단지 타입을 개발자가 정의할 수 있다는 점이 다를 뿐이다.

추상 데이터 타입을 구현하기 위한 프로그래밍 언어의 특성

  1. 타입 정의를 선언할 수 있어야 한다.
  2. 타입의 인스턴스를 다루기 위해 사용할 수 있는 오퍼레이션의 집합을 정의할 수 있어야 한다.
  3. 제공된 오퍼레이션을 통해서만 조작할 수 있도록 데이터를 외부로부터 보호할 수 있어야 한다.
  4. 타입에 대해 여러 개의 인스턴스를 생성할 수 있어야 한다.

추상 데이터 타입을 구현할 수 있는 언어적 장치를 제공하지 않는 프로그래밍 언어에서도 추상 데이터 타입을 구현하는 것은 가능하다. 하지만 언어 차원에서 추상 데이터 타입을 지원하는 것과 관슴과 약속, 기법을 통해 추상 데이터 타입을 모방하는 것은 완전히 다른 얘기다.

7_5 클래스

추상 데이터 타입과 클래스는 동일하지 않다. 가장 핵심적인 차이는 클래스는 상속과 다형성을 지원하는 데 비해 추상 데이터 타입은 지원하지 못한다는 점이다. 추상 데이터 타입은 타입을 추상화한 것이고 클래스는 절차를 추상화한 것이다.

타입 추상화는 하나의 대표적인 타입이 다수의 세부적인 타입을 감추는 것이다. 개별 오퍼레이션이 모든 개념적인 타입에 대한 구현을 포괄하도록 함으로써 하나의 물리적인 타입 안에 전체 타입을 감춘다. 즉 타입추상화는 오퍼레이션을 기준으로 타입을 통합하는 데이터 추상화 기법이다.

반면 객체지향은 타입을 기준으로 오퍼레이션을 묶는다. 클래스가 활용할 수 있는 다형성은 실제로 내부에서 수행되는 절차는 다르지만 절차에 대한 차이점을 감춘다. 즉 객체지향은 절차 추상화다.

추상 데이터 타입은 오퍼레이션을 기준으로 타입들을 추상화한다. 클래스는 타입을 기준으로 절차들을 추상화한다.

변경을 기준으로 선택하라

클래스를 사용했다고 하더라도, 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다. 클래스가 추상 데이터 타입의 개념을 따르는지를 확인할 수 있는 가장 간단한 방법은 클래스 내부에 인스턴스의 타입을 표현하는 변수가 있는지를 살펴보는 것이다. 인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반하는 것으로 간주한다.

객체지향에서는 타입 변수를 이용한 조건문을 다형성으로 대체한다. 클라이언트가 객체의 타입을 확인한 후 적절한 메서드를 호출하는 것이 아니라 객체가 메시지를 처리할 적절한 메서드를 선택한다. 기능 추가를 위해 클래스를 추가할 때, 그 클래스의 메서드를 실행하기 위한 어떤 코드도 추가할 필요가 없다. 이것은 시스템에 새로운 로직을 추가하기 위해 클라이언트 코드를 수정할 필요가 없다는 것을 의미한다. 기존 코드에 아무런 영향을 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방 폐쇄 원칙이라고 부른다.

추상 데이터 타입 vs 객체지향

항상 객체지향이 추상 데이터보다 나은 것은 아니다. 설계의 요융성은 변경의 방향성과 발생 빈도에 따라 결정된다. 추상 데이터 타입과 객체지향 설계의 유용성은 설계에 요구되는 변경의 압력이 “타입 추가”에 관한 것인지, “오퍼레이션 추가”에 관한 것인지에 따라 달라진다. 타입 추가라는 변경의 압력이 더 강한 경우에는 객체지향의 손을 들어줘야 한다. 반면 주된 압력이 오퍼레이션을 추가하는 것이라면 추상 데이터 타입의 승리를 선언해야 한다. 즉, 새로운 타입을 빈번하게 추가해야 한다면 객체지향의 클래스 구조가 더 유용하다. 새로운 오퍼레이션을 빈번하게 추가해야 한다면 추상 데이터 타입을 선택하는 것이 현명하다.

데이터 주도 설계

추상 데이터 타입의 접근법을 객체지향 설계에 구현한 것을 데이터 주도 설계라고 부른다. 책임 주도 설계는 데이터 주도를 개선하고자 한다. 모듈과 추상 데이터 타입이 데이터 중심적인 관점을 취하는 데 비해, 객체지향은 서비스 중심적인 관점을 취한다.

협력이 중요하다

클래스에 오퍼레이션의 구현 방법을 분배한다고 해서 객체지향적인 애플리케이션을 설계하는 것은 아니다. 협력이라는 문맥을 고려하지 않고 객체를 고립시킨 채 오퍼레이션의 구현 방식을 타입별로 분배하는 것은 올바른 접근법이 아니다.

객체가 참여할 협력을 결졍하고 협력에 필요한 책임을 수행하기 위해 어떤 객체가 필요한지에 관해 고민하라. 그 책임을 다양한 방식으로 수행해야 할 때만 타입 계층 안에 각 절차를 추상화하라. 타입 계층과 다형성은 협력이라는 문맥 안에서 책임을 수행하는 방법에 관해 고민한 결과물이어야 하며 그 자체가 목적이 되어서는 안 된다.

8_의존성 관리

협력은 필수적이지만 과도한 협력은 설계를 곤경에 빠트릴 수 있다. 협력은 객체가 다른 객체에 대해 알 것을 강요한다. 다른 객체와 협력하기 위해서는 그런 객체가 존재한다는 사실을 알고 있어야 한다. 객체가 수신할 수 있는 메시지에 대해서도 알고 있어야 한다. 이러한 지식이 객체 사이의 의존성을 낳는다.

협력을 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다. 객체지향 설계의 핵싱믄 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 정리하는 기술이라고 할 수 있다.

8_1 의존성 이해하기

변경과 의존성

  • 실행 시점에서 의존성의 의미: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
  • 구현 시점에서 의존성의 의미: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경된다.

어떤 객체가 예정된 작업을 정상적으로 수행하기 위해 다른 객체를 필요로 하는 경우 두 객체 사이에 의존성이 존재한다고 말한다. 두 요소 사이의 의존성은 의존되는 요소가 변경될 때 의존하는 요소도 함께 변경될 수 있다는 것을 의미한다. 즉 의존성은 변경에 의한 영향의 전파 가능성을 암시한다.

의존성 전이

의존성 전이가 의미하는 것은 A 객체가 B 객체에 의존할 때, B 객체가 의존하는 C 객체에도 A객체가 자동적으로 의존하게 된다는 것이다. 의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다. 즉 의존성 전이는 변경에 의해 영향이 널리 전파될 수도 있다는 경고일 뿐이다.

직접 의존성은 한 요소가 다른 요소에 직접 의존하는 경우를 가리킨다. 간접 의존성이란 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우를 가리킨다.

런타임 의존성과 컴파일타임 의존성

런타임 의존성은 애플리케이션이 실행되는 시점의 의존성을 가리킨다. 런타임의 주인공은 객체이고, 따라서 런타임 의존성이 다루는 주제는 객체 사이의 의존성이다.

컴파일타임은 작성된 코드를 컴파일하는 시점을 가리키지만 문맥에 따라서는 코드 그 자체를 가리키기도 한다. 컴파일타임 의존성 이 이 경우에 해당하는데, 컴파일타임 의존성이 중요하게 생각하는 것이 시간이 아니라 우리가 작성한 코드의 구조이기 때문이다. 코드 관점에서 주인공은 클래스고, 따라서 컴파일타임 의존성이 다루는 주제는 클래스 사이의 의존성이다.

코드 작성시점에서는 구체적인 의존성 구현 클래스의 존재는 모르지만, 실행 시점에서는 객체 간 협력한다. 유연하고 재사용 가능한 설계를 창조하기 위해서는 동일한 소스코드 구조를 가지고 다양한 실행 구조를 만들 수 있어야 한다. 어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다. 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 한다. 클래스가 협력할 객체의 클래스를 명시적으로 드러내고 있다면 다른 클래스의 인스턴스와 협력할 가능성 자체가 없어진다. 따라서 컴파일타임 구조와 런타임 구조 사이의 거리가 멀면 멀수록 설계가 유연해지고 재사용 가능해진다.

컨텍스트 독립성

클래스는 자신과 협력할 객체의 구체적인 클래스에 대해 알아서는 안 된다. 구체적인 클래스를 알면 알수록 그 클래스가 사용되는 특정한 문맥에 강하게 결합되기 때문이다.

클래스가 특정한 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다. 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다.

설계가 유연해지기 위해서는 가능한 한 자신이 실행될 컨텍스트에 대한 구체적인 정보를 최대한 적게 알아야 한다. 컨텍스트에 대한 정보가 적으면 적을수록 더 다양한 컨텍스트에서 재사용될 수 있기 때문이다.

시스템을 구성하는 객체가 컨텍스트 독립적이라면 해당 시스템은 변경하기 쉽다. 컨텍스트 독립적이라는 말은 각 객체가 해당 객체를 실행하는 시스템에 관해 아무것도 알지 못한다는 의미다. 컨텍스트 독립성을 따르면 다양한 컨텍스트에 적용할 수 있는 응집력 있는 객체를 만들 수 있고 객체 구성 방법을 재설정해서 변경 가능한 시스템으로 나아갈 수 있다.

의존성 해결하는 방법

컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임 의존성으로 교체하는 것을 의존성 해결이라고 부른다.

  1. 객체를 생성하는 시점에 생성자를 통해 의존성 해결
  2. 객체 생성 후 setter 메서드를 통해 의존성 해결: 객체를 생성한 이후에도 의존하고 있는 대상을 변경할 수 있는 가능성을 열어 놓고 싶은 경우에 유용하다. 단점은 객체가 생성된 후에 협력에 필요한 의존 대상을 설정하기 때문에 객체를 생성하고 의존 대상을 설정하기 전까지는 객체의 상태가 불완전할 수 있다는 점이다.
  3. 메서드 실행 시 인자를 이용해 의존성 해결: 메서드 인자를 사용하는 방식은 협력 대상에 대해 지속적으로 의존 관계를 맺을 필요 없이 메서드가 실행되는 동안만 일시적으로 의존 관계가 존재해도 무방하거나, 메서드가 실행될 때마다 의존 대상이 매번 달라져야 하는 경우에 유용하다. 하지만 클래스의 메서드를 호출하는 대부분의 경우에 매번 동일한 객체를 인자로 전달하고 있다면 생성자를 이용하는 방식이나 setter 메서드를 이용해 의존성을 지속적으로 유지하는 방식으로 변경하는 것이 좋다.

생성자 방식과 setter 방식을 혼합할 수도 있다. 객체를 생성할 때 의존성을 해결해서 완전한 상태의 객체를 생성한 후, 필요에 따라 setter 메서드를 이용해 의존 대상을 변경할 수 있게 할 수 있다. 이 방법은 시스템의 상태를 안정적으로 유지하면서도 유연성을 향상시킬 수 있기 때문에 의존성 해결을 위해 가장 선호되는 방법이다.

8_2 유연한 설계

의존성과 결합도

의존성이 객체 사이의 협력을 가능하게 만들기 때문에 존재 자체는 바람직한 것이다. 문제는 의존성의 존재가 아니라 의존성의 정도다. 클라이언트 입장에서, 자신이 전송하는 메시지를 이해할 수 있고 할인된 요금을 계산할 수만 있다면 서비스 클래스의 타입에 상관 없이 협력할 수 있다.

바람직한 의존성은 재사용성과 관련이 있다. 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다. 어떤 의존성이 다양한 환경에서 재사용할 수 있다면 그 의존성은 바람직한 것이다. 다시 말해 컨텍스트에 독립적인 의존성은 바람직한 의존성이고 특정한 컨텍스트에 강하게 결합된 의존성은 바람직하지 않은 의존성이다. 바람직한 의존성은 설계를 재사용하기 쉽게 만들고, 바람직하지 못한 의존성은 설계를 재사용하기 어렵게 만든다.

특정한 컨텍스트에 강하게 의존하는 클래스를 다른 컨텍스트에서 재사용할 수 있는 유일한 방법은 구현을 변경하는 것뿐이다. 이것은 바람직하지 못한 의존성을 바람직하지 못한 또 다른 의존성으로 대체하는 것 뿐이다. 다른 환경에서 재사용하기 위해 내부 구현을 변경하게 만드는 모든 의존성은 바람직하지 않은 의존성이다.

의존성은 결합도와 관련이 깊은데, 어떤 두 요소 사이에 존재하는 의존성이 바람직할 때 두 요소가 느슨한 결합도를 가진다고 한다. 반대로 두 요소 사이의 의존성이 바람직하지 못할 때 단단한 결합도를 가진다고 말한다. 물론 의존성과 결합도가 동의어인 것은 아니다. 의존성은 두 요소 사이의 관계 유무를 설명한다. 그에 반해 결합도는 두 요소 사이에 존재하는 의존성의 정도를 상대적으로 표현한다.

지식이 결합을 낳는다

결합도의 정도는 한 요소가 자신이 의존하고 있는 다른 요소에 대해 알고 있는 정보(지식)의 양으로 결정된다. 더 많이 알고 있다는 것은 더 적은 컨텍스트에서 재사용 가능하다는 것을 의미한다. 결합도를 느슨하게 만들기 위해서는 협력하는 대상에 대해 필요한 정보 외에는 최대한 감추는 것이 중요하다. 이 목적을 달성할 수 있는 가장 효과적인 방법은 추상화다.

추상화에 의존하라

추상화란 어떤 양상, 세부사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복하는 방법이다. 추상화를 통해 대상에 대해 알아야 하는 지식의 양을 줄일 수 있고 때문에 결합도를 느슨하게 유지할 수 있다. 의존하는 대상이 더 추상적일수록 결합도는 더 낮아진다. 인터페이스 의존성, 추상 클래스 의존성, 구체 클래스 의존성 순서로 더 추상적이다.

명시적인 의존성

결합도를 느슨하게 만들기 위해서는 인스턴스 변수의 타입을 추상클래스나 인터페이스로 선언하는 것만으로는 부족하다. 클래스 안에서 구체 클래스에 대한 모든 의존성을 제거해야만 한다. 즉, 의존성을 해결해야 한다. 예를 들어, 생성자 안에서 의존성을 설정해준다면 결합이 느슨하다고 할 수 없다. 느슨한 결합도를 위해서는 모든 경우에 의존성을 명시적으로 퍼블릭 인터페이스에 노출해야 하고, 이를 명시적인 의존성이라고 부른다. 반대로 생성자에서 의존성을 설정한다면 객체가 다른 객체에 의존하고 있다고 보아 숨겨진 의존성이라고 부른다.

의존성이 명시적이지 않으면 의존성을 파악하기 위해 내부 구현을 직접 살펴볼 수밖에 없다. 또한 클래스를 다른 컨텍스트에서 재사용하기 위해 내부 구현을 직접 변경해야 한다.

유연하고 재사용 가능한 설계란 퍼블릭 인터페이스를 통해 의존성이 명시적으로 드러나는 설계다. 명시적인 의존성을 사용해야만 퍼블릭 인터페이스를 통해 컴파일타임 의존성을 적절한 런타임 의존성으로 교체할 수 있다. 경계해야 할 것은 의존성 자체가 아니라 의존성을 감추는 것이다.

new가 해로운 이유

new를 잘못 사용하면 클래스 사이의 결합도가 극단적으로 높아지기 때문에 해롭다.

  1. new 연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야 한다. 따라서 new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수밖에 없기 때문에 결합도가 높아진다.
  2. new 연산자는 생성하려는 구체 클래스뿐만 아니라 어떤 인자를 이용해 클래스의 생서자를 호출해야 하는지도 알아야 한다. 따라서 new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.

new는 클래스를 구체 클래스에 결합시키는 것만으로 끝나지 않는다. 협력할 클래스의 인스턴스를 생성하기 위해 어떤 인자들이 필요하고 그 인자들을 어떤 순서로 사용해야 하는지에 대한 정보도 노출시킬뿐만 아니라 인자로 사용되는 구체 클래스에 대한 의존성을 추가한다.

해결 방법은 인스턴스를 생성하는 로직과 생성된 인스턴스를 사용하는 로직을 분리하는 것이다. 이를 위해서는 외부에서 인스턴스를 전달받아야 하는데, 이것은 앞에서 살펴본 의존성 해결 방법과 동일하다. 결국, 협력을 위해 new 없이 메시지를 전송하는 코드만 남아있어야 한다. 사용과 생성의 책임을 분리하고, 의존성을 생성자에 명시적으로 드러내고, 구체 클래스가 아닌 추상 클래스에 의존하게 함으로써 설계를 유연하게 만들 수 있다.

가끔은 생성해도 무방하다

클래스 안에서 객체의 인스턴스를 직접 생성하는 방식이 유용한 경우도 있다. 주로 협력하는 기본 객체를 설정하고 싶은 경우가 여기에 속한다. 이 때 결합도를 낮추는 대신, 사용성을 늘릴 수 있다. 하지만 대부분의 경우 구체 클래스에 대한 의존성을 제거하는 것이 좋다. 종종 모든 결합도가 모이는 새로운 클래스를 추가함으로써 사용성과 유연성이라는 두 마리 토끼를 잡을 수 있기 때문이다.

표준 클래스에 대한 의존은 해롭지 않다

의존성이 불편한 이유는 그것이 항상 변경에 대한 영향을 암시하기 때문이다. 따라서 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다. JDK에 포함된 표준 클래스가 이 부류에 속한다.

비록 클래스를 직접 생성하더라도 가능한 한 추상적인 타입을 사용하는 것이 확장성 측면에서 유리하다. 예를 들어 new ArrayList()를 하더라도 인스턴스 타입을 List로 선언하는 것이다. 즉 의존성에 의한 영향이 적은 경우에도 추상화에 의존하고 의존성을 명시적으로 드러내는 것은 좋은 설계 습관이다.

조합 가능한 행동

어떤 객체와 협력하느냐에 따라 객체의 행동이 달라지는 것은 유연하고 재사용 가능한 설계가 가진 특징이다. 작은 객체들의 행동을 조합함으로써 새로운 행동을 이끌어낼 수 있기 때문이다. 덕분에 컨텍스트를 쉽게 확장할 수 있다. 시스템의 행위는 객체의 조합(객체의 선택과 연결 방식)을 통해 나타나는 특성이다. 따라서 시스템에 포함된 객체의 구성을 변경해(절차적인 코드를 작성하기보다는 인스턴스 추가나 제거 또는 조합을 달리해서) 시스템의 작동 방식을 바꿀 수 있다.

9_유연한 설계

9_1 개방 폐쇄 원칙

개방 폐쇄 원칙은 소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다는 것이다. ‘확장’과 ‘수정’은 각각 ‘동작’과 ‘코드’의 관점을 반영한다. 개방 폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다.

  • 확장에 대해 열려 있다: 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 ‘동작’을 추가해서 애플리케이션의 기능을 확장할 수 있다.
  • 수정에 대해 닫혀 있다: 기존의 ‘코드’를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.

컴파일타임 의존성을 고정시키고 런타임 의존성을 변경하라

예제에서는 기존 클래스를 수정하지 않은 채 애플리케이션의 동작을 확장했다. 예제는 새로운 할인 정책을 추가해서 기능을 확장할 수 있도록 허용한다. 따라서 ‘확장에 대해서는 열려 있다’. 예제는 기존 코드를 수정할 필요 없이 새로운 클래스를 추가하는 것만으로 새로운 할인 정책을 확장할 수 있다. 따라서 ‘수정에 대해서는 닫혀 있다’. 의존성 관점에서 개방 폐쇄 원칙을 따르는 설계란 컴파일타임 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조라고 할 수 있다.

추상화가 핵심이다

계방 폐쇄 원칙의 핵심은 추상화에 의존하는 것이다. 개방 폐쇄 원칙의 관점에서 생략되지 않고 남겨지는 부분은 다양한 상황에서의 공통점을 반영한 추상화의 결과물이다. 공통적인 부분은 문맥이 바뀌더라도 변하지 않아야 한다. 다시 말해서 수정할 필요가 없어야 한다. 따라서 추상화 부부는 수정에 대해 닫혀 있다. 추상화를 통해 생략된 부분은 확장의 여지를 남긴다. 이것이 추상화가 개방 폐쇄 원칙을 가능하게 만드는 이유다.

단순히 어떤 개념을 추상화했다고 해서 수정에 대해 닫혀 있는 설계를 만들 수 있는 것은 아니다. 개방 폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다. 올바른 추상화를 설계하고 추상화에 대해서만 의존하도록 관계를 제한함으로써 설계를 유연하게 확장할 수 있다. 주의할 점은 추상화를 했다고 해서 모든 수정에 대해 설계가 폐쇄되는 것은 아니라는 것이다. 변경에 의한 파급효과를 최대한 피하기 위해서는 변하는 것과 변하지 않는 것이 무엇인지를 이해하고 이를 추상화의 목적으로 삼아야만 한다.

9_2 생성 사용 분리

객체 생성을 피할 수는 없다. 객체를 생성하는 것 자체가 문제가 아니라, 부적절한 곳에서 객체를 생성한다는 것이 문제다. 메시지를 전송하지 않고 객체를 생성하기만 한다면 아무런 문제가 없을 것이다. 또는 객체를 생성하지 않고 메시지를 전송하기만 한다면 괜찮을 것이다. 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 공존하는 것이 문제다. 이를 해결하기 위해 생성과 사용을 분리해야 한다. 생성과 사용을 분리한다는 것은 소프트웨어 시스템은 응용 프로그램 객체를 제작하고 의존성을 서로 “연결”하는 시작 단계와 시작 단계 이후에 이어지는 실행 단계를 분리한다는 것이다.

Factory 추가하기

예제에서 생성 책임을 클라이언트로 옮긴 배경엔 Movie는 특정 컨텍스트에 묶여서는 안 되지만 클라이언트는 묶여도 상관이 없다는 전제가 깔려 있다. 하지만 이렇게 되면 클라이언트 역시 생성과 책임을 함께 지니게 된다. 클라이언트의 클라이언트에게 옮길 것인가? 그러면 캡슐화가 깨질 수 있다. 아예 생성과 관련된 책임만 전담하는 별도의 객체를 추가하자. 이렇게 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 Factory라고 부른다. 덕분에 클라이언트는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있다.

순수한 가공물에게 책임 할당하기

어떤 책임을 할당하고 싶다면, 정보 전문가를 찾기 위해 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다. 그런데 Factory를 추가한 이유는 순수하게 기술적인 결정이다.

시스템을 객체로 분해하는 것은 크게 표현적 분해와 행위적 분해로 나뉜다. 표현적 분해는 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것이다. 표현적 분해는 도메인 모델에 담겨 있는 개념과 관계를 따르며 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것을 목적으로 한다. 따라서 표현적 분해는 객체지향 설계를 위한 가장 기본적인 접근법이다.

그러나 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다. 모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다. 이 때 도메인 개념을 표현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다. 이처럼 책임을 할당하기 위해 창조되는 도메인과 무관한 인공적 객체를 순수한 가공물(Pure Fabrication)이라고 부른다. 어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 순수한 가공물을 추가하고 이 객체에게 책임을 할당하라. 그 결과로 추가된 순수한 가공물은 보통 특정한 행동을 표현하는 것이 일반적이다. 따라서 순수한 가공물은 표현적 분해보다는 행위적 분해에 의해 생성되는 것이 일반적이다. 이러한 내용을 정리하는 Pure Fabrication 패턴은 Information Expert(정보 전문가) 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용된다. 어떤 객체가 책임을 수행하는 데 필요한 많은 정보를 가졌지만 해당 책임을 할당할 경우 응집도가 낮아지고 결합도가 높아진다면 가공의 객체를 추가해서 책임을 옮기는 것을 고민하라.

이런 측면에서 객체지향이 실세계의 모방이라는 말은 옳지 않다. 객체지향 애플리케이션은 도메인 개념뿐만 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다. 인공적으로 창조한 객체들이 도메인 개념을 반영하는 객체들보다 오히려 더 많은 비중을 차지하는 것이 일반적이다. 설계자는 도메인 추상화를 기반으로 애플리케이션 로직을 설계하는 동시에 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조해야 한다. 먼저 도메인의 본질적인 개념을 표현하는 추상화를 이용해 애플리케이션을 구축하기 시작하라. 만약 도메인 개념이 만족스럽지 못하다면 주저하지 말고 인공적인 객체를 창조하라.

9_3 의존성 주입

생성과 사용을 분리하면, 어떤 객체가 다른 객체에 의존할 때 인스턴스를 전달해야 한다. 사용하는 객체가 아닌 외부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입이라고 부른다. 의존성 주입이라고 부르는 이유는 외부에서 의존성의 대상을 해결한 후 이를 사용하는 객체 쪽으로 주입하기 때문이다.

의존성 주입은 근본적으로 8장의 의존성 해결 방법과 관련이 깊다. 의존성 해결은 컴파일타임 의존성과 런타임 의존성의 차이점을 해소하기 위한 다양한 메커니즘을 포괄한다. 의존성 주입은 의존성을 해결하기 위해 의존성을 객체의 퍼블릭 인터페이스에 명시적으로 드러내서 외부에서 필요한 런타임 의존성을 전달할 수 있도록 만드는 방법을 포괄하는 명칭이다. - 생성자 주입, setter 주입, 메서드 주입으로 나뉜다. setter 주입의 장점은 의존성의 대상을 런타임에 변경할 수 있다는 것이다. 생성자 주입을 통해 설정된 인스턴스는 객체의 생명주기 전체에 걸쳐 관계를 유지하는 반면, setter 주입을 사용하면 언제라도 의존 대상을 교체할 수 있다. sette 주입의 단점은 객체가 올바로 생성되기 위해 어떤 의존성이 필수적인지를 명시적으로 표현할 수 없다는 것이다. 메서드 주입은 메서드가 의존성을 필요로 하는 유일한 경우일 때 사용할 수 있다. 인터페이스 주입이라는 것도 있는데, 인터페이스 주입은 근본적으로 setter 주입과 동일하다. 단지 어떤 대상을 어떻게 주입할 것인지를 인터페이스를 통해 명시적으로 선언한다는 차이만 있을 뿐이다. 인터페이스 주입은 의존성 주입이 도입되던 초창기에 자바 진영에서 만들어진 몇몇 프레임워크에서 의존성 대상을 좀 더 명시적으로 정의하고 편하게 관라힉 위해 도입한 방법이다.

숨겨진 의존성은 나쁘다

의존성을 해결하기 위해, 의존성 주입의 대안으로 Service Locator 패턴이 있다. Service Locator는 의존성을 해결할 객체들을 보관하는 일종의 저장소다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 Service Locator의 경우 객체가 직접 Service Locator에게 의존성을 해결해줄 것을 요청한다. Service Locator 패턴은 서비스를 사용하는 코드로부터 서비스가 누구인지(서비스를 구현한 구체 클래스의 타입이 무엇인지), 어디에 있는지(클래스 인스턴스를 어떻게 얻을지)를 몰라도 되게 해준다. Service Locator 패턴은 쉽고 간단하다.

Service Locator 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다. 예제에서 Movie는 DiscountPolicy에 의존하고 있지만 Movie의 퍼블릭 인터페이스 어디에도 이 의존성에 대한 정보가 표시돼 있지 않다. 의존성은 암시적이며 코드 깊숙한 곳에 숨겨져 있다. Service Locator를 사용하는 예제에서 Movie가 온전한 상태로 생성될 것이라고 예상하지만, NullPointException 예외가 던져지는 걸 알 수 있다. 의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다는 사실을 알 수 있다. 숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제점을 발견할 수 있는 시점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문이다. 의존성을 숨기는 코드는 단위 테스트 작성도 어렵다. 모든 단위 테스트 케이스에 걸쳐 ServiceLocator의 상태를 공유하게 된다. 이것은 각 단위 테스트는 서로 고립돼야 한다는 단위 테스트의 기본 원칙을 위반한 것이다.

문제의 원인은 숨겨진 의존성이 캡슐화를 위반했기 때문이다. 단순히 인스턴스 변수의 가시성을 private으로 선언하고 변경되는 내용을 숨겼다고 해서 캡슐화가 지켜지는 것은 아니다. 캡슐화는 코드를 읽고 이해하는 행위와 관련이 있다. 클래스의 퍼블릭 인터페이스만으로 사용 방법을 이해할 수 있는 코드가 캡슐화의 관점에서 휼륭한 코드다. 클래스의 사용법을 익히기 위해 구현 내부를 샅샅이 뒤져야 한다면 그 클래스의 캡슐화는 무너진 것이다.

숨겨진 의존성이 가지는 가장 큰 문제점은 의존성을 이해하기 위해 코드의 내부 구현을 이해할 것을 강요하여, 캡슐화를 위반한다는 것이다. 숨겨진 의존성은 의존성의 대상을 설정하는 시점과 의존성이 해결되는 시점을 멀리 떨어트려 놓는다. 이 문제를 해결할 수 있는 유일한 방법은 ServiceLocator에 DiscountPolicy의 인스턴스를 설정하는 부분을 찾아 수정하는 것이다. 이것은 어떤 환경에서 개발을 하느냐에 따라 단순히 코드를 검색하는 차원을 뛰어넘는 문제일 수도 있다.

의존성 주입은 이 문제를 깔끔하게 해겨한다. 필요한 의존성은 클래스의 퍼블릭 인터페이스에 명시적으로 드러난다. 의존성을 이해하기 위해 코드 내부를 읽을 필요가 없기 때문에 의존성 주입은 객체의 캡슐을 단단하게 보호한다. 의존성과 관련된 문제도 최대한 컴파일타임에 잡을 수 있다. 단위 테스트를 작성할 때 ServiceLocator에 객체를 추가하거나 제거할 필요도 없다. 그저 필요한 인자를 전달해서 필요한 객체를 생성하면 된다. 의존성 주입이 Service Locator 패턴보다 좋다가 핵심이 아니라, 명시적인 의존성이 숨겨진 의존성보다 좋다는 것이 핵심이다. 의존성 주입은 의존성을 명시할 수 있는 방법 중 하나일 뿐이다.

어쩔 수 없이 Service Locator 패턴을 사용해야 하는 경우도 있다. 의존성 주입을 지원하는 프레임워크를 사용하지 못하는 경우나 깊은 호출 계층에 걸쳐 동일한 객체를 계속해서 전달해야 하는 고통을 견디기 어려운 경우에는 어쩔 수 없이 Service Locator 패턴을 사용하는 것을 고려하라. 일반적으로 의존성 역전을 사용하면 된다. 하지만 직접 객체를 넘기는 방식이 불필요하거나 도리어 코드를 ㅇ릭기 어렵게 하기도 한다. 예를 들어, 로그나 메모리 관리 같은 정보가 모듈의 공개 API에 포함돼 있어서는 안 된다. 렌더링 함수 매개변수에는 렌더링에 관련된 것만 있어야 하며 로그 같은 것이 섞여 있어서는 곤란하다. 또한 어떤 시스템은 본질적으로 하나 뿐(대부분의 게임 플랫폼에서 오디오나 디스플레이 시스템)인데, 이런 환경적 특징을 10겹의 메서드 계층을 통해 가장 깊숙이 들어있는 함수에 전달하는 것은 쓸데없이 복잡성을 늘리는 셈이다.

9_4 의존성 역전 원칙

추상화와 의존성 역전

예제에서 Movie는 가격 계산이라는 높은 수준의 개념을 구현한다. 그에 비해 AmountDiscoutPolicy는 영화의 가격에서 특정한 금액만큼 할인해주는 더 구체적인 수준의 메커니즘을 담당하고 있다. 객체 사이의 협력이 존재할 때 그 협력의 본질을 담고 있는 것은 상위 수준의 정책이다. 어떻게 할인 금액을 계산할 것인지는 협력의 본질이 아니다. 다시 말해 어떤 협력에서 중요한 정책이나 의사결정, 비즈니스의 본질을 담고 있는 것은 상위 수준의 클래스다. 만약 상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받게 될 것이다. 상위 수준의 변경으로 인해 하위 수준이 영향을 받아야 한다. 그러기 위해 상위 수준의 클래스는 어떤 식으로든 하위 수준의 클래스에 의존해서는 안 되는 것이다. 하위 수준에 의존하면 재사용에도 문제가 발생한다. 대부분의 경우 재사용하려는 대상은 상위 수준의 클래스다. 상위 클래스가 하위 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.

해셜책은 추상화다. 상위 클래스, 하위 클래스 모두가 추상화에 의존하도록 수정하면 하위 클래스의 변경으로 인해 상위 클래스가 영향을 받는 것을 방지할 수 있다. 또한 상위 수준을 재상요할 때 하위 수준의 클래스에 얽매이지 않고도 다양한 컨텍스트에서 재사용이 가능하다.

위의 내용을 1. 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다. 2. 추상화는 구체적인 상황에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야한다 로 정리할 수 있다. 이를 의존성 역전 원칙이라고 부른다. 의존성 역전 원칙을 따르는 설계는 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문이라고 설명한다. 역전이라는 단어를 사용하는 이유는 구조적 분석 설계와 같은 좀 더 전통적인 소프트웨어 개발 방법에서는 소프트웨어 구조에서 상위 수준의 모듈이 하위 수준의 모듈에 의존하는, 그리고 정책이 구체적인 것에 의존하는 경향이 있었기 때문이다. 이런 방법의 목표 중 하나는 상위 수준의 모듈이 하위 수준의 모듈을 호출하는 방법을 묘사하는 서브프로그램의 계층 구조를 정의하는 것이었다. 잘 설계된 객체지향 프로그램의 의존성 구조는 전통적인 절차적 방법에 의해 일반적으로 만들어진 의존성 구조에 대해 ‘역전’된 것이다.

의존성 역전 원칙과 패키지

역전은 의존성의 방향뿐만 아니라 인터페이스의 소유권에도 적용된다. 객체지향 프로그래밍 언어에서 어떤 구성 요소의 소유권을 결정하는 것은 모듈(자바-패키지)이다. 예제에서 Move / DiscoutPolicy, AmountDiscountPolicy, PercentDiscountPolicy 로 패키지를 분리할 경우, Movie의 패키지가 DiscountPolicy의 패키지에 의존하게 된다. Movie를 정상적으로 컴파일하기 위해서는 DiscoutPolicy 클래스가 필요하다. 사실 코드의 컴파일이 성공하기 위해 함께 존재해야 하는 코드를 정의하는 것이 바로 컴파일타임 의존성이다. 문제는 DiscoutPolicy가 포함돼 있는 패키지 안에 AmountDiscountPolicy 클래스와 PercentDiscountPolicy 클래스가 포함돼 있다는 것이다. 이것은 DiscountPolicy 클래스에 의존하기 위해서는 반드시 같은 패키지에 포함된 AmountDiscountPolicy 클래스와 PercentDiscountPolicy 클래스도 함께 존재해야 한다는 것을 의미한다.

의존성 정의에 따라 Movie는 DiscountPlicy를 수정하지 않을 경우에는 영향을 받지 말아야한다. 하지만 이것은 코드 수정에 있어서는 사실이지만 컴파일 측면에서는 사실이 아니다. DiscountPolicy가 포함된 패키지 안의 어떤 클래스가 수정되더라도 패키지 전체가 재배포돼야 한다. 이로 인해 이 패키지에 의존하는 Movie 클래스가 포함된 패키지 역시 재컴파일돼야 한다. Movie에 의존하는 또 다른 패키지가 있다면 컴파일은 의존성의 그래프를 타고 애플리케이션 코드 전체로 번져갈 것이다. 따라서 불필요한 클래스들을 같은 패키지에 두는 것은 전체적인 빌드 시간을 가파르게 상승시킨다. 따라서 Movie, DiscountPolicy / AmountDiscountPolicy, PercentDiscountPolicy와 같이 패키지를 분리하여 추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 한다. 그리고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. 이를 Separated Interface 패턴이라고 부른다. Movie와 추상 클래스인 DiscountPolicy를 하나의 패키지로 모으는 것은 Moive를 특정한 컨텍스트로부터 완벽하게 독립시킨다. 따라서 의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다. 이것은 객체지향 프레임워크의 모듈 구조를 설계하는 데 가장 중요한 핵심 원칙이다.

정리

유연하고 재사용 가능하며 컨텍스트에 독립적인 설계는 전통적인 패러다임이 고수하는 의존성의 방향을 역전시킨다. 전통적인 패러다임에서는 상위 수준 모듈이 하위 수준 모듈에 의존했다면 객체지향 패러다임에서는 상위 수준 모듈과 하위 수준 모듈이 모두 추상화에 의존한다. 전통적인 패러다임에서는 인터페이스가 하위 수준 모듈에 속했다면 객체지향 패러다임에서는 인터페이스가 상위 수준 모듈에 속한다.

9_5 유연성에 대한 조언

유연한 설계는 유연성이 필요할 때만 옳다

유연하고 재사용 가능한 설계란 런타임 의존성과 컴파일타임 의존성의 차이를 인식하고 동일한 컴파일타임 의존성으로부터 다양한 런타임 의존성을 만들 수 있는 코드 구조를 가지는 설계를 의미한다. 하지만 유연하고 재사용 가능한 설계가 항상 좋은 것은 아니다. 설계의 미덕은 단순함과 명확함으로부터 나온다. 단순하고 명확한 설계를 가진 코드는 읽기 쉽고 이해하기도 편하다. 융녀한 설계는 이와는 다른 길을 걷는다. 변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게 될 가능성이 높다.

유연한 설계라는 말의 이면에는 복잡한 설계라는 의미가 숨어 있다. 유연한 설계의 양면성이 설계를 어렵게 하는데, 이는 공학이라기보다 심리학에 가깝기 때문이다. 변경은 예상이 아니라 현실이어야 한다. 미래에 변경이 일어날지도 모른다는 막연한 불안감은 불필요하게 복잡한 설계를 낳는다. 아직 일어나지 않은 변경은 변경이 아니다.

유연하지 않은 설계는 단순하고 명확하다. 유연한 설계는 복잡하고 암시적이다. 코드 상에 표현된 정적인 클래스와 구조의 실행 시점의 동적인 객체 구조는 다르다. 객체지향 코드에서 클래스의 구조는 발생 가능한 모든 객체 구조를 담는 틀일 뿐이다. 특정 시점의 객체 구조를 파악하는 유일한 방법은 클래스를 사용하는 클라이언트 코드 내에서 객체를 생성하거나 변경하는 부분을 직접 살펴보는 것뿐이다.

설계가 유연할수록 클래스 구조와 객체 구조 사이의 거리는 점점 멀어진다. 다라서 유연함은 단순성과 명확성의 희생 위에서 자라난다. 유연한 설계를 단순하고 명확하게 만드는 유일한 방법은 사람들 간의 긴밀한 커뮤니케이션뿐이다.

불필요한 유연성은 불필요한 복잡성을 낳는다. 단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라. 지적 능력은 정적인 관계에 더 잘 들어맞고, 시간에 따른 진행 과정을 시각화하는 능력은 상대적으로 덜 발달했다는 주장이 있다. 이 관점에서 정적인 프로그램과 동적인 프로세스 사이의 간극을 줄이기 위해 최선을 다해야 하며, 이를 통해 프로그램(텍스트 공간에 흩뿌려진)과 (시간에 흩뿌려진) 진행 과정 사이를 가능한 한 일치시켜야 한다.

협력과 책임이 중요하다

구현 관점에서의 의존성을 설명했지만, 협력에 참여하는 객체가 다른 객체에게 어떤 메시지를 전송하는지가 중요하다. 따라서 먼저 역할, 책임, 협력에 초점을 맞춰야 한다. 책임 관점에서 객체들 간에 균형이 잡혀 있는 상태라면 생성과 관련된 책임을 지게 될 객체를 선택하는 것은 간단한 작업이 된다. 불필요한 Singleton 패턴은 객체 생성에 관해 너무 이른 시기에 고민하고 결정할 때 도입되는 경향이 있다. 고수는 무의식적으로 객체가 무엇이 되고 싶은지를 알게 될 때까지 객체들을 어떻게 인스턴스화할 것인지에 대해 전혀 신경쓰지 않는다. 이때 가장 중요한 관심거리는 마치 객체가 이미 존재하는 것처럼 이들 간의 관계를 신경 쓰는 일이다. 고수는 때가 되면 이러한 관계에 맞게 객체를 생성할 수 있을 것이라고 추측한다. 추측하는 이유는 설계 동안 머릿속에 기억해야 할 객체 수를 최소화해야 하기 때문이다. 보통 요구사항을 충족시킬 수 있는 객체를 인스턴스화하는 방법에 대해 생각하는 것을 뒤로 미룰 때 위험을 최소화한 상태로 작업할 수 있다. 너무 일찍 결정하는 것은 비생산적이다. 객체를 생성하는 방법을 신경 쓰기 전에 시스템에 필요한 것(책임)들을 생각하자.

10_상속과 코드 재사용

전통적인 패러다임에서 코드를 재사용하는 방법은 코드를 복사 후 수정하는 것이다. 반면, 객체지향에서는 코드를 재사용하기 위해 새로운 코드를 추가한다. 클래스를 재사용하기 위해서는 새로운 클래스를 추가하는 것이다. 상속은 그 대표적 방법이다.

재사용 관점에서 상속이란 클래스 안에 정의된 인스턴스 변수와 메서드를 자동으로 새로운 클래스에 추가하는 구현 기법이다.

10_1 상속과 중복 코드

중복 코드는 사람들의 마음속에 의심과 불신의 씨앗을 뿌린다. 두 코드가 정말 동일한 것인가? 유사한 코드가 이미 존재하는데도 새로운 코드를 만든 이유는 무엇일까? 의도적으로 그렇게 한 것인가, 아니면 단순한 실수인가? 두 코드가 중복이기는 한 걸까? 중복을 없애도 문제가 없을까? 양쪽을 수정하기보다는 한쪽 코드만 수정하는 게 더 안전한 방법이 아닐까? 중복 코드는 우리를 주저하게 만들뿐만 아니라 동료들을 의심하게 만든다. 더 큰 문제는 중복 코드가 변경을 방해한다는 것이다.

DRY 원칙

중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다. 우선 어떤 코드가 중복인지를 찾아야 한다. 일단 중복 코드의 묶음을 찾았다면 찾아낸 모든 코드를 일관되게 수정해야 한다. 모든 중복 코드를 개별적으로 테스트해서 동일한 결과를 내놓는지 확인해야만 한다. 중복 코드는 수정과 테스트에 드는 비용을 증가시킬뿐만 아니라 시스템과 여러분을 공황상태로 몰아넣을 수도 있다.

중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다. 함께 수정할 필요가 없다면 중복이 아니다. 중복 코드를 결정하는 기준은 코드의 모양이 아니다. 모양이 유사하다는 것은 단지 중복의 징후일 뿐이다. 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식이다. 중복 코드 제거와 관련된 DRY 원칙은 ‘모든 지식은 시스템 내에서 단일하고, 애매하지 않고, 정말로 믿을 만한 표현 양식을 가져야 한다’를 의미한다. 즉 코드 안에 중복이 존재해서는 안 된다는 것이다.

중복과 변경

많은 코드 더미 속에서 어떤 코드가 중복인지를 파악하는 일은 쉬운 일이 아니다. 중복 코드는 항상 함께 수정돼야 하기 때문에 수정할 때 하나라도 빠트린다면 버그로 이어질 것이다. 설령 모든 중복 코드를 식별했고 함께 수정했다고 하더라도, 더 큰 문제는 중복 코드는 서로 다르게 수정하기 쉽다는 것이다.

중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법이 새로운 중복 코드를 추가하는 것이기 때문이다. 새로운 중복 코드를 추가하는 과정에서 코드의 일관성이 무너질 위험이 항상 도사리고 있다.

민첩하게 변경에 대응하기 위해서는 중복 코드를 추가하는 대신 제거해야 한다.

두 클래스 사이의 중복 코드를 제거하는 한 가지 방법은 클래스를 하나로 합치는 것이다. 객체지향 프로그래밍 언어는 타입 코드를 사용하지 않고도 중복 코드를 관리할 수 있는 효과적인 방법, 상속을 제공한다.

상속을 이용해서 중복 코드 제거하기

상속의 기본 아이디어는 이미 존재하는 클래스와 유사한 클래스가 필요하다면 코드를 복사하지 말고 상속을 이용해 코드를 재사용하라는 것이다. 상속을 보여주는 예제는 상속을 사용했을 때 개발자의 가정을 이해하기 전에는 코드를 이해하기 어렵다는 것을 보여준다. 예제를 통해 상속을 염두에 두고 설계되지 않은 클래스를 상속을 이용해 재사용하는 것은 생각처럼 쉽지 않다는 것을 알 수 있다. 개발자는 재사용을 위해 상속 계층 사이에 무수히 많은 가정을 세웠을지도 모른다. 그 가정은 코드를 이해하기 어렵게 만들뿐만 아니라 직관에도 어긋날 수 있다.

가정 때문에 요구사항과 구현 사이에 차이가 생겼는데, 그 차이가 크면 클수록 코드를 이해하기 어려워진다. 실제 현업 코드는 예제보다도 훨씬 이해하기 어렵다.

상속을 이용해 코드를 재사용하기 위해서는 부모 클래스의 개발자가 세웠던 가정이나 추론 과정을 정확하게 이해해야 한다. 즉 자식 클래스의 작성자가 부모 클래스의 구현 방법에 대한 정확한 지식을 가져야 한다. 따라서 상속은 결합도를 높인다.

강하게 결합된 부모 클래스와 자식 클래스

예제를 통해 코드 중복을 제공하기 위해 상속을 사용했음에도 로직을 추가하기 위해 새로운 중복 코드를 만들어야 한다는 것을 알 수 있다. 이것은 자식 클래스가 부모 클래스의 구현에 너무 강하게 결합돼 있기 때문에 발생하는 문제다. 상속을 사용하면 적은 노력으로도 새로운 기능을 쉽고, 빠르게 추가할 수 있다. 하지만 그로 인해 커다란 대가를 치러야 할 수도 있다. 상속 관계로 연결된 자식 클래스가 부모 클래스의 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 한다.

  • 상속을 위한 경고 1: super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.

10_2 취약한 기반 클래스 문제

상속으로 인한 강한 결합도 때문에 자식 클래스는 부모 클래스의 불필요한 세부사항에 엮이게 된다. 부모 클래스의 작은 변경에도 자식 클래스는 컴파일 오류와 실행 에러라는 고통에 시달려야 할 수도 있다. 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 부른다. 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다. 안전한 방식으로 기반(부모) 클래스를 수정한 것처럼 보이더라도 새로운 행동이 파생(자식) 클래스에게 상속될 경우 파생 클래스의 잘못된 동작을 초래할 수 있기 때문에 기반 클래스는 취약하다. 단순히 기반 클래스의 메서드들만을 조사하는 것만으로는 기반 클래스를 변경하는 것이 안전하다고 확신할 수 없다. 모든 파생 클래스들을 살펴볼 뿐아니라 테스트까지 해야한다. 나아가 기반 클래스와 파생 클래스를 사용하는 모든 코드가 새로운 코드로 인해 영향을 받지 않았는지 점검해야 한다. 핵심적인 기반 클래스에 대한 단순한 변경이 전체 프로그램을 불안정한 상태로 만들수도 있다. 상속 관계를 추가할수록 전체 시스템의 결합독 높아진다는 사실을 알고 있어야 한다. 최악의 경우 모든 자식 클래스를 동시에 수정하고 테스트해야 할 수도 있다.

취약한 기반 클래스 문제는 캡슐화를 약화시키고 결합도를 높인다. 객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 하지만 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.

불필요한 인터페이스 상속 문제

Vector, Stack 클래스 예제를 통해 캡슐화가 깨지는 걸 볼 수 있다. 불필요한 상속 때문에 맨 마지막 위치에서만 요소를 추가하거나 제거할 수 있도록 허용하는 스택의 규칙을 쉽게 위반할 수 있다. 문제의 원인은 Stack이 규칙을 무너뜨릴 여지가 있는 위험한 Vector의 퍼블릭 인터페이스까지도 함께 상속받았기 때문이다. 잘못 만든 후 잘 쓰면 된다고 생각하지 말자. 인터페이스 설계는 제대로 쓰기엔 쉽게, 엉터리로 쓰기엔 어렵게 만들어야 한다. Stack 개발자 한 사람의 일시적인 편의를 위해 인터페이스를 사용해야 하는 무수한 사람들이 가슴을 졸여야 하는 상황을 초래하는 것은 어떤 경우에도 정당화하기 어렵다.

정리하자면, 불필요한 인터페이스 상속 문제는 자식 클래스에게 부모 클래스의 오퍼레이션이 상속되기 때문에 자식 클래스 인스턴스의 상태가 불안정해지는 문제다.

  • 상속을 위한 경고 2: 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.

메서드 오버라이딩의 오작용 문제

이 예제도 캡슐화가 깨지는 걸 보여준다. 수정 시 원하는대로 작동하지 않는다. 미래의 수정을 감안하여 구현할 수도 있다. 하지만 이는 미래에 발생할지 모르는 위험을 방지하기 위해 코드를 중복시키는 것이다.

  • 상속을 위한 경고 3: 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.

클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다는 주장이 있다. 그런데 문서화한다는 것은 메서드가 무슨 일(what)을 하는지 기술하고, 어떻게 하는지(how)를 설명해서는 안 된다는 통념을 어기는 것이다. 상속이 캡슐화를 위반함으로써 초래된 불행이다. 서브클래스가 안전할 수 있게끔 클래스를 문서화하려면 클래스의 상세 구현 내역을 기술해야 한다. 상속은 코드 재사용을 위해 캡슐화를 희생한다.

정리하자면, 메서드 오브라이딩의 오작용 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 때 자식 클래스가 부모 클래스의 메서드 호출 방법에 영향을 받는 문제다.

부모 클래스와 자식 클래스의 동시 수정 문제

이 예제에서는 자식 클래스가 부모 클래스의 메서드를 오버라이딩하거나 불필요한 인터페이스를 상속받지 않았음에도 부모 클래스를 수정할 때 자식 클래스를 함께 수정해야 할 수도 있다는 사실을 보여준다. 슈퍼클래스의 작성자가 확장될 목적으로 특별히 그 클래스를 설계하지 않았다면 서브 클래스는 슈퍼클래스와 보조를 맞춰서 진화해야 한다.

정리하자면, 부모 클래스와 자식 클래스의 동시 수정 문제는 부모 클래스와 자식 클래스 사이의 개념적인 결합으로 인해 부모 클래스를 변경할 때 자식 클래스도 함께 변경해야 하는 문제다.

  • 상속을 위한 경고 4: 클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.

10_3 Phone 다시 살펴보기

취약한 기반 클래스 문제를 완전히 없앨 수는 없지만 어느 정도까지 위험을 완화시키는 것은 가능하다.

추상화에 의존하자

자식 클래스가 부모 클래스의 구현이 아닌 추상호에 의존하도록 만들자. 즉, 부모 클래스와 자식 클래스 모두 추상화에 의존하도록 수정해야 한다. 코드 중복을 제거하기 위해 상속을 도입할 때 고려하면 좋은 두 가지 원칙이 있다.

  1. 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
  2. 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.

차이를 메서드로 추출하라

변하는 것으로부터 변하지 않는 것을 분리하라. 변하는 부분을 찾고 이를 캡슐화하라. 이것을 메서드 수준에 적용하는 것이 차이점을 별도의 메서드로 추출하는 것이다.

예제는 두 클래스의 유사한 함수의 로직이 다르다는 것을 보여준다. 먼저 다른 부분을 메서드로 추출하자. 기존에 유사했던 함수는 완전히 동일해졌고, 추출한 함수 안에 서로 다른 부분을 격리시켜 놓았다.

중복 코드를 부모 클래스로 올려라

‘위로 올리기’전략은 실패하더라도 수정하기 쉬운 문제를 발생시킨다. 문제는 쉽게 찾을 수 있게 쉽게 고칠 수 있다. 위로 올리기에서 실수하더라도 추상화할 코드는 눈에 띄고 결국 상위 클래스로 올려지면서 코드의 품질이 높아진다. 하지만 구체적인 구현을 아래로 내리는 방식으로 구체 클래스를 추상 클래스로 변경하려 한다면 작은 실수 한 번으로도 구체적인 행동을 상위 클래스에 남겨 놓게 된다.

추상화가 핵심이다

공통 코드를 이동시킨 후에 각 클래스는 서로 다른 변경의 이유를 가진다는 것에 주목하라. 예제에서는 리팩토링 후 자식 클래스가 부모클래스의 구체적 구현에 의존하지 않는다는 것을 보여준다. 부모, 자식 클래스 모두 추상화에만 의존하기 때문에, 추상화가 변경되지 않는 한 각각 내부 구현이 변경되더라도 서로 영향을 받지 않는다. 즉 낮은 결합도를 유지한다. 의존성 역전 원칙도 준수하는데, 부모 클래스가 자식 클래스에 의존지 않고 자식 클래스가 부모 클래스에 의존하기 때문이다.

기능 추가도 쉽다. 새로운 클래스 추가 후 오버라이딩 하면 된다. 확장에는 열려 있고 수정에는 닫혀 있기 때문에 개방 폐쇄 원칙을 준수한다.

상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 코드를 리팩터링하라. 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.

인스턴스 변수 결합

클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스가 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.

인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 경우는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.

하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 비즈니스 로직을 중복시키는 것보다는 현명한 선택이다. 객체 생성 로직의 변경에 유연하게 대응할 수 있는 다양한 방법이 존재한다. 따라서 객체 생성 로직에 대한 변경을 막기보다는 핵심 로직의 중복을 막아라. 상속으로 인한 클래스 사이의 결합을 완전히 피할 수 있는 방법은 없다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.

10_4 차이에 의한 프로그래밍

상속은 익숙한 개념을 이용해서 새로운 개념을 쉽고 빠르게 추가할 수 있기 때문에 강력하다. 지금까지 살펴본 것처럼 기존 코드와 다른 부분만을 추가함으로써 애플리케이션의 기능을 확장하는 방법을 차이에 의한 프로그래밍이라고 부른다. 차이에 의한 프로그래밍의 목표는 중복 코드를 제거하고 코드를 재사용하는 것이다. 중복을 제거하기 위해서는 코드를 재사용 가능한 단위로 분해하고 재구성해야 한다. 코드를 재사용하기 위해서는 중복 코드를 제거해서 하나의 모듈로 모아야 한다.

코드를 재사용하는 것은 단순히 문자를 타이핑하는 수고를 덜어주는 수준의 문제가 아니다. 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다. 따라서 코드를 재사용하면 코드의 품질은 유지하면서도 코드를 작성하는 노력과 테스트는 줄일 수 있다.

상속이 코드 재사용이라는 측면에서 매우 강력한 도구인 것은 사실이지만 강력한 만큼 잘못 사용할 경우에 돌아오는 피해 역시 크다. 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다.

11_합성과 유연한 설계

상속이 부모 클래스와 자식 클래스를 연결해서 부모 클래스의 코드를 재사용하는 데 비해 합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용한다. 상속 관계는 is-a 관계라고 부르고 합성 관계는 has-a 관계라고 부른다. 상속은 코드를 재사요할 수 있는 쉬운 방법일지 몰라도 우아한 방법이라고 할 수는 없다. 합성은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계다. 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다. 따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다. 코드 재사용을 위해서는 객체 합성이 클래스 상속보다 좋은 방법이다.

상속과 합성은 재사용의 대상이 다르다. 상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다. 상속 대신 합성을 사용하면 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체할 수 있다. 상속을 합성으로 바꿔보며 상속의 문제를 개선해보자.

11_1 상속을 합성으로 변경하기

상속의 문제 1: 불필요한 인터페이스 상속 문제

합성을 활용한 예시를 통해, 더이상 불필요한 부모 클래스의 오퍼레이션들이 자식 클래스의 퍼블릭 인터페이스를 오염시키지 않는다. 합성 관계로 변경함으로써 클라이언트가 자식 클래스를 잘못 사용할 수도 있다는 가능성을 깔끔하게 제거한다.

상속의 문제 2: 메서드 오버라이딩의 오작용 문제

이전 예제에서 합성을 사용한 이유는 불필요한 오퍼레이션들이 자식 클래스의 퍼블릭 인터페이스에 스며드는 것을 방지하기 위해서였다. 하지만 여기에서 보여주는 예제는 그걸 위함은 아니다. 어차피 자식 클래스도 부모 클래스의 퍼블릭 인터페이스를 그대로 제공해야 하는 것이다. 따라서 자식 클래스는 부모 클래스의 구현은 물려받지 않는 동시에 퍼블릭 인터페이스는 모두 물려받아야 한다. 이러한 상황에 자바 인터페이스를 사용하면 문제를 해결할 수 있다. 자식 클래스는 자바 인터페이스를 실체화하면서, 내부에 과거 부모 클래스의 인스턴스를 합성하면 구현 결합도는 제거하면서 퍼블릭 인터페이스는 그대로 유지할 수 있다. 이 때 동일한 메소드를 호출하는 포워딩이 활용되는데, 포워딩은 기존 클래스의 인터페이스를 그대로 외부에 제공하면서 구현에 대한 결합 없이 일부 작동 방식을 변경하고 싶은 경우에 사용할 수 있는 유용한 기법이다.

상속의 문제 3: 부모 클래스와 자식 클래스의 동시 수정 문제

안타깝게도 이 경우 합성을 적용하더라도 부모 클래스와 자식 클래스를 함께 수정해야 하는 문제가 해결되지는 않는다. 그렇다고 하더라도 여전히 상속보다는 합성을 사용하는 게 더 좋은데, 향후에 부모 클래스의 내부 구현을 변경하더라도 파급 효과를 최대한 자식 클래스 내부로 캡슐화할 수 있기 때문이다. 대부분의 경우 구현에 대한 결합보다는 인터페이스에 대한 결합이 더 좋다는 걸 기억하라.

몽키 패치

몽키패치란 현재 실행중인 환경에만 영향을 미치도록 지역적으로 코드를 수정하거나 확장하는 것을 가리킨다. 예를 들어, 부모 클래스의 코드를 수정할 권한이 없거나 소스 코드가 존재하지 않는다고 하더라도 몽키 패치가 지원되는 환경이라면 부모 클래스에 직접 메서드를 추가하는 것이 가능하다. 자바는 언어 차원에서 몽키 패치를 지원하지 않기 때문에 바이트코드를 직접 변환하거나 AOP를 이용해 몽키 패치를 구현한다.

정리

합성을 사용해서 변경에 불안정한 코드를 안정적으로 유지하는 방법을 살펴봤다.

11_2 상속으로 인한 조합의 폭발적인 증가

상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음 두 문제가 발생한다.

  1. 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
  2. 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.

기능 조합 시 생기는 문제

예제를 통해 부모 클래스에 추상 메서드를 추가하면 모든 자식 클래스들이 추상 메서드를 오버라이딩해야 하는 문제가 발생한다는 것을 알 수 있다. 자식 클래스의 수가 적다면 큰 문제가 아니겠지만 자식 클래스의 수가 많을 경우에는 꽤나 번거로운 일이 될 수밖에 없다. 심지어 모든 추상메서드의 구현이 동일하다.

추상 메서드와 훅 메서드

개방 폐쇄 원칙을 만족하는 설계를 만들 수 있는 한 가지 방법은 부모 클래스에 새로운 추상 메서드를 추가하고 부모 클래스의 다른 메서드 안에서 호출하는 것이다. 추상 메서드의 단점은 상속 계층에 속하는 모든 자식 클래스가 추상 메서드를 오버라이딩해야 한다는 것이다. 대부분의 자식 클래스가 추상 메서드를 동일한 방식으로 구현한다면 상속 계층 전반에 걸쳐 중복 코드가 존재하게 될 것이다. 해결 방법은 메서드에 기본 구현을 제공하는 것이다. 이처럼 추상 메서드와 동일하게 자식 클래스에서 오버라이딩할 의도로 메서드를 추가했지만 편의를 위해 기본 구현을 제공하는 메서드를 훅 메서드라고 부른다.

예제를 통해, 기본 기능과 부가 기능을 조합하려고 할 때 코드 중복이 발생한다는 것을 알 수 있다. 자바를 포함한 대부분의 객체지향 언어는 단일 상속만 지원하기 때문에 상속으로 인해 발생하는 중복 코드 문제를 해결하기가 쉽지 않다.

중복 코드의 덫에 걸리다

부가 정책은 자유롭게 조합할 수 있어야 하고 적용되는 순서 역시 임의로 결정할 수 있어야 한다. 상속을 이용한 해결 방법은 모든 가능한 조합별로 자식 클래스를 하나씩 추가하는 것이다. 예제를 통해 이렇게 구현하면 복잡해진다는 걸 알 수 있다. 복잡성보다 큰 문제는 새로운 정책일 추가하기 어렵다는 것이다. 현재의 설계에 새로운 정책을 추가하기 위해서는 불필요하게 많은 수의 클래스를 상속 계층 안에 추가해야 한다. 예제를 통해 고정 요금제라는 하나의 기능을 추가하기 위해 5개의 새로운 클래스를 추가해야 한다는 것을 알 수 있다.

이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발 문제 혹은 조합의 폭발 문제라고 부른다. 클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다. 컴파일 타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.

클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 된다. 예를 들어, 세금 정책을 변경할 때 세금 정책과 관련된 코드가 여러 클래스 안에 중복돼 있기 때문에 세금 정책과 관련된 모든 클래스를 찾아 동일한 방식으로 수정해야 한다. 이 클래스 중 하나라도 누락한다면 버그가 발생하고 말 것이다. 이 문제를 해결하는 최선의 방법은 상속을 포기하는 것이다.

11_3 합성 관계로 변경하기

상속 관계는 컴파일타임에 결졍되고 고정되기 때문에 코드를 실행하는 도중에는 변경할 수 없다. 따라서 여러 기능을 조합해야 하는 설계에 상속을 이용하면 모든 조합 가능한 경우별로 클래스를 추가해야 한다.

합성은 컴파일타임 관계를 런타임 관계로 변경함으로써 이 문제를 해결한다. 합성을 사용하면 구현이 아닌 퍼블릭 인터페이스에 대해서만 의존할 수 있기 때문에 런타임에 객체의 관계를 변경할 수 있다.

컴파일타임 의존성과 런타임 의존성의 거리가 멀수록 설계가 유연해진다(8장). 상속을 사용하는 것은 컴파일타임의 의존성과 런타임의 의존성을 동일하게 만들겠다고 선언하는 것이다. 따라서 상속을 사용하면 부모 클래스와 자식 클래스 사이의 관곅 정적으로 고정되기 때문에 실행 시점에 동적으로 관계를 변경할 수 있는 방법이 없다.

상속과 달리 합성 관계는 런타임에 동적으로 변경할 수 있다. 합성을 사용하면 컴파일타임 의존성과 런타임 의존성을 다르게 만들 수 있다. 클래스 폭발 문제를 해결하기 위해 합성을 사용하는 이유는 런타임에 객체 사이의 의존성을 자유롭게 변경할 수 있기 때문이다.

합성을 사용하면 마치 기능을 구상할 때처럼 기본 기능과 부가 기능을 독립적으로 분리하고 순서에 따라 조합할 수 있다. 합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다. 상속이 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수 있다. 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 큰 장점인 것이다.

설계가 유연해지면 복잡도가 상승한다고 했었다. 하지만 아이러니하게도 변경하기 편리한 설계를 만들기 위해 복잡성을 더하고 나면 원래의 설계보다 단순해지는 경우를 종종 볼 수 있다. 상속을 합성으로 변경하여 기능 조합을 해결하는 경우가 바로 그 경우다.

합성 적용

먼저 각 정책을 별도의 클래스로 구현하자. 예제를 통해 합성을 사용하면 Phone과 연결되는 RatePolicy 인터페이스의 구현 클래스가 어떤 타임인지에 따라 요금을 계산하는 방식이 달라진다는 것을 알 수 있다.

예제에서는 부가 정책에 다음과 같은 제약사항이 있다.

  1. 부가 정책은 기본 정책이나 다른 부가 정책의 인스턴스를 참조할 수 있어야 한다. 다시 말해 부가 정책의 인스턴스는 어떤 종류의 정책과도 합성될 수 있어야 한다.
  2. Phone의 입장에서는 자신이 기본 정책의 인스턴스에게 메시지를 전송하고 있는지, 부가 정책의 인스턴스에게 메시지를 전송하고 있는지를 몰라야 한다. 다시 말해 기본 정책과 부가 정책은 협력 안에서 동일한 ‘역할’을 수행해야 한다. 이것은 부가 정책이 기본 정책과 동일한 RatePolicy 인터페이스를 구현해야 한다는 것을 의미한다.

합성을 적용했더니 의존성 주입을 통해 기능 조합이 너무나 쉬워진다는 것을 알 수 있다. 이렇게 했을 때 처음엔 코드를 이해하기 어려울 수도 있지만, 설계에 익숙해지고 나면 객체를 조합하고 사용하는 방식이 상속을 사용한 방식보다 더 예층 가능하고 일관성이 있다는 사실을 알게 될 것이다.

새로운 정책을 추가할 때, 그 기능을 구현하는 클래스를 하나만 추가한 후 원하는 방식으로 조합하면 된다. 더 중요한 것은 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다는 것이다.

11_4 믹스인

우리가 원하는 것은 코드를 재사용하면서도 납득할 만한 결합도를 유지하는 것이다. 상속과 클래스를 기반으로 하는 재사용 방법을 사용하면 클래스의 확장과 수정을 일관성 있게 표현할 수 있는 추상화의 부족으로 인해 변경하기 어려운 코드를 얻게 된다. 구체적인 코드를 재사용하면서도 낮은 결합도를 유지할 수 있는 유일한 방법은 재사용에 적합한 추상화를 도입하는 것이다.

믹스인은 객체를 생성할 때 코드 일부를 클래스 안에 섞어 넣어 재사용하는 기법이다. 합성이 실행 시점에 객체를 조합하는 재사용 방법이라면 믹스인은 컴파일 시점에 필요한 코드 조각을 조합하는 재사용 방법이다.

상속과 믹스인이 유사해보일 수 있지만 엄연히 다르다. 비록 상속의 결과로 부모 클래스의 코드를 재사용할 수 있기는 하지만 상속의 진정한 목적은 자식 클래스를 부모 클래스와 동일한 개념적인 범주로 묶어 is-a 관계를 만들기 위함이다. 반면 믹스인은 말 그대로 코드를 다른 코드 안에 섞어 넣기 위한 방법이다. 상속이 클래스와 클래스 사이의 관계를 고정시키는 데 비해 믹스인은 유연하게 관계를 재구성할 수 있다. 믹스인은 코드 재사용에 특화된 방법이면서도 상속과 같은 결합도 문제를 초래하지 않는다. 믹스인은 합성처럼 유연하면서도 상속처럼 쉽게 코드를 재사용할 수 있는 방법이다.

예제를 보면 의아한 생각이 들 수 있다. BasicRatePolicy를 상속받는 다는 것은 그냥 상속이랑 똑같은 거 아닌가? 게다가 앞에서 super 호출을 사용하지 말라고 했으면서 왜 여기서는 쓰는가? 이러면 결합도가 높아지는 거 아닌가?

A 트레이트가 B 추상 클래스를 상속하도록 구현했지만 A가 B의 자식 트레이트가 되는 것은 아니다. 예제의 extends 문은 단지 A가 사용될 수 있는 문맥을 제한할 뿐이다.

상속은 정적이지만 믹스인은 동적이다. 상속은 부모 클래스와 자식 클래스의 관계를 작성하는 시점에 고정시켜 버리지만 믹스인은 제약을 둘 뿐 실제로 어떤 코드에 믹스인될 것인지를 결정하지 않는다. 트레이트는 독립적으로 작성된 후 원하는 기능을 구현하기 위해 조합된다. super 호출로 실행되는 메서드를 보관한 코드는 실제로 트레이트가 믹스인되는 시점에 결정된다. 믹스인 대상에 따라 메서드가 호출되는 것이다. 이말은 super 참조가 가리키는 대상이 컴파일 시점이 아닌 실행 시점에 결정된다는 것을 의미한다. 상속의 경우에 일반적으로 this 참조는 동적으로 결정되지만 super 참조는 컴파일 시점에 결정된다. 따라서 상속에서는 부모 클래스와 자식 클래스 관계를 변경할 수 있는 방법은 없다. 하지만 스칼라의 트레이트에서 super 참조는 동적으로 결정된다. 따라서 트레이트의 경우 this 호출뿐만 아니라 super 호출 역시 실행 시점에 바인딩된다. 상속은 재사용 가능한 문맥을 고정시키지만 트레이트는 문맥을 확장 가능하도록 열어놓기 때문에 믹스인이 상속보다 더 유연한 재사용 기법이다.

믹스인은 합성과 유사하다. 합성은 독립적으로 작성된 객체들을 실행 시점에 조합해서 더 큰 기능을 만들어내는 데 비해 믹스인은 독립적으로 작성된 트레이트와 클래스를 코드 작성 시점에 조합해서 더 큰 기능을 만들어낼 수 있다.

전통적으로 믹스인은 특정한 클래스의 메서드를 재사용하고 기능을 확장하기 위해 사용돼 왔다. 객체지향 언어에서 슈퍼클래스는 서브클래스를 명시하지 않고도 정의될 수 있다. 그러나 이것은 대칭적이지 않다. 서브클래스가 정의될 때는 슈퍼클래스를 명시해야 한다. 믹스인은 결론적으로 슈퍼클래스로부터 상속될 클래스를 명시하는 메커니즘을 표현한다. 따라서 하나의 믹스인은 매우 다양한 클래스를 도출하면서 서로 다른 서브클래스를 이용해 인스턴스화될 수 있다. 믹스인의 이런 특성은 다중 클래스를 위한 단일의 점진적인 확장을 정의하는 데 적절하게 만든다. 이 클래스들 중 하나를 슈퍼클래스로 삼아 믹스인이 인스턴스화될 때 추가적인 행위가 확장된 클래스를 생성한다.

믹스인을 사용하면 특정한 클래스에 대한 변경 또는 확장을 독립적으로 구현한 후 필요한 시점에 차례대로 추가할 수 있는데, 이러한 특징을 쌓을 수 있는 변경이라고 부른다. 트레이트는 코드 재사용의 근간을 이루는 단위다. 하나의 부모 클래스만 갖는 클래스의 상속과 달리 트레이트의 경우 몇 개라도 믹스인될 수 있다.

12_다형성

상속은 코드 재사용이 아니라 타입 계층을 구조화하기 위해 사용해야 한다. 타입 계층은 객체지향 프로그래밍의 중요한 특성 중 하나인 다형성의 기반을 제공한다. 상속을 이용할 땐 상속을 사용하려는 목적이 단순히 코드를 재사용하기 위해서인지, 아니면 클라이언트 관점에서 인스턴스들을 동일하게 행동하는 그룹으로 묶기 위해서인지 고민해봐야 한다.

다형성은 런타임에 메시지를 처리하기에 적합한 메서드를 동적으로 탐색하는 과정을 통해 구현되며, 상속은 이런 메서드를 찾기 위한 일종의 탐색 경로를 클래스 계층의 형태로 구현하기 위한 방법이다.

12_1 다형성

다형성은 ‘많은 형태를 가질 수 있는 능력’을 의미한다. 컴퓨터 과학에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다. 간단히 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이라고 볼 수 있다.

객체지향 프로그래밍에서 사용되는 다형성은 크게 유니버설 다형성과 임시 다형성으로 분류된다. 유니버셜 다형성은 매개변수 다형성과 포함 다형성으로 분류되고, 임시 다형성은 오버로딩 다형성과 강제 다형성으로 분류된다.

질문: 분류 기준이 뭘까?

일반적으로 하나의 클래스 안에 동일한 이름의 메서드가 존재하는 경우를 가리켜 오버로딩 다형성이라고 부른다. 유사한 작업을 수행하는 메서드의 이름을 통일할 수 있기 때문에 기억해야 하는 이름의 수를 극적으로 줄일 수 있다.

강제 다형성은 언어가 지원하는 자동적인 타입 변환이나 사용자가 직접 구현한 타입 변환을 이용해 동일한 연산자를 다양한 타입에 사용할 수 있는 방식을 가리킨다. 일반적으로 오버로딩 다형성과 강제 다형성을 함께 사용하면 모호해질 수 있는데 실제로 어떤 메서드가 호출될지를 판단하기가 어려워지기 때문이다.

매개변수 다형성은 제네릭 프로그래밍과 관련이 높은데 클래스의 인스턴스 변수나 메서드의 매개변수 타입을 임의의 타입으로 선언한 후 사용하는 시점에 구체적인 타입으로 지정하는 방식을 가리킨다.

포함 다형성은 메시지가 동일하더라도 수신한 객체의 타입에 따라 실제로 수행되는 행동이 달라지는 능력을 의미한다. 포함 다형성은 서브타입 다형성이라고도 부른다.

포함 다형성을 위한 전제조건은 자식 클래스가 부모 클래스의 서브타입이어야 한다는 것이다. 포함 다형성을 위해 상속을 사용하는 가장 큰 이유는 상속이 클래스들을 계층으로 쌓아 올린 후 상황에 따라 적절한 메서드를 선택할 수 있는 메커니즘을 제공하기 때문이다. 객체가 메시지를 수신하면 객체 지향 시스템은 메시지를 처리할 적절한 메서드를 상속 계층 안에서 탐색한다. 실행할 메서드를 선택하는 기준은 어떤 메시지를 수신했는지에 따라, 어떤 클래스의 인스턴스인지에 따라, 상속 계층이 어떻게 구성돼 있는지에 따라 달라진다. 상속은 포함 다형성을 구현할 수 있는 여러 방법 중 하나이고, 포함 다형성을 구현하는 여러 방법이 있다.

12_2 상속의 양면성

객체지향 프로그램을 작성하기 위해서는 항상 데이터와 행동이라는 두 가지 관점을 함께 고려해야 한다. 상속을 이용하면 부모 클래스에서 정의한 모든 데이터를 자식 클래스의 인스턴스에 자동으로 포함시킬 수 있다. 이것은 데이터 관점의 상속이다. 데이터뿐만 아니라 부모 클래스에서 정의한 일부 메서드 역시 자동으로 자식 클래스에 포함시킬 수 있다. 이것이 행동 관점의 상속이다. 단순히 데이터와 행동의 관점에서만 바라보면 상속이 재사용 메커니즘으로 보일 것이다.

상속은 코드 재사용이 아니라 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다. 문제를 피할 수 있는 유일한 방법은 상속이 무엇이고 언제 사용해야 하는지를 이해하는 것뿐이다.

메서드 오버라이딩, 메서드 오버로딩

자식 클래스 안에 상속받은 메서드와 동일한 시그니처의 메서드를 재정의해서 부모 클래스의 구현을 새로운 구현으로 대체하는 것을 메서드 오버라이딩이라고 부른다. 부모 클래스와 자식 클래스에 동일한 시그니처를 가진 메서드가 존재할 경우 자식 클래스의 메서드 우선순위가 더 높다.

부모 클래스에서 정의한 메서드와 이름은 동일하지만 시그니처는 다른 메서드를 자식 클래스에 추가하는 것을 메서드 오버로딩이라고 부른다. 메서드 오버라이딩은 메서드를 감추지만 메서드 오버로딩은 사이좋게 공존하다. 대부분의 사람들은 하나의 클래스 안에서 같은 이름을 가진 메서드들을 정의하는 것은 메서드 오버로딩으로 생각하고 상속 계층 사이에서 같은 이름을 가진 메서드를 정의하는 것은 메서드 오버로딩으로 생각하지 않는 경향이 있다. 하지만 실제로는 둘 다 메서드 오버로딩이다.

데이터 관점의 상속

상속을 인스턴스 관점에서 바라볼 때는 개념적으로 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스가 포함되는 것으로 생각하는 것이 유용하다. 데이터 관점에서 상속은 자식 클래스의 인스턴스 안에 부모 클래스의 인스턴스를 포함하는 것으로 볼 수 있다. 따라서 자식 클래스의 인스턴스는 자동으로 부모 클래스에서 정의한 모든 인스턴스 변수를 내부에 포함하게 되는 것이다.

행동 관점의 상속

행동 관점의 상속은 부모 클래스가 정의한 일부 메서드를 자식 클래스의 메서드로 포함시키는 것을 의미한다. 부모 클래스의 모든 퍼블릭 메서드는 자식 클래스의 퍼블릭 메서드에 포함된다. 따라서 외부의 객체가 부모 클래스의 인스턴스에 전송할 수 있는 모든 메시지는 자식 클래스의 인스턴스에게도 전송할 수 있다.

부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐진다고 표현했지만 실제로 클래스의 코드를 합치거나 복사하는 작업이 수행되는 것은 아니다. 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있는 이유는, 복사됐기 때문이 아니라 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문이다.

행동 관점에서 상속과 다형성의 기본적인 개념을 이해하기 위해서는 상속 관계로 연결된 클래스 사이의 메서드 탐색 과정을 이해하는 것이 가장 중요하다.

객체의 경우 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다. 예제에서 인스턴스는 두 개가 생성됐지만 클래스는 단 하나만 메모리에 로드됐다. 각 객체는 자신의 클래스의 위치를 가리키는 class라는 이름의 포인터를 가지며 이 포인터를 이용해 자신의 클래스 정보에 접근할 수 있다. 클래스 사이에는 parent라는 이름의 포인터로 연결돼있다. 이 포인터를 이용하면 클래스의 상속 계층을 따라 부모 클래스의 정의로 이동하는 것이 가능하다. 즉 각 객체에 포함된 class 포인터와 클래스에 포함된 parent 포인터를 조합해 현재 인스턴스의 클래스에서 최상위 부모 클래스에 이르기까지 모든 클래스에 접근하는 것이 가능하다.

메시지를 수신한 객체는 class 포인터로 연결된 자신의 클래스에서 적절한 메서드가 존재하는지를 찾는다. 만약 메서드가 존재하지 않으면 클래스의 parent 포인터를 따라 부모 클래스를 차례대로 훑어 가면서 적절한 메서드가 존재하는지를 탐색한다. 자식 클래스에서 부모 클래스로의 메서드 탐색이 가능하기 때문에 자식 클래스는 마치 부모 클래스에 구현된 메서드의 복사본을 가지고 있는 것처럼 보이게 된다. 하지만 사실 탐색하는 것이었다.

12_3 업캐스팅과 동적 바인딩

예제를 보면 동일한 객체 참조에 동일한 메시지를 전송하는 동일한 코드 안에서 서로 다른 클래스 안에 구현된 메서드를 실행한다는 사실을 알 수 있다. 코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅과 동적 바인딩이라는 메커니즘이 작용하기 때문이다.

  • 업캐스팅: 부모 클래스 타입으로 선언된 변수에 자식 클래스의 인스턴스를 할당하는 것을 뜻한다. 서로 다른 클래스의 인스턴스를 동일한 타입에 할당하는 것을 가능하게 해준다. 따라서 부모 클래스에 대해 작성된 코드를 전혀 수정하지 않고도 자식 클래스에 적용할 수 있다.
  • 동적 바인딩: 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다는 것을 뜻한다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다. 부모 클래스의 타입에 대해 메시지를 전송하더라도 실행 시에는 실제 클래스를 기반으로 실행될 메서드가 선택되게 해준다. 따라서 코드를 변경하지 않고도 실행되는 메서드를 변경할 수 있다.

개방 폐쇄 원칙과 업캐스팅, 동적 바인딩

개방 폐쇄 원칙이 목적이라면 업캐스팅과 동적 메서드 탐색은 목적에 이르는 방법이다. 업캐스팅과 동적 메서드 탐색은 상속을 이용해 개방 폐쇄 원칙을 따르는 코드를 작성할 때 하부에서 동작하는 기술적인 내부 메커니즘을 설명한다.

업캐스팅

업캐스팅의 특성을 활용할 수 있는 대표적인 두 가지가 대입문과 메서드의 파라미터 타입이다. 모든 객체지향 언어는 명시적으로 타입을 변환하지 않고도 부모 클래스 타입의 참조 변수에 자식 클래스의 인스턴스를 대입할 수 있게 허용한다. 반대로 부모 클래스의 인스턴스를 자식 클래스 타입으로 변환하기 위해서는 명시적인 타입 캐스팅이 필요한데, 이를 다운캐스팅이라고 부른다.

컴파일러의 관점에서 자식 클래스는 아무런 제약 없이 부모 클래스를 대체할 수 있기 때문에 부모 클래스와 협력하는 클라이언트는 다양한 자식 클래스의 인스턴스와도 협력하는 것이 가능하다. 여기서 자식 클래스는 현재 상속 계층에 존재하는 자식 클래스뿐만 아니라 앞으로 추가될지도 모르는 미래의 자식 클래스들을 포함한다. 따라서 클라이언트 입장에서 부모 클래스를 상속받는 어떤 자식 클래스와도 협력할 수 있기 때문에 무한한 확장성을 가진다. 즉 이 설계는 유연하며 확장이 용이하다.

동적 바인딩

전통적인 언어에서 함수를 실행하는 방법은 함수를 호출하는 것이다. 객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다. 이 차이는 생각보다 큰데 프로그램 안에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적인 메커니즘이 완전히 다르기 때문이다.

함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일타임에 결정한다. 즉 코드를 작성하는 시점에 호출될 코드가 결정된다. 이처럼 컴파일타임에 호출할 함수를 결정하는 방식을 정적 바인딩, 초기 바인딩, 컴파일타임 바인딩이라고 부른다.

객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. 예를 들어, foo.bar()라는 코드가 있을 때 이걸 읽는 것만으로는 실행되는 bar가 어떤 클래스의 어떤 메서드인지를 판단하기 어렵다. foo가 가리키는 객체가 실제로 어떤 클래스의 인스턴스인지를 알아야 하고 bar 메서드가 해당 클래스의 상속 계층의 어디에 위치하는지를 알아야 한다. 이처럼 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩 또는 지연 바인딩이라고 부른다.

12_4 동적 메서드 탐색과 다형성

객체지향 시스템이 실행할 메서드를 선택하는 규칙은 다음과 같다.

  1. 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
  2. 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
  3. 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.

메시지 탐색과 관련하여 self 참조를 잘 이해해야한다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. 동적 메서드 탐색은 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방향으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다. 시스템은 class 포인터, parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색한다.

메서드 탐색은 자식 클래스에 부모 클래스의 방향으로 진행된다. 따라서 항상 자식 클래스의 메서드가 부모 클래스의 메서드보다 먼저 탐색되기 때문에 자식 클래스에 선언된 메서드가 부모 클래스의 메서드보다 더 높은 우선순위를 가지게 된다. 동적 메서드 탐색은 두 가지 원리로 구성된다.

첫 번째 원리는 자동적인 메시지 위임이다. 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다. 클래스 사이의 위임은 프로그래머의 개입 없이 상속 계층을 따라 자동으로 이뤄진다.

두 번째 원리는 메서드를 탐색하기 위해 동적인 문맥을 사용한다는 것이다. 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지를 결정하는 것은 컴파일 시점이 아닌 실행 시점에 이뤄지며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정한다. 메시지가 처리되는 문맥을 이해하기 위해서는 정적인 코드를 분석하는 것만으로는 충분하지 않고, 런타임에 실제로 메시지를 수신한 객체가 어떤 타입인지를 추적해야 한다. 이 객체의 타입에 따라 메서드를 탐색하는 문맥이 동적으로 결정되며, 여기서 가장 중요한 역할을 하는 것이 바로 self 참조다.

자동적인 메시지 위임

동적 메서드 탐색의 입장에서 상속 계층은 메시지를 수신한 객체가 자신이 이해할 수 없는 메시지를 부모 클래스에게 전달하기 위한 물리적인 경로를 정의하는 것으로 볼 수 있다. 핵심은 적절한 메서드를 찾을 때까지 상속 계층을 따라 부모 클래스로 처리가 위임된다는 것이다.

상속을 이용할 경우 프로그래머가 메시지 위임과 관련된 코드를 명시적으로 작성할 필요가 없다. 메시지는 상속 계층을 따라 부모 클래스에게 자동으로 위임된다. 이런 관점에서 상속 계층을 정의하는 것은 메서드 탐색 경로를 정의하는 것과 동일하다. 상속은 메시지 위임의 하나일 뿐 자동적인 메시지 위임을 지원하는 방법은 언어에 따라 다를 수 있다.

동적인 문맥

메시지를 수신한 객체가 무엇이냐에 따레 메서드 탐색을 위한 문맥은 동적으로 바뀐다. 이 동적인 문맥을 결정하는 것은 바로 메시지를 수신한 객체를 가리키는 self 참조다.

동적인 코드라고 하더라도 self 참조가 가리키는 객체가 무엇인지에 따라 메서드 탐색을 위한 상속 계층의 범위가 동적으로 변한다. 따라서 self 참조가 가리키는 객체의 타입을 변경함으로써 객체가 실행될 문맥을 동적으로 바꿀 수 있다.

self 참조가 동적인 문맥을 결정한다는 사실은 종종 어떤 메서드가 실행될지를 예상하기 어렵게 만든다. 대표적인 경우가 자신에게 다시 메시지를 전송하는 self 전송이다. 예제를 보면, 현재 클래스의 메서드를 호출하는 것이 아니라 현재 객체에게 메시지를 전송한다. 현재 객체란 self 참조가 가리키는 객체를 말한다. self 참조가 가리키는 자기 자신에게 메시지를 전송하는 것을 self 전송이라고 부른다. self 전송을 이해하기 위해서는 self 참조가 가리키는 바로 그 객체에서부터 메시지 탐색을 다시 시작한다는 사실을 기억해야 한다. self 전송은 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다. 이로 인해 최악의 경우 실제로 실행될 메서드를 이해하기 위해 상속 계층 전체를 훑어가며 코드를 이해해야 하는 상황이 발생할 수도 있다. 결과적으로 self 전송이 깊은 상속 계층과 계층 중간중간에 함정처럼 숨겨져 있는 메서드 오버라이딩과 만나면 극단적으로 이해하기 어려운 코드가 만들어진다.

이해할 수 없는 메시지

이해할 수 없는 메시지를 처리하는 방법은 프로그래밍 언어가 정적 타입 언어에 속하는지, 동적 타입 언어에 속하는지에 따라 달라진다.

정적 타입 언어와 이해할 수 없는 메시지

정적 타입 언어에서는 코드를 컴파일할 때 상속 계층 안의 클래스들이 메시지를 이해할 수 있는지 여부를 판단한다. 상속 계층 전체를 탐색한 후에도 메시지를 처리할 수 있는 메서드를 발견하지 못했다면 컴파일 에러를 발생시킨다.

동적 타입 언어와 이해할 수 없는 메시지

동적 타입 언어에는 컴파일 단계가 존재하지 않기 때문에 실제로 코드를 실행해보기 전에는 메시지 처리 가능 여부를 판단할 수 없다는 점이다. 동적 타입 언어에서 최상위 클래스까지 메서드를 탐색한 후에 메서드를 처리할 수 없다는 사실을 발견하면 self 참조가 가리키는 현재 객체에게 메시지를 이해할 수 없다는 메시지를 전송한다. 이후 예외가 던져지거나, 객체가 자신의 인터페이스에 정의되지 않는 메시지를 처리한다.

이해할 수 없는 메시지를 처리할 수 있는 동적 타입 언어는 좀 더 순수한 관점에서 객체지향 패러다임을 구현한다고 볼 수 있다. 동적 타입 언어는 이해할 수 없는 메시지를 처리할 수 있는 능력을 가짐으로써 메시지가 선언된 인터페이스와 메서드가 정의된 구현을 분리할 수 있다. 메시지 전송자는 자신이 원하는 메시지를 전송하고 메시지 수신자는 스스로의 판단에 따라 메시지를 처리한다. 이것은 메시지를 기반으로 협력하는 자율적인 객체라는 순수한 객체지향의 이상에 좀 더 가까운 것이다. 그러나 이러한 특성과 유연성은 코드를 이해하고 수정하기 어렵게 만들뿐만 아니라 디버깅 과정을 복잡하게 만들기도 한다.

정적 타입 언어에는 이런 유연성이 부족하지만 좀 더 안정적이다. 모든 메시지는 컴파일타임에 확인되고 이해할 수 없는 메시지는 컴파일 에러로 이어진다. 이해할 수 없는 메시지를 처리할 수 있는 유연성은 잃게 되지만 실행 시점에 오류가 발생할 가능성을 줄임으로써 프로그램이 좀 더 안정적으로 실행될 수 있다.

self 대 super

self 참조의 가장 큰 특징은 동적이라는 점이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. 이런 특성을 가진 self 참조와 대비할만한 게 super 참조다. self 전송이 메시지를 수신하는 객체의 클래스에 따라 메서드를 탐색할 시작 위치를 동적으로 결정하는 데 비해 super 전송은 항상 메시지를 전송하는 클래스의 부모 클래스에서부터 시작된다.

사실 super 참조의 용도는 부모 클래스에 정의된 메서드를 실행하기 위한 것이 아니다. super 참조의 정확한 의도는 ‘지금 이 클래스의 부모클래스에서부터 메서드 탐색을 시작하세요’다. 만약 부모 클래스에서 우너하는 메서드를 찾지 못한다면 더 상위의 부모 클래스로 이동하면서 메서드가 존재하는지 검사한다. 이것은 super 참조를 통해 실행하고자 하는 메서드가 반드시 부모 클래스에 위치하지 않아도 되는 유연성을 제공한다. 그 메서드가 조상 클래스 어딘가에 있기만 하면 성공적으로 탐색될 것이기 때문이다.

부모 클래스의 메서드를 호출하는 것과 부모 클래스에서 메서드 탐색을 시작하는 것은 의미가 매우 다르다. 부모 클래스의 메서드를 호출하는 것은 그 메서드가 반드시 부모 클래스 안에 정의돼 있어야 한다는 것을 의미한다. 그에 비해 부모 클래스에서 메서드 탐색을 시작한다는 것은 그 클래스의 조상 어딘가에 그 메서드가 정의돼 있기만 하면 실행할 수 있다는 것을 의미한다. 이처럼 super 참조를 통해 메시지를 전송하는 것은 마치 부모 클래스의 인스턴스에게 메시지를 전송하는 것처럼 보이기 때문에 이를 super 전송이라고 부른다.

self 전송은 어떤 클래스에서 메시지 탐색이 시작될지 알지 못한다. super 전송은 항상 해당 클래스의 부모 클래스에서부터 메서드 탐색을 시작한다. self 전송에서 메시지 탐색을 시작하는 클래스는 미정이지만 super 전송에서는 미리 정해진다는 것이다. 따라서 self 전송의 경우 메서드 탐색을 시작할 클래스를 반드시 실행 시점에 동적으로 결정해야 하지만 super 전송의 경우 컴파일 시점에 미리 결정해 놓을 수 있다.

super 전송과 동적 바인딩

상속에서 super가 컴파일 시점에 미리 결정된다고 설명했지만 super를 런타임에 결정하는 경우도 있다. 믹스인을 설명하면서 예로 들었던 스칼라의 트레이트는 super의 대상을 믹스인되는 순서에 따라 동적으로 결정한다. 대부분의 객체지향 언어에서 상속을 사용하는 경우 super가 컴파일타임에 결정되지만, 언어의 특성에 따라 컴파일 시점이 아닌 실행 시점에 super의 대상이 결정될 수도 있다는 점을 기억하라.

정리

동적 바인딩과 self 참조는 동일한 메시지를 수신하더라도 객체의 타입에 따라 적합한 메서드를 동적으로 선택할 수 있게 한다. super 참조는 부모 클래스의 코드에 접근할 수 있게 함으로써 중복 코드를 제거할 수 있게 한다.

12_5 상속 대 위임

다형성은 self 참조가 가리키는 현재 객체에게 메시지를 전달하는 특성을 기반으로 한다. 동일한 타입의 객체 참조에게 동일한 메시지를 전송하더라도 self 참조가 가리키는 객체의 클래스가 무엇이냐에 따라 메서드 탐색을 위한 문맥이 달라진다.

위임과 self 참조

예제에서 중요한 것은 네 가지다.

첫째, 컴파일러가 제공해주던 동적 메서드 탐색 메커니즘을 직접 구현한 것이다.

둘째, 자식 클래스에서 메서드가 존재하지 않을 경우에 부모 클래스에서 메서드 탐색을 계속하는 동적 메서드 탐색 과정을 흉내낸 것이다. 동적 메서드 탐색은 런타임에 클래스의 메타 정보를 이용해 자동으로 처리를 위임하지만 여기에서는 메시지 전달 과정을 직접 구현하고 있다는 차이가 있을 뿐이다. 실행 문맥을 자식 클래스에서 부모 클래스로 전달하는 상속 관계를 흉내 내기 위해 인자로 전달받은 this를 그대로 전달했다.

셋째, 부모 클래스의 메서드와 동일한 메서드를 구현하고 부모 클래스와는 다른 방식으로 메서드를 구현하여 상속에서의 메서드 오버라이딩을 흉내냈다.

넷째, self 전송에 의한 동적 메서드 탐색 과정을 흉내냈다.

예제에서는 자식 클래스가 메시지를 직접 처리하지 않고 부모 클래스에게 전달한다. 이처럼 자신이 수신한 메시지를 다른 책게체엑 동일하게 전달해서 처리를 요청하는 것을 위임이라고 부른다. 위임은 본질적으로는 자신이 정의하지 않거나 처리할 수 없는 속성 또는 메서드의 탐색 과정을 다른 객체로 이동시키기 위해 사용한다. 이를 위해 위임은 항상 현재의 실행 문맥을 가리키는 self 참조를 인자로 전달한다. 이것이 self 참조를 전달하지 않는 포워딩과 위임의 차이다.

포워딩과 위임

객체가 다른 객체에게 요청을 처리할 때 인자로 self를 전달하지 않을 수도 있다. 이것은 요청을 전달받은 최초의 객체에 다시 메시지를 전송할 필요는 없고 단순히 코드를 재사용하고 싶은 경우라고 할 수 있따. 이철머 처리를 요청할 때 self 참조를 전달하지 않는 경우를 포워딩이라고 부른다. 이와 달리 self 참조를 전달하는 경우에는 위임이라고 부른다. 위임의 정확한 용도는 클래스를 이용한 상속 관계를 객체 사이의 합성 관계로 대체해서 다형성을 구현하는 것이다.

위임은 객체 사이의 동적인 연결 관계를 이용해 상속을 구현하는 방법이다. 상속이 매력적인 이유는 우리가 직접 구현해야 하는 이런 번잡한 과정을 자동으로 처리해 준다는 점이다. 실행 시 인스턴스들 사이에서 self 참조가 자동으로 전달되고, 이 전달은 결과적으로 자식 클래스의 인스턴스와 부모 클래스의 인스턴스 사이에 동일한 실행 문맥을 공유할 수 있게 해준다.

프로토타입 기반의 객체지향 언어

클래스가 존재하지 않고 오직 객체만 존재하는 프로토타입 기반의 객체지향 언어에서 상속을 구현하는 유일한 방법은 객체 사이의 위임을 이용하는 것이다. 클래스 기반의 객체지향 언어들이 상속을 이용해 클래스 사이에 self 참조를 자동으로 전달하는 것처럼 프로토타입 기반의 객체지향 언어들 역시 위임을 이용해 객체 사이에 self 참조를 자동으로 전달한다.

prototype은 앞의 예제에서 부모 객체를 가리키기 위해 사용했던 인스턴스 변수 @parent와 동일한 것으로 봐도 무방하다. 차이점이라면 protytype은 언어 차원에서 제공되기 때문에 self 참조를 직접 전달하거나 메시지 포워딩을 번거롭게 직접 구현할 필요가 없다는 점이다. 프로토타입 기반의 대표 예시인 자바스크립트에서는 prototype 체인으로 연결된 객체 사이에 메시지를 위임함으로써 상속을 구현할 수 있다.

프로토타입 기반의 객체지향 언어에서 메서드를 탐색하는 과정은 클래스 기반 언어의 상속과 거의 동일하다. 단지 정적인 클래스 간의 관계가 아니라 동적인 객체 사이의 위임을 통해 상속을 구현하고 있을 뿐이다.

자바스크립트에는 클래스가 존재하지 않기 때문에 오직 객체들 사이의 메시지 위임만을 이용해 다형성을 구현한다. 이것은 객체지향 패러다임에서 클래스가 필수 요소가 아니라는 점을 보여준다. 또한 상속 이외의 방법으로도 다형성을 구현할 수 있다는 사실도 보여준다.

클래스는 객체를 편리하게 정의하고 생성하기 위해 제공되는 프로그래밍 구성 요소일 뿐이며 중요한 것은 메시지와 협력이다. 클래스 없이도 객체 사이의 협력 관계를 구축하는 것이 가능하며 상속 없이도 다형성을 구현하는 것이 가능하다.

13_서브클래싱과 서브타이핑

상속의 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고 자식 클래스는 특수한 개념을 구현한다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화이고 자식 클래스는 부모 클래스의 특수화이다. 상속의 두 번째 용도는 코드 재사용이다. 하지만 이 용도로 상속을 사용하는 건 바람직하지 않다.

동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다. 상속의 가치는 이러한 타입 계층을 구현할 수 있는 쉽고 편한 방법을 제공한다는 데 있다.

객체지향 프로그래밍과 객체기반 프로그래밍

객체지향 프로그래밍은 객체기반 프로그래밍과 마찬가지로 객체들을 조합해서 애플리케이션을 개발하지만 상속과 다형성을 지원한다는 점에서 객체기반 프로그래밍과 차별화된다.

13_1 타입

개념 관점의 타입

개념 관점에서 타입이란 우리가 인지하는 세상의 사물의 종류를 의미한다. 어떤 대상이 타입으로 분류될 때 그 대상을 타입의 인스턴스라고 부른다. 일반적으로 타입의 인스턴스를 객체라고 부른다. 타입은 심볼, 내연, 외연으로 구성된다.

  • 심볼: 타입에 이름을 붙인 것이다.
  • 내연: 타입의 정의로서 타입에 속하는 객체들이 가지는 공통적인 속성이나 행동을 가리킨다.
  • 외연: 타입에 속하는 객체들의 집합이다.

프로그래밍 언어 관점의 타입

프로그래밍 언어 관점에서 타입은 연속적인 비트에 의미와 제약을 부여하기 위해 사용된다. 비트 자체에는 타입이라는 개념이 존재하지 않는다. 비트에 담긴 데이터를 문자열로 다룰지, 정수로 다룰지는 전적으로 데이터를 사용하는 애플리케이션에 의해 결정된다. 따라서 프로그래밍 언어의 관점에서 타입은 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙을 가리킨다. 타입은 두 가지 목적을 위해 사용된다.

  1. 타입에 수행될 수 있는 유효한 오퍼레이션의 집합을 정의한다. 자바의 ‘+’ 연산자 예시를 통해 알 수 있듯, 모든 객체지향 언어들은 객체의 타입에 따라 적용 가능한 연산자의 종류를 제한함으로써 프로그래머의 실수를 막아준다.
  2. 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공한다. 같은 + 연산자더라도 대상이 int 냐, String 이냐에 따라 다르게 행동한다. 즉 타입이 행동에 대한 문맥을 제공한 것이다.

정리하면 타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다.

객체지향 패러다임 관점의 타입

객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것이다. 객체가 수신할 수 있는 메시지의 집합을 퍼블릭 인터페이스라고 부른다. 객체지향 프로그래밍에서 타입을 정의하는 것은 객체의 퍼블릭 인터페이스를 정의하는 것과 동일하다. 객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류된다. 객체에게 중요한 것은 속성이 아니라 행동이다.

13_2 타입 계층

타입 사이의 포함관계

타입 계층을 구성하는 두 타입 간의 관계에서 더 일반적인 타입을 슈퍼타입이라고 부르고 더 특수한 타입을 서브타입이라고 부른다.

객체의 정의를 의미하는 내연 관점에서 일반화란 어떤 타입의 정의를 좀 더 보편적이고 추상적으로 만드는 과정을 의미한다. 반대로 특수화란 어떤 타입의 정의를 좀 더 구체적이고 문맥 종속적으로 만드는 과정을 의미한다. 일반화는 다른 타입을 완전히 포함하거나 내포하는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다. 특수화는 다른 타입 안에 전체적으로 포함되거나 완전히 내포되는 타입을 식별하는 행위 또는 그 행위의 결과를 가리킨다. 내연 관점에서 서브타입의 정의가 슈퍼 타입의 정의보다 더 구체적이고 외연의 관점에서 서브타입에 속하는 객체들의 집합이 슈퍼타입에 속하는 객체들의 집합에 포함된다는 사실을 알 수 있다. 내연과 외연의 관점에서 서프타입과 슈퍼타입을 정의하면 다음과 같다. 슈퍼타입은 집합이 다른 집합의 모든 멤버를 포함하고 타입 정의가 다른 타입보다 좀 더 일반적인 특징을 가지는 타입이다. 서브타입은 집합에 포함되는 인스턴스들이 더 큰 집합에 포함되고 타입 정의가 다른 타입보다 좀 더 구체적인 특징르 가지는 타입이다.

객체지향 프로그래밍과 타입 계층

일반적인 타입이란 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 일반적인 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 특수한 타입이란 비교하려는 타입에 속한 객체들의 퍼블릭 인터페이스보다 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 타입을 의미한다. 퍼블릭 인터페이스 관점에서 슈퍼타입과 서브타입을 정의하면 다음과 같다. 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것이다. 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것이다. 중요한 것은 일반적인 타입과 구체적인 타입 간의 관계를 형성하는 기준이 퍼블릭인터페이스라는 것이다. 서브타입의 인스턴스는 슈퍼타입의 인스턴스로 간주될 수 있다.

13_3 서브클래싱과 서브타이핑

객체지향 프로그래밍 언어에서 타입을 구현하는 일반적인 방법은 클래스를 이용하는 것이다. 타입 계층을 구현하는 일반적인 방법은 상속을 이용하는 것이다. 상속을 이용해 타입 계층을 구현한다는 것은 클래스 사이의 관계(부모 - 슈퍼타입, 자식 - 서브타입)를 정의한다는 것이다.

언제 상속을 사용해야 하는가?

두 가지 질문을 해보고 둘 다 예일 때 상속을 사용하자.

  1. 상속 관계가 is-a 관계를 모델링하는가?

    이것은 애플리케이션을 구성하는 어휘에 대한 우리의 관점에 기반한다. 일반적으로 “자식 클래스는 부모 클래스다”라고 말해도 이상하지 않다면 상속을 사용할 후보로 간주할 수 있다.

  2. 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?

    상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 부른다.

is-a 관계

“타입 S는 타입 T다”라고 말할 수 있어야 한다. 그런데 이것은 생각보다 명쾌하지 않다. 새, 펭귄 예시는 is-a 관계가 직관을 배신한다는 걸 보여준다. 이 예는 어휘적인 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 보여준다.

타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있다. 그에 따라 올바른 타입 계층이라는 의미 역시 문맥에 따라 달라질 수 있다. 어떤 애플리케이션에서 새에게 날 수 있다는 행동을 기대하지 않고 단지 울음 소리를 낼 수 있다는 행동만 기대한다면 새와 펭귄을 타입 계층으로 묶어도 무방하다. 따라서 슈퍼타입과 서브타입 관계에서 is-a봗 행동 호환성이 더 중요하다.

어떤 두 대상을 언어적으로 is-a라고 표현할 수 있더라도 일단은 상속을 사용할 예비 후보 정도로만 생각하라. 두 가지 후보 개념이 어떤 방식으로 사용되고 협력하는지 살펴본 후에 상속의 적용 여부를 결정해도 늦지 않다.

행동 호환성

타입 이름 사이에 개념적으로 어떤 연관성이 있다고 하더라도 행동에 연관성이 없다면 is-a 관계를 사용하지 말아야 한다. 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 것이다.

행동의 호환 여부를 판단하는 기준은 클라이언트 관점이다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다. 중요한 것은 클라이언트의 기대다.

행동 호환성을 고려하지 않고 언어적 is-a관계에 현혹돼 상속을 사용하고, 어거지로 끼워맞추는 방법이 있다. 첫번째로 자식 클래스의 내부 구현을 비워두는 것이다. 하지만 이것은 클라이언트의 기대를 만족시키지 못한다. 이 경우 부모 클래스와 자식 클래스의 행동은 호환되지 않기 때문에 올바른 타입 계층이라고 할 수 없다. 둘째로 자식 클래스가 메서드 오버라이딩 후 예외를 던지게 하는 것이다. 하지만 이 역시 클라이언트의 기대를 저버린다. 따라서 행동이 호환되지 않는다. 셋째로 클라이언트가 부모 클래스 중 특정 자식 클래스가 아닌 경우에만 메서드를 호출하는 것이다. 하지만 이 경우 행동이 호환되지 않는 자식 클래스가 늘어날수록 타입을 체크하는 코드를 추가해야 한다는 문제가 있다. 이것은 new 연산자와 마찬가지로 구체적인 클래스에 대한 결합도를 높인다. 일반적으로 instanceof처럼 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때마다 코드 수정을 요구하기 때문에 개방 폐쇄 원칙을 위반한다.

클라이언트의 기대에 따라 계층 분리하기

문제를 해결할 수 있는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것뿐이다. 날 수 있는 새와 날 수 없는 새를 명확하게 구분할 수 있게 상속 계층을 분리하면 서로 다른 요구사항을 가진 클라이언트를 만족시킬 수 있을 것이다. 예제는 Bird - FlyingBird / Penguin 으로 분리해 클라이언트가 잘못된 객체와 협력해서 기대했던 행동이 수행되지 않거나 예외가 던져지는 일은 일어나지 않는다.

이 문제를 해결하는 다른 방법은 클라이언트에 따라 인터페이스를 구분하는 것이다. 클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있게 된다. 대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다. 클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있게 된다. 이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙이라고 부른다. 이 원칙은 비대한 인터페이스의 단점을 해결한다. 비대한 인터페이스를 가지는 클래스는 응집성이 없는 인터페이스를 가지는 클래스다. 즉 이런 클래스의 인터페이스는 메서드의 그룹으로 분해될 수 있고, 각 그룹은 각기 다른 클라이언트의 집합을 지원한다. 비대한 클래스는 그것의 클라이언트 사이에 이상하고 해로운 결합이 생기게 만든다. 한 클라이언트가 비대한 클래스에 변경을 가하면, 나머지 모든 클래스가 영향을 받게 된다. 그러므로 클라이언트는 자신이 실제로 호출하는 메서드에만 의존해야만 한다. 이것은 비대한 클래스의 인터페이스를 여러 개의 클라이언트에 특화된 인터페이스로 분리함으로써 성취될 수 있다. 이렇게 하면 호출하지 않는 메서드에 대한 클라이언트의 의존성을 끊고, 클라이언트가 서로에 대해 독립적이 되게 만들 수 있다. 변경 후 설계는 날 수 있는 새와 날 수 없는 새가 존재한다는 현실 세계를 정확하게 반영한다. 하지만 주의할 것이 있는데, 설계가 꼭 현실 세계를 반영할 필요는 없다는 것이다. 중요한 것은 설계가 반영할 도메인의 요구사항이고 그 안에서 클라이언트가 객체에게 요구하는 행동이다. 현재의 요구사항이 날 수 있는 행동에 관심이 없다면 상속 계층에 FlyingBird를 추가하는 것은 설계를 불필요하게 복잡하게 만든다. 현실을 정확하게 묘사하는 것이 아니라 요구사항을 실용적으로 수용하는 것을 목표로 삼아야 한다.

요점은 자연어에 현혹되지 말고 요구사항 속에서 클라이언트가 기대하는 행동에 집중하라는 것이다. 클래스의 이름 사이에 어떤 연관성이 있다는 사실은 아무런 의미도 없다. 두 클래스 사이에 행동이 호환되지 않는다면 올바른 타입 계층이 아니기 때문에 상속을 사용해서는 안 된다.

서브클래싱과 서브타이핑

서브클래싱과 서브타이핑을 나누는 기준은 상속을 사용하는 목적이다.

서브클래싱은 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 가리킨다. 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없다. 서브클래싱을 구현 상속 또는 클래스 상속이라고 부르기도 한다.

서브타이핑은 타입 계층을 구성하기 위해 상속을 사용하는 경우를 가리킨다. 즉 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적으로 사용한다. 서브타이핑에서는 자식 클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 있다. 슈퍼타입 인스턴스를 요구하는 모든 곳에서 서브타입의 인스턴스를 대신 사용하기 위해 만족해야 하는 최소한의 조건은 서브타입의 퍼블릭 인터페이스가 슈퍼타입에서 정의한 퍼블릭 인터페이스와 동일하거나 더 많은 오퍼레이션을 포함해야 한다는 것이다. 그래서 인터페이스 상속이라고도 부른다. 서브타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 즉 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성을 만족해야 한다. 그리고 행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제없이 동작할 것이라는 것을 보장해야 한다. 다시 말해 자식 클래스와 부모 클래스 사이의 행동 호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성을 포함한다. 행동 호환성과 대체 가능성은 올바른 상속 관계를 구축하기 위해 따라야 할 지침이라고 할 수 있다.

13_4 리스코프 치환 원칙

리스코프 치환 원칙은 올바른 상속 관계의 특징을 정의하기 위해 발표됐다. 서브타이핑을 정의하자면, S형의 각 객체 o1에 대해 T형의 객체 o2가 하나 있고, T에 의해 정의된 모든 프로그램 P에서 T가 S로 치환될 때, P의 동작이 변하지 않으면 S는 T의 서브타입이다.” 서브타입은 그것의 기반 타입에 대해 대체 가능해야한다. 클라이언트가 차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다.

리스코프 치환 원칙은 앞에서 논의한 행동 호환성을 설계 원칙으로 정리한 것이다. 자식 클래스가 부모 클래스와 행동 호환성을 유지함으로써 부모 클래스를 대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라고 부른다. 앞의 Vector-Stack 예제는 클라이언트가 부모 클래스인 Vector에 대해 기대하는 행동을 Stack에 대해서는 기대할 수 없기 때문에 행동 호환성을 만족시키지 않는다는 것을 보여준다. 정사각형, 직사각형도 마찬가지다. 정사각형과 직사각형 사이에 어휘적으로 is-a 관계가 성립한다. 하지만 행동 관점에서는 그렇지 않다. 직사각형과 협력하는 클라이언트는 직사각형의 너비와 높이가 다르다고 가정한다. 예제에서 정사각형은 직사각형의 구현을 재사용하고 있을 뿐이다. 두 클래스는 리스코프 치환 원칙을 위반하기 때문에 서브타이핑 관계가 아니라 서브클래싱 관계다. 어휘적으로 is-a 관계가 성립하냐보다 클라이언트 관점에서 행동이 호환되는지 여부가 중요하다.

클라이언트와 대체 가능성

정사각형이 직사각형을 대체할 수 없는 이유는 클라이언트 관점에서 정사각형과 직사각형이 다르기 때문이다. 리스코프 치환 원칙은 자식 클래스가 부모 클래스를 대체하기 위해서는 부모 클래스에 대한 클라이언트의 가정을 준수해야 한다는 것을 강조한다. 앞의 Vector, Stack 예제는 부모, 자식 간 행동이 호환되지 않는다는 것을 보여주고, 이것은 Stack과 Vector가 서로 다른 클라이언트와 협력해야 한다는 것을 의미한다.

리스코프 치환 원칙은 클라이언트와 격리한 채로 본 모델은 의미 있게 검증하는 것이 불가능하다는 결론을 이끈다. 어떤 모델의 유효성은 클라이언트의 관점에서만 검증 가능하다. 상속 관계는 클라이언트의 관점에서 자식 클래스가 부모 클래스를 대체할 수 있을 때만 올바르다. 대체 가능성을 결정하는 것은 클라이언트다.

is-a 관계 다시 살펴보기

클라이언트 관점에서 자식 클래스의 행동이 부모 클래스의 행동과 호환되지 않고 그로 인해 대체가 불가능하다면 어휘적으로 Is-a라고 말할 수 있다고 하더라도 그 관계를 is-a 관계라고 할 수 없다. is-a 관계는 객체지향에서 중요한 것은 객체의 속성이 아니라 객체의 행동이라는 점을 강조한다. is-a 관계를 행동이 호환되는 타입에 어떤 이름을 붙여야 하는지 설명하는 가이드라고 생각하는 것이 좋다. 슈퍼타입과 서브타입이 클라이언트 입장에서 행동이 호환된다면 두 타입을 is-a로 연결해 문장을 만들어도 어색하지 않은 단어로 타입의 이름을 정하라는 것이다. 결론적으로 상속이 서브타이핑을 위해 사용될 경우에만 is-a 관계다.

리스코프 치환 원칙은 유연한 설계의 기반이다

리스코프 치환 원칙은 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조를 구현할 수 있는 가이드라인을 제공한다. 새로운 자식 클래스를 추가하더라도 클라이언트의 입장에서 동일하게 행동하기만 한다면 클라이언트를 수정하지 않고도 상속 계층을 확장할 수 있다. 즉 클라이언트 입장에서 퍼블릭 인터페이스의 행동 방식이 변경되지 않는다면 클라이언트의 코드를 변경하지 않고도 새로운 자식 클래스와 협력할 수 있게 된다.

리스코프 치환 원칙을 따르는 설계는 유연할뿐만 아니라 확장성이 높다. 자식 클래스가 클라이언트의 관점에서 부모 클래스를 대체할 수 있다면 기능 확장을 위해 자식 클래스를 추가하더라도 코드를 수정할 필요가 없어진다. 따라서 리스코프 치환 원칙은 개방 폐쇄 원칙을 만족하는 설계를 위한 전제 조건이다.

타입 계층과 리스코프 치환 원칙

상속은 타입 계층을 구현할 수 있는 여러 방법 중 하나일 뿐이다. 구현 방법은 중요하지 않다. 핵심은 구현 방법과 무관하게 클라이언트의 관점에서 슈퍼타입에 대해 기대하는 모든 것이 서브타입에게도 적용돼야 한다는 것이다. 상속이 아닌 다른 방법을 이용하더라도 클라이언트의 관점에서 서로 다른 구성 요소를 동일하게 다뤄야 한다면 서브타이핑 관계의 제약을 고려해서 리스코프 치환 원칙을 준수해야 한다.

13_5 계약에 의한 설계와 서브타이핑

협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계라고 부른다. 계약에 의한 설계는 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 사전조건과 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후조건, 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식의 세 가지 요소로 구성된다.

리스코프 치환 원칙은 어떤 타입이 서브타입이 되기 위해서는 슈퍼타입의 인스턴스와 협력하는 클라이언트의 관점에서 서브타입의 인스턴스가 슈퍼타입을 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다. 서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 계약을 준수해야 한다. 서브타입이 슈퍼타입처럼 보일 수 있는 유일한 방법은 클라이언트가 슈퍼타입과 맺은 계약을 서브타입이 준수하는 것뿐이다.

예제를 통해 다음을 알 수 있다. 서브타입에 더 강력한 사전조건을 정의할 수 없다. 서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있다. 서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다. 서브타입에 더 약한 사후조건을 정의할 수 없다.

서브타이핑을 위해 상속을 사용하고 있다면 부모 클래스가 클라이언트와 맺고 있는 계약에 관해 깊이 있게 고민하라.

14_일관성 있는 협력

애플리케이션을 개발하다 보면 유사한 수정을 해야할 때가 있는데, 제멋대로 코딩하다보면 전체 설계의 일관성이 무너지게 된다. 객체지향 패러다임의 장점인 설계를 재사용하기 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다. 일관성은 설계에 드는 비용을 감소시킨다. 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 시간과 노력을 대폭 줄일 수 있기 때문이다. 나아가 일관성 있는 설계를 하면 코드가 이해하기 쉬워진다. 문제를 이해하는 것만으로도 코드의 구조를 예상할 수 있게되기 때문이다. 요약하면, 일관성 있는 협력 패턴을 적용하면 코드가 이해하기 쉽고 직관적이며 유연해진다.

14_1 일관성 없는 설계 예시

예시에서 클래스들은 기본 정책을 구현한다는 공통의 목적을 공유한다. 하지만 정책을 구현하는 방식은 완전히 다르다. 다시 말해 개념적으로는 연관돼 있지만 구현 방식에 있어서는 완전히 제각각이라는 것이다.

비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다. 기능 추가 관점에서 볼 때, 다양한 방식으로 기능을 추가할 수 있지만 현재 설계에서는 새로운 기능을 추가하면 추가할수록 코드 사이의 일관성은 점점 더 어긋나게 된다. 기존의 설계가 어떤 가이드도 제공하지 않기 때문에 새로운 기능을 구현해야 하는 상황에서 새로운 방식으로 기능을 구현할 가능성이 높다. 코드의 이해 측면에서 볼 때, 유사한 요구사항이 서로 다른 방식으로 구현돼 있다면 상식과 다르기 때문에(일반적으로 유사하게 구현할 것이라고 예상) 요구사항이 유사하다는 사실 자체도 의심하게 될 것이다.

유사한 기능은 유사한 방식으로 구현해야 한다.

14_2 설계에 일관성 부여하기

일관성 있는 협력을 위한 지침

  1. 변하는 개념을 변하지 않는 개념으로부터 분리하라.
  2. 변하는 개념을 캡슐화하라.

바뀌는 부분을 따로 뽑아서 캡슐화하면, 나중에 바뀌지 않는 부분에는 영향을 미치지 않은 채로 그 부분만 고치거나 확장할 수 있다. 예제 코드가 나쁜 이유는 변경의 주기가 서로 다른 코드가 한 클래스 안에 뭉쳐있기 때문이다. 또한 새로운 기능을 추가하기 위해서는 기존 코드의 내부를 수정해야 하기 때문에 오류가 발생할 확률이 높다. 조건에 따라 분기되는 어떤 로직들이 있다면 이 로직들이 바로 개별적인 변경이라고 볼 수 있다. 절차지향 프로그램에서 변경을 처리하는 전통적인 방법은 이처럼 조건문의 분기를 추가하거나 개별 분기 로직을 수정하는 것이다. 객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다. 객체지향적인 코드는 조건을 판단하지 않는다. 단지 다음 객체로 이동할 뿐이다. 조건 로직을 객체 사이의 이동으로 대체하기 위해서는 커다란 클래스를 작은 클래스들로 분리해야 한다. 이 때 분리하기 위한 기준은 ‘변경의 이유와 주기’다. 클래스는 명확히 단 하나의 이유에 의해서만 변경돼야 하고 클래스 안의 모든 코드는 함께 변경돼야 한다. 수정된 코드는 새로운 기능을 추가하기에 용이하다. 기존 코드가 새로운 기능 추가에 대한 가이드를 제공하기 때문이다. 게다가 기존 코드를 수정할 필요도 없다. 이 점을 통해 일관성 있는 협력을 지침인 ‘변하는 개념을 변하지 않는 개념으로 분리하라’와 ‘변하는 개념을 캡슐화하라’를 알 수 있다.

캡슐화 다시 살펴보기

일반적으로 캡슐화를 데이터 은닉이라고 생각한다. 그게 아니라, 캡슐화란 변하는 어떤 것이든 감추는 것이다. 캡슐화의 대표적인 예는 객체의 퍼블릭 인터페이스와 구현을 분리하는 것이다. 객체를 구현한 개발자는 필요할 때 객체의 내부 구현을 수정하길 원한다. 객체와 협력하는 클라이언트의 개발자는 객체의 인터페이스가 변하지 않기를 원한다. 따라서 자주 변경되는 내부 구현을 안정적인 퍼블릭 인터페이스 뒤로 숨겨야 한다.

캡슐화 종류

  • 데이터 캡슐화: 클래스는 내부에 관리하는 데이터를 캡슐화한다.
  • 메서드 캡슐화: 클래스의 외부에서는 감춰진 메서드에 직접 접근할 수 없다. 따라서 클래스 외부에 영향을 미치지 않고 메서드를 수정할 수 있다.
  • 객체 캡슐화: 객체와 객체 사이의 관계를 캡슐화 한다. 객체 캡슐화가 합성을 의미한다.
  • 서브타입 캡슐화: 서브타입의 종류를 캡슐화한다. 서브타입 캡슐화가 다형성의 기반이 된다.

일관성을 위한 캡슐화의 활용

코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다. 일반적으로 데이터 캡슐화와 메서드 캡슐화는 개별 객체에 대한 변경을 관리하기 위해 사용하고 객체 캡슐화와 서브타입 캡슐화는 협력에 참여하는 객체들의 관계에 대한 변경을 관리하기 위해 사용한다. 협력을 일관성 있게 만들기 위해 가장 일반적으로 사용하는 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다. 구체적인 방법은 다음과 같다.

  1. 변하는 부분을 분리해서 타입 계층을 만든다 - 변하는 부분들의 공통적인 행동을 추상 클래스나 인터페이스로 추상화한 후 변하는 부분들이 이 추상 클래스나 인터페이스를 상속받게 만든다. 이제 변하는 부분은 변하지 않는 부분의 서브타입이 된다.
  2. 변하지 않는 부분의 일부로 타입 계층을 합성한다 - 앞에서 구현한 타입 계층을 변하지 않는 부분에 합성한다. 변하지 않는 부분에서는 변경되는 구체적인 사항에 결합돼서는 안된다. 오직 추상화에만 의존하게 만든다. 이제 변하지 않는 부분은 변하는 부분의 구체적인 종류에 대해서는 알지 못한다. 변경이 캡슐화된 것이다.

14_3 일관성 있는 기능 구현 예시

예제에서 변하지 않는 것은 ‘규칙’이고 변하는 것은 ‘적용조건’이다. 따라서 ‘규칙’으로부터 ‘적용조건’을 분리해서 추상화한 후 시간대별, 요일별, 구간별 방식을 이 추상화의 서브타입으로 만든다. 이것이 서브타입 캡슐화다. 그 후에 규칙이 적용조건을 표현하는 추상화를 합성 관계로 연결한다. 이것이 객체 캡슐화다.

변하는 부분을 적절히 추상화하고 나면 변하는 부분을 생략한 채 변하지 않는 부분만을 이용해 객체 사이의 협력을 이야기할 수 있다. 예제에서 구현한 클래스와 인터페이스는 모두 변하지 않는 추상화에 해당한다. 이 요소들을 조합하면 전체적인 협력 구조가 완성된다. 다시 말해 변하지 않는 요소와 추상적인 요소만으로도 요금 계산에 필요한 전체적인 협력 구조를 설명할 수 있다. 변하는 것과 변하지 않는 것을 분리하고 변하는 것을 캡슐화한 코드는 오로지 변하지 않는 것과 추상화에 대한 의존성만으로도 전체적인 협력을 구현할 수 있다. 변하는 것은 추상화 뒤에 캡슐화되어 숨겨져 있기 때문에 전체적인 협력의 구조에 영향을 미치지 않는다.

이전의 설계에서는 새로운 기능을 추가하기 위해 따라야 하는 지침이 존재하지 않았기 때문에 개발자는 자신이 선호하는 방식으로 구간별 정책을 추가해야 했다. 이처럼 유사한 기능을 서로 다른 방식으로 구현하면 협력의 일관성을 유지하기 어렵기 때문에 이해하고 유지보수하기 어려운 코드가 만들어질 수밖에 없다.

협력을 일관성 있게 만들면 문제를 해결할 수 있다. 변하는 부분을 변하지 않는 부분으로부터 분리했기 때문에 변하지 않는 부분을 재사용할 수 있다. 그리고 새로운 기능을 추가하기 위해 오직 변하는 부분만 구현하면 되기 때문에 원하는 기능을 쉽게 완성할 수 있다. 따라서 코드의 재사용성이 향상되고 테스트해야 하는 코드의 양이 감소한다. 기능을 추가할 때 따라야 하는 구조를 강제할 수 있기 때문에 기능을 추가하거나 변경할 때도 설계의 일관성이 무너지지 않는다. 기능 추가를 위해 규칙을 지키는 것보다 어기는 것이 더 어렵다는 것에 주목하라. 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다.

변경 전의 설계는 전체적으로 일관성이 떨어지기 때문에 코드에 대해 가지고 있던 기존의 지식이 유사한 기능을 이해하는 데 아무런 도움이 되지 않았다. 오히려 기존 코드에 대한 선입견이 이해에 걸림돌로 작용했다. 협력을 일관성 있게 만들면 상황이 달라진다. 일단 일관성 있는 협력을 이해하고 나면 변하는 부분만 따로 떼어내어 독립적으로 이해하더라도 전체적인 구조를 쉽게 이해할 수 있다.

협력 패턴에 맞추기

예제에서 고정요금 정책은 다른 정책과 달리 ‘규칙’이라는 개념이 필요하지 않아서 협력 방식에서 벗어날 수밖에 없어 보인다. 이럴 때 또 다른 협력 패턴을 적용하는 게 아니라, 가급적 기존의 협력 패턴에 맞추는 것이 좋은 방법이다.

질문: 예제에서는 대부분 ‘규칙’이라는 개념을 필요로 하기 때문에, ‘규칙’을 필요로 하지 않는 정책에 ‘규칙’을 넣어 기존 협력 패턴에 맞췄다. 그런데 만약 대부분 ‘규칙’을 필요로 하지 않고, ‘규칙’을 필요로 하는 게 소수라면 어떻게 해야할까? 상식적으로 소수를 다수에 맞추는 게 좋을 것 같다. 하지만 쉽지 않았다. 체스 구현 시, 나는 폰을 위한 협력 구조를 다른 모든 체스 말이 따르게 했다. 폰을 고려하지 않으면 파라미터를 뺄 수 있었는데, 폰을 고려하면 꼭 파라미터가 필요했다. 이럴 때 어떻게 하는게 좋을까…?

지속적 개선

처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르며 새로운 요구사항이 추가되는 과정에서 일관성이 조금쌕 깨지는 걸 보게 된다. 협력을 설계하는 초기 단계에서 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 자연스러운 현상이다. 오히려 새로운 요구사항을 수용할 수 있는 협력 패턴을 향해 설계를 진화시킬 수 있는 좋은 신호로 받아들여야 한다. 협력은 고정된 게 아니다. 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력 패턴을 향해 과감히 리팩터링하라.

15_디자인 패턴과 프레임워크

소프트웨어 설계에서 반복적으로 발생하는 문제에 대해 반복적으로 적용할 수 있는 해결 방법을 디자인 패턴이라고 부른다. 디자인 패턴은 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 템플릿이다. 디자인 패턴의 목적은 설계를 재사용하는 것이다. 디자인 패턴을 익히고 나면 변경의 방향과 주기를 이해하는 것만으로도 필요한 역할과 책임, 역할들의 협력 방식을 순간적으로 떠올릴 수 있게 된다.

프레임워크는 설계와 코드를 함께 재사용하기 위한 것이다. 프레임워크는 애플리케이션의 아키텍처를 구현 코드의 형태로 제공한다. 프레임워크가 제공하는 아키텍처가 요구사항에 적합하다면 다양한 환경에서 테스트를 거친 견고한 구현 코드를 쉽고 빠르게 재사용할 수 있다.

디자인 패턴과 프레임워크 모두 일관성 있는 협력과 관련이 있다. 디자인 패턴은 특정한 변경을 일관성 있게 다룰 수 있는 협력 템플릿을 제공한다. 프레임워크는 특정한 변경을 일관성 있게 다룰 수 있는 확장 가능한 코드 템플릿을 제공한다. 디자인 패턴이 협력을 일관성 있게 만들기 위해 재사용할 수 있는 설계의 묶음이라면, 프레임워크는 일관성 있는 협력을 제공하는 확장 가능한 코드라고 할 수 있다.

15_1 디자인 패턴과 설계 재사용

패턴은 반복적으로 발생하는 문제와 해법의 쌍으로 정의된다. 패턴을 사용함으로써 이미 알려진 문제와 이에 대한 해법을 문서로 정리할 수 있으며, 이 지식을 다른 사람과 의사소통할 수 있다. 패턴은 추상적인 원칙과 실제 코드 작성 사이의 간극을 메워주며 실질적인 코드 작성을 돕는다. 패턴의 요점은 패턴이 실무에서 탄생했다는 점이다.

패턴은 하나의 실무 컨텍스트에서 유용하게 사용해 왔고 다른 실무 컨텍스트에서도 유용할 것이라고 예상되는 아이디어다. 아이디어라는 용어를 사용하는 이유는 어떤 것도 패턴이 될 수 있기 때문이다. 협력하는 객체 그룹일 수도 있고, 프로젝트 조직 원리일 수도 있다. 실무 컨텍스트라는 용어는 패턴이 실제 프로젝트의 실무 경험에서 비롯됐다는 사실을 반영한다. 패턴은 개발자들이 다른 컨텍스트에서도 유용할 것이라고 생각하는 어떤 것이다.

패턴이 지닌 가장 큰 가치는 경험을 통해 축적된 실무 지식을 효과적으로 요약하고 전달할 수 있다는 점이다.

패턴은 지식 전달과 커뮤니케이션의 수단으로 활용할 수 있기 때문에 패턴에서 가장 중요한 요소는 패턴의 이름이다. 패턴의 이름은 커뮤니티가 공유할 수 있는 중요한 어휘집을 제공한다.

패턴은 홀로 존재하지 않는다. 특정 패턴 내에 포함된 컴포넌트와 컴포넌트 간의 관계는 더 작은 패턴에 의해 서술될 수 있으며, 패턴들을 포함하는 더 큰 패턴 내에 통합될 수 있다. 크리스토퍼 알렉산더는 연관된 패턴들의 집합들이 모여 하나의 패턴 언어를 구성한다고 정의한다. 패턴 언어는 연관된 패턴 카테고리뿐만 아니라 패턴의 생성 규칙과 함께 패턴 언어에 속한 다른 패턴과의 관계 및 협력 규칙을 포함한다.

패턴 분류

패턴은 패턴의 범위나 적용 단계에 따라 아키텍처 패턴, 분석 패턴, 디자인 패턴, 이디엄의 네 가지로 분류한다.

디자인 패턴은 특정 정황 내에서 일반적인 설계 문제를 해결하며, 협력하는 컴포넌트들 사이에서 반복적으로 발생하는 구조를 서술한다. 디자인 패턴은 중간 규모의 패턴으로, 특정한 설계 문제를 해결하는 것을 목적으로 하며, 프로그래밍 언어나 프로그래밍 패러다임에 독립적이다.

디자인 패턴의 상위에는 소프트웨어의 전체적인 구조를 결정하기 위해 사용할 수 있는 아키텍처 패턴이 위치한다. 아키텍처 패턴은 미리 정의된 서브시스템들을 제공하고, 각 서브시스템들의 책임을 정의하며, 서브시스템들 사이의 관계를 조직화하는 규칙과 가이드라인을 포함한다. 아키텍처 패턴은 구체적인 소프트웨어 아키텍처를 위한 템플릿을 제공하며, 디자인 패턴과 마찬가지로 프로그래밍 언어나 프로그래밍 패러다임에 독립적이다.

디자인 패턴의 하위에는 이디엄이 위치한다. 이디엄은 특정 프로그래밍 언어에만 국한된 하위 레벨 패턴으로, 주어진 언어의 기능을 사용해 컴포넌트, 혹은 컴포넌트 간의 특정 측면을 구현하는 방법을 서술한다. 이디엄은 언어에 종속적이기 때문에 특정 언어의 이디엄이 다른 언어에서는 무용지물이 될 수 있다.

아키텍처 패턴, 디자인 패턴, 이디엄이 주로 기술적인 문제를 해결하는 데 초점을 맞추고 있다면 분석 패턴은 도메인 내의 개념적인 문제를 해결하는 데 초점을 맞춘다. 분석 패턴은 업무 모델링 시에 발견되는 공통적인 구조를 표현하는 개념들의 집합이다. 분석 패턴은 단 하나의 도메인에 대해서만 적절할 수도 있고 여러 도메인에 걸쳐 적용할 수도 있다.

패턴과 책임 주도 설계

패턴은 공통으로 사용할 수 있는 역할, 책임, 협력의 템플릿이다. 패턴은 반복적으로 발생하는 문제를 해결하기 위해 사용할 수 있는 공통적인 역할과 책임, 협력의 훌륭한 예제를 제공한다. 특정한 상황에 적용 가능한 패턴을 잘 알고 있다면 책임 주도 설계의 절차를 하나하나 따르지 않고도 시스템 안에 구현할 객체들의 역할과 책임, 협력 관계를 빠르고 손쉽게 구성하 수 있다.

디자인 패턴의 구성요소가 클래스와 메서드가 아니라 역할과 책임이라는 사실을 이해하는 것이 중요하다. 어떤 구현 코드가 어떤 디자인 패턴을 따른다고 이야기할 때는 역할, 책임, 협력의 관점에서 유사성을 공유한다는 것이지 특정한 구현 방식을 강제하는 것은 아니라는 점을 이해하는 것 역시 중요하다. 디자인 패턴은 단지 역할과 책임, 협력의 템플릿을 제안할 뿐 구체적인 구현 방법에 대해서는 제한을 두지 않는다.디자인 패턴에서 중요한 것은 디자인 패턴의 구현 방법이나 구조가 아니다. 어떤 디자인 패턴이 어떤 변경을 캡슐화하는지를 이해하는 것이 중요하다. 그리고 각 디자인 패턴이 변경을 캡슐화하기 위해 어떤 방법을 사용하는 지를 이해하는 게 더 중요하다.

패턴은 출발점이다

패턴을 사용하면서 부딪히게 되는 대부분의 문제는 패턴을 맹목적으로 사용할 때 발생한다. 패턴을 적용하는 컨텍스트의 적절성은 무시한 채 패턴의 구조에만 초점을 맞추면 안된다. 해결하려는 문제가 아니라 패턴이 제시하는 구조를 맹목적으로 따르는 것은 불필요하게 복잡하고 난해하며 유지보수하기 어려운 시스템을 낳는다. 정당한 이유 없이 사용된 모든 패턴은 설계를 복잡하게 만드는 장애물이다. 패턴은 복잡성의 가치가 단순성을 넘어설 때만 정당화돼야 한다.

패턴 예시

Strategy 패턴은 다양한 알고리즘을 동적으로 교체할 수 있는 역할과 책임의 집합을 제공한다. Strategy 패턴의 목적은 알고리즘의 변경을 캡슐화하는 것이고 이를 구현하기 위해 객체 합성을 이용한다. 구현 관점에서 얘기하면, ‘인터페이스를 하나 추가하고 이 인터페이스를 구체화하는 클래스를 만든 후 객체의 생성자나 setter 메서드에 할당해서 런타임 시에 알고리즘을 바꿀 수 있게 하는 것’이라고 할 수 있다.

Template Method 패턴은 Strategy 패턴과 비교하여, 알고리즘을 캡슐화하기 위해 합성 관계가 아닌 상속 관계를 사용하는 것을 말한다. 부모 클래스가 알고리즘의 기본 구조를 정의하고 구체적인 단계는 자식 클래스에서 정의하게 함으로써 변경을 캡슐화한다. 다만 합성보다는 결합도가 높은 상속을 사용했기 때문에 Strategy 패턴처럼 런타임에 객체의 알고리즘을 변경하는 것은 불가능하다. 하지만 알고리즘 교체와 같은 요구사항이 없다면 상대적으로 Strategy 패턴보다 복잡도를 낮출 수 있다는 면에서는 장점이라고 할 수 있다.

Bridge 패턴은 추상화의 조합으로 인한 클래스의 폭발적인 증가 문제를 해결하기 위해 역할과 책임을 추상화와 구현의 두 개의 커다란 집합으로 분해함으로써 설계를 확장 가능하게 만든다.

Observer 패턴은 유연한 통지 메커니즘을 구축하기 위해 객체 간의 결합도를 낮출 수 있는 역할과 책임의 집합을 제공한다.

Decorator 패턴은 객체의 행동을 동적으로 추가할 수 있게 해주는 패턴으로서 기본적으로 객체에 행동을 결합하기 위해 객체 합성을 사용한다.

Composite 패턴은 개별 객체와 복합 객체라는 객체의 수와 관련된 변경을 캡슐화하는 것이 목적이다.

15_2 프레임워크와 코드 재사용

오랜 시간 동안 개발자들은 부품을 조립해서 제품을 만드는 것처럼 별도의 프로그래밍 없이 기존 컴포넌트를 조립해서 애플리케이션을 구축하는 방법을 추구해왔다. 아쉽게도 컴포넌트 기반의 재사용 방법이라는 아이디어 자체는 이상적이지만 실제로 적용하는 과정에서 현실적이지 않다는 사실이 드러났다. 만약 여러 프로젝트나 도메인 사이에 비슷한 문제가 충분히 많이 존재한다면 컴포넌트 기반의 접근법이 효과가 있을 수 있겠지만 애플리케이션과 도메인의 다양성으로 인해 두 가지 문제가 아주 비슷한 경우는 거의 없다고 한다. 따라서 가장 기본이 되는 아주 적은 부분만이 일반화될 수 있을 것이다.

가장 이상적인 형태의 재사용 방법은 설계 재사용과 코드 재사용을 적절한 수준으로 조합하는 것이다. 코드 재사용만을 강조하는 컴포넌트는 실패했다. 추상적인 수준에서의 설계 재사용을 강조하는 디자인 패턴은 재사용을 위해 매번 유사한 코드를 작성해야만 한다. 설계를 재사용하면서도 유사한 코드를 반복적으로 구현하는 문제를 피할 수 있는 방법은 없을까? 이 질문에 대한 답이 프레임워크다.

프레임워크란 ‘추상 클래스나 인터페이스를 정의하고 인스턴스 사이의 상호작용을 통해 시스템 전체 혹은 일부를 구현해 놓은 재사용 가능한 설계’, 또는 ‘애플리케이션 개발자가 현재의 요구사항에 맞게 커스터마이징할 수 있는 애플리케이션의 골격’을 의미한다. 첫 번째 정의가 프레임워크의 구조적인 측면에 초점을 맞추고 있다면 두 번째 정의는 코드와 설계의 재사용이라는 프레임워크의 사용 목적에 초점을 맞춘다.

프레임워크는 코드를 재사용함으로써 설계 아이디어를 재사용한다. 프레임워크는 애플리케이션의 아키텍처를 제공하며 문제 해결에 필요한 설계 결정과 이에 필요한 기반 코드를 함께 포함한다. 또한 애플리케이션을 확장할 수 있도록 부분적으로 구현된 추상 클래스와 인터페이스 집합뿐만 아니라 추가적인 작업 없이도 재사용 가능한 다양한 종류의 컴포넌트도 함께 제공한다.

프레임워크는 애플리케이션에 대한 아키텍처를 제공한다. 즉 프레임워크는 클래스와 객체들의 분할, 전체 구조, 클래스와 객체들 간의 상호작용, 객체와 클래스 조합 방법, 제어 흐름에 대해 미리 정의한다. 프레임워크는 설계의 가변성을 미리 정의해 뒀기 때문에 애플리케이션 설계자나 구현자는 애플리케이션에 종속된 부분에 대해서만 설계하면 된다. 프레임워크는 애플리케이션 영역에 걸쳐 공통의 클래스들을 정의해서 일반적인 설계 결정을 미리 내려둔다. 비록 프레임워크가 즉시 업무에 투입할 수 있는 구체적인 서브클래스를 포함하고 있기는 하지만 프레임워크는 코드의 재사용보다는 설계 자체의 재사용을 중요시한다.

프레임워크는 여러 애플리케이션에 걸쳐 일관성 있는 협력을 구현할 수 있게 해준다.

상위 정책과 하위 정책으로 패키지 분리하기

객체지향 이전의 구조적인 설계와 같은 전통적인 소프트웨어 개발 방법의 경우 상위 레벨 모듈이 하위 레벨 모듈에, 그리고 상위 정책이 구체적인 세부적인 사항에 의존하도록 소프트웨어를 구성한다. 하지만 상위 정책은 상대적으로 변경에 안정적이지만 세부 사항은 자주 변경된다. 만약 변하지 않는 상위 정책이 자주 변하는 세부 사항에 의존한다면 변경에 대한 파급효과로 인해 상위 정책이 불안정해질 것이다.

그리고 상위 정책이 세부 사항에 비해 재사용될 가능성이 높다. 하지만 상위 정책이 세부 사항에 의존하게 되면 상위 정책이 필요한 모든 경우에 세부 사항도 항상 함께 존재해야 하기 때문에 상위 정책의 재사용성이 낮아진다. 이 문제를 해결할 수 있는 가장 좋은 방법은 의존성 역전 원칙에 맞게 상위 정책과 세부 사항 모두 추상화에 의존하게 만드는 것이다. 의존성 역전 원칙의 관점에서 세부 사항은 ‘변경’을 의미한다. 변하지 않는 것은 상위 정책에 속하는 역할들의 협력 구조다. 변하는 것은 구체적인 세부 사항이다. 프레임워크는 여러 애플리케이션에 걸쳐 재사용 가능해야 하기 때문에 변하는 것과 변하지 않는 것들을 서로 다른 주기로 배포할 수 있도록 별도의 ‘배포 단위’로 분리해야 한다.

이를 위한 첫걸음은 변하는 부분과 변하지 않는 부분을 별도의 패키지로 분리하는 것이다. 중요한 것은 패키지 사이의 의존성 방향이다. 의존성 역전 원리에 따라 추상화에만 의존하도록 의존성의 방향을 조정하고 추상화를 경계로 패키지를 분리했기 때문에 세부 사항을 구현한 패키지는 항상 상위 정책을 구현한 패키지에 의존해야 한다. 패키지를 분리하고 나면 상위 정책을 구현하는 패키지를 다른 애플리케이션에 재사용할 수 있다. 이것은 8장에서 설명한 컨텍스트 독립성의 패키지 버전이다.

좀 더 나아가 상위 정책을 구현하고 있는 패키지가 충분히 안정적이고 성숙했다면 하위 정책 패키지로부터 완벽히 분리해서 별도의 배포 단위로 만들 수 있다. 상위 정책 패키지와 하위 정책 패키지를 물리적으로 완전히 분리하고 나면 상위 정책 패키지를 여러 애플리케이션에서 재사용할 수 있는 기반이 마련된 것이다.

제어 역전 원리

상위 정책을 재사용한다는 것은 결국 도메인에 존재하는 핵심 개념들 사이의 협력 관계를 재사용한다는 것을 의미한다. 객체지향 설계의 재사용성은 개별 클래스가 아니라 객체들 사이의 공통적인 협력 흐름으로부터 나온다. 그리고 그 뒤에는 항상 의존성 역전 원리라는 강력한 지원군이 존쟇나다. 의존성 역전 원리는 전통적인 설계 방법과 객체지향을 구분하는 가장 핵심적인 원리다. 의존성 역전 원리에 따라 구축되지 않은 시스템은 협력 흐름을 재사용할 수도 없으며 변경에 유연하게 대처할 수도 없다.

의존성 역전 원리는 프레임워크의 가장 기본적인 설계 메커니즘이다. 의존성 역전은 의존성의 방향뿐만 아니라 제어 흐름의 주체 역시 역전시킨다. 상위 정책이 구체적인 세부사항에 의존하는 전통적인 구조에서는 상위 정책의 코드가 하부의 전체적인 코드를 호출한다. 즉 애플리케이션의 코드가 재사용 가능한 라이브러리나 툴킷의 코드를 호출한다. 그러나 의존성을 역전시킨 객체지향 구조에서는 반대로 프레임워크가 애플리케이션에 속하는 서브클래스의 메서드를 호출한다. 따라서 프레임워크를 사용할 경우 개별 애플리케이션에서 프레임워크로 제어 흐름의 주체가 이동한다. 제어가 우리에게서 프레임워크로 넘어가 버린 것이다. 즉 의존성을 역전시키면 제어 흐름의 주체 역시 역전된다. 이를 제어 역전 원리라고 한다. 제어가 역전된 상황에서 개발자는 이미 특정 이름과 호출 방식이 결정된 오퍼레이션을 작성해야 하지만 결정해야 하는 설계 개념은 줄어들고 애플리케이션별로 구체적인 오퍼레이션의 구현만 남게된다.

부록 A_계약에 의한 설계

6장에 따라 의도를 드러내도록 인터페이스를 다듬고 명령과 쿼리를 분리했다고 하더라도 명령으로 인해 발생하는 부수효과를 명확하게 표현하는 데는 한계가 있다. 구현이 복잡하고 부수효과를 가진 다수의 메서드들을 연이어 호풀하는 코드를 분석하는 것은 실행 결과를 예측하기 어려울 수밖에 없다. 캡슐화의 가치는 사라지고 개발자는 복잡하게 얽히고설킨 로직을 이해하기 위해 코드의 구석구석을 파헤쳐야 하는 운명에 처하고 만다.

인터페이스만으로는 객체의 행동에 관한 다양한 관점을 전달하기 어렵다. 명령의 부수효과를 쉽고 명확하게 표현할 수 있는 커뮤니케이션 수단이 필요하다.

계약에 의한 설계를 사용하면 협력에 필요한 다양한 제약과 부수효과를 명시적으로 정의하고 문서화할 수 있다. 클라이언트 개발자는 오퍼레이션의 구현을 살펴보지 않더라도 객체의 사용법을 쉽게 이해할 수 있다. 계약은 실행 가능하기 때문에 구현에 동기화돼 있는지 여부를 런타임에 검증할 수 있다. 따라서 주석과 다르게 시간의 흐름에 뒤처질 걱정을 할 필요가 없다. 계약에 의한 설계는 클래스의 부수효과를 명시적으로 문서화하고 명확하게 커뮤니케이션할 수 있을뿐만 아니라 실행 가능한 검증 도구로써 사용할 수 있다.

부록 A_1 협력과 계약

인터페이스는 객체가 수신할 수 있는 메시지를 정의할 순 있지만 객체 사이의 의사소통 방식을 명확하게 정의할 수는 없다. 메시지의 이름과 파라미터 목록은 시그니처를 통해 전달할 수 있지만 협력을 위해 필요한 약속과 제약은 인터페이스를 통해 전달할 수 없기 때문에 협력과 관련된 상당한 내용이 암시적인 상태로 남게 된다.

계약에 의한 설계를 통해 특정 조건을 만족해야 한다는 것을 명시할 수 있다. if 문 쓰는 것과 차이가 없어 보일 수 있지만 차이가 크다. 대표적으로 문서화를 들 수 있다. 일반적인 정합성 체크 로직은 코드의 구현 내부에 숨겨져 있어 실제로 코드를 분석하지 않는 한 정확하게 파악하기가 쉽지 않다. 게다가 일반 로직과 조건을 기술한 로직을 구분하기도 쉽지 않다. 하지만 계약에 의한 설계 개념을 지원하는 라이브러리나 언어들은 일반 로직과 구분할 수 있도록 제약 조건을 명시적으로 표현하는 것이 가능하다.

이렇게 작성된 계약은 문서화로 끝나는 것이 아니라 제약 조건의 만족 여부를 실행 중에 체크할 수 있다. 또한 이 조건들을 코드로부터 추출해서 문서를 만들어주는 자동화 도구도 제공한다. 따라서 계약에 의한 설계를 사용하면 제약 조건을 명시적으로 표현하고 자동으로 문서화할 수 있을뿐만 아니라 실행을 통해 검증할 수 있다.

부록 A_2 계약에 의한 설계

계약에 의한 설계 개념은 “인터페이스에 대해 프로그래밍하라”는 원칙을 확장한 것이다. 계약에 의한 설계를 이용하면 오퍼레이션의 시그니처를 구성하는 다양한 요소들을 이용해 협력에 참여하는 객체들이 지켜야 하는 제약 조건을 명시할 수 있다. 이 제약 조건을 인터페이스의 일부로 만듦으로써 코드를 분석하지 않고도 인터페이스의 사용법을 이해할 수 있다.

계약에 의한 설계의 세 가지 구성 요소

  • 사전조건: 메서드가 호출되기 위해 만족돼야 하는 조건. 이것은 메서드의 요구사항을 명시한다. 사전조건이 만족되지 않을 경우 메서드가 실행돼서는 안 된다. 사전조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무다.
  • 사후조건: 메서드가 실행된 후에 클라이언트에게 보장해야 하는 조건. 클라이언트가 사전조건을 만족시켰다면 메서드는 사후조건에 명시된 조건을 만족시켜야 한다. 만약 클라이언트가 사전조건을 만족시켰는데도 사후조건을 만족시키지 못한 경우에는 클라이언트에게 예외를 던져야 한다. 사후조건을 만족시키는 것은 서버의 의무다.
  • 불변식: 항상 참이라고 보장되는 서버의 조건. 메서드가 실행되는 도중에는 불변식을 만족시키지 못할 수도 있지만 메서드를 실행하기 전이나 종료된 후에 불변식은 항상 참이어야 한다.

사전조건, 사후조건, 불변식을 기술할 때는 실행 절차를 기술할 필요 없이 상태 변경만을 명시하기 때문에 코드를 이해하고 분석하기 쉬워진다. 사전조건, 사후조건, 불변식에는 클라이언트 개발자가 알아야 하는 모든 것이 포함돼 있을 것이다.

사전조건

사전조건을 만족시키지 못해서 메서드가 실행되지 않을 경우 클라이언트에 버그가 있다는 것을 의미한다. 일반적으로 사전조건은 메서드에 전달된 인자의 정합성을 체크하기 위해 사용된다.

예제를 통해 계약에 의한 설계를 사용하면 계약만을 위해 준비된 전용 표기법을 사용해 계약을 명확하게 표현할 수 있다는 걸 알 수 있다. 또한 계약을 일반 로직과 분리해서 서술함으로써 계약을 좀 더 두드러지게 강조할 수 있다. 또한 계약이 메서드의 일부로 실행되도록 함으로써 계약을 강제할 수 있다.

사후조건

사후조건은 메서드의 실행 결과가 올바른지를 검사하고 실행 후에 객체가 유효한 상태로 남아 있는지를 검증한다. 사후조건을 통해 메서드를 호출한 후에 어떤 일이 일어났는지를 설명할 수 있는 것이다. 클라이언트가 사전조건을 만족시켰는데도 서버가 사후조건을 만족시키지 못한다면 서버에 버그가 있음을 의미한다.

사호 조건의 일반적인 세 가지 용도는 다음과 같다.

  • 인스턴스 변수의 상태가 올바른지를 서술하기 위해
  • 메서드에 전달된 파라미터의 값이 올바르게 변경됐는지를 서술하기 위해
  • 반환값이 올바른지를 서술하기 위해

사전조건보다 사후조건을 정의하는 것이 더 어려울 수 있는데, 그 이유는 다음과 같다.

  • 한 메서드 안에서 return 문이 여러 번 나올 경우

    모든 return 문마다 결괏값이 올바른지를 검증하는 코드를 추가해야 한다. 다행히 계약에 의한 설계를 지원하는 라이브러리 대부분은 결괏값에 대한 사후조건을 한 번만 기술할 수 있게 해준다.

  • 실행 전과 실행 후의 값을 비교해야 하는 경우

    실행 전의 값이 메서드 실행으로 인해 다른 값으로 변경됐을 수 있기 때문에 두 값을 비교하기 어려울 수 있다. 다행히 계약에 의한 설계를 지원하는 대부분의 라이브러리는 실행 전의 값에 접근할 수 있는 간편한 방법을 제공한다.

불변식

사전조건과 사후조건은 각 메서드마다 달라지는 데 반해 불변식은 인스턴스 생명주기 전반에 걸쳐 지켜져야 하는 규칙을 명세한다. 불변식은 다음 두 특성을 가진다.

  • 불변식은 클래스의 모든 인스턴스가 생성된 후에 만족돼야 한다. 이것은 클래스에 정의된 모든 생성자는 불변식을 준수해야 한다는 것을 의미한다.
  • 불변식은 클라이언트에 의해 호출 가능한 모든 메서드에 의해 준수돼야 한다. 메서드가 실행되는 중에는 객체의 상태가 불안정한 상태로 빠질 수 있기 때문에 불변식을 만족시킬 필요는 없지만 메서드 실행 전과 메서드 종료 후에는 항상 불변식을 만족하는 상태가 유지돼야 한다.

불변식은 클래스의 모든 메서드의 사전조건과 사후조건에 추가되는 공통의 조건으로 생각할 수 있다. 불변식은 메서드가 실행되기 전에 사전조건과 함께 실행되며, 메서드가 실행된 후에 사후조건과 함께 실행된다. 불변식은 생성자 실행 후, 메서드 실행 전, 메서드 실행 후에 호출된다.

부록 A_3 계약에 의한 설계와 서브타이핑

계약에 의한 설계는 클라이언트가 만족시켜야 하는 사전조건과 클라이언트의 관점에서 서버가 만족시켜야 하는 사후조건을 기술한다. 리스코프 치환 원칙은 슈퍼타입의 인스턴스와 협력하는 클라이언트의 관점에서 서브타입의 인스턴스가 슈퍼타입을 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다. 따라서 서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 계약을 준수해야 한다.

리스코프 치환 원칙은 두 종류로 세분화되는데, 첫 번째 규칙은 협력에 참여하는 객체에 대한 기대를 표현하는 계약 규칙이고, 두 번째 규칙은 교체 가능한 타입과 관련된 가변성 규칙이다.

계약 규칙은 슈퍼타입과 서브타입 사이의 사전조건, 사후조건, 불변식에 대해 서술할 수 있는 제약에 관한 규칙이다.

  1. 서브타입에 더 강력한 사전조건을 정의할 수 없다.

  2. 서브타입에 더 완화된 사후조건을 정의할 수 없다.

  3. 슈퍼타입의 불변식은 서브타입에서도 반드시 유지돼야 한다.

가변성 규칙은 파라미터와 리턴 타입의 변형과 관련된 규칙이다.

  1. 서브타입의 메서드 파라미터는 반공변성을 가져야 한다.

  2. 서브타입의 리턴 타입은 공변성을 가져야 한다.

  3. 서브타입은 슈퍼타입이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안 된다.

1. 계약 규칙

서브타입에 더 강력한 사전조건을 정의할 수 없다

예제에서 상속 계층에 속하는 클래스들이 정말 인터페이스의 서브타입인지(리스코프 치환 원칙을 만족하는지) 확인하려면 인터페이스의 구현 클래스들이 클라이언트와 체결한 계약을 준수하는지 살펴봐야 한다. 예제를 통해 서브타입이 슈퍼타입에 정의된 사전조건을 강화하면 기존에 체결된 계약을 위반하게 된다. 계약서에 명시된 의무보다 더 많은 의무를 짊어져야 한다는 사실을 순순히 납득하는 클라이언트는 없을 것이다. 따라서 사전조건 강화는 리스코프 치환 원칙이다. 그리고 사전 조건을 완화시키는 것은 리스코프 치환 원칙을 위반하지 않는다.

서브타입에 더 완화된 사후조건을 정의할 수 없다

사후조건을 완화할 경우, 서비스 클래스는 잘못된 값을 반환할 것이다. 그런데 예외 스택 트레이스는 엉뚱한 곳이 문제라고 지적한다. 문제의 원인을 제공한 위치로부터 너무나도 멀리 떨어진 곳이 문제처럼 보인다. 문제가 발생할 경우 원인이 어디인지 빠르게 파악해야 하기 때문에 차라리 문제가 발생한 그 위치에서 프로그램이 실패하는 게 낫다.

사후조건을 완화한다는 것은 서버가 클라이언트에게 제공하겠다고 보장한 계약을 충족시켜주지 못한다는 것을 의미한다. 계약의 관점에서 사후조건은 완화할 수 없다. 사후조건 완화는 리스코프 치환 원칙 위반이다. 사후조건 강화는 계약에 영향을 미치지 않는다.

슈퍼타입의 불변식은 서브타입에서도 반드시 유지돼야 한다

예제 코드는 불변식을 위반할 수 있는 취약점이 존재한다. null이 아니어야 하는 인스턴스 변수가 private이 아닌 protected 변수라는 것이다. 이러면 자식 클래스가 부모 클래스 몰래 인스턴스 변수의 값을 수정하는 것이 가능하다.

이 예제는 계약의 관점에서 캡슐화의 중요성을 잘 보여준다. 자식 클래스가 계약을 위반할 수 있는 코드를 작성하는 것을 막을 수 있는 유일한 방법은 인스턴스 변수의 가시성을 protected가 아니라 private으로 만드는 것뿐이다. 자식 클래스에서 인스턴스 변수의 상태를 변경하고 싶다면 어떻게 해야할까? 부모 클래스에 protected 메서드를 제공하고 이 메서드를 통해 불변식을 체크하게 해야 한다.

2. 가변성 규칙

서브타입은 슈퍼타입이 발생시키는 예외와 다른 타입의 예외를 발생시켜서는 안 된다

자식 클래스가 만약 부모 클래스가 발생시키는 예외와 다른 타입의 예외를 발생시킨다면 클라이언트 입장에서 협력의 결과가 예상을 벗어나기 때문에 자식 클래스는 부모 클래스를 대체할 수 없게 된다. 일반적으로 부모 클래스가 던지는 예외가 속한 상속 계층이 아닌 다른 상속 계층에 속하는 예외를 던질 경우 자식 클래스는 부모 클래스를 대체할 수 없다. 따라서 서브타입이 아니다.

오버라이드 하는 함수를 아무것도 하지 않게 만드는 것도 마찬가지다. 클라이언트 입장에서 어떤 로직을 수행할 수 있을 것이라고 예상하는데 실제로는 그렇지 않기 때문이다.

두 예시는 예외를 던지느냐, 아무것도 하지 않느냐의 차이는 있지만 클라이언트의 관점에서 자식 클래스가 부모 클래스가 하는 일보다 더 적은 일을 수행한다는 공통점이 있다. 부모 클래스보다 못한 자식 클래스는 서브타입이 아니다.

서브타입의 리턴 타입은 공변성을 가져야 한다

S가 T의 서브타입이라고 하자.

  • 공변성: S와 T 사이의 서브타입 관계가 그대로 유지된다. 이 경우 해당 위치에서 서브타입인 S가 슈퍼타입인 T 대신 사용될 수 있다. 우리가 흔히 이야기하는 리스코프 치환 원칙은 공변성과 관련된 원칙이라고 생각하면 된다.
  • 반공변성: S와 T 사이의 서브타입 관계가 역전된다. 이 경우 해당 위치에서 슈퍼타입인 T가 서브타입인 S 대신 사용될 수 있다.
  • 무공변성: S와 T 사이에는 아무런 관계도 존재하지 않는다. 따라서 S 대신 T를 사용하거나 T 대신 S를 사용할 수 없다.

지금까지 살펴본 서브타이핑은 단순히 서브타입이 슈퍼타입의 모든 위치에서 대체 가능하다는 것이다. 하지만 공변성과 반공변성의 영역으로 들어서기 위해서는 타입의 관계가 아니라 메서드의 리턴 타입과 파라미터 타입에 초점을 맞춰야 한다. 예제를 통해 슈퍼타입인 BookStall이 슈퍼타입인 Book을 반환하고, 서브타입인 MegazineStore가 서브타입인 Megazine을 반환한다는 것을 알 수 있다.

부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩할 때 부모 클래스에서 선언한 반환타입의 서브타입으로 지정할 수 있는 특성을 리턴 타입 공변성이라고 부른다. 즉 리턴 타입 공변성이란 메서드를 구현한 클래스의 타입 계층 방향과 리턴 타입의 타입 계층 방향이 동일한 경우를 가리킨다.

슈퍼타입 대신 서브타입을 반환하는 것은 더 강력한 사후조건을 정의하는 것과 같다. 따라서 리턴 타입 공변성은 계약에 의한 설계 관점에서 계약을 위반하지 않는다.

이론적으로 메서드의 리턴 타입을 공변적으로 정의하면 리스코프 치환 원칙을 만족시킬 수 있다(실제적으로는 언어의 지원 여부에 따라 리턴 타입 공변성을 사용하지 못할 수도 있다).

서브타입의 메서드 파라미터는 반공변성을 가져야 한다

참고로 자바의 경우 파라미터 반공변성을 허용하지 않는다.

부모 클래스에서 구현된 메서드를 자식 클래스에서 오버라이딩할 때 파라미터 타입을 부모 클래스에서 사용한 파라미터의 슈퍼타입으로 지정할 수 있는 특성을 파라미터 타입 반공변성이라고 부른다. 즉 파라미터 타입 반공변성이란 메서드를 정의한 클래스의 타입 계층과 파라미터의 타입 계층의 방향이 반대인 경우 서브타입 관계를 만족한다는 것을 의미한다.

서브타입 대신 슈퍼타입을 파라미터로 받는 것은 더 약한 사후조건을 정의하는 것과 같다. 따라서 파라미터 타입 반공변성은 계약에 의한 설계 관점에서 계약을 위반하지 않는다.

3. 정리

리턴 타입 공변성과 타입 반공변성을 사전조건과 사후조건의 관점에서 설명할 수도 있다. 서브타입은 슈퍼타입에서 정의한 것보다 더 강력한 사전조건을 정의할 수는 없지만 사전조건을 완화할 수는 있다.

사전조건은 파라미터에 대한 제약조건이므로 이것은 슈퍼타입에서 정의한 파라미터 타입에 대한 제약을 좀 더 완화할 수 있다는 것을 의미한다. 따라서 좀 더 완화된 슈퍼타입을 파라미터로 받을 수 있는 것이다.

리턴 타입은 사후조건과 관련이 있으며 서브타입은 슈퍼타입으로 정의된 사후조건을 완화시킬 수는 없지만 강화할 수는 있다. 따라서 슈퍼타입에서 정의한 리턴 타입보다 더 강화된 서브타입 인스턴스를 반환하는 것이 가능한 것이다.

사실 객체지향 언어 중에서 파라미터 반공변성을 지원하는 언어는 거의 없다. 제네릭 프로그래밍에서 파라미터 반공변성이 중요한 의미를 가지기 때문에 언급한 것이다.

함수 타입과 서브타이핑

이름 없이 메서드를 정의하는 것을 허용하는 언어들은 객체의 타입뿐만 아니라 메서드의 타입을 정의할 수 있게 허용한다. 그리고 타입에서 정의한 시그니처를 준수하는 메서드들을 이 타입의 인스턴스로 간주한다.

메서드에 대한 타입을 정의할 수 있다면 함수 타입의 서브타입을 정의할 수 있을까? 그리고 객체의 서브타입이 슈퍼타입을 대체할 수 있는 것처럼 서브타입 메서드가 슈퍼타입 메서드를 대체할 수 있을까? 대답은 ‘그렇다’ 이다.

앞에서 파라미터 타입이 반공변성을 가지고 리턴 타입이 공변성을 가질 경우 메서드가 오버라이드 가능하다고 했다. 메서드가 오버라이드 가능하다는 것은 메서드가 대체 가능하며, 따라서 두 메서드 사이에 서브타이핑 관계가 존재한다는 것을 의미한다.

계약에 의한 설계의 관점에서 바라본 리스코프 치환 원칙

서브타입이 슈퍼타입을 치환할 수 있다는 것은 계약에 의한 설계에서 정의한 계약 규칙과 가변성 규칙을 준수한다는 것을 의미한다. 진정한 서브타이핑 관계를 만들고 싶다면 서브타입에 더 강력한 사전조건이나 더 완화된 사후조건을 정의해서는 안되며 슈퍼타입의 불변식을 유지하기 위해 항상 노력해야 한다. 또한 서브타입에서 슈퍼타입에서 정의하지 않은 예외를 던져서는 안 된다. 사용 언어가 메서드 파라미터에 대한 반공변성과 리턴 타입에 대한 공변성을 지원한다면 이를 서브타이핑의 관점에서 활용할 수 있는지 고민하자. 계약에 의한 설계를 지원하는 프레임워크를 사용하지 않더라도 치환 가능한 타입 계층을 구축하고 있는 경우라면 계약에 의한 설계를 고려할 필요가 있다.

부록 B_타입 계층의 구현

많은 사람들이 타입과 클래스가 동일한 개념이라고 오해한다. 타입은 개념의 분류를 의미하고 클래스는 타입을 구현하는 한 가지 방법일 뿐이다.

타입 계층은 타입보다 상황이 더 복잡한데 다양한 방식으로 구현된 타입들을 하나의 타입 계층 안에 조합할 수 있기 때문이다. 예를 들어 자바에서는 인터페이스와 클래스를 이용해 개별 타입을 구현한 후 이 두 가지 종류의 타입 구현체를 함께 포함하도록 타입 계층을 구성할 수 있다.

타입 계층은 동일한 메시지에 대한 행동 호환성을 전제로 하기 때문에 여기서 언급하는 모든 방법은 타입 계층을 구현하는 방법인 동시에 다형성을 구현하는 방법이기도 하다. 이미 알다시피 슈퍼타입에 대해 전송한 메시지를 서브타입별로 다르게 처리할 수 있는 방법을 제공할 수 있다.

여기서 제시하는 방법으로 타입과 타입 계층을 구현한다고 해서 서브타이핑 관계가 보장되는 것은 아니다. 올바른 타입 계층이 되기 위해서는 서브타입이 슈퍼타입을 대체할 수 있도록 리스코프 치환 원칙을 준수해야 한다. 리스코프 치환 원칙은 특정한 구현 방법에 의해 보장될 수 없기 때문에 클라이언트 관점에서 타입을 동일하게 다룰 수 있도록 의미적으로 행동 호환성을 보장하는 것은 저적으로 개발자의 책임이다.

클래스를 이용한 타입 계층 구현

타입은 객체의 퍼블릭 인터페이스를 가리키기 때문에 결과적으로 클래스는 객체의 타입과 구현을 동시에 정의하는 것과 같다. 예제처럼 타입을 구현할 수 있는 방법이 단 한가지만 존재하는 경우에는 타입과 클래스를 동일하게 취급해도 무방하다. 타입을 구현할 수 있는 다양한 방법이 존재하는 순간부터는 클래스와 타입이 갈라지기 시작한다. 이를 통해 클래스는 타입을 구현할 수 있는 다양한 방법 중 하나라는 걸 알 수 있다.

객체의 클래스는 객체의 구현을 정의한다. 클래스는 객체의 내부 상태와 오퍼레이션 구현 방법을 정의하는 것이고 객체의 타입은 인터페이스만을 정의하는 것으로 객체가 반응할 수 있는 오퍼레이션의 집합을 정의한다. 객체의 구현은 다를지라도 인터페이스는 같을 수 있기에 클래스와 타입은 다르다는 걸 알 수 있다.

클래스와 타입 간에는 밀접한 관련이 있다. 클래스도 객체가 만족할 수 있는 오퍼레이션을 정의하고 있으므로 타입을 정의하는 것이기도 하다. 그래서 객체가 클래스의 인터페이스라고 말할 때 객체는 클래스가 정의하고 있는 인터페이스를 지원한다는 뜻을 내포한다.

타입은 동일한 퍼블릭 인터페이스를 가진 객체들의 범주다. 클래스는 타입에 속하는 객체들을 구현하기 위한 구현 메커니즘이다. 중요한 것은 클래스 자체가 아니라 타입이다. 타입이 식별된 후에 타입이 속하는 객체를 구현하기 위해 클래스를 사용하는 것이다. 클래스는 객체지향의 중심이 아니다. 중요한 것은 객체가 외부에 제공하는 행동, 즉 타입을 중심으로 객체들의 계층을 설계하는 것이다.

질문: 타입과 타입 계층의 차이 헷갈림

인터페이스를 이용한 타입 계층 구현

다양한 타입의 객체들을 클래스와 상속으로 구현해볼 수 있다. 타입 구조가 복잡할 경우, 대부분의 언어들이 다중 상속을 지원하지 않는다는 것이 문제가 된다. 게다가 클래스들을 동일한 상속 계층 안에 구현하고 싶지도 않다. 부모 자식 간 결합도가 커지기 때문이다. 상속 계층 안의 클래스 하나를 변경했는데도 수많은 자식 클래스들이 영향을 받을 수 있다.

상속으로 인한 결합도 문제를 피하고 다중 상속이라는 구현 제약도 해결할 수 있는 방법은 클래스가 아닌 인터페이스를 사용하는 것이다. 인터페이스가 다른 인터페이스를 확장하도록 만들면 슈퍼타입과 서브타입 간의 타입 계층을 구성할 수 있다.

예제를 통해 “여러 클래스가 동일한 타입을 구현할 수 있다”와 “하나의 클래스가 여러 타입을 구현할 수 있다”를 알 수 있다. 중요한 것은 인터페이스를 이용해 타입을 정의하고 클래스를 이용해 객체를 구현하면 클래스 상속을 사용하지 않고도 타입 계층을 구현할 수 있다는 것이다.

추상 클래스를 이용한 타입 계층 구현

추상 클래스는 클래스 상속을 이용해 구현을 공유하면서도 결합도로 인한 부작용을 피하는 방법이다. 구체 클래스로 타입을 정의해서 상속받는 방법과 추상 클래스로 타입을 정의해서 상속받는 방법 사이에는 두 가지 차이점이 있다. 하나는 추상화의 정도이고 다른 하나는 상속을 사용하는 의도다.

첫 번째 차이점 추상화의 정도에 대해 살펴보자. 앞의 구체 클래스를 상속받는 예제는 자식 클래스가 부모 클래스의 구현에 강하게 결합된다는 걸 보여준다. 이에 비해 추상 클래스의 경우, 자식 클래스가 부모 클래스의 내부 구현이 아닌 추상 메소드의 시그니처에만 의존한다. 이 경우 자식 클래스는 부모 클래스가 어떤 식으로 구현돼 있는지 알 필요가 없다. 단지 추상 메서드로 정의된 메서드를 오버라이딩하면 된다는 사실에만 의존해도 무방하다. 모든 구체 클래스의 부모 클래스를 항상 추상 클래스로 만드는 것을 추천한다. 의존하는 대상이 더 추상적일수록 결합도는 낮아지고 결합도가 낮아질수록 변경으로 인한 영향도가 줄어들기 때문이다.

두 번째 차이점 상속을 사용하는 의도에 대해 살펴보자. 애초에 예제의 나쁜 상속 예에선 부모 클래스가 상속을 염두에 두고 설계된 것이 아니다. 아마 부모 클래스의 개발자는 이게 상속될 것이라는 것을 몰랐을 수도 있다. 따라서 미래의 확장을 위한 어떤 준비도 돼 있지 않다. 그에 반해 또다른 예제의 부모 클래스는 처음부터 상속을 염두에 두고 설계됐다. 부모 클래스는 추상클래스이기 대문에 자신의 인스턴스를 직접 생성할 수 없다. 부모 클래스의 유일한 목적은 자식 클래스를 추가하는 것이다. 이 클래스는 추상 메서드를 제공함으로써 상속 계층을 쉽게 확장할 수 있게 하고 결합도로 인한 부작용을 방지할 수 있는 안전망을 제공한다.

추상 클래스와 인터페이스 결합하기

오직 클래스만을 이용해 타입을 구현할 경우 다중 상속이 필요하다. 대부분의 객체지향 언어들이 단일 상속만 지원하기 때문에 타입을 구현하기 위해 타입 계층을 오묘한 방식으로 비틀어야 할 때가 있다. 인터페이스를 적용하면 다중 상속 문제를 해결할 수 있다. 하지만 인터페이스만을 사용하는 방법에도 단점이 있다. 인터페이스에는 구현 코드를 포함시킬 수 없기 때문에 중복 코드를 제거하기 어렵다는 것이다. 따라서 인터페이스를 이용해 타입을 정의하고 특정 상속 계층에 국한된 코드를 공유할 필요가 있을 경우 추상 클래스를 이용해 코드 중복을 방지하는 것이 효과적이다. 이런 방식으로 추상 클래스를 사용하는 것을 골격 구현 추상 클래스라고 부른다. 즉 외부에 공개한 각각의 중요한 인터페이스와 연관시킨 골격 구현 추상클래스를 제공함으로써 인터페이스와 추상 클래스의 장점을 결합할 수 있다. 이 때 인터페이스는 여전히 타입을 정의하고 골격 구현 클래스가 그것을 구현하는 일을 맡는다.

추상 클래스를 활용하여 타입을 구현하면, 그 타입에 속하는 모든 객체들은 하나의 상속 계층 안에 묶여야 하는 제약을 가진다. 그런데 같은 타입으로 분류될 수 있는 객체들이 구현 시에 서로 다른 상속 계층에 속할 수 있도록 만들고 싶을 수도 있다. 이 때 인터페이스와 추상 클래스를 결합하면 된다. 기존 추상 클래스를 인터페이스로 변경하고 공통 코드를 담을 공격 구현 추상 클래스를 추가하면 상속 계층이라는 굴레를 벗어날 수 있다.

인터페이스와 추상 클래스를 함께 사용하는 방법은 추상 클래스만 사용하는 방법에 비해 두 가지 장점이 있다.

  1. 다양한 구현 방법이 필요할 경우 새로운 추상 클래스를 추가해서 쉽게 해결할 수 있다. 예를 들어, 로직을 빠르게 처리하는 방법과 메모리를 적게 차지하는 방법 모두 구현해 놓고 상황에 따라 적절한 방법을 선택하게 할 수 있다.
  2. 이미 부모 클래스가 존재하는 클래스라고 하더라도 인터페이스를 추가함으로써 새로운 타입으로 쉽게 확장할 수 있다. 만약 타입이 인터페이스 없이 추상 클래스로 구현돼 있는 경우에 이 문제를 해결할 수 있는 유일한 방법은 상속 계층을 다시 조정하는 것뿐이다.

설계가 상속 계층에 얽매이지 않는 타입 계층을 요구한다면 인터페이스로 타입을 정의하라. 추상 클래스로 기본 구현을 제공해서 중복 코드를 제거하라. 하지만 이런 복잡성이 필요하지 않다면 타입을 정의하기 위해 인터페이스나 추상 클래스 둘 중 하나만 사용하라. 타입의 구현 방법이 단 한 가지이거나 단일 상속 계층만으로도 타입 계층을 구현하는 데 무리가 없다면 클래스나 추상 클래스를 이용해 타입을 정의하는 것이 더 좋다. 그 외의 상황이라면 인터페이스를 이용하는 것을 고려하라.

덕 타이핑 사용하기

덕 테스트는 어떤 대상의 ‘행동’이 오리와 같다면 그것을 오리라는 타입으로 취급해도 무방하는 것이다. 즉 객체가 어떤 인터페이스에 종의된 행동을 수행할 수만 있다면 그 객체를 해당 타입으로 분류해도 문제가 없다.

자바와 같은 대부분의 정접 타입 언어에서는 설령 퍼블릭 인터페이스를 공유하더라도 두 클래스를 동일한 타입으로 취급하기 위해서는 코드 상의 타입이 동일하게 선언돼 있어야만 한다. 정적 타입 언어에서는 객체의 퍼블릭 인터페이스만으로 타입을 추측하는 것이 불가능하며 모든 요소의 타입이 명시적으로 기술돼 있어야 한다. 즉 덕 타이핑을 지원하지 않는다고 봐도 된다.

덕 타이핑은 타입이 행동에 대한 것이라는 사실을 강조한다. 두 객체가 동일하게 행동하다면 타입 관점에서 두 객체는 동일한 타입인 것이다. 8장에서 컨텍스트 독립성을 설명할 때, 인터페이스가 클래스보다 더 유연한 설계를 가능하게 해주는 이유는 클래스가 정의하는 구현이라는 컨텍스트에 독립적인 코드를 작성할 수 있게 해주기 때문이다. 덕 타이핑은 여기서 한 걸음 더 나아간다. 단지 메서드의 시그니처만 동일하면 명시적인 타입 선언이라는 컨텍스트를 제거할 수 있다. 덕 타이핑은 클래스나 인터페이스에 대한 의존성을 메시지에 대한 의존성으로 대체한다. 결과적으로 코드는 낮은 결합도를 유지하고 변경에 유연하게 대응할 수 있다. 덕 타입은 특정 클래스에 종속되지 않은 퍼블릭 인터페이스다. 여러 클래스를 가로지르는 이런 인터페이스는 클래스에 대한 값비싼 의존을 메시지에 대한 부드러운 의존으로 대치시킨다. 런타임에 타입을 결정하는 동적 타입 언어는 덕 타이핑을 지원한다.

덕 타이핑을 사용하면 메시지 수준으로 결합도를 나줄 수 있기 때문에 유연한 설계를 얻을 수 있다는 것을 알게됐다. 하지만 덕 타이핑을 사용하면 컴파일 시점에 발견할 수 있는 오류를 실행 시점으로 미루게 되기 때문에 설계의 유연성을 얻는 대신 코드의 안전성을 약화시킬 수 있다는 것에 주의하라.

믹스인과 타입 계층

믹스인은 객체를 생성할 때 코드 일부를 섞어 넣을 수 있도록 만들어진 일종의 추상 서브클래스다. 믹스인을 사용하는 목적은 다양한 객체 구현 안에서 동일한 ‘행동’을 중복 코드 없이 재사용할 수 있게 만드는 것이다. 나아가 믹스인은 간결한 인터페이스를 가진 클래스를 풍부한 인터페이스를 가진 클래스로 만들기 위해 사용될 수 있다. 물론 해당 클래스를 트레이트의 서브타입으로 만드는 부수적인 효과도 얻으면서 말이다.

자바에서 트레이트와 비슷한 것은 자바 8에 새롭게 추가된 디폴트 메서드이다. 인터페이스에 디폴트 메서드가 구현돼 있다면 인터페이스를 구현하는 클래스는 기본 구현을 가지고 있는 메서드를 구현할 필요가 없다. 디폴트 메서드를 사용하면 추상 클래스가 제공하는 코드 재사용성이라는 혜택을 그대로 누리면서도 특정한 상속 계층에 얽매이지 않는 인터페이스의 장점을 유지할 수 있다. 디폴트 메서드로 믹스인을 구현할 수 있으며, 이를 통해 간결한 인터페이스를 가진 클래스를 풍부한 인터페이스를 가진 클래스로 변경할 수 있다. 하지만 디폴트 메서드가 제공하는 혜택을 누리면서 설계를 견고하게 유지하기 위해서는 디폴트 메서드가 가지는 한계를 분명하게 인식하는 것이 중요하다. 이 인터페이스를 구현하는 모든 클래스들은 인터페이스 내에서 디폴트 메서드의 구현을 위해 정의한 메서드를 구현해야만 한다. 문제는 이 메서드가 인터페이스에 정의돼 있기 때문에 클래스 안에서 퍼블릭 메서드로 구현돼야 한다는 것이다. 추상 클래스의 경우 훅 메서드의 구현을 위해 사용한 메서드의 제어접근자가 protected였던 것과 비교된다. 즉 디폴트 메서드로 인해 노출할 필요가 없는 메서드를 불필요하게 퍼블릭 인터페이스에 추가하는 결과를 낳게 된다. 따라서 디폴트 메서드를 사용해 추상 클래스를 대체할 경우 인터페이스가 불필요하게 비대해지고 캡슐화가 약화될 수도 있다는 사실을 인지해야 한다. 게다가 이 방법은 구현 클래스 사이의 코드 중복을 완벽하게 제거해 주지도 못한다. 예제를 보면 디폴트 메서드를 사용했다고 하더라도 중복 코드가 많다는 것을 알 수 있다. 이것은 자바 8에 디폴트 메서드를 추가한 이유가 인터페이스로 추상 클래스의 역할을 대체하려는 것이 아니기 때문이다. 디폴트 메서드가 추가된 이유는 기존에 널리 사용되고 있는 인터페이스에 새로운 오퍼레이션을 추가할 경우에 발생하는 하위 호완성 문제를 해결하기 위해서지 추상 클래스를 제거하기 위한 것이 아니다. 따라서 타입을 정의하기 위해 디폴트 메서드를 사용할 생각이라면 그 한계를 명확하게 알아야 한다.

디폴트 메서드의 탄생 배경

인터페이스에 새로운 메서드를 추가하는 등 인터페이스를 바꾸면, 이전에 해당 인터페이스를 구현했던 모든 클래스의 구현도 고쳐야 하는 문제가 발생한다. 이를 개선하기 위해 자바 8에서 기본 구현을 포함하는 인터페이스를 정의하는 두 가지 방법을 제공한다. 첫째는 인터페이스 내부에 정적 메서드를 사용하는 것이다. 둘째는 인터페이스의 기본 구현을 제공할 수 있게 디폴트 메서드를 사용하는 것이다.

타입을 정의하는 기준은 객체가 외부에 제공하는 퍼블릭 인터페이스이기 때문에 실제로 타입의 개념을 코드로 옮길 수 있는 다양한 방법이 존재하며 동시에 타입의 구현 방법만큼이나 다양한 방식으로 타입 계층을 구현할 수 있다. 여기서 중요한 것은 어떤 방법을 사용하더라도 타입 계층을 구현했다고 해서 그 안에 들어있는 모든 타입 구현체들이 서브타입과 슈퍼타입의 조건을 만족시키는 것은 아니라는 것이다. 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 구현할 때 리스코프 치환 원칙을 준수해야 한다. 여기서 사용된 방법을 사용해 타입 계층을 구현한다고 하더라도 리스코프 치환 원칙을 준수하지 않는다면 올바른 타입 계층을 구현한 것이 아니니, 주의하라.

부록 C_동적인 협력, 정적인 코드

객체는 태어나고, 협력하고, 책임을 다하고나면 소멸한다. 객체의 상태는 지속적으로 변하고, 외부의 자극에 따라 다양한 방식으로 행동한다. 즉 객체는 동적이다. 반면 코드는 정적이다. 코드로 구성된 프로그램 역시 정적이다. 객체는 시간에 따라 다른 객체와 협력하며 계속 변화한다. 프로그램은 고정된 텍스트라는 형식 안에 갇혀 있으면서도 객체의 모든 변화 가능성을 담아야 한다. 이것은 프로그래머가 객체지향 프로그램을 작성하기 위해서는 두 가지 모델을 동시에 마음속에 그려야 한다는 것을 의미한다. 하나는 프로그램 실행 구조를 표현하는 움직이는 모델(동적 모델)이고 다른 하나는 코드의 구조를 담는 고정된 모델(정적 모델)이다. 동적 모델은 객체와 협력으로 구성된다. 객체는 다른 객체와 협력하면서 애플리케이션의 기능을 수행한다. 정적 모델은 타입과 관계로 구성된다. 타입은 객체를 분류하기 위한 틀로서 동일한 타입에 속하는 객체들이 수행할 수 있는 모든 행동들을 압축해서 표현한 것이다.

정적 모델보다 동적 모델이 중요하다. 정적 모델은 동적 모델에 의해 주도돼야 하고 동적 모델이라는 토대 위에 세워져야 한다. 정적 모델이 객체 사이의 협력에 기반해야 한다는 것이 핵심이다.

동적 모델을 기반으로 정적 모델을 구상할 때 변경을 고려해야 한다. 설계가 필요한 이유는 변경을 수용할 수 있는 코드를 만들기 위해서다. 변경을 수용할 수 있는 코드란 단순하고 결합도가 낮으며 중복 코드가 없는 코드를 의미한다.

수정이 용이한 코드란 응집도가 높고 결합도가 낮으며 단순해서 쉽게 이해할 수 있는 코드다. 유연한 코드란 동일한 코드를 이용해 다양한 컨텍스트에서 동작 가능한 협력을 만들 수 있는 코드다. 유연성의 관점에서 작성된 코드는 객체 사이의 다양한 조합을 지원해야 한다. 수정이 용이한 코드와 유연한 코드에 대한 욕구는 중복 코드를 제거하게 만드는 가장 큰 압력이다. 중복 코드가 많을수록 하나의 개념을 변경하기 위해 여러 곳의 코드를 한꺼번에 수정해야 한다. 코드 수정 작업 자체의 괴로움은 둘째치더라도 버그가 발생할 수 있는 확률이 높아지기 때문에 중복 코드는 언제 터질지 모르는 시한폭탄과도 같다.

부록 C_1 동적 모델과 정적 모델

행동이 코드를 결정한다

협력에 참여하는 객체의 행동이 객체의 정적 모델을 결정해야 한다. Bird - Penguin 예제를 통해 객체의 행동을 고려하지 않을 경우 날 수 있는 Penguin이 나타나거나, Bird의 인스턴스들이 예상과 다르게 행동하거나, 개방-폐쇄 원칙을 위반하는 코드가 양산될 수밖에 없다. 정적 모델을 설계하는 이유는 단지 행동과 변경을 적절하게 수용할 수 있는 코드 구조를 찾는 것이어야 한다.

변경을 고려하라

행동의 측면에서 적절하게 정적 모델을 고려하더라도 변경을 고려하지 않는다면 유지보수하기 어려운 코드가 만들어진다. 상속과 합성의 예로 이를 알 수 있다. 동일한 행동을 제공하는 정적 모델이 있다면 항상 현재의 설계에서 요구되는 변경을 부드럽게 수용할 수 있는 설계를 선택하라.

겍ㅊ[지향 설계와 관련된 책들을 보면 도메인 모델을 먼저 만들고 도메인 모델을 기반으로 설계와 구현을 진행하라는 이야기를 볼 수 있다. 도메인 안의 개념과 관계를 담고 있는 도메인 모델은 정적 모델에 속하지 않는가? 그리고 이 정적 모델에 표현된 개념들을 기반으로 객체 사이의 협력을 설계하지 않았던가? 그렇다면 정적 모델이 동적 모델에 기반하는 것이 아니라 그 반대가 아닌가? 아래에서 설명한다.

부록 C_2 도메인 모델과 구현

도메인 모델에 관하여

도메인이란 사용자가 프로그램을 사용하는 대상 영역을 가리킨다. 모델이란 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태다. 도메인 모델이란 사용자가 프로그램을 사용하는 대상 영역에 대한 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태다.

소프트웨어의 도메인에 대해 고민하고 도메인 모델을 기반으로 소프트웨어를 구축해야 한다. 이 지침을 따르면 개념과 소프트웨어 사이의 표현적 차이를 줄일 수 있기 때문에 이해하고 수정하기 쉬운 소프트웨어를 만들 수 있다. 여기서 중요한 것은 도메인 모델을 작성하는 것이 목표가 아니라 출발점이라는 것이다. 도메인 모델은 소프트웨어를 만드는 데 필요한 개념의 이름과 의미, 그리고 관계에 대한 힌트를 제공하는 역할로 끝나야 한다. 모델은 옳거나 틀린 것이 아니다. 모델은 유용하거나 유용하지 않은 정도의 차이만 있을 뿐이다. 도메인 안의 개념들을 기반으로 출발하되, 객체들의 협력이 도메인 모델에 맞지 않다면 필요한 몇 가지 개념만 남기고 모델을 과감히 수정하라.

불행은 도메인 안의 개념이 제공하는 틀에 맞춰서 소프트웨어를 구축해야 한다고 생각할 때부터 시작된다. 그리고 도메인 모델이 클래스 다이어그램과 같은 정적 모델에 기반해야 한다는 오해 역시 잘못된 코드 구조를 낳는 원인이 된다. 도메인 모델이 클래스 다이어그램과 같은 정적인 형태로 표현돼야 한다는 것 역시 오해다. 도메인 모델은 여러분이 도메인에 대한 지식을 표현하고 코드의 구조에 대한 힌트를 제공할 수 있다면 어떤 형태로 표현하더라도 상관이 없다. 사실 객체 사이의 협력을 도드라지게 보여주는 개념적인 표현 역시 도메인 모델이 될 수 있다.

중요한 것은 객체들의 협력을 지원하는 코드 구조를 만드는 것이다. 도메인 개념을 충실히 따르는 코드가 목적이 아니다. 도메인 모델에 지나치게 집착하거나 도메인 모델의 초기 구조를 맹목적으로 따르는 코드를 작성하고 있다면 변경하기 어려운 소프트웨어가 만들어질 확률이 높다.

의사소통 패턴은 객체들이 다른 객체와 상호작용하는 방법을 관장하는 각종 규칙을 구성돼 있다. 도메인 모델은 이러한 의사소통 패턴에 속한다. 의사소통 패턴은 객체 간에 있을 법한 관계에 의미를 부여하기 때문이다. 도메인 모델은 명확하게 드러나지 않는데, 이는 의사소통 패턴이 프로그래밍 언어로 명확하게 표현되지 않기 때문이다. 정적인 분류와 동적인 의사소통 간의 불일치 탓에 객체에 대한 깔금한 클래스 계층 구조를 생각해 내기란 어렵다. 기껏해야 클래스 계층 구조는 1차원적인 애플리케이션을 나타내면서 객체 간의 구현 세부 사항을 공유하는 메커니즘을 제공한다.

몬스터 설계 예제

예제의 설계는 변경하기 어려운데, 그 이유는 새로운 몬스터가 필요할 때마다 새로운 클래스를 추가해야 하기 때문이다. 물론 예제는 기존 코드를 수정하지 않고도 새로운 몬스터를 추가할 수 있기 때문에 개방-폐쇄 원칙을 준수하는 설계라고 할 수 있다. 하지만 새로운 클래스를 추가하지 않고도 몬스터를 추가 가능한 것이 더 좋다. 개선한 코드에서 새로운 몬스터의 종류를 추가하는 것은 새로운 클래스를 추가하는 것이 아니라 새로운 Breed 인스턴스를 생성하고 Monster 인스턴스에 연결하는 작업으로 바뀐다. Monster 클래스는 시스템 내 몬스터가 수행해야 하는 행동을 정의한다. Breed 클래스는 몬스터의 타입을 정의한다. 이것은 상속 대신 합성을 사용하라는 설계 지침의 또 다른 예다. 하지만 설계의 의도 측면에서 살펴보면 약간 다르다. 이전 예제에서는 중복을 제거하고, 유연성을 향상시키기 위해 합성을 사용했다면, 여기서 합성을 사용한 이유는 새로운 몬스터 타입이 추가될 때마다 새로운 클래스를 추가하고 싶지 않기 때문이다. 새로운 클래스를 추가해야 하는 작업을 인스턴스 생성으로 대체한 것이다. 사실 여기서 살펴본 방법은 부록 B에서 설명하지 않은, 타입을 구현할 수 있는 또 다른 방법이다. 이 경우 타입은 인터페이스나 클래스로 구현되지 않고, 타입을 표현하는 클래스의 인스턴스로 구현된다. 다시 말해 어떤 객체의 타입을 표현하는 별도의 객체를 이용해 타입을 구현하는 것이다. 이처럼 어떤 인스턴스가 다른 인스턴스의 타입을 표현하는 방법을 Type Object 패턴이라고 부르고, Breed의 인스턴스가 바로 Monster의 타입을 구현하는 Type Object이다.

행동과 변경을 고려한 도메인 모델

우리는 도메인 모델을 먼저 만들고 만들어진 도메인 모델에 표현된 개념과 관계를 기반으로 협력에 필요한 객체의 후보를 도출하고 구현 클래스의 이름과 관계를 설계한다. 하지만 도메인 모델을 그대로 카피해서는 안 된다. 초기의 도메인 모델은 그저 작업을 시작하기 위한 거친 아이디어 덩어리일 뿐이다. 더 많은 지식이 쌓이고 요구사항이 분명해지면 초기의 아이디어에 대한 미련을 버리고 현명한 판단을 내려야 한다.

도메인 모델은 단순히 클래스 다이어그램이 아니다. 도메인의 핵심을 간략하게 단순화해서 표현할 수 있는 모든 것이 도메인 모델이다. 그렇게 작성된 개념이 여러분의 코드에 대한 구조와 행동을 드러낸다면 그것은 더없이 훌륭한 도메인 모델이다. 형식은 중요하지 않다. 심지어 예제처럼 json 형식의 데이터도 도메인 모델이 될 수 있다.

도메인 모델과 코드는 별개의 것이 아니다. 예제의 json 데이터도 역직렬화를 통해 코드에 적용할 수 있다. 또 다른 예로 11장의 기본 정책과 부가 정책을 들 수 있다. 여기서 가장 중요한 규칙은 핸드폰 요금제가 기본 정책과 부가 정책으로 구성돼 있다는 것이며 기본 정책과 부가 정책을 특정한 순서에 따라 조합할 수 있다는 것이다. 아마 이 요금제에 대해 잘 모른다면 코드를 읽더라도 이해하는 데 한계가 있을 것이다. 따라서 여기에서 활용될 도메인 모델은 기본 정채과 부가 정책의 조합 방식을 설명한 모델이어야 한다. 이 모델은 개념들의 분류 체계를 표현한 것이 아니다. 요금제를 구성하는 각 요소들이 실제로 요금을 계산할 때 어떤 순서로 처리돼야 하는지를 표현한 것이다. 즉 실행 시점의 모습을 표현한 동적 모델이다. 요점은 도메인 모델이 단순히 정적 모델의 형태를 띨 필요가 없으며 도메인 모델의 구조가 코드와 다를 필요가 없다는 것이다. 도메인 모델은 도메인 안에 존재하는 개념과 관계를 표현해야 하지만 최종 모습은 객체의 행동과 변경에 기반해야 하며 코드의 구조를 반영해야 한다. 도메인 모델을 봤을 때 도메인의 개념뿐만 아니라 코드도 함께 이해될 수 있는 구조를 찾는 것이 중요하다.

분석 모델, 설계 모델, 그리고 구현 모델

이론적으로 분석 모델은 해결 방법에 대한 언급 없이 도메인을 설명하는 모델이다. 분석 모델은 순수하게 문제 도메인에 초점을 맞춰야 하며 기술적인 해결 방법을 언급해서는 안 된다. 분석 모델이 완성되면 이를 바탕으로 기술적인 관점에서 솔루션을 서술하는 설계 모델이 만들어진다. 프로그래머는 이렇게 만들어진 청사진을 기반으로 구현 모델을 만들고 프로그래밍 언어를 사용해 컴퓨터가 이해할 수 있는 명령어로 변환한다. 그러나 분석 모델, 설계 모델, 구현 모델을 명확하게 구분하는 것은 가능하지도 않을 뿐더러 오히려 소프트웨어의 품질에 악영향을 미친다. 우리가 원하는 것은 분석과 설계와 구현 동안 동일한 모델을 유지하는 것이다.

도메인 모델이 코드와 동일한 형태를 가진다는 것은 분석, 설계, 구현에 걸쳐 동일한 모델을 사용한다는 것을 의미한다. 사실 객체지향 패러다임이 과거의 다른 패러다임과 구별되는 가장 큰 차이점은 소프트웨어를 개발하기 위한 전체 주기 동안 동일한 설계 기법과 모델링 방법을 사용할 수 있다는 것이다.

분석과 설계의 근본적인 차이는 분석이란 도메인을 이해하는 것인 반면, 설계는 도메인을 지원하는 소프트웨어를 이해하는 것이라는 점이다. 이 두 가지는 밀접하게 연결돼 있으며 둘 사이의 경계가 매우 모호해질 때가 많다. 그런데 경계가 뚜렷할 필요는 없다. 이론적으로는 분석인 동시에 설계의 특성을 가지는 하이브리도 모델이 좋은 것이 아니지만 이런 하이브리드한 특징이 오히려 가장 좋은 모델을 만든다.

분석 모델과 설계 모델의 하이브리드한 특징이 가장 좋은 모델을 낳는 토양이라면, 프로그래밍 언어를 사용해서 구현한 코드가 이 모델을 최대한 반영하는 것이 이상적일 것이다. 만약 설계 모델의 일부가 적용 기술 내에서 구현 불가능하다면 설계 모델을 변경해야 한다. 프로그래밍 작업 동안 설계의 실현 가능성과 정확성이 검증되고 테스트되고, 그 결과로 잘못된 설계가 수정되거나 새로운 설계로 교체된다. 프로그래밍은 설계의 한 과정이며 설계는 프로그래밍을 통해 개선된다. 따라서 분석, 설계, 구현 간의 구분이 방법론과 프로젝트 관리를 위해 중요 요소라고 하더라도 모델과 코드 간의 관계에 이를 강요해서는 안 된다.

코드와 모델의 차이를 줄이기 위해서는 도메인과 코드 간의 차이가 적어야 한다. 객체지향의 가장 큰 힘은 도메인을 표현하는 방법과 프로그램 코드를 표현하는 방법이 동일하다는 것이다. 프로그램 코드가 마치 설계 문서로 간주될 수 있는 것이다.

분석 모델과 설계 모델과 구현 모델이 다르다는 생각을 버려라. 그리고 분석과 설계와 구현이 별개의 활동이라는 생각 역시 버려라. 객체지향 프로그래밍 언어는 도메인을 바라보는 관점을 소프트웨어에 투영할 수 있는 다양한 기법들을 제공한다. 도메인의 개념과 객체 사이의 협력을 잘 버무려 코드에 반영하기 위해 고민하고 프로그래밍하는 동안 분석과 설계와 구현에 대해 동시에 고민하고 있는 것이다.

설계가 도메인 모델과 대응하지 않는다면 그 모델은 그다지 가치가 없으며 소프트웨어의 정확성도 의심스러워진다. 동시에 모델과 설계 기능 사이의 복잡한 대응은 이해하기 힘들고, 실제로 설계가 변경되면 유지보수가 불가능해진다. 모델이 구현에 대해 비현실적으로 보인다면 새로운 모델을 찾아내야만 한다. 모델이 도메인의 핵심 개념을 충실하게 표현하지 않을 때도 새로운 모델을 찾아내야만 한다. 그래야 모델링과 설계 프로세스가 단 하나의 반복 고리를 형성할 수 있다.

여러분의 코드는 도메인의 개념적인 분류 체계가 아니라 객체의 행동과 변경에 영향을 받는다. 그리고 객체지향 패러다임에 대한 흔한 오해와는 다르게 분석 모델과 설계 모델, 구현 모델 사이에 어떤 차이점도 존재하지 않는다. 이것들은 모두 행동과 변경이라는 요소에 영향을 받으며 전체 개발 주기 동안 동일한 모양을 지녀야 한다.

객체지향 패러다임이 강력한 이유는 전체 개발 주기에 걸쳐 동일한 기법과 표현력을 유지할 수 있다는 점이다. 분석, 설계, 구현 단계 사이에 세부적인 내용은 다를 수 있겠지만 설계의 초점은 동일하다. 결론은 모든 단계에 걸쳐 행동과 변경에 초점을 맞추라는 것이다.