지금까지 자바 공부를 하면서 제네릭을 많이 사용해왔고, 대충 어떤 목적으로 사용하는지는 알고 있었다.
그런데 이번 프로젝트를 진행하면서 공통 응답 코드를 짜게 되었는데, 제네릭을 왜 이런 식으로 짜는걸까 의문이 드는 부분이 있었어서 이번 기회에 다시 정리해보고자 한다.
제네릭
만약 어떤 클래스가 제네릭 타입이라면, 객체를 선언하는 부분에서 <> 안에 특정 타입으로 명시해준다.
이렇게 클래스 외부에서 클래스 내부 데이터의 타입을 선언하는 방식이 제네릭이다.
class Fruit<T>{
...
}
Fruit<String> fruit = new Fruit<>();
타입 매개변수
메서드와 파라미터를 표기하는 것과 비슷한 형태로 클래스와 제네릭의 타입을 명시한다.
따라서 제네릭의 타입을 결정하는 <> 내부의 데이터 타입을 타입 매개변수라고 한다.
그리고 new ~의 구현부에서는 <>로 타입 매개변수를 한 번 더 작성하는 형태이지만, 데이터 타입을 중복으로 작성하는 것은 비효율적이기 때문에 뒷부분의 타입 매개변수는 생략이 가능하다.
methodA(String parameter);
Fruit<String> = new Fruit<>();
제네릭을 사용하는 이유
클래스 내부에서 필드나 파라미터로 Object 타입을 사용하면 굳이 제네릭을 사용하지 않고도 다양한 타입을 클래스에 적용시킬 수 있다.
하지만 그렇게 하면 객체를 생성할 때 어떤 타입을 객체를 생성했는지 개발자가 직접 확인하고, 해당 타입으로 반환값을 형변환 해줘야 한다.
아래 상황에서는 배열에 Apple 타입만 삽입했지만 개발자가 실수로 Banana 타입으로 형변환하여 데이터를 저장하려고 한다.
이 상황에서 자바는 컴파일 시점에 이 오류를 인식하지 못하기 때문에 런타임 오류가 발생하게 된다.
반면에 제네릭은 어떤 타입인지 명시해주기 때문에 자바가 컴파일 시점에 오류가 발생하는 것을 알릴 수 있고, 다운캐스팅해서 반환값을 저장하지 않아도 된다.
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);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
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);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
제네릭을 사용할 때 주의할 점
static 메서드, static 필드에는 제네릭 타입이 올 수 없다.
제네릭 타입은 객체가 생성되는 순간에 어떤 타입이 오는지 명시해줘야 하는데, static의 경우 공유 자원이기 때문에 객체가 생성되기 전에 이미 데이터 타입이 정해져 있어야 하기 때문이다.
배열의 구현부에는 제네릭 타입을 사용할 수 없다.
Sample<Integer>[] arr = new Sample<Integer>[10];
타입 소거로 인해 런타임 시에 new Sample<Integer>[10] → new Sample[10] 으로 변경되면 런타임 시에는 어떤 타입이 들어오는지 명시되지 않아서 타입 안정성 문제가 발생할 수 있기 때문에 애초에 컴파일 오류를 발생시킨다.
더보기
배열과 제네릭의 핵심 차이
공변성이란
항목 |
배열 |
제네릭 |
타입 정보 유지 |
런타임까지 유지됨 |
컴파일 후 제거됨 (타입 소거) |
타입 검사 시점 |
런타임 검사 가능 (ArrayStoreCheck) |
컴파일 시점에만 검사 |
공변성 |
허용 (String[] → Object[]) |
허용되지 않음 (무공변) |
생성 가능 여부 |
new String[10] ✅ |
new T[10] ❌ |
타입 안전성 보장 |
런타임에 보장 |
컴파일 시점에 보장 |
배열은 왜 런타임에 타입을 유지하는가?
String[] strArray = new String[10];
Object[] objArray = strArray; // 업캐스팅 가능(공변성)
objArray[0] = "문자열"; // OK
objArray[1] = 123; // 런타임 오류 (ArrayStoreException)
- objArray의 타입은 Object[]지만, 실제 배열은 String[]
- -> 자바는 초기에 배열을 객체처럼 사용할 수 있도록 설계했기 때문에 업캐스팅이 가능(공변성)
- -> 따라서 컴파일 시점에는 오류 발생하지 않음
- 하지만 123은 String이 아니므로 JVM이 런타임에 오류를 던짐
- → 이 동작을 위해 JVM은 배열의 타입 정보를 런타임까지 유지함
제네릭은 왜 런타임에 타입 정보가 사라지는가?
제네릭의 경우는 컴파일 시에 이미 특정 타입인지 검사하는 과정을 거친 뒤에 실행하기 때문에, 제네릭의 타입 정보를 런타임까지 가지고 있는 것은 메모리 낭비다.
따라서 컴파일이 끝나면 타입 정보는 사라지고 일반 클래스처럼 취급된다.
반환 타입을 제네릭으로 선언
interface IAdd<T> {
public T add(T x, T y);
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
위와 같이 메서드명 앞에 T를 명시해주면 외부에서 선언하는 타입으로 메서드의 파라미터뿐만 아니라 반환타입도 결정된다.
하지만 제네릭 클래스 내부에 있는 add() 메서드는 <T>에서 설정된 타입을 받아와 사용할 뿐 제네릭 메서드는 아니다.
제네릭 메서드
class FruitBox<T> {
// 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
public T addBox(T x, T y) {
...
}
// 독립적으로 타입 할당 운영되는 제네릭 메서드
public static <T> T addBoxStatic(T x, T y) {
...
}
}
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
제네릭 메서드는 메서드 선언부에 <T>가 선언된 메서드이다.
메서드에 선언된 <T>는 동적으로 타입을 받아와, 독립적으로 타입을 명시할 수 있는 메서드이다.
제네릭 메서드의 제네릭 타입 선언 위치는 메서드의 반환타입 바로 앞이다.
위 코드에서는 addBoxStatic의 반환타입이 T이기 때문에 그 앞에 <T>를 선언하여 제네릭 메서드임을 명시한다.
제네릭 메서드 호출
제네릭 메서드를 호출할 때는 제네릭 메서드를 선언할 때와 비슷한 형태로 메서드명 바로 왼쪽에 타입을 명시해준다.
FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
하지만 제네릭 메서드의 경우 파라미터를 보고 제네릭 타입에 들어갈 데이터 타입을 추정할 수 있기 때문에 제네릭 클래스를 선언할 때와는 다르게 타입 파라미터를 생략하고 호출할 수 있다.
FruitBox.addBoxStatic(1, 2);
FruitBox.addBoxStatic("안녕", "잘가");
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
만약 제네릭 메서드의 타입을 명시해주지 않고 호출한다면 기본적으로 제네릭 클래스의 제네릭 타입을 따른다.
하지만 <>로 직접 데이터 타입을 선언해주거나 제네릭 클래스의 타입과는 다른 타입의 값을 파라미터로 넘겨준다면 제네릭 클래스와는 독립적으로 제네릭 타입을 주입할 수 있다.
제네릭 와일드 카드
제네릭은 위에서 말했듯이 배열과는 다르게 형변환(공변)이 불가능하다.
따라서 제네릭 간의 형변환이 성립되려면 제네릭에서 제공하는 와일드 카드(?)를 사용해야 한다.
이렇게 선언하면 list는 Object 하위의 어떤 타입도 허용하고,
list2는 String 상위의 어떤 타입도 허용한다.
List<? extends Object> list = new ArrayList<String>();
List<? super String> list2 = new ArrayList<Object>();
// 출처: https://inpa.tistory.com/entry/JAVA-☕-제네릭Generics-개념-문법-정복하기 [Inpa Dev 👨💻:티스토리]
참고 링크
https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0