Inheritance(상속)
Composition:
the nature of something's ingredients or constituents; the way in which a whole or mixture is made up.
구성. 어떤 것을 이루는 재료나 구성요소를 뜻함.
자바에서의 상속(Inheritance)이라고 하면 extends를 이용하여 자식 클래스가 부모 클래스를 상속받고, 상속받은 부모클래스의 메서드를 오버라이딩(overriding)을 통해 재정의하는 예시가 있다.
상속을 통한 코드 재사용성(중복 감소 및 유지보수 편리), 다형성(Polymorphism)(높은 코드 유연성 및 추상적 코드), 확장성(쉬운 기능 확장), 추상화와 캡슐화(응집도 증가 및 결합도 감소) 등의 장점들이 존재하지만 무분별한 상속으로 인해 코드의 유연성이 깨지고 캡슐화를 해치는 등 오히려 부작용이 발생하기도 한다.
상속의 단점
아래의 예시를 통해 무분별한 상속을 하였을 시에 어떤 문제가 일어날 수 있는지 알아보자. HashSet을 상속받는 CustomHashSet 클래스를 정의하고 기존 HashSet의 add(), AbstractCollection의 addAll()을 오버라이딩을 통해 재정의 하였다.
import org.jetbrains.annotations.NotNull;
import java.util.*;
public class CompositionTest {
public static void main(String[] args) {
CustomHashSet<String> customHashSet = new CustomHashSet<>();
List<String> breadList = Arrays.asList("피자빵", "메론빵", "초코빵", "깨찰빵");
customHashSet.addAll(breadList);
System.out.println(customHashSet.cnt); // 8
}
public static class CustomHashSet<E> extends HashSet {
private int cnt = 0;
public CustomHashSet() {}
@Override
public boolean add(Object o) {
cnt++;
System.out.println("overrided add() executed.");
return super.add(o);
}
@Override
public boolean addAll(@NotNull Collection c) {
cnt += c.size();
System.out.println("overrided addAll() executed.");
return super.addAll(c);
}
}
}
의도한 출력 값 4가 아닌 8이 나오고 있으며, main메서드에서 호출하지도 않은 add()가 네 번이나 호출된 것을 확인할 수 있다. 왜 그런 것일까?
CustomHashSet과 HashSet의 상속관계 및 계층관계는 아래와 같다.
가장 하위 클래스에 CustomHashSet, 가장 상위 클래스에 AbstractCollection 클래스가 존재하여 아래 클래스들이 상속을 받는다.
CustomHashSet에서 오버라이드한 add()와 addAll()의 리턴 값이 super.add() / super.addAll()로 각자 상위 클래스의 메서드를 호출한다. CustomHashSet의 상위 클래스 HashSet과 AbstractSet에서는 해당 메서드들을 재정의하지 않아 부모 클래스인 AbstractCollection의 메서드를 그대로 사용한다. 문제는 여기서 발생한다.
// AbstractCollection의 addAll()
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
AbstractCollection의 addAll()의 내부에서 add()가 호출되는데, 이 때 CustomHashSet에서 오버라이딩을 통해 재정의가 된 add()가 호출된다. 따라서 for(E e : c) 루프문을 통해 collection의 요소 개수만큼 루프를 돌며 add()를 호출하는 것이다.
예시에서 정적 배열 breadList = ("피자빵", "메론빵", "초코빵", "깨찰빵")을 인자로 받아 오버라이드 된 메서드의 cnt += c.size()를 통해 cnt가 4가 되고, AbstractCollection.addAll(breadList)가 호출되며 for(E e : breadList) 루프문을 통해 add()가 총 네 번 실행되어 cnt 값에 1이 네 번 더해져 결과가 8이 나오게 되는 것이다.
예시의 문제 해결을 위해서는 add(), addAll() 둘 중 하나의 메서드에서 cnt 증가 기능을 없애야 한다. 하지만 해당 방법은 논리적이지 않은 코드 작성일 뿐만 아니라, 추후에 HashSet 클래스에 수정이 발생하면 다시 문제가 되고 오류 또한 발생할 수 있다.
이러한 문제를 막기 위해 꼬리를 무는 식의 상속이 아닌 깔끔하게 인터페이스 구현체를 만들고 구현체에 메서드 오버라이딩을 한 후에 구현체를 상속받아서 클래스의 메서드를 다시 오버라이드 하는 방식이 컴포지션 방식이다.
컴포지션(Composition)
컴포지션은 포워딩(Forwarding)이라고도 한다.
상속의 경우 위의 예시에서 볼 수 있듯이 결합도 증가, 자식 클래스의 부모 클래스 의존성 증가, 로직의 오류 등의 문제가 발생할 수 있다. 이러한 문제를 해결하기 위해서 컴포지션 방식에서는 클래스간 결합도를 최대한 줄여 느슨한 결합을 지향한다.
컴포지션 방식에서는 상위 클래스를 확장(extends)하는 상속이 이루어지지 않는다. 컴포지션에서는 부모 클래스에서 자식 클래스를 변수로 선언하여 부모 클래스와 자식 클래스간 영향이 적도록 설계한다. 아래의 예시를 살펴보자.
CustomSet 사용자 커스텀 클래스 내에 Set 인터페이스의 구현체 HashSet을 변수로 선언 후 내부에 hashSet 인스턴스의 메서드를 호출하는 것을 확인할 수 있다. 이전과 같이 super키워드로 상위 클래스의 메서드를 호출하는 것이 아니기 때문에 온전히 hashSet의 메서드만 호출할 수 있다. 해당 방법을 사용하여 add, addAll 메서드 호출 시 AbstractSet, AbstractCollection의 영향을 받지 않게 되어 CustomSet 클래스가 두 클래스로부터 완전히 분리된 것을 확인할 수 있다.
import java.util.*;
public class Main {
public static void main(String[] args) {
CustomSet<String> customSet = new CustomSet<>();
List<String> breadList = Arrays.asList("피자빵", "메론빵", "초코빵", "깨찰빵");
customSet.addAll(breadList);
System.out.println(customSet.getCnt()); // 4
}
}
class CustomSet<E> {
private int cnt = 0;
private Set<E> set = new HashSet<>();
public boolean add(E e) {
cnt++;
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
cnt += c.size();
return set.addAll(c);
}
public int getCnt() {
return cnt;
}
}
다른 예시를 통해 컴포지션에 대해 알아보자.
Student, Teacher 클래스 모두 Member 인터페이스를 구현하는 구현체 클래스이다. SchoolMember 클래스에서 컴포지션 방식을 활용하여 Student, Teacher 인스턴스 member를 생성자의 인자로 받아 SchoolMember의 메서드 호출 시 변수로 선언한 인스턴스인 member의 메서드를 호출한다.
public class Main {
public static void main(String[] args) {
SchoolMember student = new SchoolMember("Cheolsoo", new Student());
SchoolMember teacher = new SchoolMember("Younghee", new Teacher());
student.role(); // Cheolsoo's role is to study
teacher.role(); // Younghee's role is to teach
}
}
class SchoolMember {
private final String name;
private final Member member; // student 또는 teacher 구현체를 변수로 가진다.
public SchoolMember(String name, Member member) {
this.name = name;
this.member = member;
}
public void role() {
System.out.print(name + "'s role is to ");
member.role(); // member 객체의 role 메서드를 호출
}
}
class Student implements Member {
@Override
public void role() {
System.out.println("study");
}
}
class Teacher implements Member {
@Override
public void role() {
System.out.println("teach");
}
}
interface Member {
void role();
}
상속을 사용하지 말라는 것은 아니다. 상속을 사용하는 것도 좋지만 SOLID 원칙의 LSP 원칙을 위배하지 않으며 사용해야 한다.
리스코프 치환 원칙 LSP(Liskov Substitution Principle)
"자식 클래스는 언제나 부모 클래스의 대체가 가능해야 한다."
1. 자식 클래스는 부모 클래스의 기능을 완전히 지원하고 부모 클래스와 동일한 방식으로 동작해야 한다.
2. 상속받은 자식 클래스의 메서드는 부모 클래스와 반드시 동일한 결과를 보장해야 한다.
3. 상속된 메서드의 인자, 반환 값, 예의 등의 명세를 확장할 수 없다. 상속받은 하위 클래스의 메서드가 부모 클래스의 명세를 반드시 준수해야 한다.
하지만 설계 당시에 LSP 원칙을 위배하지 않더라도, 추후에 클래스의 변화가 발생할 가능성이 있고 결국에는 LSP 원칙의 IS-A 관계(상속 관계)를 성립할 수 없는 경우가 대부분이다. 이런 점을 고려해 Composition, Forwarding 기법을 사용하는 것이 객체 지향적, 유연한 설계에 도움이 될 수 있다.
References
[Java] 컴포지션(Composition) | 👨🏻💻 Tech Interview
[Java] 컴포지션(Composition) 우선 상속(Inheritance)이란, 하위 클래스가 상위 클래스의 특성을 재정의 한 것을 말한다. 부모 클래스의 메서드를 오버라이딩하여 자식에 맞게 재사용하는 등, 상당히 많
gyoogle.dev
💠 상속을 자제하고 합성(Composition)을 이용하자
상속과 합성 개념 정리 프로그래밍을 할때 가장 신경 써야 할 것 중 하나가 바로 코드 중복을 제거하여 재사용 함으로써 변경, 확장을 용이하게 만드는 것이다. 그런 관점에서 상속과 합성은 객
inpa.tistory.com
'Java' 카테고리의 다른 글
[Java] Reflection (0) | 2024.04.21 |
---|---|
[Java] 고유 락(Intrinsic Lock) (0) | 2024.04.08 |
[Java] Object 클래스 (0) | 2024.04.07 |
[Java] Thread(스레드) (1) | 2024.04.07 |
[Java] 가비지 컬렉션(Garbage Collection) (0) | 2024.04.06 |