내가 상속과 합성에 대해서 처음 들었던 건 약 1년 전이었다. 그때 내 머릿속에 강하게 박힌 멘트는 "상속보다는 합성을 사용해라"였다.
그때는 합성에 대한 내용을 자세하게 이해하지 못해서 그냥 넘어갔고, 막연하게 상속은 좋지 못한 구조의 예시로 기억되곤 했다.
그래서 지금까지 코드를 작성하면서 상속을 직접적으로 사용한 경험이 없었다. 그런데 요즘 오브젝트를 다시 읽기 시작하면서 이전에 크게 생각하지 않고 지나쳤던 상속과 합성 부분을 정리해보고자 한다.
상속이란
상속이란, 슈퍼클래스를 서브클래스가 상속받아 사용하는 것을 말한다. 슈퍼클래스에서 사용된 필드, 메서드를 서브클래스에서도 동일하게 사용하고 확장할 수 있다. 그래서 자연스럽게 상위 클래스의 코드를 재사용할 수 있다는 것이다. 상속 관계는 'Is-a' 관계로, 두 클래스 간의 명확한 계층적 관계를 나타낼 때 사용된다. 아래 코드에서도 SportCar 클래스는 Car 클래스를 상속받아 기능을 확장하고 있다. 이러한 관계는 "스포츠카는 카이다." (Is-a) 관계이니 상속으로 관계를 표현하는 것이 어색하지 않다.
public class Car {
public void go() {
System.out.println("go!");
}
}
public class SportCar extends Car {
public void boost() {
System.out.println("boost on!");
}
}
public class Main {
public static void main(String[] args) {
SportCar sportCar = new SportCar();
sportCar.go();
sportCar.boost();
// 실행 결과
// go!
// boost on!
}
}
합성이란
합성이란, 다른 클래스의 인스턴스를 자신의 클래스의 필드로 가지는 것을 말한다. 이는 서로 직접적인 관계가 없지만 관계를 맺어주어야 할 때 사용한다. 합성 관계는 Has-a 관계라고 하며 두 클래스가 독립적이지만 서로 협력하여 강력한 기능을 구현할 수 있음을 의미한다. 아래 코드에서 Car 클래스는 Engine 클래스를 합성하여 필요에 따라 엔진 클래스의 기능을 가져다 쓰고 있다. 이러한 관계는 "카는 엔진을 가지고 있다." (Has-a) 관계이니 합성으로 표현하는 것이 적절하다 볼 수 있다.
public class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void go() {
engine.startEngine();
System.out.println("go!");
}
}
public class Engine {
private String engineType;
public Engine(String engineType) {
this.engineType = engineType;
}
public void startEngine() {
System.out.println("start engine!");
}
}
public class Main {
public static void main(String[] args) {
Car car = new Car(new Engine("gasolineEngine"));
car.go();
// 실행 결과
// start engine!
// go!
}
}
Car 클래스를 상속받은 SportCar 클래스는 Car 클래스의 go 메서드를 활용하며, 자신만의 boost 메서드를 추가하여 기능을 확장한다. 이는 스포츠카가 기본적인 카의 기능을 유지하면서도 스포츠카에 특화된 기능을 갖춘다는 것을 의미한다.
또한 Car 클래스는 Engine 클래스와 합성되어, go 메서드가 실행되기 전에 Engine 클래스의 startEngine 메서드를 호출한다. 이를 통해 Car 클래스는 Engine 클래스의 기능을 유연하게 활용하며 재사용한다.
상속과 합성은 대체 관계인가?
여기까지 정리하다 보니, 이런 의문이 생겼다.
상속과 합성을 같이 쓸 수는 없는 건가?
상속으로 맺어진 관계를 합성으로 대체하는 것이 가능한 건가?
의문에 대한 답을 찾는 것은 생각보다 간단했다.
상속과 합성을 조합한 예제는 위에서 만든 클래스들을 통해 만들어질 수 있었다.
예를 들어, 아래 코드에서 Car 클래스를 상속받은 SportCar와 Engine 인스턴스를 포함하는 Car 클래스의 관계를 볼 수 있다. Car 클래스는 Engine 인스턴스를 사용하는데 전혀 문제가 없으며, SportCar 역시 상속받은 Car 클래스의 특성을 잘 활용하고 있다.
아래 코드를 통해 상속과 합성은 서로를 대체하는 개념이 아니라, 각기 다른 상황에 맞게 적절하게 함께 사용될 수 있다는 사실을 깨달았다.
public class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void go() {
engine.startEngine();
System.out.println("go!");
}
}
public class SportCar extends Car {
public SportCar(Engine engine) {
super(engine);
}
public void boost() {
System.out.println("boost on!");
}
}
public class Engine {
private String engineType;
public Engine(String engineType) {
this.engineType = engineType;
}
public void startEngine() {
System.out.println("start engine!");
}
}
public class Main {
public static void main(String[] args) {
Car car = new Car(new Engine("gasolineEngine"));
car.go();
SportCar sportCar = new SportCar(new Engine("gasolineEngine"));
sportCar.go();
sportCar.boost();
// 실행 결과
// start engine!
// go!
// start engine!
// go!
// boost on!
}
}
그렇다면 "상속보다는 합성을 사용하라"라는 말은 어떤 의미일까?
우선, 상속의 주요 목적은 다형성을 활용하는 데에 있다. 다형성은 동일한 인터페이스나 부모 클래스를 공유하는 서로 다른 객체들이 각기 다른 방식으로 행동할 수 있도록 하는 기능이다. 상속을 통해 자연스럽게 코드 재사용의 이점을 얻을 수 있다는 것이다.
단순히 코드 재사용을 위해 상속을 사용하는 것은 권장되지 않는다. 코드 재사용의 필요성이 있을 때 합성을 사용하는 것이 보다 적합하다고 볼 수 있다. 이는 상속이 가져오는 강한 결합과 관련된 문제를 피할 수 있기 때문이다.
코드 재사용을 위한 상속은 구체적으로 무엇일까? 이것은 구현 상속이라고 불리며, 클래스의 전체적인 기능을 상속받는 것을 의미한다. 그러나 모든 상속이 구현 상속만을 의미하는 것은 아니다. 상속에는 인터페이스 상속이라는 또 다른 형태도 있다. 인터페이스 상속은 인터페이스의 메서드 시그니처만을 상속받으며, 실제 구현은 상속받지 않는다.
인터페이스 상속과 합성을 함께 사용하면, 상속의 본래 목적인 다형성을 활용하면서도 느슨한 결합을 달성할 수 있다. 이는 더 좋은 객체지향프로그램을 만드는 데 도움이 된다. 따라서 "상속보다는 합성을 사용하라"는 말은 단순히 구현을 위한 상속을 넘어서, 인터페이스 상속과 합성을 통한 더 나은 객체지향 설계를 추구하라는 의미로 이해될 수 있다. 이 원칙은 "재사용을 위한 상속보다는 합성을 사용하라"로 요약될 수 있다. 이러한 내용은 선배 개발자분들의 토론에도 잘 나와 있어 도움을 받이 받았다.
재사용을 위한 상속의 문제
슈퍼클래스의 캡슐화가 깨지고 결합도가 높아진다.
상속을 사용하여 코드를 재사용한다면 서브 클래스가 슈퍼클래스의 로직이나 구현에 대해서 알고 있어야 한다.
지금까지 사용했던 Car 클래스와 SportCar 클래스를 조금 수정하면 좀 더 명확하게 이해할 수 있다.
public class Car {
private int fuelLevel;
public Car(int fuelLevel) {
this.fuelLevel = fuelLevel;
}
// 연료 수준을 체크하고 결과를 반환하는 메서드
protected boolean hasEnoughFuel() {
return fuelLevel > 20;
}
public void drive() {
if (hasEnoughFuel()) {
System.out.println("Car is driving.");
fuelLevel -= 20; // 연료 소모
} else {
System.out.println("Not enough fuel to drive.");
}
}
}
public class SportCar extends Car {
public SportCar(int fuelLevel) {
super(fuelLevel);
}
@Override
public void drive() {
// SportCar 클래스는 Car 클래스의 hasEnoughFuel 로직을 정확히 이해해야 함
if (hasEnoughFuel()) {
super.drive();
System.out.println("SportCar is driving faster!");
} else {
// hasEnoughFuel 메서드의 내부 구현을 알고 있어야
// 이 메시지가 언제 표시될지 이해할 수 있음
System.out.println("Not enough fuel to drive SportCar.");
}
}
}
public class Main {
public static void main(String[] args) {
SportCar sportCar = new SportCar(50);
sportCar.drive(); // 충분한 연료가 있어서 운전 가능
SportCar emptySportCar = new SportCar(10);
emptySportCar.drive(); // 연료가 부족해서 운전 불가
}
}
Car 클래스에 연료를 확인하는 메서드를 추가하고, 연료가 충분하다면 연료를 소모하며 출발하는 로직이 추가되었다. Car 클래스를 상속받은 SportCar는 Car 클래스의 충분한 연료 수준이 몇인지, 출발할 때 소모되는 연료의 양이 얼마인지 정확하게 알아야 drive 메서드를 수행할 수 있다. 이러한 설계는 컴파일 시점에 두 클래스 간의 관계를 고정시키며, 구현에 의존하는 방식으로 이루어진다. 결과적으로, 이 방식은 실행 시점에서의 관계 변경을 어렵게 만들고, 객체지향 프로그래밍에서 중요한 다형성의 이점을 활용하는 데 제약을 가져온다.
유연성과 확장성이 떨어진다.
만약 Car 클래스에 새로운 필드를 추가하거나 메서드를 변경해야 한다면, 결과는 어떻게 될까?
이때, SportCar 클래스도 반드시 동일한 변경을 적용해야 한다. 이러한 설계 구조는 코드의 확장성을 크게 저해하고 있다. 변경이 필요할 때마다 모든 관련 클래스에 동일한 수정을 반복적으로 적용해야 하므로, 시스템의 유연성을 크게 제한하고 있다. 이는 개발 과정에서의 유연성을 저하시키며, 장기적으로 볼 때 유지보수의 복잡성을 증가시키는 주요 원인이 된다.
public class Car {
private int fuelLevel;
private String name; // 슈퍼클래스 필드 추가
public Car(int fuelLevel, String name) {
this.fuelLevel = fuelLevel;
this.name = name;
}
}
public class SportCar extends Car {
public SportCar(int fuelLevel) { // 서브 클래스의 코드 변경 필요함
super(fuelLevel);
}
}
클래스 폭발이 일어날 수 있다.
만약 SportCar 외에 SUV와 MiniCar를 추가해야 한다면, 상황은 어떻게 될까?
각각의 차량 유형들은 Car 클래스로부터 상속을 받아 구현될 것이다. 여기에서 더 나아가 다양한 크기와 특성을 가진 여러 서브 카테고리로 나눌 필요성이 생긴다면 어떻게 될까? 이 경우 SmallSUV, MediumSUV, LargeSUV와 같은 추가적인 클래스들을 만들어야 할 것이다.
이렇게 될 경우, 요구사항이 추가될 때마다 새로운 클래스를 계속해서 만들어내야 한다. 이는 전체 애플리케이션의 복잡성을 급격히 증가시키며, 각 클래스 간의 관계를 관리하는 것이 점점 더 어려워진다.
합성을 사용해 보자
그렇다면 합성과 인터페이스 상속을 사용하여 설계를 변경해 보자.
// 연료 체크 인터페이스
public interface FuelChecker {
boolean hasEnoughFuel(int fuelLevel);
}
// 기본 연료 체크 구현
public class StandardFuelChecker implements FuelChecker {
@Override
public boolean hasEnoughFuel(int fuelLevel) {
return fuelLevel > 20;
}
}
// 새로운 연료 체크 구현
public class NewFuelChecker implements FuelChecker {
@Override
public boolean hasEnoughFuel(int fuelLevel) {
return fuelLevel > 10;
}
}
public class Car {
private int fuelLevel;
private FuelChecker fuelChecker;
public Car(int fuelLevel, FuelChecker fuelChecker) {
this.fuelLevel = fuelLevel;
this.fuelChecker = fuelChecker;
}
public void drive() {
if (fuelChecker.hasEnoughFuel(fuelLevel)) {
System.out.println("Car is driving.");
fuelLevel -= 20;
} else {
System.out.println("Not enough fuel to drive.");
}
}
public void changeFuelChecker(FuelChecker fuelChecker) {
this.fuelChecker = fuelChecker;
}
}
public class SportCar extends Car {
public SportCar(int fuelLevel, FuelChecker fuelChecker) {
super(fuelLevel, fuelChecker);
}
@Override
public void drive() {
super.drive();
System.out.println("SportCar is driving faster!");
}
}
연료 체크를 체크하는 FuelChecker 인터페이스를 선언하고, 이를 구현하는 StandardFuelChecker 클래스를 만들었다.
이러한 설계 변화를 통해, 연료 검사 로직은 FuelChecker 인터페이스에 의해 정의되며, StandardFuelChecker 클래스는 이 인터페이스를 구현하여 표준 연료 검사 기능을 제공한다. 이제 Car 클래스와 SportCar 클래스가 연료 체크 로직의 구체적인 구현 내용을 알 필요가 없다. 이 클래스들은 단지 FuelChecker 인터페이스에 의존함으로써, 캡슐화를 강화하고 결합도를 낮추게 되었다.
만일 새로운 연료 체크 요구사항이 발생한다면, FuelChecker 인터페이스를 확장하는 새로운 클래스를 정의하고 그 안에 요구사항에 맞는 로직을 구현하면 된다.
또한, 이러한 설계는 컴파일 시점에 클래스 간의 관계가 고정되는 구현 상속 대신 인터페이스 상속을 사용했기 때문에 실행 시점에 객체를 동적으로 변경할 수 있는 유연성을 제공한다. 이것을 동적 바인딩이라 하며 이러한 방식이 패턴화 되어 있는 것이 전략 패턴이다.
public class Main {
public static void main(String[] args) {
FuelChecker standardFuelChecker = new StandardFuelChecker();
SportCar sportCar = new SportCar(50, standardFuelChecker);
sportCar.drive();
// 실행 시점에 객체 변경이 가능 (동적 바인딩)
FuelChecker newFeelChecker = new NewFuelChecker();
sportCar.changeFuelChecker(newFeelChecker);
}
}
오브젝트를 읽으며 처음에는 상속과 합성의 차이를 단순히 빠르게 파악하고 정리하는 것이 목표였다.
그러나 실제로 예제를 만들어 보려고 시도하면서 생각보다 쉽지 않다는 것을 깨달았고, 개념에 대한 이해가 부족하다는 사실을 인지하게 되었다.
이에 따라 며칠 동안의 학습이 필요하게 되었고, '왜?'라는 질문을 끊임없이 던지며 학습했다.
학습을 진행하면서 객체지향, 다형성, 상속, 추상 클래스, 인터페이스, 디자인 패턴 등 다양한 개념들이 연결되기 시작했고 이를 통해 하나의 개념을 완전히 이해하기 위해서는 다른 관련 개념들에 대한 지식도 필요하다는 것을 더욱 확실히 깨닫게 되었다.
객체지향과 자바에 대해 조금 더 알게 된 것 같아 의미 있었고, 앞으로 코드를 작성할 때는 항상 '왜?'라는 질문에 대답할 수 있는 코드를 만드는 것을 목표로 해야겠다.
'Java' 카테고리의 다른 글
Java의 final은 불변성을 보장해주지 않는다. (2) | 2023.10.26 |
---|