Java는 대표적인 정적 타입 언어입니다. 즉 컴파일 시점에 사용하는 변수의 타입이 결정되며, 따라서 명시적으로 변수의 타입을 지정하거나 추론이 가능해야합니다.

 

만약 클래스내 일부 변수에 대하여 int형 또는 String형으로 상황에 따라 각기 다른 데이터를 담고 싶다면 어떻게 해야할까요?

 

class IntegerData{
    int data;
    double var2;
    public IntegerData(int data) {this.data = data;}
    public int getData() {return data;}
    public void method1(){}
    public void method2(){}
}

class StringData {
    String data;
    double var2;
    public StringData(String data) {this.data = data;}
    public String getData() {return data;}
    public void method1(){}
    public void method2(){}
}

public class Main {
    public static void main(String[] args) {
        IntegerData a = new IntegerData(1);
        StringData b = new StringData("1");
        
        int c = a.getData();
        String d = b.getData();
    }
}

 

먼저 타입별 클래스를 만드는 방법을 먼저 떠올릴 수 있습니다.

하지만 일부 데이터 타입만 다름에도 불구하고 매번 클래스를 만드는 것은 매우 비효율적입니다.

 

그렇다면 어떻게 문제를 해결할 수 있을까요?

 

Idea) 모든 클래스는 Object를 상속받으니, Object 타입으로 변수를 지정하자

 

class Data{
    Object data;
    public Data(Object data) {this.data = data;}
    public Object getData() {return data;}
}

public class Main {
    public static void main(String[] args) {
        Data a = new Data(1);
        Data b = new Data("1");
        
        int c = (int) a.getData();
        String d = (String) b.getData();
    }
}

 

Object 타입으로 변수를 생성하면, Object 객체에 모든 변수 값을 저장할 수 있습니다. 따라서, 타입별로 클래스를 만들지 않아도되니 효율적입니다. 하지만 데이터를 가져올 때에는 원래 타입으로 캐스팅이 필요합니다.

 

음.. 캐스팅을 개발자가 명시적으로 지정해야한다는 약간의 불편함은 있지만 그런대로 괜찮아 보입니다.

하지만 정말 이대로 괜찮다고 말할 수 있을까요?

 

문제점

 

public class Main {
    public static void main(String[] args) {
        Data a = new Data(1);
        String b = (String) a.getData();
        System.out.println(b);
    }
}

 

개발자의 실수로 Data 객체에 들어있는 값의 타입이 String으로 착각하여 String 변수에 이를 담았다고 가정해봅시다.

실제로는 담겨져있는 int 타입의 변수에 대해서 String 타입으로 캐스팅을 수행하니 오류가 나야 정상입니다.

하지만 위 소스를 컴파일을 해보면 정상적으로 컴파일됩니다. 그 이유는 지정된 타입은 Object 이고, Object 타입은 String 타입의 부모 타입이기 때문입니다.

 

실행 결과 : Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String

 

따라서, 에러가 발생하는 시점은 Application을 구동해서 데이터를 가져오는 코드가 실행되는 시점입니다. 즉 해당 방법은 TypeSafety 하지 않은 방법으로 Runtime 시점에 오류를 알 수 있는점은 잠재적인 오류를 내포하고 있으므로 좋지 못합니다.

 

public class Main {
    public static void main(String[] args) {
        List list = new ArrayList();
        list.add(1);
        String a = (String) list.get(0);
    }
}

java 1.4 doc ArrayList get 메소드

 

Generic이 지원되지 않는 Java 1.4 버전 이하에서는 우리가 흔히 사용하는 ArrayList 에서 여러 데이터 형을 담기 위하여 Object 타입으로 저장하였습니다. 또한 반환시에도 Object 타입으로 반환하였습니다. 따라서 Runtime 오류를 피하기 위해서 개발자가 조심해서 캐스팅을 수행해야했습니다.


Generic 등장

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        Data<String> b = new Data<>("1");

        int c = a.getData();
        String d = b.getData();
    }
}

 

이전에 살펴보았듯이 Object 타입으로 저장하는 것은 개발자가 명시적으로 타입을 예측하여 캐스팅을 수행해야했기에 잠재적 코드 결함율이 발생할 수 있었습니다. 따라서 이를 해결하고자 Java 진영에서는 1.5 버전부터 Generic을 지원하였습니다. Generic은 특정 변수에 대하여 객체 생성시 원하는 타입으로 지정할 수 있도록 도와줍니다.

 

위 코드와 같이 Data 클래스에 대하여 객체 생성시 <> 안에 원하는 Reference 타입을 명시해주면, 컴파일러가 해당 객체의 data 타입은 명시적으로 지정된 타입이라는 것을 보장해줍니다. 

 

기존에는 반환 값이 Object였기 때문에 명시적으로 캐스팅을 수행해야했지만, Generic을 사용하면 컴파일러가 타입을 보장해주기 때문에 명시적 캐스팅이 불필요합니다.

 

 

마치 위 그림과 같이 타입을 지정하게되면 각각의 클래스가 별도로 존재하는 것처럼 논리적으로 생각할 수 있습니다.

 

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        String d = (String)a.getData();
    }
}

 

따라서 위와 같이 개발자의 실수로 Integer 데이터를 String 변수에 담으려고 해도 컴파일 단계에서 잘못된 타입임을 알고 에러를 표시하기 때문에 TypeSafety합니다.

 

도입부에 이해를 돕기 위해 논리적으로 Class 여러개가 생성된 그림을 보여드렸습니다. 그렇다면 실제 내부 구현도 위와 같이 되어있을까요? 지금부터 Generic 내부에 대해서 살펴보면서 원리를 알아보겠습니다.


용어

 

학습하기 전 관련 용어 일부를 알아보겠습니다.

 

 

Type Parameter : Generic 타입을 명시하기 위한 Placeholder 입니다. 즉 위 코드에서 Data 클래스의 T가 이에 해당됩니다.

 

Type Argument : 실제 Generic 타입에 명시된 타입을 의미합니다. 위 코드에서는 Integer가 해당됩니다.

 

Parameterized Type : Type argument에 의하여 Type Parameter가 치환된 전체 데이터 타입을 의미합니다. 위 코드에서는 Data<Integer>가 이에 해당됩니다.

 

위 세가지 용어가 혼동되지 않도록 주의하며, 본격적으로 Generic에 대해서 알아보겠습니다.


Type Erasure

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

public class Main {
    public static void main(String[] args) {
        Data<Integer> a = new Data<>(1);
        int c = a.getData();
    }
}

 

동작 원리를 이해하기 위해 컴파일된 바이트 코드를 보는 것이 가장 좋지만, 이해를 돕기위해 위와 같이 Generic 문법을 사용한 자바 코드를 컴파일 한다음 해당 class 파일을 다시 디컴파일 결과를 보겠습니다.

 

 

먼저 Data 클래스를 보겠습니다. 기존에 Type Parameter T로 선언되었던 부분이 전부 Object 클래스로 타입이 변경되었습니다.

 

main 호출부에서도 Parameterized Type이 Data<T>였던 클래스 정보가 Data와 같이 변경되었고, 값을 가져오는 부분에서 캐스팅이 발생되었음을 확인할 수 있습니다.

 

자세히 보면 이는 Generic이 사용되기 이전에 작성하던 방식과 동일한 결과입니다.

 

이를 통해 알 수 있는 사실은 Generic을 사용하더라도 사용자가 지정한 Type은 사라지고 기존과 같이 Object 타입으로 지정되며, 대신 명시적으로 값을 가져오는 코드에 컴파일러가 캐스팅하는 코드를 삽입하여 Type 안정성을 보장해준다는 점입니다. 

 

 

또한, 컴파일러가 개발자가 지정한 Type을 알고 있기 때문에, 중간에 개발자가 실수로 잘못된 캐스팅을 시도하는 코드가 있다면 잘못 캐스팅 되었음을 알 수 있습니다.

 

public class Main {
    public static void main(String[] args) throws NoSuchFieldException {
        Data<Integer> a = new Data<>(1);
        System.out.println(a.getClass().getDeclaredField("data"));
    }
}
실행결과 : java.lang.Object Data.data

 

한 단계 더 나아가자면, 컴파일 시점에는 내가 지정한 Type 정보를 알 수 있지만 컴파일 이후에 생성된 바이트코드에서는 Object로 Type이 지정되므로 런타임 시점에는 Type 확인이 불가하다는 점입니다.

 

실제 위 코드와 같이 Reflection을 활용하여 런타임시 Data 인스턴스의 data 타입을 확인해보면 Object 타입임을 알 수 있습니다.

 

런타임 시점에 Type 정보를 알 수 없는 것은 꽤나 불편한 제약으로 느껴집니다. 그렇다면 Java는 왜 이러한 제약사항이 존재함에도 불구하고 Type Erasure를 통해서 명시된 Type을 삭제했을까요?

 

class Stub{
    List list;
}

 

예를들어 기존에 jdk 1.5 미만을 사용하던 라이브러리에서 Java Collection을 사용하려면 위와 같이 사용되었을 것입니다.

 

만약 Generic을 사용한 Collection 클래스에 대해 컴파일 하는 시점에 Type이 사용자가 지정한 Type으로 변경된다면, 그에 따른 바이트 코드 또한 변경될 것입니다.

 

따라서 낮은 버전(1.4 이하) Collection을 사용하는 라이브러리에서 상위 버전으로 업그레이드를 위해서는 변경에 따른 코드 수정이 필요합니다. 즉 New Feature 도입으로 인하여 하위 호환성 문제가 발생할 수 있습니다.

 

Java는 아주 대표적으로 하위 호환성을 중요시 여기는 언어입니다. 따라서 하위 버전의 호환성 유지를 위하여 Type Erasure를 통해 기존 코드와 상위 버전 코드간의 호환성이 잘 유지되도록 지원하였습니다.

 

그렇다면 Generic 프로그래밍을 지원하는 모든 언어가 Type erasure를 사용할까요?

 

정답은 그렇지 않습니다. 대표적으로 C++가 이에 해당합니다.

C++은 컴파일시에 실제 지정한 데이터타입에 대하여 코드를 생성합니다. 따라서 때로는 머신에 최적화된 코드를 생성하기 용이하기도 합니다. 하지만 타입만큼 코드가 생성되므로 Java에 비해 상대적으로 코드 크기가 커질 수 있는 단점 또한 존재합니다.


타입 경계

 

class Data<T>{
    T data;
    public Data(T data) { this.data = data; }
    public T getData() {return data; }
}

 

이전 내용을 통해 Type erasure에 의하여 T 타입이 Object 타입으로 변경됨을 확인하였습니다.

그렇다면 Generic으로 정의한 모든 타입이 컴파일시 Object로 타입이 변환될까요?

 

위 예제를 기반으로 T 타입이 Object로 변경된 이유를 다시 살펴보겠습니다.

 

위 코드에서 T 타입이 의미하는바는 우리가 지정한 어느 타입이여도 상관이 없음을 나타냅니다.

이는 Java를 통해 구현하는 모든 클래스 타입을 지정할 수 있음을 의미합니다. 즉 타입의 경계가 없습니다.

 

출처 : https://docs.oracle.com/javase/tutorial/java/IandI/subclasses.html

 

따라서, 런타임시 내가 지정한 어떠한 타입이라도 허용이 가능한 가장 최상위 클래스인 Object로 타입이 변경되는 것입니다.

만약 다음과 같은 요구사항이 있다고 가정해봅시다.

 

'Data 클래스에서 data 변수는 Integer, Double과 같은 Number 타입만 허용 가능하다.'

 

이 경우에는 어떻게 해야할까요?

 

방법1. 개발자끼리 명시적으로 합의하여 Number 타입이하로만 사용하게끔 조심한다.

방법2. T 타입으로 지정할 수 있는 범위를 설정하여, 잘못 지정시 컴파일 에러를 발생시킨다.

 

너무나 당연하게도, 방법 2로하는게 가장 안전할 것입니다.

Java에서는 이를 위해서 타입 경계 기능을 제공합니다.

 

class Data<T extends Number>{
    T data;
    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
}

 

 

위 코드와 같이 T에 대한 경계(Number 타입 이하로만 설정)를 지정하면, 이를 사용하는 Data 클래스 사용시 타입을 Number 이하로만 설정할 수 있습니다.

 

public class Main {
    public static void main(String[] args)  {
        Data<Object> obj = new Data<>();
    }
}

Error:(14, 14) java: type argument java.lang.Object is not within bounds of type-variable T

Error:(14, 36) java: incompatible types: cannot infer type arguments for Data<>
    reason: inference variable T has incompatible bounds
      equality constraints: java.lang.Object
      lower bounds: java.lang.Number

 

만약 main 함수에서 Data 클래스 정의를 Number의 하위 타입이 아닌 다른 클래스로 지정하게되면 위와 같은 에러를 발생하게 됩니다.

 

그렇다면 위와 같이 경계를 지정한 다음 컴파일하게되면 타입은 무엇으로 지정되어있을까요?

이전과 마찬가지로 컴파일한 내용을 다시 디컴파일해서 확인해보겠습니다.

 

class Data
{

    Data()
    {
    }

    public Number getData()
    {
        return data;
    }

    public void setData(Number number)
    {
        data = number;
    }

    Number data;
}

 

아마 눈치를 채셨겠지만, 디컴파일을 하게되면 Data 클래스의 T 타입이 Object가 아닌 Number 타입으로 변경된 것을 확인할 수 있습니다.

 

그 이유는 타입 경계로 인하여 Number 이하의 타입만 허용가능하기 때문입니다.

 

정리하면 다음과 같습니다.

  • T 타입에 대한 경계를 지정하지 않으면 가장 최상위인 Object로 컴파일시 변경된다.
  • T 타입에 대한 경계를 지정하면, 해당 타입으로 컴파일시 변경된다.

 

그렇다면 타입의 경계를 여러개 지정이 필요한 경우에는 어떻게 할까요?

 

class Data<T extends Number & Comparable<T>>{
    private T data;

    public T getData() {
        return data;
    }
    public void setData(T data) {
        this.data = data;
    }
}

 

위와 같이 Number 타입이면서 Comparable 인터페이스를 구현한 타입만 지정하게끔 설정할 수 있습니다.

그리고 이때 컴파일 시에는 지정된 경계중에 가장 왼쪽에 위치한 Number 클래스로 타입이 지정됩니다.

 

이때 주의할 점은 경계를 구체적인 클래스 타입이 가장 첫 번째로 지정되어야합니다.


마치며

 

이번 포스팅에서는 Generic 기초와 Type erasure에 대해서 살펴보았습니다. 다음 포스팅에서는 Subtyping에 대해서 살펴보겠습니다.

 

'JAVA > Generic' 카테고리의 다른 글

6. Generic 메소드  (0) 2020.03.10
5. Wildcard  (0) 2020.03.08
4. Wildcard with super(하위 경계)  (0) 2020.03.06
3. Wildcard with extends(상위 경계)  (2) 2020.03.06
2. Subtyping, Substitution 원리  (0) 2020.03.05

+ Recent posts