3. LSP(리스코브 치환의 원칙 : The Liskov Substitution Principle)
"Functions that use pointers or prferences to base classes must be able to use objects of derived classes without knowing it"
* 정의
LSP 를 한 마디로 한다면 "서브 타입은 언제나 기반 타입으로 교체할 수 있어야 한다." 라고 할 수 있다.
즉, 서브 타입은 언제나 기반 타입과 호환될 수 있어야 한다. 달리 말하면 서브 타입은 기반 타입이 약속한 규약(public 인터페이스, 물론 메소드가 던지는 예외 까지 포함) 을 지켜야 한다.
상속은 구현상속(extends 관계) 이든 인터페이스 상속(implements 관계) 이든 궁극적으로는 다형성을 통한 확장성 획득을 목표로 한다.
LSP 원리도 역시 서브 클래스가 확장에 대한 인터페이스를 준수해야 함을 의미한다.
다형성과 확장성을 극대화 하려면 하위 클래스를 사용하는 것 보다는 상위의 클래스(인터페이스) 를 사용하는 것이 더 좋다.
일반적으로 선언은 기반 클래스로, 생성은 구체 클래스로 대입하는 방법을 사용한다.
생성 시점에서 구체 클래스를 노출 시키기 꺼려질 경우 생성 부분을 Abstract Factory 등의 패턴을 사용하여 유연성을 높일 수 있다.
상속을 통한 재사용은 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있을 경우로만 제한되어야 한다. 그 외의 경우에는 합성을 이용한 재사용을 해야 한다.
상속은 다형성과 따로 생각할 수 없다. 그리고 다형성으로 인한 확장 효과를 얻기 위해서는 서브 클래스가 기반 클래스와 클라이언트 간의 규약(인터페이스) 를 어겨서는 안된다. 결국 이 구조는 다형성을 통한 확장의 원리인 OCP 를 제공하게 된다.
따라서 LSP 는 OCP 를 구성하는 구조가 된다.
객체지향 설계 원리는 이렇게 서로가 서로를 이용하기도 하고 포함하기도 하는 특징이 있다.
LSP 는 규약을 준수하는 상속 구조를 제공한다.
LSP 를 바탕으로 OCP 는 확장하는 부분에 다형성을 제공해 변화에 열려있는 프로그램을 만들 수 있도록 한다.
* 적용 방법
1. 만약 두 개체가 똑같은 일을 한다면, 둘을 하나의 클래스로 표현하고 이들을 구분할 수 있는 필드를 둔다.
2. 똑같은 연산을 제공하지만, 이들을 약간 씩 다르게 한다면 공통의 인터페이스를 만들고 둘이 이를 구현한다.
(인터페이스 상속)
3. 공통된 연산이 없다면 완전 별개인 2개의 클래스를 만든다.
4. 만약 두 개체가 하는 일에 추가적으로 무언가를 더 한다면 구현 상속을 사용한다.
* 적용 사례
- 컬렉션 프레임워크
public void f(){
LinkedList list = new LinkedList();
// ....
modify(list);
}
public void modify(LinkedList list){
list.add(...);
doSomethingWith(list);
}
List 만 사용할 것이라면 이 코드도 문제는 없다. 하지만 만약 속도 개선을 위해 HashSet 을 사용해야 하는 경우가 발생한다면 LinkedList 를 다시 HashSet 으로 어떻게 바꿀 수 있을까?
LinkedList 와 HashSet 은 모두 Collection 인터페이스를 상속하고 있으므로 다음과 같이 작성하는 것이 바람직하다.
public void f(){
Collection collection = new HashSet();
// ...
modify(collection);
}
public voic modify(Collection collection){
collection.add(...);
doSomethingWith(collection);
}
위와 같이 코드를 작성하면 마음대로 어떤 컬렉션 구현 클래스든 사용할 수 있다.
이 프로그램에서 LSP 와 OCP 모두를 찾아볼 수 있는데, 우선 컬렉션 프레임워크가 LSP 를 준수하지 않았다면 Collection 인터페이스를 통해 수행하는 범용 작업이 제대로 수행될 수 없다.
하지만 모두 LSP 를 준수하기 때문에 이들을 제외한 모든 Collectoin 연산에서는 앞의 modify() 메소드가 잘 동작하게 된다.
그리고 이를 통해 modify() 는 변화에 닫혀 있으면서, 컬렉션의 변경과 확장에는 열려있는 구조(OCP) 가 된다.
물론 Collection 이 지원하지 않는 연산을 사용한다면 한 단계 계층 구조를 내려가야 한다.
그렇다 하더라도 ArrayList, LinkedList, Vector 대신 이들이 구현하고 있는 List 를 사용하는 것이 현명한 방법이다.
* 적용 이슈
1. 혼동될 여지가 없고, 트레이드 오프를 고려해 선택한 것이라면 그대로 둔다.
2. 다형성을 위한 상속 관계가 필요 없다면 Replace with Delegation 을 한다. 상속은 깨지기 쉬운 기반 클래스 등을 지니고 있으므로 IS-A 관계가 성립되지 않는다.
LSP 를 지키기 어렵다면 상속 대신 합성(composition) 을 사용하는 것이 좋다.
3. 상속 구조가 필요하다면 Extract Subclass, Push Down Field, Push Down Method 등의 리팩토링 기법을 이용하여 LSP 를 준수하는 상속 계층 구조를 구성한다.
4. IS-A 관계가 성립한다고 프로그램에서 까지 그런것은 아니다. 이들간의 관계 맺음은 이들의 역할과 이들 사이에 공유하는 연산이 있는지, 그리고 이들 연산이 어떻게 다른지 등을 종합적으로 검토 해 봐야 한다.
5. Design by Contract ("서브 클래스 에서는 기반 클래스의 사전 조건과 같거나, 더 약한 수준에서 사전 조건을 대체할 수 있고, 기반 클래스의 사후 조건과 같거나 더 강한 수준에서 사후 조건을 대체할 수 있다.")
적용 : 기반 클래스를 서브 클래스로 치환 가능하게 하려면 받아들이는 선 조건에서 서브 클래스의 제약 사항이 기반 클래스의 제약 사항보다 느슨하거나 같아야 한다.
만약 제약 조건이 더 강하다면 기반 클래스에서 실행되던 것이 서브 클래스의 강한 조건으로 인해 실행되지 않을 수도 있기 때문이다.
반면 서브 클래스의 후 조건은 같거나 더 강해야 하는데, 약하다면 기반 클래스의 후 조건이 통과시키지 않는 상태를 통과시킬 수도 있기 때문이다.
4. ISP(인터페이스 분리의 원칙 : Interface Segregation Principle)
"Clients should not be forced to depend upon interfaces"
"That they do not use"
* 정의
ISP 원리는 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다는 원리이다.
즉, 어떤 클래스가 다른 클래스에 종속될 때는 가능한 최소한의 인터페이스 만을 사용해야 한다.
ISP 를 하나의 일반적인 인터페이스 보다는, 여러 개의 구체적인 인터페이스가 낫다라고 정의할 수도 있다.
만약 어떤 클래스를 이용하는 클라이언트가 여러개고, 이들이 해당 클래스의 특정 부분집합 만을 이용한다면, 이들을 따로 인터페이스로 빼내어 클라이언트가 기대하는 메시지만을 전달할 수 있도록 한다.
SRP 가 클래스의 단일 책임을 강조한다면, ISP 는 인터페이스의 단일 책임을 강조한다.
하지만 ISP 는 어떤 클래스 혹은 인터페이스가 여러 책임 혹은 역할을 갖는 것을 인정한다.
이러한 경우 ISP 가 사용되는데 SRP가 클래스 분리를 통해 변화에의 적응성을 획득하는 반면, ISP 에서는 인터페이스 분리를 통해 같은 목표에 도달한다.
* 적용 방법
1. 클래스 인터페이스를 통한 분리
- 클래스의 상속을 이용하여 인터페이스를 나눌 수 있다.
: 이와 같은 구조는 클라이언트 에게 변화를 주지 않을 뿐 아니라 인터페이스를 분리하는 효과를 갖는다.
하지만 거의 모든 객체지향 언어에서는 상속을 이용한 확장은 상속받는 클래스의 성격을 디자인 시점에 규정해 버린다.
따라서 인터페이스를 상속받는 순간 인터페이스에 예속되어 제공하는 서비스의 성격이 제한된다.
2. 객체 인터페이스를 통한 분리
- 위임(Delegation) 을 이용하여 인터페이스를 나눌 수 있다.
: 위임이란, 특정 일의 책임을 다른 클래스나 메소드에 맡기는 것이다.
만약 다른 클래스의 기능을 사용해야 하지만 그 기능을 변경하고 싶지 않다면, 상속 대신 위임을 사용한다.
* 적용 사례
- Java Swing 의 JTable
JTable 클래스에는 굉장히 많은 메소드들이 있다.
컬럼을 추가하고 셀 에디터 리스너를 부착하는 등 여러 역할이 하나의 클래스 안에 혼재되어 있지만, JTable의 입장에서 본다면 모두 제공해야 하는 역할이다.
JTable 은 ISP 가 제안하는 방식으로 모든 인터페이스 분리를 통해 특정 역할만을 이용할 수 있도록 해준다.
즉, Accessible, CellEditorListener, ListSelectionListener, Scrollable, TableColumnModelListener, TableModelListener 등 여러 인터페이스 구현을 통해 서비스를 제공한다.
JTable 은 자신을 이용하여 테이블을 만드는 객체, 즉 모든 서비스를 필요로 하는 객체에게는 기능 전부를 노출하지만, 이벤트 처리와 관련해서는 여러 리스너 인터페이스를 통해 해당 기능만 노출한다.
import javax.swing.event.*;
import javax.swing.table.TableModel;
public class SimpleTableDemo ... implements TableModelListener {
...
public SimpleTableDemo(){
...
table.getModel().addTableModelListener(this);
...
}
// 인터페이스를 통해 노출할 기능을 구현한다.
public void tableChanged(TableModelEvent e){
int row = e.getFirstRow();
int column = e.getColumn();
TableModel model = (TableModel)e.getSource();
String columnName = model.getColumnName(column);
Object data = model.getValueAt(row, column);
.. // Do something with the data...
}
...
}
* 적용 이슈
1. 이미 구현된 클라이언트에 변경을 주지 말아야 한다.
2. 두 개 이상의 인터페이스가 공유하는 부분의 재사용을 극대화 한다.
3. 서로 다른 성격의 인터페이스를 명백히 분리 한다.
5. DIP(의존성 역전의 원칙 : Dependency Inversion Principle)
* 정의
의존 관계의 역전 Dependency Inversion 이란, 구조적 디자인에서 발생하던 하위 레벨 모듈의 변경이 상위 레벨 모듈의 변경을 요구하는 위계관계를 끊는 의미의 역전이다.
실제 사용 관계는 바뀌지 않으며, 추상을 매개로 메시지를 주고 받음으로서 관계를 최대한 느슨하게 만드는 원칙이다.
DIP 의 키워드는 'IOC', '훅 메소드', '확장성' 이다. 이 세 가지 요소가 조합되어 복잡한 컴포넌트 들의 관계를 단순화하고 컴포넌트 간의 커뮤니케이션을 효율적이게 한다.
* 훅 메소드 : 슈퍼 클래스에서 디폴트 기능을 정의해두거나 비워뒀다가, 서브클래스에서 선택적으로 오버라이드 할 수 있도록 만들어둔 메소드를 훅(hook) 메소드라고 한다. 서브 클래스에서는 추상 메소드를 구현하거나, 훅 메소드를 오버라이드 하는 방법을 이용해 기능의 일부를 확장한다.
DIP 는 복잡하고 지난한 컴포넌트 간의 커뮤니케이션 관계를 단순화 하기 위한 원칙이다.
참고 : https://www.nextree.co.kr/p6960/
'이론 > 디자인패턴' 카테고리의 다른 글
디자인패턴 - SOLID (SRP, OCP) (0) | 2022.01.30 |
---|---|
디자인 패턴 - 싱글톤 패턴(Singleton pattern) (0) | 2022.01.30 |
디자인패턴 - 개요 (0) | 2022.01.29 |