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

학습일: 2026년 11월 9일
복습일: 2026년 11월 10일
학습 주제: Dictionary, A* 알고리즘, Factory 패턴, ASP.NET Core 구매 API, PostgreSQL 트랜잭션

오늘은 기존에 공부했던 개념 중 헷갈렸던 부분을 다시 확인하고, 실제 게임 서버에 적용할 수 있는지 복습했다.

특히 게임 서버의 구매 처리에서 트랜잭션과 멱등성이 어떤 역할을 하는지, DB 장애 상황에서 어떻게 재시도해야 하는지를 중점적으로 공부했다.

1. 자료구조 — Dictionary의 해시 충돌 처리와 메모리 구조

C#의 Dictionary<TKey, TValue>는 해시를 이용하여 Key에 해당하는 데이터를 빠르게 조회하는 자료구조다.

평균적인 조회 시간 복잡도는 O(1)이지만, 해시 충돌이 심한 경우에는 최악 O(N)이 발생할 수 있다.

해시값이 같으면 데이터를 저장할 수 없을까?

그렇지 않다.

예를 들어 다음 Dictionary가 있다고 가정하자.

Dictionary<string, Player> players = new();

서로 다른 "PlayerA"와 "PlayerB"의 해시값이 같더라도, 두 Key는 함께 저장될 수 있다.

Dictionary는 해시값만으로 Key의 중복 여부를 결정하지 않기 때문이다.

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

  • 버킷 배열: 해당 버킷의 첫 번째 엔트리를 찾기 위한 정보 관리
  • 엔트리 배열: 해시값, Key, Value, 다음 엔트리의 위치 정보 등 저장

해시값을 이용하여 버킷 위치를 결정하고, 같은 버킷에 연결된 엔트리를 확인하면서 실제 Key의 동등성을 비교한다.

따라서 서로 다른 Key가 같은 버킷에 들어가더라도 이를 구분할 수 있다.

Dictionary의 next는 무엇을 저장할까?

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

처음에는 next가 다음 엔트리 객체의 참조를 저장한다고 설명했다.

하지만 일반적인 .NET Dictionary 구현에서 next는 엔트리 배열의 인덱스를 저장한다.

예를 들어 같은 버킷에 속한 두 엔트리가 배열의 2번과 7번 위치에 있다고 가정하자.

Bucket
   ↓
Entry[7]
   │ next = 2
   ↓
Entry[2]

실제 엔트리들이 배열에서 서로 떨어져 있어도 인덱스를 통해 연결할 수 있다.

이 부분은 추가 설명을 통해 이해했지만, 다음 복습에서 다시 확인할 예정이다.

List와 LinkedList의 실제 성능 차이

이번 학습에서는 List<T>와 LinkedList<T>의 성능도 다시 비교했다.

두 자료구조 모두 전체 순회의 시간 복잡도는 O(N)이지만 실제 실행 시간은 다를 수 있다.

List는 내부 배열을 사용하므로 연속적인 메모리 접근과 CPU 캐시 지역성 측면에서 유리한 경우가 많다.

반면 LinkedList는 노드의 참조를 따라가야 하므로 메모리 접근 비용이 더 클 수 있다.

단, Player가 클래스라면 List<Player> 배열에 연속적으로 저장되는 것은 객체 자체가 아니라 객체 참조다.

시간 복잡도뿐 아니라 메모리 구조와 실제 접근 방식까지 고려해 자료구조를 선택해야 한다.

2. 알고리즘 — A* 허용성과 일관성의 차이

A* 알고리즘은 누적 이동 비용과 목적지까지의 추정 비용을 함께 사용하여 탐색할 노드를 선택한다.

f(n) = g(n) + h(n)
  • g(n): 시작점부터 현재 노드까지 누적 비용
  • h(n): 현재 노드에서 목적지까지 추정 비용
  • f(n): 탐색 우선순위를 결정하는 평가 비용

허용성(Admissibility)

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

h(n) <= h*(n)

h*(n)은 해당 노드에서 목적지까지의 실제 최단 비용이다.

예를 들어 다음 경로가 있다고 가정하자.

A ──3──> B ──2──> G
 \                  ^
  └─────6──────────┘

A에서 G까지 실제 최단 비용은 B를 거치는 3 + 2 = 5다.

휴리스틱이 다음과 같다면:

h(A) = 5
h(B) = 1
h(G) = 0

A에서는 실제 최단 비용과 같으므로 허용성을 만족한다. B와 G에서도 허용성을 만족한다.

허용성은 실제 최단 비용보다 작을 때뿐 아니라 같은 경우에도 만족한다.

일관성(Consistency)

일관성은 모든 간선에 대해 다음 조건을 만족하는 것이다.

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

앞선 그래프에서 A → B를 확인하면:

5 <= 3 + 1
5 <= 4  // 거짓

따라서 일관성은 만족하지 않는다.

B → G 및 A → G의 조건을 만족하더라도 A → B에서 위반했으므로 전체 휴리스틱은 일관적이지 않다.

이 예시는 허용적이지만 일관적이지 않은 휴리스틱이다.

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

따라서 노드 재개방을 허용하지 않는 A* 구현에서는 최단 경로를 놓칠 위험이 있다.

이번 복습에서는 최초 판단에서 허용성과 일관성을 혼동했지만, 새로운 그래프에서는 두 조건을 정확하게 구분할 수 있었다.

Sliding Window 복습

가변 길이 Sliding Window에서는 left, right 포인터가 한 방향으로만 이동하면 전체 시간 복잡도를 O(N)으로 분석할 수 있다.

각 포인터가 전체 실행 동안 최대 N번만 이동하기 때문이다.

일정한 개수의 변수만 사용하면 추가 공간 복잡도도 O(1)이다.

3. 디자인 패턴 — Factory를 사용하지 않는 것도 설계다

게임 서버에서 몬스터 생성 로직을 구현한다고 가정하자.

현재 몬스터는 Slime과 Goblin 두 종류뿐이며 생성 로직도 단순하다.

Monster monster = type switch
{
    MonsterType.Slime => new Slime(),
    MonsterType.Goblin => new Goblin(),
    _ => throw new ArgumentException()
};

이런 경우에도 Factory 패턴을 적용하는 것이 반드시 좋을까?

그렇지 않다.

Simple Factory와 Factory Method

Simple Factory는 하나의 팩토리 메서드에서 생성할 객체를 결정하는 방식이다.

예를 들어 MonsterFactory.Create(type)이 몬스터 종류를 확인하여 객체를 생성할 수 있다.

Factory Method는 상위 클래스에서 생성 메서드를 정의하고 하위 클래스에서 이를 재정의하여 구체적인 객체 생성을 결정하는 패턴이다.

구분Simple FactoryFactory Method

객체 생성 결정 하나의 팩토리 메서드 하위 클래스
주요 구조 조건 분기 등 상속과 override
고려할 점 생성 로직이 집중됨 클래스 구조가 추가됨

하지만 몬스터 종류가 적고 생성 위치가 한 곳이며 변경 가능성도 낮다면 현재의 간단한 switch로 충분할 수 있다.

별도의 Factory 구조를 도입하면 오히려 유지해야 할 코드가 늘어날 수 있기 때문이다.

Observer 패턴도 복습했다

Observer 패턴은 이벤트 발행자와 구독자의 직접적인 의존성을 줄이는 방식이다.

예를 들어 몬스터 처치 이벤트에 퀘스트, 업적, 통계 기능이 반응하도록 구성할 수 있다.

하지만 C#의 일반적인 event는 이벤트 영속성이나 자동 재시도를 제공하지 않는다.

따라서 골드 지급과 같은 핵심 데이터 변경에서는 별도의 트랜잭션과 멱등성 처리가 필요하다.

디자인 패턴은 무조건 적용하는 것이 아니라 실제 문제를 해결하기 위해 선택해야 한다.

4. C# 게임 서버 — COMMIT 결과가 불확실할 때

ASP.NET Core에서 구매 API를 처리할 때는 클라이언트 요청 취소와 DB 트랜잭션 상태를 구분해야 한다.

다음 상황을 가정하자.

① requestId 등록
② 골드 100 차감
③ 인벤토리 아이템 지급
④ COMMIT 요청 전송
⑤ DB 연결 종료

서버가 COMMIT의 성공 응답을 받지 못했다면 어떻게 해야 할까?

COMMIT 결과를 확신할 수 없다

서버가 응답을 받지 못했다는 사실만으로 DB가 COMMIT에 실패했다고 판단할 수 없다.

DB에서는 COMMIT이 성공했지만 응답이 전달되지 않았을 가능성이 있기 때문이다.

반대로 COMMIT이 실제로 완료되지 않았을 수도 있다.

따라서 서버는 이 상황을 커밋 결과가 불확실한 상태로 다뤄야 한다.

동일한 requestId로 재시도한다면?

클라이언트가 같은 requestId로 구매를 재요청했다고 가정하자.

서버는 먼저 기존 구매 기록과 처리 결과를 확인할 수 있다.

  • 기존 구매가 커밋되어 성공했다면 골드와 아이템을 다시 변경하지 않는다.
  • 기존 구매가 커밋되지 않았고 처리 중인 동일 요청도 없다면 새로운 트랜잭션에서 구매 조건을 검증한다.
  • 기존 결과가 실패이거나 처리 중이라면 해당 상태에 맞는 정책으로 대응한다.

여기서 중요한 것은 requestId를 전송하는 것만으로 멱등성이 보장되지는 않는다는 점이다.

DB UNIQUE 제약, 요청 내용 검증, 기존 처리 결과 저장, 동시 요청 제어 등을 함께 고려해야 한다.

트랜잭션과 멱등성은 다르다

트랜잭션은 여러 DB 변경을 원자적으로 처리하는 데 사용된다.

멱등성은 동일한 논리적 요청을 반복하더라도 중복 차감이나 중복 지급이 발생하지 않도록 하는 성질이다.

게임 서버 구매 API에서는 두 가지 모두 중요하다.

5. 게임 서버 DB — 조건부 UPDATE와 멱등성 SQL

이번에는 PostgreSQL에서 구매 요청을 중복 처리하지 않도록 하는 SQL과 ROLLBACK의 동작을 다시 복습했다.

조건부 골드 차감

플레이어의 현재 골드가 구매 가격 이상일 때만 차감하도록 다음 SQL을 사용할 수 있다.

UPDATE players
SET gold = gold - 100
WHERE user_id = 10
  AND gold >= 100
RETURNING gold;

WHERE gold >= 100 조건으로 잔액이 부족할 때 차감하지 않도록 한다.

RETURNING gold는 갱신된 행의 새로운 골드 잔액을 반환한다.

결과가 0행이면 차감이 이루어지지 않은 것이다. 다만 잔액 부족뿐 아니라 플레이어가 존재하지 않는 경우도 있으므로 원인을 추가로 구분할 수 있다.

UNIQUE와 ON CONFLICT

구매 요청 기록 테이블이 다음과 같다고 가정하자.

CREATE TABLE purchase_requests (
    request_id VARCHAR(50) PRIMARY KEY,
    user_id BIGINT NOT NULL
);

같은 요청 ID의 중복 삽입을 방지하기 위해 다음 SQL을 작성할 수 있다.

INSERT INTO purchase_requests (
    request_id,
    user_id
)
VALUES (
    'BUY-100',
    10
)
ON CONFLICT (request_id) DO NOTHING
RETURNING request_id;

이번 복습에서는 이 SQL을 힌트 없이 직접 작성했다.

새로운 요청이 삽입되면 RETURNING으로 해당 ID를 확인할 수 있다.

반면 같은 요청 ID가 이미 존재하여 DO NOTHING이 실행된다면 새 행은 반환되지 않는다.

단, 반환 결과가 없다는 사실만으로 기존 구매가 성공했다고 판단할 수는 없다. 구매 결과를 별도로 저장하고 확인할 수 있는 설계가 필요하다.

ROLLBACK과 재요청

다음 작업이 하나의 트랜잭션에 포함되었다고 가정하자.

BEGIN
  ↓
requestId 등록
  ↓
골드 차감
  ↓
아이템 지급 중 SQL 오류
  ↓
ROLLBACK

이 경우 같은 트랜잭션에서 등록한 구매 요청 기록과 골드 차감은 모두 취소된다.

따라서 정상적으로 롤백이 완료되었다면 requestId 기록도 남지 않고, 골드도 차감 이전 상태로 돌아간다.

클라이언트가 동일한 requestId로 다시 요청하면 서버는 새 트랜잭션에서 구매 조건을 다시 검증할 수 있다.

다만 다른 트랜잭션으로 인해 골드가 변경되었을 수 있으므로 이전 잔액을 그대로 신뢰해서는 안 된다.

이번 학습에서 가장 중요했던 점은 중복 요청 기록과 골드 차감, 아이템 지급을 동일한 트랜잭션으로 처리하는 것이었다.

실제 DB에서 동시 요청 테스트를 진행한 것은 아니므로, 구현의 완전한 안전성이 검증되었다고 볼 수는 없다.


오늘 새롭게 알게 된 점

  1. Dictionary의 next는 일반적인 .NET 구현에서 다음 엔트리의 인덱스를 저장한다.
  2. A*에서는 허용성을 만족하면서 일관성은 위반할 수 있다.
  3. 디자인 패턴은 적용하지 않는 편이 더 단순하고 적절한 상황도 있다.
  4. COMMIT 응답을 받지 못했다면 실제 커밋 결과가 불확실할 수 있다.
  5. requestId의 존재만으로 구매 성공을 단정할 수는 없다.
  6. ROLLBACK은 같은 트랜잭션에 포함된 구매 기록과 골드 차감을 함께 취소한다.

추가로 복습할 내용

  • Dictionary 엔트리 배열의 next와 버킷 연결 구조
  • A* 허용성과 일관성의 새로운 그래프 판별
  • Factory 패턴 적용 여부를 결정하는 기준
  • COMMIT 결과 불확실성 및 중복 요청 복구
  • PostgreSQL UPDATE RETURNING의 Npgsql 처리 방식
  • UNIQUE·ON CONFLICT를 활용한 멱등성 구현
  • 트랜잭션 재시도와 동시 구매 처리

이번에는 앞선 중간 평가에서 부족했던 DB SQL 작성 능력을 다시 확인할 수 있었다.

그러나 실제 코드 실행과 동시성 테스트는 아직 진행하지 않았다.

앞으로는 개념 이해 → 코드 구현 → 실제 실행 및 검증까지 연결하는 것을 목표로 공부하려고 한다.


이 글에 포함된 C#·SQL은 학습 목적의 예시이며 실제 실행이나 성능 측정을 수행한 결과는 포함하지 않았다. 회사의 비공개 소스 코드와 내부 데이터는 사용하지 않았다.

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

학습일: 2026년 11월 7일
복습일: 2026년 11월 9일
주제: C# 자료구조, A* 알고리즘, 디자인 패턴, ASP.NET Core, PostgreSQL

게임 서버 개발을 위한 14일 압축 학습의 7일차를 맞아 중간 평가를 진행했다.

그동안 배운 개념을 다시 설명하고, 코드와 SQL을 직접 작성하면서 실제로 이해하고 있는지 확인했다.

이번 평가를 통해 깨달은 점은 개념을 이해했다고 생각하는 것과 새로운 문제에 정확하게 적용하는 것은 다르다는 것이다.

1. 자료구조 — 시간 복잡도가 같아도 실제 성능이 다른 이유

C#에서 학습한 주요 자료구조를 다시 비교했다.

자료구조주요 특징대표적인 사용 사례

List<T> 내부 배열 사용 순차 접근이 많은 데이터
LinkedList<T> 노드 간 참조 연결 노드 참조를 알고 있는 상태의 삽입·삭제
Dictionary<TKey, TValue> 해시 기반 Key 조회 UserId로 플레이어 조회
HashSet<T> 중복 없는 값 관리 중복 식별자 검사
Stack<T> LIFO 되돌리기, 중첩 작업
Queue<T> FIFO 순서대로 처리하는 작업 대기열
PriorityQueue<TElement, TPriority> 우선순위 기준 처리 예약 작업, 우선순위 작업

List와 LinkedList는 둘 다 O(N)인데 왜 속도가 다를까?

오늘 복습에서는 이 질문을 다시 확인했다.

두 자료구조 모두 전체 데이터를 순회하는 시간 복잡도는 O(N)이다.

하지만 실제 실행 속도는 달라질 수 있다.

List<T>는 내부 배열을 사용하므로 요소들이 배열에 연속적으로 배치된다. 따라서 CPU 캐시 지역성 측면에서 유리한 경우가 많다.

반면 LinkedList<T>는 각 노드의 참조를 따라 이동해야 한다.

노드들이 메모리에 흩어져 있을 수 있어 추가적인 메모리 접근 비용이 발생할 수 있다.

단, List<Player>에서 Player가 클래스라면 연속적으로 저장되는 것은 Player 객체 자체가 아니라 객체 참조라는 점에 주의해야 한다.

즉, 시간 복잡도가 같다고 실제 실행 시간까지 같은 것은 아니다.

Heap의 삭제 원리도 다시 확인했다

최소 힙에서는 루트에 가장 작은 우선순위가 위치한다.

최솟값을 제거하면 마지막 노드를 루트로 이동시키고, 자식들과 비교하며 힙 조건을 복구한다.

이 과정을 Sift Down이라고 한다.

힙의 삽입과 삭제는 일반적으로 높이에 비례하는 O(log N)의 시간 복잡도를 가진다.

.NET의 PriorityQueue<TElement, TPriority>는 일반적인 이진 힙과 달리 내부적으로 4진 최소 힙을 사용한다.

2. 알고리즘 — A* 허용성과 일관성의 차이

오늘 알고리즘 복습에서 가장 헷갈렸던 부분은 A*의 허용성과 일관성이었다.

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

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

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

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은 학습용 예시이며, 별도의 컴파일·실행·성능 측정은 수행하지 않았다. 실제 회사의 소스 코드나 내부 데이터는 포함하지 않았다.

+ Recent posts