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

학습·복습 기간: 2026년 11월 11일~13일
학습 주제: Struct와 Class, Greedy 알고리즘, Adapter 패턴, TCP 통신, PostgreSQL SQL 기초

게임 서버 개발 압축 학습 11일차에는 자료구조의 메모리 구조, Greedy 알고리즘, Adapter 패턴, 네트워크 통신, SQL 기초를 복습했다.

이번에 특히 중요했던 것은 코드가 어떤 결과를 내는지만 아는 것이 아니라, 그 결과가 발생하는 내부 원리를 이해하는 것이었다.

1. 자료구조 — Struct 배열과 Class 배열은 GC 부담이 어떻게 다를까?

C#에서 struct는 값 형식(Value Type), class는 참조 형식(Reference Type)이다.

다음 두 타입을 생각해 보자.

public struct MonsterStruct
{
    public int Hp;
    public int Attack;
}

public class MonsterClass
{
    public int Hp;
    public int Attack;
}

각 타입으로 10,000개의 요소를 가진 배열을 생성한다.

MonsterStruct[] structs = new MonsterStruct[10000];
MonsterClass[] classes = new MonsterClass[10000];

Struct 배열은 무엇을 저장할까?

MonsterStruct[]는 배열 객체 내부에 10,000개의 구조체 값을 직접 저장한다.

구조체의 Hp, Attack 필드는 배열 요소의 일부로 저장되므로, 개별 구조체를 위해 별도의 관리 객체를 생성하지 않는다.

중요한 점은 Struct라고 해서 무조건 Stack에 저장되는 것은 아니라는 사실이다.

배열 객체는 Managed Heap에 생성되므로, 배열 내부에 존재하는 구조체 값들도 힙에 위치한다.

따라서 Struct 배열 역시 GC의 관리 대상이다.

Class 배열은 무엇을 저장할까?

MonsterClass[]는 구조체 배열과 다르게 객체 참조를 저장할 공간을 가진다.

하지만 다음 코드만으로는 실제 MonsterClass 객체 10,000개가 생성되지 않는다.

MonsterClass[] classes = new MonsterClass[10000];

이 시점에는 배열 객체 한 개만 만들어지고 각 요소의 참조값은 null로 초기화된다.

실제 클래스 객체를 생성하려면 다음과 같은 작업이 필요하다.

for (int i = 0; i < classes.Length; i++)
{
    classes[i] = new MonsterClass();
}

이렇게 하면 배열 객체 1개와 MonsterClass 객체 10,000개를 합쳐 총 10,001개의 관리 객체가 생성된다.

둘 다 Managed Heap에 있는데 왜 GC 부담이 다를까?

이 부분을 처음에는 잘못 이해했다.

Class 배열에 참조값이 많기 때문에 GC 부담이 크다고 생각했지만, 중요한 것은 참조값 자체의 개수만이 아니다.

Struct 배열은 이 예시에서 관리 객체 한 개에 10,000개의 구조체 값을 포함한다.

반면 Class 객체를 각각 생성하면 관리 객체의 개수가 증가하고, 각 객체의 할당·추적·수명 관리에 추가 비용이 발생할 수 있다.

비교Struct 배열Class 배열 + 개별 인스턴스

배열 객체 1개 1개
별도 요소 객체 없음 10,000개
배열 요소의 내용 구조체 값 객체 참조
GC 관리 필요 필요
순차 접근 시 캐시 효율 유리할 가능성 높음 객체 배치에 따라 불리할 수 있음

Struct는 항상 빠를까?

그렇지는 않다.

Struct는 값 형식이므로 대입할 때 값이 복사된다.

구조체가 작으면 복사 비용이 낮을 수 있지만, 크기가 크고 복사가 자주 발생한다면 오히려 부담이 될 수 있다.

또한 실제 성능은 구조체 크기, 메모리 접근 방식, JIT 최적화 등에 따라 달라진다.

게임 서버에서 수많은 몬스터의 HP와 공격력을 매 틱 갱신한다면 작은 Struct 배열을 고려할 수 있다.

하지만 성능이 우수하다고 단정하지 않고 실제 벤치마크로 검증해야 한다.

2. 알고리즘 — Greedy가 항상 최적해를 찾지 못하는 이유

Greedy(탐욕 알고리즘)는 각 단계에서 정한 기준에 따라 현재 가장 유리한 선택을 하는 방식이다.

하지만 각 단계의 최선이 전체 문제의 최선과 반드시 일치하지는 않는다.

보상 선택 반례

보상 포인트가 10이고 다음 보상을 선택할 수 있다고 가정하자.

보상필요 포인트보상 가치

A 10 12
B 5 9
C 5 9

각 보상은 최대 한 번 선택할 수 있다.

보상 가치가 가장 큰 것부터 선택하는 Greedy 규칙을 사용하면 A를 선택한다.

A를 선택하면 포인트 10을 모두 사용하고 가치 12를 얻는다.

하지만 B와 C를 함께 선택하면 같은 포인트 10으로 가치 18을 얻는다.

따라서 이 문제에서는 개별 보상 가치가 가장 큰 항목을 먼저 선택하는 Greedy 규칙이 최적해를 보장하지 못한다.

탐욕적 선택 속성과 최적 부분 구조

Greedy의 최적성을 판단할 때는 두 가지 개념을 구별해야 한다.

탐욕적 선택 속성(Greedy-choice Property)

현재 단계의 탐욕적 선택을 포함하는 전체 최적해가 존재하는 성질이다.

최적 부분 구조(Optimal Substructure)

전체 문제의 최적해가 부분 문제의 최적해를 포함하는 성질이다.

처음에는 최적 부분 구조를 매 단계에서 가장 좋은 것을 선택하는 방식이라고 이해했다.

하지만 그것은 Greedy의 선택 방식에 가까운 설명이었다.

최적 부분 구조는 전체 최적해와 부분 최적해 사이의 관계를 말한다.

최단 경로로 이해하는 최적 부분 구조

다음 두 경로가 있다고 가정하자.

경로 1
A --2--> B --6--> G
총비용: 8

경로 2
A --2--> B --1--> C --3--> G
총비용: 6

B에서 G까지 직접 이동하는 비용은 6이지만, C를 경유하면 비용은 4다.

따라서 A → B → G는 최단 경로가 아니다.

A → B → C → G가 더 짧은 경로이고, 그 부분 경로인 B → C → G도 B에서 G까지의 최단 경로가 된다.

이렇게 전체 최적해가 부분 문제의 최적해를 포함하는 성질이 최적 부분 구조다.

단, 최적 부분 구조만 존재한다고 Greedy가 항상 최적해를 구하는 것은 아니다.

작업 선택 문제와 Greedy

서로 겹치지 않는 작업을 최대한 많이 선택하는 문제에서는 종료 시간이 빠른 작업을 우선 선택하는 Greedy 알고리즘을 사용할 수 있다.

종료 시간이 빠른 작업을 선택하면 이후 작업을 선택할 수 있는 여지를 최대한 남길 수 있기 때문이다.

하지만 어떤 Greedy 규칙이 최적해를 보장하는지는 문제에 맞는 근거가 필요하다.

이번 복습에서는 반례 계산은 정확했지만, 최적 부분 구조의 의미를 처음에 잘못 설명했다.

이후 추가 문제를 통해 개념을 수정했으므로 다음에는 힌트 없이 직접 설명할 수 있는지 확인할 예정이다.

3. 디자인 패턴 — Adapter로 외부 결제 SDK 연결하기

게임 서버에서는 Google Play, App Store, 외부 웹상점 등 다양한 결제 시스템과 연동할 수 있다.

문제는 각 외부 SDK가 제공하는 인터페이스와 응답 데이터 형식이 서로 다를 수 있다는 점이다.

Adapter 패턴의 역할

Adapter 패턴은 서로 호환되지 않는 인터페이스를 연결하는 패턴이다.

게임 서버에서 다음과 같은 공통 인터페이스를 사용한다고 가정하자.

public interface IPaymentVerifier
{
    Task<VerificationResult> VerifyAsync(
        string receipt);
}

그런데 외부 SDK는 다른 메서드를 제공한다.

CheckPurchaseAsync(receipt)

이 경우 Adapter가 서버와 외부 SDK 사이에서 변환 역할을 담당할 수 있다.

PurchaseService
      ↓
IPaymentVerifier.VerifyAsync()
      ↓
PaymentAdapter
      ↓
외부 SDK.CheckPurchaseAsync()
      ↓
외부 SDK 응답 수신
      ↓
서버용 VerificationResult로 변환
      ↓
PurchaseService에 반환

위 호출 구조는 개념 설명용이며, 실제 SDK의 정확한 API 계약을 나타내는 것은 아니다.

Adapter를 사용하는 이유

Adapter를 사용하면 PurchaseService가 외부 SDK의 구체적인 메서드나 반환 형식에 직접 의존하는 부분을 줄일 수 있다.

나중에 결제 SDK가 변경되더라도 Adapter 내부의 구현을 변경하는 방식으로 대응할 수 있다.

여기서 중요한 것은 Adapter가 단순히 여러 SDK를 하나의 함수로 합치는 패턴은 아니라는 점이다.

서로 다른 인터페이스와 데이터 형식을 서버가 사용하는 방식에 맞게 연결하는 것이 핵심이다.

Strategy·Decorator·Adapter 비교

패턴주요 목적

Strategy 실행할 동작이나 알고리즘 교체
Decorator 기존 객체를 감싸 부가 기능 확장
Adapter 서로 다른 인터페이스 연결

다만 단순한 SDK 호출 하나를 위해 항상 Adapter 클래스를 만들어야 하는 것은 아니다.

또한 Adapter 자체가 결제 검증의 신뢰성, 거래 중복 방지, 트랜잭션 정합성을 자동으로 보장하지는 않는다.

실제 결제 처리에서는 거래 ID 검증, 멱등성, 오류 처리 등을 별도로 구현해야 한다.

4. C# 게임 서버 — TCP, HTTP, WebSocket의 차이

게임 서버에서 통신 방식을 선택하려면 각 프로토콜의 특성을 이해해야 한다.

TCP는 메시지가 아니라 바이트 스트림이다

TCP는 신뢰성 있는 순서형 바이트 스트림을 제공하지만 애플리케이션 메시지의 경계를 보장하지 않는다.

예를 들어 클라이언트가 100바이트 메시지 하나를 보냈다고 가정하자.

서버의 ReadAsync()가 다음과 같이 데이터를 받을 수도 있다.

클라이언트 전송: 100 bytes

서버 첫 번째 ReadAsync: 40 bytes
서버 두 번째 ReadAsync: 60 bytes

따라서 ReadAsync() 한 번이 메시지 하나에 대응한다고 생각하면 안 된다.

반대로 여러 메시지의 바이트가 하나의 읽기 작업에서 함께 전달될 수도 있다.

Length-Prefix로 메시지 경계 구분하기

TCP에서 메시지를 구분하는 방법 중 하나가 Length-Prefix다.

메시지 앞에 본문 길이를 기록한 헤더를 붙여 전송한다.

예를 들어 프로토콜에서 헤더 크기를 4바이트로 정했다면 다음과 같다.

[4바이트 메시지 길이][메시지 본문]

서버는 다음 순서로 처리한다.

  1. 헤더 4바이트가 모두 수신될 때까지 읽는다.
  2. 헤더에서 본문 길이를 해석한다.
  3. 해당 길이만큼 본문을 모두 수신할 때까지 읽는다.
  4. 완전한 메시지가 준비되면 역직렬화하거나 명령을 처리한다.

여기서 중요한 사실은 헤더 4바이트조차 한 번의 ReadAsync로 모두 받을 수 있다는 보장이 없다는 점이다.

따라서 부분 수신을 처리하는 반복 로직이 필요하다.

또한 최대 패킷 크기를 검증하고 헤더의 바이트 순서를 송수신 측에서 일치시켜야 한다.

HTTP와 WebSocket은 언제 사용할까?

HTTP는 요청·응답 중심의 통신에 적합하다.

로그인, 계정 정보 조회, 결제 요청 등에서 활용할 수 있다.

WebSocket은 연결을 유지하면서 클라이언트와 서버가 양방향 메시지를 주고받는 데 적합하다.

실시간 채팅이나 상태 변경 알림 등에 사용할 수 있다.

실시간 전투처럼 지연에 민감한 상황에서는 UDP 기반의 프로토콜도 고려할 수 있다.

다만 UDP가 항상 TCP보다 빠르다는 뜻은 아니다. 유실·순서·재전송 등을 애플리케이션에서 어떻게 처리하는지도 함께 고려해야 한다.

5. 게임 서버 DB — WHERE, ORDER BY, LIMIT 복습

이번 DB 학습에서는 복잡한 트랜잭션보다 기본 SQL의 조건과 정렬을 다시 확인했다.

특히 NULL 검사와 AND·OR 조건식에서 오류가 있었기 때문에 복습이 필요했다.

NULL은 어떻게 검사할까?

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

user_idnicknamedeleted_at

1 Alice NULL
2 Bob 2026-11-10
3 Carol NULL
4 David 2026-11-12

deleted_at에 값이 존재하는 플레이어를 조회하려면 다음과 같이 작성한다.

SELECT user_id, nickname
FROM users
WHERE deleted_at IS NOT NULL;

결과는 Bob과 David다.

  • IS NULL: 값이 NULL인 행
  • IS NOT NULL: 값이 NULL이 아닌 행

일반적인 SQL에서는 NULL을 = NULL로 검사해서는 안 된다.

NULL은 알 수 없는 값이라는 의미이므로 비교 연산의 결과에 UNKNOWN이 발생할 수 있다.

AND와 OR의 우선순위

다음 SQL을 살펴보자.

SELECT nickname
FROM users
WHERE level >= 10
  AND gold >= 500
   OR gold < 100;

SQL에서 AND는 OR보다 우선순위가 높다.

따라서 이 조건은 다음과 같은 의미다.

WHERE (level >= 10 AND gold >= 500)
   OR gold < 100

예를 들어 Carol이 레벨 12, 골드 800이라면 첫 번째 조건을 만족한다.

Eve가 레벨 20, 골드 50이라면 두 번째 조건을 만족한다.

따라서 두 플레이어가 조회 대상이다.

실무에서는 복잡한 조건식을 괄호로 명확하게 표현하여 의도하지 않은 결과를 방지하는 것이 좋다.

ORDER BY와 LIMIT

골드가 가장 많은 플레이어 3명을 조회하려면 다음 SQL을 사용할 수 있다.

SELECT nickname, gold
FROM users
ORDER BY gold DESC
LIMIT 3;
  • ORDER BY gold: 골드 기준 정렬
  • DESC: 높은 값부터 낮은 값 순서
  • LIMIT 3: 최대 세 행 반환

복습에서 주어진 데이터의 결과는 다음과 같았다.

순위nicknamegold

1 Carol 800
2 Bob 700
3 Alice 200

이번에는 SQL 문법과 예상 결과를 모두 정확하게 작성할 수 있었다.

다만 실제 PostgreSQL에서 실행해 검증한 것은 아니다.


오늘 새롭게 알게 된 점

  1. Struct 배열도 Managed Heap에 생성되며 GC 대상이다.
  2. Class 배열에 참조 공간을 생성하는 것과 실제 Class 객체를 개별 생성하는 것은 다르다.
  3. GC 부담은 단순한 참조 개수가 아니라 관리 객체의 할당·수명·참조 관계 등을 함께 고려해야 한다.
  4. Greedy의 현재 최선 선택과 최적 부분 구조는 서로 다른 개념이다.
  5. Adapter는 외부 SDK 호출 및 응답 형식을 서버 인터페이스에 맞게 연결한다.
  6. TCP에서는 Length-Prefix 헤더도 부분 수신될 수 있다.
  7. PostgreSQL의 NULL 검사, AND·OR 우선순위, ORDER BY 정렬 기준을 정확히 구분해야 한다.

추가로 복습할 내용

  • Struct의 크기와 복사 비용
  • Struct 배열과 Class 배열의 실제 GC·성능 측정
  • Greedy의 탐욕적 선택 속성과 최적 부분 구조
  • 작업 선택 알고리즘의 C# 코드 및 시간 복잡도
  • Adapter의 외부 SDK 예외 처리
  • TCP의 Length-Prefix 부분 수신 처리
  • WebSocket 재접속과 UDP 패킷 유실 처리
  • PostgreSQL의 NULL 3값 논리와 복합 WHERE 조건

이번 학습에서는 자료구조와 SQL의 개념을 다시 설명할 수 있었지만, Greedy의 최적 부분 구조는 추가 설명이 필요했다.

앞으로는 잘못 이해한 개념을 다시 설명받는 데서 끝내지 않고, 일정 시간이 지난 후에도 힌트 없이 설명할 수 있는지 확인하려고 한다.


본문의 코드와 SQL은 학습 목적의 예시이며 별도의 컴파일·실행·성능 측정 결과는 포함하지 않았다. 게임 서버 사례는 가상의 상황이며 회사 내부 소스 코드나 고객 데이터는 포함하지 않았다.

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

학습일: 2026년 11월 11일
복습일: 2026년 11월 12일
주제: C# 컬렉션 메모리, Prefix Sum, 디자인 패턴, 동시 구매 처리, PostgreSQL 기초

게임 서버 개발을 위한 압축 학습 10일차다.

오늘은 자료구조의 메모리 배치, 구간 합 알고리즘, 디자인 패턴, 동시 구매 처리, 관계형 데이터베이스를 공부했다.

특히 이번 복습에서는 개념을 이해했다고 생각했지만 실제 코드에 적용하면서 틀리는 부분이 있었다.

이번 학습에서 중요하게 느낀 것은 알고리즘의 시간 복잡도뿐 아니라 메모리 사용량, 데이터 정합성, 실제 구현 정확성까지 함께 고려해야 한다는 점이다.

1. 자료구조 — Struct와 Class의 메모리 구조

C#에서 struct와 class는 각각 값 형식(Value Type)과 참조 형식(Reference Type)이다.

이 차이는 컬렉션에 데이터를 저장하는 방식과 실제 성능에도 영향을 줄 수 있다.

List<Struct>와 List<Class>의 차이

다음 두 타입을 생각해 보자.

public struct PlayerStruct
{
    public int Level;
    public int Gold;
}

public class PlayerClass
{
    public int Level;
    public int Gold;
}

각 타입의 데이터를 List에 저장한다.

List<PlayerStruct> listA = new();
List<PlayerClass> listB = new();

두 List는 모두 내부적으로 배열을 사용하지만, 배열에 저장되는 데이터는 다르다.

List<PlayerStruct>

구조체가 값 형식이므로 내부 배열의 각 요소에 구조체 값 자체가 저장된다.

따라서 배열에 저장된 데이터를 순차적으로 접근할 때 연속적인 메모리 접근의 이점을 얻을 수 있다.

List<PlayerClass>

클래스는 참조 형식이므로 내부 배열에는 PlayerClass 객체 자체가 아니라 객체에 대한 참조가 저장된다.

실제 PlayerClass 객체들은 별도로 할당되므로 메모리에 연속적으로 위치한다는 보장이 없다.

CPU 캐시 지역성이 중요한 이유

CPU는 메모리에서 데이터를 읽을 때 인접한 데이터도 함께 캐시에 가져올 수 있다.

순차적으로 필요한 데이터가 인접해 있다면 캐시에 적재된 데이터를 활용할 가능성이 높아진다.

반면 객체 참조를 따라 여러 메모리 위치에 접근하면 캐시 효율이 불리할 수 있다.

따라서 전체 순회가 모두 O(N)이더라도 실제 실행 속도에는 차이가 발생할 수 있다.

하지만 struct가 항상 class보다 빠르다는 뜻은 아니다.

구조체가 크면 복사 비용이 증가할 수 있으며, 실제 접근 패턴과 객체 구성에 따라서도 성능이 달라진다.

게임 서버에서도 데이터의 크기, 변경 방식, 순회 빈도 등을 고려해 타입과 자료구조를 선택해야 한다.

2. 알고리즘 — Prefix Sum과 Sliding Window 비교

이번 복습에서 가장 많이 헷갈렸던 부분은 Prefix Sum과 Sliding Window의 선택 기준이었다.

두 알고리즘 모두 구간 합 계산에 활용할 수 있지만 적합한 상황이 다르다.

Prefix Sum이란?

Prefix Sum은 배열의 시작 위치부터 각 지점까지의 누적 합을 저장하는 방법이다.

예를 들어 다음 배열이 있다고 가정하자.

numbers = [2, 3, 1, 4]

맨 앞에 0을 추가하는 방식으로 Prefix Sum 배열을 만들면 다음과 같다.

prefix = [0, 2, 5, 6, 10]

이 배열을 사용하면 임의 구간의 합을 다음과 같이 계산할 수 있다.

구간 합 [left, right]
= prefix[right + 1] - prefix[left]

예를 들어 인덱스 1부터 3까지의 합은 다음과 같다.

prefix[4] - prefix[1]
= 10 - 2
= 8

Prefix Sum의 시간·공간 복잡도

작업복잡도

전처리 O(N)
구간 합 조회 O(1)
추가 공간 O(N)

데이터가 변경되지 않고 서로 다른 구간의 합을 여러 번 조회해야 한다면 적합하다.

Sliding Window와의 차이

Sliding Window는 연속된 구간을 이동하면서 기존 계산 결과를 재활용한다.

고정 길이 구간 합에서는 왼쪽에서 빠지는 값을 빼고 오른쪽에서 새로 들어오는 값을 더하면 된다.

예를 들어 연속된 100개 요소의 합 중 최댓값을 한 번 구한다면 고정 길이 Sliding Window를 사용할 수 있다.

구분Prefix Sum고정 길이 Sliding Window

적합한 상황 임의 구간 합 반복 조회 고정 길이 구간 계산
전체 처리 시간 전처리 O(N) O(N)
추가 공간 O(N) O(1)
핵심 원리 누적 합 저장 기존 구간 합 갱신

처음에는 이 두 알고리즘의 적용 상황을 반대로 선택했다.

하지만 다시 생각해 보니 차이가 명확해졌다.

Prefix Sum은 미리 계산하여 반복 조회를 빠르게 하고, Sliding Window는 구간을 이동하며 계산하는 방식이다.

RangeSum C# 구현에서 실수한 점

누적 합 배열을 이용해 구간 합을 조회하는 메서드를 작성하면서 다음 두 가지 실수가 있었다.

첫 번째는 C# 배열의 길이를 prefix.length라고 작성한 것이다.

C#에서는 Length의 첫 글자가 대문자여야 한다.

두 번째는 오른쪽 인덱스의 범위 조건을 잘못 계산한 것이다.

원본 배열의 길이가 N이라면, 맨 앞에 0을 추가한 Prefix Sum 배열의 길이는 N+1이다.

따라서 원본 배열의 마지막 유효 인덱스는 prefix.Length - 2다.

다음은 이러한 부분을 보완한 학습용 코드다.

static long RangeSum(
    long[] prefix,
    int left,
    int right)
{
    ArgumentNullException.ThrowIfNull(prefix);

    if (left < 0 || left >= prefix.Length - 1)
        throw new ArgumentOutOfRangeException(nameof(left));

    if (right < left || right >= prefix.Length - 1)
        throw new ArgumentOutOfRangeException(nameof(right));

    return prefix[right + 1] - prefix[left];
}

이 코드는 첫 요소가 0이고 길이가 N+1인 정상적인 누적 합 배열을 전달받는다는 전제다.

원본 배열이 비어 있다면 조회할 수 있는 구간이 없으므로 예외를 발생시킨다.

이번 복습에서는 구간 합 계산식과 경계 조건의 논리는 이해했지만, C#의 정확한 문법을 반복해서 틀렸다.

따라서 다음에는 전체 메서드를 다시 작성하고 컴파일까지 확인할 필요가 있다.

3. 디자인 패턴 — Observer와 Decorator의 적용 판단

디자인 패턴은 단순히 코드를 복잡하게 만드는 기술이 아니라, 반복되는 설계 문제를 해결하기 위한 방법이다.

오늘

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 실행·성능 측정·동시성 테스트 결과를 포함하지 않았으며, 회사의 비공개 구현 정보는 사용하지 않았다.

+ Recent posts