[Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작

2026. 5. 12. 17:39·Java

equals()와 hashCode()

Java에서 객체를 비교할 때 가장 헷갈리는 개념이 ==, equals(), hashCode()이다.

 

이 셋이 어떻게 다른지, 왜 equals()를 오버라이드하면 hashCode()도 같이 오버라이드해야 하는지,

그리고 hashCode() 내부에서 왜 하필 31을 곱하는지까지 정리해봤다.


객체 비교 3가지

= 대입 연산자
== 동일성 비교 — 같은 객체인가? (주소값)
equals() 동등성 비교 — 논리적으로 같은가? (내용)

 

기본형은 주소 개념이 없으니 ==으로 값 자체를 비교한다.

반면 참조형(객체)은 == 이 참조값을 비교하기 때문에 내용이 같아도 다른 객체로 판별된다.

Person p1 = new Person("홍길동", 20);
Person p2 = new Person("홍길동", 20);

p1 == p2        // false → 힙의 서로 다른 주소
p1.equals(p2)   // true  → 이름, 나이가 같으므로 같다고 정의

equals() 직접 구현

5가지 수학적 조건 (JDK 명세)

조건 의미
반사성 x.equals(x) = true — 자기 자신과 비교하면 항상 true
대칭성 x.equals(y) = true이면 y.equals(x) = true
추이성 x.equals(y) = true이고 y.equals(z) = true이면 x.equals(z) = true
일관성 비교에 쓰인 정보가 바뀌지 않는 한 여러 번 호출해도 항상 같은 결과
null 비교 x.equals(null) = false

구현 예시

@Override
public boolean equals(Object o) {
  if (this == o) return true;                       // 반사성 + 최적화
  if (o == null) return false;                      // null 비교
  if (getClass() != o.getClass()) return false;     // 타입 체크
  Person person = (Person) o;
  return age == person.age &&
         Objects.equals(name, person.name);
}

Lombok으로 자동 생성

@EqualsAndHashCode
public class Person {
  private String name;
  private int age;
}

 @EqualsAndHashCode를 붙이면 컴파일 시점에 equals() + hashCode()가 자동 생성된다.

equals() 테스트 코드를 작성해야 하는 이유

equals()가 5가지 수학적 조건을 만족하는지는 눈으로 확인하기 어렵다.

특히 HashMap, HashSet 같은 컬렉션은 내부에서 equals()를 자동 호출하기 때문에 버그가 생기면 추적이 매우 어렵다. 반드시 테스트 코드로 검증해야 한다.

class PersonEqualsTest {

  @Test void 반사성() {
      Person a = new Person("홍길동", 20);
      assertThat(a.equals(a)).isTrue();
  }

  @Test void 대칭성() {
      Person a = new Person("홍길동", 20);
      Person b = new Person("홍길동", 20);
      assertThat(a.equals(b)).isTrue();
      assertThat(b.equals(a)).isTrue();
  }

  @Test void 추이성() {
      Person a = new Person("홍길동", 20);
      Person b = new Person("홍길동", 20);
      Person c = new Person("홍길동", 20);
      assertThat(a.equals(b)).isTrue();
      assertThat(b.equals(c)).isTrue();
      assertThat(a.equals(c)).isTrue();
  }

  @Test void 일관성() {
      Person a = new Person("홍길동", 20);
      Person b = new Person("홍길동", 20);
      for (int i = 0; i < 100; i++) {
          assertThat(a.equals(b)).isTrue();
      }
  }

  @Test void null비교() {
      Person a = new Person("홍길동", 20);
      assertThat(a.equals(null)).isFalse();
  }

  @Test void 다른객체() {
      Person a = new Person("홍길동", 20);
      Person b = new Person("김철수", 30);
      assertThat(a.equals(b)).isFalse();
  }
}

hashCode()를 반드시 같이 오버라이드해야 하는 이유

HashMap / HashSet 동작 원리

HashMap/HashSet 은 객체를 저장하거나 조회할 때 내부적으로 두 단계로 동작한다.

(이때 hashCode() 와 equals() 는 컬렉션이 자동으로 호출하기 때문에, 이 두 메서드를 클래스에 미리 오버라이드해두기만 하면 된다.)

1. 1단계: hashCode()로 버킷(저장 위치)을 결정
2. 2단계: 같은 버킷 안에서 equals()로 동일 객체인지 판별

 

예시)

Person.hashCode() 내부 연산
  → 여러 필드를 하나의 int로 섞음 (예: 54321)

 

HashMap/HashSet 버킷 결정
  → 그 int를 버킷 개수로 나눈 나머지
  → 54321 % 16 = 1번 버킷에 저장

 

※ 참고: 실제 HashMap 은 key.hashCode() 결과를 그대로 쓰지 않고, 내부 hash() 메서드에서 (h ^ (h >>> 16)) 로 상하위 비트를 한 번 섞은 뒤 (n - 1) & hash 로 버킷을 결정한다.

 

즉, hashCode()를 오버라이드하지 않으면 같은 버킷에 들어가지 않아서 equals()까지 도달하지 못한다.

 

equals()만 오버라이드하면 생기는 버그

Set<Person> set = new HashSet<>();
set.add(new Person("홍길동", 20));
set.add(new Person("홍길동", 20));

 

hashCode()를 오버라이드 안 하면 Object.hashCode()를 사용한다. 이때 JVM이 객체마다 보통 서로 다른 int 값을 부여한다. (메모리 주소나 xorshift 등 JVM 구현에 따라 다름)

set.size()  // 2

결과적으로 같은 사람인데 중복 저장되게 된다. (hashCode가 달라서 서로 다른 버킷에 들어감)


hashCode() 내부 동작

hashCode()가 하는 일은 단순하다. 임의의 객체를 받아 필드값들을 연산해 int(4바이트 숫자) 하나로 압축해서 반환하는 것이다.

31을 곱하는 이유

대부분의 hashCode() 구현에서 다음 패턴이 등장한다.

 

result = 31 * result + (element == null ? 0 : element.hashCode()); 

 

왜 하필 31일까? 네 가지 이유가 맞물려 있다.

1. 32비트 int에서 적당한 균형점
비트가 적당히 퍼지면서도, 누적 곱셈 시 앞쪽 정보가 너무 빨리 오버플로우로 손실되지 않는 경험적 균형점이다.

 

2. 홀수여야 한다
int는 32비트로 고정이다. 짝수를 곱하면 비트가 왼쪽으로 밀려서 결국 사라진다.

 

큰 숫자에 짝수를 계속 곱하면

10000000 00000000 00000000 00000001
* 2
00000000 00000000 00000000 00000010  ← 맨 왼쪽 비트 사라짐
* 2
00000000 00000000 00000000 00000100  ← 계속 사라짐
* 2
...
00000000 00000000 00000000 00000000  ← 결국 0

서로 다른 값인데 hashCode가 0으로 같아져 충돌이 발생한다. 홀수는 이런 정보 손실이 없다.

 

3. 소수(Prime Number)여야 한다

소수의 약수는 1과 자기 자신뿐이라, 해시값이 고르게 분산되어 충돌(같은 버킷에 몰리는 현상)이 최소화된다.

 

4. JVM 최적화가 가능하다

31 * x는 비트 시프트로 변환된다.

31 * x
= (32 - 1) * x
= 32 * x - x
= (x << 5) - x

곱셈보다 빠르게 처리된다.


필드가 하나일 때는 31을 안 곱해도 된다.

 

31을 곱하는 목적은 여러 필드를 섞기 위한 것이다.

// 필드 여러 개 → 섞어야 함
result = 31 * name.hashCode() + address.hashCode();

// 필드 하나 → 섞을 대상이 없음
return Objects.hashCode(name);

위에서 name.hashCode() 자체가 이미 분산된 int값이다. 거기에 31을 곱해봤자 숫자만 커질 뿐 분산에 도움이 되지 않는다.

특히 String은 내부에서 이미 31을 활용해 해시를 만들기 때문에, 필드가 하나라면 그 결과를 그대로 쓰면 된다.

Objects.hash() vs Objects.hashCode() 차이

본격적인 차이를 설명하기 전에, 이름이 비슷한 세 메서드를 먼저 구분해야 한다.

이름 소속 클래스 종류
Object.hashCode() java.lang.Object 인스턴스 메서드 (native) — JVM이 처리
String.hashCode() java.lang.String 인스턴스 메서드 (오버라이드) — 31 곱하기 로직
Objects.hashCode(o) java.util.Objects 정적 유틸리티 — 내부에서 `o.hashCode()` 호출

우리가 쓰는 Objects.hashCode(o) 는 결국 o.hashCode() 를 호출하고,
그 객체가 hashCode를 오버라이드했다면 오버라이드된 버전(String, 우리가 만든 Person 등)이 실행되고,
오버라이드 안 했다면 Object.hashCode() 의 네이티브 구현까지 내려간다.


Objects.hash() 내부 동작

신버전: Objects.hash() 사용

@Override
public int hashCode() {
  return Objects.hash(name, address, email);
}

내부 구현을 따라가 보면 결국 다음과 같다.

public static int hash(Object... values) {
  return Arrays.hashCode(values);
}

public static int hashCode(Object[] a) {
  if (a == null) return 0;

  int result = 1;
  // 시작값이 1인 이유: 0으로 시작하면 31 * 0 = 0이 되어 첫 번째 요소가 영향을 주지 못함

  for (Object element : a)
      result = 31 * result + (element == null ? 0 : element.hashCode());

  return result;
}

구버전: 직접 전개

@Override
public int hashCode() {
  int result = Objects.hashCode(name);
  result = 31 * result + Objects.hashCode(address);
  result = 31 * result + Objects.hashCode(email);
  return result;
}

신/구 버전 모두 결국 같은 로직이다.


필드가 하나일 때 `Objects.hash()`를 쓰면 원본 hashCode()와 다른 값이 나온다.

String name = "홍길동";

name.hashCode()          // 예: 54321
Objects.hash(name)       // 예: 31 * 1 + 54321 = 54352  ← 다름!
Objects.hashCode(name)   // 예: 54321                   ← 이걸 써야 같음

이유는 Objects.hash()가 내부적으로 다음과 같이 동작하기 때문이다.

Objects.hash(name)
 = Arrays.hashCode(new Object[]{name})
 = 31 * 1 + name.hashCode()   // 1에서 시작해 31을 한 번 더 곱함

즉 Objects.hash() 는 내부적으로 varargs 배열을 생성한 뒤, 그 배열을 순회하며 각 요소의 hashCode를 31로 누적 곱셈하는 구조이다.

필드가 하나라면 배열을 만들 필요 자체가 없으므로 Objects.hashCode(field)를 쓰면 된다.


String.hashCode() 깊게 보기

왜 String은 내부에서 31을 한 번 더 곱하는가

int 같은 원시 타입은 값 자체가 하나라서 섞을 내부 데이터가 없다.

 

예시)

age = 20
  → 더 이상 쪼갤 게 없음
  → 그대로 20 반환
  → Arrays.hashCode()에서 31 한 번만 곱함

 

반면 String은 내부가 문자 배열(여러 문자)이라 자기 자신을 합칠 때 31이 필요하다.

 

예시)

name = "홍길동"
  → 내부적으로 ['홍', '길', '동']
  → 이 문자들을 하나의 int로 만들어야 함
  → String 내부에서 31로 한 번 섞음
  → 그 결과를 Arrays.hashCode()에서 다시 31로 섞음

String.hashCode() 코드

public int hashCode() {
  int h = hash;  // 캐시된 값 (처음엔 0)

  if (h == 0 && !hashIsZero) {
      h = isLatin1() ? StringLatin1.hashCode(value)
                     : StringUTF16.hashCode(value);

      if (h == 0) {
          hashIsZero = true;  // 진짜 0이면 다음에 재계산 방지
      } else {
          hash = h;           // 계산값 캐시
      }
  }
  return h;
}
String은 불변(immutable) 이라 한 번 계산한 hashCode는 절대 바뀌지 않는다.
→ 첫 계산 후 hash 필드에 캐싱
→ 다음 호출부터는 즉시 반환 (성능 최적화)

Latin1 vs UTF-16

Java String은 인코딩에 따라 두 가지 방식으로 해시를 계산한다.

 

1. Latin1 (영문/숫자, 1바이트 문자)

public static int hashCode(byte[] value) {
  int h = 0;
  for (byte v : value) {
      h = 31 * h + (v & 0xff);
  }
  return h;
}

2. UTF-16 (한글 등, 2바이트 문자)

public static int hashCode(byte[] value) {
  int h = 0;
  int length = value.length >> 1;  // 바이트 길이 / 2 = 문자 수
  for (int i = 0; i < length; i++) {
      h = 31 * h + getChar(value, i);  // 2바이트를 합쳐 char로 읽음
  }
  return h;
}

 

계산 예시 — "AB" (Latin1)

'A' = 0x41 = 65
'B' = 0x42 = 66
bytes = [0x41, 0x42]

h = 0
h = 31 * 0  + 65 = 65
h = 31 * 65 + 66 = 2081

계산 예시— "가나" (UTF-16, LE 플랫폼 예시)

'가' = 44032
'나' = 45208

UTF-16LE(하위 바이트가 앞으로) 저장 결과
bytes = [0x00, 0xAC, 0x98, 0xB0]
           ↑──가──↑    ↑──나──↑

i=0: getChar = (0xAC << 8) | 0x00 = 44032 ('가')
i=1: getChar = (0xB0 << 8) | 0x98 = 45208 ('나')

h = 0
h = 31 * 0     + 44032 = 44032
h = 31 * 44032 + 45208 = 1,410,200

정리

 기본형은 값이 하나라서 Arrays 레벨에서 31을 한 번만 곱한다.
 반면 String처럼 내부가 복합 구조인 타입은 31이 두 번 쓰인다.

int → 원자값 → 31 한 번
String → 복합값(문자 배열) → 자기 내부 합칠 때 31 한 번 + Arrays에서 다시 31 한 번

네이티브 hashCode JDK 명세

Object.hashCode()는 결국 네이티브 메서드다.

@IntrinsicCandidate
public native int hashCode();
 규칙 내용
일관성 같은 객체 → 항상 같은 hashCode (단, JVM 재시작 시는 달라도 됨)
equals → hashCode equals()가 true이면 hashCode()는 반드시 같아야 함
역방향은 보장 X equals()가 false여도 hashCode()는 같을 수 있음 (= 충돌, 성능 저하 요인)
왜 역방향은 보장하지 않을까

hashCode()는 무한한 객체 공간을 유한한 int(약 42억 개)로 압축하기 때문이다. 서로 다른 객체가 같은 해시를 가질 수밖에 없다.


정리

  • ==는 주소 비교, equals()는 내용 비교
  • equals()는 5가지 수학적 조건(반사성, 대칭성, 추이성, 일관성, null)을 만족해야 하고, 테스트 코드로 검증해야 한다.
  • equals()를 오버라이드하면 hashCode()도 반드시 같이 오버라이드해야 한다. 그렇지 않으면 HashMap/HashSet에서 같은 객체가 중복으로 들어간다.
  • hashCode()에서 31을 곱하는 이유는 (1) 적당한 분산, (2) 홀수라 정보 손실 없음, (3) 소수라 충돌 최소화, (4) JVM이 비트 시프트로 최적화 가능하기 때문이다.
  • 필드 1개라면 Objects.hashCode(), 여러 개라면 Objects.hash()를 쓴다.
  • String.hashCode()는 불변 특성을 이용해 결과를 캐싱한다.

'Java' 카테고리의 다른 글

[Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기  (0) 2026.05.19
[Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기  (0) 2026.05.17
[Java] enum - 왜 안전한가, private 생성자로 강등되는 이유  (0) 2026.05.14
[Java] synchronized 동작 원리 - 모니터 락, CAS  (0) 2026.05.12
'Java' 카테고리의 다른 글
  • [Java] Arrays.copyOf가 얕은 복사인 이유 — 어셈블리까지 내려가기
  • [Java] 다형성, 컴포지션, DI - 세 개념을 한 줄로 잇기
  • [Java] enum - 왜 안전한가, private 생성자로 강등되는 이유
  • [Java] synchronized 동작 원리 - 모니터 락, CAS
oniwon
oniwon
  • oniwon
    눙눙의 개발일지
    oniwon
  • 전체
    오늘
    어제
    • 분류 전체보기
      • Java
      • Spring
      • CS
      • Infra
      • Projects
      • F-Lab
      • Airflow
  • 인기 글

  • 블로그 메뉴

    • 홈
    • 개인정보처리방침 (Privacy Policy)
    • 블로그 소개 (About)
  • 최근 글

  • 태그

    DAG
    Flab추천인코드
    Flab 3주차 후기
    에프랩멘토링
    Flab후기
    F-Lab
    Flab 추천인코드
    Flab멘토링
    FLAB
    네트워크
    Flab 멘토링
    SSE
    에프랩
    자바면접
    병렬 처리
    Airflow
    자바 내부 동작
    dns
    비동기 처리
    동시성
    tls
    https
    redis
    inconcert
    커넥터스2385
    JVM
    Flab커넥터스
    자바백엔드
    자바 다형성
    Java
  • 최근 댓글

  • hELLO· Designed By정상우.v4.10.1
oniwon
[Java] equals() & hashCode() 깊게 보기 - 31의 의미, String 내부 동작
상단으로

티스토리툴바