JAVA에서 제네릭이란?
- 파라미터 타입이나 리턴 타입에 대한 정의를 외부로 미룬다
- 타입에 대해 유연성과 안정성을 확보한다. 즉, 형변환의 번거로움을 줄여준다.
- 런타임 환경에 아무런 영향이 없는 컴파일 시점의 전처리 기술이다
타입을 유연하게 처리하며, 런타임에 발생할 수 있는 타입에러를 컴파일 단계에 검출한다.
제네릭 (Generics) 이란
자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.
자바에서 배열과 함께 자주 쓰이는 자료형인 리스트(List)는 다음과 같이 클래스 선언 문법에 꺾쇠 괄호 <> 로 되어있는 코드 형태를 한번 쯤은 봤을 것이다.
1
|
ArrayList<String> list = new ArrayList<>();
|
cs |
저 꺾쇠 괄호가 바로 제네릭이다. 괄호 안에는 타입명을 기재한다. 그러면 저 리스트 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.
아래 그림과 같이 배열과 리스트의 선언문 형태를 비교해보면 이해하기 쉬울 것이다. 선언하는 키워드나 문법 순서가 다를뿐, 결국 자료형명을 선언하고 자료형의 타입을 지정한다는 점은 같다고 볼 수 있다.
이처럼 제네릭은 배열의 타입을 지정하듯이 리스트 자료형 같은 컬렉션 클래스나 메소드에서 사용할 내부 데이터 타입(type)을 파라미터(parameter) 주듯이 외부에서 지정하는 이른바 타입을 변수화 한 기능이라고 이해하면 된다.
! 우리가 변수를 선언할때 변수의 타입을 지정해주듯이, 제네릭은 객체(Object)에 타입을 지정해주는 것이라고 보면 된다.
제네릭의 특징 및 사용법
- 클래스 혹은 메소드에 선언할 수 있다.
- 동시에 여러 타입을 선언할 수 있다.
- 와일드 카드를 이용하여 타입에 대하여 유연한 처리를 가능케 한다.
- 제네릭 선언 및 정의시에 타입의 상속관계를 지정할 수 있다.
제네릭 타입 매개변수
위에서 보다시피, 제네릭은 <> 꺾쇠 괄호 키워드를 사용하는데 이를 다이아몬드 연산자라고 한다. 그리고 이 꺾쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있다. 이것을 마치 메소드가 매개변수를 받아 사용하는 것과 비슷하여 제네릭의 타입 매개변수(parameter) / 타입 변수 라고 부른다.
타입 파라미터 정의
이 타입 매개변수는 제네릭을 이용한 클래스나 메소드를 설계할 때 사용된다.
예를들어 다음 코드는 제네릭을 사용하여 클래스를 정의한 코드이다. 클래스명 옆에 <T> 기호로 제네릭을 붙여준 걸 볼 수 있다. 그리고 클래스 내부에서 식별자 기호 T 를 클래스 필드와, 메소드의 매개변수의 타입으로 지정되어 있다.
1
2
3
4
5
6
7
|
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
|
cs |
제네릭 클래스를 만들었으면 이를 인스턴스화 해보자. 마치 파라미터를 지정해서 보내는 것 처럼 생성 코드에서 꺾쇠 괄호 안에 지정해주고 싶은 타입명을 할당해주면, 제네릭 클래스 선언문 부분으로 가서 타입 파라미터 T 가 지정된 타입으로 모두 변환되어 클래스의 타입이 지정되게 되는 것이다.
1
2
3
4
5
6
7
8
9
10
11
|
// 제네릭 타입 매개변수에 정수 타입을 할당
FruitBox<Integer> intBox = new FruitBox<>();
// 제네릭 타입 매개변수에 실수 타입을 할당
FruitBox<Double> intBox = new FruitBox<>();
// 제네릭 타입 매개변수에 문자열 타입을 할당
FruitBox<String> intBox = new FruitBox<>();
// 클래스도 넣어줄 수 있다. (Apple 클래스가 있다고 가정)
FruitBox<Apple> intBox = new FruitBox<Apple>();
|
cs |
이를 그림으로 표현해보면, 다음과 같이 제네릭 타입 전파가 행해진다고 보면 된다. <T> 부분에서 실행부에서 타입을 받아와 내부에서 T 타입으로 지정한 멤버들에게 전파하여 타입이 구체적으로 설정 되는 것이다. 이를 전문 용어로 구체화(Specialization) 라고 한다.
타입 파라미터 생략
제네릭 객체를 사용하는 문법 형태를 보면 양쪽 두 군데에 꺾쇠 괄호 제네릭 타입을 지정함을 볼 수 있다. 하지만 맨 앞에서 클래스명과 함께 타입을 지정해 주었는데 굳이 생성자까지 제네릭을 지정해 줄 필요가 없다. (중복)
따라서 jdk 1.7 버전 이후부터, new 생성자 부분의 제네릭 타입을 생략할 수 있게 되었다. 제네릭 나름대로 타입 추론을 해서 생략 된 곳을 넣어주기 때문에 문제가 없는 것이다.
FruitBox<Apple> intBox = new FruitBox<Apple>();
// 다음과 같이 new 생성자 부분의 제네릭의 타입 매개변수는 생략할 수 있다.
FruitBox<Apple> intBox = new FruitBox<>();
타입 파라미터 할당 가능 타입
제네릭에서 할당 받을 수 있는 타입은 Reference 타입 뿐이다. 즉, int형 이나 double형 같은 자바 원시 타입(Primitive Type)을 제네릭 타입 파라미터로 넘길 수 없다는 말이다.
// 기본 타입 int는 사용 불가 !!!
List<int> intList = new List<>();
// Wrapper 클래스로 넘겨주어야 한다. (내부에서 자동으로 언박싱되어 원시 타입으로 이용됨)
List<Integer> integerList = new List<>();
또한 제네릭 타입 파라미터에 클래스가 타입으로 온다는 것은, 클래스끼리 상속을 통해 관계를 맺는 객체 지향 프로그래밍의 다형성 원리가 그대로 적용이 된다는 소리이다.
아래 예제 코드를 보면 타입 파라미터로 <Fruit> 로 지정했지만 업캐스팅을 통해 그 자식 객체도 할당이 됨을 볼 수 있다.
class Fruit { }
class Apple extends Fruit { }
class Banana extends Fruit { }
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
public class Main {
public static void main(String[] args) {
FruitBox<Fruit> box = new FruitBox<>();
// 제네릭 타입은 다형성 원리가 그대로 적용된다.
box.add(new Fruit());
box.add(new Apple());
box.add(new Banana());
}
}
복수 타입 파라미터
제네릭은 반드시 한개만 사용하라는 법은 없다. 만일 타입 지정이 여러개가 필요할 경우 2개, 3개 얼마든지 만들 수 있다.
제네릭 타입의 구분은 꺽쇠 괄호 안에서 쉽표(,)로 하며 <T, U> 와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있다. 그리고 당연히 클래스 초기화할 때 제네릭 타입을 두개를 넘겨주어야 한다.
import java.util.ArrayList;
import java.util.List;
class Apple {}
class Banana {}
class FruitBox<T, U> {
List<T> apples = new ArrayList<>();
List<U> bananas = new ArrayList<>();
public void add(T apple, U banana) {
apples.add(apple);
bananas.add(banana);
}
}
public class Main {
public static void main(String[] args) {
// 복수 제네릭 타입
FruitBox<Apple, Banana> box = new FruitBox<>();
box.add(new Apple(), new Banana());
box.add(new Apple(), new Banana());
}
}
중첩 타입 파라미터
제네릭 객체를 제네릭 타입 파라미터로 받는 형식도 표현할 수 있다.
ArrayList 자체도 하나의 타입으로써 제네릭 타입 파라미터가 될수 있기 때문에 이렇게 중첩 형식으로 사용할 수 있는 것이다.
public static void main(String[] args) {
// LinkedList<String>을 원소로서 저장하는 ArrayList
ArrayList<LinkedList<String>> list = new ArrayList<LinkedList<String>>();
LinkedList<String> node1 = new LinkedList<>();
node1.add("aa");
node1.add("bb");
LinkedList<String> node2 = new LinkedList<>();
node2.add("11");
node2.add("22");
list.add(node1);
list.add(node2);
System.out.println(list);
}
타입 파라미터 기호 네이밍
지금까지 제네릭 기호를 <T> 와 같이 써서 표현했지만 사실 식별자 기호는 문법적으로 정해진 것이 없다.
다만 우리가 for문을 이용할 때 루프 변수를 i 로 지정해서 사용하듯이, 제네릭의 표현 변수를 T 로 표현한다고 보면 된다. 만일 두번째, 세번째 제네릭이 필요하다고 보면 for문의 j나 k 같이 S, U 로 이어나간다.
명명하고 싶은대로 아무 단어나 넣어도 문제는 없지만, 대중적으로 통하는 통상적인 네이밍이 있으면 개발이 용이해 지기 때문에 아래 표와 같은 암묵적인 규칙(convention)이 존재한다. 예를들어 예제에서 사용된 T 를 타입 변수(type variable)라고 하며, 임의의 참조형 타입을 의미한다.
타입 | 설명 |
<T> | 타입(Type) |
<E> | 요소(Element), 예를 들어 List<E> |
<K> | 키(Key), 예를 들어 Map<K, V> |
<V> | 리턴 값 또는 매핑된 값(Variable) |
<N> | 숫자(Number) |
<S, U, V> | 2번째, 3번째, 4번째에 선언된 타입 |
제네릭 사용 이유와 이점
1. 컴파일 타임에 타입 검사를 통해 예외 방지
자바에서 제네릭(Generic)은 자바 1.5에 추가된 스펙이다. 그래서 JDK 1.5 이전에서는 여러 타입을 다루기 위해 인수나 반환값으로 Object 타입을 사용했었다. 하지만 Object로 타입을 선언할 경우 반환된 Object 객체를 다시 원하는 타입으로 일일히 타입 변환을 해야 하며, 런타임 에러가 발생할 가능성도 존재하게 된다.
아래 예제에선 Object 타입으로 선언한 배열에 Apple 과 Banana 객체 타입을 저장하고 이를 다시 가져오는 예제이다.
class Apple {}
class Banana {}
class FruitBox {
// 모든 클래스 타입을 받기 위해 최고 조상인 Object 타입으로 설정
private Object[] fruit;
public FruitBox(Object[] fruit) {
this.fruit = fruit;
}
public Object getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox box = new FruitBox(arr);
Apple apple = (Apple) box.getFruit(0);
Banana banana = (Banana) box.getFruit(1);
}
그런데 실행해보면 위와 같이 ClassCastException 런타임 에러가 발생하게 된다. 객체를 가져올때 형변환도 잘 해주어 문제가 없는 것 같은데 무엇이 문제일까?
원인은 간단하다. Apple 객체 타입의 배열을 FruitBox에 넣었는데, 개발자가 착각하고 Banana를 형변환하여 가져오려고 하였기 때문에 생긴 현상이다. 미리 코드에서 빨간줄로 알려줬으면 좋겠지만 보다시피 깨끗하다.
제네릭을 이용하면 이런 실수를 미연에 방지를 할수 있다. 왜냐하면 코드를 실행하기전 컴파일 타임에 미리 에러를 찾아 알려주기 때문이다.
class FruitBox<T> {
private T[] fruit;
public FruitBox(T[] fruit) {
this.fruit = fruit;
}
public T getFruit(int index) {
return fruit[index];
}
}
public static void main(String[] args) {
Apple[] arr = {
new Apple(),
new Apple()
};
FruitBox<Apple> box = new FruitBox<>(arr);
Apple apple = (Apple) box.getFruit(0);
Banana banana = (Banana) box.getFruit(1);
}
이 처럼 제네릭은 클래스나 메서드를 정의할 때 타입 파라미터로 객체의 서브 타입을 지정해줌으로써, 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거하여 개발을 용이하게 해준다.
2. 불필요한 캐스팅을 없애 성능 향상
위의 예제 코드에서 Apple 배열을 FruitBox의 Object 배열 객체에 넣고, 배열 요소를 가져올때 반드시 다운 캐스팅(down casting)을 통해 가져와야 했다. 이는 곧 추가적인 오버헤드가 발생하는 것과 같다.
Apple[] arr = { new Apple(), new Apple(), new Apple() };
FruitBox box = new FruitBox(arr);
// 가져온 타입이 Object 타입이기 때문에 일일히 다운캐스팅을 해야함 - 쓸데없는 성능 낭비
Apple apple1 = (Apple) box.getFruit(0);
Apple apple2 = (Apple) box.getFruit(1);
Apple apple3 = (Apple) box.getFruit(2);
반면 제네릭은 미리 타입을 지정 & 제한해 놓기 때문에 형 변환(Type Casting)의 번거로움을 줄일 수 있으며, 타입 검사에 들어가는 메모리를 줄일 수 있고 더불어 가독성도 좋아진다.
// 미리 제네릭 타입 파라미터를 통해 형(type)을 지정해놓았기 때문에 별도의 형변환은 필요없다.
FruitBox<Apple> box = new FruitBox<>(arr);
Apple apple = box.getFruit(0);
Apple apple = box.getFruit(1);
Apple apple = box.getFruit(2);
즉, 제네릭은 자바의 타입 안정성을 맡고 있다. 컴파일 과정에서 타입체크를 해주는 기능으로 객체의 타입을 컴파일 시에 체크하기 때문에 객체의 타입 안정성을 높이고 형변환의 번거로움을 줄여준다.
참고