상속과 다형성 — 코드를 재사용하는 두 가지 방법
비슷한 클래스를 여러 개 만들다 보면 같은 코드가 반복되는 순간이 온다. "이거 복붙하고 있는데, 뭔가 더 좋은 방법이 없나?" — 이 질문의 답이 바로 상속과 다형성이다. 이번 글에서는
extends부터 시작해서 오버라이딩, 업/다운캐스팅, Object 클래스까지 한 번에 정리한다.
상속이란?
상속(Inheritance)은 기존 클래스의 필드와 메서드를 새로운 클래스가 물려받는 것이다. extends 키워드로 선언한다.
// 부모 클래스 (상위 클래스, Super Class)
public class Animal {
String name;
void eat() {
System.out.println(name + "이(가) 먹는다");
}
}
// 자식 클래스 (하위 클래스, Sub Class)
public class Dog extends Animal {
void bark() {
System.out.println(name + "이(가) 짖는다");
}
}
Dog dog = new Dog();
dog.name = "바둑이"; // 부모의 필드를 그대로 사용
dog.eat(); // 부모의 메서드도 사용 가능
dog.bark(); // 자식 고유의 메서드
왜 상속이 필요한가?
- 코드 재사용: 공통 로직을 부모에 한 번만 작성
- 계층적 설계: "Dog은 Animal이다" 같은 관계를 코드로 표현
- 유지보수: 공통 기능 수정 시 부모만 고치면 자식에 자동 반영
공부하다 보니 상속이 단순히 "코드 줄이기"가 아니라, 클래스 간의 관계를 설계하는 도구라는 걸 알게 됐다.
부모 생성자 호출 — super()
자식 객체를 만들면 부모 생성자가 먼저 호출된다. 이게 중요하다. 자식 객체 안에 부모 부분이 먼저 초기화돼야 하기 때문이다.
public class Animal {
String name;
// 부모 생성자
Animal(String name) {
this.name = name;
System.out.println("Animal 생성자 호출");
}
}
public class Dog extends Animal {
String breed;
// 자식 생성자
Dog(String name, String breed) {
super(name); // 부모 생성자 호출 (반드시 첫 줄에!)
this.breed = breed;
System.out.println("Dog 생성자 호출");
}
}
Dog dog = new Dog("바둑이", "진돗개");
// 출력:
// Animal 생성자 호출
// Dog 생성자 호출
super() 규칙
super()는 자식 생성자의 첫 줄에 와야 한다- 부모에 기본 생성자(매개변수 없는 생성자)가 있으면
super()를 생략해도 컴파일러가 자동으로 넣어준다 - 부모에 매개변수 있는 생성자만 있으면
super(인자)를 반드시 명시해야 한다
여기서 헷갈렸는데, this()와 super()는 둘 다 생성자 첫 줄에 와야 하므로 동시에 쓸 수 없다. 둘 중 하나만 선택해야 한다.
메서드 오버라이딩
오버라이딩(Overriding)은 부모가 가진 메서드를 자식이 재정의하는 것이다.
public class Animal {
void sound() {
System.out.println("...");
}
}
public class Dog extends Animal {
@Override // 오버라이딩한다는 표시 (권장)
void sound() {
System.out.println("멍멍!");
}
}
public class Cat extends Animal {
@Override
void sound() {
System.out.println("야옹!");
}
}
Dog dog = new Dog();
Cat cat = new Cat();
dog.sound(); // 멍멍!
cat.sound(); // 야옹!
@Override 어노테이션을 꼭 써야 하나?
문법적으로 필수는 아니지만, 반드시 쓰는 습관을 들이자.
- 오타로 새 메서드를 만들어버리는 실수를 컴파일 시점에 잡아준다
soung()같은 오타를 치면 "이 메서드는 부모에 없다"고 에러를 띄워준다
오버라이딩 규칙
- 메서드 이름, 매개변수, 반환 타입이 부모와 동일해야 한다
- 접근제어자를 더 좁게 바꿀 수 없다 (부모가
public이면 자식도public) - 부모보다 더 넓은 예외를 던질 수 없다
final메서드는 오버라이딩할 수 없다static메서드는 오버라이딩이 아니라 **숨기기(Hiding)**가 된다
부모 메서드 호출 — super.메서드()
오버라이딩하면서도 부모의 원래 동작을 유지하고 싶을 때 super를 쓴다.
public class Dog extends Animal {
@Override
void sound() {
super.sound(); // 부모의 sound() 먼저 실행
System.out.println("멍멍!");
}
}
오버라이딩 vs 오버로딩
이 둘은 이름이 비슷해서 자주 헷갈린다. 아래 비교표로 정리하자.
| 구분 | 오버라이딩(Overriding) | 오버로딩(Overloading) |
|---|---|---|
| 관계 | 부모-자식 (상속 관계) | 같은 클래스 내 |
| 메서드 이름 | 동일 | 동일 |
| 매개변수 | 동일 | 반드시 다름 |
| 반환 타입 | 동일 (공변 반환 허용) | 무관 |
| 결정 시점 | 런타임 (동적 바인딩) | 컴파일 타임 (정적 바인딩) |
| 목적 | 부모 동작 재정의 | 같은 이름으로 다양한 입력 처리 |
public class Calculator {
// 오버로딩 — 같은 이름, 다른 매개변수
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
}
핵심 차이: 오버라이딩은 "무엇을 실행할지"가 런타임에 결정되고, 오버로딩은 컴파일 시점에 결정된다.
다형성
다형성(Polymorphism)은 하나의 타입으로 여러 형태의 객체를 다루는 것이다. 상속의 꽃이라고 할 수 있다.
Animal animal1 = new Dog(); // 부모 타입으로 자식 객체 참조
Animal animal2 = new Cat();
animal1.sound(); // 멍멍! (Dog의 sound 실행)
animal2.sound(); // 야옹! (Cat의 sound 실행)
변수 타입은 Animal이지만, 실제 실행되는 메서드는 객체의 타입(Dog, Cat)에 따라 달라진다. 이게 다형성이다.
다형성이 왜 좋은가?
배열이나 리스트로 여러 자식 객체를 한 번에 다룰 수 있다.
// 다형성이 없다면 — 타입마다 따로따로
Dog[] dogs = {new Dog(), new Dog()};
Cat[] cats = {new Cat(), new Cat()};
// 다형성 덕분에 — 하나의 배열로 관리
Animal[] animals = {new Dog(), new Cat(), new Dog()};
for (Animal a : animals) {
a.sound(); // 각 객체의 오버라이딩된 메서드가 호출됨
}
메서드 매개변수에서도 위력을 발휘한다.
// 부모 타입으로 받으면 어떤 자식이든 처리 가능
void feed(Animal animal) {
System.out.println(animal.name + "에게 밥을 준다");
animal.eat();
}
feed(new Dog()); // Dog도 OK
feed(new Cat()); // Cat도 OK
업캐스팅과 다운캐스팅
업캐스팅 (Upcasting)
자식 타입 → 부모 타입으로 변환하는 것. 자동으로 일어난다.
Dog dog = new Dog();
Animal animal = dog; // 업캐스팅 (자동)
// Animal animal = (Animal) dog; // 명시적으로 써도 되지만 불필요
업캐스팅하면 부모 타입에 정의된 멤버만 접근 가능하다. Dog에만 있는 bark() 같은 메서드는 호출할 수 없다.
Animal animal = new Dog();
animal.eat(); // OK — Animal에 정의돼 있으므로
animal.sound(); // OK — 오버라이딩된 Dog의 sound()가 실행됨
// animal.bark(); // 컴파일 에러 — Animal에 bark()가 없음
다운캐스팅 (Downcasting)
부모 타입 → 자식 타입으로 변환하는 것. 명시적 캐스팅이 필요하다.
Animal animal = new Dog(); // 실제론 Dog 객체
Dog dog = (Dog) animal; // 다운캐스팅 (명시적)
dog.bark(); // 이제 bark() 호출 가능
위험한 점: 실제 객체가 해당 타입이 아니면 ClassCastException이 터진다.
Animal animal = new Dog();
Cat cat = (Cat) animal; // 런타임에 ClassCastException 발생!
instanceof로 안전하게 확인
다운캐스팅 전에 instanceof로 타입을 확인하는 게 안전하다.
Animal animal = new Dog();
if (animal instanceof Dog) {
Dog dog = (Dog) animal;
dog.bark();
}
if (animal instanceof Cat) {
// 실행되지 않음 — animal은 Dog이므로
Cat cat = (Cat) animal;
}
Java 16부터는 패턴 매칭 instanceof로 더 간결하게 쓸 수 있다.
// Java 16+ 패턴 매칭
if (animal instanceof Dog dog) {
// 캐스팅 없이 바로 dog 변수 사용 가능
dog.bark();
}
Object 클래스
Java에서 모든 클래스는 Object를 상속받는다. extends를 쓰지 않아도 컴파일러가 자동으로 extends Object를 붙인다.
// 이 두 선언은 완전히 동일하다
public class Dog { }
public class Dog extends Object { }
그래서 모든 객체에서 toString(), equals(), hashCode() 같은 메서드를 쓸 수 있는 것이다.
toString()
객체를 문자열로 표현한다. 오버라이딩하지 않으면 클래스이름@해시코드가 출력된다.
public class Dog extends Animal {
String breed;
@Override
public String toString() {
return "Dog{name='" + name + "', breed='" + breed + "'}";
}
}
Dog dog = new Dog("바둑이", "진돗개");
System.out.println(dog); // Dog{name='바둑이', breed='진돗개'}
// 오버라이딩 안 했다면: Dog@1b6d3586 같은 형태
equals()와 hashCode()
equals()는 두 객체가 논리적으로 같은지 비교한다. 기본 구현은 ==과 동일(참조 비교)이므로, 내용 비교가 필요하면 오버라이딩해야 한다.
public class Dog extends Animal {
String breed;
@Override
public boolean equals(Object o) {
if (this == o) return true; // 같은 참조면 true
if (o == null || getClass() != o.getClass()) return false; // 타입 체크
Dog dog = (Dog) o;
return Objects.equals(name, dog.name) && Objects.equals(breed, dog.breed);
}
@Override
public int hashCode() {
return Objects.hash(name, breed);
}
}
equals()를 오버라이딩하면 hashCode()도 반드시 같이 오버라이딩해야 한다. HashMap, HashSet 같은 컬렉션이 hashCode()를 기준으로 버킷을 나누기 때문이다. 이 규칙을 어기면 컬렉션에서 객체를 못 찾는 버그가 생긴다.
상속의 한계와 주의점
다중 상속 불가
Java는 클래스 다중 상속을 지원하지 않는다. 하나의 부모만 extends 할 수 있다.
// 불가능!
public class FlyingDog extends Dog, Bird { }
왜 안 될까? 두 부모에 같은 이름의 메서드가 있으면 어느 쪽을 실행해야 할지 모호해지기 때문이다. 이를 **다이아몬드 문제(Diamond Problem)**라고 한다.
대신 인터페이스는 다중 구현이 가능하다 — 이건 다음 글에서 다룬다.
is-a 관계인지 확인하자
상속은 "자식 is a 부모" 관계일 때만 써야 한다.
- Dog is a Animal → 상속 적절
- Car is a Engine → 상속 부적절 (Car has a Engine)
"has-a" 관계라면 상속 대신 **구성(Composition)**을 써야 한다.
// 나쁜 예 — 상속
public class Car extends Engine { } // 자동차가 엔진이다? (X)
// 좋은 예 — 구성
public class Car {
private Engine engine; // 자동차가 엔진을 가지고 있다 (O)
}
상속 깊이를 깊게 만들지 말자
상속이 3단계, 4단계로 깊어지면 코드를 따라가기가 어려워진다. "이 메서드가 어디서 오버라이딩된 거지?" 하고 부모를 타고 올라가다 길을 잃기 쉽다.
실무에서는 상속보다 구성(Composition)을 선호하는 추세다.
실전 예제 — Shape 계층
지금까지 배운 걸 모아서 도형 클래스를 만들어보자.
// 부모 클래스
public class Shape {
String color;
Shape(String color) {
this.color = color;
}
// 넓이 계산 — 자식이 오버라이딩할 메서드
double area() {
return 0;
}
@Override
public String toString() {
return getClass().getSimpleName() + "{color='" + color + "'}";
}
}
// 원
public class Circle extends Shape {
double radius;
Circle(String color, double radius) {
super(color); // 부모 생성자 호출
this.radius = radius;
}
@Override
double area() {
return Math.PI * radius * radius;
}
@Override
public String toString() {
return "Circle{color='" + color + "', radius=" + radius + "}";
}
}
// 직사각형
public class Rectangle extends Shape {
double width;
double height;
Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
@Override
double area() {
return width * height;
}
@Override
public String toString() {
return "Rectangle{color='" + color + "', width=" + width + ", height=" + height + "}";
}
}
public class Main {
public static void main(String[] args) {
// 다형성 — Shape 배열에 다양한 도형 담기
Shape[] shapes = {
new Circle("빨강", 5.0),
new Rectangle("파랑", 4.0, 6.0),
new Circle("초록", 3.0)
};
// 전체 넓이 계산
double totalArea = 0;
for (Shape shape : shapes) {
System.out.println(shape + " → 넓이: " + shape.area());
totalArea += shape.area();
}
System.out.println("총 넓이: " + totalArea);
// instanceof로 타입 확인
for (Shape shape : shapes) {
if (shape instanceof Circle circle) {
System.out.println("원 발견! 반지름: " + circle.radius);
}
}
}
}
출력 결과:
Circle{color='빨강', radius=5.0} → 넓이: 78.53981633974483
Rectangle{color='파랑', width=4.0, height=6.0} → 넓이: 24.0
Circle{color='초록', radius=3.0} → 넓이: 28.274333882308138
총 넓이: 130.81415022205297
원 발견! 반지름: 5.0
원 발견! 반지름: 3.0
이 예제에서 볼 수 있는 포인트를 정리하면:
Shape배열로 Circle과 Rectangle을 한 번에 관리 (다형성)area()는 각 도형이 오버라이딩해서 자기만의 계산 로직 실행toString()오버라이딩으로 의미 있는 출력instanceof패턴 매칭으로 안전한 다운캐스팅
▸ TIP 이 글의 코드 예제를 직접 실행해보고 싶다면 Java 기본기 핸드북을 확인해보세요.
정리
| 개념 | 핵심 |
|---|---|
| 상속 | extends로 부모의 필드/메서드를 물려받음 |
| super() | 자식 생성자 첫 줄에서 부모 생성자 호출 |
| 오버라이딩 | 부모 메서드를 자식이 재정의 (런타임 결정) |
| 오버로딩 | 같은 이름, 다른 매개변수 (컴파일 타임 결정) |
| 다형성 | 부모 타입으로 자식 객체를 다루는 것 |
| 업캐스팅 | 자식 → 부모 (자동) |
| 다운캐스팅 | 부모 → 자식 (명시적, instanceof 필수) |
| Object | 모든 클래스의 최상위 부모 |
상속은 강력하지만, 아무 데나 쓰면 코드가 복잡해진다. is-a 관계인지 먼저 확인하고, 깊은 상속 계층은 피하자. 실무에서는 "상속보다 구성"이라는 원칙이 자주 언급된다.
다음 글에서는 인터페이스와 추상 클래스를 다룬다. 다중 상속이 안 되는 Java에서 유연한 설계를 가능하게 해주는 핵심 도구다.