상속의 용도는 크게 2가지라 볼 수 있다.

  • 타입 계층 구현
  • 코드 재사용

코드 재사용은 부모-자식 간 높은 결합도로 인해 변경하지 어려운 코드가 된다. 상속은 코드 재사용이 아닌 타입 계층을 구현하는데 사용하자.

동일한 메시지에 대해 서로 다르게 행동할 수 있는 다형적 객체를 구현해야 한다. 이를 위해선 객체의 행동을 기반으로 타입 계층을 구성해야 한다. 상속은 이러한 타입 계층을 구현할 때 유용하다.

타입


개념 관점의 타입

의미
사물의 종류 (e.g. 프로그래밍 언어)

인스턴스
어떤 대상이 타입으로 분류될 때 그 대상 (e.g. Java, C++)
일반적으로 타입의 인스턴스를 객체라 부른다.

구성

  • 심볼 : 타입의 이름
  • 내연 : 타입에 속하는 객체들의 공통적인 속성과 행동
  • 외연 : 타입에 속하는 객체들의 집합

프로그래밍 언어 관점

의미
연속적인 비트에 의미와 제약을 부여
(비트 자체는 타입이라는 개념이 없다. 비트에 담긴 데이터를 문자열로 다룰지 정수로 다룰지는 전적으로 어플리케이션에서 결정한다.)

목적

  • 타입이 수행될 수 있는 유효한 오퍼레이션의 집합 정의
  • 타입이 수행되는 오퍼레이션에 대해 미리 약속된 문맥을 제공
    (e.g. a + b -> String, int 여부에 따라 + 연산자의 문맥이 제공된다.)

타입은 적용 가능한 오퍼레이션의 종류와 의미를 정의함으로써 코드의 의미를 명확하게 전달하고 개발자의 실수를 방지하기 위해 사용된다 볼 수 있다.

객체지향 패러다임 관점

오퍼레이션
객체가 수신할 수 있는 메시지

의미
객체가 수신할 수 있는 메시지의 종류 (퍼블릭 인터페이스)를 정의하는 것

즉, 객체지향에서 타입은 퍼블릭 인터페이스를 정의하는 것이라 볼 수 있으며 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류됨을 알 수 있다.

어떤 객체들이 동일한 상태를 가져도 퍼블릭 인터페이스가 다르면 서로 다른 타입으로 분류된다.

타입 계층


타입 계층을 구성하는 타입은 크게 2가지로 나뉜다.

  • 슈퍼 타입 : 일반적인 타입
  • 서브 타입 : 특수적인 타입

객체지향 관점

  • 일반적인 타입
    • 비교 대상 타입에 속한 객체의 퍼블릭 인터페이스보다 더 일반적인 퍼블릭 인터페이스를 가지는 객체의 타입
  • 슈퍼 타입 : 서브 타입이 정의한 퍼블릭 인터페이스를 일반화시켜 상대적으로 범용적이고 넓은 의미로 정의한 것
  • 서브 타입 : 슈퍼 타입이 정의한 퍼블릭 인터페이스를 특수화시켜 상대적으로 구체적이고 좁은 의미로 정의한 것

중요
일반적인 타입과 구체적인 타입간의 관계를 형성하는 기준은 퍼블릭 인터페이스이다.

서브클래싱과 서브타이핑


서브클래싱

  • 다른 클래스의 코드를 재사용할 목적으로 사용 (클래스 상속)
    서브타이핑
  • 타입 계층을 구성하기 위해 상속을 사용하는 것 (인터페이스 상속)

이 둘은 상속을 사용하는 목적에 따라 구분이 된다. 재사용을 목적으로 하면 서브클래싱이며 부모 클래스의 인스턴스 대신 자식 클래스의 인스턴스를 사용할 목적일아면 서브타이핑이다. 상속이 안좋다고 하는 상황은 서브클래싱에 속한다.

상속을 사용하는 경우

상속 관계가 is-a 관계를 모델링하는가?
상속 관계가 is-a 관계를 모델링한다고 했다. 여기서 is-a 관계는 행동에 초점을 둬야 하며 클라이언트 관점에서 말하는 것이다. 다음 문장을 살펴보자.

e.g.

  • 펭귄은 새이다.
  • 새는 날 수 있다.
    여기서 펭귄은 새라고 할 수 있을까? 현실 세계에선 가능할진 몰라도 객체지향 세계에선 불가능하다.
    펭귄은 날지 못하므로 "새" 라는 객체의 행동을 가질 수 없기 때문이다.

행동 호환성

앞서 is-a 관계에서 행동의 초점을 클라이언트 입장에서 바라봐야 한다고 했다. 이 말은 즉 클라이언트 입장에서 부모 클래스 타입으로 자식 클래스를 사용해도 무방한가?를 뜻한다. 클라이언트는 자식-부모 인스턴스의 차이점을 몰라야 한다.

Penguin 클래스는 현재 Bird 클래스의 자식 클래스다. 따라서, Birdfly() 메서드를 오버라이딩해야 된다. 바꿔말하면 사용자 입장에서 Penguin 객체에 fly() 메서드를 호출하는게 당연하다는 뜻이다. 그러나 설계는 그렇지 않다.

펭귄은 날 수 없으므로 fly() 메서드를 오버라이딩하는게 어색하다. "행동"에 초점을 두지 않아 발생한 문제다. 이렇게 부모-자식 클래스간 행동이 호환 여부를 행동 호환성이라 하며 지금 상황은 행동 호환성을 만족하지 못하므로 올바른 타입 계층이라 볼 수 없다.

이를 극복하기 위한 방법으로 크게 2가지가 있다.

  • fly() 메서드 내부 구현 비우기
  • fly() 호출 시 예외 발생시키기

두 방법 모두 클라이어트 입장에서 기대한 결과일까? 전혀 아니다.

추가로 instanceof 를 활용하는 방법이 있다. 그러나 이는 확장에 불리하고 구체적인 클래스에 대한 결합도를 높인다. 이처럼 객체의 타입을 확인하는 코드는 새로운 타입을 추가할 때 마다 코드 수정을 요구하므로 OCP를 위반한다. 가급적이면 사용하지말자.

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

앞서 Bird 의 자식 클래스로 Penguin을 두면 다양한 문제가 발생했다. 크게 2가지 방법으로 계층을 분리하여 이 문제들을 해결해보자.

1. 클라이언트의 기대에 따라 상속 계층을 분리

Penguin 인스턴스에 fly 메시지를 전송할 수 있는 방법이 없으므로 앞서 언급한 문제들이 해결된다. 만약, 펭귄은 걸을 수 있다고 가정하면 어떨까? Penguinwalk()라는 메서드를 구현해야 한다.

결과적으로 행동을 기반으로 계층을 분리해야 클라이언트 기대에 맞는 설계가 가능해진다.

2. 클라이언트의 기대에 따른 인터페이스 분리

특정 클라이언트가 walk()라는 메시지를 전송하길 원하면 이 클라이언트에게는 walk()라는 메시지만 보여야 한다. 가장 좋은 방법은 이처럼 오퍼레이션을 기준으로 인터페이스를 분리하는 것이다. 덕분에 BirdPenguin은 독립적으로 자신이 수행할 수 있는 인터페이스만 구현할 수 있다.

Peuguin에서 Bird의 코드를 재사용하는 경우엔 1번이 유리해보인다. 그러나, 이는 Penguin 내부 규칙을 와해한다. 상속을 재사용 목적으로 사용하면 안된다는 걸 다시 한 번 명심하자. 재사용은 2번을 사용 + 합성으로 충분히 대체가 가능하다.

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

클라이언트에 따라 인터페이스를 분리하면 변경에 대한 영향을 더 세밀하게 제어할 수 있다. 대부분의 경우 인터페이스는 클라이언트의 요구가 바뀜에 따라 변경된다.

2번 그림에서 Client1의 기대가 바뀌어 Flyer 인터페이스 변경이 발생했다 생각해보자. 이는 Flyer를 의존하는 Bird 가 영향을 받지만 다른 클래스나 클라이언트는(Penguin, Client2)는 영향을 받지 않는다.

이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 인터페이스 분리 원칙(Interface Segregation Principle, ISP)라고 한다. 이는 비대한 인터페이스를 특화된 여러 개의 인터페이스로 나눠 변경을 세밀하게 관리함으로써 설계를 더 유연하게 한다.

서브타이핑

서브타이핑의 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 이를 위해선 행동 호환성을 만족해야 한다. 더 나아가, 부모 클래스가 사용되는 문맥에 자식 클래스가 동일하게 사용될 수 있어야 한다.

다시 말해 자식-부모 클래스간 행동 호환성은 부모 클래스에 대한 자식 클래스이 대체 가능성을 포함한다.

LSP


서브타입은 그것의 기반 타입(부모 클래스)에 대해 대체 가능해야 한다.
(이 때, 대체 가능 여부를 결정하는 건 클라이언트이다.)

클라이언트가 차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브 클래스(자식 클래스)를 사용할 수 있어야 한다. 즉, 부모 클래스에 대한 클라이언트의 가정을 자식 클래스가 준수해야 한다는 것이다. 자식 클래스가 부모 클래스를 대체할 수 있을 때 상속을 사용해야 한다.

위반 사례

Stack, Vector

  • 클라이언트가 부모 클래스인 Vector에 대해 기대하는 행동을 Stack에 대해서는 기대할 수 없다. (행동 호환성 만족 X)
  • 포함되면 안되는 부모 클래스의 퍼블릭 인터페이스(e.g. add(), get())를 자식 클래스에 포함

Penguin, Bird

  • PenguinBird의 행동(fly())을 기대할 수 없다.

Rectangle, Square

  • Rectangle과 협력하는 클라이언트는 직사각형의 너비와 높이가 다르다고 가정한다.
  • 아래 메서드는 Squre(정사각형)에서 호환되지 않는다.
public void resize(Rectangle rectangle, int width, int height) {
	rectangle.setWidth(width);
    rectangle.setHeight(height);
}
  • 정사각형이 직사각형에 포함되긴하나 클라이언트의 기대여부가 다르므로 서브클래싱 관계라 볼 수 있다. 마찬가지로 행동 호환성을 만족하지 않는다.

LSP는 유연한 설계의 기반

LSP는 클라이언트가 어떤 자식 클래스와도 안정적으로 협력할 수 있는 상속 구조 구현 가이드라인을 제공하여 유연성과 확장성을 높인다. 아래 예제는 DIP, LSP, OCP를 모두 준수하는 코드이다. 이를 통해 LSP가 어떻게 OCP를 준수하게 유도하는지 살펴보자.

OverlappedDiscountPolicy.java

public class OverlappedDiscountPolicy extends DiscountPolicy {
    private List<DiscountPolicy> discountPolicies = new ArrayList<>();

    public OverlappedDiscountPolicy(DiscountPolicy ... discountPolicies) {
        this. discountPolicies = Arrays.asList(discountPolicies);
    }

    @Override
    protected Money getDiscountAmount(Screening screening) {
        Money result = Money.ZERO;
        for(DiscountPolicy each : discountPolicies) {
            result = result.plus(each.calculateDiscountAmount(screening));
        }
        return result;
    }
}

DIP

  • 구체 클래스 Movie, XXXDiscountPolicy 모두 추상체(DiscountPolicy)에 의존
  • 상위 모듈 Movie, 하위 모듈 XXXDiscountPolicy 모두 추상체(DiscountPolicy)에 의존

LSP

  • Movie(클라이언트)와 XXXDiscountPolicy는 모두 협력 가능
    즉, 특정 XXXDiscountPolicy 는 클라이언트 영향 없이 DiscountPolicy 대체 가능

OCP

  • Movie(클라이언트)를 수정하지 않고 DiscountPolicy 구현체를 추가함으로써 기능 추가 가능

즉, LSP는 OCP 만족하기 위한 잠재적 조건이라 볼 수 있다. 바꿔말하면 LSP 위반시 OCP 위반 가능성이 높아진다.

계약에 의한 설계


클라이언트-서버 사이의 협력을 의무와 이익으로 구성된 계약의 관점으로 표현하는 것을 뜻한다.

구성

  • 사전 조건
    • 클라이언트가 정상적으로 메서드를 실행하기 위해 만족해야 하는 조건
  • 사후 조건
    • 메서드 실행 후 서버가 클라이언트에게 보장해야 하는 조건
  • 클래스 불변식
    • 메서드 실행 전후에 인스턴스가 만족해야 하는 조건

e.g. 사전/사후 조건
Movie.java

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

DiscountPolicy.java

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);
}

Movie.caculateMovieFee(Screening screening) 을 기준으로 사전/사후 조건을 살펴보자.

  • 사전 조건

    screening != null && screening.getStartTime().isAfter(LocalDateTime.now());
    
  • 사후 조건

    amount != null && amount.isGreaterThanOrEqaul(Money.ZERO);

서브클래스와 서브 타입

서브 타입 ⊂ 서브 클래스

클라이언트 입장에서 서브 타입은 슈퍼 타입의 한 종류여야 한다. 이는 목적에 따라 구분하며 재사용 목적이라면 서브 타입이라할 수 없다.

LSP는 어떤 타입이 서브 타입이 되기 위해선 슈퍼 타입의 인스턴스와 협력하는 클라이언트 관점에서 서브 타입의 인스턴스가 슈퍼타입을 대체해도 지장이 없어야 함을 의미한다.

서브타입과 계약

계약의 관점에서 상속의 가장 큰 문제점은 오버라이딩이다. 이 때 반드시 준수해야 되는 규칙이 있다.

  • 서브타입에 더 강력한 사전 조건을 정의할 수 없다.
    • 같거나 더 약한 사전 조건만 정의 가능하다.
  • 서브 타입에 더 약한 사후 조건을 정의할 수 없다.
    • 더 강한 사후 조건만 정의 가능하다.

(더 강한 사후 조건) ⊂ (사후 조건) 이므로 클라이언트 입장에서 응답값이 변질되지 않는다. 그러나, 사전 조건이 더 강해지면 파라미터 문맥이 제한되므로 클라이언트 입장에서 사용이 이전과 달라진다. 이는 LSP를 위반한다고 볼 수 있다.

0개의 댓글