---
title: "Equals 와 GetHashCode 는 반드시 같이 고친다"
description: "방금 넣은 값을 같은 키로 꺼냈는데 없다고 나옵니다. 해시가 먼저 자리를 정하기 때문입니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#"]
canonical: https://blog.unialgames.com/posts/csharp-equals-gethashcode/
---
좌표를 키로 쓰는 코드를 짜 봅니다.

```csharp
class 칸
{
    public int X;
    public int Y;
    public 칸(int x, int y) { X = x; Y = y; }
}

var 지도 = new Dictionary<칸, string>();
지도[new 칸(1, 2)] = "보물";

Console.WriteLine(지도[new 칸(1, 2)]);   // KeyNotFoundException
```

방금 넣었는데 없다고 합니다. `(1, 2)` 로 넣고 `(1, 2)` 로 꺼냈는데요.

> **Dictionary 는 키가 같은지 물어보기 전에 먼저 해시로 자리를 정합니다. 그 자리가 다르면 비교할 기회조차 없습니다.**

## 기본 Equals 는 내용을 안 본다

`class` 로 만든 타입은 아무것도 안 해 주면 **주소로 비교**합니다.

```csharp
칸 a = new 칸(1, 2);
칸 b = new 칸(1, 2);

Console.WriteLine(a == b);        // False
Console.WriteLine(a.Equals(b));   // False
```

`new` 를 두 번 했으니 힙에 상자가 둘입니다. 주소가 다르니 다른 것입니다.
안에 든 값이 같은지는 아무도 안 봤습니다.

이게 기본값인 데는 이유가 있습니다. 컴파일러는 `X` 와 `Y` 가 이 객체의 정체성인지 알 수 없습니다.
`Dog` 라면 이름이 같아도 다른 개일 수 있습니다. 그래서 **판단을 우리에게 넘깁니다.**

`struct` 는 조금 다릅니다. 기본으로 필드를 하나씩 비교해 줍니다.
그래서 앞에서 만든 `점` 은 `Equals` 를 안 고쳐도 `(1,2)` 와 `(1,2)` 가 같다고 나옵니다.
다만 그 기본 구현은 안에서 리플렉션을 쓰거나 박싱을 하는 경우가 있어 느립니다.
값 타입도 자주 비교할 것이면 직접 적어 주는 편이 낫습니다.

## 그래서 Equals 를 고쳤는데도 못 찾는다

내용으로 비교하게 고쳐 봅니다.

```csharp
public override bool Equals(object obj)
{
    if (obj is 칸 other)
        return X == other.X && Y == other.Y;
    return false;
}
```

이제 `a.Equals(b)` 는 `True` 입니다. 그런데 Dictionary 는 **여전히 못 찾습니다.**

앞에서 해시 테이블을 봤습니다. 값을 넣을 때 키를 훑어보는 것이 아니라, 키로 숫자를 계산해서 그 번호의 칸에 넣었습니다.
찾을 때도 마찬가지로 계산부터 합니다.

<x-steps>
<script type="application/json">
{
  "steps": [
    { "tag": "1", "title": "GetHashCode 를 부른다", "text": "키에서 숫자를 하나 뽑습니다. 이 숫자로 몇 번 칸을 볼지 정합니다." },
    { "tag": "2", "title": "그 칸만 본다", "text": "다른 칸은 쳐다보지도 않습니다. 이게 빠른 이유입니다." },
    { "tag": "3", "title": "칸 안에서 Equals 로 확인한다", "text": "같은 칸에 다른 키가 들어와 있을 수 있어서 마지막에 한 번 더 확인합니다." }
  ]
}
</script>
</x-steps>

`Equals` 는 **3번에서만** 쓰입니다. 자리를 정하는 것은 1번이고, 거기서는 `GetHashCode` 만 봅니다.

기본 `GetHashCode` 는 객체마다 다른 숫자를 돌려줍니다. `new` 를 두 번 했으니 숫자가 다릅니다.
숫자가 다르면 다른 칸을 보고, 그 칸은 비어 있고, 없다고 답합니다.
**`Equals` 까지 가지도 못했습니다.**

## 둘은 짝이다

여기서 규칙 하나가 나옵니다. 외우는 규칙이 아니라 위 순서에서 저절로 나오는 것입니다.

> **같다고 판단한 둘은 해시도 반드시 같아야 합니다.**

같은데 해시가 다르면 서로 다른 칸에 가서 영영 못 만납니다.

반대는 성립하지 않아도 됩니다. **해시가 같은데 다른 것은 괜찮습니다.**
같은 칸에 둘이 들어오면 그때 `Equals` 로 갈라내면 되니까요.
해시는 숫자 하나로 줄이는 일이라 겹치는 것을 아예 막을 수는 없습니다.

그래서 둘을 같이 고칩니다.

```csharp
public override bool Equals(object obj)
{
    if (obj is 칸 other)
        return X == other.X && Y == other.Y;
    return false;
}

public override int GetHashCode()
{
    return HashCode.Combine(X, Y);
}
```

`HashCode.Combine` 은 여러 값을 하나의 숫자로 잘 섞어 줍니다. 직접 만들 이유가 없습니다.
없는 환경이면 이렇게 씁니다.

```csharp
public override int GetHashCode()
{
    unchecked
    {
        return X * 397 ^ Y;
    }
}
```

`unchecked` 는 넘쳐도 그냥 두라는 뜻입니다. 정수는 원이라 한 바퀴 돌아도 상관없습니다.
해시는 넘치는 것이 정상입니다.

## 무엇으로 해시를 만드나

**Equals 에서 본 필드만** 씁니다. 그리고 그 필드는 **넣은 뒤에 안 바뀌어야** 합니다.

이걸 어기면 이런 일이 생깁니다.

```csharp
칸 k = new 칸(1, 2);
지도[k] = "보물";

k.X = 5;                       // 키로 쓴 객체를 고쳤습니다

Console.WriteLine(지도[k]);     // 못 찾습니다
```

넣을 때는 `(1,2)` 의 해시로 계산한 칸에 들어갔습니다.
지금 찾을 때는 `(5,2)` 의 해시로 계산하니 다른 칸을 봅니다.
값은 분명 안에 있는데 아무도 꺼낼 수 없습니다. 지우지도 못합니다.

그래서 키로 쓸 타입은 만든 뒤에 안 바뀌게 만드는 편이 안전합니다.
`readonly` 로 잠그거나, 아예 `record` 로 만들면 됩니다.

## 그냥 record 를 쓰면 된다

C# 9 부터는 이 셋을 한 줄로 끝낼 수 있습니다.

```csharp
record 칸(int X, int Y);
```

`Equals`, `GetHashCode`, `ToString`, `==`, `!=` 를 전부 만들어 줍니다. 내용으로 비교하는 쪽으로요.

```csharp
var 지도 = new Dictionary<칸, string>();
지도[new 칸(1, 2)] = "보물";
Console.WriteLine(지도[new 칸(1, 2)]);   // 보물
```

`record struct` 라고 쓰면 값 타입 record 가 됩니다.

그럼 직접 쓸 일이 없느냐 하면 그렇지는 않습니다.
필드 일부만 정체성으로 삼고 싶을 때가 있습니다. 예를 들어 아이디가 같으면 같은 사용자로 보고 싶은데,
`record` 는 모든 필드를 비교하니 이름이 다르면 다른 사람이 됩니다.
그때는 직접 적어야 합니다.

## 정리

- 기본 `Equals` 는 `class` 에서 주소를 봅니다. 내용이 같아도 다른 것으로 나옵니다
- Dictionary 는 `GetHashCode` 로 **자리를 먼저 정하고** 그 칸 안에서만 `Equals` 를 씁니다
- 그래서 `Equals` 만 고치면 못 찾습니다. `GetHashCode` 를 안 고쳐 자리가 어긋납니다
- 같으면 해시도 같아야 합니다. 해시가 같은데 다른 것은 괜찮습니다
- 해시는 **안 바뀌는 필드**로 만듭니다. 키로 쓴 객체를 고치면 영영 못 찾습니다
- 대부분은 `record` 한 줄로 끝납니다