들어가며
지난 28일, 자바 정규 수업을 들었다. 제네릭과 관련한 내용이었는데, 제네릭 사용 시 Type Argument를 정확히 일치시켜야 한다고 하더라. 2주라는 짧은 시간 안에 자바를 가르치다 보니 어쩔 수 없는 부분이었겠지만, 아쉽게도 '왜?'는 빠져있었다.
제네릭의 탄생
제네릭이 도입되기 이전 시대에서, List와 Map 같은 자바 컬렉션은 모든 종류의 객체를 담을 수 있는 Object 타입으로 설계되었다. Object 클래스는 최상위 클래스로서, 언뜻 보면 유연하지만 그만큼 치명적인 단점을 안고 있었다.
예를 들어 아래와 같은 원시 타입 리스트가 있다고 하자.
List items = new ArrayList();
items.add("ssafy");
items.add(14);
현재 리스트에는 문자열도, 정수도 들어간 상황이다. 그렇기 때문에 어떠한 요소를 꺼내 쓰기 위해서는 타입 캐스팅이 필수적이다.
String aca = (String) items.get(0);
System.out.println("소속: " + aca);
String gen = (String) items.get(1);
System.out.println(gen + "기");
자바에 익숙한 사람이라면 조금 억지스러운 예시로 보일 수도 있겠다. 하지만 말 그대로 진짜 예시니까, 수동 캐스팅의 불편함과 위험성을 보여주기 위한 연출로 바라봐주면 좋겠다.
문제가 되는 부분은 정수인 14를 문자열로 수동 캐스팅하는 곳에 있다. 런타임 과정에서 ClassCastException을 터트리게 될 텐데, 컴파일 시점에서는 전혀 알 수 없기 때문에 과거 개발자 입장에서는 꽤나 골치 아팠을 것이다.
물론 이러한 오류를 예방하기 위해 instanceof 키워드를 적극 활용할 수 있다. 하지만 해당 방법 역시 런타임 오류를 최소화하기 위한 중요한 도구이면서도, 엄밀히 말하면 이러한 키워드 사용 자체가 코드의 복잡성을 높일 뿐 아니라 여전히 런타임에 의존한다는 근본적인 한계를 지닌다. 나는 이것에 대해 '런타임 지연 발견'이라고 부른다. 이 문제를 해결하려거든 컴파일 시점에 강력한 타입 안성을 제공하면 좋겠다는 생각이다.
그래서 나온 것이 제네릭이다. 제네릭은 런타임 오류를 컴파일 시점으로 끌어올려 개발자의 부담을 줄이는 것을 목표로 탄생했다. 제네릭의 의도대로라면 개발자의 생산성은 물론이고 코드 신뢰성도 비약적으로 향상 가능할 것이다.
타입 파라미터
언젠가 T, E, K, V로 된 형태를 종종 보았을 것이다. 제네릭 클래스나 메서드를 정의할 때, 다룰 데이터의 타입을 특정하지 않고 마치 타입 변수처럼 사용하는 형태인데, 이것이 바로 타입 파라미터다. 타입 파라미터는 실제 코드를 사용할 때 구체적인 타입을 지정함으로써, 컴파일러가 타입 일관성을 미리 검증할 수 있게 만든다.
class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
Box<String> strBox = new Box<>();
strBox.setItem("3반 최고!");
String msg = strBox.getItem();
System.out.println("메시지: " + msg);
stringBox.setItem(123) // 컴파일 에러!
코드를 자세히 보면 두 번째 타입 아규먼트를 생략했는데, 우리는 이것을 '타입 추론'이라고 부른다.
기존의 ClassCastException은 프로그램 실행 전 컴파일 에러로 전환되었다. 덕분에 개발자는 코드 작성 단계에서 즉시 오류를 발견하고 수정할 수 있게 되어 생산성이 증가한 셈이다.
기존 버전과의 하위 호환성 이슈
새로운 이슈가 발생했다. 타입 파라미터의 출현으로 충분히 강력해지고 나니, 이제는 기존 자바 버전과의 하위 호환성 문제에 부딪히게 된 것이다. 만약 런타임에도 List <String>과 List <Integer>가 완전히 다른 타입으로 존재한다면 어떨까?
앞서 제시한 두 리스트가 완전히 다른 타입으로 존재한다면 기존 JVM이나 레거시 코드와의 호환성 문제가 발생할 수 있다. 자바에서는 이 문제를 해결하기 위해 '타입 소거'라는 방식을 택하게 된다. 여기서 타입 소거란, 컴파일러가 제네릭 코드를 바이트코드로 변환할 때, <String>이나 <Integer> 같은 모든 제네릭 타입 정보를 삭제하는 것을 뜻한다. 즉, 런타임에서는 List<String>이든 List<Integer>든 모두 List(원시 타입)로 동일하게 존재한다는 이야기다.
필요할 때 컴파일러가 자동 캐스팅 코드를 삽입하여 개발자에게는 타입 정보가 살아있는 것처럼 보이게 한다.
타입 소거 덕분에 런타임 시점에 컴파일 에러가 터졌다.
'타입 소거(Type Erasure)'의 이면
아이러니하게도 '타입 소거로 인해 같게 보는 현상'은 오히려 컴파일 에러를 줄여주게 된다. 그리고 그로 인해 런타임 에러의 잠재적 위험이 발생하기도 한다.
다시 처음으로 돌아가자. 제네릭의 가장 큰 목표는 컴파일 시점에 타입 안성을 검증하여 런타임 에러를 방지하는 것에 있다. 예를 들어 List<String>에 Integer를 추가하려고 하면, 컴파일러가 제네릭 타입 정보를 활용하여 즉시 컴파일 에러를 발생시킬 것이다.
그런데 만약 개발자가 제네릭 타입 인자 없이 List rawList = new ArrayList();와 같이 원시 타입을 사용했다면 어떻게 될까? 당연히 컴파일러는 타입 검증을 제대로 할 수 없을 것이고, 이 rawList에는 어떤 객체든 추가할 수 있게 될 것이다. 이내 타입 안성이 깨지게 되면서 ClassCastException이 발생할 위험도 증가하게 된다. 이것은 분명한 런타임 에러의 위협이고, 우리가 처음 문제 삼았던 바로 그 지점이다.
타입 아규먼트는 생략이 가능하지만, 그럼에도 원시 타입을 지양하라는 말은 이러한 취지에서 나온 건가 싶기도 하다.
의문점 1
※ 이하 지극히 개인적인 견해이므로 너무 주의 깊게 읽지 않으시길 추천드립니다.
결국 기존 원시 타입의 문제를 해결하기 위해, 즉 컴파일단으로 에러를 끌어오기 위해 제네릭(타입 파라미터)을 도입한 것으로 해석된다. 그러나 하위 호환성 문제로 컴파일 시점에는 타입 소거를 통해 원시 타입으로 만들어버리게 되었다. 이것은 분명 전보다는 나아진 것이 일정 부분 있고, 딜레마에 대한 어쩔 수 없는 타협점으로 평가하긴 하나, 부정적인 시각에서 바라볼 때 여전히 동일한 런타임 위협을 지녔으므로 태초의 문제점으로 되돌아간 것이 아닌가 의심하게 된다.
하위 호환성 문제는 자바 5 버전과 관련된 사항일 텐데, 오늘날 자바 5 버전은 이미 사회에서 퇴출되었다고 봐도 되지 않나? 그렇다면 왜 아직도 타입 소거 방식을 채택하고 있는 걸까? 혹시 이러한 문제들을 모으고 모아 해결하려고 시도한 것이 '코틀린'일까? JDK/JRE를 그대로 쓰면서 컴파일 타임에 Nullable이나 타입 안전성을 추가로 검증하는, 자바보다 더 안성을 추구하는 언어가 코틀린이라는데, 모쪼록 비슷한 이유에서인지도 모르겠다.
사실 정정 1
어쩌면 너무 뻔한 이유일 수 있는데, 가장 큰 이유는 자바 생태계를 한 번에 무너뜨리지 않기 위함이라고 한다. 자바는 수십 년간 작성된 방대한 양의 레거시 코드와 라이브러리를 지녔는데, 이 모든 코드는 제네릭이 없는 자바 1.4 이하 버전으로 작성되었다는 이야기가 존재한다.
앞서 얘기했듯, 자바가 제네릭 정보를 런타임까지 유지하도록 설계했다면 (이하 Reification), 기존 List 타입은 List<Object>와는 전혀 다른 타입이 될 것이며, 이렇게 되면 자바 5 이상에서 컴파일된 제네릭 코드가 자바 1.4 이하에서 컴파일된 라이브러리나 프레임워크와 섞여서 실행될 수 없게 된다. 모든 레거시 코드를 수정해야 하는 대혼란 상황을 유발한다. (*참고로 C# 및 일부 언어들은 제네릭에서 Reification 방식을 채택한다.)
일단 여기서 한 가지 확실하게 짚고 넘어가자. 원시 타입의 경우 최상위 클래스인 Object 타입으로 취급한다고 했는데 List 타입이 List<Object>와 전혀 다르다니, Reification 방식이라면 같다고 취급되어야 하는 거 아닌가? 하는 의문이 생길 수 있다. 원시 타입인 List는 내부적으로 Object를 다루기 때문이다.
그러나 List에는 제네릭 정보가 없고 List<Object>에는 엄연히 제네릭 정보가 존재한다. List<Object>는 런타임에도 <Object>라는 구체적인 타입 인자 정보를 가지고 있는, 별개의 '제네릭화 된 타입'으로 존재한다는 이야기다. 조금 더 쉽게 설명하자면 '내부적으로 Object를 다룬다는 것(non-generic)'과 '타입 인자를 Object로 명시했다는 것 (generic)'은 다르다는 말이다.
다시 돌아와서, C#에서는 Reification을 채택한 이유부터 살펴보자면, 해당 언어는 런타임에 instanceof (List<String>)과 같은 더 정밀한 타입 검사를 할 수 있다. 따라서 List<Object>, List<String>, List<Integer> 모두 런타임에도 명확히 다른 타입으로 구분된다. 원시 타입인 List 또한 다른 타입으로 취급될 것이다.
그렇다면 자바에서는 왜 인스턴스 타입을 체크하지 않는 걸까?
런타임에서도 제네릭 타입 정보를 유지해 인스턴스 타입을 체크하게 되면 JVM 내부 구조가 훨씬 복잡해진다. 객체 표현 방식이나 메모리 관리 등에서도 상당한 변화가 필요할 것이다. 이는 성능 오버헤드를 유발할 가능성이 다분하며, 이러한 복잡성과 성능 저하를 감수하면서까지 런타임 타입 정보를 유지할 필요는 없다고 판단한 것이다. 왜냐하면 컴파일 시점에서의 검증만으로 사실상 제네릭의 핵심 목표를 달성할 수 있기 때문이다. (C#은 복잡성 증가 없냐고 할 수 있는데 자바는 하위 호완성 문제도 있으니까..)
C#의 Reification과 관련하여 조금 더 설명하자면, 당연히 C#이라고 해서 복잡성과 성능 관련 트레이드오프에서 벗어날 수 없다. 자바에 비해 '덜' 부각되는 경향이 있을 뿐이지 완전히 자유로운 것은 아니란 의미다.
일단 가장 큰 차이는 런타임 환경이다. 자바는 JVM 위에서 동작하고, C#은 .NET의 CLR(Common Language Runtime) 위에서 동작한다. CLR은 설계될 때부터 제네릭을 포함한 최신 언어 기능을 고려하여 만들어졌다는 점이 핵심이다.
자바는 기존의 방대한 JVM과 바이트코드 스펙을 건드리지 않고 제네릭을 얹으려다 보니 타입 소거가 불가피했지만, C#은 자바보다 늦게 출시되면서 애당초 제네릭을 완벽하게 지원하도록 재설계할 수 있었고 그에 맞게 런타임 타입 정보를 유지하는 것을 목표로 했다고 이해하자.
뭐 어쨌든, 다시 돌아와서. C#도 타입 정보를 유지하며 추가적인 메모리 및 CPU 오버헤드를 가지는데, C#의 JIT(Just In Time) 컴파일러가 이러한 오버헤드를 최소화하기 위해 노력한다. 과정이 조금 복잡한지라, 간단히 말하자면 JIT 컴파일러는 각 기본 타입에 대해 최적화된 코드를 생성하여 박싱/언박싱 오버헤드를 줄인다. (참조 타입을 사용하는 제네릭은 메모리 구조가 동일하기 때문에 하나의 공유된 코드를 사용하도록 최적화)
결론적으로 JIT 컴파일러의 영리한 구현 덕분에 사실상 개발자가 체감할 수 있는 성능 저하 수준이 아니라고 한다. 물론 복잡성 측면에서는 서로 다른 타입 객체로 존재해야 하다 보니 C#도 어쩔 수 없는 듯하다. 런타임 구현의 복잡성은 증가시키지만 그만큼 개발자에게 더 강력한 리플렉션, 동적 코드 생성 등의 이점을 제공하니 윈윈 아닐까 싶다.
진짜 진짜 최종적으로 요약하자면, 개발자가 코드를 제대로 짰다면 다른 타입이 들어갈 이유가 없기 때문이다. 정말 의도해서 여러 타입을 하나에 처박았다면 어차피 그에 대한 처리를 했을 것이다. 개발자가 실수할만한 지점은 컴파일 시점에 에러로 잡아주는 것만으로 큰 목적을 달성했기 때문에 충분하다.
타입 소거와 관련하여 한 가지만 더 체크하고 넘어가자. 아래와 같이 오버로드를 시도하면 어떻게 될까?
public void print(List<String> list) {
System.out.println("List<String>: " + list);
}
public void print(List<Integer> list) {
System.out.println("List<Integer>: " + list);
}
정답은 컴파일 에러가 터진다는 것이다. 컴파일러는 .java 파일을 .class 파일로 변환하는 과정에서 타입 이레이저를 수행할 텐데, 이 과정에서 모든 제네릭 타입 정보가 지워지므로, 두 메서드의 시그니처를 모두 print(List list)로 변환된다. 자바의 메서드 오버로드 규칙에 따라, 동일한 클래스 내에서 완전히 동일한 시그니처를 가진 두 메서드는 존재할 수 없기 때문에 시그니처 충돌로 인한(또는 이미 정의된 메서드에 대한) 컴파일 에러를 발생시킨다.
공변성과 불공변성
제네릭을 이해하기 위해 꼭 알아야 할 개념 중 하나가 공변성과 불공변성이다. 이는 타입 시스템에서 타입 간의 관계를 설명하는 개념으로, 그중 '공변성'이란 A가 B의 하위 타입일 때 Container<A>도 Container<B>의 하위 타입이 되는 것을 의미한다. 자바의 배열 같은 것들이 공변성을 가진다. 예를 들어 Dog[]는 Animal[]의 하위 타입으로 간주될 것이다.
Animal[] animals = new Dog[10];
animals[0] = new Cat();
하지만 이러한 공변성은 위 예시 코드에서 볼 수 있듯, 런타임에 예기치 않은 ArrayStoreException 따위를 발생시킬 위험이 존재한다. Dog 배열에 Cat 객체를 넣으려 시도할 때, 컴파일 시점에는 문제가 없어 보이지만 런타임에 타입 불일치로 에러가 발생하기 때문이다.
반대로 불공변성은 설령 A가 B의 하위 타입이더라도(sub가 super의 하위 타입이라 할지라도) Container<A>와 Container<B>는 서로 아무런 관계가 없는 별개의 타입으로 간주된다. 자바의 제네릭은 기본적으로 불공변성을 따른다.
제네릭이 불공변성을 따르는 핵심 목적은 계속 제시했던 이유와 동일하다. 런타임 에러를 컴파일 시점으로 끌어올리기 위함인데 공변성을 따르면 본 목적을 잃기 때문이다. 이것이 제네릭을 사용할 때 타입을 정확히 일치시켜야 하는 이유인 셈이다.
불공변성 속 유연성 확보
제네릭의 불공변성 원칙 덕분에 강력한 타입 안전성(Type Safety)을 제공할 수 있었지만, 때로는 지나치게 엄격하여 유연성을 떨어뜨리기도 한다. 이러한 불공변성의 경직성을 해소하고 유연성을 확보하기 위해 도입된 것이 바로 와일드카드(?)이다.
와일드카드는 타입 소거 환경에서도 타입 안전성을 유지하면서 유연한 제네릭 코드를 작성할 수 있도록 돕는다. 여기엔 상한 와일드카드와 하한 와일드카드 얘기를 안 할 수가 없는데, 두 와일드카드에 대한 내용은 아래와 같다. 참고로, 아래에서 말하는 '읽기'란 생산이고 '쓰기'란 소비인 점을 인식하자.
- 상한 와일드카드: ? extends T, 읽기 작업에는 안전하지만 쓰기 작업에는 제한적이다. (null만 추가 가능)
- 하한 와일드카드: ? super T, 쓰기 작업에는 안전하지만 읽기 작업에는 제한적이다. (Object로만 읽기 가능)
와일드카드는 단순히 타입을 지웠다는 것을 넘어, 복잡한 환경에서도 제네릭의 타인 안전성을 보장하려는 설계 의도가 반영된 핵심적인 부분이다. 이를 통해 개발자는 필요에 따라 제네릭 타입의 유연성을 조절하면서도 런타임 에러의 위험을 최소화할 수 있다.
해당 내용에 대해 'PECS 원칙'이라고 불리는 것이 있는데, 'Producer-Extends Consumer-Super'의 약자다. 이걸 이해하기 가장 쉬운 방법은 직접적인 예시를 드는 것이라, 하단에는 PECS 원칙을 잘 보여주는 예시 코드를 보여주려 한다.
1. 상한 와일드카드
여기 List<? extends Animal>이 있다. 이 리스트는 Animal 타입이거나 Animal의 하위 타입(Dog, Cat 등)을 담을 수 있는 리스트를 뜻한다. 상한 와일드카드로서 읽기 작업이 안전하다는 이유는 다음 예시를 통해 충분히 설명된다.
List<? extends Animal> animals = new ArrayList<Dog>();
Animal a = animals.get(0); // 가능 (타입 안전)
리스트에서 무엇을 꺼내든(Get/Read), 적어도 Animal 타입이 보장된다. Dog 리스트에서 Dog를 꺼내든, Cat 리스트에서 Cat을 꺼내든, 모두 Animal 타입으로 볼 수 있다. 즉, Animal 타입으로 캐스팅 없이 안전하게 받을 수 있단 의미다.
List<? extends Animal> animals = new ArrayList<Dog>();
animals.add(new Cat()); // 컴파일 에러!
animals.add(new Dog()); // 컴파일 에러!
animals.add(new Animal()); // 컴파일 에러!
반면 쓰기 작업(Add/Write)에서는 극히 제한적이다. 일단 animals 변수가 실제로 List<Dog>를 참조하고 있으니, 여기에 new Cat()을 추가하려고 하면 Dog 리스트에 Cat을 넣는 것이니 당연히 타입 불일치가 발생할 것이다. new Animal()도 마찬가지다. 참조하는 리스트가 Dog 리스트인지라 상위 타입인 Animal 객체는 들어갈 수 없다. 그렇다면 왜 Dog 객체 또한 들어갈 수 없는 걸까?
어렵게 생각할 것 없이, 컴파일러 입장에서 생각해 보자. 컴파일러는 타입 변수에 대해 쓰기 작업을 허용할 때, 어떤 타입의 객체를 추가해도 안전하다고 확신할 수 있는 경우만 허용한다. List<? extends Animal> animals;라는 변수는 그 자체로 다양한 리스트를 참조할 수 있기 때문에, 모든 시나리오를 고려하여 쓰기 작업을 전면적으로 차단하는 것이다. 유일하게 추가할 수 있는 객체는 'null'뿐이다. 상한이 정해져 있으니 읽을 땐 상한으로 받아버리면 그만이지만, 쓰기에는 정확히 어떤 타입인지 알 수 없어 넣을 수 없다고 이해하자.
2. 하한 와일드카드
List<? super Dog> dogs = new ArrayList<Animal>();
dogs.add(new Dog()); // 가능 (타입 안전)
dogs.add(new Puppy()); // 가능 (타입 안전)
예시 속 리스트는 Dog 타입이거나 Dog의 상위 타입을 담을 수 있는 리스트를 의미한다. 따라서 Dog 객체든, Puppy 객체든 Dog의 상위 타입 리스트에 넣는 것은 항상 안전하다고 볼 수 있다.
List<? super Dog> dogs = new ArrayList<Object>();
Dog d = dogs.get(0); // 컴파일 에러!
Animal a = dogs.get(0); // 컴파일 에러!
Object o = dogs.get(0); // 가능 (타입 안전)
반면 읽기 작업에서는 큰 제약을 지닌다. 기본적으로 List<? super Dog>에서 꺼내는 객체는 최소한 Object임은 확실하다. 물론 List<Animal>이나 List<Dog>였다면 Animal 또는 Dog로도 받아볼 수 있겠지만, List<Object>일 수도 있기 때문에 가장 안전한 타입인 Object로만 읽을 수 있다.
절대 오해하면 안 되는 부분을 강조하고 넘어가자면, 여기서 말하는 '타입 안전'이란 컴파일러가 컴파일 에러를 빨간 줄로 띄워서 잘못된 타입 사용을 사전에 방지해 주는 걸 말한다. (제네릭의 핵심 목표가 컴파일 시점의 타입 안전성이니까.. 같은 말이다.)
최종적으로 이렇게 비유해도 될지 모르겠지만 감히 비유하자면, 타입 파라미터는 List<String>처럼 "이 리스트는 오직 String 타입만 담을 거야!"라고 딱 정해주는 Only-One 지정이라면, 와일드카드는 키워드(extends/super)를 통해 그 범위를 넓혀주는 조건을 추가해 주는 느낌이다. *와일드카드라고 해서 불공변 원칙을 깨뜨리는 것은 아니니, 코드가 더 넓은 범위의 타입들과 상호작용할 수 있도록 유연성을 부여하는 '문법적 도구' 정도로 이해하는 것이 좋겠다.
타입 파라미터에서도 키워드를 사용해 범위를 지정할 수 있는데, 절대 헷갈리면 안 된다. 타입 파라미터는 '정의'의 영역이고, 와일드카드는 '사용'의 영역이라는 점을 떠올리자.
'MAIN > My Study' 카테고리의 다른 글
| 06. 동적 배열의 재할당과 힙 오버플로우 (3) | 2025.07.29 |
|---|---|
| Common vs Global: 패키지 구조 이해하기 (0) | 2025.06.05 |
| .prettierrc (0) | 2025.06.04 |
| Database driver: undefined/unknown (0) | 2025.05.24 |
| Docker Compose에서 MySQL 데이터 영속성 설정하기 (1) | 2025.05.23 |