[Java] 제네릭의 거짓말, 그리고 HashMap.values()의 진짜 정체
안녕하세요. 오늘은 개발하다 보면 한 번쯤 마주치게 되는 Java 제네릭(Generics)의 허점과, 우리가 무심코 사용하는 HashMap의 내부 동작에 대해 깊게 파보려고 합니다.
최근 재미있는 코드를 하나 보게 되었습니다. “이게 에러가 안 난다고?” 싶은 코드인데, 막상 돌려보면 멀쩡히 돌아갑니다. 왜 그런지, 그리고 그 속에 숨겨진 함정은 무엇인지 살펴보겠습니다.
1. 제네릭은 런타임에 사라진다 (Type Erasure)
다음 코드를 한번 보시죠.
// 1. Map<String, String>을 담은 List 생성
List<Map<String, String>> list = new ArrayList<>();
Map<String, String> map = new HashMap<>();
map.put("a", "AA");
list.add(map);
// 2. 이걸 Object로 감싸고...
Map<String, Object> wrapper = new HashMap<>();
wrapper.put("vo", list);
// 3. 다시 꺼낼 때 전혀 다른 타입인 List<Vo>로 캐스팅!
List<Vo> voList = (List<Vo>) wrapper.get("vo");
// 4. 출력
System.out.println(voList);
// 결과: [{a=AA}] (에러 없음!)
List<Map>을 넣었는데 List<Vo>로 꺼내집니다. 심지어 ClassCastException도 발생하지 않습니다.
왜 에러가 안 날까?
범인은 바로 자바의 Type Erasure(타입 소거) 때문입니다.
우리가 코드에 적는 <String>, <Vo> 같은 제네릭 타입 정보는 컴파일 타임에만 존재합니다. 컴파일러가 유효성을 검사한 뒤, 바이트코드(.class)를 만들 때는 이 정보를 싹 지워버립니다.
즉, JVM이 코드를 실행할 때 보는 건 이렇습니다.
// JVM이 보는 세상
List voList = (List) wrapper.get("vo");
JVM 입장에서는 “List를 List로 바꾸네? OK 통과!” 하고 넘어가는 것이죠. 이것을 Heap Pollution(힙 오염)이라고 부릅니다.
시한폭탄과 같다
하지만 이건 성공한 게 아닙니다. 출력(println)만 했을 때는 내부의 toString()을 호출할 뿐이라 문제가 없었지만, 실제로 데이터를 꺼내는 순간 터집니다.
// 여기서 터짐!
Vo item = voList.get(0);
// java.lang.ClassCastException: class java.util.HashMap cannot be cast to class Vo
꺼낼 때 비로소 JVM이 “어? 이거 Vo라며? 왜 HashMap이 나와?” 하고 배신감을 표출하는 것이죠.
2. HashMap.values()는 List가 아니다
Map을 다루다 보면 값들만 뽑아서 리스트로 만들고 싶을 때가 있습니다. 흔히 이런 실수를 하곤 하죠.
Map<String, String> map = new HashMap<>();
// ... 데이터 put ...
// 컴파일 에러!
List<String> list = map.values();
// 런타임 에러! (강제 캐스팅 시)
List<String> list2 = (List<String>) map.values();
왜 안 될까?
이유는 단순하지만 중요합니다.
- 계층 구조의 문제:
map.values()의 반환 타입은Collection<V>입니다.List는Collection의 자식이지만, 부모(Collection)를 자식(List)에 그냥 담을 순 없습니다. - 구현체의 문제: “그럼 캐스팅하면 되지 않나?” 싶지만, 런타임 에러가 발생합니다.
map.values()가 리턴하는 객체는ArrayList같은 리스트 구현체가 아니기 때문입니다.
까보자, 구현체
실제로 getClass().getName()을 찍어보면 아주 낯선 녀석이 튀어나옵니다.
java.util.HashMap$Values
JDK 소스 코드를 열어보면 HashMap 안에 다음과 같은 내부 클래스(Inner Class)가 숨어 있습니다.
// JDK HashMap 소스 중 일부
final class Values extends AbstractCollection<V> {
public final Iterator<V> iterator() { return new ValueIterator(); }
public final int size() { return size; }
// ...
}
- List 아님:
AbstractCollection을 상속받았을 뿐,List인터페이스는 구현하지 않았습니다. 인덱스로 접근(.get(i))할 수 없다는 뜻입니다. - View 패턴: 이 녀석은 데이터를 따로 저장하지 않습니다. 원본
HashMap을 바라보는 창문(View) 역할만 합니다. 그래서map.values()로 얻은 컬렉션에서 아이템을 지우면 원본 맵에서도 지워집니다.
3. 결론 및 정리
오늘 내용을 요약하면 다음과 같습니다.
- 제네릭 맹신 금지:
(List<Vo>)처럼 제네릭을 포함한 강제 형변환은 런타임에 타입 체크가 무시됩니다. 컴파일 경고(Unchecked cast)를 무시하지 마세요. 데이터는 ObjectMapper 등을 통해 정석대로 변환해야 합니다. - values()는 List가 아니다:
map.values()는HashMap$Values라는 내부 전용 클래스를 반환합니다. 리스트로 쓰고 싶다면 생성자를 이용해 새로 만들어야 합니다.
// 올바른 사용법
List<String> list = new ArrayList<>(map.values());
자바의 내부 구현을 알면 “도대체 왜 안 돼?” 혹은 “이게 왜 돼?” 하는 상황에서 빠르게 원인을 파악할 수 있습니다.
4. 예시가 모두 포함된 전체 코드
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 타입 소거 (Type Erasure): 자바는 컴파일할 때만 제네릭 타입(예: <Vo>, <Map>)을 검사하고, 실행(Runtime) 시점에는 이 정보를 모두 지워버립니다.
* 형변환 성공: 실행 시점의 JVM은 리스트의 알맹이가 무엇인지 확인하지 않고, 그저 "이것이 List인가?"만 확인합니다. 따라서 Map이 담긴 리스트를 Vo 리스트로 강제 형변환해도 에러 없이 통과됩니다.
* 출력 결과: 데이터를 꺼내서 Vo로 사용하려던 것이 아니라, 단순히 리스트 자체를 출력(toString)했기 때문에 실제 들어있던 Map의 내용이 문자열로 출력된 것입니다.
* 결론: 이 코드는 **'성공한 것 처럼 보이는 시한폭탄'**입니다. 리스트에서 요소를 꺼내려는 순간(get) 바로 에러(ClassCastException)가 발생합니다.
*/
public class Main {
public static void main(String[] args) {
Map<String, String> vo = new HashMap<>();
vo.put("a", "AA");
// 컴파일은 되지만, 실행 시 에러 발생!
List<String> l = (List<String>) vo.values();
List<Map<String, String>> list = new ArrayList<>();
list.add(vo);
Map<String, Object> wapper = new HashMap<>();
wapper.put("vo", list);
// 타입 소거로 형병환 성공 BUT!
List<Vo> vo1 = (List<Vo>) wapper.get("vo");
System.out.println(vo1);
// Exception in thread "main" java.lang.ClassCastException: class java.util.HashMap cannot be cast to class Vo (java.util.HashMap is in module java.base of loader 'bootstrap'; Vo is in unnamed module of loader 'app') at Main.main(Main.java:23)
Vo realVo = vo1.get(0);
System.out.println(realVo);
}
}
class Vo {
private String a;
public String getA() {
return a;
}
public void setA(String a) {
this.a = a;
}
}