공공부하자개발 · 영어 학습 노트
자바
중급객체지향과 코어 라이브러리0/9 완료
  • 01상속, 다형성, 오버라이딩
  • 02추상 클래스 vs 인터페이스
  • 03예외 처리
  • 04java.lang 심화
  • 05컬렉션 프레임워크 딥다이브
  • 06메서드 활용 패턴 (중급)
  • 07Object 메서드와 비교
  • 08java.time 실무 날짜 계산
  • 09HTTP 와 JSON 기초
사이트 소개개인정보처리방침연락처
© 2026 공부하자
홈 › 중급 › 01 / 9

상속, 다형성, 오버라이딩

섹션 7진행 0 / 9
1왜 배우는가2핵심 원리3코드 예제4응용 변형 예제5자주 하는 실수 (Tip)6연습 문제7정리‹ 이전다음 ›

2. 핵심 원리

2.1 상속의 기본: extends와 메모리 구조

상속은 기존 클래스(부모, superclass)의 필드와 메서드를 새 클래스(자식, subclass)가 물려받는 것이다. Java는 클래스의 단일 상속만 허용한다(extends 뒤에 클래스 하나만).

java
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 객체가 따로 만들어져 연결된다"는 오해가 흔한데, 실제로는 부모 필드가 자식 객체의 앞부분에 포함된 단일 메모리 블록이다.

text
힙의 Dog 객체 레이아웃 (개념도)
┌─────────────────────┐
│ 객체 헤더 (클래스 포인터 등) │
├─────────────────────┤
│ Animal.name         │  ← 부모가 선언한 필드
├─────────────────────┤
│ Dog 고유 필드 (있다면) │
└─────────────────────┘

객체 헤더에는 "이 객체가 어떤 클래스의 인스턴스인가"를 가리키는 클래스 포인터가 들어 있다. 이 포인터가 뒤에서 설명할 동적 바인딩의 열쇠다.

2.2 생성자 호출 순서와 super()

자식 객체를 만들 때 부모 부분이 먼저 초기화되어야 한다. 부모 필드가 준비되지 않은 상태에서 자식 생성자가 부모 필드를 쓰면 위험하기 때문이다. 그래서 모든 생성자의 첫 줄은 반드시 super(...) 또는 this(...) 호출이며, 명시하지 않으면 컴파일러가 super()를 자동으로 삽입한다.

java
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 클래스)

전체 초기화 순서를 정리하면 다음과 같다.

  1. 부모 정적 초기화(static 필드, static 블록) — 클래스 로딩 시 1회
  2. 자식 정적 초기화 — 클래스 로딩 시 1회
  3. 부모 인스턴스 필드 초기화 + 인스턴스 초기화 블록
  4. 부모 생성자 본문
  5. 자식 인스턴스 필드 초기화 + 인스턴스 초기화 블록
  6. 자식 생성자 본문

2.3 메서드 오버라이딩 규칙

오버라이딩(overriding)은 부모가 정의한 메서드를 자식이 같은 시그니처로 다시 정의하는 것이다. 오버로딩(overloading, 같은 이름에 다른 매개변수)과 이름이 비슷해 헷갈리지만 완전히 다른 개념이다.

규칙 내용 이유
시그니처 동일 메서드 이름과 매개변수 타입/순서가 같아야 함 다르면 오버로딩이 됨
접근 범위 부모보다 좁게 못 함 (public → protected 불가) 부모 타입으로 호출하는 코드가 깨지면 안 됨 (리스코프 치환 원칙)
예외 부모보다 넓은 checked 예외 못 던짐 부모 타입으로 호출하는 코드가 처리 못 하는 예외가 생기면 안 됨
반환 타입 같거나 하위 타입(공변 반환, covariant return) 가능 하위 타입은 상위 타입으로 안전하게 대입 가능
static 메서드 오버라이딩 아님 (숨김, hiding) static은 객체가 아니라 클래스에 묶임
private/final 메서드 오버라이딩 불가 private은 상속 안 됨, final은 금지

@Override 애노테이션은 "이 메서드는 부모 메서드를 오버라이딩하는 것이다"라고 컴파일러에게 선언하는 장치다. 붙이지 않아도 동작은 하지만, 오타(toSting())로 새 메서드를 만들어버리는 사고를 컴파일 시점에 잡아주므로 항상 붙인다.

공변 반환 예시:

java
class Animal {
    Animal self() { return this; }
}
class Dog extends Animal {
    @Override
    Dog self() { return this; }  // 반환 타입을 Dog로 좁힘 — 허용
}
Dog d = new Dog().self();  // 캐스팅 없이 Dog로 받을 수 있음

2.4 다형성: 업캐스팅과 다운캐스팅

다형성의 출발점은 자식 객체를 부모 타입 변수에 담을 수 있다는 것이다(업캐스팅, upcasting). 자식은 부모의 모든 것을 가지고 있으므로 안전하며, 명시적 캐스팅이 필요 없다.

java
Animal a = new Dog();   // 업캐스팅: 암묵적, 항상 안전
a.eat();                // 가능 — Animal에 있는 메서드
// a.bark();            // 컴파일 에러 — Animal 타입에는 bark가 없음

변수의 타입(Animal)은 컴파일러가 허용하는 호출 범위를 결정하고, 실제 객체의 타입(Dog)은 런타임에 어떤 구현이 실행되는지를 결정한다. 이 둘의 분리가 다형성의 본질이다.

다운캐스팅(downcasting)은 반대로 부모 타입 변수를 자식 타입으로 되돌리는 것이다. 실제 객체가 그 자식 타입이 아니면 ClassCastException이 발생하므로 반드시 instanceof로 확인해야 한다. JDK 16부터 정식 도입된 패턴 매칭 instanceof는 검사와 캐스팅을 한 줄로 합쳐 준다.

java
// 전통 방식
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    -> "...";
};

2.5 동적 바인딩과 vtable

Animal a = new Dog(); a.speak();에서 JVM은 어떻게 Dog.speak()를 찾아 실행할까? 이것이 동적 바인딩(dynamic binding, 런타임에 호출할 메서드를 결정하는 것)이다.

JVM은 클래스마다 가상 메서드 테이블(vtable, virtual method table)을 만든다. 각 클래스의 vtable은 "이 클래스에서 호출 가능한 가상 메서드 → 실제 구현 주소"의 배열이다. 자식 클래스의 vtable은 부모의 vtable을 복사한 뒤, 오버라이딩한 메서드의 슬롯만 자기 구현으로 덮어쓴다.

text
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 메서드 컴파일 타임 (정적) 상속 안 되므로

필드는 다형성이 적용되지 않는다는 점이 자주 실수하는 부분이다. 자식이 같은 이름의 필드를 선언하면 부모 필드를 "숨길" 뿐이며, 부모 타입 변수로 접근하면 부모 필드가 나온다.

2.6 상속 vs 컴포지션

컴포지션(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을 필드로 갖고 위임하면 이 문제가 없다.

실무 판단 규칙:

  • 코드 재사용이 목적이면 → 컴포지션
  • 부모 타입으로 다형적으로 다뤄야 하고 실제로 "하위 타입"이면 → 상속(또는 인터페이스)
  • 확신이 없으면 → 컴포지션. 나중에 상속으로 바꾸기보다 컴포지션에서 시작하는 게 싸다

2.7 final과 sealed

final 클래스는 상속을 금지하고, final 메서드는 오버라이딩을 금지한다. String, Integer가 final인 이유는 불변성 보장 때문이다. 누군가 String을 상속해서 값을 바꾸는 하위 클래스를 만들면 String을 신뢰하는 모든 코드가 무너진다.

JDK 17에서 정식 도입된 sealed 클래스는 "상속을 완전히 막지도, 완전히 열지도 않고, 허용된 자식만 지정"한다.

java
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).

2.8 Object 클래스와 equals/hashCode/toString 규약

모든 클래스는 명시하지 않아도 Object를 상속한다. 그래서 어떤 객체든 toString(), equals(), hashCode()를 호출할 수 있다. 문제는 기본 구현이 대부분 쓸모없다는 것이다.

메서드 기본 동작 오버라이딩 이유
toString() 클래스명@해시16진수 로그/디버깅에서 읽을 수 있게
equals(Object) == (참조 동일성) 값이 같으면 같은 객체로 취급 (논리적 동등성)
hashCode() 객체 주소 기반 정수 HashMap/HashSet에서 올바르게 동작

equals를 오버라이딩할 때 지켜야 할 규약(contract):

  • 반사성: x.equals(x)는 true
  • 대칭성: x.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를 쓰는 것이 실수를 원천 차단하는 길이다.

핵심 원리
  • 2.1 상속의 기본: extends와 메모리 구조
  • 2.2 생성자 호출 순서와 super()
  • 2.3 메서드 오버라이딩 규칙
  • 2.4 다형성: 업캐스팅과 다운캐스팅
  • 2.5 동적 바인딩과 vtable
  • 2.6 상속 vs 컴포지션
  • 2.7 final과 sealed
  • 2.8 Object 클래스와 equals/hashCode/toString 규약
이전 섹션1 왜 배우는가2 / 7다음 섹션3 코드 예제