Composition이란?
기존 클래스를 상속을 통한 확장 대신, 필드로 클래스의 인스턴스를 참조하게 만드는 설계입니다.
가령, Service나 Repository를 인터페이스로 구축하고 해당 내용을 사용하기 위해 인스턴스로 받아와서 참조해 사용할 때 Composition을 쓰고 있는 것입니다.
상속과 합성의 차이점
상속
부모 클래스와 자식 클래스의 의존성을 컴파일 타임에 해결합니다.
Is-A 관계로 한 개의 부모 클래스에 한 개의 자식 클래스를 가집니다.
부모 클래스의 구현에 의존 결합도가 높습니다.
클래스 사이의 정적인 관계로 융통성 있게 사용하기 어렵습니다.
부모 클래스 안에 구현된 코드 자체를 물려 받아 재사용하기에 예기치 못한 에러가 발생할 우려가 있습니다.
합성
두 객체 사이의 의존성을 런타임에 해결합니다.
Has-A 관계로 한 개의 부모 클래스를 여러 곳에서 사용할 수 있습니다.
구현에 의존되지 않고, 내부에 포함되는 객체의 구현이 아닌 인터페이스에 의존하게됩니다.
객체 사이의 동적인 관계로 유연하게 사용할 수 있습니다.
포함되는 객체의 퍼블릭 인터페이스를 재사용할 수 있습니다.
상속하면 되지 않을까?
Class를 사용할 때, 상속은 코드를 재활용할 수 있는 강력한 수단입니다.
공통의 빵이라는 클래스에 칼로리라는 인스턴스와 먹다라는 메서드가 있다면 그 빵의 자식 클래스는 모두 칼로리 인스턴스와 먹다라는 메서드를 가지게 됩니다. 즉, 상위 클래스의 코드를 하위 클래스가 재사용 해서 불필요한 반복적인 코드를 줄일 수 있습니다.
이 경우 빵 Class를 상속만 하면 무리없이 구조화된 클래스를 만들 수 있게됩니다.
따라서, 명확한 Is-A 관계에 있는 경우, 그리고 상위 클래스가 확장할 목적으로 설계되었고 문서화도 잘되어 있는 경우에 사용하면 좋습니다.
하지만, 상속을 제대로 활용하기 위해서는 부모 클래스의 내부 구현에 대해 상세하게 알아야하기 때문에 부모 클래스의 의존도가 높은 자식 클래스를 만들 수 밖에 없습니다.
또한 상속 관계가 컴파일 타임에 결정되고 고정되기에 코드를 실행하는 도중에 변경할 수 없다는 단점이 있습니다.
따라서 여러 기능을 조합해야하는 설계에 상속을 이용하게 된다면 모든 조합별로 클래스를 하나하나 추가해줘야 합니다.
활용 예시
상속이 어떻게 잘못 쓰일 수 있는지 예시로 알아보겠습니다.
public static 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 addCount;
}
}
public static void main(String[] args) {
CustomHashSet<String> customHashSet = new CustomHashSet<>();
List<String> test = Arrays.asList("a","b","c");
customHashSet.addAll(test);
System.out.println(customHashSet.getAddCount());
}
addAll()을 통해 리스트의 값 개수를 세는 로직을 구성했습니다. 기대하는 값은 a,b,c 3개를 더했으므로 3입니다.
하지만 결과는 6이 나옵니다.
그 이유는 AbstractCollection의 addAll() 메서드를 확인하면 알 수 있습니다.
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
addAll() 클래스 내부에서 add() 메서드를 적용하기에 addAll() 메서드를 실행했을 때 2배의 값이 더해지는 것을 알수 있습니다.
이처럼, 메서드 내부의 설계 방식에 대해 이해하지 못하면 예기치 못한 에러를 만나실 수 있습니다.
따라서 컴포지션을 통해 구현해보겠습니다.
컴포지션은 기존의 클래스를 확장하는 개념이 아니라 새로운 클래스를 생성해 private 필드로 기존 클래스의 인스턴스를 참조하는 방식입니다.
우선 Set를 imple로 받아온 ForwardingSet을 만들어 줍니다.
public class ForwardingSet<E> implements Set{
private final Set<E> set;
public ForwardingSet(Set<E> set) {
this.set = set;
}
@Override
public int size() {
return set.size();
}
@Override
public boolean add(Object o) {
return set.add((E) o);
}
@Override
public boolean addAll(Collection c) {
return set.addAll(c);
}
...
}
위와 같이 ForwardingSet을 재정의 해줬습니다.
public static class CustomHashSet<E> {
private int addCount = 0;
private Set<E> set = new HashSet<>(); // 불러와서 사용
public boolean add(Object o) {
addCount++;
return set.add(o);
}
public boolean addAll(Collection c) {
addCount += c.size();
return set.addAll(c);
}
public int getAddCount() {
return addCount;
}
}
그리고 다시 기존의 Set을 정의 해보면
public static void main(String[] args) {
CustomHashSet<String> customHashSet = new CustomHashSet<>();
List<String> test = Arrays.asList("a","b","c");
customHashSet.addAll(test);
System.out.println(customHashSet.getAddCount());
}
제대로 3이 나오는 것을 알 수 있습니다.
즉, CustomHashSet이 원하는 작업을 할 수 있도록 ForwardingSet은 위임의 역할을 가집니다.
상속을 사용하는 것은 좋지만, 예기치 못한 문제가 생길 우려가 있기에 유연한 객체지향적 설계를 위해 사용을 권장합니다.
'기술 스텍 > Java' 카테고리의 다른 글
[Java] 인터페이스 vs 추상클래스 용도 차이 (3) | 2024.04.21 |
---|---|
[Java] Reflection이란? (4) | 2024.04.13 |
[Java] Interned String (1) | 2024.04.05 |
[Java] Record란? (0) | 2024.04.05 |
[Java] 프로세스 vs 스레드? (0) | 2024.02.21 |