Post

[Java] 제네릭(Generic): 타입 안정성과 재사용성

제네릭은 왜 필요한가?


자바에서 컬렉션이나 API를 설계하다 보면 타입과 관련된 다양한 문제를 마주하게 된다.
그중에서도 컴파일 타임에 타입 안정성을 확보하고, 중복 없이 유연한 코드를 작성하기 위해 도입된 기능이 바로 제네릭(Generic)이다.

제네릭을 활용하면 형 변환에 의한 오류를 방지할 수 있으며,
다양한 타입에 대해 공통된 로직을 처리할 수 있는 재사용 가능한 코드를 작성할 수 있다.

제네릭이란?


제네릭(Generic)이란 클래스나 메서드에서 사용할 데이터 타입을 코드 작성 시점이 아닌 인스턴스 생성 시점에 지정할 수 있도록 하는 기능을 의미한다.

1
List<String> list = new ArrayList<>();

위 코드에서 List<String>은 해당 리스트가 문자열(String)만을 담을 수 있음을 의미한다.
컴파일 시점에 타입 검사가 이루어지므로 런타임 오류를 줄이고, 형 변환을 줄일 수 있는 장점이 있다.

제네릭의 기본 문법


제네릭 클래스

기초 예제

1
2
3
4
5
6
7
8
9
10
11
public class Box<T> {
    private T value;

    public void setValue(T value) {
        this.value = value;
    }

    public T getValue() {
        return value;
    }
}

여기서 T 는 타입 파라미터(Type Parameter)로, 실제 사용할 타입은 인스턴스를 생성할 때 지정하게 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// <>: 다이아몬드 연산자
// 타입 파라미터로 타입추론이 가능하여 new에서는 생략
Box<Integer> integerBox = new Box<>();
Box<Double> doubleBox = new Box<>();

Integer number = Integer.valueOf(10);

integerBox.setValue(number);
Integer integerBoxValue = integerBox.getValue();

// ⚠️ 컴파일 에러
// doubleBox.setValue(number);
// Double doubleBoxValue = doubleBox.getValue();

extends 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Animal {}

public class Dog extends Animal {}

public class Cage<T extends Animal> {
    private T animal;

    public void setAnimal(T animal) {
        this.animal = animal;
    }

    public T getAnimal() {
        return animal;
    }
}
  • Cage<T>T가 반드시 Animal의 하위 클래스여야 함을 보장한다.
  • Cage<Dog>은 가능하지만, Cage<String>은 안된다.

제네릭 메서드

기초 예제

1
2
3
4
5
6
7
8
public class DebugUtil {
    // 반환 타입(void) 앞에 지정
    public <T> void printArray(T[] array) {
        for (T item : array) {
            System.out.println(item);
        }
    }
}
1
2
String[] names = { "홍길동", "임꺽정", "장길산" };
DebugUtil.printArray(names);
  • 다양한 타입의 배열을 받아 출력한다.
  • T는 호출 시점에 자동으로 유추된다.

두 값 중 큰 값을 반환하는 메서드

1
2
3
4
5
public class MathUtil {
    public static <T extends Comparable<T>> T max(T a, T b) {
        return a.compareTo(b) >= 0 ? a : b;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class Person implements Comparable<Person> {
    private String name;
    private int age;
    
    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    // 나이를 기준으로 비교
    @Override
    public int compareTo(Person other) {
        return Integer.compare(this.age, other.age);
    }

    @Override
    public String toString() {
        return name + " (" + age + ")";
    }
}
1
2
3
4
5
6
7
8
9
10
11
Integer score1 = 87;
Integer score2 = 92;
System.out.println("더 높은 점수: " + MathUtil.max(score1, score2));

LocalDate d1 = LocalDate.of(2023, 1, 1);
LocalDate d2 = LocalDate.of(2024, 5, 20);
System.out.println("더 최근 날짜: " + MathUtil.max(d1, d2));

Person p1 = new Person("홍길동", 30);
Person p2 = new Person("이몽룡", 25);
System.out.println("더 나이 많은 사람: " + MathUtil.max(p1, p2));
  • TComparable을 구현한 타입이어야 한다.

타입에 관계없는 Key-Value 출력

1
2
3
4
5
public class Logger {
    public static <K, V> void printKeyValue(K key, V value) {
        System.out.println("[" + key + "] -> " + value);
    }
}
1
2
3
Logger.printKeyValue("userId", 1234);
Logger.printKeyValue("status", true);
Logger.printKeyValue("name", "홍길동");

제네릭 메서드는 다양한 타입에 대해 동일한 로직을 적용할 수 있도록 도와준다.

제네릭과 불공변성(Invariance)


자바의 제네릭은 기본적으로 불공변적(invariant)이다.
이는 List<Number>List<Integer>가 서로 아무 관련이 없다는 의미이다.

1
List<Number> numbers = new ArrayList<Integer>(); // ⚠️ 컴파일 오류

위 코드처럼 IntegerNumber의 하위 타입이라도, List<NumberList<Integer>를 담을 수 없다.
이러한 특성은 타입 안정성을 보장하기 위한 제네릭의 핵심적인 설계 원칙이다.

와일드카드(?)와 공변성/반공변성


불공변성의 한계를 보완하기 위해 자바는 와일드카드(?)를 제공한다.
와일드카드는 공변성(covariance)반공변성(contravariance)을 표현할 수 있도록 도와준다.

공변성 (<? extends T>)

1
2
3
4
5
6
7
public void printNumbers(List<? extends Number> list) {
    Number number = list.get(0); // 가능

    // ⚠️ Number 포함한 하위타입(Number, Double, Integer)
    //    List<Integer>인 경우 Double형을 넣을 수 없기 때문에 쓰기가 제한된다.
    // list.add(1.23); // 불가능
}
  • ? extends TT 또는 T의 하위 타입을 허용한다.
  • 읽기(read)는 가능하지만, 쓰기(write)는 제한된다.
  • 주로 읽기 전용 파라미터에 사용된다.

반공변성 (<? super T>)

1
2
3
4
5
6
7
8
public void addNumbers(List<? super Integer> list) {
    list.add(10); // 가능

    // ⚠️ Integer 포함한 상위타입(Integer, Number, Object)
    //    어떤 데이터 타입이 반환될지 예측할 수 없기 때문에 읽기가 제한된다.
    // Integer number = list.get(0); // 불가능
    Object number = list.get(0); // 가능
}
  • ? super TT 또는 T의 상위 타입을 허용한다.
  • 쓰기는 가능하지만, 읽을 때는 Object로 처리해야 한다.
  • 주로 쓰기 전용 파라미터에 사용된다.

PECS 원칙


와일드카드의 사용을 보다 쉽게 이해하기 위해 PECS (Producer Extends, Consumer Super) 원칙이 자주 사용된다.

  • 데이터를 제공(produce)할 때는 extends
  • 데이터를 소비(consume)할 때는 super

제네릭 사용 시 주의할 점


  • 타입 소거(Type Erasure)
    제네릭 타입은 컴파일 후에 타입 정보가 제거되므로, 런타임에는 실제 타입을 알 수 없다.
  • 기본 타입 사용 불가
    기본 타입(int, double 등)은 직접 사용할 수 없고, Integer, Double 등의 래퍼 클래스를 사용해야 한다.
  • static에서의 제약
    static 변수나 메서드는 클래스 로딩 시점에 메모리에 올라가기 때문에 클래스에 선언된 제네릭 타입 파라미터를 적용할 수 없다.