자바 제네릭 타입과 extends 키워드로 경계 설정하기
by Jeongjin Kim
2026-10-11 업데이트:
extend를 올바른 키워드인extends로 고치고, 컴파일 오류와 실행 오류를 구분했습니다. 생략되어 있던 예제의 필드와 메서드를 보완하고 raw type, 타입 소거, 와일드카드와의 차이를 추가했습니다.적용 범위: Java의 기본 제네릭 문법을 다룹니다. 아래 독립 실행 예제는 Java 17 이상에서 사용할 수 있으며, 실제 검증 환경과 결과는 글 아래에 따로 적었습니다.
<T extends Number>는 T에 Number 또는 그 하위 타입만 받을 수 있다는 뜻입니다. 범위를 제한하면 컴파일러가 잘못된 타입 인자를 거절할 뿐 아니라, 클래스 안에서 T 값의 doubleValue() 같은 메서드를 안전하게 호출할 수 있습니다.
이 문법이 왜 필요한지 Object로 만든 박스부터 차근차근 살펴보겠습니다.
1. Object로 받으면 잘못된 사용을 실행할 때 발견할 수 있습니다
Object value = "내 문자열";
Integer number = (Integer) value; // 컴파일은 되지만 실행하면 ClassCastException
컴파일러는 value의 선언 타입인 Object를 보고 캐스트를 허용합니다. 그러나 실제 객체는 String이므로 실행할 때 실패합니다.
반대로 선언 타입 자체가 String이라면 상황이 다릅니다.
String value = "내 문자열";
Integer number = (Integer) value; // 컴파일 오류: String을 Integer로 캐스트할 수 없음
기존 글에는 이 경우에도 컴파일된다는 문장이 있었는데 잘못된 설명이었습니다. 컴파일 타임에 거절되는 코드와, 컴파일 후 실행 중 실패하는 코드는 구분해야 합니다.
2. GenericBox<T>로 타입 안전성과 재사용성을 함께 확보하기
class GenericBox<T> {
private T data;
public void setData(T data) {
this.data = data;
}
public T getData() {
return data;
}
}
메서드 안에서는 이렇게 사용합니다.
GenericBox<String> textBox = new GenericBox<>();
textBox.setData("내 문자열");
String text = textBox.getData(); // 캐스트가 필요하지 않음
// textBox.setData(10); // 컴파일 오류
// Integer n = textBox.getData(); // 컴파일 오류
- 선언의
T는 타입 파라미터(type parameter) 입니다. - 사용하는 쪽의
String은 타입 인자(type argument) 입니다. <>는 생성자 호출에 필요한 타입 인자를 컴파일러가 추론하도록 하는 다이아몬드 문법입니다.- 타입 인자에는
int,char같은 기본형 대신Integer,Character같은 참조 타입을 사용합니다.
StringBox, IntegerBox를 각각 복사해서 만들 필요 없이, 같은 클래스에 서로 다른 타입을 지정할 수 있습니다.
3. 숫자 기능이 필요하면 T extends Number
아무 경계도 선언하지 않은 T에서는 Object가 제공하는 기능만 사용할 수 있습니다. 어떤 사용자가 GenericBox<Integer>를 만든다는 사실만으로 클래스 선언 안의 T가 Integer가 되는 것은 아닙니다.
다음처럼 강제 캐스트해서 해결하려고 하면 다시 실행 오류가 생깁니다.
Object value = Integer.valueOf(5);
Double number = (Double) value; // ClassCastException
Integer 객체를 Double 객체로 캐스트하는 것은 숫자 값을 double로 변환하는 것과 다릅니다. 숫자로 제한하고 Number.doubleValue()를 사용하는 것이 이 예제의 해결 방법입니다.
아래 코드를 GenericsDemo.java 한 파일로 저장해 실행할 수 있습니다.
import java.util.Objects;
public class GenericsDemo {
static final class NumberBox<T extends Number> {
private final T value;
NumberBox(T value) {
this.value = Objects.requireNonNull(value, "value");
}
public T getValue() {
return value;
}
public double average(NumberBox<T> other) {
Objects.requireNonNull(other, "other");
return (value.doubleValue() + other.value.doubleValue()) / 2.0;
}
}
public static void main(String[] args) {
NumberBox<Integer> first = new NumberBox<>(5);
NumberBox<Integer> second = new NumberBox<>(8);
System.out.println(first.average(second));
NumberBox<Double> third = new NumberBox<>(5.0);
NumberBox<Double> fourth = new NumberBox<>(8.0);
System.out.println(third.average(fourth));
// NumberBox<String> invalid = new NumberBox<>("5"); // 컴파일 오류
}
}
javac -encoding UTF-8 --release 17 GenericsDemo.java
java GenericsDemo
출력:
6.5
6.5
Number는 인터페이스가 아니라 추상 클래스입니다. Integer, Double 등은 그 하위 타입이므로 허용되지만 String은 허용되지 않습니다. 주석 처리한 NumberBox<String> 줄을 활성화하면 컴파일 단계에서 거절됩니다.
이 예제의 average(NumberBox<T>)는 같은 타입 인자를 가진 박스끼리 계산하도록 선언했습니다. 따라서 NumberBox<Integer>에 NumberBox<Double>을 바로 전달할 수는 없습니다. 서로 다른 숫자 타입의 입력도 받고 싶다면 매개변수의 의도를 NumberBox<? extends Number>로 바꾸는 방법을 별도로 검토해야 합니다.
또한 doubleValue() 변환은 큰 정수나 정밀한 소수의 정보를 잃을 수 있습니다. 이 코드는 제네릭 경계를 설명하는 예제이지 금액 계산용 구현은 아닙니다.
4. 인터페이스의 경계에도 extends를 씁니다
제네릭 경계에서는 클래스와 인터페이스 모두 extends를 사용합니다.
class ComparableBox<T extends Comparable<T>> {
// T가 Comparable<T>를 구현한다는 조건
}
여러 조건은 &로 연결합니다. 클래스 경계가 있다면 맨 앞에 오고, 뒤에는 인터페이스 경계가 옵니다.
class OrderedNumberBox<T extends Number & Comparable<T>> {
}
Integer처럼 두 조건을 모두 만족하는 타입을 사용할 수 있습니다. 여기서 extends는 클래스의 단일 상속 선언과는 다른 타입 파라미터의 상한 경계 문법입니다.
5. 경계와 와일드카드는 다른 자리에서 사용합니다
두 문법은 역할을 구분해서 읽으면 이해하기 쉽습니다.
class NumberBox<T extends Number>: 클래스를 선언하며 T가 만족할 조건을 정합니다.NumberBox<? extends Number>: 클래스를 사용하며 구체적인 타입 인자를 모르는 숫자 박스를 표현합니다.? super Integer: Integer 또는 그 상위 타입을 타입 인자로 갖는 범위를 나타내는 하한 와일드카드입니다. 타입 파라미터를<T super Integer>처럼 선언하는 문법은 없습니다.
Integer가 Number의 하위 타입이라고 해서 GenericBox<Integer>가 GenericBox<Number>의 하위 타입이 되지는 않습니다.
GenericBox<Integer> integers = new GenericBox<>();
// GenericBox<Number> numbers = integers; // 컴파일 오류
GenericBox<? extends Number> numbers = integers; // 가능
읽고 쓰는 방향에 따라 와일드카드를 선택하는 법은 와일드카드 사용 방법과 super 와일드카드·PECS 원칙에서 이어서 설명합니다.
6. Raw type과 타입 소거도 함께 기억하세요
다음 코드는 문법적으로 허용되는 raw type 사용입니다. 곧바로 컴파일 오류가 난다고 설명하면 정확하지 않습니다.
GenericBox raw = new GenericBox<String>();
raw.setData(10); // unchecked 경고: 원래 의도한 타입 제약을 우회
새 코드에서는 GenericBox<String>처럼 타입 인자를 유지하세요. 필요하면 javac -Xlint:rawtypes,unchecked로 경고를 확인할 수 있습니다.
제네릭을 사용한다고 실행 시점에 String 전용 클래스와 Integer 전용 클래스가 각각 새로 만들어지는 것은 아닙니다. 컴파일러는 타입 소거(type erasure) 를 적용합니다. 경계가 없는 T는 Object, 경계가 있는 T는 왼쪽 첫 경계에 해당하는 타입으로 소거되고, 필요한 캐스트와 브리지 메서드가 추가됩니다.
따라서 제네릭의 핵심은 “실행 시 타입별 클래스를 자동 생성한다”가 아니라 컴파일 시 타입 관계를 검사해 잘못된 사용을 줄인다는 데 있습니다.
7. 처음의 복잡한 선언을 다시 읽어보기
다음은 Collections.max의 타입 관계를 설명하기 위해 단순화한 메서드 선언입니다.
public static <T extends Comparable<? super T>> T max(Collection<? extends T> values)
<T ...>: 이 메서드가 사용할 타입 파라미터를 선언합니다.T extends Comparable<? super T>: T가 자기 자신 또는 상위 타입을 비교 대상으로 하는 Comparable 계약을 만족해야 합니다.Collection<? extends T>: T 또는 그 하위 타입을 원소로 갖는 컬렉션을 받습니다.- 반환 타입
T: 가장 큰 원소를 T 타입으로 돌려줍니다.
실제 Java API 선언에는 Object &도 포함됩니다. 위 한 줄은 메서드 본문을 생략한 설명용 시그니처이며, 단독 실행 코드는 아닙니다.
제네릭 메서드와 타입 추론을 함께 읽으면 클래스의 타입 파라미터와 메서드의 타입 파라미터를 구분하는 데 도움이 됩니다.
검증 범위와 정리
2026-10-11에 OpenJDK 21.0.2 환경에서 javac --release 17 옵션으로 독립 실행 예제를 컴파일하고, 같은 JDK에서 6.5가 두 줄 출력되는 것을 확인했습니다. NumberBox<String>, String에서 Integer로의 캐스트, 서로 다른 타입의 숫자 박스 전달, 제네릭 불공변성은 각각 컴파일 실패를 확인했습니다. Object를 거친 잘못된 캐스트의 실행 오류와 raw type의 경고도 확인했습니다.
이 JDK 버전은 예제 재현에 사용한 고정 환경이며 최신 보안 패치 버전이라는 뜻은 아닙니다. 실제 서비스에서는 유지보수 중인 JDK의 최신 보안 업데이트를 적용하세요. 컴파일러에 따라 오류 메시지 문구는 다를 수 있습니다.
- 제네릭은 타입 안전성을 유지하면서 같은 코드를 재사용하는 도구입니다.
- 올바른 키워드는
extend가 아니라extends입니다. <T extends Number>로 제한하면Number의 기능을 사용할 수 있습니다.- 강제 캐스트, raw type, 와일드카드, 타입 소거를 구분해야 예제 밖에서도 안전하게 사용할 수 있습니다.
공식 참고 자료
Subscribe via RSS