어제 교보만 세 곳을 돌아다니면서 몇 가지 읽을만한 책을 봤다. 물론 거기에는 CS책도 포함되어있기도 했고. 그러고보니 가장 먼저 해야할 것이 자바부터 배우는 것
https://dev-dx2d2y-log.tistory.com/156
[Java] 자바의 정석 독서 #27 - 제너릭 기초
11장을 넘어서 12장으로 넘어왔다. 12장의 첫 타자는 제너릭. 사람마다 제너릭스라 하는 사람도 있고 제너릭이라 하는 사람도 있는데 난 제너릭이라고한다. 제너릭은 JDK5부터 도입되었다. 제너릭
dev-dx2d2y-log.tistory.com
저번에는 제너릭의 기초에 대해서 했다.
제너릭이란 다양한 타입을 다뤄야하는 객체 또는 클래스 등에서 임의의 타입 변수를 하나의 타입으로 사용해 컴파일 시 타입체크를 자동으로 실행하여 타입 안정성 강화 및 불필요한 형변환을 줄이는 기능을 말한다.
? | 와일드카드
public static void printArrayList(ArrayList<Object> list){
for (Object o : list) {
System.out.println(o);
}
}
C프 과제에서 주로 봤던 것 같은 ArrayList의 요소를 하나하나 출력해주는 printArrayList 메서드를 만들었다고 치자.
또한 아무 요소들이나 받을 수 있도록 ArrayList의 타입 매개변수를 Object로 설정했다.
public static void main(String[] args) {
ArrayList<Integer> intList = new ArrayList<>();
printArrayList(intList);
}
그리고나서 printArrayList 메서드를 호출하여 ArrayList<Integer>의 요소들을 출력하려면 오류가 발생한다.

이는 제너릭의 불공변성에 의한 것이다.
public static void main(String[] args) {
ArrayList<Object> intList = new ArrayList<>();
printArrayList(intList);
}
반대로 intList의 타입매개변수를 Object로 바꾸면 잘 동작한다.
즉, Object는 Integer의 상위클래스지만 ArrayList<Object>는 ArrayList<Integer>의 상위클래스가 아니다.
그렇기 때문에 아무타입이던 간에 어쨌든 특정 제너릭클래스 (ArrayList 같은)에 대해 범용적인 기능구현이라는 목표는 제너릭 타입매개변수로 Object를 넘겨주는 것으로는 해결할 수 없다.
또는 메서드 오버로딩으로 해결할 수 있을 수도 있지만.. 그 과정이 매우 번거롭기도하고
public static void main(String[] args) {
ArrayList<Integer> ia = new ArrayList<>();
printArrayList(ia);
}
public static void printArrayList(ArrayList<Integer> list){
for (Number o : list) {
System.out.println(o.toString());
}
}
public static void printArrayList(ArrayList<Double> list){
for (Number o : list) {
System.out.println(o.toString());
}
}
기본적으로 제너릭클래스는 오버로딩이 성립하지 않는다.
왜냐하면 제너릭클래스는 컴파일 시에 타입 매개변수가 소거되기 때문이다. 컴파일 후에는 ArrayList<Integer>와 ArrayList<Double> 모두 ArrayList 타입이다.
따라서 제너릭과 와일드카드, 제너릭의 상속관계에 대해 알기 위해서는 공변성에 대해서 알아야한다.
공변 | 반공변 | 무공변성
Base 클래스와 그 하위클래스 Derived 클래스, 그리고 제너릭클래스인 Generic<>이 있다고 치자.
공변성(Covariance)이란 타입변수가 Base인 제너릭클래스와 타입변수가 Derived인 제너릭클래스 간에도 상속관계가 유지되는, 즉 타입변수로 전해지는 객체의 상속관계가 타입변수의 제너릭 타입에서도 유지되도록 하는 성질을 뜻한다. 이 경우 Generic<Base>는 Generic<Derived>의 상위 타입이다.
반공변(Contravariance)이란 타입변수가 Base인 제너릭클래스가 타입변수가 Derived인 제너릭클래스의 하위타입이 되는, 즉 타입변수로 전해지는 객체의 상속관계가 타입변수의 제너릭 타입에서는 반대로 상속관계를 가지도록하는 성질을 뜻한다. 이 경우 Generic<Base>는 Generic<Derived>의 하위 타입이다.
무공변(Invariant)이란 타입변수가 Base거나 타입변수가 Derived거나 상관없이 타입변수의 제너릭 타입에서는 타입변수의 상속관계를 유지하지 않는 성질을 뜻한다. 이 경우 Generic<Base>와 Generic<Derived>는 아무런 상속관계도 가지지 않는다.

대충 그림으로 나타내보면 이정도의 관계를 가질 것이다.
제너릭을 사용할 때는 일반적으로 무공변성을 가진다. 그래서 위의 경우에서 ArrayList<Object>는 ArrayList<Integer>의 상위타입이 될 수 없었던 것이다.
그렇다면 왜 제너릭은 무공변성을 가질까?
그 이유는 제너릭은 타입 안정성을 지키기 위해 등장했기에, 지정된 타입 이외에는 다른 타입을 받을 수 없도록해야 원래의 그 목적을 달성할 수 있기 때문이다.
ArrayList<Object> l = new ArrayList<String>();
만약 제너릭이 공변이라면 다형성에 의해 위와 같은 코드가 제대로 동작할 것이다.
l.add("Hello");
l.add("DDD");
l.add("System");
String도 자유롭게 추가할 수 있으나..
l.add(3);
l.add(2);
l.add(8);
문제는 참조변수의 타입이 ArrayList<Object>이기 때문에 Integer도 추가할 수 있고, 다른 여러 타입들도 추가할 수 있게된다.
하지만 실제 인스턴스변수의 타입은 ArrayList<String>이기 때문에 실제로는 Integer 값을 추가할 수 없게된다.
Object[] o = new String[10];
o[0] = "Hello";
o[1] = 3; //에러
실제로 공변성을 가지는 배열에서 이를 실행하면 컴파일 시점에서는 아무런 오류가 생기지 않지만, 런타임 시점에서 ArrayStoreException이 발생한다.
따라서 제너릭은 '타입 안정성을 강화한다.'라는 목적을 달성하기 위해서 애초에 무공변성을 지니게 되었다.
위에서 '제너릭클래스는 컴파일 시 타입매개변수가 소거된다'라는 뜻도 애초에 무공변성이기 때문에 상속관계가 딱히 없어 타입검사를 실시하는 컴파일 이후에 타입매개변수가 필요없기 때문이다.
그렇다면 다시 처음으로 돌아가서
'특정 제너릭클래스 (ArrayList 같은)에 대해 범용적인 기능구현'은 불가능할까?
이 때 사용되는 것이 바로 와일드카드다.
제너릭클래스는 무공변성을 지니므로 제너릭 타입의 상속관계를 이용해 매개변수로 특정 타입의 제너릭클래스를 받아 범용적인 기능을 사용하는 것을 불가능하고, 와일드카드를 통해서 일단은 모든 타입을 다 받는 용도로 사용할 수 있다.
public static void main(String[] args) {
ArrayList<Integer> intList = new ArrayList<>();
printArrayList(intList);
}
public static void printArrayList(ArrayList<?> list){
for (Object o : list) {
System.out.println(o);
}
}
위의 코드에서 printArrayList의 타입 매개변수를 ArrayList<?>로 수정했다.
와일드카드를 사용하면 아직 타입이 정해지지 않은 상황에서 범용적인 기능을 구현할 수 있다.
ArrayList<? extends Number> numbers = new ArrayList<>();
이처럼 와일드카드를 사용하면 하나의 참조변수로 대입된 타입이 다른 객체를 다룰 수 있게되고, 일반적인 제너릭처럼 extends 키워드를 사용하여 특정 클래스의 하위클래스를 받도록 지정할 수도 있다.
위의 예시에서는 참조변수가 ArrayList<? extends Number>인 numbers 변수가 ArrayList<Integer>도 가리킬 수 있고 ArrayList<Double>도 가리킬 수 있게되고, 위의 printArrayList를 보면 list변수가 참조변수 ArrayList<?>이므로 모든 ArrayList를 가리킬 수 있게되는 것이다.
만약 <? extends Object>라면 Object의 하위클래스는 모두 타입매개변수로 받으라는 뜻이므로 <?>와 동일.
따라서 와일드카드는 사용자가 제너릭을 사용해야할 때, 어떤 객체를 타입변수로 지정할지 미리 정해지지 않았거나 여러 타입들에 대해서 범용적인 기능을 수행하기 위해 ? 기호를 사용해 임의의 타입 매개변수를 허용하기 위해서 필요하다.
와일드카드의 extends를 사용하면 공변성이 생긴다.
말 그대로 제너릭은 무공변성이지만 와일드카드를 사용하면 공변성이 생긴다.
ArrayList<? extends Number> ia = new ArrayList<>();
ArrayList<Integer> iia = new ArrayList<>();
ia = iia;
위 코드는 제대로 실행이 된다.
이처럼 와일드카드를 사용한 ia는 ArrayList<Number>도 가리킬 수 있고, ArrayList<Integer>도 가리킬 수 있고, ArrayList<Double>도 가리킬 수 있다. 하지만 ia가 ArrayList<Integer>를 가리키더라도 ia로 접근한 요소들에게서 Integer가 가지고 있는 고유의 메서드들은 실행하지 못한다. 즉, 이는 다형성의 경우와 일맥상통하다.
그렇기 때문에 와일드카드를 사용하면 제너릭클래스 간 일종의 공변성이 생긴다.
단, 주의해야할 점은 공변성이 생긴다는 것이지 실제로 상속관계는 아니다. 여전히 제너릭타입은 컴파일 후에 타입 매개변수가 소거된다는 점 때문이다.
와일드카드 - super T
와일드카드에만 있는 키워드가 바로 super다.
extends가 특정클래스의 하위클래스들을 타입매개변수로 받도록 설정했다면, super 키워드는 특정클래스의 상위클래스들을 타입매개변수로 받도록 설정한다.
대표적인 super 키워드의 사용예시가 바로 Collections의 sort 메서드.
public static <T> void sort(List<T> list, Comparator<? super T> c) {
list.sort(c);
}
Collections 클래스에는 sort라는 메서드가 정의되어 있는데, 여기에 와일드카드가 정의되어 있다. 우선 static과 void 사이 <T> 부분은 넘어가고 (나중에 할 예정이다.) 매개변수 부분을 보면
첫 번째 매개변수는 정렬할 대상을 나타내고, 두 번째 매개변수는 정렬할 방법의 정의된 Comparator 구현체이다.
만약 와일드카드를 사용하지 않았다고 생각해보면..
public static <T> void sort(List<T> list, Comparator<T> c) { ... }
이렇게된다.
그렇다면 내가 특정 객체들이 담긴 List를 정렬하고 싶다면, 그 리스트에 맞는 Comparator의 구현체를 만들어야한다.
하지만 그게 번거로울 때도 있고, 클래스 수도 많아지게될 뿐더러 가독성도 안좋을 때가 있다.
class People{
int age;
String name;
People(int age, String name){
this.age = age;
this.name = name;
}
}
class Student extends People{
Student(int age, String name){
super(age, name);
}
}
class Programmer extends People{
Programmer(int age, String name){
super(age, name);
}
}
이전에 내가 사용했던 People - Student - Programmer 예시를 다시 들고와봤는데..
ArrayList에 넣고 이를 정렬하려면 Comparator<People>, Comparator<Student>, Comparator<Programmer>를 모두 만들어야한다. 얼마나 귀찮아
public static <T> void sort(List<T> list, Comparator<? super T> c) {
list.sort(c);
}
하지만 Collections.sort의 Comparator에는 <? super T>라고 정의되어 있다.
즉, 개발자가 Collections.sort(ArrayList<Programmer>, Comparator<? super Programmer>)가 되는 것이다. 즉, Comparator로 Comprator<People>을 전해줄 수도 있도 Comparator<Object>도 전해줄 수 있다.
위에서 말했듯이 와일드카드의 extends를 사용하면 공변성이 생긴다고했는데, 반대로 super 키워드를 사용하면 반공변성이 생긴다. 위의 예시에서처럼 Comprator<? extends Programmer>가 Comparator<People>도 가리킬 수 있고 Comparator<Object>도 가리킬 수 있기 때문.
extends? super?
그럼 extends를 써야할까, super를 써야할까?
위의 Peple-Student-Programmer 예시를 다시 들어보면
1. extends는 읽기전용이다.
public static void main(String[] args) {
ArrayList<People> ia = new ArrayList<>();
printArrayList(ia);
}
public static void printArrayList(ArrayList<? extends People> list){
list.add(new People(328, "MRCH"));
}
extends 키워드는 읽기 전용이다. 따라서 위와같이 리스트에 직접적으로 값을 수정할 수 없다.
왜냐하면 와일드카드라는 것 자체가 '아직 타입은 모르지만 최소한 특정타입(여기서는 People)의 하위타입이 타입매개변수로 주어질 것입니다.'인데 리스트에 값을 추가하려니 어떤 타입인지 알 수 없어서 그냥 값 추가를 막아버린다.
ArrayList<? extends People>은 ArrayList<Programmer>와 ArrayList<Student>를 모두 가리킬 수 있지만, 어느 한 개로 지정되지 않았기 때문에 개발자가 People 객체를 추가해도, Programmer 객체를 추가해도, Student 객체를 추가해도 타입 안정성을 체크할 수 없기 대문이다.
즉, 타입체크가 선제적으로 필요한 작업은 수행할 수 없다.
그래서 타입에 상관없이 사용할 수 있는 작업 (remove 같은 것)은 자유롭게 가능하다.
2. super는 쓰기전용이다.
public class Main {
public static void main(String[] args) {
ArrayList<People> ia = new ArrayList<>();
printArrayList(ia);
}
public static void printArrayList(ArrayList<? super Programmer> list){
list.add(new Programmer(328, "MRCH"));
}
}
super 키워드는 '제너릭클래스의 타입매개변수는 모르지만 최소한 이 타입매개변수보다는 상위타입의 타입매개변수를 가진 제너릭클래스를 가리킬 수 있다.'라는 뜻을 가지고 있다.
ArrayList<? super Programmer>는 ArrayList<Programmer>, ArrayList<People>을 모두 가리킬 수 있다.
그렇지만 ArrayList<People>에 Programmer 객체가 추가되더라도 다형성으로 이를 처리할 수 있다.
public class Main {
public static void main(String[] args) {
ArrayList<People> ia = new ArrayList<>();
printArrayList(ia);
}
public static void printArrayList(ArrayList<? super Programmer> list){
Programmer p = list.get(0);
}
}
그래서 읽기는 안된다.
다형성을 할 때 하위타입의 참조변수로 상위타입의 인스턴스 변수를 읽을 수 없듯이, 이 경우도 같은 메커니즘으로 동작한다.
내용이 좀 많이 길어졌긴했는데 이번에는 제너릭클래스의 와일드카드에 대해서 알아보았다.
제너릭은 무공변성을 가지고 있기 때문에 제너릭클래스 간 상속관계를 만들 수 없어 상속의 장점인 '범용적인 기능적용'이 불가능하다. 하지만 와일드카드를 사용하여 어떤 객체를 타입변수로 지정할지 미리 정해지지 않았거나 여러 타입들에 대해서 범용적인 기능을 수행하기 위해 ? 기호를 사용해 임의의 타입 매개변수를 허용할 수 있다.
또한, 와일드카드의 extends 키워드를 사용하면 읽기전용, super 키워드를 사용하면 쓰기전용이다. 이는 다형성과 아주깊은 관계에서 비롯한 결과다. 따라서 객체를 읽기만할 것이면 extends를, 아예 직접 개입해서 써야한다면 super 키워드를 사용하는 것이 좋다.
'언어공부 > Java | Kotlin' 카테고리의 다른 글
| [Java] 자바 내에서 참조란 무엇인가? (0) | 2025.12.25 |
|---|---|
| [Java] 자바의 정석 독서 #29 - 제너릭 메서드, 제너릭 형변환 (0) | 2025.12.24 |
| [Java] 자바의 정석 독서 #27 - 제너릭 기초 (1) | 2025.12.18 |
| [Java] 자바의정석 독서 #26 - 컬렉션 마무리하기 (0) | 2025.12.04 |
| [Java] 자바의 정석 독서 #25 - Collections 클래스 (0) | 2025.12.04 |
