JAVA/제네릭(Generic), 컬렉션(Collection Framework)

[제네릭] 제네릭 문법 정리 : 타입 제한, 메서드, 와일드카드

jiyoon0000 2025. 4. 1. 20:17
타입 제한(Type Bound)
T extends

 

* 타입 제한이 필요한 이유?

제네릭은 타입을 일반화해 다양한 상황에 유연하게 대응할 수 있도록 도와주는 문법이지만 모든 타입을 무제한으로 허용하면, 오히려 필요한 기능을 사용하지 못해 타입 안정성을 해칠 수 있음

 

1. 기존 문제 -> 타입마다 클래스 생성

  • 타입마다 클래스를 새로 생성 -> 구조는 같고 타입만 다름
  • 결론 : 코드 재사용 X, 타입 안전성 O
public class CatHospital {

    private Cat animal;

    public void set(Cat animal) {
        this.animal = animal;
    }

    public void checkup() {
        System.out.println("동물 이름 : " + animal.getName());
        System.out.println("동물 크기 : " + animal.getSize());
        animal.sound();
    }

    public Cat bigger(Cat target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

2. 다형성 -> 공통 상위 타입으로 해결

  • 공통 부모를 만들어서 재사용성 확보
  • 하지만 항상 부모 타입 반환 -> 실제 사용 시 다운 캐스팅해야함
  • 결론 : 코드 재사용 O, 타입 안전성 X
public class AnimalHospitalV1 {

    private Animal animal;

    public void set(Animal animal) {
        this.animal = animal;
    }

    public void checkup(){
        System.out.println("동물 이름 : " + animal.getName());
        System.out.println("동물 크기 : " + animal.getSize());
        animal.sound();
    }

    public Animal bigger(Animal target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

3. 제네릭으로 개선 시도

  • T를 사용해서 다양한 타입에 대응
    • T가 어떤 타입인지 모르기 때문에 getSize() 같은 메서드 호출 불가
  • 타입 지정 가능 -> 컴파일 시점에 오류 방지
  • 형변환 필요 없음 -> 타입 안정성 확보
  • 다양한 타입을 처리 가능 -> 재사용성 확보
  • 결론 : 재사용성은 있지만 기능을 사용할 수 없음 -> 활용에 제한이 있음
public class AnimalHospitalV2<T> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup(){
        // T의 타입을 메서드를 정의하는 시점에서는 알 수 없음. Object의 기능만 사용
        animal.toString();
        animal.equals(null);

        //컴파일 오류
//        System.out.println("동물 이름 : " + animal.getName());
//        System.out.println("동물 크기 : " + animal.getSize());
//        animal.sound();
    }

    public T bigger(T target) {
//        return animal.getSize() > target.getSize() ? animal : target;
        return null;
    }
}

4. 타입 매개변수 제한 -> 제네릭 + 타입 정보 보장 (T extends 상위 타입)

제네릭으로 타입 안정성은 확보했지만, T가 어떤 타입인지 알 수 없어 원하는 기능을 호출할 수 없는 문제를 해결하기 위해 제네릭 타입에 상한(extends) 설정

  • 결론
  • T는 상위타입을 상속한 타입만 사용 가능
  • 상위타입의 메서드를 컴파일 시점에 안전하게 호출 가능
  • 반환 타입도 형변환 없이 사용 가능
  • 즉, 기존의 문제(기능 호출 불가, 타입 불일치 오류) 모두 해결 가능
public class AnimalHospitalV3<T extends Animal> {

    private T animal;

    public void set(T animal) {
        this.animal = animal;
    }

    public void checkup(){
        System.out.println("동물 이름 : " + animal.getName());
        System.out.println("동물 크기 : " + animal.getSize());
        animal.sound();
    }

    public T bigger(T target) {
        return animal.getSize() > target.getSize() ? animal : target;
    }
}

타입 제한 방식
-추가 공부

 

* 제네릭 타입 제한

제네릭 타입 매개변수는 T처럼 자유롭게 설정할 수 있지만, 필요한 기능을 사용할 수 없고 코드 안정성이 떨어질 수도 있음

이런 문제를 방지하기 위해 자바에서는 타입 매개변수에 제한을 설정하여 제네릭 타입 제한으로 타입 안정성 확보 + 기능 호출 보장을 제공

 

1. 제한 없음(기본 제네릭 - Unbounded Type)

  • 가장 기본적인 형태의 제네릭
  • 어떤 타입이든 사용할 수 있지만, T가 어떤 기능을 지원하는지 컴파일러가 알 수 없음
  • 즉, T에 정의된 메서드를 직접 호출할 수 없음
  • 사용 : 단순 저장, 조회 용도의 유틸 클래스
class Box<T> {
    private T t;
    public void set(T t) { 
    	this.t = t; 
    }
    public T get() {
    	return t;
    }
}

2. 클래스 하나로 제한(T extends 클래스)

  • T는 반드시 상위 클래스를 상속한 타입이어야 함
  • 컴파일러는 T가 상위 클래스라는 공통 기능을 제공한다고 인식
    • 상위 클래스에 존재하는 메서드를 안전하게 호출 가능
  • 상위 클래스 기준으로 다양한 하위 타입을 처리하며, 공통 기능을 사용할 수 있음
  • 사용 : 공통 부모 클래스를 가진 객체들 비교, 출력, 공통 처리 등
class AnimalBox<T extends Animal> {
    public T bigger(T a, T b) {
        return a.getSize() > b.getSize() ? a : b;
    }
}

3. 인터페이스 하나로 제한(T extends 인터페이스)

  • T는 반드시 Comparable<T> 인터페이스를 구현해야 하므로 compareTo() 같은 메서드를 컴파일 시점에 바로 사용 가능
  • 인터페이스는 기능만 정의되므로, 기능 기반의 제네릭 처리에 적합
  • 즉, 기능을 강제하고 싶을 때 인터페이스 제한을 사용
    • Comparable, Serializable, Runnable 같은 인터페이스
      • ex. T타입이 특정 기능을 반드시 가지고 있어야 할 때
        • compareTo() 같은 비교 기능이 꼭 필요한 경우 그 기능이 정의된 인터페이스인 Comparable를 반드시 구현한 타입만 오도록 제한
  • 사용 : 정렬, 크기 비교, 우선순위 판별 등
class MaxFinder<T extends Comparable<T>> {
    public T max(T a, T b) {
        return a.compareTo(b) > 0 ? a : b;
    }
}

 

4. 클래스 + 인터페이스 조합(T extends 클래스 & 인터페이스)

  • T는 상위 클래스를 상속하고, 인터페이스도 구현해야 함
  • 클래스는 하나만 지정 가능, 인터페이스는 여러 개 지정 가능
  • 상속받은 클래스의 속성&기능과 구현한 인터페이스의 메서드를 모두 사용할 수 있는 구조
  • T가 특정 클래스의 기능도 가지고 있고, 인터페이스의 기능도 동시에 제공해야 할 때 사용
    • T가 상위 클래스를 상속하고, 정렬가능한 Comparable도 구현해야할 때
  • 즉, T가 클래스 기반의 공통 속성과 구조를 갖고 있으면서 동시에 기능 인터페이스도 구현해야 하는 복합 요구 조건일 때 유용
  • 사용 : 도메인 객체 + 정렬/기능 조합
    • User, Product 등 정렬, 비교해야 하는 상황에서 자주 사용
class AnimalBox<T extends Animal & Comparable<T>> {
    public T best(T a, T b) {
        return a.compareTo(b) > 0 ? a : b;
    }
}

 

 

결론

타입 제한은 제네릭의 유연함과 안전함 사이에서 균형을 맞추는 도구로 필요한 기능만 사용할 수 있게 타입을 제한하여 불필요한 타입 허용을 방지하고, 컴파일 시점에 오류를 잡을 수 있는 구조를 만들 수 있다.


제네릭 메서드

 

* 제네릭 메서드란?

제네릭 메서드는 메서드 내부에서 사용할 타입을 외부로부터 일반화(generalize)하여, 호출 시점에 타입이 자동으로 결정되도록 만든 메서드다.

즉, 메서드를 호출할 때 전달된 인자의 타입에 따라 메서드 내부에서 사용할 타입이 결정된다. 따라서 동일한 메서드를 다양한 타입에 대해 재사용할 수 있으며, 클래스 전체를 제네릭으로 만들 필요 없이 특정 메서드만 유연하게 타입을 처리할 수 있다.

 

1. 제네릭 메서드 사용하는 이유

* 클래스 제네릭 vs 메서드 제네릭

  • 클래스 제네릭 : 객체를 생성할 때 타입을 결정 -> 클래스 전체에서 사용
  • 메서드 제네릭 : 메서드 호출 시 타입 결정 -> 유연하게 다른 타입 사용 가능

* 메서드 제네릭을 언제 사용해야 하는가?

  • 클래스 전체를 제네릭으로 만들 필요 없이, 특정 메서드만 제네릭으로 쓰고 싶을 때
  • 다양한 타입을 처리하는 유틸성 메서드를 작성하고자 할 때
  • 타입을 안전하게 일반화하고 싶을 때

결론 : 호출 시 타입이 자동 결정되어 다양한 타입을 동일한 로직으로 처리하면서 타입 안정성을 확보

 

2. 제네릭 메서드의 주요 특징 및 유형

* 인스턴스 제네릭 메서드

  • 일반적인 인스턴스 메서드에 제네릭을 적용한 형태
  • 클래스가 제네릭이 아니어도 메서드만 독립적으로 제네릭을 사용할 수 있음
  • 특징
    • 클래스 외부에서 호출할 때마다 타입이 자동으로 결정됨
    • 재사용성과 타입 안정성을 모두 확보할 수 있음
  • 주의
    • 메서드 앞에 <T> 타입 매개변수 선언을 하지 않으면 컴파일 오류 발생
public class Printer {

    public <T> void printInfo(T item) {
        System.out.println("Item: " + item);
    }
}

 

* static 제네릭 메서드

  • static 메서드에도 제네릭을 적용할 수 있고, 이 경우에는 클래스의 제네릭과는 별개로 동작
  • 특징
    • 클래스에 제네릭이 있더라도, static 메서드에는 사용할 수 없음
    • 반드시 메서드 자체에 <T>를 선언해야 함
  • 주의
    • 클래스의 제네릭 타입 <T>는 static 영역에서 사용할 수 없음
    • static 메서드는 항상 자체적으로 타입 매개변수를 선언해야 함
      • T가 무엇인지 결정되기 전에 static이 먼저 로드되기 때문에 사용할 수 없음
public class Util {

    public static <T> void print(T data) {
        System.out.println("Data: " + data);
    }
}

 

* 타입 제한 제네릭 메서드(<T extends>)

  • T타입이 특정 클래스나 인터페이스를 상속하거나 구현해야만 사용할 수 있도록 제한을 거는 방식
  • 특징
    • 타입에 대한 기능을 강제할 수 있어, 기능 기반 코드 작성에 적합
    • T가 어떤 메서드를 제공하는지 컴파일 시점에 명확하게 인식 가능
  • 주의
    • T가 인터페이스를 extends 했을 때, 인터페이스를 구현하지 않으면 컴파일 에러 발생
    • 여러 제한을 동시에 걸고 싶은 경우엔 &로 연결 가능
public <T extends Comparable<T>> T max(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}

 

* 타입 추론(Type Inference)

  • 자바 컴파일러는 메서드 호출 시 전달된 인자를 기반으로 제네릭 타입을 자동 추론
  • 특징
    • 타입을 명시하지 않아도 컴파일러가 자동으로 타입을 유추
    • 코드를 더 간결하게 작성 가능
  • 주의
    • 타입이 애매하거나 불일치할 경우 추론을 실패하거나 경고 발생
    • 명시적 타입 지정이 필요한 상황도 있음
public <T> T pickFirst(T a, T b) {
    return a;
}

 

* 제네릭 타입 vs 제네릭 메서드 우선순위

클래스와 메서드 모두 제네릭 타입 매개변수 <T>를 사용할 수 있는데, 둘 다 선언된 경우 메서드에 선언된 <T>가 우선 적용된다.

  • 메서드에 <T>가 선언되어 있다면, 클래스의 <T>는 무시됨
  • 메서드 제네릭이 클래스 제네릭을 가리는 구조(섀도잉)
  • 혼동을 피하기 위해 클래스와 메서드에서 타입 이름을 다르게 쓰는 것을 권장

와일드카드
?

 

* 와일드카드란?

와일드카드(?)는 정확한 타입을 지정하지 않고도 제네릭 타입을 다룰 수 있게 해주는 문법으로 메서드나 클래스에서 다양한 타입을 유연하게 처리할 수 있게 만들어준다.

즉, 어떤 타입이든 인자로 받을 수 있다.

 

* 와일드카드를 사용하는 이유

자바의 제네릭은 타입이 다르면 서로 다른 타입으로 간주되기 때문에, 서로 다른 제네릭 타입을 하나의 메서드에서 처리하고 싶을 때, 타입의 유연함과 안정성을 동시에 확보하기 위한 수단으로 와일드카드를 사용

List<String> strList = new ArrayList<>();
List<Object> objList = strList; // 컴파일 오류

// ex. List<Object>는 List<String>의 부모가 아님

 

 

*와일드카드의 종류와 특징

1. <?> (Unbounded Wildcard)

  • 모든 타입 허용. 단, 읽기만 가능
  • 정확한 타입을 몰라도 데이터를 읽을 수는 있지만, 쓸 수는 없음
  • 장점 : 다양한 타입을 받아야 할 때 유용
  • 단점 : 타입을 알 수 없어 메서드 호출 불가, 값을 안전하게 저장할 수 없음
  • 읽기 전용 조회용 API, 출력용 유틸 클래스에서 사용
public void printBox(Box<?> box) {
    Object value = box.get();   // 읽기 가능
    // box.set(new Object());  // 컴파일 오류 (어떤 타입인지 몰라서)
}

 

2. <? extends T> (상한 제한 와일드카드)

  • T 또는 그 하위 타입만 허용. 읽기 O 쓰기 X
  • 하위 타입이 들어오더라도 최소한 T의 기능은 쓸 수 있도록 보장
  • 장점 : get()한 값을 T로 안전하게 사용할 수 있음
  • 단점 : 어떤 하위 타입이 들어올 지 몰라 set() 불가
  • 목록 조회, 데이터 읽기 API에서 사용
public void printAnimal(Box<? extends Animal> box) {
    Animal animal = box.get(); // 읽기 가능 (Animal 보장)
    // box.set(new Dog());     // 컴파일 오류
}

 

3. <? super T> (하한 제한 와일드카드)

  • T 또는 그 상위 타입만 허용. 쓰기 O 읽기 X
  • 최소한 T 타입의 값을 안전하게 넣을 수 있도록 보장
  • 장점 : T 또는 그 하위 타입을 안전하게 set() 가능
  • 단점 : get()은 Objcet 수준까지만 가능
  • 데이터 추가, 수집기(setter) 역할의 API에서 사용
public void Animal(Box<? super Dog> box) {
    box.set(new Dog());     // 가능 (Dog는 Dog의 하위타입)
    Object obj = box.get(); // 읽기는 Object로만 가능
}

 

결론

와일드카드 read(읽기), get() write(쓰기), set() 사용 예
<?> 가능 -> Object로만 반환 불가능 읽기 전용
<? extends T> 가능 -> T로 반환 불가능 꺼내기 전용
<? super T> 가능 -> Object로만 반환 가능 -> T 넣기 가능 저장용

 

 

+ 추가공부

* PECS 원칙 (Producer Extends, Consumer Super)

역할 설명 와일드카드
Producer(생산자) 값을 꺼내기만 할 때 <? extends T>
Consumer(소비자) 값을 넣기만 할 때 <? super T>

 

-자바 제네릭에서 생산자는 extends로 소비자는 super로 선언해야 타입 안정성과 유연성을 동시에 확보할 수 있다.

 

-참고

  • 공변성(Covariant) : <? extends T>
    • T와 그 하위 타입들을 읽을 수 있다(읽기 전용)
  • 반공변성(Contravariant) : <? super T>
    • T와 그 상위 타입에 값을 쓸 수 있다(쓰기 전용)

타입 이레이저

 

* 타입 이레이저란?

자바에서 제네릭은 컴파일 시점까지만 타입 정보를 유지하고, 컴파일 이후 런타임에는 타입 정보를 제거(erase)하는 방식으로 동작

  • 타입 이레이저가 적용되면 제네릭 타입은 Object 또는 상위 제한 타입(extends)로 치환
  • 따라서 런타임에는 모든 제네릭 타입이 동일한 클래스처럼 동작
  • 장점
    • 하위 호환성 유지 : 기존 자바 코드와 함께 사용 가능
    • 런타임 성능 유지 : 타입 정보가 사라져도 별도의 처리 없이 빠르게 실행됨
  • 단점
    • 런타임 검사 불가 -> 일부 제약 발생
    • Class<T> 같은 타입 추적 불가
    • List<String> vs List<Integer> -> 런타임 구분 불가

* 동작 방식 예

public class Box<T> {
    private T value;
    public void set(T value) { this.value = value; }
    public T get() { return value; }
}

-> 컴파일 후 타입이 Objcet로 치환

public class Box {
    private Object value;
    public void set(Object value) { this.value = value; }
    public Object get() { return value; }
}

-> T가 제한된 타입이라면, 컴파일 후 T는 Animal로 치환

class Box<T extends Animal> {}

 

* 타입 이레이저 사용 이유

  • 제네릭은 자바5에서 도입되었는데 기존의 수많은 라이브러리와 코드가 같이 동작해야해서 JVM 레벨에서는 제네릭 타입 정보를 유지하지 않도록 설계됨
  • 즉, 제네릭은 컴파일러 수준의 문법 기능

결론 : 하위 호환성을 위해 타입 이레이저 사용

 

+추가공부

* 타입 이레이저로 인한 특징 & 제약

 

1. 런타임에는 타입 정보가 사라진다

List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();

System.out.println(list1.getClass() == list2.getClass()); // true
  • List<String>과 List<Integer>는 런타임에 동일한 클래스 -> 모두 List로 인식됨
  • 제네릭은 런타임 타입 구분을 못한다.

2. 제네릭 타입은 instanceof 검사 불가

if (list instanceof List<String>) { ... } // 컴파일 에러
  • 런타임에 String 타입 정보가 사라지기 때문에 검사 불가 -> 컴파일 에러

3. 제네릭 타입으로 배열 생성 불가

List<String>[] listArray = new List<String>[10]; // 컴파일 에러
  • 배열은 런타임에도 타입을 유지해야 하는데, 제네릭은 타입이 사라지기 때문에 런타임 기준에서 타입 안정성을 보장할 수 없음

=> 제네릭은 컴파일 시점에 타입 안정성을 보장하지만 배열은 런타임에도 타입 정보를 유지해야 함

따라서 타입 이레이저로 인해 제네릭 타입 정보는 런타임에 사라지므로, 제네릭 타입으로 배열을 생성하면 런타임 타입 오류를 막을 수 없어 타입 안정성이 깨질 수 있다.


총정리
  • 제네릭
    • 자바에서 타입을 일반화하여 코드의 재사용성과 타입 안정성을 동시에 확보하는 문법
    • 클래스나 메서드 내부에서 사용할 타입을 외부에서 지정 가능
  • 제네릭 주요 문법
    • 제네릭 클래스 : class Box<T>
    • 제네릭 메서드 : <T> T method(T t)
    • 타입 제한 : T extends Number
    • 타입 추론
  • 와일드카드(?)
    • ? extends T : 읽기 전용(값 꺼내기)
    • ? super T : 쓰기 전용(값 넣기)
    • <?> : 타입을 모를 때 사용
    • PECS 원칙 : 값 꺼낼 때 extends, 넣을 때 super
  • 타입 이레이저
    • 제네릭 타입 정보는 컴파일 후 삭제, 런타임에는 모두 Object 또는 상한 타입으로 치환

<참고> - 김영한의 실전 자바 중급2편 강의 섹션 3. 제네릭2

반응형