---
title: "내가 처음인지 물어보려면 CompareExchange 다"
description: "lock 은 들어가도 되는지만 알려 줍니다. 딱 한 번만 일어나야 하는 일에는 다른 도구가 필요합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-compare-exchange/
---
던전에서 마지막 몬스터가 죽으면 클리어를 발화합니다. 그런데 마지막 타격은 여러 명이 동시에 넣을 수 있습니다.

**클리어는 딱 한 번만 발화해야 합니다.** 두 번 나가면 보상이 두 번 지급됩니다.

`lock` 으로 써 보면 이렇게 됩니다.

```csharp
lock (_자물쇠)
{
    if (_클리어됨) return false;
    _클리어됨 = true;
    return true;
}
```

동작은 합니다. 그런데 하는 일에 비해 장치가 큽니다. 지키는 것이 `bool` 하나인데 자물쇠를 하나 두고 기다리게 만듭니다.

> **CPU 에 "값이 이것이면 저것으로 바꾸고, 원래 값을 알려 줘" 라는 한 걸음짜리 명령이 있습니다.**

## CompareExchange 는 이렇게 생겼다

```csharp
Interlocked.CompareExchange(ref 대상, 새값, 기대값)
```

하는 일은 이렇습니다. **끊기지 않는 한 동작으로** 처리됩니다.

1. `대상` 이 `기대값` 과 같은가
2. 같으면 `새값` 으로 바꾼다. 다르면 아무것도 안 한다
3. 어느 쪽이든 **바꾸기 전의 값**을 돌려준다

3번이 핵심입니다. 돌려받은 값이 `기대값` 이면 **내가 바꾼 것**이고, 아니면 남이 이미 바꾼 것입니다.

```csharp
private int _발화됨;   // 0 = 아직, 1 = 발화함

bool 처음인가()
{
    return Interlocked.CompareExchange(ref _발화됨, 1, 0) == 0;
}
```

동시에 열 명이 불러도 `true` 를 받는 사람은 정확히 한 명입니다.

`lock` 과 무엇이 다른지 보면 이렇습니다.

| | `lock` | `CompareExchange` |
| --- | --- | --- |
| 알려 주는 것 | 들어가도 된다 | **내가 바꾼 사람인가** |
| 못 들어가면 | 기다린다 | 기다리지 않는다. 실패를 바로 알려 준다 |
| 지킬 수 있는 것 | 여러 줄 | 변수 하나 |

기다리지 않는다는 게 중요합니다. 진 사람은 서 있지 않고 바로 `false` 를 받아 자기 갈 길을 갑니다.

## 값이 둘 이상이면 상호 배타가 된다

`bool` 대신 `int` 를 쓰면 **여러 결말 중 하나만** 고르게 만들 수 있습니다.

던전은 클리어로 끝나거나 실패로 끝납니다. 둘 다 일어나면 안 됩니다.
마지막 몬스터를 잡는 순간 마지막 플레이어가 죽을 수도 있으니 실제로 겹칠 수 있는 상황입니다.

```csharp
// 0 = 아직, 1 = 클리어, 2 = 실패
private int _outcome;

bool 클리어claim() => Interlocked.CompareExchange(ref _outcome, 1, 0) == 0;
bool 실패claim()   => Interlocked.CompareExchange(ref _outcome, 2, 0) == 0;
```

둘 다 **`0` 일 때만** 성공합니다. 그래서 한쪽이 이기면 다른 쪽은 자동으로 집니다.
자물쇠를 공유하지 않았는데도 상호 배타가 됩니다. 같은 변수 하나를 두고 다투기 때문입니다.

## 실제로 이렇게 썼다

제 게임 서버의 `Room` 에 그대로 있습니다. 주석까지 이 이야기입니다.

```csharp
// 던전 결과(클리어/실패) — 단일 terminal 상태. 0=None,1=Cleared,2=Failed.
// Interlocked.CompareExchange 로 최초 1회만 claim → 클리어/실패 상호 배타(둘 다 발화 불가).
private int _outcome;
```

클리어 판정은 이렇게 생겼습니다.

```csharp
public bool TryMarkCleared()
{
    lock (_monsters)
    {
        if (!_monstersSpawned || _monsters.Count > 0)
            return false;
    }

    // 전멸 확인 후 terminal 상태를 원자적으로 claim — 실패와 동시 발화 불가.
    return Interlocked.CompareExchange(ref _outcome, 1, 0) == 0;
}
```

**한 메서드에 두 도구가 같이 있습니다.** 그리고 자리가 다릅니다.

- `lock (_monsters)` 는 **컬렉션을 읽는 동안**만 걸립니다. 몬스터가 정말 전멸했는지 세는 구간입니다
- `CompareExchange` 는 그 **락 밖에서** 일어납니다

실패 판정도 같은 모양인데 자물쇠가 다릅니다.

```csharp
public (bool NewlyDowned, bool FailClaimed) MarkPlayerDowned(long userId)
{
    bool newly, allDown;
    lock (_downed)
    {
        newly = _downed.Add(userId);
        allDown = _downed.Count >= _expectedUserIds.Count;
    }

    bool failClaimed = allDown
        && Interlocked.CompareExchange(ref _outcome, 2, 0) == 0;
    return (newly, failClaimed);
}
```

여기가 이 글에서 제일 중요한 지점입니다.

**클리어는 `_monsters` 를 잠그고 실패는 `_downed` 를 잠급니다. 서로 다른 자물쇠입니다.**
그러니 `lock` 으로는 둘을 상호 배타로 만들 수 없습니다. 동시에 들어갈 수 있으니까요.

둘을 갈라 주는 것은 **`_outcome` 이라는 변수 하나**입니다.
둘 다 그 하나를 `0` 에서 바꾸려 하고, 한 번만 성공합니다.

세 번째 자물쇠를 만들어 둘을 묶을 수도 있었습니다. 그런데 그러면 자물쇠가 셋이 되고,
잡는 순서를 지켜야 하고, 앞에서 본 데드락 가능성이 생깁니다.
**변수 하나로 끝나는 일에 자물쇠를 늘리지 않는 편이 낫습니다.**

## 되돌아보기 패턴

`CompareExchange` 의 다른 쓰임이 하나 더 있습니다. **단순한 셈이 아닌 갱신**입니다.

`Interlocked.Add` 는 더하기만 합니다. "지금 값의 두 배로" 나 "지금 값과 새 값 중 큰 쪽으로" 같은 것은 못 합니다.
그럴 때는 이렇게 씁니다.

```csharp
int 현재, 새것;
do
{
    현재 = _최고기록;
    새것 = Math.Max(현재, 점수);
}
while (Interlocked.CompareExchange(ref _최고기록, 새것, 현재) != 현재);
```

읽고, 계산하고, **읽었을 때와 값이 그대로면** 씁니다. 그 사이에 남이 바꿨으면 돌려받은 값이 달라서 다시 돕니다.

기다리는 대신 **다시 시도합니다.** 이걸 낙관적 갱신이라고 부릅니다.
경쟁이 드물면 대개 한 번에 끝나서 `lock` 보다 쌉니다. 반대로 경쟁이 심하면 계속 헛돌아서 더 나쁩니다.

그래서 판단 기준이 나옵니다.

- **경쟁이 드물고 갱신이 짧다** → `CompareExchange`
- **경쟁이 잦거나 지킬 것이 여러 줄이다** → `lock`

## 정리

- `CompareExchange(ref x, 새값, 기대값)` 는 **기대값일 때만 바꾸고 원래 값을 돌려줍니다**
- 돌려받은 값이 기대값이면 **내가 바꾼 사람**입니다. `lock` 은 이걸 못 알려 줍니다
- 진 사람은 기다리지 않고 바로 실패를 받습니다
- `int` 로 결말을 나누면 자물쇠를 공유하지 않고도 **상호 배타**가 됩니다
- 자물쇠가 서로 다른 두 경로를 갈라 주는 것이 변수 하나일 수 있습니다
- 단순한 셈이 아닌 갱신은 `do/while` 로 다시 시도합니다. 경쟁이 드물 때만 이득입니다