제네릭(Generic)
제네릭(Generic)은 클래스 내부에서 사용할 데이터 타입을 외부에서 저장하는 기법을 의미한다. 객체별로 다른 타입의 자료가 저장될 수 있도록 한다.
1. 제네릭 클래스 기본문법 및 사용법
// 제네릭을 사용한 클래스 정의
class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
제네릭 클래스 `Box <T>`에서 사용하는 `<>` 기호는 다이아몬드(Diamond)라고 불린다. 제네릭을 사용한 클래스를 제네릭 클래스(Generic Class)라고 하며, 타입 매개변수를 지정하여 다양한 타입을 처리할 수 있도록 한다.
제네릭 클래스는 `Integer`, `String`과 같은 특정 타입을 미리 지정하지 않는다. 대신, 클래스명 오른쪽에 `<T>`와 같이 선언하며, 여기서 `T`는 타입 매개변수(Type Parameter)이다. 이 매개변수는 이후 `Integer`, `String` 등의 구체적인 타입으로 변경될 수 있다.
또한, 클래스 내부에서 `T`가 필요한 곳에 `T item`과 같이 타입 매개변수를 사용하면 된다.
🔹 실행코드
// 제네릭을 사용하는 예제
public class Main {
public static void main(String[] args) {
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");
System.out.println(stringBox.getItem()); // 출력: Hello
Box<Integer> intBox = new Box<>();
intBox.setItem(100);
System.out.println(intBox.getItem()); // 출력: 100
}
}
위 코드는 제네릭 클래스` Box<T>` 를 활용하여 서로 다른 타입(`String`과 `Integer`)의 데이터를 저장하고 가져오는 예제이다.
🔹 코드 설명
1. `Box<String> stringBox = new Box<>();`
- 제네릭 클래스 `Box<T>`의 `T`를 `String`으로 지정하여 `stringBox`를 생성한다.
- stringBox.setItem("Hello");를 통해 "Hello" 값을 저장한다.
- stringBox.getItem();을 호출하면 "Hello" 값이 반환된다.
2. `Box<Integer> intBox = new Box<>();`
- `T`를 `Integer`로 지정하여 `intBox`를 생성한다.
- `intBox.setItem(100);`을 통해 `100` 값을 저장한다.
- `intBox.getItem();`을 호출하면 `100` 값이 반환된다.
이렇게 하면 앞서 정의한 `Box`클래스의 `T`가 다음과 같이 지정된 타입으로 변환되어 생성된다고 생각하면 된다.
class Box<String> {
private String item;
public void setItem(String item) {
this.item = item;
}
public String getItem() {
return item;
}
}
2. 다이아몬드 연산자 (`<>`)와 타입 매개변수 구체화
다이아몬드 연산자(`<>`)는 자바 7부터 도입된 기능으로, 컴파일러가 타입을 자동 추론하도록 도와주는 기호를 의미한다. 객체 생성 시 생성자 부분의 타입 명시를 생략할 수 있다.
Box<String> stringBox = new Box<>(); // 컴파일러가 자동으로 <String>을 추론
타입 매개변수(`T`)는 제네릭 클래스, 인터페이스, 메서드에서 타입을 일반화하기 위해 사용하는 매개변수를 의미한다. 객체 생성 시 실제 타입(`String`, `Integer` 등)으로 대체된다.
대표적으로 다음과 같은 명칭을 사용한다.
- `T` (Type) : 일반적인 타입 매개변수
- `E` (Element) : 컬렉션에서 요소를 의미
- `K, V` (Key, Value) : 맵(Map) 형태에서 사용
- `N` (Number) : 숫자
- `S, U, V`: 2번째, 3번째, 4번째에 선언된 타입
✅ 구체화(Materialization)?
제네릭 클래스나 메서드는 타입을 일반적인 형태(T, E, K, V 등)로 선언한다. 이렇게 선언된 타입은 객체를 생성할 때 특정 타입으로 변환(구체화)된다.
즉, `T` 같은 타입 매개변수는 실제 사용된 타입(`String`, `Integer` 등)으로 대체된다.
📝 예제: 타입 파라미터의 구체화
제네릭 클래스 정의한 `Box`클래스와 실행코드이다.
// 제네릭 클래스 정의
class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
public class Main {
public static void main(String[] args) {
// 타입 파라미터의 구체화 (T → String)
Box<String> stringBox = new Box<>();
stringBox.setItem("Hello");
System.out.println(stringBox.getItem()); // 출력: Hello
// 타입 파라미터의 구체화 (T → Integer)
Box<Integer> intBox = new Box<>();
intBox.setItem(100);
System.out.println(intBox.getItem()); // 출력: 100
}
}
타입 매개변수의 구체화 과정을 다음과 같다.
1. `Box<String> stringBox = new Box<>();`
- 제네릭 클래스 `Box<T>`에서 `T`가 `String`으로 변환된다.
- 즉, 내부적으로 `T item;`은 `String item;`으로 바뀐다.
2. `Box<Integer> intBox = new Box<>();`
- `T`가 `Integer`로 변환된다.
- 내부적으로 `T item;`은 `Integer item;`으로 바뀐다.
✅ 타입 파라미터 할당 가능 타입
제네릭에서 할당받을 수 있는 타입은 Reference 타입뿐이다. 즉, `int`, `double`형 같은 자바 기본형 타입(Primitive Type)은 제네릭 타입으로 사용할 수 없다.
대신, 기본형 타입에 참조타입인 Wrapper클래스를 활용하면 된다.
// 기본 타입 int는 사용 불가
List<int> intList = new List<>();
// Wrapper 클래스로 넘겨주어야 한다. (내부에서 자동으로 언박싱되어 원시 타입으로 이용됨)
List<Integer> integerList = new List<>();
✅ 제네릭 타입과 다형성(Polymorphism)
제네릭 타입 매개변수(`T`)에 클래스 타입이 지정되면, 상속 관계를 통한 다형성이 그대로 적용된다.
즉, 부모 타입을 가진 제네릭 객체는 자식 클래스의 객체도 저장할 수 있다.
📝 예제: 다형성이 적용된 제네릭 클래스
import java.util.ArrayList;
import java.util.List;
// 부모 클래스
class Fruit { }
// 자식 클래스 (Fruit을 상속)
class Apple extends Fruit { }
class Banana extends Fruit { }
// 제네릭 클래스
class FruitBox<T> {
List<T> fruits = new ArrayList<>();
public void add(T fruit) {
fruits.add(fruit);
}
}
public class Main {
public static void main(String[] args) {
// T를 Fruit으로 지정 → Fruit 및 그 자식 클래스 사용 가능
FruitBox<Fruit> box = new FruitBox<>();
// 업캐스팅을 통해 자식 클래스 객체도 추가 가능
box.add(new Fruit()); // ✅ 부모 클래스 객체
box.add(new Apple()); // ✅ 자식 클래스 객체 (업캐스팅)
box.add(new Banana()); // ✅ 자식 클래스 객체 (업캐스팅)
}
}
- `FruitBox <Fruit>` → 제네릭 타입 `T`가 `Fruit`으로 지정됨
- `Apple extends Fruit`, `Banana extends Fruit` → 자식 클래스는 부모 타입으로 업캐스팅 가능
- `box.add(new Apple())` → `Apple`은 `Fruit`의 하위 타입이므로 업캐스팅이 발생하여 `FruitBox<Fruit>`에 저장 가능
✅ 그 외에도
복수 타입 파라미터와 같이 타입 지정을 여러 개도 선언이 가능하다.
import java.util.ArrayList;
import java.util.List;
class Apple {}
class Banana {}
class FruitBox<T, U> {
List<T> apples = new ArrayList<>();
List<U> bananas = new ArrayList<>();
public void add(T apple, U banana) {
apples.add(apple);
bananas.add(banana);
}
}
public class Main {
public static void main(String[] args) {
// 복수 제네릭 타입
FruitBox<Apple, Banana> box = new FruitBox<>();
box.add(new Apple(), new Banana());
box.add(new Apple(), new Banana());
}
}
제네릭의 장점
1. 타입 안정성(Type Safety)
제네릭을 사용하면 컴파일 시 타입을 체크할 수 있어, 잘못된 타입을 들어가는 것을 방지할 수 있다.
제네릭에 경우 자바 1.5에 추가된 스펙이다. 그래서 JDK 1.5 이전에서는 여러 타입을 다루기 위해서 아래와 같이 `Obejct` 타입을 사용하였다.
package generic.ex1;
public class ObjectBox {
private Object value;
public void set(Object object) {
this.value = object;
}
public Object get() {
return value;
}
}
제네릭을 사용하지 않고 `Object` 타입을 활용하면 모든 타입을 저장할 수 있지만, 이를 개발자가 직접 캐스팅해야 하므로 타입 안정성이 보장되지 않는다.
public class Main {
public static void main(String[] args) {
ObjectBox integerBox = new ObjectBox();
// 정수를 저장
integerBox.set(10);
Integer integer = (Integer) integerBox.get(); // Object -> Integer 다운캐스팅
System.out.println("integer = " + integer); // 출력: 10
// 잘못된 타입의 값 저장
integerBox.set("문자100"); // 문자열을 저장했지만...
// Integer로 변환 시도 → 런타임 오류 발생 (ClassCastException)
Integer result = (Integer) integerBox.get();
System.out.println("result = " + result);
}
}
하지만 다음 코드와 같이 제네릭을 활용한다면, 컴파일 단계에서 오류를 방지하여 안전한 코드 작성이 가능해진다.
Box<Integer> intBox = new Box<>();
intBox.setItem(100);
// intBox.setItem("100"); // 컴파일 오류가 발생한다.
System.out.println(intBox.getItem()); // 출력: 100
2. 코드의 재사용성(Reusability)
제네릭을 사용하면 하나의 클래스나 메서드를 여러 타입에 대해 재사용할 수 있다.
제네릭 미사용 시에는 다음과 같이 타입 별 중복 코드가 필요하다.
class StringPrinter {
private String data;
public void print(String data) {
System.out.println(data);
}
}
class IntegerPrinter {
private int data;
public void print(int data) {
System.out.println(data);
}
}
하지만 제네릭 사용 시 하나의 클래스에서 모든 타입이 처리 가능하다.
class Printer<T> {
public void print(T data) {
System.out.println(data);
}
}
public class Main {
public static void main(String[] args) {
Printer<String> stringPrinter = new Printer<>();
stringPrinter.print("Hello");
Printer<Integer> intPrinter = new Printer<>();
intPrinter.print(100);
}
}
중복 코드 제거 및 유지보수 용이성이 증가하게 된다.
3. 타입 변환(Casting) 불필요
제네릭을 사용하면 형변환(Casting)이 불필요하여 불필요한 비용과 코드 오류를 줄일 수 있다.
👉 제네릭 미사용 시 (형변환 필요)
ArrayList list = new ArrayList();
list.add("Hello");
String str = (String) list.get(0); // 직접 캐스팅 필요
👉 제네릭 사용 시 (형변환 불필요)
ArrayList<String> list = new ArrayList<>();
list.add("Hello");
String str = list.get(0); // 캐스팅 불필요
코드가 더 간결해지고 가독성이 증가한다.
정리
- 코드 중복 제거: 하나의 제네릭 클래스로 다양한 타입을 처리할 수 있어 중복 코드가 줄어든다.
- 컴파일 타임 타입 체크: 타입 오류를 조기에 발견할 수 있어 디버깅이 쉬워진다.
- 가독성 증가: 명확한 타입 명시로 코드 이해가 쉬워진다.
제네릭의 제약사항
1. 제네릭 타입의 객체는 생성이 불가
제네릭 타입 자체로 타입을 지정하여 객체를 생성하는 것은 불가능하다. 즉, `new` 연산자 뒤에 제네릭 타입 매개변수가 올 수 없다.
class Sample<T> {
public void someMethod() {
// Type parameter 'T' cannot be instantiated directly
T t = new T();
}
}
2. static 멤버에 제네릭 타입이 올 수 없다.
아래처럼 static 변수의 데이터 타입으로 제네릭 타입 메개변수가 올 수가 없다.
제네릭에 대해 생각해 보면 쉬운데, 제네릭은 기본적으로 외부에서 타입을 지정하는 것이다. 그러므로 제네릭(T)은 클래스가 인스턴스화될 때 타입이 결정된다. 하지만 static 멤버는 객체 생성과 무관하게 먼저 메모리에 올라간다.
static 변수나 메서드는 클래스 로딩 시점에서 메모리에 할당된다.
이때, 객체가 생성되기 전이므로 제네릭 타입이 결정되지 않아 사용이 불가능
class GenericClass<T> {
// ❌ 컴파일 에러: static 변수에는 제네릭 타입을 사용할 수 없음
static T staticValue;
// ❌ 컴파일 에러: static 메서드에서 제네릭 타입 사용 불가능
static void staticMethod(T value) {
System.out.println(value);
}
}
- staticValue는 클래스 로딩 시점에 메모리에 올라가지만, 이때 T의 구체적인 타입이 정해지지 않아 사용할 수 없음
- staticMethod(T value)도 마찬가지로, T가 정해지지 않아 컴파일 오류 발생
3. 제네릭으로 배열 선언 주의점
기본적으로 제네릭 클래스 자체를 배열로 만들 수 없다.
class Sample<T> {
}
public class Main {
public static void main(String[] args) {
Sample<Integer>[] arr1 = new Sample<>[10]; // ❌ 컴파일 오류 발생
}
}
🔹 문제 원인
위 코드에서 Sample<Integer>[] 배열을 생성하려고 하지만, 컴파일 오류가 발생한다.
이유는 제네릭의 타입 소거(Type Erasure)와 배열의 동작 방식이 충돌하기 때문이다.
🔹 타입 소거(Type Erasure)와 배열의 동작 원리
1. 배열(Array) 특징
- Java의 배열은 컴파일 타임에 타입이 보장되고, 런타임에도 타입 정보를 유지한다.
- 배열을 생성할 때, JVM은 내부적으로 배열의 실제 타입을 저장하여 타입 불일치(혼합 저장)를 방지한다.
2. 제네릭(Generic)의 특징
- Java의 제네릭은 타입 소거(Type Erasure)를 적용하여 런타임에는 `T`가 `Object`로 변환된다.
- 즉, 실제 실행 시점에는 `Sample<Integer>[]`가 `Sample[]`으로 취급된다.
3. 두 개념이 충돌하는 이유
- `Sample<Integer>[] arr1 = new Sample<>[10];` 에서 `Sample<Integer>[]`는 제네릭 타입 배열을 의미하지만,
런타임에는 `Sample[]` 배열이 생성되며 `Integer` 타입 정보가 유지되지 않는다. - 하지만 Java의 배열은 실제 타입을 엄격하게 검사하므로 타입이 일치하지 않는다고 판단하여 오류가 발생한다.
참고
'Language > Java' 카테고리의 다른 글
[Java] 콜바이 밸류(Call by Value)와 콜바이 레퍼런스(Call by Reference) (0) | 2025.02.17 |
---|---|
[Java] 스레드와 자바 메모리의 관계 (0) | 2025.02.05 |
[Java] 자바 예외 처리 - 예외 계층, 체크 예외, 언체크 예외, 예외의 규칙 (0) | 2025.01.31 |
[Java] Class 클래스 (0) | 2025.01.30 |
[Java] 클래스 로더 (Class Loader)와 로딩 과정 (1) | 2025.01.29 |