---
title: "count++ 는 한 번에 일어나지 않는다"
description: "한 줄짜리 코드가 기계에서는 세 걸음입니다. 그 사이에 남이 끼어들면 더한 값이 사라집니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-race-condition/
---
스레드 둘이 같은 변수를 천 번씩 더합니다. 결과는 이천이어야 합니다.

```csharp
int count = 0;

void 더하기()
{
    for (int i = 0; i < 1000; i++)
        count++;
}

// 스레드 둘이 더하기() 를 각각 실행
```

돌려 보면 1000 대 후반이 나옵니다. 1873, 1994, 어떤 때는 2000. **매번 다릅니다.**

버그가 매번 다른 답을 내면 원인을 찾기가 아주 어렵습니다. 그리고 이 코드에는 이상해 보이는 곳이 없습니다.

> **`count++` 는 한 줄이지만 기계에서는 세 걸음입니다. 그 사이에 남이 끼어들 수 있습니다.**

## 한 줄이 세 걸음인 이유

CPU 는 메모리에 있는 값을 직접 못 더합니다. 레지스터로 가져와서 더하고 다시 써야 합니다.

```
1. 읽기   count 의 값을 내 레지스터로 가져온다
2. 더하기 레지스터에서 1 을 더한다
3. 쓰기   레지스터 값을 count 에 쓴다
```

앞에서 식은 순서대로 줄어든다고 했습니다. 그 순서가 여기서는 **끊길 수 있는 세 조각**이 됩니다.

스레드는 언제든 멈출 수 있습니다. 운영체제가 CPU 를 다른 스레드에 넘기는 데 우리 허락을 받지 않습니다.
2번과 3번 사이에서 멈출 수도 있다는 뜻입니다.

<x-flow>
<script type="application/json">
{
  "label": "둘이 같이 더하면",
  "lines": [
    "// A: count 를 읽는다",
    "// A: 레지스터에서 +1",
    "// A: count 에 쓴다",
    "// B: count 를 읽는다",
    "// B: 레지스터에서 +1",
    "// B: count 에 쓴다"
  ],
  "dwell": 1.3,
  "cases": [
    {
      "label": "차례로",
      "state": "count = 0",
      "path": [
        { "line": 0, "why": "A 가 0 을 읽습니다.", "state": "count = 0 · A레지스터 = 0" },
        { "line": 1, "why": "A 의 레지스터가 1 이 됩니다.", "state": "count = 0 · A레지스터 = 1" },
        { "line": 2, "why": "A 가 다 쓰고 물러납니다.", "state": "count = 1" },
        { "line": 3, "why": "이제 B 가 시작합니다. 1 을 읽습니다.", "state": "count = 1 · B레지스터 = 1" },
        { "line": 4, "why": "B 의 레지스터가 2 가 됩니다.", "state": "count = 1 · B레지스터 = 2" },
        { "line": 5, "why": "기대한 답입니다.", "state": "count = 2", "out": "2" }
      ],
      "note": "겹치지 않으면 아무 문제가 없습니다. 그래서 대부분의 실행에서는 멀쩡해 보입니다."
    },
    {
      "label": "엇갈려서",
      "state": "count = 0",
      "path": [
        { "line": 0, "why": "A 가 0 을 읽습니다.", "state": "count = 0 · A레지스터 = 0" },
        { "line": 3, "why": "여기서 A 가 멈추고 B 가 돕니다. B 도 0 을 읽습니다. 아직 아무도 안 썼으니까요.", "state": "count = 0 · A레지스터 = 0 · B레지스터 = 0" },
        { "line": 1, "why": "A 가 다시 돕니다. 자기 레지스터에서 +1.", "state": "count = 0 · A레지스터 = 1 · B레지스터 = 0" },
        { "line": 4, "why": "B 도 자기 레지스터에서 +1. 둘 다 1 을 들고 있습니다.", "state": "count = 0 · A레지스터 = 1 · B레지스터 = 1" },
        { "line": 2, "why": "A 가 1 을 씁니다.", "state": "count = 1 · B레지스터 = 1" },
        { "line": 5, "why": "B 도 1 을 씁니다. 덮어씁니다. 두 번 더했는데 1 입니다.", "state": "count = 1", "out": "1" }
      ],
      "note": "누가 틀린 게 아닙니다. 둘 다 자기가 읽은 값에 정확히 1 을 더했습니다."
    }
  ]
}
</script>
</x-flow>

**둘 다 잘못한 게 없습니다.** 각자 자기가 읽은 값에 정확히 1 을 더했습니다.
문제는 B 가 읽은 시점에 A 의 결과가 아직 안 써졌다는 것입니다.

이런 것을 경쟁 조건이라고 부릅니다. 답이 **누가 먼저 도착하느냐**에 따라 달라진다는 뜻입니다.

## 왜 재현이 안 되나

위 그림에서 엇갈리려면 아주 짧은 순간에 스레드가 바뀌어야 합니다. 자주 일어나지 않습니다.
천 번 돌면 몇 번쯤 겹칩니다. 그래서 이렇게 됩니다.

- **개발 기기에서는 잘 됩니다.** 코어가 많고 부하가 적으면 잘 안 겹칩니다
- **테스트도 통과합니다.** 한 번 돌려서 맞으면 통과입니다
- **서버에 올리고 사람이 몰리면 터집니다.** 그리고 로그를 봐도 이상한 데가 없습니다

그래서 경쟁 조건은 **찾아서 고치는 것이 아니라 안 생기게 짜는 것**입니다.
"돌려 봤는데 되던데요" 는 아무것도 증명하지 않습니다.

## 무엇이 위험한가

`++` 만 그런 게 아닙니다. **읽고 판단하고 쓰는 것이 나뉘면** 전부 같은 문제입니다.

```csharp
if (_방장 == null)      // 읽고 판단하고
    _방장 = 나;          // 쓴다
```

둘이 동시에 들어오면 둘 다 `null` 을 보고 둘 다 방장이 됩니다.

```csharp
if (재고 > 0)
    재고--;
```

재고 1 개를 둘이 삽니다.

```csharp
if (!_목록.Contains(x))
    _목록.Add(x);
```

같은 것이 두 번 들어갑니다. 확인과 실행 사이가 벌어져 있어서입니다.

이 모양을 **확인 후 실행**이라고 부릅니다. 확인한 결과가 실행할 때도 참이라는 보장이 없습니다.

한편 이런 것은 안전합니다.

```csharp
int a = _값;            // 읽기만
_다른값 = 5;            // 쓰기만 (32비트 이하 타입)
```

한 걸음짜리는 끊길 자리가 없습니다. 다만 **읽은 값이 최신이라는 보장은 또 다른 이야기**입니다.
그건 뒤에서 따로 봅니다.

## 더하기 하나면 방법이 있다

`count++` 처럼 **한 변수를 한 번 더하는 것**이라면 세 걸음을 한 걸음으로 만들 수 있습니다.
CPU 가 그걸 지원합니다.

```csharp
Interlocked.Increment(ref count);
```

읽고 더하고 쓰는 것을 **끊기지 않는 한 동작**으로 처리합니다.
중간에 다른 스레드가 끼어들 자리가 아예 없습니다. 그리고 잠그지 않으니 싸게 끝납니다.

```csharp
Interlocked.Increment(ref x);    // ++
Interlocked.Decrement(ref x);    // --
Interlocked.Add(ref x, 10);      // += 10
Interlocked.Exchange(ref x, 5);  // = 5 하고 옛 값을 돌려줌
```

`ref` 로 넘기는 것에 주목할 만합니다. 값의 사본이 아니라 **그 자리 자체**를 건드려야 하니까요.

## 실제로 어디에 쓰나

제가 만든 게임 서버에서 이걸 쓴 자리는 전부 **번호 발급**입니다.

접속이 들어올 때마다 세션에 번호를 하나씩 붙입니다.
접속은 여러 스레드에서 동시에 들어오는데, 번호가 겹치면 세션이 서로를 덮어씁니다.

```csharp
// SessionManager.cs
public Session? CreateSession(Socket clientSocket, CancellationToken ct)
{
    var id = Interlocked.Increment(ref _nextSessionId);
    ...
}
```

방 안에서 이펙트 인스턴스에 번호를 붙일 때도 같습니다.

```csharp
// Room.cs
private int _nextEffectInstanceId;

public int NextEffectInstanceId()
    => Interlocked.Increment(ref _nextEffectInstanceId);
```

여기에 `lock` 을 쓸 수도 있었습니다. 그런데 하는 일이 **더하기 하나**뿐이라 그럴 이유가 없습니다.
`lock` 은 여러 줄을 묶어 지킬 때 쓰는 도구입니다. 한 줄짜리에 쓰면 값도 못 하고 비싸기만 합니다.

`Interlocked.Exchange` 를 쓴 자리도 하나 있습니다. 서버를 두 번 켜거나 두 번 끄는 것을 막는 곳입니다.

```csharp
// TcpNetworkListener.cs
public void Start(int backlog = 512)
{
    if (Interlocked.Exchange(ref _running, 1) == 1)
        return;      // 이미 켜져 있었다. 아무것도 안 한다
    ...
}
```

`Exchange` 는 새 값을 넣고 **옛 값을 돌려줍니다.** 그래서 "내가 켠 것인가, 이미 켜져 있었나" 를 한 걸음에 알 수 있습니다.
`if (_running == 1) return; _running = 1;` 로 쓰면 그 사이가 벌어져서 둘 다 통과할 수 있습니다. 위에서 본 그 모양입니다.

## 정리

- `count++` 는 **읽기·더하기·쓰기 세 걸음**입니다. 그 사이에 스레드가 바뀔 수 있습니다
- 둘 다 잘못한 게 없는데 더한 값이 사라집니다. 이게 경쟁 조건입니다
- **답이 매번 다르고 재현이 안 됩니다.** 개발 기기에서는 잘 되고 부하가 걸리면 터집니다
- `++` 만이 아니라 **확인 후 실행**이 전부 같은 문제입니다
- 한 변수에 대한 단순한 셈이면 `Interlocked` 로 한 걸음으로 만들 수 있습니다
- `Exchange` 는 옛 값을 돌려줘서 "내가 처음인가" 를 물을 수 있습니다