SOLID - LSP ( 리스코프 치환 원칙) 에 대하여
본 포스팅은 개인의 공부를 정리한 것이며, 위 원문의 링크에서 인용한 부분이 있음을 고지합니다.
본 포스팅을 이해하고자 한다면 다음과 같은 배경 지식이 필요하다.
-
Refused Bequest
-
트레이드 오프
-
Abstract Factory
-
개방-폐쇄 원칙
-
Wrapper
-
상속
-
다형성
이 포스팅을 찾아보는 독자라면 Java에 대한 기초적인 배경은 있다고 생각하기에 상속 다형성에 대한 내용은 생략한다.
포스팅의 이해를 돕기 위해 간단하게 설명하겠다.
Refused Bequest
Sign and Symptoms
super class를 상속받은 subclass 가 일부 메소드와 속성만 사용하면, 계층 구조는 off-killer다. 상태가 안좋다는 의미다. 필요없는 메소드들은 사용되지 않거나 redefined되어 예외를 발생시킬 수 있다.
Reason for the Problem
누군가는 super class에서 코드를 재사용하려는 마음으로 계층 간 유산을 생성하려는 동기가 생겼다. 하지만 super class와 subclass는 전혀 다르다.
Treatment
상속이 말이 되지 않고 subclass가 정말로 super class와 공통점이 없다면 상속을 위임으로 변경하기 전에 상속을 제거해야 한다.
만약 적절한 상속이라면, 불필요한 필드와 메소드를 subclass에서 제거한다. subclass가 super class에서 필요한 모든 필드와 메소드를 추출하여 새로운 클래스를 만들고 상속받아 사용하도록 변경해야 한다.
Payoff
코드의 clarity 와 organization 을 향상하라.
트레이드 오프
한 측면의 이득에 대한 대가로 품질, 수량 또는 속성 중 하나를 감소시키거나 잃는 상황에 따른 결정이다.
Java에서 Collection을 설계할 때 트레이드 오프가 이루어진다.
Abstact Factory
내용이 길어 링크로 대체한다.
추상 팩토리 패턴 (Abstract Factory Pattern)
간단하게만 확인하고 넘어가고자 하는 분은 아래 내용을 인지하면 되겠다.
추상 팩토리는 다음의 경우에 사용합니다.
-
객체가 생성되거나 구성, 표현되는 방식과 무관하게 시스템을 독립적으로 만들고자 할 때
-
여러 제품군 중 하나를 선택해서 시스템을 설정해야 하고 한번 구성한 제품을 다른 것으로 대체할 수 있을 때
-
관련된 제품 객체들이 함께 사용되도록 설계되었고, 이 부분에 대한 제약이 외부에도 지켜지도록 하고 싶을 때
-
제품에 대한 클래스 라이브러리를 제공하고, 그들의 구현이 아닌 인터페이스를 노출시키고 싶을 때
참조 문헌 : GoF
개방-폐쇄 원칙
SOLID - OCP ( 개방-폐쇄 원칙 ) 에 대하여
개요
많은 기업들이 제공하는 API는 라이브러리 버전업을 할 때 하위 호환성을 고려해야 한다. 물론 여러 이유로 마이그레이션을 필요로 하는 버전업도 존재한다. 하나의 예로 하이버네이트 3.0 RC 버전이 출시 되었을 때 기존 버전과의 호환이 어려웠던 사례가 있었다. 하이버네이트가 결단력 있게 응집력 있는 라이브러리를 출시하고자 패키지 명도 변경했고, 인터페이스도 변경했다. 하지만 이러한 부분에 있어서 많은 개발자들이 불편을 겪었던 것은 사실이다.
라이브러리 버전 업에서 새로운 기능이 추가되더라도 기존 라이브러리를 사용하던 프로그램들은 수정 없이 상위 버전의 라이브러리를 사용할 수 있어야 한다. 인터페이스 제공은 애플리케이션 개발자와 라이브러리 개발자 간의 약속이기 때문이다.
필자가 사용하는 JDK는 오랜 기간 많은 사랑을 받아왔다. 그 이유는 기존 라이버리를 사용하던 프로그램들이 수정 없이 상위 버전의 라이브러리를 사용할 수 있었기 때문이다. 물론 몇몇 부분에서는 분명히 불편함을 겪었을 것이라 생각하지만 '약속'을 아주 잘 지킨 Best Practice라고 볼 수 있다.
LSP
개인적으로 SOLID를 공부하면서 가장 어려운 파트라고 생각한다. 부족한 설명이 있을 수 있음으로 지적할 사항이 있으면 꼭 댓글을 남겨주셨으면 한다.
라이브러리에서도 최신 버전은 이전 버전의 인터페이스를 준수하여 두 라이브러리의 교체가 문제가 되면 안되며, 상속 구조에서 기반 클래스를 파생할 수 있어야 한다. 이와 같은 하위 버전으로의 호환성 문제, 조금 더 쉽게 이야기하자면 서브 클래스의 기반 클래스로의 호환성 문제가 LSP 파트의 주제이다.
LSP 규약을 어기는 하나의 예를 보자.
public class Test {
public static void main(String[] args) {
String[] infoValues = new String[]{"info1??", "??info2??", "??info3??"};
List infoList = Arrays.asList(infoValues);
infoList = InfoHelper.addInfo(infoList);
}
}
class InfoHelper{
public static java.util.List addInfo(java.util.List currentInfo){
String info = "new info??";
currentInfo.add(info);
return currentInfo;
}
}
코드를 컴파일해서 실행시켜 보면 다음과 같은 에러가 발생한다.
여기서 발생한 예외는 Arrays.asList(infoValues)가 반환한 List 구현체가 List 인터페이스의 add() 메소드를 지원하지 않아서 발생한다. 다시 말해, List 인터페이스 중 add() 메소드가 제공되어야 한다는 구약이 지켜지지 않아서 생기는 에러란 이야기다. 이상하다. List 인터페이스는 분명히 add() 메소드를 제공하고 있지 않은가?
LSP는 구현이 선언을, Subclass 가 Superclass의 규약을 준수하여 사용자에게 하위 타입의 상세정보를 관심 밖으로 돌리는 기법을 다루고 있다. 따라서 다음과 같은 규칙이 보장되어야 한다.
서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다.
서브 타입은 언제든 기반 타입과 호환되어야 한다. 서브 타입은 기반 타입이 약속한 규약 (public 인터페이스, 메소드가 던지는 예외 포함) 을 지켜야 한다. 이 규칙은 라이브러리 버전 간의 관계에서도 동일하게 적용된다.
위의 예제에서 배열을 변환한 리스트를 다시 infoList = new ArrayList(inforList) 로 다시 생성하면 문제는 해소된다.
하지만 왜 배열을 List로 만들면 불변 리스트로 생성해야 하는가? 불변 리스트가 꼭 필요한 리스트인가? 뒤에서 살펴본 것처럼 불변 리스트가 LSP를 따르지 않는 것은 다른 특성 과의 트레이드 오프 결과이다. 하지만 여전히 Arrays.asList()의 결과가 불변 리스트인 것은 나도 이해가 안된다.
상속은 extends, implements 이든 궁극적으로 다형성을 통한 획득을 목표로 한다. LSP 원칙도 Subclass가 확장에 대한 인터페이스를 준수해야 함을 의미한다. 다형성과 확장성을 극대화하기 위해서 Subclass를 사용하는 것보다 Superclass를 사용하는 것이 좋다.
예를 들어 Collection 프레임워크를 이용할 때는 가능하면 Collection 인터페이스를 사용하고, Collection 인터페이스 사용이 불가하면 List, Set 인터페이스를 이용한다면 변경에 유연해진다.
따라서 ArrayList 등의 구체 클래스를 선언하는 것은 가능한 피해야 한다. 일반적으로 선언은 기반 클래스로 생성은 구체 클래스로 대입하는 방법을 사용한다. 생성 시점에서 구체 클래스를 노출시키기 꺼려지는 경우 생성 부분은 Abstract Factory 등의 생성 패턴을 사용하여 유연성을 높일 수 있다.
상속의 목적?
상속의 목적이 단지 재사용으로 생각이 든다면 다시 한번 곰곰이 생각해보자. 또한 상속을 통한 재사용은 concrete class와 subclass 사이에 IS-A 관계가 있을 경우로 제한해야 한다. 그 외는 합성을 이용한 재사용을 해야 한다. 예를 들어 Stack 클래스는 Vector 클래스를 extends 하여 만들었다. 하지만 Stack is Vector는 성립하지 않기때문에 상속 대신 합성을 사용해야 했다. 왜냐고? Stack은 인덱스를 통한 접근을 제공하는 get() 메소드를 제공하면 안 되기 때문이다. 즉 Stack과 Vector 관계는 개념적으로 상속 관계가 성립하지 않는다. 물론 Java의 Stack은 인덱스 접근이 가능하다. 하지만 이는 우리가 학부 과정에서 배운 자료구조를 생각해 봤을 때 올바른 사용이 아님을 기억해야 한다.
상속과 다형성
상속과 다형성은 분리할 수 없는 샴쌍둥이 같은 존재다. 다형성으로 인한 확장 효과를 얻기 위해서는 Subclass가 concrete 클래스와 클라이언트 간의 규약 (인터페이스) 를 지켜야 한다. 이 구조는 결론적으로 다형성을 통한 확장의 원리인 OCP를 제공하게 된다. 따라서 LSP는 OCP를 구성하는 구조가 된다. 따라서 원칙들에 대해서 따로 다루지만 사실 서로를 이용하고 포함하는 관계에 있다.
LSP는 규약을 준수하는 상속 구조를 제공한다. LSP를 바탕으로 OCP는 확장하는 부분에 다형성을 제공해 변화에 열러 있는 프로그램을 만들 수 있도록 해준다.
컬렉션 프레임워크를 통해 OCP, LSP 적용 사례
컬렉션 프레임워크는 크게 Collection, Map이라는 인터페이스를 갖고 있다. 자바 1.2에서 도입된 컬렉션 프레임워크는 객체 지향의, 객체 지향에 의한, 객체 지향을 위한 프레임워크 라고 할 수 있다. 또한 OCP와 LSP의 바람직한 예이다.
void f(){
LinkedList list = new LinkedList();
modify(list);
}
void modify(LinkedList list) {
list.add();
doSomething(list);
}
위와 같은 코드가 있다. 이 코드에서 LinkedList만 사용한다면 전혀 문제가 될 부분은 하나도 없다. 하지만 속도 상의 문제로 HashSet을 사용해야 한다면? LinkedList와 HashSet은 Collection 인터페이스를 상속하고 있기에 다음과 같이 작성하는 것이 가장 바람직하다.
void f(){
Collection collection = new HashSet();
modify(list);
}
void modify(Collection collection) {
collection.add();
doSomething(list);
}
위와 같이 작성한다면 컬렉션 구현 클래스는 어떤 것이든 사용할 수 있게 된다. 여기서 LSP와 OCP 모두를 찾아볼 수 있다. 만약 컬렉션 프레임워크가 LSP를 준수하지 않았다면 Collection 인터페이스를 통해 수행하는 범용 작업이 제대로 수행될 수 없다. 하지만 Arrays.toList()의 경우와 불변 컬렉션을 제외하고는 모든 Collection 연산에서는 앞의 modify 메소드가 올바르게 동작할 것이다.
또한 modify() 메소드는 변화에 닫혀 있으면서, 컬렉션의 변경과 확장에 대해서는 OCP를 충족한다. 물론 Collection이 지원하지 않는 연산을 사용한다면 한 단계 계층 구조를 내려가야 한다. 그렇다고 하더라도 ArrayList, LinkedList, Vector 대신에 List를 쓰는 현명한 판단을 하길 바란다.
트레이드 오프
모든 선택에는 트레이드 오프가 있다. 항상 LSP를 지킬 수 있다면 더 없이 좋겠지만 안타깝지만 현실이 그러하다. Collection 프레임워크에서 LSP를 어겼지만 올바른 선택이였다고 하는 예를 보겠다.
Collection list = new LinkedList();
list = new Collections.unmodifiableCollection(list);
Collections의 unmodifiableCollection 메소드를 이용하면 불변 컬렉션 객체를 만들 수 있다. 불변 컬렉션 객체에 대해 add() 혹은 remove() 등의 메소드를 호출하게 되면 위에서 보았던 UnSupportOperationException을 던지게 된다. LSP 위반이다. 당연히 제공해야 됨에도 불구하고 이렇다. 왜 그런가? Wrapper를 이용하지 않는 다면 이 계층 구조는 2배로 커진다. Collection 인터페이스가 ModifiableCollection과 UnmodifiableCollection으로 나누어져야 하고, 이를 구현하는 모든 서브 클래스들 또한 숫자가 두 배가 된다.
Collection 프레임워크를 선택한 Joshua Bloch는 계층 구조의 폭주와 LSP 위반 사이에서 LSP 위반을 택했다. 생각만 해도 폭주되는 계층 구조는 끔찍하지 않은가. 때로는 이러한 트레이드 오프도 결정할 수 있는 개발자의 능력이 필요하다는 것이다.
리팩토링
LSP를 지키지 않는 다면 Refused Bequest라는 악취가 난다. 코드에 악취가 난다는 말은 조금이라도 클린 코드에 관심을 가졌다면 알 것이다.
① 부모를 상속한 자식 클래스에서 메쏘드를 지원하는 대신 예외를 던진다(예를 들어 콜렉션 프레임워크에서 UnsupportedOperationException)
② 자식 클래스가 예외를 던지지는 않지만 아무런 일도 하지 않는다.
③ 클라이언트가 부모보다는 자식을 직접 접근하는 경우가 많다.
이에 대한 해결책은 다음과 같다.
① 혼동될 여지가 없고 여러 트레이드 오프를 고려해 선택한 것이라면 그대로 놔둔다. 단 트레이드 오프와 프로그램의 범용성의 한계에 대해서 스스로 인지하고 있어야 한다.
② 다형성을 위한 상속 관계가 필요없다면 Replace Inheritance with Delegation을 한다. 상속은 깨지기 쉬운 기반 클래스 등을 지니고 있으므로 IS-A 관계가 성립되지 않는다. LSP를 지키기 어렵다면 상속 대신 합성(composition)을 사용하는 것이 좋다.
③ 상속 구조가 필요하다면 Extract Subcless, Push Down Field, Push Down Method 등의 리팩토링 기법을 이용하여 LSP를 준수하는 상속 계층 구조를 구성한다.
객체 지향의 정점 "다형성"
사실 이 포스팅을 접하기 전까지 객체 지향의 꽃은 상속을 통한 재사용이라 생각했다. 하지만 SOLID를 공부하는 과정에서 상속의 목적이 다형성을 극대화하기 위한 부분이라는 생각이 들었다. 객체 지향 프로그래밍은 캡슐화, 상속, 그리고 다형성을 기초로 한다. 캡슐화를 지키기 위해 내부의 데이터와 구현은 외부로 노출시키지 않고 public 인터페이스만 개방해야 한다. 이 때 public 인터페이스는 객체와 외부 클라이언트 사이의 약속 계약이며, 이는 상속과 다형성을 위한 걸음마가 된다. 이 전 포스팅에서 SRP는 각 객체가 어떤 역할을 캡슐화 할 것인지에 대한 가이드를 제공한다.
잘 정의된 상속 구조는 concrete class와 Subclass 간의 IS-A 관계가 성립하며 concrete class는 사용자로부터 구체 구현 클래스를 캡슐화 한다. Collection 인터페이스는 List와 Set을 캡슐화해주고, List는 ArrayList와 LinkedList, Vector를 캡슐화해주는 형태다. 스프링 MVC 패턴에서 서비스 레이어에서 Service 인터페이스와 ServiceImpl 구현체를 사용하는 것은 캡슐화를 위함이였던 것이다. 또한 객체를 생성하는 부분에서만 구체 클래스가 사용되는 데 이 또한 Abstract Factory 등의 생성 패턴을 사용해 적절히 추상화시킬 수 있다(JDBC를 생각해 보자). 그리고 LSP가 상속이 다형성을 위해 사용될 수 있도록 해준다. LSP를 지키지 않으면 Arrays.asList()와 같이 상속 구조에 포함되어 있다 하더라도 다형성으로 인한 이점을 제대로 살리지 못하게 된다.
마지막으로 다형성이야 말로 확장 가능하고 유지보수하기 쉬운 소프트웨어를 만들 수 있게 해주는 객체지향의 꽃이다. 하지만 다형성을 얻으려면 우선은 각 객체들이 적절히 책임 분배되어 있고, 캡슐화되어 있어야 하며, 다형성을 얻을 수 있는 부분은 LSP를 준수하는 상속 구조를 보장해야 한다. 그러므로 캡슐화와 SRP, 상속과 LSP가 제대로 되지 않은 객체 구조에서는 다형성과 OCP를 제공할 수 없다. 다음은 적절히 책임이 분배되지 않은 객체 구조를 SRP, LCP, OCP를 준수하는 객체 구조로 진화시켜 나가는 과정을 잘 보여준다.
개발자들은 가능한 단순한 구조, 프로그램의 완전성 그리고 수정의 용이함이란 서로 상충하는 특성을 갖는다. 객체지향 시스템은 본질적으로 절차지향 시스템에 비해 구조가 복잡하지만, 확장하고 유지보수하기 쉬우며 직관적이다. 디자인 패턴 역시 프로그램의 복잡도를 증가시키지만 역시 확장과 유지보수를 용이하게 해준다. 우리는 본질적으로 복잡한 세상을 다루고 있다. 그렇기 때문에 복잡성 자체를 피할 수 없다. 대신 복잡성을 관리하는 방법에 대해 찾으려고 노력해야 한다.
이에 대한 명쾌한 하나의 답은 없다. 객체지향 시스템을 사용하여 복잡성을 관리하려 한다면 객체지향의 특질, 그리고 이들의 장점과 단점을 파악하고, 문제 상황에서 적절히 트레이드 오프하면서 최선의 선택을 찾을 뿐이다. 즉, 그때 그때 다르다. 다행히 여러 객체지향의 특질, 원리, 패턴은 복잡한 상황 속에서 (복잡성을 고려한다면) 최대한 단순한 구조와 용이한 수정과 확장을 가능하게 해준다. 하지만 상황에 따라 이들을 어길 수도 있다. 하지만 왜 어길 수밖에 없는지, 그리고 이로 인한 장점과 단점이 무엇인지는 분명히 알고 선택해야 한다. 트레이드 오프와 장점과 단점을 생각하지 않은 선택은 라이트 없는 야간 비행을 시도하는 것이다