1. Java에서 상속의 제한
클래스는 단일 상속만 가능. Java에서 클래스는 오직 하나의 다른 클래스만을 상속할 수 있습니다. 이를 단일 상속이라고 합니다. 예를 들어, 다음은 가능합니다:
class Animal { }
class Dog extends Animal { }
하지만 이렇게는 할 수 없습니다:
class Animal { }
class Robot { }
// 오류! Java는 클래스의 다중 상속을 지원하지 않습니다
class RoboDog extends Animal, Robot { }
이런 클래스를 선언하려고 하면 컴파일러가 다음과 같이 알려 줍니다: "class RoboDog cannot extend multiple classes". 왜 그럴까요? 다중 상속은 모호함을 유발하기 때문입니다. 만약 두 부모가 동일한 시그니처의 메서드를 갖고 있다면, 어느 쪽을 사용해야 할까요? 잘 알려진 “다이아몬드 문제”(diamond problem)입니다.
인터페이스는 Java에서 원하는 만큼 구현할 수 있지만, 아직 배우지 않았으니 나중에 이야기하겠습니다.
생성자는 상속되지 않습니다. 부모 클래스에 편리한 생성자가 있더라도, 자식 클래스에 그 생성자가 자동으로 생기지 않습니다. 자식 클래스의 생성자에서 super(...)를 통해 부모 생성자를 명시적으로 호출해야 합니다.
private 멤버는 상속되지 않습니다. 부모의 모든 private 필드와 메서드는 자식 클래스에서 접근할 수 없습니다. 객체 ‘내부’에는 존재하지만, 직접 접근은 불가능합니다.
2. 취약한 계층 구조의 문제
클래스 간 강한 결합. 클래스 계층을 만들면, 하위 클래스는 부모 클래스와 강하게 결합됩니다. 기본 클래스를 변경하면 그 모든 하위 클래스에 영향을 주거나 심지어 망가뜨릴 수 있습니다. 예를 들어 Animal 클래스를 상속하는 Dog, Cat, Bird 등 수많은 클래스가 있다고 해 봅시다. Animal의 구조를 변경하면(예: 생성자에 새로운 필수 매개변수를 추가) 모든 자식 클래스를 일일이 살펴보고 코드를 수정해야 합니다. 대규모 프로젝트에서는 특히 고통스럽습니다.
‘깨지는’ 상속 문제. 때로는 하위 클래스가 기본 클래스가 기대하는 동작을 우연히 바꿔 버릴 수 있습니다. 예를 들어, 부모 클래스가 자기 메서드를 다른 메서드 안에서 호출하는데, 하위 클래스가 그 메서드를 재정의하면서 로직을 바꿔 버리는 경우입니다. 그 결과, 부모 클래스가 기대한 대로 동작하지 않을 수 있습니다.
class Animal {
void makeSound() {
System.out.println("Some sound");
}
void sleep() {
System.out.println("Animal is going to sleep...");
makeSound(); // 부모가 자신의 메서드를 호출함
}
}
class Dog extends Animal {
@Override
void makeSound() {
System.out.println("Woof!");
}
}
public class Main {
public static void main(String[] args) {
Animal a = new Dog();
a.sleep();
}
}
프로그램은 무엇을 출력할까요?
Animal is going to sleep...
Woof!
부모 클래스는 makeSound()가 자기 구현이라고 예상했지만, 실제로는 하위 클래스의 버전이 호출됩니다! 하위 클래스가 다른 로직으로 메서드를 재정의하면 예상치 못한 버그로 이어질 수 있습니다.
3. 취약한 기반 클래스 문제 (fragile base class problem)
이는 대규모 프로젝트에서 실제로 발생하는 문제입니다. 기본 클래스를 변경하면(예: 필드 추가, 메서드 구현 변경) 모든 하위 클래스의 동작이 깨질 위험이 있습니다. 때로는 즉시 드러나지 않아, 이런 오류를 찾는 데 몇 시간, 심지어 며칠이 걸리기도 합니다.
예시: Shape 클래스에 draw() 메서드가 있다고 합시다. Shape에 drawShadow()라는 새 메서드를 추가했는데, 이 메서드가 draw()를 호출합니다. 그런데 어떤 하위 클래스(Circle)가 draw()를 재정의했다면, Circle에서 drawShadow()를 호출할 때 예상치 못한 동작이 발생할 수 있습니다.
4. 강한 결합과 리팩터링의 어려움
클래스가 상속으로 연결되어 있으면, 한 클래스를 변경하는 일이 전체 의존 체인에 영향을 미칠 수 있습니다. 이는 코드를 덜 유연하게 만들고, 리팩터링과 확장을 어렵게 합니다. 새로운 기능을 추가하기 위해 전체 계층을 갈아엎어야 하는 상황도 생깁니다.
실전 예시
class Vehicle { /* ... */ }
class Car extends Vehicle { /* ... */ }
class Bicycle extends Vehicle { /* ... */ }
class Bus extends Vehicle { /* ... */ }
갑자기 “전동 킥보드를 추가하자!”라는 요구가 들어옵니다. 그런데 전동 킥보드는 교통수단이면서 동시에 가젯이기도 합니다. 어떻게 할까요? 새로운 개체를 모두 끼워 맞추려 계층을 계속 확장하다 보면, 금세 통제 불능이 됩니다.
5. 논리적 관계 없이 코드 재사용하려는 문제
초보 개발자(그리고 숙련자도 종종)는 클래스 간 “is-a”(is-a) 관계가 없는데도 코드 재사용을 위해 상속을 사용하는 경우가 많습니다. 이는 잘못된 아키텍처로 이어집니다.
잘못된 상속의 예
class DatabaseUtils {
void connect() { /* ... */ }
void disconnect() { /* ... */ }
}
class User extends DatabaseUtils { // 사용자는 데이터베이스 유틸리티가 아니다!
String name;
}
더 바람직한 방법은 합성입니다. DatabaseUtils를 별도의 클래스로 두고, 필요한 곳에서 그 메서드를 호출하면 되지 상속할 필요는 없습니다.
6. 상속의 대안
합성(has-a)
한 객체가 다른 객체를 ‘포함’한다면 합성을 사용하세요. 예를 들어 Car 클래스는 Engine 필드를 가질 수 있습니다:
class Engine { /* ... */ }
class Car {
private Engine engine;
// ...
}
위임
클래스를 확장하는 대신, 작업 수행을 다른 객체에 위임하세요. 이렇게 하면 유연성을 유지하고 컴포넌트 간 결합을 줄일 수 있습니다.
인터페이스
Java에서 클래스는 원하는 만큼 인터페이스를 구현할 수 있습니다. 이렇게 하면 경직된 계층 없이도 동작을 유연하게 조합할 수 있습니다. 인터페이스는 나중에 다시 다루겠습니다.
상속을 언제 사용해야 할까?
상속은 클래스 간에 명확한 “is-a”(is-a) 관계가 있을 때만 사용하세요:
- 고양이는 동물이다 (Cat extends Animal)
- 원은 도형이다 (Circle extends Shape)
- 관리자는 사용자이다 (Admin extends User)
코드 재사용만을 목적으로 상속을 사용하지 마세요 — 그럴 땐 합성과 위임을 사용하세요.
7. 몇 가지 실용 예제
예: 과도하게 복잡한 계층
class Animal { }
class Mammal extends Animal { }
class Cat extends Mammal { }
class PersianCat extends Cat { }
class SuperPersianCat extends PersianCat { }
계층이 3단계를 넘어간다면 한 번 생각해 보세요. 지나치게 깊은 계층은 이해와 유지 보수를 어렵게 합니다.
예: 평면적 계층
class Animal { }
class Cat extends Animal { }
class Dog extends Animal { }
class Bird extends Animal { }
class Fish extends Animal { }
class Spider extends Animal { }
class Platypus extends Animal { }
class Dragon extends Animal { }
서브클래스가 수십 개이고 각자가 단지 한두 메서드만 다르다면, 인터페이스나 합성을 고려하는 편이 좋습니다.
8. 상속 사용 시 흔한 실수
오류 №1: is-a 관계 없는 상속.
하위 클래스가 실제로 부모의 한 종류가 아니라면, 아키텍처는 부자연스럽고 빠르게 통제를 벗어납니다. 예를 들어 User 클래스는 “편리해 보인다” 하더라도 DatabaseUtils를 상속하면 안 됩니다.
오류 №2: 계약을 바꾸는 메서드 재정의.
메서드를 재정의하면서 로직을 바꿔 부모의 기대와 달라진다면, 예상치 못한 오류로 이어집니다. 예를 들어 기본 클래스가 draw()가 도형을 그린다고 가정하는데, 하위 클래스에서 갑자기 위험한 부작용을 수행하도록 바꾸면 재앙입니다.
오류 №3: 너무 깊거나 너무 평면적인 계층.
너무 깊은 계층은 이해를 어렵게 하고, 너무 평면적이면 중복을 야기합니다.
오류 №4: 언어 제약을 우회하려는 시도.
다중 상속을 ‘변칙’으로 구현하려고 시도(복사-붙여넣기, ‘유틸리티’ 슈퍼클래스 등)하면 혼란만 가중됩니다.
오류 №5: 코드 재사용만을 위한 맹목적 상속.
클래스 간의 예상치 못한 결합을 낳고, 테스트와 유지 보수를 복잡하게 만듭니다. 합성과 위임을 사용하세요.
GO TO FULL VERSION