이번 블랙잭 미션에서 나는 상속을 사용했다.
왜 상속을 사용했나?
인터페이스
이 말을 하기에 앞서 나는 인터페이스로 조합이나 상속을 적용하는 것을 고려하지 않았다.
일단 제일 처음 고민 했던 건 인터페이스다
하지만 요구사항을 보고 바로 철회했다.
내가 생각하는 인터페이스의 장점은 아래와 같다.
아래 코드는 `Driver`가 `K3Car`를 운전하는 코드이다.
public class K3Car {
public void startEngine() {
System.out.println("K3Car.startEngine");
}
public void offEngine() {
System.out.println("K3Car.offEngine");
}
public void pressAccelerator() {
System.out.println("K3Car.pressAccelerator");
}
}
public class Driver {
private K3Car k3Car;
public void setK3Car(K3Car k3Car) {
this.k3Car = k3Car;
}
public void drive() {
System.out.println("자동차를 운전합니다.");
k3Car.startEngine();
k3Car.pressAccelerator();
k3Car.offEngine();
}
}
public class CarMain {
public static void main(String[] args) {
Driver driver = new Driver();
K3Car k3Car = new K3Car();
driver.setK3Car(k3Car);
driver.drive();
}
}
여기서 만약 드라이버가 model3차도 운행해야한다면 어떻게 해야할까??
public class Driver {
private K3Car k3Car;
private Model3Car model3Car; //추가
public void setK3Car(K3Car k3Car) {
this.k3Car = k3Car;
}
//추가
public void setModel3Car(Model3Car model3Car) {
this.model3Car = model3Car;
}
//변경
public void drive() {
System.out.println("자동차를 운전합니다.");
if (k3Car != null) {
k3Car.startEngine();
k3Car.pressAccelerator();
k3Car.offEngine();
} else if (model3Car != null) {
model3Car.startEngine();
model3Car.pressAccelerator();
model3Car.offEngine();
}
}
}
이렇게 추가해볼 수 있다. 그렇다면 이제 porsche를 운전해야한다면 계속 if문을 추가해야하는가?
이때 유용하게 사용할 수 있는게 바로 인터페이스이다.
여기서 인터페이스를 활용하면 객체 지향 설계 원칙 중 하나인 OCP원칙을 지킬 수 있다.
OCP(Open-Closed Principle) 원칙
Open for extension : 새로운 기능의 추가나 변경 사항이 생겼을 때, 기존 코드는 확장할 수 있어야 한다.
Closed for modification: 기존 코드는 수정되지 않아야 한다.
확장에는 열려있고, 변경에는 닫혀 있다는 뜻인데, 쉽게 이야기해서 기존의 코드 수정없이 새로운 기능을 추가할 수 있다는 의미다.
인터페이스를 적용하면 아래처럼 된다.
public interface Car {
void startEngine();
void offEngine();
void pressAccelerator();
}
public class K3Car implements Car {
@Override
public void startEngine() {
System.out.println("K3Car.startEngine");
}
@Override
public void offEngine() {
System.out.println("K3Car.offEngine");
}
@Override
public void pressAccelerator() {
System.out.println("K3Car.pressAccelerator");
}
}
model3도 이렇게 해주면 된다.
public class Driver {
private Car car;
public void setCar(Car car) {
System.out.println("자동차를 설정합니다: " + car);
this.car = car;
}
public void drive() {
System.out.println("자동차를 운전합니다.");
car.startEngine();
car.pressAccelerator();
car.offEngine();
}
}
public class CarMain1 {
public static void main(String[] args) {
Driver driver = new Driver();
//차량 선택(k3)
Car k3Car = new K3Car();
driver.setCar(k3Car);
driver.drive();
//차량 변경(k3 -> model3)
Car model3Car = new Model3Car();
driver.setCar(model3Car);
driver.drive();
}
}
이렇게 하면 수많은 차량을 다 커버할 수 있다.
이전 처럼 if문을 사용해서 차마다 필드를 가지고 있을 필요가 없어진다.
이러한 장점으로 블랙잭을 보았을 때 어떤 플레이어든 딜러든 초기에 받는 카드 개수와 죽는 조건도 같다.
단지 닉네임 다를 뿐 모두 동일한 룰 안에서 동일한 동작으로 해야한다.
그렇다면 인터페이스를 사용해서 같은 기능을 하는 메서드를 오버라이드하는 것보다 추상클래스로 미리 동일한 구현체를 만들고 일부 다른 동작은 추상 메서드를 사용할 수 있는 상속을 사용하면 어떨지 생각이 되었다.
보통 우리는 "상속보다 조합을 사용하세요" 글이나 말을 많이 들었을 것이다.
실제로 이펙티브 자바 Item 18 <상속보다는 컴포지션을 사용하라>를 말하고 있다.
해당 내용에서는 왜 '상속을보다 컴포지션을 사용하라'고 권장하는지에 대한 이유를 말하고 있다.
간략하게 설명하자면 아래와 같다.
1️⃣ 캡슐화 위반
상속은 부모클래스의 구현 세부 사항에서 자식클래스가 의존하게 만든다.
이는 부모클래스의 변경이 자식클래스에 영향을 줄 수 있으며, 이러한 종속성은 시스템의 취약성을 증가시킨다.
2️⃣ 불필요한 API 노출
자식클래스는 부모클래스의 공개 API 일부를 무조건 상속받는다.
이 중 자식클래스의 목적에 맞지 않는 메서드가 있다면 사용자에게 혼란을 줄 수 있고 잘못된 사용을 유도할 수 있다.
3️⃣ 상속의 제약 (다중상속 불가능)
자바는 다중 상속을 지원하지 않기 때문에 하나의 부모클래스만 상속 가능하다.
이로 인해 기능 확장성이 제한되고 유연한 설계가 어려워질 수 있다.
4️⃣ 깨지기 쉬운 클래스 구조
상속을 과도하게 사용하면 계층 구조가 복잡해지고 클래스 간 의존성이 증가하여,
한 클래스의 변화가 연쇄적으로 다른 클래스에 영향을 끼쳐 유지보수 비용이 높아질 수 있다.
class Animal {
void makeSound() {
System.out.println("동물이 소리를 냅니다.");
}
}
class Cat extends Animal {
void makeSound() {
System.out.println("야옹");
}
// Animal에서 불필요하게 상속받은 메서드
void fly() {
throw new UnsupportedOperationException("고양이는 날 수 없어요!");
}
}
그렇다면 이펙티브 자바에서 말하는 합성의 장점은 뭘까?
🚀 합성의 이점
1️⃣ 강력한 캡슐화
컴포지션은 외부 클래스의 인스턴스를 `private`필드로 참조함으로써 구현 세부사항을 숨기고,
인터페이스를 통해서만 상호작용하게 하여 캡슐화를 강화한다.
2️⃣ 불필요한 API 노출 방지
클래스는 실제로 사용하는 메서드만 외부로 노출할 수 있어, 목적에 맞지 않는 API의 강제 상속문제를 해결할 수 있다.
3️⃣ 유연성과 재사용성 증가
합성을 사용하면 클래스 간의 관계가 명확해지고,
변경사항이 발생하더라도 쉽게 교체 및 확장할 수 있어 유연성과 재사용성이 크게 향상된다.
4️⃣ 구조 안정성 및 유지보수 용이
객체 간 관계가 명확히 드러나기 때문에, 클래스의 변화가 다른 클래스에 미치는 영향을 최소화 할 수 있어 유지보수가 쉽고 구조가 안정적이다.
합성 예시
class AnimalSound {
void makeSound() {
System.out.println("소리를 냅니다.");
}
}
class Cat {
private AnimalSound sound;
public Cat(AnimalSound sound) {
this.sound = sound;
}
void makeSound() {
sound.makeSound();
}
}
여기까지가 이펙티브자바에서 말하는 상속보다 컴포지션을 사용하라를 간단하게 요약한 것이다.
이런 상속의 단점에도 불구하고 언제 상속을 사용하면 좋을까?
상속
1. 진짜 'is-a'관계가 있는 경우
상속이 적절하려면 자식클래스가 부모클래스의 '진정한 하위 타입'이어야 한다.
예를 들어 전기자동차는 일반적으로 자동차의 특성을 대부분 공유하며, 추가로 전기에 관련된 특성(배터리, 충전 로직 등)만 확장하면 된다.
이런 경우 "전기 자동차는 곧 자동차다(ElectricCar is-a Car)"라는 것이 성립하므로 상속이 자연스럽다.
반면 `Stack`이 `Vector`를 `Properties`가 `HashTable`을 상속하는 것은(Java 라이브러리 예시 처럼) 엄밀히 말해 "is-a" 관계가 아니므로 문제가 되는 전형적 사례이다.
2. 클래스 설계가 '상속'을 통해 확장 가능하도록 고안된 경우
어떤 라이브러리나 프레임워크는 추상 클래스(혹은 클래스 자체)가 확장을 전제로 설계되어 있다.
예를 들어, UI 프레임워크에서 `AbstactButton`을 상속받아 MyCustomButton을 만든다거나, JUnit에서 TestCase를 상속받아 테스트 클래스를 만든다거나 하는 경우가 전형적이다.
즉, 부모클래스가 상속을 통한 확장을 안전하고 명확하게 보장해준다면, 굳이 합성으로 감싸서 복잡도를 높이는 것보다 상속을 활용하는 편이 좋다.
3. 부모클래스가 변경 가능성이 낮고, 자식 클래스 입장에서 내부 구현 의존을 크게 걱정하지 않아도 되는 경우
상속의 큰 문제점 중 하나는 "부모클래스가 변경되면 자식클래스도 영향을 받는다"는 점이다.
어떤 클래스가 처음부터 안정적이고, 변경 가능성이 거의 없으며, 오랜 시간 동안 하위 호환성을 유지하도록 설계되었다면 상속에 따른 리스크가 상대적으로 낮다.
예: 자바 표준 라이브러리의 일부 안정된 추상 클래스(예: Reader, InputStream 등)을 상속 받아 구현체를 만드는 경우, 이미 자바 진영에서 널리 사용되며, 변경 가능성을 최소화하도록 유지보수되고 있는 대표적 클래스 체계이므로 상속을 통한 확장이 일반적이고 권장되는 편이다.
4. 자식 클래스가 부모클래스의 동작을 대부분 재사용하면서, 일부 로직만 살짝 바꿔야 할 때
상속을 쓰면 부모클래스에 정의된 메서드를 거의 그대로 활용하고, 필요한 부분만 override로 교체할 수 있다.

여기서 끝나면 아쉬우니 '오브젝트'라는 책에서도 상속에 대해서 설명하고 있어서 정리해 보았다.
상속의 두 가지 용도
1. 타입 계층 구현
상속의 첫 번째 목적은 타입 계층(type hierarchy)을 구성하는 것이다.
타입 계층에서는 부모 클래스가 일반적인 개념을, 자식 클래스가 보다 구체적이고 특수한 개념을 표현한다.
즉, 부모 클래스는 자식 클래스의 일반화를, 자식 클래스는 부모 클래스의 특수화(specialization)를 나타낸다고 볼 수 있다.
2. 코드 재사용
두 번째 용도는 코드 재사용이다.
상속은 간단한 선언만으로 부모 클래스의 코드를 재사용할 수 있는 강력한 도구이다. 이를 통해 애플리케이션의 기능을 점진적으로 확장할 수 있다.
하지만 단순히 코드 재사용을 위해 상속을 사용하면, 부모와 자식 클래스가 강하게 결합되어 이후에 코드를 변경하기 어려워질 위험이 있다.
핵심 : 상속의 일차적인 목표는 코드 재사용이 아니라, 타입 계층을 올바르게 구현하는 것이다.
상속은 코드를 쉽게 재사용할 수 있게 해주지만, 강한 결합으로 인해 설계의 변화와 확장이 어려워질 수 있다. 반면, 타입 계층을 잘 설계하면 다형적인 객체들 간의 관계를 기반으로 유연하고 확장 가능한 시스템을 만들 수 있다.
타입 계층의 의미는 행동이라는 문맥에 따라 달라질 수 있다.
그에 따라 올바른 타입 계층이라는 의미 역시 문맥에 따라 달라질 수 있다.
어떤 애플리케이션에서 새에게 날 수 있다는 행동을 기대하지 않고 단지 울음 소리를 낼 수 있다는 행동만 기대한다면 새와 펭귄을 타입 계층으로 묶어도 무방하다. 따라서 슈퍼타입과 서브타입 관계에서는 is-a보다 행동 호환성이 더 중요하다.
타입 계층과 올바른 상속 사용
여기서 자연스럽게 떠오르는 질문은 “타입 계층이란 무엇인가?” 그리고 “어떤 조건을 만족시켜야 상속을 올바르게 사용했다고 할 수 있을까?”이다. 이에 대해 두 가지 조건을 고려할 수 있다.
- 상속 관계가 is-a 관계를 모델링하는가?
이는 애플리케이션에서 사용하는 용어와 개념에 근거한 질문이다.
일반적으로, 자식 클래스가 부모 클래스라고 해도 이상하지 않다면, 그 관계는 상속 사용의 후보가 될 수 있다. - 클라이언트 입장에서 부모 클래스 타입으로 자식 클래스를 사용해도 무방한가?
상속 관계를 활용하는 클라이언트는 부모 클래스와 자식 클래스 사이의 차이를 몰라야 한다.
이를 행동 호환성(behavioral compatibility)이라고 부르는데, 클라이언트가 기대하는 행동이 두 클래스에서 동일해야 올바른 타입 계층이라 할 수 있다.
디자인 관점: 상속을 적용할지 결정할 때는 ‘is-a 관계’의 어휘적인 측면보다는 클라이언트의 관점에서 두 클래스의 행동이 호환되는지에 초점을 맞춰야 한다.
만약 클라이언트가 두 클래스에 대해 다른 행동을 기대한다면, 비록 어휘적으로 is-a 관계에 부합하더라도 상속을 사용해서는 안 된다.
is-a 관계와 행동의 중요성
is-a 관계는 생각보다 직관적이지 않을 때가 많다. 스콧 마이어스는 <이펙티브 C++>에서 펭귄과 새의 예를 들어, is-a 관계가 단순히 어휘적인 면만으로는 판단하기 어렵다는 점을 보여준다.
예시로 알아보자
- 펭귄은 새다.
- 하지만 새는 날 수 있다.
아래 코드를 보자.
public class Bird {
public void fly() { ... }
}
public class Penguin extends Bird {
}
public void flyBuird(Bird bird) {
// 인자로 전달된 모든 bird는 날 수 있어야 한다.
bird.fly();
}
문제는, 펭귄은 새이지만 날 수 없다는 사실이다. 이 코드는 "펭귄은 새고, 따라서 날 수 있다"는 잘못된 전제를 담고 있다.
어휘적으로 펭귄은 새지만 만약 새의 정의에 날 수 있다는 행동이 포함된다면 펭귄은 새의 서브타입이 될 수 없다.
만약 새의 정의에 날 수 있다는 행동이 포함되지 않는다면 펭귄은 새의 서브타입이 될 수 있다.
이 경우에는 어휘적인 관점과 행동 관점이 일치하게 된다.
public class Penguin extends Bird {
@Override
public void fly() { }
}
하지만 이 방법은 어떤 행동도 수행하지 않기 때문에 bird가 날 수 있다는 클라이언트의 기대를 만족시키지 못한다.
따라서 올바른 설계라고 할 수 없다.
이 설계에서 Penguin과 Bird의 행동은 호환되지 않기 때문에 올바른 타입 계층이라고 할 수 없다.
2. 펭귄의 fly() 메서드를 오버라이딩한 후 예외를 던지는 방법
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException();
}
}
하지만 이 경우에는 flyBird 메서드에 전달되는 인자의 타입에 따라 메서드가 실패하거나 성공하게 된다.
filyBird 메서드는 fly 메시지를 전송한 결과로 UnsupportedOperationException예외가 덩져질 것이라고 기대하지 않았을 것이다.
따라서 이 방법 역시 클라이언트의 관점에서 Bird와 Penguin의 행동이 호환되지 않는다.
3. flyBird() 메서드를 수정해, 인자로 전달된 객체가 펭귄이 아닐 때만 fly()를 호출하는 방법
public void flyBird(Bird bird) {
if (!(bird instanceof Penguin)) {
bird.fly();
}
}
하지만 만약 날 수 없는 다른 새가 추가된다면, 계속해서 instanceof 검사를 추가해야 하므로 개방-폐쇄 원칙을 위배하게 된다.
해결책
클라이언트가 기대하는 행동에 맞게 상속 계층을 분리해야 한다.
예를 들어, 날 수 있는 새와 날 수 없는 새를 명확히 구분하여 다음과 같이 설계할 수 있다.
public class Bird {
}
public class FlyingBird extends Bird {
public void fly() { ... }
}
public class Penguin extends Bird {
}
이제, 날 수 있는 새를 필요로 하는 메서드는 FlyingBird 타입을 인자로 받고, 날 수 없는 새와 협력하는 메서드는 Bird 타입을 사용할 수 있게 되어, 클라이언트의 기대에 맞는 행동을 보장할 수 있다.
요점은 자연어에 현혹되지 말고 요구사항 속에서 클라이언트가 기대하는 행동에 집중하라는 것이다.
클래스의 이름 사이에 어떤 연관성이 있다는 사실은 아무런 의미도 없다.
두 클래스 사이에 행동이 호환되지 않는다면 올바른 타입 계층이 아니기 때문에 상속을 사용해서는 안 된다.
서브클래싱과 서브타이핑
그래서 언제 상속을 사용해야 하는가? 어떤 상속이 올바른 상속이고, 어떤 상속이 올바르지 않은 상속인가?
질문에 대한 답을 찾기 위해서는 상속이 두 가지 목적을 위해 사용된다는 사실을 이해해야 한다.
하나는 코드 재사용을 위해서고, 다른 하나는 타입 계층을 구성하기 위해서다.
사람들은 상속을 사용하는 두 가지 목적에 특별한 이름을 붙였는데 서브클래싱과 서브타이핑이 그것이다.
- 서브클래싱:
부모 클래스의 코드를 재사용하기 위한 상속이다.
이 경우, 자식 클래스와 부모 클래스의 행동이 반드시 호환되지 않을 수 있으므로, 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 완전히 대체할 수 없게 된다.
이를 구현 상속(implementation inheritance) 또는 클래스 상속(class inheritance)이라고도 한다. - 서브타이핑:
타입 계층을 구성하기 위한 상속이다.
자식 클래스와 부모 클래스의 행동이 호환되어, 자식 클래스의 인스턴스가 부모 클래스의 인스턴스를 대신할 수 있다.
이러한 관계는 인터페이스 상속(interface inheritance)이라고도 부른다.
Java에서 서브타이핑은 두 가지 방식으로 나타난다.
📌 클래스 상속 (Inheritance)
class Child extends Parent { ... }
이 경우, `Child`는 `Parent`의 하위 타입(subtype)이 되어, `Parent` 타입이 필요한 곳에 `Child` 객체를 사용할 수 있다.
📌 인터페이스 구현 (Implementation)
class SomeClass implements SomeInterface { ... }
이를 통해 `SomeClass`는 `SomeInterface`의 하위 타입이 되어, 인터페이스 타입이 요구되는 곳에 사용할 수 있다.
따라서 상속을 사용하는 목적에 따라, 단순한 코드 재사용을 위한 서브클래싱과 클라이언트의 기대를 충족하는 타입 계층을 위한 서브타이핑을 명확히 구분해야 한다.
대부분의 경우, 잘못된 상속 설계는 구현 재사용에 초점을 맞춘 서브클래싱으로부터 발생하는 경우가 많다.
결론
올바른 상속 설계의 핵심은 클래스의 이름이나 어휘적인 관계보다, 클라이언트가 기대하는 행동에 집중하는 것이다.
두 클래스 간의 행동이 호환되지 않는다면, 아무리 is-a 관계가 성립한다고 하더라도 상속을 통해 타입 계층을 구성해서는 안 된다.
즉, 동일한 메시지에 대해 서로 다르게 반응할 수 있는 객체를 구현하려면, 행동을 기준으로 타입 계층을 구성해야 한다.
상속의 진정한 가치는 이러한 타입 계층을 쉽고 편리하게 구현할 수 있게 해준다는 점에 있다.
그래서 나만의 상속을 쓸 때의 기준을 한 번 만들어보았다.
이게 모든곳에서 통용되는 건 아니다 그냥 현재 내가 상속을 이해한 정도로 생각해본 기준이다.
🚀 상속을 쓸 때의 기준
- is-a 관계가 성립되는지 진지하게 고민한다. (후보 선정)
- “B는 A인가?”라는 질문에 확실히 “예”라고 답할 수 있어야 한다.
- “어느 순간엔가는 B가 A가 아닐 수도 있겠는데?”라고 느껴진다면, 합성(Composition)이 더 적절할 가능성이 높다.
- 클라이언트 입장에서 부모 클래스의 타입으로 자식 클래스를 사용해도 무방한가?
- 상속 계층을 사용하는 클라이언트의 입장에서 부모 클래스와 자식 클래스의 차이점을 몰라야 한다. 이를 자식 클래스와 부모클래스 사이의 행동 호환성이라고 부른다.
- 부모클래스가 상속을 고려해 설계되었는가?
- 추상 메서드를 제공하는 등 “당신이 override해서 쓰세요”라는 의도가 들어간 클래스인지 확인한다.
- 오픈소스 라이브러리나 프레임워크 측에서 공식적으로 “이 클래스를 상속해 확장하세요”라고 안내했다면 문제가 훨씬 적다.
- 부모클래스의 API나 내부 구조가 자주 바뀌지 않고, 안정적으로 유지되는가?
- 부모클래스의 변경으로 인해 자식클래스가 깨질 위험이 낮아야 한다.
- 특히 패키지가 다르거나 외부 라이브러리를 상속할 경우, 버전업 때마다 자식클래스가 깨질 수 있으므로 주의해야 한다.
- 공통 로직을 그대로 쓰면서 추가/확장만 할 필요가 있는가?
- “수정 없이 부모의 기능을 거의 다 쓰고, 자식에서 일부만 덧붙이면 된다”면 상속이 훨씬 편리할 수 있다.
- 반대로 “부모의 내부를 여기저기 뜯어고쳐야 한다”면, 유지보수가 어려워질 가능성이 높으므로 합성을 검토하자.
- 부모클래스의 단점을 그대로 물려받더라도 괜찮은가?
- 상속은 부모클래스의 API 결함을 그대로 물려받는다.
- 만약 부모클래스 API에 문제가 있는데, 내가 자식클래스의 API로 노출시키고 싶지 않다면, 차라리 합성 후 래핑(Wrapper)하여 “결함을 숨기는” 쪽이 낫다.
📚 Reference
- 오브젝트
- Effetive Java
'JAVA' 카테고리의 다른 글
isBlank() vs isEmpty() (0) | 2025.02.28 |
---|---|
Comparable 인터페이스 너 뭐야! (0) | 2025.02.26 |
추상 클래스보다는 인터페이스를 우선하라 - 이펙티브 자바 Item 20 (0) | 2025.02.24 |