오브젝트


앞선 챕터에서 살펴본 내용의 주된 키워드를 살펴보면 다음과 같다.

  1. 자율적인 객체

  2. 인터페이스와 구현의 분리

  3. 캡슐화를 통한 낮은 결합도와 높은 응집도의 객체지향 설계

위의 세 가지 키워드 모두 캡슐화 와 관련이 깊다고 하였다.

이번에는 객체지향 프로그래밍 이라는 주제로 하나의 예제를 살펴보며 예제에 적용된 객체지향의 주 개념과 왜 이 개념들이 객체지향 패러다임에서 중요한 자리를 차지하게 되었는지 살펴보자.

챕터 2. 객체지향 프로그래밍

영화 예매 시스템

영화 예매 시스템 예제의 요구사항은 다음과 같다.

  1. 특정한 조건 을 만족하는 예매자는 요금을 할인받을 수 있다. 할인액을 결정하는 두 가지 규칙이 존재하는데 하나는 할인 조건 이라 부르고 다른 하나는 할인 정책 이라 부른다.

  2. 할인 조건은 순번 조건시간 조건 이 있다.

  3. 할인 정책은 금액 할인 정책비율 할인 정책 이 있다.

  4. 영화 별로 하나의 할인 정책 만 할당할 수 있다. 물론 할인 정책을 지정하지 않는 것도 가능하다.

  5. 할인 조건은 다수의 할인 조건을 함께 지정 하는 것이 가능하다. 순번 조건과 시간 조건을 섞는 것도 가능하다.

  6. 팔인을 적용하기 위해서는 할인 조건할인 정책 을 조합하여 사용한다.

할인 조건 할인 정책
순번 조건 금액 할인 정책
시간 조건 비율 할인 정책

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

객체지향은 객체 를 지향하는 것이다. 즉 클래스가 아닌 객체에 초점 을 맞출 때에 비로소 객체지향 패러다임으로의 전환이 가능하다.

이를 위해서는 프로그래밍 하는 동안 두 가지를 집중한다.

어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지를 고민

클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화 한 것이다. 따라서 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야 한다.

객체를 독립적인 존재가 아닌 기능을 구현하기 위해 협력하는 공동체의 일원으로 본다.

객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다. 객체지향적으로 생각하고 싶다면 객체를 고립된 존재로 바라보지 말고 협력에 참여하는 협력자로 바라봐야 한다.

객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류 하고 이 타입을 기반으로 클래스를 구현한다. 훌륭한 협력이 훌륭한 객체를 낳고 훌륭한 객체가 훌륭한 클래스를 낳는다.


도메인의 구조를 따르는 프로그램 구조

소프트웨어는 사용자가 어떤 문제를 해결하기 위해 만들어진다. 영화 예매 시스템의 목적은 영화를 좀 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야도메인 이라고 부른다.

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

위의 사진은 도메인을 구성하는 타입들의 구조 를 보여준다.

일반적으로 클래스의 이름은 대응되는 도메인 개념(타입) 의 이름과 동일하게, 적어도 유사하게 지어야 한다. 또한 클래스 사이의 관계 역시 최대한 도메인 타입 사이에 맺어진 관계와 유사하게 만들어서 프로그램의 구조를 이해하고 예상하기 쉽게 만들어야 한다.


클래스 구현하기

이제 위 구조를 한번 구현해보자. 객체지향 개념의 설명을 위해 설계 과정은 생략하고 최종 코드의 모습을 보고 개념의 의미를 하나하나 살펴보는 순서로 진행한다.

Screening 클래스는 사용자들이 예매하는 대상인 상영 을 구현한다.

위 구현이 처음에는 막상 이해가 가지 않았다. 하지만 의문점에 대한 질문을 스스로에게 던져보고 그동안 배웠던 내용들을 복기하고보니 의문점에 대해 대부분 답변이 가능했다. 구현의 결과는 트레이드 오프의 산물임을 기억하고 이해가 가지 않아도 좋은 설계는 계속해서 보고 거의 암기하고 따라하려 노력하자.

주목할 점은 인스턴스 변수의 가시성은 private 이고 메서드의 가시성은 public 이라는 것이다.

클래스를 구현하거나 다른 개발자에 의해 개발된 클래스를 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것 이다.

클래스는 내부와 외부로 구분 되며 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 공개하고 어떤 부분을 감출지 결정하는 것 이다.

Screening 에서 알 수 있는 것 처럼 외부에서는 객체의 속성에 직접 접근할 수 없도록 막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야한다. (캡슐화)

클래스의 내부와 외부를 구분해야하는 중요한 이유(인터페이스와 구현을 분리해야하는 중요한 이유)는 경계의 명확성이 객체의 자율성을 보장 하기 때문이다. 그리고 더 중요한 이유로 프로그래머에게 구현의 자유를 제공하기 때문 이다.


자율적인 객체

객체 내부에 대한 접근을 통제하는 이유는 객체를 자율적인 존재로 만들기 위함 이다.

객체가 자율적인 존재로 존재하기 위해서는 외부의 간섭을 최소화 해야 한다. 즉, 외부에서는 객체가 어떤 상태에 놓여있는지, 어떤 생각을 하고 있는지 알아서는 안되며 결정에 직접적으로 개입하려고 해서는 안된다.

객체에게 원하는 것을 요청 하고는 객체가 스스로 최선의 방법을 결정할 수 있을 것이라는 점을 믿고 기다려야 한다.

캡슐화와 접근 제어는 객체를 두 부분으로 나눈다.

  1. 외부에서 접근 가능한 부분(public 메서드). 퍼블릭 인터페이스

  2. 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분(private 메서드, protected 메서드, 속성) 구현

전 챕터에서 간단하게 살펴봤지만 인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위해 따라야 하는 핵심 원칙이다.


프로그래머의 자유

프로그래머의 역할을 두 부분으로 구분하는 것이 유용하다.

  1. 클래스 작성자 : 새로운 데이터 타입을 프로그램에 추가하는 역할

  2. 클라이언트 프로그래머 : 클래스 작성자가 추가한 데이터 타입을 사용 하는 역할

클라이언트 프로그래머의 목표는 필요한 클래스들을 엮어서 애플리케이션을 빠르고 안정적으로 구축하는 것이다.

클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개 하고 나머지는 꽁꽁 숨겨야 한다.

클라이언트 프로그래머는 숨겨놓은 부분에 마음대로 접근할 수 없기때문에 클래스 작성자는 내부 구현을 마음대로 변경 할 수 있다. 이를 구현 은닉 이라 한다.

접근 제어 메커니즘 은 프로그래밍 언어 차원에서 클래스의 내부와 외부를 명확하게 경계 지을 수 있게 하는 동시에 클래스 작성자가 내부 구현을 은닉 할 수 있게 해준다. 또한 클라이언트 프로그래머가 실수로 숨겨진 부분에 접근하는 것을 막아준다. 클라이언트 프로그래머가 private 속성이나 메서드에 접근하려고 시도하면 컴파일러는 오류를 내뱉을 것이다.

구현 은닉 은 클래스 작성자와 클라이언트 프로그래머 모두에게 유용한 개념이다. 클라이언트 프로그래머는 내부의 구현은 무시한 채 인터페이스 만 알고있어도 클래스를 사용할 수 있기 때문에 머릿속에 담아둬야 하는 지식의 양을 줄일 수 있다.

클래스 작성작는 인터페이스를 바꾸지 않는 한 외부에 미치는 영향을 걱정하지 않고도 내부 구현을 마음대로 변경 할 수 있다.

다시말해 public 영역을 변경하지 않는다면 코드를 자유롭게 수정할 수 있다.

객체의 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식의 양이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어진다. 따라서 클래스를 개발할 때 마다 인터페이스와 구현을 깔끔하게 분리하기 위해 노력 해야한다.

객체의 변경을 관리할 수 있는 기법 중에서 가장 대표적인 것이 바로 접근 제어 다. 변경될 가능성이 있는 세부적인 구현 내용을 private 영역 안에 감춤으로써 변경으로 인한 혼란을 최소화 할 수 있다.

클래스의 내부와 외부를 분리한다는 것의 의미는 ‘극장’ 예제를 다시한번 생각해 보자. Theater가 Audience의 가방에 티켓을 넣고 돈을 가져오는 프로세스는 Audience의 내부 구현과 Bag의 내부 구현이 Theater에게 드러나있기에 가능하다. 캡슐화와 책임의 이동을 통해 인터페이스와 구현을 분리함으로써 Theater는 TicketSeller의 SellTo() 인터페이스에만 의존함으로써 내부 구현은 신경쓰지 않아도 되게끔 구현되었다. 즉 클래스의 외부(공개된 퍼블릭 인터페이스)와 내부(TicketSeller가 Audience에게 어떤 메서드로 티켓을 파는지)를 분리하였다.


협력하는 객체들의 공동체

이제 영화를 예매하는 기능을 구현하는 메서드를 살펴보자. Screeningreserve() 메서드는 영화를 예매한 후 예매 정보를 담고있는 Reservation 의 인스턴스 변수를 생성해서 반환한다. 인자인 customer 는 예매자에 대한 정보를 담고 있고 audienceCount 는 인원수다.

위의 reserve() 메서드를 보면 calculateFee() 라는 private 메서드를 호출해서 요금을 계산한 후 그 결과를 Reservation 의 생성자에 전달하는 것을 알 수 있다.

calculateFee() 메서드는 요금을 계산하기 위해 다시 MoviecalculateMovieFee() 메서드를 호출한다. MoviecalculateMovieFee() 메서드의 반환 값은 1인당 예매 요금이다. 따라서 Screening 은 전체 예매 요금을 구하기 위해 calculateMovieFee() 메서드의 반환값에 인원수인 audienceCount 를 곱한다.

Money금액과 관련된 다양한 계산을 구현 하는 간단한 클래스다.

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

그 개념이 비록 하나의 인스턴스 변수만 포함하더라도 개념을 명시적으로 표현하는 것은 전체적인 설계의 명확성과 유연성을 높이는 첫 걸음 이다.

Reservation 클래스는 고객(customer), 상영정보(screening), 예매 요금(fee), 인원 수 (audienceCount)를 속성으로 포함한다.


영화를 예매하기 위해 Screening / Movie / Reservation 인스턴스들은 서로에게 메시지를 전송하며 상호작용 한다. 이처럼 시스템의 어떤 기능을 구현하기 위해 객체들 사이에 이뤄지는 상호작용을 협력 이라고 부른다.

<img src=”/assets/img/object/ch2/img8.jpeg” width=”100%” height=”auto

객체지향 프로그램을 작성할 때는 먼저 협력 의 관점에서 어떤 객체가 필요한지 를 결정하고 객체들의 공통 상태와 행위를 구현하기 위해 클래스를 작성한다.

어떤 객체가 필요한지는 가장 먼저 ‘도메인 개념’으로부터 찾자.

  1. 개념 관점 설계 : 도메인 내 존재하는 개념들의 관계를 파악한다. 사용자가 도메인을 바라보는 관점을 반영한다.
  1. 명세 관점 설계 : 실제 객체들의 책임에 초점을 둔다. 객체들의 인터페이스를 바라보고 객체가 ‘무엇’을 할 수 있는가에 초점을 맞춘다.
  1. 구현 관점 설계 : 책임 수행에 필요한 코드를 작성한다. (인터페이스를 수행하는 책임) 책임을 ‘어떻게’ 수행할지에 초점을 맞춘다.

협력에 관한 짧은 이야기

객체가 다른 객체와 상호작용 할 수 있는 유일한 방법은 메시지 전송 뿐이다.

다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신 했다고 말한다.

메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정 한다. 이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드 라 한다.

메시지와 메서드를 구분 하는 것은 매우 중요하다. 객체지향 패러다임이 유연하고 확장 가능하며 재사용 가능한 설계를 낳는다는 명성을 얻게 된 배경에는 메시지와 메서드를 명확하게 구분 한 것도 단단히 한 몫 한다.

또한 다형성 의 측면에서 메시지와 메서드의 구분 이라는 개념이 명확히 드러난다.

지금까지 ScreeningMoviecalculateMovieFee() 메서드를 호출한다고 이야기 했지만 사실은 ScreeningMovie 에게 calculateMovieFee 메시지를 전송한다 가 더 적절한 표현이다.

사실 ScreeningMovie 안에 calculateMovieFee 메서드가 존재하는지 조차 모른다. 단지 MoviecalculateMovieFee 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.(메서드는 메시지를 처리하기 위한 내부 구현이기 때문에 Screening은 Movie의 메서드를 모른다.)

메시지를 수신한 Movie 는 스스로 적절한 메서드를 선택한다. 결국 메시지를 처리하는 방법을 결정하는 것은 Movie 스스로의 문제 인 것이다.

이것이 객체가 메시지를 처리하는 방법을 자율적으로 결정 할 수 있다고 말했던 이유다.


할인 요금 구하기.

계속해서 예먜 요금을 계산하는 협력 에 대해서 살펴보자.

위 코드에서 알 수 있듯 Movie 는 제목(title), 상영시간(runningTime), 기본요금(fee), 할인 정책(discountPolicy)을 속성으로 갖는다. 그리고 이 속성들의 값은 생성자를 통해 전달된다.

calculateMovieFee() 메서드는 discountPolicycaculateDiscountAmount() 메시지를 전송하여 할인 요금을 반환받는다.

이 메서드에서는 어디에도 어떤 할인정책인지(요금 할인정책, 비율 할인정책) 판단하는 코드는 존재하지 않는다. 단지 discountPolicy 에게 메시지를 전송 할 뿐이다.

여기에는 객체지향에서 중요하다고 여겨지는 두 가지 개념이 숨겨져 있다. 하나는 상속 이고 하나는 다형성 이다. 그리고 그 기반에는 추상화 라는 원리가 숨어있다. 먼저 코드를 살펴보고 개념들을 차례로 살펴본다.


할인 정책과 할인 조건

도메인 구현 클래스
할인 정책 DiscountPolicy
금액 할인 정책 AmountDiscountPolicy
비율 할인 정책 PercentDiscountPolicy

할인 정책금액 할인 정책비율 할인 정책 으로 구분된다. 두 가지 할인 정책을 각각 AmountDiscountPolicyPercentDiscountPolicy 라는 클래스로 구현한다.

두 클래스는 대부분의 코드가 유사하고 할인 요금을 계산하는 방식 만 조금 다르다. 따라서 두 클래스 사이의 중복 코드를 제거 하기 위해 공통 코드를 보관할 장소가 필요하다.

여기서는 부모 클래스인 DiscountPolicy 안에 중복 코드를 두고 AmountDiscountPolicyPercentDiscountPolicy 가 이 클래스를 상속 받게 할 것이다. 실제 애플리케이션에서는 DiscountPolicy 인스턴스를 생성할 필요가 없기 때문에 추상 클래스 로 구현한다.

위 코드를 살펴보자.

하나의 할인 정책은 여러개의 할인 조건을 포함할 수 있다.

caculateDiscountAmount() 메서드는 전체 할인 조건에 대해 차례대로 DiscountConditionisSatisfiedBy() 메서드를 호출한다. isSatisfiedBy() 메서드는 인자로 전달된 Screening 이 할인 조건을 만족시킬 경우네는 true, 만족시키지 못할 경우에는 false를 반환한다.

할인 조건을 만족하는 DiscountCondition 이 하나라도 존재하는 경우에는 추상 메서드getDiscountAmount() 메서드를 호출해 할인 요금을 계산한다.

만족하는 할인 조건이 존재하지 않는다면 Money.Zero를 반환하여 할인된 요금이 0원임을 반환한다.

DiscountPolicy 는 할인 여부와 요금 계산에 필요한 전체적인 흐름은 정의하지만 실제로 요금을 계산하는 부분은 추상 메서드getDiscountAmount() 메서드에게 위임한다. 실제로는 DiscountPolicy 를 상속받은 자식 클래스에서 오버라이딩 한 메서드가 실행 될 것이다.

이처럼 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라 한다.

도메인 구현 클래스
할인 조건 DiscountCondition
순서 조건 SequenceCondition
기간 조건 PeriodCondition

DiscountCondition 은 자바의 인터페이스를 이용해 선언되어 있다. isSatisfiedBy() 오퍼레이션은 인자로 전달된 screening이 할인 가능한 경우 true를 반환하고 할인이 불가능한 경우에는 false를 반환한다.

영화 할인 조건 에는 순번 조건기간 조건 이 존재한다.

SequenceCondition 은 할인 여부를 판단하기 위해 사용할 순번(sequence)을 인스턴스 변수로 포함한다. isSatisfiedBy() 메서드는 파라미터로 전달된 Screening 의 상영 순번과 일치할 경우 할인 가능한 것으로 판단해서 true를, 그렇지 않은 경우에는 false를 반환한다.

PercentDiscountPolicy 은 상영 시작 시간이 특정한 기간 안에 포함되는지 여부를 판단해 할인 여부를 결정한다. 조건에 사용할 요일(dayOfWeek)과 시작 시간(startTime), 종료 시간(endTime)을 인스턴스 변수로 포함한다. isSatisfiedBy() 메서드는 인자로 전달된 Screening 의 상영 요일이 dayOfWeek과 같고 상영 시작 시간이 startTime과 endTime 사이에 있을 경우에는 true를 반환하고, 그렇지 않은 경우에는 false를 반환한다.

이제 할인 정책을 구현하자 AmountDiscountPolicyDiscountPolicy 의 자식 클래스로서 할인 조건을 만족 할 경우 일정한 금액을 할인해 주는 금액 할인 정책을 구현한다. 이 클래스는 DiscountPolicygetDiscountAmount() 메서드를 오버라이딩 한다. 할인 요금은 인스턴스 변수인 discountAmount 에 저장한다.

PercentDiscountPolicy 역시 DiscountPolicy 의 자식 클래스로서 getDiscountAmount() 메서드를 오버라이딩 한다. AmountDiscountPolicy 와 다른 점이라면 고정 금액이 아닌 일정 비율을 차감하는 방식이라는 것이다. 할인율은 인스턴스 변수인 percent 에 저장한다.

아래 그림은 영화 가격 계산 에 참여하는 모든 클래스 사이의 관계를 다이어그램으로 표현한 것이다.


상속과 다형성

의존성의 개념 을 먼저 살펴보고 상속과 다형성 을 이용해 특정한 조건을 선택적으로 실행하는 방법을 알아보자.

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

어떤 클래스가 다른 클래스에 접근할 수 있는 경로를 가지거나 해당 클래스의 객체에게 메시지를 전송할 경우 (메서드를 호출) 두 클래스 사이에 의존성이 존재 한다고 말한다.

여기서 눈여겨 봐야 할 부분은 Movie 클래스가 DiscountPolicy 클래스와 연결돼 있다는 것이다. 문제는 영화 요금을 계산하기 위해서는 추상 클래스DiscountPolicy 가 아니라 AmountDiscountPolicyPercentDiscountPolicy 인스턴스가 필요하다는 것이다. 따라서 Moive 인스턴스는 실행 시에 AmountDiscountPolicyPercentDiscountPolicy 의 인스턴스에 의존해야 한다. 하지만 코드 수준에서 Movie 클래스는 이 두 클래스 중 어떤 것에도 의존하지 않는다. 오직 추상 클래스인 DiscountPolicy 에만 의존하고 있다.

그렇다면 Movie 의 인스턴스가 코드 작성 시점 에는 그 존재조차 알지 못했던 AmountDiscountPolicyPercentDiscountPolicy 의 인스턴스와 실행 시점에 협력 이 가능한 이유는 무엇일까?

Movie 의 인스턴스를 생성하는 코드를 살펴보면 Movie 의 생성자에서 DiscountPolicy 타입의 객체를 인자로 넘겨준다. 이 떄에 AmountDiscountPolicy 또는 PercentDiscountPolicy 인스턴스를 전달하면 실행 시점에는 DiscountPolicy 가 아닌 AmountDiscountPolicy / PercentDiscountPolicy 에 의존성을 갖고 협력을 하게 된다.

위의 내용을 다시 생각해보면 코드 상에서 MovieDiscountPolicy 에 의존한다. 그러나 실행 시점 에는 Movie 인스턴스는 AmountDiscountPolicy 또는 PercentDiscountPolicy 인스턴스에 의존하게 된다.

여기서 알 수 있는 것은 코드의 의존성실행 시점의 의존성 이 서로 다를 수 있다는 것이다. 다시 말해 클래스 사이의 의존성객체 사이의 의존성 은 동일하지 않을 수 있다.

유연하고 쉽게 재사용 가능하며 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성실행 시점의 의존성 이 다르다는 것이다. 유의해야 할 점은 코드의 의존성실행 시점의 의존성 이 다를 수록 코드를 이해하기가 어렵다 는 점이다. 코드를 이해하기 위해서는 코드 뿐 아니라 객체를 생성하고 연결하는 부분을 찾아야 하기 때문이다.

이와 같은 의존성의 양면성 은 설계가 트레이드 오프의 산물 이라는 점을 잘 보여준다.

설계가 유연해질수록 코드를 이해하고 디버깅 하기는 점점 더 어려워 진다는 사실을 기억하자. 반면 유연성을 억제하면 코드를 이해하고 디버깅 하기는 쉬워지지만 재사용성과 확장 가능성은 낮아진다는 사실도 기억하자. 우리가 더 좋은 객체지향 설계자로 성장하기 위해서는 항상 유연성과 가독성 사이에서 고민 해야한다. 무조건 유연한 설계도, 무조건 읽기 쉬운 코드도 정답이 아니다. 이것이 객체지향 설계가 어려우면서도 매력적인 이유다.


상속과 인터페이스

상속 이 가치가 있는 이유는 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문 이다. 이것은 상속을 단순히 메서드나 인스턴스 변수를 재사용 하기 위한 측면이 아니다.

인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의 한다. 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함 하게 된다. 결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신 할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주 할 수 있다.

위의 MoiveDiscountPolicy 인터페이스에 정의된 caculateDiscountAmount() 메시지를 전송하고 있다. DiscountPolicy 를 상속받는 AmountDiscountPolicyPercentDiscountPolicy 의 인터페이스에도 이 오퍼레이션이 포함돼있다.

Movie 입장에서는 협력 객체가 어떤 클래스의 인스턴스인지가 중요한 것이 아니라 caculateDiscountAmount() 메시지를 수신할 수 있다는 사실이 중요하다. 다시말해 Moive 는 협력 객체가 caculateDiscountAmount() 메시지를 이해할 수 만 있다면 그 객체가 어떤 클래스의 인스턴스인지 상관하지 않는다.

따라서 caculateDiscountAmount() 메시지를 수신 할 수 있는 AmountDiscountPolicyPercentDiscountPolicy 모두 DiscountPolicy 를 대신해서 Movie 와 협력할 수 있다.

정리하면 자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 떄문에 부모 클래스 대신 사용될 수 있다. 컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장소에서 자식 클래스를 사용 하는 것을 허용한다.


다형성

다시 한 번 강조하지만 메시지와 메서드는 다른 개념 이다. MovieDiscountPolicy 의 인스턴스에게 caculateDiscountAmount() 메시지를 전송한다. 그렇다면 실행되는 메서드 는 무엇인가? Moive 와 상호작용하기 위해 연결된 객체의 클래스가 무엇인가 에 따라 달라진다.

Movie 와 협력하는 객체가 AmountDiscountPolicy 의 인스턴스냐 PercentDiscountPolicy 의 인스턴스냐에 따라 각 클래스가 오버라이딩 한 메서드가 실행될 것이다.

코드 상에서 Movie 클래스는 DiscountPolicy 클래스에게 메시지를 전송하지만 실행 시점에 실행되는 메서드는 Movie 와 협력하는 객체의 실제 클래스가 무엇인지에 따라 달라진다.

다시 말해서 Movie 는 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스가 무엇이냐에 따라 달라진다.

이를 다형성 이라 한다.

즉, 동일한 메시지에 대해 다르게 응답할 수 있는 개념 이다.

다형성 은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실 을 기반으로 한다. 프로그램을 작성할 때 Movie 클래스는 추상 클래스인 DiscountPolicy 클래스에 의존한다. 반면 실행 시점에 Movie 의 인스턴스와 실제로 상호작용 하는 객체는 AmountDiscountPolicy 또는 PercentDiscountPolicy 의 인스턴스다. 다시 말해 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행 할 수 있게 한다.

다형성 이란 동일한 메시지를 수신했을 때 객체의 타입에 따라 다르게 응답할 수 있는 능력 을 의미한다. 따라서 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야 한다. 다시말해 인터페이스가 동일 해야한다는 것이다. AmountDiscountPolicyPercentDiscountPolicy 가 다형적인 협력에 참여할 수 있는 이유는 이들이 DiscountPolicy 로부터 동일한 인터페이스를 물려받았기 때문 이다. 그리고 이 두 클래스의 인터페이스를 통일하기 위해 사용한 구현 방법이 바로 상속 인 것이다.

다형성에는 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정 한다는 특징이 있다. 다시 말해 메시지와 메서드를 실행 시점에 바인딩 한다는 것이다. 이를 동적 바인딩 이라고 부른다. 객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고 하나의 메시지를 선택적으로 서로 다른 메서드에 연결 할 수 있는 이유가 바로 동적 바인딩이라는 메커니즘을 사용하기 때문이다.

상속을 ‘구현 상속’과 ‘인터페이스 상속’으로 분류할 수 있다. 코드를 재사용하기 위한 목적으로 상속을 사용하는 것을 ‘구현 상속’이라 하고 다형적인 협력을 위해 부모 클래스와 자식 클래스가 ‘인터페이스를 공유’할 수 있도록 상속을 이용하는 것을 ‘인터페이스 상속’이라 한다.

상속은 구현 상속 이 아니라 인터페이스 상속 을 위해 사용해야한다. 대부분의 사람들은 코드 재사용이 상속의 주된 목적이라고 생각하지만 이것은 오해다. 인터페이스를 재사용 할 목적이 아니라 구현을 재사용 할 목적으로 상속을 사용하게 되면 변경에 취약한 코드 를 낳게 될 확률이 높다.


인터페이스와 다형성

DiscountPolicy 의 경우 추상 클래스 로 구현함으로써 내부 구현인터페이스 를 함께 상속하게끔 만들었다. 그러나 종종 구현은 공유할 필요가 없고 순수하게 인터페이스 만 공유해야 할 때가 많다. 이를 위해 자바의 인터페이스 라는 프로그래밍 요소를 사용한다.

추상 클래스를 이용해 다형성을 구현했던 할인 정책과 달리 할인 조건 은 구현을 공유할 필요가 없기 때문에 위 사진과 같이 자바의 인터페이스를 이용해 타입 계층을 구현했다. DiscountCondition 인터페이스를 실체화 하고 있는 SequenceConditionPeriodCondition동일한 인터페이스를 공유 하며 다형적인 협력에 참여할 수 있다.

SequenceConditionPeriodConditionisSatisfiedBy() 메시지를 이해할 수 있기 때문에 클라이언트인 DiscountPolicy 입장에서 이 둘은 DiscountCondition 과 아무런 차이가 없다. DiscountCondition 을 실체화 하는 클래스들은 동일한 인터페이스를 공유 하며 DiscountCondition 을 대신해서 사용될 수 있다.


추상화와 유연성

잠깐 추상화의 정의부터 살펴보고 가자.

어떤 양상, 세부사항, 구조를 좀 더 명확하게 이해하기 위해 특정 절차나 물체를 의도적으로 생략하거나 감춤으로써 복잡도를 극복

복잡성을 다루기 위해 추상화는 두 가지 차원 에서 이루어진다.

  1. 구체적인 사물(개체)들 간의 공통점은 취하고 차이점을 버리는 일반화를 통해 단순하게 만드는 것
  1. 중요한 부분을 강조하기 위해 불필요한 세부사항을 제거함으로써 단순화.

즉, 모든 경우의 추상화의 목적복잡성을 이해하기 쉬운 수준으로 단순화 시키는 것임을 기억하자.

  • 할인 정책의 도메인 개념의 추상화 정도
도메인 구현 클래스 비고
할인 정책 DiscountPolicy 추상적
금액 할인 정책 AmountDiscountPolicy 구체적
비율 할인 정책 PercentDiscountPolicy 구체적



  • 할인 조건의 도메인 개념의 추상화 정도
도메인 구현 클래스 비고
할인 조건 DiscountCondition 추상적
순서 조건 SequenceCondition 구체적
기간 조건 PeriodCondition 구체적

지금까지 살펴본 것 처럼 할인 정책할인 금액 정책비율 할인 정책 을 포괄하는 추상적인 개념 이다. 할인 조건 역시 더 구체적인 순번 조건기간 조건 을 포괄하는 추상적인 개념 이다.

다시말해 DiscountPolicyAmountDiscountPolicyPercentDiscountPolicy 보다 추상적이고 DiscountConditionSequenceConditionPeriodCondition 보다 추상적이다.

프로그래밍 언어 측면에서 DiscountPolicyDiscountCondition 이 더 추상적인 이유는 인터페이스에 초점을 맞추기 때문 이다. DiscountPolicy 는 모든 할인 정책들이 수신할 수 있는 caculateDiscountAmount() 메시지를 정의한다. 둘 다 같은 계층에 속하는 클래스들이 공통으로 가질 수 있는 인터페이스를 정의 하며 구현의 일부(추상 클래스의 경우) 또는 전체(자바 인터페이스의 경우)를 자식 클래스가 결정할 수 있도록 결정권을 위임 한다.

아래 사진은 자식 클래스를 생략한 코드 구조 를 그림으로 표현한 것이다. 이 그림은 추상화를 사용 할 경우의 두 가지 장점을 보여준다.

  1. 추상화의 계층만 따로 뗴어놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술 할 수 있다는 것이다. (구체적인 금액 할인 정책, 순번 기간 조건이 아닌 보다 추상적이고 높은 수준인 할인 정책, 할인 조건에 대해서 서술 가능)

  2. 추상화를 이용하면 설계가 좀 더 유연해진다.

첫 번째 장점을 하나의 문장으로 정리하면 영화 예매 요금은 최대 하나의 할인 정책과 다수의 할인 조건을 이용해 계산 가능하다. 로 표현할 수 있다. 이 문장이 영화 예매 요금은 ‘금액 할인 정책’과 ‘두개의 순서 조건, 한개의 기간 조건’을 이용해서 계산할 수 있다. 라는 문장을 포괄 할 수 있다는 사실이 중요하다. 이것은 할인 정책할인 조건 이라는 좀 더 추상적인 개념들을 사용해서 문장을 작성했기 때문이다.

추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현 할 수 있다. 추상화의 이런 특징은 세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명 할 수 있게한다.

추상화를 이용해 상위 정책을 기술한다는 것은 기본적인 애플리케이션의 협력 흐름을 기술 한다는 것을 의미한다. 할인 정책이나 할인 조건의 새로운 자식클래스들은 추상화를 이용해서 정의한 상위의 협력 흐름을 그대로 따르게 된다. 이 개념은 매우 중요한데, 재사용 가능한 설계의 기본을 이루는 디자인 패턴 이나 프레임워크 모두 추상화를 이용하여 상위 정책을 정의하는 객체지향 메커니즘을 활용하고 있기 때문이다.


유연한 설계

만일 어떤 영화의 할인 정책 이 적용되지 않은 경우는 어떻게 할까? 즉, 할인 요금을 계산 할 필요 없이 영화에 설정된 기본 금액을 그대로 사용하면 된다.

위 코드는 정상적으로 작동한다. 할인 정책 이 적용되지 않았으니 인스턴스 변수 discountPolicynull 일 것이고 이 경우 단순히 현재 영화의 요금인 fee 를 return 해준다.

하지만 협력의 측면에서 위 방식의 문제점을 살펴보자.

이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스 로 취급하기 때문에 지금까지 일관성 있던 협력 방식이 무너지게 된다는 것이다. 기존 할인 정책의 경우에는 할인할 금액을 계산하는 책임이 DiscountPolicy 의 자식 클래스에 있었지만 할인 정책이 없는 경우에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy 가 아닌 Movie 쪽에 있기 때문이다.

책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분의 경우 좋지 않은 선택이다.

항상 예외 케이스를 최소화 하고 일관성을 유지할 수 있는 방법을 선택하라.

이 경우에 일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다. NoneDiscountPolicy 클래스를 추가하자.

중요한 것은 MovieDiscountPolicy 는 수정하지 않고 NoneDiscountPolicy 라는 새로운 클래스를 추가하는 것 만으로 애플리케이션의 기능을 확장시켰다는 것이다.

이처럼 추상화를 중심으로 코드의 구조를 설계하면 유연하고 확장 가능한 설계 를 만들 수 있다.

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지 하기 때문이다. Movie 는 특정한 할인 정책에 묶이지 않는다. 할인 정책을 구현한 클래스가 DiscountPolicy 를 상속받고 있다면 어떤 클래스와도 협력이 가능하다.

DiscountPolicy 역시 특정한 할인 조건에 묶이지 않는다. DiscountCondition 을 상속받은 어떤 클래스와도 협력이 가능하다. 이것은 DiscountPolicyDiscountCondition 이 추상적이기 때문에 가능한 것이다.

결론은 간단하다.

유연성이 필요한 곳에 추상화를 사용하라.


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

앞의 NoneDiscountPolicy 클래스의 코드와 DiscountPolicy 코드를 자세히 살펴보면 NoneDiscountPolicy 클래스의 getDiscountAmount() 메서드가 어떤 값을 반환하더라도 상관이 없다는 사실을 알 수 있다. (호출이 되지 않기 때문)

부모 클래스인 DiscountPolicy 에서 할인 조건이 없을 경우에는 getDiscountAmount() 메서드를 호출하지 않기 때문이다. 이것은 부모 클래스인 DiscountPolicyNoneDiscountPolicy 를 개념적으로 결합시킨다.

이 문제를 해결하는 방법은 DiscountPolicy인터페이스 로 바꾸고 NoneDiscountPolicygetDiscountAmount() 메서드가 아닌 caculateDiscountAmount() 오퍼레이션을 오버라이딩 하도록 변경하는 것이다.

DiscountPolicy 클래스를 인터페이스 로 변경한다.

원래의 DiscountPolicy 클래스의 이름을 DefaultDscountPolicy 로 변경하고 인터페이스를 구현하도록 수정한다.

이제 NoneDiscountPolicyDiscountPolicy 인터페이스를 구현하도록 변경하면 개념적인 혼란과 결합을 제거할 수 있다.

어떤 설계가 더 좋을까? 이상적으로는 인터페이스를 사용하도록 변경한 설계가 더 좋을 것이다. 현실적으로는 NoneDiscountPolicy 만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수 도 있다.

여기서 이야기 하고 싶은 사실은 구현과 관련된 모든 것들이 트레이드오프의 대상이 될 수 있다 는사실이다. 우리가 작성하는 모든 코드에는 합당한 이유가 있어야 한다. 비록 아주 사소한 결정이더라도 트레이드오프를 통해 얻어진 결론과 그렇지 않은 결론 사이의 차이는 크다.

고민하고 트레이드오프 하라


코드 재사용

상속 은 코드를 재사용하기 위해 널리 사용되는 방법이다. 그러나 널리 사용되는 방법이라고 해서 좋은 방법인 것은 아니다.

코드 재사용을 위해서는 상속(is-a) 보다 합성(has-a)이 더 좋은 방법이다.

여기서 말하는 합성 이란 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용 하는 방법을 말한다.

MovieDiscountPolicy 의 코드를 재사용 하는 방법이 바로 합성 이다. 이 설계를 상속을 사용하도록 변경할 수도 있다.

위와 같이 Movie 를 직접 상속받아 AmmountDiscountMoviePercentDiscountMovie 라는 두 개의 클래스를 추가하면 합성을 사용한 기존 방법과 기능적인 관점에서 완벽히 동일하다.

그럼에도 많은 사람들이 상속 대신 합성을 선호하는 이유는 무엇일까?


상속

상속 은 객체지향에서 코드를 재사용하기 위해 널리 사용되는 기법이다. 하지만 두 가지 관점에서 설계에 안좋은 영향 을 미친다.

  1. 상속 은 캡슐화를 위반한다.

  2. 상속 은 설계를 유연하지 못하게 만든다.

상속의 가장 큰 문제점은 캡슐화 를 위반한다는 것이다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조 를 잘 알고있어야 한다. AmmountDiscountMoviePercentDiscountMovie 를 구현하는 개발자는 부모 클래스인 MoviecalculateMovieFee() 메서드 안에서 추상 메서드인 getDiscountAmount() 메서드를 호출한다는 사실을 알고있어야 한다.

결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화 된다. 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합 되도록 만들기 때문에 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. 결과적으로 상속을 과도하게 사용한 코드는 변경하기도 어려워진다.

상속의 두 번째 단점은 설계가 유연하지 않다 는 점이다. 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정 한다. 따라서 실행 시점에 객체의 종류를 변경하는 것이 불가능 하다.

예를 들어, 실행 시점에 금액 할인 정책 영화비율 할인 정책 영화 로 변경한다고 가정하자. 상속을 사용한 설계에서는 AmmountDiscountMovie 인스턴스를 PercentDiscountMovie 인스턴스로 변경해야 한다.

최선의 방법은 PercentDiscountMovie 인스턴스를 생성한 후 AmmountDiscountMovie 의 상태를 복사하는 것 뿐이다.

이것은 부모 클래스와 자식 클래스가 강하게 결합돼 있기 때문에 발생하는 문제 이다.

만약 인스턴스 변수로 연결 한 존 방법을 사용하면 실행 시점에 할인 정책을 간단하게 변경 할 수 있다. 다음과 같이 MovieDiscountPolicy 를 변경할 수 있는 changeDiscountPolicy() 메서드를 추가하자.

금액 할인 정책이 적용된 영화에 비율 할인 정책이 적용되도록 변경하는 것은 새로운 DiscountPolicy 인스턴스를 연결하는 간단한 작업으로 바뀐다.

위 예제를 통해 상속 보다 인스턴스 변수 로 관계를 연결한 서례가 더 유연하다는 사실을 알 수 있다.

MovieDiscountPolicy 를 포함하는 이 방법 역시 코드를 재사용 하는 방법 임을 눈여겨 보기 바란다.

왜 합성이 코드 재사용의 측면일까? 자세한 사항은 더 공부해봐야겠지만 참조되는 객체(합성 객체)의 인터페이스에 의존하여 접근하기 때문에 인터페이스를 활용한 재사용이 아닐까 싶다.


합성

Movie 는 요금을 계산하기 위해 참조 하는 객체(합성 객체)인 DiscountPolicy 의 코드를 재사용 한다. 이 방법이 상속 과 다른 점이 있다. 상속이 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합 하는데 비해 MovieDiscountPolicy인터페이스를 통해 약하게 결합 된다는 것이다.

상속은 부모 클래스의 코드와 자식 클래스의 코드를 컴파일 시점에 하나의 단위로 강하게 결합한다.

합성은 인터페이스를 통해 약하게 결합된다.

실제로 MovieDiscountPolicy 가 외부에 caculateDiscountAmount() 메서드를 제공한다는 사실만 알고 내부 구현에 대해서는 알지 못한다. 이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용 하는 방법합성 이라고 한다.

합성상속 이 가지는 두 가지 문제점을 모두 해결한다.

인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화 할 수 있다. 또한 의존하는 인스턴스를 비교적 쉽게 교체 하기 때문에 설계를 유연 하게 만든다.

상속은 클래스를 통해 강하게 결합되는 데 비해 합성은 메시지를 통해 느슨하게 결합된다.

따라서 코드 재사용 을 위해서는 상속보다 합성을 선호 하는 것이 더 좋은 방법이다.

그렇다고 해서 상속을 절대 사용하지 말라는 것은 아니다. 대부분의 설계에서는 상속과 합성을 함께 사용해야 한다. 이전 구현 코드를 살펴보면 MoiveDiscountPolicy합성 관계 로 연결되어 있고 DiscountPolicyAmmountDiscountPolicy / PercentDiscountPolicy상속 관계 로 연결되어 있다.

코드 재사용을 위해서는 합성이 옳지만 다형성을 위해 인터페이스를 재사용 하는 경우에는 상속과 합성을 함꼐 조합해서 사용할 수 밖에 없다.


지금까지 구체적인 예제를 통해 객체지향 프로그래밍과 관련된 다양한 개념을 살펴보았다.

객체지향 이란 객체를 지향하는 것이다. 따라서 객체지향 패러다임의 중심에는 객체가 위치한다. 그러나 객체를 따로 떼어놓고 이야기하는 것은 무의미하다. 객체지향에서 가장 중요한 것은 애플리케이션의 기능을 구현하기 위해 협력에 참여하는 객체들 사이의 상호작용 이다. 객체들은 협력에 참여하기 위해 역할을 부여받고 역할에 적합한 책임을 수행한다.

참고 및 출처

  • 오브젝트