상속을 사용하면 쉽게 다른 클래스의 기능을 재사용하면서 추가 기능을 확장할 수 있기 때문에, 상속은 기능을 재사용하는 매력적인 방법이다. 하지만, 상속은 변경의 유연함이라는 측면에서 치명적인 단점 을 갖는다.
상속을 통한 재사용의 단점
상속은 상위 클래스의 변경을 어렵게 만든다.
어떤 클래스를 상속받는다는 것은 해당 클래스에 의존한다는 뜻이다. 따라서, 의존하는 클래스의 코드가 변경되면 영향을 받을 수 있다는 것이다. 결과적으로 상위 클래스의 변경 여파가 계층적으로 하위 클래스에 전파가 된다.
위와 같은 이유로, 클래스 계층도에 있는 모든 클래스을 한 개의 거대한 단일 구조처럼 만들어 주는 결과를 초래한다. 따라서, 클래스 계층도가 커질수록 상위 클래스를 변경하는 것은 점점 어려워진다.(경직성)
유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다.
다중 상속을 할 수 없는 자바에서는 한 개의 클래스만 상속받고 다른 기능은 별도로 구현해야 한다. 필요한 기능의 조합이 증가할수록(새로운 요구가 추가될수록), 상속을 통한 기능 재사용을 하게 되 클래스의 개수는 함께 증가하게 된다.
상속 자체를 잘못 사용할 수 있다.
예제를 통해 어떤 문제가 발생할 수 있는지 보자. 컨테이터의 수화물 목록을 관리하는 클래스가 필요하다고 할 때, 다음의 세 가지 기능을 제공할 수 있을 것이다.
수화물을 넣는다.
수화물을 뺀다.
수화물을 넣을 수 있는지 확인한다.
담당 개발자는 목록 관린 기능을 직접 구현하지 않고 ArrayList 클래스가 제공하는 기능을 상속 받아서 사용하기로 결정했다.
public class Container extends ArrayList<Luggage> {
private int maxSize;
private int currentSize;
public Container(int maxSize) {
this.maxSize = maxSize;
}
public void put(Luggage lug) throws NotEnoughSpaceException {
if(!canContain(lug)) {
throw new NotEnoughSpaceException();
}
super.add(lug);
currentSize += lug.size();
}
public void extract(Luggage lug) {
super.remove(lug);
this.currentSize -= lug.size();
}
public boolean canContain(Luggage lug) {
return maxSize >= currentSize + lug.size();
}
}
이 Container 클래스의 사용 방법은 다음과 같다.
Container container = new Container(5);
if(container.canContain(size2Luggage)) {
canContain.put(size2Luggage);
}
그러나, Container 객체를 사용하는 개발자들은 Container 클래스에 정의된 세 개의 메서드뿐만 아니라 상위 클래스인 ArrayList 클래스에 등록된 메서드에도 접근이 가능하게 된다.
결국, Container 객체의 목적과 다르게 ArrayList 메서드에 접근하게 되면 문제가 발생하게 된다.
Luggage size3Lug = new Luggage(3);
Luggage size2Lug = new Luggage(2);
Luggage size1Lug = new Luggage(1);
Container container = new Container(5);
if(container.canContain(size3Luggage)) {
canContain.put(size3Luggage); // Container 여분 5에서 2로 줄어듬
}
if(container.canContain(size2Luggage)) {
canContain.put(size2Luggage); // 비정상 사용. Container 여분 2에서 줄지 않음
}
if(container.canContain(size1Luggage)) {
canContain.put(size1Luggage); // 통과됨! 원래는 통과되면 안됨!
}
Container 객체는 목록 기능을 제공하는 ArrayList로 사용하기 위해 만들어진 것이 아니다. 따라서 다음과 같이 Container 객체를 ArrayList에 할당해서 사용하면 원래 Container가 제공하려던 기능이 정상적으로 작동하지 않는다.
public void addLuggageToContainer(Long luggageId, ArrayList container) {
Luggage lug = getLuggageNyId(luggageId);
container.add(lug);
}
// 원하는 방식으로 동작하지 않음
Container container = new Container(10);
addLuggageToContainer(10L, container);
addLuggageToContainer(15L, container);
위와 같은 문제가 발생하는 이유는 Container는 ArrayList가 아니기 때문이다.
상속은 IS-A 관계가 성립할 때에만 사용해야 하는데, "컨테이너는 ArrayList 이다.(Container is a ArrayList)"는 IS-A 관계가 아니다.
Container는 수화물을 보관하는 책임을 갖는 반면에, ArrayList는 목록을 관리하는 책임을 갖는다. 즉, 둘은 서로 다른 책임을 갖는 것이다.
같은 종류(IS-A 관계)가 아닌 클래스 간의 구현 재사용을 위해 상속받게 되면, 잘못된 사용으로 인한 문제가 발생하게 된다.
위의 세가지 문제를 위한 해답은 객체 조립 을 이용하는 것이다.
조립을 이용한 재사용
객체 조립(Composition) 은 여러 객체를 묶어서 더 복잡한 기능을 제공하는 객체를 만들어내는 것이다.
객체 지향 언어에서 객체 조립은 보통 필드에서 다른 객체를 참조하는 방식으로 구현된다. 한 객체가 다른 객체를 조립해서 필드로 갖는다는 것은 다른 객체의 기능을 사용한다는 의미를 내포한다.
조립을 통한 재사용은 앞서 상속을 통한 재사용에서 발생했던 문제들을 해소해 준다.
아래와 같이, 압축 기능 제공 객체와 암호화 기능 제공 객체를 Storage에 조립하는 방식으로 구현한다면 불필요한 클래스 증식을 방지할 수 있다.
조립을 사용하면 상속을 잘못해서 발생했던 문제도 제거된다. 만약 다중 상속을 통해 Storage 클래스 구현했다고 생각해보면, 앞서 ArrayList 클래스를 상속받은 Container 클래스와 같이 Storage 클래스 내부 상태가 비정상적으로 변경돼서 저장 기능을 제대로 제공하지 못 할 수도 있다.
조립 방식의 또 다른 장점은 런타임에 조립 대상 객체를 교체할 수 있다는 것이다.
예를 들어 다음의 코드를 살펴보자.
public class Storage { ... }
public class CompressedStorage extends Storage { ... }
public class CompressedEncryptedStorage extends CompressedStorage { ... }
// 사용 코드
CompressedEncryptedStorage storage = new CompressedEncryptedStorage();
// ... storage의 압축 알고리즘을 변경하려면...?
위 코드에서 사용 코드 부분을 보면, 실제 코드를 실행하는 동안에는 CompressedEncryptedStorage 객체가 사용하는 압축 알고리즘을 변경할 방법이 없다. 알고리즘을 변경하려면 다음의 과정을 거쳐야 한다.
소스 코드에서 CompressedEncryptedStorage 클래스가 다른 클래스를 상속받도록 변경한다.
소스 코드를 컴파일한다.
다시 배포한다.
반면에 조립하는 방법을 사용하면 얼마든지 런타임에 교체가 가능하다. 변경된 코드는 아래와 같다.
public class Storage {
private Compressor compressor = new Compressor();
public void setCompressor(Compressor compressor) {
this.compressor = compressor; // 조립 방식을 통해, 런타임에 압축 알고리즘 변경 가능
}
public void save(FileData fileData) {
byte[] compressedByte = compressor.compress(fileData.getInputStream());
...
}
...
}
위의 조립 방식에서는 Storage 객체는 setCompressor() 메서드를 통해서 런타임에 사용할 Compressor 객체를 변경할 수 있다.
Storage storage = new Storage();
storage.save(someFileData); // Compressor 객체로 압축
...
storage.setCompressor(new FastCompressor());
storage.save(anyFileData); // FastCompressor 객체로 압축
또한, Compressor 클래스나 Encryptor 클래스는 Storage 클래스에 의존하지 않기 때문에, Storage 클래스를 쉽게 변경할 수 있다. 따라서, 앞서 상속에서 발생했던 상위 클래스 변경이 어려워지는 문제가 발생하지 않는 것이다.
이렇듯 조립이 상속 기반의 재사용에서 발생했었던 여러 문제들을 해소해주기 때문에 아래와 같은 규칙이 만들어졌다.
상속보다는 객체 조립을 사용할 것
물론 모든 상황에서 객체 조립을 사용해야 한다는 애기는 아니며, 상속을 사용하다 보면 변경 관점에서 유연함이 떨어질 가능성이 높으니 객체 조립을 먼저 고민하라는 의미이다.
상속대신 객체 조립을 사용할 경우, 상대적으로 런타임 구조가 복잡해지고 구현이 어렵다는 단점이 존재한다. 하지만, 장기적인 관점에서 구현/구조의 복잡함보다 변경의 유연함을 확보하는 데서 오는 장점이 더 크기 때문에, 기능을 재사용해야 할 경우 상속보다는 조립하는 방법을 먼저 고려해야 한다.
위임
위임(Delegation) 은 내가 할 일을 다른 객체에게 넘긴다는 의미를 담고 있으며, 보통 조립 방식을 이용해서 위임을 구현한다.
예를 들어, 이미지 편집 툴은 만들 경우 마우스 포인터의 위치가 특정 도형이 차지하는 영역에 포함되어 있는지 확인하는 기능이 필요할 것이다. 그런데, 도형과 관련된 Bounds 클래스가 이 기능을 이미 제공하고 있다면, 도형을 표현하는 Figure 클래스의 contains() 메서드는 Bounds 객체에게 포함 여부 확인 여부를 대신 확인해 달라고 위임할 수 있다.
public abstract class Figure {
private Bounds bounds = new Bounds(); // 위임 대상을 조립 형태롤 가짐
...
private void changeSize() {
// 크기 변경 코드 위치
bounds.set(x, y, width, height);
}
public boolean contains(Point point) {
// bounds 객체에 처리를 위임함
return bounds.contains(point.getX(), point.getY());
}
}
위임은 보통 조립과 마찬가지로 요청을 위임할 객체를 필드로 연결하지만, 객체를 새로 생성해서 요청을 전달한다 해도 위임이란 의미는 유지된다.
public boolean contains(Point point) {
Bounds bounds = new Bounds(x, y, width, height);
return bounds.contains(point.getX(), point.getY());
}
}
객체 지향은 책임에 따라 객체들이 세분화되는 특징을 갖는다. 따라서 객체 지향적으로 구현을 하면 자연스럽게 많은 객체들이 만들어지고, 이 과정에서 조립과 위임을 통해 객체를 재사용하게 된다.
상속은 언제 사용하나?
상속은 재사용이라는 관점이 아닌 기능의 확장이라는 관점에서 바라보고 적용해야 한다.
또한, 상속은 명확한 IS-A 관계가 성립되어야 한다.
상속의 특징은 하위로 내려갈수록 상위 클래스의 기본적인 기능을 그대로 유지하면서, 그 기능을 확장해 나간다는 점이다.
상속은 명확한 IS-A 관계에서 점진적으로 상위 클래스의 기능을 확장해 나갈 때 사용할 수 있다.
단, 최초에는 명확한 IS-A 관계로 보여서 상속을 이용해서 기능을 확장했다고 하더라도, 이후에 클래스의 개수가 불필요하게 증가하는 문제가 발생하거나 상위 클래스의 변경이 어려워지는 등 상위 클래스를 상속받을 때의 단점이 발생한다면, 조립으로 전환하는 것을 고려해야 한다.