코딩쿠의 코드 연대기

Java 제네릭 본문

코딩스터디/JAVA스터디

Java 제네릭

코딩쿠 2024. 11. 20. 18:23

학습목표

  • 제네릭 사용법
  • 제네릭 주요 개념 (바운디드 타입, 와일드카드)
  • 제네릭 메서드 만들기
  • Erasure

제네릭 사용법

Java 제네릭(Generic)이란?

Java에서 제네릭(Generic)타입을 매개변수 화하는 기능을 말합니다. 클래스나 메서드에 특정 데이터 타입을 고정하지 않고, 실제 사용 시점에 타입을 지정하여 다양한 데이터 타입을 처리할 수 있도록 유연성을 제공합니다. 이는 함수의 매개변수처럼 작동하여 코드의 재사용성을 높이고, 타입 안정성을 보장합니다.


제네릭을 사용하는 이유

  1. 타입 안정성 제공: 컴파일 시점에 타입 체크를 수행하여 런타임 에러를 방지합니다.
  2. 코드 재사용성 증가: 다양한 타입에 대해 동일한 코드를 사용할 수 있습니다.
  3. 형변환 제거: 컴파일러가 자동으로 형변환을 처리해 줍니다.
  4. 가독성 향상: 코드의 의도를 명확히 표현하여 유지보수가 쉬워집니다.

제네릭 사용법

1. 제네릭 클래스

제네릭 클래스를 정의할 때 타입 매개변수를 사용하여 다양한 타입을 처리할 수 있습니다.

class Box<T> {
    private T item;

    public void setItem(T item) {
        this.item = item;
    }

    public T getItem() {
        return item;
    }
}

사용 예시:

Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");
System.out.println(stringBox.getItem());

Box<Integer> intBox = new Box<>();
intBox.setItem(123);
System.out.println(intBox.getItem());

2. 다중 타입 매개변수

제네릭 클래스는 두 개 이상의 타입 매개변수를 사용할 수 있습니다.

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<>("Age", 30);
System.out.println(pair.getKey() + ": " + pair.getValue());

3. 제네릭 메서드

메서드에 제네릭을 적용하여 다양한 타입의 데이터를 처리할 수 있습니다.

public static <T> T print(T item) {
    System.out.println(item);
    return item;
}

사용 예시:

String str = print("Hello");
Integer num = print(123);

4. 제네릭 와일드카드

와일드카드(?)를 사용하면 더욱 유연한 타입 제약이 가능합니다.

  • <?>: 모든 타입을 허용합니다.
  • <? extends T>: T 타입 및 하위 타입을 허용합니다. (상한 제한)
  • <? super T>: T 타입 및 상위 타입을 허용합니다. (하한 제한)
List<? extends Number> numbers = new ArrayList<>();
Number num = numbers.get(0); // 읽기 가능
// numbers.add(1); // 컴파일 에러 (쓰기 불가)

List<? super Integer> integers = new ArrayList<>();
integers.add(42); // 쓰기 가능
// Integer value = integers.get(0); // 컴파일 에러 (Object로 반환)

5. 제네릭과 컬렉션

Java의 컬렉션 프레임워크에서 제네릭은 매우 자주 사용됩니다.

List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
String first = names.get(0); // 형변환 불필요
System.out.println(first);

제네릭의 제약 사항

  1. 기본 타입 사용 불가: int, double 같은 기본 타입은 제네릭으로 사용할 수 없습니다. 대신 Wrapper 클래스(Integer, Double 등)를 사용해야 합니다.
  2. static 멤버에 사용 제한: static 필드나 메서드는 제네릭 타입 매개변수를 사용할 수 없습니다.
  3. 예외 클래스 제한: 제네릭 타입으로 예외를 정의하거나 생성할 수 없습니다.
  4. 배열 생성 제한: 제네릭 타입으로 배열을 생성할 수 없습니다.
  5. 타입 소거(Type Erasure): 컴파일 시점에 제네릭 정보가 삭제되고, Object 또는 경계(bound) 타입으로 변환됩니다. 따라서 런타임에는 제네릭 타입 정보가 존재하지 않습니다.

제네릭의 장점

  1. 타입 안정성: 컴파일 시점에 타입 에러를 검출하여 런타임 에러를 예방합니다.
  2. 코드 재사용성: 다양한 타입에 대해 동일한 코드를 사용할 수 있어 효율성이 높습니다.
  3. 형변환 생략: 컴파일러가 자동으로 형변환을 처리해 주므로 코드가 간결해집니다.
  4. 가독성 향상: 코드의 의도를 명확하게 표현하여 유지보수를 용이하게 합니다.

타입 소거(Type Erasure)

Java의 제네릭은 컴파일 시점에만 타입 체크를 수행하고, 런타임에는 타입 정보가 제거됩니다. 이로 인해 다음과 같은 제약이 발생합니다:

  • 런타임 시 타입 정보를 활용하는 기능(instanceof, getClass(), 배열 생성 등)을 사용할 수 없습니다.
  • 같은 타입으로 컴파일된 두 제네릭 클래스는 런타임에는 동일한 클래스로 간주됩니다.

타입 소거 예시:

List<String> list1 = new ArrayList<>();
List<Integer> list2 = new ArrayList<>();
System.out.println(list1.getClass() == list2.getClass()); // true

결론

Java의 제네릭은 타입 안정성, 코드 재사용성, 가독성을 크게 향상하는 강력한 도구입니다. 제네릭을 효과적으로 사용하면 다양한 타입을 안전하고 간단하게 처리할 수 있는 코드를 작성할 수 있습니다.


예제 코드 요약

class Box<T> {
    private T item;
    public void setItem(T item) { this.item = item; }
    public T getItem() { return item; }
}

public static <T> T print(T item) {
    System.out.println(item);
    return item;
}

List<? extends Number> numbers = new ArrayList<>();
List<? super Integer> integers = new ArrayList<>();

제네릭 주요 개념 (바운디드 타입, 와일드카드)

Java 제네릭 주요 개념: 바운디드 타입과 와일드카드

Java 제네릭의 강력한 기능 중 하나는 바운디드 타입와일드카드를 활용하는 것입니다. 이를 통해 타입 안정성을 강화하고, 유연하면서도 안전한 코드를 작성할 수 있습니다. 이 두 가지 개념을 자세히 살펴보겠습니다.


1. 바운디드 타입 (Bounded Type)

바운디드 타입은 제네릭 타입 매개변수에 특정 타입의 범위를 제한하여, 허용 가능한 타입만 사용할 수 있도록 하는 기능입니다.

 

1.1 상한 제한 (Upper Bounded Type)

 

<? extends T> 또는 <T extends Class> 형식으로 표현하며, T 타입 또는 T의 하위 타입만 허용합니다. 이를 통해 타입 안정성을 높이고, 제한된 범위 내에서 메서드를 사용할 수 있습니다.

public static <T extends Number> double sum(List<T> list) {
    double sum = 0;
    for (T item : list) {
        sum += item.doubleValue(); // Number 클래스의 메서드 사용 가능
    }
    return sum;
}

사용 예시:

List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3);

System.out.println(sum(intList));   // 출력: 6.0
System.out.println(sum(doubleList)); // 출력: 6.6

 

1.2 하한 제한 (Lower Bounded Type)

 

<? super T> 형식으로 표현하며, T 타입 또는 T의 상위 타입만 허용합니다. 주로 쓰기(write) 연산을 안전하게 수행할 때 사용됩니다.

public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}

사용 예시:

List<Number> numList = new ArrayList<>();
addNumbers(numList);
System.out.println(numList); // 출력: [1, 2]

2. 와일드카드 (Wildcard)

와일드카드는? 기호로 표현하며, 알 수 없는 타입을 의미합니다. 주로 메서드의 매개변수 타입을 제한하지 않으면서도 안전하게 작업을 수행하기 위해 사용됩니다.

 

2.1 제한 없는 와일드카드 (<?>)

 

<?>모든 타입을 허용합니다. 주로 컬렉션의 내용을 읽기만 할 때 사용됩니다.

public static void printList(List<?> list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

사용 예시:

List<String> stringList = Arrays.asList("A", "B", "C");
List<Integer> intList = Arrays.asList(1, 2, 3);

printList(stringList); // 출력: A B C
printList(intList);    // 출력: 1 2 3

 

2.2 상한 제한 와일드카드 (<? extends T>)

 

<? extends T>T 타입 또는 T의 하위 타입만 허용합니다. 주로 읽기 전용(read-only) 작업에 사용됩니다.

public static double sum(List<? extends Number> list) {
    double sum = 0;
    for (Number item : list) {
        sum += item.doubleValue();
    }
    return sum;
}

사용 예시:

List<Integer> intList = Arrays.asList(1, 2, 3);
List<Double> doubleList = Arrays.asList(1.1, 2.2, 3.3);

System.out.println(sum(intList));   // 출력: 6.0
System.out.println(sum(doubleList)); // 출력: 6.6

주의점:
<? extends T>를 사용할 경우 리스트에 새로운 요소를 추가할 수 없습니다. 이는 컴파일러가 리스트에 저장할 객체의 정확한 타입을 알 수 없기 때문입니다.

 

2.3 하한 제한 와일드카드 (<? super T>)

 

<? super T>T 타입 또는 T의 상위 타입만 허용합니다. 주로 쓰기(write) 작업에 사용됩니다.

public static void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}

 

사용 예시:

List<Number> numList = new ArrayList<>();
addNumbers(numList);
System.out.println(numList); // 출력: [1, 2]

 

주의점:
<? super T>를 사용할 경우 리스트에서 요소를 읽을 때는 Object로 반환됩니다.


바운디드 타입과 와일드카드의 차이

구분 바운디드 타입 와일드카드
표현 <T extends Number> 또는 <T> <? extends Number> 또는 <?>
사용 대상 클래스와 메서드 정의에서 사용 메서드 매개변수에서 사용
주요 목적 타입 매개변수를 제한하고 메서드에서 사용 가능 타입 안정성을 유지하면서 유연성 제공
쓰기 가능 여부 쓰기 가능 제한 있음 (<? extends>에서는 불가)

결론

  • 바운디드 타입은 제네릭 타입 매개변수의 범위를 제한하여 타입 안전성을 강화하고, 특정 타입에만 적용 가능한 메서드를 사용할 수 있도록 합니다.
  • 와일드카드는 메서드 매개변수의 타입을 유연하게 받아들여, 다양한 타입의 컬렉션을 처리할 수 있도록 합니다.
  • 두 개념을 적절히 조합하면 안정적이고 재사용 가능한 코드를 작성할 수 있습니다.

제네릭 메서드 만들기

Java에서 제네릭 메서드는 메서드 자체가 제네릭 타입 매개변수를 선언하여 다양한 데이터 타입을 처리할 수 있도록 하는 기능입니다. 제네릭 메서드를 활용하면 타입에 관계없이 안전하게 작업할 수 있으며, 타입 안정성코드 재사용성을 높일 수 있습니다.


제네릭 메서드의 정의

제네릭 메서드는 타입 매개변수를 메서드 선언부에 <T> 형태로 명시합니다. 이 타입 매개변수는 메서드의 매개변수, 반환값, 또는 내부 로직에서 사용할 수 있습니다.

형식:

public <T> ReturnType methodName(T param) {
    // 메서드 구현
}

예제 1: 단순한 제네릭 메서드

아래는 다양한 타입의 객체를 출력하는 제네릭 메서드의 예입니다.

public class GenericMethodExample {
    public static <T> void print(T item) {
        System.out.println(item);
    }

    public static void main(String[] args) {
        print("Hello");       // String
        print(123);           // Integer
        print(45.67);         // Double
    }
}

출력:

Hello
123
45.67

예제 2: 제네릭 메서드에서 반환값 사용

제네릭 메서드는 특정 타입의 값을 반환할 수도 있습니다.

public class GenericReturnExample {
    public static <T> T returnItem(T item) {
        return item;
    }

    public static void main(String[] args) {
        String result = returnItem("Hello");
        System.out.println(result); // Hello

        Integer number = returnItem(123);
        System.out.println(number); // 123
    }
}

예제 3: 제네릭 타입 매개변수에 제한 추가

바운디드 타입을 사용하여, 특정 타입이나 그 하위 타입만 허용하도록 제한할 수 있습니다.

import java.util.List;

public class GenericBoundExample {
    public static <T extends Number> double sum(List<T> list) {
        double sum = 0;
        for (T item : list) {
            sum += item.doubleValue(); // Number 클래스의 메서드 사용 가능
        }
        return sum;
    }

    public static void main(String[] args) {
        List<Integer> intList = List.of(1, 2, 3);
        List<Double> doubleList = List.of(1.1, 2.2, 3.3);

        System.out.println(sum(intList));   // 6.0
        System.out.println(sum(doubleList)); // 6.6
    }
}

예제 4: 다중 제네릭 타입 매개변수

메서드에 여러 타입 매개변수를 선언할 수도 있습니다.

public class MultiGenericExample {
    public static <K, V> void printKeyValue(K key, V value) {
        System.out.println("Key: " + key + ", Value: " + value);
    }

    public static void main(String[] args) {
        printKeyValue(1, "One");
        printKeyValue("Name", "Alice");
        printKeyValue(101, 202);
    }
}

출력:

Key: 1, Value: One
Key: Name, Value: Alice
Key: 101, Value: 202

제네릭 메서드와 와일드카드

제네릭 메서드에서도 와일드카드를 사용할 수 있습니다.

import java.util.List;

public class WildcardExample {
    public static void printList(List<?> list) {
        for (Object item : list) {
            System.out.println(item);
        }
    }

    public static void main(String[] args) {
        List<String> strings = List.of("A", "B", "C");
        List<Integer> numbers = List.of(1, 2, 3);

        printList(strings);
        printList(numbers);
    }
}

제네릭 메서드 작성 시 주의 사항

  1. 타입 매개변수의 위치:
    • 타입 매개변수 <T>는 반드시 반환 타입 앞에 선언되어야 합니다.
      public static <T> void methodName(T param) { ... }
  2. 바운디드 타입:
    • 필요한 경우 T extends Class를 사용하여 타입 매개변수의 제한을 설정하세요.
  3. 제네릭 타입 매개변수와 와일드카드 비교:
    • 와일드카드는 메서드 호출 시 타입을 추론하지 않고, 읽기 전용 작업에 적합합니다.

결론

제네릭 메서드는 다양한 데이터 타입을 처리할 수 있는 유연성을 제공하며, 타입 안정성을 유지하면서 코드 재사용성을 극대화합니다. 제네릭 메서드의 활용은 코드의 가독성과 유지보수성을 높이는 데 큰 도움이 됩니다.


Erasure

Java의 타입 소거(Type Erasure)

Java에서 제네릭(Generic)은 타입 소거(Type Erasure)라는 특성을 가집니다. 이는 컴파일 시점에 제네릭 타입 정보가 제거되어, 런타임 시에는 제네릭 타입 정보가 존재하지 않는 것을 의미합니다. 이 특성 덕분에 Java의 제네릭은 바이너리 하위 호환성을 유지하며 기존 코드와 통합될 수 있습니다.


타입 소거의 작동 방식

  1. 타입 매개변수 대체
    • 컴파일러는 제네릭 타입 매개변수를 Object 또는 바운드(상한 경계, T extends Class) 타입으로 대체합니다.
    • 상한 경계가 없는 경우 Object로 대체되고, 상한 경계가 있는 경우 해당 타입으로 대체됩니다.
    예시:위 코드는 컴파일 시 다음과 같이 변환됩니다:
  2. public class GenericExample { private Object item; public Object getItem() { return item; } }
  3. public class GenericExample <T> { private T item; public T getItem() { return item; } }
  4. 형변환 추가
    • 제네릭 타입을 사용하는 코드에서는 컴파일러가 적절한 형변환 코드를 자동으로 삽입합니다.
    예시:컴파일된 코드는 다음과 같이 동작합니다:
  5. GenericExample example = new GenericExample(); String value = (String) example.getItem(); // 컴파일러가 형변환 추가
  6. GenericExample <String> example = new GenericExample <>(); String value = example.getItem();
  7. 제네릭의 경계 처리
    • 제네릭 타입이 경계를 가지는 경우, 해당 경계를 타입으로 대체합니다.
    예시:컴파일된 코드는 다음과 같이 변환됩니다:
  8. public class GenericBoundExample { public Number add(Number a, Number b) { return a; } }
  9. public class GenericBoundExample <T extends Number> { public T add(T a, T b) { return a; // 예제에서는 간단히 반환 } }

타입 소거로 인한 제약

  1. 런타임 타입 정보 소실
    • 제네릭 타입 정보는 컴파일 후 제거되므로 런타임에 제네릭 타입을 확인할 수 없습니다.
      List<String> stringList = new ArrayList<>();
      List<Integer> intList = new ArrayList<>();
      
    System.out.println(stringList.getClass() == intList.getClass()); // true
  2. 두 리스트는 동일한 `ArrayList` 클래스의 인스턴스로 간주됩니다.
  3. 배열 생성 불가
    • 런타임에 타입 정보를 알 수 없으므로 제네릭 타입의 배열을 생성할 수 없습니다.
      List<String>[] stringArray = new List<String>[10]; // 컴파일 에러
  4. instanceof 사용 불가
    • 런타임에 제네릭 타입 정보가 제거되므로, 제네릭 타입을 기반으로 instanceof를 사용할 수 없습니다.
      if (list instanceof List<String>) { // 컴파일 에러
        ...
      }
  5. Overloading 불가
    • 타입 소거로 인해 같은 원시 타입(raw type)을 가지는 제네릭 메서드는 오버로딩이 불가능합니다.
      public void method(List<String> list) { ... }
      public void method(List<Integer> list) { ... } // 컴파일 에러

타입 소거의 장점

  1. 바이너리 하위 호환성
    • 제네릭은 Java 5에서 도입되었지만, 기존 코드와의 호환성을 유지하기 위해 타입 소거를 사용합니다. 즉, 새로운 제네릭 코드는 이전 버전의 JVM에서도 실행 가능합니다.
  2. 코드 크기 감소
    • 런타임에 모든 제네릭 타입이 제거되므로 클래스 파일의 크기가 증가하지 않습니다.

타입 소거를 우회하는 방법

  1. 클래스 정보 전달
    • 타입 정보를 유지하려면 클래스의 Class <T> 객체를 메서드에 전달합니다.
      public static <T> T createInstance(Class<T> clazz) throws InstantiationException, IllegalAccessException {
        return clazz.newInstance();
      }
      
    String str = createInstance(String.class);
  2. 리플렉션 사용
    • 런타임 타입 정보를 명시적으로 확인할 때 리플렉션을 사용할 수 있습니다.
      List<String> stringList = new ArrayList<>();
      System.out.println(stringList.getClass().getTypeName()); // java.util.ArrayList
  3. 특정한 제네릭 타입을 사용한 대체
    • 경우에 따라 ParameterizedType과 같은 리플렉션 API를 사용하여 타입 정보를 간접적으로 처리할 수 있습니다.

타입 소거의 실제 활용 예시

타입 안전성을 유지하면서 유연성 제공

public static <T> void printList(List<T> list) {
    for (T item : list) {
        System.out.println(item);
    }
}

컴파일 시:

public static void printList(List list) {
    for (Object item : list) {
        System.out.println(item);
    }
}

경계 제한을 통한 메서드 제약

public static <T extends Number> double sum(List<T> list) {
    double result = 0;
    for (T item : list) {
        result += item.doubleValue();
    }
    return result;
}

컴파일 시:

public static double sum(List list) {
    double result = 0;
    for (Object item : list) {
        result += ((Number) item).doubleValue();
    }
    return result;
}

결론

Java의 타입 소거는 제네릭의 핵심적인 설계 철학으로, 런타임 타입 정보를 제거하여 바이너리 하위 호환성성능 효율성을 제공합니다. 하지만 이로 인해 런타임 타입 정보 소실, 배열 생성 제한, instanceof 사용 제한과 같은 제약이 발생합니다. 이를 해결하기 위해 리플렉션이나 Class 객체를 활용하는 우회 방법을 적절히 사용할 필요가 있습니다.


 

'코딩스터디 > JAVA스터디' 카테고리의 다른 글

Java) 기본 API클래스  (0) 2024.11.22
Java 람다식  (0) 2024.11.21
Java) I/O  (0) 2024.11.19
Java 애노테이션  (3) 2024.11.18
Java) Enum  (3) 2024.11.17