Day 4 — 오늘 배운 게임 서버 개발 지식 5가지
학습일: 2026년 11월 4일
학습 주제: LinkedList, A* 알고리즘, 디자인 패턴, ASP.NET Core, PostgreSQL 인덱스
오늘은 자료구조의 내부 동작부터 게임 서버의 HTTP 요청 처리와 데이터베이스 인덱스까지 복습했다.
이번 학습에서 특히 기억에 남은 것은 시간 복잡도가 낮다고 무조건 좋은 자료구조는 아니며, 특정 기능을 사용할 때 내부적으로 어떤 과정이 일어나는지 알아야 한다는 점이다.
1. 자료구조 — LinkedList<T>는 왜 삽입과 삭제가 빠를까?
C#의 LinkedList<T>는 각 노드가 이전 노드와 다음 노드를 참조하는 이중 연결 리스트다.
각 노드는 크게 다음 정보를 가진다.
- Value: 저장하는 데이터
- Previous: 이전 노드에 대한 참조
- Next: 다음 노드에 대한 참조
노드 참조가 있다면 삭제는 O(1)
예를 들어 A, B, C 노드가 순서대로 연결되어 있다고 가정하자.
A ⇄ B ⇄ C
B 노드를 삭제하려면 A의 다음 노드를 C로, C의 이전 노드를 A로 연결하도록 변경하면 된다.
A ⇄ C
이때 B 노드의 참조를 이미 알고 있다면 전체 리스트를 탐색할 필요가 없으므로 O(1)에 삭제할 수 있다.
하지만 삭제하려는 값만 알고 있다면 이야기가 달라진다.
원하는 값이 저장된 노드를 처음부터 찾아야 하므로 최악 O(n)이 발생한다. 노드를 찾은 이후의 삭제는 O(1)이지만, 전체 작업은 O(n)이다.
그렇다면 List보다 LinkedList가 항상 좋을까?
그렇지 않다.
List<T>는 연속적인 배열에 데이터를 저장하므로 인덱스로 접근하는 작업에 강점이 있다. 반면 LinkedList<T>는 노드를 순서대로 따라가야 하며 인덱스 기반 임의 접근을 제공하지 않는다.
또한 LinkedList는 각 노드의 참조와 객체 관리 비용이 추가되고, 노드들이 메모리에 흩어져 있을 수 있어 CPU 캐시 효율도 불리할 수 있다.
따라서 삽입·삭제 O(1)이라는 사실만으로 LinkedList를 선택하면 안 된다.
게임 서버에서도 노드 참조를 유지하면서 빈번하게 요소를 제거하는 구조인지, 아니면 전체 순회나 인덱스 접근이 많은지 고려해야 한다.
2. 알고리즘 — BFS, 다익스트라, A* 그리고 휴리스틱
세 알고리즘은 경로를 탐색한다는 공통점이 있지만, 다음에 탐색할 노드를 선택하는 기준이 다르다.
알고리즘주요 탐색 기준대표적인 사용 조건
| BFS | 탐색 깊이, 방문 순서 | 모든 간선의 비용이 동일한 경우의 최단 경로 |
| 다익스트라 | 시작점부터 누적 비용 g(n) | 음수 간선이 없는 가중 그래프의 최단 경로 |
| A* | f(n) = g(n) + h(n) | 목적지에 대한 추정 비용을 활용하는 경로 탐색 |
다익스트라는 현재까지 누적된 이동 비용이 가장 작은 노드를 우선 탐색한다.
A*는 여기에 목적지까지의 추정 비용을 추가하여 탐색 우선순위를 결정한다.
A*의 허용성(Admissibility)
허용성은 휴리스틱이 실제 남은 최단 비용을 과대평가하지 않는 조건이다.
h(n) <= h*(n)
- h(n): 휴리스틱으로 추정한 남은 비용
- h*(n): 해당 노드에서 목적지까지 실제 최단 비용
예를 들어 A에서 G까지 세 경로가 있다고 가정하자.
경로비용
| A → B → G | 2 + 3 = 5 |
| A → C → G | 4 + 4 = 8 |
| A → G | 10 |
A에서 G까지 실제 최단 비용은 5다.
따라서 h(A) = 6이라면 허용성을 만족하지 않는다.
반대로 h(A) = 5라면 정확히 실제 최단 비용과 같기 때문에 허용성을 만족한다.
처음에는 실제 최단 비용보다 반드시 작아야 한다고 생각했지만, 등호도 허용된다는 점을 다시 확인했다.
일관성(Consistency)은 무엇이 다를까?
일관성은 인접 노드 사이에서 다음 조건을 만족하는 것이다.
h(u) <= c(u, v) + h(v)
c(u, v)는 u에서 v까지 이동하는 비용이다.
일관성이 성립하면 경로를 따라 이동하면서 A*의 f(n) 값이 감소하지 않는다.
일관성이 깨진 경우에는 이미 탐색을 완료한 노드에 대해 나중에 더 저렴한 경로가 발견될 수 있다.
따라서 탐색 완료 노드를 다시 열지 않는 구현에서는 최단 경로를 놓칠 위험이 있다.
허용성과 일관성은 서로 다른 조건이며, 최단 경로를 보장하려면 휴리스틱의 성질뿐 아니라 알고리즘 구현 방식도 함께 고려해야 한다.
3. 디자인 패턴 — Strategy와 Factory Method의 실행 원리
오늘은 패턴의 목적뿐 아니라 실제 메서드가 어떤 과정을 거쳐 호출되는지도 복습했다.
Strategy 패턴
Strategy 패턴은 공통 인터페이스를 통해 서로 다른 동작을 교체하는 방식이다.
예를 들어 게임 캐릭터의 공격 방식을 변경한다고 가정하자.
public interface IAttackStrategy
{
void Attack();
}
public class BowAttack : IAttackStrategy
{
public void Attack()
{
Console.WriteLine("활 공격");
}
}
public class Player
{
private readonly IAttackStrategy _strategy;
public Player(IAttackStrategy strategy)
{
_strategy = strategy;
}
public void Attack()
{
_strategy.Attack();
}
}
호출 코드는 다음과 같다.
Player player = new Player(new BowAttack());
player.Attack();
new BowAttack()으로 객체를 생성하고 그 객체의 참조를 Player 생성자에 전달한다.
생성자는 해당 참조를 인터페이스 타입 필드인 _strategy에 저장한다.
player.Attack()을 호출하면 내부의 _strategy.Attack()이 실행되며, 실제 참조하는 객체 타입에 따라 BowAttack.Attack()이 선택된다.
이것이 인터페이스를 통한 다형성 호출이다.
중요한 점은 인터페이스를 구현하는 것과 클래스의 메서드를 override하는 것은 다르다는 것이다.
Factory Method 패턴
Factory Method는 객체 생성 메서드를 상위 클래스에 선언하고, 하위 클래스에서 구체적인 객체 생성 방식을 결정하도록 하는 패턴이다.
예를 들어 상위 클래스인 MonsterFactory가 Spawn()을 제공하고, 그 안에서 추상 메서드 CreateMonster()를 호출할 수 있다.
SlimeFactory가 CreateMonster()를 override했다면 실제 객체가 SlimeFactory일 때 해당 자식 클래스의 메서드가 실행된다.
실행 흐름은 다음과 같다.
MonsterFactory.Spawn()
↓
SlimeFactory.CreateMonster()
↓
new Slime()
↓
monster.Appear()
여기서 부모 클래스의 Spawn()이 실행되더라도 내부에서 호출하는 가상 메서드는 실제 객체 타입에 따라 선택된다.
언제 어떤 패턴을 사용할까?
- Strategy: 서로 다른 처리 방식을 실행 중 교체하고 싶을 때
- Simple Factory: 객체 생성 로직을 간단히 한곳에 모으고 싶을 때
- Factory Method: 하위 클래스마다 구체적인 객체 생성 방식을 다르게 결정하고 싶을 때
단순한 생성 로직에 불필요한 상속 구조를 추가하면 복잡도만 증가할 수 있다.
따라서 디자인 패턴도 유지보수성과 변경 가능성을 기준으로 선택해야 한다.
4. C# 게임 서버 — ThreadPool과 ASP.NET Core 요청 처리
ASP.NET Core는 일반적으로 ThreadPool의 작업자 스레드를 이용해 애플리케이션의 요청 처리 코드를 실행한다.
그렇다면 하나의 HTTP 요청에서 Thread.Sleep()을 사용하면 다른 요청도 모두 기다려야 할까?
그렇지는 않다.
Thread.Sleep이 차단하는 대상
다음과 같은 두 API가 있다고 가정하자.
app.MapGet("/slow", () =>
{
Thread.Sleep(5000);
return Results.Ok("Slow 완료");
});
app.MapGet("/fast", () =>
{
return Results.Ok("Fast 완료");
});
/slow 요청이 실행되는 동안 Thread.Sleep(5000)은 그 코드를 실행 중인 작업자 스레드를 차단한다.
그러나 ThreadPool에 다른 가용 스레드가 있다면 /fast 요청은 별도로 처리될 수 있다.
즉, 한 요청에서 Thread.Sleep을 실행한다고 다른 모든 요청이 반드시 기다리는 것은 아니다.
ThreadPool Starvation은 언제 발생할까?
차단 작업이 많아지면 사용 가능한 ThreadPool 작업자 스레드가 부족해질 수 있다.
ThreadPool은 필요에 따라 작업자 스레드를 추가하지만, 요청이 계속 쌓여 필요한 작업을 제때 처리하지 못하면 응답 지연이 발생한다.
이것이 ThreadPool Starvation이다.
단순히 동시 요청 수가 현재 스레드 수보다 많다는 이유만으로 Starvation이라고 판단해서는 안 된다.
특히 비동기 I/O를 사용하는 경우에는 요청이 대기하는 동안 스레드를 계속 점유할 필요가 없기 때문이다.
Task.Run은 CPU 연산을 빠르게 만들지 않는다
Task.Run()은 작업을 ThreadPool에 예약한다.
ASP.NET Core에서 이미 ThreadPool 스레드로 수행 중인 짧은 CPU 계산을 Task.Run()으로 감싸면 불필요한 스케줄링 비용이 발생할 수 있다.
반면 실제 비동기 DB·네트워크 작업은 미완료 Task를 await하는 동안 스레드가 다른 작업을 처리할 수 있도록 할 수 있다.
따라서 CPU 연산과 비동기 I/O, 차단 작업의 차이를 이해하고 코드를 작성해야 한다.
5. 게임 서버 DB — PostgreSQL B-Tree 인덱스와 EXPLAIN
오늘 가장 확실하게 새롭게 이해한 개념은 인덱스가 실제 테이블의 모든 컬럼 값을 저장하는 것은 아니라는 점이다.
B-Tree 인덱스의 탐색 원리
B-Tree 인덱스는 정렬된 키를 계층적으로 관리한다.
따라서 데이터를 순차적으로 모두 확인하지 않고, 각 단계에서 탐색 범위를 좁혀 원하는 키를 찾을 수 있다.
다음 테이블을 살펴보자.
CREATE TABLE players (
id BIGINT PRIMARY KEY,
nickname VARCHAR(50),
gold INT
);
PostgreSQL에서는 기본 키 제약 조건을 생성할 때 이를 지원하는 고유 B-Tree 인덱스가 기본적으로 생성된다.
다음 쿼리를 실행한다고 가정하자.
SELECT *
FROM players
WHERE id = 500000;
PostgreSQL이 일반적인 Index Scan을 선택했다면, 인덱스에서 id를 찾고 해당 엔트리의 TID를 이용해 실제 테이블 행에 접근한다.
TID와 Heap은 무엇일까?
PostgreSQL에서 일반적인 테이블 데이터는 Heap에 저장된다.
인덱스는 검색 키와 테이블 행을 찾는 데 필요한 위치 정보 등을 관리한다.
일반적인 Index Scan의 처리 과정은 다음과 같다.
B-Tree 인덱스 탐색
↓
id = 500000 검색
↓
TID 확인
↓
Heap에서 대상 행 접근
↓
nickname, gold 등 필요한 컬럼 조회
처음에는 nickname과 gold까지 인덱스에 저장되어 있다고 생각했다.
하지만 id만을 대상으로 하는 일반적인 인덱스에는 해당 컬럼들이 자동으로 저장되지 않는다.
따라서 실제 컬럼 값을 읽기 위해 Heap에 접근할 수 있다.
단, 필요한 컬럼이 인덱스에 포함되어 있고 MVCC 가시성 조건을 만족하면 Index Only Scan으로 Heap 접근을 생략할 수 있는 경우도 있다.
인덱스가 있어도 Seq Scan을 사용하는 이유
PostgreSQL은 인덱스가 존재한다는 사실만으로 항상 Index Scan을 선택하지 않는다.
예를 들어 gold 컬럼에 인덱스가 있다고 가정하자.
SELECT *
FROM players
WHERE gold = 999999;
결과가 극소수의 행이라면 Index Scan이 유리할 수 있다.
반면 다음 쿼리는 다르다.
SELECT *
FROM players
WHERE gold >= 0;
대부분의 플레이어가 해당 조건을 만족한다면 테이블 전체를 순차적으로 읽는 Seq Scan이 더 효율적일 수 있다.
이는 인덱스를 통한 많은 Heap 접근보다 순차적인 읽기가 더 저렴할 수 있기 때문이다.
다만 실제 선택은 테이블 크기와 통계 정보, 예상 비용 등 여러 조건에 따라 결정된다.
EXPLAIN으로 실행 계획 확인하기
EXPLAIN
SELECT *
FROM players
WHERE gold >= 0;
EXPLAIN은 PostgreSQL이 선택한 실행 계획과 예상 비용·행 수 등을 보여준다.
실제 실행 시간과 행 수를 조사하려면 EXPLAIN ANALYZE를 사용할 수 있다. 이때 대상 SQL이 실제 실행된다는 점에 주의해야 한다.
게임 서버에서도 조회가 느리다는 이유만으로 인덱스를 무조건 추가하기보다, 실행 계획을 확인하고 병목 원인을 파악하는 것이 중요하다.
오늘 새롭게 알게 된 점
- LinkedList는 삭제할 노드 참조를 알고 있을 때 O(1)이지만, 노드 탐색이 필요하면 전체 O(n)이 발생한다.
- A*의 허용성 조건은 실제 최단 비용과 같은 값도 허용하며, 일관성과는 다른 조건이다.
- Strategy의 인터페이스 구현과 Factory Method의 override 호출은 서로 다른 구조다.
- Thread.Sleep은 실행 중인 작업자 스레드를 차단하지만 다른 요청이 모두 차단되는 것은 아니다.
- PostgreSQL에서는 인덱스로 TID를 찾고 실제 Heap에서 필요한 컬럼을 가져올 수 있다.
- 인덱스가 있어도 예상 비용에 따라 Seq Scan이 선택될 수 있다.
추가로 복습할 내용
- LinkedList와 List의 실제 선택 기준
- A* 허용성·일관성의 차이와 최단 경로 보장 조건
- 인터페이스 구현과 override 메서드의 실행 흐름
- ThreadPool Starvation이 발생하는 조건
- B-Tree 인덱스, Heap 접근 및 Index Only Scan
- EXPLAIN ANALYZE를 통한 실제 실행 계획 분석
오늘은 개념을 기억하는 것뿐 아니라, 실제 내부 동작을 설명해 보는 것이 이해도를 확인하는 데 중요하다는 것을 느꼈다.
특히 PostgreSQL의 인덱스처럼 사용법은 알고 있어도 내부 원리를 정확히 이해하지 못한 부분이 있었다.
앞으로도 이러한 차이를 하나씩 줄여가며 게임 서버 개발에 필요한 기본기를 강화하려고 한다.
이 글의 코드는 학습용 예시이며 실제 실행, 성능 측정, 부하 테스트 결과를 포함하지 않는다. 모든 게임 서버 사례는 가상의 예시다.
'서버 공부 > 매일학습' 카테고리의 다른 글
| Day 3 — 오늘 배운 게임 서버 개발 지식 5가지 (1) | 2026.11.04 |
|---|---|
| Day 2 — 오늘 배운 게임 서버 개발 지식 5가지 (0) | 2026.11.03 |
