상속보다 합성? 객체지향 설계의 올바른 선택 (Composition and Inheritance)

ClydeHan·2024년 8월 27일
2

합성과 상속

Composition and Inheritance Image

이미지 출처: www.evozon.com

프로그래밍을 할 때 가장 중요한 목표 중 하나는 코드의 재사용성과 유지보수성을 높이는 것이다. 코드 중복을 제거하고, 변경이나 확장을 용이하게 만들기 위해 객체지향 프로그래밍(OOP)에서는 주로 상속합성을 사용한다. 이 두 개념은 코드 재사용의 중요한 방법론이지만, 각각의 장단점이 있으므로 상황에 맞게 선택해야 한다.


합성(Composition)

📌 개념과 정의

합성은 객체 지향 프로그래밍에서 객체를 구성할 때, 다른 객체를 포함하는 방식을 말한다. 객체를 합성함으로써 여러 객체의 기능을 결합하여 새로운 기능을 제공할 수 있다. 합성은 "has-a" 관계를 나타내며, 이는 한 객체가 다른 객체를 소유하거나 포함하고 있음을 의미한다.

  • 예시: "자동차는 엔진을 가지고 있다"라는 관계에서, Car 클래스는 Engine 객체를 포함하여 Car has an Engine이라는 "has-a" 관계를 형성한다.
class Engine {
    constructor(type) {
        this.type = type;
    }

    start() {
        console.log(`${this.type} engine started`);
    }
}

class Car {
    constructor(engine) {
        this.engine = engine; // Car는 Engine을 가지고 있다.
    }

    drive() {
        this.engine.start();
        console.log("Car is driving");
    }
}

const dieselEngine = new Engine("Diesel");
const myCar = new Car(dieselEngine);
myCar.drive(); // "Diesel engine started"와 "Car is driving" 출력

이 예제에서 Car 클래스는 Engine 객체를 포함하고 있으며, Engine의 기능을 활용하여 자동차의 동작을 정의한다.


📌 합성의 작동 방식

합성은 객체를 다른 객체의 속성으로 포함시키는 방식으로 작동한다. 예를 들어, 자동차 객체는 엔진, 바퀴, 등 여러 객체를 포함할 수 있다. 이러한 포함 관계를 통해 자동차는 개별 부품들의 기능을 활용할 수 있다.

class Engine {
  start() {
    console.log("Engine started");
  }
}

class Car {
  constructor(engine) {
    this.engine = engine;
  }

  start() {
    this.engine.start();
    console.log("Car is ready to go!");
  }
}

const myEngine = new Engine();
const myCar = new Car(myEngine);
myCar.start(); // "Engine started"와 "Car is ready to go!" 출력

위 코드에서 Car 클래스는 Engine 객체를 포함한다. CarEnginestart() 메서드를 호출하여 엔진을 시작시키고, 이후 추가적인 행동을 정의할 수 있다.


📌 합성의 장점

  • 유연성: 합성은 객체 간의 결합도가 낮아, 객체를 독립적으로 설계하고, 필요에 따라 조합하여 사용할 수 있다. 이는 시스템의 유연성과 확장성을 높인다.
  • 재사용성: 합성을 통해 다양한 객체를 조합하여 새로운 기능을 구현할 수 있다. 이를 통해 코드의 재사용성을 극대화할 수 있다.
  • 동적 관계: 합성은 런타임 동안 객체 간의 관계를 동적으로 변경할 수 있다. 이는 시스템의 유연성을 더욱 높여준다.
  • 내부 구현의 독립성: 합성은 포함된 객체의 내부 구현에 의존하지 않고, 퍼블릭 인터페이스를 통해 상호작용한다. 이는 객체의 내부 구현이 변경되더라도 외부에 미치는 영향을 최소화할 수 있다.

📌 합성의 단점

  • 복잡성 증가: 합성을 사용하면 객체 간의 관계가 복잡해질 수 있다. 여러 객체를 조합하여 기능을 구현하는 과정에서, 코드의 구조를 이해하기 어려워질 수 있다.
  • 초기 설계의 어려움: 합성은 상속보다 초기 설계가 더 복잡할 수 있다. 객체 간의 인터페이스를 명확하게 정의해야 하며, 객체들의 조합을 고려한 설계가 필요하다.

📌 합성의 사용 시기

합성은 "has-a" 관계가 있을 때, 즉 한 객체가 다른 객체의 기능을 포함하고 이를 활용할 때 사용한다. 또한, 시스템의 유연성과 재사용성이 중요한 경우, 그리고 객체 간의 결합도를 낮추고자 할 때 합성이 적합하다.


📌 합성 관계의 의존성 해결

💡 런타임

합성은 런타임에 의존성을 해결하는 방식이다. 합성을 사용하면, 프로그램이 실행되는 동안 객체 간의 관계를 동적으로 설정하고 변경할 수 있다. 이는 프로그램이 유연하게 동작할 수 있게 해준다.

  • 의존성 해결의 의미: 합성에서는 프로그램이 실행되는 동안 객체가 다른 객체와 어떻게 상호작용할지를 결정할 수 있다. 이 관계는 런타임에 동적으로 변경될 수 있다.
function Engine(type) {
    this.type = type;
}

Engine.prototype.start = function() {
    console.log(`${this.type} engine started`);
};

function Car(engine) {
    this.engine = engine; // 런타임에 어떤 엔진을 쓸지 결정됨
}

Car.prototype.drive = function() {
    this.engine.start();
    console.log("Car is driving");
};

let dieselEngine = new Engine("Diesel");
let electricEngine = new Engine("Electric");

let myCar = new Car(dieselEngine);
myCar.drive(); // "Diesel engine started"와 "Car is driving" 출력

myCar.engine = electricEngine; // 런타임에 엔진을 변경
myCar.drive(); // "Electric engine started"와 "Car is driving" 출력

여기서 Car 객체는 런타임에 Engine 객체와의 관계를 설정하고, 필요하면 변경할 수 있다. 프로그램이 실행되는 동안 사용자나 시스템의 요구에 따라 다른 엔진을 사용할 수 있다.

💡 함수형 컴포넌트 예시

function checkEnvironmentAndChangeEngine(car, environment) {
    if (environment === "water") {
        car.engine = new Engine("Electric");
    } else if (environment === "land") {
        car.engine = new Engine("Diesel");
    }
}

checkEnvironmentAndChangeEngine(myCar, "water");
myCar.drive(); // "Electric engine started"와 "Car is driving" 출력

쉽게 말하자면 if 문 등을 사용해서 상황에 맞게 객체를 골라서 선택할 수 있다는 것이다.

environment가 "water"면 Engine을 전기 엔진으로 바꾸고, "land"면 디젤 엔진으로 바꾸는 식이다.

이런 방식으로 프로그램이 실행되는 동안(런타임에) 어떤 객체를 사용할지 동적으로 결정할 수 있다. 이게 바로 합성의 특징이다.


상속(Inheritance)

📌 개념과 정의

상속은 객체지향 프로그래밍에서 하나의 클래스(부모 클래스)가 가진 속성과 메서드를 다른 클래스(자식 클래스)가 물려받아 사용하는 기법이다. 자식 클래스는 부모 클래스의 기능을 확장하거나, 필요에 따라 재정의(오버라이딩)할 수 있다. 상속은 주로 클래스 간의 "is-a" 관계를 나타내며, 이는 자식 클래스가 부모 클래스의 일종이라는 것을 의미한다.

  • 예시: "사자는 동물이다"라는 관계에서, Lion 클래스는 Animal 클래스를 상속받아 Lion is an Animal이라는 "is-a" 관계를 형성한다.
class Animal {
    speak() {
        console.log("Animal makes a sound");
    }
}

class Lion extends Animal {
    speak() {
        console.log("Lion roars");
    }
}

const myLion = new Lion();
myLion.speak(); // "Lion roars" 출력

이 예제에서 Lion 클래스는 Animal 클래스를 상속받아, speak 메서드를 오버라이딩하여 자신만의 행동을 정의했다.


📌 상속의 작동 방식

상속은 클래스 간의 계층 구조를 통해 작동한다. 자식 클래스는 부모 클래스의 모든 속성과 메서드를 자동으로 상속받으며, 필요에 따라 이를 재정의(override)하거나 확장할 수 있다.

class Animal {
  speak() {
    console.log("Animal sound");
  }
}

class Dog extends Animal {
  speak() {
    console.log("Bark");
  }
}

const myDog = new Dog();
myDog.speak(); // "Bark" 출력

위 코드에서 Dog 클래스는 Animal 클래스를 상속받아, speak() 메서드를 재정의(override)하였다. 이를 통해 Dog 객체는 Animal 객체의 기본 행동을 재정의하여 자신만의 특성을 가질 수 있다.


📌 상속의 장점

  • 코드 재사용성: 부모 클래스에서 정의된 속성이나 메서드를 자식 클래스에서 재사용할 수 있다. 이를 통해 중복 코드를 줄이고, 유지보수를 쉽게 할 수 있다.
  • 구조적 명확성: 클래스 간의 계층 구조가 명확하게 드러나며, 유사한 특성을 공유하는 클래스들을 쉽게 관리할 수 있다.
  • 다형성 지원: 부모 클래스의 인터페이스를 통해 여러 자식 클래스의 객체를 동일한 방식으로 다룰 수 있다. 이를 통해 코드의 유연성을 높인다.

📌 상속의 단점과 문제점

  • 결합도 증가: 자식 클래스는 부모 클래스의 구현에 강하게 결합된다. 부모 클래스의 변경이 자식 클래스에 영향을 미칠 수 있으며, 이로 인해 코드의 유지보수성이 저하될 수 있다.
  • 클래스 폭발: 상속을 남용할 경우, 다양한 기능 조합을 처리하기 위해 많은 클래스를 만들어야 하는 상황이 발생할 수 있다. 이를 클래스 폭발 문제라고 한다.
  • 유연성 부족: 상속 관계는 컴파일 타임에 결정되며, 런타임 동안 변경할 수 없다. 이는 코드의 유연성을 제한하며, 다양한 조합을 처리하기 어렵게 만든다.
  • 불필요한 기능 상속: 부모 클래스의 불필요한 메서드가 자식 클래스에 상속되어, 자식 클래스의 일관성을 해칠 수 있다.
  • 오버라이딩의 위험: 자식 클래스에서 부모 클래스의 메서드를 오버라이딩할 때, 부모 클래스의 내부 구현에 의존하여 예상치 못한 동작이 발생할 수 있다.

📌 상속의 사용 시기

상속은 클래스 간에 명확한 "is-a" 관계가 있을 때, 즉 자식 클래스가 부모 클래스의 일종일 때 사용해야 한다. 또한, 상속을 적용할 때는 부모 클래스가 재사용될 목적으로 잘 설계되었는지 확인해야 한다. 그렇지 않은 경우, 상속보다는 합성을 고려하는 것이 좋다.


📌 상속 관계의 의존성 해결

💡 컴파일 타임

상속 관계는 컴파일 타임에 결정된다. 상속을 사용하면, 어떤 클래스가 다른 클래스를 상속받아 그 기능을 재사용하는 구조가 컴파일 타임에 확정된다. 이 관계는 프로그램이 실행되는 동안(런타임)에 변경할 수 없다.

  • 의존성 해결의 의미: 상속을 사용하면, 클래스 간의 관계(누가 누구의 기능을 상속받고, 어떤 메서드를 사용할지)가 컴파일 타임에 결정되고, 고정된다. 프로그램이 실행되는 동안 이 구조를 변경할 수 없다.
function Animal() {}

Animal.prototype.speak = function() {
    console.log("Animal sound");
};

function Dog() {}

Dog.prototype = Object.create(Animal.prototype);

Dog.prototype.speak = function() {
    console.log("Bark");
};

let myDog = new Dog();
myDog.speak(); // "Bark" 출력

이 예제에서, Dog 클래스가 Animal 클래스를 상속받는 구조는 컴파일 타임에 결정된다. 이 관계는 프로그램이 실행되는 동안 바꿀 수 없다. 즉, 런타임에 DogAnimal 대신 다른 클래스를 상속받게 할 수 없다.

💡 다시 한 번 강조겸, 쉽게 말하자면?

상속에서는 클래스 간의 관계고정되어 있어서, 프로그램이 실행되는 동안 이 관계를 바꾸는 것은 불가능하다.

DogAnimal을 상속받고 있으면, 프로그램이 실행되는 동안 Dog가 다른 클래스를 상속받도록 변경할 수 없다. 이 말은, Dog가 항상 Animal의 자식 클래스이고, speak 메서드는 항상 "Bark"라고만 출력될 것이라는 뜻이다.

상속은 프로그램이 실행되기 전에 클래스 간의 관계가 미리 고정되고, 실행 중에는 이 관계를 바꿀 수 없다. 이는 구조적으로 명확하지만, 런타임에 객체를 유연하게 교체하거나 변경하는 합성에 비해 덜 유연할 수 있다.


선택 기준

상속과 합성은 모두 객체지향 설계에서 중요한 역할을 하지만, 언제 어떤 방식을 선택할지는 상황에 따라 달라진다. 다음은 일반적인 선택 기준이다.

📌 상속을 사용할 때

  • 클래스 간에 명확한 "is-a" 관계가 있을 때.
  • 부모 클래스의 대부분의 기능을 자식 클래스에서 재사용할 때.
  • 다형성을 이용하여 여러 자식 클래스에서 동일한 인터페이스를 사용할 때.

📌 합성을 사용할 때

  • 객체 간에 "has-a" 관계가 있을 때.
  • 시스템의 유연성과 확장성이 중요할 때.
  • 클래스 간 결합도를 낮추고 독립성을 높이고자 할 때.

참고문헌

0개의 댓글