콘텐츠로 이동

다형성 (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을 구현하지 않음

주의할 점

❌ 잘못된 다운캐스팅

Animal animal = new Cat();
Dog dog = (Dog) animal;  // ❌ ClassCastException

✅ instanceof로 검사:

if (animal instanceof Dog) {
    Dog dog = (Dog) animal;  // 안전
}

⚠️ 필드와 메서드의 바인딩 차이

// 필드는 정적 바인딩
// 메서드는 동적 바인딩

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원칙 흐름 - 상속 — 다형성의 기반 - 추상화 — 인터페이스와 다형성