반응형

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

이 Item 을 확인하기 전에 꼭 아래 글을 보고 오는 것을 추천한다. 갑자기 타입 안전 이종 컨테이너 이야기가 나오는데, 왜 사용해야 되는지, 어떤 방식으로 구현되어 있는지 책에는 자세하게 나와있지 않아 직접 서치하고 정리했다.

 

[JAVA] 타입 토큰과 슈퍼 타입 토큰이란? :: 골드에그 (tistory.com)

 

[JAVA] 타입 토큰과 슈퍼 타입 토큰이란?

1. 자바 제네릭의 한계? 자바 제네릭은 클래스에서 사용할 타입을 외부(사용부)에서 사용하게 해주는 일반적인 기법을 의미한다. 이러한 제네릭을 사용하면, 타입만 다르고 공통된 기능을 가지

g-egg.tistory.com

 

위 글에서는 자바 제네릭의 한계 때문에 런타임 시 타입을 확인하기 위해 타입 토큰을 사용하여 타입 안전 이종 컨테이너(THC Pattern(Typesafe Heterogenous Container Pattern)) 를 활용한다고 하지만, 책에서는 제네릭 확장 개념으로 설명하고 있다. Set<E>, Map<K, E> 처럼 제네릭은 매개변수를 한 개나 두 개 정도 밖에 사용을 못 하는데 더 확장하여 사용하고 싶지 않냐는 식이었다. 사실 취지가 마음에 와닿지는 않았다.

 

그래도 개념은 동일하다. 타입 안전 이종 컨테이너(THC Pattern(Typesafe Heterogenous Container Pattern))는 컨테이너 대신 키를 매개변수화한 다음, 컨테이너에 값을 꺼내거나 뺄 때 매개변수화한 키를 활용한다는 것이다.

 

// 선언부
public class SimpleTypeSafeMap {
	private Map<Class<?>, Object> map = new HashMap<>();
    public <T> void put(Class<T> k, T v) {
    	map.put(Objects.requireNonNull(k), v);
    }
    public <T> T get(Class<T> clazz) {
    	return clazz.cast(map.get(clazz));
    }
}

// 사용부
simpleTypeSafeMap.put(String.class, "테스트");
simpleTypeSafeMap.put(Integer.class, 1);

String string1 = simpleTypeSafeMap.get(String.class);
Integer integer1 = simpleTypeSafeMap.get(Integer.class);

 

위 글 예제와 같다. HashMap Key 에 타입 토큰을 지정하여 값을 로드하고 저장하면 된다.

 

여기서 중요한 점은 키가 Class<?> 이라서 비한정적 와일드 카드이기 때문에 Map 안에 아무것도 못 넣을 수 있다고 생각할 수 있지만(타입을 체크하지 못하므로), 컨테이너 입장에서는 중첩되어 있기 때문에 Key 만 와일드카드 타입이기 때문에 모든 키가 가능하다. 또 맵의 값 타입이 Object 이기 때문에 이 맵은 키와 값 사이의 타입 관계를 보증하지 않는다. 하지만 메서드들에서 타입을 가져와 동적 캐스팅해 로드하고 타입을 키로 지정해 저장하기 때문에 상관없다. Map 자체로는 안전하지 않지만, 메서드들에서 이를 보장한다.

 

안전해보이지만, 아직 두 가지 제약이 존재한다.

첫 번째 제약은 클라이언트가 아래와 같이 Generic 타입이 아닌 Raw 타입을 전달하면 타입 안전성이 깨지게 된다. HashSet 이나 HashMap 같은 일반 컬렉션에 Raw 타입을 사용하는 것처럼 말이다.

 

map.put((Class)Integer.class, "hello");
int testInteger = map.get(Integer.class);

// 닮은 꼴 예제
HashSet<Integer> set = new HashSet<>();
((HashSet)set).add("hello");

 

단순히 cast 메서드는 Class 객체가 알려주는 타입의 인스턴스인지를 검사한 다음, 맞다면 그대로 반환하고 아니면 ClassCastException 예외를 던지기 때문이다. get 메서드에서는 cast 메서드로 맞는 타입인지 점검하는 반면에 put 메서드에서는 점검하지 않으므로 생기는 문제이다. 컴파일 타임 때 모두 점검하기 위해서 아래와 같이 코드를 수정하자.

 

    public <T> void put(Class<T> k, T v) {
    	map.put(Objects.requireNonNull(k), type.cast(v));
    }

 

type.cast 로 명시한 타입과 같은지 체크한다. 그냥 동적 형변환을 사용하면 된다. java.util.Collections 의 checkedSet, checkedList, checkedMap 과 같은 메서드들이 위와 같은 구조를 사용한다고 한다.

 

두 번째 제약은 제네릭(실체화 불가 타입)은 사용할 수 없다. 위 글에서 소개한 것처럼 제네릭에 대한 타입 토큰이나 클래스 리터럴이 준비되어 있지 않기 때문이다. 예를 들어 List<String>와 List<Integer> 는 List.class 클래스 객체를 공유하므로 타입 안전성이 깨지게 된다. 위 글에서 소개한 것처럼 슈퍼 타입 토큰 방식으로 처리해야 한다.

 

현재는 어떤 Class 객체든 받아들이는데 간혹 메서드들이 허용하는 타입을 제한하고 싶을 때가 있다. 그 때 한정적 타입 매개변수나 한정적 와일드카드를 사용하면 된다. 특히 애너테이션 API 가 한정적 타입 토큰을 적극적으로 사용한다.

 

public <T extends Annotation> T getAnnotation(Class<T> annotationType);

 

이 getAnnotation 메서드는 대상 요소에 달려있는 애너테이션을 런타임 때 읽어오는 기능을 한다. 토큰으로 명시한 타입의 애너테이션이 대상 요소에 달려 있다면 그 애너테이션을 반환하고 없다면 null 을 반환한다. THC 컨테이너다. (annotationType 인수가 한정적 타입 토큰으로 선언되어 있는 것을 볼 수 있다.)

 

만약 Class<?> 타입의 객체가 있고, 이를 getAnnotation 처럼 한정적 타입 토큰을 받는 메서드에 넘기려면 어떻게 해야 할까? Class<? extends Annotation> 은 비검사 형변환이므로 컴파일 경고가 떠서 안 되고, asSubclass 메서드로 동적 형변환 해야한다. 인자로 받은 클래스 객체로 형변환해준다. 컴파일 시점에는 알 수 없으나 런타임 때 읽어낼 수 있는 메서드이다.

 

 

"일반적인 제네릭은 한 컨테이너가 다룰 수 있는 타입 매개변수의 수가 고정되어 있다.

하지만 컨테이너 자체가 아닌 키를 타입 매개변수로 바꾸면 이런 제약이 없는 THC 컨테이너를 만들 수 있다."

반응형
반응형

1. 자바 제네릭의 한계?

자바 제네릭은 클래스에서 사용할 타입을 외부(사용부)에서 사용하게 해주는 일반적인 기법을 의미한다.

이러한 제네릭을 사용하면, 타입만 다르고 공통된 기능을 가지고 있는 클래스나 메서드들에 파라미터로 타입을 외부에서 전달받아 조금 더 범용적인 코드를 만들 수 있다. 이러한 제네릭이 없었다면, 자바 객체의 최고 조상인 Object 로 전달받아 객체 타입에 따라 캐스팅해 로직을 별도로 만들어야 되는데 이러면 오히려 런타임 시 예기치 못한 오류가 더 많이 발생하고 별도의 타입으로 만들어둔 클래스 및 함수들과 다를 바 없다.

 

public class Generic {
    public List<String> list = new ArrayList<>();

    public void addString(String str) {
        list.add(str);
    }

    public String getString(int index) {
        return list.get(index);
    }
}

// 제네릭이 아니라면, 이렇게 Object 로 전달
public class NonGeneric {
    public List list = new ArrayList();

    public void addString(Object obj) {
        list.add(obj);
    }

	// 원하는 타입으로 캐스팅 해 반환
    public String getString(int index) {
        return (String) list.get(index);
    }
}

 

그런데 자바 제네릭은 다른 언어의 제네릭과 달리 다른 점이 존재한다.

제네릭 도입 이후, 컴파일 타임 때까지는 타입 안전성을 얻을 수 있었지만, Type Erasure 기능으로 인해 컴파일 이후에는 타입이 소거가 되기 때문이다. 반쪽 제네릭이라고 불리우는 이유이다.

 

Class Test<T> {
	private T value;
    
    public Test(T t) {
    	this.value = t;
    }
    
    public T get() {
    	return value;
    }
}

// -> 컴파일 이후
Class Test {
	private Object value;
    
    public Test(Object t) {
    	this.value = t;
    }
    
    public Object get() {
    	return value;
    }
}

 

컴파일 타임 이후에 Object 로 변환이 된 것을 볼 수 있다. 컴파일 때까지만 타입 안전성을 제공하고, 그 이후에는 Object 로 변환이 되기 때문에 런타임 시에는 오류가 발생할 수도 있다.

 

자바 진영에서는 동세대 언어인 C# 과 다르게 보급률이 좋아서 현업 프로젝트들이 월등히 많았고 이러한 프로젝트들을 지키고자 하위 호완성을 위해 Type Erasure 를 도입했다고 한다. 컴파일 타임 때 Type Erasure 를 수행하면서 제네릭이 없던 하위 버전들과 동일한 형태로 class 파일을 생성할 수 있었다.

 

2. 타입 토큰의 등장

자바언어 개발자였던 Neal Gafter는 JAVA JDK5에 generics를 추가할 때 java.lang.Class 가 generic type이 되도록 변경했다고 한다. 예를 들어, String.class 의 Type 이 Class<String> 되도록 만들어 주었다. 그리고 이를 타입 토큰이라고 불렀다.

 

클래스 리터럴과 타입 토큰의 차이

클래스 리터럴은 어떤 클래스인지 파라미터로 전달되는 정보로 타입 토큰의 클래스 정보이다. 타입 토큰과 동일한 개념으로 타입 토큰은 클래스의 타입을 명시하고, 클래스 리터럴은 그 타입 토큰과 매칭되는 매개변수로 보면 된다.

 

// 선언부 (타입 토큰)
void myMethod(Class<?> clazz) {
	...
}
  
// 사용부 (클래스 리터럴)
myMethod(String.class);

 

이 타입 토큰은 컴파일 타임 이후에도 타입에 대한 안전성을 확보하고자 사용된다.

제네릭만 사용했을 때, Operation 이나 타입들의 메서드들이 동일해야 사용할 수 있다는 한계가 있긴 하지만 그래도 동일한 기능을 한 코드로 만들 수 있어 좋다. 그런데 원하는 타입으로 반환은 안 된다. 아까 위에서 언급한 것처럼 타입이 모두 Object 로 변환되기 때문에 타입 캐스팅이 되지 않는다. 이 때 타입 토큰이 유용하게 사용된다.

 

// 선언부
public <T> T readValue(String content, Class<T> valueType) {
	...
}

// 사용부
ProductDto productDto = objectMapper.readValue(jsonString, ProductDto.class);

 

위 예제처럼, valueType 으로 클래스 타입을 매개변수로 전달해주면 json 을 읽어 그 타입으로 반환해줄 수 있다.

조금 더 나아가 THC (Typesafe Heterogenous Container) Pattern 으로 사용될 수 있다.

 

// 선언부
public class SimpleTypeSafeMap {
	private Map<Class<?>, Object> map = new HashMap<>();
    public <T> void put(Class<T> k, T v) {
    	map.put(k, v);
    }
    public <T> T get(Class<T> clazz) {
    	return clazz.cast(map.get(clazz));
    }
}

// 사용부
simpleTypeSafeMap.put(String.class, "테스트");
simpleTypeSafeMap.put(Integer.class, 1);

String string1 = simpleTypeSafeMap.get(String.class);
Integer integer1 = simpleTypeSafeMap.get(Integer.class);

 

타입 토큰을 키로 사용해 타입끼리 저장하고 다시 그 타입 토큰으로 캐스팅해서 가져올 수 있다. 타입 안전 이종 컨테이너라고 불리우며 영어로는 THC (Typesafe Heterogenous Container) 라고 한다. 컨테이너가 타입을 체크해서 컴파일 여부를 결정하는 것이 아니라 타입 토큰을 가지고 프로그래머가 직접 핸들링하는 개념이다.

 

3. 슈퍼 타입 토큰의 등장

타입 토큰의 한계

이 타입 토큰에도 한계는 있다. 제네릭 타입의 클래스 리터럴과 타입 토큰은 존재하지 않는다. Type Erasure 때문에 <> 내부에 있는 타입들이 제거되기 때문이다. 조금 더 쉽게 생각해보면 이중 삼중... 다중 타입에 대한 정보들을 가지고 있지 않아서 그렇다. 이러한 타입 토큰의 한계를 극복한 개념이 슈퍼 타입 토큰이다.

 

슈퍼 타입 토큰

다중 타입에 대한 정보들을 리터럴로 표현할 수 있으면 타입 토큰처럼 안전성을 확보할 수 있을 것이다.

Class.getGenericSuperclass() 와 ParameterizedType.getActualTypeArguments() 를 사용해서 전체 타입에 대한 정보와 실제 타입을 가져올  수 있다.

 

class Super<T> {}
class MyClass extends Super<List<String>> {}

MyClass myClass = new MyClass();

Type typeOfGenericSuperclass = myClass.getClass().getGenericSuperclass();

// ~~~$1Super<java.util.List<java.lang.String>> 출력됨
System.out.println(typeOfGenericSuperclass);

 

Super 껍데기 클래스의 파라미터로 해당 타입을 전달하면 getGenericSuperclass 메서드로 전체 타입에 대한 정보를 가져올 수 있다. Class (Java Platform SE 8 ) (oracle.com)

 

Class (Java Platform SE 8 )

Determines if the specified Class object represents a primitive type. There are nine predefined Class objects to represent the eight primitive types and void. These are created by the Java Virtual Machine, and have the same names as the primitive types tha

docs.oracle.com

 

getGenericSuperclass 메서드에 대한 내용을 번역해보면 다음과 같다.

해당 객체의 상위 클래스 타입을 반환하는 메서드. 만약 상위 클래스가 매개변수화된 타입이라면 소스코드에서 사용된 실제 타입들을 반영해 반환해야 한다. 매개변수화된 타입에 대한 내용은 별도의 문서를 확인하라고 적혀 있다.(ParameterizedType은 제네릭 타입을 가지고 있는 타입을 말한다.)

ParameterizedType (Java Platform SE 8 ) (oracle.com)

 

ParameterizedType (Java Platform SE 8 )

ParameterizedType represents a parameterized type such as Collection<String>. A parameterized type is created the first time it is needed by a reflective method, as specified in this package. When a parameterized type p is created, the generic type declara

docs.oracle.com

 

제네릭 타입이라면, getActualTypeArguments 메서드를 통해 이 제네릭의 실제 타입을 가져올 수 있다.

 

class Super<T> {}
class MyClass extends Super<List<String>> {}

MyClass myClass = new MyClass();

Type typeOfGenericSuperclass = myClass.getClass().getGenericSuperclass();

// ~~~$1Super<java.util.List<java.lang.String>> 출력됨
System.out.println(typeOfGenericSuperclass);

// 수퍼 클래스가 ParameterizedType 이므로 ParameterizedType으로 캐스팅 가능
// ParameterizedType의 getActualTypeArguments()으로 실제 타입 파라미터의 정보를 구함
Type actualType = ((ParameterizedType) typeOfGenericSuperclass).getActualTypeArguments()[0];

// java.util.List<java.lang.String>가 출력됨
System.out.println(actualType);

 

슈퍼 타입 토큰 예제

// 선언부
public abstract class TypeReference<T> {
    private final Type type;

    protected TypeReference() {
    	Type superClassType = getClass().getGenericSuperclass();
        if (superClassType instanceof ParameterizedType) {
        	this.type = ((ParameterizedType)superClassType).getActualTypeArguments()[0];
        } else {
            throw new IllegalArgumentException("TypeReference는 항상 실제 타입 파라미터 정보가 있어야 합니다.");
        }
    }

    public Type getType() {
        return type;
    }
}

// 사용부
public class TypeSafeMap {
    private final Map<Type, Object> map = new HashMap<>();

    public <T> void put(TypeReference<T> k, T v) {
        map.put(k.getType(), v);
    }

    public <T> T get(TypeReference<T> k) {
        final Type type = k.getType();
        final Class<T> clazz;
        if (type instanceof ParameterizedType) {
            clazz = (Class<T>) ((ParameterizedType) type).getRawType();
        } else {
            clazz = (Class<T>) type;
        }
        return clazz.cast(map.get(type));
    }
}

 

Class.getGenericSuperclass() 와 ParameterizedType.getActualTypeArguments() 를 활용해서 실제 RawType 를 가져온다.

 

4. 프레임워크의 슈퍼 타입 토큰

이렇게 매번 직접 만들기에는 수고스럽다. 프레임워크에서도 이런 슈퍼 타입 토큰을 지원한다.

 

Spring 의  ParameterizedTypeReference

Spring 프레임워크에서도 동일하게 런타임 시 발생하는 타입 안정성 문제를 해결하기 위해 ParameterizedTypeReference라는 클래스를 만들었다. 매개변수화된 타입에 대한 정보를 가져올 때 사용하면 된다. 클래스 리터럴을 전달하는 대신 아래와 같이 사용하면 된다. new ParameterizedTypeReference<List<User>>()

 

spring-framework/ParameterizedTypeReference.java at main · spring-projects/spring-framework (github.com)

 

GitHub - spring-projects/spring-framework: Spring Framework

Spring Framework. Contribute to spring-projects/spring-framework development by creating an account on GitHub.

github.com

 

앞에서 살펴본 구현방식과 유사하다. 보통 다른 서버에서 자원을 가져올 때 사용한다고 한다.

ObjectMapper 에서는 ParameterizedTypeReference 를 사용하지 않고 직접 위에서 소개한 것처럼 구성하여 만들고, Spring Cloud feign 에서는 자체적으로 타입을 넘겨주지 않더라도 Reflection 기능을 통해 확인한다고 한다.

반응형

'JAVA' 카테고리의 다른 글

[JAVA] Stream Collector groupingBy  (0) 2023.02.26

+ Recent posts