비슷한 클래스를 여러 개 만들다 보면 같은 코드가 반복되는 순간이 온다. 공통 부분을 한 곳에 모으고, 다른 부분만 따로 정의할 수는 없을까?

상속(Inheritance)은 기존 클래스의 필드와 메서드를 새 클래스가 물려받는 것 이다. 다형성(Polymorphism)은 하나의 타입으로 여러 형태의 객체를 다루는 것 이다. 이 둘은 함께 작동하면서 코드 재사용과 유연한 설계를 가능하게 한다.

상속 — extends로 물려받기

extends 키워드 하나로 부모 클래스의 필드와 메서드를 자식이 그대로 쓸 수 있다.

JAVA
public class Animal {
    String name;
    void eat() {
        System.out.println(name + "이(가) 먹는다");
    }
}

public class Dog extends Animal {
    void bark() {
        System.out.println(name + "이(가) 짖는다");
    }
}

DogAnimalnameeat()을 따로 작성하지 않아도 사용할 수 있다. 공통 로직은 부모에 한 번만 작성하고, 수정이 필요하면 부모만 고치면 모든 자식에 반영된다.

부모 생성자 호출 — super()

자식 객체가 생성되면 부모 생성자가 먼저 호출 된다. 자식 객체 안에 부모 부분이 먼저 초기화돼야 하기 때문이다.

JAVA
public class Animal {
    String name;
    Animal(String name) {
        this.name = name;
    }
}

public class Dog extends Animal {
    String breed;
    Dog(String name, String breed) {
        super(name);  // 반드시 첫 줄에서 부모 생성자 호출
        this.breed = breed;
    }
}

super()는 자식 생성자의 첫 줄 에 와야 한다. 부모에 기본 생성자가 있으면 생략해도 컴파일러가 자동 삽입하지만, 매개변수 있는 생성자만 있으면 반드시 명시해야 한다. this()super()는 둘 다 첫 줄이어야 하므로 **동시에 쓸 수 없다 **.

메서드 오버라이딩 — 부모 동작을 재정의

부모의 메서드를 자식이 같은 시그니처로 다시 구현하는 것이 오버라이딩이다.

JAVA
public class Dog extends Animal {
    @Override
    void sound() {
        System.out.println("멍멍!");
    }
}

@Override를 붙이면 컴파일러가 부모에 해당 메서드가 있는지 검증한다. soung() 같은 오타를 치면 "부모에 이 메서드가 없다"고 에러를 띄워주기 때문에 ** 항상 붙이는 습관 **을 들이는 게 좋다.

오버라이딩에는 몇 가지 제약이 있다.

  • 접근제어자를 ** 더 좁게** 바꿀 수 없다 (부모 public → 자식 protected 불가)
  • 부모보다 ** 더 넓은 예외 **를 던질 수 없다
  • final 메서드는 오버라이딩할 수 없다
  • static 메서드는 오버라이딩이 아니라 ** 숨기기(Hiding)**가 된다

오버라이딩 vs 오버로딩

이름이 비슷해서 혼동하기 쉽지만, 결정 시점이 완전히 다르다. 오버라이딩은 ** 런타임 **에 실제 객체 타입을 보고 실행할 메서드를 결정하고(동적 바인딩), 오버로딩은 ** 컴파일 타임 **에 매개변수 시그니처를 보고 결정한다(정적 바인딩).

구분오버라이딩오버로딩
관계부모-자식 (상속)같은 클래스 내
매개변수동일** 반드시 다름**
결정 시점런타임 (동적 바인딩)컴파일 타임 (정적 바인딩)
목적부모 동작 재정의같은 이름으로 다양한 입력 처리

다형성 — 부모 타입으로 자식 객체 다루기

오버라이딩의 진짜 위력은 다형성에서 나타난다. 변수 타입은 Animal이지만 실제 실행되는 메서드는 객체의 타입에 따라 달라진다.

JAVA
Animal a1 = new Dog();
Animal a2 = new Cat();
a1.sound();  // 멍멍!
a2.sound();  // 야옹!

이것이 가능한 이유는 오버라이딩이 런타임에 결정되기 때문이다. 컴파일러는 Animal 타입의 sound()를 호출한다는 것만 확인하고, JVM이 실행 시점에 ** 실제 객체가 Dog인지 Cat인지** 판단해서 해당 클래스의 sound()를 호출한다.

다형성 덕분에 배열이나 메서드 매개변수에서 여러 자식 객체를 ** 하나의 부모 타입으로 통일 **해서 다룰 수 있다.

JAVA
Animal[] animals = {new Dog(), new Cat()};
for (Animal a : animals) {
    a.sound();  // 각 객체의 오버라이딩된 메서드가 호출됨
}

새로운 동물 클래스를 추가해도 이 루프는 수정할 필요가 없다. 확장에는 열려 있고 수정에는 닫혀 있는 구조다.

업캐스팅과 다운캐스팅

업캐스팅 — 자식을 부모 타입으로

자식 → 부모 변환은 ** 자동 **으로 일어난다. 다만 부모 타입에 정의된 멤버만 접근 가능하다.

JAVA
Animal animal = new Dog();  // 업캐스팅 (자동)
animal.eat();    // OK
// animal.bark();  // 컴파일 에러 — Animal에 bark()가 없음

다운캐스팅 — 부모를 자식 타입으로

부모 → 자식 변환은 ** 명시적 캐스팅 **이 필요하다. 실제 객체가 해당 타입이 아니면 ClassCastException이 발생한다.

JAVA
Animal animal = new Dog();
Dog dog = (Dog) animal;  // OK — 실제 객체가 Dog이므로
dog.bark();

// Cat cat = (Cat) animal;  // ClassCastException!

instanceof로 먼저 타입을 확인하면 안전하다. Java 16부터는 패턴 매칭으로 캐스팅까지 한 번에 할 수 있다.

JAVA
if (animal instanceof Dog dog) {
    dog.bark();  // 캐스팅 없이 바로 사용
}

Object — 모든 클래스의 최상위 부모

Java에서 모든 클래스는 Object를 상속받는다. extends를 쓰지 않아도 컴파일러가 자동으로 extends Object를 붙인다. 그래서 toString(), equals(), hashCode() 같은 메서드를 어디서든 쓸 수 있다.

equals()와 hashCode() — 반드시 함께 오버라이딩

equals()의 기본 구현은 ==과 동일하게 참조를 비교한다. 내용 비교가 필요하면 오버라이딩해야 한다.

JAVA
@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    Dog dog = (Dog) o;
    return Objects.equals(name, dog.name);
}

@Override
public int hashCode() {
    return Objects.hash(name);
}

여기서 핵심은 equals()를 오버라이딩하면 hashCode()도 반드시 같이 오버라이딩해야 한다 는 점이다. HashMap, HashSet 같은 컬렉션은 hashCode()로 버킷을 결정한 뒤 equals()로 동등성을 판단한다. hashCode()를 오버라이딩하지 않으면 내용이 같은 두 객체가 다른 버킷에 들어가서 컬렉션에서 찾을 수 없는 버그가 생긴다.

주의할 점

is-a 관계가 아니면 상속하지 말자

상속은 "자식 is a 부모" 관계일 때만 써야 한다.

JAVA
// 나쁜 예 — Car is a Engine? (X)
public class Car extends Engine { }

// 좋은 예 — Car has a Engine (O)
public class Car {
    private Engine engine;  // 구성(Composition)
}

"has-a" 관계에 상속을 쓰면 부모의 불필요한 메서드가 자식에 노출되고, 부모 변경이 자식에 예상치 못한 영향을 준다. 실무에서는 ** 상속보다 구성을 선호 **하는 추세다.

다중 상속이 안 되는 이유

Java는 클래스 다중 상속을 지원하지 않는다. 두 부모에 같은 이름의 메서드가 있으면 어느 쪽을 실행할지 모호해지기 때문이다(다이아몬드 문제). 대신 ** 인터페이스는 다중 구현이 가능 **하다.

깊은 상속 계층의 문제

상속이 3~4단계로 깊어지면 "이 메서드가 어디서 오버라이딩된 건지" 추적하기 어려워진다. 상속 체인이 길수록 부모 변경의 파급 범위도 커진다. 계층이 깊어진다면 구성으로 전환할 수 있는지 검토해보자.

정리

개념핵심
상속extends로 부모의 필드/메서드를 물려받음
super()자식 생성자 첫 줄에서 부모 생성자 호출
오버라이딩부모 메서드를 자식이 재정의. ** 런타임 **에 실제 객체 기준으로 결정
오버로딩같은 이름, 다른 매개변수. ** 컴파일 타임 **에 결정
다형성부모 타입으로 자식 객체를 다루되, 실행은 자식 메서드가 호출됨
업캐스팅자식 → 부모 (자동). 부모 멤버만 접근 가능
다운캐스팅부모 → 자식 (명시적). instanceof 확인 필수
Object모든 클래스의 최상위 부모. equals() 오버라이딩 시 hashCode()도 필수
댓글 로딩 중...