devseop08 님의 블로그

[OOP] 제네릭스 본문

Language/Java

[OOP] 제네릭스

devseop08 2025. 9. 20. 04:40

자바 컴파일러의 특징

  • 자바는 기본적으로 상위 타입에서 하위 타입으로의 대입을 허용하지 않는 컴파일 규칙이 있다.
  • 이는 런타임에 에러가 발생할 여지가 있는 문제 코드를 조기에 발견하여 프로세스가 실행 중에 중단되는 위험을 사전에 차단하기 위한 규칙의 일환이다.
public class Generic1 {
    public static void main(String[] args) {
        Object a = Integer.valueOf(1);
        Integer b = a; 
        // error: incompatible types: Object cannot be converted to Integer
    }
}
  • 상위 타입에서 하위 타입으로의 대입을 하기 위해선 명시적으로 형변환 연산자를 사용해 다운캐스팅해야만 컴파일이 가능하다.
public class Generic1 {
    public static void main(String[] args) {
        Object a = Integer.valueOf(1);
        Integer b = (Integer)a; // 컴파일 OK
    }
}
  • 그런데 만약 자바 컴파일러가 상위 타입에서 하위 타입으로의 대입을 규칙 상 허용해준다면?
  • 즉, 자바 컴파일러가 명시적 형변환 연산자를 사용하지 않은 경우(다운캐스팅하지 않은 경우)에도 상위 타입에서 하위 타입으로의 대입을 허용해준다면?
public class Generic1 {
    public static void main(String[] args) {
        Object a = Integer.valueOf(1);
        Integer b = a; // 컴파일 OK
    }
}
  • 위의 코드에서는 a가 참조하는 인스턴스가 실제로 Integer 타입이므로 Integer b = a;는 논리적으로 문제가 없다.
  • 따라서 자바 컴파일러가 규칙 상 상위 타입에서 하위 타입으로의 대입을 허용해도 괜찮다고 판단할 수 있지만
  • 규칙 상 컴파일은 통과해도 논리적으로는 오류가 발생하는 문제 코드가 작성될 가능성이 있다.
public class Generic1 {
    public static void main(String[] args) {
        Object a = Integer.valueOf(1);
        a = "bug"; 
        Integer b = a; // 컴파일은 OK
    }
}
  • 위의 코드에서 Integer b = a; 시점에 a 자체의 타입은 Integer 타입의 상위 타입인 Object이지만 a가 참조하는 실제 인스턴스는 String 타입이므로 논리적인 오류가 발생한다.
  • 또한 추후 b가 참조하는 실제 인스턴스가 Integer 타입이라 생각하고 b로부터 Integer 타입의 객체가 수행할 수 있는 동작을 구현한 메서드를 호출하고자 했으나
  • b가 참조하는 실제 인스턴스는 String 타입이므로 해당 메서드를 찾지 못해 런타임에 에러가 발생할 수 있다.(메서드 검색은 실제 인스턴스의 타입을 기준으로 한다.)
  • 런타임에 위와 같은 에러는 시점 상 버그 코드가 작성된 시점과는 꽤나 먼 곳에서 발생할 수 있기 때문에 디버깅도 복잡해지고 프로세스도 실행 중에 중단되기 때문에 여러모로 골치가 아파진다.
  • 자바 컴파일러의 특징:
  1. 자바 컴파일러는 지금 당장 적힌 코드의 논리성은 판단하지 않고 오로지 정해진 규칙과 문법에만 의거해서 컴파일 가능 여부를 판단한다.
  2. 작성된 코드가 지금 당장 논리적으로는 문제가 없어 보여도 만약 그 코드가 규칙이나 문법을 어겼다면 자바 컴파일러는 무조건 컴파일을 허용하지 않는다.
  3. 문법과 규칙 검사를 할 때는 작성된 코드의 논리적인 흐름까지는 고려하지 않는다.
  4. 버그 코드가 작성될 가능성을 언어의 문법이나 규칙을 통해 통제할 수 있다.
  5. 컴파일 타임 때 문법과 규칙 검사를 함으로써 문제 코드를 컴파일타임에 사전 발견할 수 있다.

제네릭스의 필요성

  • 자바 제네릭스도 런타임에 발생할 수 있는 문제를 컴파일타임에 사전 발견하고 런타임 에러 발생 가능성을 제거하기 위한 취지에서 생겨난 문법이자 기능이다.
  • 제네릭이 도입되기 전(JDK 1.5 이전 버전)의 자바 List 요소들의 타입: 따로 제한을 두지 않았다.
public class Generic1 {
    public static void main(String[] args) {
        List ints = new ArrayList();
        ints.add(new Integer(1));
        ints.add(new Integer(2));
        ints.add("bug");
        System.out.println(ints);
    }
}
  • 위의 코드는 지금 당장은 논리적으로 문제가 없어보이지만 런타임 에러가 발생 가능한 코드를 추가할 수 있다.
public class Generic1 {
    public static void main(String[] args) {
        List ints = new ArrayList();
        ints.add(new Integer(1));
        ints.add(new Integer(2));
        ints.add("bug");
        System.out.println(ints);

        intSum(ints);
    }

    static int intSum(List nums){
        int sum = 0;

        for(int i = 0; i < nums.size(); i++) {
            Integer val = (Integer) nums.get(i); // 컴파일 OK, 런타임에 에러
            sum += val.intValue();
        }

        return sum
    }
}
  • 런타임에 intSum 메서드가 실행되고 for 문의 Integer val = (Integer) nums.get(i);시점에 nums.get(i)의 결과가 문자열 상수 "bug"의 참조값인 경우 Integer 타입으로의 형변환 연산 (Integer)가 불가하므로
  • 그 때서야 런타임 에러의 일종인 ClassCastException: String cannot be cast to Integer가 발생하게 된다. => 버그를 작성하는 시점과 버그가 발견되는 시점 간의 간극이 발생한다.
  • 제네릭은 위와 같은 문제를 방지하기 위해 컴파일 타임에 List 요소들의 타입을 제한하도록 도입된 문법이다.(자바 5, JDK 1.5 버전부터 도입)
public class Generic1 {
    public static void main(String[] args) {
        List<Integer> ints = new ArrayList<Integer>();
        ints.add(new Integer(1));
        ints.add(new Integer(2));
        ints.add("bug"); // 컴파일 에러
        System.out.println(ints);

        intSum(ints);
    }

    static int intSum(List<Integer> nums){
        int sum = 0;

        for(int i = 0; i < nums.size(); i++) {
            Integer val = (Integer) nums.get(i); 
            sum += val.intValue();
        }

        return sum;
    }
}
  • 제네릭스를 사용할 시의 이점
  1. 객체의 타입 안정성을 보장한다.
  2. 타입 체크와 형변환 연산을 생략 가능하기 때문에 코드가 간결해진다.

제네릭스 구현 방법

  • Toy 타입 객체의 참조값을 자신의 멤버로 갖는 ToyBox 클래스
public class ToyBox {
    private Toy toy;

    public Toy getToy(){
        return toy;
    }

    public void setToy(Toy t) {
        this.toy = t;
    }

    public void pack() {
        ...
    }

    public void unpack() {
        ...
    }
}
  • Book 타입 객체의 참조값을 자신의 멤버로 갖는 BookBox 클래스
public class BookBox {
    private Book book;

    public Book getBook(){
        return book;
    }

    public void setBook(Book b) {
        this.book = b;
    }

    public void pack() {
        ...
    }

    public void unpack() {
        ...
    }
}
  • Shoes 타입 객체의 참조값을 자신의 멤버로 갖는 ShoesBox 클래스
public class ShoesBox {
    private Shoes shoes;

    public Shoes getShoes(){
        return shoes;
    }

    public void setShoes(Shoes s) {
        this.shoes = s;
    }

    public void pack() {
        ...
    }

    public void unpack() {
        ...
    }
}
  • 정확히 동일한 기능을 하지만 멤버의 타입만 다른 Box 클래스들 => 중복 코드 발생, 기능 수정하기가 어렵다.
  • 제네릭은 이런 클래스들을 하나로 통합할 수 있도록 타입 자체를 파라미터화할 수 있는 기능을 제공한다.
public class Box<T> {
    private T item;

    public T getItem(){
        return item;
    }

    public void setItem(T i) {
        this.item = i;
    }

    public void pack() {
        ...
    }

    public void unpack() {
        ...
    }
}
  • 제네릭을 사용하지 않고 단순 일반 클래스 ObjectBox로 통합하는 경우: 컴파일 타임에 버그를 발견하지 못하고 런타임에 캐스팅 도중 에러 발생할 가능성이 있다.
  • 또한 멤버를 Object 타입으로 선언했기 때문에 매번 원하는 타입으로 캐스팅해줘야 하는 불편함이 발생한다.
public class ObjectBox {
    private Object item;

    public Object getItem(){
        return item;
    }

    public void setItem(Object i) {
        this.item = i;
    }

    public void pack() {
        ...
    }

    public void unpack() {
        ...
    }
}
ObjectBox toyBox = new ObjectBox();

toyBox.setItem(new Book()); // 버그

Toy toy = (Toy) toyBox.getItem(); // 컴파일 OK, 런타임에 에러 발생
  • 반면 제네릭을 사용해 멤버의 타입 자체를 파라미터화한 Box<T> 클래스를 이용하는 경우: 컴파일 타임에 버그를 발견한다.
Box<Toy> toyBox = new Box<Toy>();

toyBox.setItem(new Book()); // 버그 발견, 컴파일 에러

toyBox.setItem(new Toy());

Toy toy = toyBox.getItem(); // 형변환 연산 필요없다.

제네릭스 용어

  • Box<T> 클래스 선언에서의 용어
    • Box<T>: 제네릭 클래스, generic class(인터페이스라면 제네릭 인터페이스)
    • T: 타입 매개변수, 형식 타입 매개변수, 정규 타입 매개변수, type parameter, formal type parameter
    • <T>: type parameter section => 타입 매개변수를 선언하고 정의하는 용도
  • Box<T> 클래스 사용에서의 용어
    • Box<Toy>: 매겨변수화된 타입, parameterized type
    • Toy: 타입 인자, 타입 인수, 실제 타입 매개변수, type argument, actual type parameter => 타입 인자 전달 용도
    • <Toy>: type argument list
  • generic type = generic class와 generic interface 한 번에 지칭

타입 파라미터 규칙

  • 기본형(primitive type)은 타입 인자로 전달될 수 없다.
Box<int> (X)
Box<Integer> (O)
  • 자바 7부터 제네릭 클래스 타입 객체 생성 시 타입 추론: 다이아몬드 연산자를 이용
List<String> strs = new ArrayList<String>();

List<String> strs = new ArrayList<>(); // 다이아몬드 연산자로 타입 추론
  • type argument list 안의 type parameter는 2개 이상일 수 있다.
public interface Map<K, V> { ... }

public class HashMap<K,V> { ... }
Map<Long, String> nameById = new HashMap<Long, String>();

Map<Long, String> nameById = new HashMap<>(); // 다이아몬드 연산자로 타입 추론
  • parameterized type도 type argument가 될 수 있다.
Map<Long, Box<Toy>> toyBoxById = new HashMap<>();
  • 타입 인자 전달은 인스턴스 단위로 가능 => 제네릭 클래스의 static 멤버에는 타입 파라미터 사용 불가
class Box<T> {
    static T item; // error
    static int compare(T t1, T t2) {...} // error
}
  • 제네릭 클래스 안에서 배열 생성 시 배열 생성에 타입 파라미터 사용 불가
class Box<T>{
    T[] arr; // OK 
    T[] toArray(){ new T[arr.length]} // error
}

타입 파라미터의 타입 계층 제한, 상한 경계 지정

  • extends 키워드를 사용하면 제네릭 클래스의 타입 매개변수에 대한 타입 인자로 전달될 수 있는 타입의 종류를 제한시킬 수 있다. => 제네릭 클래스 타입 매개변수의 타입 계층 제한, 상한 경계 지정
interface Boxable {
    default boolean isBreakable() {
        return false;
    }
}
public class Toy implements Boxable { ... }

public class Book implements Boxable { ... }
public class Box<T extends Boxable> { 
    // extends로 타입 파라미터 T의 상한 경계를 Boxable로 지정
    // Boxable의 서브타입들만 Box의 타입 인자로 전달될 수 있다.
    private T item;
    ...
    public void pack() {
        if(item.isBreakable()){
            putMaxAirCap();
        } else {
            putAvrAirCap();
        }
    }
}
  • T extends Boxable: bounded type parameter
  • Boxable: upper bound
  • upperbound가 인터페이스인 경우에도 implements 키워드가 아닌 extends 키워드를 사용하여 타입 파라미터의 상한 경계를 지정해줘야 한다.
  • upperbound는 2개 이상일 수 있다.
  • 단, 자바는 클래스 다중 상속은 불가하고 인터페이스 다중 구현은 가능하기 때문에 여러 개의upperbound 중 클래스는 하나여야만 하고 인터페이스는 하나 이상일 수 있다.
  • upperbound가 하나의 클래스와 한 개 이상의 인터페이스로 이루어진다면 &로 구분지어야 하고 클래스가 제일 앞에 주어져야 한다.
  • upperbound가 여러 개인 경우 이를 multiple bound라고 한다.
class A { ... }
interface B { ... }
interface C { ... }
class D<T extends A & B & C> { ... }

raw type

List strs = new ArrayList();
  • 제네릭 타입을 사용하긴 했지만 type argument도 없이 이름만 가지고 사용했을 때
  • 존재 이유: 제네릭이 없던 시절에 만들어진 클래스들과의 호환을 위해서

제네릭 메서드

  • 다른 박스와 배송지 주소가 같은지를 검사하는 메서드를 구현한다고 해보자
  • 서로 다른 타입의 아이템을 갖는 박스끼리도 배송지 주소를 비교할 수 있도록 한다.
public class Box<T extends Boxable> {
    private T item;
    private String destAddress; // 배송지 주소    

    public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
        return destAddress != null 
                    && b != null 
                        && destAddress.equals(b.getDestAddress());
    }    
}
  • <U extends Boxable>: 제네릭 메서드hasSameDestinationAs의 타입 매개변수 U 선언, 상한 경계를 Boxable로 지정, U는 제네릭 클래스 Box<T extends Boxable>의 타입 매개변수 T와는 별개의 타입이다.
  • 제네릭 메서드 hasSameDestinationAs 의 첫 번째 매개변수 타입으로 선언된 실체화된 매개변수 타입 Box<U>:
  • U는 제네릭 메서드의 상한 경계가 Boxable로 지정된 타입 매개변수 U가 제네릭 클래스 Box<T extends Boxable>의 타입 매개변수 T에 대해 타입 인자로 전달된 것이다.
  • 제네릭 클래스 Box<T extends Boxable>의 타입 매개변수 T와 동일하게 제네릭 메서드의 타입 매개변수 U는 반드시 상한 경계가 Boxable이 되도록 <U extends Boxable>로 선언해야 한다. 그렇지 않으면 컴파일 에러가 발생한다.
  • 클래스 타입 매개변수 T와는 달리 U는 반드시 자신이 선언된 제네릭 메서드 hasSameDestinationAs에만 한정되어 사용 가능하다.
  • type parameter section(<U extends Boxable>)의 위치는 제네릭 메서드의 리턴 타입 바로 앞이다.
  • 단, 제네릭 메서드가 생성자인 경우엔 리턴 타입이 없므므로 이 경우엔 type parameter section의 위치가 생성자 이름 바로 앞이다.

제네릭 메서드 호출

  • 타입 인자를 . 연산자와 제네릭 메서드 이름 사이에 작성하여 제네릭 메서드의 타입 매개변수에 대해 타입 인자를 전달해준다.
Box<Toy> toyBox = new Box<>("addr1");
Box<Book> bookBox = new Box<>("addr2");
Box<Shoes> shoesBox = new Box<>("addr3");

boolean result1 = toyBox.<Book>hasSameDestinationAs(bookBox);
boolean result2 = toyBox.<Shoes>hasSameDestinationAs(shoesBox);
  • 제네릭 클래스의 타입 매개변수에 대해 전달되는 타입 인자와 마찬가지로 제네릭 메서드의 타입 매개변수에 대해 전달되는 타입 인자도 타입 추론이 가능하다 => 제네릭 메서드 타입 매개변수에 대한 타입 인자 전달 생략 가능
boolean result1 = toyBox.hasSameDestinationAs(bookBox);
boolean result2 = toyBox.hasSameDestinationAs(shoesBox);

제네릭 생성자

  • 박스의 가격을 새로운 멤버로 도입
public class Box<T extends Boxable> {
    private T item;
    private String destAddress; // 배송지 주소    
    private int boxPrice;

    public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
        return destAddress != null 
                    && b != null 
                        && destAddress.equals(b.getDestAddress());
    }    
}
  • 박스에 담을 아이템과 박스의 가격을 한 번에 파라미터로 받을 수 있는 생성자
  • 박스의 가격에 해당하는 값의 타입이 반드시 int가 아니어도 되도록 한다.
public class Box<T extends Boxable> {
    private T item;
    private String destAddress; // 배송지 주소    
    private int boxPrice;

    public <U extends Boxable> boolean hasSameDestinationAs(Box<U> b) {
        return destAddress != null 
                    && b != null 
                        && destAddress.equals(b.getDestAddress());
    }    

    public <U extends Number> Box(T i, U boxPrice){
        this.item = i;
        this.boxPrice = boxPrice.intValue();
    }
}
Box<Toy> toyBox = new <Integer>Box<Toy>(new Toy(), 1230);
Box<Book> bookBox = new <Double>Box<Book>(new Book(), 1300.0)
  • 제네릭 생성자의 타입 매개변수에 대한 타입 인자도 타입 추론이 가능하다.
Box<Toy> toyBox = new Box<Toy>(new Toy(), 1230);
Box<Book> bookBox = new Box<Book>(new Book(), 1300.0)
  • 다이아몬드 연산자 적용
Box<Toy> toyBox = new Box<>(new Toy(), 1230);
Box<Book> bookBox = new Box<>(new Book(), 1300.0)

static 제네릭 메서드

  • 박스를 요소로 하는 리스트를 전달받아 특정 주소를 배송지로 하는 박스를 제거한 리스트를 반환하는 static 메서드 removeByDestination
public class Box<T extends Boxable> {
    private T item;

    ...
    public static <U extends Boxable> List<U> removeByDestination(
                                                List<Box<U>> boxes, 
                                                String destAddress) {
        // 로직
        return removedItems;                                            
    }
}
List<Box<Toy>> toyBoxes = new ArrayList<>();
List<Box<Book>> bookBoxes = new ArrayList<>();

List<Toy> removedToys = Box.<Toy>removeByDestination(toyBoxes, "addr1");
List<Book> bookBoxes = Box.<Book>removeByDestination(boookBoxes, "addr2");

List<Toy> removedToys = Box.removeByDesintation(toyBoxes, "addr1");
List<Book> bookBoxes = Box.removeByDestination(boookBoxes, "addr2");

제네릭스 변성

  1. 공변: 타입 A가 타입 B의 상위 타입일 때, 임의의 타입 생성자 F<>에 대해서 F<A>F<B>의 상위 타입이 되는 경우 공변이라고 한다.(타입의 상하위 관계를 유지시키는 경우라 보면 된다.)
  • 대표적인 공변 예시: 자바 배열(ObjectString의 상위 타입 => Object[]String[]의 상위 타입)
Integer[] intArray = {1, 2, 3};
Number[] numArray = intArray; // 컴파일 OK
intArray = (Integer[])numArray; // 컴파일 OK
  • 배열이 공변이라서 생기는 문제: 버그를 컴파일 타임에 발견하지 못하고 런타임에 에러가 발생하여 발견 가능
Integer[] intArray = {1, 2, 3};
Number[] numArray = intArray; 
numArray[0] = 15.9 // 컴파일 OK but 논리적으로 오류가 있는 버그
// 배열의 요소가 참조하는 실제 인스턴스의 타입은 Integer여야 하는데 
// Double 타입 인스턴스의 참조값이 배열의 요소로 대입된다.
// 런타임에 ArrayStoreException이 발생   
  • 그럼에도 자바가 배열을 공변으로 만든 이유: 자바는 객체지향 패러다임을 따르기 때문에 배열도 다형성이 가능하게끔 만들기 위해
static void swap(Object[] arr, int i, int j) {
    // 로직
}
Integer[] intArray = {1, 2, 3};
String[] strArray = {"자바", "배열", "공변"};
Toy[] toyArray = { ... };

swap(intArray, 1, 2);
swap(strArray, 1, 2);
swap(toyArray, 1, 2);
  1. 반공변: 타입 A가 타입 B의 상위 타입일 때, 임의의 타입 생성자 F<>에 대해서 F<A>F<B>의 하위 타입이 되는 경우 반공변이라고 한다.(타입의 상하위 관계가 뒤집히는 경우라 보면 된다.)
  2. 불공변: 공변적이지도 않고 반공변적이지도 않은 경우 불공변이라고 한다.
  • 자바 제네릭 클래스는 기본적으로 불공변(invariant)이다. 즉, 공변적이지도 않고 반공변적이지도 않다. (ObjectString의 상위 타입이지만 List<Object>List<String>의 상위 타입이 아니다. List<Object>List<String> 간에는 아무런 상하위 관계가 없다.)
List<Integer> intList = new ArrayList<>(List.of(1,2,3));
List<Number> numList = intList; // 컴파일 에러
  • 제네릭 클래스는 기본적으로 불공변이므로 업캐스팅과 다운캐스팅, 심지어는 명시적인 캐스팅까지 불가한 것이 default다.
  • 자바 배열과 달리 자바 제네릭은 불공변으로 만든 이유: 위와 같은 자바 배열의 공변으로 인한 런타임 에러 발생 가능 문제를 방지하기 위해서

변성과 타입 파라미터의 상한 경계 지정은 관련이 없다.

  • Box<T extends Number>로 선언해도 Box<Number>Box<Integer>는 여전히 아무런 상하위 관계가 없다. 즉, 여전히 불공변인 것이다.

제네릭 클래스 타입 계층

  • 제네릭 클래스가 불공변적이라고 해서 제네릭 클래스 자체끼리의 상하위 관계를 정의하지 못하는 것은 아니다.
  • 제네릭 클래스의 변성과 관계없이 동일한 타입 파라미터를 갖는 제네릭 클래스 간의 타입 계층 구현이 가능하다.
interface A<T> { ... }
interface B<U> extends A<U> { ... }
class C<V> extends B<V> { ... } 
class D<W> extends C<W> { ... }
A<Integer> a = new D<Integer>();
B<Integer> b = new D<Integer>();
C<Integer> c = new D<Integer>();

A<Integer> a = new D<>();
B<Integer> b = new D<>();
C<Integer> c = new D<>();

A<Number> y = new D<Integer>(); // 컴파일 에러: 불공변의 영향
  • 타입 파라미터의 개수가 다른 제네릭 클래스 간에도 타입 계층을 구현할 수 있다.
interface A<T> { ... }
class H<J,K> B implements A<J> { ... }
A<Long> a1 = new H<Long, Double>();
A<Long> a2 = new H<Long, String>();
A<Long> a3 = new H<Long, Toy>();
A<Long> a4 = new H<String, Double>(); 
// 컴파일 에러: 변성과 관계없이 Long과 String은 아무런 상하위 관계가 없다.

와일드카드

  • 제네릭 클래스가 기본적으로 불공변이면 제네릭 클래스로는 다형성을 구현할 수 없는 걸까?
  • 제네릭 클래스를 이용해서도 다형성을 구현할 수 있게 하기 위해 추가된 문법이자 기능이 와일드카드이다.
  • 제네릭 클래스는 기본적으로 불공변이지만 와일드카드를 사용하면 공변 혹은 반공변으로 변경이 가능하다. => 다형성을 구현할 수 있다.
  • 자바의 사용 지점 변성: 와일드카드(?)
    • 자바는 코틀린이나 스칼라와 달리 제네릭 클래스의 선언부에서 out/in과 같은 변성 키워드를 붙이지 않는다.
    • 대신 제네릭 클래스를 사용하는 지점에서 와일드카드(?)로 변성을 표기한다.
형태 의미(하위/상위) 대입(서브타이핑) 안전한 연산
List<? extends T> T하위 타입들 List<A>A <: T일 때 대입 가능 읽기 O(최소 T로 읽힘), 쓰기 X(null만)
List<? super T> T상위 타입들 List<B>T <: B일 때 대입 가능 쓰기 O(최소 T를 넣기), 읽기 제한적(Object로만 안전)
List<?> 아무 타입 모든 List<X> 대입 가능 읽기 O, 쓰기 X(null만)
  • List<Object>가 아니라 List<?>를 쓸까?
    • List<Object>는 “정말로 요소가 Object인 리스트”만 허용 → List<String> 대입 불가.
    • List<?>는 “어떤 리스트든” 받되, 타입 안전상 요소 추가는 금지(읽기 전용) → API 인자로 “읽기만 할” 리스트를 받을 때 적합.
void printAll(List<?> xs) {      // 어떤 리스트든 가능
    for (Object x : xs) System.out.println(x);
    // xs.add("x"); // 컴파일 에러: 안전하지 않음
}
  • 와일드카드로 제네릭 클래스를 공변으로 사용(? extends) vs 와일드카드로 제네릭 클래스를 반공변으로 사용(? super)
// 읽어오기에 적합(Producer 역할): extends로 상한 지정, 공변 사용
double sum(List<? extends Number> nums) {
    double s = 0;
    for (Number n : nums) s += n.doubleValue(); // 안전: 최소 Number
    // nums.add(1); // 컴파일 에러: 구체 타입 미정
    return s;
}

// 집어넣기에 적합(Consumer 역할): super로 하한 지정, 반공변 사용
void addInts(List<? super Integer> dst, int n) {
    for (int i = 0; i < n; i++) dst.add(i); 
    // 안전: Integer 또는 그 상위(Number X, Object O)
    // Integer x = dst.get(0); // 컴파일 에러: 읽으면 타입이 Object
}
  • 와일드카드(<?>) vs 타입 파라미터(<T>)
    • 타입 변수 <T>: 여러 파라미터 간 관계(같은 타입이어야 함 등)를 선언 지점에서 모델링.
    • 와일드카드(?): 단일 파라미터의 유연한 수용 범위를 주는 문법(사용 지점 변성).
// 두 리스트의 요소 타입이 ‘같다’를 보장해야 하면 <T>가 필요
public static <T> void move(List<T> src, List<T> dst) { ... }

// ‘읽기 전용 아무 리스트’면 와일드카드가 간결
void printAll(List<?> xs) { ... }

타입 파라미터 네이밍 컨벤션

이름 의미 예시
T type  
E element List<E>, Set<E>
N number  
K V key value Map<K,V>, HashMap<K,V>
R return type Function<T, R>
U,S,... additional types BiFuntion<T, U, R>

제네릭 타입 소거

  • 컴파일 시 제네릭 타입 정보를 최대한 활용해 타입 안전성을 점검한 뒤,
  • 바이트코드에서는 대부분의 제네릭 매개변수/인자 정보를 지워(erase) Object경계(bound) 로 바꿔 넣는 방식.
  • 결과적으로 런타임에는 new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()true.
  • 소거 규칙: 타입 변수의 소거는 “왼쪽 첫 경계”. 경계가 인터페이스뿐이어도 그 첫 인터페이스가 소거 대상입니다. 경계가 없다면 Object.
소스(컴파일 전) 소거 결과(컴파일 후) 설명
List<String> List(raw와 유사) 타입 인자 제거
T T좌측 첫 경계의 소거 (없으면 Object) <T extends Number & Comparable<T>>Number
List<? extends Number> List 상한 와일드카드는 읽기 전용 보장만 컴파일 때 사용
List<? super Integer> List 하한 와일드카드도 런타임엔 사라짐
제네릭 메서드 본문 필요한 곳에 캐스트 삽입 컴파일러가 안전성 증명 후 강제 캐스트 생성
* 타입 소거의 이유    
  1. 이전(JDK 1.5 이전) 바이트코드와의 완전한 호환성을 위해
  2. 런타임에 타입 인자를 보관/검사하는 reified generics를 도입하지 않음
  • 타입 소거의 가시적 현상
  1. 런타임 타입 정보 부재 : 재귀화 가능한(reifiable) 타입만 instanceof 가능: 원시/비제네릭 타입, 원시(raw) 타입, List<?> 등.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
System.out.println(a.getClass() == b.getClass()); // true
// instanceof List<String>  // ❌ 컴파일 에러(비재귀화 타입: non-reifiable)
if (obj instanceof List<?>) { /* OK */ }          // ✅
  1. bridge 메서드 생성
  • 타입 소거 때문에 상위의 소거된 시그니처하위의 구체 시그니처가 달라지면, JVM이 “오버라이드”로 인식하지 못하는데
  • 오버라이딩 시, 반환 타입을 상위 메서드의 반환 타입의 “하위 타입”으로 좁혀서 리턴할 수 있는 규칙인 공변 변환을 보존하기 위해 컴파일러는 bridge 메서드를 생성한다.
class Box<T> { T get() { return null; } }               // 소거 후: Object get()
class StringBox extends Box<String> {
    @Override String get() { return ""; }               // 실제 구현: String get()
    // 컴파일러 생성:
    // public Object get() { return get(); }            
    // 브리지: 상위 시그니처(Object) 맞춤
}
  1. 오버로드 충돌
void f(List<String> xs) {}
void f(List<Integer> xs) {} // ❌ 동일 소거: 둘 다 f(List) 로 충돌