---
title: "한 스레드만 만지면 잠글 것이 없다"
description: "자물쇠는 부딪혔을 때 처리하는 도구입니다. 애초에 안 부딪히게 짜면 자물쇠가 필요 없습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/concurrency"
tags: ["C#", "동시성"]
canonical: https://blog.unialgames.com/posts/csharp-single-owner/
---
지금까지 본 것은 전부 **부딪혔을 때 어떻게 할까**였습니다.
`Interlocked` 도 `lock` 도 `CompareExchange` 도 여러 스레드가 같은 것을 건드린다는 전제 위에 있습니다.

그런데 전제 자체를 없앨 수 있습니다.

> **그 데이터를 만지는 스레드가 하나뿐이면 지킬 것이 없습니다. 공짜입니다.**

## 왜 이게 제일 좋은 답인가

`lock` 을 아무리 잘 써도 남는 것들이 있습니다.

- 잠근 구간만큼 남이 기다립니다
- 자물쇠가 늘면 잡는 순서를 지켜야 하고, 안 지키면 데드락입니다
- 어느 자물쇠가 무엇을 지키는지 컴파일러가 안 봐 줍니다. 사람이 기억해야 합니다
- 새로 들어온 사람이 규칙을 모르고 한 줄 추가하면 그날부터 깨집니다

**마지막이 제일 큽니다.** `lock` 으로 지킨 코드는 시간이 지나면서 조용히 무너집니다.
반면 "이 데이터는 저 스레드만 만진다" 는 규칙은 깨기가 훨씬 어렵습니다. 만질 통로 자체가 없으니까요.

그래서 순서가 이렇게 됩니다.

1. **안 나누면 되나** → 한 스레드가 다 갖는다
2. 나눠야 하면 → 값 하나인가? `Interlocked`
3. 여러 줄인가? `lock`

1번부터 봅니다. 대부분의 경우 1번으로 끝납니다.

## 게임 루프가 이미 그렇게 생겼다

이 방식이 낯설어 보이지만 이미 쓰고 있습니다. 유니티가 그렇습니다.

`Update()` 는 매 프레임 **메인 스레드 하나**에서 불립니다.
그래서 `transform.position` 을 고칠 때 아무도 `lock` 을 걸지 않습니다. 걸 이유가 없습니다.
유니티가 대부분의 API 를 메인 스레드에서만 부르게 막아 둔 것도 같은 이유입니다.

게임 서버도 같은 구조를 쓸 수 있습니다. **일정한 간격으로 도는 루프 하나가 세계를 진행시키는 것**입니다.

## 실제로 이렇게 했다

제 게임 서버에서 몬스터를 움직이는 코드는 스레드 하나입니다.

```csharp
/// 서버 권위 몬스터 시뮬레이션 루프. 고정 10Hz 로 모든 방의 몬스터를 진행(이동/페이즈)하고
/// S_MonsterState 를 방에 브로드캐스트한다.
public class RoomTickService(RoomManager roomManager, ILogger<RoomTickService> logger)
    : BackgroundService
{
    private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(100); // 10Hz
    private const float Dt = 0.1f;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TickInterval);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try { TickAllRooms(); }
            catch (Exception e) { logger.LogError(e, "Error in RoomTickService tick"); }
        }
    }
}
```

방이 백 개든 몬스터가 천 마리든 **이 루프 하나가 전부 돕니다.**
그래서 몬스터 이동 계산에는 자물쇠가 없습니다. 동시에 만지는 사람이 없으니까요.

한 틱에서 하는 일이 이렇습니다.

```csharp
private void TickAllRooms()
{
    long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
    foreach (var room in roomManager.GetAllRooms())
    {
        var packets = room.TickMonsters(Dt, nowMs);
        foreach (var packet in packets)
            room.Broadcast(packet);

        room.RegenAllPlayerMana(Dt);
    }

    roomManager.SweepDisconnectedPlayers(nowMs);
}
```

몬스터 이동, 마나 회복, 끊긴 플레이어 정리가 전부 한 줄에 서 있습니다.
**순서가 정해져 있고 겹치지 않습니다.**

## 여기서 배운 것 세 가지

**하나. 고정 간격을 씁니다.** `PeriodicTimer` 는 다음 틱까지 기다립니다.
`Thread.Sleep(100)` 을 쓰면 처리 시간이 더해져서 점점 밀립니다.
`Dt = 0.1f` 를 상수로 둔 것도 같은 맥락입니다. 실제 경과 시간을 재서 쓰면
틱이 밀릴 때 이동 거리가 튀어서 클라이언트 예측과 어긋납니다.
**시뮬레이션은 시계가 아니라 틱 수로 진행하는 편이 예측하기 쉽습니다.**

**둘. 틱 본문을 `try/catch` 로 감쌉니다.**

`BackgroundService` 에서 예외가 새어 나가면 호스트가 종료됩니다.
방 하나의 몬스터 계산이 터졌다고 서버 전체가 죽으면 안 됩니다.
그래서 한 틱이 실패해도 다음 틱은 그대로 돕니다.

**셋. 계산과 상태를 나눕니다.** 주석에 이렇게 적어 뒀습니다.

> AI 수식은 MonsterAiMath(순수), 상태/락은 Room.TickMonsters

이동 거리를 구하는 계산은 아무것도 안 건드리는 순수 함수로 뺐습니다.
그래서 그 부분은 스레드를 생각할 필요가 없고 테스트도 쉽습니다.
**동시성을 생각해야 하는 코드의 양을 줄이는 것** 자체가 목적입니다.

## 그래서 자물쇠가 사라졌나

아닙니다. 정직하게 말하면 **줄었을 뿐**입니다.

플레이어가 보낸 패킷은 다른 스레드에서 들어옵니다. 그 핸들러도 `Room` 을 만집니다.
그래서 `Room` 안에는 여전히 `lock` 이 있습니다. 앞에서 본 `lock (_monsters)` 가 그것입니다.

틱 루프가 없앤 것은 **틱이 하는 일끼리의 경쟁**입니다.
몬스터 백 마리를 백 개 스레드로 돌렸다면 서로 부딪혔을 텐데, 한 줄로 세워서 그 경쟁이 통째로 사라졌습니다.

완전히 없애려면 패킷도 큐에 넣어서 틱 스레드가 꺼내 처리하게 하면 됩니다.
그러면 `Room` 을 만지는 스레드가 정말 하나가 되고 자물쇠가 전부 없어집니다.
많은 게임 서버가 그렇게 짭니다. 대신 패킷 응답이 최대 한 틱만큼 늦어집니다.

**어디까지 몰지는 지연과 단순함 사이의 선택입니다.** 저는 중간에서 끊었습니다.

## 이 구조가 안 맞는 경우

한 스레드에 몰면 **그 스레드가 병목이 됩니다.** 코어가 여덟 개여도 하나만 씁니다.

- 한 틱 안에 끝나야 할 계산이 틱 간격을 넘기면 밀리기 시작합니다
- 밀린 채로 계속 가면 회복이 안 됩니다

그래서 무거운 것은 그 루프에서 빼야 합니다. 데이터베이스 쓰기나 외부 호출 같은 것이요.
그런 일은 다른 곳으로 넘기고 루프는 계산만 합니다.

**루프 안에서 절대 기다리지 않는다**가 원칙입니다. 앞에서 "잠근 채로 남을 부르지 않는다" 고 한 것과 같은 이야기입니다.

## 정리

- 그 데이터를 만지는 스레드가 하나면 **지킬 것이 없습니다.** 제일 싼 답입니다
- `lock` 은 시간이 지나며 조용히 무너집니다. 규칙을 사람이 기억해야 하기 때문입니다
- 순서는 **안 나누기 → `Interlocked` → `lock`** 입니다. 대부분 첫 번째로 끝납니다
- 고정 간격 타이머를 쓰고, 틱 본문을 `try/catch` 로 감싸고, 계산은 순수 함수로 뺍니다
- 자물쇠가 사라지는 게 아니라 **줄어듭니다.** 어디까지 몰지는 지연과의 맞바꿈입니다
- 한 스레드에 몰면 그 스레드가 병목입니다. 기다리는 일은 루프 밖으로 뺍니다