본문 바로가기
FullStack Survival/JAVA

[Java] Generics(제네릭)

by cloudhorison 2026. 8. 25.

1. 제네릭이란?

  • 제네릭은 클래스·인터페이스·메서드를 정의할 때 타입(클래스/인터페이스)을 파라미터로 사용하게 해준다.
  • 메서드의 formal 파라미터처럼, 타입 파라미터는 다른 입력으로 같은 코드를 재사용하는 방법이다.

결정적 차이

  • formal 파라미터의 입력은 값(value) — 타입 파라미터의 입력은 타입(type)

제네릭의 3가지 이점

  1. 컴파일 시점의 강한 타입 검사 — 타입 안전 위반을 컴파일러(컴파일 타임)가 오류로 잡음 (런타임보다 고치기 쉬움)
  2. 캐스트 제거 — 명시적 형변환 코드가 사라져 가독성·안전성 향상
  3. 제네릭 알고리즘 — 여러 타입의 컬렉션에서 동작하는 타입 안전한 알고리즘 구현 가능

1.1 캐스트 제거 (Before / After)

// 제네릭 없음 — 캐스팅 필요
List list = new ArrayList();  // Raw Type
list.add("hello");
String s = (String) list.get(0);   // 명시적 캐스트 -> 형변환

// 제네릭 사용 — 캐스트 불필요
List<String> list = new ArrayList<String>();
list.add("hello");
String s = list.get(0);            // no cast

리스트의 원소 타입을 컴파일러가 알기 때문에 캐스트가 사라지고, 엉뚱한 타입 add는 컴파일 오류가 된다.

 

2. 제네릭 타입

2.1 A Simple Box Class — 제네릭이 아닌 Box의 문제

public class Box {
    private Object object;

    public void set(Object object) { this.object = object; }
    public Object get() { return object; }
}

Box box = new Box();
box.set("Hello");
integer value = (Integer) box.get(); // 제네릭을 사용하면 이 코드에서 컴파일 에러 표시
int val = value.intValue(); // String이 아닌 Integer 메소드

모든 클래스의 최상위 부모 클래스인 Object 타입이라 String, Integer 같은 객체는 무엇이든 넣고 꺼낼 수 있음. (primitive 타입은 직접 Object에 들어가는 것이 아니라 오토박싱되어 wrapper 객체로 변환)

Object value = 100;
Object value = Integer.valueOf(100); // 실제 동작 - 객체 형태로 값이 저장
// 정상적으로 동작
Object value = 100;
Integer number = (Integer) value;

// 런타임 오류 
Object value = "100";
Integer number = (Integer) value; // ClassCastException

Integer 값을 기대하고 꺼냈지만 실제 객체는 String으로 객체 타입이 달라 강제 변환하려고 했기 때문에 런타임 오류가 발생. 위 오류는 컴파일 타임에 올바른 사용을 검증할 방법이 없어 이 문제를 해결하기 위해 제네릭이 등장

2.2 Generic Box<T> — 제네릭 버전

/**
 * Generic version of the Box class.
 * @param <T> the type of the value being boxed
 */
public class Box<T> {
    // T stands for "Type"
    private T t;   // t는 '타입 변수'

    public void set(T t) { this.t = t; }
    public T get() { return t; }
}

제네릭 클래스 정의 형식

  • class name<T1,...,Tn> — 클래스 이름 뒤 <> 안에 타입 파라미터 선언
  • 기존의 모든 Object가 T로 대체됨

타입 변수 T가 될 수 있는 것

  • 모든 클래스·인터페이스·배열 타입, 또는 다른 타입 파라미터 (단, non-primitive)

2.3 Object VS Generic — 런타임 vs 컴파일 타임 안전

Object Box는 에러가 런타임에 발생하고, 제네릭 Box<T>는 컴파일 시점에 오류를 발생시켜 미리 방지 가능

  • 컴파일 타임 : 프로그램을 실행하기 전에 자바 컴파일러가 코드를 검사하는 단계
  • 런타임 : 프로그램을 실제로 실행하는 단계

2.4 타입 파라미터 네이밍 관례

일반 변수 네이밍과 뚜렷이 구분하기 위해 단일 대문자를 사용.
그렇지 않으면 타입 파라미터와 클래스/인터페이스 이름을 구별하기 어려움

 

3. 호출·인스턴스화 · Diamond

3.1 제네릭 타입 호출과 인스턴스화

// 1) 타입 호출 — 참조 선언 (객체 생성 아님)
Box<Integer> integerBox;

// 2) 인스턴스화 — new 키워드, 이름과 () 사이에 <Integer>
Box<Integer> integerBox = new Box<Integer>();
  • 메서드 호출이 아규먼트(값)를 넘기듯, 여기서는 타입 아규먼트(Integer)를 Box 클래스에 전달
  • Box<Integer> 선언(대입 연산자 왼쪽에 선언)만으로는 객체 생성x — "Box of Integer" 참조를 가진다는 선언일 뿐이므로 new 키워드 사용
  • 제네릭 타입의 호출을 일반적으로 파라미터화된 타입(parameterized type)이라 부름

3.2 타입 파라미터 vs 타입 아규먼트

  • 클래스 정의에서 Foo<T>의 T는 타입 파라미터 (선언부)
  • Foo<String>의 String은 타입 아규먼트 (사용부) — 파라미터화된 타입을 만들기 위해 제공
  • Foo: sudo 코드, 가짜 코드 → 유명한 관습

3.3 다이아몬드 연산자 <> (Java SE 7+)

// SE 7 이전 — 양쪽에 타입 아규먼트 반복
Box<Integer> integerBox = new Box<Integer>();

// SE 7 이상 — 컴파일러가 추론(inference), 빈 <> = 다이아몬드
Box<Integer> integerBox = new Box<>();
  • 컴파일러가 컨텍스트(좌변 선언)로부터 타입 아규먼트를 추론할 수 있어 우변을 <>로 비움
  • 이 빈 한 쌍의 꺾쇠 <>를 비공식적으로 다이아몬드(diamond)라 부름
  • 보일러플레이트를 줄여 가독성 향상 — 실무 기본값

 

4. 다중 타입 파라미터 · 중첩

4.1 여러 타입 파라미터 Pair<K,V>

public interface Pair<K, V> {
    public K getKey();
    public V getValue();
}

// Pair<K, V>의 K, V는 Pair 제네릭 인터페이스의 타입 아규먼트가 됨
public class OrderedPair<K, V> implements Pair<K, V> {
    private K key;
    private V value;
    
    public OrderedPair(K key, V value) {
        this.key = key; this.value = value;
    }
    
    public K getKey()   { return key; }
    public V getValue() { return value; }
}

4.2 OrderedPair 인스턴스화 (오토박싱·다이아몬드)

// 명시적 타입 아규먼트
Pair<String, Integer> p1 = new OrderedPair<String, Integer>("Even", 8);
Pair<String, String>  p2 = new OrderedPair<String, String>("hello", "world");

// 다이아몬드로 단축 (컴파일러가 K, V 추론)
OrderedPair<String, Integer> p1 = new OrderedPair<>("Even", 8);
OrderedPair<String, String>  p2 = new OrderedPair<>("hello", "world");
  • K=String, V=Integer로 인스턴스화 — 생성자 파라미터 타입이 각각 String, Integer
  • 문자열 리터럴과 int 8 전달이 유효한 이유는 오토박싱(int → Integer) 덕분

4.3 타입 파라미터 자리에 파라미터화된 타입 넣기

// K, V의 타입 아규먼트로 Box<Integer> 같은 파라미터화된 타입을 넣을 수 있다
OrderedPair<String, Box<Integer>> p =
        new OrderedPair<>("primes", new Box<Integer>(...));
  • 타입 아규먼트는 또 다른 제네릭 타입(Box<Integer>)이 될 수 있다 — 중첩 가능
  • 앞서 "타입 변수가 될 수 있는 것"에 다른 타입 파라미터가 포함된 이유
  • 복잡한 타입 구조(예: Map<String, List<Order>>)를 타입 안전하게 표현

 

5. Raw 타입

5.1 Raw Type — 타입 아규먼트가 없는 제네릭

public class Box<T> {
    public void set(T t) { /* ... */ }
}

Box<Integer> intBox = new Box<>();   // 파라미터화된 타입
Box rawBox = new Box();              // raw 타입 (T 생략)
  • raw 타입 = 제네릭 클래스/인터페이스를 타입 아규먼트 없이 쓴 것
  • 제네릭이 아닌 클래스는 raw 타입이 아니다 (구분 주의)
  • JDK 5.0 이전 API 때문에 레거시 코드에 등장 — raw 타입은 사전(pre)-제네릭 동작

5.2 Raw VS Parameterized — 안전성 비교

raw 타입은 컴파일 타임에 컴파일러에 의한 타입 검사를 우회하므로 피하고, 파라미터화된 타입 사용 권

5.3 raw 타입 할당, 언제 경고가 뜨나

// 파라미터화 -> raw : 하위 호환을 위해 허용 (경고 없음)
Box<String> stringBox = new Box<>();
Box rawBox = stringBox;            // OK

// raw -> 파라미터화 : unchecked 변환 경고
// 컴파일러는 rawBox 안에 실제로 무엇이 들어 있는지 알 수 없음
Box rawBox = new Box();
Box<Integer> intBox = rawBox;      // warning: unchecked conversion

// raw 타입으로 제네릭 메서드 호출 : 경고
rawBox.set(8);                     // warning: unchecked invocation
  • 경고의 의미: raw 타입이 제네릭 타입 검사를 우회해 안전하지 않은 코드 포착을 런타임으로 연기
  • 참고: JPA 대표 구현체 Hibernate는 내부적으로 raw 타입을 사용하기도 함

5.4 unchecked 경고 다루기

public class WarningDemo {
    public static void main(String[] args){
        Box<Integer> bi;
        bi = createBox();
    }
    static Box createBox(){ return new Box(); }   // raw 반환
}
  • unchecked = 컴파일러가 타입 안전을 보장할 타입 정보가 부족하다는 뜻
  • 경고는 기본 비활성 — javac -Xlint:unchecked로 상세 확인
  • 완전 비활성: -Xlint:-unchecked, 지역 억제: @SuppressWarnings("unchecked")
compile error
→ 컴파일 자체를 막음

unchecked warning
→ 컴파일은 할 수 있지만 타입 안전을 보장할 수 없다고 경고

따라서 raw 타입은 컴파일러의 타입 검사를 약하게 만들어 버리는 기능이라고 이해

 

6. 제네릭 메서드

6.1 Generic Methods — 메서드가 자체 타입 파라미터를 도입

public class Util {
    public static <K, V> boolean compare(Pair<K, V> p1, Pair<K, V> p2) {
        return p1.getKey().equals(p2.getKey()) &&
               p1.getValue().equals(p2.getValue());
    }
}
  • 타입 파라미터의 범위는 그 메서드 내부로 한정
  • static · non-static 제네릭 메서드, 제네릭 생성자 모두 허용
  • 구문: 타입 파라미터 리스트 <K,V>를 리턴 타입 앞에 둔다 (static이면 static 다음)

6.2 제네릭 메서드 호출 (명시 vs 추론)

Pair<Integer, String> p1 = new Pair<>(1, "apple");
Pair<Integer, String> p2 = new Pair<>(2, "pear");

// 타입 아규먼트 명시
boolean same = Util.<Integer, String>compare(p1, p2);

// 생략 — 컴파일러가 추론 (Type Inference)
boolean same = Util.compare(p1, p2);
  • 타입 추론: <> 안에 타입을 적지 않고 일반 메서드처럼 호출 가능
  • 대부분 명시를 생략 — 컴파일러가 인자로부터 K, V를 추론

 

7. Bounded Type Parameters

7.1 Bounded Type Parameters — extends로 상한 지정

public class Box<T> {
    private T t;
    
    public void set(T t) { this.t = t; }
    public T get() { return t; }

    public <U extends Number> void inspect(U u){
        System.out.println("T: " + t.getClass().getName());
        System.out.println("U: " + u.getClass().getName());
    }
    
    public static void main(String[] args) {
        Box<Integer> integerBox = new Box<>();
        
        integerBox.set(10);
        integerBox.inspect("some text"); // 에러: String!
    }
}
  • 타입 파라미터 이름 뒤 extends로 상한 지정 — 여기서 extends는 상속 또는 인터페이스 구현(implements) 의미
  • <U extends Number>라 String 전달 시 컴파일 실패
  • 상한이란? 상속 계층 구조를 생각하면 됨. 상속 계층 구조에서 ancestor 클래스가 위에 위치함

7.2 상한 제한으로 그 타입의 메서드 호출 가능

public class NaturalNumber<T extends Integer> {
    private T n;
    public NaturalNumber(T n)  { this.n = n; }

    public boolean isEven() {
        return n.intValue() % 2 == 0;   // Integer의 intValue 호출 가능
    }
}
  • 타입을 제한하면 상한에 정의된 메서드를 타입 변수로 호출 가능
  • T extends Integer라 n으로 intValue() 호출 가능 (제한 없으면 Object 메서드만)

7.4 Mutiple Bounds — <T extends B1 & B2 & B3>

class A { /* ... */ }
interface B { /* ... */ }
interface C { /* ... */ }

class D <T extends A & B & C> { /* ... */ }   // OK

class D <T extends B & A & C> { /* ... */ }   // 컴파일 오류!
  • 여러 상한을 가진 타입 파라미터는 나열된 모든 타입의 하위 타입
  • 상한 중 클래스가 있으면 반드시 맨 앞 — 클래스 A를 B 뒤에 두면 컴파일 오류

7.5 Generic Algorithm — Problem ( > 연산자가 안 되는 이유)

public static <T> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    
    for (T e : anArray)
        if (e > elem)   // compiler error!
            ++count;
            
    return count;
}
  • > 연산자는 primitive(short, int, double, long, float, byte, char)에만 적용 가능
  • 객체 T는 >로 비교 불가 → 컴파일 오류. 해결책은 Comparable<T>로 bound

7.6 Solution — Comparable<T> (A)

public interface Comparable<T> {
    public int compareTo(T o);
}

// Integer는 Comparable<Integer>를 구현한다
public final class Integer extends Number
        implements Comparable<Integer>, Constable, ConstantDesc {
        
    public int compareTo(Integer anotherInteger) {
        return compare(this.value, anotherInteger.value);
    }
    
    public static int compare(int x, int y) {
        return (x < y) ? -1 : ((x == y) ? 0 : 1);
    }
}

7.7 Solution — Comparable<T> (B) — 수정된 countGreaterThan

public static <T extends Comparable<T>> int countGreaterThan(T[] anArray, T elem) {
    int count = 0;
    
    for (T e : anArray)
        if (e.compareTo(elem) > 0)   // > 대신 compareTo
            ++count;
            
    return count;
}
  • <T extends Comparable<T>> — T가 compareTo를 가진 타입으로 제한됨
  • e > elem을 e.compareTo(elem) > 0으로 교체 → 객체 비교가 타입 안전하게 동작

7.8 Run & Output — 여러 타입에서 동작하는 제네릭 알고리즘

Integer[] intArray = {1, 2, 3, 4, 5};
int intCount = countGreaterThan(intArray, 3);

String[] strArray = {"apple","banana","cherry","date"};
int strCount = countGreaterThan(strArray, "banana");

Double[] doubleArray = {1.1, 2.2, 3.3, 4.4, 5.5};
int doubleCount = countGreaterThan(doubleArray, 3.3);

출력 결과:

Number of elements greater than 3: 2
greater than "banana": 2
greater than 3.3: 2

같은 메서드 하나가 Integer·String·Double 배열에서 모두 동작 — 제네릭 알고리즘의 힘

 

8. 제네릭 · 상속 · 서브타입

8.1 일반 객체의 is-a 관계

Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger;        // OK (Integer is-a Object)

public void someMethod(Number n) { /* ... */ }
someMethod(new Integer(10));     // OK (Integer is-a Number)
someMethod(new Double(10.1));    // OK (Double  is-a Number)
  • 타입이 호환되면 한 타입 객체를 다른 타입에 할당 가능 — is-a 관계
  • 제네릭에서도 Box<Number>에는 Integer·Double을 add 가능

8.2 Box<Integer>는 Box<Number>의 하위 타입이 아니다

public void boxTest(Box<Number> n) { /* ... */ }

boxTest(new Box<Integer>());   // 컴파일 오류 — 허용 안 됨!
boxTest(new Box<Double>());    // 컴파일 오류 — 허용 안 됨!
  • Integer가 Number의 하위 타입이어도 Box<Integer>는 Box<Number>의 하위 타입이 아님
  • 제네릭 프로그래밍의 대표적 오해 — 반드시 익혀야 할 개념
  • 두 Concrete 타입 A, B가 관련 있어도 MyClass<A>와 MyClass<B>는 무관 (공통 부모는 Object)

8.3 타입 아규먼트가 같으면 서브타이핑은 유지된다

  • 서브타이핑(Subtyping)이란 "자식 타입을 부모 타입 자리에 대신 집어넣을 수 있는 성질"을 의미
  • ArrayList<E> implements List<E>, List<E> extends Collection<E>
  • 따라서 ArrayList<String> ⊂ List<String> ⊂ Collection<String> — 타입 아규먼트(<String>)를 바꾸지 않는 한 관계 유지

8.5 타입 파라미터를 추가하며 확장 (PayloadList)

interface PayloadList<E, P> extends List<E> {
    void setPayload(int index, P val);
    // ...
}

List<String>의 하위 타입이 되는 파라미터화들

  • PayloadList<String, String>
  • PayloadList<String, Integer>
  • PayloadList<String, Exception>

→ E(=String)를 유지하면 두 번째 파라미터 P가 무엇이든 List<String>의 하위 타입

 

SUMMARY 

타입 파라미터 값이 아니라 타입을 파라미터로 — 컴파일 타임 타입 검사 + 캐스트 제거
Box<T>·diamond class Box<T>로 선언, new Box<>()로 타입 추론. 파라미터화된 타입
raw 타입 타입 아규먼트 없는 제네릭. 타입 검사 우회 → unchecked 경고. 사용 자제
제네릭 메서드 리턴 타입 앞 <T>. 범위는 메서드 한정. 보통 타입 추론으로 호출
bounded <T extends X>로 상한 제한 → X의 메서드 호출 가능. 다중 bound는 클래스 먼저
상속·서브타입 Box<Integer> ≠ Box<Number>. 타입 아규먼트 고정 시에만 서브타이핑 유지

 

'FullStack Survival > JAVA' 카테고리의 다른 글

[Java] Generic_Type Inference  (0) 2026.08.27
[JAVA] Generic - in detail  (0) 2026.08.26
[Java] java base-1  (0) 2026.07.27