Java의 제네릭 타입 소거법과 Kotlin의 reified

EP·2022년 7월 9일
0

Overview


Java의 주요한 특징 중 하나는 ‘하위 호환성'입니다. 자바는 이전 버전과의 호환성을 위해 최대한 보수적으로 발전하였으며 그로인해 자바 생태계를 유지를 했지만 언어적 한계를 극복하기 보다는 보완을 해왔습니다. 그 중 대표적인 케이스가 제네릭(Generic)입니다.

제네릭는 자바 1.5버전부터 생겨난 개념입니다. 잠시 예제 코드를 보겠습니다.

List list = new ArrayList();
list.add(1)
String foo = (String) list.get(0); // 런타임 에러 발생 (ClassCastExceptions)

위의 코드는 에러가 발생할 것을 누구나 예측할 수 있지만 컴파일러는 감지하지 못합니다. 이에 타입을 명시해서 객체 컴파일러가 에러를 감지하고 캐스팅의 필요성을 제거하는 것이 제네릭을 사용하는 이점입니다.

List<Integer> list = new ArrayList<>();
list.add(1)
String foo = (String) list.get(0); // 컴파일 에러 발생

따라서 컴파일 시점에서 안전하게 타입을 사용할 수 있습니다.

제네릭 타입는 자바 1.5버전부터 생겨났습니다. 자바는 하위호환성을 중요하게 생각합니다. 따라서 제네릭 타입을 선언한 코드들도 1.5 이하의 하위 버전에서 동작을 해야했습니다. 이를 위해 제네릭 타입은 컴파일에서만 작성을 하고 런타임 시점에 제네릭 타입을 모두 소거해버립니다. 다른 클래스들과의 충돌을 없애기 위해서입니다.

다시 말해 제네릭 타입은 컴파일전에만 유효합니다. 따라서 Java에서는 런타임 시점에 본인의 제네릭 타입을 알 수 없고 사용을 할 수 없습니다. (자바에서는 이를 해결하기 위해 raw 타입의 방법을 사용할 수 있는데 권장하는 방법이 아니다.) 그런데 코틀린에서는 코틀린만의 문법으로 이 문제를 해결하고 런타임 시점에 제네릭 타입을 사용할 수 있도록 방법을 제공하고 있습니다.

이번 글에서는 제네릭 타입에 대해 이해를 하고, 런타임 시점에 제네릭 타입이 어떻게 소거가 되는지 알아봅니다. 코틀린에서는 런타임 시점에 제네릭 타입을 사용하기 위해 실체화(reify)된 파라미터를 정의할 수 있도록 해줍니다. 코틀린에서는 어떤 원리로 이 방법을 구현했는지와 실체화된 제네릭 타입 인자를 사용하는 예제도 같이 확인해보겠습니다.

공변성, 반공병성, 무변성


제네릭을 알아보기 위해서는 공변성(covariance), 반공변성(countervaraince), 무변성(invariant)을 이해해야 합니다. 일단 다음과 같은 객체들의 예시를 확인해보겠습니다.

우선 공통적인 단어인 변성(variance)에 대해서 알아보겠습니다. 변성은 타입의 계층 관계(Type Hierarchy)에서 서로 다른 타입 간에 어떤 관계가 있는지를 나타내는 개념입니다. 제네릭(Generic)을 사용할 때 기저 타입(Base Type)이 같고 타입 인자(Type argument)가 다른 경우 서로 어떤 관계가 있는지를 나타내는 것이라고 보면 됩니다.

public class Animal {
    private String name;
    private String sex;
    private Integer age;
}
public class Cat extends Animal {}
public class Dog extends Animal {}
public class RussianBlueCat extends Cat{}

위의 예시에서 CatAnimal의 하위 타입이고 CatAnimal을 대체해서 사용할 수 있다는 것을 알 수 있습니다. 이는 리스코프 치환 원칙(Liskov Substitution Principle)에 해당합니다. 상위 타입이 사용되는 곳에는 언제나 하위 타입의 인스턴스를 넣어도 동작할 수 있어야 합니다.

List<Cat>에서 List는 기저 타입, Cat은 타입 인자입니다. 따라서 List<Animal>에서 Cat 타입의 객체를 추가할 수 있습니다. 그런데 List<Cat>List<Animal>의 하위 타입일까요? 그렇지 않습니다. 이는 제네릭 타입이 무공변이기 때문입니다.

Java Array의 공변성


public class Main {
    public static void main(String[] args) {
        Cat[] cats = new Cat[3];
        cats[0] = new Cat("야옹이1", "male", 5);
        cats[1] = new Cat("야옹이2", "female", 5);
        cats[2] = new Cat("야옹이2", "male", 5);
        walk(cats);
    }

    private static void walk(Animal[] animals) {
        for (Animal animal : animals) {
            System.out.println(animal.getName() + ": 걸음걸음");
        }
    }
}
야옹이1: 걸음걸음
야옹이2: 걸음걸음
야옹이2: 걸음걸음

기본적으로 자바의 배열은 공변입니다. 따라서 Animal[]를 인자로 받는 메서드에서도 Cat[] 배열을 인자로 받아 메서드를 실행시킵니다. 즉, Cat[]Animal[]의 하위 타입입니다. 자바 배열의 이러한 공변성은 문제를 발생시킵니다.

public class Main {
    public static void main(String[] args) {
        Dog[] dogs = new Dog[3];
        Animal[] animals = dogs;
        animals[0] = new Cat("야옹이", "female", 3);
        Dog dog = dogs[0]; // 런타임 ArrayStoreException 발생
    }
}

위와 같은 컴파일러가 감지하지 못하는 런타임 예외가 발생합니다. 공변성은 보장되지만 type-safe하지 않은 문제가 발생합니다.

Java Generic의 공변성, 반공변성, 무공변성

제네릭은 타입의 경계를 선언할 수 있습니다. 계층 구조간의 상한(upper bound), 하한(lower bound)의 경계를 제한할 수 있습니다. 이러한 문법은 각 타입 생성자에게 리스코프 치환 법칙을 허용하므로서 유연한 설계를 가능하게 해줍니다.

boundjava변성
상한 경계Upper boundType<? extends T>공변성(covariant)
하한 경계Lower boundType<? super T>반공변성(contravariant)
unbounded TypeunboundedType or Type<? extends Object>무공변성(invariant)

무공변성

List<RussianBlueCat> list = new ArrayList<Object>(); // 컴파일 에러
List<RussianBlueCat> list2 = new ArrayList<Animal>(); // 컴파일 에러
List<RussianBlueCat> list3 = new ArrayList<Cat>(); // 컴파일 에러
List<RussianBlueCat> list4 = new ArrayList<RussianBlueCat>();

무공변성은 선언한 타입 인자의 타입만 사용이 가능합니다.

공변성

List<? extends Animal> list = new ArrayList<Object>(); // 컴파일 에러
List<? extends Animal> list2 = new ArrayList<Animal>();
List<? extends Animal> list3 = new ArrayList<Cat>();
List<? extends Animal> list4 = new ArrayList<RussianBlueCat>();

List<? extends Animal>ArrayList<Object>의 하위 타입이므로 공변성이 적용되었습니다.

반공병성

List<? super Cat> list = new ArrayList<Object>();
List<? super Cat> list2 = new ArrayList<Animal>();
List<? super Cat> list3 = new ArrayList<Cat>();
List<? super Cat> list4 = new ArrayList<RussianBlueCat>(); // 컴파일 에러

List<? super Cat>ArrayList<RussianBlueCat>의 상위 타입이므로 반공변성이 적용되었습니다.

이렇게 제네릭 타입은 변성을 적용하여 타입 안전(type-safe)과 리스코프 치환 원칙을 동반하였습니다.

제네릭 타입 소거


제네릭 타입은 자바와의 하위 호환성을 위해 제네릭 타입을 런타임시 소거를 합니다. 이유는 오직 이전 버전의 Java와의 호환성을 유지하기 위해서입니다. 제네릭 타입은 바이트코드로 컴파일 될 때 코드상에 존재하지 않습니다.

오라클 공식 문서

Generics were introduced to the Java language to provide tighter type checks at compile time and to support generic programming. 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.

제네릭은 컴파일 타임에만 제약을 가하고 런타임시 타입에 대한 정보를 버립니다. 정보를 버린다는 것은 아래와 같이 타입이 치환이 되는 것입니다.

  • bounded type → bond type 바운디드 타입 T = <T extends Cat>
  • unbounded type → Object 언바운디드 타입 T = <T extends Object>

제네릭 타입은 JVM에서 컴파일시 아래와 같은 절차를 수행합니다.

  1. Replace generic types with objects
  2. Replace bounded types (More on these in a later question) with the first bound class
  3. Insert the equivalent of casts when retrieving generic objects.

따라서 우리가 작성한 제네릭 타입 인자는 바이트 코드로 컴파일 된 이후에는 제네릭 타입이 제거되고 특정 타입으로 치환됩니다. 따라서 우리가 컴파일 전에 작성한 코드와 다를 수 있으며 런타임시 사용할 수 있는 코드를 작성할 수 없는 것입니다.

따라서 런타임 시점에 해당 타입 인자가 어떤 타입인지 검사할 수 없다는 단점이 있습니다. 하지만 저장해야 하는 타입 정보의 크기가 줄어들어서 전반적인 메모리 사용량이 줄어든다는 장점도 있다는 점도 있습니다.

Java에서 런타임시 제네릭 타입을 사용하는 방법

자바에서 런타임시 제네릭 타입을 사용하려면 아래와 같이 사용합니다.

public class CatCage implements Cage<Cat>

리플렉션을 사용해서 우리는 타입 인자를 얻어낼 수 있습니다.

(Class<T>) ((ParameterizedType) getClass()
  .getGenericSuperclass()).getActualTypeArguments()[0];

하지만 역시 권장하는 방법이 아닙니다.

Kotlin의 reified


코틀린에서는 런타임에서 제네릭 타입을 사용할 수 있도록 두가지 키워드를 활용합니다. inline 함수와 reified입니다.

코틀린도 자바와 다르지 않게 실행 시점에 제네릭 타입이 사라집니다. 따라서 자바에서 발생하는 런타임시 알 수 없는 점은 기본적으로 동일합니다.

fun <T> isA(value: Any) = value is T
Error: Cannot check for instance of erased type: T

하지만 인라인 코드를 사용하면 제네릭 타입을 알아낼 수 있습니다. 우선 인라인 함수부터 먼저 알아보겠습니다. inline로 함수를 선언하면 그 함수를 호출하는 코드를 함수를 호출하는 바이트코드 대신에 함수 본문을 번역한 바이트 코드로 컴파일 됩니다.

즉, 함수를 인라인 함수로 만들고 타입 파라미터를 reified로 지정하면 타입 인자를 해당 함수를 호출하는 시점에 검사를 할 수 있게 되는 것입니다.

inline fun <reified T> isA(value: Any) = value is T

컴파일러는 실체화한 타입 인자를 사용해 인라인 함수를 호출하는 각 부분의 정확한 타입 인자를 알 수 있습니다. 따라서 컴파일러는 타입 인자로 쓰인 구체적인 클래스를 참조하는 바이트코드를 생성해 삽입할 수 있게 됩니다. 타입 파라미터가 아니라 구체적인 타입을 사용하므로 만들어진 바이트코드는 실행 시점에 벌어지는 타입 소거의 영향을 받지 않습니다.

다만, 자바 코드에서는 refied 타입 파라미터를 사용하는 inline 함수를 호출할 수 없습니다.

logger 예제


기존에는 Lombok에 있는 @Slf4j를 사용해서 log를 바로 사용하였습니다. 하지만 코틀린에서는 롬복을 사용하는 것을 권장하고 있지 않아 Logger를 그때 그때 만들어줘야 했습니다.

import org.slf4j.LoggerFactory

...

private val log = LoggerFactory.getLogger(Example::class.java)

하지만 매번 해당하는 클래스의 이름을 작성해주는 것은 성가신 일이며 오타를 낼 가능성이 있습니다. 오타가 나면 엉뚱한 내용으로 로그를 남기게 될 수도 있습니다. 그래서 Logger를 만드는 작업을 추상화 해주었습니다.

SLF4J loggers in 3 ways

위의 글에서는 코틀린에서 Logger를 만들기 위한 3가지 방법을 제시하고 있는데 가장 짧으면서도 명료한 방법을 사용하였습니다.

package com.example.ep.common.logger

import org.slf4j.Logger
import org.slf4j.LoggerFactory

inline fun <reified T> T.logger(): Logger {
    return LoggerFactory.getLogger(T::class.java)
}

위의 로직은 런타임 시점에 제네릭 타입을 사용하여 로깅을 해주고 있습니다. 따라서 해당 logger를 사용하는 클래스의 이름을 로그에 같이 찍어낼 수 있습니다. 이 예제는 아래와 같은 코틀린 특성 4자리를 사용하여 만든 코드입니다.

  1. top-level-function
    • 함수를 특정 클래스 내부가 아닌 곳에 선언을 하게 되면 패키지만 임포트하면 어디서든 사용할 수 있다.
      • 자바에서는 보통 Util 클래스를 만들고 정적으로 메서드를 선언해서 사용을 하곤 했다.
  2. inline function
    • 인라인함수는 람다식을 사용했을 때, 객체를 자동으로 생성해주던 것을 방지하기 위해 사용되는 함수이다.
      • 자바로 컴파일했을시 Funtional Interface를 사용해야 람다를 인자로 넘길 수 있기 때문에 구현체를 만들어주는 코드가 생성된다.
    • 인라인 함수는 자바로 컴파일했을 시 람다 내부 코드를 람다 사용 위치에 인라인으로 넣어준다. 따라서 코드가 합쳐진 모양이 된다.
    • Logger를 만들 때 인라인 함수를 사용하는 것은 reified 키워드를 사용하기 위함이다.
  3. reified
    • 자바 컴파일러는 제네릭의 타입을 컴파일 타임에만 검사를 하고 런타임에는 해당 타입 정보를 알 수 없게 소거를 한다. (Generic Type erasure)
    • 따라서 런타임에서 Generic Type을 사용하려고 하면 에러가 발생한다. 이를 해결하기 위해 제네릭 타입을 인자값으로 넘겨주는 코드를 추가해야 했다.
    • 코틀린에서 인라인 함수 + reified 키워드를 함께 사용하면 제네릭 타입에 대해서 런타임에서 접근을 할 수 있게 해준다.
  4. 확장함수
    • 제네릭 타입과 확장함수를 같이 사용하면 호출하는 클래스의 함수로서 사용이 가능하다. 따라서 코드가 더 간결해진다.
      • 확장함수를 사용하지 않으면 logger(this)의 형태로 사용해야 합니다. 인자로 객체를 넘길 때 간결하게 코드를 작성하기 위해서 확장함수를 주로 사용합니다.
class Example {

    private val log = logger()

...

위 방법으로 logger를 호출하여 사용할 수 있습니다.

Reference


profile
Hello!

0개의 댓글