안녕하세요. 개발자 Jindory입니다.
오늘은 Generic이 컴파일 타임에 동작하는 방식에 대해서 글을 작성해보려고 합니다.
[ 글 작성 이유 ]
Generic을 공부하는 과정에서 Generic Type Erasure라는 과정에 대해서 정리해보고 싶어서 작성하게 되었습니다.
Generic
Generic은 타입 안정성을 보장하기 위해 JDK 1.5부터 나온 타입입니다. Java에서 소스코드 컴파일시 타입을 체크해주는 기능입니다. 주로 여러가지 타입을 다루는 클래스나 메소드에 적용하여 사용합니다. 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안전성을 높일 수 있고, 형 변환의 번거로움을 줄일 수 있습니다.
Generic이 나오기 이전에는 아래의 코드처럼 객체를 다룰 때, 특정 타입으로 지정하거나, 어떠한 타입의 값이 들어올지 모를 경우에는 매개변수 타입으로 Object 타입으로 다루어야 했습니다.
public class ListDTO implements Serializable {
private List list;
public ListDTO() {
list = new ArrayList();
}
public void addElement(Object element){
this.list.add(element);
}
public void addElements(List list){
this.list.addAll(list);
}
public Object getElement(int index){
if(index > list.size())
throw new IndexOutOfBoundsException("Index: "+index+", Size: "+list.size());
return this.list.get(index);
}
}
위와같이 만든 DTO를 만든 후 실제로 사용하기 위해서는 List에 담긴 Element를 추가하고 값을 가져올 때 Type Casting작업을 하지 않으면 ClassCastException이 발생합니다.
public static void main(String[] args) {
ListDTO list1 = new ListDTO();
list1.addElement("element");
String str = (String) list1.getElement(0);
System.out.println("list1 get element : "+str);
ListDTO list2 = new ListDTO();
list2.addElement(1);
Integer itr = (Integer) list2.getElement(0);
System.out.println("list2 get element : "+itr);
ListDTO list3 = new ListDTO();
list3.addElement(Double.valueOf(3.14));
Double dbl = (Double) list3.getElement(0);
System.out.println("list3 get element : "+dbl);
ListDTO list4 = new ListDTO();
list4.addElement(19L);
Long lng = list4.getElement(0); // 반환타입을 정해주지 않았기 때문에 ClassCastException이 발생할 수 있음.
System.out.println("list4 get element : "+lng);
}
이러한 에러를 줄이고자 컴파일 시점에 어떤 타입을 사용해야하는지 알 수 있도록, Generic이라는 개념이 jdk 1.5부터 나왔으며, 아래와 같이 작성할 경우 Type을 잘못 작성했을 경우 컴파일 타임에 알 수 있게 되었습니다.
public class ListDTO<T> implements Serializable {
private List<T> list;
public ListDTO() {
list = new ArrayList<>();
}
public void addElement(T element){
this.list.add(element);
}
public void addElements(List<? extends T> list){
this.list.addAll(list);
}
public T getElement(int index){
if(index > list.size())
throw new IndexOutOfBoundsException("Index: "+index+", Size: "+list.size());
return this.list.get(index);
}
}
그렇다면 jdk 1.4에 작성된 코드를 jdk 1.5에서 바로 사용할 때 문제는 없을까요?
정답은 "아닙니다.". jdk 1.4에서 작성한 코드는 Generic의 Type을 지정하지 않았기 때문에 Runtime시 문제가 발생할 수 있습니다.
그렇다면 jdk 1.4에서 작성한 코드가 jdk 1.5의 Runtime에서 문제 없이 실행되기 위해서 어떠한 작업이 필요했을까요?
Generic Type Erasure
Generic Type Erasure(타입소거)는 컴파일 타임에서는 Generic을 통해 Type의 안정성을 보장하지만, 런타임시에는 타입 정보가 지워지는 프로세스를 의미합니다.
위 코드를 보시면 좌측은 Generic으로 선언된 Node Class의 모습이고, 우측은 Runtime시 Node Class의 소스코드입니다. 좌측의 Generic 타입으로 선언한 T가 우측에는 모두 Object로 변경되거나 사라진것을 확인할 수 있습니다. T로 정의된 Generic읜 Unbound Type이므로, Generice 타입을 소거하면 대체될 지 모르기 때문에 프로그램은 모든 객체의 조상인 Object로 변환합니다.
이번에는 Generic의 Type을 제한하는 Bounded Type의 컴파일 타임의 코드와 Runtime시 Type이 소거된 코드의 모습을 나타냅니다. 어떤 코드로 대체될지 알 수 없는 Unbound Type과 대조되게 Bounded Type은 경계값 Type으로 대체되는것을 코드를 통해 알 수 있습니다.
이렇게 Generic Type을 런타임시 소거하는 이유는 이전 버전과의 호환성을 보장하고, 일반적인 클래스나 인터페이스와 동일하게 작동하도록 하기 위함입니다.
다음으로 Method에 선언된 Generic Type의 소거에 대해서 알아보도록 하겠습니다.
Generic Method Eraser
아래의 코드에서 Generic Type이 Method부분에서 사용된 경우(좌측)과 소거된 결과(우측)를 확인 할 수 있습니다.
위 코드에서도 Generic T가 Unbounded Type이기 때문에 Object Type으로 대체됨을 알 수 있습니다.
다음으로는 Bounded Type Generic이 Method에서 선언된 경우와 Type이 소거된 결과 입니다.
맨 위의 코드처럼 Shape이라는 Class가 존재하고, Circle과 Rectangle Class가 Shape Class를 상속하는 관계라고 정의하고, draw Method가 정의되었다고 가정합니다.
T Generic이 Shape를 상속하는 Under Bounded Generic Type이므로 Type 소거시 경계값인 Shape Type으로 대체됨을 확인할 수 있습니다.
Generice Type의 소거와 Bridge Method
이렇게 컴파일러가 Generic Type을 소거하는 과정에서 확장된 Generic Type에 대해서는 다형성을 보존하기 위해 별도의 Bridge Method를 생성하기도 합니다.
Generic Class와 Generic Class를 상속한 Class 정의
이해를 위해서 아래의 코드를 같이 보면서 설명하도록 하겠습니다.
public class Node<T> {
public T data;
public Node(T data) { this.data = data; }
public void setData(T data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node<Integer> {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
위 코드는 Node라는 Generic Class가 있고, Integer 타입을 갖는 Node Class를 상속받는 MyNode라는 Class가 정의 되어 있습니다.
위 코드만 보았을때는 MyNode가 Node를 상속받으면서 setData를 재정의 했음을 확인할 수 있습니다.
컴파일 타임에 체크하지 않은 업캐스팅 Case
그런데 만일 아래와 같이 MyNode를 생성한 후 Node로 업캐스팅 한 다음에 Node로 생성한 객체에 Integer가 아닌 문자형 데이터를 매개변수로 넘기면 어떻게 될까요?
public void static main(String[] args){
MyNode mn = new MyNode(5);
Node n = mn; // A raw type - compiler throws an unchecked warning
n.setData("Hello"); // Causes a ClassCastException to be thrown.
Integer x = mn.data;
}
이런 경우 Integer를 다루는 MyNode의 setData() 메서드에 문자열을 매개변수로 넘겨주었기 때문에 ClassCastingException이 발생합니다.
하지만 Generic을 사용한 Node Class에 잘못된 매개변수 타입을 넘겼음에도 불구하고 컴파일 시점에 Type체크를 하지 않고 Runtime에 Exception을 발생시킨 이유는 뭘까요?
Generic Type 소거
위 코드를 Generic Type이 소거된 상태의 코드로 다시 확인해 보도록 하겠습니다.
public class Node {
public Object data;
public Node(Object data) { this.data = data; }
public void setData(Object data) {
System.out.println("Node.setData");
this.data = data;
}
}
public class MyNode extends Node {
public MyNode(Integer data) { super(data); }
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
위 코드로 다시 보게되면, setData가 Node Class에는 setData(Object data)로 MyNode에는 setData(Integer data)로 각 각 정의가 되어 있어서, 원래 의도했던 오버라이딩(Overriding)이 아닌 오버로딩(Overloading)으로 인식되어 컴파일 타임에 Type check가 이뤄지지 않았던것 같습니다.
그렇다면 이런 상황에서도 컴파일 타임에 Generic Type Check가 동작하기 위해서는 어떻게 해야할까요?
Bridge Method
위 문제를 해결하기 위해서 상속 받은 MyNode 객체에 Generic Type 소거를 대비한 bridge method를 정의해주는 것입니다.
class MyNode extends Node {
public MyNode(Integer data) { super(data); }
// Bridge method generated by the compiler
public void setData(Object data) {
setData((Integer) data);
}
public void setData(Integer data) {
System.out.println("MyNode.setData");
super.setData(data);
}
}
위 코드처럼 setData의 매개변수가 Object 타입을 받을 수 있는 메서드를 정의한 후 Integer type을 매개변수로 받는 setData 메서드를 호출하면서 Integer Type으로 Casting될 수 있도록 정의하는것입니다.
이렇게 정의하면 문자열 값이 들어오면 Object로 선언한 setData 메서드에서 받고, Integer Type을 매개변수로 받는 내부 setData를 호출하는 과정에서 Type mismatch가 일어나므로 ClassCastException이 발생하게 됩니다.
이렇게 Bridge Method를 통해 Generic Class를 상속받은 Class에서 Generic Type Eraser로 인한 문제가 발생하는것을 방지할 수 있습니다.
이렇게 Generic Type의 소거에 대해서 알아봤습니다.
혹시라도 정정할 내용이나 추가적으로 필요하신 정보가 있다면 댓글 남겨주시면 감사하겠습니다.
오늘도 Jindory 블로그에 방문해주셔서 감사합니다.
[ 참조 ]
[ Java] Java의 Generics. Java 언어에서 언어적으로 가장 이해하기 어렵고 제대로 사용하기가… | by 백중원 (Leopold) | Medium
'개발 > Java' 카테고리의 다른 글
[Java] Thread Safety하게 개발하는 방법 (2) | 2024.01.14 |
---|---|
[Java] ArrayList 길이가 가변적으로 확장되는 방식(JDK 별) (2) | 2024.01.01 |
[Java] Java Garbage Collection 동작 과정 (0) | 2023.12.16 |
[Java] Java에서 Thread Unsafe한 상황 이해하기 (0) | 2023.12.15 |
[Java] Mutable Object와 Immutable Object (2) | 2023.12.15 |