본문 바로가기

타입 소거와 런타임 한계

제네릭 타입 소거가 생성·검사·배열을 제한하는 이유를 이해하고 Class 토큰과 팩터리로 런타임 타입 정보를 전달합니다.

자바 제네릭은 주로 컴파일 시점에 타입 안전성을 검사합니다.

타입 매개변수는 바이트코드에서 상한 또는 Object 관점으로 소거되고, 필요한 곳에 컴파일러가 캐스팅을 삽입합니다.

그래서 같은 제네릭 클래스 한 벌로 여러 타입을 사용할 수 있지만 런타임에 T 자체를 검사하거나 생성하는 데는 제한이 있습니다.


타입 소거와 생성 제한

이 예제는 Object로 받은 값을 T인지 검사하거나 T의 생성자를 직접 호출하려 하지만, T만으로는 그 런타임 정보를 얻을 수 없습니다.

T.class와 new T[]도 런타임 타입 정보가 필요한 연산이어서 제한됩니다. 별도로, 클래스의 타입 매개변수 T는 정적 문맥에서 참조할 수 없으므로 static T 필드를 선언할 수 없습니다.

lab/ErasedTypeOperationFailure.java
public final class ErasedTypeOperationFailure<T> {
    public boolean matches(Object value) {
        return value instanceof T;
    }

    public T create() {
        return new T();
    }
}
javac 핵심 진단
error: Object cannot be safely cast to T
error: unexpected type
required: class
found: type parameter T

이 검사를 단순히 instanceof Object로 바꾸면 null이 아닌 모든 참조에 참이므로, 호출자가 의도한 타입 인자를 판별하지 못합니다.

컴파일러는 의미가 사라지는 연산을 허용하지 않습니다.


소거 후의 런타임 구조

Box<String>과 Box<Integer>는 서로 다른 소스 타입이지만 런타임 클래스 객체는 같습니다.

get 결과를 구체 타입으로 받는 지점에는 컴파일러가 안전하다고 검증한 캐스팅을 넣습니다.

src/ErasureRuntimeObservation.java
public final class ErasureRuntimeObservation {
    public static void main(String[] args) {
        Box<String> titles = new Box<>("제네릭 질문");
        Box<Integer> viewCounts = new Box<>(13);

        System.out.println("same-class=" + (titles.getClass() == viewCounts.getClass()));
        String title = titles.get();
        Integer viewCount = viewCounts.get();
        System.out.println(title + "=" + viewCount);
    }

    private record Box<T>(T value) {
        T get() { return value; }
    }
}
same-class=true
제네릭 질문=13

제한 없는 T는 기본적으로 Object로 치환됩니다.

<T extends Animal>은 Animal 상한으로 치환되므로 구현은 Animal 메서드를 호출할 수 있습니다.

반환 지점에서 Dog가 필요하면 호출 코드에 Dog 캐스팅이 삽입됩니다.

정상적인 제네릭 사용에서는 컴파일러가 입력까지 검사했기 때문에 이 캐스팅이 안전합니다.

리플렉션 API가 필드·메서드 선언에 남은 generic signature 메타데이터를 일부 보여 주기도 하지만, 객체 하나가 Box<String>인지 Box<Integer>인지 완전히 재구성하는 실행 타입 표지는 아닙니다.

런타임 검증을 그 메타데이터에 막연히 의존하지 않습니다.


비매개변수화 타입과 힙 오염

소거가 안전하지 않은 것이 아니라 비매개변수화 타입과 unchecked 연산이 컴파일 검사를 우회하는 것이 문제입니다.

Box<String> 객체에 raw 참조로 Integer를 넣으면 힙의 실제 값과 정적 타입 약속이 달라집니다.

lab/RawGenericHeapPollutionFailure.java
public final class RawGenericHeapPollutionFailure {
    public static void main(String[] args) {
        Box<String> strings = new Box<>();
        strings.set("safe");

        Box raw = strings;
        raw.set(13);

        String title = strings.get();
        System.out.println(title);
    }

    private static final class Box<T> {
        private T value;
        void set(T value) { this.value = value; }
        T get() { return value; }
    }
}
실행 결과의 핵심
java.lang.ClassCastException:
class java.lang.Integer cannot be cast to class java.lang.String

실패는 컴파일러가 get 뒤 삽입한 String 캐스팅에서 나타납니다.

근본 원인은 raw 타입을 통한 set 호출입니다.

-Xlint:unchecked 경고를 실패로 취급하면 이런 경계를 일찍 발견할 수 있습니다.

외부 레거시 API가 raw 값을 돌려주면 한 어댑터에서 원소를 검사해 새 타입 안전 컬렉션으로 복사합니다.

정상 타입 조회와 raw 별칭 이후의 실패

raw 별칭이 13을 쓰고 typed String 읽기의 컴파일러 삽입 cast가 실패합니다. 정상/오염 사례의 쓰기와 읽기 단계를 나눠 멀리 떨어진 실패의 원인을 드러냅니다.

정상 타입 조회와 raw 별칭 이후의 실패

정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다.

타입 소거 · 안전한 조회와 오염

정상 타입 조회와 raw 별칭 이후의 실패

서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다.

같은 런타임 클래스와 raw 오염 이후 타입 실패 서로 다른 두 원문입니다. 정상 제네릭 두 Box는 같은 런타임 클래스를 가지면서 각 String·Integer 계약으로 읽어 제네릭 질문=13을 출력합니다. raw 별칭의 Integer 13 쓰기는 같은 String 박스 값을 오염시켜 typed.get의 String 캐스팅에서 실패하며 뒤 println은 실행하지 않습니다. 실선은 각 원문의 실제 관찰·읽기 단계입니다. 원문 A · 정상 타입 계약 두 Box 생성 Box<String> titles Box<Integer> viewCounts 실제 클래스 비교 둘 다 같은 Box 클래스 same-class=true 각 타입으로 읽기 String과 Integer 제네릭 질문=13 원문 B · raw 오염 String 박스의 별칭 raw도 같은 Box 참조 raw가 Integer 넣기 검사되지 않은 쓰기 값은 13으로 교체 typed의 String 읽기 Integer → String 실패 ClassCastException 영역마다 별도 원문 프로그램 같은 런타임 클래스라도 각 정적 타입 계약은 유지 raw 실패 뒤 println은 미실행
같은 런타임 클래스와 raw 오염 이후 타입 실패 서로 다른 두 원문입니다. 정상 제네릭 두 Box는 같은 런타임 클래스를 가지면서 각 String·Integer 계약으로 읽어 제네릭 질문=13을 출력합니다. raw 별칭의 Integer 13 쓰기는 같은 String 박스 값을 오염시켜 typed.get의 String 캐스팅에서 실패하며 뒤 println은 실행하지 않습니다. 실선은 각 원문의 실제 관찰·읽기 단계입니다. 원문 A · 정상 타입 계약 두 Box 생성 Box<String> titles Box<Integer> viewCounts 실제 클래스 비교 둘 다 같은 Box 클래스 same-class=true 각 타입으로 읽기 String과 Integer 제네릭 질문=13 원문 B · raw 오염 String 박스의 별칭 raw도 같은 Box 참조 raw가 Integer 넣기 검사되지 않은 쓰기 값은 13으로 교체 typed의 String 읽기 Integer → String 실패 ClassCastException 영역마다 별도 원문 프로그램 같은 런타임 클래스라도 각 정적 타입 계약은 유지 raw 실패 뒤 println은 미실행

정상 제네릭 조회

저장·조회 경계
Box<String> → 문자열
Box<Integer> → Integer
각 변수의 타입에 맞게 조회
관찰 결과
same-class=true
제네릭 질문=13

raw 별칭으로 잘못 쓰기

저장·조회 경계
raw.set(13)에 unchecked 경고
String title = strings.get()에서 삽입된 캐스팅 수행
관찰 결과
ClassCastException
Integer를 String으로 캐스팅할 수 없음
println(title) 미도달

두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.

이 예의 조건

  • 정상과 raw 오염은 독립 원문이다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보정상 타입 조회와 raw 별칭 이후의 실패 정상 타입 조회와 raw 별칭 이후의 실패 정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다. 타입 소거 · 안전한 조회와 오염 정상 타입 조회와 raw 별칭 이후의 실패 서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다. 정상 제네릭 조회 저장·조회 경계 Box<String> → 문자열 Box<Integer> → Integer 각 변수의 타입에 맞게 조회 관찰 결과 same-class=true 제네릭 질문=13 raw 별칭으로 잘못 쓰기 저장·조회 경계 raw.set(13) 에 unchecked 경고 String title = strings.get() 에서 삽입된 캐스팅 수행 관찰 결과 ClassCastException Integer를 String으로 캐스팅할 수 없음 println(title) 미도달 두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true 는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 타입 상태

정상 String/Integer Box와 오염할 String Box를 생성합니다.

  • 정상 runtime 클래스: 같은 Box
  • 정상 값: 제네릭 질문/13
  • 런타임 클래스 일치가 정적 읽기 계약을 없애지 않는다.
raw 쓰기

오염 예제의 같은 박스에 Integer 13을 저장합니다.

  • raw: typed 별칭
  • 오염값: 13
  • unchecked 쓰기가 String 저장 불변식을 깬다.
읽기 결과

정상은 출력하고 오염은 String cast에서 멈춥니다.

  • 정상: same-class=true; 제네릭 질문=13
  • 오염: ClassCastException, println 미실행
  • 쓰기 경계의 누락이 나중 typed 소비에서 실패한다.

정상 타입 조회와 raw 별칭 이후의 실패

raw 별칭이 13을 쓰고 typed String 읽기의 컴파일러 삽입 cast가 실패합니다. 정상/오염 사례의 쓰기와 읽기 단계를 나눠 멀리 떨어진 실패의 원인을 드러냅니다.

정상 타입 조회와 raw 별칭 이후의 실패

정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다.

타입 소거 · 안전한 조회와 오염

정상 타입 조회와 raw 별칭 이후의 실패

서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다.

같은 런타임 클래스와 raw 오염 이후 타입 실패 서로 다른 두 원문입니다. 정상 제네릭 두 Box는 같은 런타임 클래스를 가지면서 각 String·Integer 계약으로 읽어 제네릭 질문=13을 출력합니다. raw 별칭의 Integer 13 쓰기는 같은 String 박스 값을 오염시켜 typed.get의 String 캐스팅에서 실패하며 뒤 println은 실행하지 않습니다. 실선은 각 원문의 실제 관찰·읽기 단계입니다. 원문 A · 정상 타입 계약 두 Box 생성 Box<String> titles Box<Integer> viewCounts 실제 클래스 비교 둘 다 같은 Box 클래스 same-class=true 각 타입으로 읽기 String과 Integer 제네릭 질문=13 원문 B · raw 오염 String 박스의 별칭 raw도 같은 Box 참조 raw가 Integer 넣기 검사되지 않은 쓰기 값은 13으로 교체 typed의 String 읽기 Integer → String 실패 ClassCastException 영역마다 별도 원문 프로그램 같은 런타임 클래스라도 각 정적 타입 계약은 유지 raw 실패 뒤 println은 미실행
같은 런타임 클래스와 raw 오염 이후 타입 실패 서로 다른 두 원문입니다. 정상 제네릭 두 Box는 같은 런타임 클래스를 가지면서 각 String·Integer 계약으로 읽어 제네릭 질문=13을 출력합니다. raw 별칭의 Integer 13 쓰기는 같은 String 박스 값을 오염시켜 typed.get의 String 캐스팅에서 실패하며 뒤 println은 실행하지 않습니다. 실선은 각 원문의 실제 관찰·읽기 단계입니다. 원문 A · 정상 타입 계약 두 Box 생성 Box<String> titles Box<Integer> viewCounts 실제 클래스 비교 둘 다 같은 Box 클래스 same-class=true 각 타입으로 읽기 String과 Integer 제네릭 질문=13 원문 B · raw 오염 String 박스의 별칭 raw도 같은 Box 참조 raw가 Integer 넣기 검사되지 않은 쓰기 값은 13으로 교체 typed의 String 읽기 Integer → String 실패 ClassCastException 영역마다 별도 원문 프로그램 같은 런타임 클래스라도 각 정적 타입 계약은 유지 raw 실패 뒤 println은 미실행

정상 제네릭 조회

저장·조회 경계
Box<String> → 문자열
Box<Integer> → Integer
각 변수의 타입에 맞게 조회
관찰 결과
same-class=true
제네릭 질문=13

raw 별칭으로 잘못 쓰기

저장·조회 경계
raw.set(13)에 unchecked 경고
String title = strings.get()에서 삽입된 캐스팅 수행
관찰 결과
ClassCastException
Integer를 String으로 캐스팅할 수 없음
println(title) 미도달

두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.

이 예의 조건

  • 정상과 raw 오염은 독립 원문이다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보정상 타입 조회와 raw 별칭 이후의 실패 정상 타입 조회와 raw 별칭 이후의 실패 정상 예제에서는 Box<String>과 Box<Integer>가 같은 런타임 클래스를 공유하면서 각각 타입에 맞는 값을 반환한다. 다른 raw 예제는 Integer 저장으로 String 약속을 깨고 조회 후 캐스팅에서 실패한다. 타입 소거 · 안전한 조회와 오염 정상 타입 조회와 raw 별칭 이후의 실패 서로 다른 두 프로그램입니다. 정상 Box 조회는 타입에 맞게 읽고, raw로 Integer를 쓴 프로그램은 String 조회의 캐스팅에서 실패합니다. 정상 제네릭 조회 저장·조회 경계 Box<String> → 문자열 Box<Integer> → Integer 각 변수의 타입에 맞게 조회 관찰 결과 same-class=true 제네릭 질문=13 raw 별칭으로 잘못 쓰기 저장·조회 경계 raw.set(13) 에 unchecked 경고 String title = strings.get() 에서 삽입된 캐스팅 수행 관찰 결과 ClassCastException Integer를 String으로 캐스팅할 수 없음 println(title) 미도달 두 행은 서로 다른 프로그램의 Box 선언입니다. same-class=true 는 첫 프로그램 안의 두 매개변수화 Box를 비교한 결과입니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
원 타입 상태

정상 String/Integer Box와 오염할 String Box를 생성합니다.

  • 정상 runtime 클래스: 같은 Box
  • 정상 값: 제네릭 질문/13
  • 런타임 클래스 일치가 정적 읽기 계약을 없애지 않는다.
raw 쓰기

오염 예제의 같은 박스에 Integer 13을 저장합니다.

  • raw: typed 별칭
  • 오염값: 13
  • unchecked 쓰기가 String 저장 불변식을 깬다.
읽기 결과

정상은 출력하고 오염은 String cast에서 멈춥니다.

  • 정상: same-class=true; 제네릭 질문=13
  • 오염: ClassCastException, println 미실행
  • 쓰기 경계의 누락이 나중 typed 소비에서 실패한다.

런타임 타입 정보의 전달

T의 클래스 검사나 객체 생성을 해야 한다면 Class<T>, Supplier<T>, 전용 팩터리를 생성자 인수로 받습니다.

소거된 정보를 마법처럼 복원하는 것이 아니라 호출자가 알고 있는 런타임 전략을 명시적으로 전달합니다.

src/ClassTokenAndFactory.java
import java.util.function.Supplier;

public final class ClassTokenAndFactory {
    public static void main(String[] args) {
        TypedFactory<Post> factory = new TypedFactory<>(
                Post.class,
                () -> new Post("untitled", 1));

        System.out.println("matches=" + factory.matches(new Post("java", 40)));
        System.out.println("created=" + factory.create());
    }

    private record TypedFactory<T>(Class<T> type, Supplier<T> supplier) {
        boolean matches(Object value) { return type.isInstance(value); }
        T cast(Object value) { return type.cast(value); }
        T create() { return supplier.get(); }
    }

    private record Post(String title, int viewCount) { }
}
matches=true
created=Post[title=untitled, viewCount=1]

Class 토큰은 검사와 캐스팅, Supplier는 생성 방법을 담당합니다.

원래 main은 matches와 create만 호출하며 선언된 cast 메서드를 호출하지 않습니다. 별도 복사본의 main에 factory.matches(null), factory.cast(null) == null을 출력해 보면 각각 false와 true입니다. null이 없다는 업무 검증과 Class의 캐스팅 가능성은 같은 질문이 아닙니다.

같은 복사본에서 factory.matches("text")는 false이며 factory.cast("text")를 실제로 호출하면 ClassCastException입니다. Class.isInstance와 cast는 검사와 실패 방식을 각각 정의합니다. 타입 토큰 하나가 제목 길이나 조회 수 같은 Post의 업무 조건을 검사해 주는 것도 아닙니다.

기본 생성자를 리플렉션으로 강제하지 않으므로 검증된 생성자·팩터리도 전달할 수 있습니다.

중첩 제네릭 타입 전체가 필요하면 단순 Class 하나로 부족하므로 JSON 라이브러리의 TypeToken 같은 별도 타입 표현이 필요할 수 있습니다.

호출자가 전달한 타입 검사와 생성 방법

ClassTokenAndFactory는 Post.class로 받은 Class 토큰을 matches에서 사용하고, Supplier로 받은 생성 코드를 create에서 사용한다. T에서 런타임 정보를 자동 복원하는 동작이 아니다.

런타임 전략 · 검사와 생성

호출자가 전달한 타입 검사와 생성 방법

Post.class가 검사할 클래스를, Supplier가 생성 방법을 전달합니다. 소거된 T에서 정보를 자동 복원하는 동작은 없습니다.

Class의 검사 역할과 Supplier 생성 역할 같은 TypedFactory의 Class<Post>는 새 Post의 isInstance 검사로 matches=true를 출력합니다. Supplier<Post>의 get은 untitled와 1로 새 Post를 만들어 created를 출력합니다. 실선은 각 역할과 실제 호출 결과입니다. cast는 선언되었으나 원 main에서 호출하지 않으며, 두 레이아웃에서 나눈 created 필드는 원 stdout 한 줄입니다. 하나의 TypedFactory Class<Post> type Supplier<Post> supplier 검사의 역할 생성의 역할 Post.class 토큰 type.isInstance(value) supplier의 생성식 new Post("untitled", 1) main의 matches 호출 새 Post 객체 검사 matches=true main의 create 호출 supplier.get() 실행 created=Post[title=untitled, viewCount=1] 실선: 같은 factory의 검사와 생성 역할 · cast 선언은 원 main에서 미호출 created의 나눈 필드는 원 stdout 한 줄 · Class 검사로 Post 업무조건을 검증하는 것은 아님
Class의 검사 역할과 Supplier 생성 역할 같은 TypedFactory의 Class<Post>는 새 Post의 isInstance 검사로 matches=true를 출력합니다. Supplier<Post>의 get은 untitled와 1로 새 Post를 만들어 created를 출력합니다. 실선은 각 역할과 실제 호출 결과입니다. cast는 선언되었으나 원 main에서 호출하지 않으며, 두 레이아웃에서 나눈 created 필드는 원 stdout 한 줄입니다. 하나의 TypedFactory Class<Post> type Supplier<Post> supplier 검사의 역할 Post.class 토큰 type.isInstance(value) main의 matches 호출 새 Post 객체 검사 matches=true 생성의 역할 supplier의 생성식 new Post("untitled", 1) main의 create 호출 supplier.get() 실행 created=Post[ title=untitled, viewCount=1] 실선: 검사와 생성의 역할 연결 cast 선언은 원 main에서 미호출 created는 원 stdout 한 줄

Post.class
Post 객체를 검사하는 토큰

실제 호출되는 연산
factory.matches(...)
type.isInstance(value)
원문 결과
matches=true

Supplier<Post>
untitled · 조회 수 1을 만드는 람다

실제 호출되는 연산
factory.create()
supplier.get()
원문 결과
created=Post[title=untitled, viewCount=1]

cast(Object)도 선언되어 있지만 이 main에서는 호출하지 않습니다. 검사할 클래스와 생성 방법은 호출자가 명시적으로 전달합니다.


배열과 가변 인자의 소거 경계

배열은 런타임에 원소 타입을 검사하지만 제네릭은 소거됩니다.

그래서 new T[10]을 직접 만들 수 없고 List<String>[] 같은 제네릭 배열 생성도 금지됩니다.

두 모델을 섞으면 런타임 배열 검사와 컴파일 제네릭 검사가 서로 다른 정보를 가지게 됩니다.

제네릭 varargs는 내부적으로 배열을 만들기 때문에 힙 오염 위험이 있습니다.

메서드가 배열을 외부에 노출하거나 원소를 바꾸지 않고 안전하다는 것을 구현자가 증명한 경우에만 @SafeVarargs를 사용합니다.

경고를 숨기는 용도로 붙이지 않습니다.

가능하면 List<T>를 인수로 받는 API가 더 명확합니다.

오버로딩도 소거 뒤 시그니처가 같아질 수 있습니다.

process(List<String>)와 process(List<Integer>)는 둘 다 런타임에 process(List)가 되어 함께 선언할 수 없습니다.

메서드 이름을 역할별로 바꾸거나 하나의 제네릭 메서드로 관계를 표현합니다.


제네릭 설계 선택 기준

  • 컴파일 단계의 입력·반환 관계가 목적: 타입 매개변수
  • 실행 중 타입 검사: Class 토큰 또는 명시 식별자
  • 실행 중 생성 전략: Supplier나 팩터리
  • 읽기/쓰기 범위 확장: 와일드카드와 PECS
  • raw 경계: 한곳에서 검증·복사하고 unchecked 범위 최소화
  • 타입별 행동이 본질적으로 다름: 다형성·전략·도메인 타입

제네릭은 타입 관계가 분명할수록 강력합니다.

<A,B,C,D>가 서로 어떻게 연결되는지 설명하기 어렵다면 더 작은 객체나 이름 있는 중간 타입으로 나눕니다.

캐스팅을 숨기기 위해 unchecked helper를 만들기보다 공개 계약이 실제로 보장할 수 있는지 먼저 검토합니다.


연습 문제

체력이 큰 같은 유닛 타입을 반환하는 제네릭 메서드, BioUnit 하위 한 종류를 싣는 Shuttle, 어떤 Shuttle이든 출력하는 와일드카드 메서드를 작성하세요.

BioUnit은 name()·hp()를 제공하며 Marine은 name·hp·squad를 갖습니다. maxHp는 null이 아닌 두 유닛을 비교하고, 체력이 같으면 left를 반환합니다. Shuttle<T extends BioUnit>의 in(T)는 한 값을 보관하고 out()은 그 T를 반환합니다. 출력 전에 in을 호출해 비어 있지 않게 하며, 이번에는 교체 여부 외의 적재 용량 정책을 만들지 않습니다.

Marine M1/40/1과 M2/55/2로 비교한 값을 Shuttle<Marine>에 넣습니다. printUnit은 이름:체력을 출력하고 main은 원래 Marine 참조로 squad=번호를 출력합니다. 독립 조건에서 둘째 유닛의 체력만 40으로 바꾸면 두 출력 줄을 어떻게 예상하나요?

해설 보기
src/GenericUnitToolkitExercise.java
public final class GenericUnitToolkitExercise {
    public static void main(String[] args) {
        Marine first = new Marine("M1", 40, 1);
        Marine second = new Marine("M2", 55, 2);
        Marine stronger = maxHp(first, second);

        Shuttle<Marine> shuttle = new Shuttle<>();
        shuttle.in(stronger);
        printUnit(shuttle);
        System.out.println("squad=" + stronger.squad());
    }

    private static <T extends BioUnit> T maxHp(T left, T right) {
        return left.hp() >= right.hp() ? left : right;
    }

    private static void printUnit(Shuttle<? extends BioUnit> shuttle) {
        BioUnit unit = shuttle.out();
        System.out.println(unit.name() + ":" + unit.hp());
    }

    private static final class Shuttle<T extends BioUnit> {
        private T unit;
        void in(T unit) { this.unit = unit; }
        T out() { return unit; }
    }

    private interface BioUnit { String name(); int hp(); }
    private record Marine(String name, int hp, int squad) implements BioUnit { }
}
M2:55
squad=2

이 예제에서 maxHp의 T는 Marine 반환을 보존하고 Shuttle<Marine>은 적재하는 값의 타입 관계를 유지합니다. maxHp 자체가 서로 다른 BioUnit 하위 타입을 함께 넘기는 모든 호출을 금지하는 것은 아닙니다.

printUnit은 부모 기능만 읽으므로 ? extends BioUnit이면 충분합니다.

동률 조건은 40 >= 40이 참이므로 첫 Marine을 선택해 M1:40, squad=1을 출력합니다. printUnit 안에서 보이는 타입은 BioUnit이라 squad를 사용하지 않습니다. main의 stronger는 Marine이므로 squad()를 호출할 수 있습니다. Shuttle의 타입이 바뀌거나 printUnit이 유닛을 새로 만든 결과가 아닙니다.

Marine 예제에서 이어지는 세 타입 계약

maxHp가 선택한 M2를 Shuttle에 저장/조회하고 부모 뷰와 squad 결과를 출력합니다. 같은 참조를 고정하여 각 호출에서 타입 계약이 유지되는 실제 흐름을 추적합니다.

Marine 예제에서 이어지는 세 타입 계약

Marine 두 개의 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다.

세 타입 계약 · 선택과 보관과 관찰

Marine 예제에서 이어지는 세 타입 계약

maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printer는 BioUnit 기능만 읽으며 squad는 main에서 출력합니다.

Marine 반환·적재·부모 출력의 연결 maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printer는 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printer가 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. 같은 Marine 두 입력 M1 / 40 / 1 M2 / 55 / 2 maxHp의 T 반환 40 >= 55는 false stronger는 Marine M2 Shuttle<Marine> 저장 in(stronger) 후 out 같은 M2 참조 printer의 부모 관찰 BioUnit으로 이름·hp 읽기 M2:55 출력 main의 고유 멤버 stronger.squad() squad=2 출력 실선: 입력·보관과 멤버 관찰 · 점선: M2:55 다음 squad=2의 출력 순서 printer는 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음
Marine 반환·적재·부모 출력의 연결 maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printer는 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printer가 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. 같은 Marine 두 입력 M1 / 40 / 1 M2 / 55 / 2 maxHp의 T 반환 40 >= 55는 false stronger는 Marine M2 Shuttle<Marine> 저장 in(stronger) 후 out 같은 M2 참조 printer의 부모 관찰 BioUnit으로 이름·hp 읽기 M2:55 출력 main의 고유 멤버 stronger.squad() squad=2 출력 실선: 입력·보관과 멤버 관찰 점선: 두 stdout의 실행 순서 printer는 squad를 읽지 않음
  1. maxHp(first, second)

    타입 관계
    Marine stronger
    실제로 하는 일
    체력 40과 55 비교
    Marine M2 반환
  2. Shuttle<Marine>

    타입 관계
    in(Marine)
    out()의 반환은 Marine
    실제로 하는 일
    선택한 M2를 보관·조회
  3. printUnit(shuttle)

    타입 관계
    Shuttle<? extends BioUnit>
    BioUnit unit
    실제로 하는 일
    이름·체력 읽기
    M2:55

squad=2는 printUnit이 아니라 main에서 stronger.squad()로 출력합니다.

이 예의 조건

  • printUnit은 squad를 읽지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다.
4M1
540
61
855
92
16printUnit의 부모 관찰
23printUnit은 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음
원 도표의 설명과 주석
구분내용
설명maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다.
전체 읽기 정보Marine 예제에서 이어지는 세 타입 계약 Marine 예제에서 이어지는 세 타입 계약 Marine 두 개를 인수로 받은 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다. 세 타입 계약 · 선택과 보관과 관찰 Marine 예제에서 이어지는 세 타입 계약 maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printUnit은 BioUnit 기능만 읽으며 squad는 main에서 출력합니다. maxHp(first, second) 타입 관계 Marine stronger 실제로 하는 일 체력 40과 55 비교 Marine M2 반환 Shuttle<Marine> 타입 관계 in(Marine) out() 의 반환은 Marine 실제로 하는 일 선택한 M2 를 보관·조회 printUnit(shuttle) 타입 관계 Shuttle<? extends BioUnit> BioUnit unit 실제로 하는 일 이름·체력 읽기 M2:55 squad=2 는 printUnit 이 아니라 main에서 stronger.squad() 로 출력합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Marine 선택

M1/40/1과 M2/55/2에서 maxHp를 호출합니다.

  • 비교: 40≥55 false
  • stronger: Marine M2
  • T 반환 계약이 선택 참조의 구체 타입을 보존한다.
적재/조회

Shuttle<Marine>에 같은 stronger를 넣고 꺼냅니다.

  • 보관: M2 참조
  • out: 같은 M2
  • 컨테이너 호출이 새 유닛을 만들지 않는다.
각 API 관찰

부모 printUnit 뒤 main의 고유 멤버를 읽습니다.

  • printUnit: M2:55
  • main: squad=2
  • 같은 객체라도 관찰자가 아는 타입 계약은 다르다.

Marine 예제에서 이어지는 세 타입 계약

maxHp가 선택한 M2를 Shuttle에 저장/조회하고 부모 뷰와 squad 결과를 출력합니다. 같은 참조를 고정하여 각 호출에서 타입 계약이 유지되는 실제 흐름을 추적합니다.

Marine 예제에서 이어지는 세 타입 계약

Marine 두 개의 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다.

세 타입 계약 · 선택과 보관과 관찰

Marine 예제에서 이어지는 세 타입 계약

maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printer는 BioUnit 기능만 읽으며 squad는 main에서 출력합니다.

Marine 반환·적재·부모 출력의 연결 maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printer는 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printer가 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. 같은 Marine 두 입력 M1 / 40 / 1 M2 / 55 / 2 maxHp의 T 반환 40 >= 55는 false stronger는 Marine M2 Shuttle<Marine> 저장 in(stronger) 후 out 같은 M2 참조 printer의 부모 관찰 BioUnit으로 이름·hp 읽기 M2:55 출력 main의 고유 멤버 stronger.squad() squad=2 출력 실선: 입력·보관과 멤버 관찰 · 점선: M2:55 다음 squad=2의 출력 순서 printer는 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음
Marine 반환·적재·부모 출력의 연결 maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printer는 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printer가 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다. 같은 Marine 두 입력 M1 / 40 / 1 M2 / 55 / 2 maxHp의 T 반환 40 >= 55는 false stronger는 Marine M2 Shuttle<Marine> 저장 in(stronger) 후 out 같은 M2 참조 printer의 부모 관찰 BioUnit으로 이름·hp 읽기 M2:55 출력 main의 고유 멤버 stronger.squad() squad=2 출력 실선: 입력·보관과 멤버 관찰 점선: 두 stdout의 실행 순서 printer는 squad를 읽지 않음
  1. maxHp(first, second)

    타입 관계
    Marine stronger
    실제로 하는 일
    체력 40과 55 비교
    Marine M2 반환
  2. Shuttle<Marine>

    타입 관계
    in(Marine)
    out()의 반환은 Marine
    실제로 하는 일
    선택한 M2를 보관·조회
  3. printUnit(shuttle)

    타입 관계
    Shuttle<? extends BioUnit>
    BioUnit unit
    실제로 하는 일
    이름·체력 읽기
    M2:55

squad=2는 printUnit이 아니라 main에서 stronger.squad()로 출력합니다.

이 예의 조건

  • printUnit은 squad를 읽지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다.
4M1
540
61
855
92
16printUnit의 부모 관찰
23printUnit은 BioUnit 기능만 읽음 · main의 Marine stronger로 고유 squad를 읽음
원 도표의 설명과 주석
구분내용
설명maxHp는 M1 40과 M2 55 중 Marine M2를 반환합니다. 같은 참조를 Shuttle<Marine>에 넣고 printUnit은 BioUnit의 이름·hp를 읽어 M2:55를 출력합니다. main은 별도로 stronger의 squad 2를 출력합니다. 실선은 입력·보관·멤버 관찰의 관계이며 점선은 두 stdout의 실제 순서입니다. printUnit이 squad를 읽거나 유닛을 새로 만드는 것은 아닙니다.
전체 읽기 정보Marine 예제에서 이어지는 세 타입 계약 Marine 예제에서 이어지는 세 타입 계약 Marine 두 개를 인수로 받은 maxHp 호출은 Marine을 반환하고 Shuttle<Marine>은 그 값을 보관한다. printUnit은 BioUnit 관점으로 이름과 체력을 읽으며 squad 출력은 main의 Marine 참조에서 수행한다. 세 타입 계약 · 선택과 보관과 관찰 Marine 예제에서 이어지는 세 타입 계약 maxHp는 이 호출의 Marine을 반환하고 Shuttle<Marine>이 보관합니다. printUnit은 BioUnit 기능만 읽으며 squad는 main에서 출력합니다. maxHp(first, second) 타입 관계 Marine stronger 실제로 하는 일 체력 40과 55 비교 Marine M2 반환 Shuttle<Marine> 타입 관계 in(Marine) out() 의 반환은 Marine 실제로 하는 일 선택한 M2 를 보관·조회 printUnit(shuttle) 타입 관계 Shuttle<? extends BioUnit> BioUnit unit 실제로 하는 일 이름·체력 읽기 M2:55 squad=2 는 printUnit 이 아니라 main에서 stronger.squad() 로 출력합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
Marine 선택

M1/40/1과 M2/55/2에서 maxHp를 호출합니다.

  • 비교: 40≥55 false
  • stronger: Marine M2
  • T 반환 계약이 선택 참조의 구체 타입을 보존한다.
적재/조회

Shuttle<Marine>에 같은 stronger를 넣고 꺼냅니다.

  • 보관: M2 참조
  • out: 같은 M2
  • 컨테이너 호출이 새 유닛을 만들지 않는다.
각 API 관찰

부모 printUnit 뒤 main의 고유 멤버를 읽습니다.

  • printUnit: M2:55
  • main: squad=2
  • 같은 객체라도 관찰자가 아는 타입 계약은 다르다.