상속은 기존 클래스(부모, superclass)의 필드와 메서드를 새 클래스(자식, subclass)가 물려받는 것이다. Java는 클래스의 단일 상속만 허용한다(extends 뒤에 클래스 하나만).
class Animal {
protected String name;
public void eat() { System.out.println(name + " eats"); }
}
class Dog extends Animal {
public void bark() { System.out.println(name + " barks"); }
}new Dog()를 실행하면 힙(heap, 객체가 저장되는 메모리 영역)에 하나의 객체가 만들어진다. 이 객체 안에는 Animal 부분(name)과 Dog 부분이 함께 들어 있다. "Animal 객체와 Dog 객체가 따로 만들어져 연결된다"는 오해가 흔한데, 실제로는 부모 필드가 자식 객체의 앞부분에 포함된 단일 메모리 블록이다.
힙의 Dog 객체 레이아웃 (개념도)
┌─────────────────────┐
│ 객체 헤더 (클래스 포인터 등) │
├─────────────────────┤
│ Animal.name │ ← 부모가 선언한 필드
├─────────────────────┤
│ Dog 고유 필드 (있다면) │
└─────────────────────┘객체 헤더에는 "이 객체가 어떤 클래스의 인스턴스인가"를 가리키는 클래스 포인터가 들어 있다. 이 포인터가 뒤에서 설명할 동적 바인딩의 열쇠다.
자식 객체를 만들 때 부모 부분이 먼저 초기화되어야 한다. 부모 필드가 준비되지 않은 상태에서 자식 생성자가 부모 필드를 쓰면 위험하기 때문이다. 그래서 모든 생성자의 첫 줄은 반드시 super(...) 또는 this(...) 호출이며, 명시하지 않으면 컴파일러가 super()를 자동으로 삽입한다.
class Animal {
Animal() { System.out.println("Animal 생성자"); }
}
class Dog extends Animal {
Dog() {
// super(); ← 컴파일러가 자동 삽입
System.out.println("Dog 생성자");
}
}
new Dog();
// 출력:
// Animal 생성자
// Dog 생성자여기서 자주 터지는 컴파일 에러가 있다. 부모에 기본 생성자(매개변수 없는 생성자)가 없으면 자동 삽입된 super()가 호출할 대상이 없어서 실패한다.
| 부모 상황 | 자식이 해야 할 일 |
|---|---|
| 기본 생성자 있음 (또는 생성자 미선언) | 아무것도 안 해도 됨 |
| 매개변수 있는 생성자만 있음 | 자식 생성자 첫 줄에 super(인자) 명시 필수 |
생성자가 private |
상속 불가 (사실상 final 클래스) |
전체 초기화 순서를 정리하면 다음과 같다.
오버라이딩(overriding)은 부모가 정의한 메서드를 자식이 같은 시그니처로 다시 정의하는 것이다. 오버로딩(overloading, 같은 이름에 다른 매개변수)과 이름이 비슷해 헷갈리지만 완전히 다른 개념이다.
| 규칙 | 내용 | 이유 |
|---|---|---|
| 시그니처 동일 | 메서드 이름과 매개변수 타입/순서가 같아야 함 | 다르면 오버로딩이 됨 |
| 접근 범위 | 부모보다 좁게 못 함 (public → protected 불가) | 부모 타입으로 호출하는 코드가 깨지면 안 됨 (리스코프 치환 원칙) |
| 예외 | 부모보다 넓은 checked 예외 못 던짐 | 부모 타입으로 호출하는 코드가 처리 못 하는 예외가 생기면 안 됨 |
| 반환 타입 | 같거나 하위 타입(공변 반환, covariant return) 가능 | 하위 타입은 상위 타입으로 안전하게 대입 가능 |
| static 메서드 | 오버라이딩 아님 (숨김, hiding) | static은 객체가 아니라 클래스에 묶임 |
| private/final 메서드 | 오버라이딩 불가 | private은 상속 안 됨, final은 금지 |
@Override 애노테이션은 "이 메서드는 부모 메서드를 오버라이딩하는 것이다"라고 컴파일러에게 선언하는 장치다. 붙이지 않아도 동작은 하지만, 오타(toSting())로 새 메서드를 만들어버리는 사고를 컴파일 시점에 잡아주므로 항상 붙인다.
공변 반환 예시:
class Animal {
Animal self() { return this; }
}
class Dog extends Animal {
@Override
Dog self() { return this; } // 반환 타입을 Dog로 좁힘 — 허용
}
Dog d = new Dog().self(); // 캐스팅 없이 Dog로 받을 수 있음다형성의 출발점은 자식 객체를 부모 타입 변수에 담을 수 있다는 것이다(업캐스팅, upcasting). 자식은 부모의 모든 것을 가지고 있으므로 안전하며, 명시적 캐스팅이 필요 없다.
Animal a = new Dog(); // 업캐스팅: 암묵적, 항상 안전
a.eat(); // 가능 — Animal에 있는 메서드
// a.bark(); // 컴파일 에러 — Animal 타입에는 bark가 없음변수의 타입(Animal)은 컴파일러가 허용하는 호출 범위를 결정하고, 실제 객체의 타입(Dog)은 런타임에 어떤 구현이 실행되는지를 결정한다. 이 둘의 분리가 다형성의 본질이다.
다운캐스팅(downcasting)은 반대로 부모 타입 변수를 자식 타입으로 되돌리는 것이다. 실제 객체가 그 자식 타입이 아니면 ClassCastException이 발생하므로 반드시 instanceof로 확인해야 한다. JDK 16부터 정식 도입된 패턴 매칭 instanceof는 검사와 캐스팅을 한 줄로 합쳐 준다.
// 전통 방식
if (a instanceof Dog) {
Dog d = (Dog) a;
d.bark();
}
// JDK 16+ 패턴 매칭: 검사 + 바인딩 변수 선언
if (a instanceof Dog d) {
d.bark();
}
// JDK 21 switch 패턴 매칭
String sound = switch (a) {
case Dog d -> "멍멍 (" + d.name + ")";
case Cat c -> "야옹";
default -> "...";
};Animal a = new Dog(); a.speak();에서 JVM은 어떻게 Dog.speak()를 찾아 실행할까? 이것이 동적 바인딩(dynamic binding, 런타임에 호출할 메서드를 결정하는 것)이다.
JVM은 클래스마다 가상 메서드 테이블(vtable, virtual method table)을 만든다. 각 클래스의 vtable은 "이 클래스에서 호출 가능한 가상 메서드 → 실제 구현 주소"의 배열이다. 자식 클래스의 vtable은 부모의 vtable을 복사한 뒤, 오버라이딩한 메서드의 슬롯만 자기 구현으로 덮어쓴다.
Animal vtable Dog vtable (Animal 것을 복사 후 덮어씀)
[0] toString → Object.toString [0] toString → Object.toString
[1] eat → Animal.eat [1] eat → Animal.eat
[2] speak → Animal.speak [2] speak → Dog.speak ← 덮어씀
[3] bark → Dog.bark ← 추가a.speak()를 실행하면 JVM은 (1) a가 가리키는 객체의 헤더에서 클래스 포인터를 읽고, (2) 그 클래스의 vtable에서 speak의 슬롯 번호(컴파일 시 결정, 여기선 2)를 찾아, (3) 그 슬롯에 있는 주소로 점프한다. 변수 타입이 Animal이든 Dog이든 슬롯 번호는 같기 때문에, 실제 객체가 무엇이냐에 따라 실행되는 코드가 달라진다.
이 때문에 다음이 성립한다.
| 종류 | 바인딩 시점 | 기준 |
|---|---|---|
| 인스턴스 메서드 | 런타임 (동적) | 실제 객체 타입 |
| static 메서드 | 컴파일 타임 (정적) | 변수 타입 |
| 필드 | 컴파일 타임 (정적) | 변수 타입 |
| private 메서드 | 컴파일 타임 (정적) | 상속 안 되므로 |
필드는 다형성이 적용되지 않는다는 점이 자주 실수하는 부분이다. 자식이 같은 이름의 필드를 선언하면 부모 필드를 "숨길" 뿐이며, 부모 타입 변수로 접근하면 부모 필드가 나온다.
컴포지션(composition, 다른 객체를 필드로 가지고 위임하는 방식)은 상속의 대안이다. 실무에서는 "상속보다 컴포지션을 우선하라"(Effective Java 아이템 18)는 원칙이 널리 받아들여진다.
| 판단 기준 | 상속 (is-a) | 컴포지션 (has-a) |
|---|---|---|
| 관계 | "Dog is an Animal" 이 자연스러움 | "Car has an Engine" |
| 결합도 | 부모 내부 구현에 의존 (강함) | 공개 API에만 의존 (약함) |
| 부모 변경 영향 | 자식이 깨질 수 있음 (취약한 기반 클래스 문제) | 인터페이스 유지되면 영향 없음 |
| 런타임 교체 | 불가 (타입 고정) | 가능 (필드 교체) |
| 캡슐화 | 부모 protected 멤버 노출 | 유지 |
| 적합한 경우 | 진짜 하위 타입, 프레임워크 확장 포인트 | 기능 재사용, 위임 |
취약한 기반 클래스 문제(fragile base class problem)의 전형적 예: HashSet을 상속해서 add와 addAll을 오버라이딩해 삽입 횟수를 세는 클래스를 만들면, HashSet.addAll이 내부적으로 add를 호출하기 때문에 카운트가 두 배로 잡힌다. 부모의 내부 구현에 의존했기 때문이다. 컴포지션으로 Set을 필드로 갖고 위임하면 이 문제가 없다.
실무 판단 규칙:
final 클래스는 상속을 금지하고, final 메서드는 오버라이딩을 금지한다. String, Integer가 final인 이유는 불변성 보장 때문이다. 누군가 String을 상속해서 값을 바꾸는 하위 클래스를 만들면 String을 신뢰하는 모든 코드가 무너진다.
JDK 17에서 정식 도입된 sealed 클래스는 "상속을 완전히 막지도, 완전히 열지도 않고, 허용된 자식만 지정"한다.
public sealed interface Shape permits Circle, Rectangle, Triangle {}
public record Circle(double r) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
public record Triangle(double b, double h) implements Shape {}sealed의 진짜 가치는 switch 패턴 매칭과 결합될 때 나타난다. 컴파일러가 모든 하위 타입을 알기 때문에 default 없이도 빠짐없이 처리했는지 검사(exhaustiveness check)해 준다. 새 도형을 추가하면 처리하지 않은 switch가 컴파일 에러로 드러난다.
허용된 자식은 final, sealed, non-sealed 중 하나를 선언해야 한다(record는 암묵적으로 final).
모든 클래스는 명시하지 않아도 Object를 상속한다. 그래서 어떤 객체든 toString(), equals(), hashCode()를 호출할 수 있다. 문제는 기본 구현이 대부분 쓸모없다는 것이다.
| 메서드 | 기본 동작 | 오버라이딩 이유 |
|---|---|---|
toString() |
클래스명@해시16진수 |
로그/디버깅에서 읽을 수 있게 |
equals(Object) |
== (참조 동일성) |
값이 같으면 같은 객체로 취급 (논리적 동등성) |
hashCode() |
객체 주소 기반 정수 | HashMap/HashSet에서 올바르게 동작 |
equals를 오버라이딩할 때 지켜야 할 규약(contract):
x.equals(x)는 truex.equals(y)면 y.equals(x)x.equals(y), y.equals(z)면 x.equals(z)x.equals(null)은 false그리고 가장 중요한 규칙: equals가 true인 두 객체는 hashCode도 같아야 한다. HashMap은 먼저 hashCode로 버킷을 찾고 그 안에서 equals로 비교하기 때문에, equals만 오버라이딩하고 hashCode를 빠뜨리면 같은 값의 키를 넣어도 다른 버킷에 들어가 찾지 못한다.
JDK 16+의 record는 이 세 메서드를 모든 필드 기반으로 자동 생성해 준다. 값 객체(DTO, ID, 좌표 등)는 record를 쓰는 것이 실수를 원천 차단하는 길이다.