Please enable JavaScript to view the comments powered by Disqus.Java 성능 최적화의 비밀: hashCode와 equals 규약, 그리고 소수 31의 수학적 원리
Search
🧱

Java 성능 최적화의 비밀: hashCode와 equals 규약, 그리고 소수 31의 수학적 원리

태그
Java
HashCode
HashMap
자료구조
백엔드최적화
공개여부
작성일자
2026/04/23
안내: 제공된 소스 자료에는 데이터 중심 애플리케이션 설계(DDIA)와 분산 시스템에서의 해시 파티셔닝에 대한 귀중한 통찰이 포함되어 있으나, Java의 hashCode() 내부 구현 구조나 '31’이라는 숫자의 수학적/비트 연산적 특성에 대한 구체적인 내용은 없습니다. 따라서 이 블로그 포스트의 핵심적인 Java 내부 동작 원리와 공식(Effective Java, Java API 스펙 등)은 컴퓨터 과학의 일반적인 지식을 바탕으로 보완하여 작성되었음을 미리 알려드립니다. 실무에 적용하실 때에는 관련 공식 문서를 독립적으로 교차 검증하시기를 권장합니다.

1. 서론: "왜 내가 만든 객체는 Map에서 찾아지지 않을까?"

자바(Java) 백엔드 개발자로 실무를 진행하다 보면 누구나 한 번쯤 겪는 미스터리한 버그가 있습니다. 사용자 정보를 담기 위해 User라는 커스텀 클래스를 만들고, 이를 HashMap의 키(Key)로 사용해 데이터를 저장했습니다. 분명히 아이디와 이름이 완벽하게 동일한 새로운 User 객체를 생성하여 Map에서 값을 꺼내려(get) 했는데, 결과는 뜻밖에도 null이 반환됩니다.
"내부 데이터가 완벽히 똑같은데 왜 찾지를 못할까?"
이 문제의 근본적인 원인은 Java의 핵심 규약 중 하나인 `equals()`와 `hashCode()`의 동반 재정의(Override) 규칙을 어겼기 때문입니다. 수많은 자바 기본서에서 "equals를 재정의할 때는 반드시 hashCode도 재정의해라"라고 단순 결론을 내리지만, 많은 주니어 개발자들이 그래야 하는지, 그리고 IDE(IntelliJ 등)가 자동 생성해 주는 hashCode() 메서드는 왜 하필 '31'이라는 뜬금없는 숫자를 계속해서 곱하고 있는지 그 깊은 원리를 알지 못합니다.
이 글에서는 Java 컬렉션 프레임워크가 객체를 식별하는 내부 원리부터, 컴퓨터 과학에서 해시 충돌(Hash Collision)을 방지하기 위한 소수(Prime Number) 31의 수학적/비트 연산적 비밀, 그리고 대규모 분산 시스템에서 해시 함수를 사용할 때의 주의점까지 깊이 있게 파헤쳐 봅니다.

2. HashMap과 시간 복잡도의 비밀: 왜 HashCode가 필요할까?

자바에서 HashMap이나 HashSet 같은 해시 기반의 자료구조는 데이터의 추가, 삭제, 검색에서 이론적으로 O(1)의 경이로운 시간 복잡도를 자랑합니다. 이 엄청난 속도의 비결은 바로 데이터를 순차적으로 검색하지 않고, 데이터 자체가 자신이 저장될 메모리 주소(배열의 인덱스)를 결정하기 때문입니다.

2.1. 해시 테이블의 동작 원리

HashMap 내부는 '버킷(Bucket)'이라고 불리는 배열로 구성되어 있습니다. 객체가 Map의 키로 들어올 때 다음과 같은 두 단계를 거칩니다.
1.
위치 탐색 (Hash 기반): 먼저 키 객체의 hashCode() 메서드를 호출하여 고유한 32비트 정수(int) 값을 얻습니다. 이 정수 값을 내부 배열의 크기로 나눈 나머지(Modulus) 연산을 통해 데이터가 저장될 버킷의 인덱스를 즉각적으로 계산해 냅니다.
2.
정밀 탐색 (Equals 기반): 해당 인덱스의 버킷에 도착하면, 버킷 안에 이미 다른 객체가 있을 수 있습니다(이를 해시 충돌이라 부릅니다). 이때 비로소 equals() 메서드를 사용하여 버킷 내의 객체들과 하나씩 비교하며 완벽히 동일한 객체를 찾아냅니다.

2.2. Object 클래스의 기본 hashCode()의 맹점

자바의 모든 클래스는 Object 클래스를 상속받으며, 기본적으로 제공되는 hashCode()는 객체의 내부 메모리 주소를 기반으로 정수 값을 반환합니다(Identity Hash Code).
따라서 여러분이 equals()만 "아이디와 이름이 같으면 동일한 객체다"라고 오버라이딩하고 hashCode()를 그대로 두면, 논리적으로 완전히 똑같은 데이터를 가진 객체라도 힙(Heap) 메모리상에 생성된 위치가 다르므로 전혀 다른 해시코드를 반환하게 됩니다. 결과적으로 HashMap은 애초에 잘못된 버킷 인덱스를 뒤지게 되므로, equals()를 호출할 기회조차 얻지 못하고 null을 반환하는 것입니다. 이것이 우리가 두 메서드를 반드시 함께 구현해야 하는 아키텍처적 근거입니다.

3. 마법의 숫자 '31'의 등장: String 클래스의 해시 알고리즘

hashCode()를 직접 구현해야 한다는 사실을 깨닫고, IDE의 자동 생성 기능(Alt + Insert)을 실행해 보면 다음과 같이 코드가 만들어집니다.
@Override public int hashCode() { int result = 17; result = 31 * result + (name != null ? name.hashCode() : 0); result = 31 * result + age; return result; }
Plain Text
복사
또는 자바의 String 클래스 내부 소스코드를 까보면 다음과 같은 수학적 공식을 기반으로 해시값을 계산합니다.
s*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
왜 수많은 숫자 중에서 하필 31을 반복해서 곱하는 것일까요? 10도 아니고, 100도 아니고, 32도 아닌 31이 선택된 데에는 컴퓨터 공학자들의 철저한 수학적, 하드웨어적 계산이 숨어 있습니다.

3.1. 소수(Prime Number)의 수학적 특성

31은 1과 자기 자신으로만 나누어지는 홀수 소수(Odd Prime)입니다. 해시 함수에서 데이터를 분산시킬 때(즉, 해시 충돌을 줄일 때) 소수를 곱하는 것은 정보 이론적으로 매우 중요합니다.
만약 짝수(Even number)를 곱하게 되면, 컴퓨터 내부의 이진수 연산 특성상 값의 왼쪽 시프트(Shift) 연산이 발생하게 됩니다. 숫자가 커져서 32비트 int 타입의 범위를 초과하는 오버플로우(Overflow)가 발생할 때, 짝수를 계속 곱한 값은 오른쪽의 하위 비트들이 0으로 가득 차게 되며 원래 객체가 가지고 있던 데이터의 엔트로피(특성)가 유실됩니다. 반면 홀수 소수를 사용하면 오버플로우가 발생하더라도 비트 단위의 정보 손실을 최소화하고 데이터를 32비트 공간 내에 넓고 균일하게 흩뿌릴 수 있습니다.

3.2. 비트 연산을 통한 하드웨어 최적화 (31 = 32 - 1)

홀수 소수 중에서도 3, 5, 7, 17 등 다양한 숫자가 있는데 왜 31일까요? 자바의 창시자 중 한 명인 조슈아 블로크(Joshua Bloch)의 Effective Java에 따르면, 31은 최신 JVM 및 CPU 아키텍처에서 곱셈 연산을 엄청나게 빠른 비트 시프트(Bit Shift) 및 뺄셈 연산으로 대체할 수 있는 환상적인 수학적 성질을 가지고 있기 때문입니다.
공식으로 표현하면 다음과 같습니다:
31 * i == (i << 5) - i
즉, 어떤 숫자 i에 31을 곱하는 과정은, i를 왼쪽으로 5칸 비트 시프트한 값(32를 곱한 것과 같음)에서 원래의 i를 한 번 빼는 것과 완벽히 동일합니다. 전통적인 CPU 아키텍처에서 곱셈(*) 명령어는 매우 무거운 연산에 속하지만, 비트 이동(<<) 연산과 뺄셈(-) 연산은 CPU가 단일 사이클에 처리할 수 있을 만큼 가볍고 빠릅니다. 현대의 Java 가상 머신(JVM)의 JIT 컴파일러는 코드 내의 31 * i를 감지하면 이를 자동으로 비트 연산으로 최적화하여 애플리케이션의 성능을 극대화합니다.

3.3. 해시 충돌(Hash Collision)의 최소화 실험

과거 개발자들이 영단어 5만 개를 기준으로 다양한 상수를 대입하여 해시 충돌 발생률을 테스트했습니다. 상수가 33 이상으로 너무 커지면 계산 결과가 너무 빨리 오버플로우되어 오작동이 늘었고, 너무 작으면 해시 공간을 충분히 활용하지 못해 충돌이 빈번했습니다. 31은 이 테스트에서 아주 훌륭한 수준의 해시값 균일 분포도를 보여주었으며, 자바 생태계의 표준으로 자리 잡게 되었습니다.
만약 hashCode가 제대로 분산되지 못하고 많은 객체가 똑같은 31이라는 숫자를 피해서 모두 같은 버킷에 몰리게 되면 어떻게 될까요? 최악의 경우 버킷 내부에 데이터가 길다란 연결 리스트(Linked List)로 쌓이게 되어, O(1)이었던 검색 속도가 O(N)으로 수직 하락하는 끔찍한 실행 계획 병목 현상을 초래하게 됩니다.

4. 고급: 분산 시스템에서의 HashCode와 주의사항

단일 JVM 애플리케이션 레벨을 넘어, 대규모 트래픽을 처리하기 위해 시스템을 수평적으로 확장(Scale-out)하는 분산 시스템 환경에서는 또 다른 관점의 해시 지식이 필요합니다.
데이터 중심 애플리케이션 설계(Designing Data-Intensive Applications) 문헌에 따르면, 대규모 키-값 저장소(Key-Value Store)나 메시지 큐 등에서 데이터를 여러 노드(파티션)에 분산 저장할 때 해시 파티셔닝(Hash Partitioning) 기법을 사용합니다 [2]. 특정 키의 해시값을 구하고, 이를 전체 파티션 개수(N)로 나눈 나머지(hash mod N) 연산으로 데이터가 저장될 서버를 결정하는 방식입니다.
이때 Java 언어의 기본 내장 hashCode()를 분산 시스템의 파티셔닝 키로 절대 사용해서는 안 됩니다 [3].
Martin Kleppmann이 지적하듯, Java의 Object.hashCode()나 일부 내장 타입의 해시 코드는 같은 데이터를 가지더라도 서로 다른 JVM 프로세스(서버 1과 서버 2)에서 다른 값을 반환할 수 있는 위험성이 존재합니다(예: JVM 프로세스 재시작 시 보안 목적으로 해시 시드(Seed)를 무작위로 변경하는 언어들도 있음). 이렇게 되면 동일한 사용자의 데이터가 조회할 때마다 다른 파티션 서버로 라우팅되어 데이터를 영영 찾을 수 없게 됩니다.
따라서 분산 캐시 시스템(Memcached, Redis 등)이나 데이터베이스를 설계할 때는 Java 기본 hashCode()가 아닌, JVM 인스턴스 환경과 완전히 무관하게 수학적으로 항상 동일한 결과를 내어주는 MD5, SHA-1, 혹은 MurmurHash와 같은 암호화/비암호화 범용 해시 알고리즘을 명시적으로 사용해야 분산 아키텍처의 정합성을 보장할 수 있습니다.

5. 요약 및 결론

자바에서 객체를 설계할 때 무심코 지나치기 쉬운 hashCode()equals()에는 컴퓨터 과학의 깊은 정수가 담겨 있습니다. 이번 포스트를 통해 다음과 같은 핵심 개념을 다시 한번 명확히 정리하시길 바랍니다.
1.
규약의 일치: 논리적으로 동일한 객체(equals()true)는 반드시 동일한 해시코드(hashCode() 결과가 같음)를 반환해야만 HashMap 등에서 정상적으로 조회할 수 있습니다.
2.
31의 마법: 자바가 해시코드 생성에 31을 사용하는 이유는, 그것이 홀수 소수(Odd Prime)로서 오버플로우 시 정보 손실을 최소화하고, (i << 5) - i라는 비트 시프트 연산 최적화를 통해 CPU 처리 속도를 극적으로 끌어올리면서도 해시 충돌을 훌륭하게 분산시키기 때문입니다.
3.
분산 환경의 함정: 로컬 자료구조 탐색을 위해 설계된 hashCode()를 분산 서버 환경의 데이터 파티셔닝 라우팅 로직에 무분별하게 재사용하면 심각한 데이터 불일치 장애가 발생할 수 있습니다.
백엔드 개발자로서 단순히 "이렇게 해라"라는 규칙을 암기하는 것을 넘어, 왜 컴퓨터와 프레임워크가 이러한 숫자를 채택했는지 하드웨어와 수학적 관점에서 이해하는 것은 여러분을 진정한 시니어 엔지니어로 이끌어 줄 훌륭한 밑거름이 될 것입니다.

참고문헌

[1] Core Technologies-all — 4.3.6. Methods 4.3.7. Operators Relational Operators Logical Operators Mathematical Operators The Assignment Operator 4.3.8. Types 4.3.9. Constructors 4.3.10. Variables The #this and #root Variables 4.3.11. Functions 4.3.12. Bean References 4.3.13. Ternary Operator (If-Then-Else) 4.3.14. The Elvis…
[2] designing-data-intensive-applications — hardware faults, 7 hash indexes, 72-75 broadcast hash joins, 409 partitioned hash joins, 409 hash partitioning, 203-205, 217 consistent hashing, 204 problems with hash mod N, 210 range queries, 204 suitable hash functions, 203 with fixed number of partitions, 210 HAWQ (database), 428 HBase (database…
[3] designing-data-intensive-applications — [5] Ikai Lan: “App Engine Datastore Tip: Monotonically Increasing Values Are Bad,” ikaisays.com, January 25, 2011. [6] Martin Kleppmann: “Java’s hashCode Is Not Safe for Distributed Systems,” mar‐ tin.kleppmann.com, June 18, 2012. [7] David Karger, Eric Lehman, Tom Leighton, et al.: “Consistent Hash…