리스코프 치환 원칙 - LSP (Liskov Substitution Principle)
리스코프 치환 원칙은 1988년 바바라 리스코프(Barbara Liskov)가 올바른 상속 관계의 특징을 정의하기 위해 발표한 것으로, 서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다는 것을 뜻한다.
교체할 수 있다는 말은, 자식 클래스는 최소한 자신의 부모 클래스에서 가능한 행위에 대한 수행이 보장되어야 한다는 의미이다.
즉, 부모 클래스의 인스턴스를 사용하는 위치에 자식 클래스의 인스턴스를 대신 사용했을 때 코드가 원래 의도대로 작동해야 한다는 의미이다.
이것을 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있다고 말한다.
무슨 논문 같이 설명했지만, 그냥 우리가 지금까지 자바 프로그래밍을 하면서 질리도록 사용한 다형성 원리를 얘기하는 것이다.
다형성 기능을 이용하기 위해서는 클래스를 상속 시켜 타입을 통합할 수 있게 설정하고, 업캐스팅을 해도 메소드 동작에 문제없게 잘 설계하여야 한다는 것 쯤은 다들 잘 알고 있을 것이다.
이러한 LSP 원칙을 잘 적용한 얘제가 자바의 컬렉션 프레임워크(Collection Framework) 이다.
만일 변수에 LinkedList 자료형을 담아 사용하다, 중간에 전혀 다른 HashSet 자료형으로 바꿔도 add() 메서드 동작을 보장받기 위해서는 Collection 이라는 인터페이스 타입으로 변수를 선언하여 할당하면 된다.
왜냐하면 인터페이스 Collection의 추상 메서드를 각기 하위 자료형 클래스에서 implements하여 인터페이스 구현 규약을 잘 지키도록 미리 잘 설계되어 있기 때문이다.
void myData() {
// Collection 인터페이스 타입으로 변수 선언
Collection data = new LinkedList();
data = new HashSet(); // 중간에 전혀 다른 자료형 클래스를 할당해도 호환됨
modify(data); // 메소드 실행
}
void modify(Collection data){
list.add(1); // 인터페이스 구현 구조가 잘 잡혀있기 때문에 add 메소드 동작이 각기 자료형에 맞게 보장됨
// ...
}
다시한번 말하지만 어렵게 생각할 필요없이, 너무나도 당연하게 자바를 코딩하면서 사용해온 다형성을 규칙으로서 문서화한 것이 LSP 원칙이라고 보면 된다.
그래서 LSP는 한마디로 다형성을 지원하기 위한 원칙 이라고 딱 잘라 정의할 수 있다.
아무래도 우리는 처음 프로그래밍 언어를 배울때 코드 사용법을 배우지, 코드 설계법을 배우지 않기 때문에 이런식으로 뭔가 학습을 거꾸로 배우는 듯한 느낌이 드는것은 어쩔수 없다고 본다.
LSP 원칙 위반 예제와 수정하기
리스코프 치환 원칙의 핵심은 부모 클래스의 행동 규약을 자식 클래스가 위반하면 안 된다는 것이다.
행동 규약을 위반한다는 것은 자식 클래스가 오버라이딩을 할 때, 잘못 재정의하면 리스코프 치환 원칙을 위배할 수 있다는 의미이다.
자식 클래스가 오버라이딩을 잘못하는 경우는 크게 두 가지로 나뉜다.
- 자식 클래스가 부모 클래스의 메소드 시그니처를 멋대로 변경
- 자식 클래스가 부모 클래스의 의도와 다르게 메소드를 오버라이딩 하는 경우
자식의 잘못된 메소드 오버로딩
아래 코드 처럼 Animal 클래스를 상속하는 Eagle 자식 클래스가 부모 클래스의 go() 메소드를 자기 멋대로 코드를 재사용 한답 치고 메소드 타입을 바꾸고 매개변수 갯수도 바꿔 버렸다.
한마디로 어느 메소드 오버로딩을 부모가 아닌 자식 클래스에서 해버렸기 때문에 발생한 LSP 위반 원칙인 것이다.
어찌보면 부모 클래스의 행동 규약을 어긴 셈이다. 그리고 애초에 이 코드는 다형성 코드가 동작 자체가 되지 않는다.
class Animal {
int speed = 100;
int go(int distance) {
return speed * distance;
}
}
class Eagle extends Animal {
String go(int distance, boolean flying) {
if (flying)
return distance + "만큼 날아서 갔습니다.";
else
return distance + "만큼 걸어서 갔습니다.";
}
}
public class Main {
public static void main(String[] args) {
Animal eagle = new Eagle();
eagle.go(10, true);
}
}
이때는 그냥 메소드를 새로 만들어 사용하는 것이 옳다.
부모의 의도와 다르게 메소드 오버라이딩
다음과 같이 Animal 클래스와 이를 상속하는 Cat 클래스가 있고, 이 동물이 포유류, 영장류, 파충류인지 출력해주는 NautralType 클래스가 있다.
class NaturalType {
String type;
NaturalType(Animal animal) {
// 생성자로 동물 이름이 들어오면, 정규표현식으로 매칭된 동물 타입을 설정한다.
if(animal instanceof Cat) {
type = "포유류";
} else {
// ...
}
}
String print() {
return "이 동물의 종류는 " + type + " 입니다.";
}
}
class Animal {
NaturalType getType() {
NaturalType n = new NaturalType(this);
return n;
}
}
class Cat extends Animal {
}
이 코드의 사용법은 아래와 같다.
먼저 Animal 클래스에 확장되는 동물들(Cat, Dog, Lion ...등)을 다형성을 이용하여 업캐스팅으로 인스턴스화 해주고, getType() 메서드를 통해 NautralType 객체 인스턴스를 만들어 NautralType의 print() 메서드를 출력하여 값을 얻는 형태이다.
public class Main {
public static void main(String[] args) {
Animal cat = new Cat();
String result = cat.getType().print();
System.out.println(result); // "이 동물의 종류는 포유류 입니다."
}
}
그런데 협업하는 다른 개발자가 이런식으로 구성하면 뭔가 번거로울 것 같아, 자기 멋대로 자식 클래스에 부모 메서드인 getType() 의 반환값을 null로 오버라이딩 설정하여 메서드를 사용하지 못하게 설정하고, 대신 getName() 이라는 메서드를 만들어 한번에 출력하도록 설정한 것이다.
class Cat extends Animal {
@Override
NaturalType getType() {
return null;
}
String getName() {
return "이 동물의 종류는 포유류 입니다.";
}
}
그럼 기존의 코드는 어떻게 될까?
실행해보면 다음과 같이 NullPointerException 예외가 발생하게 된다.
Animal cat = new Cat();
String result = cat.getType().print();
System.out.println(result);
이것이 리스코프 치환 원칙의 중요 포인트다.
자식 클래스로 부모 클래스의 내용을 상속하는데, 기존 코드에서 보장하던 조건을 수정하거나 적용시키지 않아서, 기존 부모 클래스를 사용하는 코드에서 예상하지 않은 오류를 발생시킨 것이다.
만일 컴파일 단에서 오류를 체크해주면 좋을텐데, 코드 구성상 문제가 없기 때문에 이렇게 예측하지 못한 에러가 발생한 것이다.
따라서 사전에 약속한 기획대로 구현하고, 상속 시 부모에서 구현한 원칙을 따라야 한다가 이 원칙의 핵심이다.
잘못된 상속 관계 구성으로 인한 메서드 정의
Animal 이라는 추상 클래스를 정의하고 동물은 대부분 목소리를 낼 수 있기 때문에 추상 메소드 speak() 을 통하여 메서드 구현을 강제하도록 규칙을 지정하였다.
abstract class Animal {
void speak() {}
}
class Cat extends Animal {
void speak() {
System.out.println("냐옹");
}
}
class Dog extends Animal {
void speak() {
System.out.println("멍멍");
}
}
그런데 개발을 진행하다 Fish 물고기 클래스를 추가해야 할 상황이 와서, Animal 추상 클래스를 상속했더니, 물고기는 행할수 없는 speak() 메서드를 구현해야 하는 상황이 생겨 버렸다.
따라서 개발자는 호환성을 위해 Fish 클래스의 speak() 메서드는 동작을 하지 못하게 하고 예외(Exceptoin)을 던지도록 설정하였다.
class Fish extends Animal {
void speak() {
try {
throw new Exception("물고기는 말할 수 없음");
} catch (Exception e) {
e.printStackTrace();
}
}
}
이 부분을 자신이 개발하고 사용한다 그러면 문제가 되지 않는다. 문제는 다른 개발자와 협업할 때이다.
만일 다른 개발자가 제대로된 스펙 문서를 전달받지 못하고 남이 만들어 놓은 클래스를 사용하려고 할때 아래와 같은 코드를 통해 잘 동작하던 코드가 갑자기 예외를 던져버릴 수 있게 된다.
List<Animal> list = new ArrayList<>();
list.add(new Cat());
list.add(new Dog());
list.add(new Fish());
for(Animal a : list) {
a.speak();
}
LSP 원칙에 따르면 speak() 메서드를 실행하면 각 동물 타입에 맞게 울부짖는 결과를 내보내야 되는데, 갑자기 뜬금없이 예외를 던져버리니 개발자 간 상호 신뢰를 잃게 될수도 있다.
이처럼 리스코프 치환 원칙은 협업하는 개발자 사이의 신뢰를 위한 원칙이기도 하다.
따라서 코드를 수정한다면 따로 인터페이스로 빼는 작업을 통해 수정을 해야 한다.
abstract class Animal {
}
interface Speakable {
void speak();
}
class Cat extends Animal implements Speakable {
public void speak() {
System.out.println("냐옹");
}
}
class dog extends Animal implements Speakable {
public void speak() {
System.out.println("멍멍");
}
}
class Fish extends Animal {
}
LSP 원칙 적용 주의점
결국 리스코프 치환 원칙이란, 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로만 흘러가도록 구성하면 되는 것이다.
그리고 LSP 원칙의 핵심은 상속(Inheritance)이다.
그런데 주의할 점은, 객체 지향 프로그래밍에서 상속은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한 되어야 한다.
그 외의 경우에는 합성(composition)을 이용하도록 권고되어 있다.
따라서 다형성을 이용하고 싶다면 extends 대신 인터페이스로 implements하여 인터페이스 타입으로 사용하기를 권하며, 상위 클래스의 기능을 이용하거나 재사용을 하고 싶다면 상속(inheritnace) 보단 합성(composition)으로 구성하기를 권장한다.
참고