• Home
  • About
    • lahuman photo

      lahuman

      열심히 사는 아저씨

    • Learn More
    • Facebook
    • LinkedIn
    • Github
  • Posts
    • All Posts
    • All Tags
  • Projects

말도 안되는 코드를 만났다.

21 Jan 2026

Reading time ~4 minutes

[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();

왜 안 될까?

이유는 단순하지만 중요합니다.

  1. 계층 구조의 문제: map.values()의 반환 타입은 Collection<V>입니다. List는 Collection의 자식이지만, 부모(Collection)를 자식(List)에 그냥 담을 순 없습니다.
  2. 구현체의 문제: “그럼 캐스팅하면 되지 않나?” 싶지만, 런타임 에러가 발생합니다. 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. 결론 및 정리

오늘 내용을 요약하면 다음과 같습니다.

  1. 제네릭 맹신 금지: (List<Vo>) 처럼 제네릭을 포함한 강제 형변환은 런타임에 타입 체크가 무시됩니다. 컴파일 경고(Unchecked cast)를 무시하지 마세요. 데이터는 ObjectMapper 등을 통해 정석대로 변환해야 합니다.
  2. 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;
    }

}


javacastexception Share Tweet +1