제네릭 톺아보기1️⃣ 제네릭이란?2️⃣ 제네릭을 왜 사용하는거지? 🤔제네릭 타입을 적용하지 않았을 때제네릭 타입을 적용 했을 때왜 자메이카가 나온 것일까?이쯤에서 생각해 볼 수 있는 문제 - 힙 오염3️⃣ 제네릭의 특징 - 타입소거(Erasure)제네릭에는 왜 프리미티브 타입을 허용하지 않는걸까?타입소거를 한다는 점에서 알 수 있는 점4️⃣ 공변 / 무공변 / 반공변무공변(Invariance)공변(Variance)반공변(Contravariance)PECS(Producer-Extends, Consumer-Super)
제네릭 톺아보기
1️⃣ 제네릭이란?
- 클래스나 메서드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법이다.
- 즉 데이터 형식에 의존하지 않고 하나의 값이 여러 다른 데이터 타입들을 가질 수 있는 기술에 중점을 두어 재사용성을 높일 수 있는 프로그래밍 방식.
- 클래스 선언에 타입 매개변수가 쓰이면 제네릭 클래스라고 말할 수 있다.
- 도라에몽 주머니라고 생각하시면 어떨까요? 도라에몽 주머니에는 다양한 물건들이 들어있는 것 처럼 제네릭도 다양한 타입들을 하나의 클래스를 통해 사용 할 수 있어요 !
- 또한 반대로 타입을 강제할 수도 있어요 ! 강제하는 방법은 밑에 예시를 통해서 확인해 보겠습니다 😀

class Storage<T> { List<T> store = new ArrayList<>(); public void add(T data) { store.add(data); } } // 데이터 형식에 의존하지 않고 // 하나의 클래스로 정수, 문자열, 치킨, Object 등 // 여러가지 데이터 타입들을 가질 수 있게 된다 ! Storage<Integer> storage_1 = new Storage<>(); Storage<String> storage_2 = new Storage<>(); Storage<치킨> storage_3 = new Storage<>();
2️⃣ 제네릭을 왜 사용하는거지? 🤔
- 컴파일 타임에 타입 오류에 대한 검증을 수행하여 런타임에는 안전한 코드를 실행하게 됩니다.
- 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있고 형변환 또한 사라지기 때문에 가독성이 좋아집니다.
- 제네릭 클래스를 사용하지 않은 경우에는 Object 타입으로 반환되기 때문에 하나하나 캐스팅을 해줘야 합니다.
- 아래는 예시를 위한 치킨과 치킨박스 클래스입니다.


상황 : 어느날 규현 멘토님이 일을 마치고 집에 돌아와 비비큐 황금올리브 치킨을 시켰다고 가정해 보겠습니다.
제네릭 타입을 적용하지 않았을 때
public class ChickenTest { public static void main(String[] args) { ChickenBox chickenBox = new ChickenBox(); // 1 chickenBox.addChicken(new 자메이카()); // 2 chickenBox.getList().forEach(System.out::println); // 3 } }
- 흐름설명
- 치킨박스에 타입을 지정하지 않고 치킨박스 객체를 하나 생성하였습니다. (주석 1)
- 치킨박스에 실수로 황금올리브가 아닌 자메이카치킨을 담았습니다. (주석 2)
- 치킨박스에 치킨을 꺼내보면…? (주석 3)
- 결과
- 황금올리브가 아닌 자메이카 치킨이 배달이 되어버렸습니다..

제네릭 타입을 적용 했을 때
public class ChickenTest { public static void main(String[] args) { ChickenBox<황금올리브> chickenBox = new ChickenBox<>(); // 1 chickenBox.addChicken(new 황금올리브()); // 2 chickenBox.getList().forEach(System.out::println); // 3 } }
- 흐름설명
- 황금올리브 타입의 치킨박스 객체를 생성하였습니다. (주석1)
- 치킨박스에 황금올리브 치킨을 담았습니다. (주석2)
- 치킨박스에 치킨을 꺼내보면…? (주석3)
- 결과
- 황금올리브가 정상적으로 도착했습니다 🙂

왜 자메이카가 나온 것일까?
- 제네릭을 사용하지 않는다면 자료형에 대한 검증이 컴파일 시점에 이루어지지 않습니다.
- 문법적으로는 오류가 없지만 컴파일 시 타입체크가 이루어지지 않기 때문에 오류 사실을 인지 못하며 런타임 시 에러가 발생할 수 있습니다.
- 나는 황금올리브를 기대해서 황금올리브로 받았는데 꺼내진 치킨이 자메이카 치킨이기 나왔기 때문에 에러가 발생해요 자메이카가 황금올리브가 될 순 없잖아요 ..?

- 제네릭으로 올바르게 작성한다면 컴파일 시점에 에러를 알려줍니다.
- 자바 컴파일러는 제네릭 코드에 대해 타입체크를 해주게 됩니다. 그리고 타입 안전성에 위배된다면 바로 컴파일 에러를 나타냅니다.
- 황금올리브 치킨박스를 만들었는데 자메이카 치킨을 넣을 순 없겠죠?

이쯤에서 생각해 볼 수 있는 문제 - 힙 오염

List<Chicken> chickenList = new ArrayList<>(); chicken.add(new Chicken()); chicken.add(new BBQ_Chicken()); Object object = chickenList; List<Integer> list = (ArrayList<Integer>) object; list.add(15000);
- 위의 코드는 정상적으로 컴파일 되는 코드입니다.
- 그 이유는 타입 캐스팅 연산자는 컴파일러가 체크하지 않습니다.
- 따라서 캐스팅을 시도하는 런타임에 예외가 발생하게 됩니다.
- 이러한 상황을 힙 오염이라고 표현합니다.
- 도라에몽 주머니라고 생각한다고 해서 하나의 제네렉 클래스 객체에 대해서 다양한 타입을 넣고 사용하라는 의미는 아닙니다 !
3️⃣ 제네릭의 특징 - 타입소거(Erasure)
- 제네릭은 컴파일 과정에서 컴파일러가 제네릭 타입을 이용해 소스파일을 체크하고 필요한 곳에 자동으로 형변환을 넣어준다. 그 후 제네릭 타입은 제거되고 컴파일 완료된 .class 파일에는 타입이 포함되지 않습니다.
- 타입소거를 하는 이유는?
- 제네릭이 도입되기 이전의 소스코드와의 호환성을 유지하기 위해서다.
- JDK1.5부터 제네릭이 도입되었지만 아직도 원시타입을 사용해 코드를 작성하는 것을 허용한다.
- 이러한 하위호환성을 유지하기 위해 로(raw)타입 + 제네릭을 구현할 때 타입소거 방식을 이용했다.
제네릭에는 왜 프리미티브 타입을 허용하지 않는걸까?
- 프리미티브 타입도 결국 “타입"에 속하는데 왜 허용하지 않는 걸까요?
// Before List<Integer> list = new ArrayList(); // After ArrayList list = new ArrayList();
- 내부에서 타입 파라미터를 사용하는 경우 Object 타입으로 취급되어 처리하고 있습니다.
- 타입 소거는 제네릭 타입이 특정 타입으로 제한되어 있을 경우 해당 타입에 맞춰 컴파일시 타입 변경이 발생하고 타입 제한이 없을 경우 Object 타입으로 변경됩니다.
- 즉 프리미티브 타입을 사용하지 못하는 이유는 기본 타입으로 Object 클래스를 상속받고 있지 않기 때문이며 Wrapper 클래스를 이용해야 한다는 점을 알 수 있습니다.
타입소거를 한다는 점에서 알 수 있는 점
- 제네릭은 컴파일 시점에 타입 검사를 하고 컴파일 된 .class 파일에는 타입이 소거되기 때문에 메서드 오버로딩이 불가능합니다.
- 제네릭 타입이 소스코드에서는 다르지만 컴파일 된 class 파일에는 소거가 되기 때문에 중복선언을 하는 것 과 동일합니다.
- 이런 상황에 사용하기 위해 와일드카드를 사용할 수 있습니다. 하지만 와일드카드(?)만 사용한다면 Object 타입과 다를 게 없기 때문에 extends, super 키워드를 통해 적정한 상한경계, 하한경계를 제한하여 사용할 수 있습니다. (자세한 내용은 뒤에서 설명하겠습니다.)
public class View { static void printChickenName(ChickenBox<Chicken> box){ for (Chicken chicken : box.getChicken()) { Systeam.out.println(chicken) } } // 중복선언 static void printChickenName(ChickenBox<BBQ_Chicken> box){ for (Chicken chicken : box.getChicken()) { Systeam.out.println(chicken) } } } // 컴파일 전 소스코드와 후 코드를 캡쳐해서 차이를 보여주면 좋을 것 같다.
4️⃣ 공변 / 무공변 / 반공변
무공변(Invariance)
A가 B의 하위 타입일 때, T<A>와 T<B>간의 아무 관계도 없다면 무공변이라고 말한다.
- 왜 사용할까? 왜 알아야 할까?
- 일반적으로 많이 사용하는 것이죠? 위의 치킨박스 예시처럼 타입을 제한해서 사용하는 방법입니다. “나는 이 타입만 들어오게 할거야 !” 라고 제한을 할 수 있습니다 !

Chicken chiken = new BBQ();
List<Chicken> chiken = new ArrayList<BBQ>();

공변(Variance)
A가 B의 하위 타입일 때, T<A> 가 T<B>의 하위 타입이면 T가 공변의 성질을 가지고 있다고 한다.
- 왜 사용할까? 왜 이런것을 알아야 할까?
- 실제로 변성으로 가득한 것들은 대부분 무공변으로 처리할 수 있다. 하지만 공변을 선언함으로써 얻는 이득은 꽤 크다고 할 수 있다.
- 내가 만든 인터페이스, 클래스, 메서드, 라이브러리를 다른 사람이 잘못 사용할 여지를 줄여줘서 원하는 의도대로 프로그래밍이 가능하다.


- 녹색박스의 경우는 클래스 모두 Object를 상속받고 있기 때문에 가능하다.
- 빨간박스의 경우는 첫번째 줄을 제외한 나머지 세줄은 컴파일 에러가 난다.
- 그 이유는 List는 무공변하기 때문이다. 오직 List<Object>만 허용한다는 뜻이다.
- List<Number>는 List<Object>의 하위 타입이 아니다. (무공변의 성질)
List<? extends Chicken> chicken1 = new ArrayList<Chicken>(); // 1 List<? extends Chicken> chicken2 = new ArrayList<BBQChicken>(); // 2 List<? extends Chicken> chicken3 = new ArrayList<BHCChicken>(); // 3
- 공변은 extends 키워드를 사용하면 upperbound로 제한할 수 있다. 이렇게 된다면 처음에 작성했던 컴파일 에러가 사라지게 된다.
- 그렇다고해서 List를 자유롭게 사용할 수 있는것은 아니다. 이러한 제약으로 인해 위의 세 객체들은 readOnly한 List가 되버리고 만다.
- READ
- 위 세개의 예시코드 모두 들어갈 수 있는 최상위 타입은 Chicken이다.
- 첫번째
chiken1
은 Chicken, BBQ, BHC는 적어도 Chicken이라는 타입을 가지고 있게 되며 Chicken 타입을 읽을 수 있게 된다. - 두번째
chiken2
은 BBQ치킨에 해당하는 부분 만 읽을 수 있다. - 세번째
chiken3
은 BHC치킨에 해당하는 부분 만 읽을 수 있다. - WRITE
- 위 세개의 예시코드 모두 리스트에 추가(쓰는것)행위는 불가하다.
- BBQ치킨, BHC치킨에는 서로 다른 타입의 치킨이 들어올 수 있기 때문에 쓰지 못합니다.
- Chicken인 경우에도 List<BHC>, List<BBQ>를 가리킬 수 있기 때문에 안됩니다.
- 하위타입에 상위타입을 저장하는 행위
- 현재
List<Chicken>
인지List<BBQ>
인지List<BHC>
인지 작성하는 시점에 알 수 없기 때문에 쓸 수는 없지만 결국 이 List에 들어올 수 있는 타입은 Chicken의 하위타입만 올 수 있다 ! 그렇기 때문에 읽기는 가능하다 라고 생각해 주시면 좋을 것 같습니다. - 즉 선언만으로 upperBound 타입으로 read 제약을 걸 수 있게된다.
반공변(Contravariance)
A가 B의 하위 타입일 때, T<B>가 T<A>의 하위 타입이면 T가 반공변의 성질을 가지고 있다고 말한다.
- 왜 사용할까? 왜 알아야 할까?
- 무공변한 제네릭을 공변의 특성으로 변경하는 과정에서 읽기작업만 가능하고 쓰는 작업이 불가능한 부분을 반공변 특성을 통해 읽기작업만 가능했던 문제를 하한경계를 통해 하위타입에 대해 쓰는 작업이 가능하도록 해결할 수 있습니다.


List<? super Chicken> chiken1 = new ArrayList<Chicken>(); // 1 List<? super BBQChicken> chiken2 = new ArrayList<Chicken>(); // 2 List<? super 자메이카> chiken3 = new ArrayList<BBQChicken>(); // 3
- READ
- 세개 모두 READ 할 수 없다.
- Chicken일 경우 Object의 형태일 수 있기 때문에 읽을 수 없다.
- 다만 Object의 경우는 제한적으로 가능하다.
- 최소 Chicken의 상위 타입으로 반환이 될텐데 자식클래스는 더 많은 필드를 가지고 있기 때문에 읽기는 불가능합니다. 하지만 최소한 Chicken 타입은 보장이 되기 때문에 쓰는것은 가능합니다.
- WRITE
- 첫번째
chiken1
은 Chicken, Object 모두 Chicken의 상위 타입이기 때문에 Chicken의 하위타입에 대해 write를 받아들일 수 있게 된다. - 두번째
chiken2
은 BBQChicken의 하위타입에 대한 write를 받는다. - 세번째
chiken3
은 자메이카의 하위 타입에 대한 wirte를 받는다.
- 반공변도 공변과 마찬가지로 메서드에
<? super>
를 붙임으로써 메서드에 제약이 생기게 된다. - lowerBound 타입에 대한 write만 가능해진다.
PECS(Producer-Extends, Consumer-Super)
그림

- 읽기 전용으로 제한할때 사용된다. (공변, Read, 코틀린에선 Out의 개념 - return)
- 어떤 메서드가 입력 파라미터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 생산하는 작업을 하는 경우 <? extends T> 한정적 와일드카드 타입을 적용하자
- 쓰기 전용으로 제한할때 사용된다. (반공변, Write, 코틀린에선 In의 개념 - Parameter)
- 어떤 메서드가 입력 파라미터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 소비(쓰기작업)하는 작업을 하는 경우 <? super T> 한정적 와일드카드 타입을 적용하자
- PECS 공식을 잘 사용한다면 제네릭으로 타입을 안전하고 훨씬 더 유연한 API를 만들 수 있습니다.
public static <T> void copy(List<? super T> dest, List<? extends T> src) { int srcSize = src.size(); if (srcSize > dest.size()) throw new IndexOutOfBoundsException("Source does not fit in dest"); if (srcSize < COPY_THRESHOLD || (src instanceof RandomAccess && dest instanceof RandomAccess)) { for (int i=0; i<srcSize; i++) dest.set(i, src.get(i)); // src를 읽어들여 dest에 쓰고 있습니다. } ...~ }