본문 바로가기
java

상속과 합성

by CodingMasterLSW 2025. 3. 17.

우리는 공통 코드를 줄이기 위해 상속을 많이 사용한다. 하지만 상속에는 많은 단점들이 존재한다. 

공통되는 로직을 줄이기 위해서 상속이 정말 좋은 방법일까? 


상속을 사용하면 캡슐화가 깨지고 결합도가 높아진다.

예시 코드를 살펴보자.

 

1주차 미션에서 진행한 로또 코드를 가져왔다. 미션을 했다면 알다시피 로또는 두 가지 종류로 나뉜다.

 

1) 당첨 번호

2) 구매 번호

 

당첨번호와 구매번호는 매우 비슷하기에 중복되는 코드를 줄이기 위해 상속을 사용해보자. 두 객체의 차이점은 당첨 번호는 bonusNumber라는 추가 필드를 가지고 있다는 점이다.

public class Lotto {

    private int[] numbers;

    public Lotto(int[] numbers) {
        this.numbers = numbers;
    }
}
public class WinningLotto extends Lotto{

    private final int bonusNumber;

    public WinningLotto(int[] numbers, int bonusNumber) {
        super(numbers);
        this.bonusNumber = bonusNumber;
    }
}

 

 

보기에는 별 문제가 없는 코드 같다. 중복 코드를 상속을 통해 제거했으니까 훌륭한 설계 아닐까?

 

이후에 자바에 대해 공부를 하던 중, 배열보다 컬렉션을 사용하는게 더 좋다는 글을 보았고, 리팩토링을 진행하기로 결심했다.

public class Lotto {

    private List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;
    }

}

 

Lotto 객체를 수정하는 과정에서, Lotto를 상속받는 WinningLotto의 타입 또한 수정해야되는 상황을 발견했다. 부모 타입을 바꿨는데, 이를 상속하는 자식 클래스 또한 수정을 해야한다. 이는 상속의 단점을 명확하게 보여주는 예시다.

 

상속을 사용하면 부모 클래스의 캡슐화가 깨지고 결합도가 높아진다. 부모 클래스와 자식 클래스의 관계가 컴파일 시점에 결정되어 구현에 의존하기 때문이다.


상속은 불필요한 메서드 또한 상속받아야 한다.

public static void main(String[] args) {
    Stack<String> stack = new Stack<>();
    stack.push("1");
    stack.push("2");
    stack.push("3");
    stack.add(0, "4");
    System.out.println(stack.pop()); // 기댓값 : 4
}

 

우리가 알고있는 stack.push() 는 후입선출 (Last-In-First-Out)을 기대하는 메서드다. 즉 가장 마지막에 들어간 "4" 를 기댓값으로 예상하지만, 실제 stack.pop()의 결과는 "3" 이 나온다. 왜 그럴까?

 

정답은 add() 메서드에 있다. 여기서 말하는 add 메서드는 그냥 List에 add()를 하는 그런 삽입 메서드다.

 

push() 와는 다르게 add() 를 이용해 특정 인덱스에 "4" 라는 값을 삽입했다. 물론 위와 같이 사용할 수 있지만, 후입선출을 기대하는 stack에서 굳이 add() 메서드를 제공할 필요가 있을까?

 

해당 메서드는 Stack에서 제공하는 것이 아닌, Vector에서 제공해주는 메서드이다. Stack은 Vector를 상속받았고, 부모의 메서드 또한 상속받기에 stack.add() 라는 메서드를 사용할 수 있어 개발자가 메서드를 혼동할 수 있는 위험이 생겨버렸다.

public class Stack<E> extends Vector<E> 

 

해당 메서드는 Stack에서 제공하는 것이 아닌, Vector에서 제공해주는 메서드이다. Stack은 Vector를 상속받았고, 부모의 메서드 또한 상속받기에 stack.add() 라는 메서드를 사용할 수 있다.

 

즉, Stack 입장에서는 불필요한 메서드까지 상속받았고,  개발자가 메서드를 혼동할 수 있는 위험이 생겨버렸다.

 


 

클래스 폭발 문제

예시 코드를 보면서 클래스 폭발 문제에 대해 이해해보자.

public class Animal {

    public void move() {
        System.out.println("슈슉");
    }
}

 

Animal을 상속받는 Dog가 있다.

public class Dog extends Animal{

    public void bark() {
        System.out.println("멍멍");
    }
}

 

Dog를 상속받는 수영하는 강아지가 있다.

public class SwimmingDog extends Dog{
    public void swim() {
        System.out.println("풍덩풍덩");
    }

}

 

근데 이 경우에서 수영할 수 있는 여러 강아지 타입을 구분해야 한다는 조건이 생겼다.

public class SwimmingMalteseDog extends SwimmingDog{

    public void bark() {
        System.out.println("나는야 말티즈 멍멍");
    }

}

 

이 말티즈에 또 다른 조건이 붙는다고 가정하자. 아니면 수영을 할 수 있는 다른 종의 강아지들이 생긴다고 가정해보자. 상속을 사용하려면 수 많은 클래스들이 생긴다. 가능한 조합을 상속을 통해 표현하려고 하면 복잡한 요구사항에서는 위와 같이 클래스 폭발이라는 문제가 발생할 수 있다.


합성

상속을 통해 중복되는 코드를 제거하는 방법은 알고 있다. 하지만 위에서 설명한 것 처럼 중복코드를 제거하기 위한 상속은 많은 문제점을 발생시킨다. 이러한 문제를 합성을 통해 해결할 수 있다.

 

합성은 전체를 표현하는 클래스가 부분을 표현하는 객체를 포함하여 부분 객체의 코드를 재사용 하는 방법이다.

 

합성의 장점

- 상속과 달리 부분 객체의 내부 구현이 공개되지 않는다.

- 메서드를 호출하는 방식으로 퍼블릭 인터페이스에 의존해 부분객체의 내부 구현이 변경되어도 비교적 안전하다.

- 부분 객체의 모든 퍼블릭 메서드를 공개하지 않아도 된다.

 

즉 합성을 사용하면 상속의 단점을 보완할 수 있다는 말이다. 예시를 통해 단점을 어떻게 해결하는 지 알아보자.

public class WinningLotto{

    private final int bonusNumber;
    private final Lotto lotto;

    public WinningLotto(int bonusNumber, Lotto lotto) {
        this.bonusNumber = bonusNumber;
        this.lotto = lotto;
    }
}

 

위에서 봤던 WinningLotto 객체를 상속 대신에 합성을 사용하도록 수정해봤다. 합성을 사용하니 lotto 객체 내부가 변경되어도 WinningLotto는 아무 상관이 없다. 

public class Lotto {

    private List<Integer> numbers;

    public Lotto(List<Integer> numbers) {
        this.numbers = numbers;
    }
    
    public List<Integer> getNumbers() {
        return Collections.unmodifiableList(numbers);
    }
    
    public void notUsingMethod1() {
        System.out.println("메롱메롱1");
    }

    public void notUsingMethod2() {
        System.out.println("메롱메롱2");
    }

    public void notUsingMethod3() {
        System.out.println("메롱메롱3");
    }
}

 

극단적이긴 하지만, Lotto 메서드를 살펴보자. 상속을 사용한다면 WinningLotto에 필요없는 notUsingMethod 1, 2, 3을 모두 상속받아야하지만, 합성을 사용한다면 우리에게 필요한 getNumbers() 메서드만 사용할 수 있다.

public class WinningLotto{

    private final int bonusNumber;
    private final Lotto lotto;

    public WinningLotto(int bonusNumber, Lotto lotto) {
        this.bonusNumber = bonusNumber;
        this.lotto = lotto;
    }
    
    public List<Integer> getNumbers() { // 합성을 통한 부분 메서드 사용
        return lotto.getNumbers();
    }
}

 

상속은 클래스와 강하게 결합된다. 하지만 합성은 메세지를 통해 느슨하게 결합된다.

 

단순히 중복되는 코드를 줄이기 위해서는 합성을 사용하자


 

상속은 언제 사용해야 할까?

상속은 크게 두 가지 용도로 다룬다.

 

서브 타이핑 - 다형적 계층 구조를 표현하기 위해 상속을 사용하는 경우 (부모와 자식 행동이 호환O)

서브 클래싱 - 다른 클래스의 코드를 재사용 하기 위한 경우 (부모와 자식의 행동이 호환 X)

 

위에서 본 상속의 문제점은 서브 클래싱 즉, 코드를 재사용 하기 위해 상속을 사용한 경우에 발생한 문제다. 

 

서브타이핑을 고려할 경우 즉, 동일하게 행동하는 인스턴스를 그룹화 하기 위해 (다형성을 활용하기 위해) 상속을 고려할 수 있다.

 

이 상황에서, 상속을 하기 적절한 관계인지 판단하기 위한 조건이 있다. 상속관계는 is-a 관계라고 부르고, 합성 관계는 has-a 관계라고 부른다.

 

 

1) is - a  관계

개는 동물이다. 펭귄은 새다. 원은 도형이다 와 같은 예시를 들 수 있다.

 

is-a는 하위 클래스는 상위 클래스의 일종이다 라고 말할 수 있어야 한다. 

 

2) 행동 호환성 (두 객체가 동일한 행동을 할까?)

 

책 Object(조용호) 에서는 다음과 같은 예시를 통해 상속 관게에 대해 설명한다.

 

public class Bird {

    public void fly() {
        System.out.println("슈웅슈웅");
    }
}
public class Penguin extends Bird {
}

 

 

펭귄은 새다. 하지만 펭귄은 날 수 없다. 

 

부모와 자식은 서로 동일한 행동을 할 수 있어야 한다. 해당 예시는 '펭귄은 새다' 라는 is-a 관계를 충족하지만, 펭귄은 날 수 없다는 오류가 있다. 위와 같은 구조에서 Bird를 상속받는다면 펭귄은 날 수 없기에 해당 메서드를 사용할 때 Exception을 던져야할까? 부터 시작해 코드가 어지러워지기 시작한다.

 

클라이언트가 두 타입이 동일하게 행동할 것이라고 기대한다면 두 타입을 타입 계층으로 묶을 수 있다. 클라이언트가 두 타입이 동일하게 행동하지 않을 것이라고 기대한다면 두 타입을 타입 계층으로 묶어서는 안된다. 이를 기억하자. 펭귄과 새는 is-a 관계를 만족하지만, 동일하게  행동하지 않는다.

 

 

결론 : 중복 코드를 제거할 목적이라면 합성을 사용하자. 다형성을 활용하고 싶다면 행동 호환성까지 고려한 후에 상속을 고려하자.

 

참고자료

https://mangkyu.tistory.com/199

https://www.youtube.com/watch?v=U4OSS4jJ9ns

'java' 카테고리의 다른 글

Java 의 불변 객체 (final, 방어적 복사, unmodifiable)  (0) 2025.03.13
Java Custom Exception  (0) 2025.03.04
Stream  (0) 2025.02.25
함수형 인터페이스  (0) 2025.02.25
Thread(3) - volatile, synchronize, lock  (8) 2025.01.19