개발 학습 실습실

이 사이트의 실습은 자바스크립트를 켜야 움직입니다. 아래는 같은 내용을 글로 적은 것입니다.

공부 › Java › 제네릭, 열거형, 애너테이션

제네릭

List<Integer>의 <Integer>는 무엇을 막아 주나

제네릭은 List<Integer>처럼 "이 리스트에 무엇을 담는지"를 타입에 적어 두는 문법입니다. 적어 두면 잘못된 타입을 넣는 코드를 컴파일러가 실행 전에 막아 줍니다. 적지 않으면 그 코드가 그대로 컴파일되고, 한참 뒤 값을 꺼내는 자리에서 ClassCastException이 납니다.

실행으로 대조 · JDK 21에서 스무 가지 조합을 실제로 컴파일하고 실행해 대조

이 실습에 나오는 개념

제네릭
클래스나 메서드가 다룰 타입을 매개변수로 받는 기능이다. List<String>처럼 타입을 지정하면 컴파일 시점에 타입이 검사되어 잘못된 타입을 넣는 실수를 막고 형 변환 코드가 필요 없다. 타입 정보는 컴파일 후 지워지므로(타입 소거) 실행 중에는 List<String>과 List<Integer>를 구분할 수 없다.
로 타입(raw type)
List<String>처럼 타입을 적을 수 있는 제네릭 클래스를 <...> 없이 List라고만 쓴 것이다. 제네릭이 없던 Java 5 이전의 코드와 함께 쓸 수 있도록 남겨 둔 방식이다. 컴파일러는 로 타입 변수에 무엇을 넣는지 검사하지 못하고 unchecked 경고만 내므로, List<Integer>가 가리키는 리스트에 String을 넣는 코드도 컴파일된다. 그 잘못은 나중에 값을 꺼내는 자리에서 ClassCastException으로 드러난다. 새로 쓰는 코드에서는 쓰지 않으며, 원소 타입을 모를 때는 List<?>를 쓴다.
타입 소거
컴파일러가 제네릭의 타입 표시(List<Integer>의 Integer)를 컴파일할 때의 검사에만 쓰고, 실행되는 코드에는 남기지 않는 것이다. 그래서 실행 중에는 List<String>과 List<Integer>가 같은 클래스의 객체이고, 리스트 객체는 자신이 어떤 타입을 담기로 했는지 기억하지 않는다. 값을 꺼내는 자리에는 컴파일러가 형 변환 코드를 대신 넣어 둔다. 이 때문에 실행 중에 리스트의 원소 타입을 물어볼 수 없고, new T()처럼 타입 매개변수로 객체를 만들 수도 없다. 로 타입으로 컴파일러의 검사를 피해 잘못된 값을 넣으면 실행 중에는 막아 줄 것이 없다.
와일드카드(? extends와 ? super)
제네릭 타입의 <> 안에 ?를 써서 "어떤 타입인지 정해 두지 않았다"는 것을 나타내는 문법이다. List<? extends Number>는 Number나 그 하위 타입의 리스트를 모두 받을 수 있고, 꺼낸 값을 Number로 쓸 수 있지만 값을 넣을 수는 없다. List<? super Integer>는 Integer나 그 상위 타입의 리스트를 모두 받을 수 있고, Integer를 넣을 수 있지만 꺼낸 값은 Object로만 받을 수 있다. List<Integer>를 List<Number> 변수에 대입할 수 없기 때문에, 여러 타입의 리스트를 받는 메서드를 만들 때 필요하다. 값을 꺼내 쓰기만 하는 매개변수에는 extends를, 값을 넣기만 하는 매개변수에는 super를 쓴다.

이렇게 바꿔 보세요

처음 설정으로 실행하면

  1. List<Integer> ints를 만들고 1과 2를 넣습니다: <Integer>는 "이 리스트에는 Integer만 담는다"는 표시입니다. 컴파일러는 이 표시를 보고 ints에 넣고 꺼내는 코드를 검사합니다.
  2. List list = ints 검사: 같은 리스트를 <...> 표시 없이 선언한 변수 list로도 가리키게 합니다. 이렇게 제네릭 없이 쓰는 것을 로 타입이라고 합니다. 제네릭이 없던 시절의 코드와 함께 쓸 수 있도록 남겨 둔 방식이어서 대입은 컴파일됩니다. 그 대신 컴파일러는 list에 무엇을 넣는지 검사하지 못합니다.
  3. list.add("삼") 검사: list가 로 타입이라 컴파일러는 넣는 값의 타입을 확인할 수 없습니다. 오류가 아니라 경고만 냅니다("unchecked call to add(E) as a member of the raw type List"). Integer 리스트에 String을 넣는 코드인데도 컴파일됩니다.
  4. 컴파일에 성공했습니다: 경고는 컴파일을 막지 않습니다. 이제 프로그램을 실행합니다.
  5. list.add("삼") 실행: 실행 중의 리스트 객체는 자기가 Integer용이라는 것을 기억하지 않습니다. 넣을 때는 아무 검사도 하지 않으므로 String 값(삼)이 그대로 들어갑니다. list와 ints는 같은 객체이므로, Integer만 담기로 한 ints 안에 String이 들어간 것입니다.
  6. System.out.println(ints) 실행: 리스트의 내용을 출력합니다. 출력은 [1, 2, 삼]입니다. 출력만 할 때는 원소가 무슨 타입인지 따지지 않으므로 아직 아무 일도 일어나지 않습니다.
  7. ints.getClass() == strs.getClass() 실행: true를 출력합니다. List<Integer>와 List<String>은 실행 중에는 똑같은 ArrayList 클래스의 객체입니다. <Integer>와 <String>은 컴파일할 때의 검사에만 쓰이고 실행 코드에는 남지 않습니다(타입 소거).
  8. Integer last = ints.get(ints.size() - 1) 실행: ClassCastException이 납니다. ints는 List<Integer>이므로 컴파일러가 이 줄에 "꺼낸 값을 Integer로 바꾸는" 형 변환을 넣어 두었습니다. 그런데 실제로 나온 값은 String입니다. 잘못 넣은 줄이 아니라 한참 뒤 꺼내는 줄에서 터지므로 원인을 찾기 어렵습니다.

실행 결과

코드

List<Integer> ints = new ArrayList<>();
ints.add(1);
ints.add(2);

List list = ints;
list.add("삼");

System.out.println(ints);
List<String> strs = new ArrayList<>();
System.out.println(ints.getClass() == strs.getClass());
Integer last = ints.get(ints.size() - 1);
System.out.println("마지막 값: " + last);

이 모형의 전제

스무 가지 조합 모두를 이 PC의 JDK 21에서 화면의 코드를 main 메서드 안에 넣은 소스로 실제로 컴파일하고(javac -Xlint:unchecked), 컴파일된 것은 실행해서 컴파일 여부, 오류가 난 줄과 메시지, 경고 메시지, 출력, 예외의 종류와 예외가 난 줄을 대조했습니다. 컴파일러 메시지는 명령줄 javac가 보여 주는 모양(클래스 이름만, 정체 모를 타입은 CAP#1)으로 적었습니다. 화면의 코드에는 import java.util.ArrayList, import java.util.List와 main 메서드의 선언이 생략되어 있습니다. -Xlint:unchecked 없이 컴파일하면 경고의 자세한 내용 대신 "uses unchecked or unsafe operations"라는 안내만 나옵니다. ? extends로 선언한 리스트에도 null은 넣을 수 있지만 모형에서는 다루지 않았습니다. 타입 소거가 일어나도 필드나 메서드의 선언에 적힌 제네릭 정보는 class 파일에 남아 리플렉션으로 읽을 수 있습니다. 지워지는 것은 실행 중인 객체가 가진 정보입니다.

앞 실습: 체크 예외와 언체크 예외 · 다음 실습: 열거형(enum)

이 실습에 나오는 개념

이 실습에서 보는 것

설정

코드


      
    

그림

실행 비교

이렇게 바꿔 보세요