F-lab Java 1주차 / Phase 2 / Unit 2.4 본격 학습 자료
9-섹션 마스터 프롬프트 형식으로 깊이 파헤친다.선수 지식: Unit 2.3 (상속과 생성자 체이닝)
다음 Unit: 2.5 — instanceof와 형변환이 Unit의 의미: OOP 4대 원칙의 정점. 면접에서 가장 자주 묻는 영역.
다형성을 정확히 이해하면 Spring DI, JPA, Strategy 패턴 등 모든 현대 자바의 토대 가 잡힌다.
선생님이 교실에서 외칩니다:
"모두 인사하세요!"
학생들의 반응:
같은 명령 ("인사하세요") 인데 다른 반응 이 나옵니다.
핵심:
→ 이게 다형성 — 같은 메시지에 객체마다 다른 응답.
당신이 호텔 방의 리모컨을 봅니다. 리모컨에는 "전원" 버튼 이 있어요.
이 호텔에는 다양한 TV가 있습니다:
당신이 "전원" 버튼을 누르면:
당신이 TV 회사를 알아야 하나요? 아닙니다. 그냥 누르면 됨.
당신: 리모컨.전원() // ← 같은 명령
↓
TV가 자기 방식대로 처리:
- LG: LCD 방식으로 켜짐
- 삼성: OLED 방식으로 켜짐
- 소니: HDR 방식으로 켜짐
- 브라운관: 진공관 방식으로 켜짐
→ 다형성의 본질.
다형성(Polymorphism) = 그리스어 어원
자바에서:
"같은 메서드 호출이 객체에 따라 다른 동작을 하는 것"
| 비유 요소 | 자바 다형성 |
|---|---|
| "인사하세요" 명령 | 메서드 호출 |
| 학생들 | 다양한 객체 |
| 한국식/미국식/일본식 인사 | 오버라이드된 메서드 |
| 선생님이 국적 모름 | 컴파일 시 타입 모름 |
| 학생이 알아서 인사 | 런타임에 객체가 결정 |
상속(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 ❌
}
문제:
oink()) 까지 알아야 함객체지향의 본격적 정립은 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)
디자인 패턴:
→ 현대 자바 = 다형성 위에 세워진 거대한 탑.
"다형성이 없으면 객체지향은 절차지향과 다를 게 없다."
캡슐화 + 상속만 있어도 데이터를 보호하고 코드를 재사용할 수 있지만, 새 종류 추가 시마다 기존 코드를 수정해야 한다.
다형성이 있어야 "기존 코드를 안 건드리고 확장" 이 가능 — 이게 SOLID의 OCP (개방-폐쇄 원칙) 의 핵심.
다형성이 없을 때의 구체적 문제를 ILIC 시나리오로 보겠습니다.
ILIC가 다양한 고객 등급별로 다른 할인율을 적용한다고 합시다:
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 지옥 ❌
}
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(); // ← 객체에 위임
}
}
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 준수 | ❌ | ✅ |
| 새 개발자 온보딩 | 지옥 | 명확 |
→ 이게 다형성의 진짜 가치.
부모타입 변수 = new 자식타입();
변수.메서드(); // 자식의 메서드가 호출됨
예시:
Animal animal = new Dog(); // ✅ 부모 타입 = 자식 객체
animal.makeSound(); // "멍멍!" — Dog의 메서드
왜 이게 가능한가?:
Dog 는 Animal 의 한 종류 (is-a 관계)Dog 는 Animal 의 약속 (메서드 시그니처) 을 지킴Animal 타입 변수에 담을 수 있음public abstract class Animal { public abstract void makeSound(); }
public class Dog extends Animal { @Override public void makeSound() { ... } }
Animal a = new Dog(); // ✅
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(); // ✅
→ 현대 자바에서 가장 많이 쓰는 방식.
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 패턴의 토대.
바인딩(Binding):
"메서드 호출과 실제 메서드 코드를 연결하는 것"
두 종류:
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) 의 메서드가 호출됨"
→ 이게 동적 바인딩.
Animal animal = new Dog();
// ↑ ↑
// 컴파일 타임 런타임 타입
// 타입 (참조) (실제 객체)
어떤 차이를 만드나?
Animal animal = new Dog();
animal.makeSound(); // ✅ Animal에 있음
animal.eat(); // ✅ Animal에 있음
animal.wagTail(); // ❌ 컴파일 에러 — Animal에는 wagTail 없음
→ 컴파일러는 변수의 선언 타입(Animal) 만 봄.
Animal animal = new Dog();
animal.makeSound(); // "멍멍!" — Dog의 메서드
→ JVM은 실제 객체 (Dog) 의 메서드 호출.
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)로 결정
핵심 차이 ⭐ :
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" ✅
→ 필드는 직접 노출하지 말고 메서드로 — 캡슐화 + 다형성.
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.
ILIC 운임 시스템에서 다형성을 활용하는 다양한 패턴.
// 인터페이스 — 알림의 약속
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");
효과:
// 부모 추상 클래스
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)
→ 새 운임 종류 추가 시 이 코드는 한 줄도 안 바뀜.
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());
효과:
// 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, QueueList ← ArrayList, LinkedList, VectorMap ← HashMap, TreeMap, LinkedHashMap→ 인터페이스로 약속, 구현체는 자유.
미래 학습 주차이지만 미리보기:
// 인터페이스
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 에 자동 주입→ Spring 전체가 다형성 위에 설계됨. 5주차에서 본격 학습.
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() { // 컴파일 에러 즉시 발견
...
}
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 메서드 사용.
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();
// ❌ 다형성을 외면한 코드
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 가 자주 등장하면 설계 재검토 신호.
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();
}
public abstract class Animal {
public abstract void makeSound();
}
public class Dog extends Animal {
// makeSound() 구현 안 함
}
// → 컴파일 에러
규칙: 추상 메서드는 자식이 반드시 구현 (자식도 abstract면 OK).
public class Animal {
public void eat(String food) { ... }
}
public class Dog extends Animal {
@Override
public void eat(int amount) { ... } // ❌ 시그니처 다름 → 오버로딩 됨
}
→ @Override 가 있어서 컴파일 에러로 잡힘. 없으면 오버로딩 (다른 메서드) 으로 인식 → 다형성 X.
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 는 신중하게.
[Unit 2.3: 상속과 생성자 체이닝]
↓
[Unit 2.4: 다형성] ★★★ ← 지금 여기
↓
[Unit 2.5: instanceof와 형변환] — 다형성의 안전장치
↓
[Unit 2.6: Nested/Inner/Anonymous]
1주차 내:
미래 주차:
| 원칙 | 다형성과의 관계 |
|---|---|
| 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 오버라이딩?" | 오버로딩=같은 클래스 다른 시그니처, 오버라이딩=상속에서 재정의 |
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() 가 가능한 이유를 설명할 수 있다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; // ❌ 컴파일 에러 — 무관한 타입
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 (객체 생성 후 고정) |