[Java] 제네릭

heiler·2025년 4월 13일

Java

목록 보기
1/4

Intro

이펙티브 자바 서적을 기반으로 제네릭을 정리해보았다.

이미 이해가 된 부분은 제외하고, 집중해서 학습했던 내용만 기록으로 남기려고 한다.

  • 제네릭이 왜 등장했나?
  • 실체화 가능하다(refiable)가 무슨 의미인가?
  • 재귀적 타입 한정(recursive type bound)은 언제 유용하게 사용할 수 있는가?
  • PECS(Producer Extends, Consumer Supser)가 무엇인가?

Why 제네릭?

제네릭이 없던 시절의 주요 문제점은 타입 안정성(type safety) 부족코드 재사용성의 한계였다.

List와 같은 로 타입 컬렉션(raw type Collection)을 사용하여 Object 타입 객체를 처리할 때 객체를 꺼낼 때마다 형변환(type casting)을 해야 했다. 형변환 할 때 해당 타입에 맞지 않는 객체가 들어있다면 런타임 예외가 발생할 수 있다. 이러한 형변환 예외는 컴파일 타임에 잡을 수 없었다.

또한, 다운 캐스팅과 같이 특정 타입을 처리하는 로직을 여러 번 반복해야 했고 코드가 중복됐다.

제네릭은 컴파일 타임에 담을 수 있는 타입을 명확히 지정할 수 있게 하여, 타입 불일치로 인한 런타임 예외를 미리 방지할 수 있다. (타입 안정성 제공)

또한, 한 번 작성한 제네릭 클래스를 다양한 타입에 대해 재사용할 수 있어, 코드 중복을 줄이고 유지보수하기 좋은 코드를 작성할 수 있다. (코드 재사용성 제공)

제네릭은 Java 5 버전부터 사용할 수 있다.

로 타입은 사용하지 마라

제네릭을 지원하기 전에는 컬렉션을 다음과 같이 선언했다.

private final Collection stamps = ...;

이 코드를 사용하면 실수로 도장(Stamp) 대신 동전(Coin)을 넣어도 아무 오류 없이 컴파일되고 실행된다. (경고 메시지를 보여주긴 한다.)

stamps.add(new Coin(...));  // "unchecked call" 경고를 내뱉는다.

컬렉션에서 이 동전을 다시 꺼내기 전에는 오류를 알아 채지 못한다.

for(Iterator i = stamps.iterator(); i.hasNext(); ) {
		Stamp stamp = (Stamp) i.next(); // ClassCastException 발생
		stamp.canecel();
}

런타임에 오류가 발생한 코드와 오류의 원인을 제공한 코드가 물리적으로 상당히 떨어져 있을 가능성이 크다.

💡쓰면 안 좋은 로 타입 기능을 왜 유지시키는 걸까?
로 타입을 쓰면 제네릭이 주는 장점(안전성과 표현력)을 모두 잃게 된다. 그럼에도 불구하고 로 타입 기능을 살려놓은 이유는 오롯이 과거 버전과의 호환성 때문이다. 로 타입을 사용하는 메서드에 매개변수화 타입의 인스턴스를 넘겨도 동작해야만 했다. 마이그레이션 과정에서 호환성을 위해 로 타입을 지원하고 제네릭 구현에는 타입 소거 방식을 사용하기로 했다.

List 같은 로 타입을 사용하면 타입 안전성을 잃게 된다.

public static void main(String[] args) {
	List<String> list = new ArrayList<>();
	unsafeAdd(strings, Integer.valueOf(1));
	unsafeAdd(strings, Integer.valueOf(2));
	String s = list.get(1);
}

private static void unsafeAdd(List list, Object o) {
	list.add(o);
}

이 프로그램을 이대로 실행하면 strings.get(0)의 결과를 형변환하려 할 때 ClassCaseException이 발생한다. list.get(1)코드가 수행될 때 Integer를 String으로 형변환하려 시도했기 때문이다.

✅ 비한정적 와일드카드 타입 사용

제네릭 타입을 쓰고는 싶지만 실제 타입 매개변수가 무엇인지 신경쓰고 싶지 않을 때 비한정적 와일드카드 타입(unbounded wildcard type)을 사용하는 것이 좋다. List<E>의 비한정적 와일드카드 타입은 List<?>다.

public class UnboundedWildcardExample {
    public static void main(String[] args) {
        List<String> strings = List.of("apple", "banana", "cherry");
        List<Integer> numbers = List.of(1, 2, 3);

        printAll(strings);
        printAll(numbers);
    }
    
	public static void printAll(List<?> list) {
        for (Object element : list) {
            System.out.println(element);
        }
    }
}

✅ 로 타입을 사용하는 경우

로 타입을 쓰지 말라는 규칙에도 예외가 몇 개 있다.

▶️ class 리터럴에는 로 타입을 써야 한다

자바 명세는 class 리터럴에 매개변수화 타입을 사용하지 못하게 했다. (배열과 기본 타입은 허용한다.)

예를 들어 List.class, String[].class, int.class는 허용하고 List<String>.class, List<?>.class는 허용하지 않는다.

▶️ instanceof 연산자를 사용할 때

런타임에는 제네릭 타입 정보가 지워지므로 instanceof 연산자는 비한정적 와일드카드 타입 이외의 매개변수화 타입에는 적용할 수 없다.

그리고 로 타입(Set)이든 비한정적 와일드카드 타입(Set<?>)이든 instanceof는 완전히 동일하게 동작한다. 비한정적 와일드카드의 꺽쇠괄호와 물음표는 불필요하므로 로 타입을 쓰는 편이 깔끔하다.

if(o instanceof Set) { // if(o instanceof Set<?>) 과 동일
		Set<?> s = (Set<?>) o;
}

o의 타입이 Set임을 확인한 다음 와일드카드 타입인 Set<?>로 형변환하는 경우, 이는 검사 형변환(checked cast)이므로 컴파일러 경고가 뜨지 않는다.

비검사 경고를 제거해라

제네릭을 사용하기 시작하면 컴파일러로부터 수많은 비검사 경고를 보게 될 것이다. 모든 비검사 경고는 런타임에 ClassCastException을 일으킬 수 있는 잠재적 가능성을 뜻한다.

비검사 경고를 마주했을 때 아래와 같이 대응할 수 있다.

  • 할 수 있는 한 모든 비검사 경고를 제거하라. 모든 비검사 경고를 제거한다면 타입 안정성을 보장 받을 수 있다. 런타임에 ClassCastException이 발생할 일이 없다.

  • 경고를 제거할 수는 없지만 타입 안전하다고 확신할 수 있다면 @SupperssWarnings("unchecked") 어노테이션을 사용하라. 그리고 경고를 무시해도 안전한 이유를 항상 주석으로 남겨야 한다.

  • @SuppressWarnings 어노테이션은 항상 가능한 좁은 범위에 적용해라. 자칫 심각한 경고를 놓칠 수 있으니 절대로 클래스 전체에 적용해서는 안 된다.

배열보다는 리스트를 사용해라

배열은 공변(covariant)이고, 제네릭은 불공변(invariant)이다.

단어는 어려워 보이지만 뜻은 간단하다.

  • 배열의 공변성: sub이 super의 하위 타입이라면 배열 sub[]은 배열 super[]의 하위 타입이 된다. 다형성을 이용할 수 있다.
  • 제네릭의 불공변성: 서로 다른 타입 type1, type2가 있을 때, List<type1>List<type2>의 하위 타입도 아니고 상위 타입도 아니다.

배열은 실체화(reification) 되지만, 제네릭은 타입 소거(type erasure) 되기 때문에 함께 사용하기 어렵다. 이왕 사용할거면 리스트만 사용해라.

  • 배열은 런타임에도 자신의 타입을 유지한다. 생성될 때 컴파일러가 타입을 확인하고, 실행 중에도 타입을 인식할 수 있다.
  • 제네릭은 컴파일 시점에 타입 체크 후, 타입 정보가 지워진다. 런타임에는 List<String>List<Integer>가 같은 List 타입으로 취급된다.
List<String> stringList = new ArrayList<>();
List<Integer> intList = new ArrayList<>();

System.out.println(stringList.getClass() == intList.getClass()); // true (타입 소거됨)

💡 실체화 가능/불가 타입에 대한 정의는 다음과 같다.

  • 실체화 가능 타입(reifiable type): 런타임에도 타입 정보가 완전히 유지되는 타입
    - 원시 타입: int, double, float, boolean, ...
    - 제네릭이 아닌 일반 타입(non-generic type)
    - 로 타입: List, Set, ...
    - 비한정적 와일드카드(<?>)을 사용하는 제네릭 타입: List<?>, Map<?, ?>, ...

  • 실체화 불가 타입(non-reifiable type): 컴파일 시점에 타입 소거로 인해 일부 타입 정보가 제거된 타입
    - 정규 타입 매개변수: E
    - 제네릭 타입: List<E>
    - 매개변수화 타입:List<String>

실체화가 가능하다(reifiable)는 것은 "타입 정보가 소거되어도 런타임에서 그 타입을 안전하게 식별하고 사용할 수 있는가?"를 의미한다. 즉, 코드에서 선언된 타입과 런타임에서 사용되는 타입이 일관되면 실체화 가능한 것이다.

List<T>의 경우, 런타임에 T의 타입이 무엇인지 알아야 안전한 작업을 할 수 있다. 하지만 런타임에 그 정보가 없기 때문에 안전하지 않다고 할 수 있다.
List<?>의 경우, 어떤 타입이든 상관 없다는 확정된 의미를 가진다. 런타임에 어떤 타입이 사용되든 의도된 것이므로 안전하다고 할 수 있다.

이왕이면 제네릭 메서드로 만들라

매개변수화 타입을 받는 정적 유틸리티 메서드는 보통 제네릭이다.
ex. CollectionsbinarySearch(), sort()

✅ 제네릭 메서드의 사용 유형

▶️ 일반적인 제네릭 메서드

public static <E> Set<E> union(Set<E> s1, Set<E> s2) {
		Set<E> result = new HashSet<>(s1);
		result.addAll(s2);
		return result;
}

▶️ 제네릭 싱글턴 팩터리 패턴

public static Function<Integer, Integer> integerIdentityFunction() {
	return (t) -> t;
}

public static Function<Double, Double> doubleIdentityFunction() {
	return (t) -> t;
}

public static Function<String, String> stringIdentityFunction() {
	return (t) -> t;
}

위의 코드를 제네릭 싱글턴 팩터리로 축약하여 표현할 수 있다.

private static UnaryOperator<Object> IDENTITY_FN = (t) -> t;

@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
			return (UnaryOperator<T>) IDENTITY_FN;
}

중복 코드를 줄일 수 있고, 타입마다 Function 객체를 생성하지 않고 하나의 싱글턴 인스턴스를 재사용하므로 메모리를 절약할 수 있다는 장점이 있다.

✅ 재귀적 타입 한정이란?

  • 타입 매개변수(T)가 어떤 특정 타입의 하위 타입이어야 한다고 지정하는데, 그 특정 타입이 타입 매개변수 자기 자신으로 표현되는 제네릭 타입일 때 "재귀적 타입 한정"이라고 한다.
    ex. <T extends SomeType<T>>

아래는 재귀적 타입 한정을 이용하여 컬렉션에서 최댓값을 반환하는 기능이다.

public static <E extends Comparable<E>> E max(Collection<E> c) {
		if (c.isEmpty())
				throw new IllegalArgumentException("컬렉션이 비어 있습니다.");
	
		E result = null;
		for (E e : c)
				if (result == null || e.compareTo(result) > 0)
						result = Objects.requireNonNull(e);

		return result;
}

1. 같은 타입의 객체 간 비교 가능하게 만들고 싶을 때 사용할 수 있다

public enum Status implements Comparable<Status> {
	LOW, MEDIUM, HIGH;
}

2. enum 클래스는 Comparable을 구현하면 기본적(dafualt)으로 ordinal()이 비교 기준이 된다

@Test
void testSort() {
	ArrayList<Status> statuses = new ArrayList<>(List.of(Status.HIGH, Status.MEDIUM, Status.LOW));
	
	Collections.sort(statuses);
	System.out.println(statuses); // [LOW, MEDIUM, HIGH]
}

3. SomeType<E>을 정렬할 수 있는 범용적인 비교기(Comparator)를 만들고 싶을 때 사용할 수 있다

enum Status {
	LOW, MEDIUM, HIGH;
}

class EnumComparator<E extends Enum<E>> implements Comparator<E> {
	@Override
	public int compare(E e1, E e2) {
		// reverse order
		return e1.ordinal() - e2.ordinal();
	}
}
@Test
void testSortByComparator() {
	ArrayList<Status> statuses = new ArrayList<>(List.of(Status.LOW, Status.MEDIUM, Status.LOW, Status.HIGH));
	
	statuses.sort(new EnumComparator<>());
	System.out.println(statuses); // [LOW, LOW, MEDIUM, HIGH]
}

4. 빌더 패턴에서 this를 반환할 때, 재귀적 타입 한정을 사용함으로써 다양한 타입에 대해 재사용 가능한 설계를 할 수 있다

class Builder<T extends Builder<T>> {
	private String value;

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

	public String getValue() {
		return value;
	}
}

class ChildBuilder extends Builder<ChildBuilder> {
	public ChildBuilder customMethod() {
		return this;
	}
}

public class Main {
	public static void main(String[] args) {
		ChildBuilder builder = new ChildBuilder()
				.setValue("Hello") // 재귀적 타입 한정을 사용하여 메서드 체이닝이 가능해짐
				.customMethod();
				
		// 재귀적 타입 한정을 사용하지 않은 경우
		// 메서드 체이닝을 사용할 때마다 타입 캐스팅해줘야 함.
		// ChildBuilder builder = ((ChildBuilder) new ChildBuilder()
		//        .setValue("Hello"))
		//        .customMethod();

		System.out.println(builder.getValue()); // "Hello"
	}
}

한정적 와일드카드를 사용해 API 유연성을 높여라

✅ PECS (Producer Extends Consumer Super)

▶️ Producer Extends (<? extends T>)

값을 생산(Produce)하는 주체임을 의미하다.
데이터를 읽기만 할 때 사용한다.

▶️ Consumer Super (<? super T>)

값을 소비(Consume)하는 주체임을 의미한다.
데이터를 쓰기만 할 때 사용한다.

/**  
 * PECS(Producer Extends, Consumer Super) 예제
 * ? extends T: Upper Bounded Wildcard. Producer 역할. 읽기 전용.
 * ? super T: Lower Bounded Wildcard: Consumer 역할. 쓰기 전용.
 */
 public static <T> void copy(List<? extends T> src, List<? super T> dst) {
    final int size = src.size();  
    for (int i = 0; i < size; i++) {  
        dst.add(src.get(i));
    }
}

dst.get()을 통해 dst로부터 값을 읽어오려고 하거나
src.add()를 통해 src에 요소를 추가하려고 하면 컴파일 에러가 발생한다.

📌 용어 정리

한글 용어영어비고
제네릭 클래스, 제네릭 인터페이스generic class, generic interfacepublic class Box<T>선언에 타입 매개변수가 쓰이면 제네릭 클래스 혹은 제네릭 인터페이스다.
로 타입(원시 타입)raw typeList제네릭 타입에서 타입 매개변수를 전혀 사용하지 않을 때
제네릭 타입generic typeList<E>제네릭 클래스와 제네릭 인터페이스를 통틀어 제네릭 타입이라 한다.
정규 타입 매개변수 (형식 타입 매개변수)formal type parameterE
매개변수화 타입parameterized typeList<String>꺽쇠괄호 안에 들어간 실제 타입 매개변수
실제 타입 매개변수actual type parameterString
비한정적 와일드카드 타입unbounded wildcard typeList<?>
한정적 와일드카드 타입bounded wildcard typeList<? extends Number>
한정적 타입 매개변수bounded type parameter<E extends Number>
재귀적 타입 한정recursive type bound<T extends Comparable<T>>
제네릭 메서드generic methodstatic <E> List<E> asList(E[] a)제네릭 타입을 메서드에서 선언할 때는 접근제한자와 반환타입 사이에 선언한다.
타입 토큰type tokenString.class

Next To Do

11장. 동시성

Referecne

profile
Smiley

0개의 댓글