본문 바로가기

타입 상한과 메서드 추론

타입 매개변수에 extends로 상한을 정하면 무엇을 호출할 수 있게 되는지 확인하고, 메서드 하나에만 타입 매개변수를 두는 제네릭 메서드와 컴파일러가 타입 인자를 스스로 정하는 타입 추론을 배웁니다.

앞 글까지 병원 예제는 줄곧 Hospital<T extends Animal>이라고 선언했습니다. 이 extends Animal을 지우고 Hospital<T>라고만 쓰면 어떻게 될까요? 클래스 안의 animal.size()가 곧바로 컴파일 오류가 됩니다. 실제로 넣는 값이 개뿐이어도 마찬가지입니다.

이 글에서는 먼저 그 오류가 왜 나는지 확인하고, extends로 정하는 타입 상한(upper bound)이 무엇을 허락하고 무엇을 막는지 살펴봅니다. 이어서 클래스 전체가 아니라 메서드 하나에만 타입 매개변수를 두는 제네릭 메서드(generic method)를 만들고, 호출할 때 컴파일러가 타입 인자를 스스로 정하는 타입 추론(type inference)이 어떻게 동작하는지 따라가 봅니다.

상한이 없으면 T로는 Object의 메서드만 부를 수 있다

다음 프로그램은 상한 없이 Hospital<T>를 선언하고, size()에서 보관한 동물의 크기를 돌려주려 합니다. main에서는 Dog만 넣습니다.

lab/UnboundedAnimalMethodFailure.java
public final class UnboundedAnimalMethodFailure {
    public static void main(String[] args) {
        Hospital<Dog> hospital = new Hospital<>(new Dog("dog", 40));
        System.out.println(hospital.size());
    }

    private static final class Hospital<T> {
        private final T animal;

        Hospital(T animal) {
            this.animal = animal;
        }

        int size() {
            return animal.size();
        }
    }

    private interface Animal {
        int size();
    }

    private record Dog(String name, int size) implements Animal {}
}
컴파일 오류
lab/UnboundedAnimalMethodFailure.java:15: error: cannot find symbol
            return animal.size();
                         ^
  symbol:   method size()
  location: variable animal of type T
  where T is a type-variable:
    T extends Object declared in class Hospital
1 error

메시지는 'T 타입의 변수 animal에서 size() 메서드를 찾을 수 없다'는 뜻입니다. 이유는 마지막 두 줄에 있습니다. 상한을 적지 않은 T는 T extends Object, 즉 'Object이거나 그 하위 타입인 어떤 타입'으로 취급됩니다. 그런데 모든 참조 타입이 Object의 하위 타입이므로, 이 정보만으로 컴파일러가 확신할 수 있는 것은 animal이 toString()이나 equals() 같은 Object의 메서드를 가진다는 사실뿐입니다.

main에서 Hospital<Dog>만 만든다는 사실은 도움이 되지 않습니다. 컴파일러는 제네릭 클래스를 쓰는 곳마다 본문을 다시 검사하지 않고, 선언만 보고 한 번 검사합니다. 같은 클래스를 다른 곳에서 Hospital<Integer>나 Hospital<String>으로 쓸 수도 있는데, Integer에는 size()가 없습니다. 그래서 클래스 본문에는 T에 어떤 타입이 오더라도 성립하는 코드만 쓸 수 있습니다. 주석에 '동물만 넣으세요'라고 적거나 변수 이름을 animal로 지어도 컴파일러에게는 아무 의미가 없습니다.

extends로 T가 될 수 있는 타입 좁히기

<T extends Animal>은 'T 자리에는 Animal이거나 Animal의 하위 타입만 올 수 있다'는 선언입니다. 이렇게 T가 될 수 있는 타입에 위쪽 한계를 정하는 것을 타입 상한이라고 합니다. 범위가 좁아진 대신 컴파일러는 T가 무엇이든 Animal의 메서드를 가진다는 것을 알게 되므로, 클래스 안에서 그 메서드를 호출하도록 허락합니다.

다음 프로그램은 상한을 둔 병원에서 두 메서드를 호출합니다. checkup()은 보관한 동물을 검진하고, bigger()는 보관한 동물과 새 동물 가운데 큰 쪽을 돌려줍니다.

src/BoundedAnimalHospital.java
public final class BoundedAnimalHospital {
    public static void main(String[] args) {
        Hospital<Dog> dogs = new Hospital<>(new Dog("d1", 40));
        Dog bigger = dogs.bigger(new Dog("d2", 75));
        dogs.checkup();
        System.out.println("bigger=" + bigger.name());
    }

    private static final class Hospital<T extends Animal> {
        private final T animal;

        Hospital(T animal) {
            this.animal = animal;
        }

        void checkup() {
            System.out.println(animal.name() + ":" + animal.size());
            animal.sound();
        }

        T bigger(T other) {
            return animal.size() >= other.size() ? animal : other;
        }
    }

    private interface Animal {
        String name();

        int size();

        void sound();
    }

    private record Dog(String name, int size) implements Animal {
        public void sound() {
            System.out.println("woof");
        }
    }
}
d1:40
woof
bigger=d2

앞의 프로그램과 달라진 곳은 선언의 extends Animal뿐입니다. 그런데도 checkup()에서 animal.name(), animal.size(), animal.sound()를 모두 호출할 수 있습니다. 상한 덕분에 T가 무엇이든 이 세 메서드가 있다는 것을 컴파일러가 알기 때문입니다.

출력 순서도 한 번 따져 볼 만합니다. main은 bigger()를 먼저 호출합니다. 보관한 d1의 크기 40과 새 d2의 크기 75를 비교하면 40 >= 75가 거짓이므로 d2를 돌려주고, main은 그 결과를 지역 변수 bigger에 담습니다.

이때 병원이 보관한 동물은 바뀌지 않습니다. bigger()는 큰 쪽을 골라 돌려줄 뿐 animal 필드에 대입하지 않고, 애초에 animal은 final이라 다시 대입할 수도 없습니다. 그래서 이어서 호출한 checkup()은 여전히 d1을 검진해 d1:40과 woof를 출력하고, 마지막 줄에서야 main이 받아 둔 d2의 이름이 출력됩니다. 메서드가 돌려준 값과 객체가 보관한 값이 별개라는 점을 아래 그림에서 단계별로 확인해 보세요.

상한이 허용하는 멤버와 실제 예제의 대상

bigger가 d2를 지역 결과로 반환해도 checkup은 저장된 d1을 읽습니다. 실제 main 순서에서 반환값과 저장 상태가 달라지는 점을 추적할 수 있습니다.

상한이 허용하는 멤버와 실제 예제의 대상

제한 없는 Hospital의 animal.size 호출은 컴파일 실패한다. Animal 상한이 있는 예제는 저장한 Dog d1을 checkup하고, bigger는 별도로 Dog d2를 반환한다.

상한 계약 · 검진과 반환

상한이 허용하는 멤버와 실제 예제의 대상

Animal 상한은 공통 멤버를 허용하고 Dog 반환을 유지합니다. checkup은 저장한 d1을 검진하며 bigger가 고른 d2로 저장 값이 바뀌지는 않습니다.

저장 환자와 선택 반환 객체의 별개 관계 Hospital<Dog>의 final animal은 d1 40입니다. 점선은 비교가 저장값을 참조하는 관계이며 bigger는 새 d2 75를 반환할 뿐 animal에 대입하지 않습니다. checkup은 d1:40과 woof를 출력하고 main은 뒤 bigger=d2를 출력합니다. 실선은 해당 읽기·선택·출력의 관계이며 객체의 물리 메모리 배치가 아닙니다. Hospital<Dog>의 저장 final animal 참조 Dog d1 / 크기 40 checkup이 읽는 값 d1:40 출력 woof 출력 bigger의 별도 입력 저장 d1 / 40 새 Dog d2 / 75 선택한 반환 객체 40 >= 75는 false Dog d2 반환 main의 지역 변수 bigger=d2 출력 저장 값은 d1 그대로 점선: 비교가 저장값을 참조 · 실선: checkup 읽기와 bigger 반환의 각 관계 main은 비교 결과를 먼저 받고 checkup 다음 bigger=d2 출력 · 저장에 되돌리는 선 없음
저장 환자와 선택 반환 객체의 별개 관계 Hospital<Dog>의 final animal은 d1 40입니다. 점선은 비교가 저장값을 참조하는 관계이며 bigger는 새 d2 75를 반환할 뿐 animal에 대입하지 않습니다. checkup은 d1:40과 woof를 출력하고 main은 뒤 bigger=d2를 출력합니다. 실선은 해당 읽기·선택·출력의 관계이며 객체의 물리 메모리 배치가 아닙니다. Hospital<Dog>의 저장 final animal 참조 Dog d1 / 크기 40 checkup이 읽는 값 d1:40 출력 woof 출력 bigger의 별도 입력 저장 d1 / 40 새 Dog d2 / 75 선택한 반환 객체 40 >= 75는 false Dog d2 반환 main의 지역 변수 bigger=d2 출력 저장 값은 d1 그대로 점선: 비교가 저장값을 참조 실선: 읽기·선택·결과 출력 반환은 animal 대입이 아님
상한이 허용하는 멤버와 실제 예제의 대상
검사·관찰 항목선언이 보장하는 범위원문 결과
제한 없는 타입
Hospital<T>
T에 size() 계약이 없음
main의 Dog 사용만으로 선언의 상한이 바뀌지 않음
animal.size()
컴파일 실패
상한 안의 검진
checkup()
T extends Animal
저장된 d1의 이름·크기·울음 호출
d1:40
woof
상한 안의 반환
bigger(other)
40 >= 75는 거짓
other인 Dog d2 반환
Dog bigger
bigger=d2
제한 없는 타입
Hospital<T>
선언이 보장하는 범위
T에 size() 계약이 없음
main의 Dog 사용만으로 선언의 상한이 바뀌지 않음
원문 결과
animal.size()
컴파일 실패
상한 안의 검진
checkup()
선언이 보장하는 범위
T extends Animal
저장된 d1의 이름·크기·울음 호출
원문 결과
d1:40
woof
상한 안의 반환
bigger(other)
선언이 보장하는 범위
40 >= 75는 거짓
other인 Dog d2 반환
원문 결과
Dog bigger
bigger=d2

main은 bigger의 반환값을 받은 뒤 checkup()을 호출합니다. 저장된 d1이 d2로 바뀌는 것은 아닙니다.

이 예의 조건

  • bigger는 final animal에 대입하지 않는다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
5Dog d1
6크기 40
11저장 d1
1240
13새 Dog d2
1475
원 도표의 설명과 주석
구분내용
전체 읽기 정보상한이 허용하는 멤버와 실제 예제의 대상 상한이 허용하는 멤버와 실제 예제의 대상 제한 없는 Hospital의 animal.size 호출은 컴파일 실패한다. Animal 상한이 있는 예제는 저장한 Dog d1을 checkup하고, bigger는 별도로 Dog d2를 반환한다. 상한 계약 · 검진과 반환 상한이 허용하는 멤버와 실제 예제의 대상 Animal 상한은 공통 멤버를 허용하고 Dog 반환을 유지합니다. checkup은 저장한 d1을 검진하며 bigger가 고른 d2로 저장 값이 바뀌지는 않습니다. 상한이 허용하는 멤버와 실제 예제의 대상 검사·관찰 항목 선언이 보장하는 범위 원문 결과 제한 없는 타입 Hospital<T> T 에 size() 계약이 없음 main 의 Dog 사용만으로 선언의 상한이 바뀌지 않음 animal.size() 컴파일 실패 상한 안의 검진 checkup() T extends Animal 저장된 d1 의 이름·크기·울음 호출 d1:40 woof 상한 안의 반환 bigger(other) 40 >= 75 는 거짓 other 인 Dog d2 반환 Dog bigger bigger=d2 제한 없는 타입 Hospital<T> 선언이 보장하는 범위 T 에 size() 계약이 없음 main 의 Dog 사용만으로 선언의 상한이 바뀌지 않음 원문 결과 animal.size() 컴파일 실패 상한 안의 검진 checkup() 선언이 보장하는 범위 T extends Animal 저장된 d1 의 이름·크기·울음 호출 원문 결과 d1:40 woof 상한 안의 반환 bigger(other) 선언이 보장하는 범위 40 >= 75 는 거짓 other 인 Dog d2 반환 원문 결과 Dog bigger bigger=d2 main 은 bigger 의 반환값을 받은 뒤 checkup() 을 호출합니다. 저장된 d1 이 d2 로 바뀌는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장과 후보

저장 d1과 새 d2를 비교합니다.

  • animal: d1/40
  • 후보: d2/75
  • 비교: 40≥75 false
  • 선택 함수가 기존 저장값을 입력으로 읽는다.
반환 보관

main의 bigger만 d2를 받습니다.

  • bigger: d2
  • animal: d1 유지
  • 반환 참조와 저장소 갱신은 다른 동작이다.
실제 관찰 순서

checkup 뒤 main이 bigger를 출력합니다.

  • checkup: d1:40; woof
  • main: bigger=d2
  • 저장된 환자와 선택 결과가 각 호출에서 다르게 보인다.

상한이 허용하는 멤버와 실제 예제의 대상

bigger가 d2를 지역 결과로 반환해도 checkup은 저장된 d1을 읽습니다. 실제 main 순서에서 반환값과 저장 상태가 달라지는 점을 추적할 수 있습니다.

상한이 허용하는 멤버와 실제 예제의 대상

제한 없는 Hospital의 animal.size 호출은 컴파일 실패한다. Animal 상한이 있는 예제는 저장한 Dog d1을 checkup하고, bigger는 별도로 Dog d2를 반환한다.

상한 계약 · 검진과 반환

상한이 허용하는 멤버와 실제 예제의 대상

Animal 상한은 공통 멤버를 허용하고 Dog 반환을 유지합니다. checkup은 저장한 d1을 검진하며 bigger가 고른 d2로 저장 값이 바뀌지는 않습니다.

저장 환자와 선택 반환 객체의 별개 관계 Hospital<Dog>의 final animal은 d1 40입니다. 점선은 비교가 저장값을 참조하는 관계이며 bigger는 새 d2 75를 반환할 뿐 animal에 대입하지 않습니다. checkup은 d1:40과 woof를 출력하고 main은 뒤 bigger=d2를 출력합니다. 실선은 해당 읽기·선택·출력의 관계이며 객체의 물리 메모리 배치가 아닙니다. Hospital<Dog>의 저장 final animal 참조 Dog d1 / 크기 40 checkup이 읽는 값 d1:40 출력 woof 출력 bigger의 별도 입력 저장 d1 / 40 새 Dog d2 / 75 선택한 반환 객체 40 >= 75는 false Dog d2 반환 main의 지역 변수 bigger=d2 출력 저장 값은 d1 그대로 점선: 비교가 저장값을 참조 · 실선: checkup 읽기와 bigger 반환의 각 관계 main은 비교 결과를 먼저 받고 checkup 다음 bigger=d2 출력 · 저장에 되돌리는 선 없음
저장 환자와 선택 반환 객체의 별개 관계 Hospital<Dog>의 final animal은 d1 40입니다. 점선은 비교가 저장값을 참조하는 관계이며 bigger는 새 d2 75를 반환할 뿐 animal에 대입하지 않습니다. checkup은 d1:40과 woof를 출력하고 main은 뒤 bigger=d2를 출력합니다. 실선은 해당 읽기·선택·출력의 관계이며 객체의 물리 메모리 배치가 아닙니다. Hospital<Dog>의 저장 final animal 참조 Dog d1 / 크기 40 checkup이 읽는 값 d1:40 출력 woof 출력 bigger의 별도 입력 저장 d1 / 40 새 Dog d2 / 75 선택한 반환 객체 40 >= 75는 false Dog d2 반환 main의 지역 변수 bigger=d2 출력 저장 값은 d1 그대로 점선: 비교가 저장값을 참조 실선: 읽기·선택·결과 출력 반환은 animal 대입이 아님
상한이 허용하는 멤버와 실제 예제의 대상
검사·관찰 항목선언이 보장하는 범위원문 결과
제한 없는 타입
Hospital<T>
T에 size() 계약이 없음
main의 Dog 사용만으로 선언의 상한이 바뀌지 않음
animal.size()
컴파일 실패
상한 안의 검진
checkup()
T extends Animal
저장된 d1의 이름·크기·울음 호출
d1:40
woof
상한 안의 반환
bigger(other)
40 >= 75는 거짓
other인 Dog d2 반환
Dog bigger
bigger=d2
제한 없는 타입
Hospital<T>
선언이 보장하는 범위
T에 size() 계약이 없음
main의 Dog 사용만으로 선언의 상한이 바뀌지 않음
원문 결과
animal.size()
컴파일 실패
상한 안의 검진
checkup()
선언이 보장하는 범위
T extends Animal
저장된 d1의 이름·크기·울음 호출
원문 결과
d1:40
woof
상한 안의 반환
bigger(other)
선언이 보장하는 범위
40 >= 75는 거짓
other인 Dog d2 반환
원문 결과
Dog bigger
bigger=d2

main은 bigger의 반환값을 받은 뒤 checkup()을 호출합니다. 저장된 d1이 d2로 바뀌는 것은 아닙니다.

이 예의 조건

  • bigger는 final animal에 대입하지 않는다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
5Dog d1
6크기 40
11저장 d1
1240
13새 Dog d2
1475
원 도표의 설명과 주석
구분내용
전체 읽기 정보상한이 허용하는 멤버와 실제 예제의 대상 상한이 허용하는 멤버와 실제 예제의 대상 제한 없는 Hospital의 animal.size 호출은 컴파일 실패한다. Animal 상한이 있는 예제는 저장한 Dog d1을 checkup하고, bigger는 별도로 Dog d2를 반환한다. 상한 계약 · 검진과 반환 상한이 허용하는 멤버와 실제 예제의 대상 Animal 상한은 공통 멤버를 허용하고 Dog 반환을 유지합니다. checkup은 저장한 d1을 검진하며 bigger가 고른 d2로 저장 값이 바뀌지는 않습니다. 상한이 허용하는 멤버와 실제 예제의 대상 검사·관찰 항목 선언이 보장하는 범위 원문 결과 제한 없는 타입 Hospital<T> T 에 size() 계약이 없음 main 의 Dog 사용만으로 선언의 상한이 바뀌지 않음 animal.size() 컴파일 실패 상한 안의 검진 checkup() T extends Animal 저장된 d1 의 이름·크기·울음 호출 d1:40 woof 상한 안의 반환 bigger(other) 40 >= 75 는 거짓 other 인 Dog d2 반환 Dog bigger bigger=d2 제한 없는 타입 Hospital<T> 선언이 보장하는 범위 T 에 size() 계약이 없음 main 의 Dog 사용만으로 선언의 상한이 바뀌지 않음 원문 결과 animal.size() 컴파일 실패 상한 안의 검진 checkup() 선언이 보장하는 범위 T extends Animal 저장된 d1 의 이름·크기·울음 호출 원문 결과 d1:40 woof 상한 안의 반환 bigger(other) 선언이 보장하는 범위 40 >= 75 는 거짓 other 인 Dog d2 반환 원문 결과 Dog bigger bigger=d2 main 은 bigger 의 반환값을 받은 뒤 checkup() 을 호출합니다. 저장된 d1 이 d2 로 바뀌는 것은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
저장과 후보

저장 d1과 새 d2를 비교합니다.

  • animal: d1/40
  • 후보: d2/75
  • 비교: 40≥75 false
  • 선택 함수가 기존 저장값을 입력으로 읽는다.
반환 보관

main의 bigger만 d2를 받습니다.

  • bigger: d2
  • animal: d1 유지
  • 반환 참조와 저장소 갱신은 다른 동작이다.
실제 관찰 순서

checkup 뒤 main이 bigger를 출력합니다.

  • checkup: d1:40; woof
  • main: bigger=d2
  • 저장된 환자와 선택 결과가 각 호출에서 다르게 보인다.

상한이 있다고 해서 T가 Animal로 바뀌는 것은 아닙니다. dogs는 Hospital<Dog>이므로 bigger()의 반환 타입은 여전히 Dog이고, 그래서 Dog bigger = dogs.bigger(...)가 캐스팅 없이 컴파일됩니다. 클래스 안에서는 T를 Animal처럼 다루고, 클래스 밖에서는 사용하는 쪽이 고른 Dog가 그대로 유지되는 것입니다. 상한 대신 필드와 매개변수를 모두 Animal로 선언했다면 앞 글에서 본 것처럼 반환값을 Dog로 캐스팅해야 했을 것입니다.

상한은 클래스를 사용하는 쪽도 제한합니다. 같은 프로그램의 main에 Hospital<String> wrong = new Hospital<>("text");를 추가하면 다음 오류가 납니다.

컴파일 오류의 핵심
error: type argument String is not within bounds of type-variable T

'타입 인자 String이 타입 변수 T의 범위 안에 있지 않다'는 뜻입니다. 같은 이유로 다이아몬드 <>의 추론도 실패해 cannot infer type arguments for Hospital<> 오류가 하나 더 나옵니다. 상한이 없을 때 막혔던 것은 클래스 본문이었는데, 상한을 두자 잘못된 타입 인자를 쓰는 바깥 코드가 대신 막힙니다. 쓰는 쪽이 지켜야 할 조건을 선언에 적어 두었기 때문에, 본문은 그 조건을 믿고 Animal의 메서드를 쓸 수 있는 것입니다.

인터페이스 상한과 여러 개의 상한

Animal은 클래스가 아니라 인터페이스인데도 implements가 아니라 extends라고 썼습니다. 상한 문법의 extends는 '상속한다'가 아니라 '하위 타입이다'라는 뜻이라서, 상한이 클래스든 인터페이스든 언제나 extends를 씁니다.

상한을 여러 개 두려면 &로 잇습니다. <T extends Number & Comparable<T>>는 T가 Number의 하위 타입이면서 Comparable<T>도 구현해야 한다는 뜻입니다. 이때 클래스는 하나만 올 수 있고 반드시 맨 앞에 두어야 하며, 그 뒤에는 인터페이스만 옵니다. 순서를 바꿔 <T extends Comparable<T> & Number>라고 쓰면 Number 자리에서 interface expected here 오류가 납니다. 클래스는 부모를 하나만 가질 수 있다는 규칙이 상한에도 그대로 적용되는 셈입니다.

메서드 하나에만 타입 매개변수 두기

지금까지의 T는 클래스에 선언한 것이었습니다. Hospital<Dog> 객체를 만들 때 T가 Dog로 정해지고, 그 객체의 필드와 모든 메서드가 같은 T를 함께 씁니다. 그런데 '받은 값을 그대로 돌려준다'나 '둘 중 큰 쪽을 돌려준다'처럼, 한 번의 호출 안에서만 타입이 이어지면 되는 경우도 많습니다. 이럴 때는 메서드에 타입 매개변수를 선언하는데, 이런 메서드를 제네릭 메서드라고 합니다.

src/GenericMethodScope.java
public final class GenericMethodScope {
    public static void main(String[] args) {
        Integer viewCount = identity(50);
        String title = identity("제네릭 메서드 질문");
        Double larger = largerNumber(10.5, 8.0);

        System.out.println(title + "=" + viewCount);
        System.out.println("larger=" + larger);
    }

    private static <T> T identity(T value) {
        return value;
    }

    private static <T extends Number & Comparable<T>> T largerNumber(T left, T right) {
        return left.compareTo(right) >= 0 ? left : right;
    }
}
제네릭 메서드 질문=50
larger=10.5

제네릭 메서드는 반환 타입 바로 앞에 <T>를 적어 타입 매개변수를 선언합니다. private static <T> T identity(T value)에서 앞의 <T>는 'T라는 타입 매개변수를 이 메서드에서 쓰겠다'는 선언이고, 그 뒤의 T가 반환 타입입니다. 이 선언을 빼고 private static T identity(T value)라고 쓰면, 컴파일러는 T를 T라는 이름의 클래스로 보고 cannot find symbol 오류를 냅니다.

제네릭 메서드의 T는 호출할 때마다 새로 정해집니다. 첫 호출 identity(50)에서는 int 50이 오토박싱되어 T가 Integer가 되고, 둘째 호출에서는 문자열을 넘겼으니 T가 String이 됩니다. 두 호출은 서로 영향을 주지 않으므로 같은 메서드가 한 번은 Integer를, 한 번은 String을 돌려줍니다. 출력의 첫 줄은 둘째 호출의 결과 뒤에 =와 첫 호출의 결과를 이어 붙인 것입니다.

largerNumber는 상한을 두 개 둔 제네릭 메서드입니다. Number 상한은 숫자만 받겠다는 뜻이고, Comparable<T> 상한은 같은 T끼리 compareTo로 크기를 비교할 수 있어야 한다는 뜻입니다. 10.5와 8.0은 둘 다 Double이 되고 Double은 Comparable<Double>을 구현하므로 T는 Double입니다. 10.5의 compareTo(8.0)이 양수를 돌려주니 left인 10.5가 선택되어 둘째 줄이 larger=10.5가 됩니다.

타입 인자를 직접 적을 수도 있습니다. 이때는 GenericMethodScope.<Integer>identity(50)처럼 점 뒤, 메서드 이름 앞에 꺾쇠를 둡니다. 클래스 이름이나 this 같은 수신자 없이 <Integer>identity(50)이라고만 쓰면 illegal start of expression 문법 오류가 납니다. 대부분은 인수와 결과를 받는 변수만으로 컴파일러가 T를 정할 수 있으므로, 직접 적는 것은 추론 결과가 의도와 다를 때로 한정하는 편이 읽기 쉽습니다.

컴파일러가 T를 고르는 과정: 타입 추론

호출할 때 타입 인자를 적지 않았는데도 컴파일러가 T를 정할 수 있는 것은 타입 추론 덕분입니다. 컴파일러는 인수의 타입, 메서드에 선언한 상한, 결과를 받는 변수의 타입을 모두 모아 이 조건을 동시에 만족하는 T를 찾습니다. 결과를 받는 쪽이 기대하는 타입을 목표 타입(target type)이라고 합니다. 인수가 모두 같은 타입이면 답이 분명하므로, 눈여겨볼 것은 인수의 타입이 서로 다를 때입니다.

src/GenericAnimalMethodInference.java
public final class GenericAnimalMethodInference {
    public static void main(String[] args) {
        Dog first = new Dog("d1", 40, "A");
        Dog second = new Dog("d2", 60, "B");

        Dog bigger = bigger(first, second);
        Animal mixed = bigger(first, new Cat("c1", 80));

        System.out.println("dog-code=" + bigger.code());
        System.out.println("mixed=" + mixed.name());
    }

    private static <T extends Animal> T bigger(T left, T right) {
        return left.size() >= right.size() ? left : right;
    }

    private interface Animal {
        String name();

        int size();
    }

    private record Dog(String name, int size, String code) implements Animal {}

    private record Cat(String name, int size) implements Animal {}
}
dog-code=B
mixed=c1

bigger(first, second)에는 Dog 두 개가 넘어가므로 T는 Dog이고 반환 타입도 Dog입니다. 그래서 Animal에는 없는 code()를 캐스팅 없이 호출할 수 있습니다. 40과 60을 비교해 d2가 선택되므로 첫 줄은 dog-code=B입니다.

두 번째 호출에는 Dog와 Cat이 함께 넘어갑니다. T 하나로 두 인수를 모두 받으려면 T는 Dog와 Cat의 공통 상위 타입이어야 하므로, 컴파일러는 두 타입을 모두 포함하는 타입을 T로 고릅니다. 반환값도 그 타입이니 이 예제는 결과를 Animal 변수로 받았고, 크기가 더 큰 c1이 선택되어 mixed=c1이 출력됩니다. 결과를 Dog 변수로 받으려고 하면 다음과 같이 거부됩니다.

Dog wrong = bigger(first, new Cat("c1", 80));
컴파일 오류의 핵심
error: incompatible types: inference variable T has incompatible bounds
    upper bounds: Dog,Animal
    lower bounds: Cat,Dog

upper bounds는 T가 넘지 말아야 할 위쪽 한계입니다. 결과를 Dog 변수에 담으려면 T는 Dog의 하위 타입이어야 하고, 선언의 상한 때문에 Animal의 하위 타입이어야 합니다. lower bounds는 T가 적어도 포함해야 할 아래쪽 한계입니다. 인수로 받은 Cat과 Dog를 모두 받으려면 T는 두 타입의 상위 타입이어야 합니다. Cat의 상위 타입이면서 Dog의 하위 타입인 타입은 없으므로 추론이 실패합니다. 메시지가 길어 보여도 '위로는 어디까지, 아래로는 어디부터'라는 두 목록을 비교하면 원인을 찾을 수 있습니다.

이 결과에서 알 수 있듯이 <T extends Animal>은 '두 인수가 같은 종이어야 한다'는 조건이 아닙니다. 상한은 T의 위쪽 한계만 정하므로, 서로 다른 종을 넘기면 T가 공통 상위 타입으로 넓어질 뿐 호출 자체는 거부되지 않습니다. 같은 종끼리만 비교해야 한다면 호출할 때 GenericAnimalMethodInference.<Dog>bigger(...)처럼 타입 인자를 적거나, Hospital<Dog>처럼 객체를 만들 때 T를 고정하는 제네릭 클래스를 씁니다. 두 경우 모두 고양이를 넘기면 Cat cannot be converted to Dog 오류가 납니다.

메서드 호출과 결과를 받는 타입

identity의 Integer와 String 호출은 독립적이다. Dog 두 개를 비교한 결과는 Dog로, Dog와 Cat을 비교한 결과는 Animal 변수로 받으며 실제로 선택된 값은 Cat c1이다.

호출별 추론 · 선언된 결과

메서드 호출과 결과를 받는 타입

identity 호출은 각각 독립적으로 타입을 정합니다. Dog 두 값은 Dog로 받고 Dog·Cat 비교 결과는 Animal 변수로 받습니다.

호출별 타입 관계와 실제 선택 객체 독립 identity 호출은 Integer 50과 String을 각각 반환합니다. bigger의 두 Dog 호출은 Dog 결과 d2에서 code=B를 읽습니다. Dog와 Cat 호출은 Animal 결과로 받으며 실제 더 큰 객체는 Cat c1입니다. 실선은 해당 호출의 입력·반환·출력 관계이며 상한이 같은 종만 받도록 제한한다는 뜻이 아닙니다. 원문 A · 호출별 T identity 두 호출 Integer 50 String 제네릭 메서드 질문 각 반환 타입 Integer와 String 호출마다 따로 결정 출력 연결 제네릭 메서드 질문=50 원문 B · 두 Dog 인수 두 개 d1 / 40 / A d2 / 60 / B Dog 결과로 받기 40 >= 60은 false d2 선택 Dog 고유 멤버 dog-code=B 원문 B · Dog와 Cat 인수의 공통 상한 Dog d1 / 40 Cat c1 / 80 Animal 결과로 받기 40 >= 80은 false 실제 객체는 Cat 부모 멤버 출력 mixed=c1 실선: 각 호출의 입력·반환·출력 정적 반환 타입과 객체 선택 구별 같은 종만 받는다는 계약 아님
호출별 타입 관계와 실제 선택 객체 독립 identity 호출은 Integer 50과 String을 각각 반환합니다. bigger의 두 Dog 호출은 Dog 결과 d2에서 code=B를 읽습니다. Dog와 Cat 호출은 Animal 결과로 받으며 실제 더 큰 객체는 Cat c1입니다. 실선은 해당 호출의 입력·반환·출력 관계이며 상한이 같은 종만 받도록 제한한다는 뜻이 아닙니다. 원문 A · 호출별 T identity 두 호출 Integer 50 String 제네릭 메서드 질문 각 반환 타입 Integer와 String 호출마다 따로 결정 출력 연결 제네릭 메서드 질문=50 원문 B · 두 Dog 인수 두 개 d1 / 40 / A d2 / 60 / B Dog 결과로 받기 40 >= 60은 false d2 선택 Dog 고유 멤버 dog-code=B 원문 B · Dog와 Cat 인수의 공통 상한 Dog d1 / 40 Cat c1 / 80 Animal 결과로 받기 40 >= 80은 false 실제 객체는 Cat 부모 멤버 출력 mixed=c1 실선: 각 호출의 입력·반환·출력 정적 반환 타입과 객체 선택 구별 같은 종만 받는다는 계약 아님
메서드 호출과 결과를 받는 타입
원문 호출결과를 받는 변수그 변수의 값
identity(50)Integer viewCount50
문자열 identity 호출String title제네릭 메서드 질문
bigger(first, second)
Dog 40 · Dog 60
Dog bigger
d2
code() → B
bigger(first, new Cat(...))
Dog 40 · Cat 80
Animal mixed
실제 값은 Cat c1
name() → c1
identity(50)
결과를 받는 변수
Integer viewCount
그 변수의 값
50
문자열 identity 호출
결과를 받는 변수
String title
그 변수의 값
제네릭 메서드 질문
bigger(first, second)
Dog 40 · Dog 60
결과를 받는 변수
Dog bigger
그 변수의 값
d2
code() → B
bigger(first, new Cat(...))
Dog 40 · Cat 80
결과를 받는 변수
Animal mixed
그 변수의 값
실제 값은 Cat c1
name() → c1

마지막 행은 Animal로 선언된 변수에 결과를 받는 코드입니다. 메서드가 두 인수의 런타임 종을 같게 강제한다는 뜻은 아닙니다.

더 깊이: 공통 상위 타입이 여러 개일 때

Dog와 Cat의 공통 상위 타입은 Animal만이 아닙니다. 둘 다 레코드이므로 java.lang.Record도 공통 상위 타입이고, Object도 그렇습니다. 컴파일러는 이 가운데 하나를 고르지 않고 인수 타입들의 최소 상위 타입(least upper bound)을 구하는데, 이 예제에서는 'Record이면서 Animal인 타입', 즉 교차 타입(intersection type) Record & Animal이 T가 됩니다. var mixed = bigger(first, new Cat("c1", 80));로 받아 보면 mixed를 Animal 변수에도 Record 변수에도 캐스팅 없이 대입할 수 있다는 것으로 확인할 수 있습니다. Dog와 Cat을 레코드가 아닌 일반 클래스로 바꾸면 공통 상위 타입은 Animal과 Object뿐이므로 Record 변수에 대입하는 줄이 컴파일 오류가 됩니다. 교차 타입은 변수 선언에 직접 쓸 수는 없지만, 이처럼 추론 결과나 var의 타입으로는 나타날 수 있습니다.

클래스의 T와 메서드의 R 구분하기

제네릭 클래스 안에도 제네릭 메서드를 둘 수 있습니다. 그러면 객체를 만들 때 정해지는 클래스의 타입 매개변수와 호출할 때마다 정해지는 메서드의 타입 매개변수가 한 메서드에 함께 나타납니다. 다음 Converter<T>는 값 하나를 보관하고, convert는 그 값을 받은 함수로 변환한 결과를 돌려줍니다.

src/GenericCallScopes.java
import java.util.function.Function;

public final class GenericCallScopes {
    public static void main(String[] args) {
        Converter<String> source = new Converter<>("41");
        Integer number = source.convert(Integer::parseInt);
        String label = source.convert(value -> "id=" + value);
        System.out.println("number=" + number);
        System.out.println("label=" + label);
        System.out.println("source=" + source.value());
    }

    private record Converter<T>(T value) {
        <R> R convert(Function<T, R> operation) {
            return operation.apply(value);
        }
    }
}
number=41
label=id=41
source=41

convert의 매개변수 타입 Function<T, R>은 java.util.function 패키지의 인터페이스로, T 값 하나를 받아 R 값을 돌려주는 apply 메서드를 가지고 있습니다. Integer::parseInt는 '문자열을 받아 Integer.parseInt를 호출하는 함수'를 짧게 쓴 메서드 참조(method reference)이고, value -> "id=" + value는 익명 클래스로 동작 전달하기에서 본 람다입니다. 두 문법과 함수형 인터페이스는 람다와 함수형 인터페이스 장에서 자세히 다루므로, 여기서는 둘 다 apply의 구현을 넘긴다는 것만 알면 됩니다.

source는 Converter<String>이므로 T는 이 객체가 살아 있는 동안 계속 String입니다. 반면 R은 호출마다 정해집니다. 첫 호출에서는 Integer::parseInt가 Integer를 돌려주므로 R이 Integer이고, 둘째 호출에서는 람다가 문자열을 만들므로 R이 String입니다. 변환 결과를 돌려준다고 source가 보관한 값이나 그 타입이 바뀌지는 않으므로, 마지막 줄은 여전히 source=41입니다.

그렇다면 메서드의 타입 매개변수에 클래스의 것과 같은 이름을 붙이면 어떻게 될까요? 문법으로는 허용되지만, 안쪽에 선언한 T가 바깥의 T를 가립니다. 이름은 같아도 서로 다른 두 타입이 되는 것입니다.

lab/ShadowedTypeParameter.java
import java.util.function.Function;

public final class ShadowedTypeParameter {
    private record Converter<T>(T value) {
        <T> T convert(Function<T, T> operation) {
            return operation.apply(value);
        }
    }
}
컴파일 오류
lab/ShadowedTypeParameter.java:6: error: incompatible types: T#1 cannot be converted to T#2
            return operation.apply(value);
                                   ^
  where T#1,T#2 are type-variables:
    T#1 extends Object declared in record Converter
    T#2 extends Object declared in method <T#2>convert(Function<T#2,T#2>)
Note: Some messages have been simplified; recompile with -Xdiags:verbose to get full output
1 error

컴파일러는 두 T를 구분하려고 T#1과 T#2라는 이름을 붙였습니다. T#1은 레코드 Converter에 선언한 T, 즉 보관한 value의 타입이고, T#2는 메서드 convert에 선언한 T입니다. 메서드 안에서 T라고 쓰면 가까운 쪽인 T#2를 가리키므로, operation.apply는 T#2를 기대하는데 value는 T#1이라서 넘길 수 없습니다. 같은 이름은 같은 타입처럼 보여 읽는 사람만 헷갈리게 하니, 메서드의 타입 매개변수에는 R이나 U처럼 다른 이름을 붙이세요.

제네릭 클래스와 제네릭 메서드 중에서 고르기

타입 관계가 객체가 살아 있는 동안 유지되어야 한다면 제네릭 클래스를 씁니다. Box<T>나 Hospital<T>처럼 필드와 여러 메서드가 같은 T를 함께 쓰는 경우입니다. 반대로 한 번의 호출 안에서 인수와 반환값만 이어 주면 된다면 제네릭 메서드가 더 작고 직접적입니다. 클래스에 타입 매개변수를 늘리지 않아도 되고, 호출할 때마다 다른 타입을 쓸 수 있기 때문입니다.

static 메서드라면 고민할 필요도 없습니다. 클래스의 T는 객체마다 정해지는 것이라 객체 없이 호출하는 static 메서드에서는 쓸 수 없고, 쓰려고 하면 non-static type variable T cannot be referenced from a static context 오류가 납니다. 그래서 identity나 largerNumber 같은 유틸리티 메서드는 자기 타입 매개변수를 직접 선언합니다.

상한은 구현이 실제로 호출하는 메서드만큼만 요구하는 것이 좋습니다. bigger가 쓰는 것은 size() 하나인데 상한 타입에 이름과 소리, 품종까지 몰아넣으면, 크기만 가진 다른 타입은 이 메서드를 쓸 수 없게 됩니다. 필요한 기능이 여러 가지라면 큰 부모 타입 하나를 만들기보다 Sized, Named처럼 작은 인터페이스를 두고 <T extends Sized & Named>로 조합할 수 있습니다. 다만 상한이 너무 복잡해져 호출하는 쪽이 긴 타입을 이해해야 할 정도라면, 그 조합에 이름을 붙인 인터페이스를 따로 두는 편이 낫습니다.

정리

  • 상한이 없는 T는 Object의 하위 타입이라는 것만 알려져 있어서, 클래스 본문에서 Object의 메서드밖에 호출할 수 없습니다. 컴파일러는 실제로 쓰는 타입 인자가 아니라 선언만 보고 검사합니다.
  • <T extends Animal>은 T 자리에 Animal과 그 하위 타입만 오게 하고, 그 대가로 본문에서 Animal의 메서드를 쓸 수 있게 합니다. 바깥에서 보는 T는 사용하는 쪽이 고른 타입 그대로입니다.
  • 상한이 클래스든 인터페이스든 extends를 쓰고, 여러 개는 &로 잇되 클래스는 하나만 맨 앞에 둡니다.
  • 제네릭 메서드는 반환 타입 앞에 <T>를 선언하며, T는 호출할 때마다 인수와 목표 타입으로부터 추론됩니다. 서로 다른 타입을 넘기면 T가 공통 상위 타입으로 넓어질 수 있습니다.
  • 객체 전체가 같은 타입을 공유하면 제네릭 클래스를, 한 번의 호출 안에서만 타입이 이어지면 제네릭 메서드를 씁니다. 메서드의 타입 매개변수에는 클래스와 다른 이름을 붙입니다.

연습 문제

문제 1: 점수가 높은 게시글 고르기

score()를 가진 Scored 인터페이스와, postId(long)와 score(int)를 가진 레코드 ScoredPost를 만드세요. 그리고 <T extends Scored> T max(T left, T right)를 작성해 점수가 큰 쪽을 돌려주게 합니다. 점수가 같으면 left를 돌려줍니다. ScoredPost(101L, 80)과 ScoredPost(102L, 95)를 넘기고, 돌려받은 객체에서 형변환 없이 postId()를 호출해 winner=102와 score=95를 출력하세요. 두 번째 게시글의 점수를 80으로 바꾸면 출력이 어떻게 달라지는지도 예상해 보세요.

해설 보기
src/BoundedGenericMethodExercise.java
public final class BoundedGenericMethodExercise {
    public static void main(String[] args) {
        ScoredPost first = new ScoredPost(101L, 80);
        ScoredPost second = new ScoredPost(102L, 95);

        ScoredPost result = max(first, second);
        System.out.println("winner=" + result.postId());
        System.out.println("score=" + result.score());
    }

    private static <T extends Scored> T max(T left, T right) {
        return left.score() >= right.score() ? left : right;
    }

    private interface Scored { int score(); }
    private record ScoredPost(long postId, int score) implements Scored { }
}
winner=102
score=95

max의 상한 Scored는 비교에 필요한 score()만 요구합니다. 그러면서도 반환 타입이 T이므로, ScoredPost 두 개를 넘기면 T가 ScoredPost로 추론되어 Scored에는 없는 postId()를 캐스팅 없이 호출할 수 있습니다. 비교 한 번의 입력과 출력만 이어 주면 되므로 클래스가 아니라 메서드에 타입 매개변수를 두는 것이 알맞습니다.

두 번째 점수를 80으로 바꾸면 80 >= 80이 참이므로 left인 101번이 선택되어 winner=101과 score=80이 출력됩니다. 어느 쪽이 선택되든 두 인수가 모두 ScoredPost이므로 T는 그대로 ScoredPost입니다.

문제 2: 컴파일되는 호출 고르기

본문의 largerNumber를 그대로 쓸 때, 다음 세 호출 가운데 컴파일되는 것과 그 결과를 말하세요. 컴파일되지 않는 호출은 이유를 설명하세요.

largerNumber(3, 7)
largerNumber(10, 8.5)
largerNumber("a", "b")
해설 보기

첫 호출만 컴파일되고 결과는 7입니다. 3과 7은 모두 Integer로 오토박싱되고, Integer는 Number이면서 Comparable<Integer>를 구현하므로 T는 Integer입니다. 3의 compareTo(7)이 음수이므로 right인 7이 선택됩니다.

둘째 호출에서는 10이 Integer, 8.5가 Double이 됩니다. 두 인수를 T 하나로 받으려면 T가 두 타입을 모두 포함해야 하는데, Comparable<T> 상한은 T가 자기 자신과 비교할 수 있어야 한다고 요구합니다. Integer는 Integer하고만, Double은 Double하고만 비교할 수 있으므로 두 조건을 함께 만족하는 T가 없습니다. 그래서 컴파일러는 method largerNumber ... cannot be applied to given types 오류를 내면서 그 이유로 inference variable T has incompatible bounds를 들고, equality constraints: Integer,Double이라는 줄로 T가 Integer이면서 동시에 Double이어야 하는 모순을 알려 줍니다.

셋째 호출의 String은 Comparable<String>이긴 하지만 Number가 아니라서 상한을 만족하지 못합니다. 이때 오류 메시지에는 upper bounds: Number,Comparable<T>와 lower bounds: String이 나옵니다.

문제 3: 제네릭 클래스와 제네릭 메서드의 차이

Hospital<Dog> dogs가 있을 때 dogs.bigger(new Cat("c1", 80))과, 본문의 제네릭 메서드를 쓴 bigger(new Dog("d1", 40, "A"), new Cat("c1", 80))은 둘 다 개와 고양이를 비교하려는 호출입니다. 각각 컴파일되는지, 컴파일된다면 결과를 어떤 타입으로 받을 수 있는지 설명하세요.

해설 보기

첫 호출은 컴파일되지 않습니다. dogs를 Hospital<Dog>로 만든 순간 이 객체의 T는 Dog로 정해졌고, bigger(T other)는 Dog만 받습니다. 그래서 고양이를 넘기면 Cat cannot be converted to Dog 오류가 납니다.

둘째 호출은 컴파일됩니다. 제네릭 메서드의 T는 호출할 때마다 추론되는데, 개와 고양이를 함께 받을 수 있도록 공통 상위 타입으로 정해지기 때문입니다. 결과는 Animal 변수로 받을 수 있지만 Dog 변수로는 받을 수 없습니다. 같은 종끼리만 비교하는 규칙이 필요하다면 T를 객체에 고정하는 제네릭 클래스가 더 알맞고, 제네릭 메서드라면 호출할 때 타입 인자를 직접 적어야 합니다.

다음 글

와일드카드와 PECS에서는 Box<Dog>와 Box<Cat>을 함께 받는 메서드를 만듭니다. 타입 매개변수를 선언하는 대신 ?로 '어떤 타입'을 나타내는 와일드카드를 쓰고, ? extends와 ? super가 각각 값을 꺼내는 일과 넣는 일을 어디까지 허용하는지 살펴봅니다.