멈재

[JAVA] 제네릭의 공변과 불공변 그리고 타입 소거 본문

JAVA & Spring & JPA

[JAVA] 제네릭의 공변과 불공변 그리고 타입 소거

멈재 2023. 6. 4. 18:57
728x90

지금껏 면접에서 제네릭에 관한 질문이 두 번이나 있었다.

1. 제네릭은 타입 안전성을 어떤 원리로 보장해주는가
2. 이펙티브 자바로 '배열보다는 리스트를 사용하라'의 주제로 학습한 적이 있던데 설명해보라

 
첫 번째 질문이 들어왔을 때에는 변성에 관한 단어만 들어봤을 뿐 전혀 몰랐기 때문에 모르겠다고 답변을 했었다.
두 번째 질문은 비교적 최근에 봤던 면접이었는데 이때에는 내용이 기억나질 않아서 가변적인 크기와 고정적인 크기를 이야기하며 메모리적인 이유를 들며 답변했다.
 
두 번째 답변도 결국 면접관이 원했던 답변을 하지 못했고, 아쉬움이 남아 작성하게 되었다.
 
 

제네릭이란

  • 컴파일 타임에 타입을 체크하여 코드의 안정성을 높여주는 기능
  • 파라미터 타입이나 리턴 타입을 외부에서 지정하는 기법

 
아래 제네릭의 용어를 알고 가면 읽기에 더 수월할 것이다.

List<T>
List<Integer> integers = new ArrayList<Integer>();

T: 타입 매개변수
Integer: 매개변수화된 타입

 
결론에서 다시 설명하겠지만 제네릭을 사용하면 이러한 이점들을 얻을 수 있다.

  • 컴파일 시에 타입을 체크함으로써 코드의 안정성을 높일 수 있다.
  • 반환값에 대한 타입 변환 및 검사에 쓰이는 노력을 줄일 수 있고, 형변환을 생략할 수 있게 되면서 가독성이 증가한다.

여기서 말하는 코드의 안정성이란 의도하지 않은 타입의 객체가 저장되는 것을 막고, 저장된 객체를 꺼내오는 과정에서 잘못된 형변환이 발생하는 에러(ClassCastException)를 방지할 수 있다는 말로 풀어쓸 수 있다.
 
 
제네릭을 사용했을 때와 사용하지 않았을 때의 예시를 봐보자.
 
제네릭을 사용하지 않았을 때에는 두 가지 단점이 존재한다.

  1. 저장되는 요소의 타입이 지정되지 않았기 때문에 어떠한 타입을 반환받을 지 모른다.
  2. 이 말은 값을 꺼낼 때, 타입을 명시적으로 적어주어야 한다는 것을 의미한다.
  3. 리스트에 Integer 타입만 저장되길 원했으나 타입이 지정되지 않았기 때문에 모든 타입이 저장 가능하다.
  4. 즉, 데이터를 사용하는 곳에서는 Integer 타입만 존재한다고 생각하고 형변환시키지만 실제로는 다른 타입이 들어있어서 런타임시에 캐스팅 예외가 발생하게 된다.
List integers = new ArrayList<>();
integers.add(10);    // OK
integers.add(13.5);  // OK
integers.add("ten"); // OK

// 1. 형변환을 명시적으로 해주어야 한다.
// 2. 런타임 에러가 발생한다. java.lang.ClassCastException
Integer integer = (Integer) integers.get(0) + (Integer) integers.get(1) + (Integer) integers.get(2);

 
반면 제네릭을 사용했을 때에는 어떨까

  1. 저장되는 타입을 지정했기 때문에 타입이 보장돼서 값을 꺼낼 때 타입을 명시해주지 않아도 된다.
  2. 지정한 타입이 아니라면 컴파일 타임에 에러가 발생하여 런타임 에러가 발생하는 것을 미연에 방지할 수 있다.
List<Integer> integers = new ArrayList<>();
integers.add(10);    // OK
integers.add(20);    // OK
integers.add(13.5);  // ERROR!
integers.add("ten"); // ERROR!

System.out.println(integers.get(0) + integers.get(1)); // OK

 
 
제네릭을 정의할 때에는 참조 변수와 생성자에 대입된 타입 매개변수가 일치해야 한다는 문법적 제약사항이 존재하고 있다. 이것은 상속관계인 구조여도 마찬가지이다.
(대입된 타입 매개변수는 참조변수와 생성자가 일치해야 하기 때문에 JDK 1.7부터 생성자에 타입을 생략해도 된다.)

List<Object> lists = new ArrayList<Integer>();  // ERROR
List<Integer> lists = new ArrayList<Integer>(); // OK

List<Integer> lists = new ArrayList<>(); // OK

 
 
이제 변성에 대해 알아보자.
 

변성

변성이란 계층 관계인 타입 간에 어떠한 관계가 있는지 나타내는 개념이다.
 
흔히 자바에서는 '배열은 공변이고 제네릭은 불공변이다'라는 말을 하는데 여기서 말하는 공변과 불공변이 바로 변성과 관련된 것이다.
 
공변이란 타입 Sub가 타입 Super의 하위 타입이면 Sub[ ]는 Super[ ] 의 하위 타입이 가능하다.
불공변은 타입 Sub가 타입 Super의 하위 타입이더라도 List<Sub>는 List<Super>와 아무런 관련이 없다.
 
개념적으로는 어렵게 느껴질 수도 있지만 코드 예시를 보게 되면 금방 이해가 될 것이다.
 
자바의 래퍼 클래스인 Integer의 상속 계층 구조를 보면 다음과 같다.

타입 Integer은 Number의 하위 타입이고,
타입 Number는 Object의 하위 타입이다.
 
공변의 경우에는 다음과 같다.
자바에서 배열은 공변의 성질을 갖고 있기 때문에 Sub[ ]는 Super[ ]의 하위 타입이 가능하다.

Object[] objects = new Integer[1]; // 컴파일 에러 발생 X
objects[0] = "1000";               // 런타임 에러 발생, ArrayStoreException

 
불공변인 경우에는 다음과 같다.
자바에서 제네릭은 불공변의 성질을 갖고 있으므로 타입 간에 계층 구조를 이뤄도 아무런 관련이 없다.
(참고로, 제네릭 클래스 간의 타입은 상속 관계이고, 대입된 타입 매개변수가 같은 건 가능하다.)

List<Object> objects = new ArrayList<Integer>();   // Compile Error
List<Integer> integers = new ArrayList<Integer>(); // OK
이미지 출처:https://thecodinglog.github.io/java/2020/12/15/java-generic-wildcard.html

 
 

어떠한 원리로 이렇게 되는 걸까

배열은 런타임에 실체화되는 반면, 제네릭은 런타임에 타입이 소거되기 때문(비실체화)이다.
 
실체화한다는 말은 배열은 자신이 담기로 한 원소의 타입을 런타임까지 인지하고 확인한다는 말과 같다.
그래서 공변(배열) 예시에서 Object 타입의 배열에 Integer 타입의 값을 넣으면 런타임에 ArrayStoreException 예외가 발생했던 것이다.

// 배열(공변)
Object[] objects = new Integer[1];
objects[0] = "1000"; // Runtime Error. ArrayStoreException: java.lang.String

 
제네릭의 경우는 비실체화 되기 때문에 컴파일 타임에 타입을 체크한 다음 런타임에 타입이 소거되는 방식이라서 런타임시 타입 정보를 알 수 없다.
따라서 앞선 예시에서 제네릭 타입으로 지정한 타입이 아니라면 컴파일 에러가 발생했던 것이다.

List<Integer> integers = new ArrayList<>();
integers.add(10);    // OK
integers.add(20);    // OK
integers.add(13.5);  // Compile Error!
integers.add("ten"); // Compile Error!

 
오라클 공식 문서에 Type Erasure에 대해 다음과 같이 설명하고 있다.

To implement generics, the Java compiler applies type erasure to:
- Replace all type parameters in generic types with their bounds or Object if the type parameters are unbounded. The produced bytecode, therefore, contains only ordinary classes, interfaces, and methods.
- Insert type casts if necessary to preserve type safety.
- Generate bridge methods to preserve polymorphism in extended generic types.

특히 이 중에서도 볼드한 부분을 중점적으로 보면 타입이 교체된다고 쓰여있다.

  • bounded type -> bound type
  • unbounded type -> Object

 
아래와 같은 예시 코드가 존재한다고 했을 때 타입 소거가 되면 어떻게 될까

public class GenericsTypeErasure {
    public static void main(String[] args) {

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

        Item<Long> longItem = new Item<>();
        longItem.setObject(Long.MAX_VALUE);
        System.out.println("longItem.getObject() = " + longItem.getObject());
    }
}

class Item<T> {
    private T object;

    public T getObject() {
        return object;
    }

    public void setObject(T object) {
        this.object = object;
    }
}

 
참고로 List 인터페이스는 언바운디드 타입으로 선언되어 있다.

public interface List<E> extends Collection<E> { ... }

 
따라서 타입 소거가 되면 다음과 같이 치환되게 된다.

public class GenericsTypeErasure {
    public static void main(String[] args) {

        List strings = new ArrayList();
        List integers = new ArrayList();

        Item longItem = new Item();
        longItem.setObject(Long.MAX_VALUE);
        System.out.println("longItem.getObject() = " + longItem.getObject());
    }
}

class Item {
    private Object object;

    public Object getObject() {
        return object;
    }

    public void setObject(Object object) {
        this.object = object;
    }
}

 
실제 타입이 소거되었는지 리플렉션을 이용해서 확인해보자.

public class GenericsTypeErasure {
    public static void main(String[] args) throws NoSuchMethodException {
        Class<Item> itemClass = Item.class;
        Class<?> returnType = itemClass.getMethod("getObject").getReturnType();
        System.out.println("returnType.getTypeName() = " + returnType.getTypeName());
    }
}

class Item<T> {

    private T object;

    public T getObject() {
        return object;
    }

    public void setObject(T object) {
        this.object = object;
    }
}

Item은 Unbounded Type이므로 리턴 타입이 Object로 치환된 것을 확인할 수 있다.
 
 
이 포스팅에서는 Bounded Type을 다루지 않아 생략했지만 자바 컴파일러는 타입을 소거하는 데에 다음과 같은 규칙을 적용한다.

- Unbounded Type(<?>, <T>)은 Object로 변환
- Bound Type(<E extends Animal>)의 경우 Animal로 변환
- 제네릭 타입을 사용할 수 있는 일반 클래스, 인터페이스, 메서드에만 타입 소거를 적용
- 필요한 경우 타입 안정성을 위해 타입 형변환(Type Casting)을 추가
- 확장된 제네릭 타입에서 다형성을 보존하기 위해 Bridge Method를 생성

 
 
이야기가 길었지만 다음과 같이 정리할 수 있다.

제네릭은 소거 메커니즘의 특징 덕분에 컴파일 타임에 타입을 추론하여 타입의 안정성을 가져갈 수 있었고, 런타임시에는 타입이 소거되게 된다.

 
 


 
이 내용말고도 아래와 같은 추가적인 내용도 있다.

  • 제네릭 메서드
  • 반공변
  • 한정된 와일드카드(Upper Bounded, Lower Bounded)
  • 펙스(PECS, producer-extends, consumer-super)

아직 위 개념들은 온전히 이해하지 못해서 나중에 별도의 포스팅으로 다뤄보려 한다.
 
 
제네릭은 타입 안정성을 보장하는 장점도 존재하지만, 비즈니스에 따라 적절히 사용하면 코드를 재사용할 수 있는 장점도 있다.
 
내 경우에는 토이 프로젝트의 모든 페이지네이션이 '더보기' 버튼으로 되어 있기 때문에 Slice 객체로 변환하는 메서드를 만들어두었다.
그러나, 타입 매개변수만 달라지는 메서드가 도메인별로 존재해야 했고 중복 코드라고 판단되어 아래와 같이 유틸 클래스에 제네릭 메서드로 만들어 묶어버렸다.

public static <T> Slice<T> convertToSliceFrom(int page, List<T> list, Pageable pageable) {
    boolean hasNext = false;
    if(list.size() == page + 1) {
        list.remove(page);
        hasNext = true;
    }
    return new SliceImpl<>(list, pageable, hasNext);
}

 
이러한 상황처럼 본인의 비즈니스에 따라 코드 재사용의 이점도 가져갈 수 있는 이점도 존재한다.

 

혹여 잘못된 부분이 있거나 이해가 안 되는 부분이 있다면 댓글로 남겨주세요

 
 
참고

Type Erasure (The Java™ Tutorials >                     Learning the Java Language > Generics (Updated))

The Java Tutorials have been written for JDK 8. Examples and practices described in this page don't take advantage of improvements introduced in later releases and might use technology no longer available. See Java Language Changes for a summary of updated

docs.oracle.com