자바의 제네릭에 대해서 - 이론편

제네릭 프로그래밍(Generic Programming)?

자바의 제네릭에 대해서 알아보기에 앞서, 제네릭 프로그래밍이 무엇인지부터 알아보자.

위키피디아에서 아래와 같이 설명하고 있다.

“데이터 형식에 의존하지 않고, 하나의 값이 여러 다른 데이터 타입들을 가질 수 있는 기술에 중점을 두어 재사용성을 높일 수 있는 프로그래밍 방식”

쉽게 표현해보자면, 어떤 알고리즘을 수행하는 코드가 있다고 했을 때, 특정 데이터 타입에 종속적이지 않고 코드가 범용성과 재사용성을 가질 수 있게 하는 프로그래밍 방식이라고 할 수 있겠다.

왜?

그렇다면 자바는 왜 제네릭이 필요했을까?

그 필요성을 알기 위해 예를 들고자 아래와 같은 코드가 있다고 가정해보자.

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

private Object storedData;

public Object getStoredData() {
return storedData;
}

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

간단한 클래스인데, 특정 데이터를 담아두는 용도로 사용할 Storage 클래스이다. 다만 들어갈 데이터의 타입이 고정적이지 않고 유동적일 수 있다는 요구 사항이 있어서 필드 타입을 Object 로 지정했다. Object 타입은 모든 클래스의 최상위 타입이기 때문에 어떤 타입의 객체도 할당할 수 있기 때문이다.

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

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

@Test
public void setAndGetStoredData() {

final String data = "this is data.";

Storage storage = new Storage();
storage.setStoredData(data);

String storedData = storage.getStoredData();
Assert.assertEquals(data, storedData);
}
}

전혀 문제없어 보이는 코드 같은가? 하지만 사실 이 테스트 코드는 실행되지 않고 컴파일 에러가 발생한다. 문제가 되는 부분은 바로 String storedData = storage.getStoredData(); . 왜냐하면 Storage 클래스의 getStoredData 메서드는 String 타입을 반환하는 게 아니라 Object 타입을 반환하도록 만들어져 있기 때문이다.

그럼 Object 타입을 String 타입으로 캐스팅하는 코드로 바꿔보자.

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

@Test
public void setAndGetStoredData() {

final String data = "this is data.";

Storage storage = new Storage();
storage.setStoredData(data);

// 차이점이 잘 보이지 않을 수 있으나 여기에 String 타입으로 캐스팅하는 코드가 추가됐다.
String storedData = (String) storage.getStoredData();
Assert.assertEquals(data, storedData);
}
}

이제 테스트 코드를 실행해보면 정상적으로 실행되고 테스트는 무사히 통과한다. 이대로 만만세일까? 아니, 그렇지 않다.

Storage 클래스의 storedData 필드는 들어갈 수 있는 타입이 유동적이기 때문에 Object 타입으로 지정했다. 유동적으로 어떤 타입이든 storedData 필드에 넣을 수 있는 것은 원하던 바다. 다만, storedData 필드에 Object 타입이 아닌 다른 타입의 객체를 할당한다면 결국 묵시적 캐스팅이 발생하며, storedData 필드를 가져와서 사용할 때에는 사용하려는 타입에 대한 명시적 캐스팅이 필요하다. Object 타입 그대로의 객체가 필요한 것이 아니라면 말이다.

위 테스트 코드는 String 타입 하나에 대한 예제 코드인데, 만약 여러 타입이 들어갈 수 있는 경우에 대한 테스트 코드를 작성하면 어떻게 될까? 한 번 작성해보도록 하자.

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
@Test
public void setAndGetStoredDataWithMultipleTypes() {

// 타입과 값을 String이나 Double로 바꿔서 테스트해도 문제없이 성공한다.
final Integer data = 1;
// final String data = "this is data."
// final Double data = 3.14;

Storage storage = new Storage();
storage.setStoredData(data);

if(storage.getStoredData() instanceof String) {

final String storedData = (String) storage.getStoredData();
Assert.assertEquals(data, storedData);

} else if(storage.getStoredData() instanceof Integer) {

final Integer storedData = (Integer) storage.getStoredData();
Assert.assertEquals(data, storedData);

} else if(storage.getStoredData() instanceof Double) {

final Double storedData = (Double) storage.getStoredData();
Assert.assertEquals(data, storedData);

} else {
Assert.fail("unsupported type.");
}
}

일단 가능한 타입이 String, Integer, Double 3가지만 가정하더라도 이런 형태의 코드가 만들어진다. 가능한 타입에 대한 분기와 해당 타입에 대한 캐스팅하는 코드가 반복되고 있는 모습을 볼 수 있다. 코드가 데이터 타입에 종속적이라면 이와 같이 중복 코드가 발생할 수 있다.

즉, 자바가 제네릭 프로그래밍 방식을 도입하기 위해서(=코드가 타입이 종속적이지 않고 범용성과 재사용성을 확보할 수 있게 하기 위해서) Object 타입 또는 특정 상위 타입을 사용하는 것은 다음과 같은 한계들이 있다.

  1. 필요한 타입으로 캐스팅하는 코드가 필요하고,
  2. 잘못된 타입으로 캐스팅하더라도 컴파일 단계에서 체킹 되지 않기 때문에 런타임 에러가 발생할 위험이 있으며,
  3. 잦은 타입 캐스팅은 성능에 영향을 끼친다.

그래서?

그렇다면 자바의 제네릭은 위와 같은 한계들을 해결했을까?

확인해보기 위해 자바 5부터 추가된 제네릭 타입을 한 번 사용해보자.

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;
}
}

코드를 보면 낯선 표현들이 있을 것이다. 일단 클래스 이름 뒤에 붙은 <T>. 이것은 해당 클래스에서 사용할 제네릭 타입을 선언한 것이다. 제네릭 타입을 선언할 때 사용한 <> 는 다이아몬드 연산자(diamond operator)이며, 대문자 T 는 타입 파라미터(type parameter)로, 타입 파라미터는 클라이언트 코드에서 GenericStorage 클래스를 사용할 때 할당하는 타입으로 바뀌는 플레이스홀더(placeholder) 같은 역할을 한다.

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());
}
}

테스트 코드는 정상적으로 실행된다! 그러면 실제로 위에서 얘기했던 한계들이 해결되었나 확인해보자.

  1. 필요한 타입으로 캐스팅하는 코드가 필요하지 않고,
  2. 컴파일 단계에서 타입 체킹이 가능하며,
  3. 타입 캐스팅을 하지 않기 때문에 성능상으로도 이점을 얻을 수 있다.

그렇다, 자바의 제네릭은 위에서 얘기했던 한계들을 해결했다. 이것으로 일단 자바의 제네릭에 대한 이론적인 설명은 이쯤에서 마무리하고 다음 포스트에서 자바의 제네릭을 어떻게 사용할 수 있는지 본격적으로 알아보도록 하자.

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