[개발서적] 오브젝트 핵심 내용 정리 및 요약
0. 프로그래밍 패러다임
[ 패러다임의 시대 ]
프로그래밍 패러다임은 개발자 공동체가 동일한 프로그래밍 스타일과 모델을 공유할 수 있게 함으로써 불필요한 부분에 대한 의견 충돌을 방지한다. 또한 프로그래밍 패러다임을 교육시킴으로써 동일한 규칙과 방법을 공유하는 개발자로 성장할 수 있도록 준비시킬 수 있다.
이 책은 코드를 개발하는 우리가 객체지향 패러다임이라는 용어를 사용할 때 완벽하게 동일하지는 않더라도 어느 정도 유사한 그림을 머릿속에 그릴 수 있는 기반을 제공하는 것이다. 또한 객체지향에 대한 다양한 오해를 제거함으로써 객체지향 프로그래밍을 하는 개발자들이 동일한 규칙과 표준에 따라 프로그램을 작성할 수 있게 할 것이다.
1. 객체, 설계
[ 2. 무엇이 문제인가? ]
로버트 마틴은 “클린 소프트웨어”에서 소프트웨어가 가져야 하는 3가지 기능에 관해 설명한다. 여기서 모듈이란 크기와 상관 없이 클래스나 패키지, 라이브러리와 같이 프로그램을 구성하는 임의의 요소를 의미한다.
- 실행 중에 제대로 동작하는 것(모듈의 존재 이유)
- 변경을 위해 존재하는 것(모듈은 간단한 작업으로도 변경이 가능해야 함)
- 코드를 읽는 사람과 의사소통하는 것(모듈은 특별한 훈련 없이도 개발자가 쉽게 읽고 이해할 수 있어야 함)
예상을 빗나가는 코드
이해 가능한 코드란 그 동작이 우리의 예상에서 크게 벗어나지 않는 코드다. 안타깝게도 앞에서 살펴본 예제는 우리의 예상을 벗어난다.
코드를 이해하기 어렵게 만드는 또 다른 이유가 있다. 이 코드를 이해하기 위해서는 여러 가지 세부적인 내용들을 한꺼번에 기억하고 있어야 한다는 점이다. 이 코드는 하나의 클래스나 메서드에서 너무 많은 세부사항을 다루기 대문에 코드를 작성하는 사람뿐만 아니라 코드를 읽고 이해해야 하는 사람 모두에게 큰 부담을 준다.
변경에 취약한 코드
더 큰 문제는 변경에 취약하다는 것이다. 어떤 클래스가 다른 클래스의 내부에 대해 더 많이 알면 알수록 변경하기 어려워진다.
이것은 객체 사이의 의존성(dependency)과 관련된 문제다. 문제는 의존성이 변경과 관련돼 있다는 점이다. 의존성은 변경에 대한 영향을 암시한다. 의존성이라는 말 속에는 어떤 객체가 변경될 때 그 객체에게 의존하는 다른 객체도 함께 변경될 수 있다는 사실이 내포돼 있다.
그렇다고 해서 객체 사이의 의존성을 완전히 없애는 것이 정답은 아니다. 객체지향 설계는 서로 의존하면서 협력하는 객체들의 공동체를 구축하는 것이다. 따라서 우리의 목표는 애플리케이션의 기능을 구현하는 데 필요한 최소한의 의존성만 유지하고 불필요한 의존성을 제거하는 것이다.
객체 사이의 의존성이 과한 경우를 가리켜 결합도(couping)가 높다고 말한다. 반대로 객체들이 합리적인 수준으로 의존할 경우에는 결합도가 낮다고 말한다. 결합도는 의존성과 관련돼 있기 때문에 결합도 역시 변경과 관련이 있다. 두 객체 사이의 결합도가 높으면 높을수록 함께 변경될 확률도 높아지기 때문에 변경하기 어려워진다. 따라서 설계의 목표는 객체 사이의 결합도를 낮춰 변경이 용이한 객체를 만드는 것이다.
자율성을 높이자
설계를 변경하기 어려운 이유는 다른 클래스의 세부까지 마음대로 접근할 수 있기 때문이다. 해결 방법은 각각의 객체가 직접 처리하는 자율적인 존재가 되도록 설계를 변경하는 것이다.
개념적이나 물리적으로 객체 내부의 세부적인 사항을 감추는 것을 캡슐화(encapsulation)라고 부른다. 캡슐화의 목적은 변경하기 쉬운 객체를 만드는 것이다. 캡슐화를 통해 객체 내부로의 접근을 제한하면 객체와 객체 사이의 결합도를 낮출 수 있기 때문에 설계를 좀 더 쉽게 변경할 수 있게 된다.
캡슐화와 응집도
핵심은 객체 내부의 상태를 캡슐화하고 객체 간에 오직 메시지를 통해서만 상호작용하도록 만드는 것이다.
밀접하게 연관된 작업만을 수행하고 연관성 없는 작업은 다른 객체에게 위임하는 객체를 가리켜 응집도가 높다고 말한다. 자신의 데이터를 스스로 처리하는 자율적인 객체를 만들면 결합도를 낮출 수 있을 뿐더러 응집도를 높일 수 있다. 외부의 간섭을 최대한 배제하고 메시지를 통해서만 협력하는 자율적인 객체들의 공동체를 만드는 것이 훌륭한 객체지향 설계를 얻을 수 있는 지름길인 것이다.
절차지향과 객체지향
프로세스와 데이터를 별도의 모듈에 위치시키는 방식을 절차적 프로그래밍(Procedural Programming)이라고 부른다. 이것은 모든 처리가 하나의 클래스 안에 위치하고 나머지 클래스는 단지 데이터의 역할만 수행한다.
하지만 일반적으로 절차적 프로그래밍은 우리의 직관에 위배된다. 절차적 프로그래밍의 세상은 우리의 예상을 너무나도 쉽게 벗어나기 때문에 코드를 읽는 사람과 원활하게 의사소통하지 못한다. 더 큰 문제는 데이터의 변경으로 인한 영향을 지역적으로 고립시키기 어렵다는 것이다. 변경은 버그를 부르고 버그에 대한 두려움은 코드를 변경하기 어렵게 만든다. 따라서 절차적 프로그래밍의 세상은 변경하기 어려운 코드를 양산하는 경향이 있다.
변경하기 쉬운 설계는 한 번에 하나의 클래스만 변경할 수 있는 설계다. 절차적 프로그래밍은 프로세스가 필요한 모든 데이터에 의존해야 한다는 근본적인 문제점 때문에 변경에 취약할 수 밖에 없다.
해결 방법은 자신의 데이터를 스스로 처리하도록 프로세스의 적절한 단계를 이동시키는 것이다. 수정한 후의 코드에서는 데이터를 사용하는 프로세스가 데이터를 소유하는 객체 내부로 옮겨졌다. 이처럼 데이터와 프로세스가 동일한 모듈 내부에 위치하도록 프로그래밍하는 방식을 객체 지향 프로그래밍(Object-Oriented Programmin)이라고 부른다.
훌륭한 객체지향 설계의 핵심은 캡슐화를 이용해 의존성을 적절히 관리함으로써 객체 사이의 결합도를 낮추는 것이다. 객체지향 코드는 자신의 문제를 스스로 처리해야 한다는 우리의 예상을 만족시켜주기 때문에 이해하기 쉽고, 객체 내부의 변경이 객체 외부에 파급되지 않도록 제어할 수 있기 때문에 변경하기가 수월하다.
더 개선할 수 있다
이 작은 예제를 통해 여러분은 두 가지 사실을 알게 됐을 것이다. 첫째, 어떤 기능을 설계하는 방법은 한 가지 이상일 수 있다. 둘째, 동일한 기능을 한 가지 이상의 방법으로 설계할 수 있기 때문에 결국 설계는 트레이드오프의 산물이다. 어떤 경우에도 모든 사람들을 만족시킬 수 있는 설계를 만들 수는 없다.
그래, 거짓말이다.
비록 현실에서는 수동적인 존재라고 하더라도 일단 객체지향의 세계에 들어오면 모든 것이 능동적이고 자율적인 존재로 바뀐다. 레베카 워프스브록은 이처럼 능동적이고 자율적인 존재로 소프트웨어 객체를 설계하는 원칙을 가리켜 의인화(anthropomorphism)라고 부른다.
앞에서는 실세계에서의 생물처럼 스스로 생각하고 행동하도록 소프트웨어 객체를 설계하는 것이 이해하기 쉬운 코드를 작성하는 것이라고 설명했다. 하지만 이제 말을 조금 바꿔야겠다. 훌륭한 객체지향 설계란 소프트웨어를 구성하는 모든 객체들이 자율적으로 행동하는 설계를 가리킨다. 그 대상이 비록 실세계에서는 생명이 없는 수동적인 존재라고 하더라도 객체지향의 세계로 넘어오는 순간 그들은 생명과 지능을 가진 싱싱한 존재로 다시 태어난다.
[ 4. 객체지향 설계 ]
설계가 왜 필요한가
어떤 사람들은 설계가 코드를 작성하는 것보다는 높은 차원의 창조적인 행위라고 생각하는 것 같다. 하지만 설계를 구현과 떨어트려서 이야기하는 것은 불가능하다. 설계는 코드를 작성하는 매 순간 코드를 어떻게 배치할 것인지를 결정하는 과정에서 나온다. 설계는 코드 작성의 일부이며 코드를 작성하지 않고서는 검증할 수 없다.
좋은 설계란 오늘 요구하는 기능을 온전히 수행하면서 내일의 변경을 매끄럽게 수용할 수 있는 설계다.
객체지향 설계
우리가 진정으로 원하는 것은 변경에 유연하게 대응할 수 있는 코드다. 객체지향 프로그래밍은 의존성을 효율적으로 통제할 수 있는 다양한 방법을 제공함으로써 요구사항 변경에 좀 더 수월하게 대응할 수 있는 가능성을 높여준다.
변경 가능한 코드란 이해하기 쉬운 코드다. 객체지향은 여러분이 세상에 대해 예상하는 방식대로 객체가 행동하리라는 것을 보장함으로써 코드를 좀 더 쉽게 이해할 수 있게 한다.
그러나 단순히 데이터와 프로세스를 객체라는 덩어리 안으로 밀어 넣었다고 해서 변경하기 쉬운 설계를 얻을 수 있는 것은 아니다. 객체지향의 설계에서 애플리케이션은 객체들로 구성되며 애플리케이션의 기능은 객체들 간의 상호작용을 통해 구현된다. 그리고 객체들 사이의 상호작용은 객체 사이에 주고 받는 메시지로 표현된다. 메시지를 전송하기 위한 이런 지식이 두 객체를 결합시키고 이 결합이 객체 사이의 의존성을 만든다.
데이터와 프로세스를 하나의 덩어리로 모으는 것은 훌륭한 객체지향 설계로 가는 첫걸음일 뿐이다. 진정한 객체지향 설계로 나아가는 길은 협력하는 객체들 사이의 의존성을 적절하게 조절함으로써 변경에 용이한 설계를 만드는 것이다.
2. 객체지향 프로그래밍
[ 2. 객체지향 프로그래밍을 향해 ]
협력, 객체, 클래스
대부분의 사람들은 클래스를 결정한 후에 클래스에 어떤 속성과 메서드가 필요한지 고민한다. 안타깝게도 이것은 객체지향의 본질과는 거리가 멀다. 객체지향은 말 그대로 객체를 지향하는 것이다. 진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 대에만 얻을 수 있다. 이를 위해서는 프로그래밍하는 동안 다음의 두 가지에 집중해야 한다.
- 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것이다. 따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 갖는지를 먼저 결정해야 한다. 객체를 중심에 두는 접근 방법은 설계를 단순하고 깔끔하게 만든다.
- 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다. 객체는 홀로 존재하는 것이 아니다. 다른 객체에게 도움을 주거나 의존하면서 살아가는 협력적인 존재이다. 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다. 객체지향적으로 생각하고 싶다면 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 바라봐야 한다. 그리고 객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 클래스를 구현하라. 훌륭한 협력이 훌륭한 객체를 낳고 훌륭한 객체가 훌륭한 클래스를 낳는다.
도메인의 구조를 따르는 프로그램 구조
객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할 수 있기 때문이다. 요구사항과 프로그램을 객체라는 동일한 관점에서 바라볼 수 있기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결될 수 있다.
클래스 구현하기
그렇다면 클래스의 내부와 외부를 구분해야 하는 이유는 무엇일까? 그 이유는 경계의 명확성이 객체의 자율성을 보장하기 때문이다. 그리고 더 중요한 이유로 프로그래머에게 구현의 자유를 제공하기 때문이다.
자율적인 객체
객체와 관련해서 2가지 중요한 사실이 있다.
- 객체가 상태(state)와 행동을 함께 갖는 복합적인 존재라는 것
- 객체가 스스로 판단하고 행동하는 자율적인 존재라는 것
객체지향은 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 문제 영역의 아이디어를 적절하게 표현할 수 있게 했다. 이처럼 데이터와 기능을 객체 내부로 함께 묶는 것을 캡슐화라고 부른다.
대부분의 객체지향 언어들은 상태와 행동을 캡슐화하는 것에 더해 외부에서의 접근을 통제할 수 있는 접근 제어 메커니즘을 함께 제공한다.
객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위해서다. 객체지향의 핵심은 스스로 상태를 관리하고, 판단하고, 행동하는 자율적인 객체들의 공동체를 구성하는 것이다. 객체가 자율적인 존재가 되기 위해서는 외부의 간섭을 최소화해야 한다. 외부에서는 객체가 어떤 상태에 놓여 있는지, 어떤 생각을 하고 있는지 알아서는 안되며, 결정에 직접적으로 개입하려고 해서도 안된다. 객체에게 원하는 것을 요청하고 객체가 스스로 최선의 방법을 결정하는 것을 믿고 기다려야 한다.
캡슐화와 접근 제어는 객체를 2가지로 나눈다. 하나는 외부에서 접근 가능한 부분으로 이를 퍼블릭 인터페이스(public Interface)라고 부른다. 다른 하나는 외부에서 접근이 불가능하고, 오직 내부에서만 접근 가능한 부분으로 이를 구현(Implementation)이라고 부른다. 인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.
일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 한다.
프로그래머의 자유
객체의 외부와 내부를 구분하면 알아야 할 지식의 양이 줄어들고, 구현을 변경할 수 있는 폭이 넓어진다. 그러므로 클래스를 개발할 때마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력해야 한다.
설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 기억하라. 객체지향 언어는 개체 사이의 의존성을 적절히 관리함으로써 변경에 대한 파급효과를 제어할 수 있는 다양한 방법을 제공한다. 객체의 변경을 관리할 수 있는 기법 중에서 가장 대표적인 것이 바로 접근 제어다. 여러분은 변경될 가능성이 있는 세부적인 구현 내용을 private 영역 안에 감춤으로써 변경으로 인한 혼란을 최소화할 수 있다.
협력하는 객체들의 공동체
객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다. 따라서 의미를 좀 더 명시적이고 분명하게 표현할 수 있다면 객체를 사용해서 해당 개념을 구현하라. 그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높이는 첫걸음이다.
인스턴스들은 서로의 메서드를 호출하며 상호작용한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협동(collaboration)이라고 부른다.
객체지향 프로그램을 작성할 때는 먼저 협력의 관점에서 어떤 객체가 필요한지를 결정하고, 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.
협력에 관한 짧은 이야기
객체가 다른 객체와 상호작용하는 유일한 방법은 메세지를 전송하는 것 뿐이다. 메세지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메세지를 처리할 방법을 결정한다. 메세지와 달리 수신된 메세지를 처리하기 위한 자신만의 방법을 메서드라고 부른다.
메시지와 메서드를 구분하는 것은 매우 중요하다. 뒤에서 살펴보겠지만 메시지와 메서드의 구분에서부터 다형성(polymorphism)의 개념이 출발한다.
메시지를 수신한 객체는 스스로 적절한 메서드를 선택한다. 사실 예제에서 사용한 자바 같은 정적 타입 언어에는 해당하지 않지만 동적 타입 언어에서는 다른 시그니처를 가진 메서드를 통해서도 해당 메시지에 응답할 수 있다. 결국 메시지를 처리하는 방법을 결정하는 것은 객체 스스로의 문제인 것이다. 이것이 객체가 메시지를 처리하는 방법을 자율적으로 결정할 수 있다고 말했던 이유다.
[ 4. 상속과 다형성 ]
컴파일 시간 의존성과 실행 시간 의존성
코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다는 것이다. 다시 말해 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다. 그리고 유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특지은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다.
한 가지 간과해서는 안 되는 사실은 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드를 이해하기 어려워진다는 것이다. 코드를 이해하기 위해서는 코드 뿐만 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다. 반면 코드의 의존성과 실행 시점의 의존성이 다르면 다를수록 코드는 더 유연해지고 확장 가능해진다. 이와 같이 의존성의 양면성은 설계가 트레이드오프의 산물이라는 사실을 잘 보여준다.
설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다는 사실을 기억하라. 반면 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장 가능성은 낮아진다는 사실을 기억하라. 여러분이 훌륭한 객체지향 설계자로 성장하기 위해서는 항상 유연성과 가독성 사이에서 고민해야 한다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다.
상속과 인터페이스
상속이 가치 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다. 이것은 상속을 바라보는 일반적인 인식과는 거리가 있는데 대부분의 사람들은 상속의 목적이 메서드나 인스턴스 변수를 재사용하는 것이라고 생각하기 때문이다.
다형성
코드 상에서 어떤 클래스는 다른 클래스에게 메세지를 전송하지만 실행 시점에서 실제로 진행되는 메소드는 객체에 따라 달라진다. 다시 말해서 동일한 메세지를 전송하지만 실제로 어떤 메소드가 실행될 것인지는 메세지를 수신하는 객체의 클래스가 무엇인지에 따라 달라진다. 이를 다형성이라고 부른다.
다형성은 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다. 다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.
다형성이란 동일한 메시지를 수신했을 대 객체의 타입에 따라 다르게 응답할 수 있는 능력을 의미한다. 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. 다시 말해 인터페이스가 동일해야 한다는 것이다.
상속을 구현 상속(implementation inheritance)과 인터페이스 상속(interface inheritance)으로 분류할 수 있다. 흔히 구현 상속을 서브클래싱이라고 부르고 인터페이스 상속을 서브타이핑이라고 부른다. 순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것을 구현 상속이라고 부른다. 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것을 인터페이스 상속이라고 부른다.
상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다. 인터페이스를 재사용할 목적이 아니라 구현을 재사용할 목적으로 상속을 사용하면 변경에 취약한 코드를 낳게 될 확률이 높다.
[ 5. 추상화와 유연성 ]
유연한 설계
책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다. 항상 예외 케이스를 최소하하고 일관성을 유지할 수 있는 방법을 선택하라.
추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계를 만들 수 있다. 추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다.
상속
상속은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다. 하지만 두 가지 관점에서 설계에 안 좋은 영향을 미친다. 하나는 상속이 캡슐화를 위반한다는 것이고, 다른 하나는 설계를 유연하지 못하게 만든다는 것이다.
상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.
결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함게 변경될 확률을 높인다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.
상속의 두 번째 단점은 설계가 유연하지 않다는 것이다. 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능하다.
합성
이 방법이 상속과 다른 점은 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합하는 데 비해 인터페이스를 통해 약하게 결합된다는 것이다. 부모 클래스가 해당 메서드를 제공한다는 사실만 알고 내부 구현에 대해서는 전혀 알지 못한다. 이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 한다.
합성은 상속이 가지는 두 가지 문제점을 모두 해결한다. 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다. 상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다. 따라서 코드 재사용을 위해서는 상속보다는 합성을 선호하는 것이 더 좋은 방법이다.
코드를 재사용하는 경우에는 상속보다 합성을 선호하는 것이 옳지만 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수 밖에 없다.
3. 역할, 책임, 협력
객체지향 패러다임의 관점에서 핵심은 역할(role), 책임(responsibility), 협력(collaoration)이다. 클래스, 상속, 지연 바인딩이 중요하지 않은 것은 아니지만 다분히 구현 측면에 치우쳐 있기 때문에 객체지향 패러다임의 본질과는 거리가 멀다.
객체지향의 본질은 협력하는 객체들의 공동체를 창조하는 것이다. 객체지향 설계의 핵심은 협력을 구성하기 위해 적절한 객체를 찾고 적절한 책임을 할당하는 과정에서 드러난다. 클래스와 상속은 객체들의 책임과 협력이 어느 정도 자리를 잡은 후에 사용할 수 있는 구현 메커니즘일 뿐이다. 애플리케이션의 기능을 구현하기 위해 어떤 협력이 필요하고 협력을 위해 어떤 역할과 책임이 필요한지를 고민하지 않은 채 너무 이른 시기에 구현에 초점을 맞추는 것은 변경하기 어렵고 유연하지 못한 코드를 낳는 원인이 된다.
[ 1. 협력 ]
영화 예매 시스템 돌아보기
여기서 중요한 것은 다양한 객체들이 영화 예매라는 기능을 구현하기 위해 메시지를 주고 받으면서 상호작용한다는 점이다. 이처럼 객체들이 애플리케이션의 기능을 구현하기 위해 수행하는 상호작용을 협력이라고 한다. 객체가 협력에 참여하기 위해 수행하는 로직은 책임이라고 부른다. 객체들이 협력 안에서 수행하는 책임들이 모여 객체가 수행하는 역할을 구성한다.
협력
객체지향 시스템은 자율적인 객체들의 공동체다. 객체는 고립된 존재가 아니라 시스템의 기능이라는 더 큰 목표를 달성하기 위해 다른 객체와 협력하는 사회적인 존재다. 협력은 객체지향의 세계에서 기능을 구현할 수 있는 유일한 방법이다. 두 객체 사이의 협력은 하나의 객체가 다른 객체에게 도움을 요청할 때 시작된다. 메시지 전송(message sending)은 객체 사이의 협력을 위해 사용할 수 있는 유일한 커뮤니케이션 수단이다. 객체는 다른 객체의 상세한 내부 구현에 직접 접근할 수 없기 때문에 오직 메시지 전송을 통해서만 자신의 요청을 전달할 수 있다.
메시지를 수신한 객체는 메서드를 실행해 요청에 응답한다. 여기서 객체가 메시지를 처리할 방법을 스스로 선택한다는 점이 중요하다. 외부의 객체는 오직 메시지만 전송할 수 있을 뿐이며 메시지를 어떻게 처리할지는 메시지를 수신한 객체가 직접 결정한다. 이것은 객체가 자신의 일을 스스로 처리할 수 있는 자율적인 존재라는 것을 의미한다.
결과적으로 객체를 자율적으로 만드는 가장 기본적인 방법은 내부 구현을 캡슐화하는 것이다. 캡슐화를 통해 변경에 대한 파급효과를 제한할 수 있기 때문에 자율적인 객체는 변경하기도 쉬워진다.
협력이 설계를 위한 문맥을 결정한다
애플리케이션 안에 어떤 객체가 필요하다면 그 이유는 단 하나여야 한다. 그 객체가 어떤 협력에 참여하고 있기 때문이다. 그리고 객체가 협력에 참여할 수 있는 이유는 협력에 필요한 적절한 행동을 보유하고 있기 때문이다.
결론적으로 객체의 행동을 결정하는 것은 객체가 참여하고 있는 협력이다. 협력이 바뀌면 객체가 제공해야 하는 행동 역시 바뀌어야 한다. 협력은 객체가 필요한 이유와 객체가 수행하는 행동의 동기를 제공한다.
객체의 행동을 결정하는 것이 협력이라면 객체의 상태를 결정하는 것은 행동이다. 객체의 상태는 그 객체가 행동을 수행하는 데 필요한 정보가 무엇인지로 결정된다. 객체는 자신의 상태를 스스로 결정하고 관리하는 자율적인 존재이기 때문에 객체가 수행하는 행동에 필요한 상태도 함께 가지고 있어야 한다.
[ 2. 책임 ]
책임이란 무엇인가?
객체를 설계하기 위해 필요한 문맥인 협력이 갖춰졌다고 하자. 다음으로 할 일은 협력에 필요한 행동을 수행할 수 있는 적절한 객체를 찾는 것이다. 이때 협력에 참여하기 위해 객체가 수행하는 행동을 책임이라고 부른다.
책임이란 객체에 의해 정의되는 응집도 있는 행위의 집합으로, 객체가 유지해야 하는 정보와 수행할 수 있는 행동에 대해 개략적으로 서술한 문장이다.
책임은 객체지향 설계의 핵심이다. 크레이그 라만은 “객체지향 개발에서 가장 중요한 능력은 책임을 능숙하게 객체에 할당하는 것”이라는 말로 책임 할당의 중요성을 강조하기도 했다. 사실 협력이 중요한 이유는 객체에게 할당할 책임을 결정할 수 있는 문맥을 제공하기 때문이다.
객체에게 얼마나 적절한 책임을 할당하느냐가 설계의 전체적인 품질을 결정한다. 객체의 구현 방법은 상대적으로 책임보다는 덜 중요하며 책임을 결정한 다음에 해도 늦지 않다.
책임 할당
자율적인 객체를 만드는 가장 기본적인 방법은 책임을 수행하는 데 필요한 정보를 가장 잘 알고 있는 전문가에게 그 책임을 할당하는 것이다. 이를 책임 할당을 위한 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다.
따라서 객체에게 책임을 할당하기 위해서는 먼저 협력이라는 문맥을 정의해야 한다. 협력을 설계하는 출발점은 시스템이 사용자에게 제공하는 기능을 시스템이 담당할 하나의 책임으로 바라보는 것이다. 객체지향 설계는 시스템의 책임을 완료하는 데 필요한 더 작은 책임을 찾아내고 이를 객체들에게 할당하는 반복적인 과정을 통해 모양을 갖춰간다.
이렇게 결정된 메시지가 객체의 퍼블릭 인터페이스를 구성한다는 것 역시 눈여겨보기 바란다. 협력을 설계하면서 객체의 책임을 식별해 나가는 과정에서 최종적으로 얻게 되는 결과물은 시스템을 구성하는 객체들의 인터페이스와 오퍼레이션의 목록이다.
물론 모든 책임 할당 과정이 이렇게 단순한 것은 아니다.. 어떤 경우에는 응집도와 결합도의 관점에서 정보 전문가가 아닌 다른 객체에게 책임을 할당하는 것이 더 적절한 경우도 있다. 하지만 기본적인 전략은 책임을 수행할 정보 전문가를 찾는 것이다.
책임 주도 설계
어떤 책임을 선택하느냐가 전체적인 설계의 방향과 흐름을 결정한다. 이처럼 책임을 찾고 책임을 수행할 적절한 객체를 찾아 책임을 할당하는 방식으로 협력을 설계하는 방법을 책임 주도 설계(Responsibility-Driven Design, RDD)라고 부른다.
협력은 객체를 설계하기 위한 구체적인 문맥을 제공한다. 협력이 책임을 이끌어내고 책임이 협력에 참여할 객체를 결정한다. 책임 주도 설계는 자연스럽게 객체의 구현이 아닌 책임에 집중할 수 있게 한다. 구현이 아닌 책임에 집중하는 것이 중요한 이유는 유연하고 견고한 객체지향 시스템을 위해 가장 중요한 재료가 바로 책임이기 때문이다.
이제 책임을 할당할 때 고려해야 하는 두 가지 요소를 소개하는 것으로 책임에 대한 소개를 마치려고 한다. 하나는 메시지가 객체를 결정한다는 것이고, 다른 하나는 행동이 상태를 결정한다는 것이다.
메시지가 객체를 결정한다
객체에게 책임을 할당하는 데 필요한 메시지를 먼저 식별하고 메시지를 처리할 객체를 나중에 선택한다는 것이 중요하다. 다시 말해 객체가 메시지를 선택하는 것이 아니라 메시지가 객체를 선택하게 했다. 메시지가 객체를 선택해야 하는 두 가지 중요한 이유가 있다.
첫째, 객체가 최소한의 인터페이스(minimal interface)를 가질 수 있게 된다. 필요한 메시지가 식별될 때까지 객체의 퍼블릭 인터페이스에 어떤 것도 추가하지 않기 때문에 객체는 애플리케이션에 크지도, 작지도 않은 꼭 필요한 크기의 퍼블릭 인터페이스를 가질 수 있다.
둘째, 객체는 추상적인 인터페이스(abstract interface)를 가질 수 있게 된다. 객체의 인터페이스는 무엇을 하는지는 표현해야 하지만 어떻게 수행하는지를 노출해서는 안 된다. 메시지는 외부의 객체가 요청하는 무언가를 의미하기 때문에 메시지를 먼저 식별하면 무엇을 수행할지에 초점을 맞추는 인터페이스를 얻을 수 있다.
행동이 상태를 결정한다
객체가 존재하는 이유는 협력에 참여하기 위해서다. 따라서 객체는 협력에 필요한 행동을 제공해야 한다. 객체를 객체답게 만드는 것은 객체의 상태가 아니라 객체가 다른 객체에게 제공하는 행동이다. 얼마나 적절한 객체를 창조했느냐는 얼마나 적절한 책임을 할당했느냐에 달려있고, 책임이 얼마나 적절한지는 협력에 얼마나 적절한가에 달려있다.
객체지향 패러다임에 갓 입문한 사람들이 가장 쉽게 빠지는 실수는 객체의 행동이 아니라 상태에 초점을 맞추는 것이다. 초보자들은 먼저 객체에 필요한 상태가 무엇인지를 결정하고, 그 후에 상태에 필요한 행동을 결정한다. 이런 방식은 객체의 내부 구현이 객체의 퍼블릭 인터페이스에 노출되도록 만들기 때문에 캡슐화를 저해한다.
[ 3. 역할 ]
역할과 협력
객체가 어떤 특정한 협력 안에서 수행하는 책임의 집합을 역할이라고 부른다. 실제로 협력을 모델링할 때는 특정한 객체가 아니라 역할에게 책임을 할당한다고 생각하는 게 좋다.
그렇다면 어떤 이유로 역할이라는 개념을 이용해서 설계 과정을 더 번거롭게 만드는 것일까? 어차피 역할이 없어도 객체만으로 충분히 협력을 설계할 수 있는 것 아닌가?
유연하고 재사용 가능한 협력
역할이 중요한 이유는 역할을 통해 유연하고 재사용가능한 협력을 얻을 수 있기 때문이다.
영화 예매 도메인에는 금액 할인 정책과 비율 할인 정책이라는 두 가지 종류의 가격 할인 정책이 존재하기 때문에 두 종류의 객체가 “할인 요금을 계산하라” 메시지에 응답할 수 있어야 한다.
순수하게 책임의 관점에서 두 협력을 바라보면 모두 “할인 요금 계산”이라는 동일한 책임을 수행한다는 사실을 알 수 있다. 따라서 객체라는 존재를 치우고 “할인 요금을 계산하라”라는 메시지에 응답할 수 있는 대표자를 생각한다면 두 협력을 하나로 통합할 수 있을 것이다. 이 대표자를 협력 안에서 두 종류의 객체를 교대로 바꿔 끼울 수 있는 일종의 슬롯으로 생각할 수 있다. 이 슬롯이 바로 역할이다.
여기서의 역할이 두 종류의 구체적인 객체를 포괄하는 추상화라는 점에 주목하라.
요점은 동일한 책임을 수행하는 역할을 기반으로 두 개의 협력을 하나로 통합할 수 있다는 것이다. 따라서 역할을 이용하면 불필요한 중복 코드를 제거할 수 있다. 더 좋은 소식은 협력이 이제 유연해졌다는 점이다. 따라서 책임과 역할을 중심으로 협력을 바라보는 것이 바로 변경과 확장이 용이한 유연한 설계로 나아가는 첫걸음이다.
===== 역할의 구현 =====
추상화라는 말에서 예상했겠지만 역할을 구현하는 가장 일반적인 방법은 추상 클래스와 인터페이스를 사용하는 것이다. 협력의 관점에서 추상 클래스와 인터페이스는 구체 클래스들이 따라야 하는 책임의 집합을 서술한 것이다. 추상 클래스는 책임의 일부를 구현해 놓은 것이고 인터페이스는 일체의 구현 없이 책임의 집합만을 나열해 놓았다는 차이가 있지만 협력의 관점에서는 둘 모두 역할을 정의할 수 있는 구현 방법이라는 공통점을 공유한다.
객체 대 역할
대부분의 경우에 어떤 것이 역할이고 어떤 것이 객체인지가 또렷하게 드러나지는 않을 것이다. 특히나 명확한 기준을 세우기 어렵고 정보가 부족한 설계 초반에는 결정을 내리기가 더욱 어려울 것이다.
이에 대한 개인적인 견해는 설계 초반에는 적절한 책임과 협력의 큰 그림을 탐색하는 것이 목표여야 하고 역할과 객체를 명확하게 구분하는 것은 그렇게 중요하지는 않다는 것이다. 따라서 애매하다면 단순하게 객체로 시작하고 반복적으로 책임과 협력을 정제해가면서 필요한 순간에 객체로부터 역할을 분리해내는 것이 가장 좋은 방법이다.
역할과 추상화
역할은 공통의 책임을 바탕으로 객체의 종류를 숨기기 때문에 이런 관점에서 역할을 객체의 추상화로 볼 수 있다.
추상화의 첫 번째 장점은 세부 사항에 억눌리지 않고도 상위 수준의 정책을 쉽고 간단하게 표현할 수 있다는 것이다. 추상화를 적절하게 사용하면 불필요한 세부 사항을 생략하고 핵심적인 개념을 강조할 수 있다. 협력이라는 관점에서는 세부적인 사항을 무시하고 추상화에 집중하는 것이 유용하다.
추상화의 두 번째 장점은 설계를 유연하게 만들 수 있다는 것이다. 협력 안에서 동일한 책임을 수행하는 객체들은 동일한 역할을 수행하기 때문에 서로 대체 가능하다. 따라서 역할은 다양한 환경에서 다양한 객체들을 수용할 수 있게 해주므로 협력을 유연하게 만든다.
4. 설계 품질과 트레이드 오프
설계는 변경을 위해 존재하고 변경에는 어떤 식으로든 비용이 발생한다. 훌륭한 설계란 합리적인 비용 안에서 변경을 수용할 수 있는 구조를 만드는 것이다. 적절한 비용 안에서 쉽게 변경할 수 있는 설계는 응집도가 높고 서로 느슨하게 결합돼 있는 요소로 구성된다.
결합도와 응집도를 합리적인 수준으로 유지할 수 있는 중요한 원칙이 있다. 객체의 상태가 아니라 객체의 행동에 초점을 맞추는 것이다. 객체를 단순한 데이터의 집합으로 바라보는 시각은 객체의 내부 구현을 퍼블릭 인터페이스에 노출시키는 결과를 낳기 때문에 결과적으로 설계가 변경에 취약해진다. 이런 문제를 피할 수 있는 가장 좋은 방법은 객체의 책임에 초점을 맞추는 것이다. 책임은 객체의 상태에서 행동으로, 나아가 객체와 객체 사이의 상호작용으로 설계 중심을 이동시키고, 결합도가 낮고 응집도가 높으며 구현을 효과적으로 캡슐화하는 객체들을 창조할 수 있는 기반을 제공한다.
[ 1. 데이터 중심의 영화 예매 시스템 ]
시스템을 분할하기 위해 데이터와 책임 중 어떤 것을 선택해야 할까? 결론부터 말하자면 훌륭한 객체 지향 설계는 데이터가 아니라 책임에 초점을 맞춰야 한다. 이유는 변경과 관련이 있다.
객체의 상태는 구현에 속한다. 구현은 불안정하기 때문에 변하기 쉽다. 상태를 객체 분할의 중심축으로 삼으면 구현에 관한 세부사항이 객체의 인터페이스에 스며들게 되어 캡슐화의 원칙이 무너진다. 결과적으로 상태 변경은 인터페이스의 변경을 초래하며 이 인터페이스에 의존하는 모든 객체에게 변경의 영향이 퍼지게 된다. 따라서 데이터에 초점을 맞추는 설계는 변경에 취약할 수밖에 없다.
그에 비해 객체의 책임은 인터페이스에 속한다. 객체는 책임을 드러내는 안정적인 인터페이스 뒤로 책임을 수행하는 데 필요한 상태를 캡슐화함으로써 구현 변경에 대한 파장이 외부로 퍼져나가는 것을 방지한다. 따라서 책임에 초점을 맞추면 상대적으로 변경에 안정적인 설계를 얻을 수 있게 된다.
[ 2. 설계 트레이드오프 ]
캡슐화
상태와 행동을 하나의 객체 안에 모으는 이유는 객체의 내부 구현을 외부로부터 감추기 위해서다. 여기서 구현이란 나중에 변경될 가능성이 높은 어떤 것을 가리킨다. 객체지향이 강력한 이유는 한 곳에서 일어난 변경이 전체 시스템에 영향을 끼치지 않도록 파급효과를 적절하게 조절할 수 있는 장치를 제공하기 때문이다. 객체를 사용하면 변경 가능성이 높은 부분은 내부에 숨기고 외부에는 상대적으로 안정적인 부분만 공개함으로써 변경의 여파를 통제할 수 있다.
변경될 가능성이 높은 부분을 구현이라 부르고 상대적으로 안정적인 부분을 인터페이스라고 부른다는 사실을 기억하라. 객체를 설계하기 위한 가장 기본적인 아이디어는 변경의 정도에 따라 구현과 인터페이스를 분리하고 외부에서는 인터페이스에만 의존하도록 관계를 조절하는 것이다. 객체지향 설계의 가장 중요한 원리는 불안정한 구현 세부사항을 안정적인 인터페이스 뒤로 캡슐화하는 것이다.
정리하면 캡슐화란 변경 가능성이 높은 부분을 객체 내부로 숨기는 추상화 기법이다.
응집도와 결합도
응집도는 모듈에 포함된 내부 요소들이 연관돼 있는 정도를 나타낸다. 모듈 내의 요소들이 하나의 목적을 위해 긴밀하게 협력한다면 그 모듈은 높은 응집도를 가진다. 객체지향의 관점에서 응집도는 객체 또는 클래스에 얼마나 관련 높은 책임들을 할당했는지를 나타낸다.
결합도는 의존성의 정도를 나타내며 다른 모듈에 대해 얼마나 많은 지식을 갖고 있는지를 나타내는 척도다. 객체지향의 관점에서 결합도는 객체 또는 클래스가 협력에 필요한 적절한 수준의 관계만을 유지하고 있는지를 나타낸다.
응집도와 결합도의 의미를 이해하기 위한 첫걸음은 두 개념 모두 설계와 관련 있다는 사실을 이해하는 것이다. 일반적으로 좋은 설계란 높은 응집도와 낮은 결합도를 가진 모듈로 구성된 설계를 의미한다. 다시 말해 애플리케이션을 구성하는 각 요소의 응집도가 높고 서로 느슨하게 결합돼 있다면 그 애플리케이션은 좋은 설계를 가졌다고 볼 수 있다.
좋은 설계란 오늘의 기능을 수행하면서 내일의 변경을 수용할 수 있는 설계다. 그리고 좋은 설계를 만들기 위해서는 높은 응집도와 낮은 결합도를 추구해야 한다. 좋은 설계가 변경과 관련된 것이고 응집도와 결합도의 정도가 설계의 품질을 결정한다면 자연스럽게 다음과 같은 결론에 도달하게 된다. 응집도와 결합도는 변경과 관련된 것이다.
변경의 관점에서 응집도란 변경이 발생할 때 모듈 내부에서 발생하는 변경의 정도로 측정할 수 있다. 간단히 말해 하나의 변경을 수용하기 위해 모듈 전체가 함께 변경된다면 응집도가 높은 것이고 모듈의 일부만 변경된다면 응집도가 낮은 것이다. 또한 하나의 변경에 대해 하나의 모듈만 변경된다면 응집도가 높지만 다수의 모듈이 함께 변경돼야 한다면 응집도가 낮은 것이다.
응집도가 높을수록 변경의 대상과 범위가 명확해지기 때문에 코드를 변경하기 쉬워진다.
결합도 역시 변경의 관점에서 설명할 수 있다. 결합도는 한 모듈이 변경되기 위해서 다른 모듈의 변경을 요구하는 정도로 측정할 수 있다. 따라서 결합도가 높으면 높을수록 함께 변경해야 하는 모듈의 수가 늘어나기 때문에 변경하기가 어려워진다.
영향을 받는 모듈의 수 외에도 변경의 원인을 이용해 결합도의 개념을 설명할 수도 있다. 내부 구현을 변경했을 때 이것이 다른 모듈에 영향을 미치는 경우에는 두 모듈 사이의 결합도가 높다고 표현한다. 반면 퍼블릭 인터페이스를 수정했을 때만 다른 모듈에 영향을 미치는 경우에는 결합도가 낮다고 표현한다. 따라서 클래스의 구현이 아닌 인터페이스에 의존하도록 코드를 작성해야 낮은 결합도를 얻을 수 있다. 이것은 “인터페이스에 대해 프로그래밍하라”라는 격언으로도 잘 알려져있다.
===== 단일 책임 원칙 =====
단일 책임 원칙이라는 맥락에서 “책임”이라는 말이 “변경의 이유”라는 의미로 사용된다는 점이다. 단일 채임 원칙에서의 책임은 지금까지 살펴본 역할, 책임, 협력에서 이야기하는 책임과는 다르며 변경과 관련된 더 큰 개념을 가리킨다.
[ 4. 자율적인 객체를 향해 ]
캡슐화를 지켜라
캡슐화는 설계의 제1 원리다. 데이터 중심의 설계가 낮은 응집도와 높은 결합도라는 문제로 몸살을 앓게 된 근본적인 원인은 바로 캡슐화의 원칙을 위반했기 때문이다. 객체는 자신이 어떤 데이터를 가지고 있는지를 내부에 캡슐화하고 외부에 공개해서는 안된다. 객체는 스스로의 상태를 책임져야 하며 외부에서는 인터페이스에 정의된 메서드를 통해서만 상태에 접근할 수 있어야 한다.
스스로 자신의 데이터를 책임지는 객체
객체는 단순한 데이터 제공자가 아니다. 객체 내부에 저장되는 데이터보다 객체가 협력에 참여하면서 수행할 책임을 정의하는 오퍼레이션이 더 중요하다.
===== 캡슐화의 진정한 의미 =====
사실 캡슐화는 변경될 수 잇는 어떤 것이라도 감추는 것을 의미한다. 내부 속성을 외부로부터 감추는 것은 “데이터 캡슐화”라고 불리는 캡슐화의 한 종류일 뿐이다. 설계에서 변하는 것이 무엇인지 고려하고 변하는 개념을 캡슐화해야 한다.
[ 6. 데이터 중심 설계의 문제점 ]
두 번째 설계가 변경에 유연하지 못한 이유는 캡슐화를 위반했기 때문이다. 캡슐화를 위반한 설계를 구성하는 요소들이 높은 응집도와 낮은 결합도를 가질 확률은 극히 낮다. 따라서 캡슐화를 위반한 설계는 변경에 취약할 수 밖에 없다. 데이터 중심의 설계가 변경에 취약한 이유는 두 가지다.
- 데이터 중심의 설계는 본질적으로 너무 이른 시기에 데이터에 관해 결정하도록 강요한다.
- 데이터 중심의 설계에서는 협력이라는 문제를 고려하지 않고 객체를 고립시킨 채 오퍼레이션을 결정한다.
데이터 중심 설계는 객체의 행동보다는 상태에 초점을 맞춘다
데이터 중심의 설계를 시작할 때 던졌던 첫 번째 질문은 “이 객체가 포함해야 하는 데이터가 무엇인가?”다. 데이터는 구현의 일부라는 사실을 명심하라. 데이터 주도 설계는 설계를 시작하는 처음부터 데이터에 관해 결정하도록 강요하기 때문에 너무 이른 시기에 내부 구현에 초점을 맞추게 한다.
데이터 중심의 관점에서 객체는 그저 단순한 데이터의 집합체일 뿐이다.
비록 데이터를 처리하는 작업과 데이터를 같은 객체 안에 두더라도 데이터에 초점이 맞춰져 있다면 만족스러운 캡슐화를 얻기 어렵다. 데이터를 먼저 결정하고 데이터를 처리하는 데 필요한 오퍼레이션을 나중에 결정하는 방식은 데이터에 관한 지식이 객체의 인터페이스에 고스란히 드러나게 된다. 결과적으로 객체의 인터페이스는 구현을 캡슐화하는 데 실패하고 코드는 변경에 취약해진다.
결론적으로 데이터 중심의 설계는 너무 이른 시기에 데이터에 대해 고민하기 때문에 캡슐화에 시래하게 된다. 객체의 내부 구현이 객체의 인터페이스를 어지럽히고 객체의 응집도와 결합도에 나쁜 영향을 미치기 때문에 변경에 취약한 코드를 낳게 된다.
데이터 중심 설계는 객체를 고립시킨 채 오퍼레이션을 정의하도록 만든다
객체지향 애플리케이션을 구현한다는 것은 협력하는 객체들의 공동체를 구축한다는 것을 의미한다. 따라서 협력이라는 문맥 안에서 필요한 책임을 결정하고 이를 수행할 적절한 객체를 결정하는 것이 가장 중요하다. 올바른 객체지향 설계의 무게 중심은 항상 객체의 내부가 아니라 외부에 맞춰져 있어야 한다. 객체가 내부에 어떤 상태를 가지고 그 상태를 어떻게 관리하는가는 부가적인 문제다. 중요한 것은 객체가 다른 객체와 협력하는 방법이다.
안타깝게도 데이터 중심 설계에서 초점은 객체의 외부가 아니라 내부로 향한다. 실행 문맥에 대한 깊이있는 고민 없이 객체가 관리할 데이터의 세부 정보를 먼저 결정한다. 객체의 구현이 이미 결정된 상태에서 다른 객체와의 협력 방법을 고민하기 때문에 이미 구현된 객체의 인터페이스를 억지로 끼워맞출 수 밖에 없다.
5. 책임 할당하기
데이터 중심 설계로 인해 발생하는 문제점을 해결할 수 있는 가장 기본적인 방법은 데이터가 아닌 책임에 초점을 맞추는 것이다.
책임에 초점을 맞춰서 설계할 때 직면하는 가장 큰 어려움은 어떤 객체에게 어떤 책임을 할당할지를 결정하기가 쉽지 않다는 것이다. 책임 할당 과정은 일종의 트레이드오프 활동이다. 동일한 문제를 해결할 수 있는 다양한 책임 할당 방법이 존재하며, 어떤 방법이 최선인지는 상황과 문맥에 따라 달라진다. 따라서 올바른 책임을 할당하기 위해서는 다양한 관점에서 설계를 평가할 수 있어야 한다.
[ 1. 책임 주도 설계를 향해 ]
데이터 중심의 설계에서 책임 중심의 설계로 전환하기 위해서는 다음의 두 가지 원칙을 따라야 한다.
- 데이터보다 행동을 먼저 결정하라
- 협력이라는 문맥 안에서 책임을 결정하라
데이터보다 행동을 먼저 결정하라
객체에게 중요한 것은 데이터가 아니라 외부에 제공하는 행동이다.
객체지향에 갓 입문한 사람들이 가장 많이 저지르는 실수가 바로 객체의 행동이 아니라 데이터에 초점을 맞추는 것이다. 너무 이른 시기에 데이터에 초점을 맞추면 객체의 캡슐화가 약화되어 낮은 응집도와 높은 결합도를 가진 객체들로 넘쳐나게 된다. 그리고 그 결과로 변경에 취약한 설계를 얻게 된다.
그러므로 무게중심을 객체의 데이터에서 객체의 행동으로 옮겨야 하며, 먼저 객체가 수행해야 하는 책임에 대해 결정한 후에, 책임을 수행하기 위해 필요한 데이터를 결정해야 한다.
협력이라는 문맥 안에서 책임을 결정하라
객체에게 할당된 책임의 품질은 협력에 적합한 정도로 결정된다. 객체에게 할당된 책임이 협력에 어울리지 않는다면 그 책임은 나쁜 것이다. 반대로 객체의 입장에서는 책임이 어색해 보이더라도 협력에 적합하다면 그 책임은 좋은 것이다. 책임은 객체의 입장이 아니라 객체가 참여하는 협력에 적합해야 한다.
협력을 위해서는 객체에게 메세지를 전송해야 하는데, 적절한 책임을 위해서는 메세지를 결정한 후에 메세지를 받을 객체를 선택해야 한다. 메세지를 받는 객체는 책임이 할당되는 것이다.
책임 주도 설계
책임 주도 설계의 흐름은 다음과 같다.
- 시스템이 사용자에게 제공해야 하는 기능인 시스템 책임을 파악한다.
- 시스템 책임을 더 작은 책임으로 분할한다.
- 분할된 책임을 수행할 수 있는 적절한 객체 또는 역할을 찾아 책임을 할당한다.
- 객체가 책임을 수행하는 도중 다른 객체의 도움이 필요한 경우 이를 책임질 적절한 객체 또는 역할을 찾는다.
- 해당 객체 또는 역할에게 책임을 할당함으로써 두 객체가 협력하게 된다.
책임 주도 설계의 핵심은 책임을 정한 후에 책임을 수행할 객체를 결정하는 것이다. 그리고 협력에 참여하는 객체들의 책임이 어느 정도 정리될 때까지는 객체의 내부 상태에 관심을 가지지 않는 것이다.
책임 할당을 위한 GRASP 패턴
객체지향이 태어나고 성숙해가는 동안 많은 사람들이 다양한 책임 할당 기법을 고안했다. 그중에서 대중적으로 가장 널리 알려진 것은 크레이그 라만(Craig Larman)이 패턴 형식으로 제안한 GRASP 패턴이다.
GRASP 패턴은 General Responsibility Assignment Software Pattern으로써 객체에게 책임을 할당할 때 지침으로 삼을 수 있는 원칙들을 패턴 형식으로 정리한 것이다.
도메인 개념에서 출발하기
설계를 시작하기 전에 도메인에 대한 개략적인 모습을 그려보는 것이 유용하다. 도메인 안에는 무수히 많은 개념들이 존재하며 이 도메인 개념들을 책임 할당의 대상으로 사용하면 코드에 도메인의 모습을 투영하기가 좀 더 수월해진다. 따라서 어떤 책임을 할당해야 할 때 가장 먼저 고민해야 하는 유력한 후보는 바로 도메인 개념이다.
설계를 시작하는 단계에서는 개념들의 의미와 관계가 정확하거나 완벽할 필요가 없다. 단지 우리에게는 출발점이 필요할 뿐이다. 중요한 것은 설계를 시작하는 것이지 도메인 개념들을 완벽하게 정리하는 것이 아니다.
정보 전문가에게 책임을 할당하라
책임 주도 설계 방식의 첫 단계는 애플리케이션이 제공해야 하는 기능을 애플리케이션의 책임으로 생각하는 것이다. 이 책임을 애플리케이션에 대해 전송된 메시지로 간주하고 이 메시지를 책임질 첫 번째 객체를 선택하는 것으로 설계를 시작한다.
메시지를 수신할 적합한 객체는 누구인가? 객체에게 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. GRASP에서는 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다.
메세지가 결정되고, 메세지에 적합한 객체를 선택해야 할 때, 책임을 할당하는 첫 번째 원칙은 책임을 수행할 정보를 알고 있는 객체에게 책임을 할당하는 것이다. GRASP에서는 이를 INFORMATION EXPERT(정보 전문가) 패턴이라고 부른다. INFORMATION EXPERT 패턴을 따르면 정보와 행동을 최대한 가까운 곳에 위치시켜서 캡슐화를 유지할 수 있다.
여기서 정보는 데이터와 다르다는 사실에 주의하라. 책임을 수행하는 객체가 정보를 알고 있다고 해서 그 정보를 저장하고 있을 필요는 없다. 객체는 해당 정보를 제공할 수 있는 다른 객체를 알고 있거나 필요한 정보를 계산해서 제공할 수도 있다. 정보 전문가가 반드시 데이터를 저장하고 있을 필요는 없다는 사실을 이해하는 것이 중요하다.
높은 응집도와 낮은 결합도
설계는 트레이드오프 활동이라는 것을 기억하라. 동일한 기능을 구현할 수 있는 무수히 많은 설계가 존재한다. 따라서 실제로 설계를 진행하다 보면 몇 가지 설계 중에서 한 가지를 선택해야 하는 경우가 빈번하게 발생한다. 이 경우에는 올바른 책임 할당을 위해 INFORMATION EXPERT 패턴 이외의 다른 책임 할당 패턴들을 함께 고려할 필요가 있다.
높은 응집도와 낮은 결합도는 객체에 책임을 할당할 때 항상 고려해야 하는 기본 원리다. 책임을 할당할 수 있는 다양한 대안들이 존재한다면 응집도와 결합도의 측면에서 더 나은 대안을 선택하는 것이 좋다. 다시 말해 두 협력 패턴 중에서 높은 응집도와 낮은 결합도를 얻을 수 있는 설계가 있다면 그 설계를 선택해야 한다는 것이다. GRASP에서는 이를 LOW COUPLING(낮은 결합도) 패턴과 HIGH COHESION 패턴이라고 부른다.
창조자에게 객체 생성 책임을 할당하라
CREATOR 패턴은 객체를 생성할 때, 책임을 어떤 객체에게 할당할지에 대한 지침을 제공한다.
객체 A를 생성해야 할 때 아래의 조건을 최대한 많이 만족하는 B에게 객체 생성 책임을 할당하자.
- B가 A 객체를 포함하거나 참조한다.
- B가 A 객체를 기록한다.
- B가 A 객체를 긴밀하게 사용한다.
- B가 A 객체를 초기화하는데 필요한 데이터를 가지고 있다.(B가 A에 대한 정보 전문가이다.)
CREATOR 패턴의 의도는 어떤 방식으로든 생성되는 객체와 연결되거나 관련될 필요가 있는 객체에게 생성의 책임을 맡기는 것이다. 생성될 객체에 대해 잘 알고 있어야 하거나, 그 객체를 사용하는 객체는 어떤 방식으로든 생성될 객체와 연괼될 것이다. 즉, 두 객체는 서로 결합된다.
이미 결합돼 있는 객체에게 생성 책임을 할당하는 것은 전체적인 결합도에 영향을 미치지 않는다. 기존에 존재하는 객체 사이의 관계를 이용함으로써 결합도가 낮은 설계를 유지할 수 있게 한다.
[ 3. 구현을 통한 검증 ]
DiscountCondition 개선하기
설계를 개선하는 작업은 변경의 이유가 하나 이상인 클래스를 찾는 것으로부터 시작하는 것이 좋다. 문제는 객체지향 설계에 갓 입문한 개발자들은 클래스 안에서 변경의 이유를 찾는 것이 생각보다 어렵다는 것이다. 희망적인 소식은 변경의 이유가 하나 이상인 클래스에는 위험 징후를 또렷하게 드러내는 몇 가지 패턴이 존재한다는 점이다.
코드를 통해 변경의 이유를 파악할 수 있는 첫 번째 방법은 인스턴스 변수가 초기화되는 시점을 살펴보는 것이다. 응집도가 높은 클래스는 인스턴스를 생성할 때 모든 속성을 함께 초기화한다. 반면 응집도가 낮은 클래스는 객체의 속성 중 일부만 초기화하고 일부는 초기화하지 않은 상태로 남겨진다.
두 번재 방법은 메서드들이 인스턴스 변수를 사용하는 방식을 살펴보는 것이다. 모든 메서드가 객체의 모든 속성을 사용한다면 클래스의 응집도는 높다고 볼 수 있다. 반면 메서드들이 사용하는 속성에 따라 그룹이 나뉜다면 클래스의 응집도가 낮다고 볼 수 있다.
다형성을 통해 분리하기
역할을 대체할 클래스들 사이에서 구현을 공유해야 할 필요가 있다면 추상 클래스를 사용하면 된다. 구현을 공유할 필요 없이 역할을 대체하는 객체들의 책임만 정의하고 싶다면 인터페이스를 사용하면 된다.
객체의 암시적인 타입에 따라 행동을 분기해야 한다면 암시적인 타입을 명시적인 클래스로 정의하고 행동을 나눔으로써 응집도 문제를 해결할 수 있다. 다시 말해 객체의 타입에 따라 변하는 행동이 있다면 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당하라는 것이다. GRASP에서는 이를 POLYMORPHISM(다형성) 패턴이라고 부른다.
===== POLYMORPHISM(다형성) 패턴 =====
객체의 타입에 따라 행동을 분기해야 한다면, 타입을 분리하고 변화하는 행동을 각 타입의 책임으로 할당함으로써 응집도 문제를 해결할 수 있다. GRASP에서는 이를 POLYMORPHISM(다형성) 패턴이라고 부른다.
타입에 따라 if~else나 switch~case 등의 조건을 사용해 처리하는 방식은 변경이 일어난 경우 논리 조건을 수정해야 하므로 변경에 취약하다. POLYMORPHISM 패턴은 객체의 타입을 검사해서 타입에 따라 기능을 수행하는 조건들을 사용하지 말고, 다형성을 이용해 새로운 변화를 다루기 쉽게 확장하라고 권고한다.
변경으로부터 보호하기
변경을 캡슐화하도록 책임을 할당하는 것을 GRASP에서는 PROTECTED VARIATIONS(변경 보호) 패턴이라고 부른다.
===== PROTECTED VARIATIONS(변경 보호) 패턴 =====
변화와 불안정성이 다른 요소에 나쁜 영향을 미치지 않도록 방지하기 위해서는 변화가 예상되는 불안정한 지점들을 식별하고, 그 주위에 안정된 인터페이스를 형성하도록 책임을 할당해야 한다.
PROTECTED VARIATIONS(변경 보호) 패턴은 책임 할당의 관점에서 캡슐화를 설명한 것이다. 캡슐화해야 하는 것은 변경될 가능성이 있는 것들이다. 만약 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 PROTECTED VARIATIONS(변경 보호) 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화해라.
클래스를 변경에 따라 분리하고 인터페이스를 이용해 변경을 캡슐화하는 것은 설계의 결합도와 응집도를 향상시키는 매우 강력한 방법이다. 하나의 클래스가 여러 타입의 행동을 구현하고 있는 것처럼 보인다면 클래스를 분해하고 POLYMORPHISM 패턴에 따라 책임을 분산시켜라. 예측 가능한 변경으로 인해 여러 클래스들이 불안정해진다면 PROTECTED VARIATIONS 패턴에 따라 안정적인 인터페이스 뒤로 변경을 캡슐화하라. 적절한 상황에서 두 패턴을 조합하면 코드 수정의 파급 효과를 조절할 수 있고 변경과 확장에 유연하게 대처할 수 있는 설계를 얻을 수 있을 것이다.
변경과 유연성
설계를 주도하는 것은 변경이다. 개발자로서 변경에 대비할 수 있는 두 가지 방법이 있다. 하나는 코드를 이해하고 수정하기 쉽도록 최대한 단순하게 설계하는 것이다. 다른 하나는 코드를 수정하지 않고도 변경을 수용할 수 있도록 코드를 더 유연하게 만드는 것이다. 대부분의 경우에 전자가 더 좋은 방법이지만 유사한 변경이 반복적으로 발생하고 있다면 복잡성이 상승하더라도 유연성을 추가하는 두 번째 방법이 더 좋다.
[ 4. 책임 주도 설계의 대안 ]
개인적으로 책임과 객체 사이에서 방황할 때 돌파구를 찾기 위해 선택하는 방법은 최대한 빠르게 목적한 기능을 수행하는 코드를 작성하는 것이다. 아무것도 없는 상태에서 책임과 협력에 관해 고민하기보다는 일단 실행되는 코드를 얻고 난 후에 코드 상에 명확하게 드러나는 책임들을 올바른 위치로 이동시키는 것이다.
주의할 점은 코드를 수정한 후에 겉으로 드러나는 동작이 바뀌어서는 안 된다는 것이다. 캡슐화를 향상시키고, 응집도를 높이고, 결합도를 낮춰야 하지만 동작은 그대로 유지해야 한다. 이처럼 이해하기 쉽고 수정하기 쉬운 소프트웨어로 개선하기 위해 겉으로 보이는 동작은 바꾸지 않은 채 내부 구조를 변경하는 것을 리팩터링(Refactoring)이라고 부른다.
메서드 응집도
클래스 응집도와 마찬가지로 메서드의 응집도를 높이는 이유도 변경과 관련이 깊다. 응집도 높은 메서드는 변경되는 이유가 하나여야 한다. 클래스가 작고, 목적이 명확한 메서드들로 구성돼 있다면 변경을 처리하기 위해 어떤 메서드를 수정해야 하는지를 쉽게 판단할 수 있다. 또한 메서드의 크기가 작고 목적이 분명하기 때문에 재사용하기도 쉽다. 작은 메서드들로 조합된 메서드는 마치 주석들을 나열한 것처럼 보이기 때문에 코드를 이해하기도 쉽다.
객체로 책임을 분배할 때 가장 먼저 할 일은 메서드를 응집도 있는 수준으로 분해하는 것이다. 긴 메서드를 작고 응집도 높은 메서드로 분리하면 각 메서드를 적절한 클래스로 이동하기가 더 수월해지기 때문이다.
6. 메시지와 인터페이스
처음에는 의아할 수도 있겠지만 객체지향 애플리케이션의 가장 중요한 재료는 클래스가 아니라 객체들이 주고받는 메시지다. 클래스 사이의 정적인 관계에서 메시지 사이의 동적인 흐름으로 초점을 전환하는 것은 미숙함을 벗어나 숙련된 객체지향 설계자로 성장하기 위한 첫걸음이다. 애플리케이션은 클래스로 구성되지만 메시지를 통해 정의된다는 사실을 기억하라.
[ 1. 협력과 메시지 ]
클라이언트-서버 모델
협력은 어떤 객체가 다른 객체에게 무언가를 요청할 때 시작된다. 메시지는 객체 사이의 협력을 가능하게 하는 매개체다. 객체가 다른 객체에게 접근할 수 있는 유일한 방법은 메시지를 전송하는 것뿐이다.
메시지와 전송
메시지(message)는 객체들이 협력하기 위해 사용할 수 있는 유일한 의사소통 수단이다. 한 객체가 다른 객체에게 도움을 요청하는 것을 메시지 전송(message sending) 또는 메시지 패싱(message passing) 이라고 부른다. 이때 메시지를 전송하는 객체를 메시지 전송자(message sender)라고 부르고 메시지를 수신하는 객체를 메시지 수신자(message receiver)라고 부른다.
메시지는 오퍼레이션명(operation name)과 인자(argument)로 구성되며 메시지 전송은 여기에 메시지 수신자를 추가한 것이다. 따라서 메시지 전송은 메시지 수신자, 오퍼레이션 명, 인자의 조합이다.
메시지와 메서드
메시지를 수신했을 때 실제로 어떤 코드가 실행되는지는 메시지 수신자의 실제 타입이 무엇인가에 달려 있다.
이처럼 메시지를 수신했을 때 실제로 실행되는 함수 또는 프로시저를 메서드라고 부른다. 중요한 것은 코드 상에서 동일한 이름의 변수(condition)에게 동일한 메시지를 전송하더라도 객체의 타입에 따라 실행되는 메서드가 달라질 수 있다는 것이다. 기술적인 관점에서 객체 사이의 메시지 전송은 전통적인 방식의 함수 호출이나 프로시저 호출과는 다르다.
퍼블릭 인터페이스와 오퍼레이션
객체가 의사소통을 위해 외부에 공개하는 메시지의 집합을 퍼블릭 인터페이스라고 부른다.
프로그래밍 언어의 관점에서 퍼블릭 인터페이스에 포함된 메시지를 오퍼레이션(operation)이라고 부른다. 오퍼레이션은 수행 가능한 어떤 행동에 대한 추상화다. 흔히 오퍼레이션이라고 부를 때는 내부의 구현 코드는 제외하고 단순히 메시지와 관련된 시그니처를 가리키는 경우가 대부분이다.
[ 2. 인터페이스와 설계 품질 ]
디미터 법칙
협력하는 객체의 내부 구조에 대한 결합으로 인해 발생하는 설계 문제를 해결하기 위해 제안된 원칙이 바로 디미터 법칙(Law of Demeter)이다. 디미터 법칙을 간단하게 요약하면 객체의 내부 구조에 강하게 결합되지 않도록 협력 경로를 제한하라는 것이다.
디미터 법칙을 따르는 코드는 메시지 수신자의 내부 구조가 전송자에게 노출되지 않으며, 메시지 전송자는 수신자의 내부 구현에 결합되지 않는다. 따라서 클라이언트와 서버가 낮은 결합도를 유지할 수 있다.
디미터 법칙은 객체가 자기 자신을 책임지는 자율적인 존재여야 한다는 사실을 강조한다. 정보를 처리하는 데 필요한 책임을 정보를 알고 있는 객체에게 할당하기 때문에 응집도가 높은 객체가 만들어진다.
하지만 무비판적으로 디미터 법칙을 수용하면 퍼블릭 인터페이스 관점에서 객체의 응집도가 낮아질 수도 있다.
===== 디미터 법칙과 캡슐화 =====
디미터 법칙은 캡슐화를 다른 관점에서 표현한 것이다. 디미터 법칙이 가치 있는 이유는 클래스를 캡슐화하기 위해 따라야 하는 구체적인 지침을 제공하기 때문이다. 캡슐화 원칙이 클래스 내부의 구현을 감춰야 한다는 사실을 강조한다면 디미터 법칙은 협력하는 클래스의 캡슐화를 지키기 위해 접근해야 하는 요소를 제한한다. 디미터 법칙은 협력과 구현이라는 사뭇 달라 보이는 두 가지 문맥을 하나의 유기적인 개념으로 통합한다. 클래스의 내부 구현을 채워가는 동시에 현재 협력하고 있는 클래스에 관해서도 고민하도록 주의를 환기시키기 때문이다.
의도를 드러내는 인터페이스
켄트 백은 그의 기념비적인 책인 “Smalltalk Best Practice Patterns”에서 메서드를 명명하는 두 가지 방법을 설명했다. 첫 번째 방법은 메서드가 작업을 어떻게 수행하는지를 나타내도록 이름 짓는 것이다. 이 경우 메서드의 이름은 내부의 구현 방법을 드러낸다.
public PeriodCondition {
public boolean isSatisfiedByPeriod(Screening screening) { ... }
}
public SequenceCondition {
public boolean isSatisfiedBySequence(Screening screening) { ... }
}
이런 스타일을 좋지 않은데 그 이유를 두 가지로 요약할 수 있다.
- 메서드에 대해 제대로 커뮤니케이션하지 못한다. 두 메서드의 내부 구현을 정확하게 이해하지 못한다면 두 메서드가 동일한 작업을 수행한다는 사실을 알아채기 어렵다.
- 메서드 수준에서 캡슐화를 위반한다. 이 메서드들은 클라이언트로 하여금 협력하는 객체의 종류를 알도록 강요한다. 만약 할인 여부를 판단하는 방법이 변경된다면 메서드의 이름 역시 변경해야 할 것이다. 메서드 이름을 변경한다는 것은 메시지를 전송하는 클라이언트의 코드도 함께 변경해야 한다는 것을 의미한다. 따라서 책임을 수행하는 방법을 드러내는 메서드를 사용한 설계는 변경에 취약할 수밖에 없다.
메서드의 이름을 짓는 두 번째 방법은 “어떻게”가 아니라 “무엇을 하는지를 드러내는 것이다. 메서드의 구현이 한 가지인 경우에는 무엇을 하는지를 드러내는 이름은 코드를 읽고 이해하기 쉽게 만들 뿐만 아니라 유연한 코드를 낳는 지름길이다.
함께 모으기
근본적으로 디미터 법칙을 위반하는 설계는 인터페이스와 구현의 분리 원칙을 위반한다. 기억해야 할 점은 객체의 내부 구조는 구현에 해당한다는 것이다.
따라서 디미터 법칙을 위반한다는 것은 클라이언트에게 구현을 노출한다는 것을 의미하며, 그 결과 작은 요구사항 변경에도 쉽게 무너지는 불안정한 코드를 얻게 된다.
일반적으로 프로그램에 노출되는 객체 사이의 관계까 많아질수록 결합도가 높아지기 때문에 프로그램은 불안정해진다. 객체의 구조는 다양한 요구사항에 의해 쉽게 변경되기 때문에 디미터 법칙을 위반한 설계는 요구사항 변경에 취약해진다.
인터페이스에 의도를 드러내자
오퍼레이션은 클라이언트가 객체에게 무엇을 원하는지를 표현해야 한다. 다시 말해 객체 자신이 아닌 클라이언트의 의도를 표현하는 이름을 가져야 한다. sell, buy, hold라는 이름은 클라이언트가 객체에게 무엇을 원하는지를 명확하게 표현한다. setTicket은 그렇지 않다.
[ 3. 원칙의 함정 ]
잊지 말아야 할 사실은 설계가 트레이드오프의 산물이라는 것이다. 설계를 적절하게 트레이드오프 할 수 있는 능력이 숙련자와 초보자를 구분하는 가장 중요한 기준이라고 할 수 있다.
원칙이 현재 상황에 부적합하다고 판단된다면 과감하게 원칙을 무시하라. 원칙을 아는 것보다 더 중요한 것은 언제 원칙이 유용하고 언제 유용하지 않은지를 판단할 수 있는 능력을 기르는 것이다.
디미터 법칙은 하나의 도트(.)를 강제하는 규칙이 아니다.
하나 이상의 도트(.)를 사용하는 모든 케이스가 디미터 법칙 위반인 것은 아니다. 기차 충돌처럼 보이는 코드라도 객체의 내부 구현에 대한 어떤 정보도 외부로 노출하지 않는다면 그것은 디미터 법칙을 준수한 것이다.
이 메서드들은 객체의 내부에 대한 어떤 내용도 묻지 않는다. 그저 객체를 다른 객체로 변환하는 작업을 수행하라고 시킬 뿐이다. 따라서 묻지 말고 시켜라 원칙을 위반하지 않는다.
결합도와 응집도의 충돌
안타깝게도 묻지 말고 시켜라와 디미터 법칙을 준수하는 것이 항상 긍정적인 결과로만 귀결되는 것은 아니다. 모든 상황에서 맹목적으로 위임 메서드를 추가하면 같은 퍼블릭 인터페이스 안에 어울리지 않는 오퍼레이션들이 공존하게 된다. 결과적으로 객체는 상관 없는 책임들을 한꺼번에 떠안게 되기 때문에 결과적으로 응집도가 낮아진다.
클래스는 하나의 변경 원인만을 가져야 한다. 서로 상관없는 책임들이 함께 뭉쳐있는 클래스는 응집도가 낮으며 작은 변경으로도 쉽게 무너질 수 있다. 따라서 디미터 법칙과 묻지 말고 시켜라 원칙을 무작정 따르면 애플리케이션은 응집도가 낮은 객체로 넘쳐날 것이다.
영화 예매 시스템의 PeriodCondition 클래스를 살펴보자. isSatisfiedBy 메서드는 screening에게 질의한 상영 시작 시간을 이용해 할인 여부를 결정한다. 이 코드는 얼핏 보기에는 Screening의 내부 상태를 가져와서 사용하기 때문에 캡슐화를 위반한 것으로 보일 수 있다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public boolean isSatisfiedBy(Screening screening) {
return dayOfWeek.equals(screening.getWhenScreened().getDayOfWeek())
&& !startTime.isAfter(screening.getWhenScreened().toLocalTime())
&& !endTime.isBefore(screening.getWhenScreened().toLocalTime());
}
}
따라서 할인 여부를 판단하는 로직을 Screeining의 isDiscountable 메서드로 옮기고 PeriodCondition이 이 메서드를 호출하도록 변경한다면 묻지 말고 시켜라 스타일을 준수하는 퍼블릭 인터페이스를 얻을 수 있다고 생각할 것이다.
public class PeriodCondition implements DiscountCondition {
private DayOfWeek dayOfWeek;
private LocalTime startTime;
private LocalTime endTime;
public PeriodCondition(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
this.dayOfWeek = dayOfWeek;
this.startTime = startTime;
this.endTime = endTime;
}
public boolean isSatisfiedBy(Screening screening) {
return screnning.isDiscountable(dayOfWeek, startTime, endTime);
}
}
public class Screening {
public boolean isDiscountable(DayOfWeek dayOfWeek, LocalTime startTime, LocalTime endTime) {
return dayOfWeek.equals(whenScreened.getDayOfWeek())
&& !startTime.isAfter(whenScreened.toLocalTime())
&& !endTime.isBefore(whenScreened.toLocalTime());
}
}
이렇게 하면 Screening이 기간에 따른 할인 조건을 판단하는 책임을 떠안게 된다. 이것이 Screening이 담당해야 하는 본질적인 책임인가? 그렇지 않다. Screening의 책임은 영화를 예매하는 것이다. Screening이 직접 할인 조건을 판단하게 되면 객체의 응집도가 낮아진다. 반면 PeriodCondition의 입장에서는 할인 조건을 판단하는 책임이 본질적이다.
객체에게 시키는 것이 항상 가능한 것은 아니다. 가끔씩은 물어야 한다. 여기서 강조하고 싶은 것은 소프트웨어 설계에 법칙이란 존재하지 않는다는 것이다. 원칙을 맹신하지 마라. 원칙이 적절한 상황과 부적절한 상황을 판단할 수 있는 안목을 길러라. 설계는 트레이드오프의 산물이다. 소프트웨어 설계에 존재하는 몇 안되는 법칙 중 하나는 “경우에 따라 다르다”라는 사실을 명심하라.
[ 4. 명령-쿼리 분리 원칙 ]
반복 일정의 명령과 쿼리 분리하기
명령-쿼리 분리 원칙의 요지는 오퍼레이션은 부수효과를 발생시키는 명령이거나 부수효과를 발생시키지 않는 쿼리 중 하나여야 한다는 것이다. 어떤 오퍼레이션도 명령인 동시에 쿼리여서는 안 된다. 따라서 명령과 쿼리를 분리하기 위해서는 다음의 두 가지 규칙을 준수해야 한다.
- 객체의 상태를 변경하는 명령은 반환값을 가질 수 없다.
- 객체의 정보를 반환하는 쿼리는 상태를 변경할 수 없다.
명령과 쿼리를 뒤섞으면 실행 결과를 예측하기가 어려워질 수 있다. 가장 깔끔한 해결책은 명령과 쿼리를 명확하게 분리하는 것이다.
퍼블릭 인터페이스를 설계할 때 부수 효과를 가지는 대신 값을 반환하지 않는 명령과, 부수효과를 가지지 않는 대신 값을 반환하는 쿼리를 분리하기 바란다. 그 결과, 코드는 예측 가능하고 이해하기 쉬우며 디버깅이 용이한 동시에 유지보수가 수월해질 것이다.
명령-쿼리 분리와 참조 투명성
참조 투명성이란 “어떤 표현식 e가 있을 때 e의 값으로 e가 나타나는 모든 위치를 교체하더라도 결과가 달라지지 않는 특성”을 의미한다.
객체지향 패러다임이 객체의 상태 변경이라는 부수효과를 기반으로 하기 때문에 참조 투명성은 예외에 가깝다. 객체지향의 세상에 발을 내딛는 순간 견고하다고 생각했던 바닥에 심각한 균일이 생기기 시작한다는 것을 알게 된다.
하지만 명령-쿼리 분리 원칙을 사용하면 이 균열을 조금이나마 줄일 수 있다. 명령-쿼리 분리 원칙은 부수효과를 가지는 명령으로부터 부수효과를 가지지 않는 쿼리를 명백하게 분리함으로써 제한적이나마 참조 투명성의 혜택을 누릴 수 있게 된다.
===== 명령형 프로그래밍과 함수형 프로그래밍 =====
부수효과를 기반으로 하는 프로그래밍 방식을 명령형 프로그래밍(imperative programming)이라고 부른다. 명령형 프로그래밍은 상태를 변경시키는 연산들을 적절한 순서대로 나열함으로써 프로그램을 작성한다. 대부분의 객체지향 프로그래밍 언어들은 메시지에 의한 객체의 상태 변경에 집중하기 때문에 명령형 프로그래밍 언어로 분류된다. 사실 프로그래밍 언어가 생겨난 이후 주류라고 불리던 대부분의 프로그래밍 언어는 명령형 프로그래밍 언어의 범주에 속한다고 봐도 무방하다.
최근 들어 주목받고 있는 함수형 프로그래밍(functional programming)은 부수효과가 존재하지 않는 수학적인 함수에 기반한다. 따라서 함수형 프로그래밍에서는 참조 투명성의 장점을 극대화할 수 있으며 명령형 프로그래밍에 비해 실행 결과를 이해하고 예측하기가 더 쉽다. 또한 하드웨어의 발달로 병렬 처리가 중요해진 최근에는 함수형 프로그래밍의 인기가 상승하고 있으며 다양한 객체지향 언어들이 함수형 프로그래밍 패러다임을 접목시키고 있는 추세다.
7. 객체 분해
조지 밀러의 이론에 따르면 사람이 동시에 단기 기억 안에 저장할 수 있는 정보의 개수는 5개에서 많아 봐야 9개 정도를 넘지 못한다고 한다.
실제로 문제를 해결하기 위해 사용하는 저장소는 장기 기억이 아니라 단기 기억이라는 점이다. 문제를 해결하기 위해서는 필요한 정보들을 먼저 단기 기억 안으로 불러들여야 한다. 그러나 문제 해결에 필요한 요소의 수가 단기 기억의 용량을 초과하는 순간 문제 해결 능력은 급격하게 떨어지고 만다. 이런 현상을 인지 과부하(cognitive overload)라고 부른다.
인지 과부하를 방지하는 가장 좋은 방법은 단기 기억 안에 보관할 정보의 양을 조절하는 것이다. 한번에 다뤄야 하는 정보의 수를 줄이기 위해 본질적인 정보만 남기고 불필요한 세부 사항을 걸러내면 문제를 단순화할 수 있을 것이다. 이처럼 불필요한 정보를 제거하고 현재의 문제 해결에 필요한 핵심만 남기는 작업을 추상화라고 부른다.
가장 일반적인 추상화 방법은 한 번에 다뤄야 하는 문제의 크기를 줄이는 것이다. 사람들은 한 번에 해결하기 어려운 커다란 문제에 맞딱드릴 경우 해결 가능한 작은 문제로 나누는 경향이 있다. 이렇게 나눠진 문제들 역시 한 번에 해결하기 어려울 정도로 크다면 다시 더 작은 문제로 나눌 수 있다. 이처럼 큰 문제를 해결 가능한 작은 문제로 나누는 작업을 분해(decomposition)라고 부른다.
[ 2. 프로시저 추상화와 기능 분해 ]
하향식 기능 분해의 문제점
- 시스템은 하나의 메인 함수로 구성돼 있지 않다
- 기능 추가나 요구사항 변경으로 인해 메인 함수를 빈번하게 수정해야 한다
- 비즈니스 로직이 사용자 인터페이스와 강하게 결합된다
- 하향식 접근법은 기능을 분해하는 과정에서 사용자 인터페이스의 관심사와 비즈니스 로직의 관심사를 동시에 고려하도록 강요하기 때문에 “관심사의 분리”라는 아키텍처 목적을 달성하기 어렵다
- 하향식 분해는 너무 이른 시기에 함수들의 실행 순서를 고정시키기 때문에 유연성과 재사용성이 저하된다
- 데이터 형식이 변경될 경우 파급효과를 예측할 수 없다
설계는 코드 배치 방법이며 설계가 필요한 이유는 변경에 대비하기 위한 것이라는 점을 기억하라.
안타깝게도 위 목록들을 통해 알 수 있는 것처럼 하향식 접근법과 기능 분해가 가지는 근본적인 문제점은 변경에 취약한 설계를 낳는다는 것이다.
[ 3. 모듈 ]
정보 은닉과 모듈
정보 은닉은 외부에 감춰야 하는 비밀에 따라 시스템을 분할하는 모듈 분할 원리다. 모듈은 변경될 가능성이 있는 비밀을 내부로 감추고, 잘 정의되고 쉽게 변경되지 않을 퍼블릭 인터페이스를 외부에 제공해서 내부의 비밀에 함부로 접근하지 못하게 한다.모듈은 다음과 같은 두 가지 비밀을 감춰야 한다.
- 복잡성: 모듈이 너무 복잡한 경우 이해하고 사용하기가 어렵다. 외부에 모듈을 추상화할 수 잇는 간단한 인터페이스를 제공해서 모듈의 복잡도를 낮춘다.
- 변경 가능성: 변경 가능한 설계 결정이 외부에 노출될 경우 실제로 변경이 발생했을 때 파급 효과가 커진다. 변경 발생 시 하나의 모듈만 수정하면 되도록 변경 가능한 설계 결정을 모듈 내부로 감추고 외부에는 쉽게 변경되지 않을 인터페이스를 제공한다.
모듈의 장점과 한계
- 모듈의 장점
- 모듈의 내부가 변경되더라도 모듈 내부에만 영향을 미친다
- 비즈니스 로직과 사용자 인터페이스에 대한 관심사를 분리한다
- 전역 변수와 전역 함수를 제거함으로써 네임스페이스 오염을 방지한다
모듈은 기능이 아니라 변경의 정도에 따라 시스템을 분해하게 한다. 각 모듈은 외부에 감춰야 하는 비밀과 관련성 높은 데이터와 함수의 집합이다. 따라서 모듈 내부는 높은 응집도를 유지한다. 모듈과 모듈 사이에는 퍼블릭 인터페이스를 통해서만 통신해야 한다. 따라서 낮은 결합도를 유지한다.
여기서 눈여겨봐야 할 부분은 모듈이 정보 은닉이라는 개념을 통해 데이터라는 존재를 설계의 중심 요소로 부각시켰다는 것이다. 모듈에 있어서 핵심은 데이터다.
비록 모듈이 프로시저 추상화보다는 높은 추상화 개념을 제공하지만 태생적으로 변경을 관리하기 위한 구현 기법이기 때문에 추상화 관점에서의 한계점이 명확하다. 모듈의 가장 큰 단점은 인스턴스의 개념을 제공하지 않는다는 점이다.
[ 4. 데이터 추상화와 추상 데이터 타입 ]
변경을 기준으로 선택하라
단순히 클래스를 구현 단위로 사용한다는 것이 객체지향 프로그래밍을 한다는 것을 의미하지는 않는다. 타입을 기준으로 절차를 추상화하지 않았다면 그것은 객체지향 분해가 아니다. 비록 클래스를 사용하고 있더라도 말이다.
만약 인스턴스 변수에 저장된 값을 기반으로 메서드 내에서 타입을 명시적으로 구분하는 방식은 객체지향을 위반하는 것으로 간주된다.
객체지향에서는 타입 면수를 이용한 조건문을 다형성으로 대체한다. 클라이언트가 객체의 타입을 확인한 후 적절한 메서드를 호출하는 것이 아니라 객체가 메시지를 처리할 적절한 메서드를 선택한다.
모든 설계 문서가 그런 것처럼 조건문을 사용하는 방식을 기피하는 이유 역시 변경 때문이다.
이처럼 기존 코드에 아무런 영향도 미치지 않고 새로운 객체 유형과 행위를 추가할 수 있는 객체지향의 특성을 개방-폐쇄 원칙(Open-Closed Principle, OCP)라고 부른다.
설계는 변경과 관련된 것이다. 설계의 유용성은 변경의 방향성과 발생 빈도에 따라 결정된다. 그리고 추상 데이터 타입과 객체지향 설계의 유용성은 설계에 요구되는 변경의 압력이 “타입 추가”에 관한 것인지, 아니면 “오퍼레이션 추가”에 관한 것인지에 따라 달라진다.
타입 추가라는 변경의 압력이 더 강한 경우에는 객체지향의 손을 들어줘야 한다.
이에 반해 변경의 주된 압력이 오퍼레이션을 추가하는 것이라면 추상 데이터 타입의 승리를 선언해야 한다.
8. 객체 분해
협력은 필수적이지만 과도한 협력은 설계를 곤경에 빠트릴 수 있다. 협력은 객체가 다른 객체에 대해 알 것을 강요한다. 다른 객체와 협력하기 위해서는 그런 객체가 존재한다는 사실을 알고 있어야 한다. 객체가 수신할 수 있는 메시지에 대해서도 알고 있어야 한다. 이런 지식이 객체 사이의 의존성을 낳는다.
협력을 위해서는 의존성이 필요하지만 과도한 의존성은 애플리케이션을 수정하기 어렵게 만든다. 객체지향 설계의 핵심은 협력을 위해 필요한 의존성은 유지하면서도 변경을 방해하는 의존성은 제거하는 데 있다. 이런 관점에서 객체지향 설계란 의존성을 관리하는 것이고 객체가 변화를 받아들일 수 있게 의존성을 관리하는 기술이라고 할 수 있다.
[ 1. 의존성 이해하기 ]
변경과 의존성
어떤 객체가 협력하기 위해 다른 객체를 필요로 할 때 두 객체 사이에 의존성이 존재하게 된다. 의존성은 실행 시점과 구현 시점에 서로 다른 의미를 가진다.
- 실행 시점: 의존하는 객체가 정상적으로 동작하기 위해서는 실행 시에 의존 대상 객체가 반드시 존재해야 한다.
- 구현 시점: 의존 대상 객체가 변경될 경우 의존하는 객체도 함께 변경된다.
의존성 전이
의존성은 함께 변경될 수 있는 가능성을 의미하기 때문에 모든 경우에 의존성이 전이되는 것은 아니다. 의존성이 실제로 전이될지 여부는 변경의 방향과 캡슐화의 정도에 따라 달라진다.
의존성은 전이될 수 있기 때문에 의존성의 종류를 직접 의존성(direct dependency)과 간접 의존성(indirect dependency)로 나누기도 한다. 직접 의존성이란 말 그대로 한 요소가 다른 요소에 직접 의존하는 경우를 가리킨다. 간접 의존성이란 직접적인 관계는 존재하지 않지만 의존성 전이에 의해 영향이 전파되는 경우를 가리킨다.
런타임 의존성과 컴파일 의존성
어떤 클래스의 인스턴스가 다양한 클래스의 인스턴스와 협력하기 위해서는 협력할 인스턴스의 구체적인 클래스를 알아서는 안 된다. 실제로 협력할 객체가 어떤 것인지는 런타임에 해결해야 한다. 클래스가 협력할 객체의 클래스를 명시적으로 드러내고 있다면 다른 클래스의 인스턴스와 협력할 가능성 자체가 없어진다. 따라서 컴파일타임 구조와 런타임 구조 사이의 거리가 멀면 멀수록 설계가 유연해지고 재사용 가능해진다.
컨텍스트 독립성
클래스가 특정한 문맥에 강하게 결합될수록 다른 문맥에서 사용하기는 더 어려워진다. 클래스가 사용될 특정한 문맥에 대해 최소한의 가정만으로 이뤄져 있다면 다른 문맥에서 재사용하기가 더 수월해진다. 이를 컨텍스트 독립성이라고 부른다.
의존성 해결하기
컴파일타임 의존성을 실행 컨텍스트에 맞는 적절한 런타임으로 교체하는 것을 의존성 해결이라고 부른다. 의존성을 해결하기 위해서는 일반적으로 다음과 같은 세 가지 방법을 사용한다.
- 객체를 생성하는 시점에 생성자를 통한 의존성 해결
- 객체 생성 후 setter 메서드를 통한 의존성 해결
- 메서드 실행 시 인자를 이용해 의존성 해결
[ 2. 유연한 설계 ]
의존성과 결합도
바람직한 의존성은 재사용성과 관련이 있다. 어떤 의존성이 다양한 환경에서 클래스를 재사용할 수 없도록 제한한다면 그 의존성은 바람직하지 못한 것이다. 어떤 의존성이 다양한 환경에서 재사용할 수 있다면 그 의존성은 바람직한 것이다. 다시 말해 컨텍스트에 독립적인 의존성은 바람직한 의존성이고 특정 컨텍스트에 강하게 결합된 의존성은 바람직하지 않은 의존성이다.
new는 해롭다
결합도 측면에서 new가 해로운 이유는 크게 두 가지다.
- new 연산자를 사용하기 위해서는 구체 클래스의 이름을 직접 기술해야 한다. 따라서 new를 사용하는 클라이언트는 추상화가 아닌 구체 클래스에 의존할 수 밖에 없기 대문에 결합도가 높아진다.
- new 연산자는 생성하려는 구체 클래스 뿐만 아니라 어떤 인자를 이용해 클래스의 생성자를 호출해야 하는지도 알아야 한다. 따라서 new를 사용하면 클라이언트가 알아야 하는 지식의 양이 늘어나기 때문에 결합도가 높아진다.
가끔은 괜찮다
구체 클래스에 의존하게 되더라도 클래스의 사용성이 더 중요하다면 결합도를 높이는 방향으로 코드를 작성할 수 있다. 그럼에도 가급적 구체 클래스에 대한 의존성을 제거할 수 있는 방법을 찾아보기 바란다.
표준 클래스에 대한 의존은 해롭지 않다
의존성이 불편한 이유는 그것이 항상 변경에 대한 영향을 암시하기 때문이다. 따라서 변경될 확률이 거의 없는 클래스라면 의존성이 문제가 되지 않는다. 자바라면 JDK에 포함된 표준 클래스가 이 부류에 속한다. 이런 클래스들에 대해서는 구체 클래스에 의존하거나 직접 인스턴스를 생성하더라도 문제가 없다.
9. 객체 분해
[ 1. 개방-폐쇄 원칙 ]
로버트 마틴은 확장 가능하고 변화에 유연하게 대응할 수 있는 설계를 만들 수 잇는 원칙 중 하나로 개방-폐쇄 원칙(Open-Closed Principle, OCP)을 고안했다.
“소프트웨어 개체(클래스, 모듈, 함수 등등)는 확장에 대해 열려 있어야 하고, 수정에 대해서는 닫혀 있어야 한다.”
여기서 키워드는 “확장”과 “수정”이다. 이 둘은 순서대로 애플리케이션의 “동작”과 “코드”의 관점을 반영한다.
- 확장에 대해 열려 있다. 애플리케이션의 요구사항이 변경될 때 이 변경에 맞게 새로운 “동작”을 추가해서 애플리케이션의 기능을 확장할 수 있다.
- 수정에 대해 닫혀 있다. 기존의 “코드”를 수정하지 않고도 애플리케이션의 동작을 추가하거나 변경할 수 있다.
개방-폐쇄 원칙은 유연한 설계란 기존의 코드를 수정하지 않고도 애플리케이션의 동작을 확장할 수 있는 설계라고 이야기한다.
추상화가 핵심이다
개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다. 여기서 “추상화”와 “의존”이라는 두 개념 모두가 중요하다.
추상화란 핵심적인 부분만 남기고 불필요한 부분은 생략함으로써 복잡성을 극복하는 기법이다. 추상화 과정을 거치면 문맥이 바뀌더라도 변하지 않는 부분만 남게 되고 문맥에 따라 변하는 부분은 생략된다. 추상화를 사용하면 생력된 부분을 문맥에 적합한 내용으로 채워넣음으로써 각 문맥에 적합하게 기능을 구체화하고 확장할 수 있다.
단순히 어떤 개념을 추상화했다고 해서 수정에 대해 닫혀 있는 설계를 만들 수 있는 것은 아니다. 개방-폐쇄 원칙에서 폐쇄를 가능하게 하는 것은 의존성의 방향이다. 수정에 대한 영향을 취소화하기 위해서는 모든 요소가 추상화에 의존해야 한다.
여기서 주의할 점은 추상화를 했다고 해서 모든 수정에 대해 설계가 폐쇄되는 것은 아니라는 것이다. 수정에 대해 닫혀 있고 확장에 대해 열려 있는 설계는 공짜로 얻어지지 않는다. 변경에 의한 파급효과를 피하기 위해서는 변하는 것과 변하지 않는 것이 무엇인지를 이해하고 이를 추상화의 목적으로 삼아야 한다. 추상화가 수정에 대해 닫혀 있을 수 있는 이유는 변경되지 않을 부분을 신중하게 결정하고 올바른 추상화를 주의 깊게 선택했기 때문이라는 사실을 기억하라.
2. 생성 사용 분리
물론 객체 생성을 피할 수는 없다. 어딘가에서는 반드시 객체를 생성해야 한다. 문제는 객체 생성이 아니다. 부적절한 곳에서 객체를 생성한다는 것이 문제다.
메시지를 전송하지 않고 객체를 생성하기만 한다면 아무런 문제가 없었을 것이다. 또는 객체를 생성하지 않고 메시지를 전송하기만 했다면 괜찮았을 것이다. 동일한 클래스 안에서 객체 생성과 사용이라는 두 가지 이질적인 목적을 가진 코드가 종존하는 것이 문제인 것이다.
유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다. 하나는 객체를 생성하는 것이고, 다른 하나는 객체를 사용하는 것이다. 한 마디로 말해서 객체에 대한 생성과 사용을 분리(separating use from creation)해야 한다.
Factory 추가하기
객체 생성과 관련된 책임만 전담하는 별도의 객체를 추가하고 Client는 이 객체를 사용하도록 만들 수 있다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 Factory라고 부른다.
순수한 가공물에게 책임 할당하기
책임 할당의 가장 기본이 되는 원칙은 책임을 수행하는 데 필요한 정보를 가장 많이 알고 있는 INFORMATION EXPERT에게 책임을 할당하는 것이다. 도메인 모델은 INFORMATION EXPERT를 찾기 위해 참조할 수 있는 일차적인 재료다.
눈치가 빠른 사람이라면 방금 전에 추가한 Factory는 도메인 모델에 속하지 않는다는 사실을 알아챘을 것이다. Factory를 추가한 이유는 순수하게 기술적인 결정이다. 전체적으로 결합도를 낮추고 재사용성을 높이기 위해 도메인 개념에게 할당돼 있던 객체 생성 책임을 도메인 개념과는 아무런 상관이 없는 가공의 객체로 이동시킨 것이다.
크레이그 라만은 시스템을 객체로 분해하는 데는 크게 두 가지 방식이 존재한다고 설명한다. 하나는 표현적 분해(representational decomposition)이고 다른 하나는 행위적 분해(behavioral decomposition)다.
표현적 분해는 도메인에 존재하는 사물 또는 개념을 표현하는 객체들을 이용해 시스템을 분해하는 것이다. 표현적 분해는 도메인 모델에 담겨 있는 개념과 관계를 따르며 도메인과 소프트웨어 사이의 표현적 차이를 최소화하는 것을 목적으로 한다. 따라서 표현적 분해는 객체지향 설계를 위한 가장 기본적인 접근법이다.
그러나 종종 도메인 개념을 표현하는 객체에게 책임을 할당하는 것만으로는 부족한 경우가 발생한다. 도메인 모델은 설계를 위한 중요한 출발점이지만 단지 출발점이라는 사실을 명심해야 한다. 실제로 동작하는 애플리케이션은 데이터베이스 접근을 위한 객체와 같이 도메인 개념들을 초월하는 기계적인 개념들을 필요로 할 수 있다.
모든 책임을 도메인 객체에게 할당하면 낮은 응집도, 높은 결합도, 재사용성 저하와 같은 심각한 문제점에 봉착하게 될 가능성이 높아진다. 이 경우 도메인 개념을 표현한 객체가 아닌 설계자가 편의를 위해 임의로 만들어낸 가공의 객체에게 책임을 할당해서 문제를 해결해야 한다. 크레이그 라만은 이처럼 책임을 할당하기 위해 창조되는 도메인과 무관한 인공적인 객체를 PURE FABRICATION(순수한 가공물)이라고 부른다.
어떤 행동을 추가하려고 하는데 이 행동을 책임질 마땅한 도메인 개념이 존재하지 않는다면 PURE FABRICATION을 추가하고 이 객체에게 책임을 할당하라. 그 결과로 추가된 PURE FABRICATION은 보통 특정한 행동을 표현하는 것이 일반적이다. 따라서 PURE FABRICATION은 표현적 분해보다는 행위적 분해에 의해 생성되는 것이 일반적이다.
이런 측면에서 객체지향이 실세계의 모방이라는 말은 옳지 않다. 객체지향 애플리케이션은 도메인 개념뿐만 아니라 설계자들이 임의적으로 창조한 인공적인 추상화들을 포함하고 있다. 애플리케이션 내에서 인공적으로 창조한 객체들이 도메인 개념을 반영하는 객체들보다 오히려 많은 비중을 차지하는 것이 일반적이다.
설계자로서의 우리의 역할은 도메인 추상화를 기반으로 애플리케이션 로직을 설계하는 동시에 품질의 측면에서 균형을 맞추는 데 필요한 객체들을 창조하는 것이다. 도메인 개념을 표현하는 객체와 순수하게 창조된 가공의 객체들이 모여 자신의 역할과 책임을 다하고 조화롭게 협력하는 애플리케이션을 설계하는 것이 목표여야 한다.
먼저 도메인의 본질적인 개념을 포함하는 추상화를 이용해 애플리케이션을 구축하기 시작하라. 만약 도메인 개념이 만족스럽지 못하다면 주저하지 말고 인공적인 객체를 창조하라. 객체지향이 실세계를 모방해야 한다는 헛된 주장에 현혹될 필요가 없다. 우리가 애플리케이션을 구축하는 것은 사용자들이 원하는 기능을 제공하기 위해서지 실세계를 모방하거나 시뮬레이션하기 위한 것이 아니다. 도메인을 반영하는 애플리케이션의 구조라는 제약 안에서 실용적인 창조성을 발휘할 수 있는 능력은 훌륭한 설계자가 갖춰야 할 기본적인 자질이다.
도메인 모델에서 출발해서 설계에 유연성을 추가하기 위해 책임을 이리저리 옮기다 보면 많은 PURE FABRICATION을 추가하게 된다는 사실을 알게 될 것이다. FACTORY는 객체의 생성 책임을 할당할만한 도메인 객체가 존재하지 않을 때 선택할 수 있는 PURE FABRICATION이다.
[ 3. 의존성 주입 ]
이처럼 사용하는 객체가 아닌 의부의 독립적인 객체가 인스턴스를 생성한 후 이를 전달해서 의존성을 해결하는 방법을 의존성 주입(Dependency Injection)이라고 부른다. 이 기법을 의존성 주입이라고 부르는 이유는 외부에서 의존성의 대상을 해결한 후 이를 사용하는 객체쪽으로 주입하기 때문이다.
의존성 주입에는 의존성을 해결하는 세 가지 방법을 가리키는 별도의 용어를 정의한다.
- 생성자 주입 : 객체를 생성하는 시점에 생성자를 통한 의존성 해결
- setter 주입 : 객체 생성 후 setter 메서드를 통한 의존성 해결
- 메서드 주입 : 메서드 실행 시 인자를 이용한 의존성 해결
숨겨진 의존성은 나쁘다
의존성 주입 외에도 의존성을 해결할 수 잇는 다양한 방법이 존재한다. 그중에서 가장 널리 사용되는 대표적인 방법은 SERVICE LOCATOR 패턴이다. SERVICE LOCATOR는 의존성을 해결할 객체들을 보관하는 일종의 저장소다. 외부에서 객체에게 의존성을 전달하는 의존성 주입과 달리 SERVICE LOCATOR의 경우 객체가 직접 SERVICE LOCATOR에게 의존성을 해결해줄 것을 요청한다.
SERVICE LOCATOR 패턴은 의존성을 해결할 수 있는 가장 쉽고 간단한 도구인 것처럼 보인다.
SERVICE LOCATOR 패턴의 가장 큰 단점은 의존성을 감춘다는 것이다.
의존성을 구현 내부로 감출 경우 의존성과 관련된 문제가 컴파일타임이 아닌 런타임에 가서야 발견된다는 사실을 알 수 있다. 숨겨진 의존성이 이해하기 어렵고 디버깅하기 어려운 이유는 문제를 발견할 수 있는 지점을 코드 작성 시점이 아니라 실행 시점으로 미루기 때문이다.
의존성을 숨기는 코드는 단위 테스트 작성도 어렵다.
숨겨진 의존성이 가지는 가장 큰 문제점은 의존성을 이해하기 위해 코드의 내부 구현을 이해할 것을 강요한다는 것이다. 따라서 숨겨진 의존성은 캡슐화를 위반한다.
[ 4. 의존성 역전 원칙 ]
객체 사이의 협력이 존재할 때 그 협력의 본질을 담고 있는 것은 상위 수준의 정책이다. Movie와 AmountDiscountPolicy 사이의 협력이 가지는 본질은 영화의 가격을 계산하는 것이다. 어떻게 할인 금액을 계산할 것인지는 협력의 본질이 아니다. 다시 말해서 어떤 협력에서 중요한 정책이나 의사결정, 비즈니스의 본질을 담고 있는 것은 상위 수준의 클래스다.
그러나 이런 상위 수준의 클래스가 하위 수준의 클래스에 의존한다면 하위 수준의 변경에 의해 상위 수준 클래스가 영향을 받게 될 것이다. 하위 수준의 AmountDiscountPolicy를 PercentDiscountPolicy로 변경한다고 해서 상위 수준의 Movie가 영향을 받아서는 안 된다. 상위 수준의 Movie의 변경으로 인해 하위 수준의 AmountDiscountPolicy가 영향을 받아야 한다.
의존성은 변경의 전파와 관련된 것이기 때문에 설계는 변경의 영향을 최소화하도록 의존성을 관리해야 한다.
상위 수준의 클래스는 어떤 식으로든 하위 수준의 클래스에 의존해서는 안 되는 것이다.
이 설계는 재사용성에도 문제가 있다. 대부분의 경우 우리가 재사용하려는 대상은 상위 수준의 클래스라는 점을 기억하라. 상위 수준의 클래스가 하위 수준의 클래스에 의존하면 상위 수준의 클래스를 재사용할 때 하위 수준의 클래스도 필요하기 때문에 재사용하기가 어려워진다.
중요한 것은 상위 수준의 클래스다. 상위 수준의 변경에 의해 하위 수준이 변경되는 것은 납득할 수 있지만 하위 수준의 변경으로 인해 상위 수준이 변경돼서는 곤란하다. 하위 수준의 이슈로 인해 상위 수준에 위치하는 클래스들을 재사용하는 것이 어렵다면 이것 역시 문제가 된다.
가장 중요한 조언은 추상화에 의존하라는 것이다. 유연하고 재사용 가능한 설계를 원한다면 모든 의존성의 방향이 추상 클래스나 인터페이스와 같은 추상화를 따라야 한다. 구체 클래스는 의존성의 시작점이어야 한다. 의존성의 목적지가 돼서는 안 된다.
- 상위 수준의 모듈은 하위 수준의 모듈에 의존해서는 안 된다. 둘 모두 추상화에 의존해야 한다.
- 추상화는 구체적인 사항에 의존해서는 안 된다. 구체적인 사항은 추상화에 의존해야 한다.
이를 의존성 역전 원칙(Dependency Inversion Principle, DIP)라고 부른다. 이 용어를 최초로 착안한 로버트 마틴은 “역전(inversion)”이라는 단어를 사용한 이유에 대해 의존성 역전 원칙을 따르는 설계는 의존성의 방향이 전통적인 절차형 프로그래밍과는 반대 방향으로 나타나기 때문이라고 설명한다.
의존성 역전 원칙과 패키지
추상화를 별도의 독립적인 패키지가 아니라 클라이언트가 속한 패키지에 포함시켜야 한다. 그리고 함께 재사용될 필요가 없는 클래스들은 별도의 독립적인 패키지에 모아야 한다. 마틴 파울러는 이 기법을 가리켜 SEPARATED INTERFACE 패턴이라고 부른다.
따라서 의존성 역전 원칙에 따라 상위 수준의 협력 흐름을 재사용하기 위해서는 추상화가 제공하는 인터페이스의 소유권 역시 역전시켜야 한다. 전통적인 설계 패러다임은 인터페이스의 소유권을 클라이언트 모듈이 아닌 서버 모듈에 위치시키는 반면 잘 설계된 객체지향 애플리케이션에서는 인터페이스의 소유권을 서버가 아닌 클라이언트에 위치시킨다. 이것은 나중에 살펴보겠지만 객체지향 프레임워크의 모듈 구조를 설계하는 데 가장 중요한 핵심 원칙이다.
정리하자. 유연하고 재사용 가능하며 컨텍스트에 독립적인 설계는 전통적인 패러다임이 고수하는 의존성의 방향을 역전시킨다. 전통적인 패러다임에서는 상위 수준 모듈이 하위 수준 모듈에 의존했다면 객체지향 패러다임에서는 상위 수준 모듈과 하위 수준 모듈이 모두 추상화에 의존한다. 전통적인 패러다임에서는 인터페이스가 하위 수준 모듈에 속했다면 객체지향 패러다임에서는 인터페이스가 상위 수준 모듈에 속한다.
[ 5. 유연성에 대한 조언 ]
유연한 설계는 유연성이 필요할 때만 옳다
하지만 유연하고 재사용 가능한 설계가 항상 좋은 것은 아니다. 설계의 미덕은 단순함과 명확함으로부터 나온다. 단순하고 명확한 설계를 가진 코드는 읽기 쉽고 이해하기도 편하다. 유연한 설계는 이와는 다른 길을 걷는다. 변경하기 쉽고 확장하기 쉬운 구조를 만들기 위해서는 단순함과 명확함의 미덕을 버리게 될 가능성이 높다.
유연한 설계라는 말의 이면에는 복잡한 설계라는 의미가 숨어있다. 유연한 설계의 이런 양면성은 객관적으로 설계를 판단하기 어렵게 만든다. 이 설계가 복잡한 이유는 무엇인가? 어떤 변경에 대비하기 위해 설계를 복잡하게 만들었는가? 정말 유연성이 필요한가? 정보가 제한적인 상황에서 이런 질문에 대답하는 것은 공학이라기보다는 심리학에 가깝다. 변경은 예상이 아니라 현실이어야 한다. 미래에 변경이 일어날지도 모른다는 막연한 불안감은 불필요하게 복잡한 설계를 낳는다. 아직 일어나지 않은 변경은 변경이 아니다.
따라서 유연함은 단순성과 명확성의 희생 위에서 자라난다.
단순하고 명확한 해법이 그런대로 만족스럽다면 유연성을 제거하라. 유연성은 코드를 읽는 사람들이 복잡함을 수용할 수 있을 때만 가치가 있다. 하지만 복잡성에 대한 걱정보다 유연하고 재사용 가능한 설계의 필요성이 더 크다면 코드의 구조와 실행 구조를 다르게 만들어라.
10. 상속과 코드 재사용
전통적인 패러다임에서 코드를 재사용하는 방법은 코드를 복사한 후 수정하는 것이다. 객체지향은 조금 다른 방법을 취한다. 객체지향에서는 코드를 재사용하기 위해 “새로운” 코드를 추가한다.
[ 1. 상속과 중복 코드 ]
DRY 원칙
중복 코드는 변경을 방해한다. 이것이 중복 코드를 제거해야 하는 가장 큰 이유다. 프로그램의 본질은 비즈니스와 관련된 지식을 코드로 변환하는 것이다. 안타깝게도 이 지식은 항상 변한다. 그에 맞춰 지식을 표현하는 코드 역시 변경해야 한다. 그 이유가 무엇이건 일단 새로운 코드를 추가하고 나면 언젠가는 변경될 것이라고 생각하는 것이 현명하다.
중복 코드가 가지는 가장 큰 문제는 코드를 수정하는 데 필요한 노력을 몇 배로 증가시킨다는 것이다.
중복 여부를 판단하는 기준은 변경이다. 요구사항이 변경됐을 때 두 코드를 함께 수정해야 한다면 이 코드는 중복이다. 함께 수정할 필요가 없다면 중복이 아니다. 중복 코드를 결정하는 기준은 코드의 모양이 아니다. 모양이 유사하다는 것은 단지 중복의 징후일 뿐이다. 중복 여부를 결정하는 기준은 코드가 변경에 반응하는 방식이다.
DRY는 “반복하지 마라”라는 뜻의 Don’t Repeat Yourself의 첫 글자를 모아 만든 용어로 간단히 말해 동일한 지식을 중복하지 말라는 것이다.
중복과 변경
중복 코드는 새로운 중복 코드를 부른다. 중복 코드를 제거하지 않은 상태에서 코드를 수정할 수 있는 유일한 방법은 새로운 중복 코드를 추가하는 것뿐이다. 중복 코드의 양이 많아질수록 버그의 수는 증가하며 그에 비례해 코드를 변경하는 속도는 점점 더 느려진다.
민첩하게 변경하기 위해서는 중복 코드를 추가하는 대신 제거해야 한다. 기회가 생길 때마다 코드를 DRY하게 만들기 위해 노력하라.
상속을 이용해서 중복 코드 제거하기
우리는 상속을 사용할 때 다음과 같은 경고에 귀 기울일 필요가 있다.
===== 상속을 위한 경고 1 =====
자식 클래스의 메서드 안에서 super 참조를 이용해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합된다. super 호출을 제거할 수 있는 방법을 찾아 결합도를 제거하라.
[ 2. 취약한 기반 클래스 문제 ]
부모 클래스의 변경에 의해 자식 클래스가 영향을 받는 현상을 취약한 기반 클래스 문제라고 부른다. 이 문제는 상속을 사용한다면 피할 수 없는 객체지향 프로그래밍의 근본적인 취약성이다.
상속은 자식 클래스가 부모 클래스의 구현 세부사항에 의존하도록 만들기 때문에 캡슐화를 약화시킨다. 이것이 상속이 위험한 이유인 동시에 우리가 상속을 피해야 하는 첫 번째 이유다.
객체를 사용하는 이유는 구현과 관련된 세부사항을 퍼블릭 인터페이스 뒤로 캡슐화할 수 있기 때문이다. 캡슐화는 변경에 의한 파급효과를 제어할 수 있기 때문에 가치가 있다. 객체는 변경될지도 모르는 불안정한 요소를 캡슐화함으로써 파급효과를 걱정하지 않고도 자유롭게 내부를 변경할 수 있다.
안타깝게도 상속을 사용하면 부모 클래스의 퍼블릭 인터페이스가 아닌 구현을 변경하더라도 자식 클래스가 영향을 받기 쉬워진다.
객체지향의 기반은 캡슐화를 통한 변경의 통제다. 상속은 코드의 재사용을 위해 캡슐화의 장점을 희석시키고 구현에 대한 결합도를 높임으로써 객체지향이 가진 강력함을 반감시킨다.
불필요한 인터페이스 상속 문제
===== 상속을 위한 경고 2 =====
상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨트릴 수 있다.
메서드 오버라이딩의 오작용 문제
===== 상속을 위한 경고 3 =====
자식 클래스가 부모 클래스의 메서드를 오버라이딩할 경우 부모 클래스가 자신의 메서드를 사용하는 방법에 자식 클래스가 결합될 수 있다.
조슈아 블로치는 클래스가 상속되기를 원한다면 상속을 위해 클래스를 설계하고 문서화해야 하며, 그렇지 않은 경우에는 상속을 금지시켜야 한다고 주장한다.
지금 블로치는 여러분에게 내부 구현을 문서화하라고 말하고 있다. 객체지향의 핵심이 구현을 캡슐화하는 것인데도 이렇게 내부 구현을 공개하고 문서화하는 것이 옳은가?
설계는 트레이드오프 활동이라는 사실을 기억하라. 상속은 코드 재사용을 위해 캡슐화를 희생한다. 완벽한 캡슐화를 원한다면 코드 재사용을 포기하거나 상속 이외의 다른 방법을 사용해야 한다.
부모 클래스와 자식 클래스의 동시 수정 문제
결합도란 다른 대상에 대해 알고 있는 지식의 양이다. 상속은 기본적으로 부모 클래스의 구현을 재사용한다는 기본 전제를 따르기 때문에 자식 클래스가 부모 클래스의 내부에 대해 속속들이 알도록 강요한다. 따라서 코드 재사용을 위한 상속은 부모 클래스와 자식 클래스를 강하게 결합시키기 때문에 함께 수정해야 하는 상황 역시 빈번하게 발생할 수밖에 없는 것이다.
===== 상속을 위한 경고 4 =====
클래스를 상속하면 결합도로 인해 자식 클래스와 부모 클래스의 구현을 영원히 변경하지 않거나, 자식 클래스와 부모 클래스를 동시에 변경하거나 둘 중 하나를 선택할 수밖에 없다.
[ 3. Phone 다시 살펴보기 ]
추상화에 의존하자
개인적으로 코드 중복을 제거하기 위해 상속을 도입할 때 따르는 두 가지 원칙이 있다.
- 두 메서드가 유사하게 보인다면 차이점을 메서드로 추출하라. 메서드 추출을 통해 두 메서드를 동일한 형태로 보이도록 만들 수 있다.
- 부모 클래스의 코드를 하위로 내리지 말고 자식 클래스의 코드를 상위로 올려라. 부모 클래스의 구체적인 메서드를 자식 클래스로 내리는 것보다 자식 클래스의 추상적인 메서드를 부모 클래스로 올리는 것이 재사용성과 응집도 측면에서 더 뛰어난 결과를 얻을 수 있다.
차이를 메서드로 추출하라
가장 먼저 할 일은 중복 코드 안에서 차이점을 별도의 메서드로 추출하는 것이다. 이것은 흔히 말하는 “변하는 것으로부터 변하지 않는 것을 분리하라” 또는 “변하는 부분을 찾고 이를 캡슐화하라”라는 조언을 메서드 수준에서 적용한 것이다.
중복 코드를 부모 클래스로 올려라
부모 클래스를 추가하자. 목표는 모든 클래스들이 추상화에 의존하도록 만드는 것이기 때문에 이 클래스는 추상 클래스로 구현하는 것이 적합할 것이다.
추상화가 핵심이다
상속 계층이 코드를 진화시키는 데 걸림돌이 된다면 추상화를 찾아내고 상속 계층 안의 클래스들이 그 추상화에 의존하도록 리팩터링하라. 차이점을 메서드로 추출하고 공통적인 부분은 부모 클래스로 이동하라.
세금 추가하기
클래스라는 도구는 메서드뿐만 아니라 인스턴스 변수도 함께 포함한다. 따라서 클래스 사이의 상속은 자식 클래스가 부모 클래스에 구현한 행동뿐만 아니라 인스턴스 변수에 대해서도 결합되게 만든다.
인스턴스 변수의 목록이 변하지 않는 상황에서 객체의 행동만 변경된다면 상속 계층에 속한 각 클래스들을 독립적으로 진화시킬 수 있다. 하지만 인스턴스 변수가 추가되는 경우는 다르다. 자식 클래스는 자신의 인스턴스를 생성할 때 부모 클래스에 정의된 인스턴스 변수를 초기화해야 하기 때문에 자연스럽게 부모 클래스에 추가된 인스턴스 변수는 자식 클래스의 초기화 로직에 영향을 미치게 된다. 결과적으로 책임을 아무리 잘 분리하더라도 인스턴스 변수의 추가는 종종 상속 계층 전반에 걸친 변경을 유발한다.
하지만 인스턴스 초기화 로직을 변경하는 것이 두 클래스에 동일한 세금 계산 코드를 중복시키는 것보다는 현명한 선택이다.
지금까지 살펴본 것처럼 상속으로 인한 클래스 사이의 결합을 피할 수 있는 방법은 없다. 상속은 어떤 방법으로든 부모 클래스와 자식 클래스를 결합시킨다. 메서드 구현에 대한 결합은 추상 메서드를 추가함으로써 어느 정도 완화할 수 있지만 인스턴스 변수에 대한 잠재적인 결합을 제거할 수 있는 방법은 없다. 우리가 원하는 것은 행동을 변경하기 위해 인스턴스 변수를 추가하더라도 상속 계층 전체에 걸쳐 부작용이 퍼지지 않게 막는 것이다.
[ 4. 차이에 의한 프로그래밍 ]
프로그래밍의 세계에서 중복 코드는 악의 근원이다. 따라서 중복 코드를 제거하기 위해 최대한 코드를 재사용해야 한다.
코드를 재사용하는 것은 단순히 문자를 타이핑하는 수고를 덜어주는 수준의 문제가 아니다. 재사용 가능한 코드란 심각한 버그가 존재하지 않는 코드다. 따라서 코드를 재사용하면 코드의 품질은 유지하면서 코드를 작성하는 노력과 테스트를 줄일 수 있다.
시간이 흐르고 객체지향에 대한 이해가 깊어지면서 사람들은 코드를 재사용하기 위해 맹목적으로 상속을 사용하는 것이 위험하다는 사실을 깨닫기 시작했다. 상속이 코드 재사용이라는 측면에서 매우 강력한 도구인 것은 사실이지만 강력한 만큼 잘못 사용할 경우에 돌아오는 피해 역시 크다는 사실을 뼈저리게 경험한 것이다. 상속의 오용과 남용은 애플리케이션을 이해하고 확장하기 어렵게 만든다. 정말로 필요한 경우에만 상속을 사용하라.
상속은 코드 재사용과 관련된 대부분의 경우에 우아한 해결 방법이 아니다. 객체지향에 능숙한 개발자들은 상속의 단점을 피하면서도 코드를 재사용할 수 있는 더 좋은 방법이 있다는 사실을 알고 있다. 바로 합성이다.
11. 합성과 유연한 설계
상속에서 부모 클래스와 자식 클래스 사이의 의존성은 컴파일타임에 해결되지만 합성에서 두 객체 사이의 의존성은 런타임에 해결된다. 상속 관계는 is-a 관계라고 부르고 합성 관계는 has-a 관계라고 부른다. 상속과 합성은 코드 재사용이라는 동일한 목적을 가진다는 점을 제외하면 구현 방법부터 변경을 다루는 방식에 이르기까지 모든 면에서 도드라진 차이를 보인다.
상속을 이용하면 자식 클래스의 정의에 부모 클래스의 이름을 덧붙이는 것만으로 부모 클래스의 코드를 재사용할 수 있게 된다. 상속을 통해 자식 클래스는 부모 클래스의 정의 대부분을 물려받게 되며 부모 클래스와 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있다. 그러나 상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야 하기 때문에 자식 클래스와 부모 클래스 사이의 결합도가 높아질 수밖에 없다. 결과적으로 상속은 코드를 재사용할 수 있는 쉽고 간단한 방법일지는 몰라도 우아한 방법이라고 할 수는 없다.
합성은 구현에 의존하지 않는다는 점에서 상속과 다르다. 합성은 내부에 포함되는 객체의 구현이 아닌 퍼블릭 인터페이스에 의존한다. 따라서 합성을 이용하면 포함된 객체의 내부 구현이 변경되더라도 영향을 최소화할 수 있기 때문에 변경에 더 안정적인 코드를 얻을 수 있게 된다.
상속 관계는 클래스 사이의 정적인 관계인 데 비해 합성 관계는 객체 사이의 동적인 관계다. 이 차이점은 생각보다 중요한데, 코드 작성 시점에 결정한 상속 관계는 변경이 불가능하지만 합성 관계는 실행 시점에 동적으로 변경할 수 있기 때문이다. 따라서 상속 대신 합성을 사용하면 변경하기 쉽고 유연한 설계를 얻을 수 있다.
물론 상속보다 합성을 이용하는 것이 구현 관점에서 좀 더 번거롭고 복잡하게 느껴질 수도 있다. 하지만 설계는 변경과 관련된 것이라는 점을 기억하라. 변경에 유연하게 대처할 수 있는 설계가 대부분의 경우에 정답일 가능성이 높다.
상속과 합성은 재사용의 대상이 다르다. 상속은 부모 클래스 안에 구현된 코드 자체를 재사용하지만 합성은 포함되는 객체의 퍼블릭 인터페이스를 재사용한다. 따라서 상속 대신 합성을 사용하면 구현에 대한 의존성을 인터페이스에 대한 의존성으로 변경할 수 있다. 다시 말해서 클래스 사이의 높은 결합도를 객체 사이의 낮은 결합도로 대체할 수 있는 것이다.
[ 2. 상속으로 인한 조합의 폭팔적인 증가 ]
상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있다. 가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야 하는 경우다. 일반적으로 다음과 같은 두 가지 문제점이 발생한다.
- 하나의 기능을 추가하거나 수정하기 위해 불필요하게 많은 수의 클래스를 추가하거나 수정해야 한다.
- 단일 상속만 지원하는 언어에서는 상속으로 인해 오히려 중복 코드의 양이 늘어날 수 있다.
이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야 하는 경우를 가리켜 클래스 폭발(class explosion) 문제 또는 조합의 폭발(combinational explosion) 문제라고 부른다. 클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다. 컴파일타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수만큼 새로운 클래스를 추가하는 것뿐이다.
클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때도 문제가 된다.
이 문제를 해결할 수 있는 최선의 방법은 상속을 포기하는 것이다.
[ 3. 합성 관계로 변경하기 ]
합성을 사용하면 구현 시점에 정책들의 관계를 고정시킬 필요가 없으며 실행 시점에 정책들의 관계를 유연하게 변경할 수 있게 된다. 상속이 조합의 결과를 개별 클래스 안으로 밀어 넣는 방법이라면 합성은 조합을 구성하는 요소들을 개별 클래스로 구현한 후 실행 시점에 인스턴스를 조립하는 방법을 사용하는 것이라고 할 수 있다. 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성할 수 있다는 것이 합성이 제공하는 가장 커다란 장점인 것이다.
물론 컴파일타임 의존성과 런타임 의존성의 거리가 멀면 멀수록 설계의 복잡도가 상승하기 때문에 코드를 이해하기 어려워지는 것 역시 사실이다. 하지만 설계는 변경과 유지보수를 위해 존재한다는 사실을 기억하라. 설계는 트레이드오프의 산물이다. 대부분의 경우에는 단순한 설계가 정답이지만 변경에 따르는 고통이 복잡성으로 인한 혼란을 넘어서고 있다면 유연성의 손을 들어주는 것이 현명한 판단일 확률이 높다.
아이러니하게도 변경하기 편리한 설계를 만들기 위해 복잡성을 더하고 나면 원래의 설계보다 단순해지는 경우를 종종 볼 수 있다.
우리는 오직 하나의 클래스만 추가하고 런타임에 필요한 정책들을 조합해서 원하는 기능을 얻을 수 있다. 이 설계를 필요한 조합의 수만큼 매번 새로우 클래스를 추가해야 했던 상속과 비교해보라. 왜 많은 사람들이 그렇게 코드 재사용을 위해 상속보다는 합성을 사용하라고 하는지 그 이유를 이해할 수 있을 것이다.
더 중요한 것은 요구사항을 변경할 때 오직 하나의 클래스만 수정해도 된다는 것이다.
객체 합성이 클래스 상속보다 더 좋은 방법이다.
그렇다면 상속은 사용해서는 안 되는 것인가? 상속을 사용해야 하는 경우는 언제인가? 이 의문에 대답하기 위해서는 먼저 상속을 구현 상속과 인터페이스 상속의 두 가지로 나눠야 한다는 사실을 이해해야 한다. 그리고 이번 장에서 살펴본 상속에 대한 모든 단점들은 구현 상속에 국한된다는 점 또한 이해해야 한다.
12. 다형성
상속의 목적은 코드 재사용이 아니다. 상속은 타입 계층을 구조화하기 위해 사용해야 한다.
[ 1. 다형성 ]
컴퓨터 과학에서는 다형성을 하나의 추상 인터페이스에 대해 코드를 작성하고 이 추상 인터페이스에 대해 서로 다른 구현을 연결할 수 있는 능력으로 정의한다. 간단하게 말해서 다형성은 여러 타입을 대상으로 동작할 수 있는 코드를 작성할 수 있는 방법이라고 할 수 있다.
[ 2. 상속의 양명성 ]
상속의 목적은 코드 재사용이 아니다. 상속은 프로그램을 구성하는 개념들을 기반으로 다형성을 가능하게 하는 타입 계층을 구축하기 위한 것이다. 타입 계층에 대한 고민 없이 코드를 재사용하기 위해 상속을 사용하면 이해하기 어렵고 유지보수하기 버거운 코드가 만들어질 확률이 높다. 문제를 피할 수 있는 방법은 상속이 무엇이고 언제 사용해야 하는지를 이해하는 것뿐이다.
행동 관점의 상속
그렇다면 어떻게 부모 클래스에서 구현한 메서드를 자식 클래스의 인스턴스에서 수행할 수 있는 것일까? 그 이유는 런타임에 시스템이 자식 클래스에 정의되지 않은 메서드가 있을 경우 이 메서드를 부모 클래스 안에서 탐색하기 때문이다.
객체의 경우에는 서로 다른 상태를 저장할 수 있도록 각 인스턴스별로 독립적인 메모리를 할당받아야 한다. 하지만 메서드의 경우에는 동일한 클래스의 인스턴스끼리 공유가 가능하기 때문에 클래스는 한 번만 메모리에 로드하고 각 인스턴스별로 클래스를 가리키는 포인터를 갖게 하는 것이 경제적이다.
인스턴스는 두 개가 생성됐지만 클래스는 단 하나만 메모리에 로드됐다는 사실에 주목하라. 각 객체는 자신의 클래스인 Lecture의 위치를 가리키는 class라는 이름의 포인터를 가지며 이 포인터를 이용해 자신의 클래스 정보에 접근할 수 있다. Lecture 클래스가 자신의 부모 클래스인 Object의 위치를 가리키는 parent라는 이름의 포인터를 가진다는 사실에도 주목하라. 이 포인터를 이용하면 클래스의 상속 계층을 따라 부모 클래스의 정의로 이동하는 것이 가능하다.
메시지를 수신한 객체는 class 포인터로 연결된 자신의 클래스에서 적절한 메서드가 존재하는지를 찾는다. 만약 메서드가 존재하지 않으면 클래스의 parent 포인터를 따라 부모 클래스를 차례대로 훑어가면서 적절한 메서드가 존재하는지를 검색한다.
자식 클래스에서 부모 클래스로의 메서드 탐색이 가능하기 때문에 자식 클래스는 마치 부모 클래스에 구현된 메서드의 복사본을 가지고 있는 것처럼 보이게 된다. 따라서 각 객체에 포함된 class 포인터와 클래스에 포함된 parent 포인터를 조합하면 현재 인스턴스의 클래스에서 최상위 부모 클래스에 이르기까지 모든 부모 클래스에 접근하는 것이 가능하다.
[ 3. 업캐스팅과 동적 바인딩 ]
이처럼 코드 안에서 선언된 참조 타입과 무관하게 실제로 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 달라질 수 있는 것은 업캐스팅과 동적 바인딩이라는 메커니즘이 작용하기 때문이다.
- 부모 클래스(Lecture) 타입으로 선언된 변수에 자식 클래스(GradeLecture)의 인스턴스를 할당하는 것이 가능하다. 이를 업캐스팅이라고 부른다.
- 선언된 변수의 타입이 아니라 메시지를 수신하는 객체의 타입에 따라 실행되는 메서드가 결정된다. 이것은 객체지향 시스템이 메시지를 처리할 적절한 메서드를 컴파일 시점이 아니라 실행 시점에 결정하기 때문에 가능하다. 이를 동적 바인딩이라고 부른다.
업캐스팅
상속을 이용하면 부모 클래스의 퍼블릭 인터페이스가 자식 클래스의 퍼블릭 인터페이스에 합쳐지기 때문에 부모 클래스의 인스턴스에게 전송할 수 있는 메시지를 자식 클래스의 인스턴스에게 전송할 수 있다. 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용하더라도 메시지를 처리하는 데는 아무런 문제가 없으며, 컴파일러는 명시적인 타입 변환 없이도 자식 클래스가 부모 클래스를 대체할 수 있게 허용한다.
동적 바인딩
전통적인 언어에서 함수를 실행하는 방법은 함수를 호출하는 것이다. 객체지향 언어에서 메서드를 실행하는 방법은 메시지를 전송하는 것이다. 함수 호출과 메시지 전송 사이의 차이는 생각보다 큰데 프로그램 안에 작성된 함수 호출 구문과 실제로 실행되는 코드를 연결하는 언어적인 메커니즘이 완전히 다르기 때문이다.
함수를 호출하는 전통적인 언어들은 호출될 함수를 컴파일 타임에 결정한다.
다시 말해 코드를 작성하는 시점에 호출될 코드가 결정된다. 이처럼 컴파일타임에 호출할 함수를 결정하는 방식을 정적 바인딩(static binding), 초기 바인딩(early binding), 또는 컴파일타임 바인딩(compile-time binding)이라고 부른다.
객체지향 언어에서는 메시지를 수신했을 때 실행될 메서드가 런타임에 결정된다. 이처럼 실행될 메서드를 런타임에 결정하는 방식을 동적 바인딩(dynamic binding) 또는 지연 바인딩(late binding)이라고 부른다.
[ 4. 동적 메서드 탐색과 다형성 ]
객체지향 시스템은 다음 규칙에 따라 실행할 메서드를 선택한다.
- 메시지를 수신한 객체는 먼저 자신을 생성한 클래스에 적합한 메서드가 존재하는지 검사한다. 존재하면 메서드를 실행하고 탐색을 종료한다.
- 메서드를 찾지 못했다면 부모 클래스에서 메서드 탐색을 계속한다. 이 과정은 적합한 메서드를 찾을 때까지 상속 계층을 따라 올라가며 계속된다.
- 상속 계층의 가장 최상위 클래스에 이르렀지만 메서드를 발견하지 못한 경우 예외를 발생시키며 탐색을 중단한다.
메시지 탐색과 관련해서 이해해야 하는 중요한 변수가 하나 있다. self 참조(self reference)가 바로 그것이다. 객체가 메시지를 수신하면 컴파일러는 self 참조라는 임시 변수를 자동으로 생성한 후 메시지를 수신한 객체를 가리키도록 설정한다. 동적 메서드 탐색은 self가 가리키는 객체의 클래스에서 시작해서 상속 계층의 역방햐으로 이뤄지며 메서드 탐색이 종료되는 순간 self 참조는 자동으로 소멸된다. 시스템은 앞에서 설명한 class 포인터와 parent 포인터와 함께 self 참조를 조합해서 메서드를 탐색한다.
===== self와 this =====
정적 타임 언어에 속하는 c++, java에서는 self 참조를 this라고 부른다.
시스템은 메시지를 처리할 메서드를 탐색하기 위해 self 참조가 가리키는 메모리로 이동한다. 이 메모리에는 객체의 현재 상태를 표현하는 데이터와 객체의 클래스를 가리키는 class 포인터가 존재한다. class 포인터를 따라 이동하면 메모리에 로드된 클래스의 정보를 읽을 수 있다. 클래스 정보 안에는 클래스 안에 구현된 전체 메서드의 목록이 포함돼 있다. 이 목록 안에 메시지를 처리할 적절한 메서드가 존재하면 해당 메서드를 실행한 후 동적 메서드 탐색을 종료한다.
시스템은 상속 계층을 따라 최상위 클래스인 Object 클래스에 이를 때까지 메서드를 탐색한다. 최상위 클래스에 이르러서도 적절한 메서드를 찾지 못한 경우에는 에러를 발생시키고 메서드 탐색을 종료한다.
지금까지의 설명을 종합해보면 동적 메서드 탐색은 두 가지 원리로 구성된다는 것을 알 수 있다. 첫 번째 원리는 자동적인 메시지 위임이다. 자식 클래스는 자신이 이해할 수 없는 메시지를 전송받은 경우 상속 계층을 따라 부모 클래스에게 처리를 위임한다. 클래스 사이의 위임은 프로그래머의 개입 없이 상속 계층을 따라 자동으로 이뤄진다.
두 번째 원리는 메서드를 탐색하기 위해 동적인 문맥을 사용한다는 것이다. 메시지를 수신했을 때 실제로 어떤 메서드를 실행할지를 결정하는 것은 컴파일 시점이 아닌 실행 시점에 이뤄지며, 메서드를 탐색하는 경로는 self 참조를 이용해서 결정한다.
동적인 문맥
self 전송은 자식 클래스에서 부모 클래스 방향으로 진행되는 동적 메서드 탐색 경로를 다시 self 참조가 가리키는 원래의 자식 클래스로 이동시킨다. 이로 인해 최악의 경우에는 실제로 실행될 메서드를 이해하기 위해 상속 계층 전체를 훑어가며 이해해야 하는 상황이 발생할 수도 있다.
self 대 super
self 참조의 가장 큰 특징은 동적이라는 점이다. self 참조는 메시지를 수신한 객체의 클래스에 따라 메서드 탐색을 위한 문맥을 실행 시점에 결정한다. self의 이런 특성과 대비해서 언급할 만한 가치가 있는 것이 바로 super 참조다.
사실 super 참조의 용도는 부모 클래스에 정의된 메서드를 실행하기 위한 것이 아니다. super 참조의 정확한 의도는 “지금 이 클래스의 부모 클래스에서부터 메서드 탐색을 시작하세요”다. 만약 부모 클래스에서 원하는 메서드를 찾지 못한다면 더 상위 부모 클래스로 이동하면서 메서드가 존재하는지 검사한다.
13. 서브클래싱과 서브타이핑
상속의 첫 번째 용도는 타입 계층을 구현하는 것이다. 타입 계층 안에서 부모 클래스는 일반적인 개념을 구현하고 자식 클래스는 특수한 개념을 구현한다. 타입 계층의 관점에서 부모 클래스는 자식 클래스의 일반화(generalization)이고 자식 클래스는 부모 클래스의 특수화(specialization)다.
상속의 두 번째 용도는 코드 재사용이다. 상속은 간단한 선언만으로 부모 클래스의 코드를 재사용할 수 있는 마법의 주문과도 같다. 상속을 사용하면 점진적으로 애플리케이션의 기능을 확장할 수 있다. 하지만 재사용을 위해 상속을 사용할 경우 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 확률이 높다.
상속을 사용하는 일차적인 목표는 코드 재사용이 아니라 타입 계층을 구현하는 것이어야 한다. 반면 타입 계층을 목표로 상속을 사용하면 다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있게 된다.
[ 2. 타입 계층 ]
객체지향 프로그래밍과 타입 계층
객체의 타입을 결정하는 것은 퍼블릭 인터페이스다. 따라서 퍼블릭 인터페이스의 관점에서 슈퍼타입과 서브타입을 다음과 같이 정의할 수 있다.
- 슈퍼타입이란 서브타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것이다.
- 서브타입이란 슈퍼타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것이다.
[ 3. 서브클래싱과 서브타이핑 ]
언제 상속을 사용해야 하는가?
그렇다면 어떤 조건을 만족시켜야만 타입 계층을 위해 올바르게 상속을 사용했다고 말할 수 있을까? 마틴 오더스키는 다음과 같은 질문을 해보고 모두 “예”라고 답할 수 있는 경우에만 상속을 사용하라고 조언한다.
- 상속 관계가 is-a 관계를 모델링하는가?
- 이것은 애플리케이션을 구성하는 어휘에 대한 우리의 고나점에 기반한다. 일반적으로 “자식 클래스는 부모 클래스다” 라고 말해도 이상하지 않다면 상속을 사용할 후보로 간주할 수 있다.
- 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?
- 상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모 클래스 사이의 행동 호환성이라고 부른다.
설계 관점에서 상속을 적용할지 여부를 결정하기 위해 첫 번째 질문보다는 두 번째 질문에 초점을 맞추는 것이 중요하다.
is-a 관계
어떤 타입 S가 다른 타입 T의 일종이면 당연히 “타입 S는 타입 T다(S is-a T)”라고 말할 수 있어야 한다.
하지만 is-a 관계가 생각처럼 직관적이고 명쾌한 것은 아니다. 스콧 마이어스는 새와 펭귄의 예를 들어 is-a 관계까 직관에서 쉽게 배신할 수 있다는 사실을 보여준다.
이 예는 어휘적인 정의가 아니라 기대되는 행동에 따라 타입 계층을 구성해야 한다는 사실을 잘 보여준다.
따라서 슈퍼타입과 서브타입 관계에서는 is-a 보다 행동 호환성이 더 중요하다.
따라서 is-a 라고 표현할 수 있더라도 일단은 상속을 사용할 예비 후보 정도로만 생각하라.
행동 호환성
타입의 이름 사이에 개념적으로 어떤 연관성이 있다고 하더라도 행동에 연관성이 없다면 is-a 관계를 사용하지 말아야 한다.
결론은 두 타입 사이에 행동이 호환될 경우에만 타입 계층으로 묶어야 한다는 것이다.
여기서 중요한 것은 행동의 호환 여부를 판단하는 기준은 클라이언트의 관점이라는 것이다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다.
클라이언트의 기대에 따라 계층 분리하기
문제를 해결할 수 있는 방법은 클라이언트의 기대에 맞게 상속 계층을 분리하는 것뿐이다.
대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다. 클라이언트에 따라 인터페이스를 분리하면 각 클라이언트의 요구가 바뀌더라도 영향의 파급 효과를 효과적으로 제어할 수 있게 된다.
이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 부른다.
서브클래싱과 서브타이핑
- 서브 클래싱
- 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우를 가리킨다.
- 자식 클래스와 부모 클래스의 행동이 호환되지 않기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 없다. 서브클래싱을 구현 상속(implementation inheritance) 또는 클래스 상속(class inheritance)이라고 부르기도 한다.
- 서브 타이핑
- 타입 계층을 구성하기 위해 상속을 사용하는 경우를 가리킨다.
- 서브타이핑에서는 자식 클래스와 부모 클래스의 행동이 호환되기 때문에 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대체할 수 있다. 이때 부모 클래스는 자식 클래스의 슈퍼 타입이 되고 자식 클래스는 부모 클래스의 서브 타입이 된다. 서브타이핑을 인터페이스 상속(interface inheritance)이라고 부르기도 한다.
[ 4. 리스코프 치환 원칙 ]
리스코프 치환 원칙을 한마디로 정의하면 “서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다”는 것으로 클라이언트가 “차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다”는 것이다. 리스코프 치환 원칙은 앞에서 논의한 행동 호환성을 설계 원칙으로 정리한 것이다.
클라이언트와 대체 가능성
리스코프 치환 원칙은 “클라이언트와 격리한 채로 본 모델은 의미 있게 검증하는 것이 불가능하다”는 아주 중요한 결론을 이끈다. 어떤 모델의 유효성은 클라이언트의 관점에서만 검증 가능하다는 것이다.
행동 호환성과 리스코프 치환 원칙에서 한 가지만 기억해야 한다면 이것을 기억하라. 대체 가능성을 결정하는 것은 클라이언트다.
is-a 관계 다시 살펴보기
is-a 관계는 객체지향에서 중요한 것은 객체의 속성이 아니라 객체의 행동이라는 점을 강조한다. 일반적으로 클라이언트를 고려하지 않은 채 개념과 속성의 측면에서 상속 관계를 정할 경우 리스코프 치환 원칙을 위반하는 서브클래싱에 이르게 될 확률이 높다.
[ 5. 계약에 의한 설계와 서브타이핑 ]
클라이언트와 서버 사이의 협력을 의무(obligation)와 이익(benefit)으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계(Design By Contract, DBC)라고 부른다. 계약에 의한 설계는 클라이언트가 정상적으로 메서드를 실행하기 위해 만족시켜야 하는 사전조건(precondition)과 메서드가 실행된 후에 서버가 클라이언트에게 보장해야 하는 사후조건(postcondition), 메서드 실행 전과 실행 후에 인스턴스가 만족시켜야 하는 클래스 불변식(class invariant)의 세 가지 요소로 구성된다.
14. 일관성 있는 협력
[ 2. 설계에 일관성 부여하기 ]
따라서 협력을 일관성 있게 만들기 위해 다음과 같은 기본 지침을 따르는 것이 도움이 될 것이다.
- 변하는 개념을 변하지 않는 개념으로부터 분리하라.
- 변하는 개념을 캡슐화하라.
캡슐화 다시 살펴보기
많은 사람들은 객체의 캡슐화에 관한 이야기를 들으면 반사적으로 데이터 은닉(data hiding)을 떠올린다. 데이터 은닉이란 오직 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한함으로써 객체 내부의 상태 구현을 숨기는 기법을 가리킨다.
그러나 캡슐화는 데이터 은닉 이상이다. GOF의 조언에 따르면 캡슐화란 단순히 데이터를 감추는 것이 아니다. 소프트웨어 안에서 변할 수 있는 모든 “개념”을 감추는 것이다.
코드 수정으로 인한 파급 효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다.
15. 디자인 패턴과 프레임워크
[ 1. 디자인 패턴과 설계 재사용 ]
소프트웨어 패턴
패턴이 지닌 가장 큰 가치는 경험을 통해 축적된 실무 지식을 효과적으로 전달할 수 있다는 점이다. 패턴은 경험의 산물이다. 책상 위에서 탄생한 이론이나 원리와 달리 패턴은 치열한 실무 현장의 역학관계 속에서 검증되고 입증된 자산이다. 따라서 실무 경험이 적은 초보자라고 하더라도 패턴을 익히고 반복적으로 적용하는 과정 속에서 유연하고 품질 높은 소프트웨어를 개발하는 방법을 익힐 수 있게 된다.
패턴은 지식 전달과 커뮤니케이션의 수단으로 활용할 수 있기 때문에 패턴에서 가장 중요한 요소는 패턴의 “이름”이다. 패턴의 이름은 커뮤니티가 공유할 수 있는 중요한 어휘집을 제공한다.
패턴과 책임-주도 설계
패턴은 공통으로 사용할 수 있는 역할, 책임, 협력의 템플릿이다.
여기서 언급한 패턴들의 세부적인 내용이 중요한 것이 아니다. 중요한 것은 패턴을 따르면 특정한 상황에 적용할 수 있는 설계를 쉽고 빠르게 떠올릴 수 있다는 사실이다.
패턴을 구성하는 요소가 클래스가 아니라 역할이라는 사실은 패턴 템플릿을 구현할 수 있는 다양한 방법이 존재한다는 사실을 암시한다. 역할은 동일한 오퍼레이션에 대해 응답할 수 있는 책임의 집합을 암시하기 때문에 하나의 객체가 세 가지 역할 모두를 수행하더라도 문제가 없다.
캡슐화와 디자인 패턴
대부분의 디자인 패턴의 목적은 특정한 변경을 캡슐화함으로써 유연하고 일관성있는 협력을 설계할 수 있는 경험을 공유하는 것이다. 디자인 패턴에서 중요한 것은 디자인 패턴의 구현 방법이나 구조가 아니다. 어떤 디자인 패턴이 어떤 변경을 캡슐화하는지를 이해하는 것이 중요하다. 그리고 각 디자인 패턴이 변경을 캡슐화하기 위해 어떤 방법을 사용하는지를 이해하는 것이 더 중요하다.
정당한 이유 없이 사용된 모든 패턴은 설계를 복잡하게 만드는 장애물이다. 패턴은 복잡성의 가치가 단순성을 넘어설 때만 정당화돼야 한다.
부록 A. 계약에 의한 설계
여기서 말하고 싶은 것은 인터페이스만으로는, 객체의 행동에 관한 다양한 관점을 전달하기 어렵다는 것이다. 우리에게 필요한 것은 명령의 부수효과를 쉽고 명확하게 표현할 수 있는 커뮤니케이션 수단이다. 이 시점이 되면 계약에 의한 설계(Design by Contract, DBC)가 주는 혜택으로 눈을 돌릴 때가 된 것이다.
협력에 의한 설계를 사용하면 협력에 필요한 다양한 제약과 부수효과를 명시적으로 정의하고 문서화할 수 있다. 클라이언트 개발자는 오퍼레이션의 구현을 살펴보지 않더라도 객체의 사용법을 쉽게 이해할 수 있다. 계약은 실행 가능하기 때문에 구현에 동기화돼 있는지 여부를 런타임에 검증할 수 있다. 따라서 주석과 다르게 시대의 흐름에 뒤쳐질 걱정을 할 필요가 없다.
[ 1. 협력과 계약 ]
부수효과를 명시적으로
메시지의 이름과 파라미터 목록은 시그니처를 통해 전달할 수 있지만 협력을 위해 필요한 약속과 제약은 인터페이스를 통해 전달할 수 없기 때문에 협력과 관련된 상당한 내용이 암시적인 상태로 남게 된다.
대표적인 차이점으로 문서화를 들 수 있다. 일반적인 정합성 체크 로직은 코드의 구현 내부에 숨겨져 있어 실제로 코드를 분석하지 않는 한 정확하게 파악하기가 쉽지 않다. 게다가 일반 로직과 조건을 기술한 로직을 구분하기도 쉽지 않다.
하지만 Code Contracts와 같이 계약에 의한 설계 개념을 지원하는 라이브러리나 언어들은 일반 로직과 구분할 수 있도록 제약 조건을 명시적으로 표현하는 것이 가능하다.
public void Reschedule(RecurringSchedule schedule) {
Contract.Requires(isSatisfied(schedule));
}
이렇게 작성된 계약은 문서화로 끝나는 것이 아니라 제약 조건의 만족 여부를 실행 중에 체크할 수 있다. 또한 이 조건들을 코드로부터 추출해서 문서를 만들어주는 자동화 도구도 제공한다. 따라서 계약에 의한 설계를 사용하면 제약 조건을 명시적으로 표현하고 자동으로 문서화할 수 있을뿐만 아니라 실행을 통해 검증할 수 있다.
계약
계약의 세부적인 내용은 상황에 따라 다르겠지만 일반적으로 다음과 같은 특성을 가진다.
- 각 계약 당사자는 계약으로부터 이익(benefit)을 기대하고 이익을 얻기 위해 의무(obligation)를 이행한다.
- 각 계약 당사자의 이익과 의무는 계약서에 문서화된다.
[ 2. 계약에 의한 설계 ]
계약은 협력에 참여하는 두 객체 사이의 의무와 이익을 문서화한 것이다.
- 협력에 참여하는 각 객체는 계약으로부터 이익(benefit)을 기대하고 이익을 얻기 위해 의무(obligation)를 이행한다.
- 협력에 참여하는 각 객체의 이익과 의무는 인터페이스 상에 문서화된다.
계약에 의한 설계 개념은 “인터페이스에 대해 프로그래밍하라”는 원칙을 확장한 것이다. 계약에 의한 설계를 이용하면 오퍼레이션의 시그니처를 구성하는 다양한 요소들을 이용해 협력에 참여하는 객체들이 지켜야 하는 제약 조건을 명시할 수 있다.
서버는 자신이 처리할 수 있는 범위의 값들을 클라이언트가 전달할 것이라고 기대한다. 클라이언트는 자신이 원하는 값을 서버가 반환할 것이라고 예상한다. 클라이언트는 메시지 전송 전과 후의 서버의 상태가 정상일 것이라고 기대한다. 이 세 가지 기대가 바로 계약에 의한 설계를 구성하는 세 가지 요소에 대응된다. 이 요소들을 순서대로 사전조건, 사후조건, 불변식이라고 부른다.
- 사전조건
- 메서드가 호출되기 위해 만족돼야 하는 조건. 이것은 메서드의 요구사항을 명시한다. 사전조건이 만족되지 않을 경우 메서드가 실행돼서는 안 된다. 사전조건을 만족시키는 것은 메서드를 실행하는 클라이언트의 의무다.
- 사후조건
- 메서드가 실행된 후에 만족돼야 하는 조건. 클라이언트가 사전조건을 만족시켰다면 메서드는 사후에 명시된 조건을 만족시켜야 한다. 만약 클라이언트가 사전조건을 만족시켰는데도 사후조건을 만족시키지 못한 경우에는 클라이언트에게 예외를 던져야 한다. 사후조건을 만족시키는 것은 서버의 의무다.
- 불변식
- 항상 참이라고 보장되는 서버의 조건. 메서드가 실행되는 도중에는 불변식을 만족시키지 못할 수도 있지만 메서드를 실행하기 전이나 종료된 후에 불변식은 항상 참이어야 한다.
[ 3. 계약에 의한 설계와 서브타이핑 ]
계약에 의한 설계는 클라이언트가 만족시켜야 하는 사전조건과 클라이언트의 관점에서 서버가 만족시켜야 하는 사후조건을 기술한다. 계약에 의한 설계와 리스코프 치환 원칙이 만나는 지점이 바로 이곳이다. 리스코프 치환 원칙은 슈퍼타입의 인스턴스와 협력하는 클라이언트의 관점에서 서브타입의 인스턴스가 슈퍼타입을 대체하더라도 협력에 지장이 없어야 한다는 것을 의미한다. 따라서 다음과 같이 정리할 수 있다.
“서브타입이 리스코프 치환 원칙을 만족시키기 위해서는 클라이언트와 슈퍼타입 간에 체결된 계약을 준수해야 한다.”
부록 B. 타입 계층의 구현
클래스를 이용한 타입 계층 구현
타입은 객체의 퍼블릭 인터페이스를 가리키기 때문에 결과적으로 클래스는 객체의 타입과 구현을 동시에 정의하는 것과 같다.
타입을 구현할 수 있는 방법이 단 한가지만 존재하는 경우에는 타입과 클래스를 동일하게 취급해도 무방하다. 여기서 “타입의 구현 방법이 단 한 가지”라는 말이 중요하다. 타입을 구현할 수 있는 다양한 방법이 존재하는 순간부터는 클래스와 타입은 갈라지기 시작한다.
추상 클래스를 이용한 타입 계층 구현
구체 클래스로 타입을 정의해서 상속받는 방법과 추상 클래스로 타입을 정의해서 상속받는 방법 사이에는 두 가지 중요한 차이점이 있다. 하나는 추상화 정도이고 다른 하나는 상속을 사용하는 의도다.
첫 번째로 의존하는 대상의 추상화 정도가 다르다. 앞에서 클래스를 이용해 타입 계층을 구현했던 Phone 클래스의 경우 자식 클래스인 NightlyDiscountPhone의 calculateFee 메서드가 부모 클래스인 Phone의 calculateFee 메서드의 구체적인 내부 구현에 강하게 결합된다. 따라서 Phone의 내부 구현이 변경될 경우 자식 클래스인 NightlyDiscountPhone도 함께 변경될 가능성이 높다.
이에 비해 추상 클래스인 DiscountPolicy의 경우 자식 클래스인 AmountDiscountPolicy와 PercentDiscountPolicy의 내부 구현이 아닌 추상 메서드의 시그니처에만 의존한다. 이 경우 자식 클래스들은 DiscountPolicy가 어떤 식으로 구현돼 있는지는 알 필요가 없다. 단지 추상 메서드로 정의된 getDiscountAmount 메서드를 오버라이딩하면 된다는 사실에 의존해도 무방하다.
여기서 부모 클래스와 자식 클래스 모두 추상 메서드인 getDiscountAmount에 의존한다는 사실이 중요하다. 이것은 의존성 역전 원칙의 변형이다. DiscountPolicy는 할인 조건을 판단하는 고차원의 모듈이다. 그에 비해 AmountDiscountPolicy와 PercentDiscountPolicy는 할인 금액을 계산하는 저차원 모듈이다. 고차원 모듈과 저차원 모듈 모두 추상 메서드인 getDiscountAmount에 의존한다.
또한 DiscountPolicy의 구체 메서드인 calculateDiscountAmount가 추상 메서드 getDiscountAmount를 호출하며 자식 클래스들은 모두 이 추상 메서드의 시그니처를 준수한다. 따라서 구체적인 메서드가 추상적인 메서드에 의존하기 때문에 의존성 역전 원칙을 따른다고 할 수 있다. 결과적으로 이 설계는 유연한 동시에 변화에 안정적이다.
한 가지 조언은 모든 구체 클래스의 부모 클래스를 항상 추상 클래스로 만들기 위해 노력하라는 것이다. 의존하는 대상이 더 추상적일수록 결합도는 낮아지고 결합도가 낮아질수록 변경으로 인한 영향도는 줄어든다. DiscountPolicy의 자식 클래스들은 구체적인 구현이 아닌 추상 메서드에 의존하고 있기 때문에 추상 메서드의 명세가 변경되지 않는 한 영향을 받지 않는다.
두 번째 차이점은 상속을 사용하려는 의도다. 이 클래스는 추상 메서드를 제공함으로써 상속 계층을 쉽게 확장할 수 있게 하고 결합도로 인한 부작용을 방지할 수 있는 안전망을 제공한다.
추상 클래스와 인터페이스 결합하기
인터페이스를 이용해 타입을 정의하고 특정 상속 계층에 국한된 코드를 공유할 필요가 있을 경우에는 추상 클래스를 이용해 코드 중복을 방지하는 것이다. 이런 형태로 추상 클래스를 사용하는 방식을 골격 구현 추상 클래스(skeletal implementation abstract class)라고 부른다.
인터페이스와 추상 클래스를 함께 사용하는 방법은 추상 클래스만 사용하는 방법에 비해 두 가지 장점이 있다.
- 다양한 구현 방법이 필요할 경우 새로운 추상 클래스를 추가해서 쉽게 해결할 수 있다.
- 이미 부모 클래스가 존재하는 클래스라고 하더라도 인터페이스를 추가함으로써 새로운 타입으로 쉽게 확장할 수 있다.
여러분의 설계가 상속 계층에 얽매이지 않는 타입 계층을 요구한다면 인터페이스로 타입을 정의하라. 추상 클래스로 기본 구현을 제공해서 중복 코드를 제거하라. 하지만 이런 복잡성이 필요하지 않다면 타입을 정의하기 위해 인터페이스나 추상 클래스 둘 중 하나만 사용하라. 타입의 구현 방법이 단 한 가지 이거나 단일 상속 계층만으로도 타입 계층을 구현하는 데 무리가 없다면 클래스나 추상 클래스를 이용해 타입을 정의하는 것이 더 좋다. 그 외의 상황이라면 인터페이스를 이용하는 것을 고려하라.
부록 C. 동적인 협력, 정적인 코드
[ 2. 도메인 모델과 구현 ]
도메인 모델에 관하여
도메인(domain)이란 사용자가 프로그램을 사용하는 대상 영역을 가리킨다. 모델(model)이란 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태다. 도메인 모델(domain model)이란 사용자가 프로그램을 사용하는 대상 영역에 대한 지식을 선택적으로 단순화하고 의식적으로 구조화한 형태다.
객체지향 분석 설계에서 제안하는 지침 중 하나는 소프트웨어의 도메인에 대해 고민하고 도메인 모델을 기반으로 소프트웨어를 구축하라는 것이다. 이 지침을 따르면 개념과 소프트웨어 사이의 표현적 차이를 줄일 수 있기 때문에 이해하고 수정하기 쉬운 소프트웨어를 만들 수 있다.
여깃 중요한 것은 도메인 모델을 작성하는 것이 목표가 아니라 출발점이라는 것이다. 우리가 소프트웨어를 만들기 위해 수행하는 모든 활동의 궁극적인 목적은 동작하는 소프트웨어를 만드는 것이다. 도메은 모델은 소프트웨어를 만드는 데 필요한 개념의 이름과 의미, 그리고 관계에 대한 힌트를 제공하는 역할로 끝나야 한다.
도메인 모델은 여러분의 도메인에 대한 지식을 표현하고 코드의 구조에 대한 힌트를 제공할 수 있다면 어떤 형태로 표현하더라도 상관이 없다. 사실 객체 사이의 협력을 도드라지게 보여주는 개념적인 표현 역시 도메인 모델이 될 수 있다.
우리에게 중요한 것은 소프트웨어의 기능과 객체의 책임이다. 코드의 구조를 이끄는 것은 도메인 안에 정립된 개념의 분류 체계가 아니라 객체들의 협력이다. 도메인 안의 개념들을 기반으로 출발하되 객체들의 협력이 도메인 모델에 맞지 않다면 필요한 몇 가지 개념만 남기고 도메인 모델을 과감히 수정하라.
행동과 변경을 고려한 도메인 모델
초기의 도메인 모델은 그저 작업을 시작하기 위한 거친 아이디어 덩어리일 뿐이다. 더 많은 지식이 쌓이고 요구사항이 분명해지면 초기의 아이디어에 대한 미련을 버리고 현명한 판단을 내려야 한다.
구현하거나 변경하기 더 쉬운 모델이 떠올랐다면 과감하게 초기 아이디어를 버려라. 객체지향의 핵심은 객체 사이의 협력이며 설계는 변경을 위한 것이다. 따라서 행동과 변경을 고려하지 않은 채 도메인 모델을 그대로 따르는 설계는 코드의 유지보수를 방해할 뿐이다.