이미지 출처: www.evozon.com
프로그래밍을 할 때 가장 중요한 목표 중 하나는 코드의 재사용성과 유지보수성을 높이는 것이다. 코드 중복을 제거하고, 변경이나 확장을 용이하게 만들기 위해 객체지향 프로그래밍(OOP)에서는 주로 상속과 합성을 사용한다. 이 두 개념은 코드 재사용의 중요한 방법론이지만, 각각의 장단점이 있으므로 상황에 맞게 선택해야 한다.
합성은 객체 지향 프로그래밍에서 객체를 구성할 때, 다른 객체를 포함하는 방식을 말한다. 객체를 합성함으로써 여러 객체의 기능을 결합하여 새로운 기능을 제공할 수 있다. 합성은 "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
객체를 포함한다. Car
는 Engine
의 start()
메서드를 호출하여 엔진을 시작시키고, 이후 추가적인 행동을 정의할 수 있다.
합성은 "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"면 디젤 엔진으로 바꾸는 식이다.
이런 방식으로 프로그램이 실행되는 동안(런타임에) 어떤 객체를 사용할지 동적으로 결정할 수 있다. 이게 바로 합성의 특징이다.
상속은 객체지향 프로그래밍에서 하나의 클래스(부모 클래스)가 가진 속성과 메서드를 다른 클래스(자식 클래스)가 물려받아 사용하는 기법이다. 자식 클래스는 부모 클래스의 기능을 확장하거나, 필요에 따라 재정의(오버라이딩)할 수 있다. 상속은 주로 클래스 간의 "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
클래스를 상속받는 구조는 컴파일 타임에 결정된다. 이 관계는 프로그램이 실행되는 동안 바꿀 수 없다. 즉, 런타임에 Dog
가 Animal
대신 다른 클래스를 상속받게 할 수 없다.
💡 다시 한 번 강조겸, 쉽게 말하자면?
상속에서는 클래스 간의 관계가 고정되어 있어서, 프로그램이 실행되는 동안 이 관계를 바꾸는 것은 불가능하다.
Dog
가 Animal
을 상속받고 있으면, 프로그램이 실행되는 동안 Dog
가 다른 클래스를 상속받도록 변경할 수 없다. 이 말은, Dog
가 항상 Animal
의 자식 클래스이고, speak
메서드는 항상 "Bark"라고만 출력될 것이라는 뜻이다.
상속은 프로그램이 실행되기 전에 클래스 간의 관계가 미리 고정되고, 실행 중에는 이 관계를 바꿀 수 없다. 이는 구조적으로 명확하지만, 런타임에 객체를 유연하게 교체하거나 변경하는 합성에 비해 덜 유연할 수 있다.
상속과 합성은 모두 객체지향 설계에서 중요한 역할을 하지만, 언제 어떤 방식을 선택할지는 상황에 따라 달라진다. 다음은 일반적인 선택 기준이다.