[Java] Java의 Generics

백중원 (Leopold)
14 min readFeb 18, 2019

--

Java 언어에서 언어적으로 가장 이해하기 어렵고 제대로 사용하기가 어려운 개념이 Generics가 아닐까 싶다. 평소에 클래스나 인터페이스 설계 시 Generics를 자주 사용하긴 했지만 어떠한 계기로 인해 제대로 사용하고 있지 못하다는 것을 깨달았고, 그래서 오랜만에 Effective Java도 꺼내고 Oracle 문서도 살펴보면서 머릿속 개념들을 다시 정리하게 되었다.

만약 Generics 없이 Raw type 만으로 Java를 개발해야 했다면 런타임에 타입 불일치에 대한 불안함을 떨칠 수가 없었을 것이고 유연한 클래스 및 인터페이스를 설계하는 데 있어서 어려움이 있었을 것이다. Java에서 제공하는 Generics에는 어떤 형태가 있는지 차근차근 살펴보자. 본 포스팅에서 다룰 내용은 다음과 같다.

  • 매개변수화 타입(Parameterized type)
  • 언바운드 와일드카드 타입(Unbounded wildcard type)
  • 바운드 타입 매개변수(Bounded type parameter)
  • 재귀적 타입 바운드(Recursive type bound)
  • 제네릭의 서브타이핑(Subtyping in generics)
  • 와일드카드 서브 타이핑(Wildcard and subtyping)
  • 바운드 와일드카드 타입(Bounded wildcard type)
  • 제네릭 메소드(Generic method)

매개변수화 타입(Parameterized type)

하나 이상의 타입 매개변수(type parameter)를 선언하고 있는 클래스나 인터페이스를 제네릭 클래스, 또는 제네릭 인터페이스라고 하며 이를 합쳐 제네릭 타입이라고 한다. 각 제네릭 타입에서는 매개변수화 타입(Parameterized type)들을 정의하는데 이를 테면 다음과 같은 것이다.

꺾쇠 괄호<>안에 있는 String을 실 타입 매개변수(Actual type parameter)라고 하며 List 인터페이스에 선언되어 있는 List<E>의 E를 형식 타입 매개변수(Formal type parameter)라고 한다. 제네릭은 타입 소거자(Type erasure)에 의해 자신의 타입 요소 정보를 삭제한다. 그렇기에 위의 코드를 컴파일 해보면 아래와 같이 변경된다.

컴파일러는 컴파일 단계에서 List 컬렉션에 String 인스턴스만 저장되어야 한다는 것을 알게 되었고 또 그것을 보장해주기 때문에 ArrayList list로 변경하여도 런타임에 동일한 동작을 보장한다. E, List<E>, List<String>과 같은 타입들을 비 구체화(non-reifiable type) 타입이라고 하며 그 반대로 구체화(reifiable type) 타입이 있으며 primitives, non-generic types, raw types 또는 List<?>, Map<?, ?>와 같은 언바운드 와일드카드 타입 등이 있다.

비 구체화 타입(non-reifiable type) : 타입 소거자에 의해 컴파일 타임에 타입 정보가 사라지는 것(런타임에 구체화하지 않는 것)

구체화 타입(reifiable type) : 자신의 타입 정보를 런타임 시에 알고 지키게 하는 것 (런타임에 구체화하는 것)

이해를 돕기 위해 간단한 예제 코드를 살펴보자.

컴파일 이전과 이후의 코드이다. List<String>ArrayList로 변경되면서 타입 정보가 사라진 것을 확인할 수 있으며 Object[] 배열은 Long[] 배열 형태의 실제 타입으로 변경된 것을 확인할 수 있다.

언바운드 와일드카드 타입(Unbounded wildcard type)

List<?>와 같은 타입을 언바운드 와일드카드 타입이라고 한다. 우선 Unbounded라는 단어를 알면 이해하는 데 도움이 된다.

Unbounded : 한이 없는, 무한한

대충 해석해보면 어떤 타입이 오든 관계가 없다는 것이다. 언바운드 와일드카드 타입이 사용될 수 있는 시나리오는 다음과 같다.

1. Object 클래스에서 제공되는 기능을 사용하여 구현할 수 있는 메서드를 작성하는 경우

2. 타입 파라미터에 의존적이지 않은 일반 클래스의 메소드를 사용하는 경우, 예를 들면 List.clear, List.size, Class<?>

예제 코드를 살펴보자.

List<String>으로 선언된 listprintList 메소드의 인자로 전달하고 있으며 printList 메소드 내부에선 List의 타입에 의존하지 않는 코드를 수행하고 있다. 컴파일 된 코드에서도 마찬가지로 타입에 의존하는 코드는 없으며 소거자에 의해 <?>가 지워지진 않는다.

바운드 타입 매개변수(Bounded type parameter)

바운드 타입은 특정 타입으로 제한한다는 의미이다. 특정 타입의 서브타입으로만 제한을 시키겠다는 것으로 해석하면 된다. 클래스나 인터페이스 설계 시 가장 흔하게 사용할 정도로 제네릭에서 사용이 쉬운 개념이라고 볼 수 있다.

예제 코드를 살펴보자.

Box 클래스의 타입 매개변수 T를 선언하면서 <T extends Number>로 선언하였다. 이는 Box의 타입으로 Number의 서브타입만 허용한다는 의미이다. 위의 코드에서 IntegerNumber의 서브타입이기 때문에 Box<Integer>와 같은 선언이 가능하지만 set 함수의 인자로 문자열을 전달하려고 했기 때문에 컴파일 에러가 발생한다.

참고로 다중 바운드 타입이라는 것도 존재하는데 Java가 클래스 다중 상속이 안된다는 것을 기억하면 규칙을 이해하는 데 도움이 될 것이다. 다중 바운드 타입은 클래스 하나와 인터페이스 여러 개를 선언할 수 있다.

class D를 선언하면서 타입 Tclass A, interface B, interface C의 서브타입으로 선언하였다. 클래스는 한 개만 허용되고 인터페이스는 여러 개를 선언할 수 있다.

재귀적 타입 바운드(Recursive type bound)

재귀적 타입 바운드는 타입 매개변수가 자신을 포함하는 수식에 의해 한정될 수 있다. 타입의 자연율을 정의하는 Comparable 인터페이스와 가장 많이 사용된다.

자연율 : 상식적인 관점에서 알 수 있는 순서를 말하며, 문자열은 사전의 알파벳(한글은 가나다) 순으로, 숫자는 크기 순으로 정해진다.

위의 내용은 Effective Java에서 설명된 내용이다. Comparable 인터페이스를 살펴보면 아래와 같은데

타입 매개변수 T는 자신과 비교될 수 있는 요소를 정의한 것이다. Comparable을 구현하는 요소들의 목록을 정렬하거나, 최솟값, 최댓값을 구하는 등의 작업을 하기 위해서는 목록의 모든 요소들이 서로 비교 가능해야 한다. 그러한 것을 잘 설명하는 코드가 있는데 아래 코드도 Effective Java에 있는 내용이다.

함수의 시그니처에 선언된 <T extends Comparable<T>>“자신과 비교될 수 있는 모든 타입 T”라고 읽을 수 있다. 재귀적 타입 바운드가 존재하는 이유는 Java 언어의 문제점을 보완하기 위한 것이다.

Java는 연산자 오버 로딩을 지원하지 않기 때문에 short, int, double 등과 같은 primitive 타입에만 >와 같은 비교연산자를 사용할 수 있었다. 이 문제를 해결하기 위해 Comparable 인터페이스와 재귀적 타입 바운드를 활용한다. 예제 코드를 보면 이해에 도움이 될 것이다.

위 코드 중 타입 매개변수 T로만 이루어진 함수는 Operator ‘>’ cannot be applied to ‘T’, ‘T’라는 메시지와 함께 컴파일에 실패한다. 그에 반면 아래 함수는 컴파일도 되고 정상작동할 것이다. 만약 Java가 문법적으로 연산자 오버 로딩을 지원했다면 필요 없었을 거란 생각이 든다.

제네릭의 서브타이핑(Subtyping in generics)

객체지향 관점에서 아래의 코드는 is-a 관계이므로 컴파일 에러 없이 정상 동작할 것이다.

마찬가지로 다음과 같은 제네릭 코드도 정상 동작할 것이다.

Box 클래스의 매개변수화 타입으로 Number를 선언하였고 IntegerDoubleNumber의 서브타입이기 때문에 문제가 없다. 하지만 아래와 같은 코드는 어떨까?

얼핏 봐서는 문제가 없는 것처럼 보이지만 컴파일 에러가 발생한다. Box<Double>이나 Box<Integer>Box<Number>의 서브타입이 아니기 때문이다. 이걸 한눈에 이해하기 쉽게 표현한 다이어그램이 있다.

매개변수화 타입은 무공변(invariant)이기 때문에 Box<Number>Box<Integer>의 서브타입도, 슈퍼 타입도 아니다. 오로지 Box<Number>에서는 Number 타입만 허용하고 Box<Integer>는 Integer 타입만 허용하기 때문에 둘은 다른 존재다. 제네릭 클래스나 인터페이스를 상속관계로 정의하고 싶다면 다음과 같이 클래스 or 인터페이스의 상속관계를 정의해야 한다.

우리만의 List 인터페이스를 정의한다고 상상했을 때 다음과 같이 구성할 수 있다.

PayloadListList<E>를 상속받으면서 추가적인 타입 P를 선언하였다. PayloadList는 다음과 같은 형태를 가질 수 있는데 아래의 형태 모두 List<String>의 서브타입이다.

  • PayloadList<String, String>
  • PayloadList<String, Integer>
  • PayloadList<String, Exception>

와일드카드 서브타이핑(Wildcard and subtyping)

매개변수화 타입은 무공변(invariant)이라고 했었다. 아래의 코드는 동작할 것처럼 보이지만 아쉽게도 컴파일 에러가 발생한다.

IntegerNumber의 서브타입이고 형식 매개변수나 실 매개변수의 모양이 비슷해서 왠지 잘 동작할 것 만 같은데 안된다. 이와 같은 문제를 해결하기 위해 와일드카드 타입을 사용한다.

addAll 메소드의 인자로 List<Integer>, List<Float>, List<Double> 등의 형태로 Number의 서브타입을 갖는 List를 전달하고 싶다면 아래 코드와 같이 바운드 와일드카드 타입을 이용하면 된다.

List<? extends Number>가 의미하는 것은 Number의 어떤 서브 타입의 List가 되어야 한다는 것이다. 위 케이스의 List<Number> 처럼 무공변(invariant)의 성질을 가지는 매개변수화 타입의 문제점을 해결하기 위해 제공하는 것이 바운드 와일드카드 타입이다.

바운드 와일드카드 타입(Bounded wildcard type)

바운드 와일드카드 타입에는 Upper bounded wildcard와 Lower bounded wildcard가 있다. Collection 계열 클래스들의 소스코드를 살펴보면 자주 볼 수 있는 <? extends T>가 Upper bounded wildcard이고 <? super T>가 Lower bounded wildcard이다.

다른 말로 전자를 공변(covariant)이라 하고 후자를 반공변(contravariance)이라 부르는데 각각이 의미하는 내용은 다음과 같다.

무공변(invariant) : 오로지 자기 타입만 허용하는 것 <T>

공변 (covariant) : 구체적인 방향으로 타입 변환을 허용하는 것 (자기 자신과 자식 객체만 허용) <? extends T>

반공변 (contravariant) : 추상적인 방향으로의 타입 변환을 허용하는 것(자기 자신과 부모 객체만 허용) <? super T>

앞에서 언급했듯이 매개변수화 타입은 무공변이라고 하였다. 왜 자기 타입만 허용하게 했을까? 만약 아래 코드가 허용된다고 가정해보자.

어차피 컴파일은 안되겠지만 된다고 가정했을 때 런타임에 에러를 발생시킨다. Java의 배열보다 나을 것이 없다는 것이다. 그래서 런타임에 안정성을 보장하기 위해 이런 방식을 택했는데 이는 몇 가지 영향을 준다.

위 코드를 보면 논리적으로 문제가 없어 보이지만 Collection<String>Collection<Object>의 서브타입이 아니기 때문에 컴파일에 실패한다. 위 코드가 컴파일 되려면 addAll 메소드의 시그니처를 다음과 같이 변경해야 한다.

위에서 몇 번 언급했던 내용이기에 중복되는 느낌이 없지 않지만 반복학습의 개념으로봐주시면 좋을 것 같다. extends-bound, super-bound는 PECS라는 개념으로도 사용된다.

PECS : Producer(생산자)-extends, Consumer(소비자)-super

생산자와 소비자를 이해하기에 좋은 예제 코드가 있다.

예제 코드에서 컴파일이 되는 경우와 컴파일 되지 않는 경우를 같이 표시하였다. 위 코드를 보고 알 수 있는 것은 Producer-extends는 읽기만 가능하고 Consumer-super는 쓰기만 가능하다는 것이다.

그럼 언제 어떤 상황에서 extends를 사용하고 super를 사용해야 할까? Oracle 문서에서는 In, Out 개념으로 가이드 하고 있다. 예를 들어 copy(src, dest)라는 메소드가 있다고 하자.

여기서 src는 데이터를 복사할 데이터를 제공하므로(생산) In 인자가 되고 dest는 다른 곳에서 사용할 데이터를 받아들이므로(소비) Out 인자가 되므로 In의 경우 extends 키워드를 사용하고 Out의 경우는 super를 사용하라고 한다.

제네릭 메소드(Generic method)

클래스나 인터페이스에 제네릭을 사용하듯이 메소드에도 제네릭을 적용할 수 있다. 주로 static 유틸리티 메소드에 유용하게 쓰일 수 있다. 제네릭 메소드의 타입 매개변수를 선언할 때 타입 매개변수의 위치는 메소드의 접근 지시자와 반환 타입 사이이다. 예제 코드를 살펴보자.

Util 클래스에 static 메소드로 compare를 정의하였다. 타입 K, V는 클래스에는 정의되어 있지 않고 메소드에만 정의되어 있다. 물론 static 메소드가 아니어도 적용할 수 있으며 대신 메소드의 접근 지시자 와 반환 타입 사이에 위치하지 않으면 컴파일 에러가 발생한다. 아래는 compare 메소드를 호출하는 코드다.

실 타입 매개변수 Integer, String을 갖는 Pair 객체를 인자로 compare 메소드를 호출하였다. 원래 Util.<Integer, String>compare(p1, p2); 처럼 매개변수화 타입을 정의해줘야 하지만 타입 추론(Type inference)에 의해 생략할 수 있다.

위 코드에서 만약 p2Pair<String, Integer>로 선언한 뒤 compare 메소드의 인자로 전달하려고 하면 컴파일 에러가 발생할 것이다.

왜냐하면 <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) 메소드 시그니처에 p1, p2 모두 동일한 형식 타입 매개변수로 정의되어 있기 때문인데 만약 p2Pair<V, K>로 변경한다면 컴파일 에러가 발생하지 않을 것이다.

마치며

Java에서 가장 어려운 것 중에 하나가 Generics라고 생각합니다. 그만큼 제대로 사용하기 어렵긴 하지만 웬만한 Java 오픈소스 코드에 Generics 코드가 없는 것을 보기 힘들 정도로 유용하고 널리 사용되고 있습니다. 사실 Java와 Kotlin의 Generics를 각각 소개하고 비교하는 내용을 담으려고 했으나 작성하다 보니 내용이 너무 쓸데없이 많아져서 일부 빼버린 섹션도 있습니다. 피곤한 상태로 계속 작성하다 보니 좀 대충 마무리된 느낌이 없지 않아 있는데 여유가 되면 내용 보충하도록 하겠습니다.

--

--

백중원 (Leopold)

스타트업에서 ‘트리플’ 이라는 여행 서비스를 개발하고 있습니다. 디지털 노마드와 조기 은퇴를 꿈꾸는 평범한 개발자입니다.