Day 9 — 오늘 배운 게임 서버 개발 지식 5가지
학습일: 2026년 11월 10일
복습일: 2026년 11월 11일
학습 주제: Dictionary, A*와 Prefix Sum, 디자인 패턴, 구매 멱등성, PostgreSQL 트랜잭션
오늘은 게임 서버 개발에 필요한 다섯 가지 영역을 복습했다.
이번에는 새로운 개념을 외우기보다, 이전에 헷갈렸던 내용을 기억만으로 설명하고 실제 게임 서버 상황에 적용할 수 있는지 확인하는 데 집중했다.
특히 Dictionary의 해시 충돌과 PostgreSQL 구매 API의 멱등성 처리 원리를 더 정확하게 이해할 수 있었다.
1. 자료구조 — Dictionary는 해시 충돌이 발생해도 왜 빠를까?
C#의 Dictionary<TKey, TValue>는 해시 테이블을 이용하여 Key에 해당하는 Value를 관리한다.
평균 조회 시간 복잡도는 O(1)이지만, 해시 충돌이 심하면 조회 비용이 커질 수 있다.
Dictionary의 내부 구조
일반적인 .NET Dictionary는 내부적으로 버킷 배열과 엔트리 배열을 사용한다.
- 버킷 배열: 해당 버킷에 연결된 엔트리의 위치 정보를 저장
- 엔트리 배열: Key, Value, 해시값, next 정보 등을 저장
데이터를 조회할 때는 Key의 해시값으로 버킷을 찾고, 해당 버킷에 연결된 엔트리들을 확인한다.
해시값이 같더라도 실제 Key가 다르면 서로 다른 데이터로 관리할 수 있다.
해시 충돌은 어떻게 처리할까?
예를 들어 PlayerA와 PlayerB가 같은 버킷에 배정되었다고 가정하자.
Bucket
↓
Entry[7] — PlayerB
│ next = 2
↓
Entry[2] — PlayerA
여기서 next에는 다음 엔트리 객체의 참조가 아니라 엔트리 배열의 인덱스가 저장된다.
이렇게 연결된 엔트리를 탐색하면서 실제 Key의 동등성을 검사한다.
충돌이 발생하는데도 평균 O(1)인 이유
해시값이 적절하게 분포하고 버킷당 평균 엔트리 수가 작게 유지된다면, 조회할 때마다 많은 엔트리를 확인할 필요가 없다.
Dictionary는 데이터 증가에 따라 내부 배열을 확장하고 버킷에 엔트리를 다시 배정할 수 있다.
이때 저장된 데이터 개수가 줄어드는 것이 아니라, 더 많은 버킷으로 분산되어 버킷당 평균 연결 엔트리 수가 줄어들 수 있다.
예를 들어 데이터 1,000개를 100개 버킷에 분산하면 평균 10개이고, 200개 버킷에 분산하면 평균 5개다.
단, 이는 전체 버킷을 기준으로 계산한 평균이며 실제 충돌 분포는 해시 함수와 Key의 특성에 따라 달라진다.
오늘 다시 확인한 점: 일부 해시 충돌이 발생한다는 사실만으로 평균 O(1)이 깨지는 것은 아니다.
2. 알고리즘 — A* 탐색 단계와 Prefix Sum
A*에서는 발견한 경로 비용을 언제 갱신할까?
A*는 다음 평가 비용을 기준으로 탐색 우선순위를 결정한다.
f(n) = g(n) + h(n)
- g(n): 시작 노드부터 현재 노드까지 발견한 최선의 누적 비용
- h(n): 목적지까지의 추정 비용
- f(n): 두 비용을 합한 평가값
다음 그래프를 살펴보자.
A ──3──> B ──4──> G
\ ^
└─────10─────────┘
A에서 탐색을 시작한다고 가정하자.
먼저 A의 이웃 노드를 확인하면:
g(A) = 0
g(B) = 3
g(G) = 10
이 시점에는 아직 B를 경유한 경로를 확인하지 않았다.
따라서 G까지 더 짧은 경로가 존재하더라도 미리 g(G)=7로 기록하면 안 된다.
이후 B를 처리하여 B → G 간선을 확인하면:
새로운 경로 비용 = g(B) + 4
= 3 + 4
= 7
기존 g(G)=10보다 작기 때문에 7로 갱신한다.
여기서 중요한 것은 실제 최단 경로를 알고 있는 것과 탐색 과정에서 해당 경로를 발견한 것은 다르다는 점이다.
A* 허용성과 일관성
이전 학습에서 헷갈렸던 두 조건도 다시 정리했다.
허용성(Admissibility) 은 휴리스틱이 실제 남은 최단 비용을 과대평가하지 않는 조건이다.
h(n) <= h*(n)
일관성(Consistency) 은 모든 간선에 대해 다음 조건을 만족하는 것이다.
h(u) <= c(u,v) + h(v)
허용성은 목적지까지의 실제 최단 비용과 비교하고, 일관성은 인접 노드 사이의 비용과 휴리스틱을 비교한다.
일관성이 깨지면 탐색 완료한 노드에 대해 나중에 더 저렴한 경로가 발견될 수 있다. 따라서 구현에 따라 노드 재개방이 중요해질 수 있다.
Prefix Sum — 누적 합으로 구간 합 구하기
이번에는 Prefix Sum(누적 합)의 기본 원리도 학습했다.
Prefix Sum은 모든 구간의 합을 각각 저장하는 것이 아니라 배열의 시작부터 특정 위치까지의 누적 합을 저장하는 방식이다.
예를 들어 다음 배열이 있다고 가정하자.
numbers = [2, 3, 1, 4]
앞에 0을 추가한 누적 합 배열을 만들면:
prefix = [0, 2, 5, 6, 10]
인덱스 1부터 3까지의 합은 다음과 같다.
prefix[4] - prefix[1]
= 10 - 2
= 8
일반적인 누적 합 배열을 한 번 구축하면:
작업시간 또는 공간 복잡도
| 누적 합 구축 | O(N) |
| 추가 공간 | O(N) |
| 구간 합 조회 | O(1) |
배열의 값이 바뀌지 않고 여러 구간의 합을 반복해서 조회하는 경우 특히 유용하다.
다만 데이터가 자주 수정되는 경우에는 갱신 비용도 함께 고려해야 한다.
3. 디자인 패턴 — Decorator와 Observer의 실무 적용
Decorator 패턴
Decorator는 기존 객체를 다른 객체로 감싸 기존 기능을 유지하면서 부가 기능을 확장하는 패턴이다.
예를 들어 게임 서버에서 골드 지급 기능에 로그 출력을 추가한다고 가정해 보자.
LoggingRewardDecorator
↓
GoldRewardService
LoggingRewardDecorator가 GoldRewardService를 내부 객체로 보유하고, 지급 기능을 호출하기 전후에 로그를 출력하도록 구성할 수 있다.
객체 생성 순서는 내부 객체인 GoldRewardService가 먼저 생성되고, 그 객체가 LoggingRewardDecorator에 전달되는 방식이다.
실행 흐름은 다음과 같다.
Before
↓
GoldRewardService.Grant()
↓
After
이렇게 하면 여러 부가 기능을 조합하여 확장할 수 있다.
하지만 단순히 로그 한 줄만 추가하려는 상황이라면 별도 Decorator 클래스를 만드는 것이 오히려 복잡할 수 있다.
패턴 적용 여부는 기능의 재사용성, 조합 필요성, 변경 가능성 등을 기준으로 결정해야 한다.
Observer 패턴과 이벤트 정합성
Observer는 이벤트 발행자와 구독자의 직접적인 의존성을 낮추는 패턴이다.
예를 들어 골드 지급이 완료된 뒤 통계 이벤트를 발행한다고 가정하자.
골드 지급
↓
DB COMMIT 성공
↓
GoldGranted 이벤트 발행
↓
통계 구독자 실행
여기서 통계 구독자가 예외를 발생시키더라도 이미 커밋된 골드 지급은 자동으로 롤백되지 않는다.
또한 일반적인 C# 동기 이벤트에서 구독자가 처리되지 않은 예외를 발생시키면 그 이후의 구독자 호출이 중단될 수 있다.
C#의 일반적인 event는 이벤트 영속성이나 자동 재시도를 제공하지 않는다.
따라서 게임 서버의 핵심 보상 처리에서는 이벤트 발행 자체에 정합성을 의존하기보다 트랜잭션과 멱등성 처리를 별도로 고려해야 한다.
4. C# 게임 서버 — 동시 구매와 COMMIT 불확실성
게임 서버에서 동일한 구매 요청이 여러 번 전달되는 상황은 충분히 발생할 수 있다.
예를 들어 클라이언트가 구매 요청을 보내고 서버가 COMMIT을 진행하던 중 연결 장애가 발생한다면, 클라이언트는 구매 성공 여부를 확인하지 못할 수 있다.
이러한 상황에서 중요한 개념이 멱등성(Idempotency) 이다.
동일한 requestId를 두 번 등록하면?
PostgreSQL에 다음과 같은 구매 요청 테이블이 있다고 가정하자.
CREATE TABLE purchase_requests (
request_id VARCHAR(50) PRIMARY KEY,
user_id BIGINT NOT NULL
);
트랜잭션 A가 BUY-100을 등록하고 아직 커밋하지 않았다.
동시에 트랜잭션 B가 같은 요청 ID를 다음과 같이 등록하려고 한다.
INSERT INTO purchase_requests (request_id, user_id)
VALUES ('BUY-100', 10)
ON CONFLICT (request_id) DO NOTHING
RETURNING request_id;
이때 A가 아직 트랜잭션을 종료하지 않았다면 B는 UNIQUE 충돌의 결과를 확인하기 위해 대기할 수 있다.
이후 결과는 다음과 같이 달라진다.
트랜잭션 A의 결과트랜잭션 B의 동작
| A가 COMMIT | 중복 키가 확정되어 INSERT 생략 |
| A가 ROLLBACK | 중복 키가 사라져 INSERT 진행 가능 |
이러한 동작을 이용하면 동일한 요청 ID의 중복 등록을 DB 차원에서 제어할 수 있다.
다만 UNIQUE 제약이 존재한다고 구매 API 전체의 멱등성이 자동 완성되는 것은 아니다.
골드 차감, 아이템 지급, 구매 결과 기록을 같은 트랜잭션으로 처리하고, 기존 결과를 조회하는 로직까지 필요하다.
COMMIT 결과가 불확실하다면?
DB 연결이 COMMIT 도중 끊어졌다면 서버가 성공 응답을 받지 못했어도 실제 커밋이 성공했을 수 있다.
따라서 무조건 새 구매로 처리해서는 안 된다.
같은 requestId로 안전하게 결과를 확인하거나 재시도할 수 있도록 설계해야 한다.
5. 게임 서버 DB — 기존 구매 결과를 재사용하는 멱등 응답
이번 복습에서는 중복 구매 요청이 들어왔을 때 기존 구매 결과를 DB에서 읽어 반환하는 SQL을 직접 작성했다.
다음과 같은 구매 기록 테이블이 있다고 가정하자.
CREATE TABLE purchase_requests (
request_id VARCHAR(50) PRIMARY KEY,
user_id BIGINT NOT NULL,
item_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
remaining_gold INT
);
이미 처리된 구매의 데이터가 다음과 같다.
컬럼값
| request_id | BUY-100 |
| user_id | 10 |
| item_id | 500 |
| status | SUCCEEDED |
| remaining_gold | 900 |
기존 구매 결과 조회
SELECT
status,
remaining_gold,
item_id
FROM purchase_requests
WHERE request_id = 'BUY-100';
이 SQL은 복습 중 직접 작성한 쿼리다.
결과가 SUCCEEDED이고 요청자와 구매 대상이 기존 요청과 일치한다면, 서버는 골드 차감과 아이템 지급을 다시 실행할 필요가 없다.
대신 저장된 성공 결과를 반환하면 된다.
여기서 remaining_gold = 900은 기존 구매 처리 당시의 결과이며, 현재 플레이어의 골드 잔액을 반드시 의미하지는 않는다.
구매 기록이 없으면 어떻게 할까?
구매 기록이 없다는 사실만으로 아무런 동시성 검증 없이 바로 골드를 차감해서는 안 된다.
같은 requestId를 처리 중인 다른 트랜잭션이 존재할 가능성도 고려해야 하기 때문이다.
이를 위해 UNIQUE 제약과 충돌 처리를 이용해 새로운 요청의 처리 권한을 안전하게 획득하는 구조가 필요하다.
또한 골드 차감, 아이템 지급, 구매 결과 기록은 하나의 트랜잭션에 포함하는 것이 중요하다.
전체 트랜잭션이 실패하여 ROLLBACK되면 해당 트랜잭션의 구매 기록, 골드 차감, 아이템 지급 변경도 함께 취소된다.
이후 재시도할 때는 현재 골드와 구매 가능 조건을 다시 검증해야 한다.
멱등 응답 설계에서 추가로 고려할 점
- requestId와 사용자·요청 내용의 일치 여부 확인
- 기존 요청이 SUCCEEDED인지, 실패했거나 처리 중인지 상태 구분
- COMMIT 도중 연결 장애가 발생한 경우 결과 확인
- 동시 구매에서 UNIQUE 충돌 처리
- 구매 완료 당시의 결과 저장 및 재사용
이번에는 기존 성공 결과의 조회 SQL과 중복 지급을 방지하는 원리를 직접 설명할 수 있었다.
다만 실제 Npgsql 구현과 PostgreSQL 동시성 테스트는 아직 수행하지 않았다.
오늘 새롭게 알게 된 점
- Dictionary의 해시 충돌은 버킷과 엔트리 연결 구조로 처리하며, 버킷 확장이 평균 조회 비용을 유지하는 데 도움이 된다.
- A*에서는 아직 탐색하지 않은 경로의 누적 비용을 미리 갱신하면 안 된다.
- Prefix Sum은 모든 구간 합이 아니라 시작점부터의 누적 합을 저장하는 방식이다.
- Decorator는 객체를 감싸 부가 기능을 확장하지만, 단순한 기능에는 과도한 구조가 될 수 있다.
- Observer 구독자의 예외는 이미 커밋된 DB 작업을 자동으로 되돌리지 않는다.
- PostgreSQL의 UNIQUE 충돌에서는 다른 트랜잭션의 COMMIT·ROLLBACK 결과에 따라 후속 INSERT의 결과가 달라진다.
- 멱등성 처리에서는 requestId뿐 아니라 저장된 구매 결과를 조회해 재사용해야 한다.
추가로 복습할 내용
- Dictionary의 버킷 확장과 next 인덱스 연결 구조
- A*의 허용성·일관성 및 누적 비용 갱신
- Prefix Sum 구간 합 인덱스 계산
- Observer 이벤트 처리 실패와 DB 정합성
- Decorator의 기능 조합과 과도한 적용
- Npgsql에서 구매 트랜잭션과 RETURNING 결과 처리
- requestId UNIQUE 충돌 및 구매 상태 저장을 통한 멱등성 구현
오늘도 복습을 통해 알고 있다고 생각했던 개념 중 일부는 설명을 보완해야 한다는 점을 확인했다.
앞으로는 단순히 SQL을 작성하는 단계에서 끝내지 않고, 실제 PostgreSQL에서 동시 요청과 장애 상황을 재현해 보는 것이 다음 목표다.
본문의 코드와 SQL은 학습용 예시다. 실제 컴파일·DB 실행·성능 측정·동시성 테스트 결과를 포함하지 않았으며, 회사의 비공개 구현 정보는 사용하지 않았다.
'서버 공부 > 매일학습' 카테고리의 다른 글
| Day 8 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.10 |
|---|---|
| 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 |
