Java 제네릭 Generics DEEP DIVE

⏱ 16 분

JDK API 를 살펴보면 다음과 같은 코드를 흔히 볼 수 있습니다.

1
default <V> Function<V, R> compose(Function<? super V, ? extends T> before)

위 코드는 자바 8에 추가된 함수형 인터페이스 Functioncompose 라는 디폴트 메소드의 시그니쳐입니다. 위 코드가 이해하기 어려운 분들이라면 이번 포스트를 통해서 제네릭에 대한 개념을 함께 정리하시면 좋겠습니다.

살펴볼 내용

  • 제네릭
  • 제네릭 클래스
  • 타입 인자와 제한
  • 제네릭 메소드
  • 타입 추론
  • 타입 이레이져 Type Erasure
  • 와일드카드 Wildcard 와 타입 제한
  • <? extends T>
  • 제네릭 인터페이스

시작하기

쉬운 내용부터 시작합니다. 다음은 간단한 사과와 오렌지 클래스입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
class Apple {
@Override
public String toString() {
return "I am an apple.";
}
}

class Orange {
@Override
public String toString() {
return "I am an orange.";
}
}

그리고 각각 사과와 오렌지를 담을 수 있는 박스가 있습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AppleBox {
private Apple apple;

public Apple get() {
return apple;
}

public void set(Apple apple) {
this.apple = apple;
}
}

class OrangeBox {
private Orange orange;

public Orange get() {
return orange;
}

public void set(Orange orange) {
this.orange = orange;
}
}

간단한 코드죠? 그럼 다음과 같이 상자에 담거나 꺼낼 수 있을 겁니다.

1
2
3
4
5
6
7
8
AppleBox aBox = new AppleBox();
OrangeBox oBox = new OrangeBox();

aBox.set(new Apple());
oBox.set(new Orange());

Apple apple = aBox.get(); // I'm an apple.
Orange orange = oBox.get(); // I'm an orange.

그런데 이 박스는 역할이 비슷하기 때문에 통합하기로 합니다. 사과 담는 상자나 오렌지 담는 상자를 따로 둘 필요는 없겠죠.

1
2
3
4
5
6
7
8
9
10
11
class Box {
private Object fruit;

public Object get() {
return fruit;
}

public void set(Object fruit) {
this.fruit = fruit;
}
}

사과와 오렌지 모두를 받기 위해서 Object 를 사용했습니다. 동일하게 사용하는 코드를 작성해보겠습니다.

1
2
3
4
5
6
7
8
9
Box aBox = new Box();
Box oBox = new Box();

aBox.set(new Orange()); // ?
oBox.set(new Apple());

// java.lang.ClassCastException: Orange cannot be cast to Apple
Apple apple = (Apple) aBox.get();
Orange orange = (Orange) oBox.get();

여기서 나타날 수 있는 문제는 두 가지가 있습니다. 하나는 Object 타입이기 때문에 사과 상자와 오렌지 상자 구분없이 담기고, 꺼낼 때 형 변환이 필요하다는 점입니다.

  1. 다른 게 들어갈 수 있고, 명백한 오류지만 컴파일러가 알아내질 못한다.
  2. 형 변환(casting) 이 필요함.

이런 상황은 프로그래머가 의도하지 않은 실수라고 볼 수 있는데, 컴파일 단계에서 잡아내지 못했습니다. 런타임에 발생하는 에러는 치명적이기 때문에 컴파일 단계에서 미리 잡을 수 있어야 좋겠죠.

제네릭 Generics

제네릭(Generics)은 클래스 또는 메소드 내부에서 사용할 타입을 외부에서 인자로 받는 것입니다. 이 인자는 인스턴스를 생성하는 시점이나 메소드를 호출하는 시점에 정할 수 있습니다. 위의 상황을 개선하기 위해서 제네릭을 적용해보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
class Box<T> {
private T fruit;

public T get() {
return fruit;
}

public void set(T fruit) {
this.fruit = fruit;
}
}

기존의 박스와 비교해보면, ObjectT 라는 문자로 대체되었고, 클래스 이름 옆에 <T> 가 붙었습니다. 이 의미는 해당 클래스에서 사용하는 타입을 T 라고 정의하고 해당 타입은 인스턴스 생성 시 받아서 정해진다는 의미입니다. 그러면 사용하는 코드는 다음처럼 수정할 수 있습니다.

1
2
3
4
5
6
7
8
9
// 인스턴스 생성 시 타입을 정함
Box<Apple> aBox = new Box<>();
Box<Orange> oBox = new Box<>();

aBox.set(new Orange()); // Orange cannot be cast to Apple
oBox.set(new Apple());

Apple apple = aBox.get();
Orange orange = oBox.get();

제네릭을 통해서 앞서 살펴 본 문제점을 모두 해결할 수 있게 됩니다.

  1. 컴파일러 단계에서 타입 에러를 잡아낼 수 있음.
  2. 형 변환이 필요없음.

제네릭 여러 개 사용하기

제네릭은 여러 개 사용할 수 있습니다. 인자를 두 개 받는 것이죠. 다음은 두 가지 타입을 받는 박스입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class DBox<L, R> {
private L left;
private R right;

public void set(L left, R right) {
this.left = left;
this.right = right;
}

@Override
public String toString() {
return left + " & " + right;
}
}

그러면 다음과 같이 두 개의 타입을 넣을 수 있습니다. 물론 필요하다면 세 개, 그 이상도 가능합니다.

1
2
3
DBox<String, Integer> box = new DBox<>();
box.set("Apple", 25);
System.out.println(box); // Apple & 25

그렇다면 제네릭 타입을 사용하는 클래스를 제네릭 타입으로 사용할 수 있을까요? 예를 들면 박스 안에 박스를 넣는 셈입니다.

1
2
3
4
5
6
7
8
9
10
Box<Apple> box1 = new Box<>();
box1.set(new Apple());

Box<Box<Apple>> box2 = new Box<>();
box2.set(box1);

Box<Box<Box<Apple>>> box3 = new Box<>();
box3.set(box2);

System.out.println(box3.get().get().get()); // I'm an apple.

Box<Apple> 은 사과를 담는 상자가 되어 하나의 타입으로 다른 상자 안에 들어가게 됩니다. 상자 안에 상자를 담는다고 생각하면 쉽습니다.

추가적으로 살펴보자면, 기본 타입들은 Integer 처럼 래퍼 클래스(Wrapper class)를 사용해야 합니다. 그리고 생성 시 뒤에서도 똑같이 타입을 명시하게 되는데, 이는 보통 생략을 하고 <> 이렇게 표시합니다. 이런 기호는 다이아몬드(Diamond) 기호라고 부릅니다.

1
2
3
4
5
6
7
// Type argument cannot be of primitive type
Box<long> box = new Box<long>();

// Explicit type argument Long can be replaced with <>
Box<Long> box = new Box<Long>();

Box<Long> box = new Box<>(); // OK!

자주 사용하는 타입인자

제네릭 클래스를 만들 때 T 라는 키워드를 사용했습니다. T 는 Type 을 의미하는데요, 이처럼 자주 사용하는 타입 인자들이 있습니다. 물론 필요에 따라 원하는대로 만들어도 무방하지만, 다른 사람이 알아보기 쉽게 만드는 것이 좋습니다.

타입 인자 주로 사용되는 의미
E Element
K Key
N Number
T Type
V Value
R Result

타입 인자 제한하기

이처럼 제네릭을 이용하면 내부에서 사용할 타입을 외부에서 받아올 수 있습니다. 그런데 이렇게 들어올 수 있는 타입을 좀 더 명확하게 제한할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
class Box<T extends Number> {
private T object;

public T get() {
return object;
}

public void set(T object) {
this.object = object;
}
}

Box<T extends Number> 는 타입 인자 TNumber 클래스를 상속하는 하위 클래스라고 범위를 제한하고 있습니다. 따라서 다음과 같이 IntegerDouble 은 가능하지만 String 은 불가능합니다.

1
2
3
4
5
6
7
8
9
Box<Integer> iBox = new Box<>();
iBox.set(25);

Box<Double> dBox = new Box<>();
dBox.set(25.0);

// Type parameter 'java.lang.String' is not within its bound
// should extend 'java.lang.Number'
Box<String> sBox = new Box<>();

이렇게 범위를 제한하게 되면, 특정 메소드를 호출하는 것을 보장할 수 있습니다.

1
2
3
public int toIntValue() {
return object.intValue();
}

ìntValue 메소드가 Number 클래스가 가지고 있는 메소드이기 때문에, 위 메소드는 objectNumber 클래스라고 보장이 되어야만 가능합니다. 따라서 Box<T> 에서는 어떤 타입이 들어올 지 몰라서 불가능하지만 Box<T extends Number> 에서는 타입이 한정되어 있기 때문에 가능합니다.

인터페이스로 타입 인자 제한

이런 타입 제한은 인터페이스와 함께 사용하기도 합니다. 타입 인자를 제한할 때는 인터페이스도 상속과 마찬가지로 extends 키워드를 이용해서 처리합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Eatable {
String eat();
}

class Box<T extends Eatable> {
private T object;

public void set(T object) {
this.object = object;
}

public T get() {
System.out.println(object.eat()); // 호출 가능
return object;
}
}

타입이 Eatable 인터페이스를 구현하고 있다는 것을 보장하고 있기 때문에 eat 메소드를 호출할 수 있게 됩니다.

자바에서는 인터페이스 여러 개를 동시에 implements 할 수 있듯이, 타입 인자도 인터페이스를 이용하면 여러 개를 사용해 제한할 수 있습니다.

1
class Box<T extends Number & Eatable>

제네릭 메소드

메소드에서도 마찬가지로 제네릭을 사용할 수 있습니다. 메소드에서 제네릭을 사용한다는 것은, 메소드의 매개변수 타입이나 리턴 타입을 호출 시에 지정할 수 있다는 뜻이 됩니다. 기본적으로 사용법은 클래스에 적용할 때와 같습니다. 다만 제네릭 메소드는 메소드 시그니처에 매개변수 타입을 추가로 명시해줘야 합니다. 메소드 시그니처에서는 해당 T 를 어떤 의미인지 알 수가 없기 때문입니다. 명시해주는 위치는 메소드 리턴 타입 앞입니다.

1
2
3
4
5
6
7
public Box<T> makeBox(T o) { ... } // Cannot resolve symbol 'T'

public <T> Box<T> makeBox(T o) {
Box<T> box = new Box<>();
box.set(o);
return box;
}

결론적으로 위와 같은 메소드는 특정 타입의 인자를 받아서 해당 타입을 담는 박스 클래스를 리턴하는 메소드입니다. 메소드는 다음과 같이 사용할 수 있습니다.

1
2
Box<String> sBox = boxFactory.makeBox("Sweet"); // instance method
Box<String> sBox =BoxFactory.makeBox("Sweet"); // static method

물론 제네릭 메소드에서도 타입을 제한할 수 있습니다.

1
2
public static <T extends Number> Box<T> makeBox(T o) { ... }
public static <T extends Number> T openBox(Box<T> box) { ... }

타입 추론

사실 여기엔 생략된 부분이 있습니다. 메소드 시그니처에 있듯이 메소드에게 해당 타입을 전달해줘야 합니다. 하지만 컴파일러는 매개변수의 타입을 보고 해당 타입을 유추할 수 있기 때문에 생략해서 사용할 수 있습니다. 컴파일러는 메소드의 시그니처 정보를 가지고 있기 때문에 아래 코드에서 “Sweet” 이라는 문자열을 보고 타입인자 TString 이라고 판단하게 됩니다.

1
2
Box<String> sBox = boxFactory.<String>makeBox("Sweet");
Box<String> sBox = boxFactory.makeBox("Sweet"); // 생략한 모습

그렇다면 다음과 같이 매개변수가 없는 경우는 어떻게 유추를 할까요?

1
Box<Integer> iBox = boxFactory.makeEmptyBox();

이 때는 왼편의 선언된 변수의 타입을 보고 유추합니다. 이렇게 컴파일러의 타입 유추에 사용된 타입을 타겟 타입(Target type) 이라고 합니다. 이는 자바 7에서부터 지원되는 기능입니다.

타입 이레이져 Type Erasure

컴파일러 얘기가 나왔으니 이 제네릭의 매커니즘에 대해 좀 더 알아보겠습니다.

1
2
3
4
5
6
7
8
public static <E> boolean containsElement(E[] elements, E element) {
for (E e : elements) {
if (e.equals(element)) {
return true;
}
}
return false;
}

위 메소드는 제네릭을 이용해서 배열 중 특정 요소가 있는지 확인하는 제네릭 메소드입니다. 컴파일러는 제네릭을 이용해서 타입을 확인하고 에러가 없는지 체크하게 됩니다. 그런데 실제 런타임에서는 이 코드는 다음과 같이 변환되어 실행됩니다.

1
2
3
4
5
6
7
8
public static boolean containsElement(Object[] elements, Object element) {
for (Object e : elements) {
if (e.equals(element)) {
return true;
}
}
return false;
}

자세히 보시면, 제네릭 타입이 모두 사라지고 Object 타입으로 변환되었습니다. 컴파일러가 컴파일 단계에서 확인을 했기 때문에 런타임 에러가 나는 것을 방지해줍니다.

그렇다면 범위를 제한한 제네릭은 어떨까요? 이럴 경우에는 타입 에러가 나지 않기 위해서는 Object 가 아닌 특정 타입으로 바뀌어야 합니다.

1
2
3
4
5
6
7
8
public static <E extends Number> boolean containsElement(E[] elements, E element) {
for (E e : elements) {
if (e.equals(element)) {
return true;
}
}
return false;
}

따라서 타입 이레이져가 타입을 모두 지워버리면 다음과 같이 변경됩니다.

1
2
3
4
5
6
7
8
public static boolean containsElement(Number[] elements, Number element) {
for (Number e : elements) {
if (e.equals(element)) {
return true;
}
}
return false;
}

결론적으로 제네릭은 타입을 외부에서 받아서 사용하고 제한함으로써 타입 에러를 컴파일러 단계에서 방지하기 위함이라고 볼 수 있습니다.

와일드카드 Wildcard

와일드카드는 ? 키워드로 표시되는 것으로 제네릭과 비슷하지만 좀 더 간결하고 확장된 문법을 제공합니다.

앞에서 계속해서 Box 클래스를 이용했는데요, 이번에는 이 박스 클래스를 오픈하는 Unboxer 클래스를 만들어보겠습니다.

1
2
3
4
5
6
7
8
9
class Unboxer {
public static <T> T openBox(Box<T> box) {
return box.get();
}

public static <T> void peekBox(Box<T> box) {
System.out.println(box);
}
}

상자를 오픈해서 내용을 리턴하는 openBox 메소드와 상자의 내용물을 출력만 하는 peekBox 메소드가 있습니다. 이 중 peekBox 메소드를 보시면, 이 타입의 인자로는 Box<Apple> 이 올 수도 있고, Box<Orange> 가 올 수도 있습니다. 이 때 와일드카드를 이용해서 Box<?> 라고 표시할 수 있습니다. 해당 타입으로는 여러 가지가 올 수 있다는 뜻입니다.

1
2
3
public static void peekBox(Box<?> box) {
System.out.println(box);
}

제네릭과 비슷해보이지만 좀 다르죠? 와일드카드는 상한제한과 하한제한을 이용해서 타입을 제한할 수 있는데 주로 타입을 제한해서 사용하는걸 많이 볼 수 있습니다.

상한 제한 Upper-Bounded

상한 제한이라는 것은 와일드카드의 범위를 특정 객체의 하위 객체로 제한하는 것입니다. 다음과 같은 코드에서 박스 클래스 안에 들어갈 수 있는 클래스는 Integr, Double 과 같이 Number 클래스를 상속하는 하위 클래스들입니다.

1
2
3
public static void peekBox(Box<? extends Number> box) {
System.out.println(box);
}

상한 제한이 그저 클래스의 범위를 제한하는 것 이상의 기능이 있습니다. 다음은 인형을 담는 박스 클래스가 있을 때 상자에서 인형을 꺼내는 메소드입니다.

1
2
3
4
public static void outBox(Box<Toy> box) {
Toy toy = box.get();
System.out.println(toy);
}

재미있는 점은 토이 클래스를 상속받는 하위 클래스들도 담을 수 있도록 다음처럼 상한 제한을 넣었을 때, 상자 안에 넣는 메소드는 호출할 수가 없게 됩니다.

1
2
3
4
5
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get();
box.set(new Toy()); // compile error!
System.out.println(toy);
}

어떻게 이런 일이 발생할까요? 왜냐하면 상위 참조변수는 하위 클래스를 참조할 수 있지만, 반대로 하위 참조변수는 상위 클래스를 참조할 수 없기 때문입니다.

1
2
3
4
class Robot extends Toy { ... }

Toy t = new Robot(); // 가능
Robot r = new Toy(); // 불가!

그렇게 때문에 인형 박스에 로봇은 넣을 수 있지만, 로봇 박스에는 인형을 넣을 순 없게 됩니다. 다음과 같이 상한 제한을 하는 경우에 인형의 하위 클래스인 로봇 박스가 올 수 있기 때문에 박스에 넣는 기능은 컴파일러가 미리 방지하는 것입니다.

1
2
3
4
5
6
public static void outBox(Box<? extends Toy> box) {
Toy toy = box.get();
// Box<Robot> 에는 Toy 를 담을 수가 없다.
box.set(new Toy()); // compile error!
System.out.println(toy);
}

하한 제한 Lower-Bounded

하한 제한은 상한 제한과 반대로 동작합니다. 다음과 같은 클래스는 Integer 를 포함해 상위 클래스만 올 수 있습니다.

1
2
3
public static void peekBox(Box<? super Integer> box) {
System.out.println(box);
}

아까처럼 인형 클래스 예제를 살펴봅니다. 다음은 인형 박스에 로봇을 담는 메소드입니다.

1
2
3
public static void inBox(Box<? super Robot> box, Robot robot) {
box.set(robot);
}

다음과 같이 상한 제한을 걸게 되면 박스 안에는 로봇 클래스와 토이 클래스가 올 수 있게 됩니다. 그렇다면 상자 안에는 토이가 들어있을 수 있기 때문에 하위 클래스인 로봇 참조변수로는 참조할 수 없게 됩니다.

1
2
3
4
public static void inBox(Box<? super Robot> box, Robot robot) {
box.set(robot);
Robot myRobot = box.get(); // compile error!
}

상한 제한과 하한 제한은 단순히 클래스의 제한할 수 있는 기능과 더불어, 예상치 못한 에러를 컴파일 단계에서 찾아낼 수 있는 역할을 합니다.

이러한 와일드 카드와 타입 제한은 자바 API 에서도 쉽게 찾아볼 수 있습니다.

1
2
// Collections.copy 메소드
public static <T> void copy(List<? super T> dest, List<? extends T> src) { ... }

제네릭 메소드와 와일드카드

앞서 살펴본 와일드카드의 타입 제한에서 해당 타입을 제네릭으로 외부에서 받아올 수 있습니다. 코드를 보면 가끔 <? extends T> 라는 코드를 볼 수 있는데 이게 바로 그런 케이스입니다.

다음 코드에서는 메소드 오버로딩이 성립하지 않습니다. 왜냐하면 타입 이레이져가 동작하면서 두 코드는 동일한 코드가 되기 때문입니다.

1
2
3
// both methods have same erasure
public static void outBox(Box<? extends Toy> box) { ... }
public static void outBox(Box<? extends Robot> box) { ... }

이럴 땐 해당 타입을 제네릭으로 받아와 활용할 수 있습니다.

1
public static void outBox(Box<? extends T> box) { ... }

위 코드는 메소드 호출 시 타입을 받는데, 그 타입은 해당 타입과 그 타입의 하위 클래스만 올 수 있으며, 참조변수 box 가 참조하는 인스턴스를 대상으로 꺼내는 작업만 허용한다는 의미가 됩니다.

제네릭 인터페이스

인터페이스도 제네릭을 사용할 수 있습니다.

1
2
3
interface Getable<T> {
T get();
}

해당 인터페이스는 다음과 같이 구현할 수 있습니다.

1
2
3
4
class Box<T> implements Getable<T> { 
@Override
public T get() { ... }
}

제네릭 인터페이스를 구현할 때 T 를 결정한 상태로 구현할 수도 있습니다.

1
2
3
4
class Box<T> implements Getable<String> {
@Override
public String get() { ... }
}

참고