0%

JAVA Generic 제네릭을 알아보자

제네릭이 뭔데?

제네릭은 클래스 내부에서 사용할 데이터 타입을 외부에서 지정하는 기법이다. 라이브러리를 보다 보면 제네릭을 사용한 코드를 많이 볼 수 있다.

1
2
3
4
5
public class Box<T> {
private T t;
public void set(T t) { this.t = t; }
public T get() { return t; }
}

위 코드는 Box라는 클래스를 정의한 것이다. Box 클래스는 제네릭을 사용하여 Box 클래스를 생성할 때 사용할 데이터 타입을 외부에서 지정할 수 있다. Box 클래스를 사용하는 방법은 다음과 같다.

1
2
3
Box<Integer> box = new Box<>();
box.set(10);
System.out.println(box.get());

위 코드는 Box 클래스를 사용하여 Integer 타입의 데이터를 저장하고 출력하는 코드이다. Box 클래스를 생성할 때 Box로 지정하였기 때문에 Box 클래스 내부의 T는 Integer로 지정된다.

제네릭을 사용하는 이유

제네릭을 사용하는 이유는 제네릭을 사용하지 않으면 발생하는 문제를 해결하기 위해서이다. 제네릭을 사용하지 않는 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Box {
private Object t; // 모든 타입을 담기 위해 Object 사용
public void set(Object t) { this.t = t; }
public Object get() { return t; }
}

public class Main {
public static void main(String[] args) {
Box box = new Box();
box.set("Hello"); // String 타입 저장

// 값을 꺼내올 때마다 명시적으로 캐스팅 필요
String str = (String) box.get();
System.out.println(str);

box.set(123); // Integer 타입 저장
// 잘못된 타입으로 캐스팅하면 런타임 오류 발생
String str2 = (String) box.get(); // ClassCastException 발생
System.out.println(str2);

}
}

제네릭을 사용하지 않으면 Object를 사용하여 모든 타입을 담을 수 있다. 하지만 잘못된 코드를 짰을 때 런타임 오류가 발생할 수 있다.
또한 오류 검출은 빠르게 할수록 좋다. 제네릭을 사용하면 컴파일 시점에 오류를 검출할 수 있기 때문에 오류를 빠르게 확인할 수 있다.

주의할 점

제네릭을 사용할 때 주의할 점이 있다.

제네릭은 컴파일 시점에만 사용되기 때문에 런타임 시점에는 제네릭 타입이 소거된다(단순 Object 타입으로 변환된다). 따라서 런타임에 제네릭 타입의 실제 타입을 알 수 없다. 이러한 이유로 런타임에 동작하는 new, instanceof와 같은 키워드는 제네릭 타입에 사용할 수 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Box<T> {
private T t;
public void set(T t) { this.t = t; }
// 제네릭 타입의 객체를 생성하려고 할 때 컴파일 오류 발생
public void create() {
// t = new T(); // 컴파일 오류
}
}

public class Main {
public static void main(String[] args) {
Box<String> box = new Box<>();
// 런타임에 동작하는 키워드는 제네릭 타입에 사용할 수 없다.
// if(box instanceof Box<String>) {
// System.out.println("box is Box<String>");
// } // 컴파일 에러
if(box instanceof Box) {
System.out.println("box is Box");
} // 컴파일 가능
if(box instanceof Box<?>) {
System.out.println("box is Box<?>");
}// 와일드카드
}
}

위에서 ? 와일드카드를 사용했는데 ?는 모든 타입을 의미한다.
헷갈리는 부분이 있을 수 있으니 다음 표를 참고하자.

타입 설명 특징 사용 사례
Box<T> 제네릭 클래스 - 컴파일 타임에 타입 체크
- 타입 안전성 보장
- 특정 타입으로 제한됨
- 특정 타입의 객체만을 처리할 때
- 타입 안전성을 보장해야 할 때
Box 로 타입 (Raw Type) - 타입 파라미터 없음
- 모든 타입 허용
- 타입 안전성 없음
- 제네릭 정보 소거
- 이전 버전과의 호환성 유지
- 타입 안전성이 중요하지 않은 경우
Box<?> 와일드카드 타입 - 제네릭 타입 파라미터를 유지
- 특정 타입을 알 수 없음
- 읽기 전용
- 타입 안전성 일부 보장
- 메서드 파라미터나 반환 타입에서 제네릭 타입의 불특정성을 나타낼 때
- 읽기만 필요할 때
Box<Object> 제네릭 클래스의 특정 타입 - Object 타입의 객체만 처리
- 컴파일 타임에 타입 체크
- 타입 안전성 보장
- 모든 타입의 객체를 처리해야 하지만 타입 안전성을 유지하고자 할 때

제네릭 메서드

제네릭 메서드는 제네릭 타입을 메서드의 파라미터나 반환 타입으로 사용하는 메서드를 말한다. 제네릭 메서드를 사용하면 메서드를 호출할 때마다 타입을 지정할 필요가 없다.

1
2
3
4
5
public class Box {
public static <T> T getValue(T t) {
return t;
}
}

위 코드는 제네릭 메서드를 사용한 예제이다. 제네릭 메서드를 사용할 때는 메서드 이름 앞에 를 붙여주면 된다. 제네릭 메서드를 사용하는 방법은 다음과 같다.

1
2
String str = Box.getValue("Hello");
Integer num = Box.getValue(123);

와일드카드

와일드카드는 크게 제한된 와일드카드와 비한정적 와일드카드로 나뉜다. 제한된 와일드카드는 특정 타입으로 제한하는 것이고, 비한정적 와일드카드는 모든 타입을 허용하는 것이다.

1
2
3
4
5
public class Box<T> {
public void setBox(Box<?> box) {
// ...
}
}

위 코드는 비한정적 와일드카드를 사용한 예제이다. Box 클래스의 setBox 메서드는 Box 타입의 객체를 파라미터로 받는다. 이때 Box 클래스의 제네릭 타입을 모르기 때문에 와일드카드를 사용하여 모든 타입을 받을 수 있도록 하였다.

제한된 와일드카드는 두 가지 방법으로 사용할 수 있다. 상위 바운드와 하위 바운드가 있다. 상위 바운드는 특정 타입의 상위 클래스로 제한하는 것이고, 하위 바운드는 특정 타입의 하위 클래스로 제한하는 것이다.

Box클래스랑 Animal, Pet, Dog 클래스를 만들어서 와일드카드를 사용하는 예제이다. 상속 관계는 다음과 같다.

1
Animal <- Pet <- Dog
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Box<T> {
private T value;

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

public T get() {
return value;
}
}

class Animal {
public void makeSound() {
System.out.println("Some generic animal sound");
}
}

class Pet extends Animal {
}

class Dog extends Pet {
@Override
public void makeSound() {
System.out.println("Bark");
}
}

상위 바운드를 사용한 예제이다. 상위 바운드를 사용하면 특정 타입의 상위 클래스로 제한할 수 있다.

또한 상위 바운드는 읽기 전용으로 사용할 수 있다. 즉, get 메서드로 값을 읽어올 수는 있지만 set 메서드로 값을 설정할 수는 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class AnimalProcessor {
// 상위 바운드 예시: Animal의 하위 클래스만 허용
public static void processAnimalBox(Box<? extends Animal> box) {
Animal animal = box.get();
animal.makeSound(); // 안전하게 Animal의 메서드를 호출할 수 있음

// box.set(new Dog()); // 오류 발생: 컴파일러는 Box의 정확한 하위타입을 알 수 없으므로 설정 불가
}

public static void main(String[] args) {
Box<Dog> dogBox = new Box<>();
dogBox.set(new Dog());
processAnimalBox(dogBox); // 허용: Dog는 Animal의 서브타입

Box<Pet> petBox = new Box<>();
petBox.set(new Pet());
processAnimalBox(petBox); // 허용: Pet은 Animal의 서브타입

Box<Animal> animalBox = new Box<>();
animalBox.set(new Animal());
processAnimalBox(animalBox); // 허용: Animal 자체도 가능
}
}

하위 바운드를 사용한 예제이다. 하위 바운드를 사용하면 특정 타입의 하위 클래스로 제한할 수 있다.

하위 바운드는 상위 바운드와 반대로 쓰기 전용으로 사용할 수 있다. 즉, set 메서드로 값을 설정할 수는 있지만 get 메서드로 값을 읽어올 수는 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class AnimalProcessor {
// 하위 바운드 예시: Pet의 상위 클래스만 허용
public static void addPetToBox(Box<? super Pet> box) {
box.set(new Pet()); // 안전하게 Pet 객체를 추가할 수 있음

// Pet pet = box.get(); // 오류 발생: 컴파일러는 Box의 정확한 상위타입을 알 수 없으므로 읽기 불가
}

public static void main(String[] args) {
Box<Animal> animalBox = new Box<>();
addPetToBox(animalBox); // 허용: Animal은 Pet의 상위 타입

Box<Pet> petBox = new Box<>();
addPetToBox(petBox); // 허용: Pet은 Pet 타입 자체

// Box<Dog> dogBox = new Box<>();
// addPetToBox(dogBox); // 오류 발생: Dog는 Pet의 상위 타입이 아님
}
}

제한자를 &를 사용해서 여러개 사용할 수도 있다. 예를 들어 <? extends Animal & Pet>와 같이 사용할 수 있다.

마무리

제네릭은 컴파일 시점에 타입을 체크하여 안전하게 프로그래밍할 수 있도록 도와준다. 제네릭을 사용하면 런타임 오류를 줄일 수 있고, 코드의 가독성을 높일 수 있다. 제네릭은 자바에서 많이 사용되는 기법이므로 잘 익혀두는 것이 좋다.