1. 제네릭이 왜 필요할까? (도입 배경)
- JDK 1.5 (Java 5) 이전에는 다양한 타입을 처리하기 위해 각 타입별로 클래스를 따로 만들거나, 모든 클래스의 최상위 부모인 Object 타입을 사용했지만 하지만 여기에는 아래의 두 가지 큰 문제가 있음
- 번거로운 형변환(Casting): 값을 꺼낼 때마다 원래 타입으로 강제 형변환을 해야 한다.
- 타입 안정성 부족: 실수로 잘못된 타입의 객체를 넣어도 컴파일 시점에는 알 수 없고, 프로그램이 실행될 때(Runtime) 에러(ClassCastException)가 발생해 프로그램이 멈추는 위험이 있음.
📊 제네릭이 해결한 코드 비교
// 예시 1: 제네릭이 없는 경우 (Raw 타입 사용)
ArrayList list = new ArrayList(); // 무엇이든 담을 수 있음
list.add("Hello");
list.add(123); // ⚠️ 컴파일러가 잡아내지 못함
String str = (String) list.get(0); // 무조건 형변환 필요!
String str2 = (String) list.get(1); // ❌ Runtime 에러 발생! (Integer는 String으로 변환 불가)
// 🛑 예시 2: 제네릭 사용하는 경우
ArrayList<String> list = new ArrayList<>(); // String만 담겠다고 선언!
list.add("Hello");
// list.add(123); -> ❌ 컴파일 에러 발생! (잘못된 타입을 사전에 차단)
String str = list.get(0); // 형변환이 필요 없음 (안전하고 편리함)
2. 제네릭의 기본 문법
- 제네릭은 보통 꺾쇠괄호(< >) 안에 타입 매개변수(Type Parameter)를 넣어서 표현하고, 실제로 사용하는 부분에서 타입 인자(Type Argument)를 넣어 제네릭 레벨에 전달한다.
- 제네릭 타입 매개변수는 내부에서만 사용할 독립적인 매개변수를 선언하겠다라는 뜻의 제네릭 기호임 (반환 타입과 별개)
- 제네릭 레벨은 클래스, 메서드(생성자 포함), 인터페이스 레벨이 존재한다. 유효 범위가 다 별개로 존재(모두 자신만의 꺾쇠괄호(< >) 영역을 가진 독립체).
- 클래스 레벨: 클래스 내부 전체(멤버 변수, 일반 메서드의 매개변수 및 반환 타입, 지역 변수도 가능!)에서 제네릭 타입을 공유하여 사용 가능.
- 메서드 레벨: 클래스 제네릭 타입과 무관하게 메서드 호출 시점에만 동적으로 타입을 지정받고 싶을 때 사용
- 생성자 레벨: 클래스 제네릭 타입과 무관하게, 인스턴스를 생성할 때 생성자 함수에만 독립적인 타입을 전달하고 싶을때 사용.
- 제네릭 선언부는 아래의 문법 구조를 따른다.
- 클래스 레벨: 접근제어자 class 클래스명<제네릭_선언부> { ... }
- 메서드 레벨: 접근제어자 <제네릭_선언부> 반환타입 메서드명(매개변수) { ... }
- 제네릭 생성자는 반환 타입만 제거
- 제네릭 호출 문법 구조 (타입 추론 안함)
- 클래스 레벨: new 클래스명<타입인자>(); 또는 new 클래스명<>();
- 클래스는 꺽쇠(<>)까지 생략하면 로우 타입으로 간주. 제네릭 문법 기능을 꺼버리기 때문에 특수한 하위 호환성 목적이 아니면 무조건 쓰기
- 메서드 레벨: 참조변수.<타입인자>메서드명(); 또는 참조변수.메서드명();
- 생성자 레벨: new <타입인자> 클래스명();(클래스는 제네릭 아님) 또는 new <타입1> 클래스명<타입2>(); 또는 new 클래스명<>();
- 타입1 = 생성자 제네릭 E, 타입2 = 클래스 제네릭 T
- 클래스 레벨: new 클래스명<타입인자>(); 또는 new 클래스명<>();
- new 연산자로 생성사 호출 구문에서 타입 인자는 생략 가능 (타입 추론)
// 제네릭 클래스 정의 (T는 아직 정해지지 않은 타입 변수)
public class Box<T> {
private T item;
// 제네릭 생성자
public <E> Box(E constructorInput) {
System.out.println("--- 제네릭 생성자 호출 ---");
System.out.println("생성자 입력 값: " + constructorInput);
}
public void setItem(T item) { this.item = item; }
public T getItem() { return item; }
// 제네릭 메서드
public <M> void printMethodInfo(M methodInput) {
System.out.println("--- 제네릭 메서드 호출 ---");
System.out.println("클래스의 제네릭 타입(T) 값: " + this.item);
System.out.println("메서드의 제네릭 타입(M) 값: " + methodInput);
}
}
// 사용 예시
public class Main {
public static void main(String[] args) {
// 제네릭 클래스 선언, 제네릭 생성자 호출
Box<String> stringBox = new Box<>(123); // T 자리에 String을 주입, E 자리엔 Integer
// 일반 메서드 호출 (클래스 제네릭 T 사용)
stringBox.setItem("사과");
String myItem = stringBox.getItem(); // 형변환 없음
Box<Integer> intBox = new Box<>(); // T 자리에 Integer를 주입
intBox.setItem(100);
// 제네릭 메서드 호출 (호출할 때마다 타입 동적 결정)
stringBox.printMethodInfo(5.5);
stringBox.printMethodInfo(true);
}
}
💡 자주 쓰는 제네릭 타입 표기법 (관례)
- T : Type (일반적인 타입)
- E : Element (컬렉션의 요소, 예: List<E>)
- K : Key (맵의 키, 예: Map<K, V>)
- V : Value (맵의 값)
- N : Number (숫자)
💡 제네릭의 유효 범위(Scope)클래스의 제네릭 타입 T는 클래스 내부 전역에서 유효하므로, 제네릭 메서드나 생성자의 파라미터 타입이나 내부 로직에서 그대로 가져다 쓸 수 있음.
단, 이름이 같으면 클래스의 타입을 덮어씀 (섀도잉: Shadowing). 메서드 내부의 T는 클래스의 T와 완전히 다른 독립적인 타입이 되며 클래스의 타입을 가려버림
ℹ️ 로우 타입은 클래스(타입)에만 존재하기 때문에 호출시 클래스의 제네릭 타입 외엔 꺽쇠까지도 생략 가능하다
3. 제네릭의 핵심 특징과 고급 문법
① 제한(기본규칙): 제한된 타입 매개변수 (Bounded Type Parameter)
아무 타입이나 다 받는 것이 아니라, 특정 클래스의 자식 클래스로만 타입을 제한(상한 제한) 하고 싶을 때 extends 키워드를 사용한다.
// 예시 1: Number 클래스 또는 그 하위 클래스(Integer, Double 등)만 타입으로 지정 가능
public class Calculator<T extends Number> {
public double add(T num1, T num2) {
return num1.doubleValue() + num2.doubleValue();
}
}
// 예시 2: T는 무조건 Comparable이라는 인터페이스를 구현한 클래스여야 한다!
public <T extends Comparable<T>> void sort(List<T> list) { ... }
② 확장(유연성): 비한정적 와일드카드 (Wildcard)
기본적으로 제네릭은 '무공변(Invariance)'적 특성을 가짐. 즉, List<Object>와 List<String>은 아무런 상속 관계가 없음. 이를 유연하게 다루기 위해 와일드카드(<?>)를 사용.
- 와일드카드 자체는 타입을 추론하지 않고, 오히려 타입을 추론하지 마라(미지수로 남겨둬라)고 컴파일러에게 명령하는 역할
- 어떤 타입이든 들어올 수 있지만, 컴파일 단계에서타입 검사는 철저히 하겠다라는 뜻 (아래 Raw 타입과 달리, 타입 안정성을 완벽하게 보장).
- 선언할 때는 추론을 안 하지만, 실제 메서드를 호출하거나 인자를 넘겨주는 시점에는 '캡처(Capture)'라는 타입 추론이 일어난다 (호출 시점에는 컴파일러가 타입을 알아내야만 함). 이 과정을 와일드카드 캡처라고 부르며, 타입 추론의 일종
- 주로 내부에 어떤 타입이 있는지 관심 없고, 리스트의 크기(size())를 구하거나 출력하는 등 타입에 의존하지 않는 로직을 짤 때 사용
- <?> : 제한 없음. 모든 타입이 가능.
- <? extends T> : 상한 제한. T와 그 자손 클래스만 가능. (주로 데이터를 읽을 때 안전)
- <? super T> : 하한 제한. T와 그 조상 클래스만 가능 (주로 데이터를 쓸 때 안전)
// 음식점(Restaurant) 클래스의 자식들만 파라미터로 받을 수 있는 메서드
public void printStoreName(List<? extends Restaurant> list) { ... }
③ 편의성(문법): 제네릭에서의 타입 추론 (다이아몬드 연산자 <>)
- 타입 추론 (Type Inference)이란? 컴파일러가 타입을 스스로 문맥상 알아채는 기능으로, 복잡한 타입의 선언 코드를 생략해서 코드 가독성을 높임.
- 제네릭 타입 추론: 처음은 메서드 제네릭 호출에 처음으로 도입. 원래는 양쪽에 모두 타입을 적는 것이 기본 문법(Box b = new Box(...);)이었으나 우변의 타입 인자는 빈 꺾쇠(<>, 다이아몬드 연산자)로 생략할 수 있게 됨. (Java 7+)
- 지역 변수 타입 추론(var): 자바 10부터는 일반 지역 변수에도 타입 추론을 할 수 있는 var 키워드가 도입됨.
- ⚠️ 주의사항: 여기서 var는 자바 스크립트의 let이나 var처럼 런타임에 바뀌는 가변 타입이 아님 컴파일 시점에 이미 타입이 굳어지는 것이기 때문에 선언과 동시에 반드시 초기화 필요. (var x; 처럼 선언만 하는 것은 불가능)
- 람다 표현식에서 매개변수 타입 생략: 자바 8에 도입된 람다식에서도 타입 추론이 적극적으로 활용됩니다. 함수형 인터페이스의 정의를 보고 매개변수의 타입을 생략할 수
- 지역 변수 타입 추론(var): 자바 10부터는 일반 지역 변수에도 타입 추론을 할 수 있는 var 키워드가 도임됨.
- ⚠️ 주의사항: 여기서 var는 자바 스크립트의 let이나 var처럼 런타임에 바뀌는 가변 타입이 아님 컴파일 시점에 이미 타입이 굳어지는 것이기 때문에 선언과 동시에 반드시 초기화 필요. (var x; 처럼 선언만 하는 것은 불가능)
- 💡 자바 컴파일러의 제네릭 타입 추론 과정:
- 좌변 확인: 변수 타입이 ConversionProcessor<String, Integer> 를 보고(타겟 타입 힌트), 컴파일러는 클래스의 U = String, T = Integer 임을 확정.
- 우변 다이아몬드 연산자(<>) 추론(클래스 제네릭(T) 결정): 좌변의 힌트로 우변은 자동으로 new ConversionProcessor<String, Integer>(...)로 추론.
- 생성자 파라미터 매칭 (생성자 제네릭(U) 추론):
- 생성자는 Converter<String, Integer> 인터페이스 구현체를 인자로 요구
- 즉, convert(String input) 구조를 가져야 하며 반환 타입은 Integer여야 함
- 메서드 참조 타입 추론 (인터페이스 제네릭(U, T) 결정)
- Integer::parseInt는 String을 받아 int(오토박싱되어 Integer)를 반환하는 메서드
- 요구하는 인터페이스 명세와 완전히 일치하므로, 컴파일러는 이를 Converter<String, Integer>의 구현체로 완벽하게 추론, 성공적으로 주입
public class ConversionProcessor<U, T> {
private final Converter<U, T> converter;
public ConversionProcessor(Converter<U, T> converter) { this.converter = converter; }
public T process(U input) { return converter.convert(input); }
}
@FunctionalInterface
public interface Converter<U, T> { T convert(U input); }
// 사용 예시
public class Main {
public static void main(String[] args) {
ConversionProcessor<String, Integer> processor = new ConversionProcessor<>(Integer::parseInt); //혹은 input -> Integer.parseInt(input)
// 실행 및 결과 확인
Integer result = processor.process("12345");
System.out.println("변환된 결과: " + result); // 출력: 12345
}
}
💡 즉, 타입 추론은 앞뒤 문맥, 즉 변수 선언, 메서드 인자 등의 주변 코드를 기반으로 생략된 타입을 유추하는 것.
ℹ️ 기본 데이터 타입과 래퍼 클래스 간의 변환을 자동화와는 다르다
오토박싱/언박싱 (Autoboxing/Unboxing): 기본 데이터 타입과 래퍼 클래스 간의 변환을 자동화
조작 대상: 실제 메모리에 들어가는 데이터 값(Value)
예시: 문자열/숫자 리터럴을 쓰면, 래핑하고 있는 인스턴스(String이나 Integer)에 대한 참조값을 반환함 (실제로는 JVM이 관리하는 힙 메모리 영역에 존재)
④ 작동원리: 타입 소거 (Type Erasure)
- 자바 컴파일러는 제네릭 코드의 하위 호환성(Java 1.4 이하 버전과의 호환)을 위해, 컴파일 시점에만 타입 검사를 수행하고, 컴파일이 완료되면(안전하다고 판단되면) 제네릭 타입을 제거(Object로 변경)한다.
- 이전에 컴파일된 파일(.class)들과, 제네릭이 도입된 이후의 파일들이 아무 문제 없이 섞여서 실행될 수 있도록 런타임(JVM) 시점에는 제네릭 정보를 완전히 없애버리는 것.
- 컴파일 할 때 제네릭 타입(<T>)을 지우고, 컴파일이 끝난 최종 결과물(.class 파일)에서는 가장 일반적인 Object 타입으로 변경한다. 즉, 컴파일이 끝나고 런타임(실행 시점)이 되면 힙 메모리에 있는 객체는 Box<String>이든 Box<Integer>든 상관없이 그냥 똑같은 Box 객체임.
- 자세히 말하자면 타입 제한이 없는 Object 기반의 Raw 타입 형태로 변환. (제네릭이 도입되기 이전의 코드처럼 동작하는 것)
- 즉, 하위 호환성을 위해 .class 파일로 변환될 때는 원래의 Object나 extends로 제한한 상위 타입으로 변환. 런타임에는 제네릭 정보가 남지 않는다는 특징이 있음.
ℹ️ 타입 검사랑 타입 소거는 다름
4. 제네릭 사용 시 주의점 (Raw 타입)
1) Raw 타입 (원시 타입)이란?
- 제네릭 타입 변수(예: <T>)를 선언할 때 타입 매개변수를 전혀 지정하지 않고 사용하는 것을 의미.
- 제네릭 틀은 받되, 타입 검사를 아예 안 하겠다는 의미 이기도 함. (제네릭의 타입 제한이 사라짐)
- 타입 소거와 마찬가지로, 제네릭이 없던 시절의 옛날 코드와 호환성을 유지하기 위해서 허용된다 (자바가 이전 버전과 호환성을 중시한다)
- 타입 안정성 떨어짐: 아무 데이터나 막 넣을 수 있어 위험. (값 넣는건 문제 없음)
- 런타임에 프로그램이 죽을 수 있음: 값을 가져다 쓰면 ClassCastException
- 컴파일 단계에서 경고를 띄어주긴 하지만, 현재엔 사용을 권장하지 않음. (정상적인 제네릭은 컴파일 단계에서 막음)
- 컴파일러가 내부적으로 타입을 모두 Object로 처리하기 때문에 값을 다시 꺼내서 쓸 때(get) 매번 수동으로 강제 형변환(Casting)을 해야 함.
// 정상적인 제네릭 사용 (타입 매개변수를 지정함)
List<String> list = new ArrayList<>();
// 🛑 Raw 타입 사용 (타입 매개변수가 없음!)
List rawList = new ArrayList();
2) 타입 소거와 Raw 타입 (예제 코드)
- 자바의 대입 연산자(`=`)는 객체 자체를 복사하는 것이 아니라 주소값을 복사. 따라서 box1이 새로운 객체로 복사되거나 타입이 변환되는 것이 아니라, box1과 rawBox는 힙 메모리에 있는 정확히 같은 객체(동일한 주소)를 가리키게 됨.
- `Box`와 `Box<String>`은 엄연히 다른 타입처럼 보이지만, 컴파일러가 제네릭 타입을 지워버리는 타입 소거 특성 덕분에 이러한 대입이 가능한 것. (단, 컴파일러가 위험하다는 경고를 띄움.)
Box rawBox = new Box(); // 타입을 지정하지 않은 Raw 타입
Box<String> box1 = rawBox; // 제네릭 타입 변수에 대입
3) Raw 타입과 와일드카드의 결정적인 차이
- 비슷해 보이지만 컴파일러가 조작을 허용하는 범위가 완전히 다름
- Raw 타입 사용: 제네릭 문법 무시 (타입 제한 무시)
- 와일드카드 사용: 어떤 타입 인지 모른 채로 제네릭 문법 유지
public static void main(String[] args) {
List<String> stringList = new ArrayList<>();
stringList.add("Apple");
// 예시 1: Raw 타입 리스트 변수에 주소값 대입 (허용됨)
List rawList = stringList;
// ⚠️ 컴파일러가 잡아내지 못함 (아무 데이터나 막 삽입 가능)
rawList.add(123);
rawList.add(new Date());
// 🛑 예시 2: 와일드카드 타입 리스트 변수에 대입
List<?> wildcardList = stringList;
// ❌ 컴파일 에러 발생! (새로운 데이터 삽입을 원천 차단)
// wildcardList.add(123); // 에러!
// wildcardList.add("Banana"); // 에러! (String조차도 불가능)
// 오직 null만 삽입할 수 있습니다.
wildcardList.add(null);
// 데이터 추가는 막히지만, 안전하게 '읽기(get)'는 가능합니다.
for (Object obj : wildcardList) {
System.out.println(obj); // 안전하게 꺼내서 Object로 활용 가능
}
}
5. 제네릭의 장점 요약
- 타입 안정성(Type Safety) 향상(타입 검사): 컴파일 시점에 잘못된 타입이 들어오는 것을 잡아내기 때문에, 프로그램 실행 중에 타입(잘못된 형변환)으로 인한 에러가 날 확률이 줄어듦.
- 형변환(Casting) 생략으로 코드 간결화: 컴파일러가 이미 어떤 타입인지 알고 있기 때문에, 값을 꺼낼 때마다 캐스팅 코드((String))를 작성할 필요가 없어 코드가 깔끔해지고 성능상 이점도 생김.
- 코드 재사용성 증가: 하나의 클래스나 메서드로 여러 데이터 타입을 처리할 수 있어, 중복 코드를 줄어듦
⚠️ 주의할 점: 제네릭의 타입 매개변수에는 int, double, char 같은 기본형(Primitive Type)은 사용 불가. 대신 Integer, Double, Character 같은 래퍼 클래스(Wrapper Class)를 사용해야 함.
'Language & Tool > Java' 카테고리의 다른 글
| ☕Java의 기본 개념과 컴파일 메커니즘 (0) | 2026.06.14 |
|---|---|
| ☕예외 처리(Exception Handling) (0) | 2026.06.11 |
| ☕Java 제어문 총정리 (+ 레이블) (0) | 2026.06.08 |
| ☕OOP 핵심: 클래스가 반드시 구현해야 하는 설계도를 정의하는 인터페이스(Interface) (0) | 2026.06.08 |
| ☕OOP 핵심: 부모 클래스를 재사용하여 새로운 클래스를 작성하는 상속(Inheritance) (0) | 2026.06.05 |