개발하는 프로 국밥러
Published 2022. 9. 26. 07:56
상속과 합성 아키텍처/OOP

[상속이란?]

상속은 부모 클래스에 중복 로직을 구현해 놓고, 이를 상속받은 자식 클래스에서 이를 재사용하는 방법이다.

상속은 흔히 is-a 관계라고 부른다.

예를 들어 요리사 클래스와 사람 클래스가 있을 때 요리사는 사람이므로 이러한 관계를 is-a 관계라고 한다.

public class Person {

    public void walk() {
        System.out.println("걷는다");
    }

    public void talk() {
        System.out.println("말한다");
    }
}

public class Chef extends Person {
    
}

 

요리사 클래스는 사람 클래스를 상속 받았기에, 요리사 클래스는 사람 클래스의 메서드를 사용 가능하다.

Person person = new Chef();

person.walk();
person.talk();

[합성이란?]

합성은 중복되는 로직들을 갖는 객체를 구현하고, 이 객체를 주입받아서 중복 로직을 호출하므로 인터페이스를 재사용하는 방법이다. 

합성을 흔히  has-a 관계라고 부른다.

예를들어 요리사가 음식 가격을 계산해야 하는 상황이라 했을 때, 요리사는 자신이 만든 음식을 가지고 있으므로, 이러한 관계를 has-a 관계라고 한다.

public class Chef {

    private List<Food> foodList;
    
    public Chef(List<Food> foodList) {
        this.foodList = foodList;
    }

    public int calculatePrice() {
        return foodList.stream()
            .mapToInt(v -> v.getPrice())
            .sum();
	}
}

[상속과 합성의 차이]

상속된 코드를 재사용하는 것과, 합성으로 인터페이스를 사용하는 것은 근본적으로 다르다!

왜냐하면, 합성을 사용하게 되면 객체의 내부를 공개하지 않고 인터페이스를 통해 코드를 재사용 하기 때문에, 구현에 대한 의존성을 의존성을 인터페이스에 대한 의존성으로 변경할 수 있다.

[상속의 단점 및 한계]

상속은 다음과 같은 단점을 가지고 있기에, 상당히 선택적으로 사용해야 한다. 

  • 캡슐화가 깨지고 결합도가 높아짐
  • 유연성 및 확장성이 떨어짐
  • 다중상속에 의한 문제가 발생할 수 있음
  • 클래스 폭발 문제가 발생할 수 있음

 

캡슐화가 깨지고 결합도가 높아짐

객체지향 프로그래밍에서 이상적인 설계란 결합도는 낮고, 응집도는 높은 설계이다.

객체지향의 장점 중 하나는 추상화에 의존함으로써 다른 객체에 대한 결합도는 최소화 하고, 응집도를 최대화하여 변경에 유연하게 대처하는 것이다.

하지만 상속을 사용하게 되면, 부모 클래스와 자식 클래스의 관계가 컴파일 시점에 결정되어 구현에 의존하기에 문제가 생긴다. 

이를 통해, 런타임에 객체의 종류를 변경하는 것은 불가능하며, 다형성과 같은 좋은 객체지향 기술을 사용할 수 없다.

또한, 상속에서 자식 클래스는 부모 클래스의 코드를 직접 호출이 가능하기 때문에, 부모 클래스의 구조를 잘 알고 있어야 한다.

 

아래 예시를 보자

@RequiredArgsConstructor
public abstract class Food {

    private final int price;

    public int calculatePrice() {
        return price;
    }
}

public class Steak extends Food {

    public Steak(final int price) {
        super(price);
    }

    protected int discountAmount() {
        return 10000;
    }
}

public class SteakWithSaladSet extends Steak {

    public SteakWithSaladSet(final int price) {
        super(price);
    }

    @Override
    public int calculatePrice() {
        // 원래 금액 - 스테이크를 주문한 경우 할인받을 금액
        return super.calculatePrice() - super.discountAmount();
    }
}

 

위 예시를 보면, SteakWithSaladSet 메뉴는 할인 금액을 위해 구체 클래스인 Steak 클래스에 의존하고 있다.

SteakWithSaladSet 에서 할인 금액을 포함한 가격을 계산하기 위해서는 부모 클래스인 Steak 클래스에서, discountAmount를 제공해야 함을 알고 있어야 한다. 이렇게 런타임이 아닌 컴파일타임에 구현에 의존하는 것을 컴파일 타임 의존성이라고 부르고, 이는 다형성을 사용할 수 없어 객체지향적이지 못한 코드이며, 부모 메소드의 이름이 변경시 모든 부분을 변경해주어야 하는 불상사를 낳는다.

즉, 상속을 이용하기 위해서는 부모 클래스의 내부 구조를 알아야 하고, 이를 통해 자식 클래스와 부모 클래스를 강하게 결합하게 된다.

 

유연성 및 확장성이 떨어짐

상속은 위에서도 말했듯이, 부모 클래스와 자식 클래스가 강하게 결합되므로 우연성과 확장성이 상당히 떨어진다.

예를 들어 Food 추상 클래스에 음식의 개수를 반환하는 새로운 메소드를 추가해야 하는 상황이라고 하자. 

@RequiredArgsConstructor
public abstract class Food {

    private final int price;
    private final int count;

    public int calculatePrice() {
        return price;
    }

    public int getFoodCount() {
        return count;
    }
}

문제는 이런 변경 사항이 자식 클래스에도 전파된다는 것이다.

public class Steak extends Food {

    public Steak(final int price, final int count) {
        super(price, count);
    }

    protected int discountAmount() {
        return 10000;
    }
}

public class SteakWithSaladSet extends Steak {

    public SteakWithSaladSet(final int price, final int count) {
        super(price, count);
    }

    @Override
    public int calculatePrice() {
        // 원래 금액 - 스테이크가 포함된 세트메뉴인 경우 할인받을 금액
        return super.calculatePrice() - super.discountAmount();
    }
}

그리고 자식 클래스 뿐 아니라, 자식 클래스가 선언되어 객체를 생성하는 부분 모두 수정해 주어야 한다.

Food food = new SteakWithSaladSet(15000, 2);

 

클래스 폭발 문제가 발생할 수 있음

상속을 남용하게 되면 필요 이상의 많은 클래스를 추가해야 하는 클래스 폭발이 발생할 수 있다.

만약 새로운 요구사항이 생겨 Steak와 Salad 그리고 Pasta로 구성된 세트 메뉴를 추가해야 하는 상황이라고 하자. 그러면 우리는 이를 해결하기 위해 다음과 같은 SteakWithSaladSetAndPasta 클래스를 또 추가해야 할 것이다.

public class SteakWithSaladSetAndPasta extends SteakWithSaladSet {
     ...
}

 

그리고 새로운 메뉴가 또 개발된다면 계속해서 해당 클래스를 추가해야 할 것이고 지나치게 많은 클래스가 생겨야 하는 클래스 폭발 문제가 발생할 수 있다.

클래스 폭발 문제는 자식 클래스가 부모 클래스의 구현과 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생한다.
컴파일 타임에 결정된 자식 클래스와 부모 클래스 사이의 관계는 변경될 수 없기 때문에, 자식 클래스와 부모 클래스의 다양한 조합이 필요한 상황에서 유일한 해결 방법은 조합의 수 만큼 새로운 클래스를 추가하는 것 뿐이다.
클래스 폭발 문제는 새로운 기능을 추가할 때뿐만 아니라 기능을 수정할 때에도 동일하게 발생한다.

[합성을 사용하자!]

상속은 컴파일 시점에 부모 클래스와 자식 클래스의 코드가 강하게 결합되는 반면에 합성을 이용하면 구현을 효과적으로 캡슐화할 수 있다. 또한 의존하는 객체를 교체하는 것이 비교적 쉬우므로 설계가 유연해진다.
따라서 코드 재사용을 위해서는 상속보다 합성을 선호하는 것이 더 좋은 방법이다.

위에서 살펴본 예시 상황들을 합성으로 풀어보도록 하자. 우리는 다음 음식에 대한 인스턴스 변수가 추가된 Food 클래스와 Steak 클래스를 추가하였고, 이번에는 합성을 이용하기 위해 새롭게 Salad 클래스를 추가하였다.

@RequiredArgsConstructor
public abstract class Food {

    private final int price;
    private final Food next;

    public int calculatePrice() {
        return next == null
            ? price
            : price + next.calculatePrice();
    }
}

public class Steak extends Food {

    public Steak(final int price, final Food next) {
        super(price, next);
    }

    @Override
    public int calculatePrice() {
        return next == null
            ? price
            : price - 10000 + next.calculatePrice();
    }
}

public class Salad extends Food {

    public Salad(final int price, final Food next) {
        super(price, next);
    }
}

 

위와 같이 작성된 Food는 스테이크와 샐러드로 구성된 세트메뉴를 추가해달라는 요구사항을 구현하기 위해 다음과 같이 선언할 것이다. 그리고 Chef가 해당 메뉴의 가격을 계산하기 위해서는 다음과 같이 처리가 될 것이다.

Food setMenu = new Steak(20000, new Salad(15000));
int price = setMenu.calculatePrice();

 

위와 같은 합성의 구조에서 달라진 점은 가격을 계산하기 위해 더 이상 클래스의 구현(Steak클래스의 discountAmount)에 의존하지 않는다는 것이다. 우리는 해당 기능을 구현하기 위해서 구체 클래스에 어떠한 구현이 있는지 살펴볼 필요 없이 그저 calculatePrice()를 호출하면 된다. 

이제 새로운 요구사항이 생겨, 세트 메뉴가 갖는 음식의 개수를 세어야 하는 상황이라고 하자. 우리는 이번에도 다음 음식인 Food 클래스의 next 객체가 존재하는지 메세지를 보내서 파악을 해야 한다. 이를 해결하기 위해 추상화된 클래스에 Food에 getFoodCount()를 이용해  내부를 구현하고, 다음 Food가 존재할 경우 동일하게 getFoodCount()를 호출하여 메소드를 재사용하도록 구현할 수 있다.

@RequiredArgsConstructor
public abstract class Food {

    ... 
    
    public int getFoodCount() {
        return next == null
            ? 1
            : 1 + next.getFoodCount();
    }
}

 

또한 이러한 합성에 의존하는 개발은 추가적인 세트 메뉴를 개발한다고 하여도 새로운 클래스를 추가할 필요가 없을 것이다.

이렇듯 컴파일 시점에 코드 레벨에서 어느 클래스의 코드에 의존하는지 알 수 있었던 컴파일 의존성과 달리 현재 Food 객체에서 또 다른 Food 객체인 next에 의존하면서 컴파일 타임에 어떠한 구체 클래스에 의존하지 않고 추상화에 의존하는 것이 런타임 의존성이다. 컴파일 의존성에 속박되지 않고 다양한 방식의 런타임 의존성을 구성하여 추상화에 의존할 수 있다는 것은 합성의 가장 커다란 장점이다.

물론 컴파일 타임 의존성과 런타임 의존성과 거리가 멀수록 설계의 복잡도가 상승하여 코드를 이해하기 어려워지지만, 유지보수를 위해서는 반드시 필요하다.

참고

 

profile

개발하는 프로 국밥러

@gugbab2

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!