HomeAboutMeBlogGuest
© 2025 Sejin Cha. All rights reserved.
Built with Next.js, deployed on Vercel
🍗
[New] 조규현팀
/
🏪
TS Store
/
🎵
Generic
/
🚔
제네릭 1부
🚔

제네릭 1부

제네릭 톺아보기1️⃣ 제네릭이란?2️⃣ 제네릭을 왜 사용하는거지? 🤔제네릭 타입을 적용하지 않았을 때제네릭 타입을 적용 했을 때왜 자메이카가 나온 것일까?이쯤에서 생각해 볼 수 있는 문제 - 힙 오염3️⃣ 제네릭의 특징 - 타입소거(Erasure)제네릭에는 왜 프리미티브 타입을 허용하지 않는걸까?타입소거를 한다는 점에서 알 수 있는 점4️⃣ 공변 / 무공변 / 반공변무공변(Invariance)공변(Variance)반공변(Contravariance)PECS(Producer-Extends, Consumer-Super)
 

제네릭 톺아보기

 

1️⃣ 제네릭이란?

  • 클래스나 메서드에서 사용할 내부 데이터 타입을 외부에서 지정하는 기법이다.
  • 즉 데이터 형식에 의존하지 않고 하나의 값이 여러 다른 데이터 타입들을 가질 수 있는 기술에 중점을 두어 재사용성을 높일 수 있는 프로그래밍 방식.
  • 클래스 선언에 타입 매개변수가 쓰이면 제네릭 클래스라고 말할 수 있다.
  • 도라에몽 주머니라고 생각하시면 어떨까요? 도라에몽 주머니에는 다양한 물건들이 들어있는 것 처럼 제네릭도 다양한 타입들을 하나의 클래스를 통해 사용 할 수 있어요 !
  • 또한 반대로 타입을 강제할 수도 있어요 ! 강제하는 방법은 밑에 예시를 통해서 확인해 보겠습니다 😀
 
 
notion image

2️⃣ 제네릭을 왜 사용하는거지? 🤔

  • 컴파일 타임에 타입 오류에 대한 검증을 수행하여 런타임에는 안전한 코드를 실행하게 됩니다.
  • 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있고 형변환 또한 사라지기 때문에 가독성이 좋아집니다.
    • 제네릭 클래스를 사용하지 않은 경우에는 Object 타입으로 반환되기 때문에 하나하나 캐스팅을 해줘야 합니다.
 
  • 아래는 예시를 위한 치킨과 치킨박스 클래스입니다.
notion image
notion image
🤔
상황 : 어느날 규현 멘토님이 일을 마치고 집에 돌아와 비비큐 황금올리브 치킨을 시켰다고 가정해 보겠습니다.

제네릭 타입을 적용하지 않았을 때

  • 흐름설명
      1. 치킨박스에 타입을 지정하지 않고 치킨박스 객체를 하나 생성하였습니다. (주석 1)
      1. 치킨박스에 실수로 황금올리브가 아닌 자메이카치킨을 담았습니다. (주석 2)
      1. 치킨박스에 치킨을 꺼내보면…? (주석 3)
  • 결과
    • 황금올리브가 아닌 자메이카 치킨이 배달이 되어버렸습니다..
notion image

제네릭 타입을 적용 했을 때

  • 흐름설명
      1. 황금올리브 타입의 치킨박스 객체를 생성하였습니다. (주석1)
      1. 치킨박스에 황금올리브 치킨을 담았습니다. (주석2)
      1. 치킨박스에 치킨을 꺼내보면…? (주석3)
  • 결과
    • 황금올리브가 정상적으로 도착했습니다 🙂
    • notion image

왜 자메이카가 나온 것일까?

  • 제네릭을 사용하지 않는다면 자료형에 대한 검증이 컴파일 시점에 이루어지지 않습니다.
    • 문법적으로는 오류가 없지만 컴파일 시 타입체크가 이루어지지 않기 때문에 오류 사실을 인지 못하며 런타임 시 에러가 발생할 수 있습니다.
    • 나는 황금올리브를 기대해서 황금올리브로 받았는데 꺼내진 치킨이 자메이카 치킨이기 나왔기 때문에 에러가 발생해요 자메이카가 황금올리브가 될 순 없잖아요 ..?
    • notion image
  • 제네릭으로 올바르게 작성한다면 컴파일 시점에 에러를 알려줍니다.
    • 자바 컴파일러는 제네릭 코드에 대해 타입체크를 해주게 됩니다. 그리고 타입 안전성에 위배된다면 바로 컴파일 에러를 나타냅니다.
    • 황금올리브 치킨박스를 만들었는데 자메이카 치킨을 넣을 순 없겠죠?
    • notion image

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

notion image
  • 위의 코드는 정상적으로 컴파일 되는 코드입니다.
  • 그 이유는 타입 캐스팅 연산자는 컴파일러가 체크하지 않습니다.
    • 따라서 캐스팅을 시도하는 런타임에 예외가 발생하게 됩니다.
  • 이러한 상황을 힙 오염이라고 표현합니다.
  • 도라에몽 주머니라고 생각한다고 해서 하나의 제네렉 클래스 객체에 대해서 다양한 타입을 넣고 사용하라는 의미는 아닙니다 !
자세한 설명 블로그
[Java] 힙 펄루션 (Heap pollution)
펄루션(pollution)의 사전적 의미는 오염이다. 따라서 힙 펄루션은 JVM의 메모리 공간인 heap area 가 오염된 상태를 뜻한다. 즉, 어떠한 이유에서든 힙에 문제가 생기면 그것을 힙이 펄루션 되었다고 할 수 있다. 힙이 오염되는 대표적인 원인 하나를 살펴보도록 하자. 자바에서 제네릭은 타입의 안전성을 보장해주는 강력한 도구이다. 제네릭은 Java 5 에서 처음 도입될 때 약간의 논란이 있었다.
[Java] 힙 펄루션 (Heap pollution)
https://velog.io/@adduci/Java-%ED%9E%99-%ED%8E%84%EB%A3%A8%EC%85%98-Heap-pollution
[Java] 힙 펄루션 (Heap pollution)

3️⃣ 제네릭의 특징 - 타입소거(Erasure)

  • 제네릭은 컴파일 과정에서 컴파일러가 제네릭 타입을 이용해 소스파일을 체크하고 필요한 곳에 자동으로 형변환을 넣어준다. 그 후 제네릭 타입은 제거되고 컴파일 완료된 .class 파일에는 타입이 포함되지 않습니다.
  • 타입소거를 하는 이유는?
    • 제네릭이 도입되기 이전의 소스코드와의 호환성을 유지하기 위해서다.
    • JDK1.5부터 제네릭이 도입되었지만 아직도 원시타입을 사용해 코드를 작성하는 것을 허용한다.
    • 이러한 하위호환성을 유지하기 위해 로(raw)타입 + 제네릭을 구현할 때 타입소거 방식을 이용했다.
    •  

제네릭에는 왜 프리미티브 타입을 허용하지 않는걸까?

  • 프리미티브 타입도 결국 “타입"에 속하는데 왜 허용하지 않는 걸까요?
  • 내부에서 타입 파라미터를 사용하는 경우 Object 타입으로 취급되어 처리하고 있습니다.
  • 타입 소거는 제네릭 타입이 특정 타입으로 제한되어 있을 경우 해당 타입에 맞춰 컴파일시 타입 변경이 발생하고 타입 제한이 없을 경우 Object 타입으로 변경됩니다.
  • 즉 프리미티브 타입을 사용하지 못하는 이유는 기본 타입으로 Object 클래스를 상속받고 있지 않기 때문이며 Wrapper 클래스를 이용해야 한다는 점을 알 수 있습니다.
 

타입소거를 한다는 점에서 알 수 있는 점

  • 제네릭은 컴파일 시점에 타입 검사를 하고 컴파일 된 .class 파일에는 타입이 소거되기 때문에 메서드 오버로딩이 불가능합니다.
  • 제네릭 타입이 소스코드에서는 다르지만 컴파일 된 class 파일에는 소거가 되기 때문에 중복선언을 하는 것 과 동일합니다.
  • 이런 상황에 사용하기 위해 와일드카드를 사용할 수 있습니다. 하지만 와일드카드(?)만 사용한다면 Object 타입과 다를 게 없기 때문에 extends, super 키워드를 통해 적정한 상한경계, 하한경계를 제한하여 사용할 수 있습니다. (자세한 내용은 뒤에서 설명하겠습니다.)

4️⃣ 공변 / 무공변 / 반공변

무공변(Invariance)

🤔
A가 B의 하위 타입일 때, T<A>와 T<B>간의 아무 관계도 없다면 무공변이라고 말한다.
  • 왜 사용할까? 왜 알아야 할까?
    • 일반적으로 많이 사용하는 것이죠? 위의 치킨박스 예시처럼 타입을 제한해서 사용하는 방법입니다. “나는 이 타입만 들어오게 할거야 !” 라고 제한을 할 수 있습니다 !
notion image
notion image

공변(Variance)

🤔
A가 B의 하위 타입일 때, T<A> 가 T<B>의 하위 타입이면 T가 공변의 성질을 가지고 있다고 한다.
  • 왜 사용할까? 왜 이런것을 알아야 할까?
    • 실제로 변성으로 가득한 것들은 대부분 무공변으로 처리할 수 있다. 하지만 공변을 선언함으로써 얻는 이득은 꽤 크다고 할 수 있다.
    • 내가 만든 인터페이스, 클래스, 메서드, 라이브러리를 다른 사람이 잘못 사용할 여지를 줄여줘서 원하는 의도대로 프로그래밍이 가능하다.
notion image
notion image
  • 녹색박스의 경우는 클래스 모두 Object를 상속받고 있기 때문에 가능하다.
  • 빨간박스의 경우는 첫번째 줄을 제외한 나머지 세줄은 컴파일 에러가 난다.
    • 그 이유는 List는 무공변하기 때문이다. 오직 List<Object>만 허용한다는 뜻이다.
      • List<Number>는 List<Object>의 하위 타입이 아니다. (무공변의 성질)
  • 공변은 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가 반공변의 성질을 가지고 있다고 말한다.
  • 왜 사용할까? 왜 알아야 할까?
    • 무공변한 제네릭을 공변의 특성으로 변경하는 과정에서 읽기작업만 가능하고 쓰는 작업이 불가능한 부분을 반공변 특성을 통해 읽기작업만 가능했던 문제를 하한경계를 통해 하위타입에 대해 쓰는 작업이 가능하도록 해결할 수 있습니다.
notion image
notion image
  • 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)

그림
notion image
  • 읽기 전용으로 제한할때 사용된다. (공변, Read, 코틀린에선 Out의 개념 - return)
    • 어떤 메서드가 입력 파라미터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 생산하는 작업을 하는 경우 <? extends T> 한정적 와일드카드 타입을 적용하자
  • 쓰기 전용으로 제한할때 사용된다. (반공변, Write, 코틀린에선 In의 개념 - Parameter)
    • 어떤 메서드가 입력 파라미터로 제네릭을 적용한 컨테이너를 받고, 메서드 안에서 해당 컨테이너가 소비(쓰기작업)하는 작업을 하는 경우 <? super T> 한정적 와일드카드 타입을 적용하자
  • PECS 공식을 잘 사용한다면 제네릭으로 타입을 안전하고 훨씬 더 유연한 API를 만들 수 있습니다.
더 살펴보면 좋은 그림
How to check covariant and contravariant position of an element in the function?
Your Pets class can produce values of type A by returning the member variable pet, so a Pet[VeryGeneral] cannot be a subtype of Pet[VerySpecial], because when it produces something VeryGeneral, it cannot guarantee that it is also an instance of VerySpecial. Therefore, it cannot be contravariant.
How to check covariant and contravariant position of an element in the function?
https://stackoverflow.com/questions/48812254/how-to-check-covariant-and-contravariant-position-of-an-element-in-the-function/48858344#48858344
How to check covariant and contravariant position of an element in the function?
notion image
 
 
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<>();
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 } }
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 } }
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);
// Before List<Integer> list = new ArrayList(); // After ArrayList list = new ArrayList();
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) } } } // 컴파일 전 소스코드와 후 코드를 캡쳐해서 차이를 보여주면 좋을 것 같다.
Chicken chiken = new BBQ();
List<Chicken> chiken = new ArrayList<BBQ>();
List<? extends Chicken> chicken1 = new ArrayList<Chicken>(); // 1 List<? extends Chicken> chicken2 = new ArrayList<BBQChicken>(); // 2 List<? extends Chicken> chicken3 = new ArrayList<BHCChicken>(); // 3
List<? super Chicken> chiken1 = new ArrayList<Chicken>(); // 1 List<? super BBQChicken> chiken2 = new ArrayList<Chicken>(); // 2 List<? super 자메이카> chiken3 = new ArrayList<BBQChicken>(); // 3
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에 쓰고 있습니다. } ...~ }