자바는 1.5부터 제네릭(generic)이라는 개념을 지원하기 시작했다. 제네릭이 도입되기 전, 프로그래머는 컬렉션에서 객체를 읽어낼 때마다 형변환(type cast)을 해야 했다. 그래서 누군가 컬렉션에 엉뚱한 자료형의 객체를 넣으면 프로그램 실행 중에 형변환 오류가 나고 만다. 하지만 제네릭을 사용하면 컬렉션에 넣는 객체의 자료형이 무엇인지 컴파일러에게 알릴 수 있다. 형변환 코드는 컴파일러가 알아서 넣어 줄 것이고, 잘못된 자료형의 객체를 컬렉션에 넣으려는 시도는 컴파일 과정에서 차단된다. 그 덕에 프로그램은 더 안전하고 명료해지지만, 대가가 있다. 프로그램이 복잡해지는 것이다. 이번 장에서는 제네릭의 혜택을 최대한 누리면서도 복잡함은 피하는 방법을 살펴본다.

규칙 23. 새 코드에는 무인자 제네릭 자료형을 사용하지 마라

각 제네릭 자료형은 형인자 자료형(parameterized type) 집합을 정의한다. 이 집합은 이름 뒤에 <와> 기호로 감싼 실 형인자(actual type parameter) 목록이 붙은 클래스나 인터페이스 들로 구성되는데, 이 실 인자들은 제네릭 자료형의 형식 형인자(formal type parameter) 각각에 대응된다. 예를 들어 List\<String\> 는 원소 자료형이 String인 리스트를 나타내는 형인자 자료형이다.

마지막으로, 각 제네릭 자료형은 새로운 무인자 자료형(raw type)을 정의하는데, 무인자 자료형은 실 형인자 없이 사용되는 제네릭 자료형이다. 예를 들어, List<E>의 무인자 자료형은 List이다. 무인자 자료형은 자료형 선언에서 제네릭 자료형 정보가 전부 삭제된 것처럼 동작한다. 여러 가지 현실적 이유로, 무인자 자료형 List는 제네릭 도입 이전의 인터페이스 자료형 List와 똑같이 동작한다.

자바 1.5 이전에는 컬렉션을 아래와 같이 선언하고 사용했다.

// 무인자 컬렉션 자료형. Stamp 객체만 보관한다.
private final Collection stamps = ...;

이 컬렉션에 엉뚱한 자료형의 객체를 넣어도 컴파일 시에는 아무런 문제가 없다.

// 실수로 Coin 객체를 넣었다.
stamps.add(new Coin(...));

stamps 컬렉션에서 객체를 꺼내고 형변환하는 과정에서 잘못 삽입된 Coin 객체를 만나게 되면 실행 도중에 오류가 발생한다.

// 무인자 반복자 자료형. 앞으로는 이렇게 코딩하면 안 된다.
for (Iterator i = stamps.iterator(); i.hasNext()) {
    Stamp s = (Stamp) i.next(); // ClassCastException 예외 발생
    ... // 우표 객체로 뭔가 작업을 한다.
}

이 책에서 계속 강조하고 있지만, 오류는 가능한 빨리 발견해야 하며, 컴파일할 때 발견할 수 있으면 가장 이상적이다. 그러나 위 예제의 오류는 코드에 섞인 한참 후에, 프로그램 실행 중에 발견된다. 그것도 오류가 생긴 곳과 멀리 떨어진 장소에서. ClassCastException이 발생하게 되면 여러분은 stamps 컬렉션에 엉뚱한 객체를 넣는 코드를 찾아내야 한다. 컴파일러는 아무런 도움도 주지 못한다. "Stamp 객체만 보관한다"는 주석의 의미를 이해하지 못하기 때문이다.

이런 주석 대신 제네릭을 쓰면, 컴파일러에게 컬렉션에 담길 객체의 자료형이 무엇인지 선언할 수 있다.

// 형인자 컬렉션 자료형 - 형 안전성(typesafe) 확보
private final Collection<Stamp> stamps = ...;

이 선언을 보면 컴파일러는 stamps 컬렉션에 Stamp 객체만 넣을 수 있다는 것을 이해하며, 실제로 Stamp 객체만 삽입되도록 한다. 물론 모든 코드는 자바 1.5 이상을 지원하는 컴파일러로 컴파일 해야 하며, 컴파일 할 때 어떤 경고(warning)도 뜨지 않아야한다. stamps를 형인자 자료형으로 선언하면, 엉뚱한 자료형의 객체를 넣는 코드를 컴파일 할 때 무엇이 잘못됐는지 확인할 수 있는 오류 메시지가 출력된다.

게다가 형인자 자료형을 쓰면 컬렉션에서 원소를 꺼낼 때 형변환을 하지 않아도 된다. 컴파일러가 프로그래머 대신 형변환 코드를 삽입하며, 이 형변환은 절대 실패하지 않는다. for-each 순환문을 쓰건, 아니면 전통적인 for 문을 쓰건 마찬가지다.

실수로 우표 컬렉션에 동전을 넣는 일이 정말 일어날 리 있겠냐고 생각하는 사람도 있을 수 있겠으나, 실제로 벌어지는 문제다. 예를 들어, java.sql.Date 객체만 넣어야 하는 컬렉션에 java.util.Date 객체를 넣는 상황을 생각해 보라.

위에도 적었지만, 제네릭 자료형을 형인자 없이 사용할 수도 있다. 하지만 그래서는 안 된다. 무인자 자료형을 쓰면 형 안전성이 사라지고, 제네릭의 장점 중 하나인 표현력(expessiveness) 측면에서 손해를 보게 된다. 그런데 무인자 자료형이 그렇게 나쁘다면, 왜 아직 자바는 무인자 자료형을 지원하는가? 호환성 때문이다. 자바 플랫폼이 제네릭을 도입하면서 다음 단계로 진화할 시점에, 세상에는 이미 엄청나게 많은 양의 코드가 제네릭 없이 구현된 상태였다. 이 코드들이 계속 컴파일 되도록 하고, 제네릭을 사용하는 새로운 코드와 호환되도록 하는 것이 중요했다. 무인자 자료형을 인자로 받는 메서드에 형인자 자료형 객체를 전달할 수도 있어야 했고, 그 반대도 가능해야 했다. 이전 호환성(migration compatibility)으로 알려진 이 요구사항 때문에 무인자 자료형을 지원하게 된 것이다.

새로 만드는 코드에서는 List와 같은 무인자 자료형을 쓰면 안 되지만, 아무 객체나 넣을 수 있는 List<Object> 같은 자료형은 써도 좋다. 그런데 List와 List<Object> 사이에는 무슨 차이가 있나? 간단히 말해서 List는 형 검사 절차를 완전히 생략한 것이도, List<Object>는 아무 객체나 넣을 수 있다는 것을 컴파일러에게 알리는 것이다. List를 인자로 받는 메서드에는 List<String>을 인자로 전달할 수 있지만 List<Object>를 받는 메서드에 List<String>을 인자로 넘길 수는 없다. 제네릭에 대한 하위 자료형 정의 규칙에 따르면 List<String>은 List의 하위 자료형(subtype)이지만 List<Object>의 하위 자료형은 아니기 때문이다(규칙 25). 따라서 List와 같은 무인자 자료형을 사용하면 형 안전성을 잃게 되지만, List<Object>와 같은 형인자 자료형을 쓰면 그렇지 않다.

아래의 예제를 통해 좀 더 구체적으로 살펴보자.

// 실행 도중에 오류를 일으키는 무인자 자료형(List) 사용 예
public static void main(String[] args) {
    List<String> strings = new ArrayList<String>();
    unsafeAdd(strings, new Integer(42));
    String s = strings.get(0);    // 컴파일러가 자동으로 형변환
}
private static void unsafeAdd(List list, Object o) {
    list.add(o);
}

이 프로그램을 컴파일은 잘 되지만 무인자 자료형 List를 썼기 때문에 경고가 뜬다. 그리고 이 프로그램을 실제로 돌려보면 strings.get(0)의 실행 결과를 String으로 변환하려 하기 때문에 ClassCastException 예외가 발생한다. 컴파일러가 자동으로 만드는 형변환 코드는 보통 성공적으로 동작하지만 위의 경고에는 경고를 무시했기 때문에 대가를 치르는 것이다. unsafeAdd 선언부의 List를 List<Object>로 바꾼 다음 다시 컴파일 해 보면, 더 이상은 컴파일이 되지 않음을 확인할 수 있다.

컬렉션에 들어갈 원소들의 자료형을 모르고 상관할 필요도 없다면 무인자 자료형을 써보고도 싶을 것이다. 예를 들어, 집합 두 개를 인자로 받아 공통 원소 개수를 반환하는 메서드를 작성하려 한다고 해 보자. 제네릭에 익숙하지 않은 프로그래머라면 아래와 같은 코드를 만들 수도 있다.

// 원소 자료형을 모르므로 무인자 자료형 사용 - 이러면 곤란
static int numElementsInCommon(Set s1, Set s2) {
    int result = 0;
    for (Object o1 : s1)
        if (s2.contains(o1))
            result++;
    return result;
}

이 메서드는 정상 동작하지만 무인자 자료형을 사용하므로 위험하다. 버전 1.5부터 자바는 비한정적 와일드카드 자료형(unbounded wildcard type)이라는 좀 더 안전한 대안을 제공한다. 제네릭 자료형을 쓰고 싶으나 실제 형 인자가 무엇인지는 모르거나 신경쓰고 싶지 않을 때는 형 인자로 '?'를 쓰면 된다. 예를 들어, Set<E>에 대한 비한정적 와일드카드 자료형은 Set<?>이다. 이 자료형은 가장 일반적인 형인자 Set 자료형으로, 어떤 Set이건 참조할 수 있다. 비한정적 와일드카드 자료형을 사용한 numElementsInCommon 메서드 코드를 아래에 보였다.

// 비한정적 와일드카드 자료형 - 형 안전성과 유연성 만족
static int numElementsInCommon(Set<?> s1, Set<?> s2) {
    int result = 0;
    for (Object o1 : s1)
        if (s2.contains(o1))
            result++;
    return result;
}

비한정적 와일드카드 자료형 Set<?>과 무인자 자료형 Set의 차이는 무엇인가? '?' 기호만 넣는다고 정말 차이가 생기나? 간단히 말해서 와일드카드 자료형은 안전하지만 무인자 자료형은 그렇지 않다. 무인자 자료형 컬렉션에는 아무 객체나 넣을 수 있어서, 컬렉션의 자료형 불변식(type invariant)이 쉽게 깨진다. 그러나 Collection<?>에는 null 이외의 어떤 원소도 넣을 수 없다.

Collection<?>에는 null 이외의 어떤 원소도 넣을 수 없을 뿐 아니라, 어떤 자료형의 객체를 꺼낼 수 있는지도 알 수 없다. 이런 제약이 불만이라면 제네릭 메서드(규칙 27)를 사용하거나, 한정적 와일드카드 자료형(bounded wildcard types)을 쓰면 된다(규칙 28).

새로 만드는 코드에는 무인자 자료형을 쓰면 안 된따고 했지만, 그 규칙에도 사소한 예외가 두 가지 있다. 제네릭 자료형 정보가 프로그램이 실행될 때는 지워지기 때문에 생기는 예외들이다(규칙 25). 첫 번째는 클래스 리터럴(class literal)에는 반드시 무인자 자료형을 사용해야 한다 는 것이다. 자바 표준에 따르면, 클래스 리터럴에는 형인자 자료형을 쓸 수 없다. 다시 말해서, List.class, String[].class, int.class는 가능하지만 List<String>.class 나 List<?>.class 는 사용할 수 없다는 뜻이다.

두 번째 예외는 instanceof 연산자 사용 규칙에 관한 것이다. 제네릭 자료형 정보는 프로그램이 실행될 때는 지워지기 때문에, instanceof 연산자는 비한정적 와일드카드 자료형 이외의 형인자 자료형에 적용할 수 없다. 게다가 무인자 자료형 대신 비한정적 와일드카드 자료형을 쓴다고 해서 instanceof 연산자가 다르게 동작하는 것은 아니다. 따라서 <?>를 붙여봐야 코드만 지저분해질 뿐이다. 제네릭 자료형에 instanceof 연산자를 적용할 때는 다음과 같이 하는 것이 좋다.

// instanceof 연산자에는 무인자 자료형을 써도 OK
if (o instanceof Set) {     // 무인자 자료형
    Set<?> m = (Set<?>) o;  // 와일드카드 자료형
    ...
}

o가 Set 객체라는 것이 확실해진 다음에는 와일드카드 자료형 Set<?>로 형변환 해야 하는 것에 주의하자. 점검지정 형변환이므로(checked cast) 컴파일러는 경고(warning) 메시지를 표시하지 않을 것이다.

요약

  • 무인자 자료형을 쓰면 프로그램 실행 도중에 예외가 발생할 수 있으므로 새로 만드는 코드에는 무인자 자료형을 쓰지 말아야 한다.
  • 무인자 자료형은 제네릭 도입 전에 작성된 코드와의 호환성을 유지하기 위해 제공되는 것에 불과하다.
  • Set<Object>는 어떤 자료형의 객체도 담을 수 있는 집합의 형인자 자료형이며, Set<?>는 모종의 자료형 객체만을 담을 수 있는 집합을 표현하는 와일드카드 자료형이다. Set은 무인자 자료형으로, 제네릭 자료형 시스템의 일부가 아니다. Set<Object>와 Set<?>는 안전하지만, Set은 안전하지 못하다.

규칙 24. 무점검 경고(unchecked warning)를 제거하라

무점검 경고(unchecked warning) 가운데 상당수는 쉽게 없앨 수 있다. 예를 들어, 실수로 아래와 같은 선언문을 만들었다고 해 보자.

Set<Lark> exaltation = new HashSet();

컴파일러는 무슨 잘못을 저질렀는지 친절하게 알려줄 것이다.

Venery.java:4: warning: [unchecked] unchecked conversion
found   : HashSet, required: Set<Lark>
...

컴파일러가 지적하는 대로 코드를 고치면 경고 메시지는 사라진다.

Set<Lark> exaltation = new HashSet<Lark>();

모든 무점검 경고는, 가능하다면 없애야 한다. 그런 경고를 전부 없애고 나면 여러분 코드는 형 안전성(typesafe)이 보장되는 것이므로 좋다. 프로그램 실행 도중에 ClassCastException이 발생하지 않을 것이므로, 프로그램이 원하는 대로 움직일 가능성이 더 커진다는 뜻이다.

제거할 수 없는 경고 메시지는 형 안전성이 확실할 때만 @SupressWarnings(“unchecked”) 어노테이션(annotation)을 사용해 억제하기 바란다. 형 안전성 증명도 없이 경고를 억제하면, 프로그램 안전성(security)에 대해 그릇된 생각을 갖게 될 뿐이다. 코드는 아무런 경고 없이 컴파일 되겠지만, 프로그램 실행 도중에 ClassCastException이 발생할 가능성은 그대로 남는다. 한편, 형 안전성이 확실한 코드의 경고를 억제하지 않고 그대로 두면, 진짜 문제가 되는 경고 메시지를 알아채지 못하는 문제가 생길 수 있다. 억제하지 않은 경고 사이에 묻혀, 무심결에 놓치게 될 수 있다는 것이다.

SupressWarnings 어노테이션은 개별 지역 변수 선언부터 클래스 전체에까지, 어떤 크기의 단위에도 적용할 수 있다. 하지만 SupressWarnings 어노테이션은 가능한 한 작은 범위에 적용하라. 보통은 변수 선언이나, 아주 짧은 메서드 또는 생성자에 붙인다. 절대로 클래스 전체에 SupressWarnings를 적용하지 마라. 중요한 경고 메시지를 놓치게 될 것이다.

SupressWarnings 어노테이션은 return 문에 붙일 수 없는데, 선언문이 아니기 때문이다. 메서드 전체에 어노테이션을 붙이고 싶을 수도 있겠지만 그렇게 하진 마라. 대신, 반환값을 담을 지역 변수를 선언한 다음에 해당 선언문 앞에 어노테이션을 붙이라.

// @SupressWarnings의 적용 범위를 줄이기 위해 지역 변수 사용
public <T> T[] toArray(T[] a) {
    if (a.length < size) {
        // 아래의 형변환은 배열의 자료형이 인자로 전달된 자료형인 T[]와 같으므로 정확하다.
        @SupressWarnings("unchecked") T[] result =
            (T[]) Arrays.copyOf(elements, size, a.getClass());
        return result;
    }
    System.arraycopy(elements, 0, a, 0, size);
    if (a.length > size)
        a[size] = null;
    return a;
}

이 메서드는 깔끔하게 컴파일될 뿐 아니라 무점검 코드에 대한 경고 메시지의 억제 범위를 최소한으로 줄이고 있다.

@SupressWarnings(“unchecked”) 어노테이션을 사용할 때마다, 왜 형 안전성을 위반하지 않는지 밝히는 주석을 반드시 붙이라. 그러면 다른 사람이 코드를 쉽게 이해하고, 더욱 중요하게는, 누군가 그 코드를 수정해서 형 안전성을 깨뜨릴 가능성이 줄어들게 된다. 그런 주석을 만들기가 까다롭다고 느낀다면 더 생각해 보라.

요약

  • 무점검 경고(unchecked warning)는 중요하다.
  • 모든 무점검 경고는 프로그램 실행 도중에 ClassCastException이 발생할 가능성을 나타낸다.
  • 경고를 제거할 수는 없으나 형 안전성을 보장한다는 사실을 입증할 수 있다면, @SupressWarnings(“unchecked”) 어노테이션을 사용해 해당 경고를 억제하되, 어노테이션 적용 범위는 최소화하라.

Reference