왜 List<String>은 List<Object>가 될 수 없을까? (제네릭과 와일드카드가 공존하는 이유)

2025. 5. 3. 14:35·언어/Java

🧩 공변(covariant)과 무공변(invariant)이란?

먼저 개념부터 짚고 가자.
공변이란 String이 Object의 하위 타입이라면,
List<String>도 List<Object>의 하위 타입으로 간주하는 성질이다.
하지만 자바 제네릭은 무공변이다.
즉, 타입 인자가 다르면 하위 타입으로 인정하지 않는다.

String extends Object              // ✅ OK
List<String> → List<Object>       // ❌ No

이 때문에 다음 코드도 허용되지 않는다.

List<Object> list = new ArrayList<String>(); // ❌ 컴파일 에러

🔒 왜 제네릭은 무공변으로 설계되었을까?

이유는 단 하나, 타입 안정성(type safety) 때문이다.
다음처럼 컴파일이 허용된다고 가정해보자.

List<Object> list = new ArrayList<String>();
list.add(123); // 문제 없어 보이지만...

list는 실제로 ArrayList<String>인데 외부에서는 Object로 보인다.
그래서 정수도 넣을 수 있고, 결국 런타임 오류로 이어질 수 있다.
이런 상황을 막기 위해 자바는 제네릭 타입을 무공변으로 설계했다.
타입이 다르면 아예 다른 타입으로 간주해 컴파일 타임에 차단하는 방식이다.


⚠️ 그런데 배열은 왜 가능한 걸까?

Object[] arr = new String[10]; // ✅ 컴파일 OK
arr[0] = 123;                  // ❌ ArrayStoreException (런타임 오류)

이 코드는 허용된다. 배열은 공변을 허용하기 때문이다.
하지만 잘못된 값을 넣으면 런타임에 JVM이 타입을 검사하고 오류를 발생시킨다.

이 차이는 다음에서 비롯된다.

항목 배열 제네릭
런타임에 타입 정보 유지 ✅ 있음 (String[]) ❌ 없음 (타입 소거됨)
타입 오류 감지 시점 런타임 (JVM이 검사) 컴파일 타임 (컴파일러가 검사)
잘못된 타입 저장 시 ArrayStoreException 발생 컴파일 자체가 안됨

배열은 타입 정보를 런타임에도 유지하기 때문에

JVM이 나중에라도 검사할 수 있다.
하지만 제네릭은 타입 소거(type erasure) 방식이기 때문에
런타임에는 타입 정보가 사라진다.
그래서 컴파일러가 처음부터 강하게 타입 검사를 해야만 안전하다.


🤔 실무에서는 너무 불편하지 않을까?

현실에선 다음과 같은 상황이 많다.

“리스트 내부 타입이 뭔지는 상관없고,
그냥 출력만 하면 된다.”

하지만 아래 코드는 컴파일되지 않는다.

public void printList(List<Object> list) { ... }

printList(new ArrayList<String>()); // ❌ 컴파일 에러

이때 등장하는 것이 와일드카드 <?>이다.


✅ 와일드카드로 유연하게 해결하자

public void printList(List<?> list) {
    for (Object obj : list) {
        System.out.println(obj);
    }
}

이제 List<String>, List<Integer>, List<Double> 등
모든 리스트 타입을 받아들일 수 있고, 읽기 전용으로 안전하게 사용할 수 있다.

  • 쓰기(add)는 제한되지만(컴파일 오류 발생),
  • 읽기만 필요한 경우라면 가장 유연하고 안전한 방법이다.

🧾 제네릭 vs 와일드카드 정리표

항목 제네릭<T> 와일드카드 , ? extends, ? super
타입 관계 무공변 (하위 타입 인정 안 함) 유연한 타입 허용
타입 정보 유지 ❌ 타입 소거 ❌ 타입 소거
읽기 가능 가능 (? extends)
쓰기 가능 제한적 (? super만 가능)
사용 위치 내부 구현 중심 외부 API 파라미터 수용 중심

🧵 마무리하며

자바는 제네릭에서 컴파일 타임 타입 안정성을 철저히 보장한다.
그 대가로 List<String>과 List<Object>를 자유롭게 오갈 수 없다.
반면 배열은 런타임 타입 정보를 유지하기 때문에 공변을 허용하지만,
잘못된 사용 시 런타임 예외가 발생한다.

  • 배열: JVM이 나중에 검사할 수 있으므로 허용
  • 제네릭: 런타임에 타입이 사라지므로 컴파일 타임에 반드시 검사

이런 제약을 해결하기 위해 도입된 것이 바로 와일드카드이다.

  • ? extends T: 읽기 전용
  • ? super T: 쓰기 전용
  • <?>: 타입은 모르지만 읽기만 한다면 OK

제네릭과 와일드카드는 서로 다른 역할을 가진 안전장치이며,
JVM의 철학이 반영된 도구들이다.

'언어 > Java' 카테고리의 다른 글

정적 중첩 클래스는 정말 필요할까?  (0) 2025.06.22
왜 불변 객체를 써야 할까?  (0) 2025.05.03
happens-before란 무엇인가?  (0) 2025.05.02
ABA 문제란? - CAS 에서 터질 수 있는 진짜 함정  (0) 2025.05.02
Executor 프레임워크2 - Executor 프레임워크가 등장한 이유  (0) 2025.04.30
'언어/Java' 카테고리의 다른 글
  • 정적 중첩 클래스는 정말 필요할까?
  • 왜 불변 객체를 써야 할까?
  • happens-before란 무엇인가?
  • ABA 문제란? - CAS 에서 터질 수 있는 진짜 함정
gugbab2
gugbab2
국밥과 커피를 사랑하는 개발자 gugbab2 입니다.
  • gugbab2
    개발하는 프로 국밥러
    gugbab2
  • 전체
    오늘
    어제
    • 분류 전체보기 (53)
      • 프로젝트 (5)
      • 생각정리 (0)
      • Backend (4)
        • 소소한 백엔드 개발 이야기 (3)
        • Spring (1)
        • JPA (0)
      • 언어 (13)
        • Java (13)
      • CS (17)
        • 네트워크 (17)
      • 아키텍처 (14)
        • OOP (14)
        • TDD (0)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    의존성
    개발
    리뷰
    하드웨어
    새해
    스위치
    언어
    비동기
    github
    MC-LAG
    개발블로그
    네트워크
    비전공
    설계
    책
    자바
    JWT
    객체지향
    타입
    객체
    동시성
    LACP
    Executor
    제네릭
    개발자
    오브젝트
    방화벽
    프로젝트
    공부하자
    토큰
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
gugbab2
왜 List<String>은 List<Object>가 될 수 없을까? (제네릭과 와일드카드가 공존하는 이유)
상단으로

티스토리툴바