Language/Java

[effective java] 아이템 32. 제네릭과 가변인수를 함께 쓸 때는 신중하라.

JOYERIM 2023. 5. 14. 20:40

 
 
 
 
 

"제네릭 가변인수 메소드를 사용하든, List로 대체를 하든, 항상 타입 안정성을 보장하는 코드를 작성하자 !"

 
 
 
 
 
 

가변인수 메소드

 
 
 
varargs 메소드는 여러 가지 이유로 유용하다.
 

1. 유연성 : 개발자가 원하는 만큼의 인수를 전달할 수 있다. 이는 코드 작성에 더 큰 유연성을 제공하며, 메소드 오버로딩을 줄일 수 있다.

2. 코드 간결성 : 가변인수를 사용하면 배열을 명시적으로 생성하고 초기화하는 번거로움을 줄일 수 있다. 이는 코드를 간결하고 가독성을 좋게 한다.

 
 
이 아이템에서 다룬 모든 문제의 시작은 가변 varargs 메소드가 내부적으로 배열을 만드는 메커니즘을 갖기 때문이다 ..
 
함께하기엔 너무 다른 배열의 공변성과 타입 매개변수의 불공변성..
 
제네릭의 유연성도 가지고 싶고, 가변 varargs 메소드의 유연성도 가지고 싶은데 어떻게 해야 함?이 이 아이템의 주요 내용이다.
 
 
 


 
 
 
 
 
 
 

제네릭 가변인수(varargs) 메소드

 
 
 
JDK 5부터 제네릭가변인수 메소드가 도입되었다. 가변인수 메소드를 호출하면 해당 인수들을 저장하기 위한 배열이 생성되는데, 이 배열의 공변성으로 인해 타입 안정성이 깨질 수 있다.
 
이런 문제는 힙 오염으로, 매개변수화된 타입의 변수가 다른 타입의 객체를 참조할 때 발생한다. 특히 제네릭가변인수를 함께 사용할 때, 가변인수 메소드가 생성하는 배열을 통해 힙 오염이 발생할 수 있다.
 
따라서 JDK 5부터는 제네릭이나 매개변수화된 타입을 포함하는 가변인수 메소드를 사용할 경우 컴파일 경고가 발생하며, 호출하는 쪽에서도 경고가 발생한다. 이 경고는 힙 오염 문제를 사전에 알려주고 타입 안정성을 유지하기 위한 것이다.
 
 
 

예시 코드

 

static void dangerous(List<String>... stringLists){ // 가변인수 메소드
	List<Integer> intList = List.of(42); // (1)
	Object[] objects = stringLists; // (2)
	objects[0] = intList; // (3) 힙 오염 발생
	String s = stringLists[0].get(0); // (4) ClassCastException
}

 
가변인수 메소드 dangerous는 List<String>을 가변인수로 받는다.
 
(1) : List<Integer> intList를 생성한다. intList는 하나의 정수인 42를 가지는 리스트이다.
 
(2) : Object 배열인 objects을 stringLists에 대입한다. 이때 stringLists는 가변인수로서 List<String>의 배열로 선언돼 있다. 그러나 배열은 공변성을 가지므로 Object[]에 List<String>[]를 대입하는 것이 가능하다. 이때 배열의 타입 안정성이 깨지는 상황이 발생한다.
 
(3) : object[0]에 intList를 대입한다. 즉, objects 배열의 첫 번째 원소를 intList로 변경한다. 그러나 objects는 List<String>[] 타입의 배열이므로, intList는 List<String>의 하위 타입이 아니므로 힙 오염이 발생한다. 즉, objects 배열에는 List<Integer>를 포함하게 된다.
 
(4) : stringLists 배열은 이전의 힙 오염으로 List<Integer>가 저장되어 있다. List<Integer>는 List<String>으로 캐스팅할 수 없기 때문에 ClassCastException이 발생한다.
 
 
 
 
 
 
 
 
 


 
 
 
 
 
 
 
 
 
 

@SafeVarargs
: 제네릭 가변인수 메소드의 타입 안정성을 보장한다.

 
 

// 제네릭 배열 - 생성 불가 컴파일 에러
static void dangerous_item28(List<String>[] stringLists){
	List<Integer> intList = List.of(42);
	...
}

// 제네릭 가변인수 - heap pollution 경고
static void dangerous(List<String>... stringLists){
	List<Integer> intList = List.of(42);
	...
}

 
 
제네릭 배열은 컴파일 에러가 발생하지만, 제네릭 가변인수 메소드는 컴파일 경고가 발생한다. 이유가 뭘까?
 
"제네릭 가변인수 메소드가 유용하기 때문"은 너무 뻔한 말이고, 핵심은 자바 7부터 @SafeVarargs 어노테이션이 도입됐기 때문이다.
 
 
앞서 다뤘듯, 가변인수와 제네릭이나 타입매개변수가 포함되면 컴파일 경고가 발생한다.
해당 메소드가 안전하다면 SuppressWarnings 어노테이션을 메소드 전체에 선언할 수 있으나, 메소드 전체에서 발생한느 경고를 무시하게 된다.
 
따라서 자바 7부터 SafeVarargs가 도입되었으며, 이는 메소드에 들어가는 argument만 대리해주는 것이다. 즉, SafeVarargs는 SuppressWarnings의 argument 한정 축소판 버전이라고 생각하면 된다.
 
만약 SafeVarargs를 사용한 메소드가 오버라이딩 가능하다면 어떤 문제가 생길까? 당연히 재정의로 인해 안정성 문제가 발생할 수 있다. 따라서 SafeVarargs는 오버라이딩이 불가능한 타입, 즉 static이나 final 메소드에만 사용할 수 있다. JDK 9부터는 private 인스턴스 메소드에도 SafeVarargs를 사용할 수 있다.
 
 
 
 
 
 
 


 
 
 
 
 
 
 

제네릭 가변인수 메소드의 타입 안정성을 보장할 수 있는 경우

 
 

경우 1. varargs 매개변수 배열에 아무 것도 저장하지 않는다.

 

경우 2. varargs 매개변수 배열 혹은 복제본의 참조가 밖으로 노출되지 않는다. (신뢰할 수 없는 코드가 배열에 접근하지 않는다.)

 
 
 
 
 
 

예시 코드 1

 

// 자신의 제네릭 매개변수 배열의 참조를 노출한다. -> 안전하지 않다.
static <T> T[] toArray(T... args){
	return args;
}

 
즉, 반환된 배열이 외부에서 수정될 수 있으므로 안전하지 않다.
 
 
 
 

예시 코드 2

 
아래 코드는 T 타입 인수 3개를 받아 그중 2개를 무작위로 골라 담은 배열을 반환한다. toArray 메소드는 가변 인수를 받아 배열로 만드는 메소드이다.
 

// 안전하지 않다.
static <T> T[] pickTwo(T a, T b, T c){
	switch (ThreadLocalRandom.current().nextInt(3)){
		case 0: return toArray(a, b);
		case 1: return toArray(a, c);
		case 2: return toArray(b, c);
	}
	throw new AssertionError(); // 도달할 수 없다.
}

 

public static void main(String[] args) {
	String[] attributes =pickTwo("good", "fast", "cheap"); // ClassCastException
}

 
위 코드의 동작을 살펴보자.
컴파일러는 toArray 메소드를 위한 가변 매개변수 배열을 만든다. 이 배열의 타입은 어떠한 타입도 넘길 수 있도록 Object[]이다. 이렇게 toArray 메소드에서 반환된 배열은 pickTwo 메소드에서 클라이언트에게 Object[] 타입 배열을 반환한다.
 
main 메소드에서는 반환 값인 Object[]는 String[]의 하위 타입이 아니므로 ClassCastException이 발생한다.
 
즉, 해당 예시로 제네릭 가변 매개변수 배열에 다른 메소드가 접근하도록 허용하면 안전하지 않다는 점을 알 수 있다.
 
 
 
 
 
 
 


 
 
 
 
 
 
 

제네릭 varargs 매개변수 배열에 다른 메소드가 접근해도 안전한 경우

 
제네릭 varargs 매개변수 배열에 다른 메소드가 접근하도록 허용하면 안전하지 않다. 단, 예외가 두 가지 있다. 
 

예외 1. @SafeVarargs로 제대로 에노테이트된 또 다른 varargs 메소드에 넘기는 경우

 

예외 2. 그저 이 배열 내용의 일부 함수를 호출만 하는 (varargs를 받지 않는) 일반 메소드에 넘기는 경우

 
 

예시 코드 1

 

@SafeVarargs
static <T> List<T> flatten(List<? extends T>... lists){
	List<T> result = new ArrayList<>();
	for(List<? extends T> list : lists){
		result.addAll(list);
	}
	return result;
}

 
 
 
 
 


 
 
 
 
 
 
 

@SafeVarargs 사용 규칙

 
 
 
제네릭 또는 매개변수화 타입의 가변 매개변수를 받는 모든 메소드에 @SafeVarargs를 사용하라.
 
안전하지 않은 varargs 메소드는 절대 작성하면 안 된다.
 
 
 
 
 
 


 
 
 
 
 
 

@SafeVarargs가 유일한 답은 아니다.
: 제네릭 varargs 매개변수를 List로 바꾸자.

 
 

예시 코드 1

 

// 제네릭 varargs 매개변수를 사용한 예
@SafeVarargs
static <T> List<T> flatten_varargs(List<? extends T>... lists){
	List<T> result = new ArrayList<>();
	for(List<? extends T> list : lists){
		result.addAll(list);
	}
	return result;
}

 

// 제네릭 varargs 매개변수를 List로 대체한 예 - 타입 안전하다.
static <T> List<T> flatten_typesafe(List<List<? extends T>> lists){
	List<T> result = new ArrayList<>();
	for(List<? extends T> list : lists){
		result.addAll(list);
	}
	return result;
}

 

List<String> friends = List.of("friend");
List<String> romans = List.of("abc");
List<String> countrymen = List.of("zzz");
List<List<String>> audience = flatten(List.of(friends, romans, countrymen));

 
List로 대체할 경우, 
 

장점

: 컴파일러가 이 메소드의 타입 안정성을 검증할 수 있다.
: @SafeVarargs를 직접 달지 않아도 된다.
: 실수로 안전하다고 판단할 걱정이 없다.

단점

: 클라이언트가 살짝 지저분해진다.
: 속도가 조금 느려질 수 있다.

 
 
 
 

예시 코드 2

 
가변 매개변수 메소드를 안전하게 작성할 수 없다면 List로 대체할 수 있다. 해당 예제를 살펴보자.
 

// 제네릭 가변 매개변수를 사용한 경우 -> 안전하지 않음
static <T> T[] pickTwo(T a, T b, T c){
	switch (ThreadLocalRandom.current().nextInt(3)){
		case 0: return toArray(a, b);
		case 1: return toArray(a, c);
		case 2: return toArray(b, c);
	}
	throw new AssertionError();
}

 

// List로 바꾼 경우 -> 안전함
static <T> List<T> pickTwo(T a, T b, T c){
	switch (ThreadLocalRandom.current().nextInt(3)){
		case 0: return List.of(a, b);
		case 1: return List.of(a, c);
		case 2: return List.of(b, c);
	}
	throw new AssertionError();
}

 

List<String> attributes =pickTwo("good", "fast", "cheap");

 
배열없이 제네릭만 사용하므로 타입 안전하다 !
 
 
 
 
 
 


 
 
 
 
 

핵심 정리

 
가변 varargs 메소드는 뛰어난 유연성을 지니고 있다. 허나, 내부적으로 배열을 사용하기 때문에, 제네릭과 함께 사용할 경우 문제가 생길 수 있다. 이 문제를 해결하는 것이 이번 아이템의 핵심이다 !
 
가변 varargs 메소드는 내부적으로 배열을 사용한다. 따라서 제네릭 varargs 매개변수를 사용할 때 타입 안정성에 문제가 생길 수 있다. 그 이유는 서브타입의 배열을 슈퍼타입의 배열로 업캐스팅할 수 있는 배열의 공변성 때문이다. 이로 인해 런타임에 타입 오류가 발생할 수 있다.
 
이를 해결하기 위해 두 가지 방법이 있다. 하나는 @SafeVarargs를 사용하는 것, 다른 하나는 제네릭 varargs 매개변수를 List로 바꾸는 것이다.
 
@SafeVarargs를 사용하면 컴파일러에게 이 메소드가 타입 안정성을 보장함을 알려주는 것이다. 그러나 이 방법은 개발자가 메소드 내에서 타입 안정성을 수동으로 관리해야 한다는 단점이 있다. 또한, static 메소드, final 메소드, (JDK 9부터) private 메소드에만 사용할 수 있다.
 
반면, 제네릭 varargs 매개변수를 List로 바꾸는 방법은 컴파일러가 타입 안정성을 자동으로 검증하도록 하는 방법이다. 이 방법은 메소드의 인수를 List로 받으면서 타입 안정성을 보장하며, 클라이언트 코드는 조금 더 복잡해지지만 그만큼 더 안전하다. 제네릭 varargs 메소드의 안정성을 보장할 수 없다면 List로 바꿀 수 있다.
 
두 방법의 장단점을 잘 고려해서 항상 타입 안정성을 보장하는 코드를 짜도록 하자 !