[Java 응용] Generic (제네릭)

Kyung Jae, Cheong·2024년 9월 4일
post-thumbnail

Java Generic (제네릭)

0. 제네릭과 필요성

Generic이란?

  • 제네릭(Generic)은 자바에서 클래스, 인터페이스, 메서드를 정의할 때 타입을 매개변수로 받을 수 있는 기능입니다.
  • 즉, 제네릭을 사용하면 데이터 타입을 일반화하여 다양한 타입을 처리할 수 있는 코드를 작성할 수 있습니다. 이로 인해 코드의 재사용성이 높아지고, 타입 안전성을 보장받을 수 있습니다.

제네릭이 필요한 이유

  1. 타입 안전성(Type Safety): 제네릭을 사용하면 컴파일 시점에 타입을 확인하여 잘못된 타입의 객체가 사용되는 것을 방지할 수 있습니다. 이는 런타임에 발생할 수 있는 ClassCastException과 같은 오류를 줄여줍니다.

  2. 코드 재사용성(Code Reusability): 제네릭은 다양한 타입을 처리할 수 있는 일반적인 클래스를 정의할 수 있게 해줍니다. 이를 통해 코드 중복을 줄이고, 여러 타입을 지원하는 코드 작성을 가능하게 합니다.

  3. 런타임 에러 방지: 제네릭을 사용하면 타입 캐스팅으로 인한 런타임 오류를 줄일 수 있습니다. 컴파일 시점에서 타입 검사를 하므로 런타임에 발생할 수 있는 예기치 않은 오류를 예방할 수 있습니다.

  4. 의미 있는 코드: 제네릭을 사용하면 코드의 가독성과 의미를 명확하게 할 수 있습니다. 타입 정보가 명시되어 있어 코드의 의도가 더 분명해지고, 유지보수도 용이해집니다.

1. Generic Type (제네릭 타입)

1.1 제네릭 타입이란?

  • 제네릭 타입(Generic Type)은 자바에서 데이터 타입을 일반화하여 여러 종류의 데이터 타입을 하나의 클래스, 인터페이스, 메서드에서 처리할 수 있게 해주는 기능입니다.
  • 제네릭 타입을 사용하면 코드에서 사용할 데이터 타입을 미리 지정하지 않고, 외부에서 지정할 수 있어 코드의 유연성과 재사용성을 크게 높일 수 있습니다.

1.2 제네릭 타입 정의

  • 제네릭 타입은 주로 클래스나 인터페이스를 정의할 때 사용됩니다.
  • 제네릭 타입을 정의할 때는 클래스명이나 인터페이스명 뒤에 <T>와 같이 타입 매개변수를 지정합니다.
    • 여기서 T타입 매개변수를 나타내며, 임의의 이름을 사용할 수 있지만 일반적으로 T, E, K, V, N 등의 관습적인 이름을 사용합니다.
      • 주로 쓰이는 것은 T(Type), N(Numbers), K(Key), V(Value), E(Element), 등 입니다.
    • 타입 매개변수의 규칙이 따로 있는 것은 아니지만, 일반적으로 소문자는 가독성이 떨어져 대문자로 사용하는것이 관례입니다.
      • 개인적으로는 여러개의 타입이 필요할땐 T1, T2와 같이 사용하는 것을 추천합니다.
public class Box<T> {
    private T item;

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

    public T getItem() {
        return item;
    }
}
  • 위의 예제에서 Box 클래스는 T라는 제네릭 타입을 사용합니다.
    • 이로 인해 Box 클래스는 T 타입의 객체를 저장하고 반환할 수 있게 되며, T는 사용자가 클래스 인스턴스를 생성할 때 구체적인 타입으로 대체됩니다.
  • 제네릭 타입은 객체 생성을 통해 타입을 지정해야하므로 static 키워드를 통한 정의는 할 수 없습니다.

1.3 제네릭 타입의 사용

  • 제네릭 타입은 객체를 생성할 때 구체적인 타입으로 대체됩니다.
    • 예를 들어, Box<String>으로 객체를 생성하면 TString으로 대체되고, Box<Integer>로 생성하면 TInteger로 대체됩니다.
  • 여기서 중요한 점은 타입 인수(Arguments)로는 기본형(int, double, ...)은 사용할 수 없다는 점입니다. 대신 Wrapper Class(Integer, Double, ...)를 사용하시면 됩니다.
Box<String> stringBox = new Box<String>(); // 오른쪽 타입 생략 가능 (타입추론)
stringBox.setItem("Hello");
String item = stringBox.getItem();  // 타입 캐스팅 불필요

Box<Integer> intBox = new Box<>(); // 오른쪽 타입 생략 가능 (타입추론)
intBox.setItem(123);
Integer number = intBox.getItem();  // 타입 캐스팅 불필요
  • 위 예시에서 stringBoxString 타입의 데이터를, intBoxInteger 타입의 데이터를 안전하게 저장하고 반환할 수 있습니다.
    • 이때 타입 캐스팅이 필요 없으므로 코드가 간결해지고 오류 발생 가능성이 줄어듭니다.
  • 참고로 new Box<>()처럼 문장 안에서 타입을 추정 할 수 있는 경우 생략할 수 있고, 이를 타입 추론이라 부릅니다. (왼쪽의 변수 선언 부분에선 생략하면 안됩니다.)

1.4 타입 매개변수 제한

  • 제네릭 타입을 정의할 때, 특정 타입이나 그 하위 타입만 허용하도록 제한할 수 있습니다.
  • 이를 타입 매개변수 제한(Type Parameter Bound)이라고 하며, <T extends SomeClass>와 같은 형태로 사용됩니다. (SomeClass는 임의의 클래스)
  • 이를 통해 제네릭 타입이 기대하는 기능이나 인터페이스를 가진 타입만을 허용할 수 있습니다.

extends를 이용한 상한 제한

<T extends SomeClass>는 타입 매개변수 TSomeClass의 하위 타입이거나, SomeClass 자체여야 함을 의미합니다.

  • 이를 통해 T 타입이 특정 클래스의 메서드나 필드를 사용할 수 있다는 보장이 생기며, 더 안전한 코드를 작성할 수 있습니다.
public class NumberBox<T extends Number> {
    private T number;

    public NumberBox(T number) {
        this.number = number;
    }

    public double doubleValue() {
        return number.doubleValue();
    }
}
  • 위의 NumberBox 클래스에서 TNumber 클래스 또는 그 하위 클래스(Integer, Double, Float, 등)여야 합니다.
    • 이로 인해 T 타입의 객체는 항상 Number 클래스의 메서드를 사용할 수 있게 됩니다.
  • 참고로 & 연산자를 사용하여 여러 클래스를 조합하여 타입 매개변수에 여러 제한을 동시에 걸 수도 있습니다. (예를 들어, T가 특정 클래스를 상속받고, 동시에 여러 인터페이스를 구현하도록 제한할 수 있습니다.)
public class MultiBoundBox<T extends Number & Comparable<T>> {
    private T number;

    public MultiBoundBox(T number) {
        this.number = number;
    }

    public boolean isGreaterThan(T other) {
        return number.compareTo(other) > 0;
    }
}

2. Generic Method (제네릭 메서드)

2.1 제네릭 메서드란?

  • 제네릭 메서드(Generic Method)는 메서드의 선언부에 타입 매개변수를 사용하는 메서드를 말합니다.
    • 제네릭 메서드를 사용하면 메서드가 다양한 타입의 매개변수를 처리할 수 있어, 더 유연하고 재사용 가능한 코드를 작성할 수 있습니다.
  • 제네릭 메서드는 메서드의 리턴 타입 앞에 타입 매개변수를 선언하여 정의합니다. 이로 인해 메서드 내부에서 해당 타입 매개변수를 사용할 수 있습니다.
public <T> void printArray(T[] array) {
    for (T element : array) {
        System.out.println(element);
    }
}
  • 위의 printArray 메서드는 타입 매개변수 T를 사용하여 어떤 타입의 배열이든 받아서 그 배열의 모든 요소를 출력할 수 있습니다.
    • T는 메서드를 호출할 때 결정되며, 여러 타입에 대해 동일한 메서드를 재사용할 수 있게 합니다.

2.2 제네릭 메서드의 특징

  • 타입 매개변수는 메서드 범위에서만 유효: 제네릭 메서드에서 선언한 타입 매개변수는 메서드 내부에서만 유효합니다.
    • 이는 클래스의 제네릭 타입과는 독립적으로 작동할 수 있음을 의미합니다.
  • 리턴 타입으로 제네릭 사용 가능: 제네릭 메서드는 매개변수뿐만 아니라 리턴 타입에도 제네릭을 사용할 수 있습니다.
    • 이를 통해 리턴되는 객체의 타입을 유연하게 지정할 수 있습니다.
public static <T> T getFirstElement(T[] array) {
    if (array == null || array.length == 0) {
        return null;
    }
    return array[0];
}
  • 위의 getFirstElement 메서드는 배열의 첫 번째 요소를 반환하는 제네릭 메서드입니다.
  • 메서드를 호출할 때 배열의 타입에 맞는 객체가 리턴됩니다.

2.3 제네릭 메서드의 사용 예시

  • 제네릭 메서드는 다양한 상황에서 사용될 수 있으며, 특히 다음과 같은 경우에 유용합니다:
    • 데이터 타입에 독립적인 로직 구현: 여러 데이터 타입에 대해 동일한 로직을 적용해야 할 때 제네릭 메서드를 사용하면 코드 중복을 줄일 수 있습니다.
    • 타입 안전한 콜렉션 처리: 콜렉션의 요소를 처리하는 메서드를 작성할 때 제네릭을 사용하면, 요소의 타입이 일관되게 처리됨을 보장할 수 있습니다.
public static <T extends Comparable<T>> T findMax(T a, T b) {
    return a.compareTo(b) > 0 ? a : b;
}
  • 위의 findMax 메서드는 두 개의 Comparable 타입 객체를 받아, 더 큰 값을 반환하는 제네릭 메서드입니다.
  • 이 메서드는 Comparable 인터페이스를 구현한 모든 객체에 대해 사용할 수 있습니다.

2.4 제네릭 메서드와 static

  • 제네릭 메서드는 클래스의 인스턴스 메서드뿐만 아니라 static 메서드에서도 사용할 수 있습니다.
    • 이는 클래스의 타입 매개변수와 무관하게 메서드 자체가 독립적으로 타입을 다룰 수 있음을 의미합니다.
public class Utility {
    public static <T> void printElement(T element) {
        System.out.println(element);
    }
}
  • 위의 printElement 메서드는 Utility 클래스의 static 메서드로, 어떤 타입의 객체도 받아서 출력할 수 있습니다.
    • 이처럼 제네릭 메서드는 특정 인스턴스에 의존하지 않고도 다양한 타입을 처리할 수 있는 유용한 도구입니다.

3. Wildcard(와일드카드)

3.1 와일드카드란?

  • 와일드카드(Wildcard)는 제네릭 타입에서 불특정 타입을 표현하기 위해 사용되는 특수 문법입니다.
    • 제네릭에서 와일드카드는 ? 기호로 표시되며, 이는 "어떤 타입이든 가능하다"는 의미를 가집니다.
  • 와일드카드를 사용하면 다양한 타입의 객체를 처리할 수 있는 더욱 유연한 메서드나 클래스를 작성할 수 있습니다.
  • 쉽게 말해서 기존에 정의된 제네릭 타입을 일반 메서드의 인수로 사용할 수 있도록 해주는 기능이라고 보시면 됩니다.

3.2 와일드카드의 종류

  • 와일드카드는 크게 세 가지 유형으로 나눌 수 있습니다:
    • Unbounded Wildcard (제한 없는 와일드카드)
    • Upper Bounded Wildcard (상한 경계 와일드카드)
    • Lower Bounded Wildcard (하한 경계 와일드카드)

Unbounded Wildcard (제한 없는 와일드카드)

  • <?>는 어떤 타입이라도 받을 수 있는 제네릭 타입을 의미합니다.
    • 제네릭 타입에 관계없이 메서드나 클래스에서 어떤 타입의 객체든 처리할 수 있게 합니다.
public void printList(List<?> list) {
    for (Object element : list) {
        System.out.println(element);
    }
}
  • 위의 printList 메서드는 어떤 타입의 리스트라도 받아서 그 요소들을 출력할 수 있습니다.
  • 여기서 리스트의 타입이 무엇이든 상관없기 때문에, 메서드는 List<String>, List<Integer> 등 다양한 리스트 타입을 처리할 수 있습니다.

Upper Bounded Wildcard (상한 경계 와일드카드)

  • <? extends T>는 와일드카드가 T 타입이나 T의 하위 타입을 나타낼 수 있음을 의미합니다.
    • 이는 제네릭 타입이 상속 구조에서 상위 클래스와 그 하위 클래스들을 처리할 수 있게 합니다.
public void processNumbers(List<? extends Number> list) {
    for (Number number : list) {
        System.out.println(number.doubleValue());
    }
}
  • 위의 processNumbers 메서드는 Number 타입이나 그 하위 타입(Integer, Double 등)의 리스트를 받아서 처리할 수 있습니다.
  • 이를 통해, Number 클래스에서 정의된 메서드(예: doubleValue)를 안전하게 사용할 수 있습니다.

Lower Bounded Wildcard (하한 경계 와일드카드)

  • <? super T>는 와일드카드가 T 타입이나 T의 상위 타입을 나타낼 수 있음을 의미합니다.
public void addNumbers(List<? super Integer> list) {
    list.add(1);
    list.add(2);
}
  • 위의 addNumbers 메서드는 Integer 타입이나 그 상위 타입(Number, Object)의 리스트에 Integer 값을 추가할 수 있습니다.
  • 이는 특정 타입 이상의 모든 객체를 받아들일 수 있게 해주며, 하위 타입의 객체를 안전하게 추가할 수 있게 합니다.

3.3 와일드카드의 사용 예시

  • 와일드카드는 제네릭 타입의 유연성을 극대화하기 위해 자주 사용됩니다.
  • 특히 다음과 같은 경우에 유용합니다:
    • 타입 범위 제한: 특정 클래스나 인터페이스의 하위 클래스 또는 상위 클래스를 대상으로 작업할 때 와일드카드를 사용하면 코드가 더 유연해집니다.
    • 타입에 관계없는 공통 작업: 여러 타입에 대해 공통으로 수행되는 작업을 제네릭 메서드로 처리할 때, 와일드카드를 사용하면 타입에 구애받지 않는 코드를 작성할 수 있습니다.

3.4 와일드카드의 한계와 제네릭 메서드

  • 와일드카드는 제네릭 프로그래밍에서 유용하게 사용되지만, 그 한계도 존재하며, 때로는 제네릭 메서드로 대체하는 것이 더 나은 경우도 있습니다.

와일드카드의 한계

  1. 타입 정보의 모호성: 와일드카드를 사용하면, 해당 타입이 정확히 무엇인지 알 수 없기 때문에 타입에 대한 구체적인 작업이 어려워집니다.
  • 예를 들어, List<?>는 어떤 타입의 요소가 들어있는지 알 수 없기 때문에 리스트에 요소를 추가하거나 특정 타입의 메서드를 호출할 수 없습니다.
public void processList(List<?> list) {
    // list.add(new Object());  // 컴파일 에러: 특정 타입을 추가할 수 없음
    Object item = list.get(0);  // 요소를 가져오는 것은 가능
}
  • 위 코드에서, list의 타입이 불명확하기 때문에 요소를 추가하는 등의 작업이 제한됩니다. 이로 인해 코드의 기능이 제한적일 수 있습니다.
  1. 제한된 쓰기 작업: 상한 경계 와일드카드(<? extends T>)를 사용할 경우 리스트에 요소를 추가하는 작업이 제한됩니다.
  • 이는 와일드카드가 지정된 타입의 하위 타입이기 때문에, 정확한 타입을 알 수 없다는 점에서 발생하는 한계입니다.
public void addNumber(List<? extends Number> list) {
    // list.add(new Integer(10));  // 컴파일 에러: 추가할 수 없음
}
  • 위 코드에서는 리스트에 Number의 하위 타입을 추가할 수 없습니다. 이는 상한 경계 와일드카드를 사용하면 리스트의 요소가 특정 타입을 벗어날 수 있다는 위험 때문에 발생합니다.

제네릭 메서드로의 대체

  • 와일드카드의 이러한 한계를 해결하기 위해, 때로는 와일드카드를 사용하는 대신 제네릭 메서드를 사용하는 것이 더 나은 선택이 될 수 있습니다.
  • 제네릭 메서드는 타입 매개변수를 명시적으로 정의하여, 메서드 내에서 보다 명확한 타입 정보를 사용할 수 있게 합니다.
public <T> void processList(List<T> list) {
    list.add(list.get(0));  // 동일한 타입의 요소를 추가할 수 있음
}
  • 위 코드에서 processList 메서드는 타입 매개변수 T를 사용하여 리스트의 타입을 명확하게 지정하고 있습니다.
  • 이로 인해, 와일드카드를 사용할 때와 달리 리스트에 동일한 타입의 요소를 추가하는 작업이 가능해집니다.

4. Type Eraser (타입 이레이저)

4.1 타입 이레이저란?

  • 타입 이레이저(Type Erasure)는 자바에서 제네릭이 도입되었을 때, 하위 호환성을 유지하기 위해 사용된 메커니즘입니다.
    • 제네릭은 컴파일 시점에만 타입 안전성을 제공하고, 런타임 시점에서는 제네릭 타입 정보가 제거됩니다.
    • 이 과정을 타입 이레이저라고 하며, 이로 인해 런타임 시점에는 제네릭 타입이 Object 타입 또는 지정된 상한 타입으로 대체됩니다.

4.2 타입 이레이저의 동작 방식

타입 매개변수 제거

  • 컴파일 시점에 제네릭 타입은 실제 타입으로 대체되거나, 특정한 타입으로 제한된 경우 그 타입으로 대체됩니다.
    • 예를 들어, 제네릭 클래스 Box<T>에서 T가 컴파일 시점에 Object나 지정된 상한 타입으로 대체됩니다.
public class Box<T> {
    private T item;

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

    public T getItem() {
        return item;
    }
}
  • 위 코드에서 Box<String>으로 사용된 제네릭 클래스는 컴파일 후 다음과 같은 형태로 변환됩니다
public class Box {
    private Object item;

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

    public Object getItem() {
        return item;
    }
}

경계(bound)의 적용

  • 제네릭 타입이 상한 경계(bound)를 가지고 있는 경우, 해당 경계 타입으로 변환됩니다.
    • 예를 들어, TNumber로 제한된 경우, 제네릭 타입은 Number로 대체됩니다.
public class NumberBox<T extends Number> {
    private T number;

    public void setNumber(T number) {
        this.number = number;
    }

    public T getNumber() {
        return number;
    }
}
  • 위 코드에서 NumberBox<Integer>는 컴파일 후 다음과 같이 변환됩니다
public class NumberBox {
    private Number number;

    public void setNumber(Number number) {
        this.number = number;
    }

    public Number getNumber() {
        return number;
    }
}

다형성과 캐스팅

  • 타입 이레이저로 인해 제네릭 타입 정보가 제거되므로, 런타임에는 타입 안전성이 컴파일 시점보다 낮아질 수 있습니다.
  • 따라서, 제네릭 타입의 요소를 사용할 때는 명시적인 캐스팅이 필요할 수 있으며, 이는 런타임 시 ClassCastException이 발생할 가능성을 증가시킵니다.
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");

// 컴파일 후:
Box objectBox = stringBox;  // Box 타입으로 변환
String item = (String) objectBox.getItem();  // 명시적 캐스팅 필요

4.3 타입 이레이저의 장단점

장점

    1. 하위 호환성
    • 타입 이레이저 덕분에 제네릭이 도입되기 이전의 자바 코드와의 하위 호환성이 유지됩니다. 기존의 코드와 라이브러리들이 수정 없이 작동할 수 있습니다.
    1. 단순화된 JVM
    • 타입 정보가 제거되기 때문에, JVM은 제네릭을 특별히 처리할 필요가 없으며, 기존의 객체 모델을 그대로 사용할 수 있습니다.

한계와 단점

    1. 타입 안전성 감소
    • 컴파일 시점에는 타입이 명확하지만, 런타임 시에는 타입 정보가 제거되므로, 잘못된 타입 캐스팅으로 인한 오류가 발생할 수 있습니다.
    1. 런타임 타입 확인 제한
    • 제네릭 타입 정보가 제거되므로, 런타임에 특정 타입을 정확하게 확인하거나 반영하기 어렵습니다.
      • 예를 들어, List<String>List<Integer>는 컴파일 후 동일한 List로 간주됩니다.
    • instanceof의 제약: 타입 이레이저로 인해 런타임 시 제네릭 타입 정보가 사라지기 때문에, 제네릭 타입에 대해 instanceof를 사용할 수 없습니다.
      • 제네릭 타입은 컴파일 후 일반 객체로 취급되므로, 특정 타입인지 확인하려면 명확한 타입 정보가 필요하지만, 이 정보가 제거되기 때문에 instanceof 연산자는 정확한 타입을 확인할 수 없습니다.
      List<String> stringList = new ArrayList<>();
      if (stringList instanceof List<String>) {  // 컴파일 에러 발생
          // Do something
      }
    1. 오버로딩 제한
    • 제네릭 메서드의 타입 이레이저로 인해, 동일한 메서드 서명이 충돌하여 메서드 오버로딩이 제한될 수 있습니다.
    • 이는 컴파일러가 제네릭 타입을 제거할 때, 서로 다른 제네릭 메서드들이 동일한 서명을 가지게 되어 발생합니다.
public void method(List<String> list) { }
public void method(List<Integer> list) { }  // 컴파일 에러 발생
    1. 제네릭 타입으로 객체 생성 불가 (new 키워드의 제약)
    • 타입 이레이저로 인해 제네릭 타입으로는 직접 객체를 생성할 수 없습니다.
    • 제네릭 타입은 컴파일 시점에서 구체적인 타입 정보가 제거되기 때문에, new T()와 같은 구문은 사용할 수 없습니다.
    • 이로 인해 제네릭 타입으로 배열을 생성하거나 객체를 생성하는 것이 불가능합니다.
public class Box<T> {
    private T item;

    public Box() {
        this.item = new T();  // 컴파일 에러 발생
    }
}

위 코드에서 new T()는 컴파일 에러를 발생시킵니다. 이는 T가 컴파일 시점에 구체적인 타입이 아니기 때문에, 어떤 생성자를 호출해야 하는지 알 수 없기 때문입니다.

마무리

  • Generic(제네릭)은 자바에서 타입 안전성을 높이고 코드의 재사용성을 극대화하는 강력한 도구입니다.
    • 제네릭을 사용하면 컴파일 시점에서 타입 오류를 방지할 수 있고, 다양한 타입을 유연하게 처리할 수 있습니다.
  • 와일드카드는 제네릭의 유연성을 더욱 확장시켜 다양한 타입을 포괄할 수 있게 해주지만, 타입 이레이저로 인해 런타임 시 타입 정보가 제거되어 instanceofnew와 같은 구문에 제약이 발생합니다.
    • 이러한 한계에도 불구하고 제네릭과 와일드카드를 적절히 활용하면, 보다 안전하고 효율적인 자바 코드를 작성할 수 있습니다.
  • 제네릭의 개념과 한계를 이해하고 상황에 맞게 적용하는 것이 자바 프로그래밍의 핵심입니다.
profile
일 때문에 포스팅은 잠시 쉬어요 ㅠ 바쁘다 바빠 모두들 화이팅! // Machine Learning (AI) Engineer & BackEnd Engineer (Entry)

0개의 댓글