개발 공부/자바

Java의 다형성 완전 정복

baby-t 2025. 9. 25. 11:00

Java의 객체 지향 프로그래밍(OOP)을 공부하다 보면 A a = new B();와 같은 코드를 마주하게 됩니다. 이는 단순한 문법이 아니라, Java의 가장 강력한 특징 중 하나인 **다형성(Polymorphism)**을 구현하는 핵심 원리입니다.

이번 포스팅에서는 상속과 인터페이스 관계를 바탕으로, 객체 참조가 어떻게 이루어지는지, 그리고 이것이 왜 유연하고 확장성 있는 설계를 가능하게 하는지 자세히 알아보겠습니다. ☕️


## 1. 관계 설정: 상속과 인터페이스

다형성을 이해하기 위해, 먼저 예제로 사용할 클래스들의 관계를 설정하겠습니다.

  • 부모 클래스 Animal

    Java
    class Animal {
        String name;
        Animal(String name) { this.name = name; }
        public void cry() { System.out.println("동물이 웁니다."); }
    }
    
  • 자식 클래스 Dog와 Cat
     

    Java
    class 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();

가장 기본적이고 직관적인 객체 생성 방식입니다. 참조 변수의 타입과 실제 생성된 객체의 타입이 일치합니다.

Java
 
Dog dog = new Dog("뽀삐");

dog.cry();      // 출력: 뽀삐이(가) 멍멍!
dog.wagTail();  // 출력: 꼬리를 흔듭니다.
  • 동작: Dog 타입의 참조 변수 dog는 Dog 객체를 가리킵니다. 따라서 Animal로부터 상속받은 cry() 메소드(오버라이딩된 버전)와 Dog 고유의 wagTail() 메소드를 모두 사용할 수 있습니다.

## 3. 업캐스팅(Upcasting): A a = new B(); - 부모의 시선

자식 클래스의 객체를 부모 클래스 타입의 참조 변수에 할당하는 것을 업캐스팅이라고 합니다. 이는 묵시적으로(자동으로) 일어납니다.

Java
 
// Dog 객체를 생성하여 Animal 타입의 참조 변수에 할당
Animal animal = new Dog("뽀삐"); 

이때, 참조 변수 animal은 힙 메모리에 있는 Dog 객체를 부모인 Animal의 시선으로 바라보게 됩니다.

### 1) 접근 범위의 제한

참조 변수의 타입이 Animal이므로, Animal 클래스에 정의된 멤버에만 접근할 수 있습니다.

Java
animal.cry();      // 호출 가능 (Animal에 정의되어 있음)
// animal.wagTail(); // 컴파일 에러! Animal 타입에는 wagTail() 메소드가 없습니다.

### 2) 오버라이딩된 메소드의 실행 (다형성의 핵심)

animal.cry()를 호출하면, 참조 변수의 타입(Animal)이 아닌 실제 힙 메모리에 있는 객체(Dog)의 오버라이딩된 메소드가 실행됩니다.

Java
animal.cry(); // 출력: 뽀삐이(가) 멍멍!

이것이 바로 다형성의 핵심입니다. 같은 animal.cry() 코드라도, animal 변수가 new Dog()를 가리키면 "멍멍!", new Cat()을 가리키면 "야옹~"이 출력됩니다.


## 4. 다운캐스팅(Downcasting): B b = (B) a; - 원래 모습으로

업캐스팅된 참조 변수를 다시 원래의 자식 클래스 타입으로 되돌리는 것을 다운캐스팅이라고 합니다. 업캐스팅으로 인해 접근할 수 없었던 자식 클래스 고유의 멤버를 사용하고 싶을 때 필요하며, 명시적으로 타입을 지정 (Dog) 해야 합니다.

Java
 
Animal animal = new Dog("뽀삐");

// 다운캐스팅을 통해 wagTail() 메소드 호출하기
Dog poppy = (Dog) animal; // 명시적 형 변환
poppy.wagTail(); // 출력: 꼬리를 흔듭니다.

### 다운캐스팅의 위험성과 instanceof

만약 부모 타입 변수가 실제로는 다른 자식 객체를 가리키고 있을 때 잘못된 타입으로 다운캐스팅을 시도하면, 실행 시점에 ClassCastException 오류가 발생합니다.

Java
 
Animal animal = new Cat("나비");
// Dog poppy = (Dog) animal; // 실행 시 ClassCastException 발생!

이러한 위험을 방지하기 위해, 다운캐스팅 전에는 항상 instanceof 연산자로 실제 객체의 타입을 확인하는 것이 안전합니다.

Java
 
if (animal instanceof Dog) {
    Dog poppy = (Dog) animal;
    poppy.wagTail();
} else {
    System.out.println("강아지로 변환할 수 없습니다.");
}

## 5. 유연성과 확장성: 다형성의 진정한 힘

다형성은 '역할(부모/인터페이스)'과 '구현(자식/클래스)'을 분리하여 코드의 유연성과 확장성을 극대화합니다.

동물 병원에서 동물을 치료하는 메소드를 생각해 봅시다.

Java
 
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라는 새로운 클래스를 만들었다고 가정해봅시다.

Java
 
public class ImportantTask implements Runnable {
    @Override
    public void run() {
        // 더 중요한 작업 수행
    }
}

이제 main 메소드에서 Task 대신 ImportantTask를 사용하고 싶을 때, 단 한 줄만 바꾸면 됩니다.

Java
 
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 구현체)로 쉽게 교체할 수 있도록 하는 매우 좋은 프로그래밍 습관입니다.