[Effective Java] 타입 안전 이종 컨테이너를 고려하라

들어가며

제네릭은 Set<E>, Map<K,V>등의 컬렉션에도 흔히 쓰인다. 이런 모든 쓰임에서 매개변수화 되는 대상은 컨테이너 자신이다. 따라서 하나의 컨테이너에서 매개변수화할 수 있는 타입의 수가 제한된다. 하지만 더 유연한 수단이 필요할 때도 있다.

type safe heterogeneous container pattern

타입 안전 이종 컨테이너 패턴(type safe heterogeneous container pattern) 이란 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 넣거나 뺄 때 매개변수화한 키를 함께 제공하는 패턴이다. 이렇게 하면 제네릭 타입 시스템이 값의 타입이 키와 같음을 보장해준다.

다음은 간단한 예제 코드이다.

1
2
3
4
5
6
7
8
9
10
11
public class Favorites {
private Map<Class<?>, Object> favorites = new HashMap<Class<?>, Object>();

public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), Objects.requireNonNull(instance));
}

public <T> T getFavorite(Class<T> type) {
return type.cast(favorites.get(type));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
Favorites f = new Favorites();

f.putFavorite(String.class, "Java");
f.putFavorite(Integer.class, 0xcafebabe);
f.putFavorite(Class.class, Favorites.class);

String favoriteString = f.getFavorite(String.class);
int favoriteInteger = f.getFavorite(Integer.class);
Class<?> favoriteClass = f.getFavorite(Class.class);

System.out.printf("%s %x %s\n", favoriteString, favoriteInteger, favoriteClass.getName());
}

위 코드에서 각 타입의 Class 객체를 매개변수화한 키 역할로 사용하고 있는데 이 방식이 동작하는 이유는 class의 클래스가 제네릭이기 때문이다. class 리터럴의 타입은 Class가 아닌 Class\다. 예를들어 String.class의 타입은 Class\이고 Integer.class의 타입은 Class\인 방식이다.

또한 컴파일타임 타입 정보와 런타임 타입 정보를 알아내기 위해 메소드들이 주고받는 class 리터럴을 타입 토큰(type token)이라 한다.

Favorites 인스턴스는 타입 안전하다. String을 요청했는데 Integer를 반환하는 일은 절대없다. 또한 모든 키의 타입이 제각각이라 일반적인 맵과 달리 여러 가지 타입의 원소를 담을 수 있다. 따라서 Favorites는 타입 안전 이종 컨테이너라 할 만하다.

Favorites의 private Map variable을 보자. Map\, Object>이다. 비한정적 와일드카드 타입이라 이 맵 안에 아무것도 넣을 수 없다고 생각할 수 있지만 그 반대다. 와일드카드 타입이 중첩(nested) 되었다는 점을 깨달아야 한다. 맵이 아니라 키가 와일드카드 타입인 것이다. 이는 모든 키가 서로 다른 매개변수화 타입일 수 있다는 뜻으로 첫번째는 Class\, 두번째는 Class\식으로 될 수 있다. 다양한 타입을 지원하는 힘은 여기서 나온다.

그 다음으로 봐야할 부분은 favorites Map의 값 타입은 Object라는 것이다. 이는 이 맵은 키와 값 사이의 타입 관계를 보증하지 않는다.라는 이야기다. 자바의 타입 시스템에서는 이 관계를 명시할 방법이 없다. 하지만 우리는 이 관계가 성립함을 알고 있고 검색할 때 그 이점을 누린다.

putFavorites는 쉽다. 주어진 Class 객체와 즐겨찾기 인스턴스를 favorites에 추가해 관계를 지으면 끝이다. 키와 값 사이의 타입 링크(type link) 정보는 버려진다. 즉, 그 값이 그 키 타입의 인스턴스라는 정보가 사라진다. 하지만 getFavorite에서 이를 되살린다.

getFavorite는 중요하다. 먼저 주어진 Class 객체에 해당하는 값을 favorites Map 에서 꺼낸다. 이 객체가 바로 반환해야할 객체임은 분병하지만 잘못된 컴파일타임 타입을 가지고 있다. 이 객체의 타입은 Object이나 이를 T로 바꾸어야 한다. 따라서 getFavorite 구현은 Class의 cast 메소드를 사용해 이 객체 참조를 Class 객체가 가리키는 타입으로 동적 형 변환을 수행한다.

cast 메소드는 형 변환 연산자의 동적 버전이다. 이 메소드는 단순히 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지를 검사한 다음, 맞으면 인수를, 아니면 ClassCastException을 던진다. 클라이언트 코드가 깔끔히 컴파일 된다면 우리는 getFavorite이 ClassCastException을 던지지 않을 것임을 알고 있다. 그런데 cast 메소드가 인수를 그대로 반환한다면 이를 왜 사용할까? 그 이유는 cast 메소드의 시그니처가 Class 클래스가 제네릭 이라는 이점을 완벽히 활용하기 때문이다. 다음 코드를 보자.

1
2
3
public class Class<T> {
T cast(Object obj);
}

위 코드에서 보듯 cast의 반환 타입은 Class 객체의 타입 매개변수와 같다. 이것이 정확히 getFavorite 메소드에 필요한 기능으로 T로 비검사 형 변환하는 손실 없이도 Favorites를 타입 안전하게 만드는 비결이다.

제약사항

하지만 이 Favorites 클래스에는 두 가지 제약사항이 있다. 첫번째로는 악의적인 클라이언트가 Class 객체를 로 타입(non generic)으로 넘기면 Favorites 인스턴스의 타입 안전성이 쉽게 깨진다. 하지만 이렇게 짜여진 클라이언트 코드에서는 컴파일할 때 비검사 경고가 뜰 것이다. 다음 코드를 수행하면 런타임에 ClassCastException을 던진다.

1
2
f.putFavorite((Class)Integer.class, "Integer의 인스턴스가 아닙니다.");
int favoriteInteger = f.getFavorite(Integer.class);

이는 HashSet과 HashMap 등의 일반 컬렉션 구현체도 가지고 있는 문제다. 예를 들어 HashSet의 로 타입을 사용하면 HashSet에 String을 넣는건 아주 쉽다.

1
2
HashSet<Integer> set = new HashSet<>();
((HashSet)set).add("string");

이는 Favorites가 타입 불변식을 어기는 일이 없도록 보장해야 한다. 방법은 간단하다. 동적 형변환을 수행하면 된다.

1
2
3
4

public <T> void putFavorite(Class<T> type, T instance) {
favorites.put(Objects.requireNonNull(type), Objects.requireNonNull(type.cast(instance)));
}

이와 같은 방법을 사용한 컬렉션들이 checkedSet, checkedList, checkedMap같은 컬렉션 래퍼들이다.

두번째 제약은 실체화 불가 타입에는 사용할 수 없다는 것이다. 다시 말해 String이나 String[]에는 저장할 수 있어도 List\이나 List\는 저장할 수 없다는 이야기다. List\용 Class 객체를 얻을 수 없기 때문이다. List\.class는 문법 오류다. List\과 List\는 둘 다 List.class라는 객체를 공유하므로 만약 List\.class와 List\.class를 허용해서 둘 다 같은 타입의 객체 참조를 반환한다면 Favorites 객체 내부는 아수라장이 되버린다. 이 두 번째 제약에 대한 우회로는 슈퍼 타입 토큰(super type token)이 있으나 이도 완벽한 방법은 아니다. 슈퍼 타입 토큰을 고안한 닐 개프터(Neal Gafter)가 고안하였고, 닐 개프터의 가까운 지인인 조슈아 블로크(Effective Java 3/E 저자)가 슈퍼 타입 토큰을 언급하지 않고 완벽히 만족스러운 우회로는 없다라고 한 이유도 있을것이며 이는 닐 개프터의 글을 참조하길 바란다.

1
2
f.putFavorite(new TypeRef<List<String>>(){}, listItems);
List<String> returnItems = f.getFavorite(new TypeRef<List<String>>(){});

타입의 제한

Favorites가 사용하는 타입 토큰은 비 한정적이다. 즉, getFavorite와 putFavorite는 어떤 Class 객체든 받아들인다. 때로는 이 메소드들이 허용하는 타입을 제한하고 싶을 때가 있는데 이 때는 한정적 타입 토큰을 활용하면 된다.

애너테이션 API는 한정적 타입 토큰을 적극적으로 사용한 예다. 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @throws NullPointerException {@inheritDoc}
* @since 1.8
*/
@Override
public <A extends Annotation> A[] getAnnotationsByType(Class<A> annotationClass) {
Objects.requireNonNull(annotationClass);

AnnotationData annotationData = annotationData();
return AnnotationSupport.getAssociatedAnnotations(annotationData.declaredAnnotations,
this,
annotationClass);
}

여기서 annotationClass 인수는 애너테이션 타입을 뜻하는 한정적 타입 토큰이다. 이 메소드는 토큰으로 명시한 타입의 애너테이션 대상 요소에 달려 있다면 그 애너테이션을 반환하고 없다면 null을 반환한다. 즉, 애너테이션된 요소는 그 키가 애너테이션 타입인 타입 안전 이종 컨테이너인 것이다.

Class<?> 타입의 객체가 있고 이를 한정적 타입 토큰을 받는 메소드에 넘기려면 어떻게 해야 할까? 객체를 Class<? extends Annotation>으로 형 변환할 수도 있지만 이 형 변환은 비검사이므로 컴파일하면 경고가 뜰 것이다. 이 때는 asSubclass 메소드를 사용하면 된다. asSubclass 메소드는 형 변환을 안전하고 동적으로 수행하는 메소드이다. 이 메소드로 호출된 인스턴스 자신의 Class 객체를 인수가 명시한 클래스로 형변환한다. 형 변환에 성공하면 인수로 받은 클래스 객체를, 실패하면 ClassCastException을 던진다. 다음 코드는 컴파일 시점에는 타입을 알 수 없는 애너테이션을 asSubclass 메소드를 사용해 런타임에 읽어내는 예이다. 이 메소드는 오류나 경고 없이 컴파일된다.

1
2
3
4
5
6
7
8
9
static Annotation getAnnotation(AnnotatedElement element, String annotationTypeName) {
Class<?> annotationType = null;
try {
annotationType = Class.forName(annotationTypeName);
} catch (Exception ex) {
throw new IllegalArgumentException(ex);
}
return element.getAnnotation(annotationType.asSubclass(Annotation.class));
}
Author: Song Hayoung
Link: https://songhayoung.github.io/2020/08/12/Languages/Effective%20JAVA/item33/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.