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

Psj·2026년 5월 7일

F-lab

목록 보기
26/240

🎯 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의 메서드

왜 이게 가능한가?:

  • Dog 는 Animal 의 한 종류 (is-a 관계)
  • 모든 Dog 는 Animal 의 약속 (메서드 시그니처) 을 지킴
  • 그래서 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

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

  • Collection ← List, Set, Queue
  • List ← ArrayList, LinkedList, Vector
  • Map ← HashMap, 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개의 댓글