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 명령부터 다시 실행하면 안 된다.

새로운 트랜잭션을 시작하고 전체 작업을 다시 처리해야 한다.

예를 들어 게임 서버에서 골드를 이전하는 거래라면 다음 작업이 하나의 트랜잭션에 포함될 수 있다.

  1. 현재 골드 잔액 및 거래 조건 확인
  2. 보내는 플레이어의 골드 차감
  3. 받는 플레이어의 골드 증가
  4. 거래 기록 저장
  5. 커밋

데드락으로 트랜잭션이 실패했다면 새로운 트랜잭션에서 현재 데이터를 다시 읽고 조건을 재검증해야 한다.

다른 트랜잭션이 데이터를 변경했을 수 있기 때문이다.

또한 무제한 재시도보다는 재시도 횟수를 제한하고, 반복적으로 실패하면 오류 로그를 남겨 원인을 조사하는 것이 적절하다.

필요하다면 짧은 재시도 지연도 적용할 수 있다.

실제 게임 서버에서는 중복 거래 방지와 트랜잭션 밖의 부작용 처리까지 함께 고려해야 한다.


오늘 새롭게 알게 된 점

  1. Queue는 원형 배열의 인덱스를 이동시키기 때문에 Dequeue할 때 전체 요소를 이동하지 않는다.
  2. 분할 상환 O(1)은 단순히 대부분의 작업이 빠르기 때문만이 아니라, 배열 확장 비용을 여러 작업에 분산할 수 있기 때문에 성립한다.
  3. A*에서 일관성이 깨지면 이미 탐색 완료한 노드에 대해 더 저렴한 경로가 나중에 발견될 수 있다.
  4. 단순한 객체 생성에는 Factory Method보다 Simple Factory가 적합할 수 있다.
  5. ASP.NET Core에서 Task.Run은 CPU 연산 자체를 가속하지 않는다.
  6. 데드락 예방에는 잠금 순서 통일이 중요하며, 데드락 이후에는 새로운 트랜잭션에서 데이터를 재검증해야 한다.

추가로 복습할 내용

  • Stack과 Queue의 내부 배열 확장 및 분할 상환 복잡도
  • A* 휴리스틱의 허용성과 일관성
  • Factory Method를 실제 C# 코드로 구현하는 방법
  • ASP.NET Core의 비동기 I/O와 ThreadPool Starvation 진단
  • PostgreSQL의 데드락 재시도, 잠금 순서, 인덱스 및 실행 계획

이번 학습에서는 개념을 암기하는 것뿐 아니라, 그 개념을 실제 게임 서버에 적용할 때 왜 그런 선택을 해야 하는지 생각해 볼 수 있었다.

다음 학습에서도 내부 동작 원리를 이해하고 실제 코드와 연결하는 방향으로 공부할 예정이다.


이 글의 예시 코드는 학습용으로 작성했으며 실제 실행·성능 측정 결과를 의미하지 않습니다. 게임 서버 예시는 가상 상황이며 회사 내부 구현 정보는 포함하지 않았습니다.

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

학습일: 2026년 11월 2일
주제: 자료구조, 알고리즘, 디자인 패턴, C# 비동기, 데이터베이스 트랜잭션

오늘은 기존에 학습했던 개념을 복습하면서 단순히 사용법을 아는 것과 내부 동작 원리를 이해하는 것이 다르다는 점을 확인했다.

특히 C#의 비동기 실행 흐름과 PostgreSQL의 동시성 처리에서 헷갈렸던 부분을 다시 정리했다.

1. 자료구조 — List<T>와 Dictionary의 내부 구조

List<T>는 내부 배열을 관리하는 객체다

C#의 List<T>는 내부적으로 배열을 사용한다.

데이터를 계속 추가하다가 내부 저장 공간이 부족해지면 더 큰 배열을 새로 할당하고 기존 요소들을 복사한다.

이때 List<T> 객체 자체가 새로 만들어지는 것은 아니다. 기존 객체가 유지되면서 내부에서 참조하는 배열이 교체된다.

기존 배열은 더 이상 참조되지 않는다면 GC의 수거 대상이 된다.

또한 중간에 요소를 삽입하거나 삭제하면 다른 요소들의 위치를 이동시켜야 하므로 O(n)의 시간 복잡도가 발생한다.

Dictionary는 해시와 엔트리 배열을 활용한다

Dictionary<TKey, TValue>는 해시값을 이용해 데이터를 탐색한다.

일반적인 .NET 구현에는 버킷 배열과 엔트리 배열이 존재한다.

  • 버킷 배열: 해당 버킷에 연결된 첫 엔트리의 위치를 찾는 역할
  • 엔트리 배열: 해시값, Key, Value, 다음 엔트리 위치 등의 정보 저장

서로 다른 키가 같은 버킷에 배정되는 해시 충돌이 발생할 수도 있다.

이때 next 정보를 통해 같은 버킷에 연결된 다른 엔트리를 탐색하고, 실제 키를 비교해 데이터를 구분한다.

2. 알고리즘 — 다익스트라와 A*의 차이

다익스트라 알고리즘은 시작점에서 현재 노드까지의 누적 이동 비용이 가장 작은 노드를 우선 탐색한다.

반면 A* 알고리즘은 누적 이동 비용에 목적지까지의 추정 비용을 더해 탐색 우선순위를 결정한다.

  • g(n): 시작점부터 현재 노드까지의 누적 비용
  • h(n): 현재 노드에서 목적지까지의 추정 비용
  • f(n) = g(n) + h(n): A*에서 사용하는 평가 비용

예를 들어 두 탐색 후보가 다음과 같다고 가정하자.

노드g(n)h(n)f(n)

A 4 7 11
B 6 2 8

다익스트라는 A를 우선 탐색하지만, A*는 B를 우선 탐색한다.

여기서 중요한 것은 휴리스틱의 정확성이다.

h(n)이 실제 남은 최단 비용을 과대평가하면 최적 경로에 포함된 노드의 탐색 우선순위가 낮아질 수 있다. 따라서 일반적인 A* 최단 경로 보장 조건을 만족하지 못할 수 있다.

단, 과대평가했다고 항상 잘못된 경로를 찾는 것은 아니다.

휴리스틱의 허용성과 일관성 조건은 앞으로 더 복습해야 할 내용이다.

3. 디자인 패턴 — Strategy 패턴

Strategy 패턴은 공통 인터페이스를 통해 서로 다른 알고리즘이나 동작을 교체하여 실행할 수 있도록 만드는 패턴이다.

예를 들어 게임 서버에서 플랫폼마다 결제 검증 방식이 다르다고 가정하자.

Google Play, App Store, 외부 웹상점은 서로 다른 검증 로직을 가질 수 있다.

이때 각 결제 로직이 공통 인터페이스를 구현하도록 설계하면, 결제를 요청하는 코드는 구체적인 플랫폼별 구현에 강하게 의존하지 않아도 된다.

public interface IPaymentStrategy
{
    void Pay();
}

핵심은 인터페이스가 여러 결제 로직을 하나로 합치는 것이 아니라 공통 메서드 계약을 제공한다는 점이다.

인터페이스 타입 변수에 구체적인 구현 객체를 할당하면 실제 호출 시 해당 객체의 메서드가 실행된다. 이를 다형성이라고 한다.

다음 학습에서는 객체의 생성 책임을 다루는 Factory Method 패턴과 비교할 예정이다.

4. C# 게임 서버 — async, Task.Run과 ThreadPool Starvation

async 키워드를 사용했다고 해서 메서드가 자동으로 별도 스레드에서 실행되는 것은 아니다.

async 메서드는 호출한 스레드에서 실행을 시작하며, 미완료된 비동기 작업을 await할 때 실행을 중단하고 호출자에게 제어를 돌려줄 수 있다.

여기서 Thread.Sleep과 Task.Delay는 중요한 차이가 있다.

구분Thread.Sleepawait Task.Delay

동작 현재 스레드 차단 비동기 지연 대기
대기 중 스레드 점유 점유함 지연 완료까지 스레드 점유 불필요
서버에서 고려할 점 스레드 부족 위험 단순 시간 대기에 적합

Task.Run은 작업을 ThreadPool에 예약한다.

하지만 Task.Run 내부에서 Thread.Sleep을 실행하면 해당 작업을 실행하는 ThreadPool 스레드가 차단된다.

이러한 작업이 많아지면 ThreadPool에서 처리 가능한 작업 수가 줄어들고, 다른 요청들이 대기하면서 응답 지연이 발생할 수 있다.

이것이 ThreadPool Starvation과 관련된 중요한 문제다.

따라서 I/O 대기나 단순 지연에 불필요하게 Task.Run과 차단 메서드를 사용하는 것은 피해야 한다.

5. 게임 서버 DB — MVCC, SELECT FOR UPDATE와 조건부 UPDATE

PostgreSQL의 READ COMMITTED 격리 수준에서는 일반 SELECT가 다른 트랜잭션의 미커밋 변경 결과를 읽지 않는다.

예를 들어 기존 골드가 1,000인데 트랜잭션 A가 500으로 수정하고 아직 커밋하지 않았다면, 다른 트랜잭션의 일반 SELECT는 기존에 커밋된 1,000을 읽을 수 있다.

이는 MVCC를 이용해 적절한 데이터 버전을 조회하기 때문이다.

반면 SELECT FOR UPDATE는 대상 행의 잠금을 획득하려고 하므로 충돌하는 행 잠금이 있다면 대기해야 한다.

게임 서버에서 조건부 UPDATE 활용하기

플레이어에게 골드가 100 있고, 80골드짜리 아이템을 동시에 두 번 구매한다고 가정해 보자.

UPDATE players
SET gold = gold - 80
WHERE id = 1
  AND gold >= 80;

PostgreSQL의 READ COMMITTED 환경에서 첫 번째 트랜잭션이 차감을 수행하고 잠금을 유지하고 있다면 두 번째 UPDATE는 대기한다.

첫 번째 트랜잭션이 커밋한 뒤에는 변경된 골드를 기준으로 WHERE 조건을 다시 평가한다.

골드가 20이면 gold >= 80이 성립하지 않으므로 두 번째 차감은 수행되지 않는다.

이처럼 조건부 UPDATE와 영향받은 행 수 검사를 이용하면 특정 단일 행의 잔액 부족 문제를 안전하게 처리할 수 있다.

단, 실제 구매 시스템에서는 아이템 지급, 트랜잭션 원자성, 중복 요청 방지까지 별도로 고려해야 한다.


오늘 새롭게 알게 된 점

  1. List<T>의 내부 배열이 확장되어도 List 객체 자체는 유지된다.
  2. Dictionary의 버킷 배열과 엔트리 배열은 서로 다른 역할을 한다.
  3. A*의 휴리스틱은 단순히 추정 비용을 추가하는 것이 아니라 최단 경로 보장에도 영향을 준다.
  4. async는 별도 스레드 실행을 의미하지 않으며, Task.Run 역시 스레드 부족을 해결하는 방법은 아니다.
  5. PostgreSQL에서는 행 잠금이 있어도 일반 SELECT가 MVCC를 이용해 이전에 커밋된 데이터를 읽을 수 있다.

추가로 복습할 내용

  • Dictionary의 해시 충돌과 next 연결 구조
  • A*의 허용성 및 일관성 조건
  • Factory Method와 Strategy 패턴의 차이
  • ThreadPool Starvation의 실제 발생 조건과 진단
  • PostgreSQL의 MVCC, 행 잠금 및 데드락 처리

이번 학습을 통해 단순히 결과를 기억하는 것보다 왜 그런 결과가 발생하는지 설명할 수 있어야 한다는 점을 다시 확인했다.

앞으로도 게임 서버의 데이터 정합성, 동시성, 성능 문제를 내부 동작 원리와 연결해 학습할 예정이다.

예시 코드는 학습 목적으로 작성했으며, 실제 실행 및 부하 테스트를 수행한 결과는 포함하지 않았다.

C# 웹 서버 프로젝트를 생성하고 실행하는 과정까지 정리

로컬 컴퓨터에서 HTTP 서버를 실행하고 웹 브라우저로 접속했을 때 Hello World!가 출력되는 것을 확인

1. .NET이란?

.NET은 Microsoft에서 개발한 소프트웨어 개발 플랫폼이다.

C# 등의 프로그래밍 언어를 사용하여 애플리케이션을 개발하고 실행할 수 있는 환경을 제공한다.

 

예를 들어 C#으로 게임 서버를 작성했다고 가정해 보자.

작성한 C# 코드가 실제로 실행되기 위해서는 컴파일 과정과 실행 환경이 필요하다.

.NET은 이러한 개발 및 실행에 필요한 도구와 환경을 제공한다.

.NET을 이용하면 웹 서버뿐만 아니라 콘솔 프로그램, 데스크톱 애플리케이션 등 다양한 프로그램을 개발할 수 있다.

참고로 과거에는 .NET Core라는 이름을 사용했지만, .NET 5부터는 공식적으로 .NET이라는 이름을 사용한다.

단, 웹 개발 프레임워크인 ASP.NET Core는 현재도 해당 명칭을 사용한다.

주요 용어

C#

  • 프로그램을 작성하는 프로그래밍 언어

.NET

  • C# 등의 언어로 작성한 프로그램을 개발하고 실행할 수 있도록 지원하는 플랫폼

SDK (Software Development Kit)

  • 소프트웨어 개발 도구 모음
  • 프로젝트 생성, 컴파일, 빌드 등에 필요한 도구 제공

Runtime (런타임)

  • 빌드된 .NET 애플리케이션을 실행하는 데 필요한 환경

ASP.NET Core

  • .NET 기반으로 웹 서버 및 HTTP API를 개발할 수 있는 프레임워크
  • HTTP 요청 처리, 라우팅, 미들웨어 등의 기능 제공

SDK와 런타임의 차이

SDK는 프로그램을 개발하기 위한 도구, 런타임은 프로그램을 실행하기 위한 환경

 

예를 들어 개발자는 SDK를 사용하여 게임 서버를 만들고,

서버를 실행할 컴퓨터에는 해당 애플리케이션과 호환되는 런타임이 필요할 수 있다.

일반적으로 .NET SDK를 설치하면 개발에 필요한 런타임도 함께 설치된다.

단, 애플리케이션을 배포하는 방식에 따라 별도의 런타임 설치가 필요하지 않을 수도 있다.


2. dotnet 명령어

.NET SDK를 설치하면 터미널에서 dotnet 명령어를 사용할 수 있다.

명령어설명

dotnet --version 현재 선택되는 .NET SDK 버전 확인
dotnet --list-sdks 설치된 SDK 목록 확인
dotnet --list-runtimes 설치된 런타임 목록 확인
dotnet new 새 프로젝트 생성
dotnet build 프로젝트 빌드
dotnet run 프로젝트 빌드 및 실행
dotnet test 테스트 실행

설치된 SDK 버전 확인

터미널에서 다음 명령어를 실행한다.

dotnet --version

예시 출력:

10.0.100

현재 선택되는 SDK 버전이 10.0.100이라는 의미다.

여기서 확인하는 것은 C# 언어 버전이나 런타임 버전이 아니라 .NET SDK 버전이다.

설치된 SDK를 모두 확인하려면 다음 명령어를 사용한다.

dotnet --list-sdks

이 명령어를 사용하면 여러 버전의 SDK가 설치되어 있는지도 확인할 수 있다.


3. ASP.NET Core 서버 프로젝트 생성

이번에는 ASP.NET Core를 사용하는 새로운 웹 서버 프로젝트를 생성한다.

사용할 프로젝트 이름은 MergeGame.Lab.Server이다.

dotnet new web -n MergeGame.Lab.Server -o MergeGame.Lab.Server -f net10.0

 

dotnet new

dotnet new

새로운 프로젝트나 파일을 미리 정의된 템플릿을 기반으로 생성하는 명령어

프로젝트를 처음 만들 때 필요한 기본 파일과 설정을 자동으로 생성해 준다.

web

web

ASP.NET Core의 빈 웹 프로젝트 템플릿을 의미한다.

이 템플릿은 웹 서버를 실행하는 데 필요한 최소한의 구성으로 프로젝트를 생성한다.

복잡한 기능이 미리 추가되어 있지 않기 때문에 서버의 구조를 직접 학습하기에 적합하다.

-n

-n MergeGame.Lab.Server

-n은 --name의 줄임말로, 생성할 프로젝트의 이름을 지정한다.

여기서는 프로젝트 이름을 MergeGame.Lab.Server로 설정한다.

주의할 점은 -n이 프로젝트의 저장 폴더를 지정하는 옵션은 아니라는 것

-o

-o MergeGame.Lab.Server

-o는 --output의 줄임말로, 생성된 프로젝트 파일을 저장할 위치를 지정한다.

현재 위치를 기준으로 MergeGame.Lab.Server 폴더에 프로젝트 파일을 생성한다.

-o를 생략하면 기본적으로 현재 디렉터리에 프로젝트 파일이 생성된다.

따라서 프로젝트 이름과 폴더 이름을 각각 지정하고 싶다면 -n과 -o를 함께 사용하면 된다.

-f

-f net10.0

-f는 --framework의 줄임말로, 프로젝트의 대상 프레임워크를 지정한다.

net10.0은 해당 프로젝트가 .NET 10을 대상으로 빌드되도록 설정한다는 의미다.

프로젝트 생성 시 .csproj 파일에 다음과 같은 설정이 포함된다.

<TargetFramework>net10.0</TargetFramework>

주의할 점

-f net10.0은 .NET 10을 설치하는 명령어가 아니다.

또한 특정 SDK 버전을 직접 선택하는 명령어도 아니다.

프로젝트가 대상으로 사용할 .NET 프레임워크 버전을 지정하는 옵션이다.

이 예제를 실행하려면 .NET 10 프로젝트 생성을 지원하는 SDK가 설치되어 있어야 한다.


4. 생성된 프로젝트 구조

명령어 실행 후 다음과 같은 프로젝트 파일이 생성된다.

MergeGame.Lab.Server/
│
├── Program.cs
│
├── MergeGame.Lab.Server.csproj
│
├── appsettings.json
│
├── appsettings.Development.json
│
└── Properties/
    └── launchSettings.json

 

프로젝트 생성 및 빌드 과정에 따라 obj, bin 등의 폴더도 생성된다.

Program.cs

서버 애플리케이션의 시작 코드가 작성되는 파일

서버의 초기 설정, HTTP 요청 처리 경로 등록, 애플리케이션 실행 등의 코드가 포함된다.

MergeGame.Lab.Server.csproj

C# 프로젝트 설정 파일

프로젝트가 대상으로 하는 .NET 버전, 패키지 참조, 빌드 설정 등의 정보가 포함된다.

예를 들어 .NET 10을 사용하는 프로젝트라면 대상 프레임워크가 net10.0으로 지정되어 있다.

appsettings.json

애플리케이션에서 사용하는 기본 설정을 저장하는 파일

예를 들어 로그 설정이나 서버에서 사용하는 사용자 정의 설정 등을 저장할 수 있다.

appsettings.Development.json

개발 환경에서 적용할 설정을 저장하는 파일

ASP.NET Core는 개발 환경과 운영 환경 등에 따라 서로 다른 설정을 적용할 수 있다.

일반적으로 개발 환경에서는 기본 설정인 appsettings.json에 개발용 설정을 추가하거나 덮어써 사용할 수 있다.

launchSettings.json

로컬 개발 시 애플리케이션을 어떻게 시작할지 정의하는 설정 파일

실행 프로필, 개발용 URL, 환경 변수 등을 지정할 수 있다.

주로 로컬 개발 및 디버깅 과정에서 사용된다.


5. 프로젝트 실행

먼저 생성된 프로젝트 폴더로 이동한다.

cd MergeGame.Lab.Server

cd는 현재 작업 디렉터리를 변경하는 명령어다.

이제 다음 명령어를 실행한다.

dotnet run --urls "http://localhost:5080"

이 명령어는 프로젝트를 빌드하고 실행하며, 웹 서버가 지정한 주소에서 HTTP 요청을 받을 수 있도록 설정한다.

dotnet run

dotnet run

현재 프로젝트를 빌드하고 실행하는 명령어다.

일반적으로 필요한 패키지 복원과 빌드를 수행한 뒤 애플리케이션을 실행한다.

따라서 기본적인 개발 과정에서는 dotnet build를 별도로 실행하지 않고도 서버를 실행할 수 있다.

--urls

--urls "http://localhost:5080"

ASP.NET Core 애플리케이션이 요청을 수신할 URL을 지정하는 옵션이다.

여기서는 현재 컴퓨터의 5080번 포트에서 HTTP 요청을 받을 수 있도록 설정한다.

--urls는 ASP.NET Core 애플리케이션에 전달되는 설정 옵션이다.

참고로 --urls 앞의 --는 옵션 이름의 일부다.

이와 별도로 dotnet run에서는 독립적인 --를 사용하여 실행 도구의 인수와 애플리케이션에 전달할 인수를 구분할 수도 있다.

예를 들어 다음과 같이 작성할 수도 있다.

dotnet run -- --urls "http://localhost:5080"

두 명령어 모두 이 예제에서는 같은 서버 주소를 설정할 수 있다.


6. URL 구성 요소 이해하기

이번에 사용한 주소를 분리해서 살펴보자.

http://localhost:5080

 

 

구성 요소의미

http HTTP 통신 프로토콜
localhost 현재 컴퓨터를 가리키는 호스트 이름
5080 서버가 요청을 받을 포트 번호

HTTP란?

HTTP는 클라이언트와 서버가 요청과 응답을 주고받기 위한 통신 규약이다.

예를 들어 클라이언트가 서버에 게임 유저 정보를 요청한다면, 서버는 해당 요청을 처리하고 결과를 HTTP 응답으로 반환할 수 있다.

다만 HTTP 자체는 통신 내용을 암호화하지 않는다.

실제 서비스에서는 일반적으로 TLS 암호화를 적용한 HTTPS를 사용한다.

localhost란?

localhost는 현재 컴퓨터 자신을 가리키는 호스트 이름이다.

일반적인 환경에서는 다음 루프백 주소로 연결된다.

  • IPv4: 127.0.0.1
  • IPv6: ::1

따라서 로컬 환경에서 다음과 같은 주소로 서버에 접근할 수 있다.

http://localhost:5080
http://127.0.0.1:5080

두 주소는 엄밀히 말해 동일한 주소는 아니다.

localhost는 호스트 이름이고 127.0.0.1은 IPv4 주소이기 때문이다.

하지만 서버가 해당 IPv4 주소에서 요청을 받을 수 있도록 설정되어 있다면 둘 다 같은 로컬 서버에 접근할 수 있다.

또한 localhost로 서버를 실행했다고 해서 외부 컴퓨터에서도 접속할 수 있는 것은 아니다.

현재 예제는 자신의 컴퓨터에서 서버를 실행하고 테스트하기 위한 설정이다.

Port란?

포트는 한 컴퓨터에서 네트워크 통신을 담당하는 프로그램이나 연결 지점을 구분할 때 사용하는 번호

예를 들어 하나의 컴퓨터에서 여러 서버 프로그램을 실행한다고 가정해 보자.

127.0.0.1:5080
127.0.0.1:6000
127.0.0.1:7000

각 서버가 서로 다른 포트를 사용한다면, 클라이언트는 목적지 포트에 따라 원하는 서버로 연결할 수 있다.

IP 주소가 네트워크상의 컴퓨터를 구분하는 데 사용된다면,

포트 번호는 해당 컴퓨터에서 통신할 대상을 구분하는 데 사용된다고 이해하면 된다.


7. dotnet run 실행 시 내부 동작 과정

그렇다면 dotnet run을 실행하면 내부적으로 어떤 일이 발생할까?

전체 과정을 간단하게 정리하면 다음과 같다.

1단계. 프로젝트 확인

.NET SDK가 프로젝트의 .csproj 파일과 관련 설정을 확인한다.

어떤 대상 프레임워크를 사용하는지, 어떤 패키지를 참조하는지 등의 정보를 확인한다.

2단계. 빌드

C# 소스코드를 컴파일하여 실행에 필요한 결과물을 생성한다.

일반적인 .NET 빌드에서는 C# 코드가 IL(중간 언어)을 포함한 어셈블리로 컴파일된다.

빌드 결과물은 보통 프로젝트의 bin 폴더 아래에 생성된다.

3단계. 프로세스 실행

.NET이 빌드된 애플리케이션을 실행한다.

운영체제는 실행 중인 서버 애플리케이션을 프로세스로 관리한다.

4단계. 웹 서버 시작

ASP.NET Core에서 기본적으로 사용하는 Kestrel 웹 서버가 시작된다.

Kestrel은 설정된 네트워크 주소에서 연결을 수신하고 HTTP 요청을 처리하는 웹 서버다.

5단계. 요청 대기

Kestrel이 설정된 주소와 포트에서 요청을 받을 준비를 마친다.

이번 예제에서는 다음 주소를 사용한다.

http://localhost:5080

이후 클라이언트가 HTTP 요청을 보내면 서버는 등록된 요청 처리 코드에 따라 응답을 반환한다.


8. 주요 개념 정리: 컴파일, 프로세스, Kestrel

컴파일(Compile)

사람이 작성한 소스코드를 컴퓨터가 실행할 수 있는 형태로 변환하는 과정이다.

일반적인 .NET 실행 과정은 다음과 같다.

C# 소스코드
      ↓
C# 컴파일러
      ↓
IL(중간 언어)
      ↓
.NET 런타임
      ↓
JIT 컴파일
      ↓
기계어 실행

C# 코드는 먼저 IL로 컴파일된다.

그리고 일반적인 JIT 실행 방식에서는 런타임이 실행에 필요한 코드를 기계어로 변환한다.

다만 .NET에는 미리 네이티브 코드로 컴파일하는 AOT 등의 다른 실행 방식도 존재한다.

프로세스(Process)

프로세스는 운영체제에서 실행 중인 프로그램의 인스턴스

예를 들어 서버 프로그램을 실행하면 운영체제는 해당 프로그램을 실행 중인 프로세스로 관리한다.

같은 프로그램을 여러 번 실행하면 여러 프로세스가 생성될 수도 있다.

프로세스는 자신이 사용하는 메모리와 운영체제 자원 등을 가진다.

Kestrel

Kestrel은 ASP.NET Core에서 기본적으로 사용하는 웹 서버

주요 역할은 다음과 같다.

  • 네트워크 연결 수신
  • HTTP 요청 수신 및 처리
  • ASP.NET Core 애플리케이션으로 요청 전달
  • 애플리케이션의 HTTP 응답을 클라이언트로 전송

여기서 중요한 점은 Kestrel이 HTTP 통신을 처리하는 웹 서버이고,

실제 게임 로직은 개발자가 작성한 애플리케이션 코드에서 수행할 수 있다는 것이다.

 

예를 들어 게임 서버를 개발한다면 로그인, 재화 처리, 아이템 생성, 머지 로직 등은 애플리케이션에 별도로 구현해야 한다.

Kestrel은 이러한 기능 자체를 구현해 주는 것이 아니라 클라이언트가 HTTP를 통해 애플리케이션과 통신할 수 있도록 지원한다.


9. Program.cs 코드 살펴보기

이번에 생성한 빈 ASP.NET Core 프로젝트의 Program.cs를 열어보면 다음과 같은 코드가 작성되어 있다.

var builder = WebApplication.CreateBuilder(args);

var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

각 코드를 살펴보자.

WebApplication.CreateBuilder(args)

var builder = WebApplication.CreateBuilder(args);

ASP.NET Core 애플리케이션을 구성하기 위한 빌더를 생성한다.

서버 설정, 환경 변수, 명령줄 인수, 의존성 주입 등에 필요한 기본 구성을 준비한다.

여기서 args는 프로그램을 실행할 때 전달받은 명령줄 인수를 의미한다.

앞서 사용한 --urls와 같은 설정도 애플리케이션의 구성에 반영될 수 있다.

builder.Build()

var app = builder.Build();

빌더에 설정된 내용을 기반으로 WebApplication 객체를 생성한다.

이 객체를 통해 HTTP 요청 처리 경로를 등록하고 서버를 실행할 수 있다.

app.MapGet()

app.MapGet("/", () => "Hello World!");

HTTP GET 요청을 처리할 경로를 등록한다.

각 부분의 의미는 다음과 같다.

  • MapGet: GET 방식의 HTTP 요청을 처리할 경로 등록
  • /: 요청을 받을 URL의 루트 경로
  • () => "Hello World!": 요청을 받았을 때 실행할 람다식

즉 클라이언트가 / 경로로 GET 요청을 보내면 Hello World! 문자열을 응답으로 반환하도록 설정한 것이다.

app.Run()

app.Run();

구성된 ASP.NET Core 애플리케이션을 시작한다.

이 과정에서 웹 서버가 시작되고, 설정된 주소에서 HTTP 요청을 받을 수 있게 된다.

일반적인 서버 프로그램에서는 종료 요청을 받기 전까지 실행 상태를 유지한다.


10. 서버 실행 및 접속 테스트

서버를 실행

dotnet run --urls "http://localhost:5080"

정상적으로 실행되면 콘솔에 다음과 비슷한 로그가 출력된다.

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5080

info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

실제 로그의 세부 내용은 실행 환경에 따라 달라질 수 있다.

여기서 중요한 부분은 다음 문장이다.

Now listening on: http://localhost:5080

서버가 해당 주소에서 요청을 받을 준비를 마쳤다는 의미

이제 크롬과 같은 웹 브라우저를 열고 다음 주소로 접속한다.

http://localhost:5080/

정상적으로 실행되었다면 화면에 다음 문자열이 나타난다.

Hello World!

이 문자열이 출력되는 이유는 Program.cs에 다음 코드가 등록되어 있기 때문이다.

app.MapGet("/", () => "Hello World!");

11. 브라우저에서 접속할 때 실제 동작

브라우저에 http://localhost:5080/을 입력했을 때의 전체 흐름

[웹 브라우저]
      │
      │ HTTP GET /
      ▼
[localhost:5080]
      │
      ▼
[Kestrel 웹 서버]
      │
      ▼
[ASP.NET Core 애플리케이션]
      │
      ▼
[MapGet("/")에 등록된 코드]
      │
      │ "Hello World!" 반환
      ▼
[HTTP 응답]
      │
      ▼
[웹 브라우저에 출력]
  1. 브라우저가 서버에 HTTP GET 요청을 보낸다.
  2. Kestrel이 해당 요청을 수신한다.
  3. ASP.NET Core 애플리케이션이 요청을 처리할 경로를 찾는다.
  4. / 경로에 등록된 코드가 실행된다.
  5. Hello World! 문자열이 HTTP 응답으로 반환된다.
  6. 브라우저가 응답받은 문자열을 화면에 표시한다.

이렇게 간단한 예제를 통해 클라이언트와 서버가 요청과 응답을 주고받는 기본 흐름을 확인할 수 있다.


12. 서버 종료

실행 중인 서버를 종료하려면 서버가 실행되고 있는 터미널에서 다음 단축키를 누른다.

Ctrl + C

그러면 애플리케이션에 종료 신호가 전달되고 서버가 종료 절차를 진행한다.

서버가 종료되면 더 이상 해당 서버에서 HTTP 요청을 처리할 수 없다.


13. 최종 정리

 .NET과 ASP.NET Core의 기본 개념부터 웹 서버를 생성하고 실행하는 과정

 

사용한 주요 명령어는 다음과 같다.

1. SDK 버전 확인

dotnet --version

2. 프로젝트 생성

dotnet new web -n MergeGame.Lab.Server -o MergeGame.Lab.Server -f net10.0

3. 프로젝트 폴더 이동

cd MergeGame.Lab.Server

4. 서버 실행

dotnet run --urls "http://localhost:5080"

5. 브라우저 접속

http://localhost:5080/

6. 서버 종료

Ctrl + C

참고 자료

 

+ Recent posts