7.3 다형성 (Polymorphism)
다형성(Polymorphism) 은 하나의 참조 변수로 여러 타입의 객체를 참조할 수 있는 객체지향의 가장 강력한 특징 중 하나입니다. 다형성 덕분에 코드가 유연해지고 변경에 강해집니다. 자바에서 다형성을 이해하려면 크게 두 가지 개념을 알아야 합니다.
- 메서드 오버라이딩 (Overriding): 부모의 메서드를 자식 입맛에 맞게 재정의하는 것
- 참조 변수의 형변환 (Upcasting / Downcasting): 부모 타입의 변수로 자식 객체를 가리키는 것
1. 메서드 오버라이딩 (Overriding)
앞서 상속에서는 부모의 메서드를 물려받아 그대로 쓴다고 했습니다. 그런데 물려받은 기능이 자식의 특성과 맞지 않다면 어떻게 할까요? 그럴 때는 물려받은 메서드의 내용을 자신에게 맞게 재정의 할 수 있습니다. 이를 오버라이딩이라고 합니다.
class Animal {
String name;
Animal(String name) {
this.name = name;
}
void cry() {
System.out.println(name + ": 동물이 울음소리를 냅니다.");
}
void breathe() {
System.out.println(name + ": 숨을 쉽니다.");
}
}
class Dog extends Animal {
Dog(String name) {
super(name);
}
// 부모의 cry() 메서드를 재정의 (오버라이딩)
@Override
void cry() {
System.out.println(name + ": 멍멍!");
}
}
class Cat extends Animal {
Cat(String name) {
super(name);
}
// 부모의 cry() 메서드를 재정의 (오버라이딩)
@Override
void cry() {
System.out.println(name + ": 야옹~");
}
}
class Duck extends Animal {
Duck(String name) {
super(name);
}
@Override
void cry() {
System.out.println(name + ": 꽥꽥!");
}
}
오버라이딩의 조건
- 메서드 이름, 매개변수, 반환 타입이 부모 클래스와 완전히 일치 해야 합니다.
- 부모 클래스의 메서드보다 접근 제어자를 더 좁은 범위로 변경할 수 없습니다. (예: 부모가
public인데 자식이private으로 덮어쓸 수 없음) @Override어노테이션을 붙여주면 컴파일러가 실수로 철자를 틀리지 않았는지 검사해 줍니다.
@Override 어노테이션의 중요성
class Animal {
void cry() { System.out.println("동물 소리"); }
}
class Dog extends Animal {
// @Override 없이 오타 실수
void Cry() { // 대문자 C - 오버라이딩이 아니라 새 메서드가 됨!
System.out.println("멍멍!");
}
}
class Cat extends Animal {
@Override
void Cry() { // 컴파일 에러! "메서드가 오버라이딩하지 않습니다"
System.out.println("야옹~");
}
}
@Override를 붙이면 컴파일러가 실제로 부모 메서드를 오버라이딩하는지 검사합니다. 오타나 시그니처 불일치를 즉시 잡아주므로 실무에서 반드시 사용해야 합니다.
2. 업캐스팅 (Upcasting): 자동 형변환
업캐스팅(Upcasting) 은 자식 타입 객체를 부모 타입 참조 변수에 담는 것입니다. "강아지는 동물이다"처럼 is-a 관계가 성립하므로, 자동으로 변환이 이루어집니다.
// 업캐스팅 - 자동 (명시적 캐스팅 불필요)
Animal a1 = new Dog("바둑이"); // Dog → Animal 업캐스팅
Animal a2 = new Cat("나비"); // Cat → Animal 업캐스팅
Animal a3 = new Duck("도널드"); // Duck → Animal 업캐스팅
// a1은 Animal 타입으로 선언됐지만
// 실제로는 Dog 객체를 참조하고 있음
a1.cry(); // 멍멍! (Dog의 cry() 호출)
a2.cry(); // 야옹~ (Cat의 cry() 호출)
a1.breathe(); // 바둑이: 숨을 쉽니다. (Animal의 breathe() 호출)
- 참조 변수 타입(
Animal a1): 어떤 메서드를 호출할 수 있는지를 결정 (컴파일 타임) - 실제 객체 타입(
new Dog()): 어떤 메서드 구현이 실행될지를 결정 (런타임 - 동적 바인딩)
3. 동적 바인딩 (Dynamic Binding) 원리
자바에서 오버라이딩된 메서드는 런타임에 실제 객체의 타입을 보고 어떤 메서드를 실행할지 결정합니다. 이것이 동적 바인딩(Dynamic Binding)입니다.
public class DynamicBindingDemo {
public static void main(String[] args) {
Animal a = new Dog("바둑이");
// 컴파일러는 Animal 타입이니까 Animal.cry()가 있는지만 확인
// 런타임에는 실제 객체가 Dog이므로 Dog.cry()가 실행됨
a.cry(); // 멍멍!
// a를 다른 객체로 교체
a = new Cat("나비");
a.cry(); // 야옹~ (같은 코드 a.cry()인데 결과가 다름!)
}
}
이것이 다형성의 핵심입니다. 같은 a.cry() 코드가 a가 참조하는 객체에 따라 다른 결과를 냅니다.
4. 다운캐스팅 (Downcasting): 강제 형변환
업캐스팅과 반대로, 부모 타입 참조 변수를 자식 타입으로 변환하는 것을 다운캐스팅(Downcasting) 이라고 합니다. 반드시 명시적으로 타입을 지정해야 하며, 잘못하면 ClassCastException이 발생합니다.
Animal a = new Dog("바둑이"); // 업캐스팅
// 다운캐스팅 - 명시적 캐스팅 필요
Dog d = (Dog) a;
d.cry(); // 멍멍! (Dog 전용 메서드 사용 가능)
// 위험한 다운캐스팅
Animal a2 = new Cat("나비");
Dog d2 = (Dog) a2; // 런타임 에러! ClassCastException
// Cat 객체는 Dog로 변환 불가
ClassCastException 방지: instanceof 연산자
public static void makeSound(Animal a) {
// 다운캐스팅 전에 instanceof로 타입을 확인
if (a instanceof Dog) {
Dog d = (Dog) a;
d.cry();
System.out.println("강아지 특수 행동: 꼬리 흔들기");
} else if (a instanceof Cat) {
Cat c = (Cat) a;
c.cry();
System.out.println("고양이 특수 행동: 그루밍");
} else {
a.cry();
}
}
Java 16+ Pattern Matching instanceof
Java 16부터는 instanceof 검사와 캐스팅을 한 줄에 처리하는 패턴 매칭 문법을 사용할 수 있습니다.
public static void makeSound(Animal a) {
// 패턴 매칭: instanceof 검사 + 변수 선언을 동시에
if (a instanceof Dog d) {
// 이 블록 안에서 d는 이미 Dog 타입
d.cry();
System.out.println("강아지 특수 행동: 꼬리 흔들기");
} else if (a instanceof Cat c) {
c.cry();
System.out.println("고양이 특수 행동: 그루밍");
} else if (a instanceof Duck du) {
du.cry();
System.out.println("오리 특수 행동: 수영");
} else {
a.cry();
}
}
5. 다형성의 실제 활용: 배열과 리스트
다형성의 가장 큰 장점은 서로 다른 타입의 객체를 하나의 배열이나 리스트로 관리할 수 있다는 점입니다.
import java.util.ArrayList;
import java.util.List;
public class PolymorphismExample {
public static void main(String[] args) {
// 부모 타입 배열로 여러 자손 객체를 한 번에 관리
Animal[] animals = {
new Dog("바둑이"),
new Cat("나비"),
new Duck("도널드"),
new Dog("흰둥이"),
new Cat("고미")
};
System.out.println("=== 모든 동물 울음소리 ===");
for (Animal a : animals) {
a.cry(); // 동적 바인딩: 각 객체의 실제 cry()가 호출됨
}
// List를 사용해도 동일
List<Animal> animalList = new ArrayList<>();
animalList.add(new Dog("레오"));
animalList.add(new Cat("미미"));
animalList.add(new Duck("꽥이"));
System.out.println("\n=== 리스트의 모든 동물 ===");
animalList.forEach(Animal::cry); // 메서드 참조
}
}
[실행 결과]
=== 모든 동물 울음소리 ===
바둑이: 멍멍!
나비: 야옹~
도널드: 꽥꽥!
흰둥이: 멍멍!
고미: 야옹~
=== 리스트의 모든 동물 ===
레오: 멍멍!
미미: 야옹~
꽥이: 꽥꽥!
새로운 동물 Parrot이 추가되더라도 기존 반복문 코드는 수정이 전혀 없습니다. 이것이 다형성의 진정한 위력입니다.
6. 다형성 + 인터페이스: 의존성 역전 원칙 (DIP)
다형성은 인터페이스와 결합할 때 더욱 강력해집니다. 고수준 모듈이 저수준 구현에 직접 의존하는 대신, 인터페이스(추상화)에 의존하도록 만드는 것이 의존성 역전 원칙(Dependency Inversion Principle, DIP) 입니다.
// 인터페이스 (추상화)
interface Payable {
void pay(double amount);
String getPaymentMethod();
}
// 구체적인 구현들
class CreditCard implements Payable {
private String cardNumber;
CreditCard(String cardNumber) {
this.cardNumber = cardNumber;
}
@Override
public void pay(double amount) {
System.out.printf("[신용카드 %s] %.0f원 결제%n", cardNumber, amount);
}
@Override
public String getPaymentMethod() { return "신용카드"; }
}
class KakaoPay implements Payable {
private String phoneNumber;
KakaoPay(String phoneNumber) {
this.phoneNumber = phoneNumber;
}
@Override
public void pay(double amount) {
System.out.printf("[카카오페이 %s] %.0f원 결제%n", phoneNumber, amount);
}
@Override
public String getPaymentMethod() { return "카카오페이"; }
}
class NaverPay implements Payable {
@Override
public void pay(double amount) {
System.out.printf("[네이버페이] %.0f원 결제%n", amount);
}
@Override
public String getPaymentMethod() { return "네이버페이"; }
}
// 고수준 모듈: 인터페이스에만 의존 (구현체를 모름)
class CheckoutService {
// Payable 인터페이스 타입으로 선언 - 어떤 결제 방식이 와도 동작
public void checkout(Payable paymentMethod, double amount) {
System.out.println("결제 방식: " + paymentMethod.getPaymentMethod());
paymentMethod.pay(amount);
System.out.println("결제 완료!\n");
}
}
public class PaymentDemo {
public static void main(String[] args) {
CheckoutService service = new CheckoutService();
// 어떤 결제 방식이든 CheckoutService 코드 변경 없이 사용 가능
service.checkout(new CreditCard("1234-****"), 50_000);
service.checkout(new KakaoPay("010-1234-5678"), 12_000);
service.checkout(new NaverPay(), 30_000);
}
}
[실행 결과]
결제 방식: 신용카드
[신용카드 1234-****] 50000원 결제
결제 완료!
결제 방식: 카카오페이
[카카오페이 010-1234-5678] 12000원 결제
결제 완료!
결제 방식: 네이버페이
[네이버페이] 30000원 결제
결제 완료!
7. 실전 예제: 도형 계산기
Shape 추상 클래스를 부모로 삼아 Circle, Rectangle, Triangle을 구현하고, 다형성으로 한꺼번에 처리하는 도형 계산기를 만들어 봅니다.
// 추상 클래스 - Shape
abstract class Shape {
private String color;
Shape(String color) {
this.color = color;
}
// 추상 메서드: 자식이 반드시 구현해야 함
public abstract double area();
public abstract double perimeter();
// 공통 메서드: 모든 자식이 공유
public String getColor() { return color; }
public void printInfo() {
System.out.printf("[%s] 색상: %s, 넓이: %.2f, 둘레: %.2f%n",
getClass().getSimpleName(), color, area(), perimeter());
}
}
// 원
class Circle extends Shape {
private double radius;
Circle(String color, double radius) {
super(color);
this.radius = radius;
}
@Override
public double area() {
return Math.PI * radius * radius;
}
@Override
public double perimeter() {
return 2 * Math.PI * radius;
}
}
// 직사각형
class Rectangle extends Shape {
private double width;
private double height;
Rectangle(String color, double width, double height) {
super(color);
this.width = width;
this.height = height;
}
@Override
public double area() {
return width * height;
}
@Override
public double perimeter() {
return 2 * (width + height);
}
}
// 삼각형
class Triangle extends Shape {
private double base;
private double height;
private double sideA;
private double sideB;
Triangle(String color, double base, double height, double sideA, double sideB) {
super(color);
this.base = base;
this.height = height;
this.sideA = sideA;
this.sideB = sideB;
}
@Override
public double area() {
return 0.5 * base * height;
}
@Override
public double perimeter() {
return base + sideA + sideB;
}
}
import java.util.ArrayList;
import java.util.List;
public class ShapeCalculator {
public static void main(String[] args) {
// 다형성: Shape 타입 리스트에 여러 도형 담기
List<Shape> shapes = new ArrayList<>();
shapes.add(new Circle("빨강", 5.0));
shapes.add(new Rectangle("파랑", 4.0, 6.0));
shapes.add(new Triangle("초록", 3.0, 4.0, 4.0, 5.0));
shapes.add(new Circle("노랑", 3.0));
shapes.add(new Rectangle("보라", 10.0, 2.0));
System.out.println("=== 도형 목록 ===");
for (Shape s : shapes) {
s.printInfo(); // 다형성: 각 도형에 맞는 area(), perimeter() 호출
}
// 총 넓이 계산
double totalArea = shapes.stream()
.mapToDouble(Shape::area)
.sum();
System.out.printf("%n총 넓이 합계: %.2f%n", totalArea);
// 가장 넓은 도형 찾기
Shape largest = shapes.stream()
.max((a, b) -> Double.compare(a.area(), b.area()))
.orElseThrow();
System.out.println("가장 넓은 도형: " + largest.getClass().getSimpleName()
+ " (넓이: " + String.format("%.2f", largest.area()) + ")");
// instanceof 패턴 매칭으로 특정 타입만 처리
System.out.println("\n=== 원만 출력 ===");
for (Shape s : shapes) {
if (s instanceof Circle c) {
System.out.printf("원: 넓이=%.2f%n", c.area());
}
}
}
}
[실행 결과]
=== 도형 목록 ===
[Circle] 색상: 빨강, 넓이: 78.54, 둘레: 31.42
[Rectangle] 색상: 파랑, 넓이: 24.00, 둘레: 20.00
[Triangle] 색상: 초록, 넓이: 6.00, 둘레: 12.00
[Circle] 색상: 노랑, 넓이: 28.27, 둘레: 18.85
[Rectangle] 색상: 보라, 넓이: 20.00, 둘레: 24.00
총 넓이 합계: 156.81
가장 넓은 도형: Circle (넓이: 78.54)
=== 원만 출력 ===
원: 넓이=78.54
원: 넓이=28.27
마치며
다형성을 정리하면 다음과 같습니다.
| 개념 | 설명 | 핵심 |
|---|---|---|
| 오버라이딩 | 부모 메서드를 자식이 재정의 | @Override 필수 |
| 업캐스팅 | 자식 → 부모 참조 변수 | 자동 변환, 안전 |
| 다운캐스팅 | 부모 → 자식 참조 변수 | 명시적 캐스팅, instanceof 확인 필요 |
| 동적 바인딩 | 런타임에 실제 객체 메서드 호출 | 다형성의 핵심 원리 |
| DIP | 인터페이스에 의존 | 확장에 열려있고 변경에 닫혀있음 |
다형성은 Spring Boot의 의존성 주입(@Autowired), 전략 패턴(Strategy Pattern), 템플릿 메서드 패턴 등 현업에서 매일 만나는 핵심 원리입니다.