[오브젝트] 서브클래싱과 서브타이핑

서브클래싱과 서브타이핑

상속의 첫번째 용도는 타입 계층의 구현이다. 타입 계층 관점에서 부모 클래스는 자식 클래스의 일반화이고 자식 클래스는 부모 클래스의 특수화이다. 상속의 두번째 용도는 코드 재사용이다. 상속은 간단한 선언으로 부모 클래스 코드의 재사용을 돕는다. 하지만 코드 재사용을 목적으로 상속을 사용할 경우 자식 클래스와 부모 클래스는 강하게 결합된다. 그렇기 때문에 상속을 사용하는 목표를 타입 계층의 구현으로 두어야 한다. 타입 계층을 목표로 상속을 사용하면 다형적으로 동작하는 객체들의 관계에 기반해 확장 가능하고 유연한 설계를 얻을 수 있다. 결론적으로 동일한 메세지에 대해 서로 다르게 행동할 수 있는 다형적인 객체를 구현하기 위해서는 객체의 행동을 기반으로 타입 계층을 구성해야 한다.

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

타입은 두 관점으로 정의할 수 있다.

  • 개념 관점에서 타입은 공통의 특징을 공유하는 대상들의 분류다.
  • 프로그래밍 언어 관점에서 타입이란 동일한 오퍼레이션을 적용할 수 있는 인스턴스들의 집합이다.

객체지향 프로그래밍에서 오퍼레이션은 객체가 수신할 수 있는 메세지를 의미한다. 따라서 객체의 타입이란 객체가 수신할 수 있는 메세지의 종류를 정의하는 것 이다. 더 쉽게 말해 퍼블릭 인터페이스를 정의하는 것 이다. 개념 관점에서 타입은 공통의 특징을 공유하는 대상들의 분류를 위한 기준이라 했다. 즉 객체지향에서 객체가 수신할 수 있는 메세지를 기준으로 타입을 분류하기 때문에 동일한 퍼블릭 인터페이스를 가지면 동일한 타입으로 객체를 분류할 수 있다. 따라서 객체는 다음과 같이 정의할 수 있다.

객체의 퍼블릭 인터페이스가 객체의 타입을 결정한다. 따라서 동일한 퍼블릭 인터페이스를 제공하는 객체들은 동일한 타입으로 분류된다.

여기서 중요한 점이 있다. 객체에게 중요한 점은 행동이라는 점이다.

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

마틴 오더스키는 다음 질문에 모두 예라고 답할 수 있는 경우에만 상속을 사용하라 조언한다.

  • 상속 관계가 is-a 관계를 모델링 하는가?
  • 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?

is-a 관계는 직관적이고 명쾌하지 않다. 스콧 마이어스는 펭귄과 새의 예를 통해 is-a관계가 쉽게 직관을 배신함을 보여준다.

  • 펭귄은 새다.
  • 새는 날 수 있다.

이를 통해 만들어지는 코드는 다음과 같다.

1
2
3
4
5
6
7
8
public class Bird {
public void fly() { ... }
...
}

public class Penguin extends Bird {
....
}

펭귄은 새지만 날 수 없다. 하지만 코드에서는 펭귄은 날 수 있다고 표현하고 있다. 이 문제를 이해하기 위해선 행동 호환성에 대해 인지해야 한다. 부모 클래스 타입으로 자식 클래스를 사용하고자 할 때 이를 사용하는 클라이언트는 부모 클래스와 자식 클래스의 차이를 몰라야 한다. 즉, 두 타입 사이에 행동이 클라이언트 관점에서 호환될 경우에만 타입 계층으로 묶어야 한다. 이를 무시하고 위의 문제를 해결하는 방법들에는 리스크가 따른다.

1
2
3
4
5
public class Penguin extends Bird {
...
@Override
public void fly() {}
}

이 방법은 펭귄에게 fly 메세지를 전달하더라도 아무 일이 일어나지 않는다. 하지만 모든 새가 날 수 있다는 클라이언트의 기대에 충족하지 못한다.

1
2
3
4
5
6
7
public class Penguin extends Bird {
...
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}

이 방법 또한 마찬가지이다. 클라이언트는 fly 메세지를 전달한 결과로 UnsupportedOperationException 예외를 던질 것 이라는 기대를 하지 않는다.

1
2
3
4
5
public void flyBird(Bird bird) {
if(!(bird instanceof Penguin)) {
bird.fly();
}
}

이 방법은 펭귄 외에 날 수 없는 새가 상속 계층에 추가되면 또 다른 변경을 불러 일으킨다. 즉 OCP를 위반하는 것이다. 그렇기 때문에 이 문제를 해결하기 위해선 클라이언트의 기대에 따라 계층을 분리해야 한다. 따라서 다음과 같은 두 구조를 생각해볼 수 있다.

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

위 구조는 날지 못하는 새와 협력하는 Bird와 날 수 있는 새와 협력하는 FlyingBird로 분리했다. 따라서 펭귄이 새를 대체하더라도 문제가 없고 펭귄에게 fly 메세지를 전달할 일도 없다.

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

위 구조는 만약 한 클라이언트가 오직 fly 메세지만을 전달하기 원하고 다른 클라이언트는 walk 메세지를 전달하기 원할 때 고려할 수 있다. fly 오퍼레이션을 가진 Flyer와 walk 오퍼레이션을 가진 Walker 인터페이스로 분리하는 방법이다. 이처럼 인터페이스를 클라이언트의 기대에 따라 분리함으로써 변경에 의해 영향을 제어하는 설계 원칙을 ISP(Interface Segregation Principle, 인터페이스 분리 원칙)이라 한다.

서브 클래싱과 서브 타이핑

상속을 사용하는 목적은 서브 클래싱과 서브 타이핑으로 나눌 수 있다.

  • 서브 클래싱 : 다른 클래스의 코드를 재사용할 목적으로 상속을 사용하는 경우. 구현 상속 혹은 클래스 상속이라 부르기도 한다.
  • 서브 타이핑 : 타입 계층을 구성하기 위해 상속을 사용하는 경우. 인터페이스 상속이라 부르기도 한다.

서브 타이핑 관계가 유지되기 위해서는 서브타입이 슈퍼타입이 하는 모든 행동을 동일하게 할 수 있어야 한다. 즉, 어떤 타입이 다른 타입의 서브타입이 되기 위해서는 행동 호환성을 만족시켜야 한다. 또한 자식 클래스가 부모 클래스를 대신할 수 있기 위해서는 자식 클래스가 부모 클래스가 사용되는 모든 문맥에서 동일하게 행동할 수 있어야한다. 그리고 행동 호환성을 만족하는 상속 관계는 부모 클래스를 새로운 자식 클래스로 대체하더라도 시스템이 문제 없이 동작할 것을 보장해야 한다. 즉 대체 가능성을 포함해야 한다.

리스코프 치환 원칙

리스코프 치환 원칙은 서브타입은 그것의 기반 타입에 대해 대체 가능해야 한다는 의미로 클라이언트가 차이점을 인식하지 못한 채 기반 클래스의 인터페이스를 통해 서브 클래스를 사용할 수 있어야 한다는 의미이다. 즉, 자식 클래스가 부모 클래스와 행동 호환성을 유지함으로써 부모 클래스를 대체할 수 있도록 구현된 상속 관계만을 서브타이핑이라 불러야 한다. 위에서 든 예제인 새와 펭귄의 관계도 LSP를 위반한다. 그렇다면 이 예는 어떨까?

  • 정사각형은 직사각형이다.

위 말을 기반으로 코드를 짜면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
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;
}

public int getWidth() {
return width;
}

public void setWidth(int width) {
this.width = width;
}

public int getHeight() {
return height;
}

public void setHeight(int height) {
this.height = height;
}

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

Square는 Rectangle의 자식 클래스이기 때문에 Rectangle이 사용되는 모든 곳에서 Rectangle로 업케스팅이 가능하다. 문제는 이제부터 시작된다. 다음 코드를 보자.

1
2
3
4
5
public void resize(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
assert rectangle.getWidth() == width && rectangle.getHeight() == height;
}
1
2
Square square = new Square(10, 10, 10);
resize(square, 50, 100);

위 코드는 실패한다. resize 메소드의 관점에서 Rectangle 대신 Square를 사용할 수 없으니 Square는 Rectangle이 아니다. 단순히 Square는 Rectangle을 재사용할 뿐이다. 그리고 LSP를 위반하고 있으니 두 클래스는 서브 타이핑 관계가 아닌 서브 클래싱 관계다. 중요한 것은 클라이언트 관점에서 행동이 얼마나 호환되는지 이다. 결과적으로 LSP는 클라이언트와 격리한 채로 본 모델은 의미 있게 검증하는 것이 불가능하다는 결론을 이끌어 낸다.

Author: Song Hayoung
Link: https://songhayoung.github.io/2021/01/20/Object/object13/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.