왜 현대 언어들 (Go, Rust) 상속보다 컴포지션을 선호하는 이유는 무엇일까요?
Olivia Novak
Dev Intern · Leapcell

러스트와 Go 같은 최신 언어들이 상속보다 컴포지션을 선호하는 이유는 무엇일까요?
Java에는 _“합당한 이유가 없으면 상속을 사용하지 마세요.”_라는 지침이 있습니다. 하지만 Java는 상속을 엄격하게 제한하지 않아서 여전히 자유롭게 사용할 수 있습니다. 반면에 Go는 다릅니다. Go는 컴포지션만 허용합니다.
그렇다면 왜 현대 언어들은 컴포지션을 장려할까요? 사실, 새로운 언어들뿐만 아니라 베테랑 Java조차도 _Effective Java_의 항목 16에서 _“상속보다 컴포지션을 선호하라.”_고 언급합니다. 상속은 코드를 재사용하는 강력한 방법이지만, 최고의 방법은 아닐 수 있습니다.
그러므로 이 점을 신중하게 고려해 볼 가치가 있습니다. 특정 코드를 깊이 파고들지 않고, 개념에 대해 이야기해 봅시다. 저는 이 주제를 다음 관점에서 탐구할 수 있다고 생각합니다.
- 상속과 컴포지션의 특징
- 상속은 실제 사용에서 어떤 문제를 야기하는가?
- 컴포지션은 이러한 문제들을 어떻게 해결하는가?
실제 사례를 살펴보고, 상속을 버리고 인터페이스와 컴포지션을 수용함으로써 설계를 점진적으로 최적화해 보겠습니다. 이 글을 읽고 나면 상속과 컴포지션 모두에 대한 새로운 이해를 갖게 될 것입니다.
상속과 컴포지션의 특징
여기서는 정의에 대해 자세히 다루지 않겠습니다. 상속과 컴포지션은 객체 지향 프로그래밍에서 코드 재사용을 위한 두 가지 일반적인 기술입니다. 둘 다 재사용을 가능하게 하지만, 각각 장단점이 있습니다. 간단히 논의해 보겠습니다.
상속
장점:
- 코드 재사용: 부모 클래스에 정의된 속성과 메서드를 서브클래스에서 직접 사용할 수 있습니다.
- 상속 체인을 통한 확장성: 서브클래스는 조상으로부터 모든 속성과 메서드를 상속받아 확장성과 유지 관리성을 향상시킬 수 있습니다.
- 상속과 컴포지션 모두 다형성을 지원할 수 있으며, 여기서 동일한 메서드가 서로 다른 서브클래스에서 다르게 작동합니다.
단점:
- 부모 클래스의 변경 사항이 서브클래스에 영향을 미침: 부모 클래스가 변경되면 모든 서브클래스를 그에 따라 수정해야 할 수 있으므로 유지 관리 비용이 증가합니다.
- 밀결합: 서브클래스는 부모 클래스에 밀접하게 결합되어 코드 유연성과 이식성이 감소합니다.
이제 컴포지션을 살펴봅시다. 상속에 비해 컴포지션은 다음과 같은 특징을 갖습니다.
컴포지션
장점:
- 결합도 감소: 객체 간의 관계가 느슨합니다. 하나의 객체를 수정해도 다른 객체에 영향을 미치지 않습니다.
- 유연한 설계: 필요에 따라 다른 객체를 혼합하고 매칭할 수 있습니다.
- 인터페이스 분리: 컴포지션을 사용하면 관심사를 분리하여 서로 다른 기능 모듈을 독립적으로 구현할 수 있으므로 코드 재사용성이 향상됩니다.
단점:
- 코드 볼륨 증가: 상속에 비해 컴포지션은 다양한 조합을 구현하기 위해 더 많은 코드가 필요할 수 있습니다.
- 더 복잡한 상호 작용: 컴포지션에서 객체 상호 작용은 더 복잡한 인터페이스 정의와 구현이 필요할 수 있으므로 복잡성이 증가합니다.
상속은 실제로 어떤 문제를 야기하며, 어떻게 최적화할 수 있을까요?
1. 초기 문제
차량에 대한 클래스를 설계한다고 가정해 봅시다. 객체 지향적 사고에 따라 "차량"의 일반적인 개념을 기본 BaseCar
클래스로 추상화하여 기본 run()
동작을 갖습니다. 자동차 및 트럭과 같은 모든 유형의 차량은 이 추상 클래스에서 상속받을 수 있습니다.
public class BaseCar { //... 생략된 기타 속성 및 메서드... public void run() { /*...*/ } } // 자동차 public class Car extends AbstractCar { }
그러나 "차량" 객체에 대한 이해와 요구 사항에 따라 차량은 달릴 뿐만 아니라 타이어와 엔진도 수리할 수 있어야 합니다. 따라서 AbstractCar
는 다음과 같이 됩니다.
public class BaseCar { //... 생략된 기타 속성 및 메서드... public void run() { /* 실행 중... */ } public void repaireTire() { /* 타이어 수리 중... */ } public void repaireEngine() { /* 엔진 수리 중... */ } }
이제 자전거 클래스를 구현해야 합니다. 하지만 자전거에는 엔진이 없는데, 이 문제를 어떻게 처리해야 할까요?
public class Bicycle extends BaseCar { //... 생략된 기타 속성 및 메서드... public void repaireEngine() { throw new UnSupportedMethodException("저는 엔진이 없어요!"); } }
언뜻 보기에 위의 로직은 문제를 해결하는 것처럼 보이지만, 사실은 거대한 혼란을 초래할 수 있습니다. 이 설계에는 세 가지 주요 문제가 있습니다.
첫째, 가능한 모든 동작(예: 자율 주행, 파노라마 선루프, 선루프 기능)을 기본 클래스에 계속 추가하면 모든 것을 기본 클래스에 쌓아야 합니다. 이렇게 하면 재사용성이 향상되지만 모든 서브클래스의 기능도 변경되어 복잡성이 증가합니다. 이는 피하고 싶은 상황입니다.
둘째, 관련 없는 기능을 관련 없는 객체(예: 자전거의 엔진 수리)에 노출하는 것은 문제가 있습니다. 자전거 클래스는 엔진 수리 메서드를 전혀 처리할 필요가 없습니다.
셋째, 향후 확장성은 어떻습니까? 사람이나 비행기를 모델링하고 싶은데, 둘 다 (어떤 의미에서) "달릴" 수 있다면 어떻게 될까요? 이 설계는 확장성이 좋지 않고 유연성이 떨어집니다.
그렇다면 위의 문제를 어떻게 해결해야 할까요? 짐작하셨을 수도 있지만, 인터페이스입니다. 인터페이스는 동작을 정의하는 데 더 중점을 두는 반면, 추상 클래스는 일반적으로 유형에 대한 공통 기본 동작을 정의합니다. 여기서 추상 클래스를 사용하면 실제로 복잡성이 증가했습니다.
2. 인터페이스를 사용한 최적화
위의 문제를 해결하기 위해 특정 객체를 무시하고 대신 달리기, 엔진 수리, 타이어 수리와 같은 동작에만 집중해 봅시다. 이러한 동작을 IRun
, IEngine
및 ITire
인터페이스로 정의할 수 있습니다.
public interface IRun { void run(); } public interface IEngine { void repaireEngine(); } public interface ITire { void repaireTire(); }
이제 Car
클래스를 구현할 때 IRun
, IEngine
및 ITire
의 세 가지 인터페이스를 모두 구현합니다. Bicycle
의 경우 IRun
과 ITire
만 구현합니다. Person
의 경우 IRun
만 구현하면 됩니다.
public class Car implements IRun, IEngine, ITire { //... 생략된 기타 속성 및 메서드... @Override public void run() { /* 실행 중... */ } @Override public void repaireEngine() { /* 엔진 수리 중... */ } @Override public void repaireTire() { /* 타이어 수리 중... */ } } public class Bicycle implements IRun, ITire { //... 생략된 기타 속성 및 메서드... @Override public void run() { /* 실행 중... */ } @Override public void repaireTire() { /* 타이어 수리 중... */ } } public class Person implements IRun { //... 생략된 기타 속성 및 메서드... @Override public void run() { /* 실행 중... */ } }
이렇게 하면 훨씬 더 유연해지지 않나요? 이제 Go 및 Rust와 같은 최신 언어들이 상속 및 추상 클래스를 버리고 대신 동작 추상화를 위해 인터페이스를 유지한 이유를 이해하기 시작해야 합니다.
하지만 여전히 한 가지 문제가 있습니다. 모든 객체가 여전히 run()
, repaireEngine()
, repaireTire()
등을 수동으로 구현해야 하는 것처럼 보입니다. 귀찮지 않나요? 코드 재사용은 어떻게 해야 할까요?
잠시만요. 컴포지션이 무대에 오를 차례입니다.
3. 컴포지션을 사용한 최적화
위에서 언급한 문제를 해결하기 위해 먼저 인터페이스를 구현한 다음 컴포지션 및 위임을 사용하여 재사용을 달성할 수 있습니다. 코드는 다음과 같을 수 있습니다.
public class CarRunEnable implements IRun { @Override public void run() { /* 자동차 달리기... */ } } public class PersonRunEnable implements IRun { @Override public void run() { /* 사람 달리기... */ } } // 기타 구현 생략: EngineEnable / TireEnable
그런 다음 컴포지션을 사용하여 객체 클래스를 정의합니다. 각 클래스는 동작 클래스를 필드로 포함하고 인터페이스 메서드 호출을 해당 필드에 위임합니다.
public class Car implements IRun, IEngine, ITire { private CarRunEnable runEnable = new CarRunEnable(); // 컴포지션 private EngineEnable engineEnable = new EngineEnable(); // 컴포지션 private TireEnable tireEnable = new TireEnable(); // 컴포지션 //... 생략된 기타 속성 및 메서드... @Override public void run() { runEnable.run(); } @Override public void repaireEngine() { engineEnable.repaireEngine(); } @Override public void repaireTire() { tireEnable.repaireTire(); } }
Bicycle
과 Person
클래스를 살펴봅시다.
public class Bicycle implements IRun, ITire { private CarRunEnable runEnable = new CarRunEnable(); // 컴포지션 private TireEnable tireEnable = new TireEnable(); // 컴포지션 //... 생략된 기타 속성 및 메서드... @Override public void run() { runEnable.run(); } @Override public void repaireTire() { tireEnable.repaireTire(); } } public class Person implements IRun { private PersonRunEnable runEnable = new PersonRunEnable(); // 컴포지션 //... 생략된 기타 속성 및 메서드... @Override public void run() { runEnable.run(); } }
이제 위의 코드를 보면 로직이 훨씬 더 깔끔하고 만족스럽게 느껴지지 않나요?
새로운 기능을 추가하고 싶으신가요? 얼마든지 하세요. 다른 클래스에 영향을 미치지 않습니다.
결합도가 크게 감소했으며 응집도는 상속보다 나쁘지 않습니다. 이것이 사람들이 높은 응집도, 낮은 결합도라고 부르는 것입니다.
유일한 단점은 전체 코드 볼륨이 증가했다는 것입니다.
그럼 원래 질문으로 돌아가 봅시다.
러스트와 Go 같은 새로운 언어들이 컴포지션을 수용하고 상속을 버리는 이유는 무엇일까요?
이제 답변을 얻었을 것입니다.
Leapcell은 Rust 프로젝트 호스팅을 위한 최고의 선택입니다.
Leapcell은 웹 호스팅, 비동기 작업 및 Redis를 위한 차세대 서버리스 플랫폼입니다.
다국어 지원
- Node.js, Python, Go 또는 Rust로 개발하세요.
무제한 프로젝트를 무료로 배포
- 사용량에 대해서만 지불하세요. 요청이나 요금이 없습니다.
최고의 비용 효율성
- 유휴 요금 없이 사용한 만큼 지불하세요.
- 예: $25로 평균 응답 시간 60ms에서 694만 건의 요청을 지원합니다.
간소화된 개발자 경험
- 간편한 설정을 위한 직관적인 UI.
- 완전 자동화된 CI/CD 파이프라인 및 GitOps 통합.
- 실행 가능한 통찰력을 위한 실시간 메트릭 및 로깅.
간편한 확장성 및 고성능
- 고도의 동시성을 쉽게 처리할 수 있도록 자동 확장됩니다.
- 운영 오버헤드가 전혀 없습니다. 구축에만 집중하세요.
설명서에서 자세히 알아보세요!
X에서 팔로우하기: @LeapcellHQ