다형성 (Polymorphism)
왜 쓰는지
같은 메서드이지만 객체에 따라 다른 동작을 하면:
- 새로운 동물이 추가되어도 기존 코드 변경 불필요
- 결제 수단이 추가되어도 checkout() 메서드는 그대로
- 데이터베이스가 MySQL에서 PostgreSQL로 바뀌어도 로직 변경 불필요
핵심: 다형성은 부모(또는 인터페이스) 타입으로 자식 객체를 다루되, 실제 객체의 메서드를 호출하는 것입니다. OCP(Open-Closed Principle)의 실현입니다.
어떻게 쓰는지
기본 다형성: 부모 타입으로 자식 다루기
// 부모 클래스
public class Animal {
public void sound() {
System.out.println("동물이 울음");
}
}
// 자식 클래스들
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
}
public class Cat extends Animal {
@Override
public void sound() {
System.out.println("야옹");
}
}
// 다형성 활용
public static void main(String[] args) {
// 부모 타입으로 자식 객체 참조
Animal dog = new Dog(); // Dog is-a Animal
Animal cat = new Cat(); // Cat is-a Animal
dog.sound(); // "멍멍" (Dog의 메서드 호출)
cat.sound(); // "야옹" (Cat의 메서드 호출)
// 배열/컬렉션에서 강력함
List<Animal> animals = Arrays.asList(
new Dog(),
new Cat(),
new Dog(),
new Cat()
);
for (Animal animal : animals) {
animal.sound(); // 각 객체의 메서드 호출
}
}
다형성의 진가: 코드 변경 없이 확장
// 기존 코드
public void animalShow(Animal animal) {
animal.sound();
}
// 나중에 새로운 동물이 추가되어도 이 메서드는 수정 불필요
public class Bird extends Animal {
@Override
public void sound() {
System.out.println("짹짹");
}
}
Bird bird = new Bird();
animalShow(bird); // "짹짹" 출력 (메서드 수정 없음!)
인터페이스와 다형성
// 인터페이스 (역할만 정의)
public interface PaymentMethod {
void pay(int amount);
}
// 구현들
public class CreditCard implements PaymentMethod {
@Override
public void pay(int amount) {
System.out.println("신용카드 결제: " + amount + "원");
}
}
public class PhonePay implements PaymentMethod {
@Override
public void pay(int amount) {
System.out.println("휴대폰 결제: " + amount + "원");
}
}
// 다형성으로 모든 결제 수단을 동일하게 처리
public class ShoppingCart {
private PaymentMethod paymentMethod;
public ShoppingCart(PaymentMethod paymentMethod) {
this.paymentMethod = paymentMethod;
}
public void checkout(int totalAmount) {
paymentMethod.pay(totalAmount);
}
}
// 사용
PaymentMethod card = new CreditCard();
PaymentMethod phone = new PhonePay();
ShoppingCart cart1 = new ShoppingCart(card);
ShoppingCart cart2 = new ShoppingCart(phone);
cart1.checkout(50000); // "신용카드 결제: 50000원"
cart2.checkout(50000); // "휴대폰 결제: 50000원"
업캐스팅 vs 다운캐스팅
// 업캐스팅: 자식 → 부모 (자동, 안전)
Dog dog = new Dog();
Animal animal = dog; // 자동 변환
// 다운캐스팅: 부모 → 자식 (수동, 위험)
Animal animal = new Dog();
Dog dog = (Dog) animal; // 명시적 캐스팅 필요
// 실수: 잘못된 다운캐스팅
Animal animal = new Cat();
Dog dog = (Dog) animal; // ❌ ClassCastException 발생!
// ✅ 안전한 방식: instanceof로 검사
if (animal instanceof Dog) {
Dog dog = (Dog) animal;
dog.bark();
}
언제 쓰는지
| 상황 | 선택 | 이유 |
|---|---|---|
| 구현이 자주 변함 | ✅ 다형성 | 기존 코드 수정 최소화 |
| 여러 구현 존재 | ✅ 다형성 | 동일한 인터페이스로 처리 |
| 확장성 필요 | ✅ 다형성 | 새 구현 추가 시 코드 변경 불필요 |
| 테스트 용이 | ✅ 다형성 | Mock 객체로 쉽게 대체 |
| 단순한 경우 | ❌ 구체 클래스 | 과설계 방지 |
장점
| 장점 | 설명 |
|---|---|
| 유연성 | 구현을 바꿔도 호출 코드 변경 불필요 |
| 확장성 | 새로운 구현 추가가 용이 |
| 코드 재사용 | 하나의 메서드가 여러 타입 처리 |
| 유지보수 | 변경 포인트 명확화 |
| 테스트 용이 | Mock 객체로 단위 테스트 |
단점
| 단점 | 설명 |
|---|---|
| 복잡도 | 실행 시 어느 메서드가 호출되는지 파악 어려움 |
| 성능 | 동적 바인딩으로 메서드 호출 비용 발생 |
| 오버헤드 | 캐스팅, instanceof 체크 비용 |
| 학습곡선 | 개념 이해 필요 |
특징
1. 동적 바인딩 (Dynamic Binding)
public class Animal {
public void sound() {
System.out.println("동물 소리");
}
public String name = "동물"; // 필드
}
public class Dog extends Animal {
@Override
public void sound() {
System.out.println("멍멍");
}
public String name = "개"; // 필드 재정의
}
Animal animal = new Dog();
// 메서드: 동적 바인딩 (실제 객체 기준)
animal.sound(); // "멍멍" (Dog의 메서드 호출)
// 필드: 정적 바인딩 (참조 타입 기준)
System.out.println(animal.name); // "동물" (Animal의 필드)
2. 메서드 오버라이딩 (Method Overriding)
// 부모
public class Parent {
public void display() {
System.out.println("부모");
}
}
// 자식: 메서드 오버라이딩
public class Child extends Parent {
@Override
public void display() {
System.out.println("자식");
}
}
Parent obj = new Child();
obj.display(); // "자식" (실제 객체의 메서드)
3. 메서드 오버로딩 (Method Overloading)
// 같은 이름, 다른 파라미터 → 컴파일 타임에 결정
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
public int add(int a, int b, int c) {
return a + b + c;
}
}
Calculator calc = new Calculator();
calc.add(5, 3); // int 버전
calc.add(5.5, 3.3); // double 버전
calc.add(5, 3, 2); // int 3개 버전
4. 리스코프 치환 원칙 (Liskov Substitution Principle)
// 잘못된 오버라이딩
public class Bird {
public void fly() {
System.out.println("날아다닙니다");
}
}
public class Penguin extends Bird {
@Override
public void fly() {
throw new UnsupportedOperationException("펭귄은 날 수 없습니다"); // ❌
}
}
// 문제: Bird를 기대하는 곳에 Penguin을 넣으면 예외 발생
public void letFly(Bird bird) {
bird.fly(); // Penguin이면 예외!
}
// ✅ 올바른 설계
public interface Flyable {
void fly();
}
public class Bird implements Flyable {
@Override
public void fly() {
System.out.println("날아다닙니다");
}
}
// Penguin은 Flyable을 구현하지 않음
주의할 점
❌ 잘못된 다운캐스팅
✅ instanceof로 검사:
⚠️ 필드와 메서드의 바인딩 차이
// 필드는 정적 바인딩
// 메서드는 동적 바인딩
public class Parent {
public String field = "parent";
public void method() { System.out.println("parent"); }
}
public class Child extends Parent {
public String field = "child";
@Override
public void method() { System.out.println("child"); }
}
Parent obj = new Child();
obj.field; // "parent" (정적 바인딩)
obj.method(); // "child" (동적 바인딩)
💡 다형성 활용 패턴
// 팩토리 패턴
public class AnimalFactory {
public static Animal createAnimal(String type) {
return switch(type) {
case "dog" -> new Dog();
case "cat" -> new Cat();
default -> new Animal();
};
}
}
// 전략 패턴
public class PaymentProcessor {
private PaymentStrategy strategy;
public PaymentProcessor(PaymentStrategy strategy) {
this.strategy = strategy;
}
public void processPayment(int amount) {
strategy.pay(amount);
}
}
베스트 프랙티스
| 권장 방식 | 이유 |
|---|---|
| 인터페이스나 부모 타입으로 선언 | 호출 코드가 구체 구현에 덜 묶임 |
instanceof 분기보다 다형성 우선 |
타입별 조건문이 늘어나는 것을 줄일 수 있음 |
| 구현체는 생성자 주입으로 받기 | 런타임 교체와 테스트 대체가 쉬움 |
| 인터페이스는 역할 단위로 작게 분리 | 구현 클래스가 불필요한 메서드까지 알 필요가 없음 |
| 다운캐스팅은 마지막 수단 | 부모 타입으로 다룬다는 다형성의 장점이 사라짐 |
| 리스코프 치환 원칙 확인 | 부모 타입을 기대하는 코드가 자식 타입에서도 깨지지 않아야 함 |
타입별 if, switch가 계속 늘어난다면 다형성 후보입니다.
새 타입을 추가할 때마다 기존 조건문을 수정해야 한다면, 공통 인터페이스를 만들고 각 구현체가 자기 행동을 수행하게 하는 편이 낫습니다.
실무에서는?
| 위치 | 다형성 활용 |
|---|---|
| Spring DI | 인터페이스 타입으로 의존하고 실제 구현체는 컨테이너가 주입 |
| 결제/알림 전략 | 카드, 계좌이체, 카카오 알림, 이메일 알림을 같은 호출로 처리 |
| Repository 구현체 | 메모리, JDBC, JPA 구현을 같은 계약으로 교체 |
| 테스트 코드 | 실제 외부 API 대신 Fake 구현을 넣어 빠르게 검증 |
| 컬렉션 프레임워크 | List 타입으로 선언하고 ArrayList, LinkedList 구현체를 선택 |
| 플러그인 구조 | 새 구현체를 추가해 기능을 확장하되 호출 코드는 유지 |
정리
| 항목 | 설명 |
|---|---|
| 다형성 | 부모 타입으로 자식 객체를 다루기 |
| 오버라이딩 | 런타임(동적) 결정 |
| 오버로딩 | 컴파일타임(정적) 결정 |
| 업캐스팅 | 자식 → 부모 (자동) |
| 다운캐스팅 | 부모 → 자식 (수동, 위험) |
| 동적 바인딩 | 메서드는 동적, 필드는 정적 |
관련 파일: - OOP 개요 — 객체지향 4원칙 흐름 - 상속 — 다형성의 기반 - 추상화 — 인터페이스와 다형성