본문 바로가기

Backend/Java

[Java] 제네릭 이해하기

안녕하세요. 오늘 포스팅에서는 JDK1.5 이후로 추가된 자바의 제네릭스에 대해 포스팅해보겠습니다.

 

1. 제네릭이란?

 

  • Generics add stability to your code by making more of your bugs detectable at compile time. – Oracle Javadoc
  • 제네릭(Generic)은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법을 의미한다. – 생활코딩
  • 지네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크를 해주는 기능이다. – 자바의 정석

 

기본적으로 클래스 내부에서 사용할 데이터타입을 사전에 정의하지 않기 때문에 인스턴스 생성 시에 해당 클래스 안의 데이터 타입을 동적으로 넘겨줄 수 있습니다. 또한 런타임 시 발생할 수 있는 에러를 컴파일 시에 잡아줄 수 있습니다. 

 

2. 제네릭의 등장 배경

[모든 소스코드는 생활코딩님의 유튜브 강좌를 빌려왔습니다. https://www.youtube.com/watch?v=fAQPPf4CEOE]

 

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 MainClass {
    public static void main(String[] args) {
        StudentInfo si = new StudentInfo(2);
        StudentPerson sp = new StudentPerson(si);
        System.out.println(sp.info.grade);

        EmployeeInfo ei = new EmployeeInfo(1);
        EmployeePerson ep = new EmployeePerson(ei);
        System.out.println(ep.info.rank);
    }
}

 

자 위에 코드에서 어떠한 문제가 있을까요? 물론 정상적으로 작동하고 결과도 정확히 나옵니다. 

 

하지만 중복코드가 있습니다.

 

class StudentPerson{
    public StudentInfo info;
    StudentPerson(StudentInfo info) {
        this.info = info;
    }
}

class EmployeePerson{
    public EmployeeInfo info;
    EmployeePerson(EmployeeInfo info) {
        this.info = info;
    }
}

 

이 두 클래스는 전달받는 데이터의 타입만 다를 뿐 모든 로직이 동일합니다. 이렇게 동일한 로직의 클래스를 하나로 묶지 못한다면 코드가 방대해지고, 유지보수 확장성이 떨어지게 됩니다. 따라서 저희는 이 클래스를 하나로 묶고 싶습니다.

 

class Person{
    public Object info;
    Person(Object info) { this.info = info; }
}

 

자바의 모든 클래스는 공통적으로 Object 클래스를 상속하여 사용합니다. 따라서 공통 조상인 Object를 다음과 같이 사용하도록 해보겠습니다.

 

public class MainClass {
    public static void main(String[] args) {
        Person p1 = new Person("부장");
        EmployeeInfo ei = (EmployeeInfo) p1.info;
    }
}

 

무언가 이상합니다. Person 생성자로 "부장"이라는 String data type을 넘겨주어 p1의 info에 "부장"이라는 데이터가 들어있습니다. 그리고 EmployeeInfo 클래스에 해당 Object를 형 변환을 통해 넘겨주는데, 그러면 String -> EmployeeInfo로 최종적으로 형 변환이 되는 코드입니다.

 

하지만 위 코드는 이클립스 혹은 인텔리제이와 같은 IDE에서 빨간색으로 밑줄이 그어지지 않습니다.

 

 

(진짜 됨...)

이게 어떤말이냐 하면, 컴파일 시에 위 코드의 에러를 잡을 수 없다는 이야기입니다.

 

당연히 실행시키면

ClassCastException이 발생하고, 런타임 시에 캐스팅 에러가 발생합니다.

 

굉장히 위험하죠... 이게 이렇게 간단한 코드에서 조차 위 코드의 컴파일 상의 문제를 미리 알 수 없다는 이야기입니다. 

 

자바는 기본적으로 컴파일 언어입니다. 컴파일의 장점이 무엇이냐면, 실제 서비스와 같이 동적으로 항상 작동하는 시스템에서 배포 전에 사전에 오류를 파악하고 해당 오류들을 모두 정상적으로 수정한 뒤, 실제 배포를 하는 것입니다. 이것이 컴파일 언어의 장점 중에 하나입니다.

 

즉 프로그래머는 컴파일 언어가 에러를 최대한 캐치하는 방향으로 코드를 작성해야 컴파일 언어를 더 효율적으로 사용한다고 말할 수 있습니다.

 

자 그러면 왜 위와같은 문제가 발생했는지 한번 정리를 하고 넘어가겠습니다.

 

공통적인 코드가 존재한다 -> 공통적인 로직을 묶어 효율적으로 처리하고 싶다 -> 두 클래스가 상속받는 마땅한 부모가 존재하지 않는다 -> 최고 부모 조상인 Object를 사용했다 -> String도 받을 수 있다...

 

쉽게 말하면 기존의 타입을 지정하면 해당 타입만 받을 수 있었지만, Object로 받았기 때문에 타입이 안전하지 않은 상태로 받게 되는 것입니다. 자바는 기본적으로 부모 클래스를 통한 대입이나 연산을 모두 허용하기 때문에 이러한 문제가 발생하게 되는 것입니다.

 

여기서 제네릭이 등장하게 됩니다.

 

자바는 타입의 안전성(컴파일 시에 에러를 검출하고 싶음) + 공통 코드 제거(유지보수 및 확장성이 좋은 코드 생성)를 하고 싶었던 것입니다.

 

3. 제네릭의 특징

 

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

public class MainClass {
    public static void main(String[] args) {
        Person<EmployeeInfo, Integer> p1 = new Person<EmployeeInfo, Integer>(new EmployeeInfo(1), 1);
    }
}

 

1. 제네릭은 위 코드와 같이 인자를 여러개 받을 수 있습니다.

 

하지만 인자의 제한이 있는데,  우선 기본 데이터형을 받을 수 없습니다. 즉 참조형 매개 변수만 받을 수 있습니다.

 

Person<EmployeeInfo, int> p1 = new Person<EmployeeInfo, int>(new EmployeeInfo(1), 1);

 

위에서 int는 기본 데이터형이기 때문에 컴파일에러가 발생합니다. 따라서 int형의 wrapper class인 Integer를 사용하셔야 합니다.

 

2. 또한 매개변수는 여러개 받을 수 있으나 매개변수의 이름은 클래스나 인터페이스 내에서 유일해야 합니다.

 

3. 자바는 컴파일 언어이기 때문에 인스턴스 생성 시 어떠한 인자가 전달되는지 명시하지 않아도 알 수 있습니다. 따라서 제네릭스 안의 데이터형을 명시하지 않아도 잘 작동합니다.

 

Person p2 = new Person(new EmployeeInfo(1), 1);

 

4. 제네릭의 제한

제네릭은 기본적으로 인스턴스를 생성할 때 인자값으로 판단할 수 있습니다. 여기서 발생하는 문제는 오만가지 값이 다 들어갈 수 있다는 것입니다.

Person p2 = new Person<String, Integer>("부장", 1);

 

위 코드의 p2는 저희가 의도한 코드가 아닙니다. 프로그래머는 기본적으로 첫 번째 인자에 EmployeeInfo와 궤를 같이하는 클래스를 받고 싶었을 겁니다. 하지만 위와 같이 <T, S>로 제네릭을 정의하게 되면 String class도 받을 수 있게 됩니다.

 

abstract class Info {
    public abstract int getLevel();
}
class EmployeeInfo extends Info{
    public int rank;
    EmployeeInfo(int rank) {
        this.rank = rank;
    }
    @Override
    public int getLevel() {
        return this.rank;
    }
}
class Person<T extends Info> {
    public T info;
    Person(T info) {
        this.info = info;
    }
}

public class MainClass {
    public static void main(String[] args) {
        Person p1 = new Person(new EmployeeInfo(1));
        Person p2 = new Person<String>("부장");
    }
}

 

따라서 제네릭은 기본적으로 위와 같이 Info를 상속받는 객체만 받을 수 있게 제한을 걸 수 있습니다. 이렇게 되면 p2의 생성 부분에 빨간색 밑줄이 그어지게 되면서 컴파일 에러가 발생합니다.

 

추가로 

abstract class를 받은 class를 상속받는것으로 제한하는 키워드로 <T extends Info>를 사용하지만

 

interface Info {
    int getLevel();
}
class EmployeeInfo implements Info{
    public int rank;
    EmployeeInfo(int rank) {
        this.rank = rank;
    }
    @Override
    public int getLevel() {
        return this.rank;
    }
}
class Person<T extends Info> {
    public T info;
    Person(T info) {
        this.info = info;
    }
}

public class MainClass {
    public static void main(String[] args) {
        Person p1 = new Person(new EmployeeInfo(1));
        Person p2 = new Person<String>("부장");
    }
}

 

이런 식으로 interface를 구현한 객체도 같은 코드를 사용합니다. (흠...)

자바 입장에서 아마 인터페이스를 구현했는지, 객체를 상속받았는지보다 어떤 객체를 상속받았는지 어떤 인터페이스를 구현했는지를 더 중요하게 생각하여 이 부분에는 따로 제한을 걸지 않은 것 같습니다.

 

*저의 글에 대한 피드백이나 지적은 언제나 환영합니다.