JAVA

Java 제네릭 기초 — 타입 안전성의 시작

IT Lab 2026. 2. 20. 10:00

Java 제네릭의 필요성, 기본 문법, 그리고 타입 소거(Type Erasure)가 의미하는 한계와 설계 포인트를 실무 관점에서 정리합니다.

 

List에 뭔가를 담아두고 꺼냈는데, 런타임에 ClassCastException이 터진 경험 있으실 거예요. 혹은 “이 리스트엔 User만 들어간다”는 규칙을 팀원끼리 말로만 합의해 두고, 어느 날 누군가 다른 타입을 넣어 장애가 난 적도 있습니다. 제네릭은 이런 문제를 “컴파일 시점”으로 끌어와서, 실수를 빨리 발견하게 해주는 장치입니다.

핵심 개념: Java 제네릭이 중요한 이유와 타입 소거(Type Erasure)

제네릭이 필요한 이유: 타입 안정성과 의도의 문서화

제네릭의 핵심 가치는 두 가지입니다.

  1. 타입 안전성(Type Safety)
    제네릭이 없던 시절(또는 raw type을 쓸 때)은 컬렉션에서 값을 꺼낼 때 매번 캐스팅이 필요했고, 잘못 캐스팅하면 문제는 런타임에야 드러납니다. 제네릭을 쓰면 “이 컬렉션엔 이 타입만”이라는 제약이 컴파일러 수준에서 강제됩니다.
  2. API 의도의 명확화
    List<User>는 그 자체로 문서입니다. 메서드 시그니처만 봐도 “User 목록을 받는구나”가 드러나고, IDE 자동완성과 리팩터링 품질도 좋아집니다. 비유하자면, 제네릭은 컨테이너에 붙는 라벨(내용물 타입 표시) 같은 역할을 합니다.

기본 문법: 가장 자주 쓰는 형태만 먼저 잡기

  • 클래스/인터페이스 제네릭: class Box<T> { ... }
  • 메서드 제네릭: static <T> T pick(T a, T b) { ... }
  • 타입 파라미터 관례: T(Type), E(Element), K/V(Key/Value), R(Result)

또 한 가지 중요한 포인트는 raw type(원시 타입) 사용을 피하는 것입니다. 예: List list = new ArrayList();
이렇게 쓰면 제네릭의 보호막이 사라져서, 경고만 남기고 런타임 예외 가능성이 다시 열립니다.

타입 소거(Type Erasure): “컴파일 때만” 강해지는 타입

컴파일 타임 제네릭 타입 정보가 바이트코드에서 소거되고 런타임에는 raw 타입만 남는 흐름도

Java 제네릭은 타입 소거(Type Erasure) 방식입니다. 즉, 컴파일러가 제네릭 정보를 활용해 타입 체크를 하고, 바이트코드로 갈 때는 대부분의 타입 파라미터 정보가 지워집니다(호환성 때문에 이렇게 설계되었습니다).

이게 실무에서 의미하는 바는 다음과 같습니다.

  • 런타임에는 List<String>List<Integer>둘 다 그냥 List처럼 취급됩니다.
  • 그래서 new T(), T.class, instanceof List<String> 같은 건 할 수 없습니다.
  • 대신 컴파일러가 필요한 캐스팅을 바이트코드에 삽입해 주고, 개발자는 “안전한 사용”을 할 수 있습니다.

아래 흐름으로 이해하면 가장 빠릅니다.

flowchart LR
  A["Java source: List<String>"] --> B["Compiler: type check + insert casts"]
  B --> C["Bytecode: List (type erased)"]
  C --> D["Runtime: only raw types exist"]

제네릭은 컴파일 타임 안전장치이고, 런타임에는 타입 파라미터가 지워진다는 점을 보여주는 흐름도입니다.

제네릭의 장점이 “컴파일 타임에 잡아준다”인 만큼, 타입 소거는 단점이라기보다 **Java의 선택(레거시 호환 + 성능/구현 복잡도 균형)**에 가깝습니다. 다만 이 특성 때문에, 런타임 타입이 꼭 필요하다면 Class<T>를 함께 받는 패턴(타입 토큰) 같은 우회가 필요해집니다.

한눈에 비교: raw type vs 제네릭

실제로 문제가 나는 지점을 빠르게 비교해보면 감이 옵니다.

구분 raw type (List) 제네릭 (List<User>)
타입 체크 시점 런타임에 터질 수 있음 컴파일 타임에 대부분 차단
캐스팅 필요 여부 필요(직접 캐스팅) 불필요(컴파일러가 보장)
IDE/리팩터링 힌트 부족 자동완성/리팩터링 정확도↑
대표 리스크 ClassCastException 타입 소거로 인한 런타임 제약

코드 예제: 기본 문법 + 타입 소거 한계까지 한 번에 체감하기 (Java 17)

아래 코드는 (1) raw type의 위험, (2) 제네릭 클래스/메서드 문법, (3) 타입 소거로 인해 런타임에서 제네릭 타입을 구분할 수 없는 점을 한 번에 보여줍니다. 그대로 복붙해서 실행해 보셔도 됩니다.

import java.util.ArrayList;
import java.util.List;

public class GenericsBasicsDemo {

    // 제네릭 클래스: Box<T>
    static class Box<T> {
        private final T value;

        Box(T value) {
            this.value = value;
        }

        T get() {
            return value;
        }
    }

    // 제네릭 메서드: <T>
    static <T> T first(List<T> list) {
        if (list.isEmpty()) throw new IllegalArgumentException("list is empty");
        return list.get(0);
    }

    // 타입 토큰 패턴: 런타임에 T 정보를 직접 알 수 없으니 Class<T>를 함께 받기
    static <T> T castOrThrow(Object value, Class<T> type) {
        return type.cast(value); // 내부적으로 런타임 타입 체크
    }

    public static void main(String[] args) {
        // 1) raw type의 위험: 컴파일 경고는 나지만, 런타임 예외는 막지 못함
        List raw = new ArrayList(); // raw type (권장하지 않음)
        raw.add("hello");
        raw.add(123); // 서로 다른 타입이 섞임

        try {
            String s = (String) raw.get(1); // 123을 String으로 캐스팅 -> 런타임 예외
            System.out.println("unreachable: " + s);
        } catch (ClassCastException e) {
            System.out.println("raw type runtime failure: " + e);
        }

        // 2) 제네릭 사용: 컴파일 타임에 섞이는 것을 차단
        List<String> strings = new ArrayList<>();
        strings.add("A");
        // strings.add(123); // 컴파일 에러: 타입 불일치

        String first = first(strings); // 캐스팅 불필요
        System.out.println("first(strings) = " + first);

        // 3) 제네릭 클래스 사용
        Box<Integer> intBox = new Box<>(10);
        Integer v = intBox.get();
        System.out.println("intBox.get() = " + v);

        // 4) 타입 소거 체감: 런타임에는 List<String>과 List<Integer>를 구분할 수 없음
        List<String> a = new ArrayList<>();
        List<Integer> b = new ArrayList<>();
        System.out.println("a.getClass() == b.getClass() ? " + (a.getClass() == b.getClass())); // true

        // 5) 타입 토큰으로 런타임 체크 보완
        Object unknown = "safe";
        String casted = castOrThrow(unknown, String.class);
        System.out.println("casted = " + casted);

        try {
            Object unknown2 = 42;
            String fail = castOrThrow(unknown2, String.class); // 여기서 예외
            System.out.println("unreachable: " + fail);
        } catch (ClassCastException e) {
            System.out.println("type token cast failure: " + e);
        }
    }
}

실무 팁

💡 실무에서는: raw type 경고를 “일단 무시”하지 말아보세요

  • List list 같은 raw type은 보통 “급하게 맞춘 코드”에서 시작해서, 시간이 지날수록 리팩터링 비용을 키웁니다.
  • 가능하면 컴파일 경고를 빌드에서 실패로 취급(예: Gradle/CI에서 warning 관리)하거나, 최소한 PR에서 raw type 경고는 리뷰 체크리스트에 넣어두는 게 효과적입니다.

💡 실무에서는: 타입 소거 때문에 “런타임 타입이 필요한 API”는 설계를 바꿔야 할 때가 많습니다

  • 예를 들어 JSON 역직렬화, 범용 캐스팅 유틸, DI 컨테이너 연동 같은 곳에서 “T를 알고 싶다”는 요구가 자주 나옵니다.
  • 이때는 Class<T>를 파라미터로 받는 방식(예: read(String json, Class<T> type))이나, 더 복잡한 케이스는 Type/ParameterizedType 기반 접근이 필요해질 수 있습니다. (이 부분은 와일드카드/제네릭 심화에서 자연스럽게 이어집니다.)

핵심 요약

  • 제네릭은 타입 안전성을 컴파일 타임으로 끌어와 런타임 예외를 줄입니다.
  • 기본 문법은 List<T>, class Box<T>, static <T> ... 세 가지 축으로 익히면 됩니다.
  • Java 제네릭은 타입 소거 기반이라 런타임에는 타입 파라미터가 사라진다는 한계를 꼭 염두에 둬야 합니다.

다음 글: #17 와일드카드 완전 정복