Java 제네릭(Generics)이란 무엇인가?
JDK 1.5(Java 5)에서 제네릭 타입이 새로 추가되었다. 제네릭 타입을 사용함으로써 잘못된 타입이 사용될 수 있는 문제를 ‘컴파일’ 과정에서 제거할 수 있게 되었다.
제네릭은 컬렉션, 람다식, 스트림 등에서 널리 사용되는데, 클래스와 인터페이스, 메서드를 정의할 때 타입을 파라미터로 사용할 수 있도록 한다. 타입 파라미터는 코드 작성 시 구체적인 타입으로 대체되어, 다양한 코드를 생성하도록 해준다.
특히 Java API 문서에도 제네릭 표현이 많이 사용되므로, 이를 확실히 알아 두는 것이 필요하다.
제네릭을 사용하는 이유
- 타입 안전성 제공(컴파일 시 강한 타입 체크)
- 자바 컴파일러는 제네릭을 사용한 코드에 대해 컴파일 타임에 객체의 타입을 체크한다. 이를 통해 원치 않는 타입의 객체가 저장되는 것을 막아 타입 안전성을 높일 수 있다. 예를 들어, 특정 타입의 객체만 다루도록 제한된 컬렉션을 제네릭으로 정의하면, 잘못된 타입의 객체를 삽입하려는 시도는 컴파일러에 의해 바로 잡힌다.
- 타입 변환(casting) 제거
// 제네릭을 사용하지 않은 경우 List list = new ArrayList(); list.add("Hello"); // String 타입 객체 추가 list.add(123); // Integer 타입 객체 추가 String str1 = (String) list.get(0); // 명시적 캐스팅 필요 String str2 = (String) list.get(1); // ClassCastException 발생
- 제네릭을 사용하지 않으면 컬렉션에서 요소를 꺼낼 때마다 타입 변환이 필요하다. 이는 프로그램 성능을 떨어뜨릴 뿐만 아니라, 잘못된 타입을 캐스팅할 경우 ‘ClassCastException’이 발생할 수도 있다. 제네릭은 이러한 상황을 방지할 수 있다.
Generics의 기본 사용법
Generics를 다르게 설명하자면, 타입을 ‘일반화’하여 다양한 타입의 객체를 전달받아 다룰 수 있도록 하는 기법이다. List <E>
를 생각해 보자. E
자리에 우리가 요소로 사용하고 싶은 객체 타입을 지정하기만 하면 된다.
1. 제네릭 클래스 (Generic Class)
제네릭 클래스를 정의하면, 클래스 내부에서 사용할 데이터 타입을 외부에서 지정할 수 있게 된다. 클래스 이름 뒤에 <T>
를 붙여서 제네릭 클래스를 정의할 수 있는데, 여기서 T
는 타입 파라미터를 나타내며, 다른 이름을 사용할 수도 있다 (E
, K
, V
등).
// 제네릭 클래스 정의
public class Box<T> {
private T item; // 제네릭 클래스로 정의하지 않았다면, Object 타입으로 만들어야 함
static T item2; // 에러 발생
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
// 제네릭 클래스 사용
Box<String> stringBox = new Box<String>();
Box<String> stringBox = new Box<>(); // JDK 1.7부터 타입 생략 가능
- Box: 제네릭 클래스
- T: 타입 변수(type variable) 혹은 타입 매개변수로, ‘Type’의 첫 글자에서 따왔다.
List <E>:
ElementsMap <K, V>
: Key, Value
- 타입 변수는 상황에 맞게 의미 있는 문자를 선택해서 사용하는 것이 좋다. 물론, ‘임의의 참조형 타입’을 의미한다는 점에서는 동일하다.
- Box: 원시 타입(raw type)으로,
Box box = new Box();
와 같이 생성할 수 있다. 이때,T
는Object
타입으로 간주된다. 제네릭이 도입되기 이전과의 호환성을 유지하기 위해, 여전히 원시 타입을 사용할 수 있도록 허용된다. - static 변수: 모든 인스턴스에 대해 동일하게 동작해야 하기 때문에,
static
변수에는 대입된 타입에 따라 달라지는 타입 변수T
를 사용할 수 없다.
2. 제네릭 메서드 (Generic Method)
제네릭 메서드는 메서드 레벨에서 타입 파라미터를 사용할 수 있게 해 준다. 메서드의 매개변수 타입과 리턴 타입으로 타입 파라미터를 사용할 수 있다. 리턴 타입 앞에 <T>
기호를 추가하고 타입 파라미터를 적은 다음, 리턴 타입과 매개 타입으로 타입 파라미터를 사용하면 된다.
public <T> void printArray(T[] array) { ... }
public <T> Box<T> boxing(T t) { ... }
- 메서드를 호출할 때 ‘T’는
String
,Integer
등 구체적인 타입으로 대체된다. - 제네릭 메서드는 클래스의 제네릭 타입과는 독립적으로 정의될 수 있다.
3. 제네릭 인터페이스 (Generic Interface)
제네릭 인터페이스는 인터페이스를 정의할 때 타입 파라미터를 지정할 수 있게 해 준다. 인터페이스 이름 뒤에 <T>
를 붙여서 제네릭 인터페이스를 정의할 수 있다.
// 제네릭 인터페이스 정의
public interface Comparable<T> {
int compareTo(T o);
}
// 제네릭 인터페이스 구현
public class MyClass implements Comparable<MyClass> {
private int value;
public MyClass(int value) {
this.value = value;
}
@Override
public int compareTo(MyClass o) {
return Integer.compare(this.value, o.value);
}
}
Comparable <T>
인터페이스는T
타입의 객체와 비교하는 메서드를 정의한다.MyClass
클래스는Comparable <MyClass>를
구현하여,MyClass
객체 간의 비교 기능을 제공한다.
4. 와일드카드 (Wildcards)
와일드카드는 제네릭 타입의 범위를 확장하거나 제한할 때 사용된다. 제네릭 타입을 매개값이나 리턴 타입으로 사용할 때 구체적인 타입 대신에 와일드카드를 아래 3가지 형태로 사용할 수 있다. 타입 변수 ‘T’와 어떤 차이가 있는지 궁금한 부분이 있어서, 아래에 더 자세히 알아보았다.
와일드카드의 종류
- 상한 바운드 와일드카드 (
? extends T
):T
의 하위 타입만 허용- 주로 읽기 전용으로 리스트나 컬렉션에서 요소를 꺼낼 때 사용된다. (추가 작업은 불가하다) 아래 예시에서, Number 또는 그 하위 타입의 객체를 담아, 타입 안전성을 보장하면서도 특정 타입의 서브클래스를 모두 처리할 수 있는 코드를 작성할 수 있다.
public double sumOfList(List <? extends Number> list) {
double sum = 0.0;
for (Number num : list) {
sum += num.doubleValue();
}
return sum;
}
- 하한 바운드 와일드카드 (
? super T
):T
의 상위 타입만 허용- 주로 쓰기 작업에 사용되며, 특정 타입 이상의 상위 타입 객체를 받아들이도록 제한한다. 하지만 꺼낸 요소는 특정 타입으로 캐스팅하지 않으면 사용할 수 없다.
public void addNumbers(List <? super Integer> list) {
list.add(10); // 가능
list.add(20); // 가능
Integer i = (Integer) list.get(0); // 명시적인 형변환 필요
}
- 무제한 와일드카드 (
?
): 모든 타입을 허용- 읽기 전용으로 사용되고, 타입 안전성이 보장되지 않아 컬렉션에 요소를 추가하는 등의 작업은 제한된다.
5. 제네릭 타입 제한 (Bounded Type Parameters)
타입 파라미터에 지정되는 구체적인 타입을 제한할 경우 사용된다. 가령, 숫자를 연산하는 제네릭 메서드는 매개값으로 Number
타입 또는 그 하위 클래스 타입(Byte
, Short
, Integer
, Long
, Double
)의 인스턴스만 가져야 한다. 타입 파라미터 뒤에 extends
키워드를 붙이고 상위 타입을 명시하면 되는데, 클래스뿐만 아니라 인터페이스도 가능하다. 인터페이스라고 해서 implements
를 사용하지는 않는다.
public <T extends Number> void processNumber(T number) {
System.out.println(number.doubleValue());
}
와일드카드 <?>와 타입 변수의 차이
ORACLE Java Documentation의 Wildcards 부분을 읽어보면, 사용처와 사용하면 안 되는 부분을 아래와 같이 적어두었다.
와일드카드 (Wildcards)
제네릭 코드에서 물음표(?)는 와일드카드(wildcard)라고 하며, 이는 알 수 없는 타입을 나타냅니다. 와일드카드는 다양한 상황에서 사용될 수 있습니다:
매개변수, 필드, 또는 지역 변수의 타입으로 사용할 수 있으며, 때로는 반환 타입으로도 사용될 수 있습니다(하지만 반환 타입에서 더 구체적인 타입을 사용하는 것이 더 나은 프로그래밍 관행입니다). 와일드카드는 제네릭 메서드를 호출할 때나 제네릭 클래스 인스턴스를 생성할 때, 또는 상위 타입을 지정할 때는 절대로 사용되지 않습니다.
아래에서 예제 코드와 함께 살펴보자.
1. 와일드카드의 사용처
1.1 매개변수로 사용
와일드카드를 사용하여 메서드 매개변수의 타입을 유연하게 지정할 수 있다.
import java.util.List;
public class WildcardExample {
public static void printList(List<?> list) { // 매개변수로 모든 타입의 List 객체를 받는다.
for (Object element : list) {
System.out.println(element);
}
}
public static void main(String[] args) {
List<String> stringList = List.of("Hello", "World"); // String 타입 List
List<Integer> intList = List.of(1, 2, 3); // Integer 타입 List
printList(stringList); // printList 메서드의 매개변수로 List<String> 허용
printList(intList); // printList 메서드의 매개변수로 List<Integer> 허용
}
}
1.2 필드로 사용
public class Container {
private List<?> items;
public Container(List<?> items) {
this.items = items;
}
public List<?> getItems() {
return items;
}
}
1.3 지역 변수로 사용
public class LocalVariableExample {
public void process() {
List<?> items = List.of("Item1", "Item2", "Item3");
for (Object item : items) {
System.out.println(item);
}
}
}
2. 와일드카드를 사용하지 않는 경우
2.1 제네릭 클래스나 인터페이스의 정의에서 사용할 수 없음
- 와일드카드는 제네릭 클래스나 인터페이스를 정의할 때 타입 파라미터로 사용할 수 없다. 타입 파라미터는 구체적인 이름을 가져야 한다.
// 잘못된 예시 - 와일드카드를 제네릭 클래스의 타입 파라미터로 사용할 수 없음
class Box<?> { // 컴파일 에러
private ? item; // 컴파일 에러
}
// 올바른 예시 - 제네릭 타입 파라미터를 사용해야 한다.
class Box<T> { // 올바른 사용법
private T item;
}
2.2 제네릭 메서드의 타입 파라미터로 사용할 수 없음
- 와일드카드는 제네릭 메서드를 정의할 때 타입 파라미터로 사용할 수 없다. 제네릭 메서드에서는 타입 파라미터를 명확히 선언해야 한다.
// 잘못된 예시 - 와일드카드를 제네릭 메서드의 타입 파라미터로 사용할 수 없음
public static <? extends Fruit> void test(List<?> test) {
// 메서드 내용
}
// 올바른 예시 1 - 제네릭 메서드를 정의할 때는 타입 파라미터를 명확하게 선언해야 한다.
public static <T extends Fruit> void test(List<T> test) {
// 메서드 내용
}
// 올바른 예시 2 - 제네릭 메서드가 아니며, 매개변수로 와일드 카드 사용
public static void test(List<?> test) {
// 메서드 내용
}
2.3 제네릭 클래스나 인터페이스 인스턴스화 시 사용할 수 없음
- 와일드카드는 제네릭 클래스나 인터페이스의 인스턴스를 생성할 때 사용할 수 없다.
// 잘못된 예시 - 와일드카드를 사용하여 인스턴스를 생성할 수 없음
Box<?> box = new Box<?>(); // 컴파일 에러
// 올바른 예시 - 인스턴스 생성 시에는 구체적인 타입을 사용해야 함
Box<String> box = new Box<>(); // 올바른 사용법
2.4 상속이나 구현 시 사용할 수 없음
- 와일드카드는 클래스나 인터페이스를 상속하거나 구현할 때 사용할 수 없다. 상속 및 구현에서는 구체적인 타입이나 타입 파라미터를 사용해야 한다.
// 잘못된 예시 - 상속 시 와일드카드를 사용할 수 없음
class MyBox extends Box<?> { // 컴파일 에러
}
// 올바른 예시 - 상속이나 구현 시에는 타입 파라미터나 구체적인 타입을 사용해야 한다.
class MyBox<T> extends Box<T> { // 올바른 사용법
}
2.5 타입 캐스팅에서 사용할 수 없음
- 와일드카드는 타입 캐스팅에서 사용할 수 없다. 캐스팅 시에는 구체적인 타입이 필요하다.
// 잘못된 예시 - 와일드카드를 사용하여 캐스팅할 수 없음
List<?> list = new ArrayList<>();
List<String> stringList = (List<?>) list; // 컴파일 에러
// 올바른 예시 - 캐스팅 시 구체적인 타입 사용
List<String> stringList = (List<String>) list; // 올바른 사용법
2.6. 와일드카드를 사용한 타입으로는 새로운 객체를 추가할 수 없음
- 와일드카드는 타입 안전성을 보장할 수 없기 때문에(리스트의 구체적인 타입을 알 수 없다), 와일드카드를 사용한 타입에 새로운 객체를 추가할 수 없다.
- 아래 예시에서, List <? extends Numbers> 타입의 리스트에서 요소를 가져올 때는 Number 혹은 그 하위 타입으로 반환되기 때문에, 이를 ‘Integer’로 형 변환할 수 있다. 하지만 리스트에 실제로 포함된 값이 Double이라면 ClassCastException이 발생할 수 있다.
List<? extends Number> list = new ArrayList<>();
list.add(10); // 컴파일 에러: 와일드카드 타입으로는 추가할 수 없음
Integer t = (Integer) test.get(0); // OK
- 올바른 사용: 와일드카드 타입에는 객체를 추가할 수 없으며, 주로 읽기 전용 작업에 사용된다.
2.7 메서드의 반환 타입으로는 와일드카드를 사용하지 않는 것이 좋음
- 와일드카드를 반환 타입으로 사용하는 것은 일반적으로 피해야 한다. 반환 타입에 와일드카드를 사용하면 호출자가 반환된 객체를 다루기 어렵기 때문이다.
// 피해야 할 사용 예시 - 와일드카드를 반환 타입으로 사용
public static List<? extends Number> getNumbers() {
return new ArrayList<Integer>(); // 호출자가 반환 타입을 다루기 어려움
}
// 올바른 예시 - 반환 타입에는 구체적인 타입이나 타입 파라미터를 사용한다.
public static <T extends Number> List<T> getNumbers() { // 올바른 사용법
return new ArrayList<>();
}
- 타입 변수
T
는 제네릭 메서드나 클래스에서 타입을 고정하여 그 타입과 관련된 작업을 수행할 수 있도록 한다. 이는 타입 안정성을 보장하면서도 다양한 타입을 유연하게 처리할 수 있게 해 준다. - 와일드카드? 는 타입 변수 대신 사용되어 타입의 범위를 확장하거나 제한할 수 있다. 와일드카드는 특정 타입을 고정하지 않으며, 구체적인 작업보다는 타입의 유연성에 초점을 맞춘다. 그러나 이로 인해 요소 추가 등의 작업에는 제한이 따르게 된다.
제네릭 클래스, 메서드 읽어보기
Java API에서 와일드카드와 제네릭이 많이 사용되면서 실무적으로 자주 활용되는 클래스나 메서드들을 몇 가지 소개하고, 함께 읽어보자.
1. Collections
클래스의 sort
메서드
public static <T extends Comparable<? super T>> void sort(List<T> list)
- 이 메서드는 주어진 리스트를 정렬한다. (메서드 시그니처)
- 제네릭 타입
T
는Comparable
인터페이스를 구현해야 하며, Comparable 인터페이스는 그 상위 타입과도 비교할 수 있어야 한다. (<T extends Comparable <? super T>>
) - 리스트 내부의 요소들은
T
타입이다.
2. Comparator
인터페이스의 comparing
메서드
public static <T, U extends Comparable<? super U>> Comparator<T> comparing(Function<? super T, ? extends U> keyExtractor)
- 이 메서드는 특정 키를 추출하는 함수(키 추출기)를 받아, 해당 키를 기준으로 객체를 비교하는
Comparator
를 생성한다.Function <? super T,? extends U>
부분은 T 또는 그 상위 타입을 입력으로 받고, U 또는 그 하위 타입을 출력하는 함수형 인터페이스이다. - 제네릭 타입
U
는Comparable
인터페이스를 구현해야 하며, Comparable 인터페이스는 U 상위 타입과 비교할 수 있다. (<U extends Comparable <? super U>>
)
3. Optional
클래스의 flatMap
메서드
public <U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper)
Function <? super T, Optional <U>> mapper
T 타입 또는 그 상위 타입을 입력으로 받아, U 타입의 Optional 객체를 반환하는 함수 객체를 파라미터로 받는다.- 제네릭 타입
U
는 결과로 반환될Optional
의 타입이다.
4. Stream
클래스의 map
메서드
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
- 이 메서드는 스트림의 각 요소에 대해 주어진 변환 함수를 적용하고, 그 결과로 새로운 요소를 가지는 스트림을 반환한다.
- 제네릭 타입
R
은 변환 후의 새로운 요소 타입을 나타낸다. (<R>
)
'Java > 일반' 카테고리의 다른 글
Java의 상속(Inheritance)과 컴포지션(Composition) (0) | 2024.09.26 |
---|---|
Java에서 상수(constant)와 리터럴(literal)이란? (🎈static final과 final의 차이) (0) | 2024.09.24 |
오버라이딩(Overriding) vs 오버로딩(Overloading) (✅오버라이딩의 반환타입, 오버로딩의 형변환) (0) | 2024.08.20 |
JVM(Java Virtual Machine)은 어떻게 동작할까? (0) | 2024.08.14 |
GC(Garbage Collection)에 대해 알아보자 (0) | 2024.08.14 |
댓글