[CS] Generic

U·2025년 11월 24일

CS

목록 보기
21/23
post-thumbnail

📚 Generic

자바에서 제네릭이란 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.

	List<String> list = new ArrayList<>();

우리가 흔히 볼 수 있는 ArrayList 선언 시에 사용되는 <>이 바로 제네릭이다. 이 괄호 안에 타입명을 기재하면, 리스트 클래스 자료형의 타입은 지정한 타입의 데이터만 리스트에 적재할 수 있는 것이다.

이처럼 제네릭은 배열의 타입을 지정하듯이 리스트 자료형 같은 컬렉션 클래스나 메서드에서 사용할 내부 데이터 타입을 파라미터로 주듯이, 외부에서 지정하는 타입을 변수화한 기능이라고 이해하면 된다. 즉, 객체(Object)에 타입을 지정해주는 것이다.

🤔 그럼 Generic을 왜 쓸까?

1️⃣ 타입 안정성

  • 컴파일 시점의 강한 타입 체크를 할 수 있음 (런타임 시점은 체크 불가능)
  • 제네릭이 없다면 컬렉션에 다양한 타입의 객체를 담을 수 있지만, 이는 런타임 오류의 원인이 됨
  • <String>과 같이 타입을 명시하면, 컴파일러가 잘못된 타입이 들어오는 것을 사전에 차단 가능

2️⃣ 캐스팅 제거

  • 제네릭을 사용하지 않으면 데이터를 꺼낼 때마다 매번 형변환을 해야 함
  • 이는 성능 저하와 런타임 에러의 원인이 될 수 있음

제네릭 사용 전

	List list = new ArrayList();
    list.add("Hello");
    String str = (String) list.get(0); // 반드시 (String)으로 캐스팅 필요

제네릭 사용 후

	List<String> list = new ArrayList<>();
    list.add("Hello");
    String str = list.get(0); // 캐스팅 불필요. 코드가 간결해짐

3️⃣ 코드의 재사용성

  • 하나의 클래스나 메서드로 여러 타입의 데이터를 처리할 수 있음
  • 예를 들어 ArrayList<T> 하나로 ArrayList<String>, ArrayList<Integer> 등을 모두 처리할 수 있음

컴파일 시점 vs 런타임 시점

컴파일 시점은 작성한 코드 .java가 기계어 .class로 변환되는 과정이다. 문법 오류와 타입 체크, 클래스 참조를 확인하며, 제네릭에 타입 정보가 존재한다.

런타임 시점은 컴파일된 프로그램이 메모리에 적재되어 실제로 실행되는 상태이다. NullPointerException, 메모리 부족(OOM), 로직 오류 등을 체크할 수 있으며, 타입 정보가 소거되어 Object로 변경된다.

자바 컴파일러는 제네릭을 사용하여 타입을 검사한 후, 런타임에는 이전 버전과의 호환성을 위해 제네릭 타입을 제거한다. 이러한 이유는 자바 5 이전 버전으로 작성된 코드와의 하위 호환성을 유지하기 위해서이며, 이것을 타입 소거(Type Erasure)라고 한다. 따라서 제네릭은 런타임 시점에는 체크가 불가능한 것이다.

PECS

PECS는 Producer-Extends, Consumer-Super의 약자로, 제네릭의 와일드카드 ?를 사용할 때 언제 extends를 쓰고 언제 super를 써야 하는지 결정하는 공식이다. 이 원칙은 컬렉션 입장에서 데이터를 제공하는지, 데이터를 소비하는지에 따라 결정된다.

📌 Producer-Extends (<? extends T>)

  • 컬렉션이 데이터를 꺼내서(Get/Read) 사용해야 할 때, 컬렉션이 데이터를 생산(Produce)해서 우리에게 줌
  • List<? extends Number>는 Number 혹은 그 하위 타입(Integer, Double 등)을 가지고 있음이 보장됨 -> 따라서 꺼낸 데이터는 안전하게 Number 타입으로 읽을 수 있음
  • 구체적으로 어떤 하위 타입인지는 모르기 때문에 데이터를 추가할 수는 없음

📌 Consumer-Super (<? super T>)

  • 컬렉션이 데이터를 저장(Add/Write)해야 할 때, 컬렉션이 우리가 제공하는 데이터를 소비(Consume)함
  • List<? super Integer>는 Integer 혹은 그 상위 타입(Number, Object)을 담는 리스트임이 보장됨 -> 따라서 Integer는 안전하게 담을 수 있음
  • 데이터를 꺼낼 때 타입을 보장받을 수 없으며, 최상위인 Object로만 읽어야 함
  • 단, 데이터를 추가할 때는 T 자체 또는 T의 하위 타입만 가능함

💡 따라서 데이터를 읽기만 할 거면 extends, 넣기만 할 거면 super, 둘 다 해야 하면 와일드카드를 쓰지 않는게 좋다.

Wild card

제네릭은 기본적으로 불공변이다. 즉, StringObject의 자식이라도 List<String>List<Object>의 자식이 아니다.

이러한 제약으로 유연성이 떨어질 때, 이를 해결하기 위해 사용하는 것이 와일드카드(?)이다. 와일드카드는 알 수 없는 타입을 의미한다.

종류표기법설명주 용도
Unbounded (비한정)<?>모든 타입을 허용
<? extends Object>와 유사
타입에 의존하지 않는 메서드를 작성할 때
예) size(), clear()
Upper Bounded (상한 경계)<? extends T>T와 T의 자식 타입만 허용Producer(PECS) : 데이터를 안전하게 읽을 때
Lower Bounded (하한 경계)<? super T>T와 T의 부모 타입만 허용Consumer(PECS) : 데이터를 안전하게 쓸 때

예시

	class Food {}
    class Fruit extends Food {}
    class Apple extends Fruit {}

    // 1. Upper Bounded (<? extends Fruit>)
    // Fruit의 자식들은 다 올 수 있음 (과일 바구니)
    // 꺼내면 최소한 Fruit임은 보장
    
    void printFruits(List<? extends Fruit> basket) {
        for (Fruit f : basket) { // 읽기 가능 (OK)
            System.out.println(f);
        }
        // basket.add(new Apple()); // 쓰기 불가 (Error! 이 바구니가 List<Banana>일 수도 있음)
    }

    // 2. Lower Bounded (<? super Fruit>)
    // Fruit의 부모들은 다 올 수 있음 (과일이 들어가는 상자)
    // Fruit은 안전하게 담을 수 있음
    
    void addApple(List<? super Fruit> basket) {
        basket.add(new Apple()); // 쓰기 가능 (OK)
        basket.add(new Fruit()); // 쓰기 가능 (OK)
        // Fruit f = basket.get(0); // 읽기 불가 (Error! Object로만 읽힘)
    }
profile
백엔드 개발자 연습생

0개의 댓글