세상에 나쁜 코드는 없다
상속이 안티패턴이라고? 본문
상속이 안티패턴이라고?
항간에는 상속이 안티패턴이며, 사용을 하지 않아야 한다는 얘기도 들리는 것으로 보입니다. 이러한 말은 어떤 상황에서 나왔을까요?
InstrumentedHashSet 예제로 알아보는 상속의 문제점
InstrumentedHashSet 구현하기
InstrumentedHashSet은 저희가 만들고자 하는 새로운 클래스로, HashSet을 상속하여 HashSet의 기능을 모두 사용함과 동시에 객체가 생성된 이후 몇개의 원소가 추가되었는지 측정하는 기능을 추가로 제공합니다. 구현된 코드는 다음과 같습니다.
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(java.util.Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
HashSet을 상속받은 뒤 특정 기능을 오버라이딩하여 기능을 확장하고자 합니다.
addCount는 추가된 원소의 개수를 기록하는 필드입니다. Set을 통해 원소를 추가하는 메서드는 add(), addAll() 두 가지 뿐이므로, 이 메서드들을 오버라이딩하여 원소가 추가된 개수를 관리하도록 만들었습니다.
다음은 위 기능이 의도대로 동작하는지 테스트하는 코드입니다.
class InstrumentedHashSetTest {
@Test
void testAddCount() {
InstrumentedHashSet<Integer> set = new InstrumentedHashSet<>();
set.add(1);
set.add(2);
set.addAll(List.of(3, 4, 5));
Assertions.assertEquals(5, set.getAddCount());
}
}
위 코드를 수행시키면 addCount의 값으로 5를 기대했지만 실제 값은 8이 나왔다며 테스트가 실패하게 됩니다. 어떻게 이런일이 발생하게 된 걸까요? 도대체 왜?
문제의 원인은…
문제의 원인은 내부적으로 addAll() 메서드가 add() 메서드를 호출하는 방식으로 구현되어있기 때문입니다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
위 코드는 InstrumentedHashSet의 상위 타입인 HashSet의 상위 타입인 AbstractHashSet의 상위 타입인 AbstractCollection에 구현되어있는 내용으로, InstrumentHashSet의 addAll()은 위 코드를 수행합니다. 따라서 결국 오버라이딩을 통해 add()가 다시 호출되면서 addCount를 중복하여 증가시키게 됩니다. 결국 우리의 코드가 왜 의도대로 동작하지 않는지를 확인하려면 그 상위 클래스의 모든 부분을 확인하여 실질적으로 수행되는 코드를 확인해야할 필요가 있습니다.
그럼 이를 해결하기 위한 방법에는 어떤 것이 있을까요? 가장 간단한 방법은 addAll()을 재정의한 부분에서 addCount를 증가시키는 부분을 없애는 것입니다. addCount를 늘려주는 부분을 없앤다면 오버라이딩 할 필요 자체가 없어지므로 InstrumentedHashSet에서 addAll()을 오버라이딩하는 메서드 자체를 없앨 수 있습니다. 이렇게 변경한 경우 테스트 코드는 통과합니다.
public class InstrumentedHashSet<E> extends HashSet<E> {
private int addCount = 0;
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
public int getAddCount() {
return addCount;
}
}
하지만 이러한 해결법은 본질적인 문제를 해결해주지는 못합니다. 그 이유는 서브타입인 InstrumentedHashSet의 동작이 수퍼타입인 AbstractionCollection의 동작 내용에 영향을 받기 때문입니다. 이러한 해결법을 ‘서브클래스의 구현이 수퍼클래스의 구현에 의존한다’라고 표현합니다. AbstractionCollection의 위와 같은 addAll()의 구현은 다음 릴리즈에서 add()를 호출하지 않는 방식으로 바뀔지도 모릅니다. 이렇게 바뀌게 된 경우 위 해결책은 깨지게 되고, 새롭게 코드를 작성해야 할 것입니다. 만약 상위 클래스의 변경 이후 하위 클래스의 테스트가 수행되지 않는다면 로직이 깨졌음을 깨닫지 못한 상태로 운영 코드가 돌아갈 여지도 존재합니다.
이렇게 하면 해결할 수 있지 않을까? 또 다른 오버라이딩 방법
위 상황에서는 InstrumentedHashSet의 동작이 addAll()의 내부 구현에 의존하는 결과를 낳게 되었습니다. 이를 해결하기 위해서 InstrumentedHashSet의 addAll()을 다음과 같이 변경해보면 어떨까요?
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(java.util.Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
public int getAddCount() {
return addCount;
}
}
InstrumentedHashSet의 addAll()의 구현을 수퍼클래스에 위임하지 않고, 이 클래스 내에서 구현했습니다. 이제는 수퍼클래스의 로직이 바뀌어도 해당 로직에 의존하지 않으므로 InstrumentedHashSet은 정상적으로 동작할 것입니다. 이 코드는 그럼 문제가 없을까요? 이 방법은 조금 다른 결의 문제점을 낳습니다.
이 방법은 상위 클래스의 메서드 동작을 다시 구현하는 과정이므로 코드의 중복이 발생하여 변경의 필요가 생길 때 두 지점을 변경해야한다는 문제를 갖습니다. 또한 새로운 메서드를 관리해야한다는 차원에서 부가적인 시간과 노력을 필요로 할 수 있고, 자칫 오류를 낼 가능성도 존재합니다. 이 방법은 항상 사용될 수 있는 것도 아닌데, 그 이유는 수퍼클래스의 구현이 해당 클래스의 private 필드를 사용하는 경우 이를 그대로 옮길 수 없기 때문입니다.
또한 오버라이딩을 통한 방법으로는 중대한 문제가 있습니다. 바로 상위 클래스에서 add(), addAll() 이 아닌 새로운 원소를 넣는 메서드를 생성했을 때, 이 메서드를 통해 저장된 원소는 생성된 개수에 포함되지 않을 것이므로 클래스의 정합성이 깨질 것입니다. 이 때 InstrumentedHashSet 측에서 이러한 문제를 잡을 수 있는 장치는 존재하지 않습니다.
상속과 오버라이딩의 한계
결국 위 모든 문제들은 새로운 기능확장을 상속과 오버라이딩으로 했기 때문에 생기는 문제들입니다. 클래스의 상속은 수퍼클래스과 서브클래스간의 강한 결합을 만들어 내어, 수퍼클래스 측의 변경이 모든 서브클래스에 영향을 주게 됩니다. 따라서 각각의 클래스가 독립적으로 진화하는 과정에서 다른 클래스의 동작에 영향을 주어 컴파일 에러를 내거나 정합성 문제를 일으키기 쉽습니다.
Composition을 통한 해결
위 문제를 해결하기 위한 방법 중 하나는 Composition(조합, 합성)이라는 방법을 사용하는 것입니다. Composition은 사용하고자 하는 기능을 진 클래스를 private 필드로 가지고 있으면서, 이를 호출하는 방식으로 동작합니다. 코드를 먼저 보겠습니다.
public class InstrumentedCompositionHashSet<E> implements Set<E> {
private final Set<E> set = new HashSet<>();
private int addCount = 0;
public int getAddCount() {
return addCount;
}
public boolean add(E e) {
addCount++;
return set.add(e);
}
public boolean addAll(java.util.Collection<? extends E> c) {
addCount += c.size();
return set.addAll(c);
}
@Override
public int size() {
return set.size();
}
@Override
public boolean isEmpty() {
return set.isEmpty();
}
@Override
public boolean contains(Object o) {
return set.contains(o);
}
...
}
InstrumentedCompositionHashSet은 Set 인터페이스를 구현하여 Set이 사용되는 모든 곳에 사용될 수 있으며, 추가된 원소의 개수를 추적하는 기능을 가지고 있습니다. 이 때 HashSet을 재사용하는 방법을 집중해서 볼 필요가 있습니다.
기존의 방식에서는 InstrumentedHashSet이 곧 HashSet이면서 부가기능을 제공하는 클래스였습니다. 따라서 add(), addAll()을 오버라이딩하는 것은 수퍼 클래스의 구현에 영향을 받아 여러 문제를 낳았습니다. 하지만 InstrumentedCompositionHashSet은 HashSet이 아닙니다. 단지 내부 구현에 있어서 HashSet을 사용할 뿐입니다. InstrumentedCompositionHashSet은 Set이 제공해야하는 모든 메서드를 구현하며, 대부분의 메서드의 경우 그냥 내부적으로 가지고 있는 HashSet에 요청을 위임합니다. 단 입력 기록을 추적하는 부분에서만 자체적으로 갖고 있는 addCount를 늘려준 뒤 HashSet에 요청을 보냅니다.이러한 해결책에서는 이제 addAll()이 내부적으로 add()를 호출하는지 안하는지에 대해서는 전혀 상관이 없어졌습니다. HashSet의 add(), addAll()은 입력 기록 추적이랑은 전혀 상관 없는 기능이기 때문입니다. 오직 InstrumentedCompositionHashSet의 add()와 addAll()이 호출될 때에만 addCount에 영향을 줍니다. 따라서 HashSet의 구현이 바뀌어도 InstrumentedCompositionHashSet은 기존의 로직을 유지할 수 있습니다.
위에서 언급한 또 다른 문제는 HashSet 등에서 원소를 추가하는 다른 메서드가 추가되었을 때 InstrumentedHashSet은 이를 반영할 수 있는 방법이 없어 정합성이 깨질 수 있었다는 점입니다. 위 해결법은 이러한 문제를 해결합니다.
만약 HashSet 클래스에 원소를 삽입하는 메서드가 새롭게 생성되었다고 했을 때, InstrumentedCompositionHashSet은 정합성이 유지가 됩니다. HashSet의 API와 InstrumentedCompositionHashSet의 API는 본질적으로 관련이 없을 뿐 더러, 클래스의 필드로 존재하는 HashSet의 새로운 메서드를 외부에서 호출할 방법이 없기 때문입니다. 만약 Set 인터페이스 레벨에 원소를 삽입하는 메서드가 새롭게 생성되었다고 한다면, InstrumentedCompositionHashSet은 컴파일 에러가 날 것이므로 새로운 메서드를 처리하는 것에 있어서 대처가 가능합니다.
ForwardingSet
InstrumentedCompositionHashSet을 통해 상속과 오버라이딩에서 발생하는 여러 문제를 해결할 수 있었습니다. 하지만 이 코드, 여전히 안좋아 보이지 않나요? 이유는 다음과 같을 것입니다.
- 단순히 외부에서 들어온 파라미터를 내부에서 관리하는 HashSet에 넘겨주는 로직이 너무 많다.
이는 클래스의 응집도를 떨어트리는 요인 중 하나입니다. InstrumentedCompositionHashSet은 Set API에 해당하는 기능을 HashSet에 위임해야하는 역할과 동시에 원소가 추가되는 횟수를 추적해야하는 역할을 수행하고 있습니다. - 자료구조가 HashSet으로 고정되어있다.
현재 상황에서는 들어온 원소의 개수를 추적하는 TreeSet을 만드려면 InstrumentedCompositionTreeSet이라는 클래스를 새롭게 만들어야 할 것으로 보입니다.
이러한 문제는 ForwardingSet이라는 클래스를 통해 위임의 역할과 기록의 역할을 분리해냄으로써 해결할 수 있습니다.
public class ForwardingSet<E> implements Set<E> {
private final Set<E> set;
public ForwardingSet(Set<E> set) {
this.set = set;
}
@Override
public int size() {
return set.size();
}
@Override
public boolean isEmpty() {
return set.isEmpty();
}
@Override
public boolean contains(Object o) {
return set.contains(o);
}
...
}
ForwardingSet은 기존의 InstrumentedCompositionHashSet에서 단순히 내부 필드에 요청을 위임하는 역할만을 분리해낸 클래스입니다. 눈여겨 볼 점은 ForwardingSet의 생성자로 Set 을 받아 이를 필드에 저장하고 있다는 점입니다. 이는 기존의 방식에서 HashSet을 직접 필드에서 생성했던 부분과 차이가 있습니다. ForwardingSet은 요청을 위임할 객체를 외부로부터 받기 때문에 Set의 구현체에 국한되지 않은 사용이 가능합니다.
public class InstrumentedSet<E> extends ForwardingSet<E> {
private int addCount = 0;
public InstrumentedSet(Set<E> set) {
super(set);
}
@Override
public boolean add(E e) {
addCount++;
return super.add(e);
}
@Override
public boolean addAll(java.util.Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
InstrumentedSet은 ForwardingSet을 상속하는 클래스입니다. 기존의 InstrumentedCompositionHashSet 이 위임하는 코드까지 모두 갖고있었던 것에 반하여 InstrumentedSet은 오직 기록을 추적하는 부분과 관련있는 코드만 작성되어 있어 응집도가 높아지고 유지보수가 편해졌습니다. 이렇게 함으로써 Composition에서 오는 모든 이점은 챙기면서 코드 유지보수성을 높이고 자료구조에 국한되지 않는 클래스를 만들 수 있습니다. 이제는 비단 기록을 추적하는 역할을 하는 Set 뿐만 아니라 다른 부가적인 기능을 제공하는 클래스도 ForwardingSet을 통해서 손쉽게 만들 수 있을 것입니다.
아래는 InstrumentedSet이 의도대로 동작하는지 테스트하는 코드입니다.
@Test
void testAddCount() {
InstrumentedSet<Integer> set = new InstrumentedSet<>(new TreeSet<>());
set.add(1);
set.add(2);
set.addAll(List.of(3, 4, 5));
Assertions.assertEquals(5, set.getAddCount());
}
Liscov Subtitution Principle 리스코프 치환 원칙
위 예시를 통해 상속을 통한 기능확장에 대해서 어떤 점이 문제가 발생할 여지가 있는지, 이를 효과적으로 해결할 수 있는 방법은 무엇인지에 대해 살펴봤습니다. 그렇다면, 정말 상속은 나쁜걸까요? 그렇지는 않아보입니다. 당장 위의 예시에도 ForwardingSet을 상속한 InstrumentedSet을 좋은 예시로 들었습니다.
그럼 언제 어떤 경우에 상속을 사용해야할까요? LSP는 이에 대한 하나의 원칙을 제시합니다. LSP는 객체지향 설계 5원칙 중 하나로, 모든 서브타입은 수퍼타입으로 대체될 수 있어야 한다는 상속의 가이드라인을 제공합니다.
정사각형-직사각형 문제
흔히 여러 글에서 상속은 서로 다른 두 타입이 IS-A 관계일 때 이러한 관계를 추상화하고자 사용한다고 합니다. A와 B가 IS-A 관계라는 것은 곧 “A는 B이다” 혹은 A는 B에 포함된다” 라는 관계를 의미하는데, 이 의미를 올바르게 이해하지 못했을 때에는 좋은 객체지향 설계를 하지 못하게 될 수도 있습니다. 이 의미를 혼동했을 때 발생하는 예시 중 하나는 정사각형-직사각형 문제입니다.
위 타입 예시에서 동물은 더 일반화된 타입이고 척추동물은 더 특수화된 타입이었습니다. 이를 클래스 상속 구조로 표현하면 다음과 같은 것입니다. 아래의 상황에서 Vertebrate는 Animal의 모든 특성과 기능을 상속받습니다.
class Animal {
...
}
//척추동물 : Vertebrate
class Vertebrate extends Animal {
...
}
이는 합당한 상속구조로 보입니다. 모든 척추동물은 동물이고, 그렇다고 해서 모든 동물이 척추동물은 아닙니다. 동물이 할 수 있는 모든 행위 — 잠을 잔다거나, 밥을 먹는다거나 하는 행동 — 는 Vertebrate에게도 동일하게 기대할 수 있는 행동이기 때문입니다. 추가적으로 Vertebrate는 척추동물만의 특징이 있는 무언가의 행동을 추가로 수행하게 만들 수 있을 것입니다.
자 그럼 정사각형과 직사각형의 문제를 살펴보겠습니다. 직사각형이란 모든 각이 직각이며 마주보는 변의 길이가 동일한 사각형을 뜻합니다. 정사각형이란 모든 각이 직각이며 모든 변의 길이가 동일한 사각형을 뜻합니다. 이러한 정의하에 다음과 같은 문장은 옳습니다.
모든 정사각형은 직사각형이고, 그렇다고 해서 모든 직사각형이 정사각형은 아니다.
정사각형은 직사각형에 포함된다.
정사각형과 직사각형의 관계에서도 이런 수퍼타입 - 서브타입 관계가 존재하는 것으로 보입니다. 직사각형은 좀 더 일반적인 개념이고, 정사각형은 직사각형 중 모든 변이 같은 경우를 지칭하는 특수한 개념으로 보입니다. 따라서 다음과 같이 상속 구조를 만들 수 있을 것 같아 보입니다.
// 수퍼타입 직사각형
class Rectangle {
}
// 서브타입 정사각형
class Sqaure extends Rectangle {
}
문제점
이 상속구조는 몇가지 문제점을 낳습니다. 주요한 포인트 중 하나는 Rectangle에서 정의된 메서드가 Sqaure 레벨로 내려왔을 때 그 의미가 모호해진다는 점입니다.
class Rectangle {
void setWidth(int w);
void setHeight(int h);
}
Rectangle 클래스에 width와 height를 변경할 수 있는 요구사항이 있다고 합시다. 위 코드는 수퍼클래스인 Rectangle의 객체의 길이를 변경하는 메서드입니다. 한 편, Sqaure 클래스는 Rectangle을 상속했으므로 모든 기능을 가져오게 됩니다. 따라서 이제는 다음과 같은 메서드 호출이 가능해집니다.
Sqaure sqaure = new Sqaure();
square.setWidth(4); //??
square.setHeight(10); //??
Sqaure를 사용하는 개발자는 이를 어떻게 받아들여야 할까요? 분명 정사각형 객체를 만들 것으로 기대했는데 width와 height를 변경하는 메서드가 생겨버렸습니다. width든 height든 n으로 변경되면 모든 변의 길이가 n이 될 것이라고 유추를 해야할까요? 아니면 이런 동작은 할 수 없으니 exception이 터질 것이라 예상해야할까요? 이는 사용하는 측 뿐만 아니라 Sqaure를 개발하는 측에서도 마찬가지입니다. 메서드를 오버라이딩을 해야할지, 한다면 어떻게 구현해야할지, 대략난감할 것입니다. 일단 정사각형에 대해서는 width가 N으로 설정되면 height도 N으로 설정되게 구현했다고 칩시다.
void setWidthDouble(Rectangle rectangle) {
setWidth(getWidth() * 2));
}
위 메서드는 Rectangle의 width를 2배로 만드는 메서드입니다. 이 메서드를 호출하는 측에서는 당연히 파라미터로 들어간 객체의 면적이 2배가 됐을 것이라 예상할 수 있습니다. 또한 height는 바뀌지 않았을 것이라 예상할 것입니다. 하지만 이러한 예상은 파라미터에 Square 객체가 들어옴과 동시에 어긋나게 됩니다. 이러한 상황은 프로그램이 예상치 못하게 돌아가게 만들 수 있습니다.
행동을 기준으로 분류했을 때 치환할 수 있다
위와 같은 문제는 왜 발생했을까요? 분명 정사각형과 직사각형에는 IS-A 관계가 있었는데 말이죠. 사실 이는 IS-A 관계를 잘못 해석하여 발생한 문제입니다.
IS-A 관계를 따져 수퍼타입과 서브타입을 분류할 때는 어떠한 객체의 정의가 아닌 행동을 기준으로 분류해야합니다. 서브타입은 수퍼타입의 행동을 모두 수행할 수 있으면서 더 특수한 행동을 할 수 있을 때 성립합니다. 반대로 수퍼타입은 모든 서브타입의 일반화된 행동을 수행할 수 있을 때 성립합니다. 이러한 기준으로 정사각형-직사각형 문제를 봤을 때, 두 관계는 현 상황에서 IS-A 관계가 아님을 알 수 있습니다. 수퍼 타입에서 할 수 있는 행동인 width와 height를 독자적으로 바꾸는 행위는 정사각형에서 기대할 수 있는 행동이 아니기 때문입니다.
행동을 기준으로 분류했을 때, 서브타입은 수퍼타입을 치환할 수 있게 됩니다. 수퍼타입이 사용되는 곳에 서브타입이 대체해도 어색함이 없으며 기대되는 행동을 서브타입의 로직을 통해 수행할 수 있다는 의미입니다. 리스코프 치환 원칙은 이러한 분류 하에 모든 서브타입이 수퍼타입을 치환할 수 있게 만드는 것이 좋은 상속구조라는 내용을 제시합니다.
물론 IS-A 관계는 행동으로 분류되기 때문에, 현재 문제가 되는 행동인 변의 길이를 변경하는 로직이 없다면 기존의 상속관계가 괜찮을지도 모릅니다.
public class Rectangle implements Shape {
private final int width;
private final int height;
public Rectangle(int w, int h) {
this.width = w;
this.height = h;
}
@Override
public int getArea() {
return width * height;
}
}
public class Sqaure extends Rectangle {
public Square(int s) {
super(s, s);
}
}
private int sumAllRectangleArea(List<Rectangle> rectangles) {
return rectangles.stream().mapToInt(Rectangle::getArea).sum();
}
@Test
void test() {
List<Rectangle> rectangles = List.of(new Rectangle(2, 4), new Square(3));
System.out.println(sumAllRectangleArea(rectangles)); // 17
}
정리
위 글을 통해 상속에서 발생할 수 있는 문제점과 상속을 올바르게 사용하기 위한 원칙에 대해 알아보았습니다.
상속은 타입의 계층관계를 클래스 수준에서 표현할 수 있는 효과적인 방법입니다. 다만, 상속에서 발생하는 수퍼클래스와 서브클래스의 강한 결합은 종종 문제를 일으킬 여지 역시 존재합니다. 과연 우리가 개발하는 단계에서 고민해서 내놓은 결과인 IS-A 관계와 상속구조는 소프트웨어가 진화되는 과정간에 쭉 유지될까요? 우리가 할 수 있는 일은 추후에 생길 수많은 요구사항을 예측하고 분석하여 적절한 상속 관계를 사용하는 것 밖에 없습니다. 인간의 예측력이 100%가 아닌 만큼 나중에 예상치 못한 요구사항이 생긴다면 기존의 코드 중 너무 많은 부분을 수정해야할지도 모릅니다.
따라서 상속은 타입간의 계층관계가 있음이 분명할 때 사용하되, 단순 코드 재사용 목적으로는 Composition을 적극적으로 사용하면 좋을 것 같습니다.
'Computer Science > Java' 카테고리의 다른 글
Generic Programming (2) | 2024.04.10 |
---|---|
[Java] Generics, Annotation, Enum (0) | 2022.05.26 |
Java - 예외처리 정리 (2) | 2022.05.16 |