Java

Java: Varargs와 Heap Pollution

teo_99 2023. 2. 24. 23:01

가변 인자란?

가변 인자란, Java 5 이후로 도입되었으며, 임의의 수를 가진 파라미터를 받을 때 유용하게 사용할 수 있다.

 

public int adder(int a1, int a2, int a3) {
	return a1 + a2 + a3;
}

 

파라미터가 3개라면 위와 같은 덧셈 계산기를 만들 수 있다.

 

100개의 인자를 더하고 싶다면 어떻게 할까? 파라미터가 100개인 adder 메소드를 오버로딩할까?

파라미터가 10000개가 된다면?

 

이 시점에서 생각해볼 수 있는 것이, 배열 혹은 리스트이다. 

배열 또는 리스트로 값을 받을 수만 있다면 iterator로 반복 접근도 가능하며, 오버로딩에 대해 걱정할 필요가 없다.

 

따라서 Varargs(가변 인자)를 사용한다.

 

public int adder(int... numbers) {
	int sum = 0;
    for (int element : numbers) {
    	sum += element;
    }
    return sum;
}

 

이처럼 가변 인자를 사용하면 여러 파라미터를 한번에 다룰 수 있다.

이 시점에서 궁금해지는 것은, '어떻게 이런 것이 가능할까?' 이다.


가변 인자의 원리

앞에서 가변 인자를 사용할 경우, iterator를 통한 접근이 가능하다고 했다. 

유추할 수 있듯이, 가변인자는 JVM에 의해 컴파일 타임에 배열로 변환된다. 

 

즉, 다음 두 코드는 거의 같다.

 

public int adder(int... numbers) {
	int sum = 0;
    for (int element : numbers) {
    	sum += element;
    }
    return sum;
}

 

 

// 배열 버전
public int adder(int[] numbers) {
	int sum = 0;
    for (int element : numbers) {
    	sum += element;
    }
    return sum;
}

 

인자로 넘겨주는 값이 array로 매핑되고, 이를 가변인자를 사용하는 메소드에서 사용할 수 있게 된다.

 

다만, 배열로 정의한 메소드의 경우 인자를 무조건 배열로 passing 해야 하며, 0개의 인자는 허용되지 않는다.

따라서 두 메서드는 완전히 동일하지는 않다.

 


사용 규칙

가변인자에 대한 사용 제약조건이 많은 것은 아니지만, 몇가지 사용 규칙이 존재한다.

  • 메소드는 하나의 가변인자만 가질 수 있다.
public int adder(int... x, int... y) {
	...
}

가변인자의 경우 선언된 시점부터 뒤로 들어오는 모든 인자가 해당 가변인자의 일부로 인식된다.

따라서 가변인자를 두 개 이상 사용하는 것은 불가능하다.

 

  • 가변인자는 마지막 파라미터로만 존재할 수 있다.
public int adder(int... x, int y) {
	...
}

앞서 말했던 '하나의 가변인자만 가질 수 있다'와 마찬가지의 이유이다. 

가변인자 뒤에 선언되는 당연히 파라미터는 무시되게 되므로 사용할 수 없다.


성능

가변인자는 인자들을 내부적으로 배열로 변환시키기에 성능 상의 단점도 존재한다.

List에서 제공하는 List.of 와 같은 정적 메소드는 다음과 같이 가변인수를 정말 최후의 수단으로만 사용한다.

 

static <E> List<E> of() {
    return ImmutableCollections.emptyList();
}
    
    
 static <E> List<E> of(E e1) {
    return new ImmutableCollections.List12<>(e1);
}

...


static <E> List<E> of(E e1, E e2, E e3, E e4, E e5, E e6, E e7, E e8, E e9, E e10) {
    return new ImmutableCollections.ListN<>(e1, e2, e3, e4, e5,
                                            e6, e7, e8, e9, e10);
}


@SafeVarargs
@SuppressWarnings("varargs")
static <E> List<E> of(E... elements) {
    switch (elements.length) { // implicit null check of element
    case 0:
        return ImmutableCollections.emptyList();
    case 1:
         return new ImmutableCollections.List12<>(elements[0]);
    case 2:
        return new ImmutableCollections.List12<>(elements[0], elements[1]);
    default:
        return new ImmutableCollections.ListN<>(elements);
    }
}

힙 오염(Heap Pollution)

가변인자는 메소드에게 유연성을 제공하는 것 같다. 

덕분에 인자가 몇 개 인지 확실하지 않은 상태에서도 메소드를 쉽게 작성할 수 있게 되었다.

 

그러면, 가변인자는 단점은 없는 이상적인 기술인가?

치명적인 단점을 하나 가지고 있다. 그건 바로 힙 오염이다.

 

우선 힙(heap)이란?

참조형의 데이터 타입을 갖는 객체(인스턴스), 배열 등이 저장되는 메모리 공간이다.

즉, JVM의 힙 공간이 오염되었다는 의미이다. 

 

왜 오염되는지에 대해서 이해하려면, 제네릭 타입에 대한 이해가 선행되어야 한다.

 

Java에 제네릭이 도입될 당시, 기존 코드와의 호환성을 위해 타입 파라미터를 컴파일이 끝난 시점에 제거하도록 만들었다.

즉, 다음과 같은 두 리스트는 컴파일이 끝난 시점에서 동일하게 변한다.

 

ArrayList<Integer> integers = new ArrayList<>();
ArrayList<String> strings = new ArrayList<>();


// 컴파일 후
ArrayList<Object> integers = new ArrayList<>();
ArrayList<Object> strings = new ArrayList<>();

제네릭이 도입되기 이전에 사용되던 ArrayList가 Object 타입을 담는 자료구조였기 때문이다.

따라서 기존 ArrayList와의 하위 호환성을 보장하면서, 제네릭을 사용하기 위해서는 위와 같은 형태가 될 수 밖에 없다.

 

이처럼 컴파일 타임에 타입 소거가 되는 타입들을 '실체화 불가 타입'이라고 한다.

 

private static void doSomthing(List<String> ... stringLists) {
        List<Integer> intList = Arrays.asList(42);
        Object[] objects = stringLists;
        objects[0] = intList; // 힙 오염 발생
        String s = stringLists[0].get(0); // ClassCastException
 }

이제 위 코드를 되돌아가보자.

 

가변 인자로 String 타입의 List를 가진 배열을 만들어냈다.

하지만 앞서 말했듯, 제네릭은 컴파일 타임에 모두 타입 소거가 진행된다.

 

private static void doSomthing(List<Object> ... stringLists) {
        List<Object> intList = Arrays.asList(42);
        Object[] objects = stringLists;
        objects[0] = intList; // 힙 오염 발생
        String s = stringLists[0].get(0); // ClassCastException
 }

따라서 위처럼 제네릭의 경우 Object로 타입이 모두 변경된다.

 

따라서 3번째 라인에서 objects의 0번째 인덱스를 Integer List로 변환해도 컴파일러는 눈치챌 수 없다.

두 리스트 모두 Object 타입의 리스트인데, 무슨 문제가 있는건가? 라는 생각을 가질 것이다.

 

따라서 이때까지도 컴파일러는 알 수 없고,

이 시점에서 해당 배열에는 두 타입(String, Integer)의 리스트가 공존하게 된다. 이것이 힙 오염(Heap Pollution)이다.

 

힙 오염을 알아차리는 시점은 런타임에 4번째 라인처럼 직접 접근했을 때 뿐이다.

이 때는 Integer 리스트에서 String 타입의 객체를 얻으려 하므로 ClassCastException이 발생한다.


그럼에도 가변인수는 유용하다

가변인수는 실체화 불가 타입(제네릭)으로 매개변수를 선언할 수 있게 되니, 타입 안정성이 깨지게 된다.

 

사실 이는 가변인수만의 문제가 아니라, 제네릭의 근본적인 문제점이다.

하지만 가변인수에서 위험성이 더 증대되는 이유는, 매개변수를 Object 타입의 배열로 다루기 때문이다.

 

가변인수가 이런 위험성을 가지고 있는데도 사용하는 이유는 코드에 극도의 유연함을 제공하기 때문이다.

 

그렇다고 해서 가변인수를 사용할 때, 아무런 안전장치가 없는 것은 아니다.

 

힙 오염의 가능성이 존재할 때에는, 컴파일러가 경고를 표시해준다.

이러한 경고를 제거하기 위해서는 Java 1.7 이후로 도입된 @SafeVarags 어노테이션을 사용하면 된다.

다만 가변인수를 사용하는 메소드가 안전한 것이 확인된 게 아니라면 절대 @SafeVarags를 작성하면 안된다. 

 

이처럼 trade-off가 존재하는 가변인수지만, 단점을 알고 상황에 맞춰서 잘 쓰면 극도의 유연성을 제공할 것이다.

 


참고자료

https://parkadd.tistory.com/130

https://velog.io/@adduci/Java-%ED%9E%99-%ED%8E%84%EB%A3%A8%EC%85%98-Heap-pollution

https://www.baeldung.com/java-varargs