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) 내부에서는 다음과 같이 대체됩니다:
- 제약이 없는 T 는 ObjectObject로 변환됩니다.
- 만약 T에 상한(bound)이 있다면 해당 상한 타입으로 변환됩니다(예: T extends Number라면 Number).
따라서, 위 예제의 Holder<T>에서
T value;
는 컴파일 후 다음과 같은 필드 선언으로 바뀝니다:
Object value;
이것이 바로 런타임에 value가 String이어도 컴파일러 오버로딩 판단에는 반영되지 않는 이유 중 하나입니다. 바이트코드 관점에서 Holder.class 파일을 열어 보면 value 필드의 타입이 Ljava/lang/Object;로 표시됩니다.
핵심 정리
- Java에서 오버로딩은 컴파일 시점에 결정됩니다.
- 제네릭 타입 T는 컴파일러가 타입을 정확히 알 수 없기 때문에 가장 범위가 넓은 메서드로 선택됩니다.
- 클래스 내부에서 제네릭 타입은 실제 타입으로 치환되지 않고 독립적으로 컴파일됩니다.
- 정확한 메서드를 호출하려면 명시적 캐스팅을 사용해야 합니다.
'JVM' 카테고리의 다른 글
[Java] 메서드와 필드 접근: 런타임과 컴파일 타임의 차이 (1) | 2025.06.05 |
---|---|
Spring 서버가 자체 서명 SSL 서버와 통신할 때 생기는 문제와 해결 방법 (0) | 2025.05.29 |
[Java] 자바는 Call by Value (1) | 2025.05.17 |
[JVM] JDBC 쿼리 및 메서드 실행 로깅 (0) | 2024.08.17 |
[JVM] Jackson의 ObjectMapper: 객체 생성 방식과 필드 바인딩 (0) | 2024.08.10 |