SOLID 원칙 - (3) 리스코프 치환 원칙 (LSP)



(3) 리스코프 치환 원칙 (LSP)

👴 A program that uses an interface must not be confused by an implementation of that interface.

말이 너무 어렵다! 보통은 이렇게 말하는 것 같다. 상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

리스코프 치환 원칙은 기능 확장함수의 명세와 관련된 원칙이다. 또한 리스코프 치환 원칙을 어기면 개방 폐쇄 원칙을 위반할 가능성이 높아진다.


SOLID 원칙 - (2) 개방 폐쇄 원칙 (OCP)에서의 예시를 보자.

public class Character {
    public void fight() {
        combo();
    }
    protected void combo() {
				Q(); W(); E(); R();
    }
}
public void 한타(Character character) {
		character.fight();
}

위와 같은 한타() 메소드를 사용할 때, Character를 상속받은 야스오티모한타()의 파라미터로 전달해도 한타 메소드는 잘 동작해야한다는 원칙이다.

한타(new 야스오());
한타(new 티모());


LSP를 어기는 경우

리스코프 치환 원칙을 위반하는 경우에는 두 가지 경우가 있다.


EX1) 계약 위반

첫번째는 계약 위반이다. 함수를 사용할 때 아래와 같이 정해진 계약 혹은 명세를 지키지 않는 경우다.

1. 이상한 값 리턴
2. 이상한 기능 수행
3. 이상한 Exception 발행


이상한 기능을 수행하는 경우

(상황 ) 티모는 롤 캐릭터 중 하나이다. 롤 캐릭터는 한타 때 적극적으로 싸워야한다. 스킬 콤보를 넣는 fight() 메소드를 이용해서 싸우려고 한다. 근데 티모라는 캐릭터는 싸우지않고 점멸을 이용해서 혼자 뒤로 뺀다.

티모는 롤 캐릭터이면서도 이상한 기능을 수행한다.

public class 티모 extends Character {}
public void 한타(Character character) {
    if(character instanceof 티모){
        점멸(); //팀원들 다 싸우고 있는데 혼자 뒤로 빼는거
    }
    else {
        character.fight();
    }
}

이상한 Exception을 발행하는 경우

(상황 ) 앞서 말했듯이 한타 때 스킬 콤보를 넣으면서 싸워야한다. 근데 티모는 “나는 콤보 없는데용?ㅋ”하며 이상한 Exception을 발행한다.

public void 한타(Character character) {
    if(character instanceof 티모){
        throw new ComboNotExistException(); //나는 콤보 없는데용?
    }
    else {
        character.fight();
    }
}

위와 같은 예시가 리스코프 치환 원칙을 위반한 경우다. 티모는 상위 타입인 Character를 상속받았음에도 불구하고 Character를 사용하는 한타 메소드에서 제대로 동작하지 않는다. 제대로 동작하려면 추가적인 처리가 필요하다.

LSP를 지키도록 하려면?

따라서 티모는 상위 타입인 Character를 대체할 수 없다고 판단하고 Character가 아닌 Troll을 상속받는다.

// 티모는 정상적인 캐릭터가 아니라 트롤이라고 가정합니다
public class 티모 extends Troll {}


EX2) 확장 위반

확장을 하는데, 확장함으로써 기존 코드를 수정해야 하는 경우다. 상위타입만으로 프로그래밍을 할 수 없기 때문이다.


(상황 ) 사실상 티모는 스킬 콤보가 딱히 없다. 그냥 기본공격(평타)으로 패면 된다.

// 기존 코드
public void 한타(Character character) {
		character.fight();
}
// 티모라는 새로운 캐릭터가 추가됨
public void 한타(Character character) {
    if(character instanceof 티모){
        기본공격();
    }
    else {
        character.fight();
    }
}

한타에 스킬 콤보를 넣어서 싸워야 하는데, 스킬 콤보가 없는 티모라는 캐릭터가 생겼기때문에 티모는 콤보 없이 기본공격만 하겠다는 코드를 새롭게 추가해주어야한다.

만약 콤보가 없는 새로운 캐릭터가 또 추가된다면 기존 코드는 계속 수정해주어야 한다.

public void 한타(Character character) {
  if(character instanceof 티모1){
      기본공격();
  }
	else if(character instanceof 티모2){
      기본공격();
  }
  else {
      character.fight();
  }
}


🤔 그럼 어떻게 해결할 수 있을까? 🤔

답은 추상화다!

변경되는 부분을 새롭게 추상화시키면 된다. 기존 코드에서 isComboExist() 메소드가 추가됐고, Character를 사용하던 한타 메소드에서는 isComboExist()를 이용하면 된다.

스킬 콤보가 없는 새로운 티모1, 티모2, 티모3, … 이 추가되더라도 기존 코드는 바뀔 필요가 없다.

public class Character {
  public void fight() {
      combo();
  }
  protected void combo() {
  		Q(); W(); E(); R();
  }
  protected boolean isComboExist() {
  		return true;
  }
}
public class 티모 extends Character{
  @Override
  protected boolean isComboExist() {
  		return false;
  }
}
public void 한타(Character character) {
  if(character.isComboExist()){
      character.fight();
  }
  else {
      기본공격();
  }
}


결론

리스코프 치환 원칙은 개방 폐쇄 원칙과 관련되어 있다. 리스코프 치환 원칙을 어긴다면 확장 시 기존 코드를 계속 수정해주어야 한다. 이는 '확장에는 열려있고 변경에는 닫혀있어야 한다' 라는 개방 폐쇄 원칙의 개념에 위반된다. 따라서 리스코프 치환 원칙은 기능 확장을 쉽게 하는 것이 주된 목표인 것 같다.



:bookmark: REFERENCE
최범균, 「개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴」, 인투북스
Solid Relevance