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에서 동시 요청 테스트를 진행한 것은 아니므로, 구현의 완전한 안전성이 검증되었다고 볼 수는 없다.
오늘 새롭게 알게 된 점
- Dictionary의 next는 일반적인 .NET 구현에서 다음 엔트리의 인덱스를 저장한다.
- A*에서는 허용성을 만족하면서 일관성은 위반할 수 있다.
- 디자인 패턴은 적용하지 않는 편이 더 단순하고 적절한 상황도 있다.
- COMMIT 응답을 받지 못했다면 실제 커밋 결과가 불확실할 수 있다.
- requestId의 존재만으로 구매 성공을 단정할 수는 없다.
- ROLLBACK은 같은 트랜잭션에 포함된 구매 기록과 골드 차감을 함께 취소한다.
추가로 복습할 내용
- Dictionary 엔트리 배열의 next와 버킷 연결 구조
- A* 허용성과 일관성의 새로운 그래프 판별
- Factory 패턴 적용 여부를 결정하는 기준
- COMMIT 결과 불확실성 및 중복 요청 복구
- PostgreSQL UPDATE RETURNING의 Npgsql 처리 방식
- UNIQUE·ON CONFLICT를 활용한 멱등성 구현
- 트랜잭션 재시도와 동시 구매 처리
이번에는 앞선 중간 평가에서 부족했던 DB SQL 작성 능력을 다시 확인할 수 있었다.
그러나 실제 코드 실행과 동시성 테스트는 아직 진행하지 않았다.
앞으로는 개념 이해 → 코드 구현 → 실제 실행 및 검증까지 연결하는 것을 목표로 공부하려고 한다.
이 글에 포함된 C#·SQL은 학습 목적의 예시이며 실제 실행이나 성능 측정을 수행한 결과는 포함하지 않았다. 회사의 비공개 소스 코드와 내부 데이터는 사용하지 않았다.
'서버 공부 > 매일학습' 카테고리의 다른 글
| Day 7 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.09 |
|---|---|
| Day 6 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.07 |
| Day 5 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.06 |
| Day 4 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.05 |
| Day 3 — 오늘 배운 게임 서버 개발 지식 5가지 (1) | 2026.11.04 |
