[Java] 제네릭(Generics)에 대해 생각해보기

kindof

·

2021. 11. 23. 19:11

0. 들어가면서

이번 포스팅에서는 자바 제네릭(Generic)에 대한 기본적인 내용부터 제가 궁금했던 부분들이나 고민해보면 좋을 부분들을 정리하려고 합니다.

 

제네릭은 공부할수록 범위도 많고 제대로 쓰는 것이 정말 어렵겠구나를 느껴서 차근차근 더 공부를 해야 할 것 같긴 합니다.

 

우선 시작해보겠습니다.

 

 

1. 제네릭을 쓰는 이유

제네릭(Generic)은 JDK 1.5 버전에 처음 도입된 기능으로 객체의 타입 안정성을 높여주고 형변환의 번거로움을 덜어주었습니다. 이 말의 의미를 직접 느껴보기 위해 아래 코드를 보겠습니다. 

Generic이 없을 때

우선 저는 인텔리제이 IDE를 쓰고 있는데, IDE는 위처럼 ArrayList라는 현재 제네릭 클래스로 정의된 클래스에 대해 제네릭을 선언해주지 않으면 노란 밑줄을 그어줍니다. 제네릭을 써야 하는데 쓰지 않았다는 일종의 경고 메시지를 보내주는 것인데요.

 

위에서 '경고 메시지'라고 표현한 것처럼, 제네릭을 사용하지 않아도 코드를 작성하는 것이 '불가능'하지는 않습니다.

 

우리가 선언한 ArrayList에 3도 삽입할 수 있고, "Sunghyeon"이라는 스트링도 삽입할 수 있습니다. 다만, for문에서 데이터를 꺼내서 다른 로직을 처리할 때 각 데이터가 무슨 타입인지 알 수 없기 때문에 Object 클래스 객체로 받은 뒤 형변환을 통해 사용해야 하는 번거로움이 생깁니다.

 

더 심각한 것은, 적절한 형변환을 해주지 않은 채 ArrayList에서 값을 뽑아 사용하게 되면 아래와 같이 컴파일 시점에 잡을 수 없는 에러를 용인하기도 한다는 것입니다.

 

누구나 아래 코드가 런타임에 ClassCastingException을 터뜨릴 것을 예상하실 수 있을 겁니다. 그러나 컴파일러는 이렇게 예상되는 에러를 무시하죠.

잘못된 Casting을 잡아내지 못한다.

따라서 지금까지 이야기한 내용을 간단하게 정리해보면, 제네릭의 사용은 런타임에 발생할 에러를 컴파일 시점에 잡을 수 있게 해주는 안정성을 제공하고, 형변환없이 다음 로직을 처리할 수 있게 해준다는 것입니다.

 

 

 

2. 왜 Primitive Type은 제네릭 클래스의 타입 변수가 될 수 없는가

HashMap 클래스

HashMap은 우리가 자주 사용하는 대표적인 제네릭 클레스입니다. HashMap은 두 개의 제네릭 타입을 <K, V>와 같이 명시하고 있는데요. 우리는 K와 V 자리에 우리가 사용하고자 하는 Reference Type을 명시하고 사용할 수 있게 됩니다.

 

참고로 클래스나 인터페이스에서 제네릭 타입변수를 명시할 때는 아래와 같이 영어 대문자 하나로 표시를 많이 합니다. 관례적이지만 참고해서 적절히 사용하시는 게 좋은 코드를 짜는 맞다고 생각합니다.

제네릭의 타입과 의미

 

다시 돌아와서, 여기서 중요한 점은 제네릭 타입변수로는 레퍼런스 타입만 사용할 수 있다는 점입니다.

 

즉, Integer, String이나 우리가 직접 만든 클래스들은 레퍼런스 타입이기 때문에 제네릭으로 사용할 수 있지만 int, double과 같은 타입은 제네릭으로 사용할 수 없다는 것인데요.

 

그런데 여기서 궁금증이 하나 생깁니다. 왜 Primitive Type은 제네릭 타입으로 사용할 수 없을까요?

 

그 이유는 바로 타입 소거(Type Erasure) 때문입니다.

 

타입 소거는 제네릭 타입이 특정 타입으로 제한되어 있을 경우 해당 타입에 맞춰 컴파일 시 타입 변경이 발생하고, 타입제한이 없을 경우 Object 타입으로 변경되는 과정을 말합니다. 이러한 이유로 Primitve Type은 제네릭 타입 변수로 쓰일 수 없게 됩니다.

 

아래 예시를 보겠습니다. 

List<MyClass> list = new ArrayList<MyClass>();
list.add(new MyClass());
MyClass a = list.get(0);

 

위 코드는 사실 아래와 같은 방식으로 동작합니다.

List list = new ArrayList();
list.add(new MyClass());
MyClass a = (MyClass)list.get(0);

그런데 여기서 MyClass(Reference Type)대신 Primitive Type을 쓰게 되면  문제가 발생합니다.

 

제네릭 타입변수가 Primitive Type이라면 list.get(0)을 해서 나온 녀석 역시 Primitive Type인데, 사실 list.get(0)을 해서 나온 녀석은 Object 타입이어야 MyClass 타입으로 캐스팅이 가능합니다. 하지만 Primitive Type은 Object 클래스를 상속하지 않기 때문에 Object 타입으로 변환할 수 없는 것이죠.

 

이러한 이유로 Primitive Type은 제네릭 타입으로 사용할 수 없는 것입니다.

 

 

3. 제네릭 클래스의 제한 이유

제네릭 클래스의 제한은 제네릭 타입 매개변수 T에 올 수 있는 타입의 종류를 제한한다는 것인데요.

 

책이나 블로그들을 꽤 찾아봤는데, 제네릭 클래스를 제한하는 '이유'에 대해서는 많은 내용을 보지 못한 것 같아서 제가 생각하는 이유를 정리해보려고 합니다.

 

우선 가장 기본적으로 제네릭 타입에 'extends' 키워드를 붙이면 그 뒤에는 클래스나 인터페이스를 써줄 수 있습니다.

 

이렇게 타입을 제한할 경우, 제네릭 타입으로 선언하기 위한 클래스의 조건이 붙게 되는데요.

  • 특정 클래스 혹은 그 자식 클래스
  • 특정 인터페이스를 구현한 클래스

이를 곰곰이 생각해보면, 제네릭 타입을 제한한다는 것이 곧 제네릭 타입으로 오는 클래스의 형태를 제한한다는 뜻이 됩니다.

 

예를 들어보겠습니다. 아래에서 만든 제네릭 클래스는 구체적인 로직은 구현하지 않았지만 사람들(학생, 교수, 직원 등)의 정보를 담아놓고 공통적으로 관리할 클래스라고 하겠습니다.

제한된 제네릭 클래

 

그리고 학생 클래스를 아래와 같이 구현했습니다.

Student 클래스

 

이 때, Student 클래스를 제네릭 타입으로 하는 PeopleInfo 클래스는 생성될 수 없습니다. 아래와 같이 컴파일러에서 피가 나고 있죠. 

Student를 제네릭 타입으로하면 컴파일 에러

이렇게 컴파일 에러가 나는 이유는 애초에 제네릭 클래스에서 타입으로 올 수 있는 클래스를 Comparable 인터페이스를 구현한 클래스로 한정했기 때문입니다.

 

즉, 제네릭 타입에 제한을 둠으로써 우리가 다른 클래스를 만들 때 '어떤 조건은 반드시 구현하거나 만족하는' 방향으로 설계할 수 있다는 것입니다.

 

위와 같은 상황에서는 Comparable 인터페이스에 있는 compareTo() 메서드를 반드시 오버라이딩하라는 조건이 있는 것이죠. 따라서 아래와 같이 Student 클래스에 compareTo() 메서드를 오버라이딩해주고 나서야 정상적으로 컴파일이 되는 것을 볼 수 있습니다.

 

 

4. 와일드카드 이해하기

와일드카드에 대해 설명을 어떻게 하면 좋을지 고민하다가, 아래와 같이 Collections 클래스에 정의된 sort() 메서드를 가지고 이야기하면 좋을 것 같다고 생각했습니다.

Collections.sort()

위의 sort() 메서드에서 <T extends Comparable<? super T>>라고 정의된 제네릭 타입변수를 어떻게 해석해야 할까요?

 

여기서 T는 Comparable<...>을 extends 해야한다고 명시하고 있습니다. 다시 말해, T가 Comparable의 compareTo() 메서드를 구현했어야 한다는 것이죠.

 

그런데 한 가지가 더 붙습니다. Comparable<? super T>는 와일드카드의 하한을 제한하는 것을 말하는데요. 와일드카드 '?'는 아직 선언되지 않았지만 그 뒤에오는 'super T'는 T와 그 조상들이라는 뜻을 가집니다.

 

정리하자면, Collections.sort() 메서드에서 허용하는 제네릭 타입 변수는 T와 그 조상들이며 반드시 Comparable을 implements한 타입이라는 뜻입니다.

 

말이 굉장히 어려운 것 같아서 예시를 하나만 보겠습니다.

public static <T extends Comparable<? super T>> void sort(List<T> list)

public class Person implements Comparable<Person> {...}

public class Student extends Person {...}

지금까지 설명한 내용에 의하면 여기서 List<Student>는 sort() 메서드를 통해 정렬이 가능할까요? 

 

답은 '가능하다'입니다. 왜냐하면 Student는 Person을 extends 하고 있으며, Person은 Comparable을 implements하면 Student 역시 Person에서 정의한 compareTo()를 사용할 수 있기 때문입니다. 만약 Student의 정렬을 Person의 도움을 받지 않고 하고 싶다면 다시 compareTo() 메서드를 오버라이딩하면 되겠죠.

 

이해가 되시나요!?

 

 

5. 나가면서

이번 포스팅에서는 제네릭의 기본 개념과 선언, 제네릭의 타입변수에 대해 공부하고 제네릭 클래스의 제한과 와일드카드의 사용까지 공부해봤습니다.

 

자바 제네릭에 대해 제대로 알려면 사실 지금 정리한 내용보다 더 많은 내용을 공부해야 한다는 부담과 각오를 갖고 이번 글은 여기까지 하겠습니다.

 

감사합니다.