이제야 Generic을 포스팅 할 시간이 왔습니다.
개인적으로 자바에서 가장 좋아하는 기능인데요, 객체지향원칙 중 다형성을 극한으로 치중한듯 한 제네릭은 여러 타입을 같은 클래스 하나로 공유할 수 있다는점이 아주 매력적인 기능인 것 같습니다.
이러한 제네릭(Generic)은 같은 동작을 하는 코드이지만, 데이터 타입이 달라 각자의 타입에 맞는 여러 클래스들을 중복 구현하여야 하는 단점을 보완해줄 수 있는 해결사의 역할을 합니다.
제네릭은 Java에서 코드의 중복을 줄이고, 다양한 타입의 데이터에 유연하게 대처할 수 있는 강력한 기능을 제공하는데, 이번 글에서는 제네릭의 개념과 활용법에 대해 깊이 있게 알아보도록 하겠습니다.
제네릭(Generic) 개념
제네릭(Generic)은 Java에서 클래스나 메소드가 사용할 내부 데이터 타입을 컴파일 시에 지정할 수 있게 하는 기능입니다. 이를 통해 코드의 재사용성을 높이고, 타입 안전성을 보장할 수 있게 됩니다.
우리가 자바를 사용하며 흔히 사용하는 클래스 중 하나인 ArrayList를 예로 들어 보겠습니다.
List<String> list = new ArrayList<>();
list.add("Hello");
list.add("World");
String str = list.get(0);
위 코드에서 ArrayList는 제네릭 타입을 사용하고 있습니다.
<String>이라는 타입을 명시함으로써 이 리스트는 문자열만을 담을 수 있게 되고, 이후 데이터를 불러올 때 타입 캐스팅을 할 필요가 없게 됩니다. 이렇게 제네릭을 사용하면 코드의 가독성과 안정성이 높아집니다.
여기서 중요한 점은 제네릭을 사용함으로써 컴파일러가 타입 체크를 도와준다는 것입니다.
잘못된 타입의 데이터를 넣으려고 하면 컴파일 시점에서 오류가 발생하므로, 런타임에 발생할 수 있는 타입 관련 오류를 미리 방지할 수 있습니다.
제네릭 사용 방법
제네릭을 사용하기 위해서는 클래스나 메소드를 선언할 때 타입 매개변수를 지정해주면 됩니다. 아래 코드를 통해 제네릭 클래스와 메소드를 선언하고 사용하는 방법을 알아보겠습니다.
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
class Main {
public static void main(String[] args) {
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello Generic");
System.out.println(stringBox.getItem()); // result: Hello Generic
Box<Integer> integerBox = new Box<>();
integerBox.setItem(123);
System.out.println(integerBox.getItem()); // result: 123
}
}
위 코드에서 Box<T>라는 클래스는 제네릭 타입을 사용하고 있으며, T는 타입 매개변수로, 클래스가 사용될 때 실제 타입으로 대체됩니다.
Main 클래스에서 Box 클래스를 String 타입으로 사용하면 Box<String>, Integer 타입으로 사용하면 Box<Integer>로 인스턴스화되어 각각 다른 타입의 데이터를 처리할 수 있게 됩니다.
이렇게 제네릭을 사용하면 클래스나 메소드의 재사용성이 높아지고, 다양한 타입의 데이터를 다룰 수 있는 유연한 코드를 작성할 수 있게 됩니다.
제네릭 메소드
제네릭은 클래스뿐만 아니라 메소드에서도 사용할 수 있는데, 제네릭 메소드는 메소드의 리턴 타입 앞에 타입 매개변수를 선언하여, 호출될 때 그 타입이 결정되도록 할 수 있습니다.
public class GenericMethodExample {
public static <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
public static void main(String[] args) {
Integer[] intArray = {1, 2, 3, 4, 5};
String[] strArray = {"A", "B", "C"};
printArray(intArray); // result: 1 2 3 4 5
printArray(strArray); // result: A B C
}
}
위 코드에서 printArray 메소드는 제네릭 메소드로 선언되었습니다.
<T>를 메소드 앞에 선언하여, 이 메소드는 어떤 타입의 배열이든 인자로 받아 처리할 수 있게 되었는데, 이렇게 하면 타입에 구애받지 않고 다양한 데이터를 처리할 수 있어 코드의 재사용성이 높아집니다.
또한, 제네릭 메소드는 한 개의 타입만 받을 수 있는게 아닌, 여러 개의 타입 매개변수를 가질 수도 있습니다. 아래 예시는 두 개의 타입 매개변수를 사용하는 제네릭 메소드입니다.
public class GenericPairExample {
public static <K, V> void printPair(K key, V value) {
System.out.println("Key: " + key + ", Value: " + value);
}
public static void main(String[] args) {
printPair("Name", "John"); // result: Key: Name, Value: John
printPair(1, 100); // result: Key: 1, Value: 100
}
}
위 코드에서 printPair 메소드는 두 개의 타입 매개변수 K와 V를 사용하여 키와 값을 출력합니다.
이렇게 제네릭 메소드에 여러 타입을 사용할 수 있어 더 유연하게 데이터를 다룰 수 있으며, 확장성 또한 좋아집니다.
제네릭 클래스(Generic Class), 제네릭 인터페이스(Generic Interface)
제네릭은 클래스뿐만 아니라 인터페이스에서도 사용할 수 있는데요(대표적으로 많이들 접해보시는게 JpaRepository 인터페이스에서 접해보셨을겁니다.), 아래 코드는 제네릭 인터페이스의 사용 예시입니다.
public interface Pair<K, V> {
K getKey();
V getValue();
}
public class OrderedPair<K, V> implements Pair<K, V> {
private K key;
private V value;
public OrderedPair(K key, V value) {
this.key = key;
this.value = value;
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
}
class Main {
public static void main(String[] args) {
Pair<String, Integer> pair = new OrderedPair<>("One", 1);
System.out.println("Key: " + pair.getKey() + ", Value: " + pair.getValue()); // result: Key: One, Value: 1
}
}
위 코드에서 Pair 인터페이스는 제네릭 타입을 사용하고 있습니다. OrderedPair 클래스는 Pair 인터페이스를 구현하며, 키와 값을 저장하는 역할을 합니다. 이렇게 제네릭 인터페이스를 사용하면 다양한 타입을 다룰 수 있는 인터페이스를 설계할 수 있습니다.
제네릭 타입 소거(Generic Type Erasure)
Java에서 제네릭은 컴파일 시에만 타입을 검사하고, 런타임에는 해당 타입 정보가 제거되는 방식으로 구현됩니다.
이를 타입 소거(Type Erasure)라고 합니다. 타입 소거 덕분에 제네릭 코드는 타입에 안전하면서도 호환성을 유지할 수 있지만, 몇 가지 제약이 발생하게 됩니다.
타입 소거는 컴파일러가 제네릭 타입을 제거하고, 대신 해당 타입을 Object로 대체하거나 제한된 경계를 사용하는 경우 그 상위 타입으로 대체하는 것을 의미합니다.
예를 들어, 다음과 같은 제네릭 클래스가 있을 때
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
컴파일 시에는 Box<T>의 제네릭 타입 T가 제거되고, 해당 부분이 Object로 대체됩니다. 즉, Box 클래스는 내부적으로 다음과 같이 동작합니다.
public class Box {
private Object item;
public void setItem(Object item) {
this.item = item;
}
public Object getItem() {
return item;
}
}
이로 인해 발생하는 몇 가지 중요한 제약 사항은 다음과 같습니다:
- 런타임 타입 정보 소실: 제네릭 타입 정보는 컴파일 후에는 소거되기 때문에, 런타임에는 제네릭 타입에 대한 정보를 알 수 없습니다. 이로 인해 다음과 같은 코드가 컴파일 오류를 발생시킵니다.
- instanceof : 런타임에 타입을 검사하는데, 제네릭 타입 T는 이미 소거되었기 때문에 이 코드는 사용할 수 없습니다. ex) if (item instanceof T)를 사용한다면 -> 컴파일에러
- 제네릭 타입의 객체 생성 불가: 타입 소거로 인해 제네릭 타입의 객체를 직접 생성할 수 없습니다. 예를 들어, 다음과 같은 코드는 컴파일 오류를 발생시킵니다.대신, 클래스 외부에서 객체를 생성하거나 Factory 패턴 등을 활용하여 객체를 생성해야 합니다.
- public class Box<T> { private T item = new T(); -> 컴파일 에러 }
- 제네릭 배열 생성 불가: 타입 소거로 인해 제네릭 타입의 배열도 생성할 수 없습니다. 다음과 같은 코드가 컴파일 오류를 발생시키는 이유입니다.이러한 경우 List<T>와 같은 컬렉션을 사용하는 것이 대안이 됩니다.
- T[] array = new T[10]; -> 컴파일 에러
타입 소거는 Java의 제네릭 구현 방식으로 인해 발생하는 제약이지만, 이를 통해 제네릭을 사용한 코드의 타입 안전성을 확보하고, 이전 버전의 Java와의 호환성을 유지할 수 있다는 장점이 있습니다.
와일드카드(Wildcards)
제네릭에서는 ?를 사용하여 와일드카드를 표현할 수 있습니다. 와일드카드는 제네릭 타입의 경계를 지정하여 더 유연하게 사용할 수 있게 해주는데, 예를 들어, List<?>는 어떤 타입이든 담을 수 있는 리스트를 의미합니다.
와일드카드는 주로 메소드의 매개변수로 제네릭 타입을 사용할 때 유용합니다.
예로, 아래는 와일드카드를 사용하는 예시입니다.
public class WildcardExample {
public static void printList(List<?> list) {
for (Object element : list) {
System.out.println(element);
}
}
public static void main(String[] args) {
List<String> stringList = List.of("A", "B", "C");
List<Integer> intList = List.of(1, 2, 3);
printList(stringList); // result: A B C
printList(intList); // result: 1 2 3
}
}
위 코드에서 printList 메소드는 List<?> 타입의 매개변수를 받아 어떤 타입의 리스트든 처리할 수 있게 됩니다. 이렇게 와일드카드를 사용하면 메소드에서 다양한 제네릭 타입을 다룰 수 있어 유연성이 높아집니다.
또한, 와일드카드는 경계를 지정할 수도 있습니다.
extends 키워드를 사용하여 상한 경계를 지정하거나, super 키워드를 사용하여 하한 경계를 지정할 수 있습니다.
public static void printUpperBoundedList(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num);
}
}
위 코드에서 printUpperBoundedList 메소드는 Number 타입을 상속받는 타입만을 매개변수로 받을 수 있습니다. 이렇게 상한 경계를 지정하면 특정 타입 이상의 클래스들만 허용할 수 있습니다.
반대로, 하한 경계를 지정하기 위해서는 super 키워드를 사용할 수 있습니다.
public static void addNumbersToList(List<? super Integer> list) {
list.add(10);
list.add(20);
}
위 코드에서 addNumbersToList 메소드는 Integer 타입의 상위 클래스들만을 매개변수로 받을 수 있습니다. 이렇게 하한 경계를 지정하면 특정 타입 이상의 클래스들만 허용할 수 있습니다. 이를 통해 제네릭의 유연성을 더욱 높일 수 있습니다.
제네릭의 활용
제네릭은 Java 컬렉션 프레임워크에서 매우 많이 사용됩니다.
예를 들어, List, Map, Set 등의 컬렉션 클래스들은 모두 제네릭으로 구현되어 있어 다양한 타입의 데이터를 안전하게 저장하고 처리할 수 있습니다.
또한, 제네릭은 사용자 정의 클래스에서도 사용할 수 있어 코드의 재사용성을 높이고 유지보수성을 향상시킬 수 있는데, 같은 로직을 여러 타입에 대해 구현해야 하는 경우 제네릭을 사용하면 코드가 간결해지고, 오류 발생 가능성도 줄어듭니다.
이러한 제네릭을 사용하여 데이터 타입의 안정성을 보장하면서도 다양한 타입을 처리할 수 있는 유연한 코드를 작성할 수 있습니다. 이를 통해 개발자는 코드의 중복을 줄이고, 유지보수하기 쉬운 코드를 작성할 수 있게 됩니다.
이번 글을 아주 짧게 정리하자면 아래와 같습니다.
- 제네릭과 타입 안정성
제네릭을 사용하면 컴파일 타임에 타입을 검사하기 때문에 타입 안정성을 보장할 수 있습니다.
이는 런타임에 발생할 수 있는 ClassCastException과 같은 오류를 미연에 방지할 수 있도록 도와줍니다.
예를 들어, 제네릭을 사용하지 않는 경우 데이터를 처리할 때 타입 캐스팅이 필요한데, 잘못된 타입 캐스팅이 런타임 오류를 유발할 수 있습니다.
이를 방지하기 위해 제네릭을 사용하면 타입 캐스팅이 필요 없으며, 컴파일 시점에서 타입 오류를 잡아낼 수 있다는 장점이 있습니다.
- 제네릭과 코드 재사용성
제네릭을 사용하면 동일한 코드를 다양한 타입에 대해 재사용할 수 있습니다.
예를 들어, 데이터 타입이 다르다고 해서 동일한 로직을 여러 번 작성할 필요가 없으므로 코드의 중복을 줄일 수 있습니다.
이는 유지보수성을 크게 향상시키며, 코드 변경 시 일관성을 유지하는 데 도움을 줍니다.
후기
이번시간에는 드디어 제네릭에 대해 알아보았습니다.
다형성이라는것은 객체지향의 꽃이라고도 볼 수 있을정도로 대단한 개념인데, 이 개념에 부합하는것이 바로 이 제네릭이라는 개념인것같습니다.
프로그래밍을 시작한 지 얼마 안된 시절에는 많이 어렵고 생소한 개념이였지만, 자바와 스프링이라는 언어에 대해 알아가면 알아갈수록, 프로그래밍을 더욱 재미있게 만들어주는 개념중 하나가 바로 제네릭이라는 개념이 아닐까 생각합니다.
이상으로 포스팅을 마치겠습니다.
혹여나 글에서 다뤄야 할 중요한 부분이 빠졌거나, 잘못된 내용이 있다면 언제든지 알려주시기 바랍니다.
긴 글 읽어주셔서 감사합니다.
'Language > Java' 카테고리의 다른 글
[Java] Lambda, Stream API 심층분석 (0) | 2024.10.13 |
---|---|
[Java] 인터페이스 (Interface) + abstract class와의 차이 (1) | 2024.10.12 |
[Java] 추상 클래스 (Abstract class) (0) | 2024.08.22 |