12.1 지네릭스 (Generics)
지네릭스(Generics)는 클래스나 메서드를 만들 때, 그 안에서 쓰일 데이터 타입을 미리 정해두는 것이 아니라 사용하는 시점에 지정할 수 있게 해주는 기능 입니다. Java 5부터 도입되었으며, 현재 자바 프로그래밍에서 없어서는 안 될 핵심 문법입니다.
1. 왜 지네릭스가 필요한가?
지네릭스가 없다면 모든 타입을 담기 위해 최상위 부모 타입인 Object를 사용해야 합니다. 이렇게 하면 값을 꺼낼 때마다 (String), (Integer) 같은 형변환(Casting) 을 수동으로 해야 하고, 잘못 지정하면 런타임에 ClassCastException(형변환 오류)이 터집니다.
// 지네릭스 없이 Object 사용 (위험!)
public class BoxWithoutGeneric {
private Object item;
public void put(Object item) { this.item = item; }
public Object get() { return item; }
}
public class ProblemExample {
public static void main(String[] args) {
BoxWithoutGeneric box = new BoxWithoutGeneric();
box.put("Hello"); // String 저장
// 꺼낼 때 형변환 필요 → 실수하면 런타임 에러!
String s = (String) box.get(); // OK
Integer n = (Integer) box.get(); // ClassCastException! 런타임에 에러 발생
}
}
지네릭스를 사용하면:
// 지네릭스 사용 (안전!)
Box<String> stringBox = new Box<>();
stringBox.put("Hello");
String s = stringBox.get(); // 형변환 불필요! 컴파일러가 타입 보장
// stringBox.put(123); // 컴파일 에러! String만 허용
지네릭스의 세 가지 이점
- 타입 안전성: 컴파일 시점에 타입 오류를 잡아냅니다.
- 형변환 불필요: 꺼낼 때 캐스팅이 필요 없어 코드가 간결해집니다.
- 재사용성: 하나의 클래스/메서드로 다양한 타입에 대응합니다.
2. 지네릭 클래스 만들기
타입 파라미터로는 보통 한 글자 대문자를 관례로 씁니다.
| 타입 파라미터 | 관례적 의미 |
|---|---|
T | Type (일반 타입) |
E | Element (컬렉션 요소) |
K | Key (맵의 키) |
V | Value (맵의 값) |
N | Number (숫자 타입) |
R | Return (반환 타입) |
// T라는 타입 파라미터를 받는 제네릭 박스 클래스
public class Box<T> {
private T item;
public void put(T item) {
this.item = item;
}
public T get() {
return item;
}
public boolean isEmpty() {
return item == null;
}
@Override
public String toString() {
return "Box[" + item + "]";
}
}
public class GenericBoxExample {
public static void main(String[] args) {
// String 타입을 담는 박스
Box<String> strBox = new Box<>();
strBox.put("안녕하세요!");
String result = strBox.get(); // 형변환 없이 바로 String으로 반환!
System.out.println(result); // 안녕하세요!
System.out.println(strBox); // Box[안녕하세요!]
// Integer 타입을 담는 박스
Box<Integer> intBox = new Box<>();
intBox.put(100);
int num = intBox.get(); // 자동 언박싱
System.out.println(num); // 100
// Double 타입을 담는 박스
Box<Double> doubleBox = new Box<>();
doubleBox.put(3.14);
System.out.println(doubleBox); // Box[3.14]
// 같은 Box 클래스지만 타입이 다름 → 타입 안전
// strBox = intBox; // 컴파일 에러!
}
}
다중 타입 파라미터
// 두 가지 타입을 받는 Pair 클래스
public class Pair<A, B> {
private final A first;
private final B second;
public Pair(A first, B second) {
this.first = first;
this.second = second;
}
public A getFirst() { return first; }
public B getSecond() { return second; }
@Override
public String toString() {
return "(" + first + ", " + second + ")";
}
}
public class PairExample {
public static void main(String[] args) {
Pair<String, Integer> nameAge = new Pair<>("김철수", 25);
System.out.println(nameAge.getFirst() + "님의 나이: " + nameAge.getSecond());
// 김철수님의 나이: 25
Pair<Double, Double> coordinate = new Pair<>(37.5665, 126.9780); // 서울 좌표
System.out.println("위도: " + coordinate.getFirst() + ", 경도: " + coordinate.getSecond());
Pair<String, Boolean> loginStatus = new Pair<>("alice@example.com", true);
System.out.println(loginStatus.getFirst() + " 로그인: " + loginStatus.getSecond());
}
}
3. 지네릭 메서드 만들기
메서드 반환 타입 앞에 타입 파라미터를 선언합니다. 지네릭 메서드는 클래스와 독립적인 타입 파라미터를 가집니다.
public class GenericMethods {
// 두 값을 출력하고, 첫 번째 값을 반환하는 제네릭 메서드
public static <T> T printAndReturn(T first, T second) {
System.out.println("첫 번째: " + first);
System.out.println("두 번째: " + second);
return first;
}
// 배열을 받아 특정 요소를 찾는 제네릭 메서드
public static <T> int indexOf(T[] arr, T target) {
for (int i = 0; i < arr.length; i++) {
if (arr[i].equals(target)) {
return i;
}
}
return -1;
}
// 두 개의 타입 파라미터를 사용하는 메서드
public static <K, V> void printEntry(K key, V value) {
System.out.println(key + " → " + value);
}
// 배열의 최댓값을 찾는 제네릭 메서드 (Comparable 사용)
public static <T extends Comparable<T>> T max(T[] arr) {
T maxVal = arr[0];
for (T item : arr) {
if (item.compareTo(maxVal) > 0) {
maxVal = item;
}
}
return maxVal;
}
public static void main(String[] args) {
String s = printAndReturn("사과", "바나나");
Integer n = printAndReturn(10, 20);
String[] fruits = {"banana", "apple", "cherry", "mango"};
System.out.println("apple 인덱스: " + indexOf(fruits, "apple")); // 1
printEntry("국어", 95);
printEntry("도시", "서울");
Integer[] scores = {85, 92, 70, 100, 88};
System.out.println("최고점: " + max(scores)); // 100
String[] names = {"Charlie", "Alice", "Bob"};
System.out.println("사전 마지막: " + max(names)); // Charlie
}
}
4. 타입 바운드 (Type Bounds)
상한 경계 와일드카드: <T extends 상위타입>
import java.util.ArrayList;
import java.util.List;
public class UpperBoundExample {
// T는 Number의 하위 타입만 허용 (Integer, Double, Long 등)
public static <T extends Number> double sum(List<T> list) {
double total = 0;
for (T item : list) {
total += item.doubleValue(); // Number의 메서드 사용 가능
}
return total;
}
// T는 여러 인터페이스를 동시에 구현한 타입 (& 사용)
public static <T extends Comparable<T> & Cloneable> T findMax(List<T> list) {
T max = list.get(0);
for (T item : list) {
if (item.compareTo(max) > 0) max = item;
}
return max;
}
public static void main(String[] args) {
List<Integer> intList = List.of(1, 2, 3, 4, 5);
List<Double> doubleList = List.of(1.1, 2.2, 3.3);
System.out.println("정수 합계: " + sum(intList)); // 15.0
System.out.println("실수 합계: " + sum(doubleList)); // 6.6
// List<String> strList = List.of("a", "b");
// sum(strList); // 컴파일 에러! String은 Number가 아님
}
}
와일드카드 ?
와일드카드 ?는 "어떤 타입이든 상관없음"을 의미합니다.
import java.util.List;
public class WildcardExample {
// 비한정 와일드카드: 어떤 타입의 List든 받을 수 있음 (읽기만 가능)
public static void printAll(List<?> list) {
for (Object item : list) {
System.out.print(item + " ");
}
System.out.println();
}
// 상한 와일드카드: Number 또는 그 하위 타입의 List (읽기 목적)
public static double sumNumbers(List<? extends Number> list) {
return list.stream().mapToDouble(Number::doubleValue).sum();
}
// 하한 와일드카드: Integer 또는 그 상위 타입의 List (쓰기 목적)
public static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
list.add(3);
}
public static void main(String[] args) {
printAll(List.of("a", "b", "c")); // a b c
printAll(List.of(1, 2, 3)); // 1 2 3
System.out.println(sumNumbers(List.of(1, 2, 3))); // 6.0
System.out.println(sumNumbers(List.of(1.5, 2.5, 3.0))); // 7.0
List<Number> numbers = new java.util.ArrayList<>();
addIntegers(numbers);
System.out.println(numbers); // [1, 2, 3]
}
}
5. PECS 원칙 (Producer Extends, Consumer Super)
와일드카드를 언제 extends로 쓰고 super로 써야 할지 헷갈린다면 PECS 원칙을 기억하세요.
import java.util.List;
import java.util.ArrayList;
public class PECSExample {
// Producer Extends: List에서 데이터를 꺼내서(produce) 사용할 때
// → <? extends T> 사용
public static double calculateAverage(List<? extends Number> numbers) {
return numbers.stream()
.mapToDouble(Number::doubleValue)
.average()
.orElse(0.0);
}
// Consumer Super: List에 데이터를 넣을(consume) 때
// → <? super T> 사용
public static void copyIntegers(List<? super Integer> dest) {
for (int i = 1; i <= 5; i++) {
dest.add(i);
}
}
// 복사: src에서 읽고(extends), dest에 쓰기(super)
public static <T> void copy(List<? extends T> src, List<? super T> dest) {
for (T item : src) {
dest.add(item);
}
}
public static void main(String[] args) {
List<Integer> ints = List.of(1, 2, 3, 4, 5);
List<Double> doubles = List.of(1.5, 2.5, 3.5);
// Producer (꺼내서 사용)
System.out.println("정수 평균: " + calculateAverage(ints)); // 3.0
System.out.println("실수 평균: " + calculateAverage(doubles)); // 2.5
// Consumer (넣기)
List<Number> numbers = new ArrayList<>();
copyIntegers(numbers);
System.out.println("복사된 수: " + numbers); // [1, 2, 3, 4, 5]
// 복사 (src: extends, dest: super)
List<Integer> source = List.of(10, 20, 30);
List<Number> destination = new ArrayList<>();
copy(source, destination);
System.out.println("복사 결과: " + destination); // [10, 20, 30]
}
}
PECS 암기법
- P roducer E xtends: 컬렉션에서 값을 꺼낼 때(읽기 전용) →
? extends T - C onsumer S uper: 컬렉션에 값을 넣을 때(쓰기 목적) →
? super T - 둘 다 할 때는 그냥
T를 사용하세요.
6. 타입 소거 (Type Erasure)
자바의 제네릭은 컴파일 시점에만 타입을 검사하고, 컴파일 후 바이트코드에서는 타입 정보가 사라집니다(타입 소거). 이는 Java 5 이전의 코드와 호환성을 유지하기 위한 설계입니다.
public class TypeErasureExample {
public static void main(String[] args) {
java.util.ArrayList<String> stringList = new java.util.ArrayList<>();
java.util.ArrayList<Integer> intList = new java.util.ArrayList<>();
// 런타임에는 둘 다 그냥 ArrayList → 같은 타입으로 판단됨
System.out.println(stringList.getClass() == intList.getClass()); // true
System.out.println(stringList.getClass().getName()); // java.util.ArrayList
// 컴파일 후 타입 파라미터 T는 Object로 대체됨
// (바운드가 있으면 바운드 타입으로 대체)
}
}
타입 소거로 인한 제약사항
public class TypeErasureLimitations<T> {
// 제약 1: 제네릭 타입으로 인스턴스 생성 불가
// T obj = new T(); // 컴파일 에러!
// 제약 2: 제네릭 타입 배열 생성 불가
// T[] arr = new T[10]; // 컴파일 에러!
// 해결: Object 배열 사용 후 캐스팅
@SuppressWarnings("unchecked")
T[] createArray(int size) {
return (T[]) new Object[size]; // 경고 발생하지만 동작
}
// 제약 3: instanceof 검사 불가
// if (obj instanceof T) {} // 컴파일 에러!
// 해결: Class<T> 파라미터 전달
boolean isInstance(Object obj, Class<T> clazz) {
return clazz.isInstance(obj);
}
// 제약 4: 정적 필드에 타입 파라미터 사용 불가
// static T staticVar; // 컴파일 에러!
}
7. 제네릭 배열 생성 불가 이유
// 왜 제네릭 배열을 만들 수 없을까?
// 다음 코드가 가능하다고 가정하면...
// List<String>[] arr = new ArrayList<String>[10]; // 실제로는 컴파일 에러
// Object[] objArr = arr; // 배열은 공변(covariant)이므로 가능
// objArr[0] = new ArrayList<Integer>(); // 런타임에는 ArrayList[]라서 허용됨
// String s = arr[0].get(0); // ClassCastException! → 타입 안전성 깨짐
// 해결: 타입 안전한 컬렉션 사용
java.util.List<java.util.List<String>> listOfLists = new java.util.ArrayList<>();
8. 실전 예제: 제네릭 Stack 구현
public class GenericStack<T> {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_CAPACITY = 10;
@SuppressWarnings("unchecked")
public GenericStack() {
elements = new Object[DEFAULT_CAPACITY];
}
public void push(T item) {
ensureCapacity();
elements[size++] = item;
}
@SuppressWarnings("unchecked")
public T pop() {
if (isEmpty()) throw new java.util.EmptyStackException();
T item = (T) elements[--size];
elements[size] = null; // 참조 해제 (메모리 누수 방지)
return item;
}
@SuppressWarnings("unchecked")
public T peek() {
if (isEmpty()) throw new java.util.EmptyStackException();
return (T) elements[size - 1];
}
public boolean isEmpty() { return size == 0; }
public int size() { return size; }
private void ensureCapacity() {
if (size == elements.length) {
elements = java.util.Arrays.copyOf(elements, size * 2);
}
}
@Override
public String toString() {
return java.util.Arrays.toString(java.util.Arrays.copyOf(elements, size));
}
public static void main(String[] args) {
System.out.println("=== 문자열 스택 ===");
GenericStack<String> strStack = new GenericStack<>();
strStack.push("Java");
strStack.push("Python");
strStack.push("Go");
System.out.println("스택: " + strStack); // [Java, Python, Go]
System.out.println("peek: " + strStack.peek()); // Go
System.out.println("pop: " + strStack.pop()); // Go
System.out.println("pop: " + strStack.pop()); // Python
System.out.println("스택: " + strStack); // [Java]
System.out.println("\n=== 정수 스택 ===");
GenericStack<Integer> intStack = new GenericStack<>();
for (int i = 1; i <= 5; i++) intStack.push(i * 10);
System.out.println("스택: " + intStack); // [10, 20, 30, 40, 50]
while (!intStack.isEmpty()) {
System.out.print(intStack.pop() + " "); // 50 40 30 20 10
}
System.out.println();
// 제네릭 스택을 활용한 괄호 균형 검사
System.out.println("\n=== 괄호 균형 검사 ===");
System.out.println(isBalanced("((()))")); // true
System.out.println(isBalanced("({[]})")); // true
System.out.println(isBalanced("([)]")); // false
System.out.println(isBalanced("((")); // false
}
static boolean isBalanced(String expr) {
GenericStack<Character> stack = new GenericStack<>();
for (char c : expr.toCharArray()) {
if (c == '(' || c == '{' || c == '[') {
stack.push(c);
} else if (c == ')' || c == '}' || c == ']') {
if (stack.isEmpty()) return false;
char top = stack.pop();
if (c == ')' && top != '(') return false;
if (c == '}' && top != '{') return false;
if (c == ']' && top != '[') return false;
}
}
return stack.isEmpty();
}
}
출력 결과:
=== 문자열 스택 ===
스택: [Java, Python, Go]
peek: Go
pop: Go
pop: Python
스택: [Java]
=== 정수 스택 ===
스택: [10, 20, 30, 40, 50]
50 40 30 20 10
=== 괄호 균형 검사 ===
true
true
false
false
지네릭스는 코드의 재사용성 과 타입 안전성 을 동시에 높여주는 아주 강력한 기능입니다.