Technology/Java

Java의 제네릭에 대해 알아보자!

ikjo 2022. 10. 28. 01:17

제네릭이란?

제네릭은 자바 5에서 처음 도입된 것으로, 제네릭을 활용하면 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스를 다룰 때 컴파일러가 컴파일 시 타입 체크를 통해 런타임에 형변환 에러(ClassCastException)가 발생하는 것을 방지할 수 있다. 아울러, 컴파일 시에 검사를 해주므로, 객체의 타입 안정성을 높이고 형변환의 번거로움도 줄여준다.

 

예를 들어, 제네릭을 사용하지 않은 List 컬렉션을 다룰 때는 다음과 같은 문제가 있다.

 

        List numbers = new ArrayList();
        numbers.add(1);
        numbers.add("2");

        int sum = 0;
        for (Object o : numbers) {
            sum += (Integer) o; // ClassCastException 발생!
        }

 

(다소 극단적인 예시이지만,) 위 코드는 List에 숫자 2가 아닌 문자열 2가 삽입됐는데, 이를 호출하는 입장에선 이를 인지하지 못하고 정수라고 생각하여 Integer 타입으로 형변환한 경우 ClassCastException이 발생한다.

 

런타임 단계에 이러한 오류 발생을 방지하기 위해서는 다음과 같이 타입 체크를 위한 코드를 작성해주어야 한다. 또는 문자열 숫자인 경우는 따로 인식함으로써 정수로 형변환할 수도 있겠으나, 매우 번거로운 일이다.

 

        List numbers = new ArrayList();
        numbers.add(1);
        numbers.add("2");

        int sum = 0;
        for (Object o : numbers) {
            if (o instanceof Integer) {
                sum += (Integer) o;
            } else {
                System.out.println("유효하지 않은 타입의 데이터입니다.");
            }
        }

 

하지만 제네릭을 적용한 List 컬렉션을 다룰 때는 위와 같은 번거로움이 사라진다. 앞서 언급했듯이 컴파일 시 타입 체크를 해주기 때문이다.

 

        List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add("hello"); // 컴파일 에러

 

 

제네릭 클래스와 제네릭 메서드

제네릭 클래스

이러한 제네릭은 클래스와 메서드에 선언될 수 있는데, 앞선 예시에서 List 인터페이스 역시 제네릭 타입이 선언된 제네릭 클래스(여기선 제네릭 인터페이스)라고 할 수 있다. 제네릭 클래스는 클래스 이름 옆에 <T> 또는 <E> 등의 이 붙은 것이 특징이다.

 

List 인터페이스에 적용된 제네릭

 

여기서 E란 Element(요소)의 첫 글자를 따서 사용한 것으로, List 타입의 요소의 타입에 대해 임의의 참조형 타입을 지정한 것이다. 참고로 E 대신 타입 변수(type variable)를 의미하는 T를 써도 기능상 차이는 없지만, 요소를 뜻하는 E가 좀 더 의미상 명확하기 때문에 E를 사용했다고 볼 수 있다.

 

※ 이외에도 Key를 나타내는 K, Number를 나타내는 N, Value를 나타내는 V, Result를 나타내는 R이 있다.

 

앞서 Integer 타입 외의 타입이 삽입(add)될 경우 에러가 발생한 것도 이처럼 인터페이스에 제네릭 타입이 적용되어 있는데다가 List 인터페이스의 add 메서드의 파라미터 타입이 앞서 클래스에 선언된 E를 가지기 때문인 것이다. 참고로 제네릭 클래스가 아니면, 이처럼 메서드에서 반환 타입이나 파라미터 타입으로 제네릭 타입을 사용할 수 없다.

 

List 인터페이스 中 add메서드

 

참고로 제네릭 클래스에 존재하는 static 멤버에는 제네릭 타입을 사용할 수 없는데, 이는 제네릭 타입이 인스턴스 변수로 간주되기 때문이다. static 멤버는 제네릭 타입에 대입된 타입의 종류와 관계없이 동일한 것이어야 하기 때문이다. 또한, new 연산자(런타임)를 통해 제네릭 배열을 생성하는 것 역시 불가능하다. 이는 제네릭 타입이 사용되는 것은 컴파일 시점이기 때문에 제네릭 타입의 타입이 정확히 뭔지 알아야하기 때문이다.

 

제네릭 메서드

또한, 제네릭 클래스의 여부와 상관없이 제네릭 메서드가 존재할 수도 있다. 제네릭 메서드란 메서드의 선언부에 제네릭 타입이 선언된 메서드인데, 예를 들어 List 인터페이스에는 toArray라는 제네릭 메서드가 있다.

 

List 인터페이스 中 제네릭 메서드인 toArray 메서드

 

앞서 메서드에서 제네릭 타입을 사용하려면 제네릭 클래스로 선언해야한다고 했는데, 제네릭 메서드로 선언한 경우에는 제네릭 클래스인지 여부와 상관없이 해당 메서드 내에서만 지역적으로 제네릭 타입을 사용할 수 있다. 즉, List 인터페이스의 toArray는 인터페이스에 선언된 제네릭 타입과 별개의 제네릭 타입으로 볼 수 있는 것이다.

 

        List<Integer> numbers = new ArrayList<>();
        numbers.add(1);
        numbers.toArray(new String[]{""});

 

위 코드를 보면, List<Integer>로 선언이 되었지만, 이와 별개로 String[]을 인자로 받을 수 있다는 것을 확인할 수 있다.

 

참고로 앞서 제네릭 클래스에 존재하는 static 멤버에는 제네릭 타입을 사용할 수 없다고 했는데, static 메서드를 제네릭 메서드로 선언하면 제네릭 타입을 사용할 수 있다.

 

 

바운디드 타입

제네릭 타입을 통해 한 종류의 타입만 저장하는게 가능해졌다. 하지만 List의 경우 Integer 뿐만 아니라 String, Double 등 각종 참조 타입이 선언될 수 있으며, List를 생성하는 개발자는 본인이 의도한대로 특정 타입을 정해서 선언해야만 한다.

 

제네릭 인터페이스인 List의 경우 모든 타입을 명시할 수 있지만, 바운디드 타입을 이용하면 특정 타입으로만 명시되도록 제한하는 제네릭 클래스를 선언할 수도 있다.

 

public class MyList<E extends Number> {

    // ...

}

 

위 코드를 보면 제네릭 타입에 extends를 사용했는데, 이는 특정 타입을 포함하여 해당 타입의 하위 타입들만 명시할 수 있도록 제한하는 것이다. 즉, MyList의 제네릭 타입으로는 Number 클래스의 하위 타입들만 선언할 수 있게 된다.

 

        MyList<Integer> list1 = new MyList<>();
        MyList<String> list2 = new MyList<>(); // 컴파일 에러 발생!!

 

 

와일드 카드

아래와 같이 제네릭 클래스가 아닌 클래스에 선언부가 동일한 메서드가 2개 있는데, 2개 메서드 모두 static 멤버이며, 파라미터 타입의 제네릭 타입만 다르다고 가정해보자. 이 경우에는 오버로딩이 성립할까? 결론적으로 오버로딩이 성립되지 않는다. 

 

public class MyList {

    static void addAll(Collection<Integer> numbers) { // 컴파일 에러 발생!! (동일한 메서드가 존재)
        // ...
    }

    static void addAll(Collection<Double> numbers) {
        // ...
    }
}

 

제네릭 타입은 컴파일러가 컴파일할 때만 사용하고 소거(Erasure)하기 때문에, 사실상 파라미터 타입의 제네릭 타입만 다른 두 메서드는 동일한 메서드가 되는 것이다. 이러한 상황에서 해당 클래스를 제네릭 클래스로 선언하면 하나의 메서드로 통합할 수도 있겠지만, 현재 두 메서드는 static 멤버라 불가능하다. 물론, 앞서 다루었던 제네릭 메서드를 적용하면 하나로 통합할 수도 있지만, 다른 방법도 존재한다. 바로, 와일드 카드이다.

 

와일드 카드란 '?' 라는 기호로 표기되는데, 이는 어떠한 타입도 대입할 수 있음을 의미한다. 이는 제네릭 클래스, 제네릭 메서드의 여부와 상관 없이 사용할 수 있다.

 

앞선 상황을 와일드 카드를 적용하면 다음과 같이 나타낼 수 있게 된다.

 

public class MyList {

    static void addAll(Collection<? extends Number> numbers) {
        // ...
    }

}

 

 앞서 다루었던 바운디드 타입과 유사하게 와일드 카드 ? 와 extends를 사용함으로써 Number 타입 및 Number 타입의 하위 타입들만 명시할 수 있도록 제한했다. 이와 반대로 <? super Number>로도 나타낼 수 있는데, 이는 Number 타입 및 Number 타입의 상위 타입들만 명시할 수 있도록 하는 것이다.

 

아래와 같이 단순히 와일드 카드만 사용할 수도 있다. 이는 사실상 모든 타입이 대입될 수 있음을 의미하며, <? extend Obejct>와 동일하다.

 

public class MyList {

    static void addAll(Collection<?> numbers) {
        // ...
    }

}

 

 

Erasure

지금까지 제네릭 타입에 대해 다루어보았는데, 계속해서 언급했듯이 제네릭 타입은 컴파일러가 컴파일 시에 사용되는 것으로 런타임 단계에서는 사용되지 않고 소거(erasure)되는 것이다. 이러한 소거 매커니즘은 제네릭이 지원되기 전의 레거시 코드와 제네릭 타입을 함께 사용할 수 있도록(하위 호환성) 해주었는데, 이로써 자바 5에 제네릭이 처음 도입되었을 때 순조롭게 전환될 수 있도록 해주었다.

 

 

참고자료

  • 도우출판 "자바의 정석"
  • 인사이트 "이펙티브 자바"