자바를 배우다 보면, 제네릭은 List, Set, Map 등을 사용할 때 <>을 보게 되고 제네릭을 사용하게 된다.
이번 시간에는 이 제네릭에 대해 공부하며, 마지막으로 왜 사용하는 지를 생각해보려고 한다.
제네릭( Generic )이란?
개념
"데이터의 타입을 일반화 한다"는 뜻으로, 클래스나 메서드 내부에서 사용할 데이터 타입을 미리 정해두지 않고, 외부에서 객체를 생성하거나 메서드를 호출할 때 지정해 주는 기능이다.
보통 <T>, <E>, <K>, <V>, <N>와 같은 기호를 사용한다.
제네릭의 필요성
제네릭은 라벨이 붙은 투명 상자와 같다고 볼 수 있다.
- 제네릭을 사용하지 않은 경우 (Object 사용)
- 불투명한 미스터리 상자로, 사과를 넣어도, 바나나를 넣어도 겉으로는 그냥 상자일 뿐이다.
- 돌맹이를 넣고 나중에 사과를 꺼낼 때, 이게 사과 맞나 하고 확인하고(형변환), 실수로 돌멩이를 사과인 줄 알 고 먹을 경우 이빨이 부러질 수 있다.(런타임 에러)
- 제네릭을 사용한 경우 (Box<Apple> 사용)
- 사과 전용이라는 라벨을 붙인 투명 상자가 된다.
- 마찬가지로 돌맹이를 넣을 경우, 사과 상자니까 돌멩이는 안된다며, 막아준다(컴파일 에러).
- 꺼낼 때도 당연히 사과가 나올 것을 알기 때문에 의심 없이 꺼내서 먹을 수 있다(형변환 불필요).
제네릭 타입 매개변수
타입 파라미터 정의
제네릭은 <> 꺽쇠 괄호인 다이아몬드 연산자를 사용한다.
이 꺽쇠 괄호 안에 앞서 소개한 기호인 <T>, <E>, <K>, <V>, <N>를 지정함으로써 파라미터화 할 수 있다.
이것을 마치 메서드가 매개변수를 받아 사용하는 것과 비슷하여 제네릭의 타입 매개변수(parameter) / 타입 변수라고 부른다.
타입 파라미터 기호 네이밍
일반적으로 영문 대문자 한 글자를 사용하며, 관례적으로 자주 사용되는 변수 이름이지 다른 영문자를 넣어도 무방하다.( for 문처럼 i, j, k처럼 ) 하지만, 특별한 이유가 없다면 통상적인 이름을 사용하는 것이 헷갈리지 않을 수 있다.
| 타입 | 설명 |
| <T> | 타입(Type) |
| <E> | 요소(Element) |
| <V> | 값(Value) |
| <N> | 숫자(Number) |
| <K> | 키(Key) |
List<T> // 타입 매개변수
List<String> stringList = new ArrayList<String>(); // 매개변수화된 타입
// 1.7 이후
List<String> stringList = new ArrayList<>(); //매개변수화된 타입
jdk 1.7버전 이후 new 생성자 부분에 제네릭 타입을 생략할 수 있다.
위의 예시를 코드로 보면,
// 사과
class Apple {
public String toString() { return "저는 사과입니다."; }
}
// 바나나
class Banana {
public String toString() { return "저는 바나나입니다."; }
}
사과와 바나나 객체를 생성한 후,
// 상자 제작
public class LabelledBox<T> {
private T item;
public void set(T item) {
this.item = item;
}
public T get() {
return item;
}
}
// 과일 넣기
public static void main(String[] args) {
// 1. 상자를 만들 때 "사과(Apple)상자"라고 라벨을 붙임
LabelledBox<Apple> appleBox = new LabelledBox<>();
// 2. 사과를 넣음 (성공)
appleBox.set(new Apple());
// 3. 실수로 바나나를 넣으려고 시도함 (실패)
// appleBox.set(new Banana()); // <-- 컴파일 에러
// 4. 꺼낼 때: 형변환이 필요 없음
Apple myApple = appleBox.get();
System.out.println(myApple); // "저는 사과입니다." 출력
}
박스에 제네릭을 추가하고 Apple이라는 라벨을 붙여 생성을 하면, 그 박스는 사과만 담을 수 있게 된다.
타입 파라미터 할당 가능 타입
제네릭은 참조 타입만 받을 수 있다.
자바 원시 타입인 int, double, long 등은 제네릭 타입 파라미터로 넘길 수 없다.
따라서 정수형, 실수형 등을 지정하고 싶다면, Wrapper 클래스를 사용하면 된다.
// [컴파일 에러 발생!]
// Type argument cannot be of primitive type
List<int> numberList = new ArrayList<>();
// 선언: int 대신 Integer 사용
List<Integer> numberList = new ArrayList<>();
(왜 컴파일 에러가 발생하냐면, 제네릭 T는 나중에 Object로 변환되어야 하는데, int는 Object가 아니라서 들어갈 수 없기 때문)
다형성
Integer처럼 Wrapper 클래스가 올 수 있다는 것은 클래스끼리 상속을 통해 관계를 맺는 다형성 원리를 그대로 적용할 수 있을 수 있다.
사과와 바나나는 과일에 상속을 받는다고 할 때,
// 과일 상자 클래스
class FruitBox<T> {
// T 타입의 리스트 생성
List<T> fruits = new ArrayList<>();
// T 타입의 객체를 받아서 저장
public void add(T fruit) {
fruits.add(fruit);
}
// 내용물을 꺼내서 확인할 수 있도록 리스트 반환
public List<T> getFruits() {
return fruits;
}
}
// 실행 (Main)
public class Main {
public static void main(String[] args) {
// 제네릭 타입 T를 'Fruit(부모)'로 지정된다.
// 이제 이 상자는 "모든 과일"을 담을 수 있는 상자
FruitBox<Fruit> box = new FruitBox<>();
// 제네릭 메서드의 다형성 적용
// add(T fruit) -> add(Fruit fruit)가 되었으므로,
// Fruit을 상속받은 모든 자식 객체(Apple, Banana)가 들어갈 수 있다.
box.add(new Fruit()); // 부모 본인
box.add(new Apple()); // 자식 1
box.add(new Banana()); // 자식 2
}
}
과일을 담을 수 있는 상자를 생성하면, 부모, 자식 모두 담을 수 있다.
제네릭을 사용하더라도 객체지향의 기본 원리인 다형성(Polymorphism)은 제네릭 타입 변수 T 안에서 그대로 작동한다는 것이다.
복수와 중첩
타입은 하나만 사용할 수 있는 것이 아니라 복수, 중첩이 가능하다.
복수를 사용할 경우, 꺽쇠 괄호 안에서 쉼표를 사용하여 <T, U>와 같은 형식을 통해 복수 타입 파라미터를 지정할 수 있으며, 클래스 초기화할 때 제네릭 타입을 두 개를 넘겨야 한다.
// K: Key (키), V: Value (값)
class Pair<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 Main {
public static void main(String[] args) {
// <이름(String), 나이(Integer)> 쌍으로 저장
Pair<String, Integer> p1 = new Pair<>("철수", 25);
String name = p1.getKey();
Integer age = p1.getValue();
System.out.println(name + "는 " + age + "살");
// <과목(String), 학점(Double)> 쌍으로 저장
Pair<String, Double> p2 = new Pair<>("자바", 4.5);
System.out.println(p2.getKey() + " 학점: " + p2.getValue());
}
}
중첩을 사용할 경우, <> 안에 또 다른 제네릭 타입이 들어가는 구조이다.
상자 안에 상자를 들어가는 형태라고 볼 수 있다.
import java.util.*;
public class Main {
public static void main(String[] args) {
// 구조: Map< 반이름(String), 학생명단(List<String>) >
// 해석: Key는 String이고, Value는 String을 담은 List이다.
Map<String, List<String>> schoolClass = new HashMap<>();
// 자바반 학생들 (List 생성)
List<String> javaStudents = new ArrayList<>();
javaStudents.add("철수");
javaStudents.add("영희");
// 파이썬반 학생들 (List 생성)
List<String> pythonStudents = new ArrayList<>();
pythonStudents.add("길동");
pythonStudents.add("둘리");
// 맵에 넣기 (반 이름 + 학생 리스트)
schoolClass.put("자바반", javaStudents);
schoolClass.put("파이썬반", pythonStudents);
// 꺼내서 쓰기
// 자바반을 꺼내면 List<String>이 나옴
List<String> list = schoolClass.get("자바반");
System.out.println("자바반 학생 수: " + list.size()); // 2명
System.out.println("첫 번째 학생: " + list.get(0)); // 철수
}
}
Map<String, List<String>> 처럼 껍질을 하나씩 벗겨가며 타입을 생각하면 쉽다.
- Map에서 Key("자바반")로 꺼내면, value인 List<String>이 나온다.
- 그 List에서 인덱스로 꺼내면, String인 "철수"가 나온다.
제네릭을 사용하는 이유
컴파일 타임에 타입 체크: 실행 중 터질 에러를 코드 짜는 순간 미리 잡아낸다.
public class BeforeGen {
public static void main(String[] args) {
// 제네릭 없이 생성 (모든 타입 수용 가능)
ArrayList list = new ArrayList();
list.add("홍길동");
list.add("김철수");
// 개발자가 실수로 숫자를 넣음
// 하지만 빨간 줄(에러)이 안 뜸. 컴파일러는 Object라서 넘어감.
list.add(100);
// 실행 중에 데이터를 꺼내다가 터짐
for (int i = 0; i < list.size(); i++) {
// 3번째(100)을 꺼낼 때 숫자를 String으로 바꾸려다 에러 발생
// 결과: Exception in thread "main" java.lang.ClassCastException
String name = (String) list.get(i);
System.out.println(name);
}
}
}
개발자가 문자열 대신 정수로 작성하여 넣을 경우에는 문제가 없지만. String로 변경할 경우 에러 발생한다.
형변환(Casting)의 번거로움
꺼낼 때마다 **"이거 String 맞지? 믿고 바꾼다?"**라고 명시해 줘야 합니다.
ArrayList list = new ArrayList();
list.add("사과");
list.add("바나나");
// 꺼낼 때마다 (String)을 붙여야 함
String apple = (String) list.get(0);
String banana = (String) list.get(1);
// 만약 리스트 안에 데이터가 100개라면? (String)만 100번 써야 함.
// 코드가 길어지고 읽기 힘듦.
제네릭의 장점과 단점
장점
- 타입의 안정성: 엉뚱한 타입의 객체가 들어오는 것을 원천 봉쇄한다.
- 코드 재사용성: 하나의 클래스로 String, Integer, Member 등 다양한 타입의 객체를 다루는 로직을 만들 수 있다.
단점
- 복잡성: 와일드카드(?) 상한/하한 제한(extends, super) 등 깊게 들어가면 문법이 어려워진다.
- 기본 타입( Primitive Type ) 사용 불가: int, double 등을 바로 사용할 수 없고 래퍼 클래스를 사용해야 한다.
실무에서 사용하는 부분
공동 응답 객체 (API Response Wrapper )
API를 만들 때 성공/실패 여부, 메시지, 그리고 실제 데이터를 담아서 보내는데, 실제 데이터는 Member일 수도, Product일 수도 있다.
public class ApiResponse<T> {
private String status;
private String message;
private T data; // 데이터 타입이 그때그때 달라짐
public ApiResponse(String status, String message, T data) {
this.status = status;
this.message = message;
this.data = data;
}
// Getter...
}
// 사용 (컨트롤러)
// 회원 정보 리턴 시
ApiResponse<MemberDto> response1 = new ApiResponse<>("success", "조회 성공", memberDto);
// 게시글 목록 리턴 시
ApiResponse<List<PostDto>> response2 = new ApiResponse<>("success", "조회 성공", postList);
컬렉션 프레임 워크
이미 쓰고 계신 List<String>, Map<String, Object> 등이 전부 제네릭이다.
스프링의 스프링의 ResponseEntity<T>, Optional<T>
스프링 내부 로직이나 DB 조회 결과(Optional)를 처리할 때도 제네릭이 필수이다.
제네릭 사용 시 주의할 점
타입 소거( Type Erasure )
- 자바 제네릭은 컴파일할 때만 타입을 체크하고, 실행될 때는 타입 정보를 지워버리고 Object 취급한다.(하위 호환성 때문)
- 그래서 if (obj instance of T) 같은 코드를 작성할 수 없다.
실행 시점엔 T 가 무엇인지를 알 수 없기 때문이다.
Static 멤버에 사용 불가
Static 필드나 메서드는 클래스가 로딩될 때 생성되는데, 제네릭 타입 T는 객체를 생성(new 생성자)할 때 정해지므로 시점이 맞지 않아 사용할 수 없다.
배열 생성 불가
new T[10]과 같은 제네릭 타입의 배열은 직접 생성할 수 없다.
면접 답변식 요약
제네릭은 클래스나 메서드에서 사용할 데이터 타입을 컴파일 시점에 미리 지정하는 기능입니다.
사용하는 주된 이유는 두 가지입니다. 첫째, 컴파일 타임에 강력한 타입 체크를 할 수 있어 런타임 에러를 방지하고 데이터의 안정성을 높여줍니다. 둘째, 불필요한 강제 형변환(Casting) 코드를 줄여 코드의 가독성을 높이고 재사용성을 극대화할 수 있기 때문입니다. 실무에서는 주로 List 같은 컬렉션을 다루거나, API 응답 객체(ApiResponse<T>)처럼 데이터를 감싸는 래퍼 클래스를 만들 때 자주 사용합니다.
'Daily Dev Q&A 정리 템플릿' 카테고리의 다른 글
| 25.12.12 프레임워크가 무엇이고 무엇을 사용했나요? 라는 질문에 대해 정리하기 (0) | 2025.12.14 |
|---|---|
| 25.12.11 자바의 빌더 패턴(Builder Pattern) (0) | 2025.12.11 |
| 25.12.09 자바의 접근 제어자(Access Modifier)에 대하여 (0) | 2025.12.09 |
| 25.12.08 자바의 Collections Framework에 대해 설명해보기 (0) | 2025.12.08 |
| 25.12.07 SOLID원칙에 대해 (1) | 2025.12.07 |