익숙하고도 편해지지 않는 주제 “제네릭” 이다. 제네릭을 평소 사용할 때 내가 어떻게 제네릭을 알게되고 배우게 되었는지 기억이 나지 않았다. 그래서 제네릭에 대한 견해?를 나에게 물어보는 사람이 있다면 단순히 제네릭을 이용한 코드와 그렇지 못한 코드를 보여주고 효용성?을 보여주곤 했다. 하지만 제네릭의 경우에는 그것보다 더 많은 의미를 내포하고 있다는 것을 조금은 알 수 있었다.
공부하기 전의 제네릭에 대한 필자의 견해를 보여주는 코드
오라클 튜토리얼에서는 좀 더 심오한 내용으로 제네릭을 바라보았다. 필자와 같이 재사용성에 초점을 맞추는 것은 맞지만 재사용을 할 수 있도록 맞춰주는 과정에 초점을 맞추고 있다. 그 내용에 대한 나의 견해 이다.
”재사용을 위해서는 유형에 대한 검사가 철저하게 이루어져야 한다. 왜냐하면 제네릭을 사용하는 코드에서 객체에 대한 검사가 철저해 진다면 제네릭을 사용하는 코드는 더 이상 객체 검사에 대한 걱정을 할 필요가 없다. 쉽게 실행 시켜도 이미 안정성이 높아진 상태기 때문이다. 이를 제네릭이 담당하게 된다.”
// 아래 코드는 안정성이 낮아 캐스팅이 필요하다.
// 이유는 컴파일 타임에 검사가 이루어지지 않아 실제 어떤 객체가 넘어 올지는 런타임에 알게된다.
List list = new ArrayList();
list.add("hello");
String s = (String) list.get(0);
... 다른 코드
// 하지만 아래의 코드는 제네릭을 사용하여 안정성을 높였다.
// 덕분에 캐스팅이 불필요해진다. 이는 코드를 재사용하는데 이점이 된다.
// 왜냐하면 위의 코드에서 캐스팅을 위해 필요했던 (String) 같은 것들이 제외되었기 때문이다.
List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0);
Java
복사
솔직히 위의 튜토리얼만 보면 적용에 영역으로 들어갈때 아리까리 하다. 다시 예제를 만들어보자.
//이 코드를 보면 List를 재사용하는 것을 볼 수 있다. 똑같은 코드지만 위의 코드에서 바뀐거라곤 Apple을 사용한 것 말곤 없다.
List appleLists = new ArrayList();
appleLists.add(new Apple(1));
Apple o = (Apple) appleLists.get(0);
.. 다른 코드
List<Apple> genericAppleLists = new ArrayList<>();
genericAppleLists.add(new Apple(1));
Apple g = genericAppleLists.get(0);
Java
복사
위의 코드에서 볼 수 있듯이 타입 캐스팅이 불필요 해지기 때문에 재사용성을 높일 수 있다. 이렇듯 제네릭을 사용하는 이유는 재사용성을 높이기 위한 것이다. 처음에 필자도 같은 코드를 보고 “이게 왜? 재사용성이 높다는 말이지?” 라는 생각을 하곤 했다. 하지만 위의 예제를 만드는 과정에서 알 수 있었다(아니 알고는 있었지만 인지하지 못했었다). 아래에 그 이유가 나온다.
class Apple{ // 이러한 코드를 사용하는 List를 만들어야한다고 가정해보자
int kg;
public Apple(int kg) {
this.kg = kg;
}
public int getKg() {
return kg;
}
}
...
public static void main(String[] args) {
List appleLists = new ArrayList();
appleLists.add(new Apple(1));
appleLists.map(Apple::getKg).forEach(System.out :: println); // 에러가 난다(에러가 발생하는 부분은 언더라인).
List<Apple> genericAppleLists = new ArrayList<>();
genericAppleLists.add(new Apple(1));
genericAppleLists.stream().map(Apple::getKg).forEach(System.out :: println);
}
Java
복사
왜? 오류가 발생하는 것일까? 그것은 위의 제네릭을 사용하지 않은 코드의 경우 반환은 Object이다. 하지만 아래 제네릭을 사용하는 코드의 Apple 타입을 알고 있다. 이것이 재네릭을 사용할 때 재사용성이 높아지는 이유이다. 이미 컴파일 타입에 타입검사가 이루어지기 때문에 런타입 도중에는 타입을 알고 있는 것이다. 그래서 Apple을 넣던, Banana가 생겨서 Banana를 넣던 모두 타입을 알고 있어 해당 객체에서 사용할 수 있는 메서드를 알 수 있는 것이다.
제네릭 이것을 먼저 알면 사용 따로 생각할 필요 없다?
필자의 경우도 제네릭을 어떻게 공부했는지는 모르지만, 다시 공부하면서 아리송한 부분이 참 많았다. 지금 공부하는 사람도 똑같을 것이다. 그래서 좀 거시적으로 보려고 노력했다. 그 순서는 다음과 같다.
1.
제네릭이 좋은 점 (아까 서론에서 이야기한 장점 같은 것이다).
2.
제네릭이 장점을 가질 수 있었던 근본적인 맥락
3.
뭐만 알면 되는 거야?(사실 이것만 알면 되는 것은 아니지만 근본적인 이해?를 돕는다.)
a.
공변 불공변
b.
타입 소거
4.
알면 좋은 거
a.
브리지 메서드
b.
프리미티브 타입을 못 사용한다?
c.
Raw Type을 피해라!
이 정도로 정리 할 수 있을 것 같다. 물론 부족한 점도 존재할 수 있다. 완벽한 정보를 제공하고 싶지만.. 그것이 부족하다면 이걸 통해 키워드를 공부할 수 있으면 좋겠다.
필자의 경우 이해를 하고 싶고 지식이 연결되는 것을 좋아한다. 그런데 대부분의 블로그(물론 나도 그럴 수 있지만)에서 정리한 글을 보면 “개념 ~~~, 주의~~~” 이렇게 적어 놓은 경우가 많아서 좀 이해하는데 애먹었다. 단순히 외우는 것은 의미가 없으니 말이다. 그래서 필자가 생각할 때 기준으로 저 순서를 알면 어느 정도 개념이 모든 것에 적용이 되는 것 같다(참고한 문서는 오라클 제네릭 튜토리얼).
일단 1과 2는 아까 설명했다. 다시한번 말하면 제네릭은 컴파일 타임에 강한 타입 검사를 통하여 런타임에 발생하는 오류를 사전에 예방할 수 있고 그로인하여 부가적으로 높은 재사용성을 누릴 수 있다. 근본적으로 타입 제약과 검사가 강하기 때문에 재사용이 가능하다는 것을 잘 기억해두자. 그럼 제네릭이 저러한 특징을 가지게 된 계기는 무엇일까? 필자의 경우 3번의 개념이 적용되어 저것이 가능하다고 생각한다. 더 나아가 3번만 알면 4번은 조금만 생각해도 정답을 유추할 수 있게 된다.
공변과 불공변
타입 소거 (erasure)
알면 좋은 거 하지만 위의 내용을 알면 쉽게 알 수 있는 것들
필자의 경우 모든 개념이 연결되는 것을 좋아한다고 했다. 그 이유는 핵심적인 내용만 알고 있다면 따로 내가 머리속에 저장하지 않아도 따로 그 내용을 유추할 수 있고 쉽게 이해할 수 있기 때문이다. 다음을 하나하나 살펴보며 위의 개념을 적용 시켜보자.
브리지 메서드
primitive Type 제네릭에서 못써요
Raw Type을 피하세요
배열에는 못써요. static도 못써요.
정리하자면 쉽게 제네릭은 불공변 즉, 변하지 않고 그 타입만 사용하며 타입 소거 방식을 적용한다. 하지만 이 내용이 전부는 아니다. 예제도 본 내용에 국한되지 않는다. 그래서 더 공부가 필요하다. 다음은 참고한 문서 및 더 보면 좋은 내용이다.