Day 3 — 오늘 배운 게임 서버 개발 지식 5가지
학습일: 2026년 11월 3일
학습 주제: 자료구조 · 알고리즘 · 디자인 패턴 · C# 게임 서버 · PostgreSQL
오늘은 Stack과 Queue의 내부 구조부터 탐색 알고리즘, Factory 패턴, ASP.NET Core의 스레드 처리, PostgreSQL의 데드락까지 복습했다.
학습하면서 가장 중요하게 느낀 것은 단순히 기능을 사용하는 방법보다 내부에서 어떤 일이 발생하고, 왜 그 방식을 선택해야 하는지 이해하는 것이다.
1. 자료구조 — Stack과 Queue의 내부 원리
Stack과 Queue는 모두 데이터를 저장하는 자료구조이지만, 데이터를 꺼내는 순서가 다르다.
- Stack: LIFO(Last In, First Out). 마지막에 들어온 데이터가 먼저 나온다.
- Queue: FIFO(First In, First Out). 먼저 들어온 데이터가 먼저 나온다.
C#의 Stack<T>와 Queue<T>는 내부적으로 배열을 사용한다.
Queue는 왜 Dequeue할 때 데이터를 이동하지 않을까?
처음에는 Queue의 맨 앞 요소를 제거하면 나머지 요소들이 모두 앞으로 이동해야 하는지 궁금했다.
하지만 Queue<T>는 원형 배열(Circular Buffer)을 사용한다.
배열의 맨 앞에 있는 요소를 제거할 때 나머지 데이터들을 이동시키는 대신, 맨 앞을 가리키는 인덱스를 다음 위치로 이동시킨다.
마지막 인덱스에 도달하면 다시 배열의 처음으로 돌아가 빈 공간을 재활용할 수 있다.
덕분에 Dequeue()는 O(1)로 동작한다.
Enqueue는 왜 분할 상환 O(1)일까?
Enqueue()는 내부 배열에 여유 공간이 있다면 O(1)이다.
하지만 배열이 가득 찬 경우 더 큰 배열을 할당하고 기존 데이터를 복사해야 하므로 해당 작업은 O(n)이 된다.
그런데 배열 용량을 기하급수적으로 확장하면 전체 복사 작업이 매번 발생하지 않는다.
여러 번의 삽입 작업에 확장 비용을 나누어 계산하면 삽입 한 번당 분할 상환 O(1) 이 된다.
연산시간 복잡도
| Stack.Push | 분할 상환 O(1), 확장 시 O(n) |
| Stack.Pop | O(1) |
| Stack.Peek | O(1) |
| Queue.Enqueue | 분할 상환 O(1), 확장 시 O(n) |
| Queue.Dequeue | O(1) |
| Queue.Peek | O(1) |
게임 서버에서는 작업을 순서대로 처리하는 대기열이나 최근 작업부터 되돌리는 처리 등에 활용할 수 있다.
2. 알고리즘 — 탐색 방법과 시간 복잡도
오늘은 순차 탐색, 이진 탐색, Dictionary 기반 탐색을 비교했다.
탐색 방법별 시간 복잡도
탐색 방법최선평균최악
| 순차 탐색 | O(1) | O(n) | O(n) |
| 이진 탐색 | O(1) | O(log n) | O(log n) |
| Dictionary 조회 | 일반적으로 O(1) | O(1) | O(n) |
순차 탐색은 첫 번째 데이터가 원하는 값이라면 O(1)이지만, 끝까지 확인해야 할 경우 O(n)이다.
이진 탐색은 정렬된 배열에서 중앙값을 확인하고 탐색 범위를 절반씩 줄인다. 첫 번째 중앙값이 정답이면 O(1)이다.
Dictionary는 해시값을 이용하므로 평균 O(1)에 데이터를 조회할 수 있다. 다만 해시 충돌로 인해 최악에는 O(n)이 발생할 수 있다.
게임 서버에서는 어떤 자료구조를 선택할까?
접속 중인 플레이어를 UserId로 자주 조회하고, 로그인과 로그아웃에 따라 추가하거나 제거해야 한다면 Dictionary<UserId, Player>가 적절할 수 있다.
조회·삽입·삭제 모두 평균 O(1)이기 때문이다.
다만 여러 스레드가 동시에 수정하는 환경이라면 별도의 동기화가 필요하다.
추가 복습 — A* 휴리스틱의 일관성
이번 복습에서 가장 헷갈렸던 부분이다.
A*는 다음 평가 비용을 사용한다.
f(n) = g(n) + h(n)
- g(n): 시작 노드부터 현재 노드까지 누적 비용
- h(n): 현재 노드에서 목적지까지 추정 비용
휴리스틱의 일관성 조건은 다음과 같다.
h(A) <= c(A, B) + h(B)
여기서 c(A, B)는 A에서 B까지의 실제 이동 비용이다.
예를 들어 h(A)=7, c(A,B)=3, h(B)=2라면 다음과 같다.
7 <= 3 + 2
조건을 만족하지 않는다.
일관성 조건을 만족하면 경로를 따라 이동할 때 f(n)이 감소하지 않는 성질이 있다.
반대로 일관성이 깨지면 이미 탐색을 완료한 노드에 대해 나중에 더 저렴한 경로가 발견될 수 있다.
이때 탐색 완료한 노드를 다시 열지 않는 구현은 최단 경로를 놓칠 위험이 있다.
다만 일관성이 없다고 항상 최단 경로를 찾지 못하는 것은 아니다. 휴리스틱의 허용성 등 필요한 조건을 만족하고 노드 재탐색을 올바르게 처리하는 구현이라면 최단 경로를 찾을 수 있다.
3. 디자인 패턴 — Simple Factory와 Factory Method
두 방식 모두 객체 생성과 관련이 있지만 구조가 다르다.
Simple Factory
하나의 팩토리 클래스 또는 메서드에 객체 생성 로직을 모으는 방식이다.
예를 들어 몬스터 종류에 따라 Slime, Goblin, Dragon 객체를 생성하도록 만들 수 있다.
생성할 객체의 종류가 적고 변경 가능성이 낮다면 간단한 Simple Factory로 충분할 수 있다.
Factory Method
Factory Method는 상위 클래스에 객체 생성 메서드를 정의하고, 하위 클래스에서 재정의하여 구체적으로 어떤 객체를 생성할지 결정하는 패턴이다.
즉, 객체 생성 책임을 하위 클래스에 위임한다.
이 방식은 객체 생성 방법이 하위 클래스별로 달라지고 확장 가능성이 높을 때 유용하다.
하지만 몬스터가 세 종류뿐이고 추가될 가능성도 거의 없다면 Factory Method의 상속 구조가 오히려 불필요한 복잡도를 증가시킬 수 있다.
비교Simple FactoryFactory Method
| 생성 방식 | 하나의 메서드에서 생성할 객체 결정 | 하위 클래스가 생성할 객체 결정 |
| 주요 구조 | 조건 분기 등의 생성 로직 | 상속과 메서드 재정의 |
| 장점 | 단순하고 이해하기 쉬움 | 생성 방식의 다형적 확장 |
| 단점 | 종류 추가 시 생성 메서드 수정 가능성 | 클래스 구조가 복잡해질 수 있음 |
또한 Strategy 패턴과의 차이도 다시 확인했다.
Strategy는 처리 방식의 교체가 중심이고, Factory 계열은 객체 생성 책임의 분리가 중심이다.
디자인 패턴은 무조건 적용하기보다 실제 변경 가능성과 복잡도를 고려해 선택해야 한다.
4. C# 게임 서버 — async와 Task.Run의 차이
async 메서드는 호출한 스레드에서 실행을 시작한다.
미완료된 비동기 작업을 await하면 메서드 실행을 일시 중단하고, 해당 스레드가 다른 작업을 처리할 수 있도록 할 수 있다.
반대로 Task.Run()은 작업을 ThreadPool에 예약한다.
중요한 것은 Task.Run을 사용한다고 CPU 계산 자체가 빨라지는 것은 아니라는 점이다.
ASP.NET Core에서 Task.Run을 주의해야 하는 이유
ASP.NET Core는 기본적으로 ThreadPool 스레드를 이용하여 요청을 처리한다.
이미 ThreadPool 스레드에서 실행 중인 간단한 CPU 작업을 다시 Task.Run()으로 예약하면 불필요한 작업 스케줄링 비용이 발생할 수 있다.
반면 비동기 DB 조회는 성격이 다르다.
Player player = await db.GetPlayerAsync(userId);
실제 비동기 DB I/O가 진행되고 반환된 Task가 아직 완료되지 않았다면, await하는 동안 현재 스레드를 계속 점유할 필요가 없다.
그러나 다음과 같이 차단 작업이 존재하면 문제가 달라진다.
await Task.Run(() =>
{
Thread.Sleep(5000);
});
Task.Run으로 작업을 예약하더라도 Thread.Sleep을 실행하는 ThreadPool 스레드는 차단된다.
이러한 작업이 많이 발생하면 사용 가능한 ThreadPool 스레드가 부족해지고 요청 처리가 지연될 수 있다.
이를 ThreadPool Starvation이라고 한다.
결국 중요한 것은 CPU 작업인지, 비동기 I/O인지, 스레드를 실제로 차단하는 작업인지 구분하는 것이다.
5. 게임 서버 DB — 데드락과 트랜잭션 재시도
PostgreSQL에서 데드락은 서로 다른 트랜잭션이 상대방이 보유한 잠금을 기다리면서 진행하지 못하는 상황이다.
예를 들어 다음과 같은 상황을 생각할 수 있다.
- 트랜잭션 1: 플레이어 A 잠금 획득 → 플레이어 B 잠금 요청
- 트랜잭션 2: 플레이어 B 잠금 획득 → 플레이어 A 잠금 요청
두 트랜잭션이 각각 첫 번째 잠금을 획득했다면 서로 상대방의 잠금이 해제되기를 기다릴 수 있다.
이는 단순한 잠금 대기가 아니라 순환 대기 관계가 형성된 데드락이다.
데드락을 예방하는 방법
여러 행의 잠금을 획득해야 한다면 모든 트랜잭션에서 잠금 획득 순서를 통일하는 것이 중요하다.
예를 들어 두 플레이어의 PlayerId 중 작은 값을 먼저 잠그도록 규칙을 정할 수 있다.
- 트랜잭션 1: A → B
- 트랜잭션 2: A → B
이렇게 하면 동일한 두 행을 반대 순서로 획득하면서 발생하는 데드락을 예방할 수 있다.
데드락이 이미 발생했다면?
PostgreSQL은 데드락을 감지하면 관련 트랜잭션 중 하나를 중단한다.
이때 실패한 SQL 명령부터 다시 실행하면 안 된다.
새로운 트랜잭션을 시작하고 전체 작업을 다시 처리해야 한다.
예를 들어 게임 서버에서 골드를 이전하는 거래라면 다음 작업이 하나의 트랜잭션에 포함될 수 있다.
- 현재 골드 잔액 및 거래 조건 확인
- 보내는 플레이어의 골드 차감
- 받는 플레이어의 골드 증가
- 거래 기록 저장
- 커밋
데드락으로 트랜잭션이 실패했다면 새로운 트랜잭션에서 현재 데이터를 다시 읽고 조건을 재검증해야 한다.
다른 트랜잭션이 데이터를 변경했을 수 있기 때문이다.
또한 무제한 재시도보다는 재시도 횟수를 제한하고, 반복적으로 실패하면 오류 로그를 남겨 원인을 조사하는 것이 적절하다.
필요하다면 짧은 재시도 지연도 적용할 수 있다.
실제 게임 서버에서는 중복 거래 방지와 트랜잭션 밖의 부작용 처리까지 함께 고려해야 한다.
오늘 새롭게 알게 된 점
- Queue는 원형 배열의 인덱스를 이동시키기 때문에 Dequeue할 때 전체 요소를 이동하지 않는다.
- 분할 상환 O(1)은 단순히 대부분의 작업이 빠르기 때문만이 아니라, 배열 확장 비용을 여러 작업에 분산할 수 있기 때문에 성립한다.
- A*에서 일관성이 깨지면 이미 탐색 완료한 노드에 대해 더 저렴한 경로가 나중에 발견될 수 있다.
- 단순한 객체 생성에는 Factory Method보다 Simple Factory가 적합할 수 있다.
- ASP.NET Core에서 Task.Run은 CPU 연산 자체를 가속하지 않는다.
- 데드락 예방에는 잠금 순서 통일이 중요하며, 데드락 이후에는 새로운 트랜잭션에서 데이터를 재검증해야 한다.
추가로 복습할 내용
- Stack과 Queue의 내부 배열 확장 및 분할 상환 복잡도
- A* 휴리스틱의 허용성과 일관성
- Factory Method를 실제 C# 코드로 구현하는 방법
- ASP.NET Core의 비동기 I/O와 ThreadPool Starvation 진단
- PostgreSQL의 데드락 재시도, 잠금 순서, 인덱스 및 실행 계획
이번 학습에서는 개념을 암기하는 것뿐 아니라, 그 개념을 실제 게임 서버에 적용할 때 왜 그런 선택을 해야 하는지 생각해 볼 수 있었다.
다음 학습에서도 내부 동작 원리를 이해하고 실제 코드와 연결하는 방향으로 공부할 예정이다.
이 글의 예시 코드는 학습용으로 작성했으며 실제 실행·성능 측정 결과를 의미하지 않습니다. 게임 서버 예시는 가상 상황이며 회사 내부 구현 정보는 포함하지 않았습니다.
'서버 공부 > 매일학습' 카테고리의 다른 글
| Day 2 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.03 |
|---|
