타입 안전 이종 컨테이너 패턴
제네릭은 Set<E>
, Map<K,V>
등의 컬레션과 ThreadLocal<T>
, AtomicReference<T>
등의 단일원소 컨테이너에도 흔히 쓰인다.
여기서, 매개변수화되는 대상은 (원소가 아닌) 컨테이너 자신이다.
// ✅ Set이 매개변수화됨
Set<Integer> numberSet = new HashSet<>();
// ✅ List가 매개변수화됨
List<Integer> numberList = new ArrayList<>();
// ✅ Map이 매개변수화됨
Map<Integer, String> idToName = new HashMap<>();
Set
List
Map<>에서 제네릭이 적용된 대상은 Map이다.
타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern)
컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공한다.
예: Favorites 클래스
기대한 대로 "Java cafebabe Favorites"를 출력한다.
"Java"는 String 타입으로 저장되었으며,
0xcafebabe(16진수 정수)는 Integer 타입으로 저장되었고,
Favorites.class는 Class<?> 타입으로 저장되었음을 확인할 수 있다.
코드를 분석하면 다음과 같다.
Map<Class<?>, Object> 사용
favorites 필드는 키를 타입(Class<?>)으로, 값을 Object로 저장하는 Map<>이다.
이렇게 하면 다양한 타입의 객체를 저장할 수 있다.
제네릭 메서드 활용
- putFavorite(Class
type, T instance) : 타입 정보(Classtype )와 객체(T instance)를 함께 저장한다. - getFavorite(Class
type) : type.cast(favorites.get(type))을 사용하여 타입 안정성을 유지하며 객체를 반환한다.
타입 캐스팅 없이 안전한 조회
- T getFavorite(Class
type) 메서드는 타입을 추론하여 반환하므로, 호출 시 형변환이 필요 없다. - type.cast(...)를 사용하여 ClassCastException을 방지한다.
타입 안전 이종 컨테이너 패턴
타입 안전 이종 컨테이너 패턴은 Map<Class<?>, Object>
를 사용하여 여러 타입의 객체를 안전하게 저장하는 방식이다.
포인트는 제네릭 메서드를 활용하여 타입 정보를 함께 저장하고, 이를 통해 객체를 안전하게 반환하는 것이다.
위의 코드를 보면 알 수 있듯이, 다음과 같은 장점이 있다.
- 타입 안정성(Type Safety) : 컴파일 타임에서 타입 검사가 이루어져 런타임 오류를 방지할 수 있다.
- 유연성(Flexibility) : 하나의 컨테이너에서 여러 타입의 객체를 관리할 수 있다.
- 형변환 불필요(No Explicit Casting) : getFavorite(Class
type )을 호출할 때 형변환이 필요 없다.
참고
타입토큰 : 컴파일 타임 정보와 런타임 타입 정보를 알아내기 위해 메서드들이 주고받는 class 리터럴.
✔️ Class<T>
는 Class<?>
에 안전하게 들어갈 수 있다.
여기서 와일드 카드 타입으로 put 할수 없다고 생각할 수 있지만, 이때 키가 와일드 카드 타입이기 때문에 넣을 수 있다.
private Map<Class<?>, Object> favorites = new HashMap<>();
일반적으로 와일드카드(?)는 불특정한 타입을 의미하기 때문에 타입 안정성을 보장할 수 없는 경우 put이 불가능한 경우가 많다.
그러나 이번 경우에는 Class<?>가 키 타입이므로 put이 가능하다.
Map<Class<?>, Object>
를 보면 알 수 있다.
Class<?> → 임의의 클래스 타입을 의미하는 제네릭 표현이다.
Object → 해당 클래스 타입과 관련된 인스턴스를 저장할 수 있다.
즉, favorites는 임의의 Class<T>
를 키로 사용하고, 해당 타입의 인스턴스를 Object로 저장하는 구조입니다.
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), instance);
}
type의 타입은 Class
Class<T>는 Class<?>의 하위 타입이므로, Class<T>를 Class<?>으로 안전하게 저장할 수 있다.
🤔 Class<T>
는 Class<?>
에 안전하게 들어갈 수 있는 이유
Class<?>
는 임의의 Class<T>
를 의미한다.
Class<T>
는 Class<?>의 하위 타입(Class<? super T>이 아님)
이다.
즉, 모든 Class<T>는 Class<?>에 안전하게 할당
할 수 있다.
와일드카드인 ?
는 임의의 타입을 나타내며, 정해지지 않은 unknown type으로, 타입에 제한이 없어 이를 비제한 와일드카드 타입이라고 한다.
🚫 putFavorite(Class<?> type, T instance)로 하면 안된다.
public <T> void putFavorite(Class<?> type, T instance) { // ❌ 문제 발생 가능
favorites.put(type, instance);
}
type의 타입이 Class<?>
이므로, 정확한 T의 타입을 알 수 없다.
즉, T가 어떤 타입인지 알 수 없으므로, instance를 안전하게 favorites에 저장할 수 없다.
컴파일러 입장에서는 Class<?>
가 특정한 T를 보장하지 않으므로, put(type, instance); 호출이 안전하지 않다고 판단하여 오류가 발생할 수 있다.
✔️ Class<T>
Java의 Class<T>
는 제네릭 클래스로, 특정 타입 T에 대한 Class 객체를 나타낸다.
즉, Class<T>
는 해당 타입에 대한 메타정보(클래스 정보)를 담고 있으며, 런타임에서 타입 변환을 안전하게 수행할 수 있도록 돕는다.
public final class Class<T> {
T cast(Object obj);
}
이 구조에서 cast 메서드는 주어진 Object가 타입 T와 호환되는지 확인한 후 변환하는 역할을 한다.
cast(Object obj)의 동작 방식은 다음과 같다.
- cast(Object obj)는 런타임에서 타입 안정성을 보장하며, obj가 T 타입으로 변환 가능할 경우 반환한다.
- 변환이 불가능한 경우 ClassCastException을 던집니다.
Favorites 클래스의 getFavorite()를 다시 보자!
public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
getFavorite 구현은 Class cast 메서드를 사용해 이 객체 참조를 Class 객체가 가리키는 타입으로 동적 형변환한다.
👌 cast()를 이용한 안전한 형변환
String.class.cast(obj)는 obj가 String일 경우 변환을 수행하고, 그렇지 않으면 예외를 던진다.
🚫 cast()가 실패하는 경우
obj가 Integer가 아니므로 ClassCastException이 발생한다.
타입 안전 이종 컨테이너의 제약
제약 <1> Class 객체를 로타입으로 넘기면 Favorites 인스턴스의 타입 안정성이 쉽게 깨진다.
악의적인 클라이언트가 Class 객체를 (제네릭이 아닌) 로타입으로 넘기면 Favorites 인스턴스의 타입 안정성이 쉽게 깨진다.
Favorites f = new Favorites();
f.putFavorite((Class) Integer.class, "Integer의 인스턴스가 아니다."); // ❌ 타입 불일치 허용
int favoriteInteger = f.getFavorite(Integer.class); // 🚨 ClassCastException 발생!
putFavorite(Class<T> type, T instance)
의 타입 파라미터 T
는 정상적으로 사용하면 type
과 instance가 같은 타입이어야 한다.
하지만 (Class) Integer.class를 전달하면 ClassClass<?>
로 처리된다.
그 결과 T의 타입 정보가 손실되고, T는 Integer로 추론되지 않고 Object로 취급될 수 있다.
따라서 String(잘못된 값)이 Integer.class를 키로 하여 저장된다. (타입 불일치 발생)
getFavorite(Integer.class)를 호출하면 내부에서 type.cast(favorites.get(type))가 실행되는데,
favorites.get(type)이 String이므로 Integer로 변환할 수 없어 ClassCastException 발생한다.
type.cast(instacne)와 같은 동적 형변환을 넣어, 런타임 타입 안정성을 확보한다.
public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), type.cast(instance));
}
제약 <2> 실체화 불가 타입에는 사용할 수 없다.
실체화 불가 타입이란 런타임 시점에 타입 정보가 완전히 유지되지 않는 타입을 의미한다.
실체화 불가 타입은 런타임 시점에 List로만 유지되며, List<String>
인지 List<Integer>
인지 구별할 수 없다.
Favorites f = new Favorites();
// ❌ List<String>의 Class 객체를 얻을 수 없다.
f.putFavorite(List<String>.class, List.of("Java", "Generics"));
List<String> favoriteList = f.getFavorite(List<String>.class);
실체화 불가 타입을 Class
List<String>.class
라는 표현은 존재하지 않는다.- 제네릭 타입의 타입 파라미터(T)는 런타임에 지워지기 때문이다.
List<String>.class
도 List.class와 같다.
- 즉,
List<String>
과List<Integer>
가 동일한 List.class로 취급되기 때문에 타입 구별 불가능하다.
요약하면 다음과 같다.
- 실체화 불가 타입(
List<String>
,List<Integer>
)을Class<T>
로 사용할 수 없다. - 제네릭 타입은 런타임에 타입 정보가 지워지므로
List<String>.class
는 존재하지 않는다.
한정적 타입 토큰
한정적 타입 매개변수나 한정적 와일드카드를 사용하여 표현가능한 타입을 제한하는 타입토큰이다.
애너테이션 API는 한정적 타입 토큰을 적극적으로 사용한다.
public <T extends Annotation> T getAnnotation(Class<T> annotationType)
annotationType 인수: 애너테이션 타입을 뜻하는 한정적 타입 토큰이다.
대상 요소에 달려있는 애너테이션을 런타임에 읽어오는 기능을 한다.
이 메서드는 토큰으로 명시한 타입의 애너테이션이 대상 요소에 달려있으면 그 애너테이션을 반환하고, 없다면 null을 반환한다.
Class<?>
타입의 객체를 한정적 타입 토큰을 받는 메서드에 넘기고 싶을 때
Class<?>
타입의 객체가 있고, 이를 (getAnnotation처럼) 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 할까?
asSubclass 메서드 로 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환한다.
- 형변환 성공 : 인수로 받은 클래스 객체를 반환한다.
- 실패 : ClassCastException을 던진다.
예: asSubclass를 사용해 한정적 타입 토큰을 안전하게 형변환한다.
컴파일 시점에는 타입을 알 수 없는 애너테이션을 asSubclass 메서드를 사용해 런타임을에 읽어낸다.
이 메서드는 오류나 경고 없이 컴파일된다.
다시 정리하자면, 아래와 같다.
타입 토큰(Type Token)
타입 토큰(Type Token)은 제네릭 타입 정보를 런타임까지 유지하기 위한 것이다.
자바는 제네릭 타입 정보를 런타임에 삭제(타입 소거, Type Erasure)하기 때문에, 특정 타입 정보를 유지하고 활용하려면 Class<T>
를 타입 토큰으로 활용할 수 있다.
Class<String> stringType = String.class;
Class<Integer> integerType = Integer.class;
위와 같이 Class<T>
객체를 사용하면 컴파일 타임에 타입 안전성을 확보하면서 런타임에도 타입 정보를 유지할 수 있다.
비한정적 타입 토큰 (Unbounded Type Token)
Class<?> annotationType = null; // 비한정적 타입 토큰 (Unbounded Type Token)
Class<?>
는 비한정적 타입 토큰(Unbounded Type Token)이다.
즉, 어떤 타입(Class<?> 객체
)이든 저장 가능하지만, 특정한 타입을 보장하지는 않는다.
예를 들어 Class<?>
타입 변수에 String.class, Integer.class, Annotation.class 등 어떤 클래스 객체도 할당 가능하다.
단점으로, Class<?>
는 어떤 타입인지 알 수 없으므로 특정 타입의 메서드를 안전하게 호출할 수 없다.
Class<?> type = String.class;
type.getDeclaredMethods(); // 가능 ✅
String str = type.cast("hello"); // 가능 ✅
// String을 기대하지만, Integer를 넣어도 컴파일 오류 없음!
type = Integer.class; // 문제 없음 ❌ 타입 안정성 깨짐!
한정적 타입 토큰 (Bounded Type Token)
annotationType.asSubclass(Annotation.class);
한정적 타입 토큰(Bounded Type Token)이란, 특정 상위 타입을 제한
(제한된 범위에서만 사용 가능)하는 Class<T>
이다.
위 코드에서 Class<?>
를 Class<? extends Annotation>
으로 변환함으로써 어노테이션 타입으로 한정한다.
한정적 타입 토큰을 사용하는 이유
타입 안정성 확보 → annotationType이 반드시 Annotation의 하위 타입이어야 한다는 제한을 추가한다.
잘못된 타입 입력 방지 → annotationType이 Annotation을 상속하지 않으면 ClassCastException 방지한다.
메서드 호출 가능 → Annotation 관련 메서드를 안전하게 호출할 수 있다.
한정적 타입 토큰 사용 예제 코드는 아래와 같다.
T extends Number로 Number의 하위 클래스만 허용하므로 정상적으로 출력되지만,
String.class를 전달하면 컴파일 오류 발생한다.
핵심정리
일반적인 제네릭 컨테이너(List<T>
,Set<T>
등)는 동일한 타입의 객체만 저장할 수 있지만,
타입 안전 이종 컨테이너(Typesafe Heterogeneous Container)는 다양한 타입의 객체를 타입 안전하게 저장할 수 있다.
제네릭을 활용하여 Map<Class<T>, T>
구조를 사용하면 타입 안정성을 보장할 수 있다.
즉, 하나의 컨테이너에 여러 타입을 안전하게 저장하고 싶다면, 타입 안전 이종 컨테이너를 고려하자.
책에 정리된 핵심정리
컬렉션 API로 대표되는 일반적인 제네릭 형태에서는 한 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다.
하지만 컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없는 타입 안전 이종 컨테이너를 만들 수 있다.
타입 안전 이종 컨테이너는 Class를 키로 쓰며, 이런 식으로 쓰이는 Class 객체를 타입 토큰이라 한다.
또한, 직접 구현한 키 타입도 쓸 수 있다. 예컨대 데이터베이스의 행(컨테이너)을 표현한 DatabaseRow 타입에는 제네릭 타입인 Column<T>
를 키로 사용할 수 있다.
'Dev Books > Effective Java' 카테고리의 다른 글
[item 35] ordinal 메서드 대신 인스턴스 필드를 사용하라 (0) | 2025.02.17 |
---|---|
[item 34] int 상수 대신 열거 타입을 사용하라 (0) | 2025.02.13 |
[item 32] 제네릭과 가변인수를 함께 쓸 때는 신중하라 (1) | 2025.02.06 |
[item 31] 한정적 와일드카드를 사용해 API 유연성을 높여라 (0) | 2025.02.05 |
[item 30] 이왕이면 제네릭 메서드로 만들라. (0) | 2025.02.04 |
댓글