[Java] 자바의 신 학습 정리 VOL.2 주요 API 응용편 - 제네릭만 정리
* 자바의 신의 2 중 정리는 제네럴만 한다. 나머지는 설명이 너무 부족하고 사실 추가적으로 문서나 다른 예시, GPT로 그때 그때 매뉴얼을 확인 후 사용하는 것으로도 충분하기 때문이다.
* C, Python을 어느 수준 이상으로 사용한 사람으로 이후의 추가 학습을 할 것이기에 현 상황에서 제일 이해하기 어렵다 생각된 제네럴만 정리하기로 결정했다.
1. 제네릭 (Generics)의 기초
1.1. 제네릭이란 :
- 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다.
- 즉, 클래스를 만들거나 메서드를 만들 때 어떤 타입이 올지 미리 정하지 않고 사용할 때 타입을 지정해서 사용하는 것이다.
- 제네릭은 배열에서 타입을 지정하듯이, 리스트 같은 컬렉션 클래스나 메소드에서 사용할 내부 데이터 타입을 외부에서 마치 파라미터처럼 넘겨 지정할 수 있도록, 타입 자체를 변수처럼 다루게 해주는 기능이다.
- 변수를 선언할 때 타입을 지정하듯이, 제네릭은 객체에 사용할 타입을 외부에서 지정해주는 것이라고 이해하면 된다.
1.2. 기본형
ArrayList<String> list = new ArrayList<String>();
// 다이아몬드 연산자 (<>) 는 Java 7 (JDK 1.7) 부터 도입
ArrayList<String> list = new ArrayList<>();
1.3. 일반 배열 자료형과 제네럴 리스트 자료형의 비교
1.3.1. 문자열 배열 (String[])
String[] strArray = new String[10];
1.3.1.1. 자료형 구조
- 고정 크기 배열
- 컴파일 타임에 크기 결정
- 기본적으로 참조형 요소를 갖는 객체 배열
1.3.1.2. 사용 예시
strArray[0] = "Hello";
strArray[1] = "Java";
System.out.println(strArray[0]); // Hello
System.out.println(strArray.length); // 10
1.3.1.3. 설명
- String[]: 문자열을 담는 배열
- new String[10]: 크기가 10인 배열을 생성 (모든 요소는 null로 초기화)
1.3.2. 제네릭 문자열 리스트 (ArrayList<String>)
ArrayList<String> strList = new ArrayList<>(10);
1.3.2.1. 자료형 구조
- 동적 크기 배열 (가변 배열)
- 내부적으로 Object[] 배열을 사용하고 필요 시 자동으로 크기 확장
- 제네릭을 통해 타입 안정성 확보 (String만 저장 가능)
1.3.2.2. 사용 예시
strList.add("Hello");
strList.add("Java");
System.out.println(strList.get(0)); // Hello
System.out.println(strList.size()); // 2
1.3.2.3. 설명
- ArrayList<String>: 문자열을 저장하는 리스트
- new ArrayList<>(10): 내부 배열의 초기 용량을 10으로 설정 (크기가 아니라 용량)
1.3.2.4. 개념 정리: 크기 vs 용량
용어 | 설명 |
크기 (size) | 현재 실제로 저장된 요소의 개수 |
(예: .size()로 확인 가능) | |
용량 (capacity) | 내부 배열이 최대 몇 개까지 저장할 수 있는지를 나타냄 |
(자동 확장되기 전의 저장 공간) |
1.4. 제네릭의 타입 매개변수 (Type Parameter)
1.4.1. Java 제네릭 타입 매개변수 정리 (의미 + 용도 중심 예시)
표기 | 의미 | 설명 | 적절한 사용 문맥 및 예시 |
T | Type | 일반적인 데이터 타입. 하나의 "타입 객체"를 대표. 주로 데이터 래핑, 리턴값 등 | Box<T>, Response<T> |
예: Box<String>, Box<User> | |||
E | Element | 컬렉션의 "요소" 타입. 주로 리스트나 셋, 큐 등에서 사용됨 | List<E>, Stack<E> |
예: List<String>, Set<Product> | |||
K | Key | 맵의 키(Key)로 사용되는 타입. 해시 기반 자료구조의 키값 역할 | Map<K, V> |
예: Map<String, User>, Map<Integer, Order> | |||
V | Value | 맵의 값(Value)로 사용되는 타입. 키와 쌍으로 저장됨 | Map<K, V> |
예: Map<String, User>, Map<Long, String> | |||
N | Number | 숫자 타입 (Number 클래스의 하위 타입). 산술 계산이나 제한적인 숫자 연산에 사용 | Calculator<N extends Number> |
예: Calculator<Integer>, Calculator<Double> | |||
S, U, V 등 | Second, Third Type | 추가 제네릭 타입 구분자. 복수 타입 매개변수 구분 시 사용 | Pair<T, U>, Triple<T, U, V> |
예: Pair<String, Integer> |
1.4.2. 핵심 차이 이해 포인트
표기 | 강조 대상 | 사용 예 맥락 | 설명 |
T | 일반 타입 | 아무 클래스 타입에 사용됨 | T는 범용적이며, User, String, Book 등 모든 참조형 클래스에 사용 가능 |
E | 컬렉션 요소 | List, Set, Queue 등 | E는 실제로 반복되는 "단일 요소"를 다루는 구조에서만 사용함 |
K/V | Map 자료구조 | 키-값 쌍으로 관리되는 구조 (딕셔너리형) | K는 중복되지 않는 식별자 / V는 저장할 값 |
N | 숫자 전용 | Number 계열에 제한 걸고 숫자 연산 | extends Number 제약을 반드시 동반함 |
S/U | 추가 타입 | 2개 이상 타입 매개변수 필요 시 사용 | 이름에 의미는 없고 순서 구분용 (일관성 중요) |
1.5. 타입 파라미터 정의
- 타입 매개변수는 제네릭을 이용한 클래스나 메소드를 설계할 때 사용된다.
- 예를 들어 T라는 타입 매개변수를 갖는 제네릭 클래스 Box<T>를 정의하면 다음과 같다.
- Box<T>는 임의의 참조형 타입 T를 감쌀 수 있는 박스 클래스입니다. T 대신 실제 타입을 지정하여 객체를 생성하면, Box 내부에서 해당 타입으로 동작하게 된다.
// 제네릭 클래스 정의: T는 타입 매개변수
class Box<T> {
private T content; // T 타입의 멤버 변수
public void setContent(T content) {
this.content = content;
}
public T getContent() {
return content;
}
}
Box<String> stringBox = new Box<>(); // T를 String으로 지정
stringBox.setContent("Hello Generics"); // String만 저장 가능
String str = stringBox.getContent(); // 반환도 String 타입
System.out.println(str.toUpperCase()); // "HELLO GENERICS"
// 두 개의 타입 매개변수를 사용하는 제네릭 클래스
class KeyValueStore<K, V> {
private Map<K, V> map = new HashMap<>();
public void put(K key, V value) {
map.put(key, value);
}
public V get(K key) {
return map.get(key);
}
}
// 사용 예:
KeyValueStore<String, Integer> ageStore = new KeyValueStore<>();
ageStore.put("Alice", 30);
ageStore.put("Bob", 25);
Integer age = ageStore.get("Alice"); // String 키에 대응하는 Integer 값 반환
System.out.println("Alice's age: " + age);
1.6. 제네릭 타입 파라미터에서 객체지향의 다형성이 어떻게 적용되는가?
- 다형성(Polymorphism) 이란?
- 부모 타입 하나로 여러 자식 타입을 다룰 수 있게 하는 것
- 제네릭 타입 파라미터에 클래스가 오면?
- 그 타입(부모 타입)으로 자식 객체들도 다룰 수 있게 된다.
- 즉, 제네릭에도 객체지향의 다형성이 그대로 적용된다!
1.6.1. 예시
- Fruit (부모 클래스)
class Fruit {
void eat() {
System.out.println("This fruit is delicious!");
}
}
- Apple, Banana (자식 클래스)
class Apple extends Fruit {
void wash() {
System.out.println("Washing the apple...");
}
}
class Banana extends Fruit {
void peel() {
System.out.println("Peeling the banana...");
}
}
- 제네릭 클래스 Basket<T>
class Basket<T> {
private T item;
void setItem(T item) {
this.item = item;
}
T getItem() {
return item;
}
}
- 제네릭 사용
public class Main {
public static void main(String[] args) {
Basket<Fruit> fruitBasket = new Basket<>();
fruitBasket.setItem(new Apple()); // Apple을 넣음 (업캐스팅)
fruitBasket.getItem().eat(); // Fruit의 메소드 호출 가능
fruitBasket.setItem(new Banana()); // Banana도 넣을 수 있음
fruitBasket.getItem().eat(); // 역시 Fruit 메소드 사용 가능
}
}
1.6.2. 여기서 무슨 일이 벌어졌을까?
- Basket<Fruit>을 만들었다.
- setItem(new Apple())처럼 Apple 객체를 넣었다.
- Apple은 Fruit의 자식이니까 업캐스팅이 자동으로 일어남.
- 컴파일러는 오케이함. (Apple은 Fruit이니까 문제 없다)
- 뺄 때는 Fruit 타입으로 꺼내서 eat() 메소드를 쓸 수 있다.
- 같은 Basket에 Banana 객체도 넣을 수 있다.
- 마찬가지로 Banana도 Fruit의 자식이니까 업캐스팅 가능.
- 이게 바로 "부모 타입 하나로 여러 자식 객체를 다루는" 다형성이다.
- 제네릭에서도 이 원리가 그대로 적용된 것이다.
1.7. 복수 타입 파라미터
- 하나의 클래스나 인터페이스, 메서드에 여러 개의 타입 매개변수를 정의할 수 있다.
- 타입 매개변수는 콤마(,)로 구분하여 선언한다.
- 예를 들어, 두 개의 타입 매개변수 <K, V>를 사용하는 Pair<K, V> 클래스를 만들면 다음과 같다.
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<>("apple", 5);
String key = pair.getKey(); // "apple"
Integer val = pair.getValue(); // 5
System.out.println(key + ": " + val);
1.7.1. 캐시 구현
- 캐시(Cache)를 구현하는 클래스를 제네릭으로 정의하면, 키와 값의 타입을 유연하게 지정할 수 있다.
class Cache<K, V> {
private Map<K, V> store = new HashMap<>();
public void add(K key, V value) {
store.put(key, value);
}
public V get(K key) {
return store.get(key);
}
public boolean containsKey(K key) {
return store.containsKey(key);
}
}
// 사용 예:
Cache<String, List<Integer>> studentScores = new Cache<>();
studentScores.add("Alice", Arrays.asList(90, 85, 95));
studentScores.add("Bob", Arrays.asList(70, 80, 75));
List<Integer> aliceScores = studentScores.get("Alice");
if (aliceScores != null) {
System.out.println("Alice's scores: " + aliceScores);
}
1.8. 중첩 타입 파라미터
- 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);
}
[[aa, bb], [11, 22]]
1.8.1 같은 타입을 사용하지 않은 이유
- ArrayList<ArrayList<String>> list 혹은 LinkedList<LinkedList<String>> list 이렇게 만들지 않은 이유는 무엇일까?
1.8.2. 핵심 이유
- 서로 다른 두 컬렉션(ArrayList, LinkedList)의 장점을 조합해서 더 효율적으로 사용하려고 한 것이다.
- ArrayList :
- 데이터를 인덱스로 빠르게 접근할 때 더 좋은 성능을 낸다.
- 내부적으로 배열 기반이라서 list.get(0) 등을 사용할 할 때 O(1) 시간에 바로 찾을 수 있다.
- ArrayList는 중간에 추가/삭제할 때 느리다. (특히 요소가 많을 때!)
- LinkedList :
- 데이터를 앞뒤로 추가/삭제할 때 더 좋은 성능을 낸다.
- 삽입/삭제가 많을 경우 더 빠르다. (addFirst(), removeFirst() 같은 거)
- 인덱스 접근이 느리다. (list.get(1000) 이런 거 하면 O(n) 시간 걸림)
- 많이 검색해야 하는 경우에는 성능이 나쁘다.
2. 제네릭 사용 이유와 이점
2.1. 컴파일 타임에 타입 검사를 통해 예외 방지
- Java 1.5 (JDK 5) 부터 제네릭이 추가됐다.
- 그 이전에는 여러 타입을 다루기 위해 모든 데이터 타입을 Object 타입으로 처리했다.
2.1.1. 예전 스타일 (Object 사용)
List list = new ArrayList();
list.add("hello");
list.add(123); // 다른 타입도 추가 가능
String str = (String) list.get(0); // 강제 형변환 필요
Integer num = (Integer) list.get(1); // 강제 형변환 필요
2.1.2. 문제점
- Object로 받으면 타입 체크가 컴파일 타임이 아니라 런타임에 일어남.
- 개발자가 직접 (String), (Integer)처럼 형변환(casting)을 해야 함.
- 형변환 실수로 인해 런타임 에러가 쉽게 발생할 수 있다. (예: ClassCastException)
2.1.3. 제네릭이 해결한 문제
- 제네릭 적용 이후
List<String> list = new ArrayList<>();
list.add("hello");
// list.add(123); // 컴파일 에러 발생!
String str = list.get(0); // 형변환 필요 없음
- 이점
- 컴파일 시 타입 체크가 가능해진다.
- 형변환을 하지 않아도 된다.
- 코드가 더 안전하고, 가독성도 좋아진다.
2.1.4. 제네릭 사용의 주요 이점 정리
이점 | 설명 |
타입 안정성(Type Safety) | 잘못된 타입이 들어가는 것을 컴파일러가 막아준다. |
형변환(Casting) 생략 | 꺼낼 때 매번 (타입)캐스팅할 필요가 없다. |
코드 재사용성 증가 | 다양한 타입에 대해 하나의 코드로 대응할 수 있다. (ex: List<T>) |
컴파일 타임 에러 방지 | 런타임 에러(ClassCastException)를 컴파일 타임에 잡을 수 있다. |
가독성 향상 | 타입이 명확하게 드러나 코드 이해가 쉬워진다. |
2.1.5. 개발자의 실수로 형변환을 잘못하여 문제가 발생하는 경우
class Dog {}
class Cat {}
class AnimalBox {
private Object[] animals;
public AnimalBox(Object[] animals) {
this.animals = animals;
}
public Object getAnimal(int index) {
return animals[index];
}
}
public class Main {
public static void main(String[] args) {
Dog[] dogs = {
new Dog(),
new Dog()
};
AnimalBox box = new AnimalBox(dogs);
Dog dog = (Dog) box.getAnimal(0); // ✅ OK: 실제로 Dog
Cat cat = (Cat) box.getAnimal(1); // ❌ 런타임 오류 발생: ClassCastException
}
}
2.1.6. 해결 방법 제안: 제네릭을 사용해서 타입 안전성을 확보하는 경우
- 컴파일 타임에 미리 잘못된 것을 잡아 알려준다.
class AnimalBox<T> {
private T[] animals;
public AnimalBox(T[] animals) {
this.animals = animals;
}
public T getAnimal(int index) {
return animals[index];
}
}
public class Main {
public static void main(String[] args) {
Dog[] dogs = { new Dog(), new Dog() };
AnimalBox<Dog> dogBox = new AnimalBox<>(dogs);
Dog dog = dogBox.getAnimal(0); // ✅ 타입 안전하게 처리
// Cat cat = dogBox.getAnimal(1); // ❌ 컴파일 오류! → 타입 불일치
}
}
2.2. 불필요한 캐스팅을 없애 성능 향상 - 타입 안전성과 성능의 차이: 일반 객체 vs 제네릭
2.2.1. 일반 객체(Object 타입) 기반 구현
class Book {}
class BookBox {
private Object[] books;
public BookBox(Object[] books) {
this.books = books;
}
public Object getBook(int index) {
return books[index];
}
}
public class Main {
public static void main(String[] args) {
Book[] arr = { new Book(), new Book(), new Book() };
BookBox box = new BookBox(arr);
// ❗ 다운캐스팅 필요 - 성능 저하, 가독성 저하, 런타임 에러 위험
Book book1 = (Book) box.getBook(0);
Book book2 = (Book) box.getBook(1);
Book book3 = (Book) box.getBook(2);
}
}
2.2.2. 문제점 요약
항목 | 설명 |
형변환 필요 | Object → Book으로 일일이 다운 캐스팅해야 함 |
성능 낭비 | 내부적으로 instanceof 검사 + 캐스팅 처리 |
안정성 부족 | 컴파일 타임이 아닌 런타임에 오류 발생 가능성 |
가독성 저하 | 반복적인 캐스팅으로 코드 복잡도 증가 |
2.2.3. 제네릭(Generic) 기반 구현
class Book {}
class GenericBox<T> {
private T[] items;
public GenericBox(T[] items) {
this.items = items;
}
public T getItem(int index) {
return items[index];
}
}
public class Main {
public static void main(String[] args) {
Book[] arr = { new Book(), new Book(), new Book() };
GenericBox<Book> box = new GenericBox<>(arr);
// ✅ 형변환 필요 없음 - 타입 안정성, 성능, 가독성 향상
Book book1 = box.getItem(0);
Book book2 = box.getItem(1);
Book book3 = box.getItem(2);
}
}
2.2.4. 제네릭의 이점 정리
항목 | 설명 |
형변환 제거 | 컴파일 타임에 타입이 결정되어 캐스팅 생략 가능 |
타입 안정성 | 컴파일러가 타입 일치를 검사하므로 에러를 사전에 차단 |
성능 최적화 | 불필요한 형변환 제거 → JVM에서 비용 절감 |
가독성 향상 | 코드가 명확하고 반복적인 캐스팅 제거로 깔끔해짐 |
3. 제네릭 사용 주의사항
3.1. 제네릭 타입의 객체는 생성이 불가 - 왜 new T()는 안 되는 걸까?
- 제네릭 타입 T는 컴파일할 때는 타입이 무엇인지 모른다.
- 제네릭은 "타입을 나중에 결정" 하겠다는 약속일 뿐.
- 그래서 JVM이 컴파일할 때는 T가 구체적인 클래스(예: String, Integer)인지 알 수 없다.
- 결론:
- 컴파일 시점에 타입이 뭔지 모르기 때문에 new T()로 메모리를 만들 수 없다.
3.2. 잘못된 코드 (컴파일 에러 발생)
class Sample<T> {
public void someMethod() {
T t = new T(); // ❌ 에러 발생: 타입 파라미터 T를 인스턴스화 할 수 없음
}
}
Type parameter 'T' cannot be instantiated directly
3.3. 그럼 어떻게 해야 할까? (해결 방법)
3.3.1. 방법 1: 생성자 인자로 받아오기
class Sample<T> {
private T instance;
public Sample(T instance) {
this.instance = instance;
}
public T getInstance() {
return instance;
}
}
Sample<String> sample = new Sample<>("Hello");
System.out.println(sample.getInstance());
- 생성자를 통해 외부에서 만들어진 객체를 받아온다.
- 직접 new T() 하지 않고 해결할 수 있다.
3.3.2. Class<T>를 이용하기
class Sample<T> {
private Class<T> clazz;
public Sample(Class<T> clazz) {
this.clazz = clazz;
}
public T createInstance() throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
}
Sample<String> sample = new Sample<>(String.class);
String str = sample.createInstance(); // 빈 문자열 객체 생성
System.out.println(str);
- Class<T> 객체를 받아서 그걸 이용해 리플렉션(Reflection) 으로 객체를 생성한다.
3.4. T와 Class<T>는 뭐가 다를까?
- T는 실제 객체이고, Class<T>는 객체를 만들거나 다루기 위한 타입 정보(설명서)다!
3.4.1. 간단한 차이 정리
구분 | 설명 | 비유 |
T | "어떤 타입의 객체를 의미" | 물건 자체 (예: 사과, 컵) |
Class<T> | "그 객체가 어떤 타입(설계도) 인지 나타냄" | 물건 설명서 (예: 사과 설명서, 컵 설명서) |
- 간단한 차이 먼저T는 실제로 존재하는 인스턴스(객체)를 의미한다. (진짜 메모리에 만들어진 물건)
- Class<T>는 그 객체가 어떤 타입인지를 나타내는 클래스야.(메모리에 만들어진 게 아니라, "이 물건은 어떤 종류다" 를 설명하는 설명서)
3.4.2. T를 사용하는 예
- 여기서는 이미 만들어진 객체 "hello"를 넘겨주는 것이다.
class Sample<T> {
private T instance; // 어떤 '객체' 자체
public Sample(T instance) { // 이미 만들어진 객체를 받는다
this.instance = instance;
}
public T getInstance() {
return instance;
}
}
Sample<String> sample = new Sample<>("hello"); // String 객체를 넣어줌
System.out.println(sample.getInstance()); // "hello" 출력
3.4.3. Class<T>를 사용하는 예
- 여기서는 타입 정보(String.class) 를 넘겨주고, 코드 안에서 직접 새 객체를 만든다.
class Sample<T> {
private Class<T> clazz; // 타입(Class)을 기억
public Sample(Class<T> clazz) { // 타입 정보를 받는다
this.clazz = clazz;
}
public T createInstance() throws Exception {
return clazz.getDeclaredConstructor().newInstance(); // 새 객체를 생성
}
}
Sample<String> sample = new Sample<>(String.class);
String str = sample.createInstance(); // 빈 문자열 객체 생성
System.out.println(str);
3.4.4. 일반적으로 Class<T>를 넘기지 않는다. 왜일까?
- 제네릭은 "타입"만 제한하는 기능이기 때문에, 타입 정보(Class<T>)까지 넘겨야 할 필요가 없는 경우가 대부분이다.
- 일반적인 제네릭 클래스는 T 타입 객체를 저장하거나 다루기만 하면 된다. (객체를 새로 만들 필요가 없다!)
- 그래서 그냥 기본 생성자 쓰고, 타입은 제네릭 선언에서 결정해버린다.
3.4.5. 일반적인 예시
- 여기는 Class<T> 같은 걸 넘길 필요가 없다.
class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
Box<String> box = new Box<>();
box.set("hello");
String str = box.get();
System.out.println(str);
3.4.6. 그럼 언제 Class<T>를 넘겨야 할까?
- 특수한 상황에서는 "제네릭 타입을 이용해서 새로운 객체를 만들어야" 할 때가 있다.
- 예를 들면:
- 객체를 매번 새로 생성해서 내부적으로 관리해야 한다.
- 또는 복잡한 프레임워크/라이브러리 코드 (ex: DI 컨테이너, ORM) 같은 데서 "타입을 알기만 하고 객체는 나중에 생성"해야 할 때.
- 이때는 Class<T> 타입을 생성자에서 받아서, 그 타입으로 newInstance() 해서 객체를 만들어야 한다.
3.4.7. 특수한 상황 예시
- 여기서는 진짜 "String 객체를 매번 새로 만들" 필요가 있어서 Class<T>를 받아야 한다.
class Factory<T> {
private Class<T> clazz;
public Factory(Class<T> clazz) {
this.clazz = clazz;
}
public T createInstance() throws Exception {
return clazz.getDeclaredConstructor().newInstance();
}
}
Factory<String> factory = new Factory<>(String.class);
String str = factory.createInstance();
System.out.println(str); // 빈 문자열 출력
3.4.8. 최종 정리
- 대부분은 그냥 Sample<T> 만들고 사용한다.
- 객체를 "직접 생성해야" 하는 경우만 Class<T>를 넘긴다.
- 초급에서는 거의 Class<T> 넘길 일 없다!
구분 | 설명 | 예시 |
일반 제네릭 클래스 | 타입을 제한하고, 저장/반환만 한다. | Sample<String> sample = new Sample<>(); |
별도로 타입 정보를 넘길 필요 없음. | ||
특수한 제네릭 클래스 | 객체를 직접 생성해야 하거나, 타입 정보가 꼭 필요한 경우. | Sample<String> sample = new Sample<>(String.class); |
생성자에 Class<T>를 넘겨야 함. |
[일반 제네릭]
T 타입만 제한 → 저장, 반환 OK → new Sample<>();
[특수 제네릭]
T 타입도 기억 + 객체 직접 생성해야 → new Sample<>(String.class);
3.5. static 멤버에 제네릭 타입이 올수 없음
3.5.1. 제네릭 타입은 객체 생성 시점에 결정된다.
- Student<String>, Student<Integer>처럼 인스턴스를 만들 때 T가 무엇인지 결정된다.
- 하지만 static 멤버는 객체가 만들어지기도 전에 존재합니다. 즉, 클래스가 로드되는 순간 이미 메모리에 올라와야 한다.
3.5.2. static 멤버는 모든 인스턴스가 공유한다.
- Student<String> 객체와 Student<Integer> 객체가 있다고 가정한다.
- 만약 static 변수에 제네릭 타입(T)을 쓸 수 있다면, T가 무엇이냐에 따라 자료형이 달라져야 한다.
- 하지만 static은 하나만 존재해야 하므로, 자료형을 하나로 고정할 수 없는 문제가 발생한다.
3.5.3. 타입 안정성(type safety) 문제
- Java는 컴파일 단계에서 타입을 체크한다.
- static 영역에서 제네릭 타입을 쓸 수 있게 허용하면, 서로 다른 타입끼리 충돌할 수 있다.
- 이로 인해 런타임 오류가 발생할 위험이 커지고, 타입 안정성을 보장할 수 없다.
3.5.4. 코드 예시
class Student<T> {
private String name;
private int age = 0;
// 이렇게 하면 컴파일 오류 발생
public static T addAge(int n) {
// 오류: static 메서드는 T 타입이 무엇인지 알 수 없음
return null;
}
// 이 경우도 마찬가지
public static void addAge(T n) {
// 오류: static 메서드는 T 타입이 무엇인지 알 수 없음
}
}
- 컴파일 에러 메시지 예시:
- Non-static type variable T cannot be referenced from a static context.
- 해석:
- "static 영역에서는 T 타입을 참조할 수 없다."
3.5.5. 그러면 static 영역에서는 제네릭을 어떻게 사용할 수 있을까?
3.5.5.1. static 메서드 자체에 별도로 제네릭 타입을 선언하면 된다.
class Student<T> {
private String name;
private int age = 0;
// 메서드에 별도로 제네릭 타입 선언
public static <U> U returnSomething(U input) {
return input;
}
}
- 이렇게 하면 static 메서드가 자체적으로 새로운 제네릭 타입 <U>를 선언해서 사용할 수 있다.
- 중요한 점은 이 <U>는 클래스의 제네릭 <T>와는 무관하다는 것이다.
3.5.5.2. static 변수는 고정 타입으로 선언해야 한다.
class Student<T> {
private String name;
private int age = 0;
// static 변수는 명확한 타입으로 선언
private static int studentCount = 0;
public Student() {
studentCount++;
}
public static int getStudentCount() {
return studentCount;
}
}
- static 변수는 int, String, List<String>처럼 구체적인 타입으로 고정해야 한다.
- 제네릭 타입은 절대 사용할 수 없다.
3.5.6. 정리
구분 | 설명 | 가능 여부 |
static 변수에 제네릭 타입 사용 | 클래스 로딩 시 타입 미정 → 논리적 오류 | ❌ 불가 |
static 메서드 반환 타입/파라미터에 클래스 제네릭 타입(T) 사용 | static은 클래스 차원, T는 인스턴스 차원 → 타입 미정 | ❌ 불가 |
static 메서드 내부에서 별도 제네릭 선언 | static 메서드 안에서만 사용하는 별도 타입 선언 | ✅ 가능 |
3.6. 제네릭으로 배열 선언 주의점
- 기본적으로 제네릭 클래스 자체를 배열로 만들 수는 없다.
3.6.1. 기본적인 문제: 왜 제네릭 클래스를 직접 배열로 만들 수 없는가?
class Sample<T> {
}
public class Main {
public static void main(String[] args) {
Sample<Integer>[] arr1 = new Sample<>[10];
}
}
Sample<Integer>[] arr1 = new Sample<Integer>[10]; // ❌ 오류 발생
3.6.1.1. 이유
- Java의 배열은 런타임에도 타입 정보를 유지한다.
- 배열은 생성될 때, 자신이 어떤 타입의 요소를 저장하는지 정확히 알고 있어야 한다.
- 예를 들어 new String[10] 배열은 실제로 메모리에 "String 타입의 10개짜리 배열"로 생성된다.
- 이 타입 정보는 런타임에 강력하게 보장된다.
- 반면, 제네릭 타입은 컴파일 시점에 타입 정보가 소거(Erasure)된다.
- Java에서는 타입 소거(Type Erasure) 라는 특징 때문에, 컴파일 후에는 Sample<Integer>나 Sample<String> 모두 그냥 Sample로 변한다.
- 즉, 런타임에는 Sample<Integer> 인지 Sample<String> 인지 알 수가 없습니다.
- 런타임 타입 불일치 위험
- 배열은 런타임에 타입을 검사하는데, 제네릭 타입은 런타임에 어떤 타입인지 몰라서 검사할 수 없다.
- 예를 들어, Sample<Integer>[] 배열을 만들었지만, Sample<String> 객체를 넣어버려도, 런타임에서는 이를 막을 방법이 없다.
- Java는 이런 타입 안정성(type safety) 문제를 원천 차단하려고, 제네릭 타입의 직접적인 배열 생성을 금지했다.
3.6.1.2. 정리
-배열은 런타임 타입 검사를 해야 하지만, 제네릭은 타입이 런타임에 사라지기 때문에 배열로 직접 생성하면 타입 안정성을 보장할 수 없다.
3.6.2. 그렇다면, 왜 제네릭 타입의 배열 선언은 허용될까?
Sample<Integer>[] arr2 = new Sample[10]; // ✅ 가능
- 배열을 new Sample[10] 으로 생성했다.
- 즉, 구체적인 타입 없이 Sample 클래스 객체만 저장할 수 있는 배열을 만든 것이다.
- 그리고 변수 선언부 Sample<Integer>[] arr2 에서 "이 배열은 Sample<Integer> 타입만 담겠다"고 약속한다.
- 따라서, 배열은 실제 메모리상으로는 Sample 타입으로 존재합니다. (Sample[])
- 컴파일러는 Sample<Integer>[] 로 취급하여 타입을 체크해줍니다.
- 배열 자체는 "타입 없는 Sample"로 만들어 놓고, 사용하는 코드 레벨에서 "타입 있는 Sample<Integer>"만 넣도록 규칙을 강제하는 것이다.
class Sample<T> {
}
public class Main {
public static void main(String[] args) {
// new Sample<Integer>() 인스턴스만 저장하는 배열을 나타냄
Sample<Integer>[] arr2 = new Sample[10];
// 제네릭 타입을 생략해도 위에서 이미 정의했기 때문에 Integer 가 자동으로 추론됨
arr2[0] = new Sample<Integer>();
arr2[1] = new Sample<>();
// ! Integer가 아닌 타입은 저장 불가능
arr2[2] = new Sample<String>();
}
}
3.6.3. 그럼 이런 코드는 왜 문제가 되는가?
arr2[2] = new Sample<String>(); // ❌ 문제 발생 (타입 불일치)
- arr2는 Sample<Integer>[]로 선언됐기 때문에, 원칙적으로 Sample<String>을 넣으면 안된다.
- 컴파일러는 이것을 감지해서 오류를 발생시킨다.
- 만약 강제로 넣는다면, 런타임에서 ClassCastException 같은 심각한 오류를 일으킬 수 있다.
3.6.4. 요약
구분 | 설명 | 가능 여부 |
new Sample<Integer>[10] 배열 생성 | 제네릭은 런타임 타입 정보가 없어 타입 안정성을 깰 수 있음 | ❌ 불가 |
new Sample[10] 배열 생성 후 타입 제한 (Sample<Integer>[]) | 배열은 Sample 타입으로 만들고, 컴파일 시 타입 체크로 제한 | ✅ 가능 |
3.6.5. 추가 Tip
- 타입 안전성을 더 확실히 하고 싶다면, List 사용을 추천한다.
- 배열보다 ArrayList<Sample<Integer>>를 쓰는 게 낫다.
- ArrayList는 제네릭 타입을 끝까지 유지해서 타입 안정성을 보장할 수 있다.
List<Sample<Integer>> list = new ArrayList<>();
list.add(new Sample<Integer>()); // 안전
list.add(new Sample<String>()); // 컴파일 에러
- 어쩔 수 없이 배열을 써야 할 경우는 다음과 같은 패턴을 사용한다.
@SuppressWarnings("unchecked") // 경고 무시
Sample<Integer>[] arr = (Sample<Integer>[]) new Sample[10];
- 이 경우 타입 캐스팅이 필요하고, 컴파일러 경고를 수동으로 무시해야 한다.
- 하지만 타입 오류를 낼 가능성이 있으니, 꼭 주의해서 관리해야 한다.
4. 제네릭 객체 만들어보기
4.1. 제네릭 클래스
- 클래스 선언문 옆에 제네릭 타입 매개변수가 쓰이면, 이를 제네릭 클래스라고 한다.
// 제네릭 클래스를 정의한다.
class Box<U> {
private U item; // 멤버 변수 item의 타입은 U이다.
// U 타입의 값을 반환한다.
public U getItem() {
return item;
}
// U 타입의 값을 멤버 변수 item에 저장한다.
public void setItem(U item) {
this.item = item;
}
}
public class Main {
public static void main(String[] args) {
// 정수형을 다루는 제네릭 클래스
Box<Integer> box1 = new Box<>();
box1.setItem(100);
// 실수형을 다루는 제네릭 클래스
Box<Double> box2 = new Box<>();
box2.setItem(99.9);
// 문자열을 다루는 제네릭 클래스
Box<String> box3 = new Box<>();
box3.setItem("Hello");
}
}
4.2. 제네릭 인터페이스
- 인터페이스에도 제네릭을 적용 할 수 있다. 단, 인터페이스를 implements 한 클래스에서도 오버라이딩한 메서드를 제네릭 타입에 맞춰서 똑같이 구현해 주어야 한다.
interface IStorage<U> {
void store(U item, int index);
U retrieve(int index);
}
class Storage<U> implements IStorage<U> {
private U[] items;
@SuppressWarnings("unchecked")
public Storage() {
items = (U[]) new Object[10];
}
@Override
public void store(U item, int index) {
items[index] = item;
}
@Override
public U retrieve(int index) {
return items[index];
}
}
public static void main(String[] args) {
Sample<String> sample = new Sample<>();
sample.addElement("This is string", 5);
sample.getElement(5);
}
4.3. 제네릭 함수형 인터페이스
- 특히 제네릭 인터페이스가 정말 많이 사용되는 곳이 바로 람다 표현식의 함수형 인터페이스이다.
4.3.1. 함수형 인터페이스(Functional Interface)란?
- 단 하나의 추상 메서드만 가지는 인터페이스를 말한다.
- Java에서는 함수형 인터페이스를 통해 람다 표현식(lambda) 을 사용할 수 있다.
- 예시: Runnable, Comparator<T>, Consumer<T>, Function<T, R> 등
- @FunctionalInterface 어노테이션은 "이 인터페이스는 함수형 인터페이스여야 한다"고 컴파일러에게 알려준다. (실수로 2개 이상의 추상 메서드를 만들면 오류 발생)
@FunctionalInterface
interface MyFunction {
void execute(); // 단 하나의 추상 메서드
}
4.3.1. 제네릭 함수형 인터페이스란?
- 타입 파라미터(T, U, R 등)를 받아서, 다양한 타입에 대해 사용할 수 있도록 일반화(generic)한 함수형 인터페이스이다.
- 즉, 함수형 인터페이스에 제네릭(Generic) 을 적용한 형태이다.
- "다양한 타입에 대해 람다를 유연하게 쓸 수 있도록 만들어주는 인터페이스"
4.3.2. 기본 제네릭 함수형 인터페이스 만들기
// 제네릭 타입을 받는 함수형 인터페이스
@FunctionalInterface
interface Converter<T, R> {
R convert(T t); // 입력 타입 T를 받아서 결과 타입 R을 반환하는 추상 메서드
}
- T : 입력 타입 (Input Type)
- R : 반환 타입 (Return Type)
4.3.3. 기본 제네릭 함수형 인터페이스 만들기
public class Main {
public static void main(String[] args) {
// String을 Integer로 변환하는 Converter 구현
Converter<String, Integer> stringToInteger = (String str) -> Integer.parseInt(str);
// 사용
int result = stringToInteger.convert("123");
System.out.println("변환된 정수: " + result); // 출력: 변환된 정수: 123
// Double을 String으로 변환하는 Converter 구현
Converter<Double, String> doubleToString = (Double d) -> String.format("%.2f", d);
// 사용
String result2 = doubleToString.convert(3.14159);
System.out.println("변환된 문자열: " + result2); // 출력: 변환된 문자열: 3.14
}
}
4.3.4. 입력과 출력이 같은 경우
// 타입 하나만 쓰는 제네릭 함수형 인터페이스
@FunctionalInterface
interface Processor<T> {
T process(T t); // 입력 T를 받아서 T를 반환
}
public class Main2 {
public static void main(String[] args) {
// 문자열을 대문자로 변환하는 Processor 구현
Processor<String> toUpperCase = (str) -> str.toUpperCase();
String result = toUpperCase.process("hello world");
System.out.println(result); // 출력: HELLO WORLD
// 정수에 10을 더하는 Processor 구현
Processor<Integer> addTen = (num) -> num + 10;
int numberResult = addTen.process(5);
System.out.println(numberResult); // 출력: 15
}
}
// 제네릭으로 타입을 받아, 해당 타입의 두 값을 더하는 인터페이스
interface IAdd<T> {
public T add(T x, T y);
}
public class Main {
public static void main(String[] args) {
// 제네릭을 통해 람다 함수의 타입을 결정
IAdd<Integer> o = (x, y) -> x + y; // 매개변수 x와 y 그리고 반환형 타입이 int형으로 설정된다.
int result = o.add(10, 20);
System.out.println(result); // 30
}
}
4.3.5. 추가Tip
- Java 8부터 제공하는 Function<T, R>, Consumer<T>, Supplier<T>, Predicate<T> 등은 모두 제네릭 함수형 인터페이스이다.
인터페이스 | 의미 |
Function<T, R> | T를 받아서 R을 반환 |
Consumer<T> | T를 받아서 소비하고 반환 없음 |
Supplier<T> | 입력 없이 T를 제공 |
Predicate<T> | T를 받아서 true/false 반환 |
4.4. 제네릭 메서드
- 제네릭 클래스에서 제네릭 타입 파라미터를 사용하는 메서드를 제네릭 메서드라고 착각하기 쉬운데, 이것은 그냥 타입 파라미터로 타입을 지정한 메서드일 뿐이다.
class FruitBox<T> {
public T addBox(T x, T y) {
// ...
}
}
- 제네릭 메서드란, 메서드의 선언부에 <T> 가 선언된 메서드를 말한다.
- 위에서는 클래스의 제네릭 <T> 에서 설정된 타입을 받아와 반환 타입으로 사용할 뿐인 일반 메서드라면, 제네릭 메서드는 직접 메서드에 <T> 제네릭을 설정함으로서 동적으로 타입을 받아와 사용할 수 있는 독립적으로 운용 가능한 제네릭 메서드라고 이해하면 된다.
class FruitBox<T> {
// 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
public T addBox(T x, T y) {
// ...
}
// 독립적으로 타입 할당 운영되는 제네릭 메서드
public static <T> T addBoxStatic(T x, T y) {
// ...
}
}
4.4.1. 사용 예시
// 제네릭 클래스
class FruitBox<T> {
// 1. 일반 메서드: 클래스 타입 T를 사용
public T combine(T x, T y) {
System.out.println("클래스 타입 T를 사용한 combine 호출");
return x; // 단순 예시: 첫 번째 값 반환
}
// 2. 제네릭 메서드: 메서드에 별도로 타입 파라미터 <U>를 선언
public static <U> U combineStatic(U x, U y) {
System.out.println("제네릭 메서드 combineStatic 호출");
return y; // 단순 예시: 두 번째 값 반환
}
}
public class Main {
public static void main(String[] args) {
// FruitBox 클래스 인스턴스 생성 (클래스 제네릭 타입: String)
FruitBox<String> fruitBox = new FruitBox<>();
// 1. 일반 메서드 호출 (FruitBox<String> 이므로 T = String)
String result1 = fruitBox.combine("Apple", "Banana");
System.out.println("combine 결과: " + result1); // 출력: Apple
// 2. static 제네릭 메서드 호출
// 타입 파라미터 U는 호출할 때 자동 추론됨
Integer result2 = FruitBox.combineStatic(100, 200);
System.out.println("combineStatic 결과: " + result2); // 출력: 200
// static 메서드는 클래스 타입과 관계없이 별도로 타입을 지정할 수 있음
Double result3 = FruitBox.combineStatic(3.14, 6.28);
System.out.println("combineStatic 결과: " + result3); // 출력: 6.28
}
}
4.4.2. 제네릭 메서드 호출 원리
FruitBox.<Integer>addBoxStatic(1, 2);
FruitBox.<String>addBoxStatic("안녕", "잘가");
- <Integer> 혹은 <String>으로 선언된 제네릭 타입은 FruitBox 클래스의 combineStatic 클래스의 <U>에 정의된다.
- 컴파일러는 제네릭 타입에 들어갈 데이터 타입을 메소드의 매개변수를 이용해 추정할 수 있다.
- 대부분의 경우 제네릭 메서드의 타입 파라미터를 생략하고 호출할 수 있다.
// 메서드의 제네릭 타입 생략
FruitBox.addBoxStatic(1, 2);
FruitBox.addBoxStatic("안녕", "잘가");
- 클래스 옆에 붙어있는 제네릭과 제네릭 메소드는 똑같은 <T> 인데 어떻게 제네릭 메서드만이 독립적으로 운용될까?
- 처음 제네릭 클래스를 인스턴스화하면, 클래스 타입 매개변수에 전달한 타입에 따라 제네릭 메소드도 타입이 정해지게 된다.
- 만약 제네릭 메서드를 호출할때 직접 타입 파라미터를 다르게 지정해주거나, 다른 타입의 데이터를 매개변수에 넘기게 되면 처음 인스턴스화 할때 정의된 타입이 아니라, 메서드 호출시 정의한 독립적인 타입을 가진 제네릭 메서드로 운용되게 된다.
// 제네릭 클래스 선언 (클래스 타입 파라미터 A, B)
class PairBox<A, B> {
// 제네릭 메서드 선언 (메서드 자체에 별도로 타입 파라미터 X, Y 선언)
public <X, Y> void displayTypes(X first, Y second) {
// 매개변수의 실제 타입을 출력
System.out.println("첫 번째 값의 타입: " + first.getClass().getSimpleName());
System.out.println("두 번째 값의 타입: " + second.getClass().getSimpleName());
}
}
public class Main {
public static void main(String[] args) {
// 클래스 타입 파라미터를 Integer, Double로 지정하여 객체 생성
PairBox<Integer, Double> box = new PairBox<>();
// 클래스 타입 파라미터 (Integer, Double)과 무관하게
// 제네릭 메서드는 호출 시 타입을 독립적으로 지정할 수 있다.
// 타입을 명시하지 않고 호출 (컴파일러가 자동 추론)
box.displayTypes(100, 3.14);
// 타입을 명시하고 호출
box.<String, Boolean>displayTypes("Hello", true);
// 타입 명시 생략 (컴파일러가 String, Boolean 자동 추론)
box.displayTypes("World", false);
}
}
5. 제네릭 타입 범위 한정하기
- 제네릭에 타입을 지정해줌으로서 클래스의 타입을 컴파일 타임에서 정하여 타입 예외에 대한 안정성을 확보하는 것은 좋지만 문제는 너무 자유롭다는 점이다.
- 예를 들면 T를 쓰면 Integer, String, Object 등 무엇이든 들어올 수 있다.
- 하지만 특정 타입이나 특정 타입의 하위 타입만 허용하고 싶을 때가 있다.
- 이때 사용하는 것이 "타입 제한" 또는 "타입 범위 한정(Bounded Type)" 이다.
<T extends 상위클래스>
- T는 반드시 상위클래스이거나, 그 하위 타입이어야 한다.
- 여기서 상위클래스는 '부모 클래스'이고 하위 타입은 그 상위클래스를 extends(상속) 받은 '자식 클래스' 이다.
5.1. 예시 1
class Animal {
void sound() {
System.out.println("Animal sound");
}
}
class Dog extends Animal {
void bark() {
System.out.println("Dog barks");
}
}
class Cat extends Animal {
void meow() {
System.out.println("Cat meows");
}
}
- Animal → 상위 클래스 (부모)
- Dog, Cat → 하위 클래스 (자식)
5.1.1. 제네릭을 사용 예시
class Box<T extends Animal> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public T get() {
return animal;
}
}
- 여기서 T는 무조건 Animal이거나 Animal을 상속받은 타입이어야 한다.
- 그러니까 Box<Animal>, Box<Dog>, Box<Cat>은 가능하다.
Box<Dog> dogBox = new Box<>();
dogBox.set(new Dog());
Box<Cat> catBox = new Box<>();
catBox.set(new Cat());
Box<Animal> animalBox = new Box<>();
animalBox.set(new Animal());
- 하지만 이런 건 불가능하다.
Box<String> stringBox = new Box<>();
// 컴파일 에러! String은 Animal을 상속받지 않았기 때문
5.1.2. 요약
상위클래스 | 하위클래스 (타입) |
Animal | Dog, Cat |
- T extends Animal 이라면 T는 Animal 자신이거나, Animal을 상속한 Dog, Cat 같은 클래스여야 한다는 것!
- String이나 Integer 같은 다른 타입은 안 된다.
- extends는 직접 상속받은 것(Dog → Animal), 그리고 자기 자신(Animal → Animal) 모두 포함한다.
5.2. 예시 2
- 정수, 실수 구분없이 모두 받을 수 있게 하기위해 제네릭으로 클래스를 만들었지만, 단순히 <T> 로 지정하게 되면 숫자에 관련된 래퍼 클래스 뿐만 아니라 String이나 다른 클래스들도 대입이 가능하다는 점이 문제이다.
// 숫자만 받아 계산하는 계산기 클래스 모듈
class Calculator<T> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 아무 타입이나 모두 할당이 가능
Calculator<Number> cal1 = new Calculator<>();
Calculator<Object> cal2 = new Calculator<>();
Calculator<String> cal3 = new Calculator<>();
Calculator<Main> cal4 = new Calculator<>();
}
}
- 개발자의 의도로는 계산기 클래스의 제네릭 타입 파라미터로 Number 자료형만 들어오도록 하고 문자열이나 또 다른 클래스 자료형이 들어오면 안된다. 이때 사용 가능한 것이 제한된 타입 매개변수 (Bounded Type Parameter) 이다.
5.3. 타입 한정 키워드 extends
- 기본적인 용법은 <T extends [제한타입]> 이다. 제네릭 <T> 에 extends 키워드를 선언하여 <T extends Number> 제네릭을 Number 클래스와 그 하위 타입(Integer, Double)들만 받도록 타입 파라미터 범위를 제한 할 수 있다.
// 숫자만 받아 계산하는 계산기 클래스 모듈
class Calculator<T extends Number> {
void add(T a, T b) {}
void min(T a, T b) {}
void mul(T a, T b) {}
void div(T a, T b) {}
}
public class Main {
public static void main(String[] args) {
// 제네릭에 Number 클래스만 바로들 수 있다.
Calculator<Number> cal1 = new Calculator<>();
Calculator<Integer> cal2 = new Calculator<>();
Calculator<Double> cal3 = new Calculator<>();
// Number 이외의 클래스들은 오류!!
// Calculator<Object> cal4 = new Calculator<>();
// Calculator<String> cal5 = new Calculator<>();
// Calculator<Main> cal6 = new Calculator<>();
}
}
- T extends Number → T는 Number 또는 Number를 상속한 타입(Integer, Double, Float, Long, ...)만 가능하다
- Object, String, Main 같은 것은 Number와 관계없는 타입이기 때문에 제네릭 타입으로 지정하면 컴파일 에러가 발생한다.
5.3.1. 인터페이스 타입 한정
- extends 키워드 다음에 올 타입은 일반 클래스, 추상 클래스, 인터페이스 모두 올 수 있다.
// 인터페이스 정의
interface Writable {
}
// 인터페이스를 구현하는 클래스
public class Teacher implements Writable {
}
// 인터페이스 Writable을 구현한 타입만 제네릭으로 허용하는 클래스
public class Academy<T extends Writable> {
}
public class Main {
public static void main(String[] args) {
// 타입 파라미터에 인터페이스를 구현한 클래스만 사용할 수 있다
Academy<Teacher> academy = new Academy<>();
}
}
5.3.2. 다중 타입 한정
- 만일 2개 이상의 타입을 동시에 상속(구현)한 경우로 타입 제한하고 싶다면, & 연산자를 이용하면 된다. 해당 인터페이스들을 동시에 구현한 클래스가 제네릭 타입의 대상이 되게 된다.
- 자바에서는 다중 상속을 지원하지 않기 때문에 클래스로는 다중 extends는 불가능하고 오로지 인터페이스로만이 가능하다.
import java.util.ArrayList;
import java.util.List;
// 인터페이스 정의
interface Printable {}
interface Scannable {}
// 두 인터페이스를 모두 구현하는 클래스
class Document implements Printable, Scannable {}
// 두 인터페이스를 모두 구현한 타입만 제네릭으로 받을 수 있는 클래스
class Folder<T extends Printable & Scannable> {
List<T> files = new ArrayList<>();
public void addFile(T file) {
files.add(file);
}
}
public class Main {
public static void main(String[] args) {
// Printable과 Scannable을 모두 구현한 타입만 가능
Folder<Document> folder = new Folder<>();
// ❌ Object는 Printable과 Scannable을 구현하지 않았기 때문에 오류 발생
// Folder<Object> folder2 = new Folder<>(); // 컴파일 에러
}
}
- 제네릭이 여러개인 다중 타입 파라미터를 사용할 경우에도 각각 다중 제한을 거는 것도 가능하다.
interface Movable {}
interface Flyable {}
interface Swimmable {}
interface Runnable {}
class Vehicle<T extends Movable & Flyable, U extends Swimmable & Flyable & Runnable> {
void operate(T airVehicle, U waterVehicle) {
// 메서드 내부 동작 예시는 생략
}
}
5.3.3. 클래스 vs 인터페이스 한정 차이
항목 | 클래스로 한정할 때 | 인터페이스로 한정할 때 |
의미 | 해당 클래스를 상속하거나, 그 하위 클래스를 의미 | 해당 인터페이스를 구현한 타입을 의미 |
허용되는 타입 | 부모 클래스 or 자식 클래스 | 인터페이스를 구현한 클래스만 |
목적 | 상속 구조 관리 | 기능(메서드) 보장 |
5.4. 재귀적 타입 한정
5.4.1. 기본 개념
- 재귀적 타입 한정이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 말한다. 실무에선 주로 Comparable 인터페이스와 함께 쓰인다.
<E extends Comparable<E>>
- 이 표현은 다음을 의미한다:
- "E는 Comparable<E> 를 구현해야 한다."
- 즉, E 타입 객체끼리 서로 비교할 수 있는 타입이어야 한다.
- 타입 E는 자기 자신을 서브 타입으로 구현한 Comparable 구현체로 한정' 한다는 뜻이다.
5.4.1.1. Comparable
- Comparable는 객체끼리 비교를 해야 할때 compareTo() 메서드를 오버라이딩할때 구현하는 인터페이스이다.
- 자바에서 Integer, Double, String 등이 값 비교가 되는 이유가 기본적으로 Comparable를 구현하고 있기 때문이다.
5.4.2. 왜 이런 복잡한 표현을 쓸까?
- 실무에서는 객체끼리 비교(compareTo) 해야 할 일이 매우 많다. (정렬, 최대/최소 구하기 등)
- 그런데, 아무 타입이나 비교할 수는 없다. → 비교할 수 있는 기능(compareTo 메서드) 이 구현된 객체만 비교해야 한다.
- 그래서 Comparable<E>를 구현한 객체만 받겠다고 타입 제한을 거는 것이다.
- 그냥 Comparable만 구현했다는 것만으로는 부족하다. 타입이 정확히 E 자신과 비교 가능한 것만 허용해야 한다.
타입 | 비교 가능 여부 | 이유 |
Integer | 가능 | Comparable<Integer> 구현 |
String | 가능 | Comparable<String> 구현 |
Object | 불가능 | Comparable 구현 안 함 |
List | 불가능 | Comparable 구현 안 함 |
Number | 불가능 | Comparable 구현 안 함 |
5.4.3. 예시
class Compare {
// 외부로 들어온 타입 E는 Comparable<E>를 구현한 E 객체 이어야 한다.
public static <E extends Comparable<E>> E max(Collection<E> collection) {
if(collection.isEmpty()) throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
E result = null;
for(E e: collection) {
if(result == null) {
result = e;
continue;
}
if(e.compareTo(result) > 0) {
result = e;
}
}
return result;
}
}
public static void main(String[] args) {
Collection<Integer> list = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
System.out.println(Compare.max(list)); // 91
Collection<Number> list2 = Arrays.asList(56, 34, 12, 31, 65, 77, 91, 88);
System.out.println(Compare.max(list2)); // ! Error - Number 추상 메서드는 Comparable를 구현하지않았기 때문에 불가능
}
5.4.4. Tip
실무 상황 | 재귀적 타입 한정 사용 예시 |
컬렉션에서 최대값/최소값 찾기 | max(Collection<E>), min(Collection<E>) |
여러 객체를 정렬해야 할 때 | Collections.sort(List<E>) |
특정 기준에 따라 대상을 찾을 때 | 비교 가능한 타입만 다루는 메서드 만들기 |
5.4.5. 요약
항목 | 설명 |
핵심 개념 | 타입 E는 자기 자신과 비교할 수 있어야 한다 (Comparable<E>) |
목적 | 비교 가능한 타입만 다루기 위함 |
실무 활용 | 정렬, 최대/최소값 찾기, 비교 기반 로직에 필수 |
문법 | <E extends Comparable<E>> |
6. 제네릭 형변환
6.1. 핵심 개념
- 배열(Array)과 제네릭(Generic)의 다형성은 다르게 작동한다.
항목 | 배열 (Array) | 제네릭 (Generic) |
다형성 가능 여부 | 상속 관계라면 자동으로 가능 (업캐스팅) | 타입 파라미터가 다르면 상속 관계라도 불가능 |
타입 검사 시점 | 런타임 (Runtime) | 컴파일타임 (Compile Time) |
타입 안전성 | 불완전 (ArrayStoreException 가능) | 강력한 타입 안전성 보장 |
6.1.1. 배열 예시 (OK)
Object[] arr = new Integer[3]; // OK
arr[0] = 1; // OK
arr[1] = "String"; // ⚠️ 런타임 오류 (ArrayStoreException 발생 가능)
- 배열은 상속관계(다형성) 를 허용한다 (Integer[]는 Object[]로 간주 가능).
- 하지만 이 때문에 런타임에 타입 오류가 발생할 수 있다.
6.1.2. 제네릭 예시 (에러)
List<Object> list = new ArrayList<Integer>(); // ❌ 컴파일 에러
- List<Integer> 와 List<Object> 는 전혀 상속관계가 아니다.
- 제네릭은 타입 안전성(type safety) 을 강력히 보장하기 위해 다형성을 막는다.
- 컴파일 단계에서 확실히 타입이 맞지 않으면 에러를 낸다.
List<Object> listObj = null;
List<String> listStr = null;
// 에러. List<String> -> List<Object>
listObj = (List<String>) listStr;
// 에러. List<Object> -> List<String>
listStr = (List<Object>) listObj;
- 제네릭 객체에 요소를 넣거나 가져올때, 캐스팅 문제로 인해 애로사항이 발생한다.
6.2. 왜 제네릭은 다형성을 허용하지 않는가?
- 목적: 타입 안전성
- 만약 허용하면, 문제가 발생한다.
6.2.1. 만약 허용된다면, 문제 발생 예시
- 런타임 오류 발생 (ClassCastException)
- 그래서 Java는 "타입이 다르면, 제네릭끼리는 절대 호환되지 않는다" "컴파일 에러로 미리 막아버린다" 는 강력한 원칙을 지킨다.
List<Object> list = new ArrayList<Integer>(); // 허용됐다고 가정
list.add("문자열"); // List<Object>니까 문자열 넣어도 문제 없어 보임
Integer num = (Integer) list.get(0); // 그러나 실제 저장된 것은 문자열!
6.3. 왜 배열은 괜찮은 것처럼 보였나?
Apple[] apples = new Apple[3];
Fruit[] fruits = apples; // OK (Apple is-a Fruit)
fruits[0] = new Banana(); // ⚠️ 런타임 에러 (ArrayStoreException)
- 배열은 런타임에 타입 정보를 유지하기 때문이다. (Apple[]은 런타임에도 Apple로 인식됨)
- 그렇지만 실제로는 굉장히 위험한 방식이에요.
- Java에서는 배열은 런타임에 타입 체크하고, 제네릭은 컴파일타임에 타입 체크합니다.
Fruit[] fruits = apples;
- fruits는 apples 배열을 가리키는 참조 변수이다.
- apples는 Apple[] 타입의 배열이고, 따라서 fruits도 실제로는 Apple[]를 참조하고 있는 것이다.
- 즉, fruits는 변수 타입만 Fruit[]일 뿐, "배열 자체는 여전히 Apple[] 타입" 인 것이다.
6.4. 예제 분석
- 배열 같은 경우 반복문의 변수로 Object 타입으로 받아 사용해도 문제가 없다.
public static void main(String[] args) {
Apple[] integers = new Apple[]{
new Apple(),
new Apple(),
new Apple(),
};
print(integers);
}
public static void print(Fruit[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
- 하지만 위의 코드에서 배열을 리스트의 제네릭으로 바꾸면 컴파일 에러가 발생한다.
public static void main(String[] args) {
List<Integer> lists = new ArrayList<>(Arrays.asList(1, 2, 3));
print(lists); // ! 컴파일 에러 발생
}
public static void print(List<Object> list) {
for (Object e : list) {
System.out.println(e);
}
}
- 배열 같은 경우 print 메서드의 매개변수로 아규먼트가 넘어갈때 Integer[] 배열 타입이 Object[] 배열 타입으로 업캐스팅 되어 문제가 없지만, 제네릭 같은 경우 타입 파라미터가 항상 똑같은 타입만 받기 때문에 다형성을 이용할수 없어서이다.
7. 제네릭 와일드 카드
7.1. <?> : Unbounded Wildcard (제한 없음)
7.1.1. 개념
- <?>는 모든 타입의 객체를 나타내는 와일드카드이다.
- 타입 파라미터를 대치하는 구체적인 타입으로 모든 클래스나 인터페이스 타입이 올 수 있다.
7.1.2. 사용 목적
- 메서드가 어떤 타입의 객체든 받아들일 수 있도록 할 때 사용한다.
- 타입에 관계없이 컬렉션의 요소를 읽기만 할 때 유용하다.
- API가 타입 독립적으로 동작해야 할 때
- 다양한 타입을 받되, 그 안에 삽입할 일은 없을 때
7.1.3. 사용 시기
- 컬렉션의 요소를 읽기만 하고, 추가하거나 수정하지 않을 때 사용한다.
- 타입에 관계없이 컬렉션을 순회하거나 출력할 때 유용하다.
7.1.4. 장점
- 다형성을 살릴 수 있다. (다양한 타입을 받아 처리할 수 있음)
- 읽기 전용(Read-Only) 용도로 안전하다.
- 메서드 파라미터 수용 범위를 넓힐 수 있다. (타입을 하나로 고정하지 않아도 됨)
7.1.5. 제약사항
- 요소를 추가할 수 없다 (단, null은 예외적으로 추가 가능)
- 요소를 읽을 수는 있지만, 읽어도 Object 타입으로만 얻는다
- 타입을 모르는 상태라 구체적 연산을 할 수 없다.
7.1.4. 예제
7.1.4.1. 기본
public void printList(List<?> list) {
for (Object elem : list) {
System.out.println(elem);
}
}
7.1.4.2. 시나리오 1: 데이터 로거 (읽기 전용 리스트 인쇄)
- 상황: 시스템에서 다양한 타입 리스트를 읽어서 로깅하고 싶은 경우.
- <?>를 써서 List<String>, List<Integer> 모두 받을 수 있다.
- 읽기만 하며, 삽입은 금지된다.
import java.util.List;
public class DataLogger {
// 어떤 타입의 리스트든 받아서 출력
public static void logAll(List<?> list) {
for (Object item : list) {
System.out.println("Log: " + item);
}
}
public static void main(String[] args) {
List<String> stringList = List.of("apple", "banana", "cherry");
List<Integer> intList = List.of(1, 2, 3);
logAll(stringList);
logAll(intList);
}
}
7.1.4.3. 시나리오 2: 파일 업로더의 메타데이터 조회
- 상황: 파일 업로드 API에서 다양한 메타데이터(Map, DTO 등)를 받아야 함.
- 메타데이터 타입이 String, Integer, Custom 객체 등 다양할 수 있어 <?> 사용한다.
- 삽입 없이 조회만 하므로 안전하다.
import java.util.List;
public class FileUploader {
// 메타데이터를 출력 (읽기만 함)
public void printMetadata(List<?> metadataList) {
for (Object metadata : metadataList) {
System.out.println("Metadata: " + metadata);
}
}
public static void main(String[] args) {
FileUploader uploader = new FileUploader();
uploader.printMetadata(List.of("Filename", "Size", "Type"));
uploader.printMetadata(List.of(1024, 2048, 4096));
}
}
7.1.4.4. 시나리오 3: API 응답의 다양한 결과를 하나로 포장
- 상황: REST API 서버가 다양한 결과 타입을 보내는데, 이를 통합 핸들링할 때.
- 다양한 응답 타입(String, Integer 등) 핸들링할 때 사용한다.
- 응답 결과를 수정하거나 추가할 필요는 없으므로 <?>가 적합하다.
import java.util.List;
public class ApiResponseHandler {
// 어떤 타입의 응답이든 받아서 처리
public void handleResponse(List<?> response) {
for (Object item : response) {
System.out.println("Response item: " + item.toString());
}
}
public static void main(String[] args) {
ApiResponseHandler handler = new ApiResponseHandler();
handler.handleResponse(List.of("OK", "Created", "Accepted"));
handler.handleResponse(List.of(200, 201, 202));
}
}
7.1.4.5. 시나리오 4: UI 컴포넌트의 항목 렌더링
- 상황: 프론트엔드에서 다양한 데이터 리스트를 받아 렌더링할 때.
- <?> 덕분에 다양한 타입의 항목을 렌더링할 수 있다.
- 렌더링 과정에서 수정이 필요 없다.
import java.util.List;
public class UIComponentRenderer {
// 항목 렌더링 (타입 독립적)
public void renderItems(List<?> items) {
for (Object item : items) {
System.out.println("Rendering item: " + item);
}
}
public static void main(String[] args) {
UIComponentRenderer renderer = new UIComponentRenderer();
renderer.renderItems(List.of("Button", "Input", "Checkbox"));
renderer.renderItems(List.of(101, 102, 103));
}
}
7.1.4.6. 시나리오 5: 알림 시스템 (이벤트 로그 목록 처리)
- 상황: 알림 시스템에서 다양한 타입 이벤트 로그를 수집하고 출력.
- 이벤트가 다양한 타입(String, Integer 등)일 수 있어 <?> 사용.
- 읽기 전용으로 안전하게 처리.
import java.util.List;
public class NotificationService {
public void processEventLogs(List<?> eventLogs) {
for (Object log : eventLogs) {
System.out.println("Event Log: " + log);
}
}
public static void main(String[] args) {
NotificationService service = new NotificationService();
service.processEventLogs(List.of("Login Success", "New Message", "Server Restart"));
service.processEventLogs(List.of(1623, 1624, 1625)); // 이벤트 ID
}
}
7.2. <? extends T> : Upper Bounded Wildcard (상위 클래스 제한)
7.2.1. 개념
- <? extends T>는 타입 파라미터를 대치하는 구체적인 타입으로 T 또는 T의 하위 타입만 올 수 있다.
- 이는 상한 경계를 설정하여, 특정 타입과 그 하위 타입만 허용한다.
7.2.2. 사용 목적
- 메서드가 특정 타입 또는 그 하위 타입의 객체를 읽을 수 있도록 할 때 사용한다.
- 컬렉션에서 요소를 읽기만 하고, 추가하지 않을 때 유용하다.
- 상위 타입으로 일괄 처리해야 하지만, 하위 타입 객체들을 다루어야 할 때
- 타입 공변성(Covariance) 을 활용하여 읽기 작업을 통일할 때
- 공통 인터페이스/추상 클래스를 통해 다양한 타입을 읽어야 할 때
7.2.3. 사용 시기
- 컬렉션에서 요소를 읽기만 하고, 추가하거나 수정하지 않을 때 사용한다.
- 메서드가 읽기 전용으로 데이터를 처리할 때 유용하다.
7.2.4. 장점
- 타입의 상속 구조를 유연하게 지원한다.
- 읽기(Read) 전용으로 안전하게 사용할 수 있다.
- 타입으로 읽을 수 있기 때문에 다운캐스팅 없이 바로 사용 가능하다.
7.2.5. 제약 사항
- 쓰기(Add) 할 수 없다. (null만 삽입 가능 — 정확히는 타입을 정확히 모르기 때문)
- 읽을 때는 안전하게 T 타입으로 읽을 수 있지만, 추가할 때는 어떤 구체 타입인지 모르기 때문에 불안전하다.
- 컬렉션 안의 요소는 정확히 T 또는 그 하위 타입으로 취급할 수 있다.
7.2.5.1. 왜 읽는건 가능하지만 추가는 허용되지 않는가?
- 예를 들어 Animal의 하위 타입 중 어떤 것이 들어올 수 있다.
- 하지만 컴파일러는 이 리스트에 Dog가 들어있는지, Cat이 들어있는지 모른다. (둘은 형제 클래스이다)
- 때문에 이러한 문제를 막기 위해 추가를 제한하는 것이다.
7.2.5.2. 예시로 설명
class Animal {}
class Dog extends Animal {}
class Cat extends Animal {} // Dog의 형제
class BlueDog extends Dog {} // Dog의 자식
class RedDog extends Dog {} // Dog의 자식 (BlueDog과 형제)
List<? extends Animal> list;
7.2.5.2.1. 이 리스트는 다음 중 하나일 수 있다:
- 즉, Animal을 상속한 모든 클래스들이 올 수 있음
- List<Dog>
- List<RedDog>
- List<Cat>
- List<Animal>
List<? extends Animal> list = new ArrayList<RedDog>();
list.add(new Dog()); // ❌ 불가능!
7.2.5.2.2. 문제 발생:
- 컴파일러 입장에서 list가 List<RedDog> 라고 생각함
- 그런데 Dog는 RedDog의 부모 클래스니까 → 업캐스팅 방향임
- 업캐스팅은 리스트에 넣을 때 위험함
- 왜? List<RedDog>는 Dog가 아닌 RedDog 전용 리스트이므로
- 이게 바로 타입 안전성 위반이라서 컴파일 에러가 나는 것이다.
7.2.5.2.3. 핵심: add 시점의 타입 기준
- extends Animal은 **"Animal 또는 그 자식들"을 담고 있는 리스트"**이지만, 컴파일러는 "정확히 어떤 자식인지 모르므로" 그 어떤 타입도 안전하게 넣을 수 없다.
7.2.6. 예제
7.2.6.1. 기본
public double sumOfNumbers(List<? extends Number> list) {
double sum = 0.0;
for (Number number : list) {
sum += number.doubleValue();
}
return sum;
}
7.2.6.2. 시나리오 1: 동물원 동물 출력
- 상황: Animal 클래스의 하위 타입들(Dog, Cat)을 리스트로 받아 출력.
- <? extends Animal>이므로 Dog, Cat 모두 받을 수 있다.
- Animal 타입으로 안전하게 speak() 호출 가능.
- 삽입은 불가 (리스트에 추가하려면 정확한 타입이 필요함).
import java.util.List;
class Animal {
void speak() {
System.out.println("Animal speaks");
}
}
class Dog extends Animal {
@Override
void speak() {
System.out.println("Dog barks");
}
}
class Cat extends Animal {
@Override
void speak() {
System.out.println("Cat meows");
}
}
public class Zoo {
public void makeAnimalsSpeak(List<? extends Animal> animals) {
for (Animal animal : animals) {
animal.speak();
}
}
public static void main(String[] args) {
Zoo zoo = new Zoo();
List<Dog> dogs = List.of(new Dog(), new Dog());
List<Cat> cats = List.of(new Cat(), new Cat());
zoo.makeAnimalsSpeak(dogs);
zoo.makeAnimalsSpeak(cats);
}
}
7.2.6.3. 시나리오 2: 결제 시스템에서 다양한 결제 수단 처리
- 상황: PaymentMethod 상속 구조(CreditCard, Paypal) 리스트를 받아 처리.
- PaymentMethod를 기준으로 하위 타입(CreditCard, Paypal)을 안전하게 처리.
- 추가 삽입은 불가능.
import java.util.List;
abstract class PaymentMethod {
abstract void processPayment();
}
class CreditCard extends PaymentMethod {
@Override
void processPayment() {
System.out.println("Processing credit card payment");
}
}
class Paypal extends PaymentMethod {
@Override
void processPayment() {
System.out.println("Processing PayPal payment");
}
}
public class PaymentProcessor {
public void handlePayments(List<? extends PaymentMethod> payments) {
for (PaymentMethod payment : payments) {
payment.processPayment();
}
}
public static void main(String[] args) {
PaymentProcessor processor = new PaymentProcessor();
processor.handlePayments(List.of(new CreditCard(), new Paypal()));
}
}
7.2.6.4. 시나리오 3: 도형 렌더링 시스템
- 상황: Shape 기반(Circle, Rectangle) 다양한 도형을 화면에 렌더링.
- Shape 인터페이스를 상속한 객체들을 읽어서 draw() 호출.
- 다양한 하위 타입 Shape 지원 가능.
import java.util.List;
interface Shape {
void draw();
}
class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a Circle");
}
}
class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a Rectangle");
}
}
public class ShapeRenderer {
public void renderShapes(List<? extends Shape> shapes) {
for (Shape shape : shapes) {
shape.draw();
}
}
public static void main(String[] args) {
ShapeRenderer renderer = new ShapeRenderer();
renderer.renderShapes(List.of(new Circle(), new Rectangle()));
}
}
7.2.6.5. 시나리오 4: 리포트 생성기 (보고서 항목 출력)
- 상황: ReportItem 추상 클래스의 다양한 보고서 항목을 생성할 때.
- ReportItem 하위 타입을 읽어 공통 메서드 getContent() 호출.
- 타입 독립적이고, 읽기 전용으로 안전.
import java.util.List;
abstract class ReportItem {
abstract String getContent();
}
class SalesReportItem extends ReportItem {
@Override
String getContent() {
return "Sales Report Content";
}
}
class InventoryReportItem extends ReportItem {
@Override
String getContent() {
return "Inventory Report Content";
}
}
public class ReportGenerator {
public void generateReports(List<? extends ReportItem> items) {
for (ReportItem item : items) {
System.out.println("Report: " + item.getContent());
}
}
public static void main(String[] args) {
ReportGenerator generator = new ReportGenerator();
generator.generateReports(List.of(new SalesReportItem(), new InventoryReportItem()));
}
}
7.2.6.6. 시나리오 5: 알림 시스템에서 다양한 이벤트 핸들링
- 상황: Event 기반으로 다양한 이벤트(UserEvent, SystemEvent)를 처리.
- Event 하위 타입(UserEvent, SystemEvent) 리스트를 받아 공통 메서드 실행.
- 읽기 작업만 하며, 삽입은 불가.
import java.util.List;
abstract class Event {
abstract void handle();
}
class UserEvent extends Event {
@Override
void handle() {
System.out.println("Handling User Event");
}
}
class SystemEvent extends Event {
@Override
void handle() {
System.out.println("Handling System Event");
}
}
public class EventHandler {
public void handleEvents(List<? extends Event> events) {
for (Event event : events) {
event.handle();
}
}
public static void main(String[] args) {
EventHandler handler = new EventHandler();
handler.handleEvents(List.of(new UserEvent(), new SystemEvent()));
}
}
7.3. <? super T> : Lower Bounded Wildcard (하위 클래스 제한)
7.3.1. 개념
- <? super T>는 타입 파라미터를 대치하는 구체적인 타입으로 T 또는 T의 상위 타입만 올 수 있다.
- 이는 하한 경계를 설정하여, 특정 타입과 그 상위 타입만 허용한다.
7.3.2. 사용 목적
- 메서드가 특정 타입 또는 그 하위 타입의 객체를 컬렉션에 추가할 수 있도록 할 때 사용한다.
- 컬렉션에 요소를 추가하고, 읽기는 하지 않을 때 유용하다.
7.3.3. 사용 시기
- 컬렉션에 요소를 추가하고, 읽기는 하지 않을 때 사용한다.
- 메서드가 쓰기 전용으로 데이터를 처리할 때 유용하다.
7.3.4. 장점
- 쓰기(Add) 가 가능하다. (T 타입 또는 그 하위 타입 객체 추가 가능)
- 하위 타입 객체를 상위 타입 컬렉션에 안전하게 추가할 수 있다.
- 소비자(Consumer) 역할을 할 때 강력하다.
7.3.5. 제약 사항
- 컬렉션에서 요소를 꺼낼 때는 Object로만 얻는다. (왜냐하면 정확히 어떤 타입인지 알 수 없기 때문)
- 읽기(Read)는 제한적이다.
- 추가(Add)는 T 또는 T의 하위 타입만 가능하다.
7.3.5.1. 왜 읽는건 안되만 추가는 허용되는가?
public void addDogs(List<? super Dog> dogs)
- 이 뜻은 dogs라는 리스트는 Dog의 상위 타입을 요소로 가지는 리스트라는 뜻이다.
- 즉, 매개변수로 받을 수 있는 리스트 타입은:
- List<Dog>
- List<Animal>
- List<Object>
- 모두 Dog의 상위 타입이다.
7.3.5.2. add의 기준은 왜 Dog 이하(하위 클래스)만 가능한가?
- addDogs(List<? super Dog> dogs)에서 **"Dog 또는 Dog의 하위 타입만 추가 가능"**한 이유는,
- 컴파일러는 이 리스트가 List<Dog>일지, List<Animal>일지 모르지만,
- Dog는 어떤 상위 타입 리스트에도 안전하게 추가 가능하기 때문이다.
- 즉, Dog를 기준으로 "넣는 건 안전하다"고 판단하기 때문에 허용된다.
7.3.5.3. 왜 Dog보다 상위 타입은 넣지 못하는가?
dogs.add(new Animal()); // ❌ 컴파일 에러
- 이유:
- List<? super Dog>는 List<Dog>, List<Animal>, List<Object> 중 무엇일지 컴파일러가 모르기 때문이다.
- 만약 실제로 dogs가 List<Dog>였다고 가정할 때, Animal을 넣는 건 업캐스팅이 아니라 역행 방향이므로 타입 안전성이 깨진다.
- 즉, Java는 타입 안정성을 유지하기 위해, 명확히 보장할 수 없는 상황에서는 컴파일 타임에 막아버린다.
7.3.6. 예제
7.3.4.1. 기본
public void addIntegers(List<? super Integer> list) {
for (int i = 0; i < 10; i++) {
list.add(i);
}
}
7.3.4.2. 시나리오 1: 상품 관리 시스템 (상품 추가)
- 상황: Product 리스트에 다양한 하위 타입 상품을 추가.
- <? super Product>이므로 Product, Object 타입 리스트에 안전하게 추가 가능.
- 읽을 때는 Object로 읽어야 한다.
import java.util.List;
import java.util.ArrayList;
class Product {
String name;
Product(String name) {
this.name = name;
}
}
class Book extends Product {
Book(String name) {
super(name);
}
}
class Electronics extends Product {
Electronics(String name) {
super(name);
}
}
public class ProductManager {
public void addProducts(List<? super Product> products) {
products.add(new Book("Java Programming"));
products.add(new Electronics("Laptop"));
}
public static void main(String[] args) {
List<Object> productList = new ArrayList<>();
ProductManager manager = new ProductManager();
manager.addProducts(productList);
for (Object p : productList) {
System.out.println(p);
}
}
}
7.3.4.3. 시나리오 2: 메시지 큐에 다양한 타입 메시지 넣기
- 상황: Message 기반 하위 클래스(Email, SMS)를 큐에 추가.
- <? super Message>이므로 Message 및 그 상위 타입 큐에 안전하게 추가 가능.
- 읽을 때는 Object로 읽는다.
import java.util.Queue;
import java.util.LinkedList;
class Message { }
class Email extends Message { }
class SMS extends Message { }
public class MessageQueue {
public void enqueueMessages(Queue<? super Message> queue) {
queue.add(new Email());
queue.add(new SMS());
}
public static void main(String[] args) {
Queue<Object> messageQueue = new LinkedList<>();
MessageQueue mq = new MessageQueue();
mq.enqueueMessages(messageQueue);
for (Object m : messageQueue) {
System.out.println(m);
}
}
}
7.3.4.4. 시나리오 3: 결제 요청 큐 (다양한 결제 객체 추가)
- 상황: Payment 기반(CreditPayment, CashPayment)을 큐에 삽입.
- 다양한 하위 타입 Payment 객체를 상위 타입 컬렉션에 추가.
- 읽기는 Object로만 가능.
import java.util.List;
import java.util.ArrayList;
class Payment { }
class CreditPayment extends Payment { }
class CashPayment extends Payment { }
public class PaymentService {
public void submitPayments(List<? super Payment> payments) {
payments.add(new CreditPayment());
payments.add(new CashPayment());
}
public static void main(String[] args) {
List<Object> paymentQueue = new ArrayList<>();
PaymentService service = new PaymentService();
service.submitPayments(paymentQueue);
for (Object payment : paymentQueue) {
System.out.println(payment);
}
}
}
7.3.4.5. 시나리오 4: 이벤트 처리 시스템 (이벤트 추가)
- 상황: Event 상속 구조(UserEvent, SystemEvent)를 이벤트 리스트에 추가.
- 다양한 Event 하위 타입 객체를 추가할 수 있다.
- 읽을 때는 Object로 다룬다.
import java.util.List;
import java.util.ArrayList;
class Event { }
class UserEvent extends Event { }
class SystemEvent extends Event { }
public class EventDispatcher {
public void dispatchEvents(List<? super Event> events) {
events.add(new UserEvent());
events.add(new SystemEvent());
}
public static void main(String[] args) {
List<Object> eventList = new ArrayList<>();
EventDispatcher dispatcher = new EventDispatcher();
dispatcher.dispatchEvents(eventList);
for (Object e : eventList) {
System.out.println(e);
}
}
}
7.3.4.6. 시나리오 5: 재고 관리 시스템 (상품 추가)
- 상황: Item 기반(FoodItem, ElectronicItem) 다양한 상품을 재고 리스트에 추가.
- Item 하위 타입을 안전하게 상위 타입 리스트에 추가.
- Object 타입으로만 읽을 수 있다.
import java.util.List;
import java.util.ArrayList;
class Item { }
class FoodItem extends Item { }
class ElectronicItem extends Item { }
public class InventoryManager {
public void addItemsToInventory(List<? super Item> inventory) {
inventory.add(new FoodItem());
inventory.add(new ElectronicItem());
}
public static void main(String[] args) {
List<Object> inventoryList = new ArrayList<>();
InventoryManager manager = new InventoryManager();
manager.addItemsToInventory(inventoryList);
for (Object item : inventoryList) {
System.out.println(item);
}
}
}
7.4. PECS 원칙이란?
- PECS = "Producer Extends, Consumer Super"
- 생산자(Producer)는 extends를 써라
- 소비자(Consumer)는 super를 써라
- 이 원칙은 우리가 제네릭 타입을 매개변수로 받을 때, 그 제네릭이 값을 꺼내 쓸 건지 (read), 넣을 건지 (write)에 따라 어떤 와일드카드를 써야 하는지를 결정해준다.
7.4.1. Producer → extends
7.4.1.1. 의미:
- 제네릭 타입이 무언가를 생산(produce) 해서, 꺼내 쓸(read) 용도로만 사용될 때.
- 안전하게 값을 읽기만 한다면, 상위 타입으로 읽는 건 문제가 없음.
7.4.1.2. 예시
public void printAnimals(List<? extends Animal> animals) {
for (Animal a : animals) {
System.out.println(a);
}
// animals.add(new Dog()); ❌ 컴파일 에러 (무엇이 들어있는지 모르므로 추가 불가)
}
7.4.1.3. 왜 extends?
- List<? extends Animal>이면 List<Dog>, List<Cat> 등 모든 하위 타입이 될 수 있다.
- 읽을 때는 안전함: Dog든 Cat이든 Animal 타입으로 참조 가능.
- 하지만 뭘 추가해야 할지 알 수 없기 때문에 add()는 불가.
7.4.2. Consumer → super
7.4.2.1. 의미:
- 제네릭 타입이 무언가를 소비(consume) 해서, 데이터를 넣기(write) 위한 용도로만 사용될 때.
- 어떤 타입을 추가할 것인지 확실하면, 그 타입의 상위 타입은 모두 그걸 담을 수 있음.
7.4.2.2. 예시
public void addDogs(List<? super Dog> dogs) {
dogs.add(new Dog()); // ✅ 가능
dogs.add(new Poodle()); // ✅ 가능 (Poodle extends Dog)
// Dog d = dogs.get(0); ❌ 컴파일 에러: Object로 밖에 못 읽음
}
7.4.2.3. 왜 super?
- List<? super Dog>이면 List<Dog>, List<Animal>, List<Object> 등 Dog의 상위 타입들이 올 수 있다.
- Dog 객체를 넣는 건 안전함 (Dog는 Dog의 하위 타입).
- 하지만 꺼낼 때는 정확한 타입을 알 수 없어서 Object로 밖에 받을 수 없음.
8. 공변, 반공변, 무공변/불공변
- 이미 설명이 잘 되어 있는 blog의 링크로 대체한다.
- reference :
☕ JAVA 업캐스팅 & 다운캐스팅 - 완벽 이해하기
자바의 참조형 캐스팅 하나의 데이터 타입을 다른 타입으로 바꾸는 것을 타입 변환 혹은 형변환(캐스팅) 이라고 한다. 자바의 데이터형을 알아보면 크게 두가지로 나뉘게 된다. 기본형(primitive ty
inpa.tistory.com
https://inpa.tistory.com/entry/JAVA-%E2%98%95-wrapper-class-Boxing-UnBoxing
☕ 자바 Wrapper 클래스와 Boxing & UnBoxing 총정리
래퍼 클래스 (Wrapper Class) 이전 강의글에서 우리는 자바의 자료형은 기본 타입(primitive type)과 참조 타입(reference type) 으로 나누어지고 각 특징에 대해 알아보았다. 프로그래밍을 하다 보면 기본 타
inpa.tistory.com
☕ 자바 제네릭(Generics) 개념 & 문법 정복하기
제네릭 (Generics) 이란 자바에서 제네릭(Generics)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다. 자바에서 배
inpa.tistory.com
☕ 자바 제네릭의 공변성 & 와일드카드 완벽 이해
자바의 공변성 / 반공변성 제네릭의 와일드카드를 배우기 앞서 선수 지식으로 알고 넘어가야할 개념이 있다. 조금 난이도 있는 프로그래밍 부분을 학습 하다보면 한번쯤은 들어볼수 있는 공변
inpa.tistory.com
☕ 자바 제네릭 타입 소거 컴파일 과정 알아보기
제네릭 타입 소거 (Erasure) 제네릭은 타입 안정성을 보장하며, 실행시간에 오버헤드가 발생하지 않도록 하기위해 JDK 1.5부터 도입된 문법으로, 이전 자바에서는 제네릭 타입 파라미터 없이 자바를
inpa.tistory.com
https://nanamare.tistory.com/132
사례로 알아보는 Generic(제네릭)
Generic 이란 무엇일까 ? 혹시 아래 코드들이 어떤식으로 다른지 정확하게 이해하고 있나요 ? 아니라면 이번시간에 차근차근 알아보도록 합시다 public class Generics { // T == type parameter private T t; T metho
nanamare.tistory.com
https://st-lab.tistory.com/153
자바 [JAVA] - 제네릭(Generic)의 이해
정적언어(C, C++, C#, Java)을 다뤄보신 분이라면 제네릭(Generic)에 대해 잘 알지는 못하더라도 한 번쯤은 들어봤을 것이다. 특히 자료구조 같이 구조체를 직접 만들어 사용할 때 많이 쓰이기도 하고
st-lab.tistory.com
'Study > java' 카테고리의 다른 글
VSCode/Cursor + Spring Boot + Maven/Gradle 프로젝트 시작을 위한 설정 방법 (0) | 2025.04.30 |
---|---|
[Java] 자바의 신 학습 정리 VOL.1 기초 문법편 (0) | 2025.04.20 |
댓글