본문 바로가기

문자열의 해시 코드 계산하기

문자 코드를 단순히 더하는 해시가 왜 쉽게 충돌하는지 확인하고, String.hashCode()가 31을 곱해 가며 누적하는 계산과 음수 해시 코드, 해시가 같은 문자열을 equals로 구별하는 과정을 따라갑니다.

앞 글의 MyHashSetV1은 정수의 해시 코드로 정수 자신을 썼습니다. 그런데 게시글 제목이나 태그 같은 문자열은 글자가 여러 개입니다. 해시 집합에 넣으려면 이 글자들을 int 하나로 요약해야 하는데, 어떻게 요약하느냐에 따라 해시 집합의 성능이 크게 달라집니다.

좋은 문자열 해시에는 두 가지가 필요합니다. 첫째, 같은 문자열은 언제 계산해도 같은 해시 코드를 내야 합니다. 그래야 넣을 때와 찾을 때 같은 버킷을 봅니다. 둘째, 실제로 자주 쓰이는 문자열들이 버킷 전체에 고르게 퍼져야 합니다. 그래야 버킷이 짧게 유지됩니다. 이 글에서는 먼저 가장 단순한 방법인 '문자 코드 더하기'가 두 번째 조건에서 어떻게 실패하는지 보고, 자바의 String.hashCode()가 이 문제를 어떻게 줄이는지 한 글자씩 계산해 봅니다.

문자 코드를 더하면 순서가 사라진다

문자열을 숫자로 바꾸는 가장 쉬운 방법은 각 문자의 코드 값을 모두 더하는 것입니다. 'a'는 97, 'b'는 98이므로 "ab"의 합은 195입니다. 다음 코드는 이 방식으로 "ab"와 "ba"의 해시를 구합니다.

lab/AdditiveStringHashCollision.java
public final class AdditiveStringHashCollision {
    public static void main(String[] args) {
        String left = "ab";
        String right = "ba";
        int leftHash = additive(left);
        int rightHash = additive(right);
        System.out.println(left + "=" + leftHash);
        System.out.println(right + "=" + rightHash);
        System.out.println(
                "different=" + !left.equals(right) + ", collision=" + (leftHash == rightHash));
    }

    private static int additive(String value) {
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            hash += value.charAt(i);
        }
        return hash;
    }
}
실행 결과
ab=195
ba=195
different=true, collision=true

hash += value.charAt(i)에서 char는 int로 바뀌어 더해지므로, 두 문자열 모두 97 + 98 = 195가 됩니다. 덧셈은 순서를 바꿔도 결과가 같기 때문입니다. 그래서 이 해시는 "listen"과 "silent"처럼 글자 순서만 다른 문자열을 모두 같은 값으로 만듭니다. 마지막 줄은 두 문자열이 서로 다른데(different=true) 해시가 같다(collision=true)는 것을 보여 줍니다.

충돌 하나만으로 해시 함수가 틀렸다고 할 수는 없습니다. int가 나타낼 수 있는 값은 약 43억 개인데 가능한 문자열은 그보다 훨씬 많으므로, 어떤 해시 함수를 쓰든 서로 다른 문자열이 같은 해시 코드를 받는 일은 반드시 있습니다. 문제는 충돌이 얼마나 자주, 얼마나 한쪽으로 몰리느냐입니다. 문자 합의 결함은 결과가 좁은 범위에 몰린다는 데 있습니다. 소문자 세 글자로 된 문자열은 26 × 26 × 26 = 17576개인데, 합은 가장 작은 aaa의 291부터 가장 큰 zzz의 366까지 76가지밖에 없습니다. 같은 문자로 이루어진 순열이 모두 같은 값이 되고, 서로 다른 문자 조합도 합이 같으면 겹치기 때문입니다.

이 차이가 버킷에서 어떻게 나타나는지 직접 재 보겠습니다. 다음 코드는 소문자 세 글자 문자열 17576개를 만들어, 문자 합과 String.hashCode()로 각각 버킷 1024개에 나눠 넣습니다. 그리고 한 번이라도 쓰인 버킷 수(used)와 가장 긴 버킷의 길이(longest)를 출력합니다.

lab/HashSpreadCheck.java
import java.util.ArrayList;
import java.util.List;

public final class HashSpreadCheck {
    public static void main(String[] args) {
        List<String> words = new ArrayList<>();
        for (char a = 'a'; a <= 'z'; a++) {
            for (char b = 'a'; b <= 'z'; b++) {
                for (char c = 'a'; c <= 'z'; c++) {
                    words.add("" + a + b + c);
                }
            }
        }
        report("sum", words, true);
        report("x31", words, false);
    }

    private static void report(String name, List<String> words, boolean additive) {
        int bucketCount = 1024;
        int[] counts = new int[bucketCount];
        for (String word : words) {
            int hash = additive ? sum(word) : word.hashCode();
            counts[Math.floorMod(hash, bucketCount)]++;
        }
        int used = 0;
        int longest = 0;
        for (int count : counts) {
            if (count > 0) used++;
            longest = Math.max(longest, count);
        }
        System.out.println(name + ": words=" + words.size() + ", used=" + used
                + "/" + bucketCount + ", longest=" + longest);
    }

    private static int sum(String value) {
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            hash += value.charAt(i);
        }
        return hash;
    }
}
실행 결과
sum: words=17576, used=76/1024, longest=507
x31: words=17576, used=1024/1024, longest=23

문자 합은 버킷 1024개 가운데 76개만 쓰고, 가장 긴 버킷에 507개가 몰립니다. 그 버킷에 든 문자열을 찾으려면 최악에는 507번을 비교해야 합니다. 반면 String.hashCode()는 1024개 버킷을 모두 쓰고, 가장 긴 버킷도 23개에 그칩니다. 버킷 하나의 평균이 17576 ÷ 1024, 즉 약 17개이므로 평균에 가깝게 퍼진 것입니다. 이처럼 해시 함수를 평가할 때는 충돌이 있는지가 아니라, 정해진 입력 묶음을 넣었을 때 쓰인 버킷 수와 가장 긴 버킷의 길이 같은 분포를 봅니다. 다만 이 결과는 이 입력 묶음에 대한 측정이지, 모든 입력에서 고르게 퍼진다는 증명은 아닙니다.

31을 곱하며 누적하는 String.hashCode()

String.hashCode()는 문자를 하나 읽을 때마다 지금까지의 결과에 31을 곱한 뒤 새 문자 코드를 더합니다. String 공식 문서는 이 계산을 길이 n인 문자열에 대한 식 s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]로 정해 두었습니다. 여기서 s[i]는 i번째 char의 값이고, 빈 문자열의 해시 코드는 0입니다. 식을 한 글자씩 풀어 쓰면 "abc"는 ((0 * 31 + 'a') * 31 + 'b') * 31 + 'c'입니다.

곱셈이 순서를 살리는 원리는 이렇습니다. 앞에 있는 문자일수록 31이 여러 번 곱해지므로, 같은 문자라도 위치에 따라 결과에 기여하는 크기가 다릅니다. "ab"에서 'a'는 31배가 되고 'b'는 그대로 더해지지만, "ba"에서는 반대입니다. 그래서 두 문자열의 해시가 갈라집니다. 다음 코드는 이 누적 과정을 문자마다 출력하고, 마지막에 버킷 8개 기준의 버킷 번호를 구합니다.

src/StringHashWalkthrough.java
public final class StringHashWalkthrough {
    public static void main(String[] args) {
        trace("abc", 8);
        trace("ab", 8);
        trace("ba", 8);
        System.out.println("jdk-match=" + (polynomial("abc") == "abc".hashCode()));
    }

    private static void trace(String value, int bucketCount) {
        int hash = 0;
        for (int index = 0; index < value.length(); index++) {
            char character = value.charAt(index);
            hash = 31 * hash + character;
            System.out.println(
                    value + "[" + index + "] char=" + (int) character + ", hash=" + hash);
        }
        int bucketIndex = Math.floorMod(hash, bucketCount);
        System.out.println(value + " -> hash=" + hash + ", bucket=" + bucketIndex);
    }

    private static int polynomial(String value) {
        int hash = 0;
        for (int index = 0; index < value.length(); index++) {
            hash = 31 * hash + value.charAt(index);
        }
        return hash;
    }
}
실행 결과
abc[0] char=97, hash=97
abc[1] char=98, hash=3105
abc[2] char=99, hash=96354
abc -> hash=96354, bucket=2
ab[0] char=97, hash=97
ab[1] char=98, hash=3105
ab -> hash=3105, bucket=1
ba[0] char=98, hash=98
ba[1] char=97, hash=3135
ba -> hash=3135, bucket=7
jdk-match=true

세 문자열은 각각 0에서 새로 시작해 따로 누적합니다. 출력을 표로 정리하면 다음과 같습니다.

문자열첫 문자 뒤둘째 문자 뒤셋째 문자 뒤버킷 8개일 때 번호
"abc"0 × 31 + 97 = 9797 × 31 + 98 = 31053105 × 31 + 99 = 9635496354 = 12044 × 8 + 2이므로 2
"ab"0 × 31 + 97 = 9797 × 31 + 98 = 31053105 = 388 × 8 + 1이므로 1
"ba"0 × 31 + 98 = 9898 × 31 + 97 = 31353135 = 391 × 8 + 7이므로 7

문자 합에서는 둘 다 195였던 "ab"와 "ba"가 이제 3105와 3135로 갈라지고, 버킷도 1번과 7번으로 나뉩니다. 첫 문자의 차이 1(97과 98)은 31배로 커져 31이 되는데 둘째 문자의 차이는 1뿐이라, 둘이 상쇄되지 않고 30이 남기 때문입니다. "abc"의 처음 두 단계는 "ab"와 똑같다가 'c'를 처리하면서 달라집니다. 마지막 줄의 jdk-match=true는 "abc"에 대해 직접 계산한 값과 String.hashCode()의 값이 같다는 뜻입니다. 이 비교는 "abc" 하나만 한 것이지만, 같은 식을 쓰므로 다른 문자열에서도 결과는 같습니다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc/ab/ba를 각각 0에서 문자별 hash로 누산하고 floorMod합니다. 독립 세 계산의 중간값을 비교하되 모두 JDK 구현과 대조했다고 만들지 않습니다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다.

문자열 해시 · 31을 곱하는 누적식

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다.

문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스
문자열문자 처리 직후의 누적 계산최종 hash8개 그룹의 index
"abc"
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
963542
"ab"
0×31 + 97 = 97
97×31 + 98 = 3105
31051
"ba"
0×31 + 98 = 98
98×31 + 97 = 3135
31357
"abc"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
최종 hash
96354
8개 그룹의 index
2
"ab"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
최종 hash
3105
8개 그룹의 index
1
"ba"
문자 처리 직후의 누적 계산
0×31 + 98 = 98
98×31 + 97 = 3135
최종 hash
3135
8개 그룹의 index
7

인덱스는 최종 해시에 floorMod(hash, 8)을 적용한 값입니다. 앞의 문자 합 예제에서는 ab와 ba가 모두 195이지만, 여기서는 각각 3105와 3135입니다. 마지막 jdk-match=true는 abc만 비교합니다.

이 예의 조건

  • 세 문자열은 별도 누산이며 jdk-match는 abc만 비교한다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
원 도표의 설명과 주석
구분내용
설명세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
전체 읽기 정보같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다. 문자열 해시 · 31을 곱하는 누적식 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다. 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자열 문자 처리 직후의 누적 계산 최종 hash 8개 그룹의 index "abc" 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 96354 2 "ab" 0×31 + 97 = 97 97×31 + 98 = 3105 3105 1 "ba" 0×31 + 98 = 98 98×31 + 97 = 3135 3135 7 "abc" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 최종 hash 96354 8개 그룹의 index 2 "ab" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 최종 hash 3105 8개 그룹의 index 1 "ba" 문자 처리 직후의 누적 계산 0×31 + 98 = 98 98×31 + 97 = 3135 최종 hash 3135 8개 그룹의 index 7 인덱스는 최종 해시에 floorMod(hash, 8) 을 적용한 값입니다. 앞의 문자 합 예제에서는 ab 와 ba 가 모두 195 이지만, 여기서는 각각 3105 와 3135 입니다. 마지막 jdk-match=true 는 abc 만 비교합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 문자

초깃값 0에 각 첫 문자를 적용합니다.

  • abc/ab: 97
  • ba: 98
  • 식: hash×31+char
  • 순서가 다른 첫 문자가 다른 누산 상태를 만든다.
다음 문자

b 또는 a를 적용합니다.

  • abc/ab: 3105
  • ba: 3135
  • 이전 누산값의 31배가 문자 순서 차이를 보존한다.
마지막/인덱스

abc만 c를 더하고 각 hash를 8로 분류합니다.

  • abc: 96354 → index2
  • ab: 3105 → index1
  • ba: 3135 → index7
  • 같은 문자가 있어도 순서와 길이에 따라 최종 탐색 위치가 달라진다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc/ab/ba를 각각 0에서 문자별 hash로 누산하고 floorMod합니다. 독립 세 계산의 중간값을 비교하되 모두 JDK 구현과 대조했다고 만들지 않습니다.

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다.

문자열 해시 · 31을 곱하는 누적식

같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스

문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다.

문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
문자 순서별 누적 해시와 마지막 인덱스 세영역은abc/ab/ba의별도누적입니다. abc97→3105→96354/index2, ab97→3105/index1, ba98→3135/index7입니다. 실선은각문자누적이며원jdk-match는abc만비교합니다. abc의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 마지막 c와 인덱스 3105×31+99=96354 floorMod(96354,8)=2 ab의 누적 초기 0에서 a 0×31+97=97 다음 b 97×31+98=3105 인덱스 floorMod(3105,8)=1 ba의 누적 초기 0에서 b 0×31+98=98 다음 a 98×31+97=3135 인덱스 floorMod(3135,8)=7 실선: 각 문자열의 누적 계산 index는 마지막 hash로 계산 원 jdk-match는 abc만 비교
같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스
문자열문자 처리 직후의 누적 계산최종 hash8개 그룹의 index
"abc"
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
963542
"ab"
0×31 + 97 = 97
97×31 + 98 = 3105
31051
"ba"
0×31 + 98 = 98
98×31 + 97 = 3135
31357
"abc"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
3105×31 + 99 = 96354
최종 hash
96354
8개 그룹의 index
2
"ab"
문자 처리 직후의 누적 계산
0×31 + 97 = 97
97×31 + 98 = 3105
최종 hash
3105
8개 그룹의 index
1
"ba"
문자 처리 직후의 누적 계산
0×31 + 98 = 98
98×31 + 97 = 3135
최종 hash
3135
8개 그룹의 index
7

인덱스는 최종 해시에 floorMod(hash, 8)을 적용한 값입니다. 앞의 문자 합 예제에서는 ab와 ba가 모두 195이지만, 여기서는 각각 3105와 3135입니다. 마지막 jdk-match=true는 abc만 비교합니다.

이 예의 조건

  • 세 문자열은 별도 누산이며 jdk-match는 abc만 비교한다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
원 도표의 설명과 주석
구분내용
설명세 영역은 abc/ab/ba의 별도 누적입니다. abc는 97→3105→96354/index 2, ab는 97→3105/index 1, ba는 98→3135/index 7입니다. 실선은 각 문자의 누적이며 원래 jdk-match는 abc만 비교합니다.
전체 읽기 정보같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 abc, ab, ba의 실제 char 누적값과 최종 hash, 그룹 8개의 인덱스를 함께 비교한다. 문자열 해시 · 31을 곱하는 누적식 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자마다 이전 값에 31을 곱한 뒤 char 값을 더합니다. ab와 ba는 최종 해시도, 8개 그룹의 인덱스도 다릅니다. 같은 누적 규칙으로 구한 세 문자열의 해시와 인덱스 문자열 문자 처리 직후의 누적 계산 최종 hash 8개 그룹의 index "abc" 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 96354 2 "ab" 0×31 + 97 = 97 97×31 + 98 = 3105 3105 1 "ba" 0×31 + 98 = 98 98×31 + 97 = 3135 3135 7 "abc" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 3105×31 + 99 = 96354 최종 hash 96354 8개 그룹의 index 2 "ab" 문자 처리 직후의 누적 계산 0×31 + 97 = 97 97×31 + 98 = 3105 최종 hash 3105 8개 그룹의 index 1 "ba" 문자 처리 직후의 누적 계산 0×31 + 98 = 98 98×31 + 97 = 3135 최종 hash 3135 8개 그룹의 index 7 인덱스는 최종 해시에 floorMod(hash, 8) 을 적용한 값입니다. 앞의 문자 합 예제에서는 ab 와 ba 가 모두 195 이지만, 여기서는 각각 3105 와 3135 입니다. 마지막 jdk-match=true 는 abc 만 비교합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 문자

초깃값 0에 각 첫 문자를 적용합니다.

  • abc/ab: 97
  • ba: 98
  • 식: hash×31+char
  • 순서가 다른 첫 문자가 다른 누산 상태를 만든다.
다음 문자

b 또는 a를 적용합니다.

  • abc/ab: 3105
  • ba: 3135
  • 이전 누산값의 31배가 문자 순서 차이를 보존한다.
마지막/인덱스

abc만 c를 더하고 각 hash를 8로 분류합니다.

  • abc: 96354 → index2
  • ab: 3105 → index1
  • ba: 3135 → index7
  • 같은 문자가 있어도 순서와 길이에 따라 최종 탐색 위치가 달라진다.

charAt이 돌려주는 값은 화면에 보이는 글자 하나가 아니라 UTF-16 코드 단위(code unit)입니다. 위의 입력은 모두 ASCII 문자라서 글자 하나가 char 하나였습니다. 하지만 "😀" 같은 이모지는 char 두 개로 표현되므로 length()가 2이고, 해시 계산도 두 번 누적됩니다. 글자 단위(코드 포인트)로 세도록 반복문을 바꾸면 String.hashCode()와 다른 값이 나오니, 직접 구현할 때는 공식 문서의 식대로 char 단위로 계산해야 합니다.

왜 하필 31일까

31을 쓰는 이유로 흔히 두 가지를 듭니다. 하나는 31이 홀수라는 점입니다. 짝수를 곱하면 int의 32비트 안에서 정보가 사라집니다. 예를 들어 32를 곱하는 것은 비트를 왼쪽으로 5칸 미는 것과 같아서, 문자를 하나 읽을 때마다 앞 문자의 비트가 5칸씩 위로 밀려납니다. 7글자를 더 읽고 나면 첫 문자는 32비트 밖으로 완전히 밀려나 결과에 아무 영향도 주지 못합니다. 실제로 32를 곱해 누적하면 "abcdefgh"와 "zbcdefgh"는 둘 다 1282680136으로 같은 해시가 되지만, 31을 곱하면 1259673732와 1880259147로 갈라집니다. 다른 하나는 계산이 싸다는 점입니다. 31 * h는 (h << 5) - h와 같은 값이어서 곱셈을 시프트와 뺄셈으로 바꿀 수 있습니다. 다만 요즘 JVM이 실제로 이렇게 바꾸는지는 구현과 CPU에 달린 문제이고, 명세가 정한 것은 계산 결과뿐입니다.

31이 소수라는 점도 자주 언급되지만, 소수라서 실제로 얼마나 도움이 되는지는 분명하지 않습니다. 숫자 31 자체를 외우기보다, 이전 결과에 곱해서 문자의 위치 정보를 남긴다는 구조를 기억하는 편이 쓸모 있습니다.

더 깊이: String은 해시 코드를 한 번만 계산한다

문자열이 길면 해시 계산에도 길이에 비례하는 시간이 듭니다. 해시 집합에서 같은 문자열 키로 여러 번 찾을 때마다 이 계산을 반복하면 아깝습니다. 그래서 OpenJDK의 String은 처음 계산한 해시 코드를 객체 안의 hash 필드에 저장해 두고 다음부터는 그 값을 돌려줍니다. 문자열은 불변이라 내용이 바뀌지 않으므로 이렇게 저장해 둬도 안전합니다. 해시 코드가 정말로 0인 문자열(예를 들어 빈 문자열)을 매번 다시 계산하지 않도록 hashIsZero라는 표시 필드도 따로 둡니다. 이 저장 방식은 공식 문서가 약속하는 동작이 아니라 OpenJDK의 구현 세부 사항이지만(이 글은 JDK 21에서 확인했습니다), 문자열이 해시 키로 자주 쓰이는 이유 중 하나입니다.

오버플로와 음수 해시 코드

31을 계속 곱하면 값은 금방 int의 범위를 넘습니다. int의 최댓값은 약 21억인데, 'a'(97)로 시작하는 여섯 글자 문자열이라면 첫 문자에만 31을 다섯 번 곱해 97 × 28629151 = 2777027647이 되므로 이미 이 범위를 넘습니다. 자바의 정수 곱셈과 덧셈은 범위를 넘어도 예외를 던지지 않고, 결과의 아래쪽 32비트만 남깁니다. JLS의 정수 곱셈 규칙이 정한 동작이고, 이를 오버플로(overflow)라고 합니다. 이렇게 넘친 값은 음수가 될 수도 있습니다. 공식 문서는 String.hashCode()의 식을 'int 연산으로' 계산한다고 정해 두었으므로, 오버플로를 거친 음수 해시 코드도 정상입니다.

src/NegativeStringHash.java
public final class NegativeStringHash {
    public static void main(String[] args) {
        int hash = "hash-index".hashCode();
        System.out.println("hash=" + hash);
        System.out.println("remainder=" + (hash % 8));
        System.out.println("bucket=" + Math.floorMod(hash, 8));
    }
}
실행 결과
hash=-432506029
remainder=-5
bucket=3

열 글자짜리 "hash-index"의 해시 코드는 오버플로를 거쳐 -432506029가 됩니다. 이 값에 %를 쓰면 -5가 나와 배열 인덱스로 쓸 수 없고, Math.floorMod를 쓰면 3이 나옵니다. -432506029 = (-54063254) × 8 + 3이기 때문입니다. 해시 테이블의 동작 원리에서 버킷 번호를 floorMod로 구한 이유가 바로 이것입니다. 해시 코드는 음수여도 되고, 0 이상 8 미만이어야 하는 것은 배열에 접근하는 버킷 번호뿐입니다. 거꾸로 long으로 누적하거나 오버플로가 날 때마다 예외를 던지도록 직접 구현하면 String.hashCode()와 다른 값이 나옵니다. 같은 문자열을 두 계산 방식으로 처리하면 서로 다른 버킷을 보게 되므로, 표준 문자열 해시를 흉내 낸다면 int 오버플로까지 그대로 따라야 합니다.

해시 코드가 같아도 다른 문자열

31을 곱해도 충돌은 남습니다. "Aa"는 65 × 31 + 97 = 2112이고, "BB"는 66 × 31 + 66 = 2112입니다. 첫 문자가 1 커지면 31이 늘고, 둘째 문자가 31 작아지면 그만큼 줄어서 정확히 상쇄되기 때문입니다. 해시 집합은 이런 두 문자열을 어떻게 다룰까요?

src/PolynomialCollisionPair.java
import java.util.HashSet;
import java.util.Set;

public final class PolynomialCollisionPair {
    public static void main(String[] args) {
        String first = "Aa";
        String second = "BB";
        Set<String> values = new HashSet<>();
        System.out.println("hashes=" + first.hashCode() + "/" + second.hashCode());
        System.out.println("equal=" + first.equals(second));
        System.out.println("first=" + values.add(first));
        System.out.println("second=" + values.add(second));
        System.out.println("size=" + values.size());
    }
}
실행 결과
hashes=2112/2112
equal=false
first=true
second=true
size=2

두 문자열은 해시 코드가 2112로 같으므로 항상 같은 버킷에 들어갑니다. 그래도 equals로 비교하면 서로 다르므로(equal=false) 두 번의 add가 모두 true를 돌려주고, 집합의 크기는 2입니다. HashSet이 해시 코드가 같다는 이유만으로 두 번째 문자열을 중복으로 보고 버렸다면 데이터를 잃었을 것입니다. 해시 코드는 버킷을 고르는 데만 쓰이고, 같은 원소인지는 마지막에 equals로 판단합니다.

해시 코드가 같은 두 문자열은 더 긴 충돌을 만드는 재료가 됩니다. 길이가 같고 해시 코드가 같은 두 문자열 앞뒤에 같은 내용을 붙이면 해시 코드가 계속 같기 때문입니다. 그래서 "AaAa", "AaBB", "BBAa", "BBBB"는 모두 해시 코드가 2031744입니다. 이런 식으로 충돌하는 문자열을 얼마든지 만들 수 있으므로, 공격자가 이런 키를 대량으로 보내면 해시 테이블의 한 버킷이 아주 길어질 수 있습니다. HashSet·LinkedHashSet·TreeSet을 비교하는 글에서 볼 OpenJDK의 '긴 버킷을 트리로 바꾸는' 최적화가 이런 상황의 최악 성능을 줄여 줍니다.

hashCode, 버킷 번호, equals는 각자 다른 일을 한다

지금까지 본 세 가지는 해시 집합 안에서 각자 다른 일을 맡습니다. 원소를 찾는 과정의 순서대로 정리하면 다음과 같습니다.

  1. hashCode()가 원소를 int 하나로 요약합니다. 이 값은 버킷 수와 상관없이 원소만으로 정해집니다.
  2. 해시 테이블이 그 int를 버킷 수에 맞는 번호로 줄여 버킷 하나를 고릅니다. 버킷 수가 바뀌면 이 번호는 다시 계산해야 합니다.
  3. equals가 버킷 안의 후보 가운데 찾는 원소와 같은 것이 있는지 판단합니다.

어느 단계를 건너뛰어도 문제가 생깁니다. 버킷 번호가 같다는 이유로 equals를 생략하면 "Aa"와 "BB"를 같은 원소로 오판합니다. 같은 문자열의 해시 코드가 호출할 때마다 달라지면 add와 contains가 서로 다른 버킷을 봅니다. 그래서 해시 코드 계산에 현재 시각이나 난수를 섞어서는 안 됩니다. 문자열은 불변이라 저장한 뒤 내용이 바뀌지 않으므로 해시 코드도 바뀌지 않고, 그래서 해시 키로 안전합니다.

String.hashCode()는 문자와 고정된 식만으로 정해지므로 실행할 때마다 달라지지 않습니다. 그렇다고 비밀번호를 저장하거나 파일 변조를 검사하는 데 쓰는 암호학적 해시(cryptographic hash)를 대신할 수는 없습니다. 앞에서 본 것처럼 같은 해시 코드를 내는 문자열을 쉽게 만들 수 있고, 결과도 32비트뿐이기 때문입니다. String.hashCode()는 해시 테이블에서 버킷을 고르기 위한 값일 뿐입니다.

null은 문자열 객체가 아니므로 hashCode()를 호출할 수 없고, 호출하면 NullPointerException이 납니다. 그래서 해시 집합은 null을 따로 정해 다뤄야 합니다. 표준 HashSet은 null 원소 하나를 허용하고(OpenJDK는 null의 해시를 0으로 취급합니다), 직접 만든 집합은 null을 금지할 수도 있습니다. 어느 쪽이든 그 집합을 쓰는 사람이 알 수 있도록 문서에 적어 두어야 합니다.

같은 태그로 볼 문자열을 미리 맞추기

게시글 태그를 HashSet에 저장할 때, "Java", " java ", "JAVA"를 같은 태그로 볼지 다른 태그로 볼지는 해시 함수가 정할 일이 아닙니다. String의 equals와 hashCode는 대소문자와 공백까지 그대로 비교하므로 이 셋을 모두 다른 문자열로 다룹니다. 같은 태그로 보고 싶다면 집합에 넣기 전에 문자열을 정해진 형태로 바꿔야 하는데, 이를 정규화(normalization)라고 합니다. 다음 CLI는 앞뒤 공백을 없애고 소문자로 바꾼 뒤 HashSet에 넣습니다.

app/NormalizedTagCli.java
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;

public final class NormalizedTagCli {
    public static void main(String[] args) {
        Set<String> tags = new HashSet<>();
        for (String raw : new String[] {"Java", " hash ", "JAVA", "Hash"}) {
            String normalized = normalize(raw);
            System.out.println(raw + " -> " + normalized + ":" + tags.add(normalized));
        }
        System.out.println("tags=" + tags.stream().sorted().toList());
    }

    private static String normalize(String raw) {
        if (raw == null) throw new IllegalArgumentException("tag");
        String value = raw.strip().toLowerCase(Locale.ROOT);
        if (value.isEmpty()) throw new IllegalArgumentException("blank tag");
        return value;
    }
}
실행 결과
Java -> java:true
 hash  -> hash:true
JAVA -> java:false
Hash -> hash:false
tags=[hash, java]

normalize는 strip()으로 앞뒤 공백을 지우고 toLowerCase(Locale.ROOT)로 소문자로 바꿉니다. 입력 네 개는 java, hash, java, hash로 정규화되므로, 처음 두 번의 add만 true이고 JAVA와 Hash는 이미 있는 태그라 false입니다. 둘째 줄이 공백으로 시작하고 화살표 앞에 공백이 두 칸 있는 것은 원문 " hash "의 앞뒤 공백이 그대로 출력되었기 때문입니다. 마지막 줄은 집합을 정렬해서 출력한 것입니다. HashSet은 반복 순서를 정해 두지 않으므로, 출력을 늘 같게 하려고 sorted()를 거쳤을 뿐 HashSet이 이 순서로 원소를 보관한다는 뜻은 아닙니다.

정규화한 태그를 추가할 때의 실제 결과

네 입력을 정규화하여 JAVA/Hash는 이미 존재하는 java/hash로 중복 거절됩니다. 입력별 정규화와 size 변화의 실제 순서입니다.

정규화한 태그를 추가할 때의 실제 결과

네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다.

입력 정규화 · 중복 키

정규화한 태그를 추가할 때의 실제 결과

공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다.

정규화된 두 키에 모이는 네 호출원입력Java/공백hash/JAVA/Hash를strip과Locale.ROOT소문자로정규화하면java/hash/java/hash입니다. add결과true/true/false/false와size1/2/2/2이며실선은원처리순서입니다. sorted는표현순서입니다.
입력 Javastrip·소문자화 → javaadd=true / size=1
연결 관계입력 Java · strip·소문자화 → java · add=true / size=1 → 입력 hash (공백) · strip·소문자화 → hash · add=true / size=2
입력 hash (공백)strip·소문자화 → hashadd=true / size=2
연결 관계입력 hash (공백) · strip·소문자화 → hash · add=true / size=2 → 입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2
입력 JAVAstrip·소문자화 → java기존 값 / false / size=2
연결 관계입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2 → 입력 Hash · strip·소문자화 → hash · 기존 값 / false / size=2
입력 Hashstrip·소문자화 → hash기존 값 / false / size=2

실선: 원 네 입력의 처리 순서소문자는 Locale.ROOT 사용최종 sorted 출력: hash, java

정규화한 태그를 추가할 때의 실제 결과
원문 입력정규화된 키add 반환값호출 뒤 size
"Java""java"true1
" hash ""hash"true2
"JAVA""java"false2
"Hash""hash"false2
"Java"
정규화된 키
"java"
add 반환값
true
호출 뒤 size
1
" hash "
정규화된 키
"hash"
add 반환값
true
호출 뒤 size
2
"JAVA"
정규화된 키
"java"
add 반환값
false
호출 뒤 size
2
"Hash"
정규화된 키
"hash"
add 반환값
false
호출 뒤 size
2

입력·정규화된 키·add 결과는 실제 출력에 있고, 각 행의 size는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java]는 sorted()의 결과이며 HashSet의 반복 순서를 뜻하지 않습니다.

이 예의 조건

  • 소문자화는 Locale.ROOT이며 sorted는 표현 순서이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
5add=true
6size=1
9add=true
10size=2
13기존 값
15size=2
18기존 값
20size=2
원 도표의 설명과 주석
구분내용
설명원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
전체 읽기 정보정규화한 태그를 추가할 때의 실제 결과 정규화한 태그를 추가할 때의 실제 결과 네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다. 입력 정규화 · 중복 키 정규화한 태그를 추가할 때의 실제 결과 공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다. 정규화한 태그를 추가할 때의 실제 결과 원문 입력 정규화된 키 add 반환값 호출 뒤 size "Java" "java" true 1 " hash " "hash" true 2 "JAVA" "java" false 2 "Hash" "hash" false 2 "Java" 정규화된 키 "java" add 반환값 true 호출 뒤 size 1 " hash " 정규화된 키 "hash" add 반환값 true 호출 뒤 size 2 "JAVA" 정규화된 키 "java" add 반환값 false 호출 뒤 size 2 "Hash" 정규화된 키 "hash" add 반환값 false 호출 뒤 size 2 입력·정규화된 키· add 결과는 실제 출력에 있고, 각 행의 size 는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java] 는 sorted() 의 결과이며 HashSet 의 반복 순서를 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 정규화

Java를 java로 만들어 등록합니다.

  • add: true
  • 집합: java
  • size: 1
  • 원시 문자열 대신 정규화한 키로 식별한다.
다른 키

공백 hash를 strip하여 추가합니다.

  • add: true
  • 집합: java,hash
  • size: 2
  • 새 정규화 값이므로 개수가 증가한다.
두 중복

JAVA/Hash는 기존 키가 되어 거절됩니다.

  • 결과: false,false
  • size: 2 유지
  • sorted: hash,java
  • 대소문자 차이가 식별 차이로 남지 않는다.

정규화한 태그를 추가할 때의 실제 결과

네 입력을 정규화하여 JAVA/Hash는 이미 존재하는 java/hash로 중복 거절됩니다. 입력별 정규화와 size 변화의 실제 순서입니다.

정규화한 태그를 추가할 때의 실제 결과

네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다.

입력 정규화 · 중복 키

정규화한 태그를 추가할 때의 실제 결과

공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다.

정규화된 두 키에 모이는 네 호출원입력Java/공백hash/JAVA/Hash를strip과Locale.ROOT소문자로정규화하면java/hash/java/hash입니다. add결과true/true/false/false와size1/2/2/2이며실선은원처리순서입니다. sorted는표현순서입니다.
입력 Javastrip·소문자화 → javaadd=true / size=1
연결 관계입력 Java · strip·소문자화 → java · add=true / size=1 → 입력 hash (공백) · strip·소문자화 → hash · add=true / size=2
입력 hash (공백)strip·소문자화 → hashadd=true / size=2
연결 관계입력 hash (공백) · strip·소문자화 → hash · add=true / size=2 → 입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2
입력 JAVAstrip·소문자화 → java기존 값 / false / size=2
연결 관계입력 JAVA · strip·소문자화 → java · 기존 값 / false / size=2 → 입력 Hash · strip·소문자화 → hash · 기존 값 / false / size=2
입력 Hashstrip·소문자화 → hash기존 값 / false / size=2

실선: 원 네 입력의 처리 순서소문자는 Locale.ROOT 사용최종 sorted 출력: hash, java

정규화한 태그를 추가할 때의 실제 결과
원문 입력정규화된 키add 반환값호출 뒤 size
"Java""java"true1
" hash ""hash"true2
"JAVA""java"false2
"Hash""hash"false2
"Java"
정규화된 키
"java"
add 반환값
true
호출 뒤 size
1
" hash "
정규화된 키
"hash"
add 반환값
true
호출 뒤 size
2
"JAVA"
정규화된 키
"java"
add 반환값
false
호출 뒤 size
2
"Hash"
정규화된 키
"hash"
add 반환값
false
호출 뒤 size
2

입력·정규화된 키·add 결과는 실제 출력에 있고, 각 행의 size는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java]는 sorted()의 결과이며 HashSet의 반복 순서를 뜻하지 않습니다.

이 예의 조건

  • 소문자화는 Locale.ROOT이며 sorted는 표현 순서이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
5add=true
6size=1
9add=true
10size=2
13기존 값
15size=2
18기존 값
20size=2
원 도표의 설명과 주석
구분내용
설명원래 입력 Java/공백 hash/JAVA/Hash를 strip과 Locale.ROOT 소문자로 정규화하면 java/hash/java/hash입니다. add 결과는 true/true/false/false이고 size는 1/2/2/2이며 실선은 원래 처리 순서입니다. sorted는 표현 순서입니다.
전체 읽기 정보정규화한 태그를 추가할 때의 실제 결과 정규화한 태그를 추가할 때의 실제 결과 네 원문 입력을 strip과 Locale.ROOT 소문자로 정규화한 뒤 add 반환값과 중복 상태를 비교한다. 입력 정규화 · 중복 키 정규화한 태그를 추가할 때의 실제 결과 공백을 제거하고 Locale.ROOT로 소문자화하면 Java와 JAVA가 같은 키가 됩니다. 네 입력 중 새 키는 java와 hash 두 개입니다. 정규화한 태그를 추가할 때의 실제 결과 원문 입력 정규화된 키 add 반환값 호출 뒤 size "Java" "java" true 1 " hash " "hash" true 2 "JAVA" "java" false 2 "Hash" "hash" false 2 "Java" 정규화된 키 "java" add 반환값 true 호출 뒤 size 1 " hash " 정규화된 키 "hash" add 반환값 true 호출 뒤 size 2 "JAVA" 정규화된 키 "java" add 반환값 false 호출 뒤 size 2 "Hash" 정규화된 키 "hash" add 반환값 false 호출 뒤 size 2 입력·정규화된 키· add 결과는 실제 출력에 있고, 각 행의 size 는 원문의 추가 과정을 추적한 값입니다. 마지막 tags=[hash, java] 는 sorted() 의 결과이며 HashSet 의 반복 순서를 뜻하지 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
첫 정규화

Java를 java로 만들어 등록합니다.

  • add: true
  • 집합: java
  • size: 1
  • 원시 문자열 대신 정규화한 키로 식별한다.
다른 키

공백 hash를 strip하여 추가합니다.

  • add: true
  • 집합: java,hash
  • size: 2
  • 새 정규화 값이므로 개수가 증가한다.
두 중복

JAVA/Hash는 기존 키가 되어 거절됩니다.

  • 결과: false,false
  • size: 2 유지
  • sorted: hash,java
  • 대소문자 차이가 식별 차이로 남지 않는다.

toLowerCase에 Locale.ROOT를 넘긴 데도 이유가 있습니다. 인수 없는 toLowerCase()는 실행 환경의 기본 로캘(locale), 즉 언어 설정을 따릅니다. 기본 로캘이 터키어라면 "TITLE".toLowerCase()는 영문 i(U+0069) 대신 점 없는 ı(U+0131)가 들어간 tıtle을 만듭니다. 그러면 같은 태그가 서버의 언어 설정에 따라 다른 문자열이 되어 버리므로, 언어와 상관없이 같은 결과를 내도록 Locale.ROOT를 명시했습니다. 이 동작은 String.toLowerCase 공식 문서에도 예로 나와 있습니다. 다만 공백 제거와 소문자 변환만으로 유니코드의 모든 표기 차이가 하나로 합쳐지지는 않습니다. 무엇을 같은 태그로 볼지는 서비스가 정할 규칙이고, 그 규칙을 정규화 코드로 옮겨야 합니다.

정규화를 마친 값만 집합에 들어가므로, 이제 String의 equals와 hashCode가 서비스가 정한 '같은 태그'의 기준과 일치합니다. 정규화하지 않은 원문을 그대로 넣으면 hashCode가 아무리 올바르게 계산되어도 Java와 JAVA가 따로 저장됩니다.

정리

  • 문자 코드를 더하는 해시는 순서 정보를 잃고 결과가 좁은 범위에 몰립니다. 소문자 세 글자 문자열 17576개가 76가지 값으로만 나뉩니다.
  • String.hashCode()는 이전 결과에 31을 곱하고 다음 char를 더하는 계산을 반복합니다. 앞 문자일수록 31이 많이 곱해지므로 글자 순서가 결과에 반영됩니다.
  • 계산 중의 오버플로는 정해진 동작이고 음수 해시 코드도 정상입니다. 버킷 번호를 구할 때 Math.floorMod로 0 이상의 값을 만듭니다.
  • 해시 코드가 같은 문자열("Aa"와 "BB")도 있으므로 해시 집합은 마지막에 equals로 같은 원소인지 판단합니다.
  • 대소문자나 공백을 무시해야 한다면 해시 함수가 아니라 정규화로 해결합니다. 소문자 변환에는 Locale.ROOT를 명시합니다.

연습 문제

문제 1: "cat"의 해시 코드를 손으로 계산하기

문자열 "cat"의 해시 코드를 한 글자씩 계산하고, 버킷이 16개일 때의 버킷 번호를 구하세요. 'c'는 99, 'a'는 97, 't'는 116이고 누적값은 0에서 시작합니다. 같은 글자로 이루어진 "act"의 해시 코드는 같을까요?

해설 보기
exercise/StringHashTraceSolution.java
public final class StringHashTraceSolution {
    public static void main(String[] args) {
        String value = "cat";
        int hash = 0;
        for (int i = 0; i < value.length(); i++) {
            int code = value.charAt(i);
            hash = 31 * hash + code;
            System.out.println(i + ": code=" + code + ", hash=" + hash);
        }
        System.out.println("same=" + (hash == value.hashCode()));
        System.out.println("bucket=" + Math.floorMod(hash, 16));
        System.out.println("act=" + "act".hashCode());
    }
}
실행 결과
0: code=99, hash=99
1: code=97, hash=3166
2: code=116, hash=98262
same=true
bucket=6
act=96402

중간값은 0 × 31 + 99 = 99, 99 × 31 + 97 = 3166, 3166 × 31 + 116 = 98262입니다. 직접 계산한 98262가 String.hashCode()와 같으므로 same=true이고, 98262 = 6141 × 16 + 6이므로 버킷 번호는 6입니다. "act"는 같은 세 글자로 이루어졌지만 해시 코드가 96402로 다릅니다. 문자 합이었다면 둘 다 312로 같았을 것입니다.

문제 2: 해시 코드는 같고 버킷 번호만 바뀌는 경우

버킷을 16개에서 32개로 늘리면 "cat"의 해시 코드와 버킷 번호는 각각 어떻게 될까요? 이 결과가 재해시와 어떤 관계가 있는지 설명하세요.

해설 보기

해시 코드는 문자열만으로 정해지므로 98262 그대로입니다. 버킷 번호는 floorMod(98262, 32)를 다시 계산해야 하고, 98262 = 3070 × 32 + 22이므로 22입니다. 버킷 16개일 때는 6이었으니 자리가 바뀌었습니다. 이처럼 버킷 수가 바뀌면 같은 해시 코드에서도 다른 번호가 나올 수 있으므로, 재해시할 때는 모든 원소의 버킷 번호를 새 버킷 수로 다시 계산합니다. 반면 해시 코드 자체는 다시 계산할 필요가 없습니다. 실제로 OpenJDK의 HashMap은 원소마다 해시 값을 저장해 두고 재해시 때 그 값을 다시 씁니다.

문제 3: 해시 코드가 같은 문자열 찾기

"Aa"와 "BB"가 충돌하는 원리를 이용해, 해시 코드가 같은 두 글자 문자열 쌍을 하나 더 찾으세요.

해설 보기

두 글자 문자열의 해시 코드는 첫 문자 × 31 + 둘째 문자입니다. 첫 문자를 1 늘리면 31이 늘어나므로, 둘째 문자를 31 줄이면 해시 코드가 그대로입니다. 예를 들어 "Ab"는 65 × 31 + 98 = 2113이고, 첫 문자를 B(66)로 바꾸고 둘째 문자를 98 − 31 = 67인 C로 바꾼 "BC"도 66 × 31 + 67 = 2113입니다. 실행해 보면 "Ab".hashCode()와 "BC".hashCode()가 둘 다 2113으로 나옵니다. 둘째 문자를 31 줄였을 때도 문자 코드로 쓸 수 있는 값이기만 하면 이런 쌍은 얼마든지 만들 수 있습니다.

다음 글

문자열은 equals와 hashCode가 이미 내용을 기준으로 구현되어 있어서 해시 집합에 바로 넣을 수 있었습니다. 다음 글에서는 직접 만든 클래스를 해시 집합에 넣을 때 무슨 일이 생기는지 봅니다. 내용이 같은 두 객체가 왜 중복으로 걸러지지 않는지 확인하고, equals와 hashCode를 함께 구현할 때 지켜야 할 규칙을 배웁니다.