자바 제네릭, <T>만으로는 부족하다 - 타입 제한(extends)

제네릭(Generic)의 문제점
제네릭은 타입 자체를 매개변수화하여 클래스나 메서드를 다양한 타입에 대해 재사용할 수 있도록 하는 기능이다.
public interface List<E> extends SequencedCollection<E> { ...
//다양한 타입에 대해 재사용
List<String> list;
List<Integer> list;
List<List<Object>> list;
위 코드와 같이 List API를 다양한 타입으로 재사용할 수 있는 장점이 있지만,
아무런 제한이 없는 타입 매개변수는 컴파일러가 Object로 취급하기 때문에 호출할 수 있는 메서드에 제약을 받게 된다.
만약 제네릭 메서드에서 크기를 비교하는 compareTo 함수를 사용하고 싶어도 사용할 수가 없다.
static <T> T max(T a, T b) {
return a.compareTo(b) > 0 ? a : b; // ❌ Error
}
그 이유는 자바의 제네릭 메서드가 정의 하나만 컴파일되어 모든 호출을 처리하기 때문이다.
max(3, 5)가 호출되든 max("a", "b")가 호출되든 실행되는 바이트코드는 동일하다.
따라서 컴파일러는 정의 시점에 "T에 어떤 타입이 들어오더라도 안전한 연산인가"를 기준으로 검사할 수밖에 없고,
아무 제한이 없는 T에는 Thread나 Runnable처럼 compareTo가 없는 타입도 들어올 수 있으므로 이 호출을 허용하지 않는다.
참고로 Object 에 public 필드는 없으니 타입 매개변수를 통한 필드 접근은 애초에 불가능하다.
또한 위와 같은 특성으로 인해 "아무 타입이나 들어와도 막을 수 있는 방법이 없다"의 문제로 이어진다.
숫자 데이터를 받아서 계산하는 것이 목적인 클래스를 만들고 싶지만,
전혀 상관없는 타입이 들어와도 컴파일 시점에 막을 수가 없다.
public class Calculator<T> {
public double add(T a, T b) {
// T가 숫자인지 보장할 수 없어 Object의 기본 기능 외엔 사용 불가
// 결국 instanceof 등으로 런타임에 타입을 확인해야 하는 비효율 발생
if (a instanceof Number && b instanceof Number) {
return ((Number) a).doubleValue() + ((Number) b).doubleValue();
}
throw new IllegalArgumentException("숫자 타입만 지원합니다.");
}
}
// ❌ 사용 시 문제점:
Calculator<String> calc = new Calculator<>();
calc.add("hello", "world"); // 컴파일은 통과하지만, 런타임에 예외(Exception) 발생
해결: 타입 제한(extends)
이 두 가지 제약(사용 가능한 메서드의 제한, 잘못된 타입의 허용)을 깔끔하게 해결하는 방법이 바로 타입 매개변수의 범위를 제한(extends)하는 것이다.
쉽게 설명하면 타입 파라메터에 들어올 수 있는 타입을 정의하는 것이다.
상한 경계(Upper Bound) 설정
T extends SuperClass 구문을 사용하면 컴파일러에게 "T는 적어도 SuperClass이거나 그 자식 타입이다"라는 힌트를 줄 수 있다.
// T의 범위를 Comparable 인터페이스를 구현한 타입으로 제한
static <T extends Comparable<T>> T max(T a, T b) {
return a.compareTo(b) > 0 ? a : b; // ⭕ 컴파일 성공
}
타입 제한을 걸어두면 서두에서 언급했던 두 가지 문제가 한 번에 해결된다.
- 사용할 수 있는 메서드의 확장: 컴파일러는 T가 최소한 Comparable을 구현했음을 보장받으므로 안전하게 compareTo() 메서드 호출을 허용한다.
- 부적절한 타입의 차단: max(new Thread(), new Thread())와 같이 비교가 불가능한 타입을 전달하려 하면 런타임이 아닌 컴파일 시점에 에러를 발생시켜 차단한다.
참고: 자바는 클래스 상속(extends)과 인터페이스 구현(implements)을 제네릭에서 구분하지 않고 항상 extends로 통일
extends 키워드로 왜 가능한걸까?
컴파일이 끝나면 타입 매개변수는 지워진다.
이걸 타입 소거라고 한다.
class Box<T> { T value; } // → Object value;
class Calculator<T extends Number> { T value; } // → Number value;
타입 매개변수는 Object 가 아니라 상한 타입으로 소거된다.
- 상한이 없으면 → Object 로 소거 → Object 의 메서드만 호출 가능 (문제 발생)
- 상한이 Number 면 → Number 로 소거 → Number 의 메서드 호출 가능 (해결)
문제가 왜 생겼는지와 왜 해결됐는지가 같은 원리로 설명된다.
타입 소거는 파고들면 브리지 메서드, 힙 오염, new T() 금지 같은 깊은 내용이 많다.
다중 상한(Multiple)
만약 특정 클래스를 상속받음과 동시에 특정 인터페이스까지 구현한 타입만 받고 싶다면 & 연산자를 활용해 조건들을 조합할 수 있다.
// Number를 상속받으면서 동시에 Comparable도 구현한 타입만 허용
public class NumberStats<T extends Number & Comparable<T>> {
private T value;
public void checkPositive() {
// Number의 doubleValue()와 Comparable의 compareTo() 모두 사용 가능
if (value.doubleValue() > 0) {
System.out.println("양수입니다.");
}
}
}
주의: 클래스 상속과 인터페이스 구현을 함께 경계로 지정할 때, 클래스 타입은 반드시 가장 앞에 위치해야한다.
(T extends ClassA & InterfaceB)
제네릭 클래스 vs 제네릭 메서드 — 제한이 없을 때 문제의 "결"이 다르다
같은 "타입 제한 없음" 문제라도, 클래스냐 메서드냐에 따라 파급 효과가 다르다.
제네릭 클래스: 문제 전파
클래스의 T는 필드에 저장되고, 인스턴스가 살아있는 동안 계속 유지된다.
클래스 인스턴스를 생성할 때 T가 결정되면, 그 클래스 내부의 모든 필드와 메서드는 그 T에 귀속된다.
따라서 타입 제한을 걸지 않으면, 클래스 내부의 모든 메서드가 Object 메서드 수준만 사용 가능하다.
class EventQueue<T> {
private List<T> items = new ArrayList<>();
void add(T item) { items.add(item); }
void processAll() {
for (T item : items) {
item.getTimestamp(); // ❌ Error
}
// processAll() 뿐 아니라 앞으로 추가될
// sortByPriority(), filterExpired() 등 모든 메서드가 같은 벽에 부딪힘
}
}
참고: static 메서드는 특성상 인스턴스 생성 시점에 결정되는 클래스의 타입 매개변수를 아예 참조할 수 없다.
따라서 자기만의 타입 매개변수를 따로 선언해야 한다.
제네릭 메서드: 일회성
메서드의 T는 메서드가 호출되는 그 순간에만 잠깐 생겼다가 사라지는 일회성 타입 변수다.
class Utility {
// 1번 메서드: T는 Number의 자식이어야 함
static <T extends Number> double sum(T a, T b) { ... }
// 2번 메서드: T는 Comparable을 구현해야 함
static <T extends Comparable<T>> T max(T a, T b) { ... }
}
위 코드에서 sum()을 호출할 때 사용하는 T와 max()를 호출할 때 사용하는 T는 이름만 같을 뿐,
서로 아무런 상관이 없는 별개의 존재다.
메서드 스코프를 안다면 이것은 당연한 얘기다.
다만 제네릭 메서드의 경우 "타입 추론 모호성"이 발생할 수 있다.
클래스 제네릭은 new Box<User>() 처럼 개발자가 타입을 명시적으로 딱 지정해서 만든다.
반면, 제네릭 메서드는 보통 타입 지정을 생략하고 인자(파라미터)를 보고 컴파일러가 알아서 T를 추론한다.
이 추론 과정에서 컴파일 에러는 나지 않지만 의도와 다르게 동작하는 두 가지 케이스가 있다.
대표적인 예시로,
static <T extends Number> void print(T val) {
System.out.println("제네릭 버전 호출");
}
static void print(Integer val) {
System.out.println("Integer 전용 버전 호출");
}
print(10); // → "Integer 전용 버전 호출" 출력 (컴파일 에러 아님)
오버로드 메서드 케이스가 있다.
자바의 오버로드 해석은 여러 메서드가 동시에 적용 가능할 때 더 구체적인(non-generic) 시그니처를 우선 선택한다.
print(Integer)가 print(T extends Number)보다 더 구체적이므로, 컴파일러는 모호함 없이 print(Integer)를 고른다.
사일런트 에러가 발생할 수 있으니 주의해야한다.
또한,
static <T> T pick(T a, T b) {
return a;
}
var result = pick("Hello", 123); // 컴파일 에러 아님
이것도 정상 컴파일된다.
다만 T는 String도 Integer도 아닌, 둘의 공통 상위 타입(최소 상한)으로 추론된다.
정리
제네릭에서 타입 매개변수 T를 아무런 제약 없이 사용하는 것은 "모든 문을 열어두는 것"과 같다.
- 제한 없는 T: 의도치 않은 타입이 들어오는 것을 막지 못하고, 그 결과 내부에서는 Object 수준의 기능만 사용할 수 있다.
- 제한된 T (extends`): 타입 안전성을 확보하고, 그 대가로 해당 범위 내의 다양한 메서드를 자유롭게 활용할 수 있다.
컬렉션 프레임워크와 같이 저장/조회 기능만 하는 경우에는 타입 제한없이 단순 제네릭 클래스를 사용하는 케이스가 많다.
'Language > Java' 카테고리의 다른 글
| 자바 제네릭 정리: 타입 제한과 와일드카드는 서로 다른 문제를 푼다 (0) | 2026.09.20 |
|---|---|
| 자바 제네릭, 와일드카드(?)와 PECS 원칙 (0) | 2026.09.20 |
| 자바 제네릭 클래스(Java Generic Class) 개념 (0) | 2026.09.14 |
| Java Stream API (0) | 2026.09.07 |
| [기술서적] 객체지향의 사실과 오해 (0) | 2025.10.20 |
