자바의 제네릭에 대해서 - 실전편(2)

이전 포스트에 이어서 자바의 제네릭을 실제로 활용하는 내용에 대해서 정리해보자.

와일드카드 타입(Wildcard type)

이전 포스트까지 읽었다면 제네릭을 선언하여 코드 내에서 사용하고, 클라이언트 코드에서 구체적 타입을 지정하는 방법들은 알게 되었다. 그런데 또 하나, 만약 제네릭을 메서드의 파라미터로 주고 받는 경우에는 어떻게 해야할까? 또 하나의 예제를 만들어보자.

1
2
3
4
5
6
7
8
9
public class ListUtil {

public static <T> void printElements(List<T> list) {

for(int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}
}

위 예제는 List 인터페이스 구현체 객체를 파라미터로 받아서 요소를 출력하는 간단한 정적 제네릭 메서드를 가진 ListUtil 클래스다. 자, 이 간단한 메서드의 기능을 테스트해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ListUtilTest {

@Test
public void printElements() {

final List<Number> list = new ArrayList<>();
list.add(1);
list.add(2);
list.add(3);

ListUtil.<Number>printElements(list);
}
}

ListUtil 클래스의 제네릭 메서드에 구체적 타입으로 Number를 지정했고, 파라미터로 전달한 객체 역시 List<Number> 타입이기 때문에 위 테스트 코드는 문제없이 통과한다. 그렇다면 조금 바꿔보도록 하자. final List<Number> list = new ArrayList<>(); 부분에서 List의 타입 파라미터를 Number가 아닌 Integer로 바꾼다면 어떨까?

1
2
3
4
5
6
7
8
9
10
11
@Test
public void printElements() {

final List<Integer> list = new ArrayList<>();

list.add(1);
list.add(2);
list.add(3);

ListUtil.<Number>printElements(list);
}

위 코드 역시 문제없이 실행되어야 하지 않을까? 그러나 안타깝게도 위 코드는 컴파일 오류가 발생한다. 왜일까?

이는 자바의 제네릭 특성 상, 파라미터화 타입(parameterized type)List<Number>List<Integer> 간의 관계는 구체적 타입인 Number와 Integer 간의 관계와는 무관하기 때문이다. 따라서 제네릭 메서드에 구체적 타입으로 Number를 지정했다면 파라미터로 가능한 것은 List<Integer>가 아닌 List<Number>이다.

그런데 만약, 해당 제네릭 메서드에 파라미터로 지정할 List가 Number도 있고, Integer도 있고, Double도 있다면 어떻게 해야 할까? ListUtil 클래스의 printElements 메서드를 아래와 같이 수정해보자.

1
2
3
4
5
6
public static void printElements(List<?> list) {

for(int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}

어느 부분이 바뀌었는지 보이는가? 원래 제네릭 메서드였지만, 이제는 평범한 정적 메서드처럼 보인다. 하나 특이한 것이라면, 메서드의 파라미터가 List<?>로 지정되어 있다는 점이다.

이쯤에서 와일드카드 타입(Wildcard type)에 대해서 얘기해보자. 위처럼 제네릭 타입 또는 제네릭 메서드가 메서드의 파라미터로 구체적 타입을 받아들일 때, 모든 클래스와 인터페이스 타입을 허용하는 특별한 타입인 와일드카드 타입을 사용할 수 있다.

그렇다면 저렇게 와일드카드 타입으로 바꾼 메서드는 List<Integer>를 파라미터로 받아들일 수 있을까?

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void printElements() {

final List<Integer> list = new ArrayList<>();

list.add(1);
list.add(2);
list.add(3);

// 보통의 정적 메서드가 되었기 때문에 구체적 타입을 지정하던 부분은 삭제
ListUtil.printElements(list);
}

테스트 코드를 위처럼 수정하고 실행해보면 정상적으로 동작함을 알 수 있다.

한정적 와일드카드 타입(Bounded wildcard type)

와일드카드 타입을 사용해서 유연성을 확보한 것은 좋지만, 한 가지 꺼림칙한 점이 있다. 유연해도 너무 유연하다. 예를 들어 위 예제에서 누군가의 실수로 파라미터로 List<Integer> 대신 List<Object>을 넣었다고 가정해보자.

1
2
3
4
5
6
7
8
9
10
11
@Test
public void printElements() {

final List<Object> list = new ArrayList<>();

list.add(1);
list.add(2);
list.add(3);

ListUtil.printElements(list);
}

그래도 정상적으로 컴파일 되고 실행 결과도 동일하다. 그러나 의도한 바는 아니다. 작성자의 의도가 만약 *”Number 클래스와 그 하위 클래스들의 List 구현체만을 파라미터로 받고 싶다”* 라면 위 테스트 코드는 실패한 것이다.

그런데 와일드카드 타입은 위에서 설명했던 것처럼 모든 클래스와 인터페이스 타입을 허용한다. 그렇다면 와일드카드 타입의 허용 범위를 제한할 방법은 없을까? 다행히도, 방법이 있다. 한정적 와일드카드 타입(Bounded wildcard type)을 사용하면 된다.

1
2
3
4
5
6
public static void printNumberExtendElements(List<? extends Number> list) {

for(int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}

위 코드는 ListUtil 클래스에 추가한 printNumberExtendElements 메서드인데, 어떤 차이점이 있는지 알아챘는가? 와일드카드 ? 뒤에 extends 키워드가 붙어있고 그 뒤에는 Number 클래스가 지정되어 있다. 이것이 바로 한정적 와일드카드 타입을 적용했을 때의 코드이며, printNumberExtendElements 메서드에 파라미터로 넘어오는 List의 타입 파라미터 구체적 타입을 Number 또는 Number의 하위 클래스로 제한한 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void printNumberExtendElements() {

final List<Object> list = new ArrayList<>();

list.add(1L);
list.add(2L);
list.add(3L);

// 컴파일 오류 발생
ListUtil.printNumberExtendElements(list);
}

한정적 와일드카드 타입을 적용한 printNumberExtendElements 메서드에 List<Object>를 사용한 테스트 코드를 실행하려고 하면 컴파일 오류가 발생한다. 이렇게 와일드카드 타입에 허용되는 구체적 타입을 제한할 수 있다.

한정적 와일드카드 타입에는 2가지의 키워드를 사용할 수 있는데, extendssuper다. 이 키워드들의 차이점은 extends 키워드를 사용하면 상향 한정적 와일드카드 타입(Upper bounded wildcard type), super 키워드를 사용하면 하향 한정적 와일드카드 타입(Lower bounded wildcard type)이라는 것이다.

지금 printNumberExtendElements 메서드는 List 인터페이스의 구현체를 파라미터로 받을 때 타입 파라미터를 와일드카드 타입으로 받되 Number 또는 Number의 하위 클래스만을 허용하도록 되어있다. 그런데 만약, Integer 또는 Integer의 상위 클래스만을 허용하는 새로운 메서드를 추가하고 싶다면?

아래 메서드를 ListUtil에 추가해보자.

1
2
3
4
5
6
public static void printIntegerSuperElements(List<? super Integer> list) {

for(int i = 0; i < list.size(); i++) {
System.out.println(list.get(i));
}
}

차이점은 한정적 와일드카드 타입을 사용한 부분을 extends가 아닌 super로, 와일드카드 타입에 허용되는 구체적 타입이 Integer이라는 것뿐이다.

테스트 코드를 작성해서 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void printIntegerSuperElements() {

final List<Long> list = new ArrayList<>();

list.add(1L);
list.add(2L);
list.add(3L);

// 컴파일 오류 발생
ListUtil.printIntegerSuperElements(list);
}

위 테스트 코드는 역시 컴파일 오류가 발생한다. 왜냐하면 한정적 와일드카드로 Integer 또는 Integer의 상위 클래스만을 받도록 제한했기 때문이다. Long 타입은 printIntegerSuperElements 메서드에 지정된 한정적 와일드카드 타입의 허용 범위에 포함되지 않는다.

제네릭 타입의 상속과 구현

제네릭 타입 역시 상속 및 구현이 가능하다. 먼저 제네릭 클래스의 상속부터 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ParentStorage<T, U> {

private T primaryData;

private U secondaryData;

public T getPrimaryData() {
return primaryData;
}

public void setPrimaryData(T primaryData) {
this.primaryData = primaryData;
}

public U getSecondaryData() {
return secondaryData;
}

public void setSecondaryData(U secondaryData) {
this.secondaryData = secondaryData;
}
}

위의 ParentStorage 클래스는 간단한 클래스로 2종류의 데이터를 저장하는 기능이 있는데, 저장하는 데이터의 타입을 지정하기 위해 2개의 타입 파라미터를 받고 있다. 그런데 ParentStorage의 기능을 그대로 사용하면서 3번째 데이터를 저장하는 ChildStorage 클래스를 구현하고 싶다면 상속을 이용하는 것이 좋지 않을까?

1
2
3
4
5
6
7
8
9
10
11
12
public class ChildStorage<T, U, V> extends ParentStorage<T, U> {

private V tertiaryData;

public V getTertiaryData() {
return tertiaryData;
}

public void setTertiaryData(V tertiaryData) {
this.tertiaryData = tertiaryData;
}
}

위의 ChildStorage 클래스는 ParentStorage 클래스를 상속하면서 총 3개의 타입 파라미터가 선언되어 있다. 그 중 2개의 타입 파라미터는 ParentStorage 클래스의 타입 파라미터로 넘기고 있으며, 나머지 1개의 타입 파라미터는 3번째 데이터의 타입으로 지정했다.

자, 정상적으로 동작하는지 테스트 코드를 작성해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ChildStorageTest {

@Test
public void createChildStorage() {

final String primaryData = "this is primary data.";
final Integer secondaryData = 10;
final Double tertiaryData = 5.5;

final ChildStorage<String, Integer, Double> childStorage = new ChildStorage<>();

childStorage.setPrimaryData(primaryData);
childStorage.setSecondaryData(secondaryData);
childStorage.setTertiaryData(tertiaryData);

Assert.assertNotNull(childStorage);
Assert.assertEquals(primaryData, childStorage.getPrimaryData());
Assert.assertEquals(secondaryData, childStorage.getSecondaryData());
Assert.assertEquals(tertiaryData, childStorage.getTertiaryData());
}
}

테스트 코드를 실행해보면 기대한 결과대로 동작한다. 그럼 이제 제네릭 인터페이스의 구현을 보자.

1
2
3
4
5
6
public interface StorageType<T> {

void set(T data);

T get();
}

StorageType 인터페이스는 우리가 몇 번이나 예제로 써먹었던 Storage 클래스들의 기본적인 기능을 공통화한 제네릭 인터페이스다. 이것을 구현하는 클래스를 한 번 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class StorageImpl<T> implements StorageType<T> {

private T data;

@Override
public void set(T data) {
this.data = data;
}

@Override
public T get() {
return data;
}
}

실제로 사용하는 예제를 위해 테스트 코드를 작성해보자. 아래와 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class StorageImplTest {

@Test
public void setAndGet() {

final String data = "this is data.";

final StorageType<String> storage = new StorageImpl<>();

storage.set(data);

Assert.assertNotNull(storage);
Assert.assertEquals(data, storage.get());
}
}

자, 이렇게 제네릭 인터페이스를 선언하고 그것을 구현하는 클래스의 예제를 만들어보았다. 위 테스트 코드를 실행하면? 기대한 바대로 테스트는 성공한다.

이것으로 자바의 제네릭에 대해서 실전편을 마무리하자.

포스트에서 사용한 예제 코드는 여기에서도 확인할 수 있습니다.