본문 바로가기

Object 박스와 제네릭 타입

타입별 박스 중복과 Object 저장소의 ClassCastException을 재현하고 타입 매개변수·인자·추론·비매개변수화 타입을 컴파일 계약으로 정리합니다.

제네릭은 꺾쇠 문법을 줄여 쓰는 기능이 아니라 “이 컨테이너가 어떤 타입을 보관하는가”를 컴파일러가 추적하게 하는 장치입니다.

타입별 클래스를 복사하면 중복이 늘고, Object 하나로 합치면 잘못 넣은 값 때문에 나중에 캐스팅에서 실패합니다.

두 실패를 모두 피하는 과정을 JDK 25 컴파일과 실행으로 확인합니다.


Object 박스의 타입 오류

Object는 모든 참조 타입을 담을 수 있으므로 숫자용으로 생각한 박스에도 문자열을 저장할 수 있습니다.

get의 반환도 Object라 호출자가 원하는 타입으로 다운캐스팅해야 합니다.

lab/ObjectBoxCastFailure.java
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; }
    }
}
실행 결과의 핵심
java.lang.ClassCastException:
class java.lang.String cannot be cast to class java.lang.Integer

오류 원인은 캐스팅 문장에 보이지만 실제 잘못은 앞선 set에서 시작됐습니다.

두 지점이 멀수록 스택 추적만으로 누가 잘못된 값을 넣었는지 찾기 어렵습니다.

타입 안전성이 런타임까지 미뤄졌다는 뜻입니다.

타입별 IntegerBox, StringBox, PostBox를 만들면 잘못 넣는 문제는 막지만 필드·setter·getter가 똑같이 반복됩니다.

새 타입마다 클래스를 하나 더 만들고 버그 수정도 여러 파일에 적용해야 합니다.


타입 매개변수와 다중 계약

클래스 선언의 <T>는 아직 정해지지 않은 타입 매개변수입니다.

사용할 때 Box<Integer>처럼 타입 인자를 전달하면 set과 get의 T가 모두 Integer인 것으로 검사됩니다.

실제 코드 복사본을 여러 개 만드는 것이 아니라 컴파일러가 한 구현에 전달된 타입 정보를 적용합니다.

src/GenericBoxTypeSafety.java
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

호출자는 다운캐스팅하지 않습니다.

Box<Integer>에는 String을 넣을 수 없고 get()은 Integer를 반환합니다.

잘못된 값은 저장 지점에서 즉시 컴파일 오류가 되므로 원인과 진단 위치가 가까워집니다.

lab/WrongGenericAssignmentFailure.java
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 핵심 진단
error: incompatible types: String cannot be converted to Integer

제네릭의 가치는 캐스팅 글자 수보다 이 컴파일 실패에 있습니다.

값이 잘못 들어간 최초 지점에서 빌드를 멈춥니다.

잘못된 쓰기는 어느 단계에서 발견되는가

Object의 실행 중 cast 실패·정상 generic 경로·잘못된 set의 컴파일 실패는 독립 사례입니다. 저장/읽기 허가와 최초 실패 시점을 대응 단계로 구분할 수 있습니다.

잘못된 쓰기는 어느 단계에서 발견되는가

Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다.

입력 계약 · 발견 시점

잘못된 쓰기는 어느 단계에서 발견되는가

Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다.

보관 계약과 실패 단계의 비교 독립된 세 원문을 비교합니다. Object 박스의 String을 Integer로 캐스팅하면 실행 중 실패합니다. 타입에 맞게 String과 Integer를 넣은 제네릭 원문은 제네릭 질문=45를 출력합니다. Integer 박스에 String을 넣으려는 원문은 컴파일 단계에서 실패합니다. 실선은 각 영역 안의 단계 관계입니다. 원문 A · Object 박스 타입 없는 보관 String을 set Object로 읽기 Integer 캐스팅 String을 Integer로 바꾸지 못함 실행 실패 ClassCastException println에 도달 못 함 원문 B · 제네릭 정상 타입 인자 지정 Box<String> Box<Integer> 타입에 맞게 넣기 제네릭 질문 / 45 정상 출력 제네릭 질문=45 원문 C · 잘못된 대입 정수 박스 계약 Box<Integer> String 넣기 시도 set에 문자열 전달 컴파일 실패 String → Integer 불가 실행이 시작되지 않음 영역마다 별도 원문 프로그램 실선: 그 프로그램의 진행 컴파일 실패와 실행 실패 구별
보관 계약과 실패 단계의 비교 독립된 세 원문을 비교합니다. Object 박스의 String을 Integer로 캐스팅하면 실행 중 실패합니다. 타입에 맞게 String과 Integer를 넣은 제네릭 원문은 제네릭 질문=45를 출력합니다. Integer 박스에 String을 넣으려는 원문은 컴파일 단계에서 실패합니다. 실선은 각 영역 안의 단계 관계입니다. 원문 A · Object 박스 타입 없는 보관 String을 set Object로 읽기 Integer 캐스팅 String을 Integer로 바꾸지 못함 실행 실패 ClassCastException println에 도달 못 함 원문 B · 제네릭 정상 타입 인자 지정 Box<String> Box<Integer> 타입에 맞게 넣기 제네릭 질문 / 45 정상 출력 제네릭 질문=45 원문 C · 잘못된 대입 정수 박스 계약 Box<Integer> String 넣기 시도 set에 문자열 전달 컴파일 실패 String → Integer 불가 실행이 시작되지 않음 영역마다 별도 원문 프로그램 실선: 그 프로그램의 진행 컴파일 실패와 실행 실패 구별
잘못된 쓰기는 어느 단계에서 발견되는가
원문 사례저장과 읽기의 계약실제 결과 또는 진단
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제네릭 질문
1945
원 도표의 설명과 주석
구분내용
전체 읽기 정보잘못된 쓰기는 어느 단계에서 발견되는가 잘못된 쓰기는 어느 단계에서 발견되는가 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: Object에 String
  • B: String/Integer에 제네릭 질문/45
  • C: Integer에 String 시도
  • 정적 계약이 잘못된 쓰기를 미리 거절할 수 있다.
읽기/차단

A는 Integer cast, B는 맞는 타입 읽기, C는 컴파일 차단입니다.

  • A: String→Integer cast
  • B: 타입에 맞는 get
  • C: 실행 시작 안 됨
  • 실패 검출 단계가 계약에 따라 달라진다.
최종 결과

A의 출력은 없고 B는 정상 결과를 만듭니다.

  • A: ClassCastException
  • B: 제네릭 질문=45
  • C: 컴파일 실패
  • 실행 중 실패와 실행 전 실패를 같은 사건으로 합치지 않는다.

잘못된 쓰기는 어느 단계에서 발견되는가

Object의 실행 중 cast 실패·정상 generic 경로·잘못된 set의 컴파일 실패는 독립 사례입니다. 저장/읽기 허가와 최초 실패 시점을 대응 단계로 구분할 수 있습니다.

잘못된 쓰기는 어느 단계에서 발견되는가

Object 박스의 늦은 Integer 캐스팅 실패, 정상 Integer와 String 제네릭 박스, 잘못된 String 입력의 컴파일 거부를 각 원문의 실제 결과와 비교한다.

입력 계약 · 발견 시점

잘못된 쓰기는 어느 단계에서 발견되는가

Object 박스의 늦은 캐스팅 실패, 정상 제네릭 사용, 잘못된 입력의 컴파일 거부를 서로 다른 프로그램으로 비교합니다.

보관 계약과 실패 단계의 비교 독립된 세 원문을 비교합니다. Object 박스의 String을 Integer로 캐스팅하면 실행 중 실패합니다. 타입에 맞게 String과 Integer를 넣은 제네릭 원문은 제네릭 질문=45를 출력합니다. Integer 박스에 String을 넣으려는 원문은 컴파일 단계에서 실패합니다. 실선은 각 영역 안의 단계 관계입니다. 원문 A · Object 박스 타입 없는 보관 String을 set Object로 읽기 Integer 캐스팅 String을 Integer로 바꾸지 못함 실행 실패 ClassCastException println에 도달 못 함 원문 B · 제네릭 정상 타입 인자 지정 Box<String> Box<Integer> 타입에 맞게 넣기 제네릭 질문 / 45 정상 출력 제네릭 질문=45 원문 C · 잘못된 대입 정수 박스 계약 Box<Integer> String 넣기 시도 set에 문자열 전달 컴파일 실패 String → Integer 불가 실행이 시작되지 않음 영역마다 별도 원문 프로그램 실선: 그 프로그램의 진행 컴파일 실패와 실행 실패 구별
보관 계약과 실패 단계의 비교 독립된 세 원문을 비교합니다. Object 박스의 String을 Integer로 캐스팅하면 실행 중 실패합니다. 타입에 맞게 String과 Integer를 넣은 제네릭 원문은 제네릭 질문=45를 출력합니다. Integer 박스에 String을 넣으려는 원문은 컴파일 단계에서 실패합니다. 실선은 각 영역 안의 단계 관계입니다. 원문 A · Object 박스 타입 없는 보관 String을 set Object로 읽기 Integer 캐스팅 String을 Integer로 바꾸지 못함 실행 실패 ClassCastException println에 도달 못 함 원문 B · 제네릭 정상 타입 인자 지정 Box<String> Box<Integer> 타입에 맞게 넣기 제네릭 질문 / 45 정상 출력 제네릭 질문=45 원문 C · 잘못된 대입 정수 박스 계약 Box<Integer> String 넣기 시도 set에 문자열 전달 컴파일 실패 String → Integer 불가 실행이 시작되지 않음 영역마다 별도 원문 프로그램 실선: 그 프로그램의 진행 컴파일 실패와 실행 실패 구별
잘못된 쓰기는 어느 단계에서 발견되는가
원문 사례저장과 읽기의 계약실제 결과 또는 진단
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제네릭 질문
1945
원 도표의 설명과 주석
구분내용
전체 읽기 정보잘못된 쓰기는 어느 단계에서 발견되는가 잘못된 쓰기는 어느 단계에서 발견되는가 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: Object에 String
  • B: String/Integer에 제네릭 질문/45
  • C: Integer에 String 시도
  • 정적 계약이 잘못된 쓰기를 미리 거절할 수 있다.
읽기/차단

A는 Integer cast, B는 맞는 타입 읽기, C는 컴파일 차단입니다.

  • A: String→Integer cast
  • B: 타입에 맞는 get
  • C: 실행 시작 안 됨
  • 실패 검출 단계가 계약에 따라 달라진다.
최종 결과

A의 출력은 없고 B는 정상 결과를 만듭니다.

  • A: ClassCastException
  • B: 제네릭 질문=45
  • C: 컴파일 실패
  • 실행 중 실패와 실행 전 실패를 같은 사건으로 합치지 않는다.

제네릭 용어의 구분

class Box<T>에서 T는 선언 측의 타입 매개변수입니다.

Box<Post>에서 Post는 사용 측의 타입 인자입니다.

타입 인자가 적용된 Box<Post>는 매개변수화 타입이고, <T>를 가진 Box 선언은 제네릭 타입입니다.

이름은 보통 T(Type), E(Element), K(Key), V(Value), R(Result)을 사용합니다.

의미가 둘 이상이면 Pair<K, V>처럼 역할을 드러냅니다.

관례는 짧은 코드를 읽기 위한 공통 언어이지 반드시 한 글자여야 한다는 문법 규칙은 아닙니다.

생성식 오른쪽의 타입 인자는 왼쪽 변수 선언과 인수 문맥에서 추론할 수 있어 new Box<>()로 생략할 수 있습니다.

이것이 타입 추론이며, 다이아몬드가 타입 자체를 없애는 것이 아니라 컴파일러가 같은 정보를 계산한다는 뜻입니다.

var box = new Box<>()처럼 양쪽에 근거가 없으면 Object로 추론될 수 있어 의도한 계약이 약해집니다.

두 선언을 같은 작은 프로그램에서 비교하면 추론의 근거가 보입니다. 왼쪽에 String을 선언한 박스는 String 계약을 유지합니다. 반면 아래 var 선언의 생성식에는 특정 타입을 정할 인수나 목표 타입이 없으므로 Box<Object>로 추론됩니다. 이것은 raw 박스가 아니며 Object라는 타입 인자가 정해진 박스입니다.

예시

src/DiamondInferenceContext.java
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:Integer

문자열 뒤 정수를 넣는 것이 이 general 박스에서는 정상 계약입니다. 값의 종류를 제한하고 싶다면 왼쪽 타입 또는 생성식의 타입 인자를 명시해야 합니다. 런타임 클래스 이름을 출력하는 일은 이미 정해진 컴파일 타입을 바꾸지 않습니다.

기본형은 타입 인자로 사용할 수 없으므로 Box<int>가 아니라 Box<Integer>를 씁니다.

오토 박싱이 입출력을 돕지만 null 가능성과 언박싱 비용은 래퍼 계약 그대로 남습니다.


비매개변수화 타입의 제한

Box box = new Box()처럼 타입 인자를 생략한 비매개변수화 타입(raw type)은 제네릭 이전 코드와의 호환을 위해 남아 있습니다.

내부적으로 타입 검사가 약해져 다시 Object 박스와 비슷한 위험이 생깁니다.

src/RawTypeWarningBoundary.java
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; }
    }
}
runtime-type=Integer

컴파일러는 unchecked 경고를 내지만 호환 때문에 빌드를 허용할 수 있습니다.

이후 typed.get()을 String으로 받으면 컴파일러가 삽입한 캐스팅에서 ClassCastException이 납니다.

경고를 무시하지 말고 raw API 입구에서 검증·복사해 타입 안전 영역으로 옮깁니다.

단순히 모든 값을 담고 싶다면 raw가 아니라 Box<Object>로 의도를 명시합니다.

raw 별칭은 같은 박스의 저장 값을 바꾼다

typed와 raw가 같은 Box를 가리켜 raw의 Integer 쓰기가 타입 계약을 오염시키지만 raw Object 읽기는 성공합니다. 쓰기 전후와 읽기 계약의 차이를 추적합니다.

raw 별칭은 같은 박스의 저장 값을 바꾼다

같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다.

RAW 별칭 · 같은 저장 객체

raw 별칭은 같은 박스의 저장 값을 바꾼다

typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다.

같은 박스의 별칭과 raw 쓰기 이후 읽기 typed와 raw는 같은 Box 객체를 참조합니다. typed는 safe를 먼저 넣고 raw가 Integer 100으로 교체합니다. 점선은 별칭 참조, 실선은 실제 대입·쓰기·Object 읽기 관계입니다. 원 main은 runtime-type=Integer를 정상 출력하며 typed.get을 실행하지 않습니다. 삽입한 String 읽기에서 실패하는 별도 연습과 구별합니다. typed 참조 Box<String> safe를 먼저 저장 같은 박스의 raw 참조 raw = typed raw.set(100) 하나의 Box 객체 현재 값은 Integer 100 다른 박스를 만들지 않음 raw.get 읽기 Object observed 값은 Integer 100 main의 정상 출력 runtime-type=Integer 점선은 두 참조가 같은 Box를 가리킴 · 실선은 실제 대입·쓰기·읽기 순서 unchecked 경고가 있는 raw 쓰기 · 원 main은 typed.get을 실행하지 않음
같은 박스의 별칭과 raw 쓰기 이후 읽기 typed와 raw는 같은 Box 객체를 참조합니다. typed는 safe를 먼저 넣고 raw가 Integer 100으로 교체합니다. 점선은 별칭 참조, 실선은 실제 대입·쓰기·Object 읽기 관계입니다. 원 main은 runtime-type=Integer를 정상 출력하며 typed.get을 실행하지 않습니다. 삽입한 String 읽기에서 실패하는 별도 연습과 구별합니다. typed 참조 Box<String> safe를 먼저 저장 같은 박스의 raw 참조 raw = typed raw.set(100) 하나의 Box 객체 현재 값은 Integer 100 다른 박스를 만들지 않음 raw.get 읽기 Object observed 값은 Integer 100 main의 정상 출력 runtime-type=Integer 점선: 같은 박스를 참조 실선: 대입·쓰기·읽기 순서 typed.get은 원 main 미호출
  1. typed.set("safe")

    정적 계약과 저장 값
    typed의 타입은 Box<String>.
    저장 값은 문자열 "safe".
    관찰 범위
    String 계약으로 검사한 쓰기입니다.
  2. Box raw = typed

    정적 계약과 저장 값
    raw는 타입 인자를 생략한 Box.
    새 박스를 만들거나 값을 복사하지 않습니다.
    관찰 범위
    동일 객체에 raw 접근 경로가 생깁니다.
  3. raw.set(100)

    정적 계약과 저장 값
    Integer 값 100이 같은 필드를 덮어씁니다.
    관찰 범위
    컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다.
  4. 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를 넣습니다.

  • typed: Box<String>
  • 저장값: safe
  • 정적 쓰기 계약에 맞는 상태에서 시작한다.
raw 별칭 쓰기

같은 Box를 raw로 보고 100을 저장합니다.

  • 별칭: raw=typed
  • 저장값: Integer 100
  • 새 박스 없이 같은 객체의 값이 오염된다.
Object 읽기

raw.get을 Object로 받아 정상 관찰합니다.

  • observed: Integer 100
  • 출력: runtime-type=Integer
  • String 읽기: 없음
  • 원 main의 정상 출력과 별도 타입 실패 연습을 구분한다.

raw 별칭은 같은 박스의 저장 값을 바꾼다

typed와 raw가 같은 Box를 가리켜 raw의 Integer 쓰기가 타입 계약을 오염시키지만 raw Object 읽기는 성공합니다. 쓰기 전후와 읽기 계약의 차이를 추적합니다.

raw 별칭은 같은 박스의 저장 값을 바꾼다

같은 박스의 typed와 raw 참조, raw.set의 Integer 덮어쓰기와 unchecked 경고, Object로 받은 실제 Integer 출력까지 구분한다. 원문에 없는 String 재읽기를 실제 실패로 세지 않는다.

RAW 별칭 · 같은 저장 객체

raw 별칭은 같은 박스의 저장 값을 바꾼다

typed와 raw는 같은 Box 객체를 가리킵니다. raw로 100을 저장한 뒤 이 main은 Object로 읽어 Integer라는 사실만 출력합니다.

같은 박스의 별칭과 raw 쓰기 이후 읽기 typed와 raw는 같은 Box 객체를 참조합니다. typed는 safe를 먼저 넣고 raw가 Integer 100으로 교체합니다. 점선은 별칭 참조, 실선은 실제 대입·쓰기·Object 읽기 관계입니다. 원 main은 runtime-type=Integer를 정상 출력하며 typed.get을 실행하지 않습니다. 삽입한 String 읽기에서 실패하는 별도 연습과 구별합니다. typed 참조 Box<String> safe를 먼저 저장 같은 박스의 raw 참조 raw = typed raw.set(100) 하나의 Box 객체 현재 값은 Integer 100 다른 박스를 만들지 않음 raw.get 읽기 Object observed 값은 Integer 100 main의 정상 출력 runtime-type=Integer 점선은 두 참조가 같은 Box를 가리킴 · 실선은 실제 대입·쓰기·읽기 순서 unchecked 경고가 있는 raw 쓰기 · 원 main은 typed.get을 실행하지 않음
같은 박스의 별칭과 raw 쓰기 이후 읽기 typed와 raw는 같은 Box 객체를 참조합니다. typed는 safe를 먼저 넣고 raw가 Integer 100으로 교체합니다. 점선은 별칭 참조, 실선은 실제 대입·쓰기·Object 읽기 관계입니다. 원 main은 runtime-type=Integer를 정상 출력하며 typed.get을 실행하지 않습니다. 삽입한 String 읽기에서 실패하는 별도 연습과 구별합니다. typed 참조 Box<String> safe를 먼저 저장 같은 박스의 raw 참조 raw = typed raw.set(100) 하나의 Box 객체 현재 값은 Integer 100 다른 박스를 만들지 않음 raw.get 읽기 Object observed 값은 Integer 100 main의 정상 출력 runtime-type=Integer 점선: 같은 박스를 참조 실선: 대입·쓰기·읽기 순서 typed.get은 원 main 미호출
  1. typed.set("safe")

    정적 계약과 저장 값
    typed의 타입은 Box<String>.
    저장 값은 문자열 "safe".
    관찰 범위
    String 계약으로 검사한 쓰기입니다.
  2. Box raw = typed

    정적 계약과 저장 값
    raw는 타입 인자를 생략한 Box.
    새 박스를 만들거나 값을 복사하지 않습니다.
    관찰 범위
    동일 객체에 raw 접근 경로가 생깁니다.
  3. raw.set(100)

    정적 계약과 저장 값
    Integer 값 100이 같은 필드를 덮어씁니다.
    관찰 범위
    컴파일 시 이 메서드 호출에 unchecked 경고가 생깁니다.
  4. 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를 넣습니다.

  • typed: Box<String>
  • 저장값: safe
  • 정적 쓰기 계약에 맞는 상태에서 시작한다.
raw 별칭 쓰기

같은 Box를 raw로 보고 100을 저장합니다.

  • 별칭: raw=typed
  • 저장값: Integer 100
  • 새 박스 없이 같은 객체의 값이 오염된다.
Object 읽기

raw.get을 Object로 받아 정상 관찰합니다.

  • observed: Integer 100
  • 출력: runtime-type=Integer
  • String 읽기: 없음
  • 원 main의 정상 출력과 별도 타입 실패 연습을 구분한다.

제네릭 도입 범위

값의 타입만 달라지고 보관·조회 알고리즘이 같다면 제네릭 타입이 적합합니다.

반대로 타입마다 행동과 불변식이 크게 다르면 하나의 Box에 억지로 합치지 말고 각 도메인 타입이 책임을 갖게 합니다.

제네릭 매개변수가 많아 호출자가 관계를 이해하기 어렵다면 API를 작은 타입으로 나눕니다.

Box<Dog>와 Box<Animal>은 서로 다른 타입입니다.

Dog가 Animal의 자식이어도 제네릭 타입이 자동으로 같은 상속 관계를 만들지 않습니다.

그렇지 않으면 Box<Animal>으로 받은 코드가 Cat을 넣어 원래 Dog 박스를 오염시킬 수 있습니다.

이 불공변성 문제는 뒤의 와일드카드에서 안전한 읽기·쓰기 범위로 해결합니다.

프로젝트 환경은 JDK 25의 javac와 java를 같은 버전으로 사용합니다.

제네릭 진단은 컴파일 단계에서 발생하므로 IDE의 빨간 줄만 믿지 않고 실제 빌드 명령으로 확인합니다.

-Xlint:unchecked는 비매개변수화 타입과 확인되지 않은 캐스팅 경계를 찾는 데 유용합니다.

컴파일 경고를 분류할 때는 경고가 생긴 가장 작은 경계를 찾습니다.

레거시 라이브러리에서 받은 raw 값 하나 때문에 서비스 전체에 @SuppressWarnings("unchecked")를 붙이면 이후 새 위험까지 보이지 않습니다.

어댑터 메서드 안에서 원소를 하나씩 Class.isInstance로 확인한 뒤 새 컬렉션에 복사하고, 정말 증명 가능한 캐스팅 한 줄에만 억제를 제한합니다.

억제 주석에는 어떤 외부 계약 때문에 안전한지 근거를 남깁니다.

빌드와 실행 JDK가 다르면 최신 문법 지원 여부뿐 아니라 진단 위치도 달라질 수 있습니다.

이 교재 예제는 java -version과 javac -version이 모두 25인지 확인하고 같은 소스를 매번 독립 컴파일합니다.

IDE가 내부 증분 빌드 캐시로 성공시킨 결과보다 깨끗한 명령행 컴파일을 최종 계약으로 삼습니다.

제네릭은 런타임 테스트만으로 모든 잘못된 조합을 방문할 수 없으므로, 의도한 컴파일 실패 자체도 중요한 학습 결과입니다.


연습 문제

첫 값과 둘째 값의 타입이 독립적인 Pair<F, S>를 작성하세요.

생성 뒤 값을 바꾸지 못하게 만들고, 게시글 제목·조회 수와 회원 이메일·활성 상태를 각각 묶어 보세요.

Pair는 두 값을 생성자에서 받고 first()와 second()로 읽게 합니다. setter는 제공하지 않습니다. 게시글은 "제네릭 질문"과 50, 회원은 "[email protected]"과 true를 입력하고 각 쌍을 first=second 형식으로 출력합니다. 두 타입 인자는 서로 같을 필요가 없으며 null 검증은 이번 연습의 요구에 넣지 않습니다.

같은 구현으로 Pair<Integer, String>에 7과 "draft"를 넣을 때 두 접근자의 반환 타입과 출력 한 줄도 먼저 예상하세요.

해설 보기
src/GenericPairExercise.java
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]=true

F와 S는 서로 독립적으로 결정되며 반환할 때 캐스팅이 필요 없습니다.

독립 입력의 first()는 Integer, second()는 String이고 출력은 7=draft입니다. record는 두 필드를 다른 값으로 다시 대입하지 못하게 합니다. 그러나 F나 S가 가변 객체라면 그 객체의 내부까지 복사하거나 불변으로 만드는 것은 아닙니다. 원래 연습의 String·Integer·Boolean은 불변 값이므로 이 차이가 출력에 나타나지 않습니다.

값의 의미가 더 구체적이고 규칙이 늘어나면 범용 Pair 대신 이름 있는 도메인 record로 승격합니다.