자바 제네릭 기초

2025-08-26 4 min read

1. Generics

먼저 자바에 제네릭이 도입되기 전 리스트를 어떻게 사용했는지 보면 제네릭의 유용함을 볼 수 있다.

List ints = Arrays.asList(
    new Integer[] {
        new Integer(1), new Integer(2), new Integer(3),
    }
);

int s = 0;
for(Iterator it = ints.iterator(); it.hasNext();){
    int n = ((Integer)it.next()).intValue();
    s += n;
}

assert s == 6;

제네릭이 없을 떄는 List 안에 어떤 타입이 들어오는지 컴파일러는 알지 못하므로 프로그래머가 직접 작성해줘야 한다.

제네릭을 사용하면 다음과 같이 작성할 수 있다.

List<Integer> ints = Arrays.asList(1, 2, 3);

int s = 0;
for(int n: ints){
    s += n;
}

assert s == 6;

자바 제네릭과 컬렉션을 사용하면 읽기도 편하고 컴파일러가 타입을 관리하기 때문에 프로그래머의 일이 줄어들었다.

자바 제네릭을 선언하는 방법은 다음과 같다.

class name<T1, T2, ..., Tn> { /* ... */ }

< >안에 T1, T2, ...는 타입 파라미터 (타입 변수)라고 한다.

1.1 Erasure

자바의 제네릭은 타입 소거(Erasure) 방식을 사용한다. List<Integer>, List<Number>, List<List<String>>은 런타임엔 모두 같은 타입인 List로 변환된다. 따라서 자바에서는 List<Integer>같은 타입은 없기 때문에 a instanceof List<Integer> 와 같은 타입 검사는 불가능 하다.

그렇다면 컴파일러는 어떤식으로 List의 원소들의 타입을 알 수 있을까?

컴파일러는 컴파일할 때 타입 정보를 보고 필요한 곳에 캐스트를 하여 타입 안전성을 보장한다.

List<String> strs = new ArrayList<>();
String str = strs.get(0);

바이트 코드 관점에서는 위 코드와 아래 코드는 같다.

List strs = new ArrayList();
String str = (String)strs.get(0);

해당 코드의 .class를 확인해보면 checkcast로 String인지 확인하는 것을 볼 수 있다. 만약 실패한다면 ClassCastException이 발행한다.

이와 같이 제네릭을 구현한 이유는 만들 당시 기존 자바 코드의 호환성을 위해 이렇게 만들었다고 한다.

2. Boxing과 Unboxing

박싱은 primitive 타입을 reference 타입으로 변경하는 것을 말하고, 언박싱은 그 반대로 하는 것을 말한다. 여기서 primitive 타입은 int, double, char 과 같은 것이고 reference 타입은 class, interface, array와 같이 Object의 서브타입이다. 모든 primitive 타입은 해당되는 reference 타입이 있다.

자바는 필요할때 박싱과 언박싱을 자동으로 한다.

// 코드 1
List<Integer> ints = new ArrayList<Integer>();
ints.add(1);
int n = ints.get(0);

// 코드 2
List<Integer> ints = new ArrayList<Integer>();
ints.add(Integer.valueOf(1));
int n = ints.get(0).intValue();

// 코드 1과 코드 2는 같은 코드이다.

다음 코드는 정수 리스트의 합을 구하는 두 함수이다.

public static int sumPrimitive(List<Integer> ints) {
    int s = 0;
    for(int n: ints) { 
        s += n;
    }
    return s;
}

public static Integer sumInteger(List<Integer> ints){
    Integer s = 0;
    for(Integer n: ints) {
        s += n;
    }
    return s;
}

sumInteger 함수에서 반복문 내 s와 n에서 자동으로 박싱과 언박싱이 일어나므로 성능상으로 차이점이 있다. 어느정도 차이가 나는지 확인하기 위해서 다음과 같은 코드를 작성해서 테스트 해보았다.

import java.util.*;

public class Main {
    static Integer sumInteger(List<Integer> ints) {
        Integer s = 0;
        for (Integer n : ints) {
            s += n;
        }
        return s;
    }

    static int sumPrimitive(List<Integer> ints) {
        int s = 0;
        for (Integer n : ints) {
            s += n;
        }
        return s;
    }

    static long time(Runnable r, int N) {
        long best = Long.MAX_VALUE;
        for (int i=0; i<N; i++) {
            long t0 = System.nanoTime();
            r.run();
            long t1 = System.nanoTime();
            best = Math.min(best, t1 - t0);
        }
        return best;
    }

    public static void main(String[] args) {
        int N = 9000000;
        List<Integer> xs = new ArrayList<>(N);
        for (int i=0; i<N; i++) xs.add(i & 1234);

        long tA = time(() -> { sumInteger(xs); }, 5);
        long tB = time(() -> { sumPrimitive(xs); }, 5);

        System.out.printf("sumInteger: %,d ms%n", tA/1000000);
        System.out.printf("sumPrimitive: %,d ms%n", tB/1000000);
    }
}

결과는 다음과 같이 약 7배 차이가 난다.

sumInteger: 21 ms
sumPrimitive: 3 ms

3. 제네릭 메소드

class List {
    public static <T> List<T> toList(T... arr) {
        List<T> list = new ArrayList<T>();
        for(T e: list) {
            list.add(e);
        }
        return list;
    }
}

자바에서는 <T>로 타입 변수를 선언하고 이를 메소드에서도 사용할 수 있다. 또 필요한 캐스트를 컴파일러가 자동으로 해준다. 예시로, 아래와 같이 선언해서 사용할 수 있다.

List<Integer> ints = Arrays.toList(1,2,3);

컴파일러가 암묵적으로 해당 메소드에 타입을 추가하고 이를 타입 추론(Type inference)라고 한다.

List<Integer> ints = Arrays.<Integer>toList(1,2,3);

여기서는 컴파일러가 T를 Integer로 자동으로 추론하게 되면 위 코드와 유사하게 될 것이다.

Reference

© 2025 Not Cooper Blog. All rights reserved.