우테코 블랙잭 미션에서는 다음 요구사안이 있었다.
- 플레이어와 딜러의 중복 코드를 없앤다.
이에 플레이어와 딜러를 참여자라는 추상클래스로 묶었다. 그러나, 페어였던 도도의 피드백에서 is-a 관계 대신 has-a관계로도 생각해보라는 피드백이 왔고, is-a/ has-a관계가 무엇인지, 또 왜 이리도 크루들 사이에서 상속에 대한 이야기가 많은 것인지 궁금해졌다.
상속(is-a)
is-a 관계는 [a는 일종의 b이다.]가 성립하는 관계를 말한다.
ex)
상위 클래스 : 자동차
하위 클래스 :경찰차, 소방차, 버스
경찰차는 일종의 자동차다.
소방차는 일종의 자동차다.
버스는 일종의 자동차다.
=> 모두 is-a관계가 성립한다.
상속의 장점
- 코드를 재사용함으로써 중복을 줄인다
- 개발시간 단축
- 확장성이 증가한다
그러나, 최근 상속에 대한 우려와 여러 이야기가 나오는 것은
이러한 상속의 장점에 비해 단점이 두드러지고 있기 때문이다.
상속의 단점
- 캡슐화가 깨진다
- 리스코프 치환원칙을 위배할 수 있다
- 상위 클래스의 결점을 물려받는다
그럼 단점 하나하나에 대해 살펴보자
단점1. 캡슐화가 깨진다.
캡슐화가 깨진다는 것은 상속이 복잡해지면서 하위 클래스 내용만으로 구현내용을 파악하지 못하는 상황을 들 수 있다. 옐르 들어 다음과 같이 상속을 받고 있는 상황을 살펴보자
Bird > Duck > RubberDuck
RubberDuck 클래스를 읽다가 cry()라는 메서드를 발견했다고 생각해보자.
알고보니 RubberDuck 상위 클래스인 Duck에 cry()가 protected로 선언되어 있었다.
여기서 중요한 점은 cry에 대해 알아내기 위해 RubberDuck이 아닌 Duck을 뜯어보아야한 알 수 있었다는 점이다.
계속 RubberDuck 코드를 읽다가 name이라는 멤버변수를 발견했다.
상위 클래스인 Duck과 Bird에도 name은 없다.
알고보니 Bird은 Animal을 상속받고 있었고, 여기서 name이라는 멤버변수가 선언되어 있었다.
이 예시는 상위 클래스의 구현이 하위 클래스에 노출되어 있는 상황을 보여준다. 이러한 구현은 외부에서 특정 속성과 메서드를 알 수 없도록 숨겨놓는 캡슐화를 상속이 깨뜨리고 있음을 보여준다.
또한, 상위 클래스와 하위클래스가 강하게 결합되어 있음에도, 하위 클래스만으로는 갑자기 튀어나온 상속된 메서드가 어떤 동작을 할지 예측이 불가능하다는 불안전성을 포함한다. 상위 클래스의 코드를 뜯어보아야만 하위 클래스의 코드를 이해할 수 있는 상황이 생기는 것이다.
단점2. 리스코프 치환원칙을 위배할 수 있다
리스코프 치환원칙 : 서브 클래스는 언제나 기반 클래스로 취급할 수 있어야 한다.
그러나, 상속을 사용하다보면 이 원칙을 위반하는 상황이 발생할 수 있다.
예를 들어 앞서의 예시를 다시보자
Bird > Duck > RubberDuck
이를 상상에 기반에 코드로 옮겨보면
class Bird{
String name;
void fly(){
//하늘을 날아요
}
}
class RubberDuck{
void fly(){
System.out.println("날 수 없어요");
}
}
RubberDuck의 상위 클래스인 Bird는 하늘을 나는 fly를 지원한다. 그러나 고무오리인 RubberDuck은 하늘을 날 수 없다. fly 메소드를 호출하면 날수 없다는 안내메시지만을 띄운다. 이는 하위 클래스인 RubberDuck과 상위 클래스인 Bird간의 리스코프 치환법칙이 위반된 상황을 보여준다. 즉, 부모 객체의 행동규약을 하위 객체가 어기고 있는 것이다.
문제는 다음 상황에서 외부에서 Bird인 RubberDuck에게 fly를 호출했을 때, 하늘을 나는 상황을 기대하고 fly를 호출한다는 것이며 예상과 다른 상황이 발생할 때 그 원인을 쉽게 특정할 수 없는 상황이 생기는 것이다.
단점3. 상위 클래스의 결점을 그대로 물려받는다.
이는 Stack의 결점에서 잘 드러낸다.
Vector는 메서드를 강제 동기화하여 성능상으로 매우 느리다는 단점이 있다.
이러한 단점은 Vector를 상속하여 구현된 Stack에게 그대로 상속되어 나타난다.
이처럼 상위 클래스는 본인의 장점 뿐만이 아니라 단점마저 하위 클래스에 그대로 물려준다.
실제로 상속의 문제 상황을 잘보여주는 이펙티브 자바(아이템 18)의 한 예시를 보자
package effectivejava.chapter4.item18;
import java.util.*;
// Broken - Inappropriate use of inheritance! (Page 87)
public class InstrumentedHashSet<E> extends HashSet<E> {
// The number of attempted element insertions
private int addCount = 0;
public InstrumentedHashSet() {
}
public InstrumentedHashSet(int initCap, float loadFactor) {
super(initCap, loadFactor);
}
@Override public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
public static void main(String[] args) {
InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(List.of("Snap", "Crackle", "Pop"));
System.out.println(s.getAddCount());
}
}
더할 때마다 원소 개수를 체크하는 기능을 가진 Set을 구현하고 있다.
s.addAll()로 3개의 원소를 넣어주고 있고, 이는 계수기에 3이라는 숫자를 예상하게 한다.
그러나, 실제로는 3이 아닌 6이 찍힌다. addAll이 add를 통해 구현되어 있기 때문이다. 즉, addAll에서 3이 계수기에 더해지고, add를 호출하는 순간, 오버라이딩 된 add가 하나씩 호출되며 원소 하나당 한번씩 계수기가 올라간다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
즉, 상위 클래스인 HashSet가 어떻게 구현되어있느냐가 하위 클래스인 InstrumentHashSet 동작에 영향을 준 것이다.
조합(has-a)
has-a관계는 기존 클래스가 새로운 클래스의 구성요소로 쓰인다. 새로운 클래스를 만들고 private 필드로 기존 클래스의 인스턴스를 참조한다.
예를 들어 다음 두가지 코드를 보자
//1. 상속(is-a)
class PoliceCar extends Car{
private siren;
void sirenCall(){
//비키세요 경찰차 나갑니다
}
}
//2. 조합(has-a)
class PoliceCar {
private Car;
private siren;
void sirenCall(){
//비키세요 경찰차 나갑니다
}
}
위의 상속코드는 Car를 상속받아 siren기능을 구현하고 있다. 그러나, 조합코드의 경우는 Car를 인스턴스 변수로 활용하고 있다.
그럼 왜 상속보다는 조합을 더 추천하는 것일까?
그 이유는 절대적으로 상속이 안좋다기보다, 상속을 잘못사용하였을 때 발생하는 혼란은 조합에서는 원천적으로 차단할 수 있는 보완점들이 있기 때문이다.
다시 상속의 단점을 보자
- 캡슐화가 깨진다
- 리스코프 치환원칙을 위배할 수 있다
- 상위 클래스의 결점을 물려받는다
조합은
- 캡슐화가 보장된다 > 인스턴스 변수의 메서드를 호출하는 방식으로 구현되기 때문에
- 기존 클래스의 변화나 영향이 적어지고 안전하다
예를 들어 위의 코드에서 Car가 아니라 경찰 버스 클래스를 만든다고 가정한다면 상속은 Bus로의 확장을 따로 구현해주어야 하며, 그 생성자 또한 Bus에 알맞은 방식으로 별도 정의해주어야 한다. 그러나, 조합 방식은 Car 대신에 Bus 객체를 넣어주기만 하면 된다.
다음 예시는 이펙티브 자바 아이템 18에 나와있는 조합으로 구현한 계수Set의 예시이다.
package effectivejava.chapter4.item18;
import java.util.*;
// Reusable forwarding class (Page 90)
public class ForwardingSet<E> implements Set<E> {
private final Set<E> s;
public ForwardingSet(Set<E> s) { this.s = s; }
public void clear() { s.clear(); }
public boolean contains(Object o) { return s.contains(o); }
public boolean isEmpty() { return s.isEmpty(); }
public int size() { return s.size(); }
public Iterator<E> iterator() { return s.iterator(); }
public boolean add(E e) { return s.add(e); }
public boolean remove(Object o) { return s.remove(o); }
public boolean containsAll(Collection<?> c)
{ return s.containsAll(c); }
public boolean addAll(Collection<? extends E> c)
{ return s.addAll(c); }
public boolean removeAll(Collection<?> c)
{ return s.removeAll(c); }
public boolean retainAll(Collection<?> c)
{ return s.retainAll(c); }
public Object[] toArray() { return s.toArray(); }
public <T> T[] toArray(T[] a) { return s.toArray(a); }
@Override public boolean equals(Object o)
{ return s.equals(o); }
@Override public int hashCode() { return s.hashCode(); }
@Override public String toString() { return s.toString(); }
}
결론
상속은 정확한 is-a관계에서만 사용하자.
상속에는 캡슐화 위반, LSP위반, 결점 상속 등의 문제가 있다.
조합은 상속의 결점을 보완할 수 있다.
단순 다형성, 코드 재사용을 목적으로 상속하려고 한다면 조합을 고려해보자
reference)
https://github.com/jbloch/effective-java-3e-source-code/tree/master
'우테코 > Level1' 카테고리의 다른 글
자바 리플렉션 사용법과 3가지 단점 (0) | 2024.04.01 |
---|---|
[스크랩] 풀링 / 캐싱 (0) | 2024.03.14 |
자바에서 Stack보다 Deque이 권장되는 이유 (0) | 2024.03.10 |
.collect(Collectors.toList()) vs Stream.toList() (0) | 2024.03.04 |
[TDD] JUnit - @ParameterizedTest 공식문서 정리하기 (1) | 2024.02.28 |