Skip to content

14. 제네릭(Generic)


1. 키워드

  • 제네릭(Generic)
  • 타입 변수(Type Variable)
  • 제네릭 클래스(Generic Class)와 제네릭 메서드(Generic Method)
  • 와일드카드(Wild Card)


2. 제네릭(Generic)의 개념

  • 자바에서 제네릭이란 데이터의 타입(Data Type)을 일반화한다(Generalize)는 것을 의미한다.
  • 제네릭은 클래스나 메서드에서 사용할 내부 데이터 타입을 컴파일 시 미리 지정하는 방법이다.
  • 이렇게 컴파일 시 미리 타입 검사(Type Check)를 수행하면 다음과 같은 장점을 가진다.


1] 클래스나 메서드 내부에서 사용되는 객체의 타입 안정성을 높일 수 있다.

2] 반환값에 대한 타입 변환 및 타입 검사에 들어가는 노력을 줄일 수 있다.


  • JDK 1.5 이전에서는 여러 타입을 사용하는 대부분의 클래스나 메서드에서 인수나 반환값으로 Object 타입을 사용했다.
  • 하지만 이 경우에는 반환된 Object 객체를 다시 원하는 타입으로 타입 변환해야 하며, 이때 오류가 발생할 가능성도 존재했다.
  • JDK 1.5부터 도입된 제네릭을 사용하면 컴파일 시에 미리 타입이 정해지므로, 타입 검사나 타입 변환과 같은 번거로운 작업을 생략할 수 있게 된다.


1) 제네릭을 사용하지 않는 경우의 문제점

  • JDK 1.5 이전에서는 여러 타입을 사용하는 대부분의 클래스나 메서드에서 인수나 반환값으로 Object 타입을 사용했다.


  • 다음의 예제는 자바에서 자주 사용하게 되는 ArrayList를 모방하여, 제네릭을 사용하지 않는 SimpleArrayList를 만드는 예제이다.


class SimpleArrayList {

  private int size;
  private Object[] elementData = new Object[5];

  public void add(Object value) {
    elementData[size++] = value;
  }

  public Object get(int idx) {
    return elementData[idx];
  }
}

class Test {

  public static void main(String[] args) {
    SimpleArrayList list = new SimpleArrayList();

    list.add(50);
    list.add(100);

    Integer intObj1 = (Integer) list.get(0);
    Integer intObj2 = (Integer) list.get(1);

  // 언박싱
    int value1 = intObj1.intValue();
    int value2 = intObj2.intValue();

    System.out.println(value1 + value2); // 150
  }
}


  • 컴파일도 잘 되고 결괏값도 반환이 잘 되는 것을 볼 수 있다.
  • 위의 예제의 ①번 라인처럼 add() 메서드는 매개변수로 Object 타입을 받기 때문에 어떤 데이터 타입도 받을 수 있다.
  • 그러므로 ②번과 ③번 라인처럼 타입 변환만 알맞게 잘 해주면 어떤 데이터 타입이든 저장할 수 있게 된다.


  • 다음의 예제는 위의 예제에서 add() 메서드에 int 타입 데이터 대신 String 타입 데이터를 인수로 바꿔 넣는 예제이다.


class Test {

  public static void main(String[] args) {
    SimpleArrayList list = new SimpleArrayList();

   // int 타입 대신 String 타입으로 인수 전달
    list.add("50");
    list.add("100");

    Integer intObj1 = (Integer) list.get(0);
    Integer intObj2 = (Integer) list.get(1);

    // int value1 = intObj1.intValue();
    // int value2 = intObj2.intValue();

    // System.out.println(value1 + value2);
  }
}


  • 위의 예제에서 ①번과 ②번 라인처럼 int 타입 대신 String 타입으로 인수를 전달하여 add() 메서드를 호출했다.
  • 그리고 ③번과 ④번 라인처럼 Integer 타입으로 결괏값을 반환받을 수 있도록 get() 메서드를 호출했다.
  • 여기서 SimpleArrayList 클래스의 add() 메서드는 Object 타입을 인수로 받을 수 있고, get() 메서드는 Object 타입을 반환하기 때문에 ①번에서 ④번 라인까지의 코드에서는 문법적으로는 아무 문제가 없다.
  • 실제로도 컴파일까지는 잘 되는데, 실행하면 String 타입을 인수로 전달하고 나서, Integer 타입으로 타입 변환을 했다는 이유로 런타임에서 타입 캐스팅 오류가 발생하게 된다.
  • 하지만 다시 말하자면, String 타입이나 Integer 타입이나 모두 Object 클래스를 상속받기 때문에 이는 문법적으로는 문제가 없는 것이다.
  • 즉, 위의 예제와 같은 방식으로 코드를 작성하면 어떤 타입으로 타입 변환을 할 수 있는지 조차 모호한 경우가 많아지고, 컴파일 시 어떠한 오류도 발생하지 않기 때문에 잠재적인 오류를 가지게 된다.


  • 다음의 예제는 위의 예제의 문제점을 해결할 수 있는 방법 중 제네릭을 사용하지 않는 예제이다.


 class SimpleArrayListForInteger {

  private int size;
  private int[] elementData = new int[5];

  public void add(int value) {
    elementData[size++] = value;
  }

  public int get(int idx) {
    return elementData[idx];
  }
}

 class SimpleArrayListForString {

  private int size;
  private String[] elementData = new String[5];

  public void add(String value) {
    elementData[size++] = value;
  }

  public String get(int idx) {
    return elementData[idx];
  }
}


  • 제네릭을 사용하지 않는 경우 ①번과 ②번 라인처럼 SimpleArrayListForInteger 클래스와 SimpleArrayListForString 클래스 모두 작성해 줘야 한다.
  • 만약 더 많은 데이터 타입에 대한 클래스가 필요한 경우 그에 맞게 더 많은 클래스를 작성해 줘야하고, 이는 코드의 중복을 야기하게 된다.


2) 제네릭을 사용해서 문제 해결

  • 다음의 예제는 제네릭을 사용해서 위에서 나온 문제점을 해결하는 것을 보여주는 예제이다.


 class GenericArrayList<T> {

  private Object[] elementData = new Object[5];
  private int size;

  public void add(T value) {
    elementData[size++] = value;
  }

  public T get(int idx) {
    return (T) elementData[idx];
  }
}

class Test {

  public static void main(String[] args) {
    GenericArrayList<Integer> intList = new GenericArrayList<Integer>();

    intList.add(50);
    intList.add(100);

  // 오토 언박싱을 사용하는 경우
  // int intValue1 = intList.get(0);
    // int intValue2 = intList.get(1);

    Integer intObj1 = intList.get(0);
    Integer intObj2 = intList.get(1);

  // 언박싱
    int intValue1 = intObj1.intValue();
    int intValue2 = intObj2.intValue();

   // String strObj = intList.get(0); -> 컴파일 오류 발생

    System.out.println(intValue1 + intValue2); // 150
  }
}


  • 위의 예제의 ①번 라인과 같이 타입 변수 T를 추가하여 제네릭 클래스라는 것을 정의하면, 해당 클래스는 제네릭 클래스가 된다.
  • ②번 라인의 경우, 어떠한 데이터 타입의 요소가 들어오는지에 상관없이 Object 타입의 배열에 저장하기 위함이다.
  • ③번 라인은 어떠한 데이터 타입이든지 인스턴스를 생성할 때 지정한 타입이 인수로 전달되면, 타입 변수 T에 해당 데이터 타입이 대체되어 들어가게 된다.
  • ④번 라인의 경우도 ③번 라인과 마찬가지로 해당 데이터 타입으로 반환된다.
  • ⑤번 라인처럼 데이터 타입을 지정하여 인스턴스를 생성하면, 이때 생성된 인스턴스 내부의 타입 변수 T에 해당 데이터 타입이 대체되는 것이다.
  • ⑥번과 ⑦번 라인의 경우 반환 데이터 타입으로 Integer 타입이 반환되기 때문에 별도로 타입 캐스팅을 할 필요가 없게 된다.
  • 그리고 get() 메서드의 반환 데이터 타입은 Integer 타입이기 때문에 ⑧번 라인처럼 String 타입으로 저장하려고 한다면 컴파일 오류가 발생하게 된다.


  • 다음의 코드는 위의 예제를 컴파일한 후 다시 디컴파일한 것이다.


class Test {
  Test() {
 }

  public static void main(String[] var0) {
    GenericArrayList var1 = new GenericArrayList();
    var1.add(50);
    var1.add(100);
    int var2 = (Integer)var1.get(0);
    int var3 = (Integer)var1.get(1);
  }
}


  • 위의 코드의 ①번 라인과 같이, 컴파일하기 전 작성했던 GenericArrayList<Integer>에서 지정한 타입이 사라지고 GenericArrayList로 변경된 것을 볼 수 있다.
  • 그리고 ②번과 ③번 라인처럼 자동으로 Integer 타입으로 타입 변환이 이루어진 것을 볼 수 있으며, 이는 제네릭을 사용하면 컴파일러가 타입 변환을 알아서 진행한다는 것임을 알 수 있다.


3) 제네릭을 사용할 수 없는 경우

  • 다음의 예제는 위에서 작성한 제네릭을 사용해서 위에서 나온 문제점을 해결하는 것을 보여주는 예제이다.


 class GenericArrayList<T> {

  private Object[] elementData = new Object[5];
  // private T[] elementData = new T[5];
  private int size;

  public void add(T value) {
    elementData[size++] = value;
  }

  public T get(int idx) {
    return (T) elementData[idx];
  }
}

class Test {

  public static void main(String[] args) {
    GenericArrayList<Integer> intList = new GenericArrayList<Integer>();

    intList.add(50);
    intList.add(100);

  // 오토 언박싱을 사용하는 경우
  // int intValue1 = intList.get(0);
    // int intValue2 = intList.get(1);

    Integer intObj1 = intList.get(0);
    Integer intObj2 = intList.get(1);

  // 언박싱
    int intValue1 = intObj1.intValue();
    int intValue2 = intObj2.intValue();

   // String strObj = intList.get(0); -> 오류 발생

    System.out.println(intValue1 + intValue2); // 150
  }
}


  • ①번과 ④번, 그리고 ⑤번 라인처럼 GenericArrayList를 정의할 때, 모두 타입 변수 T를 사용한 것을 볼 수 있다.
  • 하지만 ②번 라인과 같이 배열 elementData을 생성하는 부분에서는 타입 변수 T를 사용하지 않고 Object 타입을 사용했고, 또한 ⑤번 라인에서 get() 메서드 호출 시 인스턴스 생성 시 지정한 타입 변수 T로 다시 타입 변환하는 코드를 삽입했다.
  • 만약 ②번 라인 대신 ③번 라인과 같이 타입 변수 T(제네릭)를 사용하여 배열을 생성한다면 오류가 발생할 것이다.
  • 왜냐하면 배열을 생성할 때 사용한 new 키워드 때문인데, new 키워드는 인스턴스를 생성하는 키워드로서 힙 영역에 충분한 공간이 있는지 확인한 후 메모리를 확보하는 역할을 한다.
  • 이때 충분한 공간이 있는지 없는지 확인하려면 먼저 해당 타입을 알아야 하는데, 타입 변수 T에 해당하는 타입이 무엇인지 알 수 없기 때문에 타입 변수 T(제네릭)를 사용하여 배열을 생성할 수 없는 것이다.
  • 또한 클래스 변수(Static Variable)에도 제네릭을 사용할 수 없다.
  • 클래스 변수는 인스턴스에 종속되지 않는 변수로서 모든 인스턴스가 공통된 저장 공간을 공유하게 되는 변수이다.
  • 클래스 변수에 제네릭을 사용하려면, GenericArrayList<Integer>에서는 Integer 타입으로, GenericArrayList<String>에서는 String 타입으로 사용될 수 있어야 한다.
  • 즉, 하나의 공유 변수인 클래스 변수가 생성되는 인스턴스에 따라 타입이 바뀐다는 것 자체가 모순이기 때문이다.
  • 하지만 아래에서 설명하겠지만, static 키워드를 사용한 클래스 메서드(Static Method)에서는 제네릭을 사용할 수 있다.


4) 제네릭의 선언 및 생성

  • 자바에서 제네릭은 클래스와 메서드에만 다음과 같은 방법으로 선언할 수 있다.


class MyArray<T> {

  T element;

  void setElement(T element) {
    this.element = element;
  }

  T getElement() {
    return element;
  }
}


  • 위의 예제에서 사용된 T를 타입 변수(Type Variable)라고 하며, 임의의 참조형 타입을 의미한다.
  • T뿐만 아니라 어떠한 문자를 사용해도 상관없으며, 여러 개의 타입 변수는 ,(쉼표)로 구분하여 명시할 수 있다.
  • 타입 변수는 클래스에서 뿐만 아니라 메서드의 매개변수나 반환값으로도 사용할 수 있다.


  • 위와 같이 선언된 제네릭 클래스(Generic Class)를 인스턴스로 생성할 때에는 타입 변수 자리에 사용할 실제 타입을 명시해야 한다.


MyArray<Integer> myArr = new MyArray<Integer>();


  • 위의 예제는 MyArray 클래스에 사용된 타입 변수로 Integer 타입을 사용하는 예제이다.
  • 위처럼 제네릭 클래스를 인스턴스로 생성할 때 사용할 실제 타입을 명시하면, 내부적으로는 정의된 타입 변수가 명시된 실제 타입으로 변환되어 처리된다.


Note

  • 자바에서 타입 변수 자리에 사용할 실제 타입을 명시할 때 기본 타입을 바로 사용할 수 없다.
  • 이때는 위 예제의 Integer와 같이 Wrapper 클래스를 사용해야만 한다.


  • 또한, Java SE 7부터 인스턴스 생성 시 타입을 추정할 수 있는 경우에는 타입을 생략할 수 있다.


MyArray<Integer> myArr = new MyArray<>();


  • 다음 예제는 제네릭에서 적용되는 타입 변수의 다형성을 보여주는 예제이다.


import java.util.*;

class LandAnimal {

  public void crying() {
    System.out.println("육지동물");
  }
}

class Cat extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("냐옹냐옹");
  }
}

class Dog extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("멍멍");
  }
}

class Sparrow {

  public void crying() {
    System.out.println("짹짹");
  }
}

class AnimalList<T> {

  ArrayList<T> al = new ArrayList<T>();

  void add(T animal) {
    al.add(animal);
  }

  T get(int index) {
    return al.get(index);
  }

  boolean remove(T animal) {
    return al.remove(animal);
  }

  int size() {
    return al.size();
  }
}

class Test {

  public static void main(String[] args) {
    AnimalList<LandAnimal> landAnimal = new AnimalList<LandAnimal>();

    landAnimal.add(new LandAnimal());
    landAnimal.add(new Cat());
    landAnimal.add(new Dog());
    // landAnimal.add(new Sparrow()); -> 오류 발생

    for (int i = 0; i < landAnimal.size(); i++) {
      landAnimal.get(i).crying();
    }
  }
}

// 육지동물
// 냐옹냐옹
// 멍멍


  • 위의 예제에서 CatDog 클래스는 LandAnimal 클래스를 상속받는 자식 클래스이므로, AnimalList<LandAnimal>에 추가할 수 있다.
  • 하지만 Sparrow 클래스는 타입이 다르므로 추가할 수 없다.


5) 제네릭의 제거 시기

  • 자바 코드에서 선언되고 사용된 제네릭 타입은 컴파일 시 컴파일러에 의해 자동으로 검사되어 타입 변환된다.
  • 그리고 코드 내의 모든 제네릭 타입은 제거되어, 컴파일된 class 파일에는 어떠한 제네릭 타입도 포함되지 않게 된다.
  • 이런 식으로 동작하는 이유는 제네릭을 사용하지 않는 코드와의 호환성을 유지하기 위함이다.


3. 다양한 제네릭 표현

1) 타입 변수의 제한

  • 제네릭은 T와 같은 타입 변수를 사용하여 타입을 제한한다.
  • 이때 extends 키워드를 사용하면 타입 변수에 특정 타입만을 사용하도록 제한할 수 있다.


class AnimalList<T extends LandAnimal> { ... }


  • 위와 같이 클래스의 타입 변수에 제한을 걸어 놓으면 클래스 내부에서 사용된 모든 타입 변수에 제한이 걸린다.
  • 이때에는 클래스가 아닌 인터페이스를 구현할 경우에도 implements 키워드가 아닌 extends 키워드를 사용해야만 한다.


interface WarmBlood { ... }

...

class AnimalList<T extends WarmBlood> { ... } // implements 키워드를 사용해서는 안 됨


  • 클래스와 인터페이스를 동시에 상속받고 구현해야 한다면 &(엠퍼샌드) 기호를 사용하면 된다.


class AnimalList<T extends LandAnimal & WarmBlood> { ... }
import java.util.*;

class LandAnimal {

  public void crying() {
    System.out.println("육지동물");
  }
}

class Cat extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("냐옹냐옹");
  }
}

class Dog extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("멍멍");
  }
}

class Sparrow {

  public void crying() {
    System.out.println("짹짹");
  }
}

class AnimalList<T extends LandAnimal> {

  ArrayList<T> al = new ArrayList<T>();

  void add(T animal) {
    al.add(animal);
  }

  T get(int index) {
    return al.get(index);
  }

  boolean remove(T animal) {
    return al.remove(animal);
  }

  int size() {
    return al.size();
  }
}

class Test {

  public static void main(String[] args) {
    AnimalList<LandAnimal> landAnimal = new AnimalList<LandAnimal>();

    landAnimal.add(new LandAnimal());
    landAnimal.add(new Cat());
    landAnimal.add(new Dog());
    // landAnimal.add(new Sparrow()); -> 오류 발생

    for (int i = 0; i < landAnimal.size(); i++) {
      landAnimal.get(i).crying();
    }
  }
}

// 육지동물
// 냐옹냐옹
// 멍멍


  • 위의 예제는 타입 변수의 다형성을 이용하여 AnimalList 클래스의 선언부에 명시한 extends LandAnimal 구문을 생략해도 제대로 동작한다.
  • 하지만 코드의 명확성을 위해서는 위와 같이 타입의 제한을 명시하는 편이 더 좋다.


2) 제네릭 메서드(Generic Method)

  • 제네릭 메서드란 메서드의 선언부에 타입 변수를 사용한 메서드를 의미한다.
  • 이때 타입 변수의 선언은 메서드 선언부에서 반환 타입 바로 앞에 위치한다.


public static <T> void sort( ... ) { ... }


  • 다음 예제의 제네릭 클래스에서 정의된 타입 변수 T와 제네릭 메서드에서 사용된 타입 변수 T는 별개의 것이다.


class AnimalList<T> {

 ...

 public static <T> void sort(List<T> list, Comparator<? super T> comp) {

  ...

 }

 ...

}


3) 와일드카드(Wild Card)의 사용

  • 와일드카드란 이름에 제한을 두지 않음을 표현하는 데 사용되는 기호를 의미한다.
  • 자바의 제네릭에서는 ?(물음표) 기호를 사용하여 이러한 와일드카드를 사용할 수 있다.


<?> // 타입 변수에 모든 타입을 사용할 수 있음
<? extends T> // T 타입과 T 타입을 상속받는 자손 클래스 타입만을 사용할 수 있음
<? super T> // T 타입과 T 타입이 상속받은 조상 클래스 타입만을 사용할 수 있음


  • 다음 예제는 클래스 메서드(Static Method)인 cryingAnimalList() 메서드의 매개변수의 타입을 와일드카드를 사용하여 제한하는 예제이다.


import java.util.*;

class LandAnimal {

  public void crying() {
    System.out.println("육지동물");
  }
}

class Cat extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("냐옹냐옹");
  }
}

class Dog extends LandAnimal {

 @Override
  public void crying() {
    System.out.println("멍멍");
  }
}

class Sparrow {

  public void crying() {
    System.out.println("짹짹");
  }
}

class AnimalList<T> {

  ArrayList<T> al = new ArrayList<T>();

  public static void cryingAnimalList(AnimalList<? extends LandAnimal> al) {
    LandAnimal la = al.get(0);
    la.crying();
  }

  void add(T animal) {
    al.add(animal);
  }

  T get(int index) {
    return al.get(index);
  }

  boolean remove(T animal) {
    return al.remove(animal);
  }

  int size() {
    return al.size();
  }
}

class Test {

  public static void main(String[] args) {
    AnimalList<Cat> catList = new AnimalList<Cat>();
    catList.add(new Cat());

    AnimalList<Dog> dogList = new AnimalList<Dog>();
    dogList.add(new Dog());

    AnimalList.cryingAnimalList(catList);
    AnimalList.cryingAnimalList(dogList);
  }
}

// 냐옹냐옹
// 멍멍

References