---
title: "취소는 부탁이지 명령이 아니다"
description: "스레드를 강제로 죽이는 방법은 없습니다. 대신 \"이제 그만해 주세요\" 를 전달하고 받는 쪽이 협조합니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-cancellation/
---
서버를 내립니다. 지금 돌고 있는 것들이 이만큼입니다.

- 세션 수백 개의 수신 루프
- 방 틱 루프 (10Hz)
- 하트비트 검사 루프 (15초마다)
- 송신 루프 (세션마다 하나)

이것들을 어떻게 세울까요. 강제로 죽이면 될 것 같은데, **그 방법이 없습니다.**

`Thread.Abort` 는 .NET Core 부터 아예 사라졌습니다. 부르면 예외가 납니다.
없앤 이유가 분명합니다. **아무 데서나 끊으면 뒷정리를 못 합니다.**
자물쇠를 잡은 채 죽으면 그 자물쇠는 영원히 안 풀리고, 파일을 반쯤 쓰다 죽으면 그 파일은 깨집니다.

> **그래서 취소는 부탁입니다. 받는 쪽이 알아서 정리하고 나가 줘야 합니다.**

## CancellationToken 은 둘로 되어 있다

```csharp
var cts = new CancellationTokenSource();
CancellationToken token = cts.Token;

cts.Cancel();   // 부탁하는 쪽
```

**취소를 시키는 쪽은 `CancellationTokenSource`, 받는 쪽은 `CancellationToken`** 입니다.

일부러 나눠 놨습니다. 토큰만 받은 코드는 **취소를 당할 수만 있고 시킬 수는 없습니다.**
남에게 토큰을 넘겨도 그쪽이 내 취소를 발동시킬 수 없다는 뜻입니다.

`CancellationTokenSource` 는 `IDisposable` 입니다. 타이머를 걸어 두는 경우가 있어서 정리가 필요합니다.

## 받는 쪽이 하는 일

토큰을 받았으면 둘 중 하나는 해야 합니다. 안 하면 취소해도 안 멈춥니다.

**하나. 주기적으로 확인합니다.**

```csharp
while (Connected && !ct.IsCancellationRequested)
{
    ...
}
```

**둘. 아래로 그대로 넘깁니다.**

```csharp
byte[] lengthBytes = await ReceiveExactAsync(4, ct);
await _dispatcher.Dispatch(this, packet, ct);
```

넘기는 쪽이 더 중요합니다. **기다리는 자리에 토큰이 안 들어가면 취소가 안 먹습니다.**
`ReceiveExactAsync` 안에서 소켓을 기다리는 중인데 토큰을 안 줬으면, 클라이언트가 뭔가 보낼 때까지 안 깨어납니다.

그래서 `CancellationToken` 이 매개변수 목록에 계속 따라다닙니다. 제 서버에서는 **172개 파일**에 있습니다.
귀찮아 보이는데, 안 넘기면 그 지점부터 취소가 끊깁니다.

## 기다리는 것에는 전부 넘긴다

`Task.Delay` 가 좋은 예입니다.

```csharp
await Task.Delay(CheckInterval, stoppingToken);
```

토큰을 안 주면 서버를 내려도 **15초를 다 기다린 뒤에야** 종료됩니다.
주면 그 자리에서 깨어납니다.

`PeriodicTimer` 도 같습니다.

```csharp
while (await timer.WaitForNextTickAsync(stoppingToken))
```

취소되면 `false` 를 돌려주거나 예외를 던져서 루프가 끝납니다.

## 취소는 예외로 온다

여기가 처음에 헷갈리는 부분입니다.

토큰을 받은 `await` 가 취소되면 `OperationCanceledException` 이 던져집니다.
그런데 이건 **오류가 아니라 정상 종료**입니다. 우리가 부탁한 대로 된 것이니까요.

그래서 이렇게 구분해서 잡습니다.

```csharp
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
    _logger.LogInformation("Session loop canceled for session {SessionId}", SessionId);
}
catch (SocketException e) when (IsExpectedDisconnect(e.SocketErrorCode))
{
    _logger.LogInformation("Session {SessionId} disconnected by peer", SessionId);
}
catch (Exception e)
{
    _logger.LogError(e, "Session loop failed for session {SessionId}", SessionId);
}
```

`when` 절이 핵심입니다. **내가 취소한 것일 때만** 조용히 넘어갑니다.

`when` 없이 `OperationCanceledException` 을 전부 삼키면, 남이 준 토큰이 취소돼서 온 것까지 정상으로 처리합니다.
그러면 진짜 문제를 못 봅니다.

로그 수준도 갈랐습니다. 정상 종료는 `Information`, 진짜 실패는 `Error` 입니다.
**정상 종료가 에러 로그를 남기면 그 로그는 곧 아무도 안 보게 됩니다.**

## 나갈 때 정리한다

취소가 왔다고 그냥 나가면 안 됩니다. 잡고 있던 것을 놓아야 합니다.

```csharp
finally
{
    RoomManager.LeaveRoom(this, graceful: true);
    Disconnect();

    // 큐를 닫아 SendLoop 을 깨우고 회수한다 — 안 하면 세션마다 Task 가 영원히 남는다(누수).
    _sendQueue.Writer.TryComplete();
    try { await sendLoop; } catch { /* 종료 경로 */ }
}
```

앞에서 송신 루프가 `WaitToReadAsync` 로 큐를 기다린다고 했습니다.
수신 루프만 끝내고 나가면 **그 송신 루프는 영원히 기다립니다.** 세션마다 `Task` 가 하나씩 쌓입니다.

그래서 큐를 닫아 깨우고, `await` 로 정말 끝난 것을 확인하고 나갑니다.
`try/catch` 로 감싼 것은 종료 경로에서 나는 실패는 이미 처리했기 때문입니다.

**이게 강제 종료로는 못 하는 일입니다.** 죽여 버리면 이 `finally` 가 안 돕니다.

## 시키는 쪽

리스너를 보면 취소를 시키는 쪽 전체가 보입니다.

```csharp
public void Start(int backlog = 512)
{
    if (Interlocked.Exchange(ref _running, 1) == 1)
        return;

    _cts = new CancellationTokenSource();
    ...
    _acceptLoopTask = AcceptLoopAsync(_cts.Token);
}

public void Stop()
{
    if (Interlocked.Exchange(ref _running, 0) == 0)
        return;

    _cts?.Cancel();
    _listenSocket?.Close();
    _listenSocket?.Dispose();
    _cts?.Dispose();
}
```

`Interlocked.Exchange` 로 두 번 켜고 끄는 것을 막습니다.
그리고 **`Cancel()` 다음에 `Dispose()`** 순서입니다. 놓기 전에 부탁부터 합니다.

## 시간으로도 취소한다

취소가 꼭 사람이 시키는 것만은 아닙니다. **시간이 시키기도 합니다.**

```csharp
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await 뭔가Async(cts.Token);   // 5초 넘으면 알아서 취소
```

제 서버의 하트비트가 같은 발상입니다. 다만 토큰 대신 직접 시간을 잽니다.

```csharp
var timeout = session.Room != null ? RoomPlayerTimeout : SessionTimeout;
if (now - session.LastRecvAt <= timeout)
    continue;
...
session.Disconnect();
```

방에 있는 사람은 1분, 로비에 있는 사람은 30초입니다.
**상태에 따라 참을성이 다릅니다.** 게임 중인 사람을 30초 만에 끊으면 잠깐 끊긴 사람이 억울하고,
로비에서 놀고 있는 유령 연결을 1분씩 붙잡고 있을 이유는 없습니다.

취소를 이야기할 때 이 부분이 빠지기 쉬운데, **얼마나 기다릴 것인가를 정하는 것**이 실무에서는 절반입니다.

## 정리

- **스레드를 강제로 죽이는 방법은 없습니다.** 뒷정리를 못 하기 때문에 없앴습니다
- `CancellationTokenSource` 가 시키고 `CancellationToken` 이 받습니다. 나눠 둔 이유가 있습니다
- 받는 쪽은 **확인하거나 아래로 넘기거나** 해야 합니다. 안 하면 취소가 안 먹습니다
- **기다리는 자리에 토큰을 안 주면 그 지점부터 취소가 끊깁니다.** `Task.Delay` 도 마찬가지입니다
- 취소는 예외로 옵니다. `when (ct.IsCancellationRequested)` 로 **정상 종료와 진짜 오류를 구분**합니다
- 나갈 때 잡고 있던 것을 놓습니다. 큐를 닫아 소비자를 깨우지 않으면 `Task` 가 남습니다
- 시간으로도 취소합니다. 얼마나 기다릴지는 상태마다 다릅니다