[자바/코틀린/스프링] 제네릭

제네릭

제네릭 프로그래밍(generic programming)이란 다양한 데이터 타입에 대해 동작하는 코드를 작성하는 방법을 사용한 프로그래밍 방식이다. 특정 데이터 타입 대신 일반화된 데이터 타입에 대해 동작하는 코드를 작성함으로써 코드의 유연성과 재사용성, 그리고 안전성을 높일 수 있다.

제네릭 프로그래밍을 통해 특정 데이터 타입에 종속되지 않고 일반화된 방식으로 코드를 작성할 수 있다. 제네릭 프로그래밍에서는 런타임이 아닌 컴파일 타임에 데이터 타입과 관련된 확인을 수행하여 코드에 안정성을 더한다. 제네릭(generic)이란 제네릭 프로그래밍에서 일반화된 데이터 타입을 처리하기 위한 언어 수준의 기능이다. 객체가 여러 다른 객체를 구성 요소로 가지는 복합 객체(complex object)이거나 데이터를 요소로 갖는 컬렉션(collection) 객체인 경우 제네릭을 적용하면 보다 유연하고 안전성 높은 프로그래밍이 가능하다.

제네릭을 통해 선언한 클래스 또는 인터페이스를 제네릭 클래스 또는 제네릭 인터페이스라고 하며 이를 통틀어 제네릭 타입이라고 한다. 타입을 파라미터로 전달받을 수 있는 클래스 또는 인터페이스를 제네릭 타입으로 보면 된다. 제네릭 타입은 어떤 타입을 파라미터로 전달 받을 수 있으므로 제네릭에 담는 타입을 파라미터화된 타입(parameterized type)이라고도 한다. 메서드를 제네릭으로 선언할 수도 있다. 이를 제네릭 메서드라고 한다.

타입을 파라미터로 전달한다는 것은 제네릭 타입이 다룰 수 있는 타입을 명시하는 것과 같다. 코드 상에 제네릭 타입의 파라미터를 명시함으로써 컴파일 타임에 제네릭 타입이 해당 파라미터 타입을 처리하는지 확인할 수 있어 타입 안정성을 높일 수 있다.

제네릭을 사용하는 이유와 장점은 다음과 같다.

  1. 컴파일 시 더 강력한 타입 체크: 자바 컴파일러는 타입 체크를 통해 코드가 타입 안전성을 위반하는 경우 컴파일 타임 오류를 발생시킨다. 컴파일 타임 오류를 수정하는 것은 런타임 오류를 수정하는 것보다 쉽다.
  2. 타입 캐스팅(형 변환) 코드 제거: 제네릭 타입이 아닌 원시 타입을 사용하는 경우 타입 캐스팅이 필요하다. 제네릭을 사용하면 컴파일 시점에 특정 타입 사용을 명시하므로 타입 캐스팅이 필요하지 않다.
  3. 제네릭 알고리즘 구현: 제네릭을 사용하면 다양한 타입을 원소로 갖는 컬렉션에서 작동하고, 사용자화될 수 있으며, 타입 안전하고 읽기 쉬운 제네릭 알고리즘을 구현할 수 있다.


원시 타입

원시 타입(raw type)이란 타입 파라미터를 명시하지 않고 사용하는 제네릭 타입을 말한다. 예를 들어 다음 코드에서 List 타입은 원시 타입이다.

List list = new ArrayList<>();


타입을 명시한 제네릭 타입을 파라미터화된 타입(parameterized type)이라고 한다. 다음 코드에서 List<String>은 파라미터화된 타입이다.

List<String> list = new ArrayList<>();


제네릭 타입을 정의하면 자동으로 원시 타입도 정의되며 원시 타입은 제네릭 타입(파라미터화된 타입)의 상위 타입이므로 클라이언트는 이를 그대로 사용할 수 있다.

원시 타입에는 어떠한 타입도 원소로 넣을 수 있다. 따라서 런타임에 서로 다른 타입의 원소들이 제네릭 타입에 담겨 있을 수 있다. 원시 타입은 제네릭이 도입되기 전에 사용되던 코드이며 제네릭 타입에 대해 원시 타입을 사용하면 제네릭이 제공하는 타입 안정성, 컴파일 타임 에러 확인 등의 장점을 잃게 되므로 권장되지 않는다.

제네릭 타입에 대해 원시 타입을 사용할 경우 런타임 시 ClassCastException 예외가 발생 가능하다. 예를 들어 원시 타입으로 정의한 List 변수에 String 타입의 값을 원소로 추가한 후 String 타입으로 타입 캐스팅이 되지 않는 다른 타입을 원소로 추가하면 ClassCastException 예외가 발생하게 된다. 이러한 예외는 컴파일 타임이 아닌 런타임에 발생한다. 원시 타입 사용으로 인한 ClassCastException 예외를 막기 위해서는 타입 캐스팅 전 instanceof 연산자나 isInstance() 메서드를 통해 타입 간 관계를 확인한 후 cast() 메서드를 통해 타입 캐스팅을 수행함으로써 안전하게 타입 캐스팅을 수행하는 것이 필요하다.


타입 추론

타입 추론(type inference)이란 자바 컴파일러가 메서드 호출과 해당 선언을 살펴보고 해당 메서드 호출 시 적용할 수 있는 타입 인자를 결정하는 것을 말한다. 추론 알고리즘은 인자의 타입을 결정하거나 가능한 경우 결과가 할당되거나 반환되는 타입을 결정한다. 추론 알고리즘은 모든 인자로 동작하는 가장 구체적인 타입을 찾으려고 시도한다.

제네릭 메서드에 대해서도 타입 추론이 적용된다. 자바 컴파일러는 제네릭 메서드 호출의 타입 파라미터를 추론할 수 있다. 따라서 대부분의 경우 타입 파라미터를 지정할 필요가 없다. 예를 들어, 제네릭 메서드 addBox()를 호출하려면 다음과 같이 타입 감시(type witness)를 사용하여 타입 파라미터를 지정할 수 있다.

BoxDemo.<Integer>addBox(Integer.valueOf(10), listOfIntegerBoxes);


타입을 생략하면 자바 컴파일러는 메서드의 인자를 통해 타입 파라미터를 추론한다.

BoxDemo.addBox(Integer.valueOf(20), listOfIntegerBoxes);


제네릭 메서드 호출이 아닌 제네릭 클래스의 생성자 호출 시에도 타입 추론이 가능하다. 이 경우 대괄호 사이에 타입을 지정하지 않고 일반 메서드처럼 제네릭 메서드를 호출할 수 있다. 자바 컴파일러가 컨텍스트에서 타입 파라미터를 추론할 수 있다면 제네릭 클래스의 생성자를 호출하는데 필요한 타입 파라미터를 생략할 수 있다. 단, 제네릭 클래스 인스턴스화 시 타입 추론을 활용하려면 대괄호(<>)(다이아몬드)를 사용해야 한다.

Map<String, List<String>> myMap = new HashMap<String, List<String>>();


다음과 같이 생성자의 파라미터 타입을 생략할 수 있다. 대괄호는 생략해서는 안 된다.

Map<String, List<String>> myMap = new HashMap<>();


제네릭과 서브타이핑

서브타이핑(subtyping)이란 상속 관계에 있는 타입에서 하위 타입이 상위 타입을 대체하는 것을 말한다. 변성(variance)이란 복합 타입(complex type)의 서브타이핑이 구성 요소 간의 서브타이핑과 어떻게 관련되는가에 대한 것이다. 복합 타입의 예로는 리스트, 배열 등 구성 요소를 포함하는 자료 구조에 해당되는 타입이 있다. 변성의 종류와 개념은 다음과 같다.

  • 공변성 (covariance): 구성 요소의 하위 타입과 상위 타입의 관계가 복합 타입에 대해서도 그대로 유지된다. 이 경우 복합 타입의 하위 타입이 상위 타입 대신 사용될 수 있다. 즉, 복합 타입의 서브타이핑이 가능하다. 리스코프 치환 원칙은 공변성과 관련된 원칙이다.
  • 반공변성 (contravariance): 구성 요소의 하위 타입과 상위 타입의 관계가 복합 타입에 대해서는 역전된다. 이 경우 복합 타입의 상위 타입이 하위 타입 대신 사용될 수 있다. 즉, 복합 타입의 서브타이핑이 불가능하다.
  • 불공변성(또는 무공변성) (invariance): 구성 요소의 하위 타입과 상위 타입 사이의 관계와 복합 타입 간 관계에는 아무런 관련이 없다. 따라서 하위 타입 대신 상위 타입을 사용하거나 상위 타입 대신 하위 타입을 사용할 수 없다. 즉, 복합 타입의 서브타이핑이 불가능하다.


일반적으로 제네릭에서 상위 클래스를 타입 파라미터로 하는 제네릭 타입과 하위 클래스를 타입 파라미터로 하는 제네릭 타입은 서로 상속 관계에 있을 수도, 그렇지 않을 수도 있다. 즉, 제네릭 타입에 대해 서브타이핑은 가능할 수도 불가능할 수도 있다. 프로그래밍 언어마다 제네릭 타입의 공변성 지원 여부가 다르다. 자바에서 제네릭 타입은 불공변성을 갖는다. 따라서 제네릭 타입의 서브타이핑이 불가능하다. 예를 들어 대표적인 제네릭 타입인 List의 경우 List<SuperClass>List<SubClass>의 상위 타입이 아니므로 List 제네릭 타입은 서브타이핑이 불가능하다. 반면 배열은 제네릭 타입인 List와 달리 공변성을 갖는다. 따라서 SuperClass[]SubClass[]의 상위 타입이므로 배열은 서브타이핑이 가능하다. List나 배열 모두 특정 원소를 담는 저장소에 서로 다른 타입의 원소를 넣을 수는 없으며 오류가 발생한다. 배열에서는 런타임에서 오류가 발생하지만 제네릭 타입인 List의 경우 컴파일 시 오류가 발생한다. 이는 런타임에 발생하는 타입 소거(type erasure)라는 제네릭 타입의 특성 때문이다. 따라서 컴파일 시 타입 체크가 가능한 제네릭 타입의 타입 안정성이 더 높다.

상속 관계에서 메서드에 대한 공변성과 반공변성의 특성은 다음과 같다. 상위 클래스에서 구현된 메서드를 하위 클래스에서 오버라이딩할 때 메서드의 반환 타입을 상위 클래스에서 선언한 메서드의 반환 타입의 하위 타입으로 지정할 수 있는 특성을 반환 타입 공변성(return type covariance)이라고 한다. 반환 타입 공변성을 만족하는 경우 클래스의 타입 계층 방향과 메서드의 반환 타입의 타입 계층 방향이 동일하다. 반환 타입 공변성을 사용하면 클라이언트는 타입 캐스팅을 신경 쓰지 않고 메서드를 호출할 수 있다. 공변성과 반공변성의 지원 여부는 언어에 따라 다르며 자바는 반환 타입 공변성을 지원한다. 반환 타입 공변성을 지원하는 언어는 반환 타입에 대해 공변적이지만 그렇지 않은 언어는 반환 타입에 대해 불공변적이라고 한다.

상위 클래스에서 구현된 메서드를 하위 클래스에서 오버라이딩할 때 파라미터 타입을 상위 클래스에서 선언한 메서드의 파라미터의 상위 타입으로 지정할 수 있는 특성을 파라미터 타입 반공변성(parameter type contravariance)이라고 한다. 파라미터 타입 반공변성을 만족하는 경우 클래스의 타입 계층 방향과 메서드의 파라미터의 타입 계층의 방향이 반대이다. 자바는 파라미터 타입 반공변성을 지원하지 않는다.

이처럼 프로그래밍 언어는 유연하게 타입을 할당할 수 있도록 공변성과 반공변성이라는 특성을 제공할 수 있다. 타입을 파라미터로 하는 제네릭 타입의 파라미터에 대해서도 공변성과 반공변성 특성을 통해 더욱 유연하게 제네릭 타입의 파라미터를 할당하고 사용할 수 있다.

제네릭 타입의 파라미터로 전달하는 타입들 간의 관계는 제네릭 타입의 관계와 관련이 있을 수도 있고 없을 수도 있다. 제네릭 타입의 파라미터 타입 간 상속 관계 측면에서 변성은 다음과 같이 정리할 수 있다.

  • 공변성: 하위 파라미터 타입과 상위 파라미터 타입의 관계가 그대로 유지된다. 이 경우 하위 타입을 파라미터로 갖는 제네릭 타입이 상위 타입을 파라미터로 갖는 제네릭 타입 대신 사용될 수 있다. C<SubClass>C<SuperClass>의 하위 타입이며 C<SubClass>C<SuperClass>를 서브타이핑할 수 있다.
  • 반공변성: 하위 타입과 상위 타입의 관계가 역전된다. 이 경우 상위 타입을 파라미터로 갖는 제네릭 타입이 하위 타입을 파라미터로 갖는 제네릭 타입 대신 사용될 수 있다. C<SuperClass>C<SubClass>의 하위 타입이 되며 C<SuperClass>C<SubClass>를 서브타이핑할 수 있다.
  • 불공변성: 하위 타입과 상위 타입 사이에 아무런 관계도 존재하지 않는다. 따라서 하위 타입을 파라미터로 갖는 제네릭 타입이 상위 타입을 파라미터로 갖는 제네릭 타입 대신 사용하거나 그 반대를 수행할 수 없다. C<SubClass>C<SuperClass>는 서로 관계가 없으며 서브타이핑은 불가능하다.


자바에서 제네릭 타입은 기본적으로 불공변성을 갖지만 와일드카드(wildcard)라는 기능을 사용하여 제네릭 타입이 공변성 또는 반공변성을 갖도록 할 수 있다.


와일드카드

자바는 제네릭 타입을 보다 유연하게 정의하고 사용하기 위해 와일드카드(wildcard)라는 타입을 제공한다. 와일드카드를 사용하면 제네릭 타입의 불공변성 특성으로 인한 제약을 극복할 수 있다.

와일드카드 타입에는 비한정적(unbounded) 와일드카드 타입과 한정적(bounded) 와일드카드 타입이 있다. 비한정적 와일드카드 타입은 제네릭 타입의 타입 파라미터를 특정 타입으로 제한하지 않는다. 따라서 모든 타입을 제네릭 타입에 넣을 수 있다. 비한정적 와일드카드 타입은 ?를 사용하여 선언한다. 비한정적 와일드카드 타입에 서로 다른 타입을 원소로 넣을 수 있다는 의미는 아니다. 비한정적 와일드카드 타입을 타입 파라미터로 정의한 제네릭 타입에는 특정 타입이 아닌 다른 타입의 원소는 넣을 수는 없다. null은 넣을 수 있다. 모든 타입에 대해 C<Type> 타입은 C<?> 타입의 서브타입이 된다.

한정적 와일드카드 타입은 제네릭 타입의 타입 파라미터를 특정 타입으로 제한한다. 이를 통해 파라미터의 타입을 특정 타입의 하위 타입 또는 상위 타입으로 제한할 수 있다. 한정적 와일드카드 타입은 특정 타입의 하위 타입으로의 제한 시 ? extends 타입, 특정 타입의 상위 타입으로의 제한 시 ? super 타입을 사용하여 선언한다. 타입 파라미터를 특정 타입의 하위 타입으로의 제한하는 ? extends 타입 와일드카드를 상한(upper-bounded) 와일드카드, 특정 타입의 상위 타입으로 제한하는 ? super 타입 와일드카드를 하한(lower-bounded) 와일드카드라고 한다.

한정적 와일드카드 타입을 타입 파라미터로 정의한 제네릭 타입에는 특정 타입의 하위 타입(또는 상위 타입)이 아닌 다른 타입의 원소는 넣을 수 없다. null은 넣을 수 있다. 기본적으로 SubClassSuperClass의 서브타입이므로 C<SuperClass> 타입의 원소로 SubClass 타입 파라미터를 전달할 수 있지만 반대로 C<SubClass> 타입의 원소로 SuperClass 타입 파라미터를 전달할 수는 없다. ? extends 타입 상한 와일드카드 타입을 사용하여 C<? extends SuperClass> 타입의 원소로 SubClass 타입 파라미터를 전달할 수 있을 뿐만 아니라, ? super 타입 하한 와일드카드 타입을 사용하면 C<? super SubClass> 타입의 원소로 SuperClass 타입 파라미터를 전달할 수 있다.

자바에서는 기본적으로 제네틱 타입의 불공변성에 의해 SubClassSuperClass 하위 타입이지만, C<SubClass>C<SuperClass>의 하위 타입이 아니며, 두 타입은 서로 관련이 없다. C<SubClass>C<SuperClass>의 공통 상위 클래스는 C<?>이다. C<SubClass>C<SuperClass> 두 타입 간의 관계를 만들기 위해 상한 또는 하한 와일드카드를 사용한다. 상한 와일드카드에 의해 C<? extends SubClass>C<? extends SuperClass>의 하위 타입이 된다. 하한 와일드카드에 의해 C<? super SuperClass>C<? super SubClass>의 하위 타입이 된다.

원시 타입은 타입 안전하지 않지만 와일드카드 타입은 타입 안전하다.


제네릭과 PECS

제네릭 타입은 타입 파라미터에 대해 공변성이 아닌 불공변성을 가진다. 와일드카드를 사용하면 서브타이핑 제약을 해결하고 제네릭 타입을 파라미터로 받는 API를 사용하는 클라이언트에게 유연성을 제공할 수 있다. PECS(Producer-Extends, Consumer-Super)는 리스트 같은 컬렉션(collection) 제네릭 타입에 공변성 또는 반공변성 특성을 제공하기 위해 한정적 와일드카드 타입을 사용한 제네릭의 타입 파라미터 정의 시 따라야하는 규칙이다.

PECS는 컬렉션 제네릭 타입에 대한 규칙이다. 타입 파라미터가 컬렉션에 저장된 원소의 조회 또는 접근을 위해 사용된다면 클라이언트는 소비자이며 제네릭 타입은 생산자(producer)이다. 반면 타입 파라미터가 컬렉션의 원소로 저장된다면 클라이언트는 생산자이며 제네릭 타입은 소비자(consumer)이다. PECS에 따르면 제네릭 타입이 생산자라면 와일드카드를 사용한 타입 파라미터 정의 시 상한 와일드카드인 <? extends T>를 사용하고, 소비자라면 하한 와일드카드인 <? super T>를 사용해야 한다.

파라미터화된 타입을 컬렉션에 저장된 원소의 접근 또는 조회를 위해 사용한다면 제네릭 타입은 생산자이다. 이때 제네릭 타입은 읽기 전용으로 사용된다. 제네릭 타입이 일반적인 타입 보다 구체적인 타입(하위 타입)을 파라미터 타입(원소의 타입)으로 받도록 하기 위해 파라미터 타입 정의 시 extends 키워드를 사용한 상한 와일드카드를 사용한다. 이 상한 와일드카드는 제네릭 타입에 공변성을 제공하므로 C<SubClass>C<? extends SuperClass>의 하위 타입이 된다. 컬렉션에 저장된 원소를 사용할 때 상한 와일드카드를 사용하는 이유는 제네릭 타입을 파라미터로 전달할 때 하위 타입 원소를 갖는 컬렉션을 상위 타입 원소를 갖는 컬렉션으로 서브타이핑 가능하게 하고(C<SuperClass> 대신 C<SubClass>를 사용), 생산자인 제네릭 타입의 보다 구체적인 타입의 원소에 접근할 수 있도록 하기 위함이다. 컬렉션에서 SuperClass를 안전하게 읽을 수는 있지만 SuperClass의 하위 타입에 해당하는 객체가 무엇인지 모르기 때문에 컬렉션에 윈소를 저장할 수는 없다. 메서드가 파라미터로 컬렉션을 전달 받고 컬렉션의 원소에 접근하는 경우 해당 파라미터를 상한 와일드카드로 선언하면 메서드는 원소의 하위 타입을 파라미터로 갖는 제네릭 타입을 인자로 전달 받을 수 있다. 이를 통해 메서드는 하위 타입의 원소를 갖는 컬렉션에도 접근할 수 있다.

반면 파라미터화된 타입을 컬렉션의 원소로 저장한다면 제네릭 타입은 소비자이다. 제네릭 타입이 구체적인 타입 보다 일반적인 타입(상위 타입)을 파라미터 타입으로 받도록 하기 위해 파라미터 타입 정의 시 super 키워드를 사용한 하한 와일드카드를 사용한다. 이 하한 와일드카드는 제네릭 타입에 반공변성을 제공하므로 C<SuperClass>C<? super SubClass>의 하위 타입이 된다. 컬렉션에 저장된 원소를 사용할 때 하한 와일드카드를 사용하는 이유는 제네릭 타입을 파라미터로 전달할 때 상위 타입 원소를 갖는 컬렉션을 하위 타입 원소를 갖는 컬렉션으로 서브타이핑 가능하게 하고, 소비자인 제네릭 타입이 보다 일반적인 타입의 원소를 소비할 수 있도록 하기 위함이다. 컬렉션에 SubClass를 안전하게 저장할 수는 있지만 SubClass의 상위 타입에 해당하는 객체가 무엇인지 모르기 때문에 컬렉션에서 윈소를 읽을 수는 없다. 메서드가 파라미터로 컬렉션을 전달 받고 컬렉션에 원소를 저장하는 경우 해당 파라미터를 하한 와일드카드로 선언하면 메서드는 원소의 상위 타입을 파라미터로 갖는 제네릭 타입을 인자로 전달 받을 수 있다. 이를 통해 메서드는 상위 타입의 원소를 갖는 컬렉션에 원소를 저장할 수 있다.

결론적으로 PECS에 따르면 컬렉션으로부터 하위 타입만 조회할 수 있고 컬렉션에 상위 타입만 저장할 수 있게 된다. 즉, PECS는 컬렉션에서 원소를 꺼내 사용할 때는 서브타이핑을 통해 원소의 구체적인 타입을 명시하여도 동작에 문제가 없도록 하며, 컬렉션에 원소를 저장할 때에는 구체적인 타입을 명시하지 않더라도 동작에 문제가 없도록 하는 규칙이다. 컬렉션에서 파라미터 타입 원소를 조회하면서 동시에 컬렉션에 저장한다면 하한 와일드카드나 상한 와일드카드를 사용해서는 안되며 정확한 타입을 지정하는 것이 좋다.

제네릭 프로그래밍 시 PECS 규칙을 따르는 예는 한 컬렉션에서 다른 컬렉션으로 원소를 복사하는 메서드이다. 메서드 정의 시 PECS에 따라 메서드의 파라미터 타입을 제네릭으로 선언한다.

public void copy(List<? extends T> src, List<? super T> dest) {
    for (T element : src) {
        dest.add(element);
    }
}


와일드카드 사용 시 PECS를 위해 상한 와일드카드와 하한 와일드카드 중 어느 것을 사용할지 혼란스러울 수 있다. 오라클의 자바 문서에 따르면 변수를 두 가지 기능 중 하나를 제공하는 것으로 생각하여 타입 파라미터를 구분하고 이러한 개념을 기반으로 와일드카드 사용에 대한 지침을 제공한다.

  1. in 변수: 코드에 데이터를 제공하는 변수이다. 예를 들어, srcdest 두 인자를 받는 copy() 메서드에서 src 파라미터는 복사할 데이터를 제공하므로 in 파라미터이다.
  2. out 변수: 다른 곳에서 사용할 데이터를 저장하는 변수이다. 예를 들어, copy() 메서드에서 dest 파라미터는 저장할 데이터를 받으므로 out 파라미터이다.


in 변수이면서 out 변수인 경우도 존재한다.

in 변수와 out 변수를 기반으로 와일드카드 사용 시 다음 내용을 따를 수 있다.

  • in 변수는 extends 키워드를 사용하여 상한 와일드카드로 정의한다.
  • out 변수는 super 키워드를 사용하여 하한 와일드카드로 정의한다.
  • Object 클래스에 정의된 메서드를 사용하여 in 변수에 접근할 수 있는 경우 비한정적 와일드카드 ?를 사용한다.
  • 코드가 inout 변수 모두 접근해야하는 경우에는 와일드카드를 사용하지 않는다.


위 지침은 메서드의 반환 타입에는 적용되지 않는다. 와일드카드를 반환 타입으로 사용할 경우 코드를 사용하는 클라이언트가 와일드카드를 처리해야 하므로 지양해야 한다.


타입 소거

타입 소거(type erasure)란 런타임에 제네릭의 타입 정보가 삭제되는 것을 말한다. 런타임에 원소의 타입 정보가 소거되면 원소가 무슨 타입인지 알 수 없으며 컴파일 타임에만 원소의 타입 검사가 가능하다. 타입 소거에 의해 모든 제네릭 타입은 런타임에 하나의 단순한 원시 타입이 된다. 예를 들어 List<Int>, List<String> 제네릭 타입은 타입 소거에 의해 런타임에 List 타입이 된다. 자바에서 제네릭 타입은 타입 소거로 인해 컴파일 타임에는 타입 안전하지만 런타임에는 타입 안전하지 않다. 런타임에 타입 소거가 되는 타입을 비구체화(non-reification)(또는 비실체화) 타입이라고 한다. 제네릭 타입은 비구체화 타입이다.

런타임에 타입 소거가 되지 않는 경우도 있다. 이를 구체화(reification)(또는 실체화) 타입이라고 한다. 런타임에 타입 소거가 일어나지 않는 구체화 타입은 런타임에는 타입 안전하지만 컴파일 타임에는 타입 안전하지 않다. 자바에서 배열은 런타임에 타입 소거가 되지 않는 구체화 타입이다. 따라서 배열은 런타임에는 타입 안전하지만 컴파일 타임에는 타입 안전하지 않다. 배열에 잘못된 타입의 원소를 저장하는 코드의 경우 컴파일 타임에 오류가 발생하지는 않지만 런타임에 원소의 타입이 확인되어 예외(ArrayStoreException)가 발생한다. 컴파일러에 의해 타입 안정성이 보장되도록 하려면 비구체화 타입인 제네릭 타입을 사용해야 한다.

제네릭 타입은 타입 소거로 인해 하위 타입 대신 상위 타입을 사용할 수 있는 암시적 다형성을 지원하지 않는다. 제네릭 타입이 아닌 인터페이스와 상위 클래스의 구현체 및 하위 클래스는 인터페이스 타입 및 상위 클래스를 타입으로 선언할 수 있지만 제네릭(제네릭의 타입 파라미터)에 대해서는 그렇지 않다. 자바에서 제네릭으로 다형성을 활용하기 위해서는 명시적 타입 캐스팅이나 와일드카드를 사용해야 한다.


제네릭 메서드

클래스나 인터페이스 뿐만 아니라 메서드에도 제네릭을 적용할 수 있다. 메서드의 반환 타입 또는 파라미터 타입을 제네릭 타입으로 선언할 수 있다. 메서드 정의 시 타입 파라미터 정의는 반환 타입 정의 앞에 위치시킨다. 제네릭 선언을 하지 않은 클래스 또는 인터페이스에 제네릭 메서드를 선언할 수 있으며 제네릭 클래스 또는 인터페이스에서 제네릭 메서드를 선언할 수도 있다.

다음 코드는 제네릭 타입이 아닌 일반 클래스에서 제네릭 메서드를 정의하는 코드이다.

public class MyClass {
  <T> T getElement() {
    ...
  } 
  
  <T> getElement(T param) {
    ...
  }
  
  <T> T getElement(T param) {
    ...
  } 
  
  <T> List<T> getElement() {
    ...
  }
  
  <T> List<T> getElement(T param) {
    ...
  }
}


제네릭 메서드 정의 시 타입 파라미터는 반환 타입 앞에 위치시키며 꺽쇠(<>) 기호를 사용한다. 메서드의 반환 타입 또는 파라미터를 제네릭으로 타입 파라미터화할 수 있으며 반환 타입은 또다른 제네릭 클래스 또는 인터페이스가 될 수 있다.

다음 코드는 제네릭 클래스에서 제네릭 메서드를 정의하는 코드이다.

public class MyClass<T> {
  T getElement() {
    ...
  }
  
  // 제네릭 클래스의 타입 파라미터로 인해 잘못된 타입 파라미터 선언이 된다.
  <T> T getElement() {
    ...
  }
  
  // 제네릭 클래스의 타입 파라미터로 인해 잘못된 타입 파라미터 선언이 된다.
  <T> getElement(T param) {
    ...
  }
  
  // 제네릭 클래스의 타입 파라미터로 인해 잘못된 타입 파라미터 선언이 된다.
  <T> T getElement(T param) {
    ...
  }
  
  // 제네릭 메서드의 타입 파라미터가 숨겨진다.
  <T> List<T> getElement() {
    ...
  }
  
  // 제네릭 메서드의 타입 파라미터가 숨겨진다.
  <T> List<T> getElement(T param) {
    ...
  }
  
  <U> U getElement() {
    ...
  }
  
  <U> getElement(U param) {
    ...
  }
  
  <U> U getElement(U param) {
    ...
  }
  
  <U> List<U> getElement() {
    ...
  }
  
  <U> List<U> getElement(U param) {
    ...
  }
}


제네릭 클래스의 타입 파라미터명과 제네릭 메서드의 타입 파라미터명이 중복되도록 정의하는 경우 컴파일이 되지 않는다. 따라서 타입 파라미터명을 구별해야 한다. 제네릭 메서드에 대해서만 타입을 제한하기 위해서라도 타입 파라미터명을 다르게 정의해야 한다.

타입 파라미터명을 구분함으로써 다음과 같이 메서드의 반환 타입을 제네릭 클래스의 타입 파라미터로 정의하고, 메서드의 파라미터 타입만 제네릭 메서드의 타입 파라미터로 정의하는 것이 가능하다.

public class MyClass<T> {
  <U> T getElement(U param) {
    ...
  }
  
  <U> List<T> getElement(U param) {
    ...
  }
}


이 경우 메서드의 파라미터에 대한 컴파일 타입 검사는 메서드 호출 시 전달하는 인자의 타입에 의해, 메서드의 반환 타입에 대한 컴파일 타입 검사는 클래스 인스턴스화 시 전달하는 타입에 의해 수행된다.


메서드 반환형이 리스트일 경우 원소를 제네릭 타입으로 선언

리스트를 반환하는 메서드를 제네릭 메서드로 정의하는 경우 인터페이스의 메서드가 리스트를 반환하고 인터페이스를 구현하는 클래스마다 서로 다른 타입을 원소로 갖는 리스트를 반환하도록 하려면 리스트 제네릭 타입의 원소 타입을 파라미터로 전달하면 된다.

다형성을 이용하여 단순히 상속 관계나 인터페이스 구현 관계에 있는 클래스에서 상위 클래스나 인터페이스를 리스트 원소의 타입으로 선언할 수도 있지만 이 경우 제네릭이 제공하는 다양한 이점을 활용하지 못하게 된다.


코드

  • 제네릭 타입 선언
public interface MyService<T> {
  List<T> getListOfType();
}
  • 메서드 호출
    • 타입 파라미터 없이 사용 (원시 타입 사용)
    public class MyServiceImpl {
      @Override
      List getListOfType() {
        List list = new ArrayList<>();
        return list;
      }    
    }
      
    public class MyClient {
      void getListOfType() {
        List list = myServiceImpl.getListOfType();
      }    
    }
    
    • 타입 파라미터 명시
    public class MyServiceImpl<String> {
      @Override
      List<String> getListOfType() {
        List<String> list = new ArrayList<>();
        return list;
      }
    } 
        
    public class MyClient {
      void getListOfType() {
        List<String> list = myServiceImpl.getListOfType();
      }    
    }
    
  • 리스트 원소의 타입이 서로 캐스팅 가능한 경우 (다형성 사용)
public interface MyService<T extends SuperClass> {
  List<T> getListOfType();
}

public class MyServiceImplA<SubClassA> {
  @Override
  List<SubClassA> getListOfType() {
    List<SubClassA> list = new ArrayList<>();
    return list;
  }
}

public class MyServiceImplB<SubClassB> {
  @Override
  List<SubClassB> getListOfType() {
    List<SubClassB> list = new ArrayList<>();
    return list;
  }
}

public class MyClient {
  void getListOfType() {
    List<SubClassA> list = myServiceImplA.getListOfType();
    List<SubClassB> list = myServiceImplB.getListOfType();
  }
}
  • 리스트 원소의 타입이 서로 캐스팅 불가능한 경우 -> 와일드카드 사용


스프링 프레임워크에서 제네릭 타입 빈의 의존성 주입

스프링 프레임워크에서는 다음과 같이 런타임 시 컨테이너에서 특정 타입의 빈을 탐색할 수 있다.

@RequiredArgsConstructor
public class MyClient {
  @Autowire
  private Map<String, MyService> serviceByBeanName;
  
  @Autowire
  private List<MyService> services;
}


제네릭 타입인 ListMap 타입의 필드 선언 및 필드 의존성 주입 시 탐색하고자 하는 빈의 타입을 파라미터로 명시하면 된다. 이렇게 하면 해당 타입의 빈들이 ListMap 자료구조에 담기게 된다.

스프링 4.0 버전부터는 제네릭 타입 빈 또한 탐색 가능하도록 ResolveableType이 제공되며 다음과 같이 제네릭 타입의 클래스 또는 인터페이스 타입으로 등록된 빈도 의존성 주입할 수 있다. 제네릭 타입을 빈 등록하기 위해서는 제네릭 클래스 정의 시 @Component 어노테이션을 설정한다.

@Component
public class MyService<T> {
}

public class MyClient {
  @Autowired
  private List<MyService<Integer>> servicesContainingInteger;
  
  @Autowired
  private List<MyService<String>> servicesContainingString;
} 


상속 구조를 갖는 클래스를 제네릭 타입 파라미터로 갖는 제네릭 클래스 또한 빈 등록 및 탐색할 수 있다.

public interface MyService<T extends SuperClass> {
  ...
}

@Component
public class MyServiceImpl implements MyService<SubClassA> {
  ...
}

@Component
public class MyServiceImpl implements MyService<SubClassB> {
  ...
}

@RequiredArgsConstructor
public class MyClient {
  @Autowire
  private Map<String, MyService<SubClassA> serviceByBeanName;
  
  @Autowire
  private Map<String, MyService<SubClassB> serviceByBeanName;
  
  @Autowire
  private List<MyService<SubClassA> services;
  
  @Autowire
  private List<MyService<SubClassB> services;
}


그러나 위와 같은 경우 상위 클래스 타입을 제네릭 타입 파라미터로 갖는 클래스를 빈 등록 및 탐색할 수는 없다. 일반적으로 인터페이스 구현체나 하위 클래스를 빈 등록하는 경우 인터페이스 또는 상위 클래스 타입으로 빈 탐색 및 의존성 주입이 가능하지만 제네릭의 파라미터 타입의 경우 이러한 스프링 프레임워크의 동작이 동일하게 적용되지는 않는다.

@RequiredArgsConstructor
public class MyClient {
  // MyService<SuperClass> 타입 빈 등록이 되지 않는다.
  @Autowire
  private Map<String, MyService<SuperClass> serviceByBeanName;
  
  // MyService<SuperClass> 타입 빈 등록이 되지 않는다.
  @Autowire
  private List<MyService<SuperClass> services;
}


이를 위해서는 인터페이스 구현체 정의 시 파라미터 타입을 상위 클래스로 명시해야 빈 등록이 된다.

public interface MyService<T extends SuperClass> {
  ...
}

@Component
public class MyServiceImpl implements MyService<SuperClass> {
  ...
}

@RequiredArgsConstructor
public class MyClient {
  // MyService<SuperClass> 타입 빈 등록이 된다.
  @Autowire
  private Map<String, MyService<SuperClass> serviceByBeanName;
  
  // MyService<SuperClass> 타입 빈 등록이 된다.
  @Autowire
  private List<MyService<SuperClass> services;
}


코틀린의 제네릭

코틀린 언어에서 제네릭 타입은 자바와 크게 다르지 않다. 다음과 같이 제네릭 클래스에서 제네릭 타입의 멤버 변수를 선언할 수 있다.

class Box<T>(t: T) {
  var value = t
}


클래스의 인스턴스를 생성 시 타입 파라미터를 전달한다.

val box: Box<Int> = Box<Int>(1)


컴파일러가 생성자 인자로부터 타입 파라미터를 추론할 수 있는 경우 생략할 수 있다.

val box = Box(1)


제네릭 클래스의 생성자 호출 시 자바와 달리 대괄호를 작성하지 않아도 된다.

함수도 타입 파라미터를 갖도록 선언할 수 있다. 타입 파라미터는 함수명 앞에 위치시킨다.

fun <T> singletonList(item: T): List<T> {
    // ...
}

fun <T> T.basicToString(): String { // extension function
    // ...
}


함수 호출 시 함수 이름 바로 뒤에 타입을 지정한다. 타입 추론 시 타입 파라미터를 생략할 수 있다.

val l = singletonList<Int>(1)
val l = singletonList(1)


자바의 경우 타입 파라미터를 지정하지 않는 원시 타입을 사용할 수 있지만 코틀린에서는 불가능하며 반드시 타입 파라미터를 지정해야 한다.

자바와 다르게 코틀린에는 와일드카드 타입(?)이 없으며 대신 선언 사이트 변성(declaration-site variance)과 타입 프로젝션(type projection) 개념이 존재한다. 자바에서 제네릭 타입은 불공변성을 가지며 와일드카드 기능을 사용하여 제네릭 타입이 공변성 또는 반공변성을 갖도록 할 수 있다고 했다.


구체화 타입

코틀린도 자바와 마찬가지로 기본적으로는 컴파일 타임에 타입 소거가 발생하여 제네릭 타입 정보가 제거된다. 그러나 reified라는 키워드를 사용할 경우 해당 타입을 구체화 타입으로 만들 수 있다.

인라인 함수


참고

Comments