Day 5 — 오늘 배운 게임 서버 개발 지식 5가지
학습일: 2026년 11월 5일
복습일: 2026년 11월 6일
학습 주제: HashSet, Sliding Window, Singleton과 DI, CancellationToken, PostgreSQL 복합 인덱스
오늘은 C#의 HashSet과 알고리즘의 Sliding Window, ASP.NET Core의 DI 수명과 CancellationToken, PostgreSQL의 복합 인덱스를 공부했다.
이번 학습에서 특히 중요하게 느낀 부분은 자료구조나 기능의 사용 방법만 아는 것이 아니라, 내부에서 어떻게 동작하고 어떤 문제가 발생할 수 있는지 알아야 한다는 것이다.
1. 자료구조 — HashSet과 Dictionary의 차이
C#의 HashSet<T>는 중복되지 않는 값을 관리하는 해시 기반 자료구조다.
Dictionary<TKey, TValue>는 Key와 Value를 함께 저장하지만, HashSet은 값 자체의 중복 여부를 관리하는 데 적합하다.
HashSet은 중복을 어떻게 판단할까?
var players = new HashSet<string>();
players.Add("PlayerA"); // true
players.Add("PlayerA"); // false
같은 값을 다시 추가하려고 하면 Add()는 false를 반환한다.
그렇다면 서로 다른 두 값의 해시값이 우연히 같으면 어떻게 될까?
HashSet은 해시값만으로 중복을 판단하지 않는다.
내부적으로 해시값을 이용해 해당 버킷의 엔트리들을 찾고, 필요할 때 EqualityComparer<T>를 이용해 실제 값의 동등성을 비교한다.
따라서 "PlayerA"와 "PlayerB"의 해시값이 충돌하더라도 실제 값이 다르다면 둘 다 저장할 수 있다.
시간 복잡도
연산평균최악
| 조회(Contains) | O(1) | O(n) |
| 추가(Add) | O(1) | O(n) |
| 삭제(Remove) | O(1) | O(n) |
평균적으로 조회와 중복 검사가 빠르기 때문에 게임 서버에서 이벤트 참여자 관리, 이미 처리한 식별자 확인 등에 활용할 수 있다.
다만 실제 결제나 재화 지급의 중복 처리는 메모리의 HashSet만으로 보장할 수 없다. 서버 재시작, 다중 인스턴스, 동시 요청 등을 고려해 DB의 고유 제약이나 영속적인 멱등성 처리도 필요하다.
2. 알고리즘 — Sliding Window와 최대 구간 합
Sliding Window는 연속된 구간을 이동하면서 필요한 계산 결과를 효율적으로 갱신하는 방법이다.
예를 들어 배열에서 연속된 K개 요소의 최대 합을 구한다고 가정하자.
int[] numbers = { -5, -2, -8, -1, -4 };
int k = 3;
가능한 구간의 합은 다음과 같다.
구간합
| -5, -2, -8 | -15 |
| -2, -8, -1 | -11 |
| -8, -1, -4 | -13 |
최대 합은 -11이다.
구간의 합을 매번 다시 계산할 필요가 없는 이유
이전 구간의 합에서 가장 왼쪽 값을 빼고, 새로 들어오는 오른쪽 값을 더하면 다음 구간의 합을 계산할 수 있다.
다음 구간 합
= 이전 구간 합 - 빠지는 값 + 새로 들어오는 값
첫 구간의 합을 구하는 데 O(K), 이후 구간을 이동하는 데 O(N-K)가 필요하므로 전체 시간 복잡도는 O(N)이다.
음수 배열에서 주의할 점
처음에는 최대 합을 다음과 같이 초기화했다.
int max = 0;
하지만 모든 구간의 합이 음수라면 결과가 잘못될 수 있다.
따라서 첫 번째 구간의 합을 계산한 뒤 해당 값으로 최댓값을 초기화해야 한다.
다음은 일반적인 고정 길이 Sliding Window 예시다.
static long MaxWindowSum(int[] numbers, int k)
{
ArgumentNullException.ThrowIfNull(numbers);
if (k < 1 || k > numbers.Length)
throw new ArgumentOutOfRangeException(nameof(k));
long sum = 0;
for (int i = 0; i < k; i++)
sum += numbers[i];
long max = sum;
for (int i = k; i < numbers.Length; i++)
{
sum -= numbers[i - k];
sum += numbers[i];
max = Math.Max(max, sum);
}
return max;
}
이 코드의 핵심은 초기 구간도 최댓값 후보에 포함하고, 이후에는 한 번에 두 요소만 이용해 합을 갱신한다는 점이다.
3. 디자인 패턴 — Singleton과 ASP.NET Core DI 수명
Singleton 패턴은 하나의 인스턴스를 만들어 여러 곳에서 공유하는 설계 패턴이다.
하지만 ASP.NET Core의 DI 컨테이너에는 Singleton 외에도 Scoped와 Transient라는 서비스 수명이 있다.
세 가지 수명의 차이
수명생성 및 재사용 기준
| Singleton | DI 컨테이너 내에서 하나의 인스턴스를 공유 |
| Scoped | 동일 Scope 안에서 하나의 인스턴스 재사용 |
| Transient | DI 컨테이너에서 서비스를 해결할 때마다 새 인스턴스 생성 |
일반적인 ASP.NET Core HTTP 요청에서는 요청마다 Scope가 생성된다.
따라서 Scoped 서비스는 요청 A와 요청 B에서 각각 다른 인스턴스를 사용한다.
Singleton이 Scoped DbContext를 주입받으면?
다음과 같은 상황을 생각해 보자.
builder.Services.AddSingleton<GameService>();
builder.Services.AddDbContext<GameDbContext>();
AddDbContext는 기본적으로 DbContext를 Scoped 수명으로 등록한다.
그런데 Singleton인 GameService가 생성자에서 GameDbContext를 주입받는다면 수명 불일치가 발생한다.
Scoped 객체를 수명이 긴 Singleton이 붙잡게 되는 문제를 Captive Dependency라고 한다.
ASP.NET Core에서 Scope 검증이 활성화되어 있다면 이러한 잘못된 의존성은 예외로 감지될 수 있다.
반대로 검증 없이 허용되면 DbContext가 의도보다 오래 유지되어 여러 요청 사이에 공유될 위험이 있다.
DbContext는 스레드 안전하지 않기 때문에 이러한 구조는 특히 위험하다.
서버 전체에서 공유해야 할 특별한 상태가 없는 일반적인 요청 처리 서비스라면 다음과 같은 등록이 적절할 수 있다.
builder.Services.AddScoped<GameService>();
builder.Services.AddDbContext<GameDbContext>();
이렇게 하면 일반적인 HTTP 요청에서 두 서비스가 같은 요청 Scope에 속하고, 다른 요청에서는 별도 인스턴스를 사용할 수 있다.
단, Singleton이 정말 필요한 서비스라면 별도의 Scope 생성이나 IDbContextFactory<TContext>와 같은 대안도 검토할 수 있다.
Singleton은 스레드 안전할까?
Singleton은 인스턴스 공유를 의미할 뿐, 스레드 안전성을 자동으로 보장하지 않는다.
여러 요청이 공유 상태에 동시에 접근한다면 Race Condition이 발생하지 않도록 별도 동기화가 필요하다.
4. C# 게임 서버 — CancellationToken과 비동기 예외 처리
CancellationToken은 비동기 작업을 강제로 종료하는 기능이 아니다.
취소 요청을 작업에 전달하고, 작업이 이를 확인하여 협력적으로 중단하도록 만드는 구조다.
ASP.NET Core에서는 클라이언트 연결 종료 등의 상황을 HttpContext.RequestAborted를 통해 확인할 수 있다.
Minimal API의 CancellationToken 매개변수도 요청 취소 토큰을 전달받을 수 있다.
CancellationToken은 하위 작업까지 전달해야 한다
app.MapGet("/player/{id}", async (
long id,
CancellationToken ct,
PlayerService service) =>
{
var player = await service.GetPlayerAsync(id, ct);
return Results.Ok(player);
});
GetPlayerAsync()가 비동기 DB 조회를 수행한다면 내부 DB API까지 토큰을 전달하는 것이 중요하다.
그렇지 않으면 클라이언트의 요청이 취소되었더라도 서버의 DB 작업은 계속 진행될 수 있다.
단, 토큰을 전달했다고 모든 DB 작업이 반드시 즉시 취소되는 것은 아니다.
catch와 throw의 차이
이번 복습에서 헷갈렸던 부분은 취소 예외를 처리한 이후의 실행 흐름이었다.
다음 코드를 보자.
try
{
await Task.Delay(5000, ct);
}
catch (OperationCanceledException)
{
Console.WriteLine("취소됨");
}
Console.WriteLine("다음 작업");
취소 예외가 발생해 catch에서 처리되고 다시 던지지 않는다면, 예외 처리 이후의 코드도 계속 실행될 수 있다.
반면 다음 코드는 다르다.
catch (OperationCanceledException)
{
Console.WriteLine("취소됨");
throw;
}
throw;는 현재 예외를 다시 던진다.
따라서 상위 호출자가 await하는 지점까지 예외가 전달될 수 있다.
중요한 것은 throw;는 return과 다르다는 점이다.
취소 예외를 처리하고 정상적으로 종료하면 반환 Task가 정상 완료 상태가 될 수 있고, 예외를 전파하면 호출자가 취소를 인식하고 처리할 수 있다.
취소와 DB 장애는 서로 다른 상황이므로 운영 로그 및 예외 처리 정책에서도 구분해야 한다.
5. 게임 서버 DB — 복합 인덱스와 EXPLAIN ANALYZE
PostgreSQL에서 복합 인덱스는 여러 컬럼을 하나의 인덱스로 관리하는 방식이다.
예를 들어 게임 서버에 다음 테이블과 인덱스가 있다고 가정하자.
CREATE TABLE player_items (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status INT NOT NULL
);
CREATE INDEX idx_user_status
ON player_items(user_id, status);
복합 인덱스의 컬럼 순서
(user_id, status) 복합 B-Tree 인덱스는 먼저 user_id를 기준으로 정렬되고, 같은 user_id 안에서 status를 기준으로 정렬된다.
따라서 다음 세 조회 조건의 효율이 달라질 수 있다.
-- A
SELECT * FROM player_items
WHERE user_id = 100;
-- B
SELECT * FROM player_items
WHERE status = 1;
-- C
SELECT * FROM player_items
WHERE user_id = 100 AND status = 1;
일반적으로 A는 선행 컬럼인 user_id를 이용해 범위를 좁힐 수 있다.
C는 두 컬럼 조건을 모두 이용해 탐색 범위를 더 좁힐 수 있다.
반면 B는 선행 컬럼 조건이 없으므로 일반적으로 불리하다.
단, PostgreSQL 18에는 Skip Scan 최적화가 있어 선행 컬럼 조건이 없더라도 데이터 분포와 비용에 따라 복합 인덱스가 활용될 수 있다.
따라서 복합 인덱스의 선행 컬럼 규칙은 중요하지만, 특정 조건에서 인덱스를 절대로 사용할 수 없다고 단정해서는 안 된다.
EXPLAIN ANALYZE는 무엇을 확인할까?
EXPLAIN은 PostgreSQL이 선택한 실행 계획과 예상 비용·행 수 등을 보여준다.
EXPLAIN ANALYZE는 SQL을 실제 실행하여 실행 시간과 실제 처리 행 수 등을 보여준다.
EXPLAIN ANALYZE
SELECT *
FROM player_items
WHERE user_id = 100
AND status = 1;
이 결과에서는 예상 행 수와 실제 행 수를 비교하고, 실행 계획이 Index Scan, Seq Scan 등 어떤 방식을 사용했는지 확인할 수 있다.
예상 행 수와 실제 행 수가 크게 다르다면 통계 정보나 데이터 분포 등을 살펴볼 필요가 있다.
다만 EXPLAIN ANALYZE는 실제 쿼리를 실행하므로, 운영 DB에서 변경 SQL을 분석할 때는 특히 주의해야 한다.
이번 복습에서는 복합 인덱스의 선행 컬럼 원리를 직접 다시 설명했다. EXPLAIN ANALYZE를 실제 실행하여 분석하는 실습은 별도로 진행할 예정이다.
오늘 새롭게 알게 된 점
- HashSet은 해시값이 충돌하더라도 실제 값이 다르면 중복으로 판단하지 않는다.
- Sliding Window에서는 첫 구간의 합으로 최댓값을 초기화해야 음수 배열도 정확하게 처리할 수 있다.
- Singleton이 Scoped 객체를 오래 참조하면 DI 수명 문제가 발생할 수 있다.
- catch에서 throw;를 실행하면 정상 반환이 아니라 예외가 다시 전파된다.
- CancellationToken은 하위 비동기 작업까지 전달해야 취소 요청을 활용할 수 있다.
- 복합 인덱스는 컬럼 순서가 중요하며 선행 컬럼만으로도 효율적인 탐색이 가능하다.
추가로 복습할 내용
- HashSet과 Dictionary의 내부 구조 및 사용 기준
- 가변 길이 Sliding Window 문제
- Singleton의 스레드 안전성과 Scoped 의존성 관리
- CancellationToken과 예외 전파의 실행 순서
- PostgreSQL 복합 인덱스의 범위 조건 및 ORDER BY 최적화
- EXPLAIN ANALYZE를 통한 실제 실행 계획 분석
오늘도 복습 중 잘못 이해한 부분이 발견되었다.
특히 DI 서비스 수명, throw;의 동작, 복합 인덱스 컬럼 순서는 처음부터 완전히 설명하지 못했다.
하지만 추가 질문을 통해 원리를 다시 이해하고 설명할 수 있었다.
앞으로도 외운 정답보다 왜 그렇게 동작하는지 설명할 수 있는 능력을 기르는 데 집중하려고 한다.
이 글의 C#과 SQL 예시는 학습용이다. 별도의 코드 실행, DB 쿼리 실행, 성능 측정 결과는 포함하지 않았다. 게임 서버 사례는 모두 일반화한 가상 상황이다.
