자바 개발자라면 List<Object>에 List<String>을 대입할 수 없다는 사실을 한 번쯤 마주하게 됩니다. "String은 Object의 자식이니까 당연히 되어야 하는 거 아니야?"라는 의문이 들 수 있죠. 오늘은 배열과 제네릭의 차이, 그리고 제네릭의 서브타입 관계에 대해 정리해 봅니다.
1. 기본 개념: Java의 타입 계층 구조
우선 자바의 기본적인 상속 관계를 짚고 넘어가겠습니다.
- Integer는 Number의 서브타입입니다 (Integer < Number).
- Number는 Object의 서브타입입니다 (Number < Object).
- 따라서 Integer는 Object의 서브타입이기도 합니다.
이 관계는 **Liskov Substitution Principle (리스코프 치환 원칙)**에 의해 자식 객체를 부모 타입 변수에 대입할 수 있게 해줍니다.
2. 배열의 공변성 (Covariance)
자바에서 **배열(Array)**은 '공변'합니다. 공변이란, **"Sub가 Super의 하위 타입이라면, Sub[]는 Super[]의 하위 타입이다"**라는 뜻입니다.
- Integer < Number 이므로, Integer[] < Number[] 관계가 성립합니다.
// 배열의 공변성 예시
Integer[] intArray = {1, 2, 3};
Number[] numArray = intArray; // OK! (업캐스팅)
왜 자바는 배열을 공변으로 만들었을까? 초기 자바(제네릭이 없던 시절)에서는 다형성을 지원하기 위해 어쩔 수 없는 선택이었습니다. 예를 들어, 모든 종류의 배열을 받아 내용을 섞거나 정렬하는 유틸리티 메서드(swap 등)를 만들려면, Object[] 같은 상위 타입으로 배열을 받을 수 있어야 했기 때문입니다.
치명적인 단점: 런타임 에러 배열의 공변성은 컴파일 시점에는 편하지만, 런타임에 심각한 버그를 초래할 수 있습니다.
numArray[0] = 15.9; // 컴파일 에러 없음! (Number 배열에 double 넣는 것은 문법상 허용)
// 하지만 실행 시: ArrayStoreException 발생!
실제 메모리에는 Integer만 담을 수 있는 공간인데, Double을 넣으려 하니 런타임에서야 터지는 것이죠. 버그는 컴파일 타임에 잡는 것이 가장 좋으므로, 이는 타입 안정성에 구멍이 있는 셈입니다.
3. 제네릭의 불공변성 (Invariance)
제네릭은 배열의 이러한 문제를 해결하기 위해 **불공변(Invariant)**으로 설계되었습니다. 즉, TypeA와 TypeB가 상속 관계여도, List<TypeA>와 List<TypeB>는 아무 관계가 없다는 것입니다.
List<Integer> intList = new ArrayList<>(List.of(1, 2, 3));
// List<Number> numList = intList; // 컴파일 에러! (불공변)
만약 이것이 허용된다면, 앞서 배열에서 겪었던 ArrayStoreException과 같은 상황(힙 오염)이 발생할 수 있기 때문에 컴파일러가 아예 막아버립니다.
4. 헷갈리는 포인트 정리 (Q&A)
작성 중 궁금해하셨던 부분을 명확히 정리해 드립니다.
Q1. List<String> strs = new ArrayList<String>(); 이건 공변인가요?
A. 아닙니다. 이것은 제네릭의 변성(공변/불공변) 개념이 아니라, 기본적인 클래스 상속(Inheritance) 개념입니다.
- Type Argument(타입 인자): < > 안에 들어가는 String을 의미합니다.
- 여기서는 <String>이라는 Type Argument가 동일하기 때문에, ArrayList가 List를 구현(implements)했다는 사실에 따라 자연스럽게 다형성이 적용된 것입니다.
- ArrayList<String>은 List<String>의 서브타입입니다.
Q2. Type Argument란 무엇인가요?
- List<String>에서 List는 Raw Type(또는 제네릭 타입), <String> 안에 있는 String이 바로 Type Argument입니다.
- 핵심: 제네릭에서 상속 관계(서브타입)가 성립하려면, Type Argument가 완전히 동일해야 합니다. (와일드카드 <?>를 쓰지 않는 한)
5. 제네릭 서브타입 심화 분석
첨부해주신 사진들은 **"Type Argument가 동일할 때, 클래스 간의 상속 관계가 어떻게 제네릭으로 연결되는가"**를 보여줍니다.
[이미지 1] 제네릭 타입의 상속 구조

이 그림은 **"껍데기(클래스/인터페이스)는 상속 관계를 따르지만, 알맹이(타입 인자)는 같아야 한다"**는 것을 보여줍니다.
- 구조: interface A<T> ⬅ interface B<U> ⬅ class C<V> ⬅ class D<W>
- 화살표를 따라가 보면, D는 C를 상속, C는 B를 구현, B는 A를 상속합니다.
- 이때 제네릭 타입 파라미터(T, U, V, W)를 그대로 물려주고 있습니다.
- 결과 (오른쪽 초록 박스):
- SameType이라는 똑같은 타입 인자를 넣었을 때만 이 상속 계층이 유효합니다.
- D<String>은 C<String>의 서브타입이고, A<String>의 서브타입이 됩니다.
- 만약 D<String>을 A<Integer>에 대입하려 한다면? ➡ 불가능합니다 (Type Argument 불일치).
[이미지2] 다중 타입 파라미터와 서브타입

이 그림은 **"여러 개의 제네릭 타입을 가진 클래스가 특정 인터페이스를 구현할 때의 관계"**를 설명합니다.
- 구조: class H<J, K> implements A<J>
- 클래스 H는 두 개의 제네릭(J, K)을 받지만, 인터페이스 A는 그중 J 하나만 사용합니다.
- 핵심 해석 (오른쪽 점선 박스):
- A<Long> a1 = new H<Long, Double>(); (O)
- H의 첫 번째 인자(J)가 Long입니다. A의 타입 인자(T)도 Long입니다. 일치하므로 OK.
- A<Long> a2 = new H<Long, String>(); (O)
- H의 두 번째 인자(K)가 String으로 바뀌었지만 상관없습니다. A와의 관계를 결정짓는 건 오직 첫 번째 인자(J)이기 때문입니다.
- A<Long> a4 = new H<String, Long>(); (X - 컴파일 에러)
- H의 첫 번째 인자(J)가 String입니다. 이는 A<String>이 되므로, 변수 타입인 A<Long>과 타입 인자가 불일치하여 대입할 수 없습니다.
- A<Long> a1 = new H<Long, Double>(); (O)
요약
- 배열은 공변(Covariant): Sub[]는 Super[]의 하위 타입이다. (런타임 에러 위험 있음)
- 제네릭은 불공변(Invariant): List<Sub>는 List<Super>와 아무 관계가 없다. (컴파일 타임 안전)
- 제네릭의 서브타입 조건:
- Raw Type(클래스/인터페이스) 간에 상속 관계가 있어야 한다. (예: ArrayList implements List)
- Type Argument(꺾쇠 안의 타입)가 정확히 일치해야 한다. (예: <String> == <String>)
- 이미지 2처럼 여러 타입 파라미터가 있을 경우, 부모 타입과 매핑되는 파라미터만 일치하면 된다.
* 유튜브 쉬운코드님의 강의를 듣고 정리한 내용입니다.
'자바 > 자바' 카테고리의 다른 글
| [Java] 제네릭 와일드카드 완벽 정리 (feat. 공변과 반공변) (1) | 2026.01.22 |
|---|---|
| (java) 패턴 매칭 instanceof 캐스팅 제거 (Flow Scoping 완벽 이해) (0) | 2026.01.20 |
| (자바) Apache Kafka 핵심 개념 (RabbitMQ <->Redis (Pub/Sub) <->Apache Kafka 차이 ) (0) | 2025.12.15 |
| [Java] I/O 스트림과 안전한 자원 관리: Try-with-Resources 완벽 분석 (Try-Catch-Finally 비교) (0) | 2025.12.04 |
| (JAVA) 함수형 인터페이스 완벽 정리: 람다부터 스트림까지의 연결고리 (0) | 2025.12.03 |