본문 바로가기
프로그래밍/Java

제네릭에 관하여

by supernovaMK 2025. 3. 17.

제네릭에 대해서 잘 사용해지 못했는데 이번 미션 키워드로 제네릭이 나와 공부해보고자 한다.

 

제네릭이란

데이터 형식에 의존하지 않고, 하나의 값이 여러 다른 데이터 타입들을 가질 수 있도록 하는 방법 이라고 한다.

 

즉 클래스 내부에서 지정하는 것이 아닌 외부에서  사용자에 의해 지정되는 것이라고 한다.

타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.

 

 

<> 다이아몬드 연산자 안에 타입 매개변수를 넣어 사용한다. 메소드가 매개변수를 받아 사용하는 것과 비슷하여 타입 매개변수/변수 라고 부른다.

List<T> //T:타입 매개변수

List<Integer> //Integer: 매개변수화된 타입

 

 

 

생성자에서는 타입 파라미터 생략 가능

 

Food<Meat> food = new Food<Meat>();

Food<Apple> food = new Food<>();

 

 

원시값은 넘겨줄 수 없다, wrapper를 사용해야한다.

List<int> list = new List<>()      ----> (x)
List<Integer> list = new List<>()  ----> (o)

 

 

다형성 원리가 적용된다.

class Vehicle { }
class Car extends Vehicle { }
class Bike extends Vehicle { }

class VehicleBox<T> {
    List<T> vehicles = new ArrayList<>();

    public void add(T vehicle) {
        vehicles.add(vehicle);
    }
}

public class Main {
    public static void main(String[] args) {
        VehicleBox<Vehicle> box = new VehicleBox<>();

        // 제네릭 타입은 다형성 원리가 그대로 적용된다.
        box.add(new Vehicle());
        box.add(new Car());
        box.add(new Bike());
    }
}

복수 타입 파라미터도 가능하다

import java.util.ArrayList;
import java.util.List;

class Car { }
class Bike { }

class VehicleBox<T, U> {
    List<T> cars = new ArrayList<>();
    List<U> bikes = new ArrayList<>();

    public void add(T car, U bike) {
        cars.add(car);
        bikes.add(bike);
    }
}

public class Main {
    public static void main(String[] args) {
        // 복수 제네릭 타입
        VehicleBox<Car, Bike> box = new VehicleBox<>();
        box.add(new Car(), new Bike());
        box.add(new Car(), new Bike());
    }
}

 

타입 파라미터 기호 네이밍

 

T Type 임의의 타입을 의미하며, 가장 일반적으로 사용됩니다.
K Key 키를 표현할 때 사용합니다. (주로 Map과 같은 자료구조)
V Value 값을 표현할 때 사용합니다. (주로 Map과 같은 자료구조)
E Element 컬렉션 클래스에서 요소 타입을 표현할 때 사용합니다.

 


그럼 쓰냐

 

1. 컴파일 타임에 타입 검사를 통해 예외 방지

2. 불필요한 캐스팅을 없애 성능 향상

 

이 두가지라고 한다.


1. 컴파일 타임에 타입 검사를 통해 예외 방지

기존에 Object형식으로 넣어 놓았다면,다시 꺼내 쓸때 다운캐스팅 과정에서 이를 컴파일 과정에서 잡아낼 수 없었다.

즉 런타임 과정에서 확인이 가능했다. 하지만 제네릭을 쓰게 된다면 컴파일 타임에 이를 확인 할 수 있게 되었다.

import java.util.Arrays;

class Car { }
class Bike { }

class VehicleBox<T> {
    private T[] vehicles;

    public VehicleBox(T[] vehicles) {
        this.vehicles = vehicles;
    }

    public T getVehicle(int index) {
        return vehicles[index];
    }
}

public class Main {
    public static void main(String[] args) {
        // 올바른 제네릭 타입을 사용
        Car[] cars = {
                new Car(),
                new Car()
        };
        VehicleBox<Car> carBox = new VehicleBox<>(cars);

        // 컴파일 시점에 타입이 확인됨
        Car car = carBox.getVehicle(0); // 올바름
        // Bike bike = carBox.getVehicle(1); // 오류: 컴파일 시점에 타입 불일치

        // 아래와 같이 잘못된 다운캐스팅을 시도하면 컴파일 에러 발생
        // Bike bike = (Bike) carBox.getVehicle(1);
        System.out.println("Car retrieved successfully!");
    }
}

 

 

2. 불필요한 캐스팅을 없애 성능 향상

기존 제네릭이 없던 상황에서는 하나씩 캐스팅을 해주어 받아오게 했었지만, 제네릭으로 이 작업을 할 필요 없어졌다.

class Vehicle { }
class Car extends Vehicle { }

class VehicleBox {
    Object[] vehicles;

    public VehicleBox(Object[] vehicles) {
        this.vehicles = vehicles;
    }

    public Object getVehicle(int index) {
        return vehicles[index];
    }
}

public class Main {
    public static void main(String[] args) {
        Car[] arr = { new Car(), new Car(), new Car() };
        VehicleBox box = new VehicleBox(arr);

        // 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
        Car car1 = (Car) box.getVehicle(0);
        Car car2 = (Car) box.getVehicle(1);
        Car car3 = (Car) box.getVehicle(2);

        System.out.println("Cars retrieved successfully!");
    }
}

 

 

⚠️주의사항

제네릭 타입의 객체는 생성이 불가

  • 제네릭 타입 객체 생성이 불가능한 이유는 타입 소거(type erasure) 때문입니다. 자바의 제네릭은 컴파일 시점에만 타입 정보를 가지고 있으며, 런타임 시에는 해당 타입 정보가 사라지기 때문에 new T()와 같이 제네릭 타입의 객체를 직접 생성할 수 없습니다

static 멤버에 제네릭 타입이 올수 없음

 

  • static은 클래스 레벨에서 존재하지만, 제네릭은 객체 인스턴스 레벨에서 존재합니다. 제네릭 타입은 인스턴스화되는 객체와 관련된 타입 매개변수이기 때문에 static 메서드나 변수에서 제네릭을 사용할 때 발생하는 혼동을 방지하기 위해 제네릭 타입이 static 멤버에서 사용되지 않습니다.
  • static 멤버는 클래스의 인스턴스가 없어도 사용 가능해야 합니다. 하지만 제네릭 타입은 클래스의 인스턴스가 생성될 때 제공되는 타입이므로 클래스 레벨에서는 제네릭 타입을 알 수 없습니다.

 

제네릭으로 배열 선언 주의점

 

  • 자바의 제네릭은 컴파일 시점에만 타입 정보를 확인하고, 런타임에는 해당 정보가 소거됩니다.
  • 배열은 런타임에도 타입 정보를 유지해야 하는데, 제네릭 타입은 런타임에 소거되어 타입 충돌이 발생할 가능성이 생깁니다.

 

class Sample<T> { 
}

public class Main {
    public static void main(String[] args) {
        Sample<Integer>[] arr1 = new Sample<>[10];
    }
}
Sample<Integer>[] arr = new Sample[10];
arr[0] = new Sample<>();  // 정상
arr[1] = new Sample<>();  // 정상
arr[2] = new Sample<String>();  // 컴파일 오류 발생

 

 


  • 공변(covariant) : A가 B의 하위 타입일 때, T <A> 가 T<B>의 하위 타입이면 T는 공변
  • 불공변(invariant) : A가 B의 하위 타입일 때, T <A> 가 T<B>의 하위 타입이 아니면 T는 불공변

 

오키 한번 써보자

 

 

제네릭 클래스

class Vehicle<T> {
    private T vehicle;

    public Vehicle(T vehicle) {
        this.vehicle = vehicle;
    }

    public T getVehicle() {
        return vehicle;
    }

    public void setVehicle(T vehicle) {
        this.vehicle = vehicle;
    }
}

public class Main {
    public static void main(String[] args) {
 
        Vehicle<String> car = new Vehicle<>("Sedan");
        System.out.println("Car model: " + car.getVehicle());

        Vehicle<Integer> speed = new Vehicle<>(120);
        System.out.println("Car speed: " + speed.getVehicle() + " km/h");
    }
}

제네릭 인터페이스

interface ISample<T> {
    public void addElement(T t, int index);
    public T getElement(int index);
}

class Sample<T> implements ISample<T> {
    private T[] array;

    public Sample() {
        array = (T[]) new Object[10];
    }

    @Override
    public void addElement(T element, int index) {
        array[index] = element;
    }

    @Override
    public T getElement(int index) {
        return array[index];
    }
}
public static void main(String[] args) {
    Sample<String> sample = new Sample<>();
    sample.addElement("This is string", 5);
    sample.getElement(5);
}

제네릭 함수형 인터페이스

// 제네릭으로 타입을 받아, 해당 타입의 두 값을 더하는 인터페이스
interface IAdd<T> {
    public T add(T x, T y);
}

public class Main {
    public static void main(String[] args) {
        // 제네릭을 통해 람다 함수의 타입을 결정
        IAdd<Integer> o = (x, y) -> x + y; // 매개변수 x와 y 그리고 반환형 타입이 int형으로 설정된다.
        
        int result = o.add(10, 20);
        System.out.println(result); // 30
    }
}

 

 

제네릭 메서드

class VehicleBox<T> {

    // 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
    public T addBox(T x, T y) {
        System.out.println("Adding two vehicles to the box: " + x + " and " + y);
        return x;  // 예시로 첫 번째 차량을 반환
    }

    // 독립적으로 타입 할당 운영되는 제네릭 메서드
    public static <T> T addBoxStatic(T x, T y) {
        System.out.println("Adding two vehicles to the static box: " + x + " and " + y);
        return x;  // 예시로 첫 번째 차량을 반환
    }
}

class Vehicle {
    private String model;

    public Vehicle(String model) {
        this.model = model;
    }

    @Override
    public String toString() {
        return "Vehicle{" + "model='" + model + '\'' + '}';
    }
}

public class Main {
    public static void main(String[] args) {
        
        Vehicle car1 = new Vehicle("Sedan");
        Vehicle car2 = new Vehicle("SUV");

        // 제네릭 타입을 명시적으로 지정하여 호출
        VehicleBox.<Vehicle>addBoxStatic(car1, car2);
    }
}

 

 

타입 한정 키워드 extends

 

  • T extends SomeClass: T는 SomeClass 또는 그 하위 클래스여야 한다는 의미
  • T extends SomeInterface: T는 SomeInterface를 구현한 클래스여야 한다는 의미

 

다중 타입 한정

만일 2개 이상의 타입을 동시에 상속(구현)한 경우로 타입 제한하고 싶다면,  & 연산자를 이용하면 된다. 해당 인터페이스들을 동시에 구현한 클래스가 제네릭 타입의 대상이 되게 된다.

단, 자바에서는 다중 상속을 지원하지 않기 때문에 클래스로는 다중 extends는 불가능하고 오로지 인터페이스로만이 가능하다.

 

 

제네릭 캐스팅

// 배열은 OK 
Object[] arr = new Integer[1];

// 제네릭은 ERROR 
List<Object> list = new ArrayList<Integer>();

 

 

업캐스팅이 제네릭에서 안 되는 이유는 타입 안전성을 보장하기 위한 것입니다. 자바 제네릭은 컴파일 타임에 타입 검사를 수행하며, 이를 통해 타입 안정성을 유지합니다. 업캐스팅이 허용되면, 타입 불일치나 런타임 오류를 유발할 수 있기 때문에 이를 방지하려고 합니다.

 


여기서부터 멘탈이 흔들리고 있으니 같이 공부해보자.

 

와일드 카드

와일드 카드에 대해서 알아보자,

와일드 가드는 ?를 사용하므로써 어떤 타입도 가능하다는 뜻이다.

 

사용하는 이유를 알아보자.
제네릭 같은 경우 타입 파라미터가 오로지 똑같은 타입만 받기 때문에 다형성을 이용할수 없다.

 

ArrayList<Object> parent = new ArrayList<>();
ArrayList<Integer> child = new ArrayList<>();

parent = child; // ! 업캐스팅 불가능
child = parent; // ! 다운캐스팅 불가능
출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭-와일드-카드-extends-super-T-완벽-이해 [Inpa Dev 👨‍💻:티스토리]

꺾쇠 괄호 부분을 제외한 원시 타입(Raw Type) 부분은 공변성이 적용되지만, 꺾쇠 괄호 안의 실제 타입 매개변수에 대해서는 적용이 되지 않는다.

 

public static void print(List<Object> arr) {
    for (Object e : arr) {
        System.out.println(e);
    }
}

public static void main(String[] args) {
    List<Integer> integers = Arrays.asList(1, 2, 3);
    print(integers); // ! Error
}

 

따라서 이 부분을 해결하기 위해서는 

public static void print(List<Integer> arr) {
}

public static void print(List<Double> arr) {
}

public static void print(List<Number> arr) {
}

...

이렇게 만들어 주어야 한다고 한다. 이 부분을 해결 하기 위해서 제네릭 와일드카드가 등장한 것이다.

 

// 제네릭 메서드
public <T> void generitMethod(Box<T> box) {
     System.out.println("T = " + box.get());
 }
 
// 와일드카드를 활용한 일반적인 메서드
public void wildcardMethod(Box<?> box) {
     System.out.println("? = " + box.get());
 }

근데 그러면 그냥 이렇게 제네릭 메서드를 사용해도 되는 것이 아닌가? 라는 고민이 되었다.

 

그리고 ? 만 사용하게 되면 사실상 Object와 다를게 없어보이니 좀 더 찾아보자.

 

class MyArrayList<T> {
    Object[] element = new Object[5];
    int index = 0;

    // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
    public MyArrayList(Collection<T> in) {
        for (T elem : in) {
            element[index++] = elem;
        }
    }

    // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
    public void clone(Collection<T> out) {
        for (Object elem : element) {
            out.add((T) elem);
        }
    }

    @Override
    public String toString() {
        return Arrays.toString(element); // 배열 요소들 출력
    }
}

 

public static void main(String[] args) {
    // MyArrayList의 제네릭 T 타입은 Number
    MyArrayList<Number> list;

    // MyArrayList 생성하기
    Collection<Integer> col = Arrays.asList(1, 2, 3, 4, 5);
    list = new MyArrayList<>(col); // ! ERROR

    // LinkedList 에 MyArrayList 요소들 복사하기
    List<Object> temp = new LinkedList<>();
    temp = list.clone(temp); // ! ERROR

	// LinkedList 출력
    System.out.println(temp);
}

 

저 두 부분에 대해서 오류가 생길 수 있다는 점이다.

 

아래와 같이 바꿔주게 된다면 문제가 없다.

class MyArrayList<T> {
    Object[] element = new Object[5];
    int index = 0;

    // 외부로부터 리스트를 받아와 매개변수의 모든 요소를 내부 배열에 추가하여 인스턴스화 하는 생성자
    public MyArrayList(Collection<? extends T> in) {
        for (T elem : in) {
            element[index++] = elem;
        }
    }

    // 외부로부터 리스트를 받아와 내부 배열의 요소를 모두 매개변수에 추가해주는 메서드
    public void clone(Collection<? super T> out) {
        for (Object elem : element) {
            out.add((T) elem);
        }
    }

    @Override
    public String toString() {
        return Arrays.toString(element); // 배열 요소들 출력
    }
}

 

근데 만약 저기서 Collection<?>만 사용하게 된다면 어떤 문제가 있을까?

일단 값이 ? 타입으로 들어왔기 때문에 해당 컬렉션에서 값을 뽑아도 어떤 타입인지 모르기때문에 사용할 수 없고, 어떤 타입 컬렉션인지 모르기에 추가하기도 어렵다.

 

Box<? extends Fruit> box1 = new Box<Fruit>();
Box<? extends Fruit> box2 = new Box<Apple>();
Box<? extends Fruit> box3 = new Box<Banana>();
Box<? super Fruit> box1 = new Box<Fruit>();
Box<? super Fruit> box2 = new Box<Food>();
Box<? super Fruit> box3 = new Box<Object>();
Box<?> box1 = new Box<Vegetable>();
Box<?> box2 = new Box<Fruit>();
Box<?> box3 = new Box<Food>();
Box<?> box3 = new Box<Carrot>();

 

이 세 부분은 자연스러운 형식임을 인지하자.

 

와일드카드 경계 꺼내기 / 넣기

 

여기서부터 또 새로운 개념이 추가 된다.

 

List<? extends U>

  • GET : 안전하게 꺼내려면 U 타입으로 받아야함
  • SET : 어떠한 타입의 자료도 넣을수 없음 (null만 삽입 가능)
  • 꺼낸 타입은 U / 저장은 NO

 

List<? super U>

  • GET : 안전하게 꺼내려면 Object 타입으로만 받아야한다
  • SET : U와 U의 자손 타입만 넣을 수 있음 (U의 상위타입 불가능)
  • 꺼낸 타입은 Object / 저장은 U와 그의 자손만

 

 

PECS 공식

PECS란, Producer-Extends / Consumer-Super 라는 단어의 약자인데 다음을 의미한다.

  • 외부에서 온 데이터를 생산(Producer) 한다면 <? extends T> 를 사용 (하위타입으로 제한)
  • 외부에서 온 데이터를 소비(Consumer) 한다면 <? super T> 를 사용 (상위타입으로 제한).

 

in / out 공식

오라클 공식 문서에서는 PECS 대신 in 과 out 의 개념으로 와일드카드 사용처를 설명한다.

위의 예제에서도 in 과 out 방법을 사용했는데, extends 에선 매개변수명이 in 이고, super 에선 매개변수명이 out 인걸 확인 할 수 있다. 이를 합치면 아래와 같이 정리할 수 있다.

  • in 변수는 코드에 복사할 데이터를 제공이 목적 → extends
  • out 변수는 다른 곳에서 사용할 데이터를 보유 → super
public static <E> void copyList(List<? extends E> in, List<? super E> out) {
    for(E elem : in) {
        out.add(elem);
    }
}

 

 

잘못된 사용

 

class Sample<? extends T> { // ! Error
    
}

 

클래스 생성에서는 사용할 수 없다.

 

class Sample<T> {
    public static <E> void run(List<? super E> l) {}
}

public class Main {
    public static void main(String[] args) {
        Sample<?> s2= new Sample<String>();
        
        Sample<? extends Number> s1 = new Sample<Integer>();
        
        Sample.run(new ArrayList<>());
    }
}

 

아오 넌 또 뭐냐 진짜 장난삐끼치나

 

 

<T extends 타입>  <? extends U> 차이점

바로 위에서 언급했듯이, 와일드 카드는 제네릭 클래스를 만들때 사용하는 것이 아니라, 이미 만들어진 제네릭 클래스를 사용할때 타입을 지정할때 이용되는 것이다.

즉, <T extends 타입> 는 제네릭 클래스를 설계할때 적어주는 것이고, <? extends 타입> 는 이미 만들어진 제네릭 클래스를 인스턴스화 하여 사용할때 타입 파라미터로 넘겨줄때 적어주는 것이다.

 

 

<T super 타입> 은 왜 없을까

와일드카드에 <T extends 타입> 은 존재하지만, <T super 타입> 은 없는 걸 볼 수 있다. 

<T extends 타입> 는 정의할 제네릭 타입 범위를 상한 제한하기 위해 사용하는데, <T super 타입> 이 된다면 무수히 많은 자바의 클래스와 인터페이스가 올 수 있다는 뜻이기 때문에, Object와 다르지 않아 그냥 쓸모없는 코드이기 때문이다. 

 

<?> 와 <Object> 는 다르다

비경계 와일드카드가 모든 타입이 들어올 수 있으니 Object와 다를바 없다고 말할수 있겠지만, 엄밀히 List<?> 와 List<Object> 는 다른 놈이다. 왜냐하면  List<Object>에는 Object의 하위 타입은 모두 넣을 수 있지만, List<?> 에는 오직 null만 넣을 수 있기 때문이다. (잘 모르면 위로 올라가 다시 복습하자!)

 

'프로그래밍 > Java' 카테고리의 다른 글

Tdd로 개발하기  (1) 2025.03.03
[Java] try with resource 사용에 관하여.  (1) 2025.02.22
[Java] enum  (0) 2024.04.23
[Java] Method,Stack,Heap 와 New연산자  (0) 2024.04.23