티스토리 뷰
우아한테크코스 블랙잭 미션에서는 딜러와 플레이어에서 발생하는 중복 코드를 제거해야 한다는 요구사항이 존재한다. 중복 코드를 상위 클래스로 옮기고 딜러와 플레이어가 상속하여 중복을 제거할 수 있을 것이다. 딜러와 플레이어 모두 블랙잭 게임을 참여하는 참가자이기에 공통 로직을 참가자에서 처리하면 된다.
하지만 상속에는 많은 단점들이 존재하기에 이를 고려해서 사용해야 한다. 지금까지는 상속의 단점을 제대로 이해하지 못했었기에 이번 기회를 통해 정리해보고자 한다.
상속
객체지향에서 클래스를 재사용하기 위해, 다시 말해서 중복 코드를 줄이기 위해서는 새로운 클래스를 추가하는 상속이라는 기법이 존재한다. 재사용 관점에서 본다면 클래스 안의 인스턴스 변수와 메서드를 자동으로 클래스에 추가하는 기법을 의미한다.
중복 코드는 왜 작성하면 안 될까?
코드는 한번 작성되었다고 끝이 아니라 변화하는 현실 세계, 요구사항 등에 의해 지속적으로 변화해야 한다. 중복 코드는 이러한 변화의 장애물이 된다. 중복 코드가 있다면 모두 동일하게 동작해야 하기에 중복 코드가 어디에 위치하고 있는지 찾아야 하고, 동일하게 수정해야 한다. 혹시 모를 에러를 방지하기 위해서 각각 테스트 코드도 작성이 필요하다. 중복 코드로 인한 유지보수 비용이 너무 크기에 신뢰할 수 있고 수정하기 쉬운 소프트웨어를 만들기 위해서는 중복을 제거해야 한다. DRY(Don't Repeat Yourself) 원칙을 따라야 한다.
한번 작성된 중복 코드는 새로운 중복 코드를 부른다. 중복 코드가 늘어날수록 코드의 일관성이 무너지고 변경에 취약해지고 버그가 발생할 가능성이 높아진다. 처음에 쉽게 가려다 가면 갈수록 어려워지는 결과를 초래한다.
상속으로 인한 문제
상속을 편하다고 느끼는 이유는 지금까지 단순한 예제만 살펴보았기 때문이다. 실제 현실세계 도메인이 담긴 프로젝트에서는 훨씬 복잡하고 상속 계층도 매우 복잡하다. 상속을 이용해 코드를 재사용하기 위해서는 부모 클래스에서 발생하는 가정과 추론을 정확하게 이해해야 한다. 이는 결합도를 높여 오히려 수정을 어렵게 만드는 결과를 초래한다.
자식 클래스가 부모 클래스 구현에 강하게 결합될 경우 부모 클래스의 변경에 의해 자식 클래스가 영향을 받는다. 자식 클래스 메서드 안에서 super 참조를 통해 부모 클래스의 메서드를 직접 호출할 경우 두 클래스는 강하게 결합되어 있다고 볼 수 있다. 부모 클래스 변경에 취약해지는 현상을 가리켜 취약한 기반 클래스 문제라고 부른다.
상속은 자식 클래스를 점진적으로 추가해서 기능을 확장하는 데는 용이하지만, 높은 결합도로 인해 부모 클래스를 점진적으로 개선하는 것은 어렵게 만든다. 상속은 코드 재사용을 위해 캡슐화의 장점을 약화시키고 결합도를 높임으로 객체지향의 강점을 떨어뜨린다.
부모 클래스의 불필요한 인터페이스를 상속하는 문제도 발생할 수 있다. 가장 대표적인 예시가 Vector를 상속한 Stack 클래스이다. 원소의 추가, 삭제 기능을 재사용하기 위해 Stack을 자식 클래스로 구현했지만, LIFO 구조를 가지도록 한 구현 의도와 다르게 Vector 메서드에 의해 임의의 위치에 원소 추가가 가능하다. 상속받은 부모 클래스의 메서드가 자식 클래스의 내부 구조에 대한 규칙을 깨뜨릴 수 있다.
자식 클래스가 부모 클래스의 메서드를 오버라이딩해서 사용할 때 부모 클래스가 해당 메서드를 사용하는 방법에 따라 결합도가 높아질 수 있다. 결국 클래스를 상속하면 결합도로 인해 부모 클래스와 자식 클래스의 구현을 영원히 변경하지 않거나, 동시에 변경이 필요하다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> e) {
addCount += c.size();
return super.addAll(c);
}
}
위 코드에서 addAll을 실행한다면 addCount 값이 c의 크기가 될 것이라고 생각하지만, 실제 실행 후 addCount 값은 c의 크기의 2배가 된다. super.addAll()에서는 내부적으로 add 메서드를 호출하기 때문이다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> e) {
boolean modified = false;
for (E e : c) {
if (add(e)) {
modified = true;
}
}
return modified;
}
}
문제를 해결하기 위해 각 요소에 대해 한 번씩 add를 호출하도록 할 수 있지만, 여전히 문제는 존재한다. 오버라이딩된 메서드 구현이 상위 메서드와 동일하다는 것이다. 미래에 발생할지 모르는 위험을 방지하기 위해 코드를 중복시킨 것이다.
상속을 적절히 사용하기 위해서는
자식 클래스가 부모 클래스의 구현이 아닌 추상화에 의존하도록 해야 한다. 중복 코드에서 변하는 부분을 메서드로 추출한다. 이후 중복 코드를 부모 클래스로 올린다. 위에서 아래로 내리는 방식이 아닌 아래에서 위로 올리는 방법을 사용하면 구체적인 행동을 상위 클래스에 남겨놓는 실수를 줄일 수 있다.
부모 클래스의 변화가 자식 클래스에 영향을 주어서는 안 되고, 자식 클래스의 변화도 부모 클래스에 영향을 주어서는 안 된다. 새로운 자식 클래스가 추가되더라도 변하는 부분만 구현하도록 하여 확장에도 열려 있어야 한다. 결국 추상화가 핵심인 것을 알 수 있다.
클래스의 행동뿐 아니라 인스턴스 변수에 대해서도 결합이 발생할 수 있다. 인스턴스 변수가 추가되면 자식 클래스에서 초기화 로직 수정이 필요하다. 핵심 로직의 중복을 막는 것이 중요하기에 객체 생성 로직에 대한 변경을 가져가도록 한다.
마치며
객체지향에서 상속은 중복을 줄일 수 있는 확실한 아이디어이다. 처음에는 그렇게 생각했고, 존재하는 기법이기에 적극적으로 활용해도 괜찮을 것이라는 판단을 가지고 있었다. 하지만 잘못 사용할 경우 가져오는 많은 비용들에 대해 이해하고 알게 되었을 때는 사용에 많은 고려가 필요한 기법이라는 생각이 들게 되었다.
상속의 오용과 남용은 애플리케이션의 이해도를 떨어뜨릴뿐더러 확장과 수정을 어렵게 만드는 요소들도 존재한다. 아래의 기준이 충족될 때 상속의 사용을 고려해 볼 것 같다.
- 절대적인 상하관계가 존재한다.
- 구체적으로 문서화가 가능하다.
상속의 단점을 피하면서 중복 코드를 줄일 수 있는 조합(합성)이라는 방법도 존재하기에 다음에는 조합을 공부해보고자 한다.
참고
- 오브젝트(코드로 이해하는 객체지향 설계) - 조영호
'Java' 카테고리의 다른 글
[Java] JDBC 개념 정리 (4) | 2023.06.08 |
---|---|
[Java] 자바의 특징 (0) | 2022.11.26 |
방어적 복사에서 clone은 안전한가 (0) | 2022.07.12 |
Java 정규표현식 (0) | 2022.06.23 |
Java Enum (0) | 2022.06.16 |