우아한테크코스 레벨 1에서 학습한 내용을 정리한 글입니다.
💭 들어가며
자바의 제네릭은 컬렉션 프레임워크를 사용할 때 자연스럽게 접하게 되지만, 정확한 용도를 깊이 이해하지 못한 채 사용해 왔다는 생각이 들었다. 이번 기회를 통해 학습하면서, 제네릭이 예상보다 복잡한 개념임을 알게 되었다. 따라서, 이번 포스팅에서는 기본적인 내용을 간단히 정리하고, 추가적인 학습이 필요할 경우 심화 내용을 따로 다루고자 한다.
✅ 제네릭(Generic)이란
▶ 정의
제네릭(Generic)은 타입을 변수화하여, 클래스나 메서드 선언 시 구체적인 타입을 적지 않고, 사용하는 쪽에서 그 타입을 지정해 줄 수 있도록 하는 기술이다.
🔽 예시 코드
List<String> genericList = new ArrayList<>();
위 예시에서 List<String>은 "String 타입의 원소만 저장하는 리스트"라는 의미이다.
▶ 목적
1️⃣ 타입 안전성
제네릭이 등장하기 전, List와 같은 컬렉션에는 어떤 객체 타입이든 들어갈 수 있었다. 이 경우, 잘못된 타입의 객체가 들어가도 컴파일 시에 잡지 못하고, 런타임에서야 ClassCastException 등의 예외가 발생했다. 제네릭을 사용하면 컴파일 시점에 타입을 체크할 수 있어 잘못된 타입을 미리 방지할 수 있어 타입 안정성이 크게 향상된다.
제네릭 도입 전:
List list = new ArrayList();
list.add("Miso");
list.add(10); // 잘못된 타입
String string1 = (String) list.get(0);
String string2 = (String) list.get(1); // 런타임 에러 발생(ClassCastException)
제네릭 도입 후:
List<String> list = new ArrayList<>();
list.add("Miso");
// list.add(10); // 컴파일 에러
String str = list.get(0);
2️⃣ 불필요한 타입 캐스팅 제거
제네릭 도입 이전에는 컬렉션으로부터 데이터를 꺼낼 때마다 Object 타입을 원하는 타입으로 매번 캐스팅해야 했다. 제네릭을 사용하면 컴파일러가 타입을 추론해 주므로 불필요한 캐스팅을 하지 않아도 된다.
제네릭 도입 전:
List numbers = new ArrayList();
numbers.add(10);
numbers.add(20);
int num1 = (int) numbers.get(0); // 캐스팅 필요
int num2 = (int) numbers.get(1); // 캐스팅 필요
제네릭 도입 후:
List<Integer> numbers = new ArrayList<>();
numbers.add(10);
numbers.add(20);
int num1 = numbers.get(0); // 캐스팅 불필요
int num2 = numbers.get(1); // 캐스팅 불필요
✅ 제네릭의 사용 방법
▶ 타입 파라미터 생략
Java 7부터 도입된 다이아몬드 연산자(<>)를 사용하면, 생성자 쪽의 타입 파라미터를 생략할 수 있다. 컴파일러가 타입 추론을 해주기 때문에 더욱 간결하게 작성이 가능하다.
// Java 6까지
List<String> list1 = new ArrayList<String>();
// Java 7 이상
List<String> list2 = new ArrayList<>();
▶ 단일 타입 파라미터
한 개의 타입 파라미터를 사용하는 경우이다.
🔽 선언
public class Score<T> {
private T value;
public Score(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
🔽 사용 예시
Score<String> stringScore = new Score<>("A+");
stringScore.getValue(); // A+
Score<Integer> integerScore = new Score<>(100);
integerScore.getValue(); // 100
▶ 다중 타입 파라미터
두 개 이상의 타입 파라미터를 선언할 수도 있다.
🔽 선언
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<String, Integer> pair = new Pair<>("Miso", 100);
pair.getKey(); // Miso
pair.getValue(); // 100
▶ 중첩 타입 파라미터
제네릭 타입이 여러 단계로 중첩될 수도 있다.
List<List<String>> nestedList = new ArrayList<>();
▶ 타입 파라미터 기호 네이밍
일반적으로 다음과 같은 약어를 사용한다.
타입 | 설명 |
T | 타입(Type) |
E | 요소(Element) |
K | 키(Key) |
V | 값(Value) |
R | 반환 타입(Return) |
이러한 네이밍을 따르는 것이 일반적이지만, 반드시 사용해야 하는 것은 아니다.
✅ 제네릭 객체
▶ 클래스
클래스를 정의할 때, 내부에서 다룰 타입을 외부에서 지정할 수 있게 해준다.
🔽 선언
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return this.item;
}
}
🔽 사용 예시
Box<String> stringBox = new Box<>();
stringBox.setItem("Miso");
stringBox.getItem(); // Miso
▶ 인터페이스
구현체가 인터페이스를 구현할 때, 원하는 타입을 활용해 로직을 구현할 수 있다.
🔽 선언
public interface Storage<T> {
void add(T item);
T get(int index);
}
🔽 사용 예시
public class MemoryStorage<T> implements Storage<T> {
private List<T> items = new ArrayList<>();
@Override
public void add(T item) {
items.add(item);
}
@Override
public T get(int index) {
return items.get(index);
}
}
Storage<String> stringStorage = new MemoryStorage<>();
stringStorage.add("Miso");
stringStorage.add("이소은");
stringStorage.get(0); // Miso
stringStorage.get(1); // 이소은
▶ 함수형 인터페이스
하나의 추상 메서드를 갖는 함수형 인터페이스에 제네릭을 도입하면, 입력 타입(T)과 반환 타입(R)을 자유롭게 설정해 람다나 메서드 레퍼런스로 표현할 수 있다.
🔽 선언
@FunctionalInterface
public interface Converter<T, R> {
R convert(T source);
}
🔽 사용 예시
Converter<String, Integer> converter = (str) -> Integer.valueOf(str);
Integer result = converter.convert("123"); // 123
▶ 메서드
클래스 자체가 제네릭이 아니더라도, 개별 메서드에서 제네릭을 사용할 수 있다. 이를 제네릭 메서드라고 하며, 메서드 선언부에 <T>를 명시함으로써 호출 시점에 원하는 타입을 동적으로 지정할 수 있다. 제네릭 클래스에서 사용하는 제네릭 타입 파라미터와는 별개로, 특정 메서드에서만 제네릭을 적용할 수 있다는 점이 특징이다.
🔽 선언
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
🔽 사용 예시
Integer[] integerArray = {1, 2, 3};
String[] stringArray = {"A", "B", "C"};
printArray(integerArray); // 1, 2, 3
printArray(stringArray); // A, B, C
✅ 제네릭의 고급 기능
▶ 와일드카드(?)
와일드카드는 제네릭 타입을 불특정 타입으로 표현할 때 사용하는 문법이다. 특정한 타입을 미리 정하지 않고, 다양한 타입을 허용하고 싶을 때 사용한다.
종류 | 와일드카드 | 설명 | 쓰기 가능 | 읽기 가능 |
무공변 | ? | 모든 타입 허용 (Object로 처리) | 불가능 | 가능 (Object로 읽음) |
상한 제한 | ? extends T | T 및 그 하위 타입만 허용 (읽기 전용) | 불가능 | 가능 (T로 읽음) |
하한 제한 | ? super T | T 및 그 상위 타입만 허용 (쓰기 전용) | 가능 (T 추가 가능) | 제한됨 (Object로 읽음) |
🔽 무공변: 어떤 타입이든 허용
읽기는 Object로만 가능하고, 쓰기(add())는 불가능하다.
public static void printList(List<?> list) {
for (Object element : list) {
System.out.println(element);
}
}
List<String> strings = List.of("A", "B", "C");
List<Integer> numbers = List.of(1, 2, 3);
printList(strings); // 가능
printList(numbers); // 가능
🔽 상한 제한: 특정 타입 혹은 그 하위 클래스만 허용
읽기는 가능하지만, 쓰기(add())는 불가능하다.
public static void printNumbers(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num);
}
}
List<Integer> intList = List.of(1, 2, 3);
List<Double> doubleList = List.of(1.1, 2.2, 3.3);
printNumbers(intList); // 가능
printNumbers(doubleList); // 가능
🔽 하한 제한: 특정 타입 혹은 그 상위 클래스만 허용
쓰기(add())는 가능하지만, 읽기는 불가능하다.
public static void addNumbers(List<? super Integer> list) {
list.add(10);
// list.add(new Object()); // 규칙상 불가능
}
List<Number> numberList = new ArrayList<>();
List<Object> objectList = new ArrayList<>();
addNumbers(numberList); // 가능
addNumbers(objectList); // 가능
와일드카드 문법은 난이도가 크기 때문에, "언제 extends를 쓰고, 언제 super를 쓰는가" 등의 활용법을 추가로 학습하길 권장한다.
▶ 제한된 타입 파라미터
타입 파라미터에 특정 클래스나 인터페이스를 상속(extends) 또는 구현(implements)하도록 제한할 수 있다. 즉, T가 특정 타입을 반드시 상속하거나 인터페이스를 구현해야만 사용할 수 있도록 제약을 거는 기능이다.
🔽 예시 코드
public static <T extends Comparable<T>> T getMax(T x, T y) {
return x.compareTo(y) > 0 ? x : y;
}
T는 반드시 Comparable<T> 인터페이스를 구현한 타입이어야 한다. 그렇지 않다면, compareTo() 메서드를 보장받을 수 없으므로 컴파일 에러가 발생한다.
String maxString = getMax("Apple", "Banana");
System.out.println(maxString); // Banana
Integer maxNumber = getMax(10, 20);
System.out.println(maxNumber); // 20
- String은 Comparable<String>을 구현하고 있으므로 정상 작동한다.
- Integer도 Comparable<Integer>를 구현하고 있으므로 정상 작동한다.
Person p1 = new Person("Miso");
Person p2 = new Person("Pobi");
// getMax(p1, p2); // 컴파일 에러
- Person 클래스가 Comparable<Person>을 구현하지 않았다면, compareTo() 메서드가 없으므로 컴파일 에러가 발생한다.
✅ 제네릭 사용 시 주의사항
▶ 기본 타입(primitive type)은 사용할 수 없다
List<int>와 같은 선언은 불가능하며, 래퍼(wrapper) 클래스인 Integer를 사용해야 한다.
// 불가능
// List<int> list = new ArrayList<>();
// 가능
List<Integer> list = new ArrayList<>();
▶ 런타임 시 타입이 소거된다
제네릭 타입 정보는 컴파일 시점에만 유효하고, 런타임에는 사라진다. 따라서, List<String>과 List<Integer>는 런타임에 똑같이 List로 인식된다.
List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // true
따라서 instanceof에서는 구체적인 제네릭 타입(List<String>)이 아닌, List<?>처럼만 사용할 수 있다.
▶ 제네릭 타입의 객체는 생성이 불가능하다
Java의 제네릭은 타입 소거(Erasure)로 인해 컴파일 시점에만 존재하므로, 제네릭 타입의 객체는 생성이 불가능하다.
// 불가능
// T t = new T(); // 어떤 타입인지 런타임에 확정할 수 없음
▶ 제네릭 클래스 자체를 배열로 만들 수 없다
List<String>[] 처럼 제네릭 클래스로 직접 배열을 생성하는 것은 불가능하다.
// 불가능
// List<String>[] array = new List<String>[10];
다만, 아래처럼 캐스팅을 통한 우회는 가능하지만, 타입 안전성이 떨어지므로 권장되지 않는다.
List<String>[] array = (List<String>[]) new List[10];
▶ static 멤버에 제네릭 타입이 올 수 없다
Java의 제네릭은 타입 소거(Erasure)로 인해 컴파일 시점에만 존재하므로, 정적 컨텍스트(static)에서는 타입 파라미터를 인식할 수 없다.
public class MyClass<T> {
// 불가능
// private static T staticValue;
// 불가능
// public static T getInstance() { ... }
}
static 멤버 변수와 메서드는 클래스 레벨에서 공유되므로, 객체를 생성하지 않아도 접근할 수 있다. 즉, 모든 객체가 동일한 static 변수를 공유해야 하는데, 제네릭 타입 T는 객체마다 다를 수 있으므로 static을 적용할 수 없다.
📍 참고 자료
'Programming > Java' 카테고리의 다른 글
[Java] 상속, 조합 (0) | 2025.04.01 |
---|---|
[Java] 인터페이스, 추상 클래스, 일반 상속 (0) | 2025.03.22 |
[Java] 컬렉션 프레임워크(Collection Framework) (0) | 2025.03.11 |
[Java] 람다 표현식, 함수형 인터페이스, Stream API (0) | 2025.02.23 |
[Java] 테스트 코드, JUnit & AssertJ (0) | 2025.02.21 |