HJW's IT Blog

상속보단 합성을 사용하자 본문

개발 개념

상속보단 합성을 사용하자

kiki1875 2025. 1. 6. 14:59

1. 상속은 무엇인가?

Inheritance 는 간단히 말해 부모 클래스의 기능과 속성을 자식 클래스가 물려받는 관계이다. 예를 들어 다음과 같은 상황이 상속이다.


class Animal{
	void eat() {
		System.out.println("Eating...");
	}
}

public class Cat extends Animal{
    public void meow(){
        System.out.println("meow");
    }
}

public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();
        cat.eat();
        cat.meow();
    }
}

 

이와 같이 Cat 에는 분명 meow 함수만 정의되어 있지만, 상위 클래스의 eat 메서드도 사용이 가능하다. 상속을 사용함으로써 얻을 수 있는 장점은 다음과 같다.

  1. 코드의 재사용성 증가
  2. 명확한 계층 구조
  3. 다형성 활용

언뜻 보면 상속은 코드의 재사용성을 줄일 수 있는 강력한 기능 같다. 하지만 당신이 개발자라면 이 말을 한번은 들어 보았을 것이다. “상속보다는 합성을 우선 고려하라”. 이 말의 의의를 한번 탐구해 보겠다.

 

1.1 상속의 문제점

상속의 대표적인 문제점을 꼽자면, 강한 결합도이다. 부모 클래스를 상속받은 자식 클래스는 부모 클래스에 강하게 결합된다. 즉, 부모 클래스에 변경이 생긴다면, 자식 클래스 또한 영향받을 확률이 매우 높다. 예를 들어 위 예시에서 Animal 클래스의 eat 메서드가 사라진다면 Cat 클래스에서 또한 사용이 불가능 한 것이다.

 

상속은 기본적으로 is-a 관계를 기반으로 한다. 이 뜻은, 자식 클래스는 부모 클래스의 특수한 유형 임을 뜻한다. 그렇기에, Cat is an Animal 이라는 관계가 형성된다. 하지만 이로 인해 재사용 가능성 / 유연성이 제한된다. Java 는 우선, 다중 상속을 지원하지 않는다. 하나의 클래스는 둘 이상의 부모 클래스를 상속받을 수 없다는 의미이다. 만약 위 상황의 Cat 클래스가 다른 부모 클래스인 Pet 클래스의 속성도 상속받고 싶다면? → 상속으로 해결 불가능.

 

다음 상황도 한번 생각해 보자. 이전에 언급했듯이, 자식 클래스는 부모 클래스의 모든 메서드를 상속받는다. 하지만 모든 기능이 필요 없다면? 오버라이딩으로 일일이 비활성화해야 하는 상황이 발생할 것이다.

 

2. 합성이란?

 

class Animal {
    void eat() {
        System.out.println("Eating...");
    }
}

public class Cat {
    private Animal animal;

    public Cat() {
        this.animal = new Animal(); 
    }

    public void eat() {
        animal.eat();
    }

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

public class Main {
    public static void main(String[] args) {
        Cat cat = new Cat();
        cat.eat();
        cat.meow();
    }
}

위는 이전의 상속기반의 코드를 합성으로 변경한 예시이다. Composition 이란, 객체가 다른 객체를 상속받지 않고, 자신의 구성요소 인스턴스로 포함하여 동작을 구현하는 설계 기법이다. 이러한 관계를 has-a 관계라 한다.

 

2.1 합성의 특징

합성은 상속과 달리 객체간의 의존성을 낮추고 개별적인 동작과 역할을 쉽게 교체, 확장할 수 있게 한다. 상속 기반의 설계에선, Cat은 Animal 의 모든 메서드를 상속받아야 했지만, 합성 기반의 설계에선, 필요한 메서드만 사용할 수 있다.

여기서 Animal 클래스에 수정사항이 생긴다고 가정해 보자. 합성 기반의 설계는 이러한 상황에서의 유지보수를 더욱 쉽게 만들어 준다. 만약 상속 기반의 설계였다면, Cat 클래스는 높은 확률로 영향을 받겠지만, 합성 기반의 설계는 객체간의 결합도가 낮아 최소한의 영향만 받게 된다.

또한 합성은 여러 객체를 자신의 인스턴스로써 가지기 때문에 역할의 분리가 가능해지며, 유연한 설계가 가능해진다. 즉, 객체를 동적으로 조합하여 새로운 동작을 만들 수 있다. Animal 과 더불어, Cat 의 행동을 정의한 PetBehavior 클래스가 있다고 가정하자. 상속 기반이라면, Java 는 다중 상속을 지원하지 않기 때문에, PetBehavior 를 일일이 Cat 내부에 구현해야 할 것이다.

 

2.2 합성 패턴 예시

아래는 객체지향 디자인 에서 합성의 대표적인 패턴 예시이다.

 

2.2.1 전략 패턴

핵심 아이디어 : 알고리즘, 동작을 추상화 하여, 런타임에 동적으로 교체할 수 있도록 하는 패턴

interface PaymentStrategy {
    void pay(int amount);
}

class CreditCardPayment implements PaymentStrategy {
    public void pay(int amount) {
        System.out.println(amount + "원을 신용카드로 결제했습니다.");
    }
}

class PaypalPayment implements PaymentStrategy {
    public void pay(int amount) {
        System.out.println(amount + "원을 PayPal로 결제했습니다.");
    }
}

class Order {
    private PaymentStrategy paymentStrategy; // 합성을 통한 '전략' 보유

    public Order(PaymentStrategy paymentStrategy) {
        this.paymentStrategy = paymentStrategy;
    }

    public void processOrder(int amount) {
        paymentStrategy.pay(amount); // 합성한 객체에게 결제를 위임
    }
}
  • 위 코드와 같이 결제를 PaymentStrategy 에 위임하게 되면, 새로운 결제 방식이 추가되더라도, Order 클래스는 전혀 바뀔 필요가 없다.

 

2.2.2 장식자(Decorator) 패턴

핵심 아이디어: 상속 대신 합성을 통해 기존 객체의 기능을 확장하거나 변경할 수 있게 해주는 패턴

interface Coffee {
    String getDescription();
    int getCost();
}

class BasicCoffee implements Coffee {
    public String getDescription() {
        return "기본 커피";
    }
    public int getCost() {
        return 2000;
    }
}

abstract class CoffeeDecorator implements Coffee {
    protected Coffee coffee;
    public CoffeeDecorator(Coffee coffee) {
        this.coffee = coffee;
    }
    public String getDescription() {
        return coffee.getDescription();
    }
    public int getCost() {
        return coffee.getCost();
    }
}

class SyrupDecorator extends CoffeeDecorator {
    public SyrupDecorator(Coffee coffee) {
        super(coffee);
    }
    public String getDescription() {
        return coffee.getDescription() + ", 시럽 추가";
    }
    public int getCost() {
        return coffee.getCost() + 500;
    }
}

// 사용 예시
public class Main {
    public static void main(String[] args) {
        Coffee coffee = new BasicCoffee();                 // 기본 커피
        Coffee syrupCoffee = new SyrupDecorator(coffee);   // 커피에 시럽 추가
        System.out.println(syrupCoffee.getDescription() + 
                           " : " + syrupCoffee.getCost() + "원");
    }
}
  • Decorator pattern 에선, 확장하려는 대상 객체를 감싸는 Wrapper 객체를 생성하여 처리한다. BasicCoffee 는 description 과 cost 만 가지지만, 데코레이터 패턴을 사용하여 , BasicCoffee 에 시럽을 추가할 수도 있다.

 

2.2.3 위임(Delegation) 패턴

핵심 아이디어: 어떤 기능을 직접 수행하는 것이 아닌 , 해당 기능을 전문적으로 다루는 다른 객체에게 위임하는 것

 

interface Printer {
    void print(String message);
}

class ConsolePrinter implements Printer {
    public void print(String message) {
        System.out.println("콘솔: " + message);
    }
}

class App {
    private Printer printer; // 합성을 통한 위임

    public App(Printer printer) {
        this.printer = printer;
    }

    public void doWork() {
        // 원래라면 App 내부에서 직접 출력을 처리할 수 있음
        // 하지만 Printer 객체에 위임함으로써 출력 로직을 분리
        printer.print("작업을 시작합니다!");
        // ... 나머지 작업 처리 ...
        printer.print("작업이 끝났습니다!");
    }
}


public class Main {
    public static void main(String[] args) {
        Printer printer = new ConsolePrinter();
        App app = new App(printer);
        app.doWork();
    }
}
  • 이처럼 출력로직을 별도의 객체에 위임하여 해당 행동 패턴을 다르게 구상할 수 있다.

 

 

마무리

합성이 항상 정답은 아닐 수도 있다. 다만, 새로운 클래스를 설계하는 과정에서 강한 결합도와 유연한 유지보수의 균형점을 찾는것이 중요하며, 대부분의 경우, “상속보다는 합성을 우선 고려하라” 라는 원칙이 유효한 것이다. 코드는 작성 만큼이나 그 설계도 중요하다. 그렇기에 교체와 확장에 유연해야 하며, 각 객체는 독립적이어야 한다. 이러한 구조를 잡기 쉽게 도와주는 것이 “합성을 우선 고려하라” 의 핵심이라 생각한다.

합성만이 정답 이라 단언하기 보단, 비즈니스 로직, 요구사항을 고려해 어떤 방식이 효과적인지 잘 생각해 보자. 객체간 관계를 어떻게 맺어줄 것인지, 시간이 흐를수록 확장, 변경에 얼마나 유연하게 대처할 수 있는지를 고려해 보자.