본문 바로가기

DEVELOPER/Java

[JAVA 파헤치기] 제네릭(Generic) 완벽 정리

안녕하세요😎 백엔드 개발자 제임스입니다 :)

오늘은 자바의 제네릭(Generic)에 대해서 포스팅하도록 하겠습니다. 제네릭은 알고있으면 굉장히 유용한 개념인데요. 이는 우리가 만드는 클래스 또는 메서드를 다양하게 활용할 수 있도록 만들어주는 방법입니다. 아래에서 자세하게 알아보도록 하겠습니다.

목차
1. 제네릭(Generic)은 무엇일까요?
2. 타입 매개변수
3. 제네릭 메서드


1. 제네릭(Generic)은 무엇일까요?


제네릭 : 클래스 내부에서 사용할 데이터 타입을 외부에서 파라미터 형태로 지정하면서, 데이터 타입을 일반화한 것입니다.

*참고
사전적 의미는 "일반적인"입니다.

제네릭은 클래스 및 인터페이스 이름 뒤에 '<>' 안에 타입 파라미터를 넣어 작성합니다.

public class 클래스명<타입 매개변수>{...}
public interface 인터페이스명<타입 매개변수>{...}

 

1) 제네릭 사용

이제 위에서 알아본 제네릭의 개념을 토대로 사용하는 것을 보도록 하겠습니다.

public class Stack<T> {

    private ArrayList<T> stack = new ArrayList<T>();

    /**
     * push
     */
    public void push(T item) {
        stack.add(item);
    }

... 생략

해당 코드는 자료구조의 스택을 구현한 코드의 일부입니다. 제네릭 타입을 사용한 예로 가져왔는데요. 클래스의 이름 옆에 '< >' 가 붙어있는 것이 보입니다. 그리고 안에는 T가 적혀 있죠. 위에서 알아봤듯이 T는 Type을 나타내는 인자입니다.

그리고 클래스 내부에 실제 타입이 적혀야할 위치에 T가 적혀있는 것이 보입니다. 이것이 타입 인자의 쓰임인데요. 다음 실행 코드를 보면서 더 자세하게 이해해보도록 하겠습니다.

// 실행 부분
public static void main(String[] args) {
	
    // 직접 만든 Stack 클래스 옆에 <>를 적고 안에 Integer 라는 타입 선언
    Stack<Integer> myStack = new Stack<Integer>();

    myStack.push(1);
    myStack.push(2);
    
    System.out.println(myStack.pop()); // 2

}

위 코드는 실행하는 main 부분입니다.

따라서 Stack 클래스의 인스턴스를 생성해서 메서드를 사용하고 있는데요. 여기서 자세히 보면, 클래스 타입 옆에 <> 모양과 안에 Integer 타입이 적혀있는 것을 볼 수 있습니다. 

위에서 작성했던 Stack 클래스를 떠올려보면, 클래스명 옆에 <T>가 있었습니다. 

즉, 클래스 내부에서 사용할 데이터 타입을 제네릭을 통해 외부에서 지정해준 것이죠. 그렇게 되면서 push() 메서드를 사용할 때 매개변수로 정수로 넣어주게 됩니다.

여기서 눈치를 채신 분들도 있을텐데요. 만약 Stack 인스턴스르 생성할 때 <> 안에 String을 넣었다면, myStack 내에 T로 선언했던 타입들은 String으로 사용되게 됩니다.

Java 7 버전 이후, 제네릭 클래스를 생성할 때 생성자에 자료형을 생략할 수 있습니다.
ex)
ArrayList<String> list = new ArrayList<>(); // 생성자의 자료형 생략

2) 제네릭 사용 이유

위에서 Integer 대신 String을 넣었다면, 내부에서 사용되는 타입이 String이 된다고 언급했습니다. 이 말을 더 넓게 생각해보면, 다른 다양한 타입으로도 쓸 수 있게 되면서, 필요에 의해 자유롭게 지정해서 쓸 수도 있습니다. 아래에 코드를 보면 같은 클래스지만, 다른 타입이 적용된 것을 볼 수 있습니다.

// 실행 부분
public static void main(String[] args) {
	
    // 직접 만든 Stack 클래스 옆에 <>를 적고 안에 Integer 라는 타입 선언
    Stack<Integer> intStack = new Stack<Integer>();
    
    // <> 안에 String 타입 선언
    Stack<String> strStack = new Stack<String>();

    intStack.push(1); // Integer
    strStack.push("문자열"); // String
}

 

🥸 만약 제네릭 타입이 아니였다면!

public class Random {
	
    private Object object;
    
    // default 생성자
    Random() {}

    // default 생성자
    Random(Object object) {
    	this.object = object;
    }
    
    public void set(Object object) {
    	this.object = object;
    }
    
    public Object get() {
    	return object;
    }
}

어떤 타입이라도 적용할 수 있도록 모든 타입을 Object로 적용한 클래스를 만들었습니다. 이어서 아래 실행 코드를 보겠습니다.

#1 default 생성자를 통해 생성한 후, set 매개변수에 각각 다른 타입 할당

public static void main(String[] args) {
	
    Random random = new Random();

    random.set("String");
    System.out.println(random.get()); // String 출력

    random.set(1);
    System.out.println(random.get()); // 1 출력

    random.set('c');
    System.out.println(random.get()); // c 출력
}
  • 각 get() 메서드에서 서로 다른 타입으로 출력되는 것을 볼 수 있습니다.

#2 객체 생성 시 매개변수에 문자열 할당

public static void main(String[] args) {
	
    Random random = new Random("String");
    
    String a = (String) random.get();
    
    Integer b = (Integer) random.get(); // ClassCastException 에러 발생
}
  • 이번엔 get()을 하기 위해서 String으로 타입 변환을 해줘야합니다.
    • 그 이유는 여기서 get()은 Object 타입으로 반환하기 때문입니다.
  • Integer로 변환한다면 어떻게 될까요?
    • 이번엔 ClassCastException 에러가 발생합니다.

 

이처럼 Object 타입인 인스턴스를 생성할 때는 자유롭게 타입을 변환할 수 있는 것이 아닙니다. 다시 말하면, 굉장히 복잡해지죠. 따라서 이러한 문제를 보완하기 위해 사용되는 것이 제네릭입니다.

* 제네릭의 장점
1) 타입체크와 형변환을 생략해서 간결한 코드 작성이 가능
2) 클래스나 메서드 내부에서 사용되는 객체의 타입 안정성을 제공


2. 타입 매개변수


타입 매개변수란, 제네릭 클래스 <> 안에 있는 변수명을 말합니다. 형태는 "하나의 대문자"로 표현합니다. 제네릭 클래스에는 여러 개의 타입 매개변수를 가질 수 있습니다.

  • <> 을 다이아몬드 연산자라고 합니다.

타입 매개변수

public class Test<T, V> {
	
    T thing;
    V value;
    
    //construtor
    public Test(T thing, V value) {
    	
        this.thing = thing;
        this.value = value;
    }
    
    public void print() {
    	
        System.out.println("T : " + thing);
        System.out.println("V : " + value);
    }
}

와일드 카드(wild card)

와일드 카드<?> 기호를 사용하여 타입의 제한을 두지 않는 것을 의미합니다. 

<?> // 타입 매개변수에 모든 타입 사용
<? extends T> // T 타입과 T 타입을 상속받는 하위 클래스 타입만 사용
<? super T> // T 타입과 T 타입을 상속받은 상위 클래스 타입만 사용
  • <?> 에는 모든 클래스나 인터페이스 타입도 올 수 있습니다.

 

와일드 카드 사용 예시

상속 여부를 나타내는 클래스 관계도.

위 그림과 같이 상속 관계를 갖는 클래스들이 있다고 가정하겠습니다.

public static void register(School<?> school) {
	System.out.println(school.getName() + " 의  수강생 명단 : " + school.getStudents());
}
  • School<?> : Person, Programmer, Student, HighStudent, CollegeStudent 모든 타입이 수강생이 될 수 있음.

 

public static void register(School<? extends Student> school) {
	System.out.println(school.getName() + " 의  수강생 명단 : " + school.getStudents());
}
  • School<? extends Student> : Student, HighStudent, CollegeStudent만 수강생이 될 수 있음.

 

public static void register(School<? super Programmer> school) {
	System.out.println(school.getName() + " 의  수강생 명단 : " + school.getStudents());
}
  • School<? super Programmer> : Programmer 와 Person만 수강생이 될 수 있음


3. 제네릭 메서드


우리는 위에서 클래스를 생성할 때 타입 매개변수를 통해서 제네릭 클래스를 생성할 수 있다고 했습니다. 이제 알아볼 것은 제네릭 메서드인데요. 이는 클래스 내부의 특정 메서드에만 제네릭으로 선언하는 것을 의미합니다.

* 제네릭 클래스와 제네릭 메서드의 차이
- 제네릭 클래스
1) 객체를 생성하는 시점에서 타입 지정
2) 제네릭 클래스 타입이 전역 변수처럼 사용
- 제네릭 메서드
1) 호출되는 시점에서 타입 지정
2) 제네릭 메서드의 타입은 지역 변수처럼 사용

일반 클래스 내부의 제네릭 메서드 선언

class Test {
	
    public <T> T accept(T t) {
    	return t;
    }
    
    public <K, V> void getPrint(K k, V v) {
    	System.out.println( k + ":" + v );
    }
}

 

Main 클래스 (메서드가 호출되는 시점)

public class Main {
	public static void main(String[] args) {
    	
        Test test = new Test();
        
        String str1 = test.<String>accept("James");
        
        // 입력 매개변수 값만으로 제네릭 타입이 유추가 되면 타입 생략 가능
        String str2 = test.accept("James");
        
        Test.<String, Integer>getPrint("James", 1);
        //동일
        Test.getPrint("James",2);
    }
}
* 마지막 참고!
제네릭 메서드는 호출되는 시점에서 타입이 지정되기 때문에 메서드를 정의할 때는 length()와 같은 특정 타입, 클래스의 메서드를 사용할 수 없습니다.
단, Object 클래스는 최상위이기 때문에 Object의 메서드는 사용가능합니다.

 

반응형