지네릭스(Generics)

이성민·2025년 9월 11일

JAVA

목록 보기
4/4
post-thumbnail

서론

이 글은 자바의 정석 3판을 완독한 후 작성한 내용입니다.
학습 과정에서 자주 접하게 된 지네릭스(Generics)가 헷갈려 정리할 필요성을 느꼈습니다. 훗날 제가 복습할 때나 처음 지네릭스를 접하는 분들에게 작은 도움이 되길 바랍니다.


지네릭스(Generics)란?

다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입체크(compile-time type check)를 해주는 기능이다.
객체의 타입을 컴파일 시에 체크하기 때문에 타입 안정성이 높아지고, 불필요한 형변환이 줄어든다.
쉽게 말해서, 다룰 객체의 타입을 미리 명시해줌으로써 번거로운 형변환을 줄여준다.

-> 여기서 핵심은, 지네릭스의 타입 정보는 “컴파일 시점에만” 쓰이고, 런타임에는 사라진다(type erasure)라는 점이다.

  • 컴파일 시점: 문법·타입을 검사하는 단계 (잘못된 타입 사용 시 에러 발생)

  • 런타임 시점: 프로그램이 실제 실행되는 단계 (이때는 타입 정보가 지워져 Box<String>Box<Integer>가 같은 클래스 취급됨)

지네릭스의 장점

  1. 타입 안정성을 제공한다.
  2. 타입체크와 형변환을 생략할 수 있으므로 코드가 간결해진다.

지네릭스는 클래스와 메서드에 선언할 수 있는데 먼저 클래스에 선언하는 지네릭 타입에 대해서 설명하겠다.


지네릭 클래스의 선언

1. 기본 클래스 선언

먼저 지네릭스를 사용하지 않은 클래스 예시:

class Box {
	Object item;
    
    void setItem(Object item) { this.item = item; }
    Object getItem() { return item; }
}

2. 지네릭스로 변환

Object 대신 타입 변수 T를 사용하면, 클래스 옆에 <T>를 붙인다.

class Box<T> {
	T item;
    
    void setItem(T item) { this.item = item; }
    T getItem() { return item; }
}

3. 타입 변수 네이밍

타입 변수는 꼭 T일 필요는 없다. 의미에 맞는 문자로 자유롭게 사용 가능하다.

  • Box<T> : T(Type)
  • ArrayList<E> : E(Element)
  • Map<K, V> : K(Key), V(Value)

여러 개를 쓰려면 콤마(,) 로 구분한다.

4. 사용 예시

Box<String> b = new Box<String>();  // 실제 타입 지정
b.setItem(new Object());			// Error
b.setItem("ABC");					// OK
String item = (String) b.getItem(); // 왼쪽에 보이는 (String)을 안적어도됨. 형변환 필요없음

타입이 지정되면, 마치 Box<String>이 아래와 같이 정의된 것처럼 동작한다:

class Box<String> {
	String item;
    
    void setItem(String item) { this.item = item; }
    String getItem() { return item; }
 }

Box b = new Box();처럼 타입을 지정하지 않으면 경고 발생
반드시 타입을 명시하는 습관을 들이자.


지네릭스 용어

아래와 같이 지네릭 클래스가 선언 되었을 때를 예시로 설명하겠다.

class Box<T> {}
  • Box<T> : 지네릭 클래스. 'T'의 'Box'또는 'T Box'라고 읽는다.
  • T : 타입 변수 또는 타입 매개변수(T는 타입 문자)
  • Box : 원시 타입(raw type)

지네릭스의 제한

1. static 멤버에 타입 변수 사용 불가

T는 인스턴스 변수처럼 동작하므로 static 멤버에는 쓸 수 없다.

class Box<T> {
	static T item; // 에러
    static int compare(T t1, T t2) {...} // 에러
}

2. 지네릭 배열 생성 불가

참조 변수 선언은 가능하지만, new T[]는 불가능하다.

class Box<T> {
	T[] itemArr; // OK T타입의 배열을 위한 참조변수
    ...
    T[] toArray() {
    	T[] tmpArr = new T[itemArr.length]; // Error 지네릭 배열 생성 불가
        ...
        return tmpArr;
    }
    ...
}

이유:

new 연산자는 컴파일 시점에 타입을 알아야 배열을 만들 수 있다.
하지만 지네릭 클래스는 컴파일할 때 T가 어떤 타입인지 알 수 없으므로 허용되지 않는다.


지네릭스 클래스의 객체 생성과 사용

1. 객체 생성 시 타입 일치

Box<T> 객체를 생성할 때는 참조 변수와 생성자에 지정된 타입이 일치해야 한다.
일치하지 않으면 컴파일 에러가 발생한다.

Box<Apple> appleBox = new Box<Apple>(); // OK
Box<Apple> appleBox = new Box<Grape>(); // Error

상속 관계여도 불일치하면 에러

Apple이 Fruit의 자손이라고 가정

Box<Fruit> appleBox = new Box<Apple>(); // Error 대입된 타입이 다름.

2. 지네릭 클래스 자체가 상속관계인 경우

클래스 간 상속 + 대입된 타입 일치 → OK
FruitBox는 Box의 자손이라고 가정

Box<Apple> appleBox = new FruitBox<Apple>(); // OK 다형성

3. 타입 추론 (JDK 1.7+)

컴파일러가 타입을 추론할 수 있으면 생성자 쪽 타입 생략 가능

Box<Apple> appleBox = new Box<Apple>();
Box<Apple> appleBox = new Box();

add 메서드에 객체 추가

생성된 Box<T> 객체에 void add(T item)을 호출할 때,
대입된 타입과 다른 객체는 추가 불가

Box<Apple> appleBox = new Box<Apple>();
appleBox.add(new Apple()); // OK
appleBox.add(new Grape()); // Error

타입이 Fruit인 경우

타입 매개변수 T가 Fruit이면 → void add(Fruit item)
즉, Fruit와 그 자손들(Apple 등) 추가 가능
Apple이 Fruit의 자손이라고 가정한다.

Box<Fruit> appleBox = new Box<Fruit>();
appleBox.add(new Fruit()); // OK
appleBox.add(new Apple()); // OK

제한된 지네릭 클래스

1. 기본 지네릭 클래스

타입 변수 T는 기본적으로 어떤 타입이든 대입 가능하다.

FruitBox<Toy> fruitBox = new FruitBox<Toy>();
fruitBox.add(new Toy()); // OK.

2. 클래스 상속으로 제한 (extends)

extends 키워드를 사용하면 특정 클래스의 자손만 타입으로 지정할 수 있다.

class FruitBox<T extends Fruit> {
	ArrayList<T> list = new ArrayList<T>();
    ...
}

여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것이다.

FruitBox<Apple> appleBox = new FruitBox<Apple>(); // OK.
FruitBox<Toy> toBox = new FruitBox<Toy>(); // 에러. Toy는 Fruit의 자손이 아님

3. 다형성과 add() 메서드

제한된 타입은 조상 타입 참조 변수에 자손 객체를 담는 것처럼 동작한다.
즉, Fruit를 상한 제한으로 지정하면, 그 자손 클래스들도 추가 가능하다.

FruitBox<Fruit> fruitBox = new FruitBox<Fruit>();
fruitBox.add(new Apple()); // OK. Apple이 Fruit의 자손
fruitBox.add(new Grape()); // OK. Grape가 Fruit의 자손

다형성에서 조상타입의 참조변수로 자손타입의 객체를 가릴킬 수 있는 것처럼, 매개변수화된 타입의 자손 타입도 가능한 것이다.

4. 인터페이스 구현으로 제한

클래스가 아니라 특정 인터페이스를 구현해야 한다는 제한도 가능하다.
(주의: implements가 아니라 extends를 사용해야 함)

interface Eatable {}
class FruitBox<T extends Eatable> { ... }

5. 클래스 + 인터페이스 동시 제한

클래스와 인터페이스를 동시에 제한할 수도 있다.
(여러 개의 인터페이스는 & 기호로 연결한다.)

class FruitBox<T extends Fruit & Eatable> { ... }

이제 FruitBox에는 Fruit의 자손이면서 Eatable을 구현한 클래스만 타입 매개변수 T에 대입될 수 있다.


와일드 카드

와일드 카드를 사용하면 좋은 문제 상황을 예들어 설명하겠다.

1. 문제 상황

지네릭 메서드에서 매개변수를 FruitBox<Fruit>로 고정하면,
FruitBox<Apple> 같은 다른 타입은 받을 수 없음.

static Juice makeJuice(FruitBox<Fruit> box) { ... }

Juicer.makeJuice(new FruitBox<Fruit>());  // OK.
Juicer.makeJuice(new FruitBox<Apple>());  // Error.

해결하려고 makeJuice(FruitBox<Fruit>), makeJuice(FruitBox<Apple>) 등 오버로딩하면?
→ 컴파일 에러 (지네릭 타입 정보는 컴파일 시점에만 쓰이고 런타임에는 사라짐 → 중복 정의로 간주됨)

2. 와일드 카드(?) 개념

지네릭 타입을 유연하게 받기 위해 도입된 기호.

  • <? extends T> : 와일드 카드의 상한 제한. T와 그 자손들만 가능
  • <? super T> : 와일드 카드의 하한 제한. T와 그 조상들만 가능
  • <T> : 제한 없음. 모든 타입이 가능. <? extends Object>와 동일

참고 : 지네릭 클래스와 달리 와일드 카드에는 '&'를 사용할 수 없다. 즉 <? extends T & E>와 같이 할 수 없다.

3. 문제 해결 예시

static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();

Juicer.makeJuice(fruitBox); // OK.
Juicer.makeJuice(appleBox); // OK.

지네릭 메서드

1. 정의

  • 메서드 선언부에 지네릭 타입 매개변수를 선언한 메서드
  • 선언 위치는 반환 타입 바로 앞
static <T> void sort(List<T> list, Comparator<? super T> c)

2. 특징

  • 지네릭 클래스에 선언된 타입 매개변수와는 완전히 별개

    • 이름이 같아도 다른 타입 변수
  • static 메서드에도 사용 가능

    • static 멤버에는 클래스의 타입 매개변수를 쓸 수 없지만
    • 메서드에 직접 타입 매개변수를 선언하면 static 가능
  • 메서드에 선언된 타입 매개변수는 지역 변수처럼 해당 메서드 내부에서만 유효

3. 예시

일반 메서드 :

static Juice makeJuice(FruitBox<? extends Fruit> box) {
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

지네릭 메서드로 변경 :

static <T extends Fruit>Juice makeJuice(FruitBox<T> box) {
    String tmp = "";
    for (Fruit f : box.getList()) tmp += f + " ";
    return new Juice(tmp);
}

4. 호출 방법

FruitBox<Fruit> fruitBox = new FruitBox<>();
FruitBox<Apple> appleBox = new FruitBox<>();

// 타입 명시
System.out.println(Juicer.<Fruit>makeJuice(fruitBox));
System.out.println(Juicer.<Apple>makeJuice(appleBox));

// 대부분은 타입 추정 가능 (생략 가능)
System.out.println(Juicer.makeJuice(fruitBox));
System.out.println(Juicer.makeJuice(appleBox));

주의 : 타입을 명시해야 하는 경우에는 클래스/참조변수도 반드시 적어야 함

System.out.println(<Fruit>makeJuice(fruitBox));        // Error.
System.out.println(this.<Fruit>makeJuice(fruitBox));   // OK.
System.out.println(Juicer.<Fruit>makeJuice(fruitBox)); // OK.

지네릭 타입의 형변환

1. 지네릭 타입 ↔ 원시 타입(raw type)

  • 지네릭 타입과 원시 타입 간의 형변환은 항상 가능
  • 단, 컴파일러 경고 발생 (타입 안전성 보장 불가)
Box box = null;
Box<Object> objBox = null;

box = (Box)objBox;               // OK. (경고 발생)
objBox = (Box<Object>)box;       // OK. (경고 발생)

2. 서로 다른 지네릭 타입 간의 형변환

  • 불가능
  • 대입된 타입이 Object여도 변환 안 됨
Box<Object> objBox = null;
Box<String> strBox = null;

objBox = (Box<Object>)strBox; // Error.
strBox = (Box<String>)objBox; // Error.

Box<Object> objBox = new Box<String>(); // Error.

3. 와일드카드 타입으로의 형변환

  • Box<String>Box<? extends Object> 변환은 가능
  • 즉, 와일드카드를 쓰면 다형성이 적용됨
Box<? extends Object> wBox = new Box<String>(); // OK.

4. 활용 예시 – 다형성과 와일드카드

makeJuice() 메서드가 FruitBox<? extends Fruit>를 받도록 하면,
FruitBox<Fruit>, FruitBox<Apple>, FruitBox<Grape> 모두 전달 가능

static Juice makeJuice(FruitBox<? extends Fruit> box) { ... }

FruitBox<? extends Fruit> box1 = new FruitBox<Fruit>();  // OK.
FruitBox<? extends Fruit> box2 = new FruitBox<Apple>();  // OK.
FruitBox<? extends Fruit> box3 = new FruitBox<Grape>();  // OK.
profile
BE 개발자

0개의 댓글