마음만 바쁜 사람
article thumbnail
List<String> userNames = new ArrayList<>();

자바로 프로그래밍을 하는 분들이라면 굉장히 흔하게 볼 수 있는 구문이다. '제네릭'이라는 용어를 들어본 적이 없더라도 다들 자연스럽게 위와 같은 형태로 String 리스트를 선언하는 방식을 사용하고 있을 텐데, 여기서 꺽쇠(<String>, <>)는 무엇이고 어떤 역할을 하고 있는 걸까?

제네릭이란?

클래스나 메서드에서 사용할 내부 데이터 타입을 외부에서 지정하는 방법으로, 자바 5부터 추가되었다. 꺽쇠 안에 타입 매개변수를 위치시켜(<T>) 제네릭 타입을 선언하고, 해당 객체의 타입은 컴파일 타임에 지정한다는 특징이 있다.

타입 매개변수에는 보통 T, E, K, V 등의 시그니처를 사용하지만, 굳이 따르지 않아도 된다. 

타입 매개변수를 한글로 설정해도 된다. (아쉽게도 물음표까진 못붙인다.)

 

외부에서 지정하는 것이 뭔지, 컴파일 타임에 지정한다는 것이 무슨 뜻인지는 제네릭의 장점들을 살펴보며 알아보도록 하자. 

타입 안정성

제네릭은 컴파일 타임에 타입 검사를 진행하기 때문에 런타임 에러를 줄일 수 있다. 정확히 말하면 컴파일 시점에 미리 에러를 발생시켜 프로그램이 실행된 후 에러가 발생할 경우를 줄여주는 방식이다. 컴파일 에러는 우리가 보통 사용하는 대부분의 IDE에서 빨간 밑줄 등으로 표시를 해주기 때문에 오류를 쉽게 파악해 고칠 수 있게 해 준다.  다음의 예를 살펴보자.

List names = new ArrayList();
names.add("dino");
names.add(14);

String name1 = (String) names.get(0);
String name2 = (String) names.get(1);

System.out.println(name1 +","+ name2);

해당 코드는 런타임 에러가 발생하는 코드이다. 다시 말하면 해당 코드는 IDE상에서 아무런 위험 신호를 보이지 않는다. 로직 상으로도 리스트에 변수를 집어넣고 get을 통해서 빼낼 때 String으로 타입 캐스팅을 해 주니 name1와 name2가 String형 일것만 같다. 하지만 해당 코드를 실행하면 다음과 같은 결과를 반환한다. 

코드 상에선 아무 위험사항을 보이지 않지만 140 line에서 미스캐스팅 에러가 발생한다.

(String)으로 타입 캐스팅을 진행해주려 했지만 names.get(1)이 반환하는 값은 int형이기 때문에 타입 캐스팅 에러를 반환한다.

다음과 같은 상황을 방지하기 위해 리스트의 네이밍을 조금더 신경 써 주고, add 시킬 값을 다시 한번씩 확인해 가며 조심조심 코드를 짜는 방법이 있겠지만, 애초에 리스트를 선언할 때 특정한 자료형만 가질 수 있도록 제한을 걸어줄 수 있다면 훨씬 편하지 않을까? 이러한 생각에서 나온 것이 바로 제네릭이다. 

int값을 삽입할 수 없다는 에러 메시지를 띄워준다.

다음과 같이 names의 선언 시점에 <String>을 추가하면 해당 객체는 String값만 다룰 수 있는 리스트임을 보장해 준다. 

따라서 해당 리스트에 int 값을 삽입하려 하면 IDE상에서 컴파일 에러가 발생한다는 것을 미리 알려준다. 

또한 names가 String만 들어갈 수 있다는 것을 보장하기 때문에 해당 리스트에서 값을 가져왔을 때 따로 타입 캐스팅을 해줄 필요가 없다는 장점도 있다.

(String)을 통해 타입 캐스팅을 할 필요가 없어졌다!

코드의 유연성(재사용성)

제네릭의 또 다른 장점은 하나의 코드를 여러 타입에 적용시킬 수 있다는 점이다. 다음 코드를 살펴보자.

public static void printInt(int[] array) {
    for (int i : array) {
        System.out.print(i + " ");
    }
    System.out.println();
}

public static void printDouble(double[] array) {
    for (double d : array) {
        System.out.print(d + " ");
    }
    System.out.println();
}

위 코드는 각각 int형과 double형 배열을 매개변수로 받아 원소들을 출력하는 함수이다. 두 함수의 유일한 차이점은 매개변수의 타입이 다르다는 것이다. 이러한 상황에서 제네릭을 적용하면 다음과 같은 코드를 만들 수 있다. 

public static <T> void printArray(T[] array) {
    for (T element : array) {
        System.out.print(element + " ");
    }
    System.out.println();
}

제네릭을 타입 매개변수 T를 사용하면 T에 대한 구체적인 타입 선언을 사용자의 함수 호출 시점으로 미룰 수 있다. 다시 말하면 해당 함수 내에서는 타입을 확정하지 않고 외부에서의 함수 호출 시점에 타입이 구체적으로 정해지는 것이다. 실제 List나 ArrayList 등을 보면 제네릭을 사용해 모든 타입에 대해 같은 함수를 사용할 수 있도록 구현해 놓았다. 

제네릭을 이용해 모든 타입에 대해 동일한 함수를 적용할 수 있게 해준다.

코드의 가독성

제네릭을 사용하면 선언 시 타입 매개변수 자리에 사용할 타입을 명시해줘야 하기 때문에 조금 더 확실하게 해당 객체의 타입을 파악할 수 있다. 

List<String> values1 = new ArrayList<>();
List<Integer> values2 = new ArrayList<>();
List<Double> values3 = new ArrayList<>();

 

제네릭 클래스

제네릭 클래스와 인터페이스는 다음과 같이 클래스 이름 뒤에 타입 매개변수를 위치시키는 방식으로 선언한다.

public class ClassName <T> { ... }
public Interface InterfaceName <T> { ... }

여기서 선언된 T 타입은 해당 클래스/인터페이스 안에서 자유롭게 사용이 가능하며, 제네릭 타입은 두 개 이상 둘 수도 이 있다. 

두 개의 제네릭 타입을 선언한 대표적인 예

제네릭 메서드

제네릭 함수는 클래스와 다르게 접근 지정자와 반환 타입 사이에 타입 매개변수를 위치시키는 방식으로 선언한다. 

public <T> void functionName(T parameter) {
    ...
}

위에서 잠깐 살펴본 printArray 함수를 다시 가져와 보자.

public static <T> void printArray(T[] array) {
    for (T element : array) {
        System.out.print(element + " ");
    }
    System.out.println();
}

String[] array = {"Hello", "world", "!"};
printArray(array);

Integer[] array2 = {1, 2, 3};
printArray(array2);

 

각각 함수를 호출할 때 T를 String과 Integer로 대체해 해당 배열을 출력할 수 있도록 한다. 

public static void printArray(String[] array) {
    for (String element : array) {
        System.out.print(element + " ");
    }
    System.out.println();
}

public static void printArray(Integer[] array) {
    for (Integer element : array) {
        System.out.print(element + " ");
    }
    System.out.println();
}

그러면 클래스와 함수 모두에 제네릭이 적용되어 있고, 해당 타입 매개변수의 시그니처가 같다면 어떻게 될까? 다음 예시를 보자.

public class ExampleClass<T> {
    private T data;

    public ExampleClass(T data) {
        this.data = data;
    }

    public <T> void printData(T info) {
        T dataCopy = data;
        System.out.println(dataCopy);
        System.out.println(info);
    }
}

다음 코드는 올바르게 작동할까? IDE에서 확인해보자.

필요한 타입은 T인데 제공된 타입은 T 이다.?

클래스의 인스턴스 변수인 data를 받아온 dataCopy의 선언 부분에서 컴파일 에러를 발생시킨다. 타입 매개변수의 밑줄을 살펴보면 조금 더 명확하게 이유를 알 수 있다. 

클래스의 T는 함수의 T선언에 의해 숨겨졌다.

data의 타입은 클래스의 T 형을 받아와야 하는데, 이는 무시되고 메서드에서 선언된 T형을 따르는 것이다. 이를 통해 같은 시그니처의 타입 매개변수가 있다면, 클래스보다 함수의 타입이 우선시 된다는 점을 알 수 있다. 

 

하지만 간단하게 클래스와 함수의 타입 매개변수를 다르게 설정하면 헷갈리지 않고 사용할 수 있지 않을까? 그냥 다른 시그니처로 선언하자..

함수의 타입 매개변수를 E로 바꾸면 아주 깨끗해진다.

 

Integer 타입으로 클래스를 생성하고, printData의 매개변수는 String 타입을 받아 실행시킨다.

제한된 제네릭

이제까지 우리는 제네릭을 통해 모든 타입에 대해 적용 가능한 클래스와 함수를 선언하고 실행하는 방법들에 대해 살펴보았다. 하지만 모든 타입이 아닌 내가 범위를 지정해 해당 타입만 사용할 수 있는 클래스나 함수를 만들고 싶다면 어떻게 해야 할까? 다음 코드를 살펴보자.

여기서 SimpleList의 sum 함수는 리스트 안의 값을 모두 더해주는 기능을 가지고 있다는 것을 유추할 수 있다. 하지만 리스트 안의 값을 모두 더하려면 우선 리스트가 숫자들만 가지고 있다는 것이 보장되어야 한다. 다시 말하면 Number와 그 자손 타입만 리스트에 들어가 있다는 것을 보장해야 한다. 이를 제네릭을 이용해 구현할 수 있을까?

 

앞에서 말했듯이 "Number와 그 자손 타입만 가능하도록 보장한다."는 조건을 제한된 제네릭 선언으로 구현할 수 있다. 

 

제한된 제네릭은 < T extends Type> 형태로 선언한다. 이는 "Type과 이의 자손 타입만 가능하도록 제한을 가한다."는 의미를 가지며 위 코드의 경우에 Type은 넘버에 해당하기 때문에 <T extends Number> 형태로 제네릭을 선언할 수 있다. 

이를 그래프 형태로 보면 다음과 같다. 

Number와 그 자손 타입만 가능하도록 상한 제한을 가한다.

다음과 같은 선언을 통해 위 코드의 함수를 구현해 보자.

제네릭 함수의 타입 매개변수 선언 부분에 상한 제한을 설정해 해당 함수에서의 T는 Number와 그 자손 타입만 대입 가능하도록 설정되었다. 

잘 실행된다.

작성한 함수가 올바르게 동작하는 것을 볼 수 있다. 그러면 정말 Number의 범위를 벗어난 타입으로 선언했을 때 에러를 발생시키는지 확인해 보자.

String을 대입할 수 없다는 경고를 띄운다.

Number의 자손 타입이 아닌 String 타입을 매개변수로 받았을 때 에러를 발생시키는 것을 볼 수 있다.

 

여기서 잠깐!

다른 내용 없이 여기 나와 있는 내용만 쭉 읽어 나가다 보면 이런 생각이 들 수 도 있다. '애초에 목적은 Number의 하위 원소들을 받아서 더하고 그 합을 반환하는 것이니까 매개변수에만 List<Number> 조건이 있으면 되는거 아닌가?' 

함수 정의에는 문제가 없다.

위와 같이 매개변수로 받을 리스트의 조건은 List<Number>의 형식으로 지정해 주면 Number의 하위 타입인 Double과 Integer등의 리스트도 원활하게 받을 수 있어 보인다. convertValue도 Nuber형으로 받기 때문에 덧셈에도 문제가 없다. 그럼 이제 해당 함수를 사용해 보자.

타입이 서로 달라 에러를 띄운다.

아쉽게도 매개변수를 List<Number>로 지정한 함수에는 List<Integer>와 List<Double>가 대입될 수 없다. 이는 제네릭이 불공변(무공변) 특성을 가지고 있기 때문인데,  이는 Integer가 Number의 하위 타입이더라도 List<Integer>는 List<Number>의 하위 타입은 아니라는 의미이다. 하지만 제한된 제네릭 형태는 이를 공변 또는 반공변 성질로 바꾸어 주는 역할을 한다.

 

와일드카드

제네릭에는 와일드카드라는 기능도 존재한다. 와일드카드는 물음표 기호(?)를 사용하며 이는 '알 수 없는 타입'을 의미한다. 와일드카드는 함수의 매개변수 또는 반환 타입에 한정해서 사용 가능하다는 특징이 있다.

제네릭 클래스로 선언된 Box는 생성자로 T 타입의 item을 받고 간단한 getter와 setter로 구성되어 있는 클래스이다. 

다음과 같이 RandomBox는 타입을 와일드카드로 지정하였다. 또한 getItem을 통해 해당 인스턴스 변수를 가져오는 것은 문제가 없다는 것도 확인 가능하다. 하지만 setter에서 문제가 발생한다.

다음 경고를 보면 매개변수는? 형으로 대입해야 한다는 이상한 구문을 띄워준다. 그럼 형변환을 통해 다른 타입으로 넣어보자.

Object형으로 타입 캐스팅을 해도 안되고, (?) 형태는 아예 불가능한 문법이라는 경고를 띄워준다. 타입의 최상위 형인 Object가 대입이 안된다는 것은 와일드카드 형태에서는 값의 대입이 불가능하다는 것을 의미한다. 그럼 와일드카드가 왜 있는 건가? 

와일드카드도 위에서 살펴봤던 제한된 제네릭처럼 경계를 설정해 주었을 때 기능을 발휘한다. 

경계 와일드카드

와일드카드에는 상한 경계 와일드카드하한 경계 와일드카드가 존재한다. 형태도 <? extends Type>과 <? super Type>으로 제한된 제네릭의 T 대신 와일드카드 기호를 넣어주는 형식으로 선언한다. 

하지만 그 뜻은 미묘한 차이가 있는데, <T extends Number>가 "Number와 그 자손 타입에 해당하는 타입 중 하나로 치환 가능"이라면

<? extends Number> 는 다음 바운더리 중 하나로 정해질 것이다.

 

<? extends Number>는 "Number와 그 자손 타입에 해당하는 타입들 중 하나"라는 느낌이다."Number의 하위 타입이라는 것은 보장 가능하지만 구체적으로 어떤 타입인지는 모른다."는 것이다.

대충 타입의 범위는 정해지지만 최종적으로 타입이 정해지지는 않는다.

뜻을 봐도 잘 이해하기엔 쉽지 않다. 다음 예제를 살펴보자.

여기서 filterNegative는 숫자 리스트를 매개변수로 받아 음수인 숫자를 제외한 리스트를 다시 반환하는 기능을 가지고 있다는 것을 유추할 수 있다. 이를 상한 경계 와일드카드를 사용해 만들어보자.

작성한 코드를 쭉 읽어보며 흐름을 파악해 보자. 우선 매개변수 simpleList의 타입은 Number와 그 자손 타입들 중 하나지만 확실하게 하나로 확정하지 못하는? 타입의 SimpleList이다. 그리고 제네릭 함수를 적용해 SimpleList <T> 형태의 반환 타입을 가지도록 만들었다. 

내부 구현은 SimpleList<T> 타입의 tempList를 생성한 후, simpleList의 값들을 꺼내 tempList에 삽입해 주는 방식인데,? 타입은 getter를 사용 가능하고, tempList는 타입이 T이기만 하면 삽입을 허용하니까 (T)로 타입 캐스팅을 진행한 후 add의 매개변수에 대입하면 에러 없이 구현이 가능하다. 

음수를 제외한 값들을 출력하고 있다.

다음의 출력값을 보면 원하는 기능을 충실히 이행하고 있다는 것을 확인할 수 있다. 자 그러면 와일드카드는 이런 방식으로 사용하면 되는 걸까? 그런데 와일드카드를 사용한 이 방식이 아까 위에서 보았던 상한 제한 제네릭과 다른 점이 있나?

 

제한된 제네릭과 경계 와일드카드의 차이

위 함수 구현에는 맹점이 존재한다. 가만히 살펴보면 반환하는 객체는 SimpleList <T> 타입의 tempList 이다. <? extends Number>로 제한을 걸어준 객체는 simpleList인데? 그렇다면 다음과 같은 호출이 가능해진다. 

Integer 리스트를 필터링한 객체를 String 리스트로 받는데 에러가 발생하지 않는다.

현재 함수에서는 반환 타입에 대해 아무런 제약조건이 걸려있지 않다. 때문에 <T>에 String이 들어와도 반환 객체의 타입은 SimpleList<T> -> SimpleList<String> 이기 때문에 해당 코드는 문법적으로 오류가 없게 된다. 

실행하면 당연히 실패한다.

 

결국 와일드카드는 선언한 부분에 한정에서만 제약을 부여한다.  매개변수 부분에 선언했다면 해당 매개변수만, 반환 타입에 선언했다면 함수의 내부 구현은 관여하지 않고 오직 반환하는 객체의 타입에 대해서만 검사를 한다는 뜻이다.

매개변수 부분에 String 리스트를 넣으면 칼같이 금지한다.

 

그럼 이번에는 제한된 제네릭을 사용해 같은 기능을 제공하는 함수를 만들어보자.

모든 T에 대해 제약을 걸어준다.

제네릭 함수를 선언하는데 해당 함수 내에서 사용되는 T의 범위를 Number의 상한 경계로 걸어주므로 매개변수와 반환 객체의 타입 양쪽에 제약을 부여하게 된다. 

왼쪽은 String 리스트, 오른쪽은 Integer 리스트이기 때문에 바로 에러를 띄워준다.

 

그러면 와일드카드는 도대체 어떤 상황에 써야 하는 걸까? 결론부터 말하자면 와일드카드는 제네릭과 함께 쓰일 때 그 진가를 발휘한다. 다음 코드를 살펴보자.

Printer를 상속하는 LaserPrinter
printer에 laserPrinter의 값을 복사한다.

다음 코드에서 copy 함수는 첫 번째 매개변수 리스트의 값들을 두 번째 매개변수 리스트로 복사하는 기능을 수행한다는 것을 유추할 수 있다. 그리고 해당 두 매개변수는 (자식, 부모)의 상속관계를 가진다는 특징도 가지고 있다. 따라서 copy는 자식의 값들을 부모에게 복사하는 함수일 것이다. 이러한 경우 두 매개변수 사이의 상속 관계를 확인할 수 있는 제한조건이 필요하다. 이러한 경우에 와일드카드를 이용하면 두 매개변수들 사이의 관계성을 부여해 줄 수 있다.

자식 클래스에게는 상한 제한, 부모 클래스에는 하한 제한

해당 코드를 보면 자식 클래스인 laserPrinters에는 T 상한 제한, 부모 클래스인 printers에는 T 하한 제한 조건을 부여해 주었다. 와일드카드는 getter에 대해서 제한이 없으므로 값을 꺼내올 수 있고, SimpleList의 add 함수는 해당 리스트의 타입과 같거나 자식 타입인 값들은 문제없이 삽입할 수 있다. 따라서 laserPrinter의 값을 추출해 printer에 삽입하는데 아무런 문제가 없어지게 된다.

 

 이를 그림으로 나타내면 다음과 같다. 

laserPrinters안 객체의 타입은 무조건 printers가 받을 수 있는 타입임이 보장된다.
실행하면 값이 잘 복사됨을 확인할 수 있다.

 

다음과 같은 성질을 이펙티브 자바 '아이템 31- 한정적 와일드카드를 사용해 API 유연성을 높이라' 챕터에서는 펙스(PECS)공식을 들어 설명하고 있다. PECS는 'Producer-extends, Consumer-super'의 약자인데, 말 그대로 생산하는 쪽은 extends를, 소비하는 쪽은 super를 설정해 주라는 얘기이다. 위 함수에 해당 공식을 대입해 보면, laserPrinters에서 값을 꺼낸 뒤 이를 Pinters에 삽입하는 식으로 소비하고 있으니, laserPrinters에는 extneds, Printers에는 super 조건을 걸어 주는 식이다. 위 그림을 통한 이해가 어렵디면 해당 공식을 외워 놓는 것도 좋은 방법일 듯 하다.

 

정리하자면 와일드카드는 제한된 제네릭보다 조금 약한 제약의 강도를 보이지만 이 특성을 활용해 제네릭과 결합한다면 더욱 복잡한 제약조건을 부여할 수 있다. 그러니 제한된 제네릭과 와일드카드의 차이점을 명확하게 이해한 뒤, 특정 상황에 대해 어떠한 방식을 사용해야 할지 파악할 수 있는 능력을 가져야 할 것 같다,,!

 

 

+ 추가 생각거리

마지막으로 살펴본 copy에 대한 예제는 다음과 같이 제한된 제네릭으로 구현한 방식으로도 문제 없이 실행된다. 두 방식의 차이점은 무엇인가?

static <T, G extends T> void copy2(SimpleList<G> laserPrinters, SimpleList<T> printers) {
        for (int i = 0; i < laserPrinters.size(); i++) {
            printers.add((T) laserPrinters.get(i));
        }
    }

 

'Programing Language > Java' 카테고리의 다른 글

[JAVA] 일급 컬렉션 (First Class Collection)  (0) 2023.02.12
profile

마음만 바쁜 사람

@훌루훌루

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!