Day 4 — 오늘 배운 게임 서버 개발 지식 5가지

학습일: 2026년 11월 4일
학습 주제: LinkedList, A* 알고리즘, 디자인 패턴, ASP.NET Core, PostgreSQL 인덱스

오늘은 자료구조의 내부 동작부터 게임 서버의 HTTP 요청 처리와 데이터베이스 인덱스까지 복습했다.

이번 학습에서 특히 기억에 남은 것은 시간 복잡도가 낮다고 무조건 좋은 자료구조는 아니며, 특정 기능을 사용할 때 내부적으로 어떤 과정이 일어나는지 알아야 한다는 점이다.

1. 자료구조 — LinkedList<T>는 왜 삽입과 삭제가 빠를까?

C#의 LinkedList<T>는 각 노드가 이전 노드와 다음 노드를 참조하는 이중 연결 리스트다.

각 노드는 크게 다음 정보를 가진다.

  • Value: 저장하는 데이터
  • Previous: 이전 노드에 대한 참조
  • Next: 다음 노드에 대한 참조

노드 참조가 있다면 삭제는 O(1)

예를 들어 A, B, C 노드가 순서대로 연결되어 있다고 가정하자.

A ⇄ B ⇄ C

B 노드를 삭제하려면 A의 다음 노드를 C로, C의 이전 노드를 A로 연결하도록 변경하면 된다.

A ⇄ C

이때 B 노드의 참조를 이미 알고 있다면 전체 리스트를 탐색할 필요가 없으므로 O(1)에 삭제할 수 있다.

하지만 삭제하려는 값만 알고 있다면 이야기가 달라진다.

원하는 값이 저장된 노드를 처음부터 찾아야 하므로 최악 O(n)이 발생한다. 노드를 찾은 이후의 삭제는 O(1)이지만, 전체 작업은 O(n)이다.

그렇다면 List보다 LinkedList가 항상 좋을까?

그렇지 않다.

List<T>는 연속적인 배열에 데이터를 저장하므로 인덱스로 접근하는 작업에 강점이 있다. 반면 LinkedList<T>는 노드를 순서대로 따라가야 하며 인덱스 기반 임의 접근을 제공하지 않는다.

또한 LinkedList는 각 노드의 참조와 객체 관리 비용이 추가되고, 노드들이 메모리에 흩어져 있을 수 있어 CPU 캐시 효율도 불리할 수 있다.

따라서 삽입·삭제 O(1)이라는 사실만으로 LinkedList를 선택하면 안 된다.

게임 서버에서도 노드 참조를 유지하면서 빈번하게 요소를 제거하는 구조인지, 아니면 전체 순회나 인덱스 접근이 많은지 고려해야 한다.

2. 알고리즘 — BFS, 다익스트라, A* 그리고 휴리스틱

세 알고리즘은 경로를 탐색한다는 공통점이 있지만, 다음에 탐색할 노드를 선택하는 기준이 다르다.

알고리즘주요 탐색 기준대표적인 사용 조건

BFS 탐색 깊이, 방문 순서 모든 간선의 비용이 동일한 경우의 최단 경로
다익스트라 시작점부터 누적 비용 g(n) 음수 간선이 없는 가중 그래프의 최단 경로
A* f(n) = g(n) + h(n) 목적지에 대한 추정 비용을 활용하는 경로 탐색

다익스트라는 현재까지 누적된 이동 비용이 가장 작은 노드를 우선 탐색한다.

A*는 여기에 목적지까지의 추정 비용을 추가하여 탐색 우선순위를 결정한다.

A*의 허용성(Admissibility)

허용성은 휴리스틱이 실제 남은 최단 비용을 과대평가하지 않는 조건이다.

h(n) <= h*(n)
  • h(n): 휴리스틱으로 추정한 남은 비용
  • h*(n): 해당 노드에서 목적지까지 실제 최단 비용

예를 들어 A에서 G까지 세 경로가 있다고 가정하자.

경로비용

A → B → G 2 + 3 = 5
A → C → G 4 + 4 = 8
A → G 10

A에서 G까지 실제 최단 비용은 5다.

따라서 h(A) = 6이라면 허용성을 만족하지 않는다.

반대로 h(A) = 5라면 정확히 실제 최단 비용과 같기 때문에 허용성을 만족한다.

처음에는 실제 최단 비용보다 반드시 작아야 한다고 생각했지만, 등호도 허용된다는 점을 다시 확인했다.

일관성(Consistency)은 무엇이 다를까?

일관성은 인접 노드 사이에서 다음 조건을 만족하는 것이다.

h(u) <= c(u, v) + h(v)

c(u, v)는 u에서 v까지 이동하는 비용이다.

일관성이 성립하면 경로를 따라 이동하면서 A*의 f(n) 값이 감소하지 않는다.

일관성이 깨진 경우에는 이미 탐색을 완료한 노드에 대해 나중에 더 저렴한 경로가 발견될 수 있다.

따라서 탐색 완료 노드를 다시 열지 않는 구현에서는 최단 경로를 놓칠 위험이 있다.

허용성과 일관성은 서로 다른 조건이며, 최단 경로를 보장하려면 휴리스틱의 성질뿐 아니라 알고리즘 구현 방식도 함께 고려해야 한다.

3. 디자인 패턴 — Strategy와 Factory Method의 실행 원리

오늘은 패턴의 목적뿐 아니라 실제 메서드가 어떤 과정을 거쳐 호출되는지도 복습했다.

Strategy 패턴

Strategy 패턴은 공통 인터페이스를 통해 서로 다른 동작을 교체하는 방식이다.

예를 들어 게임 캐릭터의 공격 방식을 변경한다고 가정하자.

public interface IAttackStrategy
{
    void Attack();
}

public class BowAttack : IAttackStrategy
{
    public void Attack()
    {
        Console.WriteLine("활 공격");
    }
}

public class Player
{
    private readonly IAttackStrategy _strategy;

    public Player(IAttackStrategy strategy)
    {
        _strategy = strategy;
    }

    public void Attack()
    {
        _strategy.Attack();
    }
}

호출 코드는 다음과 같다.

Player player = new Player(new BowAttack());
player.Attack();

new BowAttack()으로 객체를 생성하고 그 객체의 참조를 Player 생성자에 전달한다.

생성자는 해당 참조를 인터페이스 타입 필드인 _strategy에 저장한다.

player.Attack()을 호출하면 내부의 _strategy.Attack()이 실행되며, 실제 참조하는 객체 타입에 따라 BowAttack.Attack()이 선택된다.

이것이 인터페이스를 통한 다형성 호출이다.

중요한 점은 인터페이스를 구현하는 것과 클래스의 메서드를 override하는 것은 다르다는 것이다.

Factory Method 패턴

Factory Method는 객체 생성 메서드를 상위 클래스에 선언하고, 하위 클래스에서 구체적인 객체 생성 방식을 결정하도록 하는 패턴이다.

예를 들어 상위 클래스인 MonsterFactory가 Spawn()을 제공하고, 그 안에서 추상 메서드 CreateMonster()를 호출할 수 있다.

SlimeFactory가 CreateMonster()를 override했다면 실제 객체가 SlimeFactory일 때 해당 자식 클래스의 메서드가 실행된다.

실행 흐름은 다음과 같다.

MonsterFactory.Spawn()
        ↓
SlimeFactory.CreateMonster()
        ↓
new Slime()
        ↓
monster.Appear()

여기서 부모 클래스의 Spawn()이 실행되더라도 내부에서 호출하는 가상 메서드는 실제 객체 타입에 따라 선택된다.

언제 어떤 패턴을 사용할까?

  • Strategy: 서로 다른 처리 방식을 실행 중 교체하고 싶을 때
  • Simple Factory: 객체 생성 로직을 간단히 한곳에 모으고 싶을 때
  • Factory Method: 하위 클래스마다 구체적인 객체 생성 방식을 다르게 결정하고 싶을 때

단순한 생성 로직에 불필요한 상속 구조를 추가하면 복잡도만 증가할 수 있다.

따라서 디자인 패턴도 유지보수성과 변경 가능성을 기준으로 선택해야 한다.

4. C# 게임 서버 — ThreadPool과 ASP.NET Core 요청 처리

ASP.NET Core는 일반적으로 ThreadPool의 작업자 스레드를 이용해 애플리케이션의 요청 처리 코드를 실행한다.

그렇다면 하나의 HTTP 요청에서 Thread.Sleep()을 사용하면 다른 요청도 모두 기다려야 할까?

그렇지는 않다.

Thread.Sleep이 차단하는 대상

다음과 같은 두 API가 있다고 가정하자.

app.MapGet("/slow", () =>
{
    Thread.Sleep(5000);
    return Results.Ok("Slow 완료");
});

app.MapGet("/fast", () =>
{
    return Results.Ok("Fast 완료");
});

/slow 요청이 실행되는 동안 Thread.Sleep(5000)은 그 코드를 실행 중인 작업자 스레드를 차단한다.

그러나 ThreadPool에 다른 가용 스레드가 있다면 /fast 요청은 별도로 처리될 수 있다.

즉, 한 요청에서 Thread.Sleep을 실행한다고 다른 모든 요청이 반드시 기다리는 것은 아니다.

ThreadPool Starvation은 언제 발생할까?

차단 작업이 많아지면 사용 가능한 ThreadPool 작업자 스레드가 부족해질 수 있다.

ThreadPool은 필요에 따라 작업자 스레드를 추가하지만, 요청이 계속 쌓여 필요한 작업을 제때 처리하지 못하면 응답 지연이 발생한다.

이것이 ThreadPool Starvation이다.

단순히 동시 요청 수가 현재 스레드 수보다 많다는 이유만으로 Starvation이라고 판단해서는 안 된다.

특히 비동기 I/O를 사용하는 경우에는 요청이 대기하는 동안 스레드를 계속 점유할 필요가 없기 때문이다.

Task.Run은 CPU 연산을 빠르게 만들지 않는다

Task.Run()은 작업을 ThreadPool에 예약한다.

ASP.NET Core에서 이미 ThreadPool 스레드로 수행 중인 짧은 CPU 계산을 Task.Run()으로 감싸면 불필요한 스케줄링 비용이 발생할 수 있다.

반면 실제 비동기 DB·네트워크 작업은 미완료 Task를 await하는 동안 스레드가 다른 작업을 처리할 수 있도록 할 수 있다.

따라서 CPU 연산과 비동기 I/O, 차단 작업의 차이를 이해하고 코드를 작성해야 한다.

5. 게임 서버 DB — PostgreSQL B-Tree 인덱스와 EXPLAIN

오늘 가장 확실하게 새롭게 이해한 개념은 인덱스가 실제 테이블의 모든 컬럼 값을 저장하는 것은 아니라는 점이다.

B-Tree 인덱스의 탐색 원리

B-Tree 인덱스는 정렬된 키를 계층적으로 관리한다.

따라서 데이터를 순차적으로 모두 확인하지 않고, 각 단계에서 탐색 범위를 좁혀 원하는 키를 찾을 수 있다.

다음 테이블을 살펴보자.

CREATE TABLE players (
    id BIGINT PRIMARY KEY,
    nickname VARCHAR(50),
    gold INT
);

PostgreSQL에서는 기본 키 제약 조건을 생성할 때 이를 지원하는 고유 B-Tree 인덱스가 기본적으로 생성된다.

다음 쿼리를 실행한다고 가정하자.

SELECT *
FROM players
WHERE id = 500000;

PostgreSQL이 일반적인 Index Scan을 선택했다면, 인덱스에서 id를 찾고 해당 엔트리의 TID를 이용해 실제 테이블 행에 접근한다.

TID와 Heap은 무엇일까?

PostgreSQL에서 일반적인 테이블 데이터는 Heap에 저장된다.

인덱스는 검색 키와 테이블 행을 찾는 데 필요한 위치 정보 등을 관리한다.

일반적인 Index Scan의 처리 과정은 다음과 같다.

B-Tree 인덱스 탐색
       ↓
id = 500000 검색
       ↓
TID 확인
       ↓
Heap에서 대상 행 접근
       ↓
nickname, gold 등 필요한 컬럼 조회

처음에는 nickname과 gold까지 인덱스에 저장되어 있다고 생각했다.

하지만 id만을 대상으로 하는 일반적인 인덱스에는 해당 컬럼들이 자동으로 저장되지 않는다.

따라서 실제 컬럼 값을 읽기 위해 Heap에 접근할 수 있다.

단, 필요한 컬럼이 인덱스에 포함되어 있고 MVCC 가시성 조건을 만족하면 Index Only Scan으로 Heap 접근을 생략할 수 있는 경우도 있다.

인덱스가 있어도 Seq Scan을 사용하는 이유

PostgreSQL은 인덱스가 존재한다는 사실만으로 항상 Index Scan을 선택하지 않는다.

예를 들어 gold 컬럼에 인덱스가 있다고 가정하자.

SELECT *
FROM players
WHERE gold = 999999;

결과가 극소수의 행이라면 Index Scan이 유리할 수 있다.

반면 다음 쿼리는 다르다.

SELECT *
FROM players
WHERE gold >= 0;

대부분의 플레이어가 해당 조건을 만족한다면 테이블 전체를 순차적으로 읽는 Seq Scan이 더 효율적일 수 있다.

이는 인덱스를 통한 많은 Heap 접근보다 순차적인 읽기가 더 저렴할 수 있기 때문이다.

다만 실제 선택은 테이블 크기와 통계 정보, 예상 비용 등 여러 조건에 따라 결정된다.

EXPLAIN으로 실행 계획 확인하기

EXPLAIN
SELECT *
FROM players
WHERE gold >= 0;

EXPLAIN은 PostgreSQL이 선택한 실행 계획과 예상 비용·행 수 등을 보여준다.

실제 실행 시간과 행 수를 조사하려면 EXPLAIN ANALYZE를 사용할 수 있다. 이때 대상 SQL이 실제 실행된다는 점에 주의해야 한다.

게임 서버에서도 조회가 느리다는 이유만으로 인덱스를 무조건 추가하기보다, 실행 계획을 확인하고 병목 원인을 파악하는 것이 중요하다.


오늘 새롭게 알게 된 점

  1. LinkedList는 삭제할 노드 참조를 알고 있을 때 O(1)이지만, 노드 탐색이 필요하면 전체 O(n)이 발생한다.
  2. A*의 허용성 조건은 실제 최단 비용과 같은 값도 허용하며, 일관성과는 다른 조건이다.
  3. Strategy의 인터페이스 구현과 Factory Method의 override 호출은 서로 다른 구조다.
  4. Thread.Sleep은 실행 중인 작업자 스레드를 차단하지만 다른 요청이 모두 차단되는 것은 아니다.
  5. PostgreSQL에서는 인덱스로 TID를 찾고 실제 Heap에서 필요한 컬럼을 가져올 수 있다.
  6. 인덱스가 있어도 예상 비용에 따라 Seq Scan이 선택될 수 있다.

추가로 복습할 내용

  • LinkedList와 List의 실제 선택 기준
  • A* 허용성·일관성의 차이와 최단 경로 보장 조건
  • 인터페이스 구현과 override 메서드의 실행 흐름
  • ThreadPool Starvation이 발생하는 조건
  • B-Tree 인덱스, Heap 접근 및 Index Only Scan
  • EXPLAIN ANALYZE를 통한 실제 실행 계획 분석

오늘은 개념을 기억하는 것뿐 아니라, 실제 내부 동작을 설명해 보는 것이 이해도를 확인하는 데 중요하다는 것을 느꼈다.

특히 PostgreSQL의 인덱스처럼 사용법은 알고 있어도 내부 원리를 정확히 이해하지 못한 부분이 있었다.

앞으로도 이러한 차이를 하나씩 줄여가며 게임 서버 개발에 필요한 기본기를 강화하려고 한다.


이 글의 코드는 학습용 예시이며 실제 실행, 성능 측정, 부하 테스트 결과를 포함하지 않는다. 모든 게임 서버 사례는 가상의 예시다.

Day 3 — 오늘 배운 게임 서버 개발 지식 5가지

학습일: 2026년 11월 3일
학습 주제: 자료구조 · 알고리즘 · 디자인 패턴 · C# 게임 서버 · PostgreSQL

오늘은 Stack과 Queue의 내부 구조부터 탐색 알고리즘, Factory 패턴, ASP.NET Core의 스레드 처리, PostgreSQL의 데드락까지 복습했다.

학습하면서 가장 중요하게 느낀 것은 단순히 기능을 사용하는 방법보다 내부에서 어떤 일이 발생하고, 왜 그 방식을 선택해야 하는지 이해하는 것이다.

1. 자료구조 — Stack과 Queue의 내부 원리

Stack과 Queue는 모두 데이터를 저장하는 자료구조이지만, 데이터를 꺼내는 순서가 다르다.

  • Stack: LIFO(Last In, First Out). 마지막에 들어온 데이터가 먼저 나온다.
  • Queue: FIFO(First In, First Out). 먼저 들어온 데이터가 먼저 나온다.

C#의 Stack<T>와 Queue<T>는 내부적으로 배열을 사용한다.

Queue는 왜 Dequeue할 때 데이터를 이동하지 않을까?

처음에는 Queue의 맨 앞 요소를 제거하면 나머지 요소들이 모두 앞으로 이동해야 하는지 궁금했다.

하지만 Queue<T>는 원형 배열(Circular Buffer)을 사용한다.

배열의 맨 앞에 있는 요소를 제거할 때 나머지 데이터들을 이동시키는 대신, 맨 앞을 가리키는 인덱스를 다음 위치로 이동시킨다.

마지막 인덱스에 도달하면 다시 배열의 처음으로 돌아가 빈 공간을 재활용할 수 있다.

덕분에 Dequeue()는 O(1)로 동작한다.

Enqueue는 왜 분할 상환 O(1)일까?

Enqueue()는 내부 배열에 여유 공간이 있다면 O(1)이다.

하지만 배열이 가득 찬 경우 더 큰 배열을 할당하고 기존 데이터를 복사해야 하므로 해당 작업은 O(n)이 된다.

그런데 배열 용량을 기하급수적으로 확장하면 전체 복사 작업이 매번 발생하지 않는다.

여러 번의 삽입 작업에 확장 비용을 나누어 계산하면 삽입 한 번당 분할 상환 O(1) 이 된다.

연산시간 복잡도

Stack.Push 분할 상환 O(1), 확장 시 O(n)
Stack.Pop O(1)
Stack.Peek O(1)
Queue.Enqueue 분할 상환 O(1), 확장 시 O(n)
Queue.Dequeue O(1)
Queue.Peek O(1)

게임 서버에서는 작업을 순서대로 처리하는 대기열이나 최근 작업부터 되돌리는 처리 등에 활용할 수 있다.

2. 알고리즘 — 탐색 방법과 시간 복잡도

오늘은 순차 탐색, 이진 탐색, Dictionary 기반 탐색을 비교했다.

탐색 방법별 시간 복잡도

탐색 방법최선평균최악

순차 탐색 O(1) O(n) O(n)
이진 탐색 O(1) O(log n) O(log n)
Dictionary 조회 일반적으로 O(1) O(1) O(n)

순차 탐색은 첫 번째 데이터가 원하는 값이라면 O(1)이지만, 끝까지 확인해야 할 경우 O(n)이다.

이진 탐색은 정렬된 배열에서 중앙값을 확인하고 탐색 범위를 절반씩 줄인다. 첫 번째 중앙값이 정답이면 O(1)이다.

Dictionary는 해시값을 이용하므로 평균 O(1)에 데이터를 조회할 수 있다. 다만 해시 충돌로 인해 최악에는 O(n)이 발생할 수 있다.

게임 서버에서는 어떤 자료구조를 선택할까?

접속 중인 플레이어를 UserId로 자주 조회하고, 로그인과 로그아웃에 따라 추가하거나 제거해야 한다면 Dictionary<UserId, Player>가 적절할 수 있다.

조회·삽입·삭제 모두 평균 O(1)이기 때문이다.

다만 여러 스레드가 동시에 수정하는 환경이라면 별도의 동기화가 필요하다.

추가 복습 — A* 휴리스틱의 일관성

이번 복습에서 가장 헷갈렸던 부분이다.

A*는 다음 평가 비용을 사용한다.

f(n) = g(n) + h(n)

  • g(n): 시작 노드부터 현재 노드까지 누적 비용
  • h(n): 현재 노드에서 목적지까지 추정 비용

휴리스틱의 일관성 조건은 다음과 같다.

h(A) <= c(A, B) + h(B)

여기서 c(A, B)는 A에서 B까지의 실제 이동 비용이다.

예를 들어 h(A)=7, c(A,B)=3, h(B)=2라면 다음과 같다.

7 <= 3 + 2

조건을 만족하지 않는다.

일관성 조건을 만족하면 경로를 따라 이동할 때 f(n)이 감소하지 않는 성질이 있다.

반대로 일관성이 깨지면 이미 탐색을 완료한 노드에 대해 나중에 더 저렴한 경로가 발견될 수 있다.

이때 탐색 완료한 노드를 다시 열지 않는 구현은 최단 경로를 놓칠 위험이 있다.

다만 일관성이 없다고 항상 최단 경로를 찾지 못하는 것은 아니다. 휴리스틱의 허용성 등 필요한 조건을 만족하고 노드 재탐색을 올바르게 처리하는 구현이라면 최단 경로를 찾을 수 있다.

3. 디자인 패턴 — Simple Factory와 Factory Method

두 방식 모두 객체 생성과 관련이 있지만 구조가 다르다.

Simple Factory

하나의 팩토리 클래스 또는 메서드에 객체 생성 로직을 모으는 방식이다.

예를 들어 몬스터 종류에 따라 Slime, Goblin, Dragon 객체를 생성하도록 만들 수 있다.

생성할 객체의 종류가 적고 변경 가능성이 낮다면 간단한 Simple Factory로 충분할 수 있다.

Factory Method

Factory Method는 상위 클래스에 객체 생성 메서드를 정의하고, 하위 클래스에서 재정의하여 구체적으로 어떤 객체를 생성할지 결정하는 패턴이다.

즉, 객체 생성 책임을 하위 클래스에 위임한다.

이 방식은 객체 생성 방법이 하위 클래스별로 달라지고 확장 가능성이 높을 때 유용하다.

하지만 몬스터가 세 종류뿐이고 추가될 가능성도 거의 없다면 Factory Method의 상속 구조가 오히려 불필요한 복잡도를 증가시킬 수 있다.

비교Simple FactoryFactory Method

생성 방식 하나의 메서드에서 생성할 객체 결정 하위 클래스가 생성할 객체 결정
주요 구조 조건 분기 등의 생성 로직 상속과 메서드 재정의
장점 단순하고 이해하기 쉬움 생성 방식의 다형적 확장
단점 종류 추가 시 생성 메서드 수정 가능성 클래스 구조가 복잡해질 수 있음

또한 Strategy 패턴과의 차이도 다시 확인했다.

Strategy는 처리 방식의 교체가 중심이고, Factory 계열은 객체 생성 책임의 분리가 중심이다.

디자인 패턴은 무조건 적용하기보다 실제 변경 가능성과 복잡도를 고려해 선택해야 한다.

4. C# 게임 서버 — async와 Task.Run의 차이

async 메서드는 호출한 스레드에서 실행을 시작한다.

미완료된 비동기 작업을 await하면 메서드 실행을 일시 중단하고, 해당 스레드가 다른 작업을 처리할 수 있도록 할 수 있다.

반대로 Task.Run()은 작업을 ThreadPool에 예약한다.

중요한 것은 Task.Run을 사용한다고 CPU 계산 자체가 빨라지는 것은 아니라는 점이다.

ASP.NET Core에서 Task.Run을 주의해야 하는 이유

ASP.NET Core는 기본적으로 ThreadPool 스레드를 이용하여 요청을 처리한다.

이미 ThreadPool 스레드에서 실행 중인 간단한 CPU 작업을 다시 Task.Run()으로 예약하면 불필요한 작업 스케줄링 비용이 발생할 수 있다.

반면 비동기 DB 조회는 성격이 다르다.

Player player = await db.GetPlayerAsync(userId);

실제 비동기 DB I/O가 진행되고 반환된 Task가 아직 완료되지 않았다면, await하는 동안 현재 스레드를 계속 점유할 필요가 없다.

그러나 다음과 같이 차단 작업이 존재하면 문제가 달라진다.

await Task.Run(() =>
{
    Thread.Sleep(5000);
});

Task.Run으로 작업을 예약하더라도 Thread.Sleep을 실행하는 ThreadPool 스레드는 차단된다.

이러한 작업이 많이 발생하면 사용 가능한 ThreadPool 스레드가 부족해지고 요청 처리가 지연될 수 있다.

이를 ThreadPool Starvation이라고 한다.

결국 중요한 것은 CPU 작업인지, 비동기 I/O인지, 스레드를 실제로 차단하는 작업인지 구분하는 것이다.

5. 게임 서버 DB — 데드락과 트랜잭션 재시도

PostgreSQL에서 데드락은 서로 다른 트랜잭션이 상대방이 보유한 잠금을 기다리면서 진행하지 못하는 상황이다.

예를 들어 다음과 같은 상황을 생각할 수 있다.

  • 트랜잭션 1: 플레이어 A 잠금 획득 → 플레이어 B 잠금 요청
  • 트랜잭션 2: 플레이어 B 잠금 획득 → 플레이어 A 잠금 요청

두 트랜잭션이 각각 첫 번째 잠금을 획득했다면 서로 상대방의 잠금이 해제되기를 기다릴 수 있다.

이는 단순한 잠금 대기가 아니라 순환 대기 관계가 형성된 데드락이다.

데드락을 예방하는 방법

여러 행의 잠금을 획득해야 한다면 모든 트랜잭션에서 잠금 획득 순서를 통일하는 것이 중요하다.

예를 들어 두 플레이어의 PlayerId 중 작은 값을 먼저 잠그도록 규칙을 정할 수 있다.

  • 트랜잭션 1: A → B
  • 트랜잭션 2: A → B

이렇게 하면 동일한 두 행을 반대 순서로 획득하면서 발생하는 데드락을 예방할 수 있다.

데드락이 이미 발생했다면?

PostgreSQL은 데드락을 감지하면 관련 트랜잭션 중 하나를 중단한다.

이때 실패한 SQL 명령부터 다시 실행하면 안 된다.

새로운 트랜잭션을 시작하고 전체 작업을 다시 처리해야 한다.

예를 들어 게임 서버에서 골드를 이전하는 거래라면 다음 작업이 하나의 트랜잭션에 포함될 수 있다.

  1. 현재 골드 잔액 및 거래 조건 확인
  2. 보내는 플레이어의 골드 차감
  3. 받는 플레이어의 골드 증가
  4. 거래 기록 저장
  5. 커밋

데드락으로 트랜잭션이 실패했다면 새로운 트랜잭션에서 현재 데이터를 다시 읽고 조건을 재검증해야 한다.

다른 트랜잭션이 데이터를 변경했을 수 있기 때문이다.

또한 무제한 재시도보다는 재시도 횟수를 제한하고, 반복적으로 실패하면 오류 로그를 남겨 원인을 조사하는 것이 적절하다.

필요하다면 짧은 재시도 지연도 적용할 수 있다.

실제 게임 서버에서는 중복 거래 방지와 트랜잭션 밖의 부작용 처리까지 함께 고려해야 한다.


오늘 새롭게 알게 된 점

  1. Queue는 원형 배열의 인덱스를 이동시키기 때문에 Dequeue할 때 전체 요소를 이동하지 않는다.
  2. 분할 상환 O(1)은 단순히 대부분의 작업이 빠르기 때문만이 아니라, 배열 확장 비용을 여러 작업에 분산할 수 있기 때문에 성립한다.
  3. A*에서 일관성이 깨지면 이미 탐색 완료한 노드에 대해 더 저렴한 경로가 나중에 발견될 수 있다.
  4. 단순한 객체 생성에는 Factory Method보다 Simple Factory가 적합할 수 있다.
  5. ASP.NET Core에서 Task.Run은 CPU 연산 자체를 가속하지 않는다.
  6. 데드락 예방에는 잠금 순서 통일이 중요하며, 데드락 이후에는 새로운 트랜잭션에서 데이터를 재검증해야 한다.

추가로 복습할 내용

  • Stack과 Queue의 내부 배열 확장 및 분할 상환 복잡도
  • A* 휴리스틱의 허용성과 일관성
  • Factory Method를 실제 C# 코드로 구현하는 방법
  • ASP.NET Core의 비동기 I/O와 ThreadPool Starvation 진단
  • PostgreSQL의 데드락 재시도, 잠금 순서, 인덱스 및 실행 계획

이번 학습에서는 개념을 암기하는 것뿐 아니라, 그 개념을 실제 게임 서버에 적용할 때 왜 그런 선택을 해야 하는지 생각해 볼 수 있었다.

다음 학습에서도 내부 동작 원리를 이해하고 실제 코드와 연결하는 방향으로 공부할 예정이다.


이 글의 예시 코드는 학습용으로 작성했으며 실제 실행·성능 측정 결과를 의미하지 않습니다. 게임 서버 예시는 가상 상황이며 회사 내부 구현 정보는 포함하지 않았습니다.

Day 2 — 오늘 배운 게임 서버 개발 지식 5가지

학습일: 2026년 11월 2일
주제: 자료구조, 알고리즘, 디자인 패턴, C# 비동기, 데이터베이스 트랜잭션

오늘은 기존에 학습했던 개념을 복습하면서 단순히 사용법을 아는 것과 내부 동작 원리를 이해하는 것이 다르다는 점을 확인했다.

특히 C#의 비동기 실행 흐름과 PostgreSQL의 동시성 처리에서 헷갈렸던 부분을 다시 정리했다.

1. 자료구조 — List<T>와 Dictionary의 내부 구조

List<T>는 내부 배열을 관리하는 객체다

C#의 List<T>는 내부적으로 배열을 사용한다.

데이터를 계속 추가하다가 내부 저장 공간이 부족해지면 더 큰 배열을 새로 할당하고 기존 요소들을 복사한다.

이때 List<T> 객체 자체가 새로 만들어지는 것은 아니다. 기존 객체가 유지되면서 내부에서 참조하는 배열이 교체된다.

기존 배열은 더 이상 참조되지 않는다면 GC의 수거 대상이 된다.

또한 중간에 요소를 삽입하거나 삭제하면 다른 요소들의 위치를 이동시켜야 하므로 O(n)의 시간 복잡도가 발생한다.

Dictionary는 해시와 엔트리 배열을 활용한다

Dictionary<TKey, TValue>는 해시값을 이용해 데이터를 탐색한다.

일반적인 .NET 구현에는 버킷 배열과 엔트리 배열이 존재한다.

  • 버킷 배열: 해당 버킷에 연결된 첫 엔트리의 위치를 찾는 역할
  • 엔트리 배열: 해시값, Key, Value, 다음 엔트리 위치 등의 정보 저장

서로 다른 키가 같은 버킷에 배정되는 해시 충돌이 발생할 수도 있다.

이때 next 정보를 통해 같은 버킷에 연결된 다른 엔트리를 탐색하고, 실제 키를 비교해 데이터를 구분한다.

2. 알고리즘 — 다익스트라와 A*의 차이

다익스트라 알고리즘은 시작점에서 현재 노드까지의 누적 이동 비용이 가장 작은 노드를 우선 탐색한다.

반면 A* 알고리즘은 누적 이동 비용에 목적지까지의 추정 비용을 더해 탐색 우선순위를 결정한다.

  • g(n): 시작점부터 현재 노드까지의 누적 비용
  • h(n): 현재 노드에서 목적지까지의 추정 비용
  • f(n) = g(n) + h(n): A*에서 사용하는 평가 비용

예를 들어 두 탐색 후보가 다음과 같다고 가정하자.

노드g(n)h(n)f(n)

A 4 7 11
B 6 2 8

다익스트라는 A를 우선 탐색하지만, A*는 B를 우선 탐색한다.

여기서 중요한 것은 휴리스틱의 정확성이다.

h(n)이 실제 남은 최단 비용을 과대평가하면 최적 경로에 포함된 노드의 탐색 우선순위가 낮아질 수 있다. 따라서 일반적인 A* 최단 경로 보장 조건을 만족하지 못할 수 있다.

단, 과대평가했다고 항상 잘못된 경로를 찾는 것은 아니다.

휴리스틱의 허용성과 일관성 조건은 앞으로 더 복습해야 할 내용이다.

3. 디자인 패턴 — Strategy 패턴

Strategy 패턴은 공통 인터페이스를 통해 서로 다른 알고리즘이나 동작을 교체하여 실행할 수 있도록 만드는 패턴이다.

예를 들어 게임 서버에서 플랫폼마다 결제 검증 방식이 다르다고 가정하자.

Google Play, App Store, 외부 웹상점은 서로 다른 검증 로직을 가질 수 있다.

이때 각 결제 로직이 공통 인터페이스를 구현하도록 설계하면, 결제를 요청하는 코드는 구체적인 플랫폼별 구현에 강하게 의존하지 않아도 된다.

public interface IPaymentStrategy
{
    void Pay();
}

핵심은 인터페이스가 여러 결제 로직을 하나로 합치는 것이 아니라 공통 메서드 계약을 제공한다는 점이다.

인터페이스 타입 변수에 구체적인 구현 객체를 할당하면 실제 호출 시 해당 객체의 메서드가 실행된다. 이를 다형성이라고 한다.

다음 학습에서는 객체의 생성 책임을 다루는 Factory Method 패턴과 비교할 예정이다.

4. C# 게임 서버 — async, Task.Run과 ThreadPool Starvation

async 키워드를 사용했다고 해서 메서드가 자동으로 별도 스레드에서 실행되는 것은 아니다.

async 메서드는 호출한 스레드에서 실행을 시작하며, 미완료된 비동기 작업을 await할 때 실행을 중단하고 호출자에게 제어를 돌려줄 수 있다.

여기서 Thread.Sleep과 Task.Delay는 중요한 차이가 있다.

구분Thread.Sleepawait Task.Delay

동작 현재 스레드 차단 비동기 지연 대기
대기 중 스레드 점유 점유함 지연 완료까지 스레드 점유 불필요
서버에서 고려할 점 스레드 부족 위험 단순 시간 대기에 적합

Task.Run은 작업을 ThreadPool에 예약한다.

하지만 Task.Run 내부에서 Thread.Sleep을 실행하면 해당 작업을 실행하는 ThreadPool 스레드가 차단된다.

이러한 작업이 많아지면 ThreadPool에서 처리 가능한 작업 수가 줄어들고, 다른 요청들이 대기하면서 응답 지연이 발생할 수 있다.

이것이 ThreadPool Starvation과 관련된 중요한 문제다.

따라서 I/O 대기나 단순 지연에 불필요하게 Task.Run과 차단 메서드를 사용하는 것은 피해야 한다.

5. 게임 서버 DB — MVCC, SELECT FOR UPDATE와 조건부 UPDATE

PostgreSQL의 READ COMMITTED 격리 수준에서는 일반 SELECT가 다른 트랜잭션의 미커밋 변경 결과를 읽지 않는다.

예를 들어 기존 골드가 1,000인데 트랜잭션 A가 500으로 수정하고 아직 커밋하지 않았다면, 다른 트랜잭션의 일반 SELECT는 기존에 커밋된 1,000을 읽을 수 있다.

이는 MVCC를 이용해 적절한 데이터 버전을 조회하기 때문이다.

반면 SELECT FOR UPDATE는 대상 행의 잠금을 획득하려고 하므로 충돌하는 행 잠금이 있다면 대기해야 한다.

게임 서버에서 조건부 UPDATE 활용하기

플레이어에게 골드가 100 있고, 80골드짜리 아이템을 동시에 두 번 구매한다고 가정해 보자.

UPDATE players
SET gold = gold - 80
WHERE id = 1
  AND gold >= 80;

PostgreSQL의 READ COMMITTED 환경에서 첫 번째 트랜잭션이 차감을 수행하고 잠금을 유지하고 있다면 두 번째 UPDATE는 대기한다.

첫 번째 트랜잭션이 커밋한 뒤에는 변경된 골드를 기준으로 WHERE 조건을 다시 평가한다.

골드가 20이면 gold >= 80이 성립하지 않으므로 두 번째 차감은 수행되지 않는다.

이처럼 조건부 UPDATE와 영향받은 행 수 검사를 이용하면 특정 단일 행의 잔액 부족 문제를 안전하게 처리할 수 있다.

단, 실제 구매 시스템에서는 아이템 지급, 트랜잭션 원자성, 중복 요청 방지까지 별도로 고려해야 한다.


오늘 새롭게 알게 된 점

  1. List<T>의 내부 배열이 확장되어도 List 객체 자체는 유지된다.
  2. Dictionary의 버킷 배열과 엔트리 배열은 서로 다른 역할을 한다.
  3. A*의 휴리스틱은 단순히 추정 비용을 추가하는 것이 아니라 최단 경로 보장에도 영향을 준다.
  4. async는 별도 스레드 실행을 의미하지 않으며, Task.Run 역시 스레드 부족을 해결하는 방법은 아니다.
  5. PostgreSQL에서는 행 잠금이 있어도 일반 SELECT가 MVCC를 이용해 이전에 커밋된 데이터를 읽을 수 있다.

추가로 복습할 내용

  • Dictionary의 해시 충돌과 next 연결 구조
  • A*의 허용성 및 일관성 조건
  • Factory Method와 Strategy 패턴의 차이
  • ThreadPool Starvation의 실제 발생 조건과 진단
  • PostgreSQL의 MVCC, 행 잠금 및 데드락 처리

이번 학습을 통해 단순히 결과를 기억하는 것보다 왜 그런 결과가 발생하는지 설명할 수 있어야 한다는 점을 다시 확인했다.

앞으로도 게임 서버의 데이터 정합성, 동시성, 성능 문제를 내부 동작 원리와 연결해 학습할 예정이다.

예시 코드는 학습 목적으로 작성했으며, 실제 실행 및 부하 테스트를 수행한 결과는 포함하지 않았다.

+ Recent posts