본문 바로가기
JAVA

컴포지션(Composition)

by 다미르 2019. 4. 28.

객체지향 언어의 특징 중의 하나는 '상속'이다.

여기서 말하는 상속이란 클래스 B가 다른 클래스 A를 확장(Extends)하는 것.

즉, 인터페이스가 다른 인터페이스를 확장하거나 클래스가 인터페이스를 구현하는 것과는 다른 의미이다.

애초에 인터페이스 상속은 구현(Implements)이라는 별도의 keyword를 사용하기도 하고..

쨋든 상속의 내재된 위험성과 그 것을 피해갈 수 있는 구성(Composition)에 대해 정리해보자.


여러 객체의 공통되는 부분을 상위 클래스로 정의하고, 하위 클래스에서는 그 것을 확장하고 구현한다.

당연한 소리다.

확장할 목적으로 설계되고, 문서화도 잘 되어있는 클래스는 상속을 통해서 클래스 계층을 구성하며

개인적으로 내가 객체지향언어를 좋아하는 이유다.

 

하지만..

패키지의 경계를 너머서 다른 클래스의 구체 클래스를 상속하는 것은 위험하다.

1. 먼저 상위 클래스가 변하면 하위 클래스는 계속 수정되어야 한다.

 

해당 상위 클래스의 설계자가 상속을 염두해두고 작성했다면 사정이 좀 나을지는 몰라도,

구체 클래스에 그런 경우는 잘... 없는 듯 하다.

의존성이 생기면서 하위 클래스는 상위 클래스에 종속되는 것이다.

 

더 심각한 문제가 있다.

2. 상위 클래스의 메서드를 재정의 하는 경우 내부 구현 방식을 잘 모르면 예상치 못한 오류가 생길 수 있다.

아래의 예를 보자.

public class CustomHashSet<E> extends HashSet<E> {

	private int addCount = 0;
	public CustomHashSet() {
	}


	public CustomHashSet(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 this.addCount;
	}
	
	public static void main(String[] args) {
		CustomHashSet<String> c = new CustomHashSet<String>();
		c.addAll(Arrays.asList("1","2","3"));
		System.out.println(c.getAddCount());
		
	}
}

 

Set 인터페이스의 구현 클래스 중 한 개인 HashSet을 상속해서 CustomHashSet을 만들었다.

HashSet의 add()와 addAll() 메서드를 재정의 하였고,

이 메서드가 호출 될 때마다 총 몇개의 Collection이 추가되었는지 Count한다.

아래 예시에서는 1,2,3 총 3개의 Collection을 추가했으니, getAddCount()를 호출하면 3이라는 결과가 나와야한다.

 

하지만 실제로 실행시켜보면 6이라는 결과를 return한다.

그 이유는 상위 클래스 HashSet의 addAll()메서드는 내부적으로 add() 메서드를 호출하기 때문이다.

그래서 addAll() 메서드 호출 → addCount += Collection.size() → 상위 클래스의 addAll() 호출

→ add() 호출 → addCount++ → 다시 add() 호출 ... 과정을 거쳐서

두배씩 count가 된 것이다.

 

이는 HashSet의 내부구현에 해당하며 문서를 통해 소개되어 있지 않으면 체크하기 쉽지않다.

그리고 다음 release에서도 이러한 내부구현 방식을 유지할지도 미지수이며,

그 때마다 확인해서 수정하는 작업을 반복해야 할 것이다.

 

3. 다음 release에서 상위 클래스에 새로운 메서드가 추가될 수도 있다.

만약 내가 기존에 사용하던 메서드와 이름이 같고, return type이 다르면 클래스는 컴파일이 되지 않는다.

또한 내가 CustomHashSet을 작성하던 시점에는 HashSet에 추가된 메서드가 존재하지 않았으니,

그 추가된 메서드의 규약을 만족하지 못할 가능성도 크다.

 

이런 저런 이유로 다른 패키지의 구체 클래스를 상속하는 것은 깨지기 쉽고, 종속적인 일회성 코드가 되기 쉽다.


이러한 문제를 피해갈 수 있는 것이 구성(Composition)이다.

기존 구체 클래스(이하 기존 클래스)를 확장하는 대신,

새로운 클래스에 private 필드로 구체 클래스의 인스턴스를 참조하자.

 

기존 클래스가 새로운 클래스의 구성요소로 사용된다는 의미에서 이러한 설계를 구성이라고 부른다.

새로운 클래스가 하는 일은 간단하다.

private 필드로 참조하는 기존 클래스의 메서를 호출하여 그 결과를 반환한다.

이러한 것을 전달(Forwarding)이라고 하고, 새로운 클래스의 메서드를 전달 메서드라고 부른다.

 

그렇다면 이러한 방식의 장점은 무엇일까?

새롭게 구성한 CustomHashSet의 예제를 보자.

public class ForwardingSet<E> implements Set<E> {

	private final Set<E> s;

	public ForwardingSet(Set<E> s) {
		this.s = s;
	}

	@Override
	public boolean add(E e) {
		return s.add(e);
	}

	@Override
	public boolean addAll(Collection<? extends E> c) {
		return s.addAll(c);
	}
    
    //....
    }

먼저 ForwardingSet 클래스는 Set 인터페이스를 구현한 전달 객체이다.

private 필드로 Set 객체 참조변수를 저장하며,

전달 메서드를 통해서 이에 대응하는 Set 메서드를 호출하여 return한다.

 

그리고 새로운 CustomHashSet이 이 ForwardingSet을 확장한다.

public class NewCustomHashSet<E> extends ForwardingSet<E>{
	private int addCount = 0;

	public NewCustomHashSet(Set<E> s) {
		super(s);
	}
	
	// 새로운 HashSet의 add
	@Override
	public boolean add(E e) {
		addCount++;
		return super.add(e);
	}
	
	// 새로운 HashSet의 addAll
	@Override
	public boolean addAll(Collection<? extends E> c) {
		addCount += c.size();
		return super.addAll(c);
	}
	
	public int getAddCount() {
		return this.addCount;
	}
	
	public static void main(String[] args) {
		NewCustomHashSet<String> n = new NewCustomHashSet<>(new TreeSet<>());
		n.addAll(Arrays.asList("1","2","3"));
		System.out.println(n.getAddCount());
	}
}

달라진 것은 크게 없다.

HashSet을 직접 확장하지 않고 Set의 전달 클래스인 ForwardingSet을 확장했을 뿐이다.

하지만 결과는 많이 다르다.

1. 기존 클래스의 내부 구현의 영향에서 벗어난다.

위의 코드를 실행해보면 addCount의 결과값이 '3'으로 나온다.

addAll() 호출 → addCount += collection의 size → 상위 클래스(전달 클래스)의 addAll() 호출

→ 상위 클래스의 addAll() 반환 → 종료..

이런 식으로 진행되기 때문이다.

 

2. CustomHashSet은 HashSet 타입의 객체이지만,

새로운 NewCustomHashSet은 이름만 HashSet이지 사실 모든 Set type을 사용할 수 있다.

즉, 임의의 Set에 계측 기능을 추가하여 새로운 Set을 만들어 낼 수 있다는 것이다.

이러한 클래스를 다른 Set 인스턴스를 감싸고 있다는 뜻에서 래퍼(Wrapper) 클래스라고 한다.

또한 Set에 계측 기능을 추가한다는 뜻에서 데코레이터 패턴이라고 부르기도 한다.


상속은 강력하지만 하위 클래스를 해칠 수 있다.

정말 상속이 필요한 경우는 상위 클래스와 하위 클래스가 is-a 관계일 때만 사용하는 것이 원칙이다.

또한 is-a의 관계더라도 서로 다른 패키지일 경우 상위 클래스가 상속을 고려해서 설계되지 않은 경우

위에 살펴본 문제들이 발생할 수 있다.

 

이런 경우를 대비해서 사용할 수 있는 것이 컴포지션과 전달이다.

특히 래퍼 클래스를 생성해 낼 수 있는 적당한 인터페이스(여기 예제에서는 Set 인터페이스)가 존재한다면,

래퍼 클래스를 통해 견고하고 유연한 확장이 가능하다.

'JAVA' 카테고리의 다른 글

싱글톤  (0) 2019.04.15
빌더 패턴!  (0) 2019.04.08
정적 팩토리 메서드의 장단점  (0) 2019.04.07