지난 장에서 배운 상속 위에 올라타는 개념이에요. 다형성(Polymorphism)은 "같은 명령어 한 줄이 상황에 따라 다르게 동작"하게 만드는 마법입니다. 처음엔 낯설지만, 비유로 하나씩 짚어가면 "아, 이래서 객체 지향이구나" 하는 순간이 와요.
다형성(Polymorphism)은 말 그대로 "여러(poly) 모습(morph)"이라는 뜻이에요. 자바에서는 하나의 참조 변수(타입)가, 실제로는 여러 종류의 객체를 가리킬 수 있다는 성질을 말해요. 그리고 그 변수로 메서드를 부르면, 실제로 담긴 객체에 맞는 동작이 실행됩니다.
extends와 @Override가 그대로 재료로 쓰입니다.왜 유용할까요? 만약 다형성이 없다면, 강아지용 코드·고양이용 코드·앵무새용 코드를 따로따로 써야 해요. 새 동물이 늘 때마다 코드를 뜯어고쳐야 하죠. 다형성을 쓰면 "동물"이라는 하나의 창구로 모두를 다룰 수 있어서, 코드가 유연하고 확장하기 쉬워집니다.
먼저 재료를 준비할게요. 부모 클래스 Animal과, 이를 상속받아 sound()를 오버라이딩한 자식 Dog·Cat이 있다고 해봐요.
class Animal {
void sound() {
System.out.println("어떤 소리를 냅니다");
}
}
class Dog extends Animal {
@Override
void sound() {
System.out.println("멍멍");
}
}
class Cat extends Animal {
@Override
void sound() {
System.out.println("야옹");
}
}
이제 핵심입니다. 부모 타입 변수에 자식 객체를 담을 수 있어요. 이걸 업캐스팅(upcasting)이라고 불러요 — 자식을 위(부모 방향)로 올려서 본다는 뜻이에요.
Animal a = new Dog(); // ⬆️ 업캐스팅 — Dog 객체를 Animal 타입 변수에 담음
Animal b = new Cat(); // ⬆️ Cat 객체도 Animal 타입으로
(Animal)을 붙이지 않아도 됩니다.Animal a = new Dog();는 그냥 되지만, 반대로 자식 타입에 부모를 담는 다운캐스팅은 자동이 아니에요(뒤 sec-4에서 다룸). 방향을 기억하세요: 위로(자식→부모)는 자동, 아래로(부모→자식)는 수동.
Object o = new Dog();도 되는데, 모든 클래스의 최상위 부모가 Object이기 때문이에요.이제 진짜 신기한 부분이에요. Animal a = new Dog(); 한 뒤 a.sound()를 부르면 뭐가 나올까요? 변수 타입은 Animal인데 말이죠.
Animal a = new Dog();
a.sound(); // 출력: 멍멍 ← Animal의 "어떤 소리를 냅니다"가 아님!
Animal b = new Cat();
b.sound(); // 출력: 야옹
참조 타입(Animal)이 아니라, 실제 객체(Dog)의 오버라이딩된 메서드가 실행돼요. 변수는 Animal이라고 적혀 있지만, 안에 진짜 들어있는 건 Dog 객체니까, Dog가 재정의한 sound()가 불립니다.
Animal a = new Dog();일 때 a로는 Animal에 선언된 메서드만 부를 수 있어요. Dog에만 있는 고유 메서드(예: a.bark())는 컴파일 에러예요. "어떤 메서드를 부를 수 있나"는 변수 타입이 정하고, "어떤 버전이 실행되나"는 실제 객체가 정합니다. 이 둘을 구분하세요!부모 타입 하나로 자식들을 다 담을 수 있으니, 부모 타입 배열에 서로 다른 자식들을 섞어 넣을 수 있어요. 그리고 반복문 한 줄로 돌리면, 각자 자기 방식대로 동작합니다.
Animal[] arr = { new Dog(), new Cat(), new Dog() };
for (Animal a : arr) {
a.sound(); // 같은 a.sound() 한 줄이지만…
}
// 출력:
// 멍멍
// 야옹
// 멍멍
코드에는 a.sound() 딱 한 줄뿐인데, 반복문이 도는 동안 담긴 객체가 바뀌면서 매번 다른 소리가 나와요. if (a instanceof Dog) … else if (a instanceof Cat) … 같은 분기가 전혀 필요 없어요. 동적 바인딩이 알아서 처리하니까요.
makeSound 메서드는 동물의 종류를 몰라도 동작해요. 나중에 Parrot(앵무새) 클래스를 새로 추가해도, 이 메서드는 한 글자도 안 고치고 그대로 씁니다. 이게 다형성이 주는 "확장성"이에요.
static void makeSound(Animal a) { // Animal 자리에 Dog·Cat 무엇이든 OK
a.sound();
}
makeSound(new Dog()); // 멍멍
makeSound(new Cat()); // 야옹
// 훗날 Parrot 을 추가해도 makeSound 는 그대로 재사용!
앞에서 봤듯, Animal a = new Dog();일 때 a로는 Animal 메서드만 부를 수 있어요. 그런데 Dog에만 있는 고유 기능(예: bark())을 써야 한다면? 다시 Dog 타입으로 되돌려야 해요. 이걸 다운캐스팅(downcasting)이라고 합니다 — 부모 타입을 아래(자식 방향)로 내려서 보는 것.
Animal a = new Dog();Dog d = (Dog) a;다운캐스팅은 업캐스팅과 달리 자동이 아니에요. "이 동물이 진짜 강아지가 맞아?"가 보장되지 않으니, 개발자가 명시적으로 캐스트 연산자를 써서 "내가 책임진다"고 알려줘야 합니다.
Animal a = new Dog();
// a.bark(); // ❌ 컴파일 에러 — Animal 타입엔 bark()가 없음
Dog d = (Dog) a; // 🔽 다운캐스팅 — 명시적 캐스트 필요
d.bark(); // ✅ 이제 Dog 고유 메서드 호출 가능
(Dog)로 캐스트하면 실행 중에 터져요. 그래서 다운캐스팅 전에 instanceof로 실제 타입을 확인하는 게 안전해요.객체 instanceof 타입은 그 객체가 해당 타입(또는 자식)이면 true, 아니면 false를 줍니다.
💡 규칙: 확인(instanceof) → 통과하면 다운캐스팅. 이 순서를 지키면 캐스팅 사고를 막을 수 있어요.
Animal a = new Cat();
System.out.println(a instanceof Cat); // true — 실제 객체가 Cat
System.out.println(a instanceof Dog); // false — Cat은 Dog가 아님
System.out.println(a instanceof Animal); // true — Cat은 Animal이기도 함
if (a instanceof Dog) { // false → 블록 안 들어감 (안전!)
Dog d = (Dog) a;
d.bark();
} else {
System.out.println("이 동물은 강아지가 아니에요");
}
Dog d = (Dog) a;처럼 억지로 다른 자식 타입으로 캐스트하면, 컴파일은 통과하지만 실행 중에 ClassCastException이 발생해요. 그래서 instanceof 확인이 중요합니다. 시험에서 "잘못된 다운캐스팅 예외"를 물으면 답은 ClassCastException이에요.instanceof로 확인한 뒤 또 캐스트하는 게 조금 번거로워요. Java 16부터는 패턴 매칭 instanceof로 이걸 한 방에 합칠 수 있어요.
Animal a = new Dog();
// 확인이 true면, 곧바로 d(Dog 타입)에 캐스팅된 값이 담김
if (a instanceof Dog d) {
d.bark(); // 별도 캐스트 없이 바로 사용!
}
instanceof Dog d에서 뒤의 d가 바로 캐스팅까지 끝난 변수예요. 조건이 참일 때만 유효하고, 코드가 짧고 안전해집니다. 최신 자바를 쓴다면 이 형태를 즐겨 쓰게 될 거예요. (지금 단계에선 "이런 것도 있구나" 정도면 충분해요.)
Animal a = new Dog();처럼 캐스트 없이 됩니다.a.sound()는 변수 타입(Animal)이 아니라 실제 객체(Dog)의 오버라이딩 메서드를 실행 → "멍멍".(Dog)a). 잘못하면 ClassCastException. 그래서 instanceof로 먼저 확인하세요.