33. 타입 안전 이종 컨테이너를 고려하라

신명철·2022년 3월 1일
0

Effective Java

목록 보기
31/80

타입 안전 이종 컨테이너의 필요성

제네릭은 Set<E>, Map<K,V> 등의 컬렉션과 ThreadLocal<T>, AtomicReference<T> 등의 단일 원소 컨테이너에도 흔히 쓰인다. 하지만 이처럼 클래스 레벨에서 매개변수화 할 수 있는 타입의 수는 제한적이다. (e.g Map 2개, Set 1개)

  • 더 유연한 수단이 필요할 때가 종종 있다.
    • DB의 행은 임의 개수의 열을 가질 수 있는데, 모두 열을 타입 안전하게 이용할 수 있다면 멋질 것이다.
    • 컨테이너의 키를 매개변수화해서 값을 넣거나 뺄 때 키를 함께 제공하면 된다. 이런 설계 방식을 타입 안정 이종 컨테이너 패턴이라고 한다.
public class Favorite {
	private Map<Class<?>, Object> favorites = new HashMap<>();

	public <T> void putFavorite(Class<T> type, T instance) {
		favorites.put(Objects.requireNonNull(type), type.cast(instance));
	}
	public <T> T getFavorite(Class<T> type) {
		return type.cast(favorites.get(type));
	}
}
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<T> 이다.

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

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

키와 값 사이의 타입 링크(type link)는 넣는 순간 사라지지만 getFavorite에서 이를 되살린다. cast 메서드를 이용해서 객체 참조를 Class 객체가 가리키는 타입으로 동적 형 변환을 수행한다.

cast 메소드는 주어진 인수가 Class 객체가 알려주는 타입의 인스턴스인지를 검사하고, 맞으면 인수를 아니면 ClassCastException을 던진다.

public class Class<T> {
	T cast(Object obj);
}
  • cast 메소드가 단순히 인수를 그대로 반환하는데도 cast 메소드를 사용하는 이유는 cast 메소드의 시그니처 Class 클래스가 제네릭이라는 이점을 완벽히 활용하기 때문이다.

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

제약 사항

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

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

이것은 HashSet, HashMap 등의 일반 컬렉션도 가지고 있는 문제다. HashSet의 로 타입을 사용하면 HashSet에 String을 넣는건 아주 쉽다.

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

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

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<String>이나 List<Integer>는 저장할 수 없다는 이야기다.

List<String>를 저장하려고 해도 컴파일되지 않는다. List<String>용 Class 객체를 얻을 수 없기 때문이다. (List<String>.class -> 문법 오류가 난다)

그 이유는 List<String> 이나 List<Integer>List.class라는 같은 Class 객체를 공유하기 때문이다.

한정적 타입

Favorites 가 사용하는 타입 토큰은 비한정적이다. 즉, getFavorites와 putFavorites는 어떤 Class 객체든 받아들인다는 뜻이다. 타입을 제한하고 싶을 때는 한정적 타입 토큰을 사용하면 된다.

@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<?> 타입의 객체가 있고 이를 getAnnotaion 처럼 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 해야 할까?

객체를 Class<? extends Annotation> 으로 형 변환 할수도 있지만 이 형변환은 비검사이므로 컴파일하면 경고가 뜰 것이다.

이 때는 asSubclass라는 메서드를 사용하면 된다. 호출된 인스턴스 자신의 Class객체를 인수가 명시한 클래스로 형 변환한다.

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));
}
  • 위 코드는 컴파일 시점에는 타입을 알 수 없는 어노테이션을 asSubclass 메소드를 사용해 런타임에 읽어내는 예이다.
profile
내 머릿속 지우개

0개의 댓글