본문으로 건너뛰기

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만 허용
지네릭스의 세 가지 이점
  1. 타입 안전성: 컴파일 시점에 타입 오류를 잡아냅니다.
  2. 형변환 불필요: 꺼낼 때 캐스팅이 필요 없어 코드가 간결해집니다.
  3. 재사용성: 하나의 클래스/메서드로 다양한 타입에 대응합니다.

2. 지네릭 클래스 만들기

타입 파라미터로는 보통 한 글자 대문자를 관례로 씁니다.

타입 파라미터관례적 의미
TType (일반 타입)
EElement (컬렉션 요소)
KKey (맵의 키)
VValue (맵의 값)
NNumber (숫자 타입)
RReturn (반환 타입)
// 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

지네릭스는 코드의 재사용성타입 안전성 을 동시에 높여주는 아주 강력한 기능입니다.