본문 바로가기

프로그래밍 언어/JAVA

JAVA 입문 - 제네릭

지난 글: [프로그래밍 언어/JAVA] - JAVA 입문 - Class 클래스

제네릭이란?
프로그램에서 변수를 선언할 때 모든 변수는 자료형이 있다. 메서드에서 매개변수를 사용할 때도 자료형을 갖고 있다. 대부분은 하나의 자료형으로 구현하지만, 변수나 메서드의 자료형을 필요에 따라 여러 자료형으로 바꿀 수 있다. 이처럼 어떤 값이 하나의 참조 자료형이 아닌 여러 참조 자료형을 사용할 수 있도록 프로그래밍하는 것을 '제네릭(Generic)'프로그래밍이라 한다. 제네릭 프로그램은 참조 자료형이 변환될 때 이에 대한 검증을 컴파일러가 하므로 안정적이다. 앞으로 배울 '컬렉션 프레임워크'도 많은 부분이 제네릭으로 구현돼 있다고 한다. 예제를 통해 제네릭의 사용 방식과 장점 등을 알아보자.

제네릭의 필요성
3D 프린터를 예로 들어 제네릭을 이해해보자. 3D 프린터는 재료를 가지고 입체 모형을 만드는 일을 한다. 프린터에 쓰이는 재료는 여러 가지가 있을 수 있는데, 쌓아 올려 입체 모형을 만드는 경우 파우더나 플라스틱 액체를 사용한다고 한다. 그러면 아래와 같이 파우더를 재료로 사용하는 3D 프린터 클래스 코드를 살펴보자.

public class ThreeDPrinter {
	private Powder material;
    
    public void setMaterial(Powder material) {
    	this.material = material;
    }
    
    public Powder getMaterial( ) {
    	return material;
    }
}

헌데 위에서 이야기했듯 재료는 다른 것도 사용할 수 있다. 이번에는 플라스틱 액체를 재료로 사용하는 프린터를 구현해보자.

public class ThreeDPrinter { 
	private Plastic material;
    
    public void setMaterial(Plastic material) {
    	this.material = material;
    }
    
    public Plastic getMaterial( ) {
    	return material;
    }
}

그런데 재료만 바뀌었을 뿐 프린터 기능이 동일하다면 프린터 클래스를 두 개 만드는 것은 비효율적이다. 이런 경우에 어떤 재료든 쓸 수 있도록 material 변수의 자료형을 Object로 사용할 수 있다. Object를 활용하여 만든 코드는 아래와 같다.

public class ThreeDPrinter {
	private Object material;
    
    public void setMaterial(Object material) {
    	this.material = material;
    }
    
    public Object getMaterial( ) {
    	return material;
    }
}

material 변수의 자료형을 Object로 선언한 ThreeDPrinter에 파우더를 재료로 사용하면 아래와 같은 코드를 구현할 수 있다.

setMaterial( ) 메서드를 활용하여 Powder를 재료로 선택할 때는 매개변수 자료형이 Object이므로 자동으로 형 변환이 된다. 하지만 반환형이 Object 클래스인 getMaterial( ) 메서드로 Powder 자료형 변수를 반환받을 때는 형 변환을 해줘야 한다. 즉 어떤 변수가 여러 참조 자료형을 사용할 수 있도록 Object 클래스를 사용하면 다시 원래 자료형으로 반환해 주기 위해 매번 형 변환을 해야하는 번거로움이 있다. 이럴 때 사용하는 프로그래밍 방식이 제네릭이다. 여러 참조 자료형이 쓰일 수 있는 곳에 특정한 자료형을 지정하지 않고, 클래스나 메서드를 정의한 후 사용하는 시점에 어떤 자료형을 사용할 것인지 지정하는 방식이다. 이제 제네릭 클래스를 구현하고 사용하며 배워보자.

제네릭 클래스 정의하기
제네릭에서는 여러 참조 자료형을 사용해야 하는 부분에 Object가 아닌 하나의 문자로 표현한다. 위에서 예로 든 ThreeDPrinter 를 제네릭 클래스로 정의하면 아래와 같다.

코드를 보면 여러 자료형으로 바꾸어 사용할 material 변수의 자료형을 T라 썼다. 이때 T를 자료형 매개변수(type parameter) 라고 부른다. 클래스 이름을 GenericPrinet<T>라고 정의하고 나중에 클래스를 사용할 때 T 위치에 실제 사용할 자료형을 지정한다. 클래스의 각 메서드에 해당 자료형이 필요한 부분에는 모두 T 문자를 사용해 구현한다.

다이아몬드 연산자 <>
자바 7부터 제네릭 자료형의 클래스를 생성할 때 생성자에 사용하는 자료형을 명시하지 않을 수 있다 한다. 그동안 공부하며 많이 사용한 ArrayList를 살펴보자.

여기에서 <>를 다이아몬드 연산자라 한다. 선언된 자료형을 보고 생략된 부분이 String임을 컴파일러가 유추할 수 있기에 생성 부분에서는 생략할 수 있다.

자료형 매개변수 T와 static
static 변수나 메서드는 인스턴스를 생성하지 않아도 클래스 이름으로 호출할 수 있다. static 변수는 인스턴스 변수가 생성되기 이전에 생성된다. 또한 static 메서드에서는 인스턴스 변수를 사용할 수 없다. 그런데 T의 자료형이 정해지는 순간은 제네릭 클래스의 인스턴스가 생성되는 순간이다. 따라서 T의 자료형이 결정되는 시점보다 빠르기에 static 변수의 자료형이나 static 메서드 내부 변수의 자료형으로 T를 사용할 수 없다. 자료형 매개변수로 T외에 다른 문자도 사용할 수 있다. E는 element, K는 key, V는 value를 의미한다. 의미가 그렇다는 것이지 꼭 이 문자들을 사용해야 하는 것은 아니다. A, B 등 아무 문자나 사용해 정의할 수도 있다.

제네릭에서 자료형 추론하기
자바 10부터는 지역 변수에 한해서 자료형 추론을 할 수 있다. 이는 제네릭에도 적용된다. 위에서 String을 자료형 매개변수로 사용한 ArrayList 선언 코드를 아래처럼 바꿀 수 있다.

var List = new ArrayList<String>( );

생성되는 인스턴스를 바탕으로 list의 자료형이 ArrayList<String>임을 추론할 수 있기 때문이다. 물론 list가 지역 변수로 선언되는 경우만 가능하다.

제네릭 클래스 사용하기
파우더가 재료인 프린터는 아래와 같이 선언하여 생성한다.

GenericPrinter<Powder> powderPrinter = new GenericPrinter<Powder>( );
powderPrinter.setMaterial(new Powder());
Powder powder = powderPrinter.getMaterial( ); //명시적 형 변환을 하지 않음

T로 정의한 클래스 부분에 Powder형을 넣어주고, T형 매개변수가 필요한 메서드에 Powder 클래스를 생성하여 대입해준다. GenericPrinter<Powder>에서 어떤 자료형을 사용할지 명시했으므로 getMaterial( ) 메서드에서 반환할 때 형 변환을 하지 않았다. 이렇게 실제 제네릭 클래스를 사용할 때 T 위치에 사용한 Powder형을 '대입된 자료형'이라고 하고, Powder를 대입해 만든 GenericPrinter<Powder>를 '제네릭 자료형'이라고 하자. 제네릭으로 구현하면 왜 형 변환을 하지 않아도 될까? 제네릭 클래스를 사용하면 컴파일러는 일단 대입된 자료형이 잘 쓰였는지 확인한다. 그리고 class 파일을 생성할 때 T를 사용한 곳에 지정된 자료형에 따라 컴파일하므로 형 변환을 하지 않아도 된다. 따라서 제네릭을 사용하면 컴파일러가 자료형을 확인해주기에 안정적이면서 형 변환 코드가 줄어든다.

제네릭 클래스 사용 예제
지금까지 배운 내용을 예제 코드로 작성하면 아래와 같다. 재료로 사용할 Powder와 Plastic 클래스를 먼저 정의한다.

Powder 클래스
Plastic 클래스

파우더와 플라스틱 액체를 재료로 모형을 출력하는 프린터를 제네릭 클래스로 정의하면 아래와 같다.

GenericPrinter<T> 클래스의 인스턴스 변수 material은 자료형 매개변수 T로 선언했다. 그리고 10~12행의 getMaterial( ) 메서드는 T 자료형 변수 material을 반환한다. 메서드 선언부나 메서드의 매개변수로 자료형 매개변수 T를 사용한 메서드를 '제네릭 메서드'라고 한다. 제네릭 메서드는 일반 메서드뿐 아니라 static 메서드에서도 활용할 수 있다. 이제 프로그램을 실행해 보자.

5, 11행처럼 사용할 참조 자료형을 지정하여 GenericPrinter 클래스를 생성한다. 만약 새로운 재료가 추가되면 추가된 재료 클래스를 만들고 T 대신 해당 클래스를 대입하여 GenericPrinter를 생성하면 된다.

제네릭에서 대입된 자료형을 명시하지 않는 경우
제네릭 클래스를 사용할 때는 GenericPrinter<Powder>의 Powder처럼 대입된 자료형을 명시해야 한다. 그런데 아래와 같이 자료형을 명시하지 않고 사용할 수도 있다. 이 문법은 이전 버전과의 호환을 위해 제공한다고 한다.

이렇게 클래스에 대입된 자료형을 명시하지 않는 경우 컴파일 오류는 아니지만, 사용할 자료형을 명시하라는 의미로 노란색 경고 줄이 나타난다. 또한 컴파일러가 어떤 자료형을 사용할 것인지 알 수 없으므로 getMaterial( ) 메서드에서 강제로 형 변환을 해야 한다. 하여 제네릭 클래스를 사용하는 경우 되도록이면 대입된 자료형으로 사용할 참조 자료형을 지정하는 것이 좋다. 만약 여러 자료형을 동시에 사용하려면 아래와 같이 Object 클래스를 사용할 수도 있다.

GenerinPrinter<Object> generalPrinter = new GenericPrinter<Object>( );


T 자료형에 사용할 자료형을 제한하는 <T extends 클래스>
제네릭 클래스에서 T 자료형에 사용할 자료형에 제한을 둘 수 있다. 예를 들어 위에 구현한 GenericPrinter<T> 클래스는 사용할 수 있는 재료가 한정되어 있다. 만약 아무 제약이 없다면 물을 재료로 쓰겠다고 할 수도 있다. 물은 3D 출력을 할 수 없는 재료이다. 이런 일을 방지하기 위해 사용할 클래스에 자료형 제한을 두는 방식으로 extends 예약어를 사용할 수 있다. GenericPrinter<T> 클래스의 T에 대입된 자료형으로 사용할 재료 클래스를 Material 추상 클래스에서 상속받는다. Material 클래스는 아래처럼 추상 클래스로 정의하고, 상속받은 클래스는 doPrinting( ) 추상 메서드를 구현해야만 한다.

Material을 상속받은 Powder와 Plastic 클래스 코드는 아래와 같다.

<T extends Material>을 사용한 코드는 아래와 같다.

클래스 이름에 <T extends Material>이라고 명시하여 사용할 수 있는 자료형에 제한을 두었다. 만약 Material 클래스를 상속받지 않은 Water 클래스를 사용하면 오류가 발생한다. T 위치에 특정 인터페이스를 구현한 클래스만 사용하려는 경우에도 extends 예약어를 사용할 수 있다.

<T extends 클래스>로 상위 클래스 메서드 사용하기
<T extends Material>로 선언하면 제네릭 클래스를 사용할 때 상위 클래스 Material에서 선언한 메서드를 사용할 수도 있다. 우선 <T extends Material>을 사용하지 않은 경우부터 보자.

public class GenericPrinter<T> {
	private T material;
}

위처럼 선언하면 T는 컴파일할 때 Object 클래스로 변환된다. 즉 이 경우에 Object 클래스가 기본으로 제공하는 메서드만 사용할 수 있다. 이유는 자료형을 알 수 없기 때문이다. 만약 <T extends Material>을 사용하면 어떻게 될까? Material 추상 클래스에 doPrintng( ) 메서드가 선언되어 있다. 클래스 선언부에 아래와 같이 <T extends Material>을 추가한다.

public class GenericPrniter<T extends Material> {
	private T material;
}

그러면 material이 사용할 수 있는 메서드에 doPrinting( )이 추가된다. 즉 상위 클래스 Material에서 선언하거나 구현한 메서드를 모두 사용할 수 있다. 실제로 <T extends Material>을 사용하면 컴파일할 때 내부적으로 T 자료형이 Object가 아니라 Material로 변환된다. 위 내용을 전체 코드로 살펴보자.

19행처럼 T형 material변수에서 doPrinting( ) 메서드를 호출할 수 있다. 테스트 코드는 아래와 같다.


제네릭 메서드 활용하기
메서드의 매개변수를 자료형 매개변수로 사용하는 경우에 대해 알아보자. 또한 자료형 매개변수가 하나 이상인 경우도 살펴보자. 제네릭 메서드의 일반 형식은 아래와 같다.

public <자료형 매개변수> 반환형 메서드이름(자료형 매겨변수...) { }

반환형 앞에 사용하는 <자료형 매개변수>는 여러 개일 수 있고, 이는 메서드 내에서만 유효하다. 그러면 자료형 매개변수를 여러 개 사용하는 제네릭 메서드 예제를 살펴보자. 아래와 같은 Point 클래스가 있다. 이 클래스는 한 점을 나타내기 위해 x, y 두 멤버 변수를 사용하는데 이는 모두 자료형 매개변수로 선언한다.

한 점을 나타내는 Point 클래스의 두 좌표 x, y는 정수일 수도 있고 실수일 수도 있다. 그래서 T와 V라는 자료형 매개변수로 표현했다. 그리고 이 변수들을 위한 메서드 getX( ), getY( )는 T와 V를 반환하고 있으므로 제네릭 메서드이다. 이제 이 Point 클래스를 활용하여 아래와 같이 두 점을 생성한다.

두 점의 위치를 표현할 때 x좌표는 Integer를 사용했고 y 좌표는 Double을 사용했다. 컴파일러는 선언된 자료형을 보고 생성되는 인스턴스의 자료형을 유추할 수 있으므로 <> 다이아몬드 연산자에는 자료형을 명시하지 않아도 된다. 그럼 두 점을 매개변수로 받아 만들어지는 사각형의 넓이를 계산하는 makeRectangle( ) 메서드를 만들어 보자. 두 점이 Integer형으로 만들어질 수도 있고, Double형으로 만들어질 수도 있기에 넓이를 계산하는 makeRectangle( ) 역시 제네릭 메서드로 만들어야 한다. 코드는 아래와 같다.

GenericMethod 클래스는 제네릭 클래스가 아니다. 제네릭 클래스가 아니라도 내부에 제네릭 메서드를 구현할 수 있다. 제네릭 메서드인 makeRectangle( ) 메서드는 static으로 구현했다. makeRectangle( ) 메서드에서 사용하는 T와 V는 makeRectangle( ) 메서드 내부에서만 유효하게 사용할 수 있다. 이제 제네릭 메서드를 호출하자. 20행에서 사용할 자료형으로 Integer와 Double을 대입하여 메서드를 호출한다. 만약 사용할 자료형을 명시하지 않고 메서드를 호출하면 매개변수 클래스에서 자료형을 유추하게 된다. 만약 p1, p2가 Point<Integer, Double>형으로 선언된 경우 제네릭 메서드에 대입할 자료형이 생략되어도 컴파일러에 의해 <Integer, Double>로 유추된다.

Point<Integer, Double> p1 = new Point<>(0, 0.0);
Point<Integer, Double> p2 = new Point<>(10, 10.0);
double rect = GenericMethod.makeRectangle(p1, p2);


컬렉션 프레임워크에서 사용하는 제네릭
앞으로 공부할 컬렉션 프레임워크에서도 다양한 자료형을 관리하기 위해 제네릭을 자주 사용한다고 한다. ArrayList를 예로 살펴보면 ArrayList.java에서 ArrayList 클래스의 정의는 아래와 같다.

public class ArrayList<E> ectends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
}

배열은 요소를 가지므로 T보다는 Element를 의미하는 E를 더 많이 사용한다. 아래와 같이 E 위치에 원하는 자료형을 넣어 배열을 사용할 수 있다.

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

ArrayList에서 미리 정의되어 있는 메서드 중 가장 많이 사용하는 get( ) 메서드를 살펴보면 아래 코드와 같다.

public E get(int index) {
	rangeCheck(index);
    return elementData(index);
}

E라고 쓰인 반환형은 ArrayList를 생성할 때 사용한 자료형으로 반환한다. 여기에서는 String이 된다. 또한 컴파일러가 형 변환을 구현하므로 프로그래머가 직접 형 변환을 하지 않아도 된다.


참고 서적: 자바 프로그래밍 입문 - 박은종

'프로그래밍 언어 > JAVA' 카테고리의 다른 글

JAVA 입문 - List 인터페이스  (0) 2022.06.05
JAVA 입문 - 컬렉션 프레임워크  (0) 2022.06.04
JAVA 입문 - Class 클래스  (0) 2022.06.02
JAVA 입문 - wrapper 클래스  (0) 2022.06.01
JAVA 입문 - String 클래스  (0) 2022.05.31