제네릭이란?
제네릭이란 클래스나 메서드 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법입니다. 제네릭을 통해 다양한 타입을 처리할 수 있습니다. 제네릭을 사용하면 컴파일 타임에 타입을 지정할 수 있어, 타입 안정성이 보장되고, 형변환에 대한 부담이 줄어듭니다.
예시: 제네릭을 사용하지 않은 경우와 제네릭을 사용하는 경우 비교
// 제네릭스를 사용하지 않은 경우
List list = new ArrayList();
list.add("Hello");
list.add(123); // 문자열과 정수를 동시에 저장 가능, 타입 안정성 없음
String str = (String) list.get(0); // 형변환 필요
// 제네릭스를 사용하는 경우
List<String> list = new ArrayList<>();
list.add("Hello");
// list.add(123); // 컴파일 오류: 정수는 허용되지 않음, 타입 안전성 확보
String str = list.get(0); // 형변환 불필요
제네릭 선언
제네릭 클래스/메서드 선언 시 타입 매개변수를 사용합니다. 제네릭을 선언하려면 클래스/메서드 이름 뒤에 꺾쇠괄호( <> ) 안에 타입 매개변수를 선언합니다.
제네릭 클래스 예시:
// T는 타입 매개변수
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
public static void main(String[] args) {
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");
System.out.println(stringBox.getItem());
Box<Integer> intBox = new Box<>();
intBox.setItem(123);
System.out.println(intBox.getItem());
}
Box<T>는 제네릭 클래스이고, T는 타입 매개변수입니다. 실제 사용할 때는 Box<String>이나 Box<Integer>처럼 구체적인 타입을 지정합니다.
제네릭 메서드 예시:
public class GenericMethodExample {
public <T> void print(T item) {
System.out.println(item);
}
}
public static void main(String[] args) {
GenericMethodExample example = new GenericMethodExample();
example.<String>print("Hello"); // 명시적인 타입 지정
example.print(123); // 타입 추론에 의해 자동으로 Integer로 처리
}
메서드 선언에서 <T>는 타입 매개변수를 나타내며, 메서드를 호출할 때 구체적인 타입을 지정하거나 타입 추론이 이루어집니다.
💁 타입 추론 (Type Inference)
타입 추론은 컴파일러가 명시적으로 선언하지 않아도 적절한 제네릭 타입을 추론해 주는 기능입니다. 자바 7부터 도입된 다이아몬드 연산자(<>)는 타입 추론을 더 쉽게 만들어줍니다.
예시: 명시적 타입 선언 vs 타입 추론
다음 코드에서 ArrayList<String>() 대신 ArrayList<>()라고만 적으면, 컴파일러는 List<String> 타입을 자동적으로 추론합니다. 이를 통해 코드가 더 간결해지며, 타입 안정성은 그대로 유지됩니다.
// 명시적 타입 선언 List<String> list1 = new ArrayList<String>(); // 타입 추론을 사용한 다이아몬드 연산자 List<String> list2 = new ArrayList<>();
제네릭 메서드에서의 타입 추론
위의 예시처럼 메서드를 호출할 때도 컴파일러는 전달된 인수에 따라 적절한 타입을 추론할 수 있습니다.public <T> void printItem(T item) { System.out.println(item); } public static void main(String[] args) { printItem("Hello"); // 컴파일러가 타입을 추론해 String으로 처리 printItem(123); // 컴파일러가 Integer로 추론 }
제네릭에서 할당받을 수 있는 타입: 참조형
제네릭 클래스나 메서드는 참조형 데이터 타입만을 타입 매개변수로 받을 수 있습니다. 즉 기본형 타입(int, double 등)은 사용할 수 없고, 래퍼 클래스(예: Integer, Double)를 사용해야 합니다.
// List<int> list = new ArrayList<>(); // 오류: 기본 타입은 제네릭에 사용할 수 없음
List<Integer> list = new ArrayList<>(); // 래퍼 클래스를 사용
제네릭에서 기본형 타입을 사용할 수 없고, 참조형 타입만을 사용할 수 있는 이유는 타입 시스템과 제네릭스 구현 방식에서 비롯됩니다. 때문에 이를 이해하려면 자바의 제네릭의 동작 방식과 기본형과 참조형의 차이를 살펴봐야 합니다.
1. 타입 소거
타입 소거는 자바 컴파일러가 제네릭 코드를 컴파일할 때(컴파일 타임) 제네릭 타입 정보를 제거하고, 런타임에는 타입 정보가 사라지고, 대신에 참조형 타입인 Object로 대체하는 과정을 말합니다. 즉, 컴파일된 바이트코드에는 타입 정보가 남아있지 않으며, 런타임에서는 해당 타입 정보 없이 코드가 동작합니다.
왜 타입 소거를 할까?
자바는 제네릭 기능이 도입되기 전의 레거시 코드와의 호환성을 유지하기 위해 타입 소거 방식을 채택했습니다. 이렇게 함으로써, 제네릭을 사용한 새로운 코드도 이전의 자바 코드와 함께 동작할 수 있습니다.
타입 소거 과정
- 제네릭 타입(T 같은 타입 파라미터)을 사용해도, 컴파일 시에는 타입 안정성을 검사합니다. 즉, List<String>이나List<Integer>가 컴파일 중에 타입 체크가 되어, 타입 오류가 발생하지 않도록 보장합니다.
- 예를 들어, List<String>이나 List<Integer>처럼 타입 매개변수를 사용한 코드는 컴파일 후에는 그냥 List<Object>로 취급됩니다.
- 이렇게 타입 정보가 제거되기 때문에, 런타임에는 해당 타입이 무엇인지 알 수 없게 됩니다.
제네릭을 사용한 코드 (컴파일 타임):
public class Box<T> {
private T value;
public Box(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
Box<String> stringBox = new Box<>("Hello");
System.out.println(stringBox.getValue());
타입 소거 후 (런타임 바이트코드):
public class Box {
private Object value;
public Box(Object value) {
this.value = value;
}
public Object getValue() {
return value;
}
}
Box stringBox = new Box("Hello");
System.out.println((String) stringBox.getValue()); // 컴파일러가 형변환을 삽입
타입 소거 이후, 컴파일된 바이트코드에서 Object로 처리되지만, 컴파일러가 자동으로 적절한 형변환을 삽입해 줍니다. 위 코드에서도, Box<T>의 T가 타입 소거된 후 Object로 변환되었더라도, 컴파일러는 적절하게 형변환 코드를 삽입하여 런타임 오류가 발생하지 않도록 보장합니다.
2. 왜 참조형 타입만 사용될까?
제네릭에서 참조형 타입만 사용되는 이유는 타입 소거(type erasure) 후에 Object로 타입을 대체되기 때문입니다.
제네릭은 컴파일 시점에 타입 검사를 수행하지만, 런타임에는 제네릭 타입에 대한 정보가 제거되고 모든 타입이 Object로 대체됩니다. 이때, 자바의 참조형 타입(예: Integer, String)은 모두 Object의 하위 타입이므로 타입 소거 후에도 문제가 없이 처리됩니다. 그러나 원시 타입(예: int, char)은 다릅니다. 원시 타입은 자바의 객체 모델에서 Object 클래스를 상속하지 않기 때문에 Object로 대체될 수 없습니다.
예를 들어, 자바의 기본형 타입인 int나 char와 같은 값들은 객체가 아니라 메모리의 스택 영역에 값 자체로 저장되며, 참조형 타입과 달리 객체의 주소를 참조하는 구조가 아닙니다. 자바에서 모든 참조형 타입은 힙 메모리에 객체로 저장되며, 그 주소를 참조하게 되는데, 참조형 타입은 이 때문에 제네릭의 타입 소거 과정에서 Object로 대체될 수 있는 반면, 원시 타입은 이러한 특성으로 인해 제네릭에서 사용할 수 없습니다.
이 문제를 해결하기 위해, 자바는 래퍼 클래스(wrapper class)라는 것을 제공합니다. 원시 타입을 참조형 타입으로 감싸는 래퍼 클래스들을 사용하면 제네릭에서도 원시 타입을 사용하는 것처럼 작업할 수 있습니다. 예를 들어, int 대신 Integer, char 대신 Character를 사용할 수 있습니다. 이러한 래퍼 클래스는 제네릭에서 문제없이 사용할 수 있으며, 실제로는 참조형 타입으로 취급되기 때문에 타입 소거 후에도 Object로 처리됩니다. 따라서, 제네릭에서 원시 타입을 직접 사용할 수 없으며, 그 대신 래퍼 클래스를 통해 원시 타입을 감싸서 제네릭에서 사용할 수 있습니다.
제네릭 멀티 타입 파라미터
멀티 타입 파라미터는 제네릭 클래스나 메서드에서 여러 개의 타입 매개변수를 사용하는 것을 말합니다. 이 방식은 다양한 타입을 하나의 클래스나 메서드에서 처리할 수 있게 해 주고, 복잡한 제네릭 로직을 쉽게 처리할 수 있습니다.
멀티 타입 파라미터를 사용하는 제네릭 클래스 예시:
public class Pair<K, V> {
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
멀티 타입 파라미터 사용 예시:
아래 코드는 Pair<K, V>는 두 개의 타입 매개변수를 받아, 키와 값을 서로 다른 타입으로 처리할 수 있습니다.
Pair<String, Integer> pair = new Pair<>("Age", 30);
System.out.println("Key: " + pair.getKey());
System.out.println("Value: " + pair.getValue());
제네릭 타입 제한하기
제네릭 타입은 특정 타입 범위로 제한할 수 있습니다. 이를 제한된 제네릭 타입이라고 하며, 주로 상속 관계를 통해 제한을 설정합니다.
상위 클래스 제한 (Upper Bound)
// T는 Number 타입 또는 그 하위 타입이어야 함
public class NumberBox<T extends Number> {
private T number;
public void setNumber(T number) {
this.number = number;
}
public T getNumber() {
return number;
}
}
상위 클래스 제한 사용 예시:
NumberBox<Integer> intBox = new NumberBox<>();
intBox.setNumber(100);
NumberBox<Double> doubleBox = new NumberBox<>();
doubleBox.setNumber(99.9);
// NumberBox<String> stringBox = new NumberBox<>(); // 컴파일 오류: String은 Number의 하위 타입이 아님
위 예시에서, T extends Number는 T가 Number 클래스이거나 그 하위 클래스(예, Integer, Double)여야 한다는 제약을 의미합니다.
와일드카드
제네릭 타입이 도입되면서 컴파일 타임에 타입 안정성을 보장하게 되었지만, 제네릭은 불공변(invariance)입니다. 즉, Collection<Integer>는 Collection<Object>의 하위 타입이 아니며, 컴파일 타임에 이러한 코드가 허용되지 않습니다. 특히, 서로 다른 타입을 받아야 하는 공통 메서드를 작성하는 것이 어려웠습니다. 예를 들어, List<Object>와 List<Integer>는 서로 호환되지 않기 때문에, 모든 타입의 리스트를 받아 처리할 수 있는 메서드를 작성하는 것이 어려웠습니다.
List<Integer> integerList = new ArrayList<>();
List<Object> objectList = integerList; // 컴파일 에러! 불공변성으로 인해 허용되지 않음
이러한 문제를 해결하기 위해 와일드카드(?)가 도입되었습니다. 이를 통해 보다 유연한 제네릭 사용이 가능해졌습니다.
예를 들어, 제네릭 메서드에서 어떤 타입이라도 허용하고 싶을 때 와일드카드를 사용할 수 있습니다. 와일드카드(?)는 특정 타입이 아닌 불특정 한 타입을 나타내는 역할을 합니다.
public void printCollection(List<?> list) {
for (Object obj : list) {
System.out.println(obj);
}
}
public static void main(String[] args) {
List<Integer> integerList = Arrays.asList(1, 2, 3);
List<String> stringList = Arrays.asList("A", "B", "C");
printCollection(integerList); // 타입 안전성 보장
printCollection(stringList); // 타입 안전성 보장
}
위 코드에서는 List<?>가 어떤 타입의 리스트라도 받을 수 있음을 의미합니다. 즉, List<String>, List<Integer>, List<Double>와 같은 다양한 타입의 리스트가 올 수 있습니다. 이 방법을 통해 불공변성 문제를 해결할 수 있게 되었습니다.
List<?> list = new ArrayList<>();
list.add(123); // 컴파일 에러! unknown 타입이므로 추가 불가
하지만 list.add() 같은 경우 컴파일 에러가 발생합니다. ?는 '어떤 타입이든 들어올 수 있다'는 의미지만, 구체적으로 어떤 타입인지 컴파일러가 알 수 없기 때문에 타입 안정성을 보장하기 위해 요소 추가를 막습니다. 그래서 컴파일러는 리스트에 추가하려는 값이 리스트의 타입과 맞는지 확인할 수 없으므로 에러를 발생시킵니다.
읽기 작업에 경우 컴파일러가 그 결과가 적어도 Object 타입임을 보장할 수 있기 때문에 읽기는 가능합니다.
List<?> list = Arrays.asList(1, 2, 3);
Object obj = list.get(0); // 읽기는 가능 (Object로 처리 가능)
System.out.println(obj); // 출력: 1
상위 제한 와일드카드 (? extends Type):
상위 제한은 와일드카드를 사용하여 제네릭 타입에 상한선을 지정할 수 있습니다. 이 와일드카드는 Type의 하위 타입만을 허용합니다.
아래 코드에서는 List<? extends Number>로 선언되었기 때문에, Number 클래스의 하위 클래스와 Number 클래스만 리스트에 들어올 수 있습니다. 즉 Integer, Double, Float 등은 가능하지만, String은 허용되지 않습니다.
// Number 또는 그 하위 타입 허용
public void processNumbers(List<? extends Number> numbers) {
for (Number num : numbers) {
System.out.println(num);
}
}
public static void main(String[] args) {
List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3);
processNumbers(intList); // Integer는 Number의 하위 타입
processNumbers(doubleList); // Double도 Number의 하위 타입
}
하위 제한 와일드카드(? super Type):
하위 제한은 와일드카드를 사용하여 타입의 하한선을 지정할 수 있습니다. 이는 Type의 상위 타입들만을 허용합니다.
// Integer 또는 그 상위 타입 허용
public void addNumbers(List<? super Integer> numbers) {
numbers.add(123); // 추가 가능
}
public static void main(String[] args) {
List<Number> numberList = new ArrayList<>();
addNumbers(numberList); // Number는 Integer의 상위 타입이므로 허용
}
List<? super Integer>는 Integer 타입의 상위 타입들(Number, Object)과 Integer 클래스만 리스트에 들어올 수 있으며, 이때는 리스트에 값을 추가할 수 있습니다. 왜냐하면 Integer 타입의 값을 추가할 때, 상위 타입이 수용 가능하기 때문입니다.
'Java' 카테고리의 다른 글
Java 스레드(Thread), 동기화(synchronized), 스레드풀(ThreadPool) 완벽 이해하기 (1) | 2024.10.23 |
---|---|
Java Enum 완벽 이해하기: 상수 관리를 위한 도구 (0) | 2024.10.07 |
Java 멀티스레드 환경에서 Collection 사용: 동기화된 컬렉션과 Concurrent 컬렉션 (1) | 2024.09.26 |
Java 컬렉션 프레임워크 완벽 이해하기 (0) | 2024.09.26 |
Java 날짜 및 시간 포맷 다루기. SimpleDateFormat, DateTimeFormatter, FastDateFormat (1) | 2024.09.24 |