The Square-Rectangle Problem를 만나다

박세환·2025년 1월 21일

⟪이펙티브 타입스크립트⟫를 읽던 중, 다음과 같은 코드 조각을 보았다.

interface Square {
  width: number;
}

interface Rectangle extends Square {
  height: number;
}

보통 정사각형은 직사각형의 부분집합으로 본다. 모든 정사각형은 직사각형이기 때문이다.
그런데 위의 코드에서는 직사각형이 정사각형의 서브클래스가 되어 있었다.

...?

정사각형은 width 하나만 있으면 되고, 직사각형은 여기에 더해 height가 있어야 하니, 직사각형이 정사각형을 상속하여 만들어지는 것이 머리로는 이해가 된다만, 현실 세계에서의 부분집합 관계와 반대가 되어 헷갈린다.

정사각형 클래스가 직사각형 클래스의 서브 클래스가 되도록 객체 관계를 모델링해봐야겠다.

class Rectangle {
    protected x: number;
    protected y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
    get area() {
        return this.x * this.y
    }
    get width() {
        return this.x
    }
    set width(w: number) {
        this.x = w
    }
    get height() {
        return this.y
    }
    set height(h: number) {
        this.y = h
    }
}

class Square extends Rectangle {
    constructor(x: number) {
        super(x, x)
    }
    set width(w: number) {
        this.x = w
        this.y = w
    }
    set height(h: number) {
        this.y = h
        this.x = h
    }
}

직사각형을 상속하여 정사각형을 만들어 보았다.
그런데 이렇게 하면 또 다른 문제가 발생한다.

The Square-Rectangle Problem

정사각형은 직사각형의 부분집합이다.
다시 말해, 정사각형은 직사각형처럼 동작해야 한다.

바로 위의 코드에 이어지는 부분이다.

const rec: Rectangle = new Square(20)
rec.height // 20
rec.width // 20
rec.width = 10
rec.area // 100??

정사각형은 직사각형처럼 동작해야 한다.
rec은 직사각형이다. 너비만 20에서 10으로 바꿨는데, 넓이가 200이 아닌 100이 되어 버렸다.
직사각형의 동작으로 이해할 수 없는 일이 일어났다.

이 문제를 The Square-Rectangle Problem이라고 한다.
현실 세계의 통념에 따라 직사각형을 상속하여 정사각형을 만들었는데, 정사각형이 직사각형을 대체할 수 없게 되어 버렸다.

이 문제는 내가 모델링한 정사각형 클래스와 직사각형 클래스가 리스코프 치환 원칙을 위배했기 때문에 발생한다고 볼 수 있다.

Liskov Substitution Principle

리스코프 치환 원칙. Liskov Substitution Principle을 줄여 LSP로 표현하기도 한다. 객체 지향 설계 원칙 SOLID 중 L을 담당하는 규칙이다.

리스코프 치환 원칙이란, 하위 타입이 상위 타입으로 교체되어도 동일한 동작을 수행해야 한다는 것을 의미한다.

정사각형 클래스는 직사각형 클래스의 서브 클래스이므로, 직사각형 클래스처럼 사용해도 문제가 없어야 한다. 그러나 위의 예시에서 rec의 width, height setter는 직사각형의 setter처럼 동작하지 않는다.

잘못 설계된 객체 관계라고 볼 수 있다.
어떻게 해결해야 할까?

해결법

정사각형과 직사각형을 상속을 통해 객체로 만들었을 때 호환되지 않는다면, 굳이 상속을 사용하지 않아도 된다.

공통 속성을 인터페이스로 분리하고, 정사각형과 직사각형을 별도의 클래스로 만들면 LSP에 위배되지 않는다.

interface Shape {
    readonly area: number;
}

class Rectangle implements Shape {
    protected x: number;
    protected y: number;
    constructor(x: number, y: number) {
        this.x = x;
        this.y = y;
    }
    get area() {
        return this.x * this.y
    }
    get width() {
        return this.x
    }
    set width(w: number) {
        this.x = w
    }
    get height() {
        return this.y
    }
    set height(h: number) {
        this.y = h
    }
}

class Square implements Shape {
    protected x: number;
    constructor(x: number) {
        this.x = x
    }
    get area() {
        return this.x ** 2
    }
    get width() {
        return this.x
    }
    set width(w: number) {
        this.x = w
    }
    get height() {
        return this.x
    }
    set height(h: number) {
        this.x = h
    }
}

이 방식의 단점은 크게 2가지가 있다.

  1. 현실 세계의 정사각형과 직사각형의 관계를 표현할 수 없다. 이제 Square와 Rectangle은 부모 자식 관계가 아니다.
  2. 코드 중복이 늘어난다.

결론

현실 속의 포함 관계를 객체로 완전히 동일하게 표현하는데 한계가 존재한다는 사실을 알게 되었다.

상속을 통해 객체를 표현해야 한다면, 개념적으로 하위 타입일 때뿐만 아니라 객체의 행동까지도 완전히 대체할 수 있을 때 가능하다는 점을 기억하자.

profile
And I'm ready to dive

0개의 댓글