상속과 다형성 — 코드를 재사용하는 두 가지 방법
비슷한 클래스를 여러 개 만들다 보면 같은 코드가 반복되는 순간이 온다. 공통 부분을 한 곳에 모으고, 다른 부분만 따로 정의할 수는 없을까?
상속(Inheritance)은 기존 클래스의 필드와 메서드를 새 클래스가 물려받는 것 이다. 다형성(Polymorphism)은 하나의 타입으로 여러 형태의 객체를 다루는 것 이다. 이 둘은 함께 작동하면서 코드 재사용과 유연한 설계를 가능하게 한다.
상속 — extends로 물려받기
extends 키워드 하나로 부모 클래스의 필드와 메서드를 자식이 그대로 쓸 수 있다.
public class Animal {
String name;
void eat() {
System.out.println(name + "이(가) 먹는다");
}
}
public class Dog extends Animal {
void bark() {
System.out.println(name + "이(가) 짖는다");
}
}
Dog는 Animal의 name과 eat()을 따로 작성하지 않아도 사용할 수 있다. 공통 로직은 부모에 한 번만 작성하고, 수정이 필요하면 부모만 고치면 모든 자식에 반영된다.
부모 생성자 호출 — super()
자식 객체가 생성되면 부모 생성자가 먼저 호출 된다. 자식 객체 안에 부모 부분이 먼저 초기화돼야 하기 때문이다.
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()는 둘 다 첫 줄이어야 하므로 **동시에 쓸 수 없다 **.
메서드 오버라이딩 — 부모 동작을 재정의
부모의 메서드를 자식이 같은 시그니처로 다시 구현하는 것이 오버라이딩이다.
public class Dog extends Animal {
@Override
void sound() {
System.out.println("멍멍!");
}
}
@Override를 붙이면 컴파일러가 부모에 해당 메서드가 있는지 검증한다. soung() 같은 오타를 치면 "부모에 이 메서드가 없다"고 에러를 띄워주기 때문에 ** 항상 붙이는 습관 **을 들이는 게 좋다.
오버라이딩에는 몇 가지 제약이 있다.
- 접근제어자를 ** 더 좁게** 바꿀 수 없다 (부모
public→ 자식protected불가) - 부모보다 ** 더 넓은 예외 **를 던질 수 없다
final메서드는 오버라이딩할 수 없다static메서드는 오버라이딩이 아니라 ** 숨기기(Hiding)**가 된다
오버라이딩 vs 오버로딩
이름이 비슷해서 혼동하기 쉽지만, 결정 시점이 완전히 다르다. 오버라이딩은 ** 런타임 **에 실제 객체 타입을 보고 실행할 메서드를 결정하고(동적 바인딩), 오버로딩은 ** 컴파일 타임 **에 매개변수 시그니처를 보고 결정한다(정적 바인딩).
| 구분 | 오버라이딩 | 오버로딩 |
|---|---|---|
| 관계 | 부모-자식 (상속) | 같은 클래스 내 |
| 매개변수 | 동일 | ** 반드시 다름** |
| 결정 시점 | 런타임 (동적 바인딩) | 컴파일 타임 (정적 바인딩) |
| 목적 | 부모 동작 재정의 | 같은 이름으로 다양한 입력 처리 |
다형성 — 부모 타입으로 자식 객체 다루기
오버라이딩의 진짜 위력은 다형성에서 나타난다. 변수 타입은 Animal이지만 실제 실행되는 메서드는 객체의 타입에 따라 달라진다.
Animal a1 = new Dog();
Animal a2 = new Cat();
a1.sound(); // 멍멍!
a2.sound(); // 야옹!
이것이 가능한 이유는 오버라이딩이 런타임에 결정되기 때문이다. 컴파일러는 Animal 타입의 sound()를 호출한다는 것만 확인하고, JVM이 실행 시점에 ** 실제 객체가 Dog인지 Cat인지** 판단해서 해당 클래스의 sound()를 호출한다.
다형성 덕분에 배열이나 메서드 매개변수에서 여러 자식 객체를 ** 하나의 부모 타입으로 통일 **해서 다룰 수 있다.
Animal[] animals = {new Dog(), new Cat()};
for (Animal a : animals) {
a.sound(); // 각 객체의 오버라이딩된 메서드가 호출됨
}
새로운 동물 클래스를 추가해도 이 루프는 수정할 필요가 없다. 확장에는 열려 있고 수정에는 닫혀 있는 구조다.
업캐스팅과 다운캐스팅
업캐스팅 — 자식을 부모 타입으로
자식 → 부모 변환은 ** 자동 **으로 일어난다. 다만 부모 타입에 정의된 멤버만 접근 가능하다.
Animal animal = new Dog(); // 업캐스팅 (자동)
animal.eat(); // OK
// animal.bark(); // 컴파일 에러 — Animal에 bark()가 없음
다운캐스팅 — 부모를 자식 타입으로
부모 → 자식 변환은 ** 명시적 캐스팅 **이 필요하다. 실제 객체가 해당 타입이 아니면 ClassCastException이 발생한다.
Animal animal = new Dog();
Dog dog = (Dog) animal; // OK — 실제 객체가 Dog이므로
dog.bark();
// Cat cat = (Cat) animal; // ClassCastException!
instanceof로 먼저 타입을 확인하면 안전하다. Java 16부터는 패턴 매칭으로 캐스팅까지 한 번에 할 수 있다.
if (animal instanceof Dog dog) {
dog.bark(); // 캐스팅 없이 바로 사용
}
Object — 모든 클래스의 최상위 부모
Java에서 모든 클래스는 Object를 상속받는다. extends를 쓰지 않아도 컴파일러가 자동으로 extends Object를 붙인다. 그래서 toString(), equals(), hashCode() 같은 메서드를 어디서든 쓸 수 있다.
equals()와 hashCode() — 반드시 함께 오버라이딩
equals()의 기본 구현은 ==과 동일하게 참조를 비교한다. 내용 비교가 필요하면 오버라이딩해야 한다.
@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 부모" 관계일 때만 써야 한다.
// 나쁜 예 — 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()도 필수 |