자바의 제네릭 소개

자바의 제네릭 소개

원문: Pedro Lopes, "Introduction to Generics in Java"

1. 소개

자바에서 제네릭을 사용하면 더욱 유연한 타입을 만들 수 있습니다.

이 튜토리얼에서는 제네릭이 무엇인지, 왜 존재하는지, 그리고 클래스, 메서드, 레코드에서 어떻게 사용하는지 살펴보겠습니다.

2. 자바에서 제네릭이 도입된 이유

J2SE 5.0릴리스 문서에 따라 컴파일 타임에 타입 안정성을 제공하면서 다양한 타입의 객체에 대해 작동할 수 있는 타입, 클래스 또는 메서드를 허용하기 위해 제네릭을 도입하였습니다.

즉, 클래스, 메서드, 레코드의 범위에서 일반적으로 작동하는 특정 타입을 만들 수 있습니다. 컴파일러는 다양한 타입을 추론할 수 있으며 끔찍하고 오류가 발생하기 쉬운 캐스팅을 필요로 하지 않습니다.

자바 제네릭은 정적 타입 시스템을 깨뜨리지 않습니다. 컴파일러가 제네릭 변수 타입을 추론하면 런타임에 변경될 수 없습니다.

2.1. 제네릭 vs 객체

제네릭이 도입되기 전 개발자들은 Object 클래스를 사용하여 제네릭 API를 만들었습니다. 모든 클래스가 Object를 암묵적으로 상속받기 때문에 변수를 Object로 만들고 특정 클래스로 캐스팅할 수 있습니다.

예를 들어 다음 코드는 이전에 자바 애플리케이션에서 흔하게 사용되었습니다.

List myList = new ArrayList();
myList.add(1);
Integer myNumber = (Integer) myList.get(0);

get(0)의 결과를 Integer로 캐스팅해야 하는 이유는 무엇일까요?

이전 버전에서 ArrayList는 인수로 Object 타입을 사용했기 때문에 Object에서 Integer로 명시적인 캐스팅이 필요합니다.

그러나 캐스팅은 더 많은 보일러플레이트 코드를 추가하고 ClassCastException 예외가 발생할 가능성이 있습니다. 특정 코드를 보면 리스트는 Integer만 포함한다 것을 알 수 있지만 전체 애플리케이션에서 이를 보장할 수 있을까요? 대답은 대체로 '아니오'입니다.

따라서 다양한 타입을 만들고 캐스팅 및 타입 오류를 피하기 위해 정적 타이핑 시스템을 유지하려면 제네릭을 사용할 수 있습니다. 다음은 이전의 동일한 코드를 제네릭을 사용해 다시 작성한 것입니다.

List<Integer> myList = new ArrayList<>();
myList.add(1);
Integer myNumber = myList.get(0);

위의 코드는 리스트에서 숫자를 가져오기 위해 캐스팅이 필요하지 않습니다. 또한 Double과 같은 다른 타입을 사용하는 것도 동일한 List 인터페이스를 사용할 수 있습니다.

List<Double> myList1 = new ArrayList<>();
myList1.add(2.0);
Double myNumber2 = myList1.get(0);

컬렉션에서 요소를 검색하기 위해 애플리케이션 곳곳에서 캐스팅을 사용하는 상황을 상상해 보세요. 꽤 성가십니다. 자바에서 이러한 문제를 제네릭이 해결하게 됩니다.

자바는 클래스, 메서드, 레코드에서 제네릭 사용을 허용합니다. 다음 섹션에서 이들을 생성하는 방법을 살펴보겠습니다.

3. 제네릭 클래스 생성하기

List 인터페이스를 예시로 들어 보겠습니다. 다음은 JDK 17에서 List의 시그니처입니다.

public interface List<E> extends Collection<E> { ... }

<E>는 제네릭을 정의하고 있다는 것을 의미합니다. 따라서 <> 주변에 사용된 타입은 리스트가 보유할 수 있는 객체의 타입입니다. 예를 들어 이전에 사용된 List<Integer> 참조는 오직 정수만을 보유하는 리스트를 만듭니다.

또한 제네릭을 사용하여 클래스를 정의할 수 있습니다.

public class GenericClass<T, U, V> {
}

위의 예시에서 클래스 범위에서 사용할 수 있는 세 가지 제네릭 타입을 정의했습니다. 제네릭 타입 이름은 무엇이든 될 수 있지만 하나의 대문자를 사용하는 것이 관례입니다.

GenericClass를 참조하려면 다음과 같이 할 수 있습니다.

GenericClass<Integer, Double, String> myClass = new GenericClass<>();

오른쪽에 왼쪽에서 정의한 타입이 포함되어 있지 않다는 점을 주목하세요. 그 이유는 <> 연산자나 다이아몬드 연산자가 왼쪽에서 타입을 이미 추론하기 때문입니다.

4. 제네릭 메서드 정의하기

메서드 범위에서만 존재하는 제네릭 타입을 정의할 수도 있습니다. 제네릭 타입을 인수로 받고 Double을 반환하는 genericMethod() 시그니처를 살펴보겠습니다.

private static <I> Double genericMethod(I input) { ... }

이 메서드를 호출할 때 두 가지 대안이 있습니다. 첫 번째는 컴파일러가 타입을 추론하게 하는 것입니다.

Double result = genericMethod(2);

이 경우 컴파일러는 genericMethod(2) 호출의 인수 타입으로 Integer를 추론합니다.

두 번째는 다루고 있는 타입을 명시적으로 말하는 것입니다.

GenericClass.<Integer>genericMethod(2);

참고: 정적 메서드라서 클래스 이름을 사용하여 메서드를 참조했습니다. 하지만 객체 이름, this, 또는 super를 사용하여 제네릭 메서드를 참조하는 방법도 있습니다.

5. 제네릭 레코드 설명하기

Java 14 Records에서는 다음과 같이 제네릭을 정의할 수 있습니다.

public record GenericRecord<U, V>(U first, V second) {
}

이제 제네릭이면서 불변의 객체를 가질 수 있습니다!

6. 경계가 지정된 제네릭

간단히 말하자면 경계가 지정된 제네릭은 다른 클래스 정의에 고정된 타입입니다. 다른 타입의 하위 클래스나 상위 클래스일 수 있는 타입을 정의할 수 있습니다.

6.1. 상위 경계 제네릭

제네릭 타입에 상위 경계를 설정한다는 것은 사용된 타입이 다른 클래스의 하위 클래스여야 한다는 것을 의미합니다. 두 개의 상위 경계 제네릭 타입을 가진 클래스를 정의해 보겠습니다.

public class UpperBoundedGeneric<S extends String, I extends Integer>{
}

클래스 UpperBoundedGeneric은 두 개의 제네릭 타입을 받습니다. S 타입은 String 또는 그 하위 클래스여야 합니다. 두 번째 타입은 Integer 또는 그 하위 클래스여야 합니다. 따라서 아래의 코드는 컴파일되지 않습니다.

UpperBoundedGeneric<CharSequence, Integer> myType = null;

변수 myType은 첫 번째 제네릭 타입으로 CharSequence를 정의하는데, 이는 String 클래스를 확장하거나 구현하지 않습니다. 이 코드를 컴파일하려고 하면 매우 유용한 오류 메시지가 표시됩니다.

Type parameter 'java.lang.CharSequence' is not within its bound; should implement 'java.lang.String'

참고: 위의 예시처럼 제네릭을 잘못 사용하지 않도록 주의하세요! StringInteger 클래스는 final로 선언되어 있습니다. 따라서 어떤 클래스도 이들을 확장할 수 없으므로 클래스 UpperBoundedGeneric은 제네릭 타입으로 StringInteger만 받아들입니다. 따라서 그 예시에서 제네릭은 불필요하며 혼란을 더욱 가중시킬 수 있습니다.

6.2. 하위 경계 제네릭

하위 경계를 설정한다는 것은 사용된 제네릭 타입이 다른 클래스의 상위 클래스여야 한다는 것을 의미합니다. 와일드카드 (<?> 식별자)를 사용해 하위 경계 제네릭을 적절하게 구현할 수 있습니다. 곧 와일드카드를 살펴볼 것입니다.

하위 경계 제네릭의 예시를 하나 사용해 보겠습니다.

Map<? super String, ? super Integer> myType = new HashMap<CharSequence, Integer>();

위 코드는 정상적으로 실행되어 키가 CharSequence고 값이 Integer인 HashMap의 새 인스턴스를 생성합니다. CharSequence 클래스는 String의 상위 클래스이므로 <? super String>을 만족합니다. <? super Integer> 표현도 같은 타입이기 때문에 문제가 없습니다.

예를 들어 아래의 코드는 DoubleInteger에 맞지 않기 때문에 컴파일되지 않습니다.

Map<? super String, ? super Integer> myType = new HashMap<CharSequence, Double>();

7. 와일드카드

마지막으로 자바 제네릭 API가 제공하는 또 다른 기능은 와일드카드입니다. 와일드카드는 물음표(?)로 표시된 알 수 없는 타입이며 변수 및 메서드 인수의 참조 타입으로만 사용할 수 있습니다. 와일드카드는 하위 경계, 상위 경계 또는 무경계일 수 있습니다.

와일드카드로 생성된 세 가지 타입 참조의 예시를 살펴보겠습니다.

List<? extends Serializable> list1 = new ArrayList<>();
List<? super String> list2 = new ArrayList<>();
List<?> list3 = new ArrayList<>();

list1Serializable 인터페이스를 구현하는 어떤 제네릭 타입도 받아들입니다. list2는 요소가 String의 상위 클래스인 리스트를 받아들입니다 (예: CharSequence, Serializable). 마지막으로 list3는 어떤 클래스의 요소든 보유합니다.

와일드카드는 변수를 캐스팅할 필요 없이 다양한 타입을 받아들이는 메서드를 만드는 데 특히 유용합니다. 예를 들어 리스트의 첫 번째 요소가 비어 있는지 확인하는 메서드를 만들어야 한다고 가정해 보겠습니다.

private boolean firstElementIsEmpty(List<? extends CharSequence> list){
    return list.get(0).isEmpty();
}

위 메서드는 isEmpty() 메서드에 접근할 수 있도록 <? extends CharSequence\>의 리스트를 사용합니다. 따라서 StringBuilder와 같은 CharSequence 구현의 리스트를 인수로 사용할 수 있습니다.

firstElementIsEmpty(Arrays.asList(new StringBuilder(""))); // 1
firstElementIsEmpty(Arrays.asList(new StringBuffer(""))); // 2
firstElementIsEmpty(Arrays.asList("")); // 3

동일한 메서드는 StringBuilder, StringBuffer, String을 인수로 사용하여 문제없이 작동합니다.

8. 제네릭의 장단점

제네릭을 사용할 수 있는 상황과 그 한계에 대해 고려해 보겠습니다.

8.1. 자바에서 제네릭의 장점

이 글을 통해 자바에서 제네릭을 사용하는 주요 이점을 살펴보았습니다. 이점을 요약하면 다음과 같습니다.

  • 컴파일 타임에 강력한 타입 검사.

  • 캐스팅 제거.

  • 개발자가 제네릭 API를 구현할 수 있게 합니다.

8.2. 자바에서 제네릭의 단점 및 한계

자바에서 제네릭 타입의 주요 한계는 다음과 같습니다.

  • 기본 타입으로 제네릭 타입을 인스턴스화할 수 없습니다.

  • 제네릭 인스턴스를 생성할 수 없습니다.

  • 제네릭 타입이 있는 정적 필드를 선언할 수 없습니다.

  • 제네릭 타입으로 캐스팅 또는 instanceof를 사용할 수 없습니다.

  • 제네릭 타입의 배열을 생성할 수 없습니다.

  • 제네릭 타입의 객체를 생성, 캐치 또는 스로우할 수 없습니다.

각각에 대한 코드 예시를 살펴보겠습니다.

  1. 기본 타입으로 제네릭 타입을 인스턴스화할 수 없습니다.

     List<int> primitiveList = new ArrayList<>(); // 컴파일되지 않습니다.
    
  2. 제네릭 인스턴스를 생성할 수 없습니다.

     public class GenericClass<T> {
         public void instantiate() {
             T myGeneric = new T(); // 컴파일되지 않습니다.
     }
    
  3. 제네릭 타입이 있는 정적 필드를 선언할 수 없습니다.

     public class GenericClass<T> {   
         static T staticGeneric; // 컴파일되지 않습니다.
     }
    
  4. 제네릭 타입으로 캐스팅 또는 instanceof를 사용할 수 없습니다.

    위의 두 줄은 서로 독립적으로 컴파일되지 않습니다.

     private void compareAndCast(T myType) {
         if (T instanceof String) { // 컴파일되지 않습니다.
             var casted = (String) T; // 컴파일되지 않습니다.
         }
     }
    
  5. 제네릭 타입의 배열을 생성할 수 없습니다.

     public class GenericClass<T> {
         T[] array = new T[]{}; // 컴파일되지 않습니다.
     }
    
  6. 제네릭 타입의 객체를 생성, 캐치 또는 스로우할 수 없습니다.

    아래의 모든 줄은 서로 독립적으로 컴파일되지 않습니다.

     public class GenericClass<T extends RuntimeException> {
         public void catchAndThrow() {
             try {
                 // 무언가를 수행합니다.
             } catch (T exception) { // 컴파일되지 않습니다.
                 throw new T(); // 컴파일되지 않습니다.
             }
         }
     }
    

    그러나 제네릭 타입과 함께 throws 키워드를 사용하는 것은 가능합니다. 아래의 코드는 문제없이 컴파일됩니다.

     public class GenericClass<T extends RuntimeException> {
         public void catchAndThrow() throws T{
             try {
                 // 무언가를 수행합니다.
             } catch (NullPointerException exception) { 
                 throw new RuntimeException();
             }
         }
     }
    

    위의 코드가 컴파일되는 이유는 제네릭 타입을 상위 경계로 지정하고 허용된 타입을 던졌기 때문입니다. 아래의 코드는 컴파일되지 않습니다. 왜냐하면 ExceptionRuntimeException의 하위 클래스가 아니기 때문입니다.

     public class GenericClass<T extends RuntimeException> {
         public void catchAndThrow() throws T{
             try {
                 // 무언가를 수행합니다.
             } catch (NullPointerException exception) { 
                 throw new Exception(); // 컴파일되지 않습니다.
             }
         }
     }
    

9. 결론

이 글에서는 제네릭의 기초, 제네릭을 생성하는 방법, 제네릭을 언제 사용하면 좋은지에 대해 다루었습니다.

제네릭과 와일드카드는 개발자가 정적 타이핑을 희생하지 않으면서 다양한 타입을 받아들이는 API를 생성하도록 도와줍니다.