제네릭(Generic)은 컴파일 시 타입 체크를 강화하여 타입 안전성을 제공하는 자바의 기능입니다. 제네릭을 사용하면 클래스, 인터페이스, 메서드가 다양한 타입을 처리할 수 있게 됩니다. 이를 통해 코드의 재사용성을 높이고, 잘못된 타입 사용을 컴파일 시점에서 방지할 수 있습니다.
정리하면, 타입을 유연하게 처리하며, 잘못된 타입 사용으로 발생할 수 있는 런타임 타입 에러를 컴파일 과정에서 검출하기 위해 사용하는 기능입니다.
제네릭이 도입되기 전에는, 모든 타입을 담을 수 있는 Object 타입을 이용하여 데이터를 처리했습니다. 컬렉션 예시로, ArrayList 같은 자료 구조에 타입 제한이 없었고, 어떤 객체든 추가할 수 있었습니다. 하지만 데이터를 추출할 때마다 명시적으로 타입 캐스팅이 필요했으며, 캐스팅 과정에서 잘못된 타입으로 변환할 경우 런타임 에러가 발생했습니다.
import java.util.ArrayList;
public class NonGenericExample {
public static void main(String[] args) {
ArrayList list = new ArrayList(); // 타입을 지정하지 않음
list.add("Hello"); // String 추가
list.add(123); // Integer 추가
String str = (String) list.get(0); // 형 변환 필요
System.out.println(str);
String numStr = (String) list.get(1); // ClassCastException 발생 (런타임 에러)
}
}
위 코드에서 list.add(123)로 정수(Integer)가 추가되었지만, String으로 잘못된 캐스팅을 시도하면서 런타임 에러가 발생합니다. 이는 코드 작성 시 문제가 드러나지 않고, 프로그램을 실행했을 때만 알 수 있기 때문에 매우 위험한 방식입니다.
이러한 문제를 해결하고자 JDK 1.5에서 제네릭이 도입되었습니다. 제네릭을 사용하면, 컬렉션이나 메서드에 구체적인 타입을 지정할 수 있으며, 이로 인해 다음과 같은 이점을 얻을 수 있습니다.
import java.util.ArrayList;
public class GenericExample {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>(); // String 타입 지정
list.add("Hello");
// list.add(123); // 컴파일 에러: Integer는 추가할 수 없음
String str = list.get(0); // 형 변환 필요 없음
System.out.println(str);
}
}
이 코드는 컴파일 시점에 타입 오류를 감지하며, 형변환이 불필요해지고 코드가 안전해집니다.
제네릭의 가장 큰 장점은 컴파일 시점에 타입을 미리 확인할 수 있다는 점입니다. 제네릭을 사용하면, 개발자가 잘못된 타입을 사용했을 경우 컴파일러가 이를 즉시 경고하거나 에러를 발생시킵니다. 따라서 런타임 에러로 이어질 수 있는 타입 불일치 문제를 미리 방지할 수 있습니다.
import java.util.ArrayList;
public class GenericExample {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
list.add(123); // 컴파일 에러: Integer는 허용되지 않음
}
}
위 예제에서 컴파일 시점에 잘못된 타입(Integer)을 추가하려고 하면 컴파일러가 오류를 발생시켜 프로그램 실행 전에 오류를 수정할 수 있게 해줍니다. 이는 제네릭의 도입 이전과 비교해 안정성을 대폭 강화한 것입니다.
제네릭과 관련된 중요한 개념으로는 공변(covariant)과 불공변(invariant)이 있습니다.
자바에서 배열은 공변(covariant)입니다. 이는 하위 타입의 배열이 상위 타입의 배열로 대체될 수 있음을 의미합니다.
Integer[] integers = {1, 2, 3};
Object[] objects = integers; // Integer[]는 Object[]의 하위 타입
위 코드에서 Integer[] 배열은 Object[] 배열로 참조할 수 있습니다. 이는 공변이기 때문에 가능하며, 배열의 타입 간 상속 관계가 그대로 적용되기 때문입니다.
public class ArrayCovarianceExample {
public static void main(String[] args) {
Integer[] integers = new Integer[]{1, 2, 3}; // Integer 배열 생성
printArray(integers); // Object 배열을 받는 메서드에 Integer 배열 전달
}
// Object[]를 인수로 받는 메서드
public static void printArray(Object[] arr) {
for (Object e : arr) {
System.out.println(e);
}
}
}
/*
출력 결과
1
2
3
*/
배열은 공변이기 때문에, 하위 타입의 배열을 상위 타입으로 받을 수 있으며, 이를 활용해 메서드에서도 하위 타입의 배열을 상위 타입으로 처리할 수 있습니다. 예를 들어, 배열의 요소를 출력하는 메서드를 작성할 때 Object[] 배열을 인수로 받는 메서드에 Integer[]를 전달할 수 있습니다.
이것이 가능한 이유는 배열이 공변이기 때문입니다. Integer는 Object의 하위 타입이며, Integer[] 역시 Object[]의 하위 타입으로 간주되어 타입 변환이 가능합니다.
반면에, 제네릭은 불공변(invariant)입니다. 즉, 하위 타입의 제네릭 타입이 상위 타입의 제네릭 타입으로 대체될 수 없습니다. List<Integer>는 List<Object>의 하위 타입이 아닙니다. 제네릭 타입은 서로 독립적이며, 상속 관계가 적용되지 않습니다.
List<Integer> integerList = Arrays.asList(1, 2, 3);
List<Object> objectList = integerList; // 컴파일 에러: 제네릭은 불공변
위 코드에서는 List<Integer>가 List<Object>로 대체될 수 없기 때문에 컴파일 에러가 발생합니다. 제네릭은 불공변이므로, 타입 간의 상속 관계가 제네릭 타입 간에는 적용되지 않습니다.
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class GenericInvariantExample {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 컴파일 에러 발생
}
// Object 타입의 컬렉션을 받는 메서드
public static void printCollection(Collection<Object> c) {
for (Object e : c) {
System.out.println(e);
}
}
}
/*
에러 발생
error: incompatible types: List<Integer> cannot be converted to Collection<Object>
*/
제네릭은 불공변이기 때문에, 컬렉션을 다루는 메서드에서 상위 타입을 허용하도록 선언하더라도, 하위 타입의 제네릭을 전달할 수 없습니다. 코드를 보면, List<Integer>를 Collection<Object>를 받는 메서드에 전달하려고 하면 컴파일 에러가 발생합니다.
컴파일 에러가 발생하는 이유는 List<Integer>는 Collection<Object>의 하위 타입이 아니기 때문입니다.
배열과는 다르게, 제네릭은 타입 안전성을 보장하기 위해 불공변성을 채택했습니다. 제네릭이 불공변이어야 잘못된 타입 사용을 막을 수 있습니다. 만약 List<Integer>가 List<Object>의 하위 타입으로 간주되었다면, Object 타입의 객체(예: String)를 List<Integer>에 추가할 수 있었을 것입니다. 이는 타입 안전성을 해치고, 런타임 에러를 초래할 수 있습니다.
List<Integer> integerList = Arrays.asList(1, 2, 3);
List<Object> objectList = integerList; // 만약 허용된다면...
objectList.add("String"); // 타입 불일치 문제 발생
Integer num = integerList.get(0); // Integer로 캐스팅 시 런타임 에러
위 코드에서, objectList.add("String")과 같은 코드가 허용되면 정수 리스트에 문자열을 추가할 수 있습니다. 그러나 리스트의 원소가 Integer 타입이라고 가정했기 때문에 런타임 시 타입 오류가 발생하게 됩니다.
제네릭이 도입되기 전에는 타입 안정성이 없어서 런타임 시 에러가 발생할 수 있었습니다. 제네릭이 도입됨으로써 컴파일 시점에 타입 체크가 가능해졌지만, 제네릭이 불공변이기 때문에 특정 타입의 컬렉션을 모두 처리하는 메서드를 정의할 때 제한이 생겼습니다.
이 문제를 해결하기 위해 와일드카드(?)가 등장했습니다.
와일드카드는 제네릭의 타입 불일치 문제를 해결하기 위한 방편으로, 제네릭이 불공변인 상황에서 타입 유연성을 제공하여 상속 관계를 활용한 타입 처리를 가능하게 합니다.
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class WildcardExample {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3);
printCollection(list); // 와일드카드를 사용하면 정상 동작
}
// 와일드카드 ? 를 사용하여 모든 타입 허용
public static void printCollection(Collection<?> c) {
for (Object e : c) {
System.out.println(e);
}
}
}
위 코드에서는 와일드카드(?)를 사용하여 타입 유연성을 높였으며, List<Integer>를 Collection<?>로 처리할 수 있게 되었습니다. 와일드카드를 통해 타입 안정성을 유지하면서 제네릭의 유연성을 확보할 수 있습니다.
와일드카드의 더 자세한 내용은 다음 [JAVA] 시리즈인 와일드카드 파트에서 설명하겠습니다.
제네릭은 클래스, 인터페이스, 메서드에 적용할 수 있습니다. 제네릭을 사용하면 클래스, 인터페이스, 메서드에서 타입을 유연하게 처리할 수 있고, 구체적인 타입은 사용 시점에 결정됩니다.
<T>와 같은 형식으로 타입 매개변수를 선언합니다.<T> 형식으로 타입 매개변수를 선언합니다.<T>와 같은 형식으로 타입 매개변수를 선언합니다.제네릭은 동시에 여러 개의 타입 매개변수를 선언할 수 있습니다. 여러 타입을 선언할 때는 콤마(,)로 구분합니다.
class Pair<K, V> { // K와 V 두 개의 타입 매개변수를 사용
private K key;
private V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
public class MultipleGenericsExample {
public static void main(String[] args) {
Pair<String, Integer> pair = new Pair<>("Age", 25); // String, Integer로 타입 지정
System.out.println("Key: " + pair.getKey() + ", Value: " + pair.getValue());
}
}
Pair<K, V> 클래스는 두 개의 타입 매개변수를 받아들이며, 이를 통해 두 개의 서로 다른 타입을 처리할 수 있습니다.자바 제네릭에서는 와일드카드(?)를 사용하여 타입을 유연하게 처리할 수 있습니다. 와일드카드는 알 수 없는 타입을 의미하며, 상한 제한이나 하한 제한을 지정할 수 있습니다.
<? extends T>는 T의 하위 클래스만 허용합니다.<? super T>는 T의 상위 클래스만 허용합니다.import java.util.ArrayList;
import java.util.List;
public class WildcardExample {
// 상한 제한: Number와 그 하위 클래스만 허용
public static void printNumbers(List<? extends Number> list) {
for (Number num : list) {
System.out.println(num);
}
}
public static void main(String[] args) {
List<Integer> intList = new ArrayList<>();
intList.add(1);
intList.add(2);
intList.add(3);
printNumbers(intList); // Integer는 Number의 하위 클래스이므로 허용
}
}
List<? extends Number>는 Number와 그 하위 클래스만 허용하는 제네릭 리스트입니다.자바 제네릭을 사용할 때 자주 사용되는 타입 매개변수에 대한 표준화된 약어들이 있습니다. 이는 자바 개발자들 사이에서 관습적으로 많이 사용되며, 의미를 쉽게 이해할 수 있도록 돕습니다.
| 타입 인자 | 의미 |
|---|---|
T | Type: 일반적으로 임의의 타입을 나타낼 때 사용합니다. |
E | Element: 컬렉션과 같은 자료구조의 요소를 나타낼 때 사용합니다. |
K | Key: 맵(Map)의 키를 나타낼 때 사용합니다. |
V | Value: 맵(Map)의 값을 나타낼 때 사용합니다. |
N | Number: 숫자를 나타낼 때 사용합니다. |
이러한 약어를 사용하면 코드를 읽을 때 타입 매개변수의 의미를 쉽게 파악할 수 있습니다. 제네릭 클래스나 메서드를 정의할 때, 적절한 이름을 사용하면 코드 가독성을 높이는 데 도움이 됩니다.
제네릭 클래스(Generic Class)는 타입 매개변수(Type Parameter)를 사용하여 여러 타입을 처리할 수 있는 클래스를 정의하는 방법입니다. 제네릭 클래스를 사용하면 특정 타입에 종속되지 않고, 클래스 정의 시에는 구체적인 타입을 명시하지 않은 채 구현할 수 있으며, 클래스의 인스턴스를 만들 때 타입을 명시하여 사용할 수 있습니다.
제네릭 클래스는 클래스 이름 뒤에 꺽쇠(<>)를 사용해 타입 매개변수를 선언하여 정의됩니다. 자바에서는 관습적으로 대문자 알파벳 한 글자로 타입 매개변수를 선언하는 것이 일반적이며, 보통 T(Type), E(Element), K(Key), V(Value) 등이 사용됩니다.
class ClassName<T> { // 여기서 T는 타입 매개변수
private T field; // T 타입의 필드를 선언
public void setField(T field) { // T 타입의 파라미터를 받는 메서드
this.field = field;
}
public T getField() { // T 타입을 반환하는 메서드
return field;
}
}
T는 타입 매개변수로, 구체적인 타입으로 대체될 예정입니다. 실제로 이 클래스를 사용할 때는 타입을 명시해야 합니다.
// T는 사용자가 지정하는 타입을 나타냄
class Box<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
}
public class GenericClassExample {
public static void main(String[] args) {
// Box 클래스에 String 타입을 지정
Box<String> stringBox = new Box<>(); // T가 String으로 대체됨
stringBox.set("Hello");
System.out.println("String Box: " + stringBox.get());
// Box 클래스에 Integer 타입을 지정
Box<Integer> integerBox = new Box<>(); // T가 Integer로 대체됨
integerBox.set(123);
System.out.println("Integer Box: " + integerBox.get());
}
}
Box<T>는 제네릭 클래스입니다. T는 타입 매개변수로, 사용자가 클래스를 사용할 때 원하는 타입으로 대체됩니다.Box<String>: T가 String으로 대체되어, 문자열만 저장할 수 있는 박스가 됩니다.Box<Integer>: T가 Integer로 대체되어, 정수만 저장할 수 있는 박스가 됩니다.이 예시를 통해 제네릭 클래스가 다양한 타입을 안전하게 처리하면서 코드의 재사용성을 높일 수 있음을 알 수 있습니다.
제네릭 인터페이스(Generic Interface)는 인터페이스 선언에 타입 매개변수를 지정하여 타입에 유연한 인터페이스를 정의하는 방법입니다. 제네릭 인터페이스를 사용하면 다양한 타입에 대해 동일한 동작을 제공하는 재사용 가능한 인터페이스를 만들 수 있습니다.
제네릭 인터페이스는 인터페이스 이름 뒤에 꺽쇠(<>)를 사용하여 타입 매개변수를 선언합니다. 제네릭 인터페이스를 구현하는 클래스는 구체적인 타입을 명시하거나 제네릭 클래스로 구현할 수 있습니다.
interface DataStore<T> { // T는 제네릭 타입 매개변수
void save(T data); // T 타입 데이터를 저장하는 메서드
T load(); // T 타입 데이터를 로드하는 메서드
}
(1) 구체적인 타입을 지정하는 경우
인터페이스를 구현할 때 구체적인 타입을 지정하여 제네릭 인터페이스를 사용할 수 있습니다.
// String 타입을 사용하는 DataStore 구현
class StringDataStore implements DataStore<String> {
private String data;
@Override
public void save(String data) {
this.data = data;
}
@Override
public String load() {
return data;
}
}
public class GenericInterfaceExample {
public static void main(String[] args) {
DataStore<String> store = new StringDataStore(); // String 타입 명시
store.save("Hello World");
System.out.println(store.load()); // "Hello World" 출력
}
}
(2) 제네릭 클래스로 구현하는 경우
또는 제네릭 인터페이스를 제네릭 클래스로 구현하여 더 유연한 처리를 할 수 있습니다.
// 제네릭 클래스로 DataStore 구현
class GenericDataStore<T> implements DataStore<T> {
private T data;
@Override
public void save(T data) {
this.data = data;
}
@Override
public T load() {
return data;
}
}
public class GenericInterfaceExample2 {
public static void main(String[] args) {
DataStore<Integer> intStore = new GenericDataStore<>(); // Integer 타입 명시
intStore.save(123);
System.out.println(intStore.load()); // 123 출력
DataStore<String> stringStore = new GenericDataStore<>(); // String 타입 명시
stringStore.save("Hello Generics");
System.out.println(stringStore.load()); // "Hello Generics" 출력
}
}
제네릭 메서드(Generic Method)는 메서드 자체에서 타입 매개변수를 선언하여 다양한 타입을 처리할 수 있는 메서드를 말합니다. 제네릭 메서드는 클래스가 제네릭일 필요는 없으며, 메서드의 타입 매개변수를 독립적으로 정의할 수 있습니다.
제네릭 메서드는 메서드 리턴 타입 앞에 타입 매개변수를 명시합니다. 이렇게 하면 해당 메서드에서 사용할 수 있는 타입을 유연하게 지정할 수 있습니다.
class ClassName {
// 제네릭 메서드 선언, <T>는 이 메서드에서 사용할 타입 매개변수
public <T> void printArray(T[] array) {
for (T element : array) {
System.out.println(element);
}
}
}
<T>는 이 메서드의 타입 매개변수입니다. 이 메서드는 호출될 때 T가 구체적인 타입으로 대체됩니다.public class GenericMethodExample {
// <T>를 통해 메서드가 여러 타입을 처리할 수 있게 정의
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}; // Integer 타입 배열
String[] strArray = {"A", "B", "C"}; // String 타입 배열
// Integer 타입 배열 출력
// 1
// 2
// 3
// 4
printArray(intArray); // T가 Integer로 대체됨
// String 타입 배열 출력
// A
// B
// C
printArray(strArray); // T가 String으로 대체됨
}
}
<T>는 타입 매개변수이며, 컴파일 시점에 타입이 결정됩니다.printArray 메서드는 다양한 타입의 배열을 처리할 수 있습니다. 이 예제에서 intArray는 Integer[] 타입으로, strArray는 String[] 타입으로 호출됩니다.제네릭에서 타입 제한을 통해 특정 타입이나 그 상위/하위 타입만 허용할 수 있습니다. 이를 통해 제네릭을 사용할 때 타입 안정성을 유지하면서도, 더 구체적인 타입을 요구할 수 있습니다. 이때 제네릭 타입 제한은 클래스 선언부에서 직접 설정할 수도 있고, 와일드카드(?)를 이용해 메서드에서 유연하게 처리할 수도 있습니다.
상한 제한은 T extends 클래스로 지정하여 특정 클래스와 그 하위 클래스만 허용하는 방식입니다. 즉, 제네릭 타입 파라미터가 특정 클래스 또는 그 하위 클래스여야 한다는 조건을 설정할 수 있습니다.
class NumberBox<T extends Number> { // T는 Number 또는 그 하위 클래스만 허용
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
public class BoundedTypeExample {
public static void main(String[] args) {
NumberBox<Integer> intBox = new NumberBox<>(); // Integer는 Number의 서브클래스
intBox.set(100);
System.out.println(intBox.get());
// NumberBox<String> strBox = new NumberBox<>(); // 컴파일 에러: String은 Number의 서브클래스가 아님
}
}
T extends Number는 Number 클래스의 하위 클래스만 제네릭 타입으로 사용할 수 있다는 것을 의미합니다. 따라서 Integer, Double, Float 같은 숫자 타입만 허용됩니다.NumberBox<String>과 같은 잘못된 타입 선언을 방지할 수 있습니다.하한 제한은 와일드카드(? super T)를 통해 특정 타입과 그 상위 타입만 허용하는 방식입니다. 이는 주로 객체를 추가하거나 수정하는 메서드에서 사용되며, 입력이나 추가 작업이 필요할 때 유용합니다.
하한 제한을 사용하면, 특정 타입뿐만 아니라 그 상위 클래스에서도 안전하게 값을 추가할 수 있습니다.
import java.util.List;
import java.util.ArrayList;
public class LowerBoundedWildcardExample {
// Integer의 상위 클래스인 Number나 Object만 허용
public static void addNumbers(List<? super Integer> list) {
list.add(1); // Integer 타입 값 추가 가능
list.add(2);
}
public static void main(String[] args) {
List<Number> numberList = new ArrayList<>(); // Number는 Integer의 상위 타입
addNumbers(numberList); // Number 타입 리스트에 Integer 추가 가능
System.out.println(numberList); // [1, 2] 출력
List<Object> objectList = new ArrayList<>(); // Object는 Integer의 상위 타입
addNumbers(objectList); // Object 타입 리스트에도 Integer 추가 가능
System.out.println(objectList); // [1, 2] 출력
}
}
<? super Integer>는 Integer와 그 상위 클래스(예: Number, Object)만 허용하는 제네릭 리스트입니다.Number나 Object와 같은 상위 타입 리스트에도 안전하게 값을 추가할 수 있습니다.하한 제한은 주로 와일드카드(? super T)를 사용하여 메서드나 제네릭 클래스에서 상위 클래스까지도 허용하고 싶을 때 사용됩니다. 즉, 상위 클래스와의 유연한 타입 처리를 지원합니다.
<? extends T> 형태로, 특정 클래스와 그 하위 클래스만 허용합니다.<? super T> 형태로, 특정 클래스와 그 상위 클래스만 허용합니다.하한 제한은 제네릭 메서드에서 와일드카드를 통해 사용되므로, 더 자세한 설명은 와일드카드 파트에서 설명하겠습니다.
T extends Number는 제네릭 클래스 선언 시 특정 타입 제한을 명확히 지정하는 것입니다.? extends T) 또는 하한(? super T)을 통해 메서드의 파라미터 타입을 유연하게 처리할 수 있습니다. 와일드카드는 제네릭 클래스나 메서드 호출 시점에 입력 파라미터의 타입을 제한하는 방식으로 사용됩니다.