'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴'을 읽고 학습합니다. ( 최범균 저 )
객체 지향의 주요 특징으로 재사용이 있으며, 이를 지원하는 대표적인 예는 상속이다.
상속이 상위 클래스에 구현된 기능을 그대로 사용할 수 있기에 상속의 사용이 재사용을 쉽게 해준다는 점은 분명하다.
그러나 상속을 통한 재사용 과정에서 발생하는 문제점이 있다. 이를 살펴보고 다른 재사용의 방법으로 객체 조립을 파악하여 상속을 통한 재사용의 단점을 해소화하는 방법에 대해서 알아보자.
상속과 재사용
스프링(2점대)은 웹 요청을 처리하기 위한 컨트롤러 기능을 위해서 아래와 같은 클래스들을 제공하고 있다.
위의 그림을 보면, 상속 계층을 따라서 아래와 같이 기능을 확장한다.
◎ AbstractController → 웹 요청을 처리함에 필요한 가장 기본적 기능 구현 제공
◎ BaseCommandController → 파라미터를 읽고 객체로 변환해 주기 위한 기능 제공
◎ AbstractCommandController → 파라미터를 커맨드 객체로 처리하는 기능 제공
이처럼, 상속을 통해 부모 클래스가 제공하는 기능을 재사용하며, 추가적으로 기능을 확장하기 위해 상속받은 자식 클래스는 저마다 추가적인 기능을 추가로 제공하는 형태를 지닌다.
상속을 사용하면 다른 클래스의 기능을 재사용하면서 추가 기능을 확장하기에 상속은 기능을 재사용함에 좋은 방법 중 하나이다. 그러나 상속은 "변경의 유연함" 이라는 측면에서 많은 단점을 갖게 되는 구조이다.
이에 대해서 알아보자.
1.1 상속을 통한 재사용의 단점 - 상위 클래스 변경의 어려움
어떤 클래스를 상속받는다는 것은 그 클래스에 의존한다는 것이다. 따라서 의존하는 클래스의 코드가 변경되면 영향을 받을 수 있다.
그럼 상속을 통한 구조에서는 부모 클래스가 변한다면, 이를 상속받는 하위의 자식 클래스들은 모두 부모 클래스의 변경에 따른 여파가 전파된다. ( 위의 그림과 같이 )
상속 계층에 해당하는 상위 클래스의 변경이 하위 클래스에 영향을 주기 때문에, 최악의 경우에는 상위 클래스의 변화가 모든 클래스에 영향을 줄 수 있다.
이는 클래스 계층도에 있는 클래스들을 한 개의 거대한 단일 구조처럼 만들어 주는 결과를 초래한다.
이런 이유로 클래스 계층도가 커질수록 상위 클래스를 변경하는 것은 어려워진다.
1.2 상속을 통한 재사용의 단점 - 클래스의 불필요한 증가
상속을 통한 기능 재사용의 두 번째 단점은 유사한 기능을 확장하는 과정에서 클래스의 개수가 불필요하게 증가할 수 있다는 점이다.
다음과 같은 예를 들어보자.
파일 보관소를 구현한 'Storage' 클래스가 있다.
추가 요구 사항으로 용량을 아낄 수 있는 방법을 제공해 달라는 요구가 발생하였다. 따라서 Storage 클래스를 상속받아 압축 기능을 추가한 CompressedStorefe 클래스를 만들었다. 또 보안이 문제가 되어서 파일을 암호화해서 저장해 주는 EncryptedStrorage 클래스를 추가하였다.
그 결과 위와 같은 클래스 계층도가 만들어졌다.
그런데, 만약 압축을 먼저하고 암호화 하는 저장소가 필요하다면 어떻게 해야할까?? 또는 암호화를 먼저하고 압축을 해달라고 하면 어떻게 해야할까? 그리고 성능 향상을 위해 캐시를 제공하는 저장소가 필요하고, 추가로 암호화된 저장소에 캐시를 적용하려면 어떻게 될까??
이를 상속을 통해서 구현하면 아래와 비슷한 클래스 계층이 만들어질 것이다.
하지만 자바는 다중 상속을 지원하지 않는다. 따라서 한 개의 클래스만 상속받고 다른 기능은 별도로 구현해야 한다. 필요한 기능의 조합이 증가할수록 상속을 통한 기능을 재사용 하면 클래스의 개수가 함께 증가하는 현상을 보이게 된다.
1.3 상속을 통한 재사용의 단점 - 상속의 오용
상속을 통한 기능 재사용의 문제는 상속 자체를 잘못 사용할 수도 있다는 점에 있다. 예시로 컨테이너의 수화물으 관리하는 클래스가 필요하다고 가정해보자.
그럼 이 클래스는 아래와 같은 기능들을 필요할 것이다.
→ 수화물을 넣는다.
→ 수화물을 뺀다.
→ 수화물을 넣을 수 있는지 확인한다.
이 기능을 구현해야 할 개발자는 목록 관리 기능을 직접 구현하지 않고 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 c = new Container(5);
if(c.canContain(size2Luggage)) {
c.put(size2Luggage);
}
이처럼 해당 클래스는 요구 기능을 수행함에 이상없이 작동한다. 그리고 문제없이 잘 작동하도록 보인다.
그런데 ArrayList는 클래스 자체적으로 만든 메소드인 put을 제외하고도 add 메소드가 있다.
만약, 객체의 put을 사용하지않고 상속받은 add 메서드를 사용한다면,,?
→ 클래스 자체에서 사용하는 변수인 currentSize 변수가 활용되지 않고 단순하게 데이터가 추가만 되어서 Container 객체의 여분 계산이 정상적으로 동작하지 않게 된다.
이처럼 상속을 통해 기능을 추가하고 만약 부모 클래스에서 이를 수행할 수 있는 기능이 있었다면, 사용자는 해당 객체를 제대로 사용하지 않는 오용의 소지가 남고 기능이 정상적으로 쓰이지 않을 수 있는 것이다.
이와 같은 문제가 발생하는 근본적인 이유는 Container는 ArrayList가 아니기 때문이다.
즉, 컨테이너는 ArrayList와 "IS-A" 관계가 성립되지 않는다.
Container는 수화물을 관리하지만, ArrayList는 목록을 관리하는 책임을 가진다. 둘은 서로 다른 책임을 가지고 있는 것이다.
이렇게 다른 종류의 클래스에 구현을 위해서 상속을 받으면 잘못된 사용으로 문제가 발생하게 된다.
상속을 통해서 발생하는 세 가지 문제점에 대해서 알아봤고, 아래와 같다.
1 ) 상위 클래스의 변경의 어려움 2) 클래스 개수의 증가 3) 상속의 오용
그럼 이 문제를 해소하면서 객체지향적으로 확장성 있는 설계 방법은 무엇일까?
바로, 객체 조립을 이용하는 것이다.
조립을 이용한 재사용
객체 조립이란 말 그대로 여러 객체를 묶어 더 복잡한 기능을 제공하는 객체를 만들어내는 것이다.
앞선 내용에서 파일 암호화 예시로 객체조립을 이미 사용하고 있었다. 단어의 뜻한 보면 파악하기 어려울 수 있지만, 특정 클래스의 기능을 사용하기 위해서 해당 클래스 내부에 사용하고자 하는 클래스를 필드로 참조하는 형식이고, 이는 매우 흔한 코드 형태이다.
public class FlowController {
private Encryptor encryptor = new Encryptor(); // 필드로 조합
public void process() {
...
}
}
하나의 객체가 다른 객체를 조립하여 필드로 갖는 다는 것은 다른 객체의 기능을 사용하는 것이다.
상속을 통한 기능의 재사용이 야기했던 다양한 문제들을 객체 조립을 이용하여 구현하면 해소해준다.
먼저, 클래스 증식과 관련한 문제가 해결되는데, 이전 Strorage 예시에서 ( 암호화 -> 압축 / 압축 -> 암호화 )의 순서에 따라서도 클래스를 새로만들고 상속받는 모습을 보였다.
하지만 객체 조립을 통해서 Storage 클래스에 필요한 기능을 구현하다보면 불필요한 상속 증식을 하지 않는다.
필요한 기능을 담당하는 클래스를 필드로 참조하여 필요한 기능을 사용하여 구현하면 되는 것이다.
또한 조립 방식의 또 다른 장점은 런타임에 조립 대상 객체를 교체할 수 있다는 것이다.
상속을 사용하여 기능의 재사용 및 확장하는 경우 관계가 고정되기에 런타임에 상위 클래스를 교체하여 사용할 수 없다.
예를 들어
public class Storage{ ... }
public class CompressedStorage extends { ... }
public class CompressedEncryptedStorage extends CompressedStorage { ... }
// 사용 코드
CompressedEncryptedStorage storage = new CompressedEncryptedStorage();
// 그런데 .. 알고리즘을 변경하려면??
위처럼, 상속을 통해서 기능을 재사용 및 확장하는 경우 객체가 사용하는 압축 알고리즘을 변경할 방법은 아래와 같다.
1) 소스 코드에서 CompressedEncryptedStorage 클래스가 다른 클래를 상속 받도록 한다.
2) 소스 코드 컴파일한다.
3) 다시 배포한다.
그러나 조립하는 방법은 얼마든지 런타임에 변경이 가능하다.
Storage 객체에서 필요한 기능을 지닌 객체를 참조하고 사용하면 되기 때문이다.
게다가, 압축 기능을 담은 객체나 암호화 객체는 Storage 객체에 의존하지 않기 때문에 Storage객체의 내부 로직에 대한 변경이 보다 자유롭다. 앞서 상속으로 발생하는 상위 클래스의 변경이 어려워지는 현상이 발생하지 않는 것이다.
결국 확장성 있고 유연한 코드를 설계하기 위해선
상속보다는 객체 조립을 사용할 것
물론 모든 상황에서 객체 조립을 사용해야함을 의미하는 것은 아니다. 상속을 사용하다 보면 생기는 변경의 관점에서 유연함이 떨어질 가능성이 있으니 객체 조립을 보다 우선순위로 고민하는 것이 순서임을 알고가자.
객체 조립의 경우에도 생기는 단점이 있다. 상대적으로 런타임 환경에서 구조가 복잡해진다는 것이다. 또한 상속보다 구현이 어렵다.
이처럼 프로그래밍에서 완벽한 기술이란 없기에 두 구현 방법에 있어 장단점을 고려하고 사용하는 것인데, 객체 조립 방법을 더 지향하는 이유는 일반적으로 장기적 관점에서 구현/구조의 복잡함보다 변경의 유연함을 확보하는 것에서 얻는 이점이 더 크기 때문이다.
따라서 기능을 재사용하는 경우, 상속보다는 조립을 먼저 고려하자.
상속을 사용한 경우(왼) 필요한 기능을 담고 있는 클래스를 하나만 쓰면 기능을 수행하지만, 조립을 사용하는 경우(오) 하나의 객체에서 다른 객체를 참조하여 기능을 수행하는 형식이기에 구조가 다소 복잡해진다. 만약 암호화하고 압축을 사용해야한다면, 왼쪽의 경우는 EncryptedCompressedStorege 클래스 하나만 사용하면 구현되지만, 오른쪽은 하나의 객체에서의 기능을 위해 다른 객체를 만들고 참조하여 구현한다.
2.1 위임
위임은 내가 할 일을 다른 객체에 넘긴다는 의미이다. 그리고 조립 통한 구현에서 대게 위임을 구현한다.
예를 들어, 이미지 편집 툴을 만들 경우에 마우스 포인터의 위치가 특정 도형이 차지하는 영역에 포함되어 있는지 확인하는 기능이 필요할 것이다.
따라서 도형을 표현하는 Figure 클래스를 만들고 있었는데, Bounds 클래스에서 현재 필요로하는 기능을 지니고 있었다.
그럼 Figure 클래스는 Bounds 객체에게 포함 여부 확인하는 기능의 대리 수행을 위임할 수 있다.
public abstract class Figure {
private Bounds bounds = new Bounds(); // 위임 대상을 조립 형태로 가진 모습
...
private void changeSize() {
// 크기 변경 코드 위치
bounds.set(x, y, width, heigh);
}
public boolean contains(Point point) {
// bounds 객체에 처리를 위임
return bounds.contains(point.getX(), point.getY());
}
}
위임은 조립과 마찬가지로 요청을 위임할 객체를 필드로 연결한다.
또는 필요한 경우에만 객체를 생성하여 필요한 기능을 요청하여 넘긴다. 이 역시 위임의 한 형태이다.
public abstract class Figure {
public boolean contains(Point point) {
Bounds bounds = new Bounds(x, y, width, height); // 필요한 객체를 새로 생성
return bounds.contains(point.getX(), point.getY());
}
}
단, 위임을 사용하는 경우, 바로 실행할 수 있는 걸, 다른 객체에게 한 번 더 요청하게 된다. 과정에서 메서드 호출이 추가되기에 실행시간은 약간 증가한다. 만약 연산 속도가 매우 중요한 시스템이라면, 많은 위임은 성능 문제를 일으킬 수 있지만, 대부분 위임으로 발생하는 미세한 성능저하는 위임을 통해 얻는 이점보다 작다.
2.2 상속은 언제 사용하나?
지금까지 상속 대신 조립을 이용한 재사용의 장점을 파악했다. 그런데 그럼 상속은 언제 사용해야 할까?
상속을 사용함에는 재사용이라는 관점이 아닌 기능의 확장이라는 관점에서 상속을 적용하는 것이 맞다.
또한 명확한 IS-A 관계가 성립되어야 한다.
상속을 통해 클래스가 하위로 추가되어도 상위 클래스의 기본적 기능을 그대로 유지하면서, 그 기능을 확장하는 형태면 상속을 사용하는 것이 바람직하며 명확한 IS-A 관계라면 상속을 통한 기능의 재사용 및 확장을 사용하자.
다만, 이후에 클래스 개수가 불필요하게 증가하거나 상위 클래스의 변경 어려움 등의 문제가 발생한다면, 그 때는 조립으로 전환하는 것을 고려하자.
댓글