BackEnd/OOP

[오브젝트] 객체지향 프로그래밍 (2장)

샤아이인 2022. 2. 2.

조영호님의 오브젝트 라는 책을 읽으며, 공부 내용을 정리하는 용도로 간략하게 정리해 봅니다.

 

오브젝트: 코드로 이해하는 객체지향 설계

역할, 책임, 협력을 향해 객체지향적으로 프로그래밍하라! 객체지향으로 향하는 첫걸음은 클래스가 아니라 객체를 바라보는 것에서부터 시작한다. 객체지향으로 향하는 두 번째 걸음은 객체를

wikibook.co.kr

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

진정한 객체지향 페러다임으로의 전환은 Class 가 아닌, Object에 초점을 맞출 때 에만 얻을 수 있다.

 

 

1. 어떤 클래스가 필요한지가 아니라, 어떤 객체가 필요한지 고민해야 한다.

클래스는 공통적인 객체들의 상태와 행동을 추상화 한 것 이다. 따라서 Class를 추상화 시키려면 어떤 객체가 필요한지 알아야 한다.

 

2. 객체는 독립적인 존재가 아니다, 기능 구현을 위해 협력하는 공동체의 일원으로 봐야한다.

객체를 고립된 존재로 바라보지 말고, 협력에 참여하는 협력자로 바라봐야 한다.

다른 객체에게 도움을 주거나, 의존하면서 살아가는 협력적인 존재이다.

객체들의 모양과 윤곽이 잡히면 공통된 특성과 상태를 가진 객체들을 타입으로 분류하고 이 타입을 기반으로 Class를 구현해라.

 

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

도메인(domain)이란 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야를 도메인이라 부른다.

 

객체지향 패러다임이 강력한 이유는 요구사항을 분석하는 초기 단계부터, 프로그램을 구현하는 마지막 단계까지 객체라는 동일한 추상화 기법을 사용할수 있기 때문이다.

요구사항과 프로그램을 객체라는 동일한 관점에서 보기 때문에 도메인을 구성하는 개념들이 프로그램의 객체와 클래스로 매끄럽게 연결된다.

 

다음 도메인을 살펴보자.

출처 - 오브젝트 41p

일반적으로 클래스 기반의 객체지향 언어에서는 도메인 개념을 구현하기 위해 Class를 사용한다.

Class의 이름은 대응되는 도메인 개념의 이름과 동일하거나 적어도 유사하게 지어야 한다.

Class 사이의 관계도 최대한 도메인 개념 사이에 맺어진 관계와 유사하게 만들어야 프로그램의 구조를 이해하고, 예상하기 쉬워진다.

 

따라서 Class 다이어그램을 도메인 모델과 유사한 형태를 보이게 된다. 다음과 같이 말이다.

 

출처 - 오브젝트 42p

 

자율적인 객체

객체에게는 중요한 2가지 사실이 있다.

1) 객체는 상태(state) 와 행동(behavior)을 함께 가지는 복합적인 존재라는 것 이다.

2) 객체는 스스로 판단하고, 행동하는 자율적인 존재이다.

 

객체지향 페러다임 에서는 객체라는 단위 안에 데이터와 기능을 한 덩어리로 묶음으로써 캡슐화 시킬수 있다.

 

또한 대부분의 객체지향 언어에서는 public, private 과 같은 접근 수정자를 제공한다.

이와 같이 접근 수정자를 통해 객체 내부에 대한 접근을 제어하는 이유는 객체를 자율적인 존재로 만들기 위해서이다.

 

객체 스스로가 상태를 관리하고, 행동하는 지율적인 객체가 되려면 외부의 간섭을 최소화 해야한다.

외부에서 객체의 결정에 직접적으로 개입하려 하면 안된다. 객체에게 요청만 하고, 스스로 최선의 방법을 경정하도록 해야한다.


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

 

1) 외부에서 접근 가능한 부분으로 이를 public interface 라고 부른다.

2) 외부에서는 접근 불가능하고, 오직 내부에서만 접근 가능한 부분으로 이를 implementation 이라 부른다. 

 

일반적으로 Java로 생각해보면 클래스의 속성은 private로 감추고, 외부에 제공해야 하는 일부 메서드만 public으로 선언한다.

public interface에는 public으로 지정된 메서드만 포함한다.

그 밖의 private 메서드나 protected 메서드, 속성은 implementation 에 해당된다.

 

프로그래머의 자유

  • 클래스 작성자(class creator) : 새로운 데이터 타입을 프로그램에 추가
  • 클라이언트 프로그래머(client programmer) : 클래스 작성자가 추가한 데이터 타입을 사용한다.

class creator는 client programmer 에게 필요한 부분만 공개하고, 나머지는 숨겨야 한다.

class creator입장에서는 client programmer가 숨겨놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 클라이언트 프로그래밍에 대한 영향을 걱정하지 않고도 내부 구현을 마음대로 변경할 수 있다. => 이를 구현은닉(implementation hiding)이라고 부른다.

 

이를 통해 client programmer는 내부 구현은 무시한채 인터페이스만 알고 있어도 class를 사용할 수 있기 때문이다.

따라서 client programmer가 알아야 할 지식의 양이 줄어든다.

 

협력에 관한 짧은 이야기

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

 

객체가 다른 객체와 상호작용 하는 유일한 방법은 메시지를 전송(send a message)하는 것 뿐이다.

메시지를 수신한 객체는 스스로의 결정에 따라 자율적으로 메시지를 처리할 방법을 결정한다.

이처럼 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드(method)라고 부른다.

 

예를 들어 다음 코드의 일부를 살펴보자.

public class Screening {

    private Money calculateFee(int audienceCount) {
        return movie.calculateMovieFee(this).times(audienceCount);
    }
}

위 코드는 'Screening이 Movie 에게 calculateMovieFee 메시지를 전송한다' 라고 말하는것이 적절하다.

사실 Screening은 Movie안에 calculateMovieFee 메서드가 존재하고 있는지 조차 모른다.

단지 Movie 가 calculateMovieFee 메시지에 응답할 수 있다고 믿고 메시지를 전송할 뿐이다.

 

메시지를 받은 Movie는 스스로 적절한 메서드를 선택한다.

 

2. 할인 요금 구하기

이번에는 할인 정책과 할일 조건을 구현해 보자.

 

부모 클래스인 DiscountPolicy 안에 중복 코드를 두고, AmountPolicy, PercentDiscountPolicy가 이 클래스를 상속한다.

실제 애플리케이션에서 DiscountPolicy의 인스턴스를 생성할 필요가 없기 때문에 abstract class 로 구현했다.

public abstract class DiscountPolicy {
    private List<DiscountCondition> conditions = new ArrayList<>();

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

    Money calculateDiscountAmount(Screening screening){
        for(DiscountCondition each : conditions){
            if(each.isSatisfiedBy(screening)){
                return getDiscountAmount(screening);
            }
        }

        return Money.ZERO;
    }

    protected abstract Money getDiscountAmount(Screening screening);
}

DiscountPolicy는 할인 여부와 요금 계산에 필요한 전체적인 흐름을 정의하지만, 실질적인 요금을 계산하는 부분을 추상메서드 인

getDiscountAmount() 메서드에 위임한다.

 

이처럼 기본적인 알고리즘의 흐름을 결정하고, 중간에 필요한 처리를 자식 class에게 위임하는 디자인 패턴을

TEMPLATE METHOD 패턴 이라 부른다. 다음 글은 내가 정리해둔 템플릿 메서드 패턴에 관한 글 이다.

 

[Design Patterns] Template Method Pattern : 템플릿 메소드 패턴

Head First Design Patterns 책을 읽으며 정리한 내용입니다. 문제가 될 시 글을 내리도록 하겠습니다! Template Method Pattern 이란? " data-ke-type="html"> <>HTML 삽입 미리보기할 수 없는 소스 Template Me..

blogshine.tistory.com

 

3. 상속과 다형성

우선 다음 코드를 살펴보자.

public class Movie {
    private String title;
    private Duration runningTime;
    private Money fee;
    private DiscountPolicy discountPolicy;

    public Movie(String title, Duration runningTime, Money fee, DiscountPolicy discountPolicy) {
        this.title = title;
        this.runningTime = runningTime;
        this.fee = fee;
        this.discountPolicy = discountPolicy;
    }

    public Money getFee() {
        return fee;
    }

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

Movie 클래스 어디를 봐도 할인 정책이 금액 할인 정책인지?, 비율 할일 정책인지? 판단하지 않는다.

Movie 내부에 할인 정책을 결정하는 조건문도 없는데도 불구하고 어떻게 영화 요금을 계산할때 할인 정책을 선택할수 있을까?

 

이는 상속과 다형성을 통해 가능하다.

 

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

출처 - 오브젝트 57p

위 클래스 다이어 그램을 보면, Movie 클래스가 DiscountPolicy와 연관관계를 맺고 있다.

문제는 영화 요금을 계산하기 위해서는 abstarc class가 아닌!, AmountDiscountPolicy, PercentDiscountPolicy 와 같은 인스턴스에 의존해야 한다.

 

하지만 Movie 클래스는 두 클래스 중 어떤것도 의존하지 않는다. 추상 클래스에만 의존하고 있다.

 

그럼 Movie 코드를 작성하던 시점에는 존재조차 모르던 AmountDiscountPolicy, PercentDiscountPolicy의 인스턴스를 사용할수 있는 이유는 무엇일까?

 

다음은 아바타 영화에 대한 코드이다.

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

Movie의 인스턴스를 생성할때 생성자의 인자로 AmountDiscountPolicy 를 전달하면 된다.

출처 - 오브젝트 58p

실행시에 Movie 인스턴스는 AmountDiscountPolicy 클래스의 인스턴스에 의존하게 될 것 이다.

 

여기서의 핵심은 코드의 의존성과 실행시점의 의존성이 서로 다를 수 있다는 점이다.

=> 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다. 이는 확장 가능한 객체지향 설계의 특징이다.

 

설계가 유연해질수록 코드를 이해하고 디버깅하기는 점점 더 어려워진다.

반면 유연성을 억제하면 코드를 이해하고 디버깅 하기는 쉬워지지만, 재사용성과 확장 가능성은 낮아진다.

 

상속과 인터페이스

인터페이스는 객체가 이해할 수 있는 메시지의 목록을 정의한다는 것을 기억하자.

상속을 통해 자식은 부모 클래스가 수신할 수 있는 모든 메시지를 수신할수 있기 때문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.

 

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

Movie는 DiscountPolicy의 인터페이스에 정의된 calculateDiscountAmount 라는 메시지를 전송하고 있다.

DiscountPolicy를 상속받은 AmountDiscountPolicy, PercentDiscountPolicy의 인터페이스에도 이 메서드가 포함되어 있다.

 

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

 

다형성

메시지와 메서드는 다른 개념이다.

Movie는 discountPolicy의 인스턴스 에게 calculateDiscountPolicy 메시지를 전송한다.

그럼 실행되는 메서드는 무엇인가? 이는 연결된 객체의 클래스가 무엇인지에 따라 달라진다.

 

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

(즉, 동일한 메시지에 대하여 객체의 타입에 따라 다르게 응답할수 있는 능력을 의미한다.)

 

이처럼 메시지와 메서드를 실행 시점에 바인딩 하는것을 지연 바인딩(lazy binding) 또는 동적 바인딩(dynamic binding)이라 부른다.

반면에 전통적인 컴파일 시점에 실행될 함수나, 프로시저를 결정하는 것을 초기 바인딩(early binding), 정적 바인딩(static binding)이라 부른다.

 

4. 추상화와 유연성

지금까지 살펴본 코드들에서 DiscountPolicy는 AmountDiscountPolicy, PercentDiscountPolicy 보다 더 추상적이고,

DiscountCondition 또한 더 추상적이다.

 

이는 인터페이스에 초점을 맞추기 때문이다.

DiscountPolicy는 모든 할인 정책이 수신할 수 있는 calculateDiscountAmount 메시지를 정의한다.

DiscountCondition은 모든 할인 조건들이 수신할 수 있는 isSatisfiedBy 메시지를 정의한다.

 

다음 다이어그램을 살펴보자.

출처 - 오브젝트 65p

위 그림은 추상화를 사용할 경우 2가지 장점을 보여준다.

1) 추상화의 계층만 따로 떼어 놓고 살펴보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.

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

 

위 다이어 그램을 하나의 문장으로 정리하면 "영화 예매 요금은 최대 하나의 할인정책 과 다수의 할인조건을 이용해 계산할 수 있다"와 같다.

위 문장은 "금액할인 정책과 두개의 순서조건, 한개의 기간 조건을 이용해 계산할 수 있다" 라는 문장을 포괄할수 있다는 사실이 중요하다.

 

이는 좀더 추상적인 개념들을 사용해 문장을 작성했기 때문이다.

 

추상화를 통해 상위 정책을 기술한다는 것은 기본적인 어플리케이션의 협력 흐름을 기술한다는것을 의미한다.

영화의 예매 가격을 계산하기 위한 흐름은 항상 Movie -> DIscountPolicy -> DiscountCondition 로 흘러간다.

할인 정책이나, 조건의 자식들은 추상화를 이용해서 정의한 흐름에 그대로 따르게 된다.

 

추상화가 유연한 설계를 가능하게 하는 이유는 설계가 구체적인 상황에 결합되는것을 방지하기 때문이다.

Movie는 특정한 할인 정책에 묶이지 않는다. 할인 정책을 구현한 클래스가 DiscountPolicy를 상속받고 있다면 어떤 클래스와도 협력이 가능하다.

 

DiscountPolicy 역시 특정 할인 조건에 묶여있지 않다. DiscountCondition을 상속받은 어떤 클래스와도 협력이 가능하다.

이 모든것이 추상화 덕분이다.

 

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

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);
}
public class NoneDiscountPolicy entends DiscountPolicy {

    @Override
    protected Money getDiscountAmount(Screening screening) {
    	return Money.ZERO;
    }
}

위 코드를 보면, NoneDiscountPolicy는 getDiscountAmount() 를 오버라이딩 하여 ZERO를 반환하도록 하고있다.

하지만 할인조건이 없는경우, calculateDiscountAmount 메시지 호출시 getDiscountAmount() 자체를 호출하지 않는다.

 

부모 클래스인 DiscountPolicy는 할인 조건이 없는경우 getDiscountAmount()를 호출하지 않고, ZERO를 반환한다.

이는 DiscountPolicy내부에 NoneDiscountPolicy를 개념적으로 결합시켜둔 것 이다.

개발자는 DiscountCondition이 없으면 0원을 반환할 것이라는 사실을 가정하기 때문이다.

 

이러한 문제를 해결하기 위해 DiscountPolicy를 인터페이스로 바꾸고, 기존의 DiscountPolicy를 DefaultDiscountPolicy 라는 abstract class로 만든다.

 

출처 - 오브젝트 69p

과연 어떤 설계가 더 좋은가?

구현과 관련된 모든 것들이 트레이드 오프의 대상이 될 수 있다.

우리가 작성하는 코드는 모두 합당한 이유가 있어야 한다. 비록 아주 사소하더라도 트레이트오프를 통해 얻어진 결론은 그렇지 않은 경우와 매우 다르다.

 

코드 재사용

객체지향을 조금이라도 공부해봤다면 코드 재사용을 위해서는 상속이 아닌, 합성을 사용해야 함을 배웠을 것이다.

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

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

 

다음은 이 합성 방식을 상속으로 변경한 다이어그램이다.

출처 - 오브젝트 70p

우리는 왜 상속 보다 합성을 사용해야 할까?


상속은 2가지 이유에서 설계에 좋지 못하다

1) 캡슐화를 위반한다 : 결정적으로 부모클래스의 구현이 자식클래스에 노출되기 때문에 캡슐화가 약화된다.

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

 

합성

상속은 컴파일 시점에 하나의 단위로 강하게 결합하는데(부모, 자식 코드) 반해, 합성은 클래스 간 인터페이스를 통해 약하게 결합된다.

 

즉, 내부 구현에 대해서는 전혀 알지 못한다.

Movie는 DiscountPolicy 가 외부에 calculateDiscountAmount 메서드를 제공한다는 사실만 알고 내부 구현에 대해서는 전혀 알지 못한다.

 

이처럼 인터페이스에 정의된 메세지를 통해서만 코드를 재사용하는 방법을 합성이라 부른다.

 

 

댓글