Java의 객체 지향 프로그래밍(OOP)을 공부하다 보면 A a = new B();와 같은 코드를 마주하게 됩니다. 이는 단순한 문법이 아니라, Java의 가장 강력한 특징 중 하나인 **다형성(Polymorphism)**을 구현하는 핵심 원리입니다.
이번 포스팅에서는 상속과 인터페이스 관계를 바탕으로, 객체 참조가 어떻게 이루어지는지, 그리고 이것이 왜 유연하고 확장성 있는 설계를 가능하게 하는지 자세히 알아보겠습니다. ☕️
## 1. 관계 설정: 상속과 인터페이스
다형성을 이해하기 위해, 먼저 예제로 사용할 클래스들의 관계를 설정하겠습니다.
- 부모 클래스 Animal
Javaclass Animal { String name; Animal(String name) { this.name = name; } public void cry() { System.out.println("동물이 웁니다."); } } - 자식 클래스 Dog와 Cat
Javaclass Dog extends Animal { Dog(String name) { super(name); } @Override // 메소드 재정의 public void cry() { System.out.println(name + "이(가) 멍멍!"); } public void wagTail() { System.out.println("꼬리를 흔듭니다."); } // Dog 고유 메소드 } class Cat extends Animal { Cat(String name) { super(name); } @Override public void cry() { System.out.println(name + "이(가) 야옹~"); } }
## 2. 기본 객체 참조: A a = new A();
가장 기본적이고 직관적인 객체 생성 방식입니다. 참조 변수의 타입과 실제 생성된 객체의 타입이 일치합니다.
Dog dog = new Dog("뽀삐");
dog.cry(); // 출력: 뽀삐이(가) 멍멍!
dog.wagTail(); // 출력: 꼬리를 흔듭니다.
- 동작: Dog 타입의 참조 변수 dog는 Dog 객체를 가리킵니다. 따라서 Animal로부터 상속받은 cry() 메소드(오버라이딩된 버전)와 Dog 고유의 wagTail() 메소드를 모두 사용할 수 있습니다.
## 3. 업캐스팅(Upcasting): A a = new B(); - 부모의 시선
자식 클래스의 객체를 부모 클래스 타입의 참조 변수에 할당하는 것을 업캐스팅이라고 합니다. 이는 묵시적으로(자동으로) 일어납니다.
// Dog 객체를 생성하여 Animal 타입의 참조 변수에 할당
Animal animal = new Dog("뽀삐");
이때, 참조 변수 animal은 힙 메모리에 있는 Dog 객체를 부모인 Animal의 시선으로 바라보게 됩니다.
### 1) 접근 범위의 제한
참조 변수의 타입이 Animal이므로, Animal 클래스에 정의된 멤버에만 접근할 수 있습니다.
animal.cry(); // 호출 가능 (Animal에 정의되어 있음)
// animal.wagTail(); // 컴파일 에러! Animal 타입에는 wagTail() 메소드가 없습니다.
### 2) 오버라이딩된 메소드의 실행 (다형성의 핵심)
animal.cry()를 호출하면, 참조 변수의 타입(Animal)이 아닌 실제 힙 메모리에 있는 객체(Dog)의 오버라이딩된 메소드가 실행됩니다.
animal.cry(); // 출력: 뽀삐이(가) 멍멍!
이것이 바로 다형성의 핵심입니다. 같은 animal.cry() 코드라도, animal 변수가 new Dog()를 가리키면 "멍멍!", new Cat()을 가리키면 "야옹~"이 출력됩니다.
## 4. 다운캐스팅(Downcasting): B b = (B) a; - 원래 모습으로
업캐스팅된 참조 변수를 다시 원래의 자식 클래스 타입으로 되돌리는 것을 다운캐스팅이라고 합니다. 업캐스팅으로 인해 접근할 수 없었던 자식 클래스 고유의 멤버를 사용하고 싶을 때 필요하며, 명시적으로 타입을 지정 (Dog) 해야 합니다.
Animal animal = new Dog("뽀삐");
// 다운캐스팅을 통해 wagTail() 메소드 호출하기
Dog poppy = (Dog) animal; // 명시적 형 변환
poppy.wagTail(); // 출력: 꼬리를 흔듭니다.
### 다운캐스팅의 위험성과 instanceof
만약 부모 타입 변수가 실제로는 다른 자식 객체를 가리키고 있을 때 잘못된 타입으로 다운캐스팅을 시도하면, 실행 시점에 ClassCastException 오류가 발생합니다.
Animal animal = new Cat("나비");
// Dog poppy = (Dog) animal; // 실행 시 ClassCastException 발생!
이러한 위험을 방지하기 위해, 다운캐스팅 전에는 항상 instanceof 연산자로 실제 객체의 타입을 확인하는 것이 안전합니다.
if (animal instanceof Dog) {
Dog poppy = (Dog) animal;
poppy.wagTail();
} else {
System.out.println("강아지로 변환할 수 없습니다.");
}
## 5. 유연성과 확장성: 다형성의 진정한 힘
다형성은 '역할(부모/인터페이스)'과 '구현(자식/클래스)'을 분리하여 코드의 유연성과 확장성을 극대화합니다.
동물 병원에서 동물을 치료하는 메소드를 생각해 봅시다.
class AnimalHospital {
// 매개변수를 Animal 타입으로 받으면, Dog, Cat 등 모든 자식 객체를 받을 수 있다.
public void checkup(Animal animal) {
System.out.println(animal.name + "을(를) 진찰합니다.");
animal.cry(); // 어떤 동물이 들어오든, 각자에 맞는 울음소리를 낼 것이다.
}
}
// 사용 예시
AnimalHospital hospital = new AnimalHospital();
Dog poppy = new Dog("뽀삐");
Cat navi = new Cat("나비");
hospital.checkup(poppy); // "뽀삐을(를) 진찰합니다.", "뽀삐이(가) 멍멍!" 출력
hospital.checkup(navi); // "나비을(를) 진찰합니다.", "나비이(가) 야옹~" 출력
나중에 Lion이라는 새로운 Animal 자식 클래스를 만들어도, checkup 메소드는 단 한 줄도 수정할 필요 없이 new Lion() 객체를 받아 처리할 수 있습니다. 이처럼 다형성은 변화에 열려 있고, 수정에는 닫혀 있는(Open-Closed Principle) 좋은 설계를 가능하게 합니다.
이 글을 작성하게 된 이유
public class Task implements Runnable {
@Override
public void run() {
int sum = 0;
for (int index = 0; index < 10; index++) {
sum += index;
System.out.println(sum);
}
System.out.println( Thread.currentThread() + "최종 합 : " + sum);
}
}
public static void main(String args[]){
Runnable task = new Task();
Thread subTread1 = new Thread(task);
Thread subTread2 = new Thread(task);
subTread1.start();
subTread2.start();
}
왜 Runnable task = new Taks로 하는지 궁금했다. 그래서 gemini에 물어봤다.
결론부터 말하면, Task task = new Task(); 라고 써도 문법적으로는 아무런 문제가 없습니다. 하지만 Runnable task = new Task(); 라고 쓰는 것이 훨씬 더 유연하고 확장성 있는 좋은 코드입니다.
## 1. '인터페이스'를 타입으로 사용하는 이유
Runnable은 **'실행 가능한 작업'이라는 역할(규칙)**을 정의하는 인터페이스입니다. 반면 Task는 그 역할을 실제로 구현한 구현체 중 하나일 뿐입니다.
Runnable task = new Task(); 코드는 다음과 같이 해석할 수 있습니다.
"나는 task라는 변수를 선언할 건데, 여기에는 '실행 가능한 작업(Runnable)'이면 무엇이든 들어올 수 있어. 그리고 지금 당장은 그 역할의 구현체로 Task 객체를 넣어둘게."
### 유연성과 확장성
이렇게 '역할'을 기준으로 변수를 선언하면, 나중에 다른 종류의 작업을 만들어도 코드를 거의 수정할 필요가 없습니다.
예를 들어, ImportantTask라는 새로운 클래스를 만들었다고 가정해봅시다.
public class ImportantTask implements Runnable {
@Override
public void run() {
// 더 중요한 작업 수행
}
}
이제 main 메소드에서 Task 대신 ImportantTask를 사용하고 싶을 때, 단 한 줄만 바꾸면 됩니다.
public static void main(String args[]){
// Runnable task = new Task(); // 이전 코드
Runnable task = new ImportantTask(); // 이 부분만 교체!
Thread subTread1 = new Thread(task); // 이 코드는 전혀 수정할 필요가 없음
Thread subTread2 = new Thread(task); // 이 코드는 전혀 수정할 필요가 없음
subTread1.start();
subTread2.start();
}
Thread 생성자는 Task가 무엇인지는 전혀 관심이 없고, 오직 전달된 객체가 Runnable이라는 '실행 가능한' 역할만 할 수 있으면 만족하기 때문입니다.
## 2. 비유: USB 포트와 장치들
- Runnable (인터페이스): USB 포트 규격 그 자체입니다.
- Task, ImportantTask (구현 클래스): USB 메모리, USB 키보드, USB 마우스 등 개별 USB 장치입니다.
- new Thread(...) (사용하는 코드): 컴퓨터입니다.
컴퓨터는 "USB 포트에 맞는 장치"라면 메모리든 키보드든 가리지 않고 연결하여 사용할 수 있습니다. Runnable task = new Task();는 바로 이러한 **"느슨한 연결(Loosely Coupled)"**을 만들어, 나중에 다른 USB 장치(다른 Runnable 구현체)로 쉽게 교체할 수 있도록 하는 매우 좋은 프로그래밍 습관입니다.
'개발 공부 > 자바' 카테고리의 다른 글
| 자바 공부 일지 (면접 질문 위주) (0) | 2026.02.16 |
|---|---|
| 모든 클래스의 조상: Object 클래스 핵심 정리 (0) | 2025.12.03 |
| 자바 면접 질문2 (0) | 2025.09.24 |
| 자바 면접 질문1 (0) | 2025.09.24 |
| 스프링 부트 개발자를 위한 Java 핵심 정리 (0) | 2025.09.24 |