Generics가 없었던 시절에는 List에 값이 어떤 형태든 허용되었다.
그래서 위와 같이 코드를 짤 수 있었다.List list = new ArrayList(); list.add("Hello"); list.add(123); list.add(new User()); ㅤ String str = (String) list.get(1);위 코드를 실행하게 되면 ClassCastException이 발생한다.
이유는 리스트에서Integer타입의 값을 꺼내String으로 타입 캐스팅을 시도했기 때문이다.
Generics가 도입된 이후,
런타임에 발생하던 타입 관련 오류를 컴파일 시점에 잡을 수 있게 되었다.List<String> list = new ArrayList<>(); list.add("Hello"); list.add(123); // 컴파일 에러 ㅤ String str = list.get(0);위 코드에서 list.add(123)은 컴파일 단계에서 에러가 발생한다.
왜냐하면,List<String>으로 타입을 지정했기 때문에String이 아닌 값은 애초에 넣을 수 없기 때문이다.
또한 값을 꺼낼 때도 별도의 타입 캐스팅 없이 바로 사용할 수 있다.
클래스나 메서드에서 사용할 타입을 외부에서 지정할 수 있게 해주는 기능이다.
쉽게 말해, 타입을 파라미터처럼 전달받아 사용하는 것이다.// 제네릭 클래스 정의 public class Box<T> { private T item; ㅤ public void set(T item) { this.item = item; } ㅤ public T get() { return item; } } ㅤ // 사용 Box<String> stringBox = new Box<>(); stringBox.set("Hello"); String value = stringBox.get(); ㅤ Box<Integer> intBox = new Box<>(); intBox.set(123); Integer number = intBox.get();
| 기호 | 의미 | 예시 |
|---|---|---|
T | Type | Box<T> |
E | Element | List<E> |
K | Key | Map<K, V> |
V | Value | Map<K, V> |
public class Utils { ㅤ public static <T> T getFirst(List<T> list) { return list.get(0); } } ㅤ // 사용 List<String> names = List.of("Kim", "Lee", "Park"); String first = Utils.getFirst(names); // "Kim"여기서 주의할 점이 있다면,
Generic 메서드는 항상 메서드 반환 타입 앞에 타입 파라미터<T>를 넣어야 한다.
그 이유는<T>는 Java가 모르는 타입이라서,
Java에게 "이 메서드에서는<T>타입을 쓸거야" 라고 알려줘야하기 때문이다.
타입 파라미터에 특정 타입의 하위 타입만 올 수 있도록 제한하는 기능이다.
// 제한 없는 제네릭
public <T> void printDouble(T value) {
System.out.println(value.doubleValue()); // 컴파일 에러
}
<T>에는 아무 타입이나 올 수 있기 때문에, doubleValue() 메서드가 있는지 보장할 수 없다.
// Number의 하위 타입만 허용
public <T extends Number> void printDouble(T value) {
System.out.println(value.doubleValue());
}
<T extends Number>로 선언하면,<T>는 반드시Number의 하위 타입이어야 한다.
Number클래스에는 doubleValue() 메서드가 있으므로 안전하게 호출할 수 있다.
"타입 간의 상속 관계가 제네릭에서도 유지되는가?"에 대한 개념이다.
String은Object의 하위 타입이다.
그렇다면List<String>은List<Object>의 하위 타입일까?
A가B의 하위 타입이면,T<A>도T<B>의 하위 타입이다.즉, 상속 관계가 그대로 유지된다.
// 배열은 공변 Object[] objectArr = new String[3]; // 컴파일 성공 ㅤ Object[] objectArr = new String[3]; objectArr[0] = 123; // ArrayStoreException 발생위 코드를 보면 알 수 있듯이,
공변은 컴파일 시점에 문제가 발생하지 않고, 런타임 시점에 문제가 발생한다.
A가B의 하위 타입이어도,T<A>와T<B>는 아무 관계가 없다.즉, 상속 관계가 유지되지 않는다.
List<String> stringList = new ArrayList<>(); List<Object> objectList = stringList; // 컴파일 에러위 코드를 보면 알 수 있듯이,
무공변으로 코드를 짜면, 컴파일 단계에서 오류가 발생한다.
A가B의 하위 타입이면,T<B>가T<A>의 하위 타입이다.즉, 상속 관계가 반대로 뒤집힌다.
List<? super Integer> list1 = new ArrayList<Integer>(); List<? super Integer> list2 = new ArrayList<Number>(); List<? super Integer> list3 = new ArrayList<Object>();위 코드를 보면 알 수 있듯이,
Integer의 상위 타입인Number,Object를 담는 리스트도 대입이 가능하다.
ㅤ
일반적인 상속 관계는Integer → Number → Object순이지만,
반공변에서는List<Object> → List<Number> → List<Integer>순으로 뒤집힌다.
Wildcard는 제네릭에서 유연한 타입을 허용하기 위해 사용하는 기능이다.
?기호로 표현하며, "어떤 타입이든" 이라는 의미를 가진다.
public void printList(List<Object> list) { for (Object obj : list) { System.out.println(obj); } } ㅤ List<String> names = List.of("Kim", "Lee", "Park"); printList(names); // 컴파일 에러제네릭은 무공변이기 때문에
List<String>을List<Object>로 받을 수 없다.
ㅤpublic void printList(List list) { for (Object obj : list) { System.out.println(obj); } } ㅤ List<?> names = List.of("Kim", "Lee", "Park"); printList(names); // ✅ 가능!이를 위해서,
List<?>를 사용하면 어떤 타입의 리스트든 받을 수 있다.
T와 ?의 차이| 구분 | T (타입 파라미터) | ? (와일드카드) |
|---|---|---|
| 역할 | 타입을 선언해서 재사용 | 아무 타입이나를 의미 |
| 사용 | 여러 곳에서 같은 타입으로 묶을 때 | 일회성으로 타입을 받을 때 |
T - 타입을 묶어서 재사용public <T> void copy(List<T> src, List<T> dest) {
for (T item : src) {
dest.add(item);
}
}
src와dest가 같은 타입이어야 한다.
T로 선언했기 때문에 둘이 묶여있다.
? - 그냥 아무거나public void printAll(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
어떤 타입의 List든 상관없이 받겠다는 의미다.
타입을 재사용할 필요가 없을 때 사용한다.
| 종류 | 문법 | 의미 | 특징 |
|---|---|---|---|
| 비한정 | <?> | 모든 타입 허용 | 읽기만 가능 |
| 상한 경계 | <? extends T> | T와 T의 하위 타입만 허용 | 읽기만 가능 |
| 하한 경계 | <? super T> | T와 T의 상위 타입만 허용 | 쓰기만 가능 |
Producer-Extends, Consumer-Super
데이터를 읽을 때는
extends, 데이터를 쓸 때는super를 사용하라.
| 약자 | 의미 | 와일드카드 | 동작 |
|---|---|---|---|
| Producer | 데이터를 생산 (꺼내는 쪽) | <? extends T> | 읽기 |
| Consumer | 데이터를 소비 (넣는 쪽) | <? super T> | 쓰기 |
제네릭 타입 정보가 컴파일 시점에만 존재하고, 런타임에는 제거되는 것을 말한다.
하위 호환성 때문이다.
- 제네릭은 Java 5 (2004년)에 도입되었다.
- 그 전에 작성된 수많은 코드들과 호환되어야 했다.
- 그래서 런타임에는 제네릭 정보를 지우는 방식을 선택했다.
ResponseEntity<T>HTTP 응답을 표현하는 클래스로, Body의 타입을 제네릭으로 지정한다.
@GetMapping("/{id}") public ResponseEntity getUser(@PathVariable Long id) { UserDto user = userService.findById(id); return ResponseEntity.ok(user); } ㅤ @GetMapping public ResponseEntity<List> getUsers() { List users = userService.findAll(); return ResponseEntity.ok(users); }ㅤ
ResponseEntity<T>는HttpEntity<T>를 상속받아 구현되어 있다.
제네릭 덕분에 어떤 타입이든 Body로 감싸서 반환할 수 있다.
List<int> list = new ArrayList<>(); // 컴파일 에러
List<Integer> list = new ArrayList<>(); // Wrapper 클래스 사용
제네릭은 참조 타입만 사용할 수 있다.
기본형 대신 Wrapper 클래스(Integer,Double,Boolean등)를 사용해야 한다.
public void print(List<String> list) { }
public void print(List<Integer> list) { } // 컴파일 에러
타입 소거 후 둘 다
print(List list)가 되어 메서드 시그니처가 동일해진다.
문제
public class Box { public T createInstance() { return new T(); // 컴파일 에러 } }런타임에
T가 무슨 타입인지 모르기 때문에 인스턴스를 생성할 수 없다.해결 방법
public class Box { public T createInstance(Supplier supplier) { return supplier.get(); // Supplier를 통해 생성 } }