제네릭 클래스와 타입 매개변수
Object로 값을 받는 클래스의 문제를 확인하고, 타입 매개변수를 선언한 제네릭 클래스로 잘못된 값을 컴파일 단계에서 막는 방법과 다이아몬드, raw 타입의 위험을 익힙니다.
게시판 프로그램을 만들다 보면 값 하나를 담아 두었다가 나중에 꺼내 쓰는 작은 상자가 여러 곳에서 필요해집니다. 어떤 곳에는 조회 수를, 어떤 곳에는 게시글 제목을 담습니다. 상자가 하는 일은 넣기(set)와 꺼내기(get)뿐이라서, 담는 값의 타입만 다르고 코드는 똑같습니다. 그렇다면 이 상자를 한 번만 만들어 여러 타입에 쓸 수는 없을까요?
이 글에서는 먼저 지금까지 배운 문법으로 할 수 있는 두 가지 방법을 시험해 봅니다. 타입마다 상자 클래스를 따로 만드는 방법과, Object 하나로 모든 값을 받는 방법입니다. 두 방법이 각각 어디서 막히는지 확인한 다음, 자바가 이 문제를 풀려고 Java 5에서 도입한 제네릭(generics)으로 상자를 다시 만듭니다. 제네릭은 꺾쇠괄호로 코드를 줄여 쓰는 문법이 아니라, '이 상자에 어떤 타입이 들어 있는가'를 컴파일러가 기억하고 검사하게 하는 장치입니다.
타입마다 상자를 만들면 코드가 복제된다
가장 먼저 떠오르는 방법은 담을 타입마다 클래스를 하나씩 만드는 것입니다. 조회 수를 담는 IntegerBox와 제목을 담는 StringBox를 만들면 다음과 같습니다.
class IntegerBox {
private Integer value;
void set(Integer value) { this.value = value; }
Integer get() { return value; }
}
class StringBox {
private String value;
void set(String value) { this.value = value; }
String get() { return value; }
}이 방법은 안전합니다. IntegerBox의 set은 Integer만 받으므로 문자열을 넣으려 하면 컴파일 오류가 나고, get은 처음부터 Integer를 돌려주므로 꺼낼 때 캐스팅할 필요도 없습니다. 문제는 두 클래스가 타입 이름만 빼면 똑같다는 점입니다. 게시글을 담을 상자가 필요하면 PostBox를 또 복사해야 하고, 상자에 기능을 더하거나 버그를 고칠 때는 복사한 클래스를 모두 찾아 똑같이 고쳐야 합니다. 복사본 하나만 다르게 고쳐졌을 때 어떤 일이 생기는지는 다음 글에서 직접 확인합니다.
Object 하나로 합치면 타입 검사가 실행 시점으로 밀린다
복사를 피하는 두 번째 방법은 모든 클래스의 조상인 Object를 쓰는 것입니다. Object 타입 변수에는 어떤 객체든 담을 수 있으므로, 상자 클래스 하나로 조회 수도 제목도 담을 수 있습니다. 다음 프로그램은 조회 수를 담으려던 상자에 실수로 문자열을 넣은 뒤 조회 수를 꺼냅니다.
public final class ObjectBoxCastFailure {
public static void main(String[] args) {
ObjectBox box = new ObjectBox();
box.set(40);
box.set("forty");
Integer viewCount = (Integer) box.get();
System.out.println("viewCount=" + viewCount);
}
private static final class ObjectBox {
private Object value;
void set(Object value) { this.value = value; }
Object get() { return value; }
}
}실행하면 viewCount=로 시작하는 줄은 끝내 출력되지 않고 예외가 납니다. 첫 줄은 예외의 종류와 메시지이고, 둘째 줄은 예외가 난 위치입니다.
Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer (java.lang.String and java.lang.Integer are in module java.base of loader 'bootstrap')
at ObjectBoxCastFailure.main(ObjectBoxCastFailure.java:7)한 줄씩 따라가 보겠습니다. box.set(40)에서 int 값 40은 매개변수 타입 Object에 맞추기 위해 Integer 객체로 박싱되어 들어갑니다. 문자열 역시 Object 변수에 담을 수 있으므로 다음 줄의 box.set("forty")도 문제없이 컴파일되고, 이 문자열이 앞의 40을 덮어씁니다. get()의 반환 타입은 Object라서 조회 수로 쓰려면 (Integer)로 다운캐스팅해야 하는데, 이때 상자에 실제로 들어 있는 것은 String입니다. 업캐스팅과 다운캐스팅에서 본 것처럼 캐스트는 객체를 바꾸지 않고 실행 중에 검사만 하므로, 7번째 줄에서 ClassCastException이 나고 println은 실행되지 않습니다.
여기서 눈여겨볼 점은 오류가 보고된 곳과 실수한 곳이 다르다는 것입니다. 스택 트레이스는 캐스팅한 7번째 줄을 가리키지만, 잘못은 문자열을 넣은 5번째 줄에서 이미 일어났습니다. 이 예제는 두 줄이 붙어 있어 금방 찾을 수 있지만, 실제 프로그램에서는 값을 넣는 코드와 꺼내는 코드가 서로 다른 클래스에 있고 실행되는 시점도 다른 경우가 많습니다. 그러면 예외를 보고도 누가 잘못된 값을 넣었는지 거꾸로 추적해야 합니다.
컴파일러가 이 실수를 막지 못한 이유는 막을 정보가 없었기 때문입니다. set(Object value)라는 선언은 '아무 객체나 받는다'는 뜻이니 문자열을 거부할 이유가 없고, get()이 돌려주는 값도 컴파일러가 아는 한 그저 Object입니다. (Integer) 캐스트는 '이 값은 Integer다'라는 프로그래머의 주장일 뿐이어서, 컴파일러는 그 주장이 맞는지 실행할 때 확인하도록 미뤄 둡니다.
정리하면 타입별 상자는 안전하지만 코드가 복제되고, Object 상자는 코드가 하나지만 타입 검사가 실행 시점으로 밀립니다. 우리가 원하는 것은 둘의 장점을 합친 상자, 즉 구현은 하나이면서 쓰는 곳마다 컴파일러가 타입을 검사해 주는 상자입니다.
타입을 매개변수로 받는 Box<T>
제네릭은 타입을 메서드의 매개변수처럼 다룹니다. 메서드가 값을 매개변수로 받아서 호출할 때마다 다른 값으로 일하듯이, 제네릭 클래스는 타입을 매개변수로 받아서 쓰는 곳마다 다른 타입으로 일합니다. 다음 프로그램은 상자 클래스 Box<T> 하나로 조회 수와 제목을 각각 담습니다.
public final class GenericBoxTypeSafety {
public static void main(String[] args) {
Box<Integer> viewCountBox = new Box<>();
viewCountBox.set(45);
Integer viewCount = viewCountBox.get();
Box<String> titleBox = new Box<>();
titleBox.set("제네릭 질문");
String title = titleBox.get();
System.out.println(title + "=" + viewCount);
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
}제네릭 질문=45클래스 이름 뒤의 <T>가 핵심입니다. class Box<T>는 'Box는 아직 정하지 않은 타입 하나를 받고, 그 타입을 T라고 부른다'는 선언이며, 이 T를 타입 매개변수(type parameter)라고 합니다. 클래스 안에서 T는 보통의 타입 이름처럼 쓰입니다. 그래서 필드 value의 타입도, set의 매개변수 타입도, get의 반환 타입도 모두 T입니다.
T가 실제로 무엇인지는 상자를 쓰는 쪽에서 정합니다. Box<Integer> viewCountBox라고 선언하면, 이 변수로 다루는 상자에서는 T가 모두 Integer인 것으로 검사됩니다. 그래서 viewCountBox.set(45)는 45가 Integer로 박싱되어 통과하고, viewCountBox.get()의 결과는 캐스팅 없이 바로 Integer 변수에 담깁니다. Box<String> titleBox에서는 같은 클래스의 T가 String이 되므로 titleBox.get()은 String을 돌려줍니다. 마지막 줄은 이렇게 꺼낸 제목과 조회 수를 =로 이어 붙여 제네릭 질문=45를 출력합니다.
이때 Box 클래스는 하나뿐입니다. Box<Integer>용 클래스와 Box<String>용 클래스가 따로 만들어지는 것이 아니라, 컴파일러가 하나의 구현을 두고 쓰는 곳마다 다른 타입 정보로 검사합니다. 타입별 상자의 안전성과 Object 상자의 '구현은 하나'라는 장점을 함께 얻은 것입니다.
이제 Object 상자에서 사고를 일으킨 실수를 제네릭 상자에 그대로 해 보겠습니다. 조회 수를 담는 Box<Integer>에 문자열을 넣습니다.
public final class WrongGenericAssignmentFailure {
public static void main(String[] args) {
Box<Integer> box = new Box<>();
box.set("forty");
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
}
}이 파일은 컴파일되지 않습니다. javac lab/WrongGenericAssignmentFailure.java를 실행하면 다음 오류가 납니다.
lab/WrongGenericAssignmentFailure.java:4: error: incompatible types: String cannot be converted to Integer
box.set("forty");
^
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 errorbox는 Box<Integer>이므로 set의 매개변수 타입은 Integer인데, "forty"는 String이라 Integer로 바꿀 수 없다는 뜻입니다. 오류가 가리키는 곳은 문자열을 넣으려 한 4번째 줄, 즉 실수가 일어난 바로 그 자리입니다. Object 상자에서는 실행 중에, 그것도 값을 꺼내는 곳에서 드러나던 문제가 이제는 실행하기도 전에 값을 넣는 곳에서 드러납니다. 끝의 Note 줄은 진단 메시지를 간단하게 줄여 보여 줬다는 안내이니 신경 쓰지 않아도 됩니다.
제네릭의 진짜 가치는 캐스트 몇 글자를 덜 쓰는 데 있지 않고 이 컴파일 오류에 있습니다. 잘못된 값이 처음 들어가려는 순간에 빌드가 멈추므로, 잘못된 값이 프로그램 안을 돌아다니다가 엉뚱한 곳에서 터지는 일이 없습니다. 아래 도표는 지금까지 본 세 프로그램을 나란히 놓고, 각각 어느 단계에서 끝나는지 비교합니다.
잘못된 쓰기는 어느 단계에서 발견되는가
Object의 실행 중 cast 실패·정상 generic 경로·잘못된 set의 컴파일 실패는 독립 사례입니다. 저장/읽기 허가와 최초 실패 시점을 대응 단계로 구분할 수 있습니다.
Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다.
입력 계약 · 발견 시점
잘못된 쓰기는 어느 단계에서 발견되는가
Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다.
| 원문 사례 | 저장과 읽기의 계약 | 실제 결과 또는 진단 |
|---|---|---|
| Object 박스 | set(40) 뒤 set("forty")도 허용합니다.get()의 정적 타입은 Object입니다. | (Integer) box.get()에서 ClassCastException.viewCount= 출력에 도달하지 않습니다. |
| 정상 제네릭 박스 | Box<Integer>는 45를, Box<String>은 제목을 보관합니다.두 get() 결과에 명시적 다운캐스팅이 없습니다. | stdout 한 줄:제네릭 질문=45 |
| 제네릭의 잘못된 입력 | Box<Integer>의 set에 "forty"를 전달합니다. | 컴파일 단계에서 String 인수를 거부합니다.이 프로그램은 실행되지 않습니다. |
- Object 박스
- 저장과 읽기의 계약
set(40)뒤set("forty")도 허용합니다.get()의 정적 타입은Object입니다.- 실제 결과 또는 진단
(Integer) box.get()에서ClassCastException.viewCount=출력에 도달하지 않습니다.
- 정상 제네릭 박스
- 저장과 읽기의 계약
Box<Integer>는 45를,Box<String>은 제목을 보관합니다.두get()결과에 명시적 다운캐스팅이 없습니다.- 실제 결과 또는 진단
- stdout 한 줄:
제네릭 질문=45
- 제네릭의 잘못된 입력
- 저장과 읽기의 계약
Box<Integer>의set에"forty"를 전달합니다.- 실제 결과 또는 진단
- 컴파일 단계에서
String인수를 거부합니다.이 프로그램은 실행되지 않습니다.
Object 원문은 String이 Integer를 덮어쓴 뒤 실패합니다. 제네릭 원문은 각각 독립된 프로그램이며, 잘못된 쓰기와 정상 실행을 한 실행의 연속 단계로 보지 않습니다.
이 예의 조건
- 세 영역은 독립 프로그램이며 컴파일 실패는 실행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 18 | 제네릭 질문 |
| 19 | 45 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 잘못된 쓰기는 어느 단계에서 발견되는가 잘못된 쓰기는 어느 단계에서 발견되는가 Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다. 입력 계약 · 발견 시점 잘못된 쓰기는 어느 단계에서 발견되는가 Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다. 잘못된 쓰기는 어느 단계에서 발견되는가 원문 사례 저장과 읽기의 계약 실제 결과 또는 진단 Object 박스 set(40) 뒤 set("forty") 도 허용합니다. get() 의 정적 타입은 Object 입니다. (Integer) box.get() 에서 ClassCastException . viewCount= 출력에 도달하지 않습니다. 정상 제네릭 박스 Box<Integer> 는 45를, Box<String> 은 제목을 보관합니다. 두 get() 결과에 명시적 다운캐스팅이 없습니다. stdout 한 줄: 제네릭 질문=45 제네릭의 잘못된 입력 Box<Integer> 의 set 에 "forty" 를 전달합니다. 컴파일 단계에서 String 인수를 거부합니다. 이 프로그램은 실행되지 않습니다. Object 박스 저장과 읽기의 계약 set(40) 뒤 set("forty") 도 허용합니다. get() 의 정적 타입은 Object 입니다. 실제 결과 또는 진단 (Integer) box.get() 에서 ClassCastException . viewCount= 출력에 도달하지 않습니다. 정상 제네릭 박스 저장과 읽기의 계약 Box<Integer> 는 45를, Box<String> 은 제목을 보관합니다. 두 get() 결과에 명시적 다운캐스팅이 없습니다. 실제 결과 또는 진단 stdout 한 줄: 제네릭 질문=45 제네릭의 잘못된 입력 저장과 읽기의 계약 Box<Integer> 의 set 에 "forty" 를 전달합니다. 실제 결과 또는 진단 컴파일 단계에서 String 인수를 거부합니다. 이 프로그램은 실행되지 않습니다. Object 원문은 String이 Integer를 덮어쓴 뒤 실패합니다. 제네릭 원문은 각각 독립된 프로그램이며, 잘못된 쓰기와 정상 실행을 한 실행의 연속 단계로 보지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보관 계약 Object 박스와 typed 박스의 쓰기 조건을 둡니다. |
|
|
| 읽기/차단 A는 Integer cast, B는 맞는 타입 읽기, C는 컴파일 차단입니다. |
|
|
| 최종 결과 A의 출력은 없고 B는 정상 결과를 만듭니다. |
|
|
잘못된 쓰기는 어느 단계에서 발견되는가
Object의 실행 중 cast 실패·정상 generic 경로·잘못된 set의 컴파일 실패는 독립 사례입니다. 저장/읽기 허가와 최초 실패 시점을 대응 단계로 구분할 수 있습니다.
Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다.
입력 계약 · 발견 시점
잘못된 쓰기는 어느 단계에서 발견되는가
Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다.
| 원문 사례 | 저장과 읽기의 계약 | 실제 결과 또는 진단 |
|---|---|---|
| Object 박스 | set(40) 뒤 set("forty")도 허용합니다.get()의 정적 타입은 Object입니다. | (Integer) box.get()에서 ClassCastException.viewCount= 출력에 도달하지 않습니다. |
| 정상 제네릭 박스 | Box<Integer>는 45를, Box<String>은 제목을 보관합니다.두 get() 결과에 명시적 다운캐스팅이 없습니다. | stdout 한 줄:제네릭 질문=45 |
| 제네릭의 잘못된 입력 | Box<Integer>의 set에 "forty"를 전달합니다. | 컴파일 단계에서 String 인수를 거부합니다.이 프로그램은 실행되지 않습니다. |
- Object 박스
- 저장과 읽기의 계약
set(40)뒤set("forty")도 허용합니다.get()의 정적 타입은Object입니다.- 실제 결과 또는 진단
(Integer) box.get()에서ClassCastException.viewCount=출력에 도달하지 않습니다.
- 정상 제네릭 박스
- 저장과 읽기의 계약
Box<Integer>는 45를,Box<String>은 제목을 보관합니다.두get()결과에 명시적 다운캐스팅이 없습니다.- 실제 결과 또는 진단
- stdout 한 줄:
제네릭 질문=45
- 제네릭의 잘못된 입력
- 저장과 읽기의 계약
Box<Integer>의set에"forty"를 전달합니다.- 실제 결과 또는 진단
- 컴파일 단계에서
String인수를 거부합니다.이 프로그램은 실행되지 않습니다.
Object 원문은 String이 Integer를 덮어쓴 뒤 실패합니다. 제네릭 원문은 각각 독립된 프로그램이며, 잘못된 쓰기와 정상 실행을 한 실행의 연속 단계로 보지 않습니다.
이 예의 조건
- 세 영역은 독립 프로그램이며 컴파일 실패는 실행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 18 | 제네릭 질문 |
| 19 | 45 |
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | 잘못된 쓰기는 어느 단계에서 발견되는가 잘못된 쓰기는 어느 단계에서 발견되는가 Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다. 입력 계약 · 발견 시점 잘못된 쓰기는 어느 단계에서 발견되는가 Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다. 잘못된 쓰기는 어느 단계에서 발견되는가 원문 사례 저장과 읽기의 계약 실제 결과 또는 진단 Object 박스 set(40) 뒤 set("forty") 도 허용합니다. get() 의 정적 타입은 Object 입니다. (Integer) box.get() 에서 ClassCastException . viewCount= 출력에 도달하지 않습니다. 정상 제네릭 박스 Box<Integer> 는 45를, Box<String> 은 제목을 보관합니다. 두 get() 결과에 명시적 다운캐스팅이 없습니다. stdout 한 줄: 제네릭 질문=45 제네릭의 잘못된 입력 Box<Integer> 의 set 에 "forty" 를 전달합니다. 컴파일 단계에서 String 인수를 거부합니다. 이 프로그램은 실행되지 않습니다. Object 박스 저장과 읽기의 계약 set(40) 뒤 set("forty") 도 허용합니다. get() 의 정적 타입은 Object 입니다. 실제 결과 또는 진단 (Integer) box.get() 에서 ClassCastException . viewCount= 출력에 도달하지 않습니다. 정상 제네릭 박스 저장과 읽기의 계약 Box<Integer> 는 45를, Box<String> 은 제목을 보관합니다. 두 get() 결과에 명시적 다운캐스팅이 없습니다. 실제 결과 또는 진단 stdout 한 줄: 제네릭 질문=45 제네릭의 잘못된 입력 저장과 읽기의 계약 Box<Integer> 의 set 에 "forty" 를 전달합니다. 실제 결과 또는 진단 컴파일 단계에서 String 인수를 거부합니다. 이 프로그램은 실행되지 않습니다. Object 원문은 String이 Integer를 덮어쓴 뒤 실패합니다. 제네릭 원문은 각각 독립된 프로그램이며, 잘못된 쓰기와 정상 실행을 한 실행의 연속 단계로 보지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 보관 계약 Object 박스와 typed 박스의 쓰기 조건을 둡니다. |
|
|
| 읽기/차단 A는 Integer cast, B는 맞는 타입 읽기, C는 컴파일 차단입니다. |
|
|
| 최종 결과 A의 출력은 없고 B는 정상 결과를 만듭니다. |
|
|
더 깊이: 컴파일된 Box는 Object 상자와 닮았다
GenericBoxTypeSafety.java를 컴파일하면 클래스 파일은 GenericBoxTypeSafety.class와 GenericBoxTypeSafety$Box.class 두 개만 생깁니다. 타입 인자마다 클래스가 복제되지 않는다는 뜻입니다. 컴파일러는 타입 검사를 마친 뒤, 실행에 쓰이는 필드와 메서드의 타입에서 T를 지우고 그 자리를 Object로 바꿉니다. 이를 타입 소거(type erasure)라고 합니다. 클래스 선언에 <T>가 있었다는 정보는 리플렉션과 컴파일러를 위한 부가 정보로 남지만, 실행되는 코드는 T를 모릅니다.
대신 컴파일러는 Integer viewCount = viewCountBox.get();처럼 값을 꺼내는 곳에 Integer로의 캐스트를 직접 넣어 둡니다. 그러니 실행되는 코드만 보면 제네릭 상자도 앞의 Object 상자와 거의 같습니다. 차이는 그 캐스트를 누가 넣었고, 넣기 전에 무엇을 검사했느냐입니다. 컴파일러는 set으로 들어오는 값을 미리 검사했으므로, 뒤에서 볼 raw 타입처럼 검사를 건너뛰는 코드가 끼어들지 않으면 이 캐스트가 실패할 일은 없습니다. 타입 소거가 실행 중에 어떤 제약을 남기는지는 타입 소거와 런타임 한계에서 자세히 다룹니다.
제네릭 용어 정리하기
제네릭을 다루는 글이나 컴파일러 메시지에는 비슷해 보이는 용어가 여러 개 나옵니다. 메서드의 매개변수와 인자를 구분하던 방식대로 정리하면 헷갈리지 않습니다. void set(Integer value)의 value는 매개변수(parameter)이고, set(45)로 호출할 때 넘기는 45는 인자(argument)입니다. 제네릭에서도 선언에 쓰는 이름과 사용할 때 채우는 타입을 구분합니다.
| 용어 | 예 | 뜻 |
|---|---|---|
| 타입 매개변수(type parameter) | class Box<T>의 T | 선언할 때 아직 정하지 않은 타입에 붙인 이름 |
| 타입 인자(type argument) | Box<Integer>의 Integer | 사용할 때 타입 매개변수 자리에 채우는 실제 타입 |
| 제네릭 타입(generic type) | class Box<T> | 타입 매개변수를 하나 이상 선언한 클래스나 인터페이스 |
| 매개변수화 타입(parameterized type) | Box<Integer> | 제네릭 타입에 타입 인자를 채운 타입 |
이 비유가 들어맞지 않는 부분도 있습니다. 메서드 인자는 실행할 때 호출마다 넘어가는 값이지만, 타입 인자는 Box<Integer>처럼 코드에 적힌 타입의 일부이고 컴파일러가 검사할 때 씁니다. 앞의 '더 깊이'에서 본 것처럼, 실행 중인 상자 객체는 자기가 Box<Integer>로 만들어졌는지 기억하지 않습니다.
타입 매개변수의 이름은 관례적으로 대문자 한 글자를 씁니다. 가장 흔한 T는 Type의 머리글자이고, 컬렉션의 원소(Element)에는 E, 키와 값(Key, Value)에는 K와 V, 숫자(Number)에는 N, 결과(Result)에는 R을 자주 씁니다. 타입이 둘 이상이면 Pair<K, V>처럼 역할이 드러나는 글자를 고르고, 특별한 역할이 없으면 T 다음에 S, U 같은 글자를 이어 씁니다.
이 관례는 문법 규칙이 아니라서 class Box<Item>처럼 써도 컴파일됩니다. 그래도 한 글자 관례에는 이유가 있습니다. Item은 클래스 이름처럼 보이기 때문에, 읽는 사람은 Item이라는 클래스가 어디 있는지부터 찾게 됩니다. 대문자 한 글자는 '이것은 타입 매개변수'라는 신호가 되어 코드를 빨리 읽게 해 줍니다.
다이아몬드 <>로 타입 인자 줄여 쓰기
앞 예제의 Box<Integer> viewCountBox = new Box<>();를 보면, 오른쪽에는 타입 인자 없이 빈 꺾쇠괄호 <>만 있습니다. 모양 때문에 다이아몬드(diamond)라고 부르는 이 표기는 Java 7부터 쓸 수 있으며, 타입 인자를 문맥에서 알아내라는 뜻입니다. 여기서 문맥은 왼쪽 변수의 타입입니다. 변수가 Box<Integer>이므로 컴파일러는 오른쪽을 new Box<Integer>()로 읽습니다. 이처럼 컴파일러가 코드에 적히지 않은 타입을 문맥에서 알아내는 것을 타입 추론(type inference)이라고 합니다.
다이아몬드는 타입 정보를 없애는 것이 아니라, 같은 정보를 두 번 적지 않게 해 줄 뿐입니다. 그래서 추론할 단서가 없으면 결과가 달라집니다. Java 10부터 쓸 수 있는 var는 지역 변수의 타입을 오른쪽 식에서 정하라고 컴파일러에게 맡기는 선언인데, var와 다이아몬드를 함께 쓰면 왼쪽에도 오른쪽에도 타입 인자가 없게 됩니다. 다음 프로그램은 두 선언을 비교합니다.
public final class DiamondInferenceContext {
public static void main(String[] args) {
Box<String> titles = new Box<>();
titles.set("safe");
var general = new Box<>();
general.set("first");
general.set(7);
Object observed = general.get();
System.out.println("title=" + titles.get());
System.out.println("general=" + observed + ":"
+ observed.getClass().getSimpleName());
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
}title=safe
general=7:Integertitles는 왼쪽에 Box<String>이 적혀 있으므로 String 상자이고, 첫 줄에 넣은 safe가 그대로 나옵니다. 반면 var general = new Box<>();에는 T를 정할 단서가 하나도 없습니다. 이럴 때 컴파일러는 T가 될 수 있는 가장 넓은 타입, 즉 아무 제한이 없는 이 Box에서는 Object로 추론해 general을 Box<Object>로 만듭니다. 그래서 general.set("first")도 general.set(7)도 정상적인 호출이고, 두 번째 값이 첫 번째 값을 덮어씁니다.
둘째 줄의 7:Integer는 마지막에 넣은 7과, 그 값이 실행 중에는 Integer 객체라는 사실을 보여 줍니다. getClass()는 실제 객체의 클래스를 알려 줄 뿐이고, general.get()의 컴파일 타입은 여전히 Object입니다. 그렇다고 general이 타입 검사를 받지 않는 것은 아닙니다. 타입이 Box<Object>로 정해졌으니 컴파일러는 그 타입대로 검사해서, Integer n = general.get();이라고 쓰면 Object cannot be converted to Integer, Box<String> other = general;이라고 쓰면 Box<Object> cannot be converted to Box<String>이라는 컴파일 오류를 냅니다.
문제는 검사의 기준이 너무 넓다는 데 있습니다. 담을 값의 종류를 제한하려던 것이라면 Box<String> titles = new Box<>();처럼 왼쪽에 타입을 적거나, var titles = new Box<String>();처럼 오른쪽에 타입 인자를 적어야 합니다.
기본형 대신 래퍼 클래스를 쓰는 이유
타입 인자에는 참조 타입만 올 수 있습니다. 조회 수 상자를 Box<int>로 선언하면 unexpected type이라는 컴파일 오류가 나고, 참조 타입이 필요한데 int가 왔다는 설명(required: reference, found: int)이 붙습니다. 앞의 예제들이 Box<Integer>를 쓴 이유가 이것입니다. 제네릭 클래스는 T 자리에 언제나 객체가 온다고 보고 만들어지기 때문에(컴파일된 코드에서 T는 Object로 바뀝니다), 객체가 아닌 기본형 값은 T 자리에 들어갈 수 없습니다.
Box<Integer>로 써도 set(45)나 Integer viewCount = box.get();이 자연스럽게 동작하는 것은 래퍼 클래스와 박싱·언박싱에서 배운 오토박싱 덕분입니다. 다만 래퍼의 성질도 함께 따라옵니다. 아무것도 넣지 않은 Box<Integer>의 get()은 필드의 기본값인 null을 돌려주므로, int viewCount = box.get();처럼 기본형 변수로 받으면 언박싱하다가 NullPointerException이 납니다. 반복문 안에서 박싱과 언박싱이 쌓이는 비용도 래퍼를 직접 쓸 때와 똑같습니다.
raw 타입: 타입 인자를 빼면 검사도 빠진다
제네릭 클래스를 타입 인자 없이 Box라고만 쓸 수도 있습니다. 이렇게 쓴 타입을 raw 타입(raw type)이라고 합니다. 우리말로는 비매개변수화 타입이나 원시 타입이라고도 옮기는데, 원시 타입은 int 같은 기본형을 부르는 말과 헷갈리기 쉬워서 이 글에서는 raw 타입이라고 쓰겠습니다.
raw 타입이 남아 있는 이유는 호환성입니다. 제네릭이 생기기 전에 작성된 코드는 타입 인자 없이 클래스를 썼고, 새 컴파일러에서도 그런 코드가 계속 컴파일되도록 타입 인자를 생략하는 것을 허용한 것입니다. 대신 raw 타입을 거치면 컴파일러의 타입 검사도 함께 빠집니다.
public final class RawTypeWarningBoundary {
public static void main(String[] args) {
Box<String> typed = new Box<>();
typed.set("safe");
Box raw = typed;
raw.set(100);
Object observed = raw.get();
System.out.println("runtime-type=" + observed.getClass().getSimpleName());
}
private static final class Box<T> {
private T value;
void set(T value) { this.value = value; }
T get() { return value; }
}
}이 프로그램은 컴파일도 되고 실행도 됩니다. 다만 javac src/RawTypeWarningBoundary.java로 컴파일하면 다음 안내가 함께 나옵니다.
Note: src/RawTypeWarningBoundary.java uses unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.실행하면 예외 없이 한 줄을 출력합니다. 컴파일러의 안내는 경고일 뿐 오류가 아니므로 .class 파일은 정상적으로 만들어지고, 실행도 평소처럼 할 수 있습니다.
runtime-type=IntegerBox raw = typed;는 새 상자를 만들지 않습니다. typed가 가리키는 바로 그 객체를 raw라는 다른 이름으로도 가리키게 할 뿐입니다. raw 타입으로 보면 set의 매개변수가 Object로 취급되므로, raw.set(100)은 String 상자에 Integer 100을 넣는데도 컴파일됩니다. 그 결과 Box<String>으로 만든 상자 안에 Integer가 들어 있게 됩니다. 이 main은 값을 Object 변수로 꺼내 실제 클래스 이름만 출력하므로 runtime-type=Integer가 나오고 정상 종료합니다.
컴파일러가 남긴 안내대로 -Xlint:unchecked 옵션을 붙여 javac -Xlint:unchecked src/RawTypeWarningBoundary.java로 다시 컴파일하면, 어느 줄이 문제인지 알려 줍니다.
src/RawTypeWarningBoundary.java:7: warning: [unchecked] unchecked call to set(T) as a member of the raw type Box
raw.set(100);
^
where T is a type-variable:
T extends Object declared in class Box
1 warningraw 타입 Box의 멤버로 set(T)를 호출했으므로, 컴파일러가 타입을 확인하지 못한(unchecked) 호출이라는 경고입니다. 경고는 오류가 아니어서 클래스 파일은 만들어지지만, 컴파일러가 이 줄의 타입 안전성은 확인할 수 없다고 알려 주는 신호입니다.
문제는 나중에 드러납니다. 이 main의 끝에 String title = typed.get(); 한 줄을 덧붙여 실행하면, 그 줄에서 ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String이 납니다. typed는 Box<String>이므로 컴파일러가 get()의 결과에 String으로의 캐스트를 넣어 두었는데, 실제로 들어 있는 것은 Integer이기 때문입니다. 실수는 raw.set(100)에서 했는데 예외는 엉뚱한 읽기에서 나는, Object 상자의 문제가 그대로 돌아온 것입니다. 아래 도표는 덧붙이기 전의 원래 main이 같은 상자에 쓰고 읽는 과정을 차례로 보여 줍니다.
raw 별칭은 같은 박스의 저장 값을 바꾼다
typed와 raw가 같은 Box를 가리켜 raw의 Integer 쓰기가 타입 계약을 오염시키지만 raw Object 읽기는 성공합니다. 쓰기 전후와 읽기 계약의 차이를 추적합니다.
같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다.
RAW 별칭 · 같은 저장 객체
raw 별칭은 같은 박스의 저장 값을 바꾼다
typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다.
typed.set("safe")- 정적 계약과 저장 값
typed의 타입은Box<String>.저장 값은 문자열"safe".- 관찰 범위
- String 계약으로 검사한 쓰기입니다.
Box raw = typed- 정적 계약과 저장 값
raw는 타입 인자를 생략한Box.새 박스를 만들거나 값을 복사하지 않습니다.- 관찰 범위
- 동일 객체에 raw 접근 경로가 생깁니다.
raw.set(100)- 정적 계약과 저장 값
Integer값 100이 같은 필드를 덮어씁니다.- 관찰 범위
- 컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다.
Object observed = raw.get()- 정적 계약과 저장 값
Object로 받으며 실제 객체는Integer입니다.- 관찰 범위
- stdout 한 줄:
runtime-type=Integer
현재 원문 실행에는 ClassCastException이 없습니다. typed.get()을 String 변수로 받는 코드를 추가하면 삽입된 캐스팅에서 실패하지만, 그 읽기는 이 main의 실행 결과가 아닙니다.
이 예의 조건
- 원 main은 typed.get을 실행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | raw 별칭은 같은 박스의 저장 값을 바꾼다 raw 별칭은 같은 박스의 저장 값을 바꾼다 같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다. RAW 별칭 · 같은 저장 객체 raw 별칭은 같은 박스의 저장 값을 바꾼다 typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다. typed.set("safe") 정적 계약과 저장 값 typed 의 타입은 Box<String> . 저장 값은 문자열 "safe" . 관찰 범위 String 계약으로 검사한 쓰기입니다. Box raw = typed 정적 계약과 저장 값 raw 는 타입 인자를 생략한 Box . 새 박스를 만들거나 값을 복사하지 않습니다. 관찰 범위 동일 객체에 raw 접근 경로가 생깁니다. raw.set(100) 정적 계약과 저장 값 Integer 값 100이 같은 필드를 덮어씁니다. 관찰 범위 컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다. Object observed = raw.get() 정적 계약과 저장 값 Object 로 받으며 실제 객체는 Integer 입니다. 관찰 범위 stdout 한 줄: runtime-type=Integer 현재 원문 실행에는 ClassCastException이 없습니다. typed.get()을 String 변수로 받는 코드를 추가하면 삽입된 캐스팅에서 실패하지만, 그 읽기는 이 main의 실행 결과가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정상 저장 typed String Box에 safe를 넣습니다. |
|
|
| raw 별칭 쓰기 같은 Box를 raw로 보고 100을 저장합니다. |
|
|
| Object 읽기 raw.get을 Object로 받아 정상 관찰합니다. |
|
|
raw 별칭은 같은 박스의 저장 값을 바꾼다
typed와 raw가 같은 Box를 가리켜 raw의 Integer 쓰기가 타입 계약을 오염시키지만 raw Object 읽기는 성공합니다. 쓰기 전후와 읽기 계약의 차이를 추적합니다.
같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다.
RAW 별칭 · 같은 저장 객체
raw 별칭은 같은 박스의 저장 값을 바꾼다
typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다.
typed.set("safe")- 정적 계약과 저장 값
typed의 타입은Box<String>.저장 값은 문자열"safe".- 관찰 범위
- String 계약으로 검사한 쓰기입니다.
Box raw = typed- 정적 계약과 저장 값
raw는 타입 인자를 생략한Box.새 박스를 만들거나 값을 복사하지 않습니다.- 관찰 범위
- 동일 객체에 raw 접근 경로가 생깁니다.
raw.set(100)- 정적 계약과 저장 값
Integer값 100이 같은 필드를 덮어씁니다.- 관찰 범위
- 컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다.
Object observed = raw.get()- 정적 계약과 저장 값
Object로 받으며 실제 객체는Integer입니다.- 관찰 범위
- stdout 한 줄:
runtime-type=Integer
현재 원문 실행에는 ClassCastException이 없습니다. typed.get()을 String 변수로 받는 코드를 추가하면 삽입된 캐스팅에서 실패하지만, 그 읽기는 이 main의 실행 결과가 아닙니다.
이 예의 조건
- 원 main은 typed.get을 실행하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 읽기 정보 | raw 별칭은 같은 박스의 저장 값을 바꾼다 raw 별칭은 같은 박스의 저장 값을 바꾼다 같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다. RAW 별칭 · 같은 저장 객체 raw 별칭은 같은 박스의 저장 값을 바꾼다 typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다. typed.set("safe") 정적 계약과 저장 값 typed 의 타입은 Box<String> . 저장 값은 문자열 "safe" . 관찰 범위 String 계약으로 검사한 쓰기입니다. Box raw = typed 정적 계약과 저장 값 raw 는 타입 인자를 생략한 Box . 새 박스를 만들거나 값을 복사하지 않습니다. 관찰 범위 동일 객체에 raw 접근 경로가 생깁니다. raw.set(100) 정적 계약과 저장 값 Integer 값 100이 같은 필드를 덮어씁니다. 관찰 범위 컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다. Object observed = raw.get() 정적 계약과 저장 값 Object 로 받으며 실제 객체는 Integer 입니다. 관찰 범위 stdout 한 줄: runtime-type=Integer 현재 원문 실행에는 ClassCastException이 없습니다. typed.get()을 String 변수로 받는 코드를 추가하면 삽입된 캐스팅에서 실패하지만, 그 읽기는 이 main의 실행 결과가 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 정상 저장 typed String Box에 safe를 넣습니다. |
|
|
| raw 별칭 쓰기 같은 Box를 raw로 보고 100을 저장합니다. |
|
|
| Object 읽기 raw.get을 Object로 받아 정상 관찰합니다. |
|
|
Java 언어 명세는 raw 타입을 오래된 코드와의 호환을 위해서만 허용한다고 밝히고, 제네릭이 도입된 뒤에 작성하는 코드에서는 쓰지 말라고 강하게 권합니다. 앞으로의 버전에서 raw 타입이 금지될 수도 있다는 말도 덧붙여 두었습니다(JLS §4.8). 새 코드에서 어떤 객체든 담는 상자가 필요하다면 raw Box가 아니라 Box<Object>라고 의도를 적으세요. Box<Object>는 raw 타입과 달리 타입 검사를 그대로 받습니다. 예를 들어 Box<Object> anything = typed;는 Box<String> cannot be converted to Box<Object>라는 컴파일 오류가 나므로, 위 예제처럼 String 상자에 다른 이름을 붙여 엉뚱한 값을 넣는 일이 처음부터 막힙니다.
더 깊이: 오래된 raw 코드와 함께 써야 할 때
javac는 기본 설정에서 unchecked 경고를 파일 단위의 Note 두 줄로만 알려 줍니다. -Xlint:unchecked를 붙이면 경고마다 줄 위치를 보여 주고, -Xlint:rawtypes를 함께 켜면 Box raw = typed;처럼 raw 타입을 쓴 선언 자체도 found raw type: Box라고 짚어 줍니다. 빌드 설정에서 이 경고들을 켜 두면 raw 타입이 새로 끼어드는 순간을 놓치지 않습니다.
그래도 제네릭 이전에 만들어진 라이브러리처럼 raw 타입을 돌려주는 코드를 꼭 써야 할 때가 있습니다. 이럴 때는 그 값을 받는 메서드 한 곳에서 원소마다 instanceof로 실제 타입을 확인한 뒤, 새로 만든 타입 안전한 객체로 옮겨 담습니다. 그러면 raw 타입은 그 메서드 안에만 머물고, 나머지 코드는 평범한 제네릭 코드로 남습니다.
경고를 숨기는 @SuppressWarnings("unchecked")는 왜 안전한지 설명할 수 있는 가장 좁은 범위, 예를 들어 변수 선언 하나나 메서드 하나에만 붙이고 그 이유를 주석으로 적어 둡니다. SuppressWarnings의 API 문서도 이 애노테이션을 효과가 있는 가장 안쪽 요소에 붙이라고 권합니다. 클래스 전체에 붙이면 나중에 생긴 새 경고까지 함께 가려지기 때문입니다.
Box<Dog>는 Box<Animal>이 아니다
Dog가 Animal을 구현한다면 Dog 객체는 Animal 변수에 담을 수 있습니다. 그렇다면 Box<Dog>도 Box<Animal> 변수에 담을 수 있을까요? 답은 '아니요'입니다.
Box<Dog> dogBox = new Box<>();
Box<Animal> animalBox = dogBox; // 컴파일 오류error: incompatible types: Box<Dog> cannot be converted to Box<Animal>이 대입이 허용된다고 가정하고 따라가 보면 이유가 보입니다. animalBox의 set은 Animal을 받으므로 animalBox.set(new Cat())이 컴파일됩니다. 그런데 animalBox와 dogBox는 같은 상자이므로, 이제 dogBox.get()은 Dog를 돌려준다고 선언되어 있으면서 실제로는 Cat을 꺼내게 됩니다. raw 타입에서 본 문제가 다시 생기는 것입니다. 그래서 자바는 Dog와 Animal의 관계와 상관없이 Box<Dog>와 Box<Animal>을 서로 관계없는 타입으로 다루는데, 이런 성질을 불공변(invariant)이라고 합니다.
그렇다고 개 상자와 고양이 상자를 함께 받는 메서드를 만들 수 없는 것은 아닙니다. 여러 매개변수화 타입을 안전하게 받는 방법은 와일드카드를 다루는 글에서 배웁니다.
더 깊이: 배열은 반대쪽을 택했다
배열은 제네릭과 반대로 동작합니다. Dog[]는 Animal[] 변수에 대입할 수 있는데, 이런 성질을 공변(covariant)이라고 합니다. 대신 그렇게 대입한 배열에 Cat을 넣으면 컴파일은 되지만, 실행 중에 JVM이 배열의 실제 원소 타입을 확인하고 ArrayStoreException을 던집니다. 배열은 대입을 허용하고 실행 중에 검사하는 쪽을, 제네릭은 대입을 막아 컴파일할 때 문제를 잡는 쪽을 택한 것입니다. 두 방식이 한 코드에서 만날 때 생기는 제약은 타입 소거와 런타임 한계에서 다룹니다.
제네릭으로 만들 만한 클래스
제네릭이 잘 맞는 것은 담는 값의 타입만 다르고 하는 일은 같은 클래스입니다. 이 글의 상자처럼 값을 보관하고 꺼내는 방법이 타입과 상관없다면, 타입 매개변수 하나로 모든 타입을 처리할 수 있습니다. 반대로 타입마다 동작과 지켜야 할 규칙이 크게 다르다면, 하나의 제네릭 클래스에 억지로 합치기보다 타입마다 자기 클래스를 두는 편이 낫습니다. 타입 매개변수가 너무 많아서 쓰는 사람이 매개변수 사이의 관계를 이해하기 어렵다면 더 작은 타입으로 나누는 것도 방법입니다.
정리
Object로 값을 받는 클래스는 구현이 하나지만, 잘못 넣은 값을 컴파일러가 막지 못하고 꺼낼 때의 캐스팅에서 실행 중에 실패합니다. 타입마다 클래스를 만들면 안전하지만 코드가 복제됩니다.- 제네릭 클래스는
class Box<T>처럼 타입 매개변수를 선언하고, 사용하는 쪽이Box<Integer>처럼 타입 인자를 정합니다. 구현은 하나이고 쓰는 곳마다 컴파일러가 타입을 검사하므로, 잘못된 값은 넣는 줄에서 컴파일 오류가 됩니다. - 다이아몬드
<>는 타입 인자를 문맥에서 추론하게 합니다.var와 함께 쓰면 단서가 없어Box<Object>가 되므로, 이때는 오른쪽에 타입 인자를 적습니다. - 타입 인자에는 참조 타입만 올 수 있습니다.
int대신Integer를 쓰고,null언박싱에 주의합니다. - raw 타입은 오래된 코드와의 호환을 위해 남은 표기로, 타입 검사를 건너뜁니다. unchecked 경고를 무시하지 말고, 아무 객체나 담으려면
Box<Object>를 씁니다. Dog가Animal의 하위 타입이어도Box<Dog>는Box<Animal>의 하위 타입이 아닙니다.
연습 문제
-
두 값을 묶는
Pair<F, S>를 작성하세요. 첫 값과 둘째 값의 타입은 서로 달라도 됩니다. 생성자에서 두 값을 받고first()와second()로 읽게 하며, 만든 뒤에는 값을 바꿀 수 없어야 합니다. 게시글 제목과 조회 수("제네릭 질문"과 50), 회원 이메일과 활성 상태("[email protected]"과true)를 각각 묶어first=second형식으로 한 줄씩 출력하세요. 다 만들었다면 같은 구현으로Pair<Integer, String>에 7과"draft"를 넣을 때first()와second()의 반환 타입과 출력을 예상해 보세요. -
다음 코드에서 ①부터 ⑤까지의 각 줄이 컴파일 오류인지, 경고만 나는지, 문제없이 컴파일되는지 예상하세요.
Box는 이 글의Box<T>와 같습니다.Box<String> a = new Box<>(); a.set(10); // ① var b = new Box<>(); b.set(10); // ② Integer n = b.get(); // ③ Box c = a; c.set(10); // ④ Box<Object> d = a; // ⑤ -
Dog와Cat이 모두Animal을 구현한다고 합시다. 자바가Box<Dog>를Box<Animal>변수에 대입하도록 허용한다면 어떤 코드가 문제를 일으킬지 네 줄 안팎으로 써 보고, 문제가 어디서 드러날지 설명하세요.
해설 보기
1번. 레코드로 쓰면 생성자, 읽기 메서드, final 필드를 컴파일러가 만들어 주므로 짧게 끝납니다.
public final class GenericPairExercise {
public static void main(String[] args) {
Pair<String, Integer> post = new Pair<>("제네릭 질문", 50);
Pair<String, Boolean> member = new Pair<>("[email protected]", true);
System.out.println(post.first() + "=" + post.second());
System.out.println(member.first() + "=" + member.second());
}
private record Pair<F, S>(F first, S second) { }
}제네릭 질문=50
[email protected]=trueF와 S는 서로 독립된 타입 매개변수라서 Pair<String, Integer>와 Pair<String, Boolean>처럼 각각 다른 타입으로 채울 수 있고, 꺼낼 때 캐스팅할 필요도 없습니다. 같은 구현으로 만든 Pair<Integer, String>이라면 first()는 Integer, second()는 String을 돌려주고 출력은 7=draft입니다.
레코드의 필드는 final이고 setter가 없으므로 만든 뒤에 다른 값을 대입할 수 없습니다. 다만 F나 S 자리에 내용을 바꿀 수 있는 객체가 들어오면, 레코드가 그 객체의 내부까지 막아 주지는 않습니다. 이 연습의 String, Integer, Boolean은 모두 불변이라 그 차이가 드러나지 않을 뿐입니다. 두 값에 이름 붙일 만한 의미가 생기고 지켜야 할 규칙이 늘어나면, 범용 Pair보다 PostStat(String title, int viewCount)처럼 이름 있는 레코드를 만드는 편이 읽기 좋습니다.
2번. ①은 컴파일 오류입니다. a는 Box<String>이므로 set은 String만 받고, int 10은 int cannot be converted to String 오류가 납니다. ②는 문제없습니다. b는 var와 다이아몬드 때문에 Box<Object>가 되었고, 10은 Integer로 박싱되어 Object 자리에 들어갑니다.
③은 컴파일 오류입니다. b.get()의 타입은 Object라서 Object cannot be converted to Integer 오류가 납니다. 방금 넣은 값이 정수라는 사실은 컴파일러가 따지지 않습니다.
④는 경고만 납니다. raw 타입 c로 set을 호출했으므로 unchecked 경고가 나지만 컴파일은 되고, String 상자에 10이 들어갑니다. -Xlint:rawtypes를 켰다면 바로 윗줄의 Box c = a;에도 raw 타입 경고가 붙습니다.
⑤는 컴파일 오류입니다. 제네릭은 불공변이라 Box<String>을 Box<Object>에 대입할 수 없습니다. ④와 ⑤를 비교하면, 아무 값이나 담으려 할 때 raw 타입이 아니라 Box<Object>를 써야 하는 이유가 드러납니다.
3번. 다음 네 줄이면 충분합니다.
Box<Dog> dogBox = new Box<>();
Box<Animal> animalBox = dogBox; // 허용된다고 가정
animalBox.set(new Cat()); // Animal 상자이므로 통과
Dog dog = dogBox.get(); // 실제로는 Cat이 나온다셋째 줄은 Animal 상자에 Animal을 넣는 평범한 호출이라 컴파일러가 막을 이유가 없습니다. 그런데 두 변수가 같은 상자를 가리키므로, 넷째 줄의 dogBox.get()은 Cat을 꺼냅니다. 컴파일러가 이 줄에 넣어 둔 Dog 캐스트가 실행 중에 실패해 ClassCastException이 나고, 오류 위치는 실제 실수가 있었던 둘째나 셋째 줄이 아니라 넷째 줄이 됩니다. Object 상자에서 본 것과 같은 문제이므로, 자바는 처음부터 둘째 줄을 컴파일 오류로 막습니다.
다음 글
타입별 클래스 중복을 제네릭으로 줄이기에서는 개 병원과 고양이 병원처럼 타입마다 복사한 클래스가 시간이 지나며 어떻게 서로 다른 규칙을 갖게 되는지 확인하고, 공통 알고리즘을 제네릭 클래스 하나로 모으는 방법을 살펴봅니다.