LSP(리스코프 치환 법칙)

Yeongjong Kim·2022년 4월 26일
0
post-thumbnail

리스코프 치환 법칙

오늘은 OOP의 5대 원칙이라는 SOLID에서 L에 해당하는, 리스코프 치환 법칙에 대해 알아보려고 한다. 리스코프 치환 법칙은 상위 클래스의 객체를 하위 클래스의 객체로 치환했을 때, 동작에 문제가 없어야 한다는 법칙이다.

무슨 의미이며 왜 이런 법칙이 생겼을까? 짐작해보면, 하위 클래스에서 상위 클래스의 필드를 생각없이 재정의하는 경우 재정의된 코드는 수퍼클래스가 생각했던 기능처럼 동작하지 않을 확률이 높다. 즉, 프로그램 규모가 커지게 되면 같은 뿌리로 부터 얻은 동일한 기능이 여러 갈래로 재각각 동작하게 될 것이다. 개발자는 통일되지 않은 기능을 사용하게 되어 오류가 발생하지 않을지 계속 신경쓸 수 밖에 없는 방어적인 코드를 작성해야만 한다. 혹은 기존에 작성된 코드를 계속 수정해야하는 불상사가 벌어질 수 도 있다. 이러한 문제를 사전에 방지하고자 이 법칙이 존재하는 듯 하다.

따라서 잘못된 상속을 기피해야한다. LSP를 지키는게 절대적으로 좋은 상속이라고 할 수는 없지만, 적어도 상속을 고려할 때는 이 상속 구조가 LSP를 위반 할 수 밖에 없는 구조인지는 생각해보자.

무조건 LSP를 위반하게 되는 케이스

  1. 서브클래스에서 수퍼클래스의 메서드를 거부해야 하는가?
  2. 서브클래스가 수퍼클래스의 메서드를 퇴화시켜야 하는가?

위반 예시1

대표적인 예시로 직사각형과 정사각형을 다룹니다. 두 기능을 클래스로 만들려고 합니다. 방법은 3가지가 있습니다.

  1. 직사각형(부모)을 상속받은 정사각형(자식)을 만든다.
  2. 정사각형(부모)을 상속받은 직사각형(자식)을 만든다.
  3. 각각 구현.

각각 어떤 특징이 있을까요?

1. 직사각형(부모)을 상속받은 정사각형(자식)을 만든다.

상식적으로 생각해보면 정사각형은 직사각형의 한 종류이기 때문에 1번으로 구현하는 것이 옳바른 방법이라고 생각됩니다. 하지만...!

class Rectangle {
  protected _width: any;
  protected _height: any;

  constructor(width, height) {
    this._width = width;
    this._height = height;
  }

  get width() {
    return this._width;
  }

  set width(width) {
    if (width < 0) throw new Error('width는 0보다 작을 수 없습니다.');
    this.width = width;
  }

  get height() {
    return this._height;
  }

  set height(height) {
    if (height < 0) throw new Error('height는 0보다 작을 수 없습니다.');
    this.height = height;
  }
}

class Square extends Rectangle {
  constructor(sideLength) {
    super(sideLength, sideLength);
  }

  get width() {
    return this._width;
  }

  set width(width) {
    if (width < 0) throw new Error('width는 0보다 작을 수 없습니다.');

    this._width = width;
    this._height = width;
  }

  get height() {
    return this._height;
  }

  set height(height) {
    if (height < 0) throw new Error('width는 0보다 작을 수 없습니다.');

    super._height = height;
    super._width = height;
  }
}

Suare는 Rectangle의 width 접근 제한자를 반드시 재정의 해야 합니다. 정사각형은 width와 height가 같아야 하기 때문에, 상속받은 set width를 사용하게 되면 반드시 set heigth도 실행되야하기 때문이죠. 즉 자식이 거부해야만하는 기능을 부모가 포함하고 있습니다. 부모의 기능을 거부한다면 리스코프 치환 원칙에 위배됩니다.

자 이상태에서 new Rectangle(10, 10) 과, new Square(10)을 바꿔봅시다.

let rect: Rectangle = new Rectangle(10, 10);
let sqre = new Square(10);

rect = sqre // 치환

rect.width = 20;

console.log(rect.width, rect.height); // 20, 20

위의 코드에서 리스코프 치환 원칙에 의해 치환이 되더라도 동작에 문제가 없어야 하기 때문에 width가 20이고 height은 10이어야 하죠. 하지만 결과는 width를 20으로 바꿨을 뿐인데 height도 20으로 바뀌었습니다. 모순이 생긴것이죠. 소규모 프로젝트에서는 위와 같은 일이 발생하지 않겠지만, 프로제트 규모가 커질수록 문제가 발생될 것이라 짐작할 수 있습니다.

2. 정사각형(부모)을 상속받은 직사각형(자식)을 만든다.

정사각형을 직사각형으로 상속하더라도 setter width를 했들 때 height는 수정이 되지 않도록 재정의가 필요합니다. 이 경우에도 정사각형의 정합성을 부수는 예가 되기 때문에 적합하지 않다.

3. 각각 클래스로 만든다.

class Rectangle {
  constructor(width, height) {
    this._width = width;
    this._height = height;
  }
  
  get width() {
    return this._width;
  }
  
  set width(width) {
    if (width < 0) throw new Error('width는 0보다 작을 수 없습니다.')
    this.width = width;
  }
  
  get height() {
    return this._height;
  }
  
  set height(height) {
    if (height < 0) throw new Error('height는 0보다 작을 수 없습니다.')
    this.height = height;
  }
}

class Square {
  constructor(sideLength) {
    this._width = sideLength;
    this._height = sideLength;
  }
  
  get width() {
    return this._width;
  }
  
  set width(width) {
    if (width < 0) throw new Error('width는 0보다 작을 수 없습니다.')
    this.width = width;
    this.height = width;
  }
  
  get height() {
    return this._height;
  }
  
  set height(height) {
    if (height < 0) throw new Error('height는 0보다 작을 수 없습니다.')
    this.height = height;
    this.width = height;
  }
}

각각 클래스로 만들어 버리면 중복된 코드가 생기며 코드가 길어지게 된다. 즉 가독성 뿐만아니라, 유지보수도 힘들어질 것이다. 따라서 적합한 수퍼클래스를 만들어 직사각형과 정사각형이 모두 이 클래스를 상속받는 구조로 만들어야 한다.

위반 예시2

String(불변), StringMutable(변)

이 경우에도 3가지 방법이 존재한다.

  1. StringMutable을 상속받은 String
  2. String을 상속받은 StringMutable
  3. 각각 구현

간단하게 정리해 보면,

1번의 경우 부모 클래스(StringMutable)의 기능중 하나인 수정을 String이 거부해야한다. 이렇게 되면 추후 두 가지 타입을 모두 입력받을 수 있는 상황에서 치환이 불가능해지며, 문제가 발생할 수 있다.

2번의 경우 기존의 클래스에서 기능을 확장한 형태이다. 이론상으로 치환시 문제가 되지 않으며 LSP를 위반 하지 않기 때문에 적절한 예이다.

3번의 경우 공유되는 코드가 하나도 없으며 치환자체가 불가능하기 때문에 비효율 적이다.

사소하게 알게된 것

  1. js에서 접근 제한자(getter, setter)는 상속이 안된다.
  2. 1번의 이유로 접근 제한자를 상속하려면 super로 해당 기능을 호출해야 한다.

결론

  1. 클래스에서 다형성을 지원하는 overriding 기술은 유연함이라는 장단점을 가진 양날의 검이다.
  2. overriding을 통해 클래스가 다양하게 파생되면 기능을 유추하기 어려우며, 초기 클래스의 정의에서 점점 벗어나게 될 것이다.
  3. 2번의 이유로 프로덕트의 구조가 복잡해질 가능성이 크다.
  4. LSP(리스코프 치환 법칙)는 상위 클래스의 객체를 하위 클래스의 객체로 치환했을 때 문제가 없는지 확인하는 방식으로 초기 클래스의 목적을 보존한다.
profile
Front 💔 End

0개의 댓글