👨💻 해당 포스팅은 '김영한의 실전 자바 중급 2편'을 수강하며 학습한 것을 정리한 글입니다.
이번 포스팅에서는 자바의 제네릭에 대해서 이야기해보고자 한다.
제네릭은 범용적이라는 의미를 가지는데 범용적으로 사용될 수 있는 어떠한 것을 말한다. 제네릭을 왜 사용하게 됐는지 그 이유를 코드와 함께 살펴보겠다.
🔗 제네릭을 사용하는 이유
public class BoxMain2 {
public static void main(String[] args) {
ObjectBox integerBox = new ObjectBox();
integerBox.set(10);
Object object = integerBox.get();
Integer integer = (Integer) object;
System.out.println("integer = " + integer);
ObjectBox stringBox = new ObjectBox();
stringBox.set("hello");
String str = (String) stringBox.get();
System.out.println("str = " + str);
// 잘못된 타입의 인수 전달시
integerBox.set("문자100");
Integer result = (Integer) integerBox.get();
System.out.println("result = " + result); // 에러 발생
}
}
public class ObjectBox {
private Object object;
public void set(Object object) {
this.object = object;
}
public Object get() {
return object;
}
}
모든 클래스의 최상위 부모인 Object 클래스를 멤버변수로 가지는 ObjectBox 클래스를 생성한다. 이후에 ObjectBox 클래스를 활용해 객체를 생성할 때 Object를 제외한 다른 모든 클래스 또한 파라미터로 들어갈 수 있다. ex). Integer, String, Boolean 등등
👨💻 하지만 위 코드는 타입 안정성이 없다는 것과 형변환을 해주어야 된다는 단점이 존재한다.
예시코드와 같이 ObjectBox 객체를 integerBox 변수로 선언했다는 것은 Integer 타입의 값이 저장되길 기대하고 있을 것이다. 하지만 파라미터로 들어갈 수 있는 타입은 Object이기에 Integer든 String이든 다 들어갈 수가 있다. 아래와 같이 말이다.
integerBox.set("문자100");
Integer result = (Integer) integerBox.get();
System.out.println("result = " + result);
integerBox.set()을 할 때에는 파라미터로 Object를 받을 수 있기에 String 타입인 “문자100”을 넣어도 컴파일 시에 문제가 되지 않는다. 또한 integerBox.get()이 반환하는 것이 Object이기에 이 경우에도 컴파일 시 문제가 되지 않는다. 하지만 런타임 과정에서 ClassCastException 에러가 발생한다. String을 Integer로 캐스팅할 수 없다는 것이다.
👨💻 이런 에러가 발생하지 않도록 조심하면 될 수도 있겠지만 이건 올바른 해결법은 아니다. 또한 매번 원하는 타입으로 캐스팅을 해주어야 하는 것도 추가적인 작업이 된다. 아래의 코드는 이러한 단점들을 개선하는 방법이다.
public class IntegerBox {
private Integer integer;
public void set(Integer integer) {
this.integer = integer;
}
public Integer get() {
return integer;
}
}
public class StringBox {
••• 위와 동일
}
ObjectBox 대신 IntegerBox와 StringBox 클래스를 정의해주어서 다른 타입의 파라미터가 들어오지 못하도록 방지할 수 있다. 타입 안정성을 확보하고 캐스팅을 일일히 해주는 것을 없애게 됐지만 모든 타입별로 클래스를 작성해주어야 될 것이다.
이렇게 되면 코드의 재사용성이 떨어지고 작성해야 될 코드가 많아진다는 치명적 단점이 생긴다. 이러한 것을 해결해주는 것이 Generic이다.
🔗 제네릭(Generic)
public class GenericBox<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
위 코드는 제네릭의 예시이다. <>를 다이아몬드라고 하며 다이아몬드 기호를 사용한 클래스를 제네릭 클래스라고 한다.
제네릭 클래스의 몇 가지 특징은 아래와 같다.
- 제네릭 클래스를 사용할 때에는 타입을 지정하지 않은데 클래스명 오른쪽에 와 같이 선언하면 제네릭 클래스가 된다.
- 관례상 T(ype)라고 작성하며 이는 타입 매개변수라고 한다.
- 클래스 내부에 T 타입이 필요한 곳에 타입 매개변수를 적어두면 된다. ex). private T value;
이렇게 만든 GenericBox를 아래와 같이 활용할 수 있다.
public class BoxMain3 {
public static void main(String[] args) {
/*
원래는 new GenericBox<Integer>(); 가 필수인데 자바 컴파일러가
타입 추론을 해주어 타입을 넣지 않아도 되게 된 것임.
*/
GenericBox<Integer> integerBox = new GenericBox<>();
integerBox.set(10);
System.out.println("integerBox.get() = " + integerBox.get());
GenericBox<String> stringBox = new GenericBox<>();
stringBox.set("Hello");
System.out.println("stringBox.get() = " + stringBox.get());
}
}
타입을 미리 지정하지 않고 객체를 생성할 수 있기에 이로써 타입 안정성과 재사용성 모두를 챙길 수 있게 된다.
🔗 제네릭의 한계
제네릭은 재사용성을 높이고 타입 안정성을 높여주는 장점이 있다고 했다. 그리고 타입을 미리 지정하지 않아도 되기에 어느 타입이 들어가도 문제가 되지 않는다.
하지만 어느 타입이 들어가도 문제가 되지 않는 것이 문제가 될 수 있다. 이유는 아래 코드와 함께 알아보겠다. Animal(부모 클래스) 클래스를 작성하고 이를 상속하는 Cat과 Dog 클래스가 있다고 가정하겠다.
public class Animal {
private String name;
private int size;
public Animal(String name, int size) {
this.name = name;
this.size = size;
}
public String getName() {
return name;
}
public int getSize() {
return size;
}
public void sound() {
System.out.println("동물 울음 소리");
}
@Override
public String toString() {
return "Animal{" +
"name='" + name + '\'' +
", size=" + size +
'}';
}
}
public class Cat extends Animal {
public Cat(String name, int size) {
super(name, size);
}
@Override
public void sound() {
System.out.println("냐옹");
}
}
public class Dog extends Animal {
public Dog(String name, int size) {
super(name, size);
}
@Override
public void sound() {
System.out.println("멍멍");
}
}
그리고 AnimalHospital이라는 제네릭 클래스를 아래와 같이 정의한다.
public class AnimalHospitalV2<T> {
private T animal;
public void set(T animal) {
this.animal = animal;
}
public void checkup() {
// 메서드를 정의하는 시점에는 T의 타입을 알 수 없기에 모든 클래스의 최상위 부모클래스인 Object의 기능만 사용 가능하다.
animal.toString();
animal.equals(null);
// Object에는 getName() 메서드가 없기 때문에 컴파일 에러가 발생한다.
// System.out.println("동물 이름: " + animal.getName());
// animal.sound();
}
public T getBigger(T target) {
// 컴파일 에러
// return animal.getSize() > target.getSize() ? animal : target;
return null;
}
}
제네릭 타입을 선언하면 자바 컴파일러 입장에서는 어떤 타입 매개변수가 들어올지 알 수가 없다. Animal 타입(Dog 혹은 Cat 포함)이 타입 매개변수로 들어올 것을 기대하지만 제네릭은 다른 타입이 타입 매개변수로 들어가도 컴파일에 문제가 되지 않는다.
AnimalHospitalV2<Dog> dogHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Cat> catHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Integer> integerHospital = new AnimalHospitalV2<>();
AnimalHospitalV2<Object> objectHospital = new AnimalHospitalV2<>();
그렇기 때문에 Cat, Dog, Animal이 아닌 Integer, String이 들어가도 컴파일 에러를 잡아낼 수가 없게 된다.
이를 해결해주는 것이 extends 키워드이다.
extends는 제네릭 클래스의 상한선을 정의하는 것인데 예시 코드는 아래와 같다.
public class AnimalHospitalV3<T extends Animal> {
private T animal;public void set(T animal) {
this.animal = animal;
}
public void checkup() {
System.out.println("동물 이름: " + animal.getName());
System.out.println("동물 크기: " + animal.getSize());
animal.sound();
}
public T getBigger(T target) {
return animal.getSize() > target.getSize() ? animal : target;
}
}
AnimalHospitalV3 제네릭 클래스로 객체를 생성 시에 Animal과 그 자식 클래스만 타입 매개변수로 지정할 수 있다는 것이다.
즉, 자바 컴파일러는 사실상 AnimalHospitalV3를 최소 Animal 타입으로는 가정한다는 이야기이다. 그렇기 때문에 앞선 예시 코드에서 불가했던 animal.getName()과 animal.getSize()를 문제없이 작성할 수 있게 되는 것이다.
이외에 제네릭 클래스와 마찬가지로 제네릭 메서드도 정의할 수 있다.
👨💻 제네릭에 대해서 학습하면서 가볍게만 알고 있던 것을 제대로 익힐 수 있었다. 그리고 그동안 사용했던 다양한 컬렉션들이 제네릭이었다는 것을 알게 되었다. ex). ArrayList<Integer>, HashSet<String> 등등..
Spring에서 이미 존재하는 여러 자바 코드를 보면 제네릭이 굉장히 많이 사용된 것을 볼 수 있었는데 이제는 좀 더 쉽게 이해할 수 있으려나 🤔
'TIL' 카테고리의 다른 글
| 파사드 패턴에 대한 생각 (3) | 2025.09.18 |
|---|---|
| 메모리 계층 구조 (0) | 2025.09.15 |
| 멀티 프로젝트가 무엇일까? (0) | 2025.02.11 |
| 인덱스 적용을 통한 성능 개선 (0) | 2025.01.14 |
| 인덱스 이해하기 (0) | 2025.01.10 |