🎯 1주차 Unit 2.4 — 다형성 (Polymorphism) ★★★

Psj·2026년 5월 7일

F-lab

목록 보기
26/230

🎯 Unit 2.4 — 다형성 (Polymorphism) ★★★

F-lab Java 1주차 / Phase 2 / Unit 2.4 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.

선수 지식: Unit 2.3 (상속과 생성자 체이닝)
다음 Unit: 2.5 — instanceof와 형변환

이 Unit의 의미: OOP 4대 원칙의 정점. 면접에서 가장 자주 묻는 영역.
다형성을 정확히 이해하면 Spring DI, JPA, Strategy 패턴 등 모든 현대 자바의 토대 가 잡힌다.


🌍 1. 세상 속 비유

다형성 = "같은 명령, 다른 반응"

선생님이 교실에서 외칩니다:

"모두 인사하세요!"

학생들의 반응:

  • 민수 (한국인): "안녕하세요" + 90도 인사
  • John (미국인): "Hello!" + 손 흔들기
  • Yuki (일본인): "こんにちは" + 깊은 절
  • Ahmed (아랍인): "السلام عليكم" + 가슴에 손

같은 명령 ("인사하세요") 인데 다른 반응 이 나옵니다.

핵심:

  • 선생님은 학생들의 국적을 일일이 확인하지 않음
  • 그냥 "인사하세요" 라고만 함
  • 각 학생이 자기 방식대로 인사

이게 다형성 — 같은 메시지에 객체마다 다른 응답.


더 일상적인 비유 — "TV 리모컨"

당신이 호텔 방의 리모컨을 봅니다. 리모컨에는 "전원" 버튼 이 있어요.

이 호텔에는 다양한 TV가 있습니다:

  • LG TV
  • 삼성 TV
  • 소니 TV
  • 옛날 브라운관 TV

당신이 "전원" 버튼을 누르면:

  • 어떤 TV든 켜지거나 꺼짐
  • 내부 작동은 다 다르지만 결과는 같음

당신이 TV 회사를 알아야 하나요? 아닙니다. 그냥 누르면 됨.

당신: 리모컨.전원()  // ← 같은 명령
        ↓
TV가 자기 방식대로 처리:
- LG: LCD 방식으로 켜짐
- 삼성: OLED 방식으로 켜짐
- 소니: HDR 방식으로 켜짐
- 브라운관: 진공관 방식으로 켜짐

다형성의 본질.


핵심 정리 — "한 단어, 여러 의미"

다형성(Polymorphism) = 그리스어 어원

  • Poly (폴리) = "여러"
  • Morph (모프) = "형태"
  • → "여러 형태"

자바에서:

"같은 메서드 호출이 객체에 따라 다른 동작을 하는 것"

비유 요소자바 다형성
"인사하세요" 명령메서드 호출
학생들다양한 객체
한국식/미국식/일본식 인사오버라이드된 메서드
선생님이 국적 모름컴파일 시 타입 모름
학생이 알아서 인사런타임에 객체가 결정

🔥 2. 탄생 배경

다형성 없이 — if 지옥의 시대

상속(Unit 2.3) 만 있고 다형성이 없다면, 객체별 처리를 하려면 매번 if 분기를 써야 합니다.

// 다형성 없이 — 매번 타입 확인
public void printSound(Animal animal) {
    if (animal instanceof Dog) {
        Dog dog = (Dog) animal;
        System.out.println(dog.bark());
    } else if (animal instanceof Cat) {
        Cat cat = (Cat) animal;
        System.out.println(cat.meow());
    } else if (animal instanceof Cow) {
        Cow cow = (Cow) animal;
        System.out.println(cow.moo());
    } else if (animal instanceof Duck) {
        Duck duck = (Duck) animal;
        System.out.println(duck.quack());
    }
    // 새 동물 추가 시 또 else if ❌
}

문제:

  • 새 동물 (Pig) 추가 시 → 모든 함수 수정
  • 100개 동물 종류 → 100개 분기
  • 새 동물의 메서드 이름 (oink()) 까지 알아야 함
  • OOP 가 아니라 절차지향 으로 회귀

다형성의 등장 — Smalltalk-80 (1980)

객체지향의 본격적 정립은 Smalltalk-80 (1980) 에서 이루어졌습니다. Smalltalk가 강조한 핵심 개념:

"같은 메시지를 보내면, 받는 객체가 알아서 처리한다"

// 다형성 활용 — if 사라짐
public void printSound(Animal animal) {
    System.out.println(animal.makeSound());  // ← 객체가 알아서!
}

// 새 동물 추가해도 이 코드는 안 바뀜 ✅

필요한 것:

  • 모든 동물이 makeSound() 메서드를 가짐 (인터페이스 통일)
  • 각 동물이 자기 방식으로 구현 (오버라이드)
  • Animal 타입으로 다루기만 하면 됨

자바의 채택

자바가 1995년 등장 시 다형성을 1급 시민으로 도입했습니다. 그 결과:

Spring 생태계의 토대:

List<UserRepository> repos;  // 어떤 구현체든 OK
NotificationService service;  // 인터페이스만 알면 끝

JPA:

@Entity 클래스를 다형적으로 처리 (Inheritance Strategies)

디자인 패턴:

  • Strategy Pattern
  • Template Method
  • Factory
  • 거의 모든 GoF 패턴이 다형성 활용

현대 자바 = 다형성 위에 세워진 거대한 탑.


핵심 통찰 ⭐

"다형성이 없으면 객체지향은 절차지향과 다를 게 없다."

캡슐화 + 상속만 있어도 데이터를 보호하고 코드를 재사용할 수 있지만, 새 종류 추가 시마다 기존 코드를 수정해야 한다.

다형성이 있어야 "기존 코드를 안 건드리고 확장" 이 가능 — 이게 SOLID의 OCP (개방-폐쇄 원칙) 의 핵심.


💣 3. 없으면 생기는 문제

다형성이 없을 때의 구체적 문제를 ILIC 시나리오로 보겠습니다.

시나리오: ILIC 운임 등급별 할인 처리

ILIC가 다양한 고객 등급별로 다른 할인율을 적용한다고 합시다:

  • 일반 고객 (NORMAL): 할인 없음
  • VIP 고객: 20% 할인
  • 파트너 (PARTNER): 30% 할인
  • 학생 (STUDENT): 15% 할인

다형성 없이 — if 지옥 ❌

public class Customer {
    private String level;  // "NORMAL", "VIP", "PARTNER", "STUDENT"
    private String name;
    private String email;
    
    public String getLevel() { return level; }
}

public class FareService {
    
    // 할인 계산 — if 지옥
    public int calculateDiscount(Customer customer, int amount) {
        if (customer.getLevel().equals("NORMAL")) {
            return 0;
        } else if (customer.getLevel().equals("VIP")) {
            return amount * 20 / 100;
        } else if (customer.getLevel().equals("PARTNER")) {
            return amount * 30 / 100;
        } else if (customer.getLevel().equals("STUDENT")) {
            return amount * 15 / 100;
        }
        return 0;
    }
    
    // 알림 메시지 — 또 if 지옥
    public String getNotificationGreeting(Customer customer) {
        if (customer.getLevel().equals("NORMAL")) {
            return "안녕하세요";
        } else if (customer.getLevel().equals("VIP")) {
            return "VIP 고객님";
        } else if (customer.getLevel().equals("PARTNER")) {
            return "파트너님";
        } else if (customer.getLevel().equals("STUDENT")) {
            return "학생 고객님";
        }
        return "고객님";
    }
    
    // 추가 혜택 가능 여부 — 또또 if 지옥
    public boolean canApplyExtraBenefit(Customer customer) {
        if (customer.getLevel().equals("VIP")) return true;
        if (customer.getLevel().equals("PARTNER")) return true;
        return false;
    }
    
    // 멤버십 포인트 적립률 — 또또또 if 지옥
    public double getPointAccumulationRate(Customer customer) {
        if (customer.getLevel().equals("VIP")) return 0.05;
        if (customer.getLevel().equals("PARTNER")) return 0.10;
        if (customer.getLevel().equals("STUDENT")) return 0.03;
        return 0.01;
    }
    
    // ... 등급 관련 메서드 30개 모두 if 지옥 ❌
}

새 등급 (PLATINUM) 추가 시 — 30개 메서드 다 수정 ❌

public int calculateDiscount(Customer customer, int amount) {
    if (...) return 0;
    else if (...) return amount * 20 / 100;
    else if (...) return amount * 30 / 100;
    else if (...) return amount * 15 / 100;
    else if (customer.getLevel().equals("PLATINUM")) {  // ← 추가
        return amount * 25 / 100;
    }
    return 0;
}

public String getNotificationGreeting(Customer customer) {
    // ... 기존 분기들 ...
    else if (customer.getLevel().equals("PLATINUM")) {  // ← 또 추가
        return "플래티넘 고객님";
    }
    return "고객님";
}

// 30개 메서드에 모두 PLATINUM 분기 추가 ❌
// 한 군데라도 빠뜨리면 버그 ❌
// 새 개발자 입장에서 "PLATINUM 추가하려면 어디 어디 수정?" 추적 불가 ❌

문제 정리:
1. 수정 영역 폭발 — 1개 추가에 30개 수정
2. 버그 위험 — 한 곳 빠뜨림
3. 추적 불가 — "이 등급 관련 로직이 어디 있나?"
4. 테스트 폭증 — 30개 메서드 × 5개 등급 = 150개 케이스
5. OCP 위반 — "확장에는 열려있고, 수정에는 닫혀있어야" 의 정반대


다형성 활용 — 깔끔한 해결 ✅

// 부모 — 추상 클래스
public abstract class Customer {
    protected String name;
    protected String email;
    
    // 추상 메서드 — 자식이 반드시 구현
    public abstract int calculateDiscountRate();
    public abstract String getNotificationGreeting();
    public abstract boolean canApplyExtraBenefit();
    public abstract double getPointAccumulationRate();
    
    // 공통 행동
    public int calculateDiscount(int amount) {
        return amount * calculateDiscountRate() / 100;
    }
}

// 각 등급 — 자기 방식대로 구현
public class NormalCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 0; }
    @Override public String getNotificationGreeting() { return "안녕하세요"; }
    @Override public boolean canApplyExtraBenefit() { return false; }
    @Override public double getPointAccumulationRate() { return 0.01; }
}

public class VipCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 20; }
    @Override public String getNotificationGreeting() { return "VIP 고객님"; }
    @Override public boolean canApplyExtraBenefit() { return true; }
    @Override public double getPointAccumulationRate() { return 0.05; }
}

public class PartnerCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 30; }
    @Override public String getNotificationGreeting() { return "파트너님"; }
    @Override public boolean canApplyExtraBenefit() { return true; }
    @Override public double getPointAccumulationRate() { return 0.10; }
}

public class StudentCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 15; }
    @Override public String getNotificationGreeting() { return "학생 고객님"; }
    @Override public boolean canApplyExtraBenefit() { return false; }
    @Override public double getPointAccumulationRate() { return 0.03; }
}

Service — if 지옥 사라짐:

public class FareService {
    
    public int calculateDiscount(Customer customer, int amount) {
        return customer.calculateDiscount(amount);  // ← 객체에 위임
    }
    
    public String getNotificationGreeting(Customer customer) {
        return customer.getNotificationGreeting();  // ← 객체에 위임
    }
    
    public boolean canApplyExtraBenefit(Customer customer) {
        return customer.canApplyExtraBenefit();  // ← 객체에 위임
    }
    
    public double getPointAccumulationRate(Customer customer) {
        return customer.getPointAccumulationRate();  // ← 객체에 위임
    }
}

새 등급 (PLATINUM) 추가 — 새 클래스 1개만 ✅

public class PlatinumCustomer extends Customer {
    @Override public int calculateDiscountRate() { return 25; }
    @Override public String getNotificationGreeting() { return "플래티넘 고객님"; }
    @Override public boolean canApplyExtraBenefit() { return true; }
    @Override public double getPointAccumulationRate() { return 0.08; }
}

// 끝! ✅
// 기존 FareService 코드는 한 줄도 안 바뀜
// 사용 시:
Customer platinum = new PlatinumCustomer();
fareService.calculateDiscount(platinum, 10000);  // 자동으로 PlatinumCustomer.calculateDiscount() 호출

효과 비교

항목다형성 없이다형성 활용
새 등급 추가 시 수정30개 메서드1개 클래스
버그 위험매우 높음낮음
코드 추적어려움쉬움 (각 등급 클래스만 보면 됨)
테스트150개 케이스등급별 + Service 별도
OCP 준수
새 개발자 온보딩지옥명확

이게 다형성의 진짜 가치.


✅ 4. 해결책 — 다형성의 작동 방식

핵심 문법 — "부모 타입으로 자식 객체 받기" ⭐

부모타입 변수 = new 자식타입();
변수.메서드();  // 자식의 메서드가 호출됨

예시:

Animal animal = new Dog();   // ✅ 부모 타입 = 자식 객체
animal.makeSound();          // "멍멍!" — Dog의 메서드

왜 이게 가능한가?:

  • DogAnimal 의 한 종류 (is-a 관계)
  • 모든 DogAnimal 의 약속 (메서드 시그니처) 을 지킴
  • 그래서 Animal 타입 변수에 담을 수 있음

다형성이 가능한 3가지 형태 ⭐

1. 클래스 상속

public abstract class Animal { public abstract void makeSound(); }
public class Dog extends Animal { @Override public void makeSound() { ... } }

Animal a = new Dog();  // ✅

2. 인터페이스 구현

public interface Drawable { void draw(); }
public class Circle implements Drawable { @Override public void draw() { ... } }
public class Square implements Drawable { @Override public void draw() { ... } }

Drawable d = new Circle();  // ✅
Drawable d2 = new Square();  // ✅

현대 자바에서 가장 많이 쓰는 방식.

3. 추상 클래스 + 구현

public abstract class Shape {
    public abstract double area();
}

public class Circle extends Shape {
    @Override public double area() { ... }
}

Shape s = new Circle();  // ✅

"리스트로 다양한 타입 받기" — 가장 강력한 활용

List<Animal> animals = new ArrayList<>();
animals.add(new Dog());
animals.add(new Cat());
animals.add(new Cow());
animals.add(new Duck());

for (Animal animal : animals) {
    animal.makeSound();  // 각자 자기 방식으로
}

// 출력:
// 멍멍!
// 야옹~
// 음매
// 꽥꽥

타입을 일일이 신경 쓰지 않고 통일된 처리.


메서드 매개변수의 다형성 ⭐

public class AnimalShelter {
    public void care(Animal animal) {  // ← Animal 타입
        animal.makeSound();
        animal.eat();
        animal.sleep();
    }
}

AnimalShelter shelter = new AnimalShelter();
shelter.care(new Dog());     // ✅
shelter.care(new Cat());     // ✅
shelter.care(new Penguin()); // ✅ — 새 동물도 OK

AnimalShelter 코드 한 줄도 안 건드리고 새 동물 처리.


메서드 반환의 다형성

public class AnimalFactory {
    public static Animal create(String type) {
        switch (type) {
            case "dog": return new Dog();
            case "cat": return new Cat();
            default: throw new IllegalArgumentException();
        }
    }
}

Animal a = AnimalFactory.create("dog");
a.makeSound();  // "멍멍!"

→ Factory 패턴의 토대.


🏗️ 5. 내부 동작 원리

핵심 메커니즘 — 동적 바인딩 (Dynamic Binding) ⭐⭐⭐

바인딩(Binding):

"메서드 호출과 실제 메서드 코드를 연결하는 것"

두 종류:

정적 바인딩 (Static Binding) — 컴파일 시 결정

  • 어느 메서드를 호출할지 컴파일러가 미리 결정
  • 예: static 메서드, private 메서드, final 메서드, 메서드 오버로딩

동적 바인딩 (Dynamic Binding) — 런타임에 결정 ⭐

  • 어느 메서드를 호출할지 JVM이 실행 중에 결정
  • 예: 인스턴스 메서드 (오버라이드된 것)

동적 바인딩의 메커니즘 — VMT (Virtual Method Table) ⭐⭐

JVM은 각 클래스마다 VMT (Virtual Method Table) 를 가집니다.

public class Animal {
    public void makeSound() { System.out.println("동물 소리"); }
    public void eat() { System.out.println("먹다"); }
}

public class Dog extends Animal {
    @Override
    public void makeSound() { System.out.println("멍멍!"); }
    public void wagTail() { System.out.println("꼬리 흔들기"); }
}

JVM이 만드는 VMT:

Animal의 VMT:
[0] makeSound → Animal.makeSound
[1] eat       → Animal.eat
[2] toString  → Object.toString
[3] equals    → Object.equals
...

Dog의 VMT (Animal 상속 + 오버라이드 + 추가):
[0] makeSound → Dog.makeSound  ← 오버라이드됨 ⭐
[1] eat       → Animal.eat     ← 상속받음
[2] toString  → Object.toString
[3] equals    → Object.equals
[4] wagTail   → Dog.wagTail    ← Dog만의 새 메서드

메서드 호출의 실제 흐름 ⭐⭐

Animal animal = new Dog();
animal.makeSound();

JVM 내부 동작:

1. animal.makeSound() 호출
        ↓
2. JVM: "animal 변수의 컴파일 타임 타입은? Animal"
        ↓
3. JVM: "animal이 가리키는 실제 객체는? Dog 인스턴스"
        ↓
4. JVM: "Dog 인스턴스의 클래스 정보 (Class) 확인"
        ↓
5. JVM: "Dog의 VMT에서 makeSound 찾기"
        ↓
6. VMT[0] = Dog.makeSound  ← 발견
        ↓
7. Dog.makeSound() 실행 → "멍멍!"

핵심:

"컴파일 타임 타입은 'Animal' 이지만, 런타임에 실제 객체 (Dog) 의 메서드가 호출됨"

→ 이게 동적 바인딩.


컴파일 타임 타입 vs 런타임 타입 ⭐ (자기 점검 Q2)

Animal animal = new Dog();
//  ↑           ↑
// 컴파일 타임  런타임 타입
//  타입 (참조)  (실제 객체)

어떤 차이를 만드나?

1. 사용 가능한 메서드 (컴파일 시 결정)

Animal animal = new Dog();

animal.makeSound();  // ✅ Animal에 있음
animal.eat();        // ✅ Animal에 있음
animal.wagTail();    // ❌ 컴파일 에러 — Animal에는 wagTail 없음

컴파일러는 변수의 선언 타입(Animal) 만 봄.

2. 실제 호출되는 메서드 (런타임 결정)

Animal animal = new Dog();
animal.makeSound();  // "멍멍!" — Dog의 메서드

JVM은 실제 객체 (Dog) 의 메서드 호출.


정적 바인딩 vs 동적 바인딩 비교 ⭐

public class Parent {
    public static void staticMethod() { System.out.println("Parent static"); }
    public void instanceMethod() { System.out.println("Parent instance"); }
}

public class Child extends Parent {
    public static void staticMethod() { System.out.println("Child static"); }
    @Override
    public void instanceMethod() { System.out.println("Child instance"); }
}

Parent p = new Child();

p.staticMethod();    // ❓
p.instanceMethod();  // ❓

결과:

Parent static    ← 정적 바인딩 — 변수 타입(Parent)으로 결정
Child instance   ← 동적 바인딩 — 실제 객체(Child)로 결정

핵심 차이 ⭐ :

  • static 메서드: 오버라이드 X, 단순히 가려짐 (hiding)
  • 인스턴스 메서드: 오버라이드 O, 동적 바인딩

필드도 정적 바인딩 ⚠️

public class Parent {
    public String name = "Parent";
}

public class Child extends Parent {
    public String name = "Child";
}

Parent p = new Child();
System.out.println(p.name);  // ❓

결과: "Parent" — 필드는 변수 타입으로 결정.

필드는 다형성 X, 메서드만 다형성 O.

해결: getter 메서드 사용:

public class Parent {
    private String name = "Parent";
    public String getName() { return name; }  // 메서드는 다형성 O
}

public class Child extends Parent {
    private String name = "Child";
    @Override
    public String getName() { return name; }
}

Parent p = new Child();
System.out.println(p.getName());  // "Child" ✅

필드는 직접 노출하지 말고 메서드로 — 캡슐화 + 다형성.


다형성과 생성자 ⚠️ (Effective Java 함정)

public class Parent {
    public Parent() {
        init();  // ⚠️ 위험!
    }
    public void init() { System.out.println("Parent init"); }
}

public class Child extends Parent {
    private String name = "Child";
    
    @Override
    public void init() {
        System.out.println("Child init: " + name.length());  // NPE!
    }
}

new Child();

왜 NPE?:
1. new Child() → Parent 생성자 먼저 실행
2. Parent 생성자에서 init() 호출
3. 다형성 으로 Child의 init() 실행
4. 그러나 이 시점에 Child의 name 은 아직 초기화 X (null)
5. name.length() → NPE

교훈: 생성자에서 오버라이드 가능한 메서드 호출 X.


💻 6. 실전 코드 예시

ILIC 운임 시스템에서 다형성을 활용하는 다양한 패턴.

예시 1: 알림 채널의 다형성

// 인터페이스 — 알림의 약속
public interface NotificationSender {
    void send(String message, String recipient);
}

// 다양한 구현체
public class EmailSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("[Email] " + recipient + ": " + message);
        // 실제 이메일 발송 로직
    }
}

public class SmsSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("[SMS] " + recipient + ": " + message);
    }
}

public class SlackSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("[Slack] " + recipient + ": " + message);
    }
}

public class KakaoTalkSender implements NotificationSender {
    @Override
    public void send(String message, String recipient) {
        System.out.println("[KakaoTalk] " + recipient + ": " + message);
    }
}

// 사용 — 다형성 활용
public class NotificationService {
    private final List<NotificationSender> senders;
    
    public NotificationService(List<NotificationSender> senders) {
        this.senders = senders;
    }
    
    public void notifyAll(String message, String recipient) {
        for (NotificationSender sender : senders) {
            sender.send(message, recipient);  // ← 다형성!
        }
    }
}

// 호출
List<NotificationSender> senders = List.of(
    new EmailSender(),
    new SmsSender(),
    new SlackSender(),
    new KakaoTalkSender()
);

NotificationService service = new NotificationService(senders);
service.notifyAll("운임이 등록되었습니다", "alice@example.com");

효과:

  • 새 채널 (Telegram) 추가 시 → 새 클래스 1개만, NotificationService 안 건드림
  • 채널마다 다른 발송 로직, 같은 인터페이스로 통일
  • Spring DI 패턴의 토대

예시 2: 운임 종류별 처리

// 부모 추상 클래스
public abstract class Fare {
    protected Long id;
    protected int amount;
    protected FareStatus status;
    
    public Fare(Long id, int amount) {
        this.id = id;
        this.amount = amount;
        this.status = FareStatus.DRAFT;
    }
    
    // 추상 메서드 — 종류별로 다름
    public abstract int calculateTotal();
    public abstract String getDescription();
    
    // 공통 메서드 — 모든 운임에 공통
    public void submit() {
        if (status != FareStatus.DRAFT) {
            throw new IllegalStateException();
        }
        status = FareStatus.SUBMITTED;
    }
}

public class StandardFare extends Fare {
    public StandardFare(Long id, int amount) {
        super(id, amount);
    }
    
    @Override
    public int calculateTotal() {
        return amount;
    }
    
    @Override
    public String getDescription() {
        return "일반 운임";
    }
}

public class UrgentFare extends Fare {
    private int urgentFee;
    
    public UrgentFare(Long id, int amount, int urgentFee) {
        super(id, amount);
        this.urgentFee = urgentFee;
    }
    
    @Override
    public int calculateTotal() {
        return amount + urgentFee;
    }
    
    @Override
    public String getDescription() {
        return "긴급 운임 (긴급비: " + urgentFee + ")";
    }
}

public class InternationalFare extends Fare {
    private String currency;
    private double exchangeRate;
    
    public InternationalFare(Long id, int amount, String currency, double rate) {
        super(id, amount);
        this.currency = currency;
        this.exchangeRate = rate;
    }
    
    @Override
    public int calculateTotal() {
        return (int)(amount * exchangeRate);
    }
    
    @Override
    public String getDescription() {
        return "국제 운임 (" + currency + " " + exchangeRate + ")";
    }
}

// 사용 — 다형성으로 통일된 처리
List<Fare> fares = List.of(
    new StandardFare(1L, 50000),
    new UrgentFare(2L, 50000, 10000),
    new InternationalFare(3L, 100, "USD", 1300.0)
);

int totalRevenue = 0;
for (Fare fare : fares) {
    totalRevenue += fare.calculateTotal();  // ← 다형성!
    System.out.println(fare.getDescription());
}

출력:

일반 운임
긴급 운임 (긴급비: 10000)
국제 운임 (USD 1300.0)

새 운임 종류 추가 시 이 코드는 한 줄도 안 바뀜.


예시 3: 결제 수단의 다형성

public interface PaymentMethod {
    void process(int amount);
    String getMethodName();
}

public class CreditCardPayment implements PaymentMethod {
    private String cardNumber;
    
    public CreditCardPayment(String cardNumber) {
        this.cardNumber = cardNumber;
    }
    
    @Override
    public void process(int amount) {
        System.out.println("신용카드 " + cardNumber + " 로 " + amount + "원 결제");
    }
    
    @Override
    public String getMethodName() {
        return "신용카드";
    }
}

public class BankTransferPayment implements PaymentMethod {
    private String accountNumber;
    
    public BankTransferPayment(String accountNumber) {
        this.accountNumber = accountNumber;
    }
    
    @Override
    public void process(int amount) {
        System.out.println("계좌 " + accountNumber + " 에서 " + amount + "원 이체");
    }
    
    @Override
    public String getMethodName() {
        return "계좌이체";
    }
}

public class KakaoPayPayment implements PaymentMethod {
    @Override
    public void process(int amount) {
        System.out.println("카카오페이로 " + amount + "원 결제");
    }
    
    @Override
    public String getMethodName() {
        return "카카오페이";
    }
}

// 사용
public class CheckoutService {
    public void checkout(Fare fare, PaymentMethod paymentMethod) {
        int total = fare.calculateTotal();
        paymentMethod.process(total);
        System.out.println(paymentMethod.getMethodName() + " 결제 완료");
    }
}

CheckoutService service = new CheckoutService();
Fare fare = new StandardFare(1L, 50000);

service.checkout(fare, new CreditCardPayment("1234-5678"));
service.checkout(fare, new BankTransferPayment("110-1234"));
service.checkout(fare, new KakaoPayPayment());

효과:

  • 새 결제 수단 (PayPal, Apple Pay) 추가 시 → 새 클래스만
  • CheckoutService 코드는 안 바뀜
  • Strategy 패턴의 정확한 사례

예시 4: 컬렉션 다형성

// List 인터페이스 — 다양한 구현체
List<String> arrayList = new ArrayList<>();    // 빠른 조회
List<String> linkedList = new LinkedList<>();   // 빠른 삽입/삭제
List<String> immutableList = List.of("a", "b"); // 불변

// 동일한 List 타입으로 통일된 처리
public void process(List<String> list) {
    for (String item : list) {
        System.out.println(item);
    }
}

process(arrayList);    // OK
process(linkedList);   // OK
process(immutableList); // OK

자바 표준 라이브러리 자체가 다형성의 거대한 예시:

  • CollectionList, Set, Queue
  • ListArrayList, LinkedList, Vector
  • MapHashMap, TreeMap, LinkedHashMap

인터페이스로 약속, 구현체는 자유.


예시 5: Spring DI에서의 다형성 ⭐

미래 학습 주차이지만 미리보기:

// 인터페이스
public interface UserRepository {
    User findById(Long id);
}

// 다양한 구현
@Repository
public class JpaUserRepository implements UserRepository { ... }

// 또는
@Repository
public class MongoUserRepository implements UserRepository { ... }

// 사용
@Service
public class UserService {
    private final UserRepository repository;  // 인터페이스 타입
    
    public UserService(UserRepository repository) {
        this.repository = repository;
    }
    
    public User getUser(Long id) {
        return repository.findById(id);  // 어느 구현이든 OK
    }
}

Spring이 자동으로:

  • UserRepository 인터페이스를 구현한 빈을 찾음
  • UserService 에 자동 주입
  • → 나중에 MongoDB로 바꾸려면 구현체만 교체

Spring 전체가 다형성 위에 설계됨. 5주차에서 본격 학습.


⚠️ 7. 주의사항 & 흔한 실수

실수 1: @Override 누락 ⚠️

public class Animal {
    public void makeSound() { ... }
}

public class Dog extends Animal {
    public void makesound() {  // 오타: 's' 가 소문자
        System.out.println("멍멍");
    }
}

Animal a = new Dog();
a.makeSound();  // "동물 소리" — 오버라이드 안 됨! ❌

해결: 모든 오버라이드에 @Override 필수.

@Override
public void makesound() {  // 컴파일 에러 즉시 발견
    ...
}

실수 2: 필드는 다형성 X 라는 사실 모름

public class Parent {
    public String name = "Parent";
}

public class Child extends Parent {
    public String name = "Child";
}

Parent p = new Child();
System.out.println(p.name);  // "Parent" — 필드는 변수 타입으로!

필드는 다형성 X. 메서드만 다형성.

해결: 필드를 private으로 + getter 메서드 사용.


실수 3: static 메서드는 오버라이드 X

public class Parent {
    public static void method() { System.out.println("Parent"); }
}

public class Child extends Parent {
    public static void method() { System.out.println("Child"); }
}

Parent p = new Child();
p.method();  // "Parent" — static은 컴파일 시 결정!

왜?: static 메서드는 인스턴스가 아닌 클래스에 속함. 다형성 적용 X.

→ static 메서드는 숨김(hiding) 일 뿐 오버라이드 X.

: static 메서드는 클래스명으로 호출 권장:

Parent.method();  // 명시적
Child.method();

실수 4: instanceof 남발

// ❌ 다형성을 외면한 코드
public void process(Animal animal) {
    if (animal instanceof Dog) {
        ((Dog) animal).bark();
    } else if (animal instanceof Cat) {
        ((Cat) animal).meow();
    }
}

다형성을 사용하지 않는 코드. if 지옥의 부활.

해결: 공통 메서드를 부모에 두기:

public abstract class Animal {
    public abstract void makeSound();
}

public void process(Animal animal) {
    animal.makeSound();  // ✅ 다형성!
}

instanceof 가 자주 등장하면 설계 재검토 신호.


실수 5: 캐스팅 후 NullPointerException

Animal animal = new Cat();
Dog dog = (Dog) animal;  // ⚠️ 런타임 에러: ClassCastException
dog.bark();

해결: 캐스팅 전 instanceof 체크 (Unit 2.5에서 자세히):

if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark();
}

또는 Java 16+ 패턴 매칭:

if (animal instanceof Dog dog) {
    dog.bark();
}

실수 6: 추상 메서드 누락

public abstract class Animal {
    public abstract void makeSound();
}

public class Dog extends Animal {
    // makeSound() 구현 안 함
}
// → 컴파일 에러

규칙: 추상 메서드는 자식이 반드시 구현 (자식도 abstract면 OK).


실수 7: 부모-자식 메서드 시그니처 다름

public class Animal {
    public void eat(String food) { ... }
}

public class Dog extends Animal {
    @Override
    public void eat(int amount) { ... }  // ❌ 시그니처 다름 → 오버로딩 됨
}

@Override 가 있어서 컴파일 에러로 잡힘. 없으면 오버로딩 (다른 메서드) 으로 인식 → 다형성 X.


실수 8: 다형성과 equals/hashCode

public class Customer {
    private Long id;
    
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null) return false;
        if (getClass() != obj.getClass()) return false;  // ⚠️
        return Objects.equals(id, ((Customer) obj).id);
    }
}

public class VipCustomer extends Customer { ... }

Customer c1 = new Customer(1L);
Customer c2 = new VipCustomer(1L);

c1.equals(c2);  // false — getClass() 다름

문제: 부모-자식 비교 불가.

해결 시 주의: instanceof 사용 vs getClass() — Joshua Bloch는 일관성 위해 getClass() 권장. 깊은 주제이지만, 상속 구조 + equals 는 신중하게.


🔗 8. 연관 개념 맵

직접 이어지는 학습

[Unit 2.3: 상속과 생성자 체이닝]
        ↓
[Unit 2.4: 다형성] ★★★  ← 지금 여기
        ↓
[Unit 2.5: instanceof와 형변환] — 다형성의 안전장치
        ↓
[Unit 2.6: Nested/Inner/Anonymous]

이 Unit의 개념이 활용되는 곳

1주차 내:

  • Unit 2.5: instanceof — 다형성에서 실제 타입 확인
  • Phase 3 (SOLID): OCP, LSP, DIP 모두 다형성 기반
  • Phase 6 (컬렉션): List/Map 인터페이스의 다형성 활용

미래 주차:

  • 3주차 (제네릭): 다형성 + 타입 안전성 결합
  • 3주차 (람다): 함수형 인터페이스의 다형성
  • 5주차 (Spring DI): 인터페이스 기반 의존성 주입의 토대
  • 5주차 (디자인 패턴): Strategy, Template Method, Factory 등 거의 모든 패턴
  • 8-9주차 (AOP): 프록시 객체가 원본을 다형적으로 대체
  • 11-12주차 (JPA): Entity 상속 (@Inheritance)
  • 17주차 (MSA): 인터페이스로 서비스 약속

SOLID와 다형성 ⭐

원칙다형성과의 관계
SRP책임 분리 후 다형성으로 조립
OCP다형성의 직접적 결과 — 확장 열려있고 수정 닫혀있음
LSP다형성의 정확한 규칙 — 자식이 부모를 안전하게 대체
ISP인터페이스 분리 → 다형성 단위 작게
DIP추상화에 의존 → 다형성 없이 불가능

SOLID 5원칙 중 4개가 다형성에 직접 의존. Phase 3에서 자세히.


디자인 패턴과 다형성

거의 모든 GoF 패턴이 다형성 기반:

패턴다형성 활용
Strategy알고리즘을 인터페이스로, 구현체 교체
Template Method부모가 흐름, 자식이 구체
Factory객체 생성을 추상화
Decorator같은 인터페이스로 기능 추가
Observer다양한 Observer를 통일된 인터페이스로
Command명령을 객체로 추상화
State상태별 다른 동작을 다형성으로

다형성을 모르면 디자인 패턴 학습 불가.


면접 단골 질문 매핑

질문이 Unit에서의 답
"다형성이란?"같은 메서드 호출이 객체에 따라 다르게 동작
"다형성의 장점?"새 종류 추가 시 기존 코드 안 건드림 (OCP)
"동적 바인딩이란?"런타임에 실제 객체 타입의 메서드를 호출
"정적 바인딩 vs 동적 바인딩?"static/private/final = 정적, 인스턴스 메서드 = 동적
"VMT가 뭔가요?"클래스별 메서드 테이블, JVM이 동적 바인딩에 사용
"필드도 다형성?"NO — 필드는 변수 타입으로 결정
"오버로딩 vs 오버라이딩?"오버로딩=같은 클래스 다른 시그니처, 오버라이딩=상속에서 재정의

📝 9. 핵심 요약 — 3줄 정리

1️⃣ 다형성은 "같은 메시지에 객체마다 다른 응답" 이다.

부모 타입 변수가 자식 객체를 가리킬 수 있고 (Animal a = new Dog()), 메서드 호출 시 JVM이 런타임에 실제 객체의 메서드를 찾아 실행 (동적 바인딩). 이게 OOP 4대 원칙의 정점이며, 자바 생태계 (Spring, JPA, 디자인 패턴) 의 토대.

2️⃣ VMT (Virtual Method Table) 가 다형성의 비밀이다.

각 클래스는 자기만의 VMT를 가지고, 오버라이드된 메서드는 자식 클래스의 VMT에 등록됨. JVM은 메서드 호출 시 참조 타입이 아닌 실제 객체의 VMT 를 참조. 단, static/private/final 메서드와 필드는 정적 바인딩 — 다형성 X.

3️⃣ 다형성은 OCP의 직접적 구현 — if 지옥을 없애는 마법이다.

"새 종류 추가 시 기존 코드를 안 건드린다" 는 OCP가 다형성으로 가능. if (type.equals("VIP")) 같은 분기는 다형성으로 대체. instanceof 가 코드에 많이 등장하면 설계 재검토 신호. SOLID, Spring DI, 디자인 패턴 모두 다형성 위에 서 있음.


🎓 학습 자기 점검

기본 이해

  • 다형성을 한 문장으로 설명할 수 있다
  • Animal a = new Dog() 가 가능한 이유를 설명할 수 있다
  • 동적 바인딩과 정적 바인딩의 차이를 안다
  • VMT의 역할을 설명할 수 있다

실전 적용

  • ILIC 코드의 if 지옥을 다형성으로 리팩토링할 수 있다
  • 인터페이스 + 구현체 패턴을 작성할 수 있다
  • List 같은 컬렉션 다형성을 활용할 수 있다
  • @Override를 모든 오버라이드에 사용한다

면접 대비 (3-5분 답변)

  • "다형성이란 무엇인가?" 답변 가능
  • "다형성의 내부 동작 원리?" (VMT, 동적 바인딩) 답변 가능
  • "필드도 다형성이 적용되나?" 답변 가능 (NO)
  • "static 메서드는 다형성?" 답변 가능 (NO)
  • "ILIC에서 다형성을 어떻게 활용하셨나요?" 답변 가능

자기 점검 질문 답변

Q1: Animal a = new Dog(); a.eat(); 에서 호출되는 eat()은 누구 것인가?

Dog의 eat() 입니다 (Dog가 eat()을 오버라이드한 경우).

상세 설명:

public class Animal {
    public void eat() { System.out.println("동물이 먹는다"); }
}

public class Dog extends Animal {
    @Override
    public void eat() { System.out.println("개가 먹는다"); }
}

Animal a = new Dog();
a.eat();  // "개가 먹는다"

왜?동적 바인딩 (Dynamic Binding) 때문.

JVM 내부 흐름:
1. a.eat() 호출
2. JVM: "a 변수의 컴파일 타임 타입은 Animal이지만..."
3. JVM: "a가 가리키는 실제 객체는 무엇인가? → Dog 인스턴스"
4. JVM: "Dog의 VMT에서 eat() 메서드 찾기"
5. VMT[eat] = Dog.eat (오버라이드되어 있음)
6. Dog.eat() 실행 → "개가 먹는다"

핵심 통찰 ⭐ :

"참조 변수의 타입(Animal)이 무엇인지는 중요하지 않다. 실제 객체의 타입(Dog)이 메서드를 결정한다."

예외 상황:

만약 Dog가 eat()을 오버라이드하지 않았다면?

public class Dog extends Animal {
    // eat() 오버라이드 X
}

Animal a = new Dog();
a.eat();  // "동물이 먹는다" — 상속받은 Animal.eat() 호출

→ 자식이 안 가지면 부모의 것을 사용 (VMT의 자연스러운 동작).


Q2: 컴파일 타임 타입과 런타임 타입의 차이는?

컴파일 타임 타입 = 변수 선언 시 명시한 타입 (참조 타입)
런타임 타입 = 변수가 실제로 가리키는 객체의 타입

Animal a = new Dog();
//  ↑           ↑
// 컴파일 타임  런타임 타입
//  타입 (참조)  (실제 객체)

각각이 결정하는 것:

컴파일 타임 타입이 결정하는 것 (컴파일러가 봄)

1. 사용 가능한 메서드 ⭐ :

Animal a = new Dog();
a.eat();           // ✅ Animal에 있음
a.bark();          // ❌ 컴파일 에러 — Animal에는 bark() 없음

컴파일러는 변수의 선언 타입만 봄.

2. 캐스팅 가능 여부:

Animal a = new Dog();
Dog d = (Dog) a;     // ✅ 명시적 캐스팅 필요
String s = (String) a; // ❌ 컴파일 에러 — 무관한 타입

런타임 타입이 결정하는 것 (JVM이 봄)

1. 실제 호출되는 메서드 (다형성) ⭐ :

Animal a = new Dog();
a.eat();  // Dog.eat() 호출 — 런타임 타입으로 결정

2. instanceof 결과:

Animal a = new Dog();
a instanceof Dog;     // true — 런타임 타입
a instanceof Animal;  // true — 런타임 타입의 부모
a instanceof Cat;     // false

3. ClassCastException 발생 여부:

Animal a = new Cat();
Dog d = (Dog) a;  // 컴파일 OK, 런타임에 ClassCastException ⚠️

→ 컴파일러는 "Dog로 캐스팅하겠다" 만 확인, 실제 가능 여부는 런타임.


비유로 정리:

컴파일 타임 타입 = 명함의 직책
  - "나는 매니저입니다"
  - 명함만 보면 알 수 있는 정보

런타임 타입 = 실제 사람
  - 그 사람의 진짜 능력
  - 실제로 만나야 알 수 있음

Animal a = new Dog();
명함: "동물 (Animal)"
실제: 개 (Dog)

핵심 차이 표:

측면컴파일 타임 타입런타임 타입
결정 시점컴파일 시실행 시
누가 봄컴파일러JVM
결정하는 것사용 가능한 멤버실제 메서드
변경 가능?NO (선언 후 고정)NO (객체 생성 후 고정)

다음 Unit으로

  • instanceof와 형변환 을 학습할 준비 완료
  • "다형성에서 자식의 고유 메서드를 호출하려면?" 이 궁금하다
profile
Software Developer

0개의 댓글