자바에서 상속을 배우고 나면 다음으로 만나는 중요한 개념이 다형성입니다. 다형성은 이름 그대로 하나의 대상이 여러 형태로 다뤄질 수 있다는 뜻입니다.

처음에는 말이 조금 어렵게 느껴질 수 있습니다. 하지만 핵심은 단순합니다.

부모 타입 변수로 자식 객체를 참조할 수 있고, 같은 메서드 호출이 실제 객체에 따라 다르게 동작할 수 있습니다.

아직 상속과 오버라이딩이 익숙하지 않다면 자바 상속 정리 - extends와 오버라이딩 이해하기를 먼저 보는 편이 좋습니다.


다형성을 이해하기 위한 예시

알림을 보내는 프로그램을 만든다고 생각해보겠습니다. 알림 방식은 여러 가지가 있을 수 있습니다.

  • 이메일 알림
  • 문자 알림
  • 앱 푸시 알림

세 알림은 보내는 방식은 다르지만, 모두 “알림을 보낸다”는 공통 역할을 가집니다. 이 공통 역할을 부모 클래스로 만들 수 있습니다.

class Notification {
    void send() {
        System.out.println("기본 알림을 보냅니다.");
    }
}

이제 구체적인 알림 클래스를 자식 클래스로 만듭니다.

class EmailNotification extends Notification {
    @Override
    void send() {
        System.out.println("이메일 알림을 보냅니다.");
    }

    void attachFile() {
        System.out.println("파일을 첨부합니다.");
    }
}

class SmsNotification extends Notification {
    @Override
    void send() {
        System.out.println("문자 알림을 보냅니다.");
    }

    void countTextLength() {
        System.out.println("문자 길이를 확인합니다.");
    }
}

EmailNotificationSmsNotification은 모두 Notification을 상속합니다. 그리고 send() 메서드를 각자 방식에 맞게 오버라이딩합니다.


부모 타입으로 자식 객체를 담을 수 있습니다

일반적으로는 변수 타입과 생성하는 객체 타입을 맞춰서 작성합니다.

EmailNotification email = new EmailNotification();
SmsNotification sms = new SmsNotification();

하지만 상속 관계에서는 부모 타입 변수에 자식 객체를 담을 수 있습니다.

Notification notification1 = new EmailNotification();
Notification notification2 = new SmsNotification();

이것을 다형적 참조라고 합니다. 부모 타입 하나로 여러 자식 객체를 참조할 수 있기 때문입니다.

Notification 타입 변수
  ├─ EmailNotification 객체 참조 가능
  └─ SmsNotification 객체 참조 가능

이 문장을 먼저 기억하면 좋습니다.

부모 타입은 자식 객체를 담을 수 있습니다.


부모 타입으로 볼 때 사용할 수 있는 기능

부모 타입 변수로 자식 객체를 담으면, 실제 객체는 자식 객체입니다. 하지만 변수의 타입은 부모 타입입니다.

Notification notification = new EmailNotification();

notification.send();
// notification.attachFile(); // 컴파일 오류

notification이 실제로는 EmailNotification 객체를 가리키고 있습니다. 그래도 변수 타입이 Notification이기 때문에, Notification에 있는 기능을 기준으로 사용할 수 있습니다.

send()는 부모 클래스에 있으므로 호출할 수 있습니다. 하지만 attachFile()EmailNotification에만 있는 기능이므로 부모 타입 변수로는 바로 호출할 수 없습니다.

이 부분이 다형적 참조를 처음 배울 때 가장 헷갈리는 지점입니다.

실제 객체: EmailNotification
변수 타입: Notification
호출 가능 기준: 변수 타입인 Notification

메서드 오버라이딩은 실제 객체 기준으로 실행됩니다

부모 타입 변수로는 부모 클래스에 있는 메서드만 호출할 수 있습니다. 그런데 호출한 메서드가 자식 클래스에서 오버라이딩되어 있다면 결과는 달라집니다.

public class Main {
    public static void main(String[] args) {
        Notification notification1 = new EmailNotification();
        Notification notification2 = new SmsNotification();

        notification1.send();
        notification2.send();
    }
}

실행 결과입니다.

이메일 알림을 보냅니다.
문자 알림을 보냅니다.

변수 타입은 둘 다 Notification입니다. 하지만 실제 객체는 각각 EmailNotification, SmsNotification입니다.

오버라이딩된 메서드는 실제 객체 기준으로 실행됩니다. 그래서 같은 send() 호출이지만 서로 다른 결과가 나옵니다.

이것이 다형성의 중요한 힘입니다.


업캐스팅

자식 타입을 부모 타입으로 바꾸는 것을 업캐스팅이라고 합니다.

EmailNotification email = new EmailNotification();
Notification notification = email;

아래처럼 직접 캐스팅을 적어도 됩니다.

Notification notification = (Notification) email;

하지만 업캐스팅은 안전하기 때문에 보통 생략합니다.

Notification notification = email;

자식 객체를 만들면 부모 부분도 함께 만들어집니다. 그래서 자식 객체를 부모 타입으로 보는 것은 문제가 없습니다.

EmailNotification 객체
  ├─ Notification 부분
  └─ EmailNotification 부분

부모 쪽으로 올라가서 보는 것이므로 업캐스팅은 자연스럽고 안전합니다.


다운캐스팅

부모 타입으로 보고 있던 객체를 다시 자식 타입으로 바꾸는 것을 다운캐스팅이라고 합니다.

Notification notification = new EmailNotification();

EmailNotification email = (EmailNotification) notification;
email.attachFile();

notification 변수 타입은 Notification입니다. 그래서 attachFile()을 바로 호출할 수 없습니다.

자식 클래스의 기능을 사용하려면 EmailNotification으로 다운캐스팅해야 합니다.

다운캐스팅은 아래처럼 한 번만 임시로 사용할 수도 있습니다.

Notification notification = new EmailNotification();

((EmailNotification) notification).attachFile();

하지만 다운캐스팅은 항상 조심해야 합니다. 실제 객체가 해당 자식 타입이 아닐 수도 있기 때문입니다.


잘못된 다운캐스팅은 런타임 오류가 납니다

아래 코드는 컴파일은 될 수 있지만 실행 중에 문제가 생깁니다.

Notification notification = new SmsNotification();

EmailNotification email = (EmailNotification) notification;
email.attachFile();

notification 변수는 부모 타입입니다. 그래서 문법상 EmailNotification으로 캐스팅을 시도할 수 있습니다.

하지만 실제 객체는 SmsNotification입니다. SmsNotification 객체 안에는 EmailNotification 부분이 없습니다.

그래서 실행 중에 ClassCastException이 발생합니다.

Notification notification = new SmsNotification();

실제 객체: SmsNotification
시도한 캐스팅: EmailNotification
결과: 불가능

업캐스팅은 안전하지만, 다운캐스팅은 실제 객체 타입을 잘못 판단하면 런타임 오류가 날 수 있습니다.


instanceof로 타입 확인하기

다운캐스팅을 하기 전에 실제 객체 타입을 확인하고 싶을 때 instanceof를 사용합니다.

static void handle(Notification notification) {
    notification.send();

    if (notification instanceof EmailNotification) {
        EmailNotification email = (EmailNotification) notification;
        email.attachFile();
    }
}

이 코드는 notification이 실제로 EmailNotification 객체일 때만 다운캐스팅합니다. 그래서 잘못된 캐스팅 위험을 줄일 수 있습니다.

자바 16 이후 문법을 사용하면 더 짧게 작성할 수 있습니다.

static void handle(Notification notification) {
    notification.send();

    if (notification instanceof EmailNotification email) {
        email.attachFile();
    }
}

notification instanceof EmailNotification email은 두 가지 일을 함께 합니다.

  • 실제 객체가 EmailNotification인지 확인합니다.
  • 맞다면 email 변수를 바로 사용할 수 있게 합니다.

처음에는 아래 형태부터 익숙해져도 충분합니다.

if (notification instanceof EmailNotification) {
    EmailNotification email = (EmailNotification) notification;
    email.attachFile();
}

필드는 오버라이딩되지 않습니다

다형성을 배울 때 메서드와 필드의 차이도 중요합니다. 메서드는 오버라이딩되지만, 필드는 오버라이딩된다고 생각하지 않는 편이 좋습니다.

아래 코드를 보겠습니다.

class ParentMessage {
    String label = "부모";

    void print() {
        System.out.println("부모 메서드");
    }
}

class ChildMessage extends ParentMessage {
    String label = "자식";

    @Override
    void print() {
        System.out.println("자식 메서드");
    }
}

실행 코드입니다.

public class Main {
    public static void main(String[] args) {
        ParentMessage message = new ChildMessage();

        System.out.println(message.label);
        message.print();
    }
}

실행 결과입니다.

부모
자식 메서드

message의 변수 타입은 ParentMessage입니다. 그래서 필드 label은 부모 타입 기준으로 접근합니다.

하지만 print()는 자식 클래스에서 오버라이딩되어 있습니다. 오버라이딩된 메서드는 실제 객체 기준으로 실행되므로 자식 메서드가 출력됩니다.

정리하면 다음과 같습니다.

구분기준
필드 접근변수 타입 기준
오버라이딩 메서드 호출실제 객체 기준

실무 코드에서는 같은 이름의 필드를 부모와 자식에 중복해서 두는 상황을 피하는 것이 좋습니다. 헷갈리기 쉽고 의도도 흐려지기 때문입니다.


다형성을 왜 사용할까?

다형성을 사용하면 여러 자식 클래스를 부모 타입 하나로 다룰 수 있습니다.

예를 들어 알림 목록을 배열로 관리할 수 있습니다.

public class Main {
    public static void main(String[] args) {
        Notification[] notifications = {
            new EmailNotification(),
            new SmsNotification(),
            new AppPushNotification()
        };

        for (Notification notification : notifications) {
            notification.send();
        }
    }
}

class AppPushNotification extends Notification {
    @Override
    void send() {
        System.out.println("앱 푸시 알림을 보냅니다.");
    }
}

실행 결과입니다.

이메일 알림을 보냅니다.
문자 알림을 보냅니다.
앱 푸시 알림을 보냅니다.

반복문은 Notification 타입만 알고 있습니다. 하지만 실제 실행 결과는 각 객체의 send() 구현에 따라 달라집니다.

나중에 새로운 알림 방식이 추가되어도, Notification을 상속하고 send()를 오버라이딩하면 같은 흐름에 넣을 수 있습니다.


실수하기 쉬운 부분

다형성을 처음 배울 때는 아래를 자주 헷갈립니다.

  • 부모 타입 변수로 자식 객체를 담을 수 있습니다.
  • 부모 타입 변수로는 부모에 정의된 기능을 기준으로 호출합니다.
  • 오버라이딩된 메서드는 실제 객체 기준으로 실행됩니다.
  • 업캐스팅은 안전해서 생략할 수 있습니다.
  • 다운캐스팅은 실제 객체 타입이 맞을 때만 해야 합니다.
  • 다운캐스팅 전에는 instanceof로 확인하는 편이 안전합니다.
  • 필드는 오버라이딩 메서드처럼 동작하지 않습니다.

특히 중요한 문장은 이것입니다.

호출 가능한지는 변수 타입이 결정합니다.
실행되는 오버라이딩 메서드는 실제 객체가 결정합니다.

이 문장을 이해하면 다형적 참조와 오버라이딩이 훨씬 덜 헷갈립니다.


정리

다형성은 부모 타입으로 여러 자식 객체를 다룰 수 있게 해주는 객체 지향의 중요한 개념입니다.

부모 타입 변수는 자식 객체를 참조할 수 있습니다. 이것을 다형적 참조라고 합니다.

업캐스팅은 자식 타입을 부모 타입으로 보는 것이며 안전합니다. 다운캐스팅은 부모 타입으로 보던 객체를 자식 타입으로 다시 보는 것이며, 실제 객체 타입이 맞지 않으면 런타임 오류가 발생할 수 있습니다.

instanceof는 다운캐스팅 전에 실제 타입을 확인할 때 사용합니다.

마지막으로 오버라이딩된 메서드는 실제 객체 기준으로 실행됩니다. 이 성질 덕분에 같은 부모 타입으로 묶어도, 각 자식 객체는 자기 방식대로 동작할 수 있습니다.