도도한 개발자

[Java] #21. Generic(제네릭) 본문

Backend/Java

[Java] #21. Generic(제네릭)

Kiara Kim 2022. 4. 5. 20:00

* 제네릭이란?

 

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

메소드 안에서 매개변수와 비슷하게 동작할 수 있다. 이 매개변수는 어떠한 변수에 들어갈 값과 관련되어 있는데 이때 제네릭은 그 변수의 데이터 타입과 관련되어 있다.

 

 

* 실수를 방지할 수 있도록 도와주는 제네릭

 

점(.) 하나, 쉼표(,), 세미콜론(;) 하나 잘못 찍어 컴파일이 안 된 경우 이클립스 사용 시 코딩 단계에서 매우 쉽게 걸러낼 수 있다. 그러나 실행시 개발자가 미처 생각하지 못한 부분에서 프로그램이 예외를 발생시키는 경우 그 원인을 찾기 쉽지 않은데, 이러한 경우를 대비하기 위해 메소드 개발과 함께 JUnit과 같은 테스트를 잘 해야 한다. 

 

아래 코드를 보자

 

class Person<T>{
	public T info;
}
Person<String> p1 = new Person<String>();
Person<StringBuilder> p2 = new Person<StringBuilder>;

 

클래스 이름 옆 'T'는 클래스 Person 안에서 info라고 하는 필드의 데이터 타입이다. Person이라는 클래스를 정의하는 시점에서는 info의 데이터타입을 명시적으로 지정하지 않고 있다가 나중에 Person이 실제로 사용될 때, 즉 'new Person<String>()과 같이 구체적인 데이터타입을 넣어 인스턴스화 할 때 비로소 info의 데이터타입이 String으로 지정되는 것이다. 자연스럽게 그렇게 만들어진 인스턴스를 담아낼 수 있는 p1이라는 변수의 데이터타입은 방금 생성한 인스터스의 데이터타입과 동일해야 한다. 

 

* 왜 제네릭을 사용할까?

 

class StdInfo{
	public int grade;
	StdInfo(int grade) {this.grade = grade;}
}
class StdPerson{
	public StdInfo info;
	StdPerson(StdInfo info) {this.info = info;}
}
class EmpInfo{
	public int rank;
	EmpInfo(int rank) {this.rank = rank;}
}
class EmpPerson{
	public EmpInfo info;
	EmpPerson(EmpInfo info) {this.info = info;}
}
public class GenericDemo {
	public static void main(String[] args) {
		StdInfo si = new StdInfo(2);
		StdPerson sp = new StdPerson(si);
		System.out.println(sp.info.grade);
		EmpInfo ei = new EmpInfo(1);
		EmpPerson ep = new EmpPerson(ei);
		System.out.println(ep.info.rank);
	}
}

 

main() 메소드를 보면 StdInfo에 정수 2를 넣어 grade가 2인 StdInfo 인스턴스를 생성했고 이를 매개변수로 사용하여 StdPerson 인스턴스를 만들었다. 다음의 EmpInfo와 EmpPerson도 동일한 매커니즘이다. 여기엔 개발자들이 가장 싫어하는 중복이 있으니 중복을 없애보자.

 

class StdInfo{
	public int grade;
	StdInfo(int grade) {this.grade = grade;}
}
class EmpInfo{
	public int rank;
	EmpInfo(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("인턴");
		System.out.println(p1.info);
	}
}

 

이렇게 코드를 작성하고 실행을 하면 오류 없이 출력이 잘 나온다. 이는 문법적으로는 아무런 문제가 없으나 이 코드의 취지와 코드가 설계된 목적성에 부합하지 않는 상황인 것이다. 이를 버그라고 부르지만 이런 버그는 컴파일 할 때 검출되지도 않고 실제로 동작하는 과정에서도 이 버그는 검출되지 않기 때문에 이러한 문제는 대부분 심각한 문제를 야기한다. 

 

main() 메소드를 다르게 작성해보자.

 

public static void main(String[] args) {
    Person p1 = new Person("인턴");
    EmpInfo ei = (EmpInfo)p1.info;
    System.out.println(ei.rank);
}

 

현재 info안에 담겨있는 값은 '인턴'이다. String 데이터타입임에도 불구하고 EmpInfo로 형변환을 시도했는데도 eclipse에서 아무런 에러 메세지가 뜨지 않는다. 즉 컴파일타임에서 오류가 검출되고 있지 않는다는 뜻이다.

 

실행을 시켜보면 

 

Exception in thread "main" java.lang.ClassCastException: class java.lang.String cannot be cast to class chapter21.EmpInfo (java.lang.String is in module java.base of loader 'bootstrap'; chapter21.EmpInfo is in unnamed module of loader 'app')
	at chapter21.GenericDemo.main(GenericDemo.java:18)

 

그때 비로소 타입을 EmpInfo로 타입을 캐스팅할 수 없다고 뜬다. 그리고 ClassCastException은 runtime Exception이기 때문에 우리가 컴파일하는 단계에서는 발생하지 않는 Exception인 것이다. 이 말은 다행히 main() 메소드 안의 두번째 행은 런타임할 때 에러가 발생되긴 하지만 컴파일할 땐 예외가 발생하지 않는다는 것을 보여준다. 이를 '타입이 안전하지 않다'라고 말한다.

 

자바는 컴파일 언어이다. 이클립스에서 save하는 과정이 일종의 컴파일 단계인 것이다. 컴파일 언어의 장점은, 컴파일러 프로그램이 되기 전에 사용자의 실수나 착오를 미리 검출해낸다는 것이다. 그 얘기인 즉슨, 우리가 코드를 작성할 때 우리가 유발시킬 수 있는 에러는 컴파일 타임에서 검출될 수 있도록 코딩을 해야지만 컴파일 언어가 제공하는 혜택을 얻을 수 있게 되는 것이다. 

 

위에서 수정된 코드에서, 코드의 중복을 제거함을 통해 더 좋은 코드를 만들었다고 생각했지만 결과적으로 Person의 생성자에 들어올 수 있는 데이터타입을 Object로 할 수 있게 해버린 덕에 엉뚱한 String 타입이 들어갈 수 있게 되어버린 것이다. 우리가 변수의 데이터 타입을 지정하는 것의 중요한 장점은 무엇일까? 바로 그러한 변수 안에는 그런 데이터타입만 허용함을 보장받을 수 있다는 것이다. 또 다른 데이터타입이 들어오는 것을 금지시키는 효과가 나는 것이기도 하다. 타입이 안전하지 않다는 것은 자바의 정서상 허용되지 않는 것이다. 이러한 문제를 해결하기 위해 도입된 기능이 제네릭이라는 것이다.

 

위에서 StdPerdon과 EmpPerson이 나뉘어져있을 때에는 타입이 안전하다는 장점이 있었는데, 제네릭을 사용하면 이러한 장점과 코드의 중복을 제거한다는 편의성을 둘 다 잡을 수 있게 될 것이다. 

 

class EmpInfo{
	public int rank;
	EmpInfo(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) {
		Person<EmpInfo, int> p1 = new Person<EmpInfo, int>(new EmpInfo(1), 1);
	}
}

 

여기 보이는 것처럼 Person 안에 어떤 데이터타입을 확정시키고 싶지 않은 새로운 필드를 추가했다. 이런 경우 변수 이름 앞에 S와 같은 와일드카드를 지정한다. 이렇게 작성하고 실제로 인스턴스를 생성할 때에는 Person<T,S>의 T에 EmpInfo가 와서 info의 데이터타입이 EmpInfo가 되고, S의 위치에 int가 와서 id의 데이터타입이 int가 된다. 이때의 T와 S는 어떠한 기능을 갖고 있진 않지만 약간의 네이밍 약속 같은 것이 있다. 보통 제네릭이라고 하면 T부터 시작해서 T뒤에 오는 대문자를 쓴다. 이클립스에서 위와 같이 코드를 작성하면 int부분에 에러나 나타나는데 이를 수정해야한다.

 

제네릭의 특성에 대한 또 한가지 알아야 할 점은, 클래스명 뒤의 <와>사이에 올 수 있는 데이터 타입은 레퍼런스(참조)형만 올 수 있다. 다시 말해 기본 데이터타입(int, char, double 등)은 올 수 없다는 뜻이다. 그렇다면 기본 데이터타입은 쓸 수 없을까? 아니다. wrapper class를 사용하면 된다. 기본데이터타입은 자바 안에서 객체가 아니다. 즉 객체가 필요한 맥락에서는 애매한 상황이 발생할 수 있다는 말이다. 그러한 경우를 대비해 자바에선 기본 데이터타입을 마치 객체인 것처럼 만들 수 있는 객체를 제공하는데 그것이 wrapper class이다. 그럼 다시 main() 코드를 수정해보자.

 

public static void main(String[] args) {
	EmpInfo e = new EmpInfo(1);
    Integer i = new Integer(10);
    Person<EmpInfo, Integer> p1 = new Person<EmpInfo, Integer>(e, i);
    System.out.println(p1.id.intValue());
}

 

int에 대한 wrapper class인 Integer를 대신 넣어주고 매개변수를 실제 객체로 만들어 내야 한다. 그러기 위해선 선행으로 Integer 객체를 만들어야 한다. 이렇게 함으로써 기존에 int 형식의 데이터타입이었던 숫자 1이 wrapper class인 Integer의 생성자로 들어가서 숫자 1을 의미하는 하나의 객체를 만들었다. 출력을 하기 위한 코드를 보자.Integer라고 하는 wrapper class가 갖고 있는 메소드 중에 intValue()라는 메소드를 호출하게 되면 그 wrapper class가 담고 있는 본래의 숫자를 원시 데이터 타입(int)으로 되돌려주는 기능을 한다.

 

 

* 제네릭의 생략

 

위에서 Person이라는 클래스를 인스턴스화 시킬 때 그 Person 내부적으로 사용하는 데이터 타입을 매개 변수처럼 지정해줬는데 이것은 결과적으로 이 Person이 생성될 때 info와 id에 데이터타입을 지정하는 것이라고 볼 수 있다. 그런데 e와 i라고 하는 생성자의 매개 변수는 각각 EmpInfo와 Integer라고 하는 것을 자바는 알 수 있다. 따라서 <와>사이의 제네릭은 생략해도 무방하다. 아래의 두 코드가 그 예시이다. 

 

EmpInfo e = new EmpInfo(1);
Integer i = new Integer(10);

Person<EmpInfo, Integer> p1 = new Person<EmpInfo, Integer>(e, i);
Person p2 = new Person(e, i);

 

 

* 메소드에서의 제네릭 사용

 

class Person<T,S>{
	public T info;
	public S id;
	Person(T info, S id) {
		this.info = info;
		this.id = id;
	}
	public <U> void printInfo(U info) {
		System.out.println(info);
	}
}

 

제네릭을 꼭 클래스 레벨에서 써야하는 것은 아니다. 메소드 레벨에서도 적용할 수 있다. 위의 printInfo() 메소드 안에서 info라고 하는 매개 변수를 사용하는데 그 매개 변수의 데이터타입을 아직 확정하고 싶지 않을 때 위와 같이 U라고 적는다. 그리고 나서 printInfo라는 메소드명 앞에 접근제어자와 리턴값 사이에 '<U>'라고 적는다. 이 메소드 안에서 U로 지정된 데이터 타입이 info라는 매개 변수의 제네릭 데이터 타입이 된다는 뜻이다. 

 

public static void main(String[] args) {
	EmpInfo e = new EmpInfo(1);
    Integer i = new Integer(10);
    Person p1 = new Person(e, i);
    p1.<EmpInfo>printInfo(e);
}

출력을 할 때 <>안에 U 대신 EmpInfo를 넣음으로써 데이터 타입을 EmpInfo로 지정한다. info라는 매개 변수의 U는 EmpInfo가 되는 것이다. 그런데 info에 어떤 값이 들어왔느냐에 따라 U의 데이터 타입을 추정할 수 있기 때문에 p1.<EmpInfo>printInfo(e);는 p1.printInfo(e);로 생략 가능하다. 

 

 

제네릭이라고 하는 것은 그 클래스가 내부적으로 정해놓지 않은 어떤 데이터 타입을 인스턴스화 할 때 지정하는 것이다. 그러다보니 제네릭으로 모든 것이 들어올 수 있다는 허점이 있다. 이를 제한할 수 있는 방법은 extends라는 키워드이다. 

 

코드를 살펴보자.

 

abstract class Info{
	public abstract int getLevel();
}
class EmpInfo extends Info{
	public int rank;
	EmpInfo(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 EmpInfo(1));
		Person<String> p2 = new Person<String>("인턴");
	}
}

 

부모 클래스 Info를 추상 클래스로 만들어줬고, 부모 클래스의 getLevel() 메소드를 자식 클래스 EmpInfo에서 구현해주었다. 그리고 Person 클래스에서 T라는 데이터 타입을 확정하지 않은 상태이고 T는 info의 데이터 타입이 될 것이다. 그런데 T가 쌩뚱맞은 데이터 타입이 되면 안되므로 T로 올 수 있는 데이터 타입은 Info 클래스 이거나 또는 저 클래스의 자식들만이 오도록 강제하고 싶다. 그러기 위해선 T 뒤에 extends, 그 뒤에 클래스 Info를 적어주면 된다. 그러면 Info라는 클래스의 자식들만이 T로 올 수 있게 되는 것이다. 

 

원래는 Person p1 = new Person(new EmpInfo(1)); 이 부분이 Person<EmpInfo> p1 = new Person<EmpInfo>(new EmpInfo(1)); 이렇게 되어서 명시적으로 데이터 타입을 EmpInfo로 지정해줘야 한다. 그럼 자바는 이를 검사해서 이것이 Info의 자식인지를 체크한다. 맞으면 문제 없이 실행될 것이지만 아래의 <String>의 경우 String은 Info의 자식이 아니기 때문에 자바는 이를 거절하게 된다. 즉, 컴파일 에러를 발생시켜서 런타임에 발생할 에러를 미연에 방지해주는 효과를 갖게 된다. 

 

이때 extends의 경우 추상클래스 대신 인터페이스를 사용해도 좋은데, class EmpInfo 뒤의 extends는 implements로 바꿔야한다. 한편 class Person<T extends Info>에서 사용한 extends는 제네릭 맥락 안에서 사용될 경우 상속한다의 개념이 아니라 '부모가 누구다'라는 개념이기 때문에 그대로 둬야 한다. 

 

 

* 정리

 

1. 제네릭이 자바에 추가된 이유는 무엇인가요?

제네릭은 타입 형 병환에서 발생할 수 있는 문제점을 "사전"에 없애기 위해서 만들어졌다.

2. 제네릭 타입의 이름은 T나 E 처럼 하나의 캐릭터로만 선언되어야 하나요?

제네릭의 선언시 타입 이름은 예약어만 아니면 어떤 단어도 사용할 수 있다. 단, 일반적으로 대문자로 시작한다.

3. 메소드에서 제네릭 타입을 명시적으로 지정하기 애매할 경우에는 < > 안에 어떤 기호를 넣어 주어야 하나요?

? 를 제네릭 선언 꺽쇠 사이에 넣으면 Wildcard로 어떤 타입도 올 수 있다.

4. 메소드에서 제네릭 타입을 명시적으로 지정하기에는 애매하지만, 어떤 클래스의 상속을 받은 특정 타입만 가능하다는 것은 나타내려면 < > 안에 어떤 기호를 넣어 주어야 하나요?

특정 타입으로 제네릭을 제한하고 싶을 때에는 "? extends 타입"을 제네릭 선언 안에 넣으면 된다.

5. 제네릭 선언시 wildcard라는 것을 선언했을 때 어떤 제약사항이 있나요?

Wildcard 타입을 Object 타입으로만 사용해야 한다.

6. 메소드를 제네릭하게 선언하려면 리턴타입 앞에 어떤 것을 추가해 주면 되나요?

꺽쇠 안에 원하는 제네릭 타입을 명시함으로써 제네릭한 메소드를 선언할 수 있다.