Computer Science/객체지향

서브클래싱과 서브타이핑 - 코드로 이해하는 객체지향

sh1mj1 2023. 9. 19. 22:40

상속은 크게 두 가지로 목적으로 사용됩니다.

 

상속타입 계층을 구현하기 위해 사용됩니다.

부모 클래스는 계층 안에서 일반적인 개념을 구현하고 자식클래스는 특수한 개념을 구현합니다.

 

상속은 코드 재사용을 위해 사용됩니다.

간단한 선언으로 부모 클래스의 코드를 재사용할 수 있습니다. 하지만 재사용을 위해 상속으 사용하면 부모 클래스와 자식 클래스가 강하게 결합되기 때문에 변경하기 어려운 코드를 얻게 될 확률이 높습니다.

 

그러므로 상속의 사용의 일차적인 목표는 타입 계층을 구현하기 위함이어야 합니다.

코드 재사용을 목표로 상속을 사용하면 부모, 자식 클래스가 강하게 결합되어 설계의 변경을 방해합니다. 하지만 타입 계층을 목표로 상속을 사용하면 다형적으로 동작하는 객체들의 관계에 기반하여 확장 가능하고 유연한 설계를 얻을 수 있습니다.

 

즉, 동일한 메시지에 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 합니다.

 

타입

개념 관점의 타입

먼저 타입을 일상 생활에서의 개념 관점에서 바라봅시다. 타입은 사물을 분류하기 위한 틀로 사용됩니다.

자바, 루비, 자바 스크립트를 프로그래밍 언어라는 타입으로 분류하는 것이지요. 그럴 때 자바, 루비, 자바 스크립트는 타입의 인스턴스라고 합니다. 보통 타입의 인스턴스를 객체라고 합니다.

 

프로그래밍 언어 관점의 타입

프로그래밍 언어의 관점에서 타입은 0, 1 의 비트 묶음에 의미를 부여하기 위해 정의된 제약과 규칙입니다.

 

  • 타입은 타입에 수행될 수 있는 오퍼레이션의 집합을 정의함.
  • 타입에 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공함.

 

타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의해서 코드의 의미를 명확히 하고, 개발자의 실수를 방지하기 위해 사용됩니다.

 

객체지향 패러다임 관점의 타입

위에서 본 개념 관점의 타입과 프로그래밍 언어 관점의 타입을 조합해봅시다.

객체의 타입이란 객체가 수신할 수 있는 메시지의 종류를 정의하는 것입니다. 그 종류, 집합이 퍼블릭 인터페이스 입니다!!

 

즉, 객체의 퍼블릭 인터페이스가 객체의 타입을 결정하며, 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류됩니다.

 

객체에게 중요한 것은 속성이 아니라 행동입니다! 각 객체의 내부 상태가 달라도 퍼블릭 인터페이스가 같다면 이들은 동일한 타입이지요.

 

타입 계층

타입은 다른 타입을 포함할 수 있습니다. 

위 그림을 보면 알 수 있겠지만, 포함되는 타입은 더 특수하며 포함하는 관계는 더 일반적입니다.  포함 관계로 연결된 타입 사이에는 개념적으로 일반화, 특수화 관계가 있습니다.

 

타입 계층을 구성하는 두 타입 간의 관계에서 더 일반적인 타입을 슈퍼타입(supertype) 이라고 부르고, 더 특수한 타입을 서브타입(subtype) 이라고 부릅니다.

 

객체지향 프로그래밍과 타입 계층

객체의 타입을 결정하는 것은 퍼블릭 인터페이스입니다.

더 일반적인 퍼블릭 인터페이스를 가지는 객체들은 더 특수한 퍼블릭 인터페이스를 가지는 객체들의 슈퍼타입입니다. 

서브 타입의 인스턴스는 슈퍼타입의 인스턴스로도 간주될 수 있습니다.

 

서브클래싱과 서브타이핑

상속을 이용해서 타입 계층을 구현한다는 것은 부모 클래스가 슈퍼타입의 역할을, 자식 클래스가 서브타입의 역할을 수행하도록 클래스 사이의 관계를 정의하는 것입니다.

 

언제 상속을 사용해야 하는가?

상속의 올바른 용도는 타입 계층을 구현하는 것입니다. 그렇다면 구체적으로 어떤 경우에 타입 계층을 위해 올바른 상속을 사용한 것일까요?

 

아래 두 조건을 만족했을 때 사용해야 합니다.

 

  • 상속 관계가 is-a 관계를 모델링한다. ('자식 클래스' 는 '부모 클래스' 이다 를 만족)
  • 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 된다. (클라이언트 입장에서 부모 클래스와 자식 클래스의 차이를 몰라야 함)

 

설계 관점에서 상속을 적용할지 여부를 결정하기 위해서는 두번째 조건에 집중해야 합니다. 실제로는 두 클래스가 다른 행동을 할 것으로 보이지만 어휘적으로만 is-a  관계를 만족할 수 있기 때문이죠.

 

is-a 관계

위에서 두 클래스가 다른 행동을 할 것으로 보이는데 어휘적으로만 is-a 관계를 만족할 수 있다고 했습니다.

정말 간단한 예시 코드로 이러한 함정을 봅시다.

 

public class Bird {
    public void fly() { ... }
    // ...
}

public class Penguin extends Bird {
    // ...
}

펭귄은 새입니다. 하지만 펭귄은 날 수 없습니다. 즉, 어휘적인 정의가 아닌 기대되는 행동에 따라 타입 계층을 구성해야 합니다.

 

어휘적인 관점보다 행동 호환성이 중요한 것이죠.

 

행동 호환성

행동의 호환 여부를 판단하는 기준은 클라이언트의 관점입니다. 클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있습니다. 그렇지 않다면 타입 계층으로 묶으면 안되겠죠.

 

클라이언트의 기대에 따라 계층 분리하기

위에서 fly 라는 메서드를 가지는 Bird 클래스를 직접 Penguin 클래스가 상속한다면 펭귄은 날 수 없기 때문에 문제가 생깁니다.

그렇다면 날 수 있는 새와 날 수 없는 새를 명확히 구분하여 상속 계층을 분리하면 되지 않을까요?

 

public class Bird {
    ...
}

public class FlyingBird extends Bird {
    public void fly() { .... }
}

public class Penguin extends Bird {
    ...
}

 

이제 FlyingBird 타입의 인스턴스만 fly 메시지를 수신할 수 있습니다.  타입 계층을 그림으로 보면 아래와 같죠.

 

또 다른 방법이 있습니다.

클라이언트에 따라 인터페이스를 분리하는 것입니다. Bird 가 날 수 있으면서 걸을 수도 있어야 하고 Penguin 은 오직 걸을 수만 있다고 합시다.

 

인터페이스는 클라이언트가 기대하는 바에 따라 분리되어야 합니다.

한 클라이언트가 오직 fly 메시지만 전송하기를 원한다면 클라이언트에게는 fly 메시지만 보여야 하고, 다른 클라이언트가 오직 walk 메시지만 전송하기를 원한다면 walk 메시지만 보여야 합니다.

 

 

이제 BirdPenguin 은 자신이 수행할 수 있는 인터페이스만 구현할 수 있습니ㅏㄷ.

 

만약 PenguinBird 의 코드를 재사용해야 한다면?

그렇다면 Penguin 의 퍼블릭 인터페이스에 fly 오퍼레이션이 추가되기 때문에 PenguinBird 를 상속받을 수 없습니다. 또한 단순 재사용을 위한 상속을 위험합니다.

 

그렇다면 우리는 합성을 사용할 수 있겠네요! 물론 Bird 의 퍼블릭 인터페이스를 통해 재사용 가능하다는 전제를 만족시켜야 합니다. 만약 그것이 어렵다면 Bird 를 약간 수정해야 할 수도 있습니다.

대부분은 불안정한 상속 계층을 가지고 가는 것보다 Bird 를 재사용 가능하도록 수정하는 것이 더 좋은 방법입니다.

 

 

클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있게됩니다.

 

만약 Client1 의 기대가 바뀌어서 Flyer 인터페이스가 변경되어야 하면 Flyer 에 의존하는 Bird 가 변경되지만 변경의 영향은 Bird 에서 끝납니다. Client2 는 이에 대해 전혀 알지 못하기 때문에 영향을 받지 않습니다.

 

이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의한 영향을 제어하는 설계 원칙인터페이스 분리 원칙(Interface Segregation Principle, ISP) 라고 합니다.

 

서브클래싱과 서브타이핑

그래서 상속은 언제 사용해야 할까요?

 

서브클래싱(subclassing): 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우. 자식, 부모 클래스의 행동이 호환되지 않아서 자식의 인스턴스가 부모의 인스턴스를 대체할 수 없다. 구현 상속(Implementation inheritance), 클래스 상속(class inheritance) 라고도 함.

 

서브타이핑(subtyping): 타입 계층을 구성하기 위해 상속을 사용하는 경우. 자식, 부모 클래스의 행동이 호환되어 자식의 인스턴스가 부모의 인스턴스를 대체할 수 있다. 부모 클래스는 자식 클래스의 슈퍼타입이 되고 자식은 부모의 서브타입이 된다. 인터페이스 상속(interface inheritance) 라고도 한다.

 

서브타이핑 관계가 유지되려면, 즉, 자식 클래스가 부모 클래스를 대신하려면, 

자식 클래스가 부모 클래스가 사용되는 모든 문맥에서 자식과 동일하게 행동할 수 있어야 합니다.

 

즉, 자식과 부모 클래스 사이의 행동호환성은 부모 클래스에 대한 자식 클래스의 대체 가능성(substitutability)을 포함합니다.

 

행동 호환성과 대체 가능성이 올바른 상속 관계를 구축하기 위한 지침입니다. 이 지침이 바로 리스코프 치환 원칙입니다.

 

리스코프 치환 원칙

리스코프 치환 원칙(Liskov Substituion Principle, LSP) 

 

S 타입의 각 객체 o1 에 대해 T 타입의 객체 o2 가 하나 있고,
T 로 정의된 모든 프로그램 P 에서 T 가 S 로 치환될 때,P 의 동작이 변하지 않으면 S 는 T 의 서브타입이다.

 

즉, 

서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다.
클라이언트가 차이점을 인식하지 못한채 기반 클래스의 인터페이스를 통해 서브클래스를 사용할 수 있어야 한다.

입니다.

 

독특한 예제를 하나 살펴봅시다.

일반적으로 '정사각형은 직사각형이다' 라고 합니다.

 

하지만 정사각형, 직사각형의 상속 관계는 리스코프 치환 원칙을 위반하는 유명한 사례입니다.

 

public class Rectangle {
    private int x, y, width, height;
    
    public Rectangle(int x, int y, int width, int height) {
        this.x = x;
        this.y = y;
        this.width = width;
        this.height = height;
    }
    // width 와 height 의 getter, setter 메서드
    
    public int getArea() {
        return width * height;
    }
}
public class Square extends Rectangle {
    public Square(int x, int y, int size) {
        super(x, y, size, size);
    }
    
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }
    
    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

 

SquareRectangle 의 자식 클래스이기 때문에 Rectangle 이 사용되는 모든 곳에서 Rectangle 로 업캐스팅될 수 있습니다.

Rectangle 과 협력하는 클라이언트는 직사각형은 너비, 높이가 서로 다르다고 생각할 것입니다.

 

public void resize(Rectangle rectangle, int width, int height) {
    rectangle.setWidth(width);
    rectangle.setHeight(height);
    assert rectangle.getWidth() == width && rectangle.getHeight() == height;
}

그런데 만약 resize 메서드의 인자로 Rectangle 이 아닌 Square 을 전달한다면...?

SquaresetWidth, setHeight 메서드 모두 정사각형의 너비, 좊이를 같게 설정합니다. 위 resize 코드에 따르면 Square 의 너비, 높이는 항상 나중에 설정된 height 의 값으로 설정될 것입니다. 

 

그래서 아래 코드처럼 resize 의 파라미터 width, height 값을 다르게 설정한다면 메서드 실행이 실패할 것 입니다.

Square square = new Square(10, 10, 10);
resize(square, 50, 100);

 

resize 메서드의 관점에서 Rectangle 대신 Square 를 사용할 수 없기 때문에 Square 은 Rectangle 이 아닙니다. SquareRectangle 의 구현을 재사용할 뿐입니다.

즉, 두 클래스는 리스코프 치환 원칙을 위반하기 때문에 서브 타이필 관계가 아닌 서브 클래싱 관계입니다.

 

우리는 어휘적으로 is-a 관계에 집중하기 보다는 클라이언트 관점에서 행동 호환성에 집중해야 합니다!!

 

클라이언트와 대체 가능성

우리는 의존성 관리-코드로 이해하는 객체지향 의 마지막 부분에서 영화 할인 정책 중 중복 할인 정책을 구현할 때 기존 DiscountPolicy 상속 계층에 새로운 자식 클래스 OverlappedDiscountPolicy 를 추가하였습니다. 그럼에도 불구하고 클라이언트는 수정할 필요가 없었죠.

 

이 설계는 의존성 역전 원칙개방-폐쇄 원칙, 리스코프 치환 원칙을 모두 만족하도록 설계를 확장 가능하게 만들었던 것입니다.

 

 

  • 의존성 역전 원칙(DIP): 구체 클래스인 MovieOverlappedDiscountPolicy 모두 추상 클래스인 DiscountPolicy 에 의존함.
    상위 수준 모듈인 Movie 와 하위 수준 모듈 OverlappedDiscountPolicy 는 모두 추상 클래스 DiscountPolicy 에 의존함.
    따라서 DIP 를 만족.
  • 리스코프 치환 원칙: DiscountPolicy 와 협력하는 Movie 관점에서 DiscountPolicy 대신 OverlappedDiscountPolicy 와 협력하더라도 문제가 없음. OverlappedDiscountPolicy 는 클라이언트에 대한 영향 없이도 DiscountPolicy 를 대체 가능함.
    따라서 리스코프 치환 원칙 만족.
  • 개방-폐쇄 원칙(OCP): 중복 할인 정책이라는 새로운 기능을 추가하기 위해 DiscountPolicy 의 자식인 OverlappedDiscountPolicy 를 추가해도 Movie 에는 영향이 없음. 기능 확장하면서 클라이언트를 수정할 필요가 없음.
    따라서 OCP 만족.

 

물론 클래스 상속을 사용하지 않고도, 서브 타이핑 관계를 구현할 수 있습니다. 클래스 상속은 단지 구현 방법입니다. 구현 방법은 중요하지 않습니다.

 

계약에 의한 설계와 서브타이핑

클라이언트와 서버 사이 협력을 의무와 이익으로 구성된 계약의 관점에서 표현하는 것을 계약에 의한 설계(Design By Contract, DBC) 라고 부릅니다.

 

DBC 는 클라이언트가 메서드를 실행하기 위해 만족시켜야 하는 사전조건(precondition)과 메서드가 실행된 후, 인스턴스가 실행된 후에 서버가 클라이언트에게 보장해야하는 사후조건(postcondition) , 메서드 실행 전, 후에 인스턴스가 만족시켜야 하는 클래스 불변식(class invariant) 이렇게 세가지로 구성됩니다.

 

DBC 와 리스코프 치환 원칙 사이의 관계는 이렇게 요약됩니다.

서브 타입이 리스코프 치환원칙을 만족시키기 위해서는 클라이언트가 슈퍼타입 간에 체결된 '계약' 을 준수해야 한다.

 

public class Movie {
    ...
    public Money calculateMovieFee(Screening screening) {
        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}
public abstract class DiscountPolicy {
    ...
    public Money calculateDiscountAmount(Screening screening) {
        for(DiscountCondition each : conditions) {
            if (each.isSatisfiedBy(screening)) {
                return getDiscountAmount(screening);
            }
        }

        return screening.getMovieFee();
    }

    abstract protected Money getDiscountAmount(Screening Screening);
}

 

지금까지 MovieDiscountPolicy 사이의 계약에 대해서는 크게 상관하지 않았습니다. 하지만 코드를 보면 사전조건, 사후 조건이 존재한다는 사실을 알 수 있습니다.

 

사전 조건

DiscountPolicycalculateDiscountAmount 메서드는 패러미터 screeningnull 인지 확인하지 않습니다. 만약 null 이 전달되면 screening.getMovieFee() 가 실행될 때 NullPointerException 이 던져질 것입니다.

 

calculateDiscountAmount 메서드는 screeningnot-null 이고 영화 시작 시간이 아직 지나지 않았다고 가정합니다.

assert screening != null && screening.getStartTime().isAfter(LocalDateTime.now());

 

MoviecalculateMovieFee 메서드는 DiscountPolicycalculateDiscountAmount 메서드의 리턴값를 바로 fee 에서 차감합니다. 즉 calculateDiscountAmount 메서드의 리턴값은 not-null 이고, 최소 0원보다 커야 합니다.

assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);

 

calculateDiscountAmount 메서드에 사전조건과 사후조건을 추가하면 아래처럼 되겠네요.

public abstract class DiscountPolicy {
    ...
    public Money calculateDiscountAmount(Screening screening) {
        checkPrecondition(screening);

        Money amount = Money.ZERO;
        for(DiscountCondition each : conditions) {
            if (each.isSatisfiedBy(screening)) {
                amount = getDiscountAmount(screening);
                checkPostcondition(amount);
                return amount;
            }
        }

        amount = screening.getMovieFee();
        checkPostcondition(amount);
        return amount;
    }

    protected void checkPrecondition(Screening screening) {
        assert screening != null &&
                screening.getStartTime().isAfter(LocalDateTime.now());
    }

    protected void checkPostcondition(Money amount) {
        assert amount != null && amount.isGreaterThanOrEqual(Money.ZERO);
    }


    abstract protected Money getDiscountAmount(Screening Screening);
}

 

그리고 calculateDiscountAmount 메서드가 정의한 사전 조건을 만족시키는 것은 Movie 의 책임입니다.

Movie 는 사전 조건을 위반하는 screening 을 전달하면 안됩니다.

public class Movie {
    ...
    public Money calculateMovieFee(Screening screening) {
        if (screening == null ||
                screening.getStartTime().isBefore(LocalDateTime.now())) {
            throw new InvalidScreeningException();
        }

        return fee.minus(discountPolicy.calculateDiscountAmount(screening));
    }
}

 

 

DiscountPolicy 의 자식인 AmountDiscountPolicy, PercentDiscountPolicy, OverlappedDiscountPolicyDiscountPolicycalculateDiscountAmount 메서드를 그대로 상속받기 때문에 계약을 만족시킵니다. 즉, Movie 입장에서 이 클래스들은 DiscountPolicy 를 대체할 수 있어 서브타이핑 관계입니다.

 

서브타입과 계약

DBC 에서 상속의 문제는 자식 클래스가 부모 클래스의 메서드를 오버라이딩할 수 있다는 것이 문제가 됩니다.

 

예를 들어서 DiscountPolicy 를 상속받는 BrokenDiscountPolicy 클래스가 있다고 합시다.

이 클래스는 calcualteDiscountAmount 메서드를 오버라이딩하고 사전 조건을 추가합니다. 종료 시간이 자정이 넘는 영화를 예매할 수 없다는 것입니다.

public class BrokenDiscountPolicy extends DiscountPolicy {

    public BrokenDiscountPolicy(DiscountCondition... conditions) {
        super(conditions);
    }

    @Override
    public Money calculateDiscountAmount(Screening screening) {
        checkPrecondition(screening);                 // 기존의 사전조건
        checkStrongerPrecondition(screening);         // 더 강력한 사전조건

        Money amount = screening.getMovieFee();
        checkPostcondition(amount);                   // 기존의 사후조건
        return amount;
    }

    private void checkStrongerPrecondition(Screening screening) {
        assert screening.getEndTime().toLocalTime()
                .isBefore(LocalTime.MIDNIGHT);
    }
    
    @Override
    protected Money getDiscountAmount(Screening screening) {
        return Money.ZERO;
    }
}

이 경우 Movie 는 오직 DiscountPolicy 의 사전 조건만 알고 있습니다. 

하지만 BrokenDiscountPolicy 는 더 강력한 사전 조건을 가지고 있죠. Movie 입장에서 자정이 지난 후에 종료되는 Screening 을 전달해도 문제가 없다고 가정하지만, BrokenDiscountPolicy 는 이를 허용하지 않아 협력이 실패합니다.

 

즉, 클라이언트 관점에서 BrokenDiscountPolicyDiscountPolicy 를 대체할 수 없기 때문에 서브타입이 아닙니다. 

비슷한 방식으로 사전조건에 더 약한 사전조건을 정의해본다면 계약을 위반하지 않습니다.

결론적으로 아래와 같은 결과를 얻을 수 있습니다.

 

서브타입에 더 강력한 사전조건을 정의할 수 없다.

서브타입에 슈퍼타입과 같거나 더 약한 사전조건을 정의할 수 있다.

 

그렇다면 만약 사후조건을 강화한다면?

public class BrokenDiscountPolicy extends DiscountPolicy {
    ...
    @Override
    public Money calculateDiscountAmount(Screening screening) {
        checkPrecondition(screening);                 // 기존의 사전조건

        Money amount = screening.getMovieFee();
        
        checkPostcondition(amount);                   // 기존의 사후조건
        checkStrongerPostcondition(amount);           // 더 강력한 사후조건
        return amount;
    }

    private void checkStrongerPostcondition(Money amount) {
        assert amount.isGreaterThanOrEqual(Money.wons(1000));
    }

}

이번에는 BrokenDiscountPolicyDiscountPolicy 에 정의된 사후조건인 amountnot-null 이고 0원보다 크며, 1,000원 이상이어야 한다는 사후조건을 추가했습니다.

 

MovieDiscountPolicy 의 사후조건만 알고 있기 때문에 최소 0원보다 큰 금액을 리턴받기만 하면 협력이 정상적으로 수행된다고 가정합니다. 따라서 BrokenDiscountPolicy 가 1,000 원 이상의 금액을 리턴하는 것은 계약을 위반하지 않습니다.

 

비슷한 방식으로 사후조건에 더 약한 사후조건을 정의해본다면 계약을 위반하게 됩니다.

결론적으로 아래와 같은 결과를 얻을 수 있습니다.

 

서브타입에 슈퍼타입과 같거나 더 강한 사후조건을 정의할 수 있다.

서브타입에 더 약한 사후조건을 정의할 수 없다.

 

 

계약에 의한 설계(DBC)는 클라이언트 관점에서의 대체  가능성을 계약으로 설명할 수 있다는 사실을 보여줍니다. 

서브타이핑을 위해 상속을 사용하고 있다면 부모 클래스가 클라이언트와 맺고 있는 계약에 관해 깊이 있게 고민해야 합니다.