Java 클래스 상속의 자유도와 주의점
안녕하세요. 주말이 더 길었으면 하는 개발자 stark입니다!
오늘은 Java에서 클래스 상속을 사용할 때 알면 좋은 기본적인 내용을 정리해보고자 합니다. 저는 실무에서 클래스 상속을 사용할 때마다 "이 클래스는 정말 상속해도 괜찮을까?"라는 고민을 하게 됩니다. 이런 고민을 하는 이유는 상속이 잘못 사용되면 오히려 코드를 더 복잡하게 만들고 유지보수를 어렵게 만들 수 있기 때문입니다.
그렇다면 Java에서는 상속을 어떻게 사용하는 것이 좋을까요? 오늘은 Java가 제공하는 상속의 자유도와, 그로 인해 개발자가 주의해야 할 점들에 대해 알아보도록 하겠습니다. 특히 '기술적으로 가능하다'는 것과 '실제로 해야 한다'는 것의 차이에 대해 이야기해보려 합니다.
클래스 상속의 기본 제한사항
Java에서 클래스 상속할 때 기본적으로 꼭 지켜야 하는 몇 가지 규칙이 있습니다.
1. final 클래스는 상속할 수 없다
- final로 선언된 클래스는 더 이상 확장할 수 없습니다. 이건 그 클래스가 더 이상 변화되거나 다른 클래스에 의해 확장되지 않도록 보호하는 것입니다.
// final 클래스 상속 시도 - 컴파일 에러!
final class FinalClass { }
class Child extends FinalClass { } // 에러!
2. private 생성자만 있는 클래스는 상속할 수 없다
- 생성자가 private이면 외부 클래스가 해당 클래스를 인스턴스화하거나 상속할 수 없습니다.
// private 생성자만 있는 클래스 상속 시도
class PrivateConstructorClass {
private PrivateConstructorClass() { }
}
class Child extends PrivateConstructorClass { } // 에러!
3. 다중 상속은 불가하다
- Java에서는 한 클래스가 두 개 이상의 부모 클래스를 상속받는 다중 상속을 허용하지 않습니다. 상속 구조에서의 모호성을 방지하기 위해서입니다. (다이아몬드 문제 같은 현상을 방지하기 위함)
// 만약 Java가 다중 상속을 허용한다면...
class Grandparent {
void greeting() {
System.out.println("안녕하세요!");
}
}
class Father extends Grandparent {
void greeting() {
System.out.println("아버지가 인사합니다.");
}
}
class Mother extends Grandparent {
void greeting() {
System.out.println("어머니가 인사합니다.");
}
}
// 이런 다중 상속이 허용된다면?
class Child extends Father, Mother { // Java에서는 불가능!
public static void main(String[] args) {
Child child = new Child();
child.greeting(); // 어떤 greeting이 호출되어야 할까요?
}
}
이 상황에서 발생하는 문제점은 다음과 같습니다.
- Child가 greeting()을 호출할 때 Father의 것을 호출해야 할까요? Mother의 것을 호출해야 할까요?
- 아래와 같이 상속 구조가 다이아몬드 모양(◇)이 되어서 '다이아몬드 문제'라고 부릅니다.
Grandparent
/ \
Father Mother
\ /
Child
이렇게 Java의 상속은 꽤 명확한 제한을 두고 있습니다. 만약 개발자가 이걸 어기려고 하면 바로 컴파일 에러가 발생합니다.
상속의 자유로움과 그 함정
Java에서 상속은 위의 제한사항을 지키기만 하면 꽤 자유롭게 사용할 수 있습니다. 심지어 논리적으로는 전혀 말이 안 되는 상속도 가능합니다. 예를 들면 다음과 같은 것들도 가능합니다.
// 논리적으로는 이상하지만 실제로 동작하는 코드입니다
class Pizza extends Thread {
public void run() {
System.out.println("피자가 돌아갑니다...?");
}
}
피자의 부모가 스레드라는 괴상한 코드가 만들어졌습니다. 문제는 이 코드가 컴파일 에러 없이 잘 동작한다는 것입니다.
다음으로 조금 더 극단적인 예제를 살펴봅시다.
class Hamburger extends Scanner {
public Hamburger(InputStream in) {
super(in);
}
public static void main(String[] args) {
// 햄버거로 텍스트 읽기가 가능합니다 (!)
Hamburger burger = new Hamburger(System.in);
String text = burger.nextLine();
System.out.println("햄버거가 읽은 텍스트: " + text);
}
}
이 코드 또한 굉장히 어색함이 느껴집니다. 햄버거 클래스가 스캐너 클래스를 상속받아서 텍스트를 읽는 데 사용되고 있습니다.
위의 두 예시코드를 보면 알 수 있듯이 자바에서 상속은 꽤나 자유롭게 사용할 수 있습니다. Pizza가 Thread를 상속받아 돌아가게 하고, Hamburger가 Scanner를 상속받아 텍스트를 읽게 할 수도 있습니다. 이런 코드를 실무에서 작성한다면 어떻게 될지 불 보듯 뻔합니다.
즉, 상속을 사용할 때는 논리적으로도 맞아야 한다는 것입니다.
좋은 설계를 위한 상속의 사용
상속을 쓸 때 가장 중요한 기준은 "IS-A" 관계를 고려하는 것입니다.
즉, 상속을 받는 클래스가 부모 클래스의 일종(하위 카테고리)이어야 한다는 점입니다. 그 어떤 개발자가 봐도 상속 구조가 논리적으로 이해가 가야만 한다는 것입니다.
// 좋은 예시
class Animal { }
class Dog extends Animal { } // 개는 동물이다 (O)
// 이상한 예시이지만 컴파일은 됩니다
class Building { }
class Dog extends Building { } // 개는 건물이다 (?)
위 예시를 보면 Dog가 Animal을 상속받는 건 자연스럽습니다. 반면 Dog가 Building을 상속받는 건 컴파일은 되지만 논리적으로는 이상합니다. 이렇게 상속을 남용하면 나중에 코드가 복잡해지고 유지보수가 어려워지게 됩니다.
실용적인 상속의 예
지금까지 이상한 녀석들을 봐왔으니 이번에는 논리적인 예시를 들어봅시다.
// 논리적으로 자연스러운 상속
class Restaurant {
void open() { System.out.println("가게 문을 엽니다"); }
}
class PizzaRestaurant extends Restaurant {
void makePizza() {
open(); // 부모 클래스의 메서드 사용
System.out.println("피자를 만듭니다");
}
}
PizzaRestaurant가 Restaurant를 상속받는 것은 매우 자연스럽습니다. 피자 가게는 레스토랑의 일종이고, 레스토랑의 기능(가게 문 열기 등)을 그대로 사용할 수 있습니다.
드디어 제대로 상속을 사용하는 코드를 보게 되었습니다. 뇌가 정화되는 기분입니다.
정리하면
- Java에서 클래스 상속은 기본적으로 자유롭지만, final 클래스, private 생성자, 다중 상속 등 몇 가지 제한이 있습니다.
- 모든 클래스가 상속 가능한 건 사실이지만, 상속은 논리적으로 맞아야 합니다. "개는 건물이다" 같은 상속은 매우 이상합니다.
- 상속은 "IS-A" 관계를 만족할 때만 사용하는 게 좋습니다. (연관된 카테고리여야 합니다.)
상속 대신 컴포지션
상속은 너무 남용하면 오히려 코드를 복잡하게 만들 수 있기 때문에 진짜로 상속이 필요한 상황인지, 아니면 다른 방법이 더 좋은지 고민해 보는 것이 필요합니다. 어쩌면 상속 대신 컴포지션(포함 관계, 클래스 내부에 다른 클래스의 인스턴스를 포함)을 사용하는 것도 하나의 방법입니다.
아래와 같이 상속을 사용하는 코드가 있습니다.
// 상속을 사용한 방식
class Engine {
void start() {
System.out.println("엔진 시동");
}
}
class Car extends Engine { // 자동차는 엔진이다 (?)
void drive() {
start(); // 상속받은 메서드 사용
System.out.println("운전");
}
}
이 코드를 아래처럼 컴포지션을 사용하도록 변경해 봅시다.
// 컴포지션을 사용한 방식
class Engine {
void start() {
System.out.println("엔진 시동");
}
}
class Car { // 자동차는 엔진을 가지고 있다 (O)
private Engine engine; // 컴포지션
public Car() {
this.engine = new Engine();
}
void drive() {
engine.start(); // 포함된 객체의 메서드 사용
System.out.println("운전");
}
}
컴포지션을 사용함으로써 관계가 더 명확하게 표현되었습니다.
- 상속: "IS-A" 관계 (자동차는 엔진이다 - 이상함)
- 컴포지션: "HAS-A" 관계 (자동차는 엔진을 가진다 - 자연스러움)
정리하면
- 상속은 "IS-A" 관계일 때 사용합니다. (개는 동물이다)
- 컴포지션은 "HAS-A" 관계일 때 사용합니다. (자동차는 엔진을 가진다)
- 컴포지션이 더 유연하고 명확한 경우가 많습니다.
- 실무에서는 "상속보다 컴포지션을 선호하라"는 원칙이 있습니다.
마무리하며
오늘 포스팅은 자바 개발자에게는 너무 간단하고 기본적인 내용일 수 있습니다. 단순히 몇 가지 제약사항만 지키면 어떤 클래스든 상속받을 수 있고, 실제로도 동작한다는 점을 살펴보았습니다. 물론 대부분의 개발자들은 이런 상속 구조를 설계할 때 논리적으로 이상하다면 당연히 사용하지 않을 것입니다.
하지만 저는 상속이 조금 위험하게 느껴졌습니다. 왜냐하면 사람마다 보는 관점이 다르고 단순히 몇 가지 조건만 맞으면 전혀 관련 없는 클래스도 상속이 가능했기 때문입니다.
실무에서 개발을 하다 보면 우리는 종종 '중복해서 사용 중인 코드를 줄이고 싶다'는 생각을 하게 됩니다. 이런 생각을 하다 상속 구조로 설계하게 되는 경우가 있습니다. 하지만 이럴 때일수록 우리가 설계한 상속 구조가 진정한 'IS-A' 관계이며 논리적으로 타당한지에 대해 깊은 고민이 필요합니다.
때로는 코드가 조금 더 길어지더라도, 상속 대신 컴포지션을 사용하는 등 더 명확한 설계를 선택하는 것이 현명한 판단일 수 있습니다. 이것이 바로 제가 오늘 포스팅을 통해 전달하고 싶었던 내용입니다.