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

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

제네릭 타입(Generic Type)

제네릭 타입은 타입을 파라미터로 가지는 클래스, 또는 인터페이스를 의미한다. 이론편에서 봤던 예제를 다시 한번 보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class GenericStorage<T> {

private T storedData;

public T getStoredData() {
return storedData;
}

public void setStoredData(T storedData) {
this.storedData = storedData;
}
}

위 코드에서 보면 GenericStorage 클래스명 뒤에 <> 부호가 붙어있으며 이 안에 T가 선언되어 있는데, 이 T타입 파라미터라고 한다.

타입 파라미터의 이름은 변수명과 동일한 룰에 따라 작성할 수 있으나 일반적으로 대문자 알파벳 한 글자로 표현하는 것이 관례이다. 이렇게 제네릭 타입에 사용된 타입 파라미터는 제네릭 타입 내의 코드에서 사용할 수 있다.

그렇다면 제네릭 타입의 선언은 위와 같이 했는데, 사용할 때에는 어떻게 사용해야 하는 걸까?

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

@Test
public void setAndGetStoredDataWithMultipleTypes() {

final Integer data = 1;
// final String data = "this is data.";
// final Double data = 3.14;

GenericStorage<Integer> storage = new GenericStorage<>();
// GenericStorage<String> storage = new GenericStorage<>();
// GenericStorage<Double> storage = new GenericStorage<>();
storage.setStoredData(data);

Assert.assertEquals(data, storage.getStoredData());
}
}

위 코드 역시 이전 포스트에서 사용한 테스트 코드이다. 위 테스트 코드를 보면 제네릭 타입을 실제로 사용할 때에는 어떻게 사용하는지 알 수 있는데, 사실 여기서는 생략된 부분이 있다.

제네릭 타입 변수를 선언하고 인스턴스를 생성하는 코드인 GenericStorage<Integer> storage = new GenericStorage<>(); 에서 보면, 인스턴스를 생성할 때에 new GenericStorage<>(); 라는 코드로 인스턴스를 생성하고 있다. 원래는 제네릭 타입 변수를 선언할 때처럼 new GenericStorage<Integer>(); 와 같이 구체적 타입을 지정해야 하지만 자바 7부터 제네릭 타입의 인스턴스 생성 시 구체적 타입을 생략한 다이아몬드 연산자(diamond operator)를 지원한다.

멀티 타입 파라미터(Multi Type Parameter)

그런데 이 GenericStorage 클래스를 사용하다보니 또 다른 요구 사항이 발생했다, 라고 가정해보자. 이 하나의 클래스에 1종류의 데이터만 담고 있었는데, 또 다른 종류의 데이터도 함께 담고 싶다는 것이었다. 그럼 어떻게 해야 할까? 더군다나 지금은 제네릭 타입으로 바꾼 상태인데?

다행히도 제네릭의 타입 파라미터는 여러 개를 선언할 수 있으며, 이를 멀티 타입 파라미터라고 표현할 수 있다. 아래와 같이 변경해보자.

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

private T storedData;

private U secondaryData;

public T getStoredData() {
return storedData;
}

public void setStoredData(T storedData) {
this.storedData = storedData;
}

public U getSecondaryData() {
return secondaryData;
}

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

이렇게 멀티 타입 파라미터를 사용한 경우, 테스트 코드는 아래와 같이 수정할 수 있다.

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

final Integer data = 1;
final String secondaryData = "this is data.";

GenericStorage<Integer, String> storage = new GenericStorage<>();
storage.setStoredData(data);
storage.setSecondaryData(secondaryData);

Assert.assertEquals(data, storage.getStoredData());
Assert.assertEquals(secondaryData, storage.getSecondaryData());
}

자, 이제 위의 요구 사항이었던 또 다른 종류의 데이터를 담을 수 있는 GenericStorage 클래스가 되었다.

제네릭 메서드(Generic Method)

반드시 클래스 또는 인터페이스 선언에서만 제네릭을 선언할 수 있는 것은 아니다. 메서드에도 제네릭을 적용하여 제네릭 메서드를 사용할 수 있다. 아래 예제를 보자.

1
2
3
4
5
public <E> List<E> wrapList(E e) {
List<E> list = new ArrayList<>();
list.add(e);
return list;
}

위와 같이 파라미터 타입과 리턴 타입에 타입 파라미터를 가지는 메서드를 제네릭 메서드라고 한다.

리턴 타입 앞에 <>를 추가하고 타입 파라미터를 선언하며, 리턴 타입과 파라미터 타입에 선언한 타입 파라미터를 사용할 수 있다. 또한 제네릭 타입과 마찬가지로 멀티 타입 파라미터도 가능하다. 이렇게 제네릭 메서드에 선언한 타입 파라미터는 해당 메서드 내에서만 유효하다.

제네릭 메서드를 사용하는 테스트 코드는 아래와 같다.

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

final String data = "this is data.";

GenericStorage<Integer, Double> storage = new GenericStorage<>();
final List<String> stringList = storage.<String>wrapList(data);
// final List<String> stringList = storage.wrapList(data);

Assert.assertEquals(data, stringList.get(0));
}

위 코드에서 실제로 제네릭 메서드를 사용한 부분은 final List<String> stringList = storage.<String>wrapList(data); 인데, 여기서 제네릭 메서드를 호출할 때 구체적 타입을 명시함을 알 수 있다. 그러나 구체적 타입을 생략하고 final List<String> stringList = storage.wrapList(data); 와 같은 방법으로 호출하더라도 파라미터의 타입을 통해 구체적 타입을 추정한다.

한정적 타입 파라미터(Bounded Type Parameter)

필요에 따라 타입 파라미터의 범위를 한정해야 하는 경우가 있다. 그럴 때에는 한정적 타입 파라미터를 선언할 수 있다.

한정적 타입 파라미터를 사용하는 예제를 위해, Beverage 클래스와 그 하위 클래스를 보관할 수 있는 BeverageStorage 클래스를 만들어달라는 요구 사항이 생겼다고 가정해보자. 먼저 Beverage 클래스와 그 하위 클래스들부터 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 실제 코드는 각각 클래스 파일을 나눠서 작성했다.
public class Beverage {

public void drink() {
System.out.println("this is beverage.");
}
}

public class Coke extends Beverage {

@Override
public void drink() {
System.out.println("this is coke.");
}
}

public class Coffee extends Beverage {

@Override
public void drink() {
System.out.println("this is coffee.");
}
}

public class Beer extends Beverage {

@Override
public void drink() {
System.out.println("this is beer.");
}
}

자, 다음은 BeverageStorage 클래스를 만들어보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class BeverageStorage<T extends Beverage> {

private T beverage;

public T getBeverage() {
return beverage;
}

public void setBeverage(T beverage) {
this.beverage = beverage;
}
}

여기서 핵심은 한정적 타입 파라미터를 사용한 <T extends Beverage> 부분이다. 타입 파라미터를 선언할 때 extends 키워드를 사용하여 상위 타입을 지정하면 해당 타입 파라미터의 구체적 타입은 지정한 상위 타입 및 상위 타입을 상속(또는 구현)하는 하위 타입으로만 한정할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class BeverageStorageTest {

@Test
public void setAndGetBeverage() {

final Beverage beverage = new Beverage();

final BeverageStorage<Beverage> beverageStorage = new BeverageStorage<>();
beverageStorage.setBeverage(beverage);

beverageStorage.getBeverage().drink();
Assert.assertEquals(beverage, beverageStorage.getBeverage());
}

@Test
public void setAndGetCoke() {

final Coke coke = new Coke();

final BeverageStorage<Coke> cokeStorage = new BeverageStorage<>();
cokeStorage.setBeverage(coke);

cokeStorage.getBeverage().drink();
Assert.assertEquals(coke, cokeStorage.getBeverage());
}

@Test
public void setAndGetCoffee() {

final Coffee coffee = new Coffee();

final BeverageStorage<Coffee> coffeeStorage = new BeverageStorage<>();
coffeeStorage.setBeverage(coffee);

coffeeStorage.getBeverage().drink();
Assert.assertEquals(coffee, coffeeStorage.getBeverage());
}

@Test
public void setAndGetBeer() {

final Beer beer = new Beer();

final BeverageStorage<Beer> beerStorage = new BeverageStorage<>();
beerStorage.setBeverage(beer);

beerStorage.getBeverage().drink();
Assert.assertEquals(beer, beerStorage.getBeverage());
}
}

위 테스트 코드와 같이 Beverage 클래스 및 그 하위 클래스만을 타입 파라미터의 구체적 타입으로 지정할 수 있다. 만약 다른 클래스를 구체적 타입으로 지정한다면 컴파일 오류가 발생한다.

자바의 제네릭에 대해 다루는 내용이 길어져 다음 포스트로 내용을 이어갑니다.
포스트에서 사용한 예제 코드는 여기에서도 확인할 수 있습니다.