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 동시성 테스트는 아직 수행하지 않았다.


오늘 새롭게 알게 된 점

  1. Dictionary의 해시 충돌은 버킷과 엔트리 연결 구조로 처리하며, 버킷 확장이 평균 조회 비용을 유지하는 데 도움이 된다.
  2. A*에서는 아직 탐색하지 않은 경로의 누적 비용을 미리 갱신하면 안 된다.
  3. Prefix Sum은 모든 구간 합이 아니라 시작점부터의 누적 합을 저장하는 방식이다.
  4. Decorator는 객체를 감싸 부가 기능을 확장하지만, 단순한 기능에는 과도한 구조가 될 수 있다.
  5. Observer 구독자의 예외는 이미 커밋된 DB 작업을 자동으로 되돌리지 않는다.
  6. PostgreSQL의 UNIQUE 충돌에서는 다른 트랜잭션의 COMMIT·ROLLBACK 결과에 따라 후속 INSERT의 결과가 달라진다.
  7. 멱등성 처리에서는 requestId뿐 아니라 저장된 구매 결과를 조회해 재사용해야 한다.

추가로 복습할 내용

  • Dictionary의 버킷 확장과 next 인덱스 연결 구조
  • A*의 허용성·일관성 및 누적 비용 갱신
  • Prefix Sum 구간 합 인덱스 계산
  • Observer 이벤트 처리 실패와 DB 정합성
  • Decorator의 기능 조합과 과도한 적용
  • Npgsql에서 구매 트랜잭션과 RETURNING 결과 처리
  • requestId UNIQUE 충돌 및 구매 상태 저장을 통한 멱등성 구현

오늘도 복습을 통해 알고 있다고 생각했던 개념 중 일부는 설명을 보완해야 한다는 점을 확인했다.

앞으로는 단순히 SQL을 작성하는 단계에서 끝내지 않고, 실제 PostgreSQL에서 동시 요청과 장애 상황을 재현해 보는 것이 다음 목표다.


본문의 코드와 SQL은 학습용 예시다. 실제 컴파일·DB 실행·성능 측정·동시성 테스트 결과를 포함하지 않았으며, 회사의 비공개 구현 정보는 사용하지 않았다.

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): 현재 노드에서 목적지까지의 추정

+ Recent posts