이펙티브 자바 완벽 공략 2부

아이템 29. 이왕이면 제네릭 타입으로 만들라

아이템 29. 이왕이면 제네릭 타입으로 만들라


아이템 29. 핵심 정리

아이템 29. 이왕이면 제네릭 타입으로 만들라

클래스를 설계하다 보면 다른 객체를 내부에 저장하는 역할을 하는 타입을 자주 만들게 된다.

대표적인 예가 자료구조다.

Stack
Queue
Tree
Cache
Repository
Container

이런 클래스들은 특정 타입 하나만을 저장하기보다 다양한 타입을 저장할 수 있도록 설계되는 경우가 많다.

예를 들어 Stack을 생각해보자.

Stack<String>

Stack<Integer>

Stack<Order>

Stack<Member>

모두 동일한 Stack 자료구조를 사용할 수 있다.

제네릭이 없다면 이런 범용성을 얻기 위해 흔히 Object를 사용하게 된다.

하지만 Object 기반 설계에는 문제가 있다.

저장할 때
→ 어떤 객체든 저장 가능

꺼낼 때
→ Object 반환

클라이언트
→ 직접 형변환 필요

잘못된 형변환은 결국 런타임의 ClassCastException으로 이어질 수 있다.

따라서 다른 객체를 저장하는 컨테이너 성격의 클래스를 직접 만든다면 가능한 한 제네릭 타입으로 설계하는 것이 좋다.


Object 기반 Stack부터 살펴보자

먼저 제네릭을 사용하지 않은 Stack을 만들어보자.

public class Stack {

    private Object[] elements;

    private int size = 0;

    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements =
                new Object[
                        DEFAULT_INITIAL_CAPACITY
                ];
    }

    public void push(Object element) {
        ensureCapacity();

        elements[size++] =
                element;
    }

    public Object pop() {

        if (size == 0) {
            throw new EmptyStackException();
        }

        Object result =
                elements[--size];

        elements[size] = null;

        return result;
    }

    private void ensureCapacity() {

        if (elements.length == size) {

            elements =
                    Arrays.copyOf(
                            elements,
                            2 * size + 1
                    );
        }
    }
}

Stack은 내부에 Object[] 배열을 가지고 있다.

private Object[] elements;

따라서 사실상 모든 참조 타입의 객체를 저장할 수 있다.

stack.push("A");

stack.push(100);

stack.push(new Order());

범용성은 있다.

하지만 타입 안전성이 부족하다.


Stack은 LIFO 구조다

Stack은 대표적인 LIFO(Last In First Out) 자료구조다.

먼저 push

A

↓

B

↓

C

꺼낼 때는 반대로 나온다.

pop()

C

↓

B

↓

A

예를 들어

Stack stack =
        new Stack();

for (String value :
        List.of(
                "A",
                "B",
                "C"
        )) {

    stack.push(value);
}

순서대로 A, B, C를 넣으면

while (!stack.isEmpty()) {

    System.out.println(
            stack.pop()
    );
}

개념적으로 다음 순서로 나온다.

C
B
A

자료구조 자체에는 문제가 없다.

문제는 pop()의 반환 타입이다.


Object를 반환하기 때문에 클라이언트가 형변환해야 한다

현재 pop()의 반환 타입은 다음과 같다.

public Object pop()

따라서 Stack에 문자열만 넣었다고 하더라도 클라이언트는 값을 꺼낼 때 직접 형변환해야 한다.

String value =
        (String) stack.pop();

Stack 입장에서는 내부에 어떤 타입이 들어 있는지 모른다.

그래서 Object밖에 반환할 수 없다.


잘못된 형변환을 할 수도 있다

클라이언트가 실수로 다음과 같이 작성할 수 있다.

Integer value =
        (Integer) stack.pop();

실제 객체가 String이라면 실행하는 순간

String 객체

↓

Integer 형변환

↓

ClassCastException

이 발생한다.

문제는 이것이 컴파일 시점에 완전히 차단되지 않는다는 것이다.


제네릭 Stack으로 만들자

이 문제를 해결하기 위해 Stack 자체를 제네릭 타입으로 만들 수 있다.

public class Stack<E> {
}

E는 Stack에 저장되는 원소의 타입을 의미한다.

예를 들어

Stack<String>

이라면 EString이다.

Stack<Integer>

라면 EInteger다.

따라서 Stack의 API를 다음처럼 만들 수 있다.

public void push(E element)

그리고

public E pop()

으로 반환할 수 있다.

이제 클라이언트는 더 이상 형변환할 필요가 없다.


이상적인 모습은 E[] 배열이다

기존 Stack의 내부 저장소는 다음과 같았다.

private Object[] elements;

제네릭 Stack으로 변경하면서 자연스럽게 다음과 같이 바꾸고 싶어진다.

private E[] elements;

그러면 내부적으로도 Stack의 원소 타입을 명확하게 표현할 수 있다.

문제는 배열을 만드는 순간 발생한다.


new E[]는 만들 수 없다

다음 코드는 사용할 수 없다.

elements =
        new E[
                DEFAULT_INITIAL_CAPACITY
        ];

컴파일 오류가 발생한다.

이유는 앞에서 살펴본 제네릭과 배열의 특성 때문이다.

배열
→ 런타임에 원소 타입이 실체화됨

제네릭 E
→ 타입 소거로 인해
  런타임에 그대로 실체화되지 않음

따라서 Java는 다음과 같은 제네릭 배열 생성을 허용하지 않는다.

new E[10];

그렇다면 제네릭 Stack에서 배열을 어떻게 사용할까?

대표적으로 두 가지 방법을 사용할 수 있다.

방법 1

E[]를 필드 타입으로 사용하고
Object[]를 만들어 E[]로 한 번 변환


방법 2

Object[]를 그대로 유지하고
값을 꺼낼 때 E로 변환

두 방법 모두 장단점이 있다.


방법 1. Object[]를 E[]로 한 번 형변환한다

첫 번째 방법은 필드를 다음처럼 선언한다.

private E[] elements;

그리고 생성자에서 Object[]를 만든 후 E[]로 형변환한다.

elements =
        (E[]) new Object[
                DEFAULT_INITIAL_CAPACITY
        ];

전체 구조는 다음과 같다.

public class Stack<E> {

    private E[] elements;

    private int size;

    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {

        elements =
                (E[]) new Object[
                        DEFAULT_INITIAL_CAPACITY
                ];
    }

    public void push(E element) {

        ensureCapacity();

        elements[size++] =
                element;
    }

    public E pop() {

        if (size == 0) {
            throw new EmptyStackException();
        }

        E result =
                elements[--size];

        elements[size] = null;

        return result;
    }
}

이제 push()

push(E element)

이고 pop()

E pop()

이다.

나머지 코드는 상당히 자연스럽다.


하지만 비검사 형변환 경고가 발생한다

문제는 다음 코드다.

(E[]) new Object[
        DEFAULT_INITIAL_CAPACITY
]

컴파일러는 경고를 발생시킨다.

unchecked cast

Object[]

↓

E[]

컴파일러 입장에서는 이 형변환이 정말 안전한지 완전히 확인할 수 없다.

E가 런타임에 무엇인지 알 수 없기 때문이다.


여기서 무조건 @SuppressWarnings를 붙이면 안 된다

아이템 27에서 살펴본 원칙을 적용해야 한다.

Unchecked Warning 발생

↓

제거할 수 있는가?

↓

제거하기 어렵다면

↓

정말 타입 안전한가?

↓

안전한 이유를 증명할 수 있는가?

↓

YES일 때만
@SuppressWarnings("unchecked")

따라서 먼저 Stack의 내부 구조를 분석해야 한다.


왜 이 Stack에서는 형변환이 안전하다고 볼 수 있을까?

핵심은 elements 배열을 누가 변경할 수 있는가다.

private E[] elements;

우선 필드가 private이다.

따라서 Stack 외부에서는 직접 접근할 수 없다.

그리고 배열 참조를 외부에 반환하지 않는다.

elements

↓

Stack 내부에만 존재

그렇다면 배열에 값을 집어넣을 수 있는 통로를 확인한다.

대표적으로 push()다.

public void push(E element) {

    ensureCapacity();

    elements[size++] =
            element;
}

매개변수 타입이

E

로 제한되어 있다.

즉 타입 안전하게 사용하는 한 배열에는 E와 호환되는 객체만 들어온다.


타입 안전성의 근거를 정리하면

elements는 private이다.

↓

배열 참조를 외부에 노출하지 않는다.

↓

외부에서 배열을 직접 수정할 수 없다.

↓

원소를 넣는 공개 경로는 push(E)다.

↓

따라서 E와 호환되는 원소만 저장된다.

이런 불변식이 유지된다면 개발자는 해당 비검사 형변환이 Stack 내부 사용 범위에서 안전하다고 판단할 수 있다.


그때 @SuppressWarnings를 적용한다

다음처럼 작성할 수 있다.

@SuppressWarnings("unchecked")
public Stack() {

    elements =
            (E[]) new Object[
                    DEFAULT_INITIAL_CAPACITY
            ];
}

다만 가능하다면 경고가 실제 발생하는 범위를 더 좁게 유지하는 것도 고려할 수 있다.

그리고 왜 안전한지를 설명하는 주석을 남기는 것이 좋다.

// elements 배열은 private이며 외부로 노출되지 않는다.
// 모든 원소는 push(E)를 통해서만 저장되므로
// 배열에는 E와 호환되는 객체만 들어간다.
@SuppressWarnings("unchecked")
public Stack() {

    elements =
            (E[]) new Object[
                    DEFAULT_INITIAL_CAPACITY
            ];
}

단순히

경고가 떠서 suppress했다.

가 아니라 왜 안전한지를 설명할 수 있어야 한다.


첫 번째 방법의 장점은 형변환이 한 번뿐이라는 것이다

비검사 형변환은 배열을 생성할 때 딱 한 번 발생한다.

(E[]) new Object[...]

이후에는 자연스럽게 E[]처럼 사용할 수 있다.

elements[size++] =
        element;

그리고 값을 꺼낼 때도

E result =
        elements[--size];

형변환이 필요하지 않다.

코드가 읽기 쉽다.


첫 번째 방법에는 Heap Pollution이 존재한다

하지만 이 방법은 엄밀하게 보면 Heap Pollution을 일으킨다.

필드의 컴파일타임 타입은

E[]

이다.

하지만 실제 런타임 배열 객체의 타입은

Object[]

이다.

컴파일타임 관점

E[]


실제 런타임 객체

Object[]

라는 차이가 존재한다.

제네릭 타입이 기대하는 타입과 실제 객체의 런타임 타입 사이에 불일치가 있기 때문에 힙 오염 상태로 볼 수 있다.

다만 이 Stack처럼 배열이 완전히 캡슐화되어 있고 타입 불변식을 확실하게 유지한다면 실제 타입 오류로 이어지지 않도록 관리할 수 있다.


배열을 외부에 노출하면 문제가 달라진다

다음과 같은 메서드를 추가했다고 생각해보자.

public E[] getElements() {
    return elements;
}

이제 내부 배열 참조가 외부로 빠져나간다.

Stack

↓

E[] 반환

↓

외부 코드가 배열 참조 획득

그러면 내부에서 유지하던 안전성 조건을 더 이상 Stack만으로 통제할 수 없게 된다.

따라서 이런 방식으로 제네릭 배열을 사용하는 경우에는 특히 배열 참조를 외부에 노출하지 않는 것이 중요하다.


방법 2. Object[]를 그대로 사용한다

두 번째 방법은 애초에 필드 타입을 변경하지 않는다.

private Object[] elements;

생성자도 매우 단순하다.

public Stack() {

    elements =
            new Object[
                    DEFAULT_INITIAL_CAPACITY
            ];
}

여기에는 비검사 배열 형변환이 없다.


push()에서는 E만 받는다

내부 저장소가 Object[]라고 해서 공개 API까지 Object를 받을 필요는 없다.

public void push(E element) {

    ensureCapacity();

    elements[size++] =
            element;
}

공개 API에서는 여전히 E만 허용한다.

따라서

Stack<String> stack =
        new Stack<>();

이라면 다음은 가능하다.

stack.push("hello");

하지만 다음은 컴파일되지 않는다.

stack.push(100);

외부 API의 타입 안전성은 그대로 유지된다.


값을 꺼낼 때 E로 형변환한다

문제는 내부 배열이 Object[]라는 것이다.

Object result =
        elements[--size];

클라이언트에게는 E를 반환해야 한다.

따라서 다음처럼 형변환한다.

E result =
        (E) elements[--size];

여기에서도 비검사 형변환 경고가 발생한다.


이 형변환 역시 안전성의 근거가 있다

왜 다음 형변환이 안전하다고 볼 수 있을까?

(E) elements[--size]

다시 Stack의 불변식을 생각하면 된다.

배열에 값을 넣는 통로는

push(E element)

이다.

따라서 elements에 저장되어 있는 실제 원소는 E와 호환되는 타입이어야 한다.

elements는 Object[]지만

↓

저장되는 값은 모두 push(E)를 통함

↓

따라서 저장된 실제 원소는 E와 호환됨

↓

꺼낸 Object를 E로 변환해도 안전

이것을 개발자가 증명할 수 있다면 비검사 경고를 억제할 수 있다.


가능한 한 좁은 범위에서 경고를 억제한다

예를 들어 다음과 같이 할 수 있다.

public E pop() {

    if (size == 0) {
        throw new EmptyStackException();
    }

    @SuppressWarnings("unchecked")
    E result =
            (E) elements[--size];

    elements[size] = null;

    return result;
}

이 방법은 메서드 전체에

@SuppressWarnings("unchecked")

를 붙이는 것보다 범위가 좁다.

아이템 27의 원칙과도 잘 맞는다.


두 번째 방법은 배열 자체의 Heap Pollution을 피할 수 있다

이 방식에서 실제 배열의 타입은 처음부터 끝까지

Object[]

이다.

필드 타입

Object[]

↓

실제 배열 타입

Object[]

둘이 일치한다.

첫 번째 방법처럼

E[]로 선언했지만
실제로는 Object[]

라는 배열 참조의 타입 불일치를 만들지 않는다.

따라서 그 의미의 힙 오염은 피할 수 있다.


하지만 값을 꺼낼 때마다 형변환이 필요하다

첫 번째 방법은 생성할 때 한 번만 형변환했다.

(E[]) new Object[...]

두 번째 방법은 배열에서 E 값을 꺼내야 하는 지점마다 형변환이 필요할 수 있다.

(E) elements[index]

Stack에서는 pop() 한 곳 정도라면 크게 복잡하지 않을 수 있다.

하지만 배열을 읽는 코드가 여러 곳에 존재한다면

cast

cast

cast

cast

가 반복될 수 있다.

코드가 장황해질 수 있다는 것이 단점이다.


두 가지 방법을 비교해보자

구분방법 1: E[]방법 2: Object[]
필드 타입E[]Object[]
실제 생성 배열Object[]Object[]
비검사 형변환 위치배열 생성 시원소 조회 시
형변환 횟수보통 한 번조회 지점마다 가능
이후 코드 가독성좋음상대적으로 캐스팅 증가
배열 수준 Heap Pollution발생피할 수 있음
안전성 근거 필요OO
@SuppressWarnings 필요 가능성OO

두 방법 모두 사용할 수 있다.

중요한 것은 경고를 아무 생각 없이 억제하는 것이 아니라 왜 안전한지 명확하게 증명하는 것이다.


첫 번째 방법이 자주 사용되는 이유

첫 번째 방법은 다음과 같다.

private E[] elements;

그리고 생성할 때 한 번만

(E[]) new Object[...]

로 변환한다.

이후 코드에서는 자연스럽게 E[]처럼 사용할 수 있다.

E value =
        elements[index];

따라서

형변환 지점 하나

+

나머지 구현 코드가 깔끔함

이라는 장점이 있다.

내부 배열을 완전히 캡슐화하고 타입 불변식을 지킬 수 있다면 실용적으로 사용할 수 있는 방식이다.


제네릭 Stack 전체 예제

첫 번째 방법으로 Stack을 완성하면 다음과 같은 형태가 된다.

public class Stack<E> {

    private E[] elements;

    private int size = 0;

    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    // elements는 외부에 노출되지 않으며
    // 모든 원소는 push(E)를 통해서만 저장되므로
    // 배열에는 E와 호환되는 객체만 들어간다.
    @SuppressWarnings("unchecked")
    public Stack() {

        elements =
                (E[]) new Object[
                        DEFAULT_INITIAL_CAPACITY
                ];
    }

    public void push(E element) {

        ensureCapacity();

        elements[size++] =
                element;
    }

    public E pop() {

        if (size == 0) {
            throw new EmptyStackException();
        }

        E result =
                elements[--size];

        elements[size] = null;

        return result;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    private void ensureCapacity() {

        if (elements.length == size) {

            elements =
                    Arrays.copyOf(
                            elements,
                            2 * size + 1
                    );
        }
    }
}

이제 클라이언트 코드가 달라진다

기존에는 다음과 같았다.

Stack stack =
        new Stack();

stack.push("A");
stack.push("B");
stack.push("C");

String value =
        (String) stack.pop();

형변환이 필요했다.

제네릭 Stack에서는 다음과 같다.

Stack<String> stack =
        new Stack<>();

stack.push("A");
stack.push("B");
stack.push("C");

String value =
        stack.pop();

다음 코드가 사라졌다.

(String)

클라이언트가 형변환 책임을 지지 않는다.


잘못된 타입의 값도 넣을 수 없다

다음 Stack이 있다.

Stack<String> stack =
        new Stack<>();

정상적인 코드는 다음과 같다.

stack.push("hello");

하지만 다음은 컴파일되지 않는다.

stack.push(100);

즉 기존의 Object 기반 Stack에서 가능했던

String 넣기

Integer 넣기

Order 넣기

같은 타입 혼합을 컴파일러가 막아준다.


꺼낼 때도 타입이 보장된다

Stack<String>pop()은 결과적으로 String을 반환한다.

String value =
        stack.pop();

Stack<Integer>라면

Integer value =
        stack.pop();

이다.

즉 제네릭 클래스 하나로 여러 타입을 지원하면서도 각각의 Stack은 자신의 타입 계약을 유지한다.

Stack<String>
→ String만 저장
→ String 반환


Stack<Integer>
→ Integer만 저장
→ Integer 반환

범용성과 타입 안전성을 동시에 얻는다

Object 기반 클래스의 특징은 다음과 같았다.

여러 타입 사용 가능

O

타입 안전성

X

타입별 클래스를 따로 만들면 안전할 수 있지만 재사용성이 떨어진다.

StringStack

IntegerStack

OrderStack

MemberStack

제네릭은 두 장점을 함께 얻는다.

Stack<E>

↓

여러 타입에서 재사용

O

+

각 인스턴스의 타입 안전성

O

이것이 제네릭 클래스를 직접 만들어야 하는 가장 중요한 이유 중 하나다.


기존 Raw Type 코드도 바로 깨지는 것은 아니다

Java의 제네릭은 Java 5에서 도입됐다.

기존 코드에서는 다음처럼 사용했을 것이다.

Stack stack =
        new Stack();

stack.push("hello");

String value =
        (String) stack.pop();

Stack 클래스를 제네릭으로 변경했다고 생각해보자.

public class Stack<E> {
}

기존 코드에서 계속

Stack stack =
        new Stack();

라고 사용할 수도 있다.

이 경우 Stack은 Raw Type이 된다.

컴파일러 경고가 발생할 수 있지만 Java가 기존 비제네릭 코드와의 마이그레이션 호환성을 고려해 Raw Type을 지원하므로 이런 형태가 즉시 문법적으로 사라지는 것은 아니다.


새로운 코드는 매개변수화 타입을 사용해야 한다

기존 코드가 동작할 수 있다고 해서 새 코드에서도 Raw Type을 사용할 이유는 없다.

다음처럼 작성하면 안 된다.

Stack stack =
        new Stack();

아이템 26에서 살펴봤듯 Raw Type은 타입 안전성을 포기한다.

새로운 코드는 다음처럼 작성한다.

Stack<String> stack =
        new Stack<>();

이제

stack.push("hello");

는 가능하지만

stack.push(100);

은 컴파일러가 차단한다.

그리고 다음과 같은 형변환도 필요 없다.

String value =
        stack.pop();

타입 소거가 기존 코드와의 호환성을 돕는다

Java 제네릭은 타입 소거를 중심으로 구현되어 있다.

소스에서는 다음처럼 보인다.

Stack<String>

하지만 런타임에 StackOfString이라는 별도의 클래스가 생기는 것은 아니다.

개념적으로 런타임에서는 기존 Stack 구조와 호환될 수 있는 방식으로 처리된다.

그래서 Java 5 이전 스타일의 코드와 새로운 제네릭 코드가 함께 존재할 수 있도록 마이그레이션하는 데 유리했다.

정확하게는 최신 Java로 컴파일한 클래스가 무조건 오래된 JVM에서도 실행된다는 의미가 아니라, 기존 API와 기존 비제네릭 코드가 새로운 제네릭 타입 시스템으로 점진적으로 이동할 수 있도록 설계되었다는 의미로 이해하는 것이 좋다.


그렇다면 배열보다 List를 사용하라는 아이템 28과 모순되지 않을까?

앞에서는

배열보다는 리스트를 사용하라.

고 했다.

그렇다면 Stack에서도 다음처럼 만들면 되는 것 아닐까?

private List<E> elements;

물론 상황에 따라 가능하다.

하지만 아이템 28이

배열을 절대로 사용하지 마라.

라는 뜻은 아니다.


직접 자료구조를 구현할 때는 배열이 필요할 수 있다

Stack 같은 저수준 자료구조를 직접 구현한다면 내부 표현으로 배열을 사용하는 것이 자연스러울 수 있다.

Stack

↓

내부 저장 공간

↓

Array

배열은 구조가 단순하고 인덱스를 이용해 빠르게 접근할 수 있으며 추가적인 컬렉션 추상화 없이 직접 메모리 구조를 제어할 수 있다.

특히

자료구조

저수준 라이브러리

성능 민감 코드

에서는 배열을 완전히 피할 수 없는 경우가 있다.

따라서 아이템 28의 의미는

제네릭 코드에서 배열 때문에
타입 안전성 문제가 생기고

List로 자연스럽게 대체할 수 있다면

List를 우선 고려하라.

에 가깝다.


배열을 사용해야 한다면 위험을 한곳에 가둔다

제네릭 Stack 예제에서 중요한 설계 원칙은 이것이다.

불가피한 unchecked 연산

↓

아주 작은 범위에 격리

↓

왜 안전한지 검증

↓

외부 API는 완전히 타입 안전하게 제공

예를 들어 생성자 한 곳에서만 다음 형변환이 존재한다.

(E[]) new Object[...]

그리고 외부에는

push(E)
E pop()

만 제공한다.

클라이언트는 내부의 비검사 연산을 알 필요가 없다.


위험한 코드를 API 바깥으로 밀어내지 않는다

좋은 제네릭 라이브러리는 내부에서 불가피한 복잡성을 처리하고 클라이언트에게는 안전한 API를 제공한다.

Stack 내부

unchecked cast
배열 관리
타입 안전성 불변식 관리

↓

API 경계

push(E)
pop(): E

↓

클라이언트

cast 없음
unchecked warning 없음
타입 안전

이런 구조가 이상적이다.


컨테이너 클래스에서 Object가 보이면 제네릭화를 검토하자

실무 코드에서 다음과 같은 클래스가 있다고 생각해보자.

public class Cache {

    private Object value;

    public void put(Object value) {
        this.value = value;
    }

    public Object get() {
        return value;
    }
}

클라이언트는 매번 형변환해야 한다.

Order order =
        (Order) cache.get();

이런 클래스는 제네릭화할 수 있는지 확인할 가치가 있다.

public class Cache<T> {

    private T value;

    public void put(T value) {
        this.value = value;
    }

    public T get() {
        return value;
    }
}

사용하는 쪽은

Cache<Order> cache =
        new Cache<>();

가 된다.

그리고

Order order =
        cache.get();

처럼 안전하게 사용할 수 있다.


Repository에서도 동일한 원리가 적용된다

앞서 살펴본 Generic Repository 역시 같은 원리다.

public class Repository<E> {

    public void save(E entity) {
        ...
    }

    public E findById(Long id) {
        ...
    }
}

제네릭을 사용하지 않는다면

Object findById(Long id)

형태가 될 수도 있다.

그러면 모든 클라이언트가 직접 형변환해야 한다.

제네릭을 사용하면 타입 정보가 API 전체에 전달된다.

Repository<Order>

↓

save(Order)

↓

findById()

↓

Order

컨테이너 성격의 타입에서 제네릭이 특히 강력한 이유다.


언제 제네릭 타입으로 만들지 판단하는 기준

클래스를 설계하면서 다음과 같은 특징이 보이면 제네릭을 검토할 수 있다.

다른 객체를 내부에 저장한다.

다양한 타입에서 동일한 알고리즘을 사용한다.

현재 Object를 필드나 매개변수로 많이 사용한다.

값을 반환할 때 Object를 반환한다.

클라이언트가 결과를 반복해서 형변환한다.

타입만 다르고 거의 같은 클래스가 반복된다.

이런 경우

<T>

를 도입할 수 있는지 살펴본다.


하지만 모든 Object를 제네릭으로 바꿀 필요는 없다

Object가 보인다고 해서 무조건 제네릭으로 바꿔야 하는 것은 아니다.

실제로 여러 종류의 서로 무관한 객체를 함께 처리하는 것이 클래스의 목적이라면 Object가 적절할 수도 있다.

중요한 질문은 다음이다.

이 클래스는 정말 아무 객체나 다루는가?

아니면

한 인스턴스가 특정 타입 하나를
일관되게 다루는가?

후자라면 제네릭이 적합할 가능성이 높다.


첫 번째 구현 방식의 안전성 조건

E[] 전략을 사용할 때는 특히 다음 조건을 확인해야 한다.

배열이 private인가?

배열 레퍼런스를 외부에 공개하지 않는가?

배열에 값을 넣는 모든 경로를 통제하는가?

그 경로에서 E 타입만 받아들이는가?

unchecked cast가 안전한 이유를 설명할 수 있는가?

조건이 깨지면

@SuppressWarnings("unchecked")

를 붙일 근거 역시 사라진다.


두 번째 구현 방식의 안전성 조건

Object[] 전략에서도 마찬가지다.

Object[] 안에 저장되는 값이
모두 E라는 것을 보장하는가?

외부 코드가 배열을 변경할 수 없는가?

Object → E 캐스팅이
왜 안전한지 설명할 수 있는가?

이런 근거가 있어야

(E) elements[index]

의 경고를 억제할 수 있다.


@SuppressWarnings를 붙인다는 것은 책임을 가져간다는 뜻이다

컴파일러가 다음과 같이 말하고 있다고 생각하면 된다.

"나는 이 형변환이
정말 안전한지 모르겠다."

그때 개발자가

@SuppressWarnings("unchecked")

를 붙인다는 것은 사실상 다음을 선언하는 것이다.

"내가 이 코드의 불변식을 분석했고

컴파일러가 증명하지 못할 뿐

실제로는 타입 안전하다는 것을
보장하겠다."

따라서 단순히 노란 경고를 없애는 용도로 사용하면 안 된다.


제네릭 Stack으로 얻은 효과

리팩터링 전에는 다음 구조였다.

Stack

↓

Object push(Object)

↓

Object pop()

↓

Client cast

↓

잘못된 cast 가능

↓

ClassCastException

제네릭으로 변경하면 다음과 같다.

Stack<E>

↓

push(E)

↓

pop(): E

↓

Client cast 불필요

↓

잘못된 타입 사용은
Compile Time에 차단

클래스 하나를 제네릭화함으로써 타입 안전성에 대한 책임을 모든 클라이언트에게 떠넘기지 않고 Stack 구현 자체에서 관리할 수 있게 된 것이다.


아이템 26부터 29까지 연결하기

제네릭 관련 아이템을 연결해서 보면 방향이 명확하다.

아이템 26

Raw Type을 사용하지 말라.

↓

타입 정보를 버리지 않는다.

아이템 27

비검사 경고를 제거하라.

↓

컴파일러가 확인하지 못하는
타입 안전성 문제를 줄인다.

아이템 28

배열보다는 리스트를 사용하라.

↓

제네릭과 배열의 충돌을
가능하면 피한다.

아이템 29

이왕이면 제네릭 타입으로 만들라.

↓

라이브러리와 클래스 자체가
타입 안전한 API를 제공한다.

결국 공통 목표는 하나다.

형변환과 타입 오류의 책임을
클라이언트에게 넘기지 말고

타입 시스템을 이용해
컴파일러가 검증하도록 만들자.

핵심 정리

  • 다른 객체를 내부에 저장하는 컨테이너 성격의 클래스는 제네릭 타입으로 만들었을 때 특히 큰 장점을 얻을 수 있다.
  • Object 기반 Stack은 다양한 타입을 저장할 수 있지만 값을 꺼낼 때마다 클라이언트가 직접 형변환해야 한다.
  • 잘못된 형변환은 런타임의 ClassCastException으로 이어질 수 있다.
  • Stack<E>처럼 클래스를 제네릭 타입으로 만들면 push(E)pop(): E를 제공할 수 있다.
  • 제네릭 Stack을 사용하면 클라이언트의 명시적인 형변환이 사라지고 잘못된 타입 사용을 컴파일 단계에서 차단할 수 있다.
  • 내부 배열을 E[]로 만들고 싶어도 new E[]는 사용할 수 없다.
  • E는 타입 소거 때문에 일반적으로 실체화되지 않으므로 Java가 제네릭 배열 생성을 막는다.
  • 제네릭 Stack에서 배열을 사용하는 첫 번째 방법은 Object[]를 생성한 뒤 E[]로 한 번 비검사 형변환하는 것이다.
  • 첫 번째 방법은 이후 구현 코드가 간결하고 형변환이 한 곳에 집중된다는 장점이 있다.
  • 이 방식에서는 실제 배열이 Object[]인데 E[]로 참조하므로 힙 오염이 존재하지만 배열을 완전히 캡슐화하고 타입 불변식을 지키면 안전하게 관리할 수 있다.
  • 비검사 경고를 억제하려면 배열이 외부에 노출되지 않고 모든 값이 push(E) 같은 타입 안전한 경로를 통해서만 들어온다는 근거가 필요하다.
  • 두 번째 방법은 내부 필드를 Object[]로 그대로 유지하고 값을 꺼낼 때마다 E로 형변환하는 것이다.
  • 두 번째 방법은 배열의 런타임 타입과 변수 타입이 모두 Object[]이므로 첫 번째 방식의 배열 수준 힙 오염을 피할 수 있다.
  • 대신 원소를 꺼내는 여러 위치에서 비검사 형변환이 반복될 수 있다는 단점이 있다.
  • 어느 방법을 사용하더라도 @SuppressWarnings("unchecked")는 코드가 실제로 안전하다는 근거를 설명할 수 있을 때만 사용해야 한다.
  • 제네릭 타입으로 변경한 후에도 Raw Type인 Stack으로 사용할 수 있는 것은 기존 비제네릭 코드와의 마이그레이션 호환성을 고려한 Java 제네릭 설계와 관련이 있다.
  • 새로운 코드는 Stack<String>처럼 매개변수화 타입으로 사용해 제네릭의 타입 안전성을 활용해야 한다.
  • 아이템 28에서 배열보다 List를 권장했다고 해서 배열 자체를 절대 사용하지 말라는 뜻은 아니다.
  • Stack 같은 자료구조나 저수준 구현, 성능에 민감한 코드에서는 내부 자료구조로 배열을 사용할 필요가 있을 수 있다.
  • 배열이 불가피하다면 위험한 비검사 연산을 구현 내부의 작은 범위에 가두고 외부에는 완전히 타입 안전한 API를 제공하는 것이 중요하다.
  • Stack뿐 아니라 Cache, Repository, Queue 등 한 인스턴스가 특정 타입을 일관되게 저장하고 반환하는 클래스라면 제네릭화를 적극적으로 검토할 수 있다.
  • 제네릭 타입을 직접 설계하는 핵심 가치는 범용성을 유지하면서도 타입 안전성을 클라이언트의 형변환이 아니라 컴파일러의 타입 검사로 보장하도록 만드는 것이다.

한 줄 요약

다른 객체를 저장하고 반환하는 클래스가 Object에 의존해 클라이언트에게 형변환을 요구하고 있다면 Stack<E>처럼 제네릭 타입으로 바꾸는 것을 우선 고려하고, 제네릭 배열이 불가피한 저수준 구현에서는 비검사 형변환을 최소 범위에 격리하고 안전성의 근거를 명확히 유지해 외부에는 완전히 타입 안전한 API를 제공해야 한다.

아이템 29. 완벽 공략 - 한정적 타입 매개변수

아이템 29 완벽 공략: 한정적 타입 매개변수

아이템 29에서는 직접 만든 클래스를 제네릭 타입으로 변경하는 방법을 살펴봤다.

예를 들어 기존의 Object 기반 Stack을

public class Stack<E> {
}

처럼 제네릭 타입으로 변경하면 클라이언트가 직접 형변환할 필요가 없어지고 타입 안전성도 높아진다.

이번에는 여기서 한 단계 더 나아간다.

현재 E에는 거의 모든 참조 타입을 사용할 수 있다.

Stack<String>

Stack<Integer>

Stack<Order>

Stack<Member>

그런데 특정 클래스가 정말 모든 타입을 지원할 필요는 없을 수도 있다.

예를 들어

이 Stack에는 숫자 타입만 허용하고 싶다.

라는 요구사항이 있다고 생각해보자.

이럴 때 사용하는 것이 한정적 타입 매개변수(Bounded Type Parameter) 다.


일반적인 타입 매개변수 E는 무엇으로 소거될까?

먼저 다음 Stack부터 살펴보자.

public class Stack<E> {

    private E[] elements;

    public void push(E element) {
        // ...
    }

    public E pop() {
        // ...
        return null;
    }
}

여기서 E는 특정 타입으로 제한되어 있지 않다.

<E>

따라서 String도 가능하다.

Stack<String>

Integer도 가능하다.

Stack<Integer>

Order도 가능하다.

Stack<Order>

그렇다면 이 클래스를 컴파일했을 때 E는 무엇이 될까?

클라이언트가 다음처럼 사용했다고 해서

Stack<Integer> stack =
        new Stack<>();

Stack 클래스 자체의 바이트코드가 Integer 전용 Stack으로 만들어지는 것은 아니다.

Java 제네릭은 기본적으로 타입 소거를 사용한다.

한정되지 않은 타입 매개변수의 소거 타입은 Object다.

즉 개념적으로

E

는 소거 후

Object

를 기준으로 처리된다.


메서드도 Object를 기준으로 바뀐다

소스 코드에서는 다음과 같다.

public void push(E element) {
}

타입 소거 후 개념적으로는

public void push(Object element) {
}

와 같은 형태가 된다.

pop()도 마찬가지다.

public E pop() {
}

소거 이후의 메서드 디스크립터 관점에서는 반환 타입이 Object를 기준으로 처리된다.

push(E)
    ↓
push(Object)


pop(): E
    ↓
pop(): Object

제네릭 타입 정보는 컴파일 시점의 타입 검사에 활용되고, 필요하다면 클라이언트 쪽에서 컴파일러가 적절한 형변환을 추가한다.


E[]도 Object[]를 기준으로 소거된다

필드가 다음과 같다고 해보자.

private E[] elements;

한정되지 않은 E의 소거 타입이 Object이므로 E[] 역시 런타임 구현 관점에서는 Object[]와 연결된다.

그래서 앞에서 다음과 같은 구현을 사용할 수 있었다.

@SuppressWarnings("unchecked")
public Stack() {

    elements =
            (E[]) new Object[
                    DEFAULT_INITIAL_CAPACITY
            ];
}

소스만 보면

Object[]

↓

E[]

라는 상당히 수상한 형변환이다.

그래서 컴파일러가 비검사 경고를 발생시킨다.

그런데 한정되지 않은 E의 소거 타입은 Object다.

따라서 이 Stack의 내부 구현에서는 배열을 외부에 노출하지 않고 push(E)를 통해서만 원소를 저장한다는 불변식을 기반으로 형변환의 안전성을 관리할 수 있었다.


타입 매개변수를 Number로 제한해보자

이번에는 Stack이 아무 타입이나 지원하지 않고 숫자만 지원해야 한다고 해보자.

다음처럼 작성할 수 있다.

public class Stack<
        E extends Number
        > {
}

핵심은 이것이다.

E extends Number

이것이 바로 한정적 타입 매개변수다.

의미는

E는 Number 또는 Number의 하위 타입이어야 한다.

이다.


Integer는 사용할 수 있다

IntegerNumber를 상속한다.

Object
  ↑
Number
  ↑
Integer

따라서 다음은 가능하다.

Stack<Integer> stack =
        new Stack<>();

DoubleNumber의 하위 타입이다.

Stack<Double> stack =
        new Stack<>();

Long도 가능하다.

Stack<Long> stack =
        new Stack<>();

String은 사용할 수 없다

반면 StringNumber의 하위 타입이 아니다.

따라서 다음 코드는 컴파일되지 않는다.

Stack<String> stack =
        new Stack<>();

컴파일러가 타입 인수 자체를 거부한다.

Stack<E extends Number>

             ↓

허용되는 E

Number
Integer
Long
Double
Float
...


String

X

이것이 한정적 타입 매개변수의 가장 기본적인 역할이다.


extends는 Number의 상위 타입이라는 의미가 아니다

여기서 자주 혼동하는 부분이 있다.

<E extends Number>

E가 Number의 상위 타입이어야 한다.

는 뜻이 아니다.

반대다.

E는 Number 자체이거나
Number의 하위 타입이어야 한다.

예를 들어

        Number
       /      \
  Integer     Double

가 있다면 Integer, Double 등을 E로 사용할 수 있다.


인터페이스를 제한할 때도 extends를 사용한다

제네릭의 타입 경계에서는 클래스뿐만 아니라 인터페이스를 사용할 수도 있다.

예를 들어

<E extends Comparable<E>>

라고 작성할 수 있다.

Comparable은 인터페이스지만 제네릭 문법에서는

extends

를 사용한다.

즉 한정적 타입 매개변수 문맥에서 extends는 단순히 클래스 상속만 의미하는 것이 아니라

이 타입은 최소한 이 상위 경계를 만족해야 한다.

정도로 이해하는 것이 좋다.


중요한 변화: E의 소거 타입도 달라진다

E를 한정하지 않았을 때는

<E>

였다.

이 경우 소거 타입은

Object

다.

그런데 다음처럼 바꿨다.

<E extends Number>

이제 E의 소거 타입은 Object가 아니라 Number다.

이것이 매우 중요하다.

<E>

E의 소거 타입
→ Object

반면

<E extends Number>

E의 소거 타입
→ Number

이다.

한정적 타입 매개변수는 단순히 소스 코드에서 사용할 수 있는 타입만 제한하는 것이 아니라 타입 소거 결과에도 영향을 준다.


push(E)는 push(Number)를 기준으로 처리된다

다음 메서드가 있다.

public void push(E element) {
}

E의 상위 경계가 Number이므로 소거 후에는 개념적으로

public void push(Number element) {
}

를 기준으로 처리된다.

pop()도 마찬가지다.

public E pop() {
}

소거 타입을 기준으로 보면

public Number pop() {
}

에 가까운 형태가 된다.

E extends Number

↓

Erasure(E) = Number

이므로

push(E)
→ push(Number)

pop(): E
→ pop(): Number

가 된다.


E[] 역시 Number[]를 기준으로 소거된다

여기서 앞의 Stack 구현과 중요한 충돌이 발생한다.

필드가 다음과 같다고 하자.

private E[] elements;

E의 소거 타입이 Number가 되었으므로 E[]도 소거 관점에서

Number[]

와 연결된다.

그런데 기존 생성자에서는 다음과 같이 작성하고 있었다.

elements =
        (E[]) new Object[
                DEFAULT_INITIAL_CAPACITY
        ];

한정되지 않은 E에서는 이 코드가 내부 불변식을 전제로 사용할 수 있었지만 E extends Number로 바꾸면 상황이 달라진다.


Object[]를 Number[]로 바꾸려고 하는 셈이 된다

소거 관점에서 보면 기존 코드가 사실상 다음과 비슷한 상황을 만들 수 있다.

Number[] elements =
        (Number[]) new Object[10];

이 코드는 안전할까?

안전하지 않다.

실제 생성된 배열의 런타임 타입은

Object[]

이다.

그런데 이를

Number[]

로 형변환하려고 한다.

실행하면 문제가 발생한다.

Object[]

↓

Number[]

↓

ClassCastException

배열은 런타임 타입을 기억한다

왜 문제가 발생할까?

배열은 실체화 타입이기 때문이다.

다음 배열을 만들면

new Object[10]

JVM은 이것이 런타임에도 Object[]라는 사실을 알고 있다.

변수 선언을 어떻게 해도 실제 객체 자체가 Number[]로 변하는 것이 아니다.

실제 객체

Object[]

를 단순한 형변환만으로

실제 객체

Number[]

로 바꿀 수는 없다.


Number[]를 Object[]로 참조하는 것은 가능하다

반대 방향은 다르다.

Number[] numbers =
        new Number[10];

Object[] objects =
        numbers;

이것은 가능하다.

배열은 공변이기 때문이다.

NumberObject의 하위 타입이므로

Number <: Object

배열에서도

Number[] <: Object[]

관계가 성립한다.


하지만 Object[]는 Number[]가 아니다

반대로 다음은 안전하지 않다.

Object[] objects =
        new Object[10];

Number[] numbers =
        (Number[]) objects;

왜냐하면 Object[]에는 다음과 같은 값들도 들어갈 수 있기 때문이다.

objects[0] = "hello";

objects[1] = new Object();

objects[2] = new Member();

이 배열을 무조건 Number[]라고 부를 수 없다.

그래서 런타임이 형변환을 거부한다.


기존 구현에 한정을 추가했더니 갑자기 깨질 수 있다

이 부분이 한정적 타입 매개변수를 이해할 때 굉장히 중요하다.

처음에는

class Stack<E>

였다.

그리고 내부에서

(E[]) new Object[...]

를 사용했다.

그런데 단순히

class Stack<E extends Number>

로 변경했더니 런타임 동작까지 달라질 수 있다.

왜냐하면

E의 소거 타입

Object

↓

Number

로 바뀌었기 때문이다.

따라서 비검사 형변환이 있다는 것은 이런 변경에서 특히 신중해야 한다.

컴파일러의 경고를 억제했다고 해서 런타임 형변환 자체가 사라지는 것은 아니다.


해결 방법: Object[]를 그대로 유지하자

아이템 29에서 살펴본 두 번째 구현 방법을 사용하면 이 문제를 피할 수 있다.

필드를 다음과 같이 유지한다.

private Object[] elements;

생성자도 그대로다.

public Stack() {

    elements =
            new Object[
                    DEFAULT_INITIAL_CAPACITY
            ];
}

Object[]E[]로 변환하지 않는다.

따라서

Object[]

↓

Number[]

와 같은 위험한 배열 형변환 자체가 없다.


push()는 여전히 E를 받는다

내부 배열은 Object[]지만 외부 API는 제네릭 타입을 그대로 사용할 수 있다.

public void push(E element) {

    ensureCapacity();

    elements[size++] =
            element;
}

그리고 클래스 선언은

public class Stack<
        E extends Number
        > {
}

이다.

따라서 클라이언트는 Number 계층의 객체만 넣을 수 있다.

예를 들어

Stack<Integer> stack =
        new Stack<>();

이라면

stack.push(10);
stack.push(20);
stack.push(30);

은 가능하다.

하지만

stack.push("hello");

는 컴파일되지 않는다.


값을 꺼낼 때 E로 형변환한다

내부 배열의 타입은 Object[]이므로 값을 꺼내면 Object가 된다.

Object value =
        elements[--size];

하지만 Stack이 반환해야 하는 타입은 E다.

따라서 다음과 같이 형변환한다.

@SuppressWarnings("unchecked")
E result =
        (E) elements[--size];

그리고 반환한다.

return result;

이 형변환을 안전하다고 판단할 수 있는 근거가 있다

컴파일러는 다음 형변환을 완전히 증명하지 못한다.

(E) elements[index]

그래서 비검사 경고가 발생한다.

하지만 Stack 구현자는 안전성의 근거를 알고 있다.

배열에 값을 넣는 공개 경로를 살펴보자.

public void push(E element) {

    elements[size++] =
            element;
}

push()는 오직 E만 받는다.

그리고 elements

private Object[] elements;

로 외부에서 접근할 수 없다.

배열 자체도 외부로 반환하지 않는다고 하자.

그러면 다음 불변식을 유지할 수 있다.

elements는 Object[]지만

↓

Stack 내부에서만 접근

↓

모든 삽입은 push(E)를 통해 이루어짐

↓

실제로 저장된 값은
모두 E와 호환됨

↓

Object를 E로 꺼내는 cast가 안전

이러한 논리적인 근거가 있을 때만 비검사 경고를 억제해야 한다.


가능한 한 좁은 범위에 @SuppressWarnings를 사용한다

예를 들면 다음과 같다.

public E pop() {

    if (size == 0) {
        throw new EmptyStackException();
    }

    // elements에는 push(E)를 통해 들어온
    // E와 호환되는 객체만 저장된다.
    @SuppressWarnings("unchecked")
    E result =
            (E) elements[--size];

    elements[size] = null;

    return result;
}

클래스 전체에

@SuppressWarnings("unchecked")

를 붙이는 것보다 훨씬 좋다.

이렇게 하면 다른 곳에서 새로운 비검사 경고가 발생했을 때 컴파일러가 정상적으로 알려줄 수 있다.


Number로 제한하면 얻는 또 하나의 장점

한정적 타입 매개변수의 장점은 허용할 타입을 제한하는 것만이 아니다.

E에 대해 사용할 수 있는 메서드 역시 늘어난다.

먼저 다음 선언을 생각해보자.

class Container<E> {
}

컴파일러가 E에 대해 확실히 알고 있는 것은 사실상 Object 수준이다.

따라서 다음과 같은 메서드를 사용할 수 있다.

value.toString();
value.hashCode();
value.equals(other);

하지만 Number에 정의된 메서드를 사용할 수는 없다.


E extends Number라면 Number의 API를 사용할 수 있다

다음처럼 제한한다.

class NumberContainer<
        E extends Number
        > {
}

이제 컴파일러는 다음을 안다.

E가 정확히 Integer인지

Double인지

Long인지

알 수는 없다.

하지만 최소한 Number라는 것은 안다.

따라서 Number가 제공하는 메서드를 호출할 수 있다.

예를 들어

public double doubleValue(
        E value
) {

    return value.doubleValue();
}

가 가능하다.

또는

public long longValue(
        E value
) {

    return value.longValue();
}

도 가능하다.


제한은 곧 사용할 수 있는 기능의 최소 계약이다

한정적 타입 매개변수를 이렇게 이해하면 쉽다.

<E>

라고 하면

E에 대해 거의 아무것도 모른다.

↓

Object 수준의 기능만 확실하게 사용 가능

반면

<E extends Number>

라고 하면

E가 정확히 무엇인지는 모른다.

하지만 Number인 것은 안다.

↓

Number의 기능 사용 가능

이다.

이것은 앞에서 GenericRepository를 만들 때도 사용했던 원리다.

class GenericRepository<
        E extends Entity
        > {
}

이렇게 선언했기 때문에

entity.getId();

를 호출할 수 있었다.


타입 제한은 API의 의미도 명확하게 만든다

다음 코드를 보자.

public class Stack<E> {
}

이 선언은

어떤 타입의 Stack인지 제한 없음

을 의미한다.

반면

public class Stack<
        E extends Number
        > {
}

는 타입 선언 자체가 다음 의도를 전달한다.

이 Stack은 숫자 계층을 위한 Stack이다.

즉 타입 안전성뿐 아니라 API의 표현력도 좋아진다.


여러 개의 상위 경계를 지정할 수도 있다

타입 매개변수를 하나의 타입으로만 제한해야 하는 것은 아니다.

여러 개의 경계를 동시에 요구할 수도 있다.

예를 들어 다음과 같다.

<T extends Number
        & Comparable<T>>

의미는

T는 Number이면서 동시에 Comparable를 구현해야 한다.

이다.

즉 두 조건을 모두 만족해야 한다.

Number

AND

Comparable<T>

여러 경계는 OR가 아니라 AND 조건이다

다음 선언을 보자.

<T extends A & B & C>

이것은

A이거나
B이거나
C면 된다.

는 의미가 아니다.

다음 의미다.

A 조건 만족

그리고

B 조건 만족

그리고

C 조건 만족

즉 모든 경계를 동시에 만족해야 한다.


클래스 경계가 있다면 가장 먼저 작성한다

Java에서 여러 경계를 사용할 때 중요한 문법 규칙이 있다.

클래스와 인터페이스를 함께 사용한다면 클래스가 가장 먼저 와야 한다.

예를 들어

<T extends Number
        & Comparable<T>
        & Serializable>

처럼 작성할 수 있다.

여기서

Number
→ 클래스

Comparable<T>
→ 인터페이스

Serializable
→ 인터페이스

이다.

클래스를 뒤에 쓰는 것은 허용되지 않는다.

잘못된 예는 다음과 같다.

<T extends Serializable
        & Number>

이런 순서는 사용할 수 없다.


클래스는 하나만 올 수 있다

Java는 클래스 다중 상속을 지원하지 않는다.

따라서 다음처럼 두 클래스를 경계로 사용할 수는 없다.

<T extends ClassA & ClassB>

일반적으로 구조는 다음과 같다.

하나의 클래스 경계

+

0개 이상의 인터페이스 경계

예를 들어

<T extends SomeClass
        & InterfaceA
        & InterfaceB>

다.

클래스 경계가 없다면 인터페이스만 여러 개 지정할 수도 있다.

<T extends InterfaceA
        & InterfaceB>

Number & Serializable은 사실 중복될 수 있다

예를 들어 다음과 같은 타입 제한을 생각할 수 있다.

<T extends Number
        & Serializable>

문법적으로는 이런 다중 경계를 이해하는 예제로 볼 수 있다.

다만 Java의 Number 자체가 이미 Serializable을 구현하고 있기 때문에 이 경우에는 Serializable 조건이 실질적으로 추가적인 제약을 제공하지 않는다.

Number
   │
   └─ implements Serializable

따라서 실제 설계에서는 의미 있는 추가 제약인지 확인해야 한다.

예를 들어

<T extends Number
        & Comparable<T>>

처럼 별도의 계약을 요구하는 경우가 다중 경계의 의미를 더 잘 보여준다.


여러 경계를 지정했을 때 E는 어떤 기능을 사용할 수 있을까?

다음 선언을 보자.

<T extends Number
        & Comparable<T>>

컴파일러는 T에 대해 두 가지 사실을 안다.

T는 Number다.

+

T는 Comparable<T>다.

따라서 다음 모두 사용할 수 있다.

Number의 기능:

value.doubleValue();

Comparable의 기능:

value.compareTo(other);

즉 타입 경계가 늘어나면 제네릭 구현 내부에서 안전하게 사용할 수 있는 계약 역시 늘어난다.


다중 경계에서 타입 소거는 어떻게 될까?

이 부분도 중요하다.

다음처럼 선언했다고 하자.

<T extends Number
        & Comparable<T>>

타입 소거에서는 첫 번째 경계가 중요한 역할을 한다.

여기에서는 첫 번째 경계가

Number

다.

따라서 T의 소거 타입은 Number가 된다.

<T extends Number & Comparable<T>>

↓

Erasure(T)

↓

Number

이것이 클래스 경계를 가장 먼저 작성하는 규칙과 함께 알아두면 좋은 부분이다.


인터페이스 경계만 있다면?

다음과 같이 클래스 경계 없이 인터페이스만 사용할 수도 있다.

<T extends Comparable<T>>

이 경우 첫 번째 경계인 Comparable이 소거와 관련된 기준이 된다.

즉 타입 매개변수의 경계는 단순히 컴파일러가 허용할 타입만 결정하는 것이 아니라 타입 소거 결과에도 직접적인 영향을 준다.


한정적 타입 매개변수와 한정적 와일드카드는 다르다

비슷한 문법이 하나 더 있다.

<T extends Number>

그리고

List<? extends Number>

다.

둘은 같은 것이 아니다.

첫 번째는 한정적 타입 매개변수다.

<T extends Number>

T라는 이름이 있는 타입 변수를 선언한다.

그리고 여러 곳에서 그 타입 관계를 재사용할 수 있다.

public static <
        T extends Number
        >
T process(T value) {
    return value;
}

입력 타입과 출력 타입이 같은 T라는 관계를 표현하고 있다.


? extends Number는 와일드카드다

반면

List<? extends Number>

에서 ?는 이름이 없는 타입이다.

의미는

정확히 무슨 타입인지는 모르겠지만

Number 또는 Number의 어떤 하위 타입을
요소 타입으로 사용하는 List

다.

예를 들어

List<Integer>

도 가능하고

List<Double>

도 가능하다.

두 문법 모두 extends를 사용하지만 역할은 다르다.

<T extends Number>

→ 타입 매개변수 선언


<? extends Number>

→ 와일드카드 경계

와일드카드는 이후 제네릭 API를 설계할 때 더욱 중요하게 다루게 된다.


한정적 타입 매개변수를 사용해야 하는 경우

다음과 같은 상황에서 유용하다.

특정 타입 계층만 지원해야 할 때

<T extends Number>

숫자 타입만 받는다.


특정 인터페이스의 기능이 필요할 때

예를 들어 정렬이나 비교가 필요하다.

<T extends Comparable<T>>

이제 구현에서

value.compareTo(other);

를 사용할 수 있다.


여러 계약을 동시에 요구할 때

<T extends SomeClass
        & InterfaceA
        & InterfaceB>

해당 타입이 여러 기능을 모두 제공해야 한다.


단순히 사용자를 막기 위한 기능만은 아니다

한정적 타입 매개변수의 목적을

String 못 쓰게 하는 기능

정도로만 이해하면 절반만 이해한 것이다.

핵심 효과는 두 가지다.

1. 사용할 수 있는 타입의 범위를 제한한다.

2. 제네릭 구현 내부에서
   상위 경계가 제공하는 기능을
   타입 안전하게 사용할 수 있게 한다.

예를 들어

<T extends Number>

String을 차단하는 동시에 doubleValue() 등의 Number 기능을 사용할 수 있게 한다.


기존 제네릭 코드에 경계를 추가할 때 주의하자

이번 Stack 예제에서 특히 중요한 실무적 교훈이 있다.

처음에는

class Stack<E>

였다.

이때 E의 소거 타입은 Object였다.

그 상태에서 내부 구현이 다음과 같았다.

(E[]) new Object[...]

그리고 나중에 요구사항 때문에

class Stack<E extends Number>

로 바꿨다.

단순히 타입 제한 하나 추가한 것처럼 보인다.

하지만 실제로는

Erasure(E)

Object

↓

Number

로 바뀐다.

그 결과 기존의 비검사 배열 형변환이 런타임에 실패할 수 있다.

비검사 경고를 억제한 코드는 제네릭의 경계가 변경될 때 특히 다시 검토해야 한다.

는 중요한 교훈을 얻을 수 있다.


@SuppressWarnings는 영원한 안전 보장이 아니다

처음에는 다음 근거로 안전하다고 판단했을 수 있다.

@SuppressWarnings("unchecked")
E[] elements =
        (E[]) new Object[10];

하지만 나중에 클래스 선언이 변경됐다.

<E>

에서

<E extends Number>

로 바뀌었다.

이제 타입 소거 조건이 달라졌다.

따라서 이전에 작성한

@SuppressWarnings("unchecked")

가 있다고 해서 안전성이 자동으로 유지되는 것이 아니다.

애너테이션은

"이 코드가 안전하다."

를 JVM에게 강제로 보장하는 장치가 아니다.

개발자가 당시의 조건을 분석하고 경고를 억제한 것일 뿐이다.

전제가 바뀌면 다시 검증해야 한다.


Stack을 안전하게 구현해보자

한정적 타입 매개변수와 Object[] 방식을 조합하면 다음과 같이 작성할 수 있다.

public class Stack<
        E extends Number
        > {

    private Object[] elements;

    private int size = 0;

    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {

        elements =
                new Object[
                        DEFAULT_INITIAL_CAPACITY
                ];
    }

    public void push(E element) {

        ensureCapacity();

        elements[size++] =
                element;
    }

    public E pop() {

        if (size == 0) {
            throw new EmptyStackException();
        }

        // elements에는 push(E)를 통해 들어온
        // E와 호환되는 객체만 저장된다.
        @SuppressWarnings("unchecked")
        E result =
                (E) elements[--size];

        elements[size] = null;

        return result;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    private void ensureCapacity() {

        if (elements.length == size) {

            elements =
                    Arrays.copyOf(
                            elements,
                            2 * size + 1
                    );
        }
    }
}

사용하는 쪽에서는 다음과 같다.

Stack<Integer> stack =
        new Stack<>();

stack.push(1);
stack.push(2);
stack.push(3);

다음은 컴파일되지 않는다.

stack.push("hello");

타입 제한이 API 계약에 반영되었기 때문이다.


타입 소거까지 이해하면 제네릭 코드가 더 잘 보인다

한정적 타입 매개변수를 이해할 때 단순히

<E extends Number>

숫자만 받는다.

라고 외우는 것보다 타입 소거까지 함께 생각하면 훨씬 깊게 이해할 수 있다.

<E>

↓

E의 최소 계약은 Object

↓

Erasure = Object

그리고

<E extends Number>

↓

E의 최소 계약은 Number

↓

Erasure = Number

그리고

<E extends Number & Comparable<E>>

↓

E는 Number와 Comparable 계약을 모두 만족

↓

Erasure의 기준은 첫 번째 경계 Number

이 흐름을 기억하면 바이트코드, 비검사 형변환, 배열과 제네릭의 충돌도 자연스럽게 연결된다.


아이템 29와 연결해서 이해하기

아이템 29의 핵심은

Object 기반 클래스를 가능하면 제네릭 타입으로 만들라.

는 것이었다.

그런데 모든 제네릭 타입이 무제한으로 모든 객체를 받을 필요는 없다.

Stack<E>

↓

모든 종류의 E 허용

보다 도메인과 알고리즘이 요구한다면

Stack<E extends Number>

↓

Number 계층만 허용

처럼 더 구체적인 계약을 만들 수 있다.

결국 좋은 제네릭 타입은 단순히 <T>를 붙인 클래스가 아니라

실제 구현이 필요로 하는 최소한의 타입 계약을 제네릭 경계로 정확하게 표현한 타입

이라고 볼 수 있다.


핵심 정리

  • 한정적 타입 매개변수는 제네릭 타입 매개변수로 사용할 수 있는 타입의 범위를 제한하는 기능이다.
  • <E>는 특별한 상위 경계가 없으므로 소거 시 Object가 기준이 된다.
  • <E extends Number>ENumber 또는 Number의 하위 타입으로 제한한다.
  • Integer, Long, Double 등은 Number 계층이므로 <E extends Number>에 사용할 수 있지만 String은 사용할 수 없다.
  • 제네릭 문법의 extends는 클래스뿐 아니라 인터페이스를 경계로 지정할 때도 사용한다.
  • 타입 매개변수에 상위 경계를 지정하면 사용할 수 있는 타입만 제한되는 것이 아니라 타입 소거 결과에도 영향을 준다.
  • <E>의 소거 타입은 일반적으로 Object지만 <E extends Number>의 소거 타입은 Number다.
  • 따라서 push(E)pop(): E도 소거 관점에서는 Number를 기준으로 처리된다.
  • E[] 역시 경계가 없는 경우와 E extends Number인 경우 소거 관점의 배열 타입이 달라질 수 있다.
  • 기존의 (E[]) new Object[...] 같은 비검사 배열 형변환은 E의 경계를 Number로 변경했을 때 런타임에 Object[] → Number[] 캐스팅이 되어 ClassCastException으로 이어질 수 있다.
  • Number[]Object[]로 참조하는 것은 배열의 공변성 때문에 가능하지만 실제 Object[] 객체를 Number[]로 변환하는 것은 안전하지 않다.
  • 한정된 제네릭 Stack에서는 내부 저장소를 Object[]로 유지하고 값을 꺼낼 때 E로 형변환하는 방법을 사용할 수 있다.
  • 이때 배열이 외부로 노출되지 않고 모든 삽입이 push(E)를 통해서만 이루어진다면 Object → E 형변환의 안전성을 논리적으로 증명할 수 있다.
  • 비검사 형변환 경고를 억제할 때는 가능한 한 작은 범위에 @SuppressWarnings("unchecked")를 사용하고 왜 안전한지 근거를 남기는 것이 좋다.
  • <E extends Number>처럼 타입을 한정하면 구현 내부에서 doubleValue(), longValue()Number가 제공하는 메서드를 사용할 수 있다.
  • 상위 경계는 단순한 제한이 아니라 제네릭 구현이 사용할 수 있는 최소 타입 계약을 정의한다.
  • <T extends Number & Comparable<T>>처럼 여러 경계를 동시에 지정할 수도 있다.
  • 여러 경계는 OR가 아니라 AND 조건이므로 타입 인수는 모든 경계를 만족해야 한다.
  • 클래스와 인터페이스를 함께 경계로 지정한다면 클래스 경계를 가장 먼저 작성해야 한다.
  • 클래스 경계는 하나만 사용할 수 있고 그 뒤에 여러 인터페이스 경계를 추가할 수 있다.
  • 다중 경계를 사용하면 첫 번째 경계가 타입 소거에서 중요한 기준이 된다.
  • Number 자체가 Serializable을 구현하므로 <T extends Number & Serializable>처럼 이미 상위 클래스가 보장하는 인터페이스를 다시 적는 것은 실질적인 추가 제한이 아닐 수 있다.
  • <T extends Number>는 이름을 가진 한정적 타입 매개변수이고 <? extends Number>는 이름이 없는 한정적 와일드카드이므로 역할이 다르다.
  • 기존 제네릭 타입에 상위 경계를 추가하면 타입 소거 결과가 바뀔 수 있으므로 이미 존재하던 unchecked cast의 안전성을 다시 검토해야 한다.
  • @SuppressWarnings가 붙어 있다는 사실 자체가 타입 안전성을 보장하지 않으며 코드의 전제가 바뀌면 억제의 근거 역시 다시 확인해야 한다.
  • 한정적 타입 매개변수의 핵심은 제네릭 타입이 허용하는 타입 범위를 제한하는 동시에 구현이 안전하게 사용할 수 있는 최소 기능과 타입 계약을 컴파일러에게 알려주는 것이다.

한 줄 요약

<E extends Number> 같은 한정적 타입 매개변수는 사용할 수 있는 타입을 특정 계층으로 제한하고 그 상위 타입의 기능을 제네릭 코드 안에서 안전하게 사용할 수 있게 해주며, 동시에 E의 소거 타입도 Object에서 Number로 바꾸므로 기존의 비검사 배열 형변환 같은 저수준 구현이 있다면 경계를 추가할 때 런타임 안전성까지 반드시 다시 검토해야 한다.


© 2020. All rights reserved.

SIKSIK