[오브젝트] 챕터 02: 객체지향 프로그래밍

✏️, 💡,❓ 해당 이모지는 저의 생각임을 나타냅니다.

 

기술 글을 쓸 때 가장 어려운 부분은 적당한 수준의 난이도를 유지하면서도 이해하기 쉬운 예제를 선택하는 것이다. 이번 장의 목표는 책을 읽으면서 이해하게 될 다양한 주제들을 얕은 수준으로나마 살펴보는 것이다. 이번 장을 읽을 때 가장 중요한 준비물은 가벼운 마음가짐이다.

 

01 영화 예매 시스템

이번 장에서 소개할 예제는 온라인 영화 예매 시스템이다. '영화'와 '상영'이라는 용어를 구분할 필요가 있는데, '상영'은 실제로 관객들이 영화를 관람하는 사건으로 사용자가 실제로 예매하는 대상은 '상영'이다. (상영 일자, 시간, 순번등이 포함된)

또 '할인 조건'은 상영 시작 시간 또는 상영 순번을 이용해 할인 여부를 결정하는 규칙이고, '할인 정책'은 할인 요금을 결정하는 금액 할인 정책과 비율 할인 정책이 있다.

 

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

협력, 객체, 클래스

객체지향은 말 그대로 객체를 지향하는 것이 아니다. 클래스가 아닌 객체에 초점을 맞출 때에만 얻을 수 있다.

💡객체지향과 사실과 오해 책에서도 똑같은 말을 강조한다. 객사고를 복습하는 느낌이 많이 든다.

첫째, 어떤 클래스가 필요한지 고민하기 전에 어떤 객체들이 필요한지 고민하라.

둘째, 객체를 독립적인 존재가 아니라 협력하는 공동체의 일원으로 봐라. = 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다. (협력자로 객체를 인식)

 

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

이 시점에서 도메인(domain)이라는 용어를 살펴보자. 영화 예매 시스템의 목적은 영화를 더 쉽고 빠르게 예매하려는 사용자의 문제를 해결하는 것이다. 이처럼 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라고 부른다.

아래는 영화 예매 도메인을 구성하는 개념과 관계를 표현한 것이다.

도메인의 개념과 관계를 반영하도록 프로그램을 조화해야 하기 때문에 클래스 구조는 도메인 구조와 유사한 형태를 띠어야 한다.

 

클래스 구현하기

클래스를 구현하거나 사용할 때 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다. 클래스는 내부와 외부로 구분되며 훌륭한 클래스를 설계하기 위한 핵심은 어떤 부분을 공개하고 어떤 부분을 감출지를 정하는 것이다.

클래스의 내부와 외부를 구분해야 하는 이유는, 경계의 명확성이 객체의 자율성을 보장하기 때문이다.

 

자율적인 객체

더보기

객체는 상태(state)와 행동(behavior)을 함께 가지는 복합적인 존재이다. 그리고 스스로 판단하고 행동하는 자율적인 존재이다.

객체지향은 객체라는 단위 안에 데이터와 기능을 묶음으로써 문제 영역의 아이디어를 표현할 수 있게 했다. 이처럼 데이터와 기능을 객체 내부로 묶는 것을 캡슐화라고 한다. 여기서 한 걸음 더 나아가 외부에서 접근을 통제할 수 있는 접근 제어(access control) 메커니즘도 제공한다. 이를 위해 많은 프로그래밍 언어들은 public, protected, private같은 접근 수정자(access modifier)를 제공한다.

 

객체 내부에 대한 접근을 통제하는 이유 -> 객체를 자율적인 존재로 만들기 위해서 -> 자율적인 존재가 되기 위해선 외부의 간섭을 최소화 -> 객체가 스스로 최선의 방법을 결정할 수 있을 것이라 믿어야 함

 

캡슐화와 접근 제어는 객체를 두 부분으로 나누는데, 외부에서 접근 가능한 부분을 퍼블릭 인터페이스라고 하고 내부에서만 접근 가능한 부분을 구현이라고 한다. 인터페이스와 구현의 분리 원칙은 훌륭한 객체지향 프로그램을 만들기 위한 핵심 원칙이다.

 

일반적으로 객체는 상태는 숨기고 행동만 외부에 공개해야 한다. 속성은 private으로 선언하고 일부 메서드만 public으로 선언해야 한다.

 

프로그래머의 자유

더보기

프로그래머의 역할을 클래스 작성자와 클라이언트 프로그래머로 구분해보자. 

클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 새로운 데이터 타입을 사용한다.

클래스 작성자는 클라이언트 프로그래머에게 필요한 부분만 공개하고 나머지는 꽁꽁 숨긴다. 클라이언트 프로그래머가 숨겨 놓은 부분에 접근을 못하도록 방지함으로써, 클라이언트 프로그래머에 대한 영향을 걱정하지 않고 내부 구현을 맘대로 변경할 수 있다. (인터페이스를 바꾸지 않는 한 외부 영향을 걱정하지 않는다. = public 영역을 바꾸지 않는다면 코드를 자유롭게 수정할 수 있다.)-> 구현 은닉(implementation hiding)

객체 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 잇는 폭이 넓어진다. 

설계가 필요한 이유는 변경을 관리하기 위해서라는 것을 기억하라. 그리고 객체의 변경을 관리할 수 있는 기법 중 가장 대표적인 것이 접근 제어이다.

 

협력에 관한 짧은 이야기

객체는 다른 객체의 인터페이스에 공개된 행동을 수행하도록 요청(request)할 수 있다. 요청 받은 객체는 자율적인 방법에 따라 요청을 처리한 후 응답(response) 한다.

객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것이고, 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고한다.

메시지와 메서드를 구분하는 것은 중요하다. 메시지와 메서드의 구분에서부터 다형성(polymorphism) 개념이 출발한다.

`movie.calculateMovieFee()`라는 코드가 있을 때 '메서드를 호출한다'보다 '메시지를 전송한다'라고 말하는 것이 더 적절하다. 사실 cacluateMovieFee 메서드가 존재하는지 알지도 못한다. 단지 cacluateMovieFee 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다. (실제로 루비나 스몰토크같은 동적 타입 언어에서는 calculateMovieFee가 아닌 다른 시그니처를 가진 메서드를 통해서도 해당 메시지에 응답할 수 있다.)

 

03 할인 요금 구하기

할인 요금 계산을 위한 협력 시작하기

Movie는 제목, 상영시간, 기본 요금, 할인 정책을 속성으로 가진다. (코드 확인)

public Money calculateMovieFee(Screening screening) {
	return fee.minus(discountPolicy.calculateDiscountAmount(screening));
}

위 메서드는 할인 요금을 반환받는 메서드다. 하지만 어떤 할인 정책을 사용할 것인지 결정하는 코드가 존재하지 않는다. 도메인 설명 시 언급했던 영화 예매 시스템에는 두가지 할인 정책이 존재한다. 하지만 코드 어디에도 할인 정책을 판단하는 코드는 존재하지 않는다. 

 

할인 정책과 할인 조건

할인 정책은 AmountDiscountPolicy와 PercentDiscountPolicy라는 클래스로 구현할 것이다. 두 클래스는 대부분 코드가 유사하고 할인 요금 계산 방식만 조금 다르다. 따라서 중복 제거를 위해 공통 코드를 보관할 장소가 필요하다.

부모 클래스로 DiscountPolicy 안에 중복 코드를 두고 실제로는 추상 클래스(abstract class)로 구현했다.

public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>(); // 하나의 할인 정책은 여러 개의 할인 조건을 포함할 수 있다.

    public DiscountPolicy(DiscountCondition... conditions) {
        this.conditions = Arrays.asList(conditions);
    }

    public Money calculateDiscountAmount(Screening screening) {
        for(DiscountCondition each: conditions) {
            if(each.isSatisfiedBy(screening)) { // 할인 조건 만족 여부
                return getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    abstract protected Money getDiscountAmount(Screening screening);
}

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

DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름을 정의하지만 실제 요금 계산 부분은 getDiscountAmount()에 위임한다. 이처럼 부모 클래스에 기본적인 알고리즘을 구현하고 중간에 필요한 처리를 자식 클래스에게 위임하는 디자인 패턴을 TEMPLATE METHOD 패턴이라고 부른다. (* Movie는 discountPolicy를 가지고 있다.)

 

04 상속과 다형성

Movie 클래스 어디에도 할인 정책이 어떤 할인 정책인지 판단하지 않는다. 그런데 어떻게 영화 요금을 계산할 때 할인 정책을 결정할 수 있을까?

 

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

 

Movie 클래스는 DiscountPolicy 클래스와 연결되어 있다. 

구현체와 연결되어있지 않아도 되었던 이유는, Movie의 인스턴스를 생성할 때 인자로 AmountDiscountPolicy 인스턴스를 전달하기 때문이다. (물론 PercentDiscountPolicy를 전달해도 된다.)

Movie avatar = new Movie("아바타", 
	Duration.ofMinutes(120), 
	Money.wons(1000), 
	new AmountDiscountPolicy(Money.wons(800)...));

 

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

한 가지 간과해서는 안 되는 사실은 코드의 의존성과 실행 시점의 의존성이 다를수록 코드를 이해하기 어려워진다는 것이다. 

Movie 인스턴스가 어떤 객체에 의존하고 있는지 알려면 Movie 클래스 코드만 살펴보는 것으로는 해답을 얻을 수 없다. 위 경우 인스턴스 생성하는 부분을 찾아 확인해봐야 한다.

설계가 유연해질 수록 코드를 디버깅하기 어려워진다는 사실을 기억하라. 반면 유연성을 억제하면 코드를 이해하고 디버깅하긴 쉬워지지만 재사용성과 확장 가능성은 낮아진다는 사실도 기억하라. 훌륭한 객체지향 설계자가 되기 위해선 항상 유연성과 가독성 사이에서 고민해야 한다.

 

차이에 의한 프로그래밍

클래스를 추가하고 싶은데 기존 클래스와 매우 흡사하다고 가정해보자. 그때 사용할 수 있는 방법이 상속이다. 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍(programming by difference)라고 부른다.

 

상속과 인터페이스

상속이 가치 있는 이유는 부모 클래스가 제공하는 인터페이스를 자식 클래스가 물려받을 수 있기 때문이다. 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.

실제로 Movie 입장에서는 자신과 협력하는 객체가 어떤 클래스의 인스턴스인지가 중요한 것이 아니라 자신이 필요한 메시지를 수신할 수 있다는 사실이 중요하다.

 

자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅(upcasting)이라고 부른다.  (아래에 위치한 자식 클래스가 위에 위치한 부모 클래스로 자동 타입 캐스팅되는 것처럼 보이기 때문)

 

다형성

메시지와 메서드는 다른 개념이다. Movie는  DiscountPolicy 인스턴스에게 calculateDiscountAmount 메시지를 전송한다. 하지만 실행되는 메서드는 Movie와 상호작용하기 위해 연결된 객체 클래스가 무엇인가에 따라 달라진다.

이처럼 동일한 메시지를 전송하지만 실제로 어떤 메서드가 실행될 것인지는 메시지를 수신하는 객체의 클래스에 따라 달라지는 것을 다형성이라고 부른다.

다형성 구현 방법은 다양하지만 메시지 응답을 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다. 다시 말해 메시지와 메서드를 실행 시점에 바인딩 한다는 것이다. 이를 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)이라고 부른다. 반대로 컴파일 시점에 함수나 프로시저를 결정하는 것을 초기 바인딩(early binding) 또는 정적 바인딩(static binding)이라고 한다.

상속은 구현 상속과 인터페이스 상속으로 분류할 수 있다. 구현 상속을 서브클래싱(subclassing)이라고 부르고 인터페이스 상속을 서브타이핑(subtyping)이라고 부른다.
상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다. 대부분의 사람들은 코드 재사용을 상속의 목적이라고 생각하지만 이것은 오해다. 인터페이스를 재사용할 목적이 아니라 구현을 재사용할 목적으로 상속하면 변경에 취약한 코드를 낳게 될 확률이 높다.
✏️ 상속을 할 때 '메시지'를 재사용할 목적으로 사용해야한다는 말인 것 같다.

 

 

05 추상화와 유연성

유연한 설계

영화에 할인 정책이 없는 경우도 있다. 그 때는 영화에 설정된 기본 금액을 그대로 사용하면 된다.

public class Movie {
   public Money calculateMovieFee(Screening screening) {
        if(discountPolicy != null) {
            return fee;
        }
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

하지만 이 방식의 문제점은 할인 정책이 없는 경우를 예외 케이스로 취급하기 때문에 지금까지 일관성있던 협력 방식이 무너지게 된다는 것이다.

기존엔 할인할 금액을 계산하는 책임이 DiscountPolicy의 자식 클래스에 있었지만 할인 정책이 없는 경우엔 Movie 쪽에 있기 때문이다. 따라서 책임의 위치를 결정하기 위해 조건문을 사용하는 것은 협력의 설계 측면에서 대부분 좋지 않은 선택이다.

💡 평소 개발을 할 때 '일관성있던 협력 방식', '책임의 위치'를 크게 고려하지 않았던 것 같다. 내가 했을법한 실수라는 생각이 들었다.

이 경우 일관성을 지킬 수 있는 방법은 NoneDiscountPolicy 클래스를 추가하는 것이다. (코드 확인)

public class NoneDiscountPolicy extends DiscountPolicy {
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

그리고 할인 정책이 없는 영화의 경우 NoneDiscountPolicy 인스턴스를 연결한다. 

중요한 것은 기존 Movie와 DiscountPolicy를 수정하지 않고 애플리케이션 기능을 확장했다는 것이다.

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는 것을 방지하기 때문이다. Movie는 특정 할인 정책에 묶이지 않고 DiscountPolicy도 특정한 할인 조건에 묶여있지 않다. 컨텍스트 독립성(context independency)라고 불리는 개념은 유연한 설계가 필수적인 분야에서 그 진가를 발휘한다.

추상화를 이용하면 기존 코드를 수정하지 않고도 기능을 확장할 수 있다. 유연성이 필요한 곳에 추상화를 사용하라!

 

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

하지만 현재 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 때 getDiscountAmount()를 호출하지 않는다. 

이 문제를 해결하는 방법은 NoneDiscountPolicy가 DiscountPolicy의 getDiscountAmount()가 아닌 calcuateDiscountAmount()를 오버라이딩 하도록 하는 것이다.

DiscountPolicy를 인터페이스로 변경하고, 기존 DiscountPolicy 클래스 이름을 DefaultDiscountPolicy로 변경한다. 이제 NoneDiscountPolicy가 DiscountPolicy 인터페이스를 구현하도록 변경하면 개념적 혼란을 제거할 수 있다. (코드 확인)

✏️ 쉽게 말해 NoneDiscountPolicy는 '할인 조건'이 없을 경우 0을 응답한다. 그렇기에 getDiscountAmount()가 아닌 calcuateDiscountAmount()을 오버라이딩 하는 것이 자연스럽다. 현재 DiscountPolicy에 두 개의 메시지가 함께 있는데 이를 인터페이스로 분리해 NoneDiscountPolicy가 cacluateDiscountAmount를 구현하도록 변경한다면 개념적 혼란이 제거된다.

 

현실적으로 NoneDiscountPolicy만을 위해 인터페이스를 추가하는 것이 과하다는 생각이 들 수 있다.

여기서 이야기하고 싶은 사실은 구현과 관련된 모든 것들이 트레이드 오프의 대상이 될 수 있다는 사실이다. 여러분이 작성하는 모든 코드에는 합당한 이유가 있어야 한다. 고민하고 트레이드오프하라.

 

코드 재사용

상속은 코드 재사용을 위해 널리 사용되는 방법이다. 하지만 가장 좋은 방법인 것은 아니다. 상속보다는 합성(composition)이 더 좋은 방법이라는 이야기를 많이 들었을 것이다. 합성은 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법이다.

Movie가 DiscountPolicy의 코드를 재사용하는 방법이 바로 합성이다.

 

상속

상속은 두 가지 관점에서 설계에 안 좋은 영향을 미친다.

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

상속을 이용하기 위해선 부모 클래스의 내부 구조를 잘 알고 있어야 한다. 부모 클래스 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다. 부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다. -> 상속을 과도하게 사용하면 코드는 변경하기 어려워진다.

2) 설계를 유연하지 못하게 만든다.

상속은 부모 클래스와 자식 클래스 관계를 컴파일 시점에 결정하기 때문에 실행 시점에 객체의 종류를 변경하는 것이 불가능하다. 예를 들어 실행 시점에 할인 정책인 영화를 비율 할인 정책으로 변경한다고 가정하자. 인스턴스 변수로 연결한 기존 변경을 사용하면 실행 시점에 할인 정책을 간단히 변경할 수 있다.

public void changeDiscountPolicy(DefaultDiscountPolicy discountPolicy) {
    this.discountPolicy = discountPolicy;
}

이 예제를 통해 상속보다 인스턴스 변수로 관계를 연결한 설계가 더 유연하다는 사실을 알 수 있을 것이다. Movie가 DiscountPolicy를 포함해 코드를 재사용하는 방법은 합성이라고 불린다.

 

합성

합성이 상속과 다른점은 상속이 컴파일 시점에 부모와 자식을 강하게 결합하는 데 비해 합성은 Movie가 DiscountPolicy 인터페이스를 통해 약하게 결합된다는 것이다. 실제로 Movie는 DiscountPolicy가 외부에 cacluateDiscountAmount 메서드를 제공한다는 사실만 알고 내부 구현은 전혀 모른다. 이처럼 인터페이스에 정의된 메시지를 통해서만 코드를 재사용하는 방법을 합성이라고 부른다.

대부분 설계에선 상속과 합성을 함께 사용한다. 우리가 설계한 구조도 Movie와 DiscountPolicy는 합성 관계지만 DiscountPolicy와 AmountDiscountPoilicy등은 상속 관계이다. 코드를 재사용하는 경우는 상속보다 합성을 선호하는 게 옳지만, 다형성을 위해 인터페이스를 재사용하는 경우에는 상속과 합성을 함께 조합해서 사용할 수밖에 없다.

 

마무리하며 - 챕터 2 마무리

헉... 너무 재밌다. 특히 '05 추상화와 유연성'부분에서 할인 정책이 없는 경우 조건문을 구현하는 방식을 먼저 알려주고 그 뒤 추상화를 이용하는 방법을 알려주는 부분에서 많이 배웠다.

1) 메시지와 메서드는 다르다

2) 결합되지 않아야 유연한 설계가 가능하다.

3) 구현과 관련된 모든 것들은 트레이드 오프의 대상이 된다.

세 가지 메세지를 중요하게 강조한 것 같다. 나는 3번이 특히 많이 다가왔다. 앞으로 계속해 개발을 하면서 트레이드 오프를 잘 할 수 있는 개발자가 되고 싶다.

 

오브젝트는 코드를 따라가며 함께 고치는 방식이 무척 도움이 되고 좋은 것 같다. 개념과 코드가 적절하게 섞여서 이해하기도 편하다. 

 

전체 코드 확인👇

 

Prography-10th-object-study/src/jongeuni/chapther02 at jongeuni · prography/Prography-10th-object-study

10기 <오브젝트>를 학습하는 스터디입니다. Contribute to prography/Prography-10th-object-study development by creating an account on GitHub.

github.com