본문 바로가기
Daily Dev Q&A 정리 템플릿

25.12.10 자바에 제네릭을 왜 사용할까?

by teg0 2025. 12. 10.

자바를 배우다 보면, 제네릭은 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>> 처럼 껍질을 하나씩 벗겨가며 타입을 생각하면 쉽다.

  1. Map에서 Key("자바반")로 꺼내면, value인 List<String>이 나온다.
  2. 그 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>)처럼 데이터를 감싸는 래퍼 클래스를 만들 때 자주 사용합니다.