개발서적

[오브젝트 - 코드로 이해하는 객체지향 설계] 2장. 객체지향 프로그래밍

SunYerim 2025. 3. 21. 15:18

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

협력, 객체, 클래스

진정한 객체지향 패러다임으로의 전환은 클래스가 아닌 객체에 초점을 맞출 때에 얻을 수 있다.

  • 어떤 클래스가 필요한지를 고민하기 전에 어떤 객체들이 필요한지 고민하라.
    • 클래스는 공통적인 상태와 행동을 공유하는 객체들을 추상화한 것
    • 클래스의 윤곽을 잡기 위해서는 어떤 객체들이 어떤 상태와 행동을 가지는지를 먼저 결정해야함.
  • 객체를 독립적인 존재가 아니라 기능을 구현하기 위해 협력하는 공동체의 일원으로 봐야 한다.
    • 객체를 협력하는 공동체의 일원으로 바라보는 것은 설계를 유연하고 확장 가능하게 만든다.

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

  • 도메인: 문제를 해결하기 위해 사용자가 프로그램을 사용하는 분야
public class Screening {
    private Movie movie;
    private int sequence;
    private LocalDateTime whenScreened;

    public Screening(Moive movie, int sequence, LocalDateTime whenScreened) {
        this.movie = movie;
        this.sequence = sequence;
        this.whenScreened = whenScreened;
    }

    public LocalDateTime getStartTime() {
        return whenScreened;
    }

    public boolean isSequence(int sequence) {
        return this.sequence == sequence;
    }

    public Money getMovieFee() {
        return movie.getFee();
    }
}
  • 인스턴스 변수의 가시성은 private고 메서드의 가시성은 public이라는 점에 주목하자!

  • 가장 중요한 것은 클래스의 경계를 구분 짓는 것이다.

  • 클래스 설계의 핵심은 어떤 부분을 외부에 공개하고 어떤 부분을 감출지를 결정하는 것이다.

  • Screening에서 알 수 있듯이 외부에서는 객체의 속성에 직접 접근할 수 없도록 막고 적절한 public 메서드를 통해서만 내부 상태를 변경할 수 있게 해야한다.

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

자율적인 객체

  • 객체가 상태행동을 함께 가지는 복합적인 존재다.

  • 객체가 스스로 판단하고 행동하는 자율적인 존재다.

  • 객체지향 이전 → 데이터와 기능이라는 독립적인 존재를 서로 엮어 프로그램을 구성

  • 객체지향 → 객체라는 단위 안에 데이터와 기능을 묶어서 표현. 이를 캡슐화라고 한다.

  • 객체지향 프로그래밍 언어들은 외부에서의 접근을 통제할 수 있는 접근 제어메커니즘도 함께 제공한다.

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

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

    • 퍼블릭 인터페이스: 외부에서 접근 가능한 부분
    • 구현: 외부에서는 접근 불가능하고 오직 내부에서만 접근 가능한 부분
    • 인터페이스와 구현의 분리 원칙
  • 일반적으로 객체의 상태는 숨기고 행동만 외부에 공개해야 한다.

  • 클래스의 속성은 private으로 선언해서 감추고 외부에 제공하는 일부 메서드만 public으로 선언해야 한다.

  • 어떤 메서드들이 서브클래스나 내부에서만 접근 가능해야 한다면 가시성을 protected나 private으로 지정해야 한다.

  • 퍼블릭 인터페이스에는 public으로 지정된 메서드만 포함한다. 그 밖의 private 메서드나 protected 메서드, 속성은 구현에 포함된다.

프로그래머의 자유

  • 프로그래머의 역할 → 클래스 작성자와 클라이언트 프로그래머로 구분하는 것이 유용.
  • 클래스 작성자는 새로운 데이터 타입을 프로그램에 추가하고, 클라이언트 프로그래머는 클래스 작성자가 추가한 데이터 타입을 사용한다.
  • 구현 은닉: 필요한 부분만 공개하고 나머지는 꽁꽁 숨김. → 숨겨 놓은 부분에 마음대로 접근할 수 없도록 방지함으로써 내부 구현을 마음대로 변경 가능하다.
  • 객체의 외부와 내부를 구분하면 클라이언트 프로그래머가 알아야 할 지식의 양이 줄어들고 클래스 작성자가 자유롭게 구현을 변경할 수 있는 폭이 넓어진다 !! → 인터페이스와 구현을 분리하기 위해 노력하자.
  • 객체지향의 장점은 객체를 이용해 도메인의 의미를 풍부하게 표현할 수 있다는 것이다.

협력에 관한 짧은 이야기

  • 객체의 내부 상태는 외부에서 접근하지 못하도록 감춰야 한다.
  • 외부에 공개하는 퍼블릭 인터페이스를 통해 내부 상태에 접근할 수 있도록 허용한다.
  • 객체가 다른 객체와 상호작용할 수 있는 유일한 방법은 메시지를 전송하는 것뿐이다.
  • 다른 객체에게 요청이 도착할 때 해당 객체가 메시지를 수신했다고 이야기한다.
  • 수신된 메시지를 처리하기 위한 자신만의 방법을 메서드라고 한다.

할인 요금 구하기

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));
    }
}
  • 상속과 다형성의 개념이 숨겨져있다.
  • 그 기반에는 추상화라는 원리가 숨겨져있다.

할인 정책과 할인 조건

  • 할인 정책: 금액 할인 정책, 비율 할인 정책

    • 두 클래스의 중복 코드를 제거하기 위해 공통 코드를 보관할 장소가 필요 → 부모 클래스인 DiscountPolicy안에 중복 코드를 두고 AmountDiscountPolicy와 PercentDiscountPolicy가 이 클래스를 상속받도록 하자.
    • 실제 애플리케이션에서는 DiscountPolicy 인스턴스를 생성할 필요가 없기 때문에 추상 클래스로 구현.
  • TEMPLATE METHOD 패턴: 부모 클래스에 기본적인 알고리즘의 흐름을 구현하고 중간에 필요한 처리를 자식 클래스에 위임하는 디자인 패턴

오바라이딩과 오버로딩

  • 오버라이딩

    • 부모 클래스에 정의된 같은 이름, 같은 파라미터 목록을 가진 메서드를 자식 클래스에서 재정의하는 경우를 가리킴.
    • 자식 클래스의 메서드는 오버라이딩한 부모 클래스의 메서드를 가리기 때문에 외부에서는 부모 클래스의 메서드가 보이지 않는다.
  • 오버로딩

    • 메서드의 이름은 같지만 제공되는 파라미터의 목록이 다르다.

    • 오버로딩한 메서드는 원래의 메서드를 가리지 않기 때문에 메서드 공존 가능.

      // 예시
      public class Money {
        public Money plus (Money amount) {
            return new Money(this.amount.add(amount.amount));
        }
      
        public Money plus(long amount) {
            return new Money(this.amount.add(BigDecimal.valueOf(amount)));
        }
      }
    • 두 개의 메서드 이름은 같지만 파라미터가 다르다. 두 메서드는 공존하며 외부에서는 두 개의 메서드 모두 호출 가능한데, 이 경우를 오버로딩이라고 부른다.


상속과 다형성

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

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

  • Movie는 DiscountPolicy와 연결돼 있으며, AmountDiscountPolicy와 PercentDiscountPolicy는 추상 클래스인 DiscountPolicy를 상속받는다.

  • 영화 요금을 계산하기 위해서는 추상 클래스인 DiscountPolicy가 아니라 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스에 의존해야 한다. 하지만 코드 수준에서 Movie 클래스는 이 두 클래스 중 어떤 것에도 의존하지 않는다. 오직 추상 클래스인 DiscountPolicy에만 의존하고 있다.

  • Movie 인스턴스가 코드 작성 시점에는 존재조차 알지 못했던 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스와 실행 시점에 협력 가능한 이유는?

    • 코드의 의존성과 실행 시점의 의존성이 서로 다를 수 있다.
    • 클래스 사이의 의존성과 객체 사이의 의존성은 동일하지 않을 수 있다.
    • 유연하고, 쉽게 재사용할 수 있으며, 확장 가능한 객체지향 설계가 가지는 특징은 코드의 의존성과 실행 시점의 의존성이 다르다는 것이다.
  • but, 코드의 의존성과 실행 시점의 의존성이 다를수록 코드를 이해하기 어려워짐. ⇒ 트레이드오프의 산물.

  • 설계가 유연해질수록 코드를 이해하고 디버깅하기는 어려워지지만, 유연성을 억제하면 코드를 이해하고 디버깅하기는 쉬워지지만 재사용성과 확장 가능성은 나아진다.

차이에 의한 프로그래밍

  • 클래스를 하나 추가하고 싶은데 그 클래스가 기존의 어떤 클래스와 매우 흡사하다면? ⇒ 클래스 재사용 ⇒ 상속

  • 상속은 코드를 재사용하기 위해 가장 널리 사용되는 방법.

  • 상속을 이용하면 클래스 사이에 관계를 설정하는 것만으로 기존 클래스가 가지고 있는 모든 속성과 행동을 새로운 클래스에 포함시킬 수 있다.

  • 상속은 기존 클래스를 기반으로 새로운 클래스를 쉽고 빠르게 추가할 수 있는 간편한 방법을 제공함.

  • 상속을 이용하면 부모 클래스의 구현은 공유하면서도 행동이 다른 자식 클래스를 쉽게 추가할 수 있다.

  • AmountDiscountPolicy와 PercentDiscountPolicy의 경우 DiscountPolicy에서 정의한 추상 메서드인 getDiscountAmount 메서드를 오버라이딩해서 DiscountPolicy의 행동을 수정한다는 것을 알 수 있다.

  • 부모 클래스와 다른 부분만을 추가해서 새로운 클래스를 쉽고 빠르게 만드는 방법을 차이에 의한 프로그래밍이라고 부른다.

자식 클래스와 부모 클래스 (p.60 참고)

  • 상속은 두 클래스 사이의 관계를 정의하는 방법
  • 상속 관계를 선언함으로써 한 클래스는 자동으로 다른 클래스가 제공하는 코드를 자신의 일부로 합칠 수 있다.
  • 따라서 상속을 사용하면 코드 중복을 제거하고 여러 클래스 사이에서 동일한 코드를 공유할 수 있게 된다.

상속과 인터페이스

  • 부모 클래스가 제공하는 모든 인터페이스를 자식 클래스가 물려받을 수 있기 때문에 상속은 가치 있다.

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

  • 상속을 통해 자식 클래스는 자신의 인터페이스에 부모 클래스의 인터페이스를 포함하게 된다.

  • 결과적으로 자식 클래스는 부모 클래스가 수신할 수 있는 모든 메시지를 수신할 수 있기 대문에 외부 객체는 자식 클래스를 부모 클래스와 동일한 타입으로 간주할 수 있다.

  • 자식 클래스는 상속을 통해 부모 클래스의 인터페이스를 물려받기 때문에 부모 클래스 대신 사용될 수 있다.

  • 컴파일러는 코드 상에서 부모 클래스가 나오는 모든 장ㅈ소에서 자식 클래스를 사용하는 것을 허용한다.

    • Moive의 생성자에서 인자의 타입이 DiscountPolicy임에도 AmountDiscountPolicy와 PercentDiscountPolicy의 인스턴스를 전달할 수 있는 이유도 바로 이 때문이다.
  • 자식 클래스가 부모 클래스를 대신하는 것을 업캐스팅이라고 부른다.

다형성

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

  • 다형성은 객체지향 프로그램의 컴파일 시간 의존성과 실행 시간 의존성이 다를 수 있다는 사실을 기반으로 한다.

  • 컴파일 시간 의존성: Movie → DiscountPolicy

  • 실행 시점에 Moive의 인스턴스와 실제로 상호작용하는 객체 → AmountDiscountPolicy나 PercentDiscountPolicy.

  • 즉, 실행 시간 의존성: Movie → AmountDiscountPolicy나 PercentDiscountPolicy

  • 다형성은 컴파일 시간 의존성과 실행 시간 의존성을 다르게 만들 수 있는 객체지향의 특성을 이용해 서로 다른 메서드를 실행할 수 있게 한다.

  • 다형적인 협력에 참여하는 객체들은 모두 같은 메시지를 이해할 수 있어야되는데, 다시 말해 인터페이스가 동일해야 한다.

  • AmountDiscountPolicy와 PercentDiscountPolicy가 다형적인 협력에 참여할 수 있는 이유는 이들이 DiscountPolicy로부터 동일한 인터페이스를 물려받았기 때문.

  • 이 두 클래스의 인터페이스를 통일하기 위해 사용한 구현 방법 ⇒ 상속

다형성을 구현하는 방법

  • 메시지에 응답하기 위해 실행될 메서드를 컴파일 시점이 아닌 실행 시점에 결정한다는 공통점이 있다. ⇒ 메시지와 메서드를 실행 시점에 바인딩한다.

  • 지연 바인딩과 또는 동적 바인딩이라고 부른다.

  • 초기 바인딩 또는 정적 바인딩: 전통적인 함수 호출처럼 컴파일 시점에 실행될 함수나 프로시저를 결정하는 것.

  • 객체지향이 컴파일 시점의 의존성과 실행 시점의 의존성을 분리하고, 하나의 메시지를 선택적으로 서로 다른 메서드에 연결할 수 있는 이유가 지연 바인딩이라는 메커니즘을 사용하기 때문.

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

  • 상속을 구현 상속과 인터페이스 상속으로 분류할 수 있다.

  • 구현 상속 → 서브클래싱

  • 인터페이스 상속 → 서브타이핑

  • 순수하게 코드를 재사용하기 위한 목적으로 상속을 사용하는 것을 구현 상속이라고 부른다.

  • 다형적인 협력을 위해 부모 클래스와 자식 클래스가 인터페이스를 공유할 수 있도록 상속을 이용하는 것을 인터페이스 상속 이라고 부른다.

  • 상속은 구현 상속이 아니라 인터페이스 상속을 위해 사용해야 한다.

인터페이스와 다형성

  • 앞에서는 DiscountPolicy를 추상 클래스로 구현함으로써 자식 클래스들이 인터페이스와 내부 구현을 함께 상속받도록 만들었지만, 종종 구현은 공유할 필요가 없고 순수 인터페이스만 공유하고 싶을 때가 있다.
  • 그래서 인터페이스라는 프로그래밍 요소를 제공한다.

추상화와 유연성

추상화의 힘

  • 추상화를 사용할 경우의 두 가지 장점

    • 추상화의 계층만 따로 떼어 놓고 보면 요구사항의 정책을 높은 수준에서 서술할 수 있다.
    • 추상화를 이용하면 설계가 좀 더 유연해진다.
  • 추상화를 사용하면 세부적인 내용을 무시한 채 상위 정책을 쉽고 간단하게 표현할 수 있다.

  • 세부사항에 억눌리지 않고 상위 개념만으로도 도메인의 중요한 개념을 설명할 수 있게 한다.

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

  • 재사용 가능한 설계의 기본을 이루는 디자인 패턴이나 프레임워크 모두 추상화를 이용해 상위 정책을 정의하는 객체지향의 메커니즘을 활용하고 있다.

  • 추상화를 이용해 상위 정책을 표현하면 기존 구조를 수정하지 않고도 새로운 기능을 쉽게 추가하고 확장할 수 있다. ⇒ 설계를 유연하게 만들 수 있다.

유연한 설계

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

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

  • 할인 정책이 없는 경우

    • 기존에는 할인 금액이 0원이라는 사실을 결정하는 책임이 DiscountPolicy가 아닌 Movie쪽에 있었다.

    • 일관성을 지킬 수 있는 방법은 0원이라는 할인 요금을 계산할 책임을 그대로 DiscountPolicy 계층에 유지시키는 것이다. NonDiscountPolicy 클래스를 추가해주면 된다.

      public class NoneDiscountPolicy extends DiscountPolicy {
        @Override
        protected Money getDiscountAmount (Screening screening) {
            return Money.ZERO;
        }
      }
  • 새로운 클래스를 추가하는 것만으로 애플리케이션의 기능을 확장했다.

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

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

  • Movie는 특정한 할인 정책에 묶이지 않음. DiscountPolicy 역시 특정한 할인 조건에 묶여있지 않음. DiscountCondition을 상속받은어떤 클래스와도 협력이 가능함. ⇒ DiscountPolicy와 DiscountCondition이 추상적이기 때문에 가능. ⇒ 컨텍스트 독립성

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

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

  • NoneDiscountPolicy 클래스에서 getDiscountAmount() 메서드가 어떤 값을 반환하더라도 상관이 없다.

  • 부모 클래스인 DiscountPolicy에서 할인 조건이 없을 경우에는 getDiscountAmount() 메서드를 호출하지 않기 때문이다.

  • 해결하는 방법은 DiscountPolicy를 인터페이스로 바꾸고 NoneDiscountPolicy가 DiscountPolicy의 method가 아닌 calculateDisscountAmount() 오퍼레이션을 오버라이딩 하도록 변경하면 된다.

public interface DiscountPolicy {
    Money calculateDiscountAmount (Screening screeniing);
}
// 기존의 DiscountPolicy 클래스 이름 변경
public abstract class DefaultDiscountPolicy implements DiscountPolicy {
    ...
}
public class NoneDiscountPolicy implements DiscountPolicy {
    @Override
    public Money calculateDiscountAmount (Screening screening) {
        return Money.ZERO;
    }
}

코드 재사용

  • 상속은 코드를 재사용하기 위해 널리 사용되는 방법.
  • 코드 재사용을 위해서는 상속보다는 합성이 더 좋은 방법이 될 수도 있다.
  • 합성: 다른 객체의 인스턴스를 자신의 인스턴스 변수로 포함해서 재사용하는 방법

상속

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

  • 첫째, 상속이 캡슐화를 위반한다는 것

  • 둘째, 설계를 유연하지 못하게 만든다는 것

  • 상속의 가장 큰 문제점은 캡슐화를 위반한다는 것이다. 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 잘 알고 있어야 한다.

    • AmountDiscountMovie와 PercentDiscountMovie를 구현하는 개발자 → 부모 클래스인 Movie의 calculateMovieFee 메서드 안에서 추상 메서드인 getDiscountAmount 메서드를 호출한다는 사실을 알고 있어야 한다.
  • 결과적으로 부모 클래스의 구현이 자식 클래스에게 노출되기 때문에 캡슐화가 약화된다.

  • 캡슐화의 약화는 자식 클래스가 부모 클래스에 강하게 결합되도록 만들기 때문에부모 클래스를 변경할 때 자식 클래스도 함께 변경될 확률을 높인다.

  • 두 번째 단점은 설계가 유연하지 않다는 것.

  • 상속은 부모 클래스와 자식 클래스 사이의 관계를 컴파일 시점에 결정 ⇒ 실행 시점에 객체의 종류를 변경하는 것이 불가능함.

  • 반면, 인스턴스 변수로 연결한 기존 방법을 사용하면 실행 시점에 할인 정책을 간단하게 변경할 수 있다.

public class Movie {
    private DiscountPolicy discountPolicy;

    public void changeDiscountPolicy (DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}
  • 이를 통해 상속보다 인스턴스 변수로 관계를 연결한 원래의 설계가 더 유연하다는 사실을 알 수 있다.

합성

  • Movie는 요금을 계산하기 위해 DiscountPolicy의 코드를 재사용한다.

  • 상속과 다른 점은 상속이 부모 클래스의 코드와 자식 크래스의 코드를 컴파일 시점에 하나의 단위로 결합하는 것에 비해 Movie가 DiscountPolicy의 인터페이스를 통해 약하게 결합된다는 것.

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

  • 합성은 인터페이스에 정의된 메시지를 통해서만 재사용이 가능하기 때문에 구현을 효과적으로 캡슐화할 수 있다.

  • 또한 의존하는 인스턴스를 교체하는 것이 비교적 쉽기 때문에 설계를 유연하게 만든다.

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

  • 코드 재사용을 위해서는 상속보다는 합성을 선호하는 게 더 좋은 방법이 될 수 있다.