HashMap을 비롯한 자료구조들은 Serializable 을 구현한다.
Serializable은 구식이고 문제가 많아 JSON으로 넘어가는 추세인데 왜 아직도 Serializable을 구현하는 걸까?
Java의 HashMap이 여전히 Serializable을 구현하는 이유
1. 역사적 배경: "그때는 그게 최선이었습니다" (1998년의 상황)
HashMap은 Java 1.2 버전(1998년)에 처음 등장했습니다. 그 당시 자바(Sun Microsystems)가 꿈꾸던 세상은 지금과 달랐습니다.
- 당시의 비전: "모든 컴퓨터가 자바 가상 머신(JVM) 위에서 돌아가고, 네트워크로 연결된 자바 객체들이 서로 통신(RMI, EJB)하는 세상."
- 필수 기능: 자바 객체끼리 통신하려면 객체를 바로 전송할 수 있어야 했으므로, 핵심 라이브러리인 HashMap이나 ArrayList에 Serializable을 넣는 것은 당시로서는 매우 혁신적이고 당연한 선택이었습니다.
지금 우리가 쓰는 JSON(2000년대 초반 등장)은 그때 존재감이 거의 없었습니다. 즉, HashMap은 **"자바끼리만 대화하던 시절"**에 태어난 아이입니다.
2. 자바의 철학: "절대로 뒤를 버리지 않는다" (하위 호환성)
이게 가장 큰 이유입니다. 자바는 하위 호환성(Backward Compatibility)에 목숨을 거는 언어입니다.
만약 내일 당장 Java 21 업데이트에서 HashMap의 implements Serializable을 지워버린다면?
- 20년 전에 만들어진 은행 시스템, 관공서 서버, 공장 자동화 프로그램들이 업데이트 후 전부 멈춰버립니다.
- 수많은 레거시 시스템이 HashMap을 직렬화해서 파일에 저장하거나 네트워크로 보내고 있기 때문입니다.
"비록 지금은 잘 안 쓰는 기능이라도, 옛날 코드가 돌아가게 하기 위해 남겨둔다." 이것이 자바가 엔터프라이즈(기업) 시장을 지배하는 이유이기도 합니다.
레거시 시스템은 옛날 자바(ex) Java 8)를 쓸 텐데, 미래의 자바(Java 21)가 바뀌든 말든 무슨 문제일까?
시스템이 영원히 Java 8 환경에만 머물러 있다면 아무 문제가 없습니다. 하지만 "하위 호환성"이 깨졌을 때 발생하는 진짜 공포는 "환경을 업그레이드할 때" 발생합니다.
시나리오 1. "소프트웨어는 그대로인데, 컴퓨터(JVM)만 바꿀 때" (가장 흔한 경우)
기업에서는 보안 패치나 성능 향상을 위해 서버의 자바 버전(Runtime Environment)을 올리곤 합니다.
- 상황:
- 2015년에 만든 LegacyApp.jar가 있습니다. (Java 8로 컴파일됨)
- 이 프로그램은 내부적으로 HashMap을 직렬화해서 파일로 저장하는 기능을 씁니다.
- 2025년, 보안 팀에서 "서버의 자바 버전을 Java 21로 올리세요"라고 지시합니다. (Java 8 지원 종료 이슈 등)
- Java의 약속 (하위 호환성):
- "걱정 마세요. 2015년에 만든 .jar 파일을 수정 없이 그대로 Java 21에서 돌려도 잘 돌아갑니다."
- 만약 Java 21에서 Serializable을 뺐다면?
- LegacyApp.jar는 HashMap이 당연히 직렬화될 거라 믿고 실행됩니다.
- 하지만 Java 21의 JVM(실행 환경)이 "어? 지금 내 HashMap에는 그 기능 없는데?" 라고 하며 NotSerializableException을 뱉고 프로그램이 뻗어버립니다.
- 결과: 기업은 Java 버전을 올리기 위해 수십 년 된 코드를 다시 까서 다 고쳐야 합니다. (이게 싫어서 기업들이 Java를 떠나게 됩니다.)
시나리오 2. "오래된 라이브러리를 갖다 쓸 때"
신입 개발자인 님이 Java 21로 아주 최신 프로젝트를 만든다고 가정해 봅시다. 하지만 회사 내부에는 **10년 전에 선배들이 만들어둔 공통 라이브러리(company-utils-1.0.jar)**가 있습니다.
- 상황:
- 님은 최신 Java 21을 씁니다.
- 하지만 company-utils-1.0.jar 내부에는 HashMap을 직렬화해서 캐시에 넣는 코드가 들어있습니다.
- 이 라이브러리는 소스 코드도 유실되어 수정할 수 없는 상태입니다. (흔한 일입니다...)
- 만약 Java 21에서 Serializable을 뺐다면?
- 님의 최신 프로젝트는 이 라이브러리를 가져다 쓰는 순간 에러가 납니다.
- 결과: "Java 21은 옛날 라이브러리랑 호환이 안 되네? 못 쓰겠다."라는 결론이 나옵니다.
시나리오 3. "과거에 저장해둔 데이터를 읽을 때" (타임캡슐)
이게 가장 치명적입니다. 데이터는 영속성(Persistence)을 가집니다.
- 상황:
- 2010년부터 2020년까지 Java 7 시스템이 매일 밤 HashMap 객체를 직렬화해서 data_backup.dat 파일로 저장해뒀습니다.
- 2025년에 Java 21로 만든 새로운 데이터 분석 도구가 이 과거의 파일들을 읽어서 통계를 내려고 합니다.
- 만약 Java 21에서 Serializable을 뺐다면?
- Java 21 프로그램이 data_backup.dat를 여는 순간, "이 파일 안에는 직렬화된 HashMap이 있는데, 나는 이걸 객체로 만들 능력이 없어(역직렬화 불가)"라며 읽기를 거부합니다.
- 결과: 지난 10년 치 데이터를 읽을 수 없게 됩니다. 데이터 복구 불능 상태에 빠집니다.
요약
- 코드(Bytecode)는 옛날 것이지만, 실행은 최신 기계(JVM)에서 합니다.
- 최신 JVM의 HashMap에서 기능을 빼버리면, 옛날 코드가 최신 JVM 위에서 돌아갈 때 배신을 당하게 됩니다.
- 이 "믿음(내가 짠 코드는 10년 뒤에도 최신 컴퓨터에서 돌아갈 것이다)"이 깨지는 순간, 기업들은 자바를 버릴 것입니다.
3. 기술적 반전: HashMap은 직렬화를 '똑똑하게' 합니다
여기서 백엔드 전문가로서 꼭 알아야 할 디테일이 있습니다. HashMap은 Serializable을 구현하고 있지만, 무식하게 모든 데이터를 다 저장하지 않습니다.
HashMap의 소스 코드를 뜯어보면 아주 흥미로운 설계를 발견할 수 있습니다.
실제 HashMap 내부 코드 예시 (단순화)
public class HashMap<K,V> implements Serializable {
// 1. 실제 데이터가 담기는 바구니(Node 배열)
// ⚠️ transient가 붙어있습니다! 즉, 기본 직렬화 대상에서 제외됩니다.
transient Node<K,V>[] table;
// 2. 직렬화를 직접 구현 (커스텀 직렬화)
private void writeObject(java.io.ObjectOutputStream s) throws IOException {
// 기본 설정 저장 (loadFactor 등)
s.defaultWriteObject();
// 테이블의 빈 공간(null)은 저장하지 않고,
// 실제 데이터가 들어있는 개수와 그 내용(Key, Value)만 루프를 돌며 저장합니다.
s.writeInt(size);
for (Map.Entry<K,V> e : entrySet()) {
s.writeObject(e.getKey());
s.writeObject(e.getValue());
}
}
}
왜 이렇게 했을까요? HashMap은 성능을 위해 실제 데이터보다 넉넉한 공간(Bucket)을 미리 잡아둡니다. (예: 데이터는 10개지만 배열 크기는 100개일 수 있음) 만약 그냥 직렬화하면 비어있는 90개의 공간(null)까지 모두 저장되므로 용량이 낭비됩니다.
그래서 HashMap은:
- 저장소(table)를 transient로 선언해 직렬화에서 뺍니다.
- writeObject를 직접 구현해서 "알맹이 데이터"만 골라서 저장합니다.
즉, HashMap은 Serializable의 단점(용량 낭비)을 극복하기 위해 최적화된 방식을 이미 내부에 구현해 둔 것입니다.
4. 빅데이터 처리 (Spark/Hadoop)
자바 기반의 빅데이터 프레임워크들은 내부적으로 데이터를 분산 처리할 때 자바 직렬화를 여전히 많이 사용합니다. 이때 HashMap이 직렬화가 안 된다면 데이터를 노드 간에 주고받을 수 없겠죠.
1. 빅데이터 처리의 핵심: "분산(Distributed)"과 "셔플(Shuffle)"
빅데이터는 컴퓨터 한 대(Node)로 처리가 불가능해서 수십, 수백 대의 컴퓨터가 나눠서 일을 합니다.
- 상황: 100GB짜리 로그 파일에서 단어 개수를 세는 작업(WordCount)을 한다고 가정해 봅시다.
- Node A: "apple"이라는 단어를 100개 찾았습니다. ({"apple": 100} - HashMap 사용)
- Node B: "apple"을 50개 찾았습니다. ({"apple": 50} - HashMap 사용)
- 집계(Reduce): 최종 결과를 내려면 Node A와 Node B가 가진 데이터를 한곳(Node C)으로 모아야 합니다.
이때, 서로 다른 컴퓨터(Node) 간에 데이터가 네트워크를 타고 이동하는 과정을 "셔플(Shuffle)"이라고 합니다.
2. 왜 HashMap이 직렬화되어야만 할까요?
이 "셔플" 단계에서 HashMap이 직렬화되지 않는다면 어떤 일이 벌어질지 상상해 보겠습니다.
1) 메모리 속 객체는 텔레포트 할 수 없습니다.
Node A의 메모리(RAM)에 있는 HashMap 객체는 전기 신호로 존재하는 구조체입니다. 이걸 그대로 LAN 케이블을 통해 Node C로 보낼 수는 없습니다. 반드시 **0과 1의 바이트 스트림(직렬화)**으로 변환해야 네트워크를 통과할 수 있습니다.
2) 자바의 표준 자료구조 = HashMap
빅데이터 처리는 대부분 Key-Value(키-값) 쌍으로 이루어집니다.
- 개발자가 하둡이나 스파크 코드를 짤 때, 가장 편하게 쓰는 자바 자료구조가 바로 Map, 그중에서도 HashMap입니다.
- 만약 HashMap이 Serializable을 구현하지 않았다면?
- 개발자는 데이터를 보낼 때마다 HashMap을 쪼개서 String이나 배열로 변환하는 코드를 수동으로 짜야 합니다.
- 프레임워크가 내부적으로 데이터를 뭉쳐서 보낼 때마다 에러(NotSerializableException)가 터집니다.
- Json으로 전달하면?
- "CPU가 과로사합니다" (파싱 오버헤드)
- JSON: 텍스트 포맷입니다. 컴퓨터는 0과 1밖에 모릅니다.
- 보낼 때: 객체(HashMap) → 문자열("{...}")로 변환 (CPU 사용)
- 받을 때: 문자열을 한 글자씩 읽어서(P, a, r, s, i, n, g) 다시 객체로 조립 (엄청난 CPU 사용)
- 바이트(Binary) 직렬화: 메모리에 있는 데이터를 그대로 복사하거나 조금만 압축해서 보냅니다. CPU가 할 일이 거의 없습니다.
- 결과: 수백억 건의 데이터를 JSON으로 변환하고 다시 파싱 하느라 정작 중요한 데이터 분석은 시작도 못 하고 CPU사용이 100%에 도달한다.
- JSON: 텍스트 포맷입니다. 컴퓨터는 0과 1밖에 모릅니다.
- 용량 뻥튀기
빅데이터는 보통 같은 구조의 데이터가 반복됩니다. -
- 데이터: name="홍길동", age=20 인 사람 100만 명을 보낸다고 가정합시다.
- JSON 방식: 문제점: "name", "age"라는 글자가 100만 번 반복해서 전송됩니다. 정작 데이터 값보다 키(Key) 값이 더 많은 용량을 차지합니다. (네트워크 비용 폭발)
-
{"name": "홍길동", "age": 20}, {"name": "김철수", "age": 22}, ... - JSON
- Binary 방식 (Java/Kryo): "name"과 "age"라는 정보는 딱 한 번만(헤더에) 보내고, 뒤에는 값(홍길동, 20, 김철수, 22)만 보냅니다. 용량이 1/2 ~ 1/5로 줄어듭니다.
- "CPU가 과로사합니다" (파싱 오버헤드)
3. 실제 스파크(Spark) 코드에서의 예시
스파크에서 아주 흔하게 작성하는 코드를 보겠습니다.
// JavaSparkContext sc ...
// 1. 데이터를 읽어서 단어별로 맵을 만듭니다.
JavaRDD<Map<String, Integer>> maps = textFile.map(line -> {
Map<String, Integer> localCounts = new HashMap<>(); // 여기서 HashMap 생성!
// ... 단어 세는 로직 ...
localCounts.put("word", 1);
return localCounts; // HashMap을 리턴함
});
// 2. collect()를 호출하면 분산된 데이터가 드라이버(메인 서버)로 모입니다.
List<Map<String, Integer>> result = maps.collect();
여기서 무슨 일이 일어날까요?
- map 함수 안에서 생성된 HashMap은 각 작업 노드(Worker Node)의 메모리에 만들어집니다.
- collect()를 호출하는 순간, 수백 대의 노드에 흩어진 HashMap들이 메인 서버(Driver)로 전송됩니다.
- 이때 스파크는 내부적으로 **자바 직렬화(Java Serialization)**를 사용하여 이 HashMap들을 포장해서 가져옵니다.
- 만약 HashMap에 implements Serializable이 없었다면? 이 코드는 실행하자마자 런타임 에러로 뻗어버립니다.
4. "사실 스파크는 자바 직렬화를 싫어합니다"
사실 스파크나 하둡 같은 고성능 프레임워크들은 자바 기본 직렬화가 느리고 용량이 커서 별로 좋아하지 않습니다.
- Hadoop: 자체적인 직렬화 인터페이스인 Writable(Text, IntWritable 등)을 쓰라고 권장합니다. 하지만 편의상 자바 기본 타입(String, HashMap)을 써도 돌아가게는 해줍니다(Java Serialization 이용).
- Spark: 기본적으로 Kryo Serializer라는 훨씬 빠르고 효율적인 라이브러리를 사용하도록 설정할 수 있습니다.
- 하지만 Kryo를 쓰더라도, **기본적인 호환성과 "아무 설정 없이도 자바 객체가 돌아가게 하는 것(Zero Configuration)"**이 중요하기 때문에, 자바 표준인 Serializable 인터페이스가 구현되어 있는지는 여전히 중요합니다.
참고