티스토리 뷰

JAVA

[JAVA] 제네릭(Generic) 개념 이해하기

까리한 새우 2024. 3. 16. 23:48

프로그래밍을 공부하면서 제대로 집고가지 못한 개념 중 하나가 제네릭(Generic)이다.

프로그래밍을 하다보면 한 번쯤은 혹은 종종 들어보았을 개념이다. 

특히나 자료구조 같이 구조체를 직접 만들어 사용할 때도 많이 쓰이기도 하고 매우 유용하기도 하다.

 

그럼 제네릭(Generic) 이라는 것은 무엇일까?

 

제네릭(Generic)은 직역하자면 '일반적인' 이라는 뜻인데, 딱 보았을 때는 이해가 잘 되지 않는다.

자바에서 제네릭은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다.

 

한마디로 특정(Specific)타입을 미리 지정해주는 게 아닌 필요할 때 지정할 수 있도록 한 일반(Generic)타입이라는 것이다.

자바에서 배열과 함께 자주 쓰이는 자료형이 리스트(List)인데, 다음과 같이 클래스 선언 문법에 꺽쇠 괄호 '< >' 로 되어있는 코드 형태를 한번쯤은 보았을 것이다.

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

 

꺽쇠 괄호(<>)가 바로 제네릭이다. 괄호 안에는 타입명을 기재하면 된다. 그러면 저 List 클래스 자료형의 타입은 String 타입으로 지정되어 문자열 데이터만 리스트에 적재할 수 있게 된다.

 

예를 들면, 우리가 어떤 자료구조를 만들어 배포하려 한다고 생각해보자. 그런데 String 타입도 지원하고 싶고 Integer 타입도 지원하고 싶고 등등의 다양한 타입을 지원하고 싶을때가 있다. 그러면 String에 대한 클래스, Integer에 대한 클래스 등 하나하나 타입에 따라 만들어 줄 수 없을 것이다. 너무 비효율적이기 때문이다. 이러한 문제를 해결하기 위해 우리는 제네릭이라는 것을 사용한다.

 

이 글에서는 제네릭에 대해 깊게 파고들어 갈 것이다.

(간단하게 작성하려 했으나 이번 글부터는 꼼꼼하게 작성 예정이다.)

 


 

제네릭 타입 매개변수에 대해

 

다음 코드에서 볼 수 있듯이 제네릭은 <> 꺽쇠 괄호 키워드를 사용하는데 이를 다이아몬드 연산자라고 합니다.

그리고 이 꺽쇠 괄호 안에 식별자 기호를 지정함으로써 파라미터화 할 수 있습니다.

이것을 마치 메소드가 매개변수를 받아 사용하는 것과 비슷하다고 하여 제네릭의 타입 매개변수(parameter) / 타입 변수라고 부릅니다.

List<T>

 

 

타입 파라미터 기호

제네릭을 사용할 때 기호를 <T> 이런식으로 자주 사용하고 확인 해보았을 것이다.

단순히 암묵적인 규칙이 존재하기 때문에 제네릭의 변수를 T 로 표현한다고 보면 된다.

만약 두번째, 세번째 제네릭이 필요하다면 S, U 로 이어나간다.

타입 설명
<T> 타입 (Type)
<E> 요소 (Element)
<K> 키 (Key)
<V> 리턴 값 또는 매핑된 값 (Variable)
<N> 숫자 (Number)
<S, U, V> 2번째, 3번째, 4번째에 선언된 타입

 

 

타입 파라미터 정의

타입 매개변수(파라미터)는 제네릭을 이용한 클래스나 메소드를 설계할 때 사용됩니다.

다음 코드는 제네릭을 사용하여 클래스를 정의한 코드입니다.

 

클래스명 옆에 <T> 기호로 제네릭을 붙여준 것을 볼 수 있습니다. 

그리고 클래스 내부에서 식별자 기호 T 가 클래스 필드와, 메소드의 매개변수의 타입으로 지정되어 있습니다.

class Animals<T> {
    List<T> animal = new ArrayList<>();
    
    public void add(T animal) {
    	animal.add(animal);
    }
}

 

이후에 인스턴스화를 해보자.

마치 파라미터를 지정해서 보내는 것처럼 생성 코드에서 꺽쇠 괄호 안에 지정해주고 싶은 타입명을 할당해주면, 제네릭 클래스 선언문 부분으로 가서 타입 파라미터 T 가 지정된 타입으로 모두 변환되어 클래스의 타입이 지정되는 것입니다.

// 제네릭 타입 매개변수에 정수 타입을 할당
Aniamls<Integer> animals = new Animals<>();

// 제네릭 타입 매개변수에 문자열 타입을 할당
Aniamls<String> animals = new Animals<>();

// 제네릭 타입 매개변수에 클래스 할당 (Dog 클래스가 있다고 가정하자.)
Aniamls<Dog> animals = new Animals<>();

 

이를 설명하자면, T 타입으로 작성한 클래스의 그 T 부분에서 실행부의 매개변수 타입을 받아와 내부에서 타입이 구체적으로 설정이 되는 것이다. 이를 전문 용어로 구체화(Specialization) 라고 합니다.

추가로 예전에는 인스턴스를 생성할 때 new 생성자 부분에도 제네릭타입을 지정해주어야 했지만, jdk 1.7 버전 이후? 부터는 제네릭 타입을 생략할 수 있게 되었습니다. (위 코드 참고)

 

 

타입 파라미터 할당 가능 타입

제네릭에서 할당 받을 수 있는 타입은 Reference 타입(참조 타입) 뿐이다.

즉, int형 이나 double형 같은 자바 원시 타입(Primitive Type)을 제네릭 타입 파라미터로 넘길 수 없다는 말이다.

추후에 Wrapper 클래스에 대해 정리하고 공부해보자. 

지금은 그냥 "아 그렇구나" 라고 생각하고 넘어가자.

 

 

복수 타입 파라미터

제네릭은 타입 지정이 여러개가 필요할 경우에 2개, 3개 얼마든지 만들 수 있다.

제네릭 타입의 구분은 꺽쇠 괄호 안에서 쉼표(,)로 하며 <T, U>와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있다.

그리고 당연히 클래스 초기화할때 제네릭 타입 두개를 넘겨주어야 한다.

Map 컬렉션을 사용 할 떄 본 적이 있을 것이다.

Map<T, V> map = new HashMap<>();

Map<Integer, String> map = new HashMap<>();

 

 

뿐만 아니라, 제네릭 객체를 타입 파라미터로 받는 형식도 사용할 수 있다.

예를 들어, ArrayList 자체도 하나의 타입으로써 제네릭 타입 파라미터가 될 수 있기 때문에 이렇게 중첩 형식으로 사용할 수 있는 것이다.

 

이 부분은 간단하게 코드만 살펴보고 넘어가겠다.

public class Generic {

    public static void main(String[] args) {
    	// LinkedList<String>을 원소로서 저장하는 ArrayList
        ArrayList<LinkedList<String>> list = new ArrayList<>();

        LinkedList<String> link1 = new LinkedList<>();
        link1.add("aa");
        link1.add("bb");

        LinkedList<String> link2 = new LinkedList<>();
        link2.add("11");
        link2.add("22");

        list.add(link1);
        list.add(link2);
        System.out.println(list);
    }
}

 


 

제네릭 사용 이유

 

1. 컴파일 단계에서 타입 검사를 통해 예외를 방지한다.

제네릭 타입을 사용함으로써 잘못된 타입이 사용될 수 있는 문제를 컴파일 과정에서 제거할 수 있습니다.

자바 컴파일러는 코드에서 잘못 사용된 타입 때문에 발생하는 문제점을 제거하기 위해 제네릭 코드에 대해 강한 타입 체크를 합니다.

실행 시에 타입 에러가 나는것보다(런타임 오류) 컴파일 시에 미리 타입을 체크해서 에러를 사전에 방지하는 것이 좋습니다.

 

2. 클래스 외부에서 타입을 지정해주기 때문에 불필요한 캐스팅을 없애 성능이 향상 된다.

만약 우리가 제네릭을 사용하지 않는다고 하였을 때, 객체의 부모 클래스인 Objcet 클래스를 사용하여 타입을 받고 자신이 필요한 타입으로 형변환을 하여 객체를 가지고 와야 할 것입니다.

일일히 다운캐스팅을 해야하기 때문에 번거롭고 쓸데없는 성능만 낭비하는 꼴입니다.

 

반면, 제네릭을 사용하면 미리 타입을 지정하고 제한해 놓기 때문에 형 변환(Type Casting)의 번거로움을 줄일 수 있으며, 타입 검사에 들어가는 메모리를 줄일 수 있고 추가로 가독성도 좋아지는 장점이 있습니다.

 

 

제네릭 사용 시 주의사항

1. 제네릭 타입의 객체는 생성이 불가하다.

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

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

class Test<T> {
    public void testMethod() {
    // 불가X
    T t = new T();
    }
}

 

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

아래 코드처럼 static 변수의 데이터 타입으로 제네릭 타입 파라미터가 올 수 없습니다.

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

class Person<T> {
    private String name;
    private int age;
    
    // static 메서드의 반환 타입으로 사용 불가
    public static T addAge(int age) { ... }
}

 

 

클래스 및 인터페이스 선언

 

기본적으로 제네릭 타입의 클래스나 인터페이스의 경우 아래와 같이 선언합니다.

public class ClassName <T> {...}
public interface InterfaceName <T> {...}

 

추가로 제네릭 타입을 하나가 아닌 두 개로 둘 수도 있습니다. (예로 HashMap이 있습니다.)

public class HashMap <K, V> {...}

public interface InterfaceName <T, K> {...}

 

이렇게 데이터 타입을 외부로부터 지정할 수 있도록 할 수 있습니다.

그렇다면 이렇게 생성이 된 제네릭 클래스를 사용하는 방법에 대해 알아보겠습니다.

그럴려면, 객체를 생성해야 하는데 이때 구체적인 타입을 명시해주어야 합니다.

// TestGeneric 클래스가 T 와 K 타입을 받게 선언되어있다.
public class TestGeneric <T, K> {...}

public class Main {
    public static void main(String[] args) {
        // new 연산자 부분에 <Integer, String> 생략 가능하다.
        TestGeneric<Integer, String> test = new TestGeneric<>();
    }
}

 

위 코드대로면 T는 Integer 타입이 되고, K는 String이 됩니다.

 

아까 위에서 얘기했듯이 타입 파라미터로 명시할 수 있는 것은 참조 타입(Reference Type) 밖에 올 수 없습니다.

따라서 참조 타입이 올 수 있다는 것은 사용자가 정의한 클래스도 타입으로 올 수 있다는 것입니다.

public class Main {
    public static void main(String[] args) {
        // Person 클래스가 있다고 가정
        TestGeneric<Person> test = new TestGeneric<>();
    }
}

 

 

제네릭 클래스

지금까지 제네릭에 대해 알아보았으니, 이젠 제네릭 클래스를 만들어보고 활용해보자.

우선 파라미터를 하나만 받는 제네릭을 만들어보자.

 

< 코드 >

class TestGeneric <T> {
    private T id;

    void set(T id) {
        this.id = id;
    }
    T get() {
        return id;
    }
}

public class Generic {

    public static void main(String[] args) {
        TestGeneric<Integer> t1 = new TestGeneric<>();
        TestGeneric<String> t2 = new TestGeneric<>();

        t1.set(1);
        t2.set("hello");

        System.out.println("첫 번째 id = " + t1.get());
        //반환된 변수의 타입 출력
        System.out.println("첫 번째 id의 Type= " + t1.get().getClass().getName());

        System.out.println("두 번째 id = " + t2.get());
        //반환된 변수의 타입 출력
        System.out.println("두 번째 id의 Type= " + t2.get().getClass().getName());
    }
}

 

< 결과 >

 

위 코드에서 TestGeneric 이란 객체를 생성할 때 <> 안에 타입 파라미터(Type parameter)를 지정한다.

 

그러면 t1 객체의 T 제네릭 타입은 Integer로 모두 변환되고, t2 객체의 T 제네릭 타입은 String으로 모두 변환된다.

 

다음은 제네릭을 두 개 쓰는 클래스이다.

 

< 코드 >

class TestGeneric <T, K> {
    private T id;
    private K name;

    void set(T id, K name) {
        this.id = id;
        this.name = name;
    }
    T getId() {
        return id;
    }

    K getName() {
        return name;
    }
}

public class Generic {

    public static void main(String[] args) {
        TestGeneric<Integer, String> t = new TestGeneric<>();

        t.set(1, "baek");

        System.out.println("id = " + t.getId());
        //반환된 변수의 타입 출력
        System.out.println("id의 Type= " + t.getId().getClass().getName());

        System.out.println("name = " + t.getName());
        //반환된 변수의 타입 출력
        System.out.println("name의 Type= " + t.getName().getClass().getName());
    }
}

 

< 결과 >

 

 

제네릭 메서드

지금까지는 클래스 이름 옆에 <T> 라는 제네릭타입을 붙여 해당 클래스 내에서 사용할 수 있는 T 타입으로 일반화를 했다.

그러나 그 외 별도로 메서드에 한정한 제네릭도 사용할 수 있다.

 

우선 선언 방법은 다음과 같다.

public <T> T getnericMethod(T t) {...} //제네릭 메소드

[접근제어자] <제네릭타입> [반환타입] [메소드명]([제네릭타입] [파라미터]) {...}

 

클래스에서 제네릭을 사용한 것과는 다르게 반환타입 이전에 <> 이용하여 제네릭 타입을 선언한다.

 

위에서 사용한 제네릭 코드에서 활용해보자

 

< 코드 >

class TestGeneric <T> {
    private T id;

    void set(T id) {
        this.id = id;
    }
    T get() {
        return id;
    }
    
    static <T> T genericMethod(T t) { // 제네릭 메서드
        return t;
    }
}

public class Generic {

    public static void main(String[] args) {
        TestGeneric<Integer> t1 = new TestGeneric<>();
        TestGeneric<String> t2 = new TestGeneric<>();

        t1.set(1);
        t2.set("hello");

        System.out.println("첫 번째 id = " + t1.get());
        //반환된 변수의 타입 출력
        System.out.println("첫 번째 id의 Type= " + t1.get().getClass().getName());

        System.out.println("두 번째 id = " + t2.get());
        //반환된 변수의 타입 출력
        System.out.println("두 번째 id의 Type= " + t2.get().getClass().getName());
        System.out.println();
        
        // 제네릭 메서드 Integer
        System.out.println("<T> Method returnType= " + t1.genericMethod(5).getClass().getName());
        
        // 제네릭 메서드 String
        System.out.println("<T> Method returnType= " + t1.genericMethod("hello").getClass().getName());
        
        // 제네릭 메서드 TestGeneric
        System.out.println("<T> Method returnType= " + t1.genericMethod(t1).getClass().getName());
    }
}

 

< 결과 >

 

우선, 공부를 제대로 했다면 가장 먼저 의문이 드는 점은

"어라? 분명 아까 위의 주의사항에 static 변수의 데이터 타입으로는 제네릭이 올수 없다고 했는데? 거짓말인가?" 이다.

 

이 얘기는 잠깐만 미루고 우선, 코드를 보면 t1 객체의 TestGeneric의 T 타입은 Integer로 모두 변환되고, t2 객체의 T 타입은 String으로 모두 변환된다.

그리고 genericMethod()를 보면 파라미터 타입에 따라 T 타입이 결정되는 것을 볼 수 있다.

 

즉, 클래스에서 지정한 제네릭 유형과 별도로 메서드에서 독립적으로 제네릭 유형을 선언하여 쓸 수 있다.

독립적이라는 것은 쉽게 예로 t1의 제네릭 타입이 Integer 이든 String 이든 뭐든 상관없이 독립적으로 사용된다는 것이다.

 

그래서 아래 코드를 보면 t1 에서 실행하든 t2 에서 실행하든 결과는 동일하게 나온다

왜냐면 t1, t2 의 제네릭 타입은 전~혀 상관없기 때문이다.

(메서드의 선언된 파라미터 타입만 중요함!)

 

< 코드 >

// 제네릭 메서드 Integer
System.out.println("<T> t1 Method returnType= " + t1.genericMethod(5).getClass().getName());

// 제네릭 메서드 String
System.out.println("<T> t1 Method returnType= " + t1.genericMethod("hello").getClass().getName());

// 제네릭 메서드 TestGeneric
System.out.println("<T> t1 Method returnType= " + t1.genericMethod(t1).getClass().getName());

// 제네릭 메서드 Integer
System.out.println("<T> t2 Method returnType= " + t2.genericMethod(5).getClass().getName());

// 제네릭 메서드 String
System.out.println("<T> t2 Method returnType= " + t2.genericMethod("hello").getClass().getName());

// 제네릭 메서드 TestGeneric
System.out.println("<T> t2 Method returnType= " + t2.genericMethod(t1).getClass().getName());

 

< 결과 > 

 

위와 같은 방식이 필요한 이유는 '정적 메서드로 선언할 때 필요'하기 때문이다.

 

하지만 static 즉, 정적 변수 및 함수들은 기본적으로 객체가 생성되기전에 프로그램이 실행 시 메모리에 이미 올라가있는데, 이 말은 객체 생성을 통해 접근할 필요 없이 이미 메모리에 올라가 있기 때문에 클래스 이름을 통해 바로 쓸 수 있다.

class TestGeneric <T> {
    
    // static 붙였으므로 타입을 불러오지 못해 에러 발생
    static T genericMethod(T t) {
        return t;
    }
    
    // static 붙었지만 <T> 제네릭 타입이 독립적으로 사용되므로 정상
    static <T> T genericMethod(T t) { 
        return t;
    }
}

 

정리하자면, 제네릭이 사용되는 메서드를 정적메서드로 정의하고 싶은 경우에는 제네릭 클래스와 별도로 독립적인 제네릭이 사용되어야 한다.


 

제네릭 타입 제한과 와일드 카드(?)

 

지금까지 살펴본 제네릭은 가장 일반적인 예시이다.

예를 들어 타입을 T로 하고 외부클래스에서 Integer를 파라미터로 보내면 T는 Integer가 되고, String을 보내면 String 그리고 Person 클래스를 만들어 넣으면 T 파라미터는 Person이 된다. 즉, 제네릭은 참조 타입 모두 될 수 있다는 뜻이다.

 

근데 만약 특정 범위 내로 좁혀서 제한하고 싶다면 어떻게 해야할까?

 

이때 필요한 것이 extends 와 super, 그리고 ?(와일드카드)이다.

 

자바에서 사용하는 상속과 부모 호출이 생각 나지 않나? 

비슷하게 생각하면 이해가 쉽긴 할 것 같다.

먼저 예시를 보면서 얘기하자면 크게 세 가지 방식이 있다. 바로 super 키워드, extends 키워드, 마지막으로 ? 하나만 오는 경우다.

<K extends T> // T와 T의 자손 타입만 가능 (K는 들어오는 타입으로 지정 된다.)
<K super T> // T와 T의 부모(조상) 타입만 가능 (K는 들어오는 타입으로 지정 된다.)

<? extends T> // T와 T의 자손 타입만 가능
<? super T> // T와 T의 부모(조상) 타입만 가능
<?> // 모든 타입 가능. <? extends Object> 랑 같은 의미라고 보면된다.

 

보통은 이해하기 쉽게 다음과 같이 부른다.

extends T : 상한 경계

super T : 하한 경계

<?> : 와일드 카드(Wild card)

 

이때 이해가 잘 되지 않는 부분이 있을 수도 있다. 바로 <K extends T> 와 <? extends T> 인데, 이는 비슷한 구조지만 차이점이 있다.

K는 특정 타입으로 지정 되지만, ?는 타입이 지정되지 않는다는 의미이다.

 

아래 코드를 보자.

// T는 지정된 타입으로 변환 가능, 사용 가능
class Calculator<T extends Number> {
    void add(T a, T b) {};
    void min(T a, T b) {};
    void mul(T a, T b) {};
    void div(T a, T b) {};
}

// ?는 지정된 타입으로 변환 불가능, 아래와 같이 클래스 작성 불가!
class CalculatorWileCard<? extends Number> {
    // 컴파일 오류가 발생
    void add(? a, ? b) {};
    void min(? a, ? b) {};
    void mul(? a, ? b) {};
    void div(? a, ? b) {};
}

public class GenericWildCard {
    public static void main(String[] args) {
        // 제네릭에 Number 클래스만 받도록 제한
        // 정상 실행 가능
        Calculator<Number> cal1 = new Calculator<>();
        Calculator<Integer> cal2 = new Calculator<>();
        Calculator<Double> cal3 = new Calculator<>();

        // Number 이외의 클래스들은 오류!!
        // 컴파일 오류 발생
        Calculator<Object> cal4 = new Calculator<>();
        Calculator<String> cal5 = new Calculator<>();
        Calculator<Main> cal6 = new Calculator<>();
    }
}

 

<T extends Number> 와 <? extends Number> 는 Number와 이를 상속하는 Integer, Short, Double, Long 등의 타입이 지정될 수 있으며, 객체 혹은 메서드를 호출 할 경우 지정된 타입으로 변환이 가능하냐 불가능하냐 차이가 있다.

 

그렇기 때문에 특정 타입의 데이터를 조작하고자 할 경우에는 T 같이 특정 제네릭 인수로 지정을 해줘야 한다.

 

 

재귀적 타입 한정

재귀적 타입 한정이란 자기 자신이 들어간 표현식을 사용하여 타입 매개변수의 허용 범위를 한정 시키는 것을 말한다.

실무에서는 주로 Comparable 인터페이스와 함께 쓰인다고 한다.

 

예를 들어 <T extends Comparable<T>> 과 같이 제네릭 E의 타입 범위를 Comparable<T> 로 한정한다는 T를 중첩시킨 표현식을 사용할 수 있다. 그리고 extends 뒤에 오는 타입이 최상위 타입이 되고, 해당 타입과 그에 대한 하위 타입이라고 위에서 설명했다.

즉, 이 말은 "T는 반드시 Comparable을 구현해야한다." 와 같은 뜻이다.

 

어렵다 그냥 예제로 보자

class TestClass <T extends Comparable<T>> {...}

class Student implements Comparable<Student> {...}


public class ComparableTest {
    public static void main(String[] args) {
        TestClass<Student> t = new TestClass<>();
    }
}

 

TestClass의 T 는 Student 가 되어야 하는데, Comparable<Student> 의 하위타입이어야 하므로 거꾸로 말하면 Comparable<Student>을 구현해야한다는 의미인 것이다.

결국엔 자기 자신만 받는다는 표현을 어렵게 표현한 것이다. 

자기 자신만 받겠다라고 생각하면 편할 것 같다.

 


 

여기까지 공부를 해보았는데, 의문이 드는 부분은 굳이 ?(와일드카드)를 쓸 필요가 있을까? 이다.

내용을 정리하고 공부하면서도 아직도 이해가 가질 않는다.

제네릭은 어렵고도 어렵다...

아직 내공이 부족한거 같다.

추후에 따로 정리해서 포스팅을 해야 겠다.

 

참고

https://st-lab.tistory.com/153

https://inpa.tistory.com/entry/JAVA-%E2%98%95-%EC%A0%9C%EB%84%A4%EB%A6%ADGenerics-%EA%B0%9C%EB%85%90-%EB%AC%B8%EB%B2%95-%EC%A0%95%EB%B3%B5%ED%95%98%EA%B8%B0