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

학습일: 2026년 11월 6일
복습일: 2026년 11월 7일
학습 주제: PriorityQueue와 Heap, Sliding Window, Observer 패턴, C# 비동기 예외 처리, PostgreSQL 인덱스와 페이지네이션

오늘은 게임 서버 개발에서 자주 접할 수 있는 우선순위 큐, 알고리즘, 이벤트 처리, 비동기 예외, 데이터베이스 조회 최적화를 공부했다.

이번 복습에서는 개념을 설명하는 것은 어느 정도 가능했지만, 직접 C# 코드와 SQL을 작성하는 과정에서 여러 오류가 발생했다.

특히 알고리즘의 원리를 이해하는 것과 정확하게 구현하는 것은 별개의 능력이라는 점을 확인했다.

1. 자료구조 — PriorityQueue와 Heap의 내부 원리

우선순위 큐(Priority Queue)는 데이터가 들어온 순서가 아니라 우선순위에 따라 데이터를 꺼내는 자료구조다.

C#의 PriorityQueue<TElement, TPriority>는 내부적으로 4진 최소 힙(Quaternary Min-Heap) 을 사용한다.

최소 힙에서는 부모 노드의 우선순위가 자식 노드보다 크지 않아야 한다.

중요한 것은 왼쪽 자식이 오른쪽 자식보다 반드시 작아야 하는 구조는 아니라는 점이다.

Heap에 데이터를 삽입하면?

이진 최소 힙을 예시로 생각해 보자.

        3
       / \
      8   5
     / \
    12 10

여기에 우선순위 2를 삽입한다면 다음 과정이 발생한다.

  1. 힙의 마지막 빈자리에 2를 추가한다.
  2. 부모 노드인 5와 비교한다.
  3. 2가 더 작으므로 부모와 위치를 교환한다.
  4. 다시 부모인 3과 비교하여 위치를 교환한다.
  5. 힙 조건을 만족하면 종료한다.

이러한 작업을 Sift Up이라고 한다.

최소값을 삭제하면?

최소 힙에서 가장 작은 우선순위는 루트 노드에 있다.

루트 노드를 제거할 때는 마지막 노드를 루트 위치로 이동하고 자식들과 비교하면서 힙 조건을 복구한다.

이를 Sift Down이라고 한다.

예를 들어 기존 힙에서 3을 제거하면 마지막 노드인 10을 루트로 이동시킨 뒤, 두 자식 중 더 작은 5와 비교하여 위치를 조정한다.

시간 복잡도

연산일반적인 시간 복잡도

Peek O(1)
Enqueue O(log N)
Dequeue O(log N)

Enqueue와 Dequeue가 O(log N)인 이유는 모든 데이터를 탐색하는 것이 아니라 힙의 높이를 따라 이동하기 때문이다.

단, 내부 배열의 용량 확장이 필요한 Enqueue에서는 배열 복사 비용이 추가로 발생할 수 있다.

.NET의 실제 구현은 이진 힙이 아니라 4진 최소 힙이므로 한 부모가 최대 네 자식을 가질 수 있다. 그만큼 힙 높이가 달라지지만, 점근적인 탐색 복잡도는 O(log N)이다.

게임 서버에서는 예약 작업이나 타이머, 우선순위가 있는 작업 처리 등에 활용할 수 있다.

다만 동일한 우선순위의 데이터가 반드시 먼저 들어온 순서대로 처리되는 것은 아니다.

2. 알고리즘 — 가변 길이 Sliding Window

Sliding Window는 연속된 구간을 이동하면서 조건을 만족하는 결과를 효율적으로 찾는 기법이다.

고정 길이 Sliding Window는 구간의 크기가 일정하지만, 가변 길이 Sliding Window는 조건에 따라 구간을 확장하거나 축소한다.

문제: 합이 7 이상인 최소 연속 구간

int[] numbers = { 2, 3, 1, 2, 4, 3 };
int target = 7;

연속된 구간의 합이 7 이상인 경우 중 가장 짧은 구간의 길이를 구해야 한다.

이 배열에서는 [4, 3]의 합이 7이므로 최소 길이는 2다.

가변 길이 Sliding Window의 원리

두 인덱스 left, right를 사용한다.

  • right를 증가시키면서 새로운 요소를 구간 합에 추가한다.
  • 구간 합이 target 이상이면 최소 길이를 갱신한다.
  • 조건을 만족하는 동안 왼쪽 요소를 제거하고 left를 증가시킨다.
  • 전체 탐색이 끝나면 최소 길이를 반환한다.

이번 복습에서 직접 코드를 작성하면서 세 가지 실수를 했다.

첫 번째는 sum > target으로 비교한 것이다. 문제 조건은 합이 target 이상이므로 등호를 포함해야 한다.

두 번째는 구간 길이를 right - left로 계산한 것이다. 양쪽 인덱스를 포함하므로 right - left + 1이어야 한다.

세 번째는 정답 구간을 찾자마자 반환한 것이다. 더 짧은 구간이 뒤에 있을 수 있으므로 전체 탐색이 필요하다.

학습용 C# 구현 예시

다음은 복습 과정에서 교정한 원리를 반영한 예시다. 내가 처음부터 독립적으로 완성한 코드는 아니며, 별도 컴파일·실행 검증은 수행하지 않았다.

static int MinLength(int[] numbers, int target)
{
    int left = 0;
    long sum = 0;
    int minLength = int.MaxValue;

    for (int right = 0; right < numbers.Length; right++)
    {
        sum += numbers[right];

        while (sum >= target)
        {
            minLength = Math.Min(
                minLength,
                right - left + 1
            );

            sum -= numbers[left];
            left++;
        }
    }

    return minLength == int.MaxValue ? 0 : minLength;
}

이 코드는 모든 배열 요소가 양의 정수이고 target이 양수라는 전제를 둔다.

반복문이 중첩되어 있는데 왜 O(N)일까?

for문 안에 while문이 있지만, left와 right는 뒤로 이동하지 않는다.

right는 배열의 각 요소를 한 번씩 방문하고, left도 전체 실행 과정에서 최대 N번 증가한다.

따라서 전체 작업량은 O(N)이다.

이 문제에서 중요한 조건은 모든 요소가 양수라는 사실이다.

음수가 포함되면 왼쪽 값을 제거할 때 구간 합이 오히려 증가할 수 있으므로, 같은 방식의 가변 Sliding Window가 항상 올바른 답을 찾지는 못한다.

이번에는 개념과 시간 복잡도는 이해했지만 전체 코드를 스스로 정확하게 완성하는 데 실패했다. 이후 다시 구현해 볼 필요가 있다.

3. 디자인 패턴 — Observer 패턴과 결합도

Observer 패턴은 이벤트를 발행하는 객체와 그 이벤트에 반응하는 구독자를 분리하는 패턴이다.

게임 서버에서 몬스터를 처치하면 다음 기능들이 실행되어야 한다고 가정하자.

  • 퀘스트 진행도 갱신
  • 업적 조건 확인
  • 통계 기록

기존에는 몬스터 서비스가 각 서비스를 직접 호출할 수도 있다.

하지만 Observer 패턴을 적용하면 몬스터 서비스는 이벤트를 발행하고, 각 기능이 해당 이벤트를 구독하도록 구성할 수 있다.

결합도가 낮아지는 이유

처음에는 Observer 패턴을 사용하면 각 서비스를 직접 생성하지 않아도 되기 때문에 결합도가 감소한다고 생각했다.

하지만 이것은 정확하지 않았다.

서비스 객체들은 여전히 생성되어야 한다.

실제 장점은 이벤트 발행자가 각 구독자의 구체적인 구현을 알 필요가 없어진다는 것이다.

예를 들어 길드 미션 기능이 새롭게 추가되어도 몬스터 서비스의 내부 로직을 변경하지 않고 새로운 구독자를 등록할 수 있다.

C#에서는 event, +=, -=, Invoke() 등을 활용할 수 있다.

Observer 패턴의 한계

이벤트 기반 구조에는 단점도 있다.

이벤트가 여러 구독자에게 전달되므로 전체 실행 흐름과 오류를 추적하기가 복잡해질 수 있다.

또한 C#의 일반적인 이벤트만으로는 다음 기능이 자동으로 보장되지 않는다.

  • 이벤트 영속성
  • 실패한 이벤트의 자동 재시도
  • 이벤트 처리의 트랜잭션 원자성

특히 게임 서버의 골드 지급이나 구매 처리 같은 핵심 데이터 정합성을 이벤트에만 의존해서는 안 된다.

핵심 상태 변경은 적절한 트랜잭션으로 보호하고, 통계 기록처럼 분리할 수 있는 작업에 이벤트를 활용하는 방식을 고려할 수 있다.

Factory Method 복습

Observer와 함께 Factory Method의 호출 순서도 다시 확인했다.

부모 객체 타입으로 자식 인스턴스 참조
             ↓
부모의 Spawn() 호출
             ↓
자식에서 override한 CreateMonster() 실행
             ↓
부모의 Spawn()으로 복귀
             ↓
생성된 Monster의 Appear() 호출

실제 객체 타입이 자식 클래스라면 가상 메서드 호출 시 자식의 재정의된 구현이 선택된다는 점을 다시 확인했다.

4. C# 게임 서버 — try/catch/finally와 비동기 예외

C# 서버에서 비동기 메서드를 사용하다 보면 예외가 어느 위치에서 발생하고 어떻게 전달되는지 이해하는 것이 중요하다.

return이 있어도 finally는 실행된다

다음과 같은 코드를 생각해 보자.

static async Task<int> GetGoldAsync()
{
    try
    {
        await Task.Delay(100);
        return 100;
    }
    finally
    {
        Console.WriteLine("finally");
    }
}

return 100에 도달하더라도 일반적으로 finally가 실행된 후 메서드가 완료된다.

따라서 return이 있다고 해서 finally가 생략되는 것은 아니다.

이는 예외가 발생해 제어 흐름이 이동하는 경우에도 마찬가지다.

다만 프로세스가 강제로 종료되는 등 일부 비정상적인 상황에서는 finally 실행이 보장되지 않는다.

비동기 예외는 await에서 어떻게 전달될까?

다음과 같은 메서드가 있다고 가정하자.

static async Task<int> GetGoldAsync()
{
    try
    {
        await Task.Delay(100);

        throw new InvalidOperationException();
    }
    finally
    {
        Console.WriteLine("정리 작업");
    }
}

예외가 발생하면 finally가 실행된다.

이후 예외가 처리되지 않은 상태로 메서드 밖으로 전파되면 반환된 Task는 Faulted 상태가 된다.

호출자는 다음과 같이 예외를 처리할 수 있다.

try
{
    int gold = await GetGoldAsync();
    Console.WriteLine(gold);
}
catch (InvalidOperationException)
{
    Console.WriteLine("작업 실패");
}

await GetGoldAsync()에서 예외가 전파되므로 바로 아래의 Console.WriteLine(gold)는 실행되지 않고 catch로 이동한다.

return과 throw의 차이

이번 복습에서 throw를 예외 객체를 반환하는 것처럼 표현한 적이 있었다.

하지만 return은 정상적인 반환 흐름이고, throw는 예외를 발생시키거나 전파하는 동작이다.

catch 내부에서 throw;를 사용하면 현재 처리 중인 예외를 다시 던진다.

게임 서버에서는 요청 취소, DB 장애, 잘못된 요청 등 예외 상황에 따라 적절한 처리 정책이 필요하다.

5. 게임 서버 DB — 복합 인덱스와 Keyset Pagination

PostgreSQL에서 조회 성능을 개선하려면 WHERE 조건뿐 아니라 ORDER BY와 LIMIT도 함께 고려해야 한다.

최근 구매 기록 20개 조회

다음과 같은 테이블이 있다고 가정하자.

CREATE TABLE purchases (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    purchased_at TIMESTAMP NOT NULL
);

플레이어 한 명의 최근 구매 기록을 조회한다.

SELECT *
FROM purchases
WHERE user_id = 100
ORDER BY purchased_at DESC
LIMIT 20;

이번에는 다음과 같이 인덱스를 구성할 수 있다.

CREATE INDEX idx_user_purchased
ON purchases (user_id, purchased_at DESC);

user_id로 특정 플레이어의 구매 기록 범위를 찾고, purchased_at 기준으로 최신 기록부터 접근하도록 도움을 줄 수 있다.

중요한 것은 인덱스 컬럼의 순서를 무조건 고유값의 개수만으로 결정하지 않는다는 것이다.

WHERE의 동등 조건과 ORDER BY에서 요구하는 정렬 순서를 함께 고려해야 한다.

PostgreSQL B-Tree는 역방향 인덱스 스캔도 지원하므로 이 쿼리에서는 purchased_at ASC 인덱스도 활용할 수 있다.

OFFSET Pagination의 한계

많은 페이지를 건너뛰려면 다음과 같이 사용할 수 있다.

SELECT *
FROM purchases
WHERE user_id = 100
ORDER BY purchased_at DESC
LIMIT 20 OFFSET 100000;

하지만 OFFSET이 커지면 건너뛰는 행도 처리해야 하므로 비용이 증가할 수 있다.

이러한 상황에서는 Keyset Pagination을 고려할 수 있다.

Keyset Pagination의 원리

Keyset Pagination은 이전 페이지의 마지막 정렬 키를 기준으로 다음 페이지를 조회한다.

예를 들어 최근 구매 기록부터 정렬하고, 이전 페이지의 마지막 구매 시각이 다음과 같다고 가정하자.

2026-11-05 15:00:00

모든 구매 시각이 서로 다르다는 전제라면 다음 페이지를 조회하는 조건은 다음과 같다.

SELECT *
FROM purchases
WHERE user_id = 100
  AND purchased_at < TIMESTAMP '2026-11-05 15:00:00'
ORDER BY purchased_at DESC
LIMIT 20;

내림차순에서는 마지막 기록보다 오래된 시각을 조회해야 하므로 <를 사용한다.

반대로 오름차순에서 다음 페이지를 조회하려면 >를 사용한다.

이번 복습에서 처음에는 비교 연산자의 방향을 반대로 작성했지만, 정렬 방향을 바꾼 추가 질문에서는 정확하게 답변했다.

구매 시각이 같다면 어떻게 할까?

실제 서비스에서는 여러 구매 기록의 purchased_at 값이 같을 수 있다.

이 경우 구매 시각만으로 페이지를 구분하면 일부 데이터가 누락될 수 있다.

따라서 고유한 id를 추가하여 정렬 순서를 결정하는 방법을 사용할 수 있다.

CREATE INDEX idx_purchase_page
ON purchases (user_id, purchased_at DESC, id DESC);

페이지 조회에는 다음과 같은 복합 정렬을 사용할 수 있다.

SELECT *
FROM purchases
WHERE user_id = 100
  AND (purchased_at, id)
      < (TIMESTAMP '2026-11-05 15:00:00', 5000)
ORDER BY purchased_at DESC, id DESC
LIMIT 20;

여기서 5000은 예시로 가정한 마지막 구매 기록의 ID다.

실제 서비스에서는 이전 페이지의 마지막 행에서 구매 시각과 ID를 함께 커서로 전달해야 한다.

이 방식은 동일한 구매 시각이 존재하는 경우에도 안정적인 정렬 기준을 제공한다. 다만 조회 도중 데이터가 추가·삭제되는 상황까지 포함하여 페이지의 일관성을 어떻게 유지할지는 별도로 고려해야 한다.


오늘 새롭게 알게 된 점

  1. 최소 힙에서는 부모와 자식 사이의 우선순위 조건이 중요하며, .NET PriorityQueue는 4진 최소 힙을 사용한다.
  2. 가변 Sliding Window는 중첩 반복문이더라도 두 포인터가 앞으로만 이동하면 O(N)으로 처리할 수 있다.
  3. Observer 패턴은 객체 생성 자체를 없애는 것이 아니라 발행자와 구독자 사이의 직접 의존성을 줄인다.
  4. finally는 일반적인 return 및 예외 전파 과정에서도 실행된다.
  5. 비동기 Task의 Faulted 상태와 await에서의 예외 전파를 구분해야 한다.
  6. 복합 인덱스는 WHERE 조건과 ORDER BY 순서를 함께 고려하여 설계한다.
  7. Keyset Pagination에서는 정렬 방향에 따라 비교 연산자가 달라진다.

추가로 복습할 내용

  • 가변 Sliding Window 전체 C# 코드의 독립 구현
  • left, right 구간 길이 계산과 경계 조건
  • Observer의 이벤트 예외 처리와 트랜잭션 정합성
  • 비동기 예외 처리 미들웨어 및 CancellationToken
  • CREATE INDEX SQL 문법
  • Keyset Pagination의 복합 커서 SQL 직접 작성
  • EXPLAIN ANALYZE를 이용한 실제 실행 계획 비교

오늘의 가장 큰 과제는 개념 설명에 그치지 않고, 실제 코드를 처음부터 정확하게 작성하는 능력을 기르는 것이다.

다음 복습에서는 Sliding Window와 Keyset Pagination을 힌트 없이 직접 구현해 보고 싶다.


모든 C# 코드와 SQL은 학습용 예시이며, 별도의 컴파일·실행·성능 측정은 수행하지 않았다. 실제 회사의 소스 코드나 내부 데이터는 포함하지 않았다.

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

학습일: 2026년 11월 5일
복습일: 2026년 11월 6일
학습 주제: HashSet, Sliding Window, Singleton과 DI, CancellationToken, PostgreSQL 복합 인덱스

오늘은 C#의 HashSet과 알고리즘의 Sliding Window, ASP.NET Core의 DI 수명과 CancellationToken, PostgreSQL의 복합 인덱스를 공부했다.

이번 학습에서 특히 중요하게 느낀 부분은 자료구조나 기능의 사용 방법만 아는 것이 아니라, 내부에서 어떻게 동작하고 어떤 문제가 발생할 수 있는지 알아야 한다는 것이다.

1. 자료구조 — HashSet과 Dictionary의 차이

C#의 HashSet<T>는 중복되지 않는 값을 관리하는 해시 기반 자료구조다.

Dictionary<TKey, TValue>는 Key와 Value를 함께 저장하지만, HashSet은 값 자체의 중복 여부를 관리하는 데 적합하다.

HashSet은 중복을 어떻게 판단할까?

var players = new HashSet<string>();

players.Add("PlayerA"); // true
players.Add("PlayerA"); // false

같은 값을 다시 추가하려고 하면 Add()는 false를 반환한다.

그렇다면 서로 다른 두 값의 해시값이 우연히 같으면 어떻게 될까?

HashSet은 해시값만으로 중복을 판단하지 않는다.

내부적으로 해시값을 이용해 해당 버킷의 엔트리들을 찾고, 필요할 때 EqualityComparer<T>를 이용해 실제 값의 동등성을 비교한다.

따라서 "PlayerA"와 "PlayerB"의 해시값이 충돌하더라도 실제 값이 다르다면 둘 다 저장할 수 있다.

시간 복잡도

연산평균최악

조회(Contains) O(1) O(n)
추가(Add) O(1) O(n)
삭제(Remove) O(1) O(n)

평균적으로 조회와 중복 검사가 빠르기 때문에 게임 서버에서 이벤트 참여자 관리, 이미 처리한 식별자 확인 등에 활용할 수 있다.

다만 실제 결제나 재화 지급의 중복 처리는 메모리의 HashSet만으로 보장할 수 없다. 서버 재시작, 다중 인스턴스, 동시 요청 등을 고려해 DB의 고유 제약이나 영속적인 멱등성 처리도 필요하다.

2. 알고리즘 — Sliding Window와 최대 구간 합

Sliding Window는 연속된 구간을 이동하면서 필요한 계산 결과를 효율적으로 갱신하는 방법이다.

예를 들어 배열에서 연속된 K개 요소의 최대 합을 구한다고 가정하자.

int[] numbers = { -5, -2, -8, -1, -4 };
int k = 3;

가능한 구간의 합은 다음과 같다.

구간합

-5, -2, -8 -15
-2, -8, -1 -11
-8, -1, -4 -13

최대 합은 -11이다.

구간의 합을 매번 다시 계산할 필요가 없는 이유

이전 구간의 합에서 가장 왼쪽 값을 빼고, 새로 들어오는 오른쪽 값을 더하면 다음 구간의 합을 계산할 수 있다.

다음 구간 합
= 이전 구간 합 - 빠지는 값 + 새로 들어오는 값

첫 구간의 합을 구하는 데 O(K), 이후 구간을 이동하는 데 O(N-K)가 필요하므로 전체 시간 복잡도는 O(N)이다.

음수 배열에서 주의할 점

처음에는 최대 합을 다음과 같이 초기화했다.

int max = 0;

하지만 모든 구간의 합이 음수라면 결과가 잘못될 수 있다.

따라서 첫 번째 구간의 합을 계산한 뒤 해당 값으로 최댓값을 초기화해야 한다.

다음은 일반적인 고정 길이 Sliding Window 예시다.

static long MaxWindowSum(int[] numbers, int k)
{
    ArgumentNullException.ThrowIfNull(numbers);

    if (k < 1 || k > numbers.Length)
        throw new ArgumentOutOfRangeException(nameof(k));

    long sum = 0;

    for (int i = 0; i < k; i++)
        sum += numbers[i];

    long max = sum;

    for (int i = k; i < numbers.Length; i++)
    {
        sum -= numbers[i - k];
        sum += numbers[i];

        max = Math.Max(max, sum);
    }

    return max;
}

이 코드의 핵심은 초기 구간도 최댓값 후보에 포함하고, 이후에는 한 번에 두 요소만 이용해 합을 갱신한다는 점이다.

3. 디자인 패턴 — Singleton과 ASP.NET Core DI 수명

Singleton 패턴은 하나의 인스턴스를 만들어 여러 곳에서 공유하는 설계 패턴이다.

하지만 ASP.NET Core의 DI 컨테이너에는 Singleton 외에도 Scoped와 Transient라는 서비스 수명이 있다.

세 가지 수명의 차이

수명생성 및 재사용 기준

Singleton DI 컨테이너 내에서 하나의 인스턴스를 공유
Scoped 동일 Scope 안에서 하나의 인스턴스 재사용
Transient DI 컨테이너에서 서비스를 해결할 때마다 새 인스턴스 생성

일반적인 ASP.NET Core HTTP 요청에서는 요청마다 Scope가 생성된다.

따라서 Scoped 서비스는 요청 A와 요청 B에서 각각 다른 인스턴스를 사용한다.

Singleton이 Scoped DbContext를 주입받으면?

다음과 같은 상황을 생각해 보자.

builder.Services.AddSingleton<GameService>();
builder.Services.AddDbContext<GameDbContext>();

AddDbContext는 기본적으로 DbContext를 Scoped 수명으로 등록한다.

그런데 Singleton인 GameService가 생성자에서 GameDbContext를 주입받는다면 수명 불일치가 발생한다.

Scoped 객체를 수명이 긴 Singleton이 붙잡게 되는 문제를 Captive Dependency라고 한다.

ASP.NET Core에서 Scope 검증이 활성화되어 있다면 이러한 잘못된 의존성은 예외로 감지될 수 있다.

반대로 검증 없이 허용되면 DbContext가 의도보다 오래 유지되어 여러 요청 사이에 공유될 위험이 있다.

DbContext는 스레드 안전하지 않기 때문에 이러한 구조는 특히 위험하다.

서버 전체에서 공유해야 할 특별한 상태가 없는 일반적인 요청 처리 서비스라면 다음과 같은 등록이 적절할 수 있다.

builder.Services.AddScoped<GameService>();
builder.Services.AddDbContext<GameDbContext>();

이렇게 하면 일반적인 HTTP 요청에서 두 서비스가 같은 요청 Scope에 속하고, 다른 요청에서는 별도 인스턴스를 사용할 수 있다.

단, Singleton이 정말 필요한 서비스라면 별도의 Scope 생성이나 IDbContextFactory<TContext>와 같은 대안도 검토할 수 있다.

Singleton은 스레드 안전할까?

Singleton은 인스턴스 공유를 의미할 뿐, 스레드 안전성을 자동으로 보장하지 않는다.

여러 요청이 공유 상태에 동시에 접근한다면 Race Condition이 발생하지 않도록 별도 동기화가 필요하다.

4. C# 게임 서버 — CancellationToken과 비동기 예외 처리

CancellationToken은 비동기 작업을 강제로 종료하는 기능이 아니다.

취소 요청을 작업에 전달하고, 작업이 이를 확인하여 협력적으로 중단하도록 만드는 구조다.

ASP.NET Core에서는 클라이언트 연결 종료 등의 상황을 HttpContext.RequestAborted를 통해 확인할 수 있다.

Minimal API의 CancellationToken 매개변수도 요청 취소 토큰을 전달받을 수 있다.

CancellationToken은 하위 작업까지 전달해야 한다

app.MapGet("/player/{id}", async (
    long id,
    CancellationToken ct,
    PlayerService service) =>
{
    var player = await service.GetPlayerAsync(id, ct);

    return Results.Ok(player);
});

GetPlayerAsync()가 비동기 DB 조회를 수행한다면 내부 DB API까지 토큰을 전달하는 것이 중요하다.

그렇지 않으면 클라이언트의 요청이 취소되었더라도 서버의 DB 작업은 계속 진행될 수 있다.

단, 토큰을 전달했다고 모든 DB 작업이 반드시 즉시 취소되는 것은 아니다.

catch와 throw의 차이

이번 복습에서 헷갈렸던 부분은 취소 예외를 처리한 이후의 실행 흐름이었다.

다음 코드를 보자.

try
{
    await Task.Delay(5000, ct);
}
catch (OperationCanceledException)
{
    Console.WriteLine("취소됨");
}

Console.WriteLine("다음 작업");

취소 예외가 발생해 catch에서 처리되고 다시 던지지 않는다면, 예외 처리 이후의 코드도 계속 실행될 수 있다.

반면 다음 코드는 다르다.

catch (OperationCanceledException)
{
    Console.WriteLine("취소됨");
    throw;
}

throw;는 현재 예외를 다시 던진다.

따라서 상위 호출자가 await하는 지점까지 예외가 전달될 수 있다.

중요한 것은 throw;는 return과 다르다는 점이다.

취소 예외를 처리하고 정상적으로 종료하면 반환 Task가 정상 완료 상태가 될 수 있고, 예외를 전파하면 호출자가 취소를 인식하고 처리할 수 있다.

취소와 DB 장애는 서로 다른 상황이므로 운영 로그 및 예외 처리 정책에서도 구분해야 한다.

5. 게임 서버 DB — 복합 인덱스와 EXPLAIN ANALYZE

PostgreSQL에서 복합 인덱스는 여러 컬럼을 하나의 인덱스로 관리하는 방식이다.

예를 들어 게임 서버에 다음 테이블과 인덱스가 있다고 가정하자.

CREATE TABLE player_items (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    status INT NOT NULL
);

CREATE INDEX idx_user_status
ON player_items(user_id, status);

복합 인덱스의 컬럼 순서

(user_id, status) 복합 B-Tree 인덱스는 먼저 user_id를 기준으로 정렬되고, 같은 user_id 안에서 status를 기준으로 정렬된다.

따라서 다음 세 조회 조건의 효율이 달라질 수 있다.

-- A
SELECT * FROM player_items
WHERE user_id = 100;

-- B
SELECT * FROM player_items
WHERE status = 1;

-- C
SELECT * FROM player_items
WHERE user_id = 100 AND status = 1;

일반적으로 A는 선행 컬럼인 user_id를 이용해 범위를 좁힐 수 있다.

C는 두 컬럼 조건을 모두 이용해 탐색 범위를 더 좁힐 수 있다.

반면 B는 선행 컬럼 조건이 없으므로 일반적으로 불리하다.

단, PostgreSQL 18에는 Skip Scan 최적화가 있어 선행 컬럼 조건이 없더라도 데이터 분포와 비용에 따라 복합 인덱스가 활용될 수 있다.

따라서 복합 인덱스의 선행 컬럼 규칙은 중요하지만, 특정 조건에서 인덱스를 절대로 사용할 수 없다고 단정해서는 안 된다.

EXPLAIN ANALYZE는 무엇을 확인할까?

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

EXPLAIN ANALYZE는 SQL을 실제 실행하여 실행 시간과 실제 처리 행 수 등을 보여준다.

EXPLAIN ANALYZE
SELECT *
FROM player_items
WHERE user_id = 100
  AND status = 1;

이 결과에서는 예상 행 수와 실제 행 수를 비교하고, 실행 계획이 Index Scan, Seq Scan 등 어떤 방식을 사용했는지 확인할 수 있다.

예상 행 수와 실제 행 수가 크게 다르다면 통계 정보나 데이터 분포 등을 살펴볼 필요가 있다.

다만 EXPLAIN ANALYZE는 실제 쿼리를 실행하므로, 운영 DB에서 변경 SQL을 분석할 때는 특히 주의해야 한다.

이번 복습에서는 복합 인덱스의 선행 컬럼 원리를 직접 다시 설명했다. EXPLAIN ANALYZE를 실제 실행하여 분석하는 실습은 별도로 진행할 예정이다.


오늘 새롭게 알게 된 점

  1. HashSet은 해시값이 충돌하더라도 실제 값이 다르면 중복으로 판단하지 않는다.
  2. Sliding Window에서는 첫 구간의 합으로 최댓값을 초기화해야 음수 배열도 정확하게 처리할 수 있다.
  3. Singleton이 Scoped 객체를 오래 참조하면 DI 수명 문제가 발생할 수 있다.
  4. catch에서 throw;를 실행하면 정상 반환이 아니라 예외가 다시 전파된다.
  5. CancellationToken은 하위 비동기 작업까지 전달해야 취소 요청을 활용할 수 있다.
  6. 복합 인덱스는 컬럼 순서가 중요하며 선행 컬럼만으로도 효율적인 탐색이 가능하다.

추가로 복습할 내용

  • HashSet과 Dictionary의 내부 구조 및 사용 기준
  • 가변 길이 Sliding Window 문제
  • Singleton의 스레드 안전성과 Scoped 의존성 관리
  • CancellationToken과 예외 전파의 실행 순서
  • PostgreSQL 복합 인덱스의 범위 조건 및 ORDER BY 최적화
  • EXPLAIN ANALYZE를 통한 실제 실행 계획 분석

오늘도 복습 중 잘못 이해한 부분이 발견되었다.

특히 DI 서비스 수명, throw;의 동작, 복합 인덱스 컬럼 순서는 처음부터 완전히 설명하지 못했다.

하지만 추가 질문을 통해 원리를 다시 이해하고 설명할 수 있었다.

앞으로도 외운 정답보다 왜 그렇게 동작하는지 설명할 수 있는 능력을 기르는 데 집중하려고 한다.


이 글의 C#과 SQL 예시는 학습용이다. 별도의 코드 실행, DB 쿼리 실행, 성능 측정 결과는 포함하지 않았다. 게임 서버 사례는 모두 일반화한 가상 상황이다.

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의 인덱스처럼 사용법은 알고 있어도 내부 원리를 정확히 이해하지 못한 부분이 있었다.

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


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

+ Recent posts