이펙티브 자바 완벽 공략 1부

아이템 11. equals를 재정의하려거든 hashCode도 재정의하라

아이템 11. equals를 재정의하려거든 hashCode도 재정의하라


핵심 정리 1 - hashCode 규약

equals()를 재정의했다면 반드시 함께 재정의해야 하는 메서드가 있다. 바로 hashCode()다.

이 둘은 항상 쌍으로 움직이는 계약(contract)이며, 하나라도 빠지면 컬렉션에서 심각한 문제가 발생한다.


hashCode 규약 정리

hashCode는 다음 3가지 규약을 반드시 만족해야 한다.


1. 동일한 객체는 항상 같은 hashCode를 가져야 한다

equals 비교에 사용되는 정보가 변하지 않았다면, hashCode는 항상 동일해야 한다

a.equals(b) == true
 a.hashCode() == b.hashCode()

👉 가장 중요한 규약


2. equals가 같으면 hashCode도 같아야 한다

두 객체가 equals로 같다면, 반드시 동일한 hashCode를 가져야 한다

이 규약을 어기면 HashMap, HashSet이 정상 동작하지 않는다


3. equals가 다르면 hashCode는 달라도 되고 같아도 된다

a.equals(b) == false
 a.hashCode() == b.hashCode() (가능)

👉 다를 필요는 없지만 👉 성능을 위해 다르게 만드는 것이 좋다


왜 equals와 hashCode는 같이 구현해야 하는가

이걸 이해하려면 HashMap의 동작 방식을 알아야 한다.


HashMap 동작 원리

1. 데이터 저장 (put)

  1. key의 hashCode() 호출
  2. 해시 값을 기반으로 버킷(bucket) 결정
  3. 해당 버킷에 데이터 저장

2. 데이터 조회 (get)

  1. key의 hashCode() 호출
  2. 같은 버킷 찾기
  3. 해당 버킷 내부에서 equals() 비교

👉 핵심

hashCode → 위치 찾기
equals   → 실제 비교

❌ hashCode를 구현하지 않은 경우

예제:

Map<PhoneNumber, String> map = new HashMap<>();

map.put(new PhoneNumber("123"), "Alice");
map.get(new PhoneNumber("123"));

문제 상황

  • equals는 같음 → true
  • hashCode는 다름

👉 결과

값을 못 찾는다 (null 반환)

왜 이런 일이 발생하는가

  • put할 때 → A 버킷에 저장
  • get할 때 → B 버킷에서 찾음

👉 애초에 다른 위치를 보고 있음

👉 equals가 호출될 기회조차 없음


❌ 모든 객체가 같은 hashCode를 반환하는 경우

@Override
public int hashCode() {
    return 42;
}

이 경우는?

  • 모든 객체가 같은 버킷에 저장됨

👉 해시 충돌(hash collision) 발생


해시 충돌 발생 시

  • 버킷 내부가 LinkedList로 변함
  • 모든 객체가 한 줄로 연결됨
Bucket 42:
[A] → [B] → [C] → [D] → ...

결과

  • 탐색 시간: O(1) → O(N)
  • HashMap → 그냥 LinkedList 수준

👉 성능 급격히 저하


핵심 포인트

hashCode는 “정확성”과 “성능”을 동시에 고려해야 한다


올바른 hashCode 구현 기준

1. equals에서 사용하는 모든 필드를 포함해야 한다

@Override
public int hashCode() {
    return Objects.hash(x, y);
}

👉 일부 필드 빠지면 문제 발생


2. 불변 필드를 사용하는 것이 좋다

  • 값이 바뀌면 hashCode도 바뀜
  • Map에서 위치가 달라짐

👉 매우 위험


equals와 hashCode 관계 정리

상황결과
equals true, hashCode 다름❌ 심각한 버그
equals false, hashCode 같음⚠️ 성능 저하
equals true, hashCode 같음✅ 정상

실무에서의 구현 방법


1. Lombok

@EqualsAndHashCode

👉 가장 많이 사용


2. Java Record

public record Point(int x, int y) {}

👉 equals + hashCode 자동 생성


3. IDE 자동 생성

  • IntelliJ Generate 기능

절대 하면 안 되는 것


❌ hashCode를 구현하지 않는 경우

👉 equals 재정의했다면 무조건 구현해야 한다


❌ 항상 같은 값 반환

return 1;

👉 HashMap 성능 완전 붕괴


❌ 일부 필드만 사용

👉 equals와 불일치 발생


핵심 요약

  • equals와 hashCode는 반드시 함께 구현해야 한다
  • equals가 같으면 hashCode도 같아야 한다
  • hashCode는 HashMap의 “위치 결정” 역할
  • 잘못 구현하면 데이터 조회 자체가 실패한다
  • 충돌이 많으면 성능이 O(1) → O(N)으로 떨어진다

한 줄 결론

equals는 “같은지 판단”이고, hashCode는 “어디에 있는지 찾는 키”다


핵심 정리 2 - hashCode 구현 방법

hashCode()는 단순히 규약만 지키는 것으로 끝나지 않는다. 어떻게 구현하느냐에 따라 성능과 안정성이 크게 달라진다.

이번에는 실무에서 가장 널리 사용되는 표준적인 구현 방식을 정리해보자.


1. 가장 전형적인 hashCode 구현 방식

기본 아이디어는 다음과 같다.

모든 핵심 필드를 조합해서 하나의 int 값으로 만든다


구현 패턴

@Override
public int hashCode() {
    int result = Short.hashCode(areaCode);
    result = 31 * result + Short.hashCode(prefix);
    result = 31 * result + Short.hashCode(lineNum);
    return result;
}

핵심 로직 설명

1. 첫 필드로 시작

int result = 필드1.hashCode();

2. 이후 필드는 누적 방식

result = 31 * result + 필드.hashCode();

👉 이 패턴을 반복하면서 모든 필드를 반영한다


왜 31을 사용하는가

31을 사용하는 이유는 크게 두 가지다.


1. 홀수이기 때문

  • 짝수 사용 시 비트 이동 과정에서 정보 손실 가능
  • 홀수는 분포 유지에 유리

2. 해시 충돌 최소화 경험적 결과

  • 다양한 문자열/데이터를 실험한 결과
  • 31이 가장 충돌이 적은 값 중 하나로 검증됨

👉 그래서 Java에서도 관례적으로 31 사용


필드 타입별 hashCode 처리


1. Primitive 타입

Integer.hashCode(value)
Double.hashCode(value)

2. 객체 타입

object.hashCode()

3. 배열

Arrays.hashCode(array)

👉 타입에 맞는 hashCode 사용이 중요


2. 실무에서 더 많이 쓰는 방법

사실 위 방식은 “이론적으로 표준”일 뿐이다.

실무에서는 대부분 아래 방법을 사용한다.


1. Objects.hash()

@Override
public int hashCode() {
    return Objects.hash(areaCode, prefix, lineNum);
}

👉 내부적으로 동일한 방식(31 기반)으로 처리됨

👉 코드 가독성 훨씬 좋음


2. Lombok

@EqualsAndHashCode

👉 equals + hashCode 자동 생성

👉 실무에서 가장 많이 사용


3. AutoValue (Google)

@AutoValue

👉 equals / hashCode / toString 자동 생성


3. 성능 최적화: 캐싱 전략


문제 상황

  • hashCode 계산 비용이 큰 경우
  • 객체가 불변(immutable)인 경우

해결 방법

private int hashCode;

@Override
public int hashCode() {
    if (hashCode == 0) {
        int result = ...
        hashCode = result;
    }
    return hashCode;
}

이 방식의 특징

  • 최초 1회 계산
  • 이후 재사용 (캐싱)
  • Lazy Initialization 적용

⚠️ 주의: 멀티스레드 환경

두 개의 스레드가 동시에 계산 → 다른 값 저장 가능

👉 반드시 thread-safe 고려 필요


4. 절대 하면 안 되는 것


❌ 일부 필드만 사용하는 경우

return Objects.hash(areaCode); // prefix, lineNum 빠짐

👉 equals와 불일치 발생


❌ 항상 동일한 값 반환

return 1;

👉 해시 충돌 → 성능 붕괴


❌ 구현에 의존하는 코드 작성

if (obj.hashCode() == 12345) ...

👉 내부 구현 변경 시 전부 깨짐

👉 hashCode는 외부 계약이 아니라 내부 구현


5. 좋은 hashCode의 조건


1. equals와 반드시 일치

  • 동일 객체 → 동일 hashCode

2. 분포가 균등해야 한다

  • 충돌 최소화
  • 성능 유지

3. 계산 비용이 적절해야 한다

  • 너무 무거우면 오히려 성능 저하

핵심 요약

  • hashCode는 equals와 반드시 함께 구현해야 한다
  • 가장 기본 방식은 31 * result + field.hashCode()
  • 실무에서는 Objects.hash() 또는 Lombok 사용
  • 불변 객체라면 캐싱 전략 고려 가능
  • 성능과 정확성 모두 중요

한 줄 결론

좋은 hashCode는 “정확하게 같고”, “골고루 퍼지고”, “빠르게 계산되는 값”이다


완벽 공략 27 - 해시맵 내부의 연결 리스트

hashCode()를 제대로 이해하려면 결국 HashMap 내부 구조를 이해해야 한다. 특히 “해시 충돌(hash collision)”이 발생했을 때 어떤 일이 벌어지는지가 핵심이다.


1. 해시 충돌(Hash Collision)이란?

서로 다른 객체가 동일한 hashCode 값을 가지는 상황

A.hashCode() == B.hashCode()
하지만 A.equals(B) == false

👉 이 상황이 발생하면 같은 “버킷(bucket)”에 저장된다.


2. Java HashMap 내부 구조 (Java 8 이전)

기본 구조는 다음과 같다.

배열 (bucket)
  ↓
같은 hash → 동일 bucket
  ↓
LinkedList로 연결

구조 예시

Bucket[42]
 → A → B → C → D

왜 LinkedList를 사용했을까?

  • 삽입: O(1)
  • 삭제: O(1)
  • 구조 단순
  • 메모리 효율 (배열보다 유연)

👉 특히 “충돌 시 확장성”이 좋다


하지만 문제는?

조회 성능

LinkedList 탐색 → O(N)

👉 충돌 많아질수록 성능 급격히 저하


3. Java 8 이후 개선 (핵심 변화)

Java 8부터는 중요한 최적화가 추가됐다.


조건

하나의 bucket에 노드가 8개 이상 쌓이면


변화

LinkedList → Red-Black Tree 변환

성능 비교

구조조회 성능
LinkedListO(N)
Tree (RB Tree)O(log N)

👉 충돌 많아도 성능 유지


왜 8개인가?

  • 너무 적으면 오버헤드
  • 너무 많으면 성능 저하

👉 경험적으로 최적값


4. 해시 충돌이 중요한 이유

충돌이 많아질수록:

HashMap → LinkedList처럼 동작

즉,

O(1) → O(N)

👉 해시맵의 핵심 장점이 사라진다


5. 그래서 좋은 hashCode가 중요한 이유

좋은 hashCode는:

  • 균등한 분포
  • 충돌 최소화
  • 빠른 계산

👉 결국 HashMap 성능을 결정


6. ArrayList 대신 LinkedList를 쓴 이유 (추론)

HashMap 내부에서 충돌 처리 구조로 왜 ArrayList가 아닌 LinkedList를 선택했을까?


ArrayList

  • 연속 메모리 필요
  • 크기 확장 비용 존재
  • 중간 삽입 비용 O(N)

LinkedList

  • 동적 확장
  • 참조 기반
  • 삽입/삭제 O(1)

👉 충돌 상황에서는 LinkedList가 더 유리


7. 핵심 인사이트

이 구조를 이해하면 다음이 자연스럽게 보인다.


✔ hashCode 잘못 구현하면

충돌 증가 → 성능 저하

✔ equals 잘못 구현하면

조회 실패

✔ 둘 다 중요

hashCode = 위치 찾기
equals   = 실제 비교

8. 학습 관점에서 중요한 포인트

이 내용에서 더 중요한 건 단순 암기가 아니다.


“메타적 이해”

기술을 외우는 것이 아니라 그 기술이 왜 그렇게 설계됐는지를 이해해야 한다


예를 들어:

  • 왜 LinkedList인가?
  • 왜 Tree로 바뀌었나?
  • 왜 8개인가?

👉 이런 질문을 던질 수 있어야 실력이 올라간다


9. 실무 관점에서 정리

  • 해시 충돌은 피할 수 없다
  • 하지만 줄일 수는 있다
  • Java는 내부적으로 최적화를 해준다
  • 하지만 hashCode 구현이 잘못되면 모든 게 무너진다

한 줄 결론

HashMap 성능은 hashCode 품질 + 충돌 처리 구조 이해에서 결정된다


완벽 공략 28 - 스레드 안전

Thread Safety 완벽 이해 (멀티스레드 환경에서 안전한 코드)

멀티스레드 환경에서 코드를 작성할 때 반드시 고려해야 하는 개념이 바로 Thread Safety(스레드 안전성)이다.


Thread Safety란 무엇인가

여러 스레드가 동시에 실행하더라도 항상 의도한 결과를 보장하는 코드


쉽게 말하면

동시에 실행해도
결과가 깨지지 않으면 → Thread-safe
결과가 예측과 다르면 → Thread-unsafe

멀티스레드 환경이란?

  • 하나의 프로그램 안에서
  • 여러 스레드가 동시에 코드 실행

문제 상황 예시

Thread1 → 값 수정
Thread2 → 값 읽기

→ 서로 간섭하면?
→ 잘못된 값 발생

hashCode 캐싱 코드와 Thread Safety

다음과 같은 코드가 있다고 가정해보자.

private int hashCode;

public int hashCode() {
    if (hashCode == 0) {
        int result = ... // 계산
        hashCode = result;
    }
    return hashCode;
}

이 코드가 위험한 이유

  • 여러 스레드가 동시에 진입 가능
  • 동시에 계산 가능
  • 동시에 값 쓰기 가능

그런데 왜 “겉보기엔 안전해 보일까?”

👉 핵심 이유

모든 스레드가 동일한 계산 결과를 생성

상황

  • Thread1 → 계산 후 100 저장
  • Thread2 → 계산 후 100 저장

👉 결과는 동일


그래서 결론

완전히 thread-safe는 아니지만 “사실상 안전한 코드처럼 보인다”

👉 이를 effectively thread-safe라고 볼 수 있다


하지만 진짜 안전하게 만들려면?


1. synchronized 사용

public synchronized int hashCode() {
    if (hashCode == 0) {
        hashCode = compute();
    }
    return hashCode;
}

장점

  • 완벽한 thread safety 보장

단점

모든 스레드가 대기 → 성능 저하

2. Double-Checked Locking (DCL)

성능 문제를 해결하기 위한 대표적인 패턴


구현

private volatile int hashCode;

public int hashCode() {
    if (hashCode == 0) {
        synchronized (this) {
            if (hashCode == 0) {
                hashCode = compute();
            }
        }
    }
    return hashCode;
}

동작 원리

  1. 첫 번째 체크 → 빠른 반환
  2. 값 없을 때만 synchronized 진입
  3. 두 번째 체크 → 중복 계산 방지

👉 락 범위를 최소화

👉 성능 + 안정성 확보


왜 volatile이 필요한가


문제 상황

CPU는 값을 캐시에 저장할 수 있다

Thread1 → 값 업데이트
Thread2 → 이전 캐시 값 읽음

👉 결과: 최신 값이 보장되지 않음


volatile 역할

항상 메인 메모리에서 읽고 쓰게 함

👉 모든 스레드가 최신 값 공유


Thread Safety를 만드는 다양한 방법


1. synchronized

  • 가장 직관적
  • 가장 확실
  • 하지만 느림

2. volatile

  • 가시성 보장
  • 원자성은 보장하지 않음

3. 불변 객체 (Immutable)

final class Point {
    private final int x;
    private final int y;
}

👉 상태 변경 불가능 👉 동시 접근 완전 안전


4. ThreadLocal

ThreadLocal<Integer> local = new ThreadLocal<>();

👉 스레드마다 별도 데이터


사용 사례

  • Spring Transaction
  • 사용자 요청 컨텍스트

5. Concurrent Collection


예시

ConcurrentHashMap
CopyOnWriteArrayList

특징

  • 다중 스레드 접근 최적화
  • synchronized보다 효율적

HashMap vs Hashtable

컬렉션Thread Safety
HashMap
Hashtable

👉 하지만 Hashtable은 구식

👉 ConcurrentHashMap 사용 권장


실무에서 자주 발생하는 문제


ArrayList + 멀티스레드

Thread1 → add()
Thread2 → iterate()

→ ConcurrentModificationException

👉 해결

  • synchronized
  • Concurrent Collection 사용

핵심 정리

  • Thread Safety는 “동시 실행에서도 일관성 유지”
  • synchronized는 가장 기본적인 방법
  • 성능 문제 해결 위해 DCL 사용 가능
  • volatile은 가시성 보장
  • 불변 객체는 가장 안전한 방법
  • Concurrent 컬렉션은 실무 필수

한 줄 결론

Thread Safety는 “동시성 환경에서 예측 가능한 결과를 보장하는 설계 능력”이다



© 2020. All rights reserved.

SIKSIK