JVM

[Java] 제네릭과 오버로딩, 왜 예상과 다를까?

kyoulho 2025. 6. 5. 17:14

Java에서 흔히 사용하는 제네릭과 오버로딩은 각각 명확한 규칙을 가지고 있지만, 함께 사용할 때는 가끔 예상과 다른 결과가 발생합니다. 이번 글에서는 왜 이런 일이 발생하는지, Java 컴파일러의 동작 원리까지 함께 살펴보겠습니다.

예제 코드 분석

class Display {
    void show(String s) {
        System.out.println("String: " + s);
    }

    void show(Object o) {
        System.out.println("Object: " + o);
    }
}

public class GenericExample {

    public static void main(String[] args) {
        Holder<String> holder = new Holder<>("Hello");
        holder.print();
    }

    static class Holder<T> {
        T value;

        Holder(T value) {
            this.value = value;
        }

        void print() {
            new Display().show(value);
        }
    }
}

이 코드를 실행하면 어떤 메서드가 호출될까요?

언뜻 보면, value가 String 타입이니까 당연히 show(String s)가 호출될 것 같지만, 실제로는...

Object: Hello

 

🤔 컴파일러는 왜 정확한 타입을 보지 못할까?

Java 컴파일러가 오버로딩을 결정할 때 사용하는 핵심 원칙은 "컴파일 시점에 선언된 타입을 기준으로 판단한다"입니다.

위 예제에서, 컴파일러는 제네릭 클래스 내부를 독립적으로 컴파일하기 때문에 T 타입 파라미터가 정확히 무엇인지 모르고 단지 "미지의 타입"으로 처리합니다. 즉, 컴파일 시점에서 value는 T라는 불확실한 타입으로 남아 있게 됩니다. 따라서 가장 넓은 범위에서 일치하는 메서드, 즉 show(Object o)를 선택하게 됩니다.

📌 컴파일러는 main 함수를 미리 확인하지 않을까?

만약 컴파일러가 main 함수까지 미리 분석하고 실제 타입(String)을 확인할 수 있다면 이 문제는 쉽게 해결될 것 같습니다. 하지만 Java는 그런 방식을 채택하지 않습니다.

  • Java 컴파일러는 각 클래스를 독립적으로 컴파일합니다.
  • 독립 컴파일 방식을 유지하면 클래스 간 복잡한 의존성을 피할 수 있고, 컴파일 속도와 유지보수성을 높일 수 있습니다.
  • 따라서 클래스 내부의 메서드를 컴파일할 때, 외부(main 함수 등) 호출 지점의 정보를 참고하지 않습니다.

이 방식으로 인해 제네릭 타입 T는 오버로딩 결정 시 실제 타입과 관계없이 항상 "알 수 없는 타입"으로 인식됩니다.


✅ 정확한 메서드를 호출하려면 어떻게 해야 할까?

메서드 오버로딩은 컴파일 시점에 결정되므로, 실행 시점의 타입을 기준으로 올바른 오버로드를 호출하고 싶다면 런타임에 타입을 확인해야 합니다. 예를 들어 Holder<T> 내부에서 다음과 같이 작성할 수 있습니다.

void print() {
    Display d = new Display();
    if (value instanceof String) {
        // 런타임에 value가 String이라면 show(String) 호출
        d.show((String) value);
    } else {
        // 그 외 타입은 Object 오버로드 호출
        d.show(value);
    }
}

이렇게 하면, value가 실제로 String일 때 show(String s)가 호출되어 원하는 대로 실행 시점 타입을 반영할 수 있습니다.

다만 위 예시는 단순한 런타임 타입 확인 방식이며, 상황에 따라 instanceof 대신 패턴 매칭(Java 16 이상)을 활용할 수도 있습니다:

void print() {
    Display d = new Display();
    if (value instanceof String s) {
        d.show(s); // 런타임에 String 오버로드 호출
    } else {
        d.show(value);
    }
}

이처럼 런타임에 타입을 체크하여 적절한 오버로드 메서드를 직접 호출하면, 컴파일러 오버로딩의 한계를 극복할 수 있습니다.


🧐 클래스 파일에서
value의 타입은?

Java 컴파일러는 제네릭 타입을 처리할 때 타입 소거(type erasure)라는 과정을 사용합니다. 이 과정에서 제네릭 파라미터 T컴파일 후 클래스 파일(.class) 내부에서는 다음과 같이 대체됩니다:

  • 제약이 없는 TObjectObject로 변환됩니다.
  • 만약 T에 상한(bound)이 있다면 해당 상한 타입으로 변환됩니다(예: T extends Number라면 Number).

따라서, 위 예제의 Holder<T>에서

T value;

는 컴파일 후 다음과 같은 필드 선언으로 바뀝니다:

Object value;

이것이 바로 런타임에 valueString이어도 컴파일러 오버로딩 판단에는 반영되지 않는 이유 중 하나입니다. 바이트코드 관점에서 Holder.class 파일을 열어 보면 value 필드의 타입이 Ljava/lang/Object;로 표시됩니다.


핵심 정리

  • Java에서 오버로딩은 컴파일 시점에 결정됩니다.
  • 제네릭 타입 T는 컴파일러가 타입을 정확히 알 수 없기 때문에 가장 범위가 넓은 메서드로 선택됩니다.
  • 클래스 내부에서 제네릭 타입은 실제 타입으로 치환되지 않고 독립적으로 컴파일됩니다.
  • 정확한 메서드를 호출하려면 명시적 캐스팅을 사용해야 합니다.