자바의 Generic

제네릭

제네릭(Generic)은 클래스나 메서드에서 사용할 데이터 타입을 일반화해서 재사용성을 높이고 타입 안전성을 제공하는 Java의 기능입니다. 

제네릭 클래스 (Generic Class)

클래스 선언 시 타입 매개변수(T)를 사용하여, 해당 클래스가 특정 타입에 의존하지 않고 재사용 가능하게 만듭니다.

<T>는 타입 매개변수를 의미하며, 이는 클래스의 인스턴스를 생성할 때 구체적인 타입으로 대체됩니다.

  • 클래스 단위로 제네릭을 도입합니다.
  • 클래스를 정의할 때는 타입을 특정하지 않고, 객체를 생성하는 시점에 구체적인 타입을 지정합니다.
// 제네릭 클래스 정의
public class GenericClass<T> {
    private T data; // T 타입의 필드

    public GenericClass(T data) {
        this.data = data;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

// 객체 생성 시 타입 인자를 전달
GenericClass<String> stringBox = new GenericClass<>("Hello, World!");
System.out.println(stringBox.getData()); // 출력: Hello, World!

GenericClass<Integer> intBox = new GenericClass<>(123);
System.out.println(intBox.getData()); // 출력: 123

 

 

타입 파라미터 생략

제네릭 객체를 사용하는 문법 형태를 보면 양쪽 두 군데에 꺾쇠괄호 제네릭 타입을 지정함을 볼 수 있습니다.

하지만 맨 앞에서 클래스명과 함께 타입을 지정해 주었는데 굳이 생성자까지 제네릭을 지정해 줄 필요가 없습니다.

 

따라서 JDK 1.7 version 이후부터, new 생성자 부분의 제네릭 타입을 생략할 수 있게 되었습니다. 제네릭 나름대로 타입 추론을 해서 생략된 곳을 넣어주기 때문에 문제가 없는 것입니다.

FruitBox<Apple> intBox = new FruitBox<Apple>();

// 다음과 같이 new 생성자 부분의 제네릭의 타입 매개변수는 생략할 수 있다.
FruitBox<Apple> intBox = new FruitBox<>();

 

제네릭의 다형성

제네릭 타입 파라미터에 클래스가 타입으로 온다는 것은, 클래스끼리 상속을 통해 관계를 맺는 객체 지향 프로그래밍의 다형성 원리가 그대로 적용이 된다는 소리입니다.

class Fruit { }
class Apple extends Fruit { }
class Banana extends Fruit { }

class FruitBox<T> {
    List<T> fruits = new ArrayList<>();

    public void add(T fruit) {
        fruits.add(fruit);
    }
}

public class Main {
    public static void main(String[] args) {
        FruitBox<Fruit> box = new FruitBox<>();
        
        // 제네릭 타입은 다형성 원리가 그대로 적용된다.
        box.add(new Fruit());
        box.add(new Apple());
        box.add(new Banana());
    }
}

 

복수 타입 파라미터

제네릭은 타입 지정이 여러개가 필요한 경우 2개, 3개 등 원하는 개수대로 만들 수 있습니다.

꺽쇠 괄호 안에서 쉼표로 하여 <T , U> 와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있습니다.

import java.util.ArrayList;
import java.util.List;

class Apple {}
class Banana {}

class FruitBox<T, U> {
    List<T> apples = new ArrayList<>();
    List<U> bananas = new ArrayList<>();

    public void add(T apple, U banana) {
        apples.add(apple);
        bananas.add(banana);
    }
}

public class Main {
    public static void main(String[] args) {
    	// 복수 제네릭 타입
        FruitBox<Apple, Banana> box = new FruitBox<>();
        box.add(new Apple(), new Banana());
        box.add(new Apple(), new Banana());
    }
}

 

중요한 점

T는 타입 매개변수로, 특정 타입이 아닌 임의의 타입을 나타냅니다.

객체를 생성할 때 타입을 지정하므로, 컴파일러가 타입 검사를 수행하여 타입 안전성을 보장합니다.

  • GenericClass <String>은 String만 저장 가능.
  • GenericClass <Integer>는 Integer만 저장 가능.
또한 제네릭에서 할당 받을 수 있는 타입은 Reference 타입뿐입니다. 즉, int, double 같은 자바 원시타입을 제네릭 타입 파라미터로 넘길 수 없다는 말입니다.
// 기본 타입 int는 사용 불가 !!!
List<int> intList = new List<>(); 

// Wrapper 클래스로 넘겨주어야 한다. (내부에서 자동으로 언박싱되어 원시 타입으로 이용됨)
List<Integer> integerList = new List<>();

 

주의사항

1. 제네릭타입 자체로 타입을 지정하여 객체를 생성하는 것은 불가능합니다. 

즉, new 연산자 뒤에 제네릭타입 파라미터가 올수는 없습니다.

class Sample<T> {
    public void someMethod() {
        // Type parameter 'T' cannot be instantiated directly
        T t = new T();
    }
}

 

2. static 멤버에 제네릭 타입이 올 수 없습니다.

static 멤버는 클래스가 동일하게 공유하는 변수로서 제네릭 객체가 생성되기도 전에 이미 자료 타입이 정해져 있어야 하기 때문입니다.

class Student<T> {
    private String name;
    private int age = 0;

    // static 메서드의 반환 타입으로 사용 불가
    public static T addAge(int n) {

    }
}

 

제네릭 메서드 (Generic Method)

메서드 선언 시 <T>를 사용하여 메서드 단위에서 타입 매개변수를 정의합니다.

제네릭 메서드는 특정 클래스가 제네릭 클래스일 필요 없이, 개별 메서드 단위로 제네릭을 도입할 수 있습니다.

가장 중요한 점은 메서드를 호출하는 시점에 타입을 결정합니다.

 

위에서는 클래스의 제네릭 <T> 에서 설정된 타입을 받아와 반환 타입으로 사용할 뿐인 일반 메서드라면, 제네릭 메서드는 직접 메서드에 <T> 제네릭을 설정함으로써 동적으로 타입을 받아와 사용할 수 있는 독립적으로 운용 가능한 제네릭 메서드라고 이해하시면 됩니다.

class FruitBox<T> {
	
    // 클래스의 타입 파라미터를 받아와 사용하는 일반 메서드
    public T addBox(T x, T y) {
        // ...
    }
    
    // 독립적으로 타입 할당 운영되는 제네릭 메서드
    public static <T> T addBoxStatic(T x, T y) {
        // ...
    }
}

 

즉 아래 사진처럼 제네릭 클래스에 정의된 타입 매개변수와 제네릭 메서드에 정의된 타입 매개변수는 별개인게 되는 것입니다.

 

// 제네릭 메서드 정의
public class GenericMethod {
    public static <T> T genericMethod(T t) {
        System.out.println("Value: " + t);
        return t;
    }
}

// 메서드 호출 시 타입 전달
GenericMethod.genericMethod("Hello"); // 출력: Value: Hello
GenericMethod.genericMethod(123);     // 출력: Value: 123
GenericMethod.genericMethod(3.14);    // 출력: Value: 3.14

 

컴파일러가 제네릭 타입에 들어갈 데이터 타입을 메소드의 매개변수를 통해 추정할 수 있기 때문에, 대부분의 경우 제네릭 메서드의 타입 파라미터를 생략하고 호출할 수 있습니다.

// 메서드의 제네릭 타입 생략
FruitBox.addBoxStatic(1, 2); 
FruitBox.addBoxStatic("안녕", "잘가");

 

 

사실은 처음 제네릭 클래스를 인스턴스화 하면, 클래스 타입 매개변수에 전달한 타입에 따라 제네릭 메서드도 타입이 정해지게 됩니다. 그런데 만일 제네릭 메서드를 호출할 때 직접 타입 파라미터를 다르게 지정해 주거나, 다른 타입의 데이터를 매개변수에 넘긴다면 독립적인 타입을 가진 제네릭 메서드로 운용되게 됩니다.

class FruitBox<T, U> {
    // 독립적으로운영되는 제네릭 메서드
    public <T, U> void printBox(T x, U y) {
        // 해당 매개변수의 타입 출력
        System.out.println(x.getClass().getSimpleName());
        System.out.println(y.getClass().getSimpleName());
    }
}
public static void main(String[] args) {
    FruitBox<Integer, Long> box1 = new FruitBox<>();

    // 인스턴스화에 지정된 타입 파라미터 <Integer, Long>
    box1.printBox(1, 1);

    // 하지만 제네릭 메서드에 다른 타입 파라미터를 지정하면 독립적으로 운용 된다.
    box1.<String, Double>printBox("hello", 5.55);
    box1.printBox("hello", 5.55); // 생략 가능
}

 

제네릭 메서드를 사용하는 이유

제네릭 메서드는 코드의 재사용성을 높이고 타입 안정성을 유지하면서 다양한 데이터 타입을 처리하기 위해 사용됩니다. 아래에서 주요 이유를 하나씩 설명하겠습니다.

재사용성 증가

제네릭 메서드는 하나의 메서드로 다양한 타입을 처리할 수 있습니다.

 

타입별로 메서드를 정의하지 않아도 됨

// 제네릭이 없을 때
public String printString(String value) {
    return "String: " + value;
}

public Integer printInteger(Integer value) {
    return "Integer: " + value;
}

 

위처럼 각 타입별로 메서드를 따로 정의하면 코드가 중복되고 유지보수가 어렵습니다.

제네릭 메서드로 해결할 수 있습니다:

// 제네릭 메서드로 재사용성 증가
public <T> String print(T value) {
    return "Value: " + value;
}

// 호출 시
System.out.println(print("Hello")); // Value: Hello
System.out.println(print(123));    // Value: 123
System.out.println(print(3.14));   // Value: 3.14

타입 안정성 보장

컴파일러가 제네릭 메서드에서 타입 검사를 수행하므로, 런타임 에러를 방지할 수 있습니다.

 

제네릭이 없는 경우 (타입 안정성 없음)

public void print(Object value) {
    System.out.println(value);
}

print(123);   // 정상
print("abc"); // 정상

 

하지만 이런 경우, 반환값을 사용할 때 항상 타입 캐스팅이 필요하며, 잘못된 캐스팅으로 인해 런타임 에러가 발생할 수 있습니다:

Object result = getValue();
Integer number = (Integer) result; // 런타임 에러 가능!

 

제네릭 메서드는 타입 안정성을 보장

public <T> T getValue(T value) {
    return value;
}

Integer number = getValue(123);  // 컴파일러가 타입 확인
String text = getValue("abc");  // 안전한 타입 처리

 

컴파일 타임에 타입이 지정되므로, 잘못된 타입 캐스팅으로 인한 런타임 에러를 방지할 수 있습니다.

 

타입 추론 (Type Inference)

Java 컴파일러는 메서드를 호출할 때 전달된 인자의 타입을 기반으로 제네릭 타입을 자동 추론합니다.

따라서 코드가 간결해집니다.

 

타입 추론 활용

public static <T> T identity(T value) {
    return value;
}

// 호출 시 타입 명시 없이 사용
Integer num = identity(42);  // 컴파일러가 T를 Integer로 추론
String str = identity("Hello"); // 컴파일러가 T를 String으로 추론

 

타입을 명시적으로 작성하지 않아도 되므로 코드가 간결해지고 가독성이 높아집니다.

 

특정 타입의 제약 조건 설정 가능

제네릭 메서드는 특정 타입에 제약 조건 (Bounded Type Parameter)을 설정할 수 있습니다.

 

특정 타입만 허용

// Number와 그 하위 클래스 (Integer, Double 등)만 허용
public static <T extends Number> double add(T a, T b) {
    return a.doubleValue() + b.doubleValue();
}

System.out.println(add(1, 2));       // 정상: Integer
System.out.println(add(1.5, 2.5));   // 정상: Double
System.out.println(add("Hello", "World")); // 컴파일 에러!

 

이렇게 하면 타입 제한을 설정하여 불필요한 타입 에러를 방지할 수 있습니다.

 

유연성과 가독성 향상

제네릭 메서드를 사용하면 동일한 메서드 이름과 구조를 유지하면서 다양한 타입의 데이터를 처리할 수 있습니다.

이는 코드의 가독성과 유지보수성을 높입니다.

 

일반화된 유틸리티 메서드

// 리스트를 출력하는 유틸리티 메서드
public static <T> void printList(List<T> list) {
    for (T item : list) {
        System.out.println(item);
    }
}

// 호출 시
printList(Arrays.asList(1, 2, 3));      // Integer 리스트
printList(Arrays.asList("A", "B", "C")); // String 리스트
printList(Arrays.asList(3.14, 2.71));    // Double 리스트

 

'Java' 카테고리의 다른 글

Java - Enum  (1) 2024.12.24
자바의 Optional  (0) 2024.12.23
자바의 오류처리: 예외(Exception) 와 트랜잭션 처리  (0) 2024.12.18
자바의 toString 메서드  (1) 2024.12.17
String  (0) 2024.12.17