개발/Spring

[Java] Generic과 wild card

뽀글뽀글 개발자 2023. 10. 14. 19:30

Generic이란?

클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다.

 

예시1

class Person<T> {
	public T info;
}

Person<String> p1 = new Person<String>();
Person<StringBuilder> p2 = new Person<StringBuilder>();

Person의 Info를 String으로 받을 수도 있고, StringBuilder로 받을 수도 있다.

따라서 p1의 타입은 Person 클래스 타입이고 내부에서 사용하는 제네릭 변수의 타입이 String이라는 뜻이다.

 

Generic이 필요한 이유

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class StudentPerson{
    public StudentInfo info;
    StudentPerson(StudentInfo info){ this.info = info; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class EmployeePerson{
    public EmployeeInfo info;
    EmployeePerson(EmployeeInfo info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        StudentInfo si = new StudentInfo(2);
        StudentPerson sp = new StudentPerson(si);
        System.out.println(sp.info.grade); // 2
        EmployeeInfo ei = new EmployeeInfo(1);
        EmployeePerson ep = new EmployeePerson(ei);
        System.out.println(ep.info.rank); // 1
    }
}

Object 사용

위 코드에서 Student와 Employee는 매우 유사한 구조를 가졌다.

중복을 줄이고 하나의 Person 클래스로 만들고 싶다면 아래와 같이 Object를 사용하는 방법이 있다.

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class Person{
    public Object info;
    Person(Object info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        Person p1 = new Person("부장");
        EmployeeInfo ei = (EmployeeInfo)p1.info;
        System.out.println(ei.rank);
    }
}

하지만 Object로 타입이 불일치하는 코드 작성 시 런타임에는 ClassCastException이 발생하지만, 컴파일 시에는 에러를 발견하기 힘들다. 

런타임에러는 찾기도 힘들고 심각한 문제를 초래할 수 있기 때문에 이러한 방식은 위험하고, 모든 타입이 올 수 있기 때문에 타입을 엄격하게 제한할 수도 없다.

Generic 사용

class StudentInfo{
    public int grade;
    StudentInfo(int grade){ this.grade = grade; }
}
class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T>{
    public T info;
    Person(T info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        Person<EmployeeInfo> p1 = new Person<EmployeeInfo>(new EmployeeInfo(1));
        EmployeeInfo ei1 = p1.info;
        System.out.println(ei1.rank); // 성공
         
        Person<String> p2 = new Person<String>("부장");
        String ei2 = p2.info;
        System.out.println(ei2.rank); // 컴파일 실패
    }
}

제네릭을 사용하면 중복을 제거하여 가독성 높은 코드를 짤 수 있고, 컴파일 타임에 타입에러를 발견할 수 있어 타입 안정성이 확보된다.

 

클래스 내에서 여러 개의 제네릭이 필요하다면?

class EmployeeInfo{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
}
class Person<T, S>{
    public T info;
    public S id;
    Person(T info, S id){ 
        this.info = info; 
        this.id = id;
    }
}

public class GenericDemo {
     public static void main(String[] args) {
        EmployeeInfo e = new EmployeeInfo(1);
        Integer i = new Integer(10);
        Person<EmployeeInfo, Integer> p1 = new Person<EmployeeInfo, Integer>(e, i);
        //Person p1 = new Person(e, i);	//꺽쇄 괄호는 생략이 가능하다.
        System.out.println(p1.id.intValue());
    }
}

여러 개의 제네릭이 필요할 때는 T,S 처럼 다른 문자를 명시해서 구분한다.

또한 제네릭은 참조형만 올 수 있다. 즉 기본 데이터 타입인 int 대신 Integer를 사용해야한다.

 

제네릭 메소드

public <U> void printInfo(U info){
    System.out.println(info);
}

매개변수에도 제네릭을 사용할 수 있다. 이 때 메서드의 접근제어자와 리턴타입 사이에 <U>라고 적어주면 되고, 사용은 일반 메서드와 같이 사용할 수 있다.

 

제한

제네릭은 어떤 타입이든 들어올 수 있기 때문에 아무 타입이나 다 들어올 수 있다는 문제가 있다.

이러한 문제를 해결하기 위해 extends를 사용해서 특정 클래스를 상속받은 클래스 타입만 들어올 수 있도록 제한할 수 있다.

반대로 특정 클래스의 부모 클래스로 제한하고 싶을 때는 <T super EmployeeInfo>와 같이 사용할 수 있다. 

interface Info{
    int getLevel();
}
class EmployeeInfo implements Info{
    public int rank;
    EmployeeInfo(int rank){ this.rank = rank; }
    public int getLevel(){
        return this.rank;
    }
}
class Person<T extends Info>{
    public T info;
    Person(T info){ this.info = info; }
}
public class GenericDemo {
    public static void main(String[] args) {
        Person p1 = new Person(new EmployeeInfo(1));
        Person<String> p2 = new Person<String>("부장");
    }
}

제네릭 클래스 내부에서 extends로 상속받은 클래스의 메서드를 호출할 수 있지만, extends를 명시하지 않았다면, 사용할 수 없다.

 

와일드 카드

와일드 카드는 <?>로 표현하며 단순히 <?>만 사용했을 때는 Object로 받는 것과 같다.

따라서 제네릭과 같이 extends와 super로 타입을 제한할 수 있다.

 

제네릭 메서드는 다음과 같이 타입에 종속적인 메서드를 선언해놓고 외부에서 호출할 때 타입이 틀리다면, 컴파일 에러를 발생해주기 때문에 타입에 안정적이다. 하지만 매개변수의 타입을 알 수 없는 상황이라면? 제네릭을 사용하기 애매하다.

public <T extends Number> double addNumbers(T a, T b) {
    return a.doubleValue() + b.doubleValue();
}

 

이런 상황에서 와일드 카드를 사용하면 다음과 같이 Person을 상속받는 클래스들에 대해 출력하는 메서드를 정의할 수 있다.

하지만 위 제네릭의 예시처럼 doubleValue()와 같은 타입에 종속적인 메서드를 메서드 내부에서 사용한다면, 컴파일 시점에는 걸리지 않지만 런타임 시점에 에러가 발생할 수 있다.

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

 

 

Reference

https://opentutorials.org/module/516/6237

https://mangkyu.tistory.com/241

자바의 정석