한 언어로
게임도 서버도
데스크탑도
C# 하나로 Unity 게임 · 게임 서버 · 웹 API · 윈도우 프로그램을 전부 만듭니다. 문법은 Java 와 사촌이라 Java 를 아신다면 절반은 이미 아는 것이고, 모르셔도 이 탭만 따라오면 됩니다. 여기까지 마치면 남의 C# 코드를 읽고 고치고 새로 짜는 일을 혼자 해낼 수 있습니다.
C# 을 실행하는 법
설치부터 Hello, World 까지 5분. 어디서 어떻게 도는지도 함께 봅니다.
SDK 하나면 끝납니다
# .NET SDK 설치 (Windows·macOS·Linux 공통)
# https://dotnet.microsoft.com/download
dotnet --version # 10.0.x 가 나오면 준비 끝
dotnet new console -o hello # 콘솔 프로젝트 만들기
cd hello
dotnet run # 빌드 + 실행
# 자주 쓰는 템플릿
dotnet new console # 콘솔 프로그램
dotnet new classlib # 라이브러리
dotnet new webapi # 웹 API
dotnet new xunit # 테스트 프로젝트
dotnet new sln # 솔루션(프로젝트 묶음)
// Program.cs — 최상위 문(top-level statements)
// Main 메서드를 적지 않아도 됩니다. 컴파일러가 만들어 줍니다.
Console.WriteLine("Hello, World!");
var name = Console.ReadLine();
Console.WriteLine($"반갑습니다, {name}님");
어디서 도는가 — 런타임 지도
<!-- hello.csproj — 프로젝트 파일이 곧 빌드 설정입니다 -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <!-- null 안전 켜기 -->
<ImplicitUsings>enable</ImplicitUsings><!-- 흔한 using 자동 -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
Project Settings 에서 바꿉니다.
변수 · 타입 · var
C# 은 정적 타입 언어입니다. 타입을 적는 순간 컴파일러가 실수를 대신 잡아 줍니다.
기본형과 var
int hp = 100; // 32비트 정수
long gold = 9_000_000_000; // 64비트 (밑줄로 자릿수 구분 가능)
float speed = 5.5f; // Unity 에서 가장 많이 쓰는 실수형
double ratio = 0.75; // 기본 실수형 (정밀도 ↑)
decimal price = 19_900m; // ★ 돈 계산은 무조건 decimal
bool alive = true;
char grade = 'S';
string name = "용사";
// var — '타입을 안 쓰는 것'이 아니라 '컴파일러가 추론'하는 것
var count = 10; // int 로 확정 (나중에 문자열 대입 불가)
var items = new List<string>();
// 상수
const int MaxLevel = 99; // 컴파일 시점 고정
readonly int seed; // (필드 전용) 생성자에서 한 번만 대입 가능
0.1 + 0.2 != 0.3 이기 때문입니다.
금액·수량 정산은 decimal,
게임의 좌표·속도처럼 오차가 허용되는 값은 float 이 정답입니다.
값 타입 vs 참조 타입 — 이 구분이 성능을 가릅니다
값 타입 (struct)
int · float · bool · Vector3- 스택에 놓이고 복사됩니다
- GC 부담이 없습니다 — 게임 루프에서 중요
참조 타입 (class)
string · 배열 · List · 내가 만든 class- 힙에 놓이고 주소가 복사됩니다
- 많이 만들면 GC 가 돌며 프레임이 튑니다
struct Point { public int X, Y; }
class Node { public int Value; }
var p1 = new Point { X = 1 };
var p2 = p1; p2.X = 99; // 복사본만 바뀐다
Console.WriteLine(p1.X); // 1
var n1 = new Node { Value = 1 };
var n2 = n1; n2.Value = 99; // 같은 객체를 가리킨다
Console.WriteLine(n1.Value); // 99 ← 함께 바뀐다
Vector3·Quaternion 은 struct 입니다.
그래서 transform.position.x = 1; 이 컴파일되지 않습니다 —
복사본을 고쳐 봐야 원본이 안 바뀌기 때문입니다.
transform.position = new Vector3(1, y, z); 처럼 통째로 넣어야 합니다.
문자열 · 보간 · StringBuilder
문자열은 바꿀 수 없습니다(immutable). 이 사실 하나가 성능 문제의 절반을 설명합니다.
쓰는 법
var name = "용사"; var lv = 12;
// ① 보간 문자열 — 기본으로 이걸 쓰세요
var msg = $"{name}님 Lv.{lv}";
var pct = $"{0.4567:P1}"; // 45.7%
var pad = $"{lv,4}"; // 오른쪽 정렬 폭 4
// ② 축약 문자열 — 경로·정규식에 (역슬래시를 그대로)
var path = @"C:\Games\Save\slot1.dat";
// ③ 원시 문자열 리터럴 (C# 11+) — JSON·SQL 을 그대로
var json = """
{ "id": 1, "name": "칼" }
""";
// 비교 — 대소문자·문화권을 명시하는 습관
if (string.Equals(a, b, StringComparison.OrdinalIgnoreCase)) { }
// 비어 있음 검사
if (string.IsNullOrWhiteSpace(input)) return;
반복해서 이어 붙일 때
루프 안에서 +=
var s = "";
for (int i = 0; i < 10_000; i++)
s += i; // 매번 새 문자열 생성
// 문자열 1만 개가 쓰레기로 쌓인다
StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 10_000; i++)
sb.Append(i);
var s = sb.ToString(); // 마지막에 한 번
Update() 안에서 문자열을 만들면 프레임이 튑니다.
scoreText.text = $"점수 {score}"; 를 매 프레임 돌리면
초당 60개의 쓰레기가 생깁니다.
값이 바뀔 때만 갱신하세요 — 게임 최적화의 첫 단추입니다.
string.Join(", ", items),
자르기는 Split 보다 AsSpan() 이 빠릅니다
(🧬 C# 고급 탭 Span 참고).
이어 붙이는 방법 — 상황별로 답이 다릅니다
| 상황 | 쓸 것 | 이유 |
|---|---|---|
| 몇 개를 한 줄에 | $"{a} {b}" | 컴파일러가 알아서 최적화합니다 — 가장 읽기 좋습니다 |
| 목록을 구분자로 | string.Join(", ", items) | 전체 길이를 미리 계산해 한 번에 만듭니다 |
| 반복문에서 누적 | StringBuilder | 중간 결과를 안 만듭니다 |
| 조건에 따라 조금씩 | StringBuilder | — |
| 고정 길이·초당 수천 번 | string.Create | 버퍼에 직접 씁니다 — 할당 1회 |
| 여러 줄 템플릿 | """원시 문자열""" | 이스케이프 없이 그대로 |
루프에서 += | 쓰지 마세요 | N번 반복에 N개의 문자열이 쓰레기로 |
비교와 검색 — 문화권이 결과를 바꿉니다
// ★ 비교는 항상 방식을 명시하세요. 기본값이 상황마다 다릅니다.
string.Equals(a, b, StringComparison.Ordinal); // 바이트 그대로 — 가장 빠름
string.Equals(a, b, StringComparison.OrdinalIgnoreCase); // ID·파일명·토큰 비교
// 사용자에게 보여줄 정렬은 문화권을 따릅니다
names.Sort(StringComparer.CurrentCulture); // 한국어 가나다순
Array.Sort(ids, StringComparer.Ordinal); // 내부 키 정렬
// ✗ 이렇게 쓰면 터키어 환경에서 "I".ToLower() 가 ı(점 없는 i)가 됩니다
if (input.ToLower() == "id") { }
// ✓
if (input.Equals("id", StringComparison.OrdinalIgnoreCase)) { }
// 딕셔너리 키도 비교자를 지정할 수 있습니다
var lookup = new Dictionary<string, Item>(StringComparer.OrdinalIgnoreCase);
// 시작·포함 검사
path.StartsWith("http", StringComparison.OrdinalIgnoreCase);
text.Contains("검", StringComparison.CurrentCulture);
// 여러 값 중 하나인지
if (cmd is "help" or "?" or "h") { } // 패턴 매칭이 읽기 좋습니다
"ID".ToLower() 가 "ıd" 가 되어
설정 키를 못 찾는 버그가 대표적입니다.
사람에게 보여줄 것은 문화권, 프로그램이 판단할 것은 Ordinal —
이 한 줄만 지키면 이 종류의 버그가 통째로 사라집니다.
숫자·날짜 서식 — 자주 쓰는 것만
var gold = 1234567; var rate = 0.4567; var dt = DateTime.Now;
$"{gold:N0}" // 1,234,567 천 단위 구분
$"{gold:C}" // ₩1,234,567 통화(현재 문화권)
$"{rate:P1}" // 45.7% 백분율
$"{rate:F2}" // 0.46 소수점 2자리
$"{gold:X}" // 12D687 16진수
$"{42:D5}" // 00042 자릿수 채우기
$"{dt:yyyy-MM-dd}" // 2026-08-07
$"{dt:HH:mm:ss}" // 14:30:22
$"{dt:yyyyMMdd_HHmmss}" // 파일명에
$"{dt:O}" // ISO 8601 — ★ 저장·전송에는 항상 이것
// 정렬 폭 지정 — 콘솔 표 만들 때
foreach (var p in players)
Console.WriteLine($"{p.Name,-12}{p.Level,4}{p.Gold,12:N0}");
// 왼쪽정렬 12 오른쪽 4 오른쪽 12
// ★ 저장·통신에는 문화권 무관 형식을 쓰세요
var s = value.ToString(CultureInfo.InvariantCulture);
// 독일 등에서는 소수점이 쉼표(1,5)라 파싱이 깨집니다
// 파싱도 마찬가지
decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var v);
{dt:O}(ISO 8601) 로 저장하면
어느 나라에서 읽어도 같은 값이 됩니다.
조건 · 반복 · switch 식
C# 의 switch 는 값을 돌려주는 식입니다. 여기부터 코드가 짧아집니다.
반복문 네 가지
for (int i = 0; i < 10; i++) { }
foreach (var e in enemies) { } // 가장 많이 씁니다
while (hp > 0) { }
do { } while (retry);
// 인덱스가 함께 필요하면
foreach (var (item, i) in items.Select((x, i) => (x, i))) { }
// 범위·인덱스 문법 (C# 8+)
var last = arr[^1]; // 마지막
var first3 = arr[..3]; // 앞 3개
var mid = arr[2..5]; // 2~4
foreach 중에 컬렉션을 고치면 예외가 납니다.
지울 대상은 따로 모았다가 루프 밖에서 지우거나,
for 문을 뒤에서 앞으로 돌리세요.
게임에서 "죽은 적 제거" 코드가 여기서 자주 터집니다.
switch 식 · 패턴 매칭
// 값을 돌려주는 switch — if/else 사슬이 사라집니다
string Grade(int score) => score switch
{
>= 90 => "S",
>= 80 => "A",
>= 70 => "B",
_ => "F", // _ 는 나머지 전부
};
// 타입으로 분기
decimal Damage(IWeapon w) => w switch
{
Sword s when s.IsCritical => s.Power * 2,
Sword s => s.Power,
Bow { Arrows: 0 } => 0, // 속성 패턴
Bow b => b.Power * 0.8m,
null => throw new ArgumentNullException(nameof(w)),
_ => 1,
};
// 튜플 패턴 — 가위바위보
string Result(int me, int you) => (me, you) switch
{
(0, 2) or (1, 0) or (2, 1) => "승",
var (a, b) when a == b => "무",
_ => "패",
};
_ 없이도 됩니다.
enum 을 switch 할 때 _ 를 빼 두면,
나중에 enum 에 값을 추가했을 때 컴파일러가 경고해 줍니다 —
빠뜨린 분기를 사람이 찾지 않아도 됩니다.
클래스 · 구조체 · record
셋 다 "데이터 묶음"이지만 쓰는 자리가 다릅니다. 고르는 기준을 정리합니다.
클래스 — 기본 선택지
public class Player
{
// 자동 속성
public string Name { get; } // 읽기 전용
public int Hp { get; private set; } // 밖에서는 못 바꿈
public int Level { get; set; } = 1; // 기본값
// 생성자 — 튜플로 한 줄 대입 (주 생성자 문법은 class Player(string name, int hp), C# 12+)
public Player(string name, int hp) => (Name, Hp) = (name, hp);
public void TakeDamage(int dmg)
{
Hp = Math.Max(0, Hp - dmg);
if (Hp == 0) Died?.Invoke(this);
}
public event Action<Player>? Died; // 이벤트 (c09 에서)
public override string ToString() => $"{Name}(Lv.{Level})";
}
record 와 struct 는 언제
// record — '값으로 비교되는' 데이터 묶음 (DTO·설정·이벤트)
public record ItemDto(int Id, string Name, int Price);
var a = new ItemDto(1, "칼", 100);
var b = new ItemDto(1, "칼", 100);
Console.WriteLine(a == b); // True ← 내용이 같으면 같다
var c = a with { Price = 200 }; // 일부만 바꾼 복사본
// struct — 작고 많이 만들어지는 값 (좌표·색·ID)
public readonly record struct Tile(int X, int Y); // 값 타입 record
| 고르는 기준 | 추천 |
|---|---|
| 상태가 바뀌고 동작이 있다 | class |
| 내용이 같으면 같은 것으로 보고 싶다 | record |
| 16바이트 이하 · 초당 수천 개 생성 | readonly struct |
| Unity 컴포넌트 | class(MonoBehaviour) |
in 매개변수로 복사를 막으세요.
상속 · 인터페이스
게임 코드가 커질 때 가장 먼저 무너지는 곳입니다. 상속은 얕게, 인터페이스는 작게.
문법
public abstract class Enemy // 추상 — 직접 생성 불가
{
public abstract int Hp { get; } // 자식이 반드시 구현
public virtual void OnSpawn() { } // 자식이 바꿔도 되고 안 바꿔도 됨
protected void Log(string m) => Console.WriteLine(m);
}
public sealed class Slime : Enemy // sealed — 더는 상속 못 함(성능↑)
{
public override int Hp => 30;
public override void OnSpawn()
{
base.OnSpawn(); // 부모 것도 실행
Log("슬라임 등장");
}
}
public interface IDamageable
{
void TakeDamage(int amount);
bool IsAlive => true; // 기본 구현 (C# 8+)
}
게임에서는 상속보다 조합
상속으로 밀어붙이면
Enemy
└ FlyingEnemy
└ FlyingShootingEnemy
└ FlyingShootingBossEnemy
"날면서 폭발하는 보스"는 어디에?
기능을 부품으로
class Enemy {
IMovement move; // 걷기·날기
IAttack attack; // 근접·원거리
IDeath death; // 폭발·드롭
}
인터페이스로 프로그래밍한다 — 실전 감각
// "무엇이든 맞을 수 있는 것"을 향해 코드를 짭니다
void Explode(Vector3 center, float radius, int dmg)
{
foreach (var hit in FindInRadius(center, radius))
if (hit is IDamageable target) // 적·상자·벽 무엇이든
target.TakeDamage(dmg);
}
// 새 오브젝트(드럼통)를 추가할 때
public class Barrel : IDamageable // 인터페이스만 구현하면
{
public void TakeDamage(int dmg) => ExplodeChain();
}
// ★ Explode() 는 한 글자도 고치지 않습니다 —
// "확장에는 열려 있고 수정에는 닫혀 있다"의 실제 모습
언제 abstract, 언제 interface
| 질문 | 답 |
|---|---|
| 공유할 코드·상태가 있다 | abstract class — 절반 만든 부모 |
| 약속(시그니처)만 필요하다 | interface — 계약서 |
| 여러 능력을 동시에 가져야 한다 | interface — 클래스 상속은 1개뿐 |
| 망설여진다 | interface — 나중에 바꾸기 쉬운 쪽 |
IDamageable(맞을 수 있는) · IInteractable(상호작용 가능한) ·
ISaveable(저장되는). "~할 수 있는" 으로 읽히면 잘 지은 것입니다.
컬렉션 완전정복
무엇을 고르느냐가 곧 성능입니다. 넣기·찾기·순회 중 무엇이 잦은지로 결정합니다.
기본 네 가지
// List — 순서가 있고 인덱스로 접근 (가장 많이 씁니다)
var enemies = new List<Enemy>(capacity: 64); // 크기를 알면 미리 잡기
enemies.Add(e); enemies.RemoveAt(0);
// Dictionary — 키로 즉시 찾기 O(1)
var byId = new Dictionary<int, Item>();
if (byId.TryGetValue(id, out var item)) { } // ★ 있으면 꺼내기
// HashSet — 중복 없이 담고 '있는지'만 확인
var visited = new HashSet<int>();
if (!visited.Add(nodeId)) return; // 이미 있었다면 false
// Queue / Stack — 순서 규칙
var q = new Queue<Job>(); q.Enqueue(j); q.Dequeue();
var s = new Stack<Node>(); s.Push(n); s.Pop();
ContainsKey 뒤에 [key] 로 또 꺼내지 마세요.
해시 계산을 두 번 합니다. TryGetValue 한 번이면 됩니다.
고르는 표 · 흔한 실수
| 하려는 일 | 고를 것 | 비고 |
|---|---|---|
| 순서대로 담고 순회 | List | 가장 무난 |
| ID 로 즉시 찾기 | Dictionary | O(1) |
| 중복 제거 · 포함 검사 | HashSet | O(1) |
| 먼저 온 것부터 처리 | Queue | 작업 큐 |
| 여러 스레드가 함께 | Concurrent* | 락 내장 |
| 바뀌지 않는 목록 | Immutable* · 배열 | 안전 |
List.Remove 는 O(n) 입니다.
매 프레임 수백 개를 지우면 그것만으로 느려집니다.
게임에서는 지우는 대신 비활성화하고 재사용하세요 —
오브젝트 풀링(🕹️ Unity 실전 탭)의 출발점입니다.
new List<T>(1000) 처럼 예상 크기를 넘기는 것만으로
내부 배열 복사가 없어져 GC 압력이 눈에 띄게 줄어듭니다.
제네릭 · null 안전
제네릭은 타입을 매개변수로 받는 것, null 안전은 NullReferenceException 을 컴파일 시점으로 옮기는 것입니다.
제네릭
// 타입만 다르고 로직이 같은 코드를 하나로
public class Pool<T> where T : class, new()
{
private readonly Stack<T> _idle = new();
public T Rent() => _idle.Count > 0 ? _idle.Pop() : new T();
public void Return(T item) => _idle.Push(item);
}
var bullets = new Pool<Bullet>();
// 제약(where) 종류
// where T : class 참조 타입만
// where T : struct 값 타입만
// where T : new() 기본 생성자 있어야
// where T : IComparable<T> 인터페이스 구현
// where T : Enemy 특정 클래스의 자식만
nullable 참조 형식 — 켜 두세요
// csproj 에 <Nullable>enable</Nullable> 을 넣으면
string name = null; // ⚠️ 경고 — null 이 들어갈 수 없는 타입
string? maybe = null; // OK — null 이 될 수 있다고 표시
void Print(string? s)
{
// Console.WriteLine(s.Length); ⚠️ 경고 — null 일 수 있음
if (s is null) return;
Console.WriteLine(s.Length); // 여기서는 안전(컴파일러가 압니다)
}
// 유용한 연산자
var len = s?.Length ?? 0; // null 이면 0
var item = dict.GetValueOrDefault(id);
ArgumentNullException.ThrowIfNull(weapon); // 인자 검사 한 줄
!(null 아님 단언)은 최후 수단입니다.
obj!.Value 는 "내가 책임진다"는 뜻일 뿐
런타임에는 아무 보호도 없습니다.
Unity 의 GetComponent 처럼 null 이 올 수 있는 API 앞에서는
반드시 검사하세요 — 게임 크래시 1위 원인입니다.
null 을 다루는 다섯 가지 방법
| 쓰는 것 | 뜻 | 언제 |
|---|---|---|
ArgumentNullException.ThrowIfNull(x) | null 이면 즉시 예외 | 공개 메서드의 인자 검사 — 기본으로 이것 |
x?.Foo() | null 이면 건너뜀 | 선택적 값 · 이벤트 발행 |
x ?? 기본값 | null 이면 대체 | 기본값이 명확할 때 |
x ??= 값 | null 일 때만 대입 | 지연 초기화 |
x! | “null 아님”이라고 컴파일러에 단언 | 최후 수단 — 런타임 보호가 전혀 없습니다 |
경고를 없애는 올바른 방법
// ✗ ! 로 경고만 지우면 런타임에 그대로 터집니다
var weapon = inventory.Find(x => x.Id == id)!;
weapon.Use(); // 못 찾으면 NullReferenceException
// ✓ 못 찾은 경우를 코드로 다룹니다
if (inventory.Find(x => x.Id == id) is not { } weapon)
return Result.NotFound;
weapon.Use(); // 여기서는 컴파일러도 압니다
// 필수 값은 required 로 — 생성자를 안 써도 강제됩니다 (C# 11+)
public class Player
{
public required string Name { get; init; }
public required int Level { get; init; }
public string? Guild { get; init; } // 선택
}
var p = new Player { Name = "용사", Level = 1 }; // Name 을 빼면 컴파일 오류
// 나중에 채워지는 속성 — 프레임워크가 넣어 주는 경우 (Blazor 속성 주입)
[Inject] public ILogger<Home> Log { get; set; } = null!; // 여기서는 !가 정당합니다
// (프레임워크가 반드시 채운다는 계약)
// 패턴 매칭으로 한 번에
if (dict.TryGetValue(key, out var v) && v is { Count: > 0 })
{
Process(v);
}
is not { } x 패턴이 가장 실용적입니다.
null 검사와 변수 선언을 동시에 하면서,
아래 코드에서는 컴파일러가 null 이 아님을 알게 됩니다.
if (x != null) 보다 짧고, ! 를 쓸 이유를 없앱니다.
제네릭 제약 — 무엇을 언제 거나
| 제약 | 얻는 것 |
|---|---|
where T : class | null 비교 · 참조 비교 가능 |
where T : struct | 박싱 없음 · T? 가 Nullable<T> |
where T : notnull | 딕셔너리 키로 안전 |
where T : new() | new T() 호출 가능 |
where T : IComparable<T> | 박싱 없이 인터페이스 메서드 호출 |
where T : Enemy | 그 타입의 멤버 사용 |
where T : INumber<T> | 숫자 연산 — .NET 7+ 제네릭 수학 |
// 제약이 없으면 아무것도 못 합니다 — object 수준
static T Max<T>(T a, T b) => a > b ? a : b; // ✗ 컴파일 오류
// 인터페이스 제약 — 박싱 없이 호출됩니다
static T Max<T>(T a, T b) where T : IComparable<T>
=> a.CompareTo(b) >= 0 ? a : b; // ✓
// 제네릭 수학 (.NET 7+) — int·float·decimal 을 하나로
static T Sum<T>(ReadOnlySpan<T> xs) where T : INumber<T>
{
var acc = T.Zero;
foreach (var x in xs) acc += x;
return acc;
}
Sum<int>([1, 2, 3]); // 6
Sum<double>([1.5, 2.5]); // 4.0
// 게임에서 자주 쓰는 형태 — 컴포넌트 풀
public sealed class Pool<T> where T : Component
{
private readonly Stack<T> _idle = new();
private readonly T _prefab;
public T Rent()
{
var x = _idle.Count > 0 ? _idle.Pop() : Object.Instantiate(_prefab);
x.gameObject.SetActive(true);
return x;
}
public void Return(T x)
{
x.gameObject.SetActive(false);
_idle.Push(x);
}
}
T 를 object 로 다루면 박싱이 일어납니다.
where T : IComparable<T> 처럼 제네릭 인터페이스로 제약하면
박싱 없이 직접 호출됩니다.
값 타입을 수만 개 다루는 게임 코드에서는
이 차이가 GC 부담으로 그대로 나타납니다 —
🧬 고급 탭의 메모리 배치 절에서 자세히 다룹니다.
델리게이트 · 이벤트 · 람다
"어떤 일이 생기면 이걸 실행해 줘"를 코드로 넘기는 방법. 게임 로직의 접착제입니다.
함수를 값처럼 넘긴다
// 미리 준비된 델리게이트 타입만 알면 충분합니다
Action onStart; // 인자 없음, 반환 없음
Action<int> onDamage; // int 를 받음
Func<int, bool> isDead; // int 받고 bool 반환
Predicate<Enemy> isBoss; // bool 반환 전용
// 람다로 바로 넘기기
enemies.RemoveAll(e => e.Hp <= 0);
var sorted = enemies.OrderBy(e => e.Distance).ToList();
// 여러 줄 람다
Action<Enemy> kill = e =>
{
e.PlayDeathAnim();
Score += e.Reward;
};
이벤트 — 구독하고 해지하기
public class Health
{
public event Action<int>? Changed; // 밖에서는 += / -= 만 가능
private int _hp;
public int Hp
{
get => _hp;
set { _hp = value; Changed?.Invoke(value); } // ?. 로 null 안전
}
}
// 구독
health.Changed += UpdateHpBar;
// ★ 반드시 해지 — 안 하면 객체가 살아남아 메모리가 샙니다
health.Changed -= UpdateHpBar;
OnEnable 에서 구독하고
OnDisable 에서 반드시 해지하세요 — 짝을 맞추는 것이 규칙입니다.
LINQ 입문
컬렉션을 SQL 처럼 다루는 문법. 열 줄짜리 for 문이 한 줄이 됩니다.
자주 쓰는 것부터
using System.Linq;
var alive = enemies.Where(e => e.Hp > 0); // 거르기
var names = enemies.Select(e => e.Name); // 변환
var nearest = enemies.OrderBy(e => e.Distance).First();// 정렬 후 첫 개
var total = items.Sum(i => i.Price); // 합계
var byType = items.GroupBy(i => i.Type); // 묶기
var any = enemies.Any(e => e.IsBoss); // 하나라도?
var all = party.All(p => p.IsAlive); // 전부?
var top3 = scores.OrderByDescending(s => s).Take(3);
// 없을 수도 있으면 ...OrDefault
var boss = enemies.FirstOrDefault(e => e.IsBoss);
if (boss is null) return;
// 최종적으로 목록이 필요하면 ToList / ToArray
var list = alive.ToList();
지연 실행 — 이것만 알면 함정을 피합니다
var q = enemies.Where(e => e.Hp > 0); // ← 아직 아무 일도 안 일어남
enemies.Add(new Enemy { Hp = 50 });
foreach (var e in q) { } // ← 여기서 비로소 실행 (새 적 포함)
// 두 번 순회하면 두 번 계산됩니다
var slow = enemies.Where(Expensive);
var a = slow.Count(); // 1회 계산
var b = slow.ToList(); // 또 계산 ← 낭비
var cached = enemies.Where(Expensive).ToList(); // 한 번만
Update() 안에서 LINQ 를 쓰지 마세요.
Where·OrderBy 는 내부적으로 객체를 만들어
매 프레임 쓰레기를 남깁니다.
초기화 때 한 번이나 이벤트가 있을 때만 쓰고,
루프 안에서는 평범한 for 문이 정답입니다.
읽는 훈련 — 실무 LINQ 는 이렇게 생겼습니다
// "부서별 평균 급여 상위 3개 부서" — SQL 로 치면 GROUP BY + ORDER BY + LIMIT
var top = employees
.Where(e => e.IsActive) // ① 재직자만
.GroupBy(e => e.Dept) // ② 부서로 묶고
.Select(g => new // ③ 그룹마다 요약을 만들고
{
Dept = g.Key,
Avg = g.Average(e => e.Salary),
Count = g.Count(),
})
.Where(x => x.Count >= 3) // ④ 3명 이상 부서만
.OrderByDescending(x => x.Avg) // ⑤ 평균 높은 순
.Take(3) // ⑥ 상위 3개
.ToList(); // ⑦ 여기서 비로소 실행
// 위에서 아래로 "거르고 → 묶고 → 요약하고 → 정렬하고 → 자른다"
// 같은 일을 for 문으로 짜면 30줄 + 중간 Dictionary 두 개가 필요합니다
| SQL | LINQ |
|---|---|
WHERE | Where |
SELECT 컬럼 | Select |
GROUP BY | GroupBy |
ORDER BY … DESC | OrderByDescending |
LIMIT n | Take(n) |
JOIN | Join — 또는 Dictionary 로 O(1) 매칭 |
예외 처리 · 파일 · JSON
터질 곳을 정해 두고, 저장·불러오기를 안전하게 만듭니다.
예외 — 잡을 것만 잡는다
try
{
var text = File.ReadAllText(path);
}
catch (FileNotFoundException) // 구체적인 것부터
{
return Save.Default; // 첫 실행이면 기본값
}
catch (IOException ex)
{
log.Error(ex, "세이브 읽기 실패 {Path}", path);
throw; // ★ throw ex; 아님 (스택 추적 보존)
}
finally
{
stream?.Dispose();
}
// using — 끝나면 자동으로 Dispose
using var fs = File.OpenRead(path);
// 던지기
if (level < 1) throw new ArgumentOutOfRangeException(nameof(level));
catch (Exception) { } 로 전부 삼키지 마세요.
원인을 알 수 없는 버그가 되어 돌아옵니다.
여기서 무엇을 할 수 있는가를 답할 수 있을 때만 잡고,
아니면 위로 흘려보내세요.
파일 · JSON 저장
using System.Text.Json;
public record SaveData(int Level, int Gold, string[] Items)
{
public static readonly SaveData Default = new(1, 0, []); // 첫 실행용 기본값
}
var opt = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
// 저장 — ★ 임시 파일에 쓰고 바꿔치기 (쓰는 도중 꺼져도 원본이 남는다)
var tmp = path + ".tmp";
await File.WriteAllTextAsync(tmp, JsonSerializer.Serialize(data, opt));
File.Move(tmp, path, overwrite: true);
// 불러오기
var json = await File.ReadAllTextAsync(path);
var save = JsonSerializer.Deserialize<SaveData>(json, opt)
?? SaveData.Default;
Path.Combine(dir, "save.json") 을 쓰면 OS 차이를 신경 쓸 필요가 없습니다.
Unity 라면 저장 위치는 반드시 Application.persistentDataPath —
설치 폴더에 쓰면 권한 오류가 납니다.
이 예외를 만나면 — 원인 1순위
| 예외 | 원인 1순위 | 고치는 법 |
|---|---|---|
NullReferenceException | 초기화 전 접근 · Unity 인스펙터 미할당 | Nullable 켜기 · is not { } 검사 |
IndexOutOfRangeException | 배열 경계 — <= 를 < 로 | ^1·범위 연산자 사용 |
InvalidOperationExceptionCollection was modified | foreach 도중 컬렉션 변경 | ToList() 로 복사 후 순회 · 역순 for |
ObjectDisposedException | using 블록 밖에서 사용 | 수명을 다시 설계 |
FormatException | int.Parse 에 빈 값·문자 | TryParse 로 바꾸기 |
JsonException | 필드 이름·타입 불일치 | 네이밍 정책 확인 · [JsonPropertyName] |
UnauthorizedAccessException | 쓰기 권한 없는 경로 | LocalApplicationData · persistentDataPath |
IOException파일이 사용 중 | 다른 프로그램이 열어 둠 | 사용자에게 “파일을 닫아 주세요” 안내 |
예외를 던질 곳과 안 던질 곳
// ✗ 흔한 상황을 예외로 다루면 느리고 코드가 지저분해집니다
int n;
try { n = int.Parse(input); }
catch (FormatException) { n = 0; }
// ✓ 실패가 정상 흐름이면 Try 패턴
if (!int.TryParse(input, out var n)) n = 0;
// ✓ 결과 타입으로 명시하는 방법도 있습니다
public readonly record struct Result<T>(bool Ok, T? Value, string? Error)
{
public static Result<T> Success(T v) => new(true, v, null);
public static Result<T> Fail(string e) => new(false, default, e);
}
public Result<Player> Login(string id, string pw)
{
if (Find(id) is not { } p) return Result<Player>.Fail("없는 계정");
if (!p.Verify(pw)) return Result<Player>.Fail("비밀번호 불일치");
if (p.Banned) return Result<Player>.Fail("정지된 계정");
return Result<Player>.Success(p);
}
// ✓ 예외는 "일어나면 안 되는 일"에 — 프로그래머의 실수·복구 불가
if (config is null)
throw new InvalidOperationException("설정을 먼저 초기화하세요");
// 커스텀 예외는 정보를 담아서
public sealed class NotEnoughGoldException(long need, long have)
: Exception($"골드 부족: 필요 {need:N0} 보유 {have:N0}")
{
public long Need { get; } = need;
public long Have { get; } = have;
}
try/catch 가 코드 곳곳에 퍼지지 않습니다.
저장이 깨지지 않게 — 실전 패턴
public sealed class SaveStore(string dir, ILogger<SaveStore> log)
{
private readonly JsonSerializerOptions _opt = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
public async Task SaveAsync(SaveData data, CancellationToken ct = default)
{
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "save.json");
var tmp = path + ".tmp";
var bak = path + ".bak";
// ① 임시 파일에 먼저 씁니다
await using (var fs = File.Create(tmp))
await JsonSerializer.SerializeAsync(fs, data, _opt, ct);
// ② 원자적으로 바꿔치기 — 쓰는 도중 전원이 꺼져도 원본은 무사합니다
if (File.Exists(path)) File.Replace(tmp, path, bak);
else File.Move(tmp, path);
}
public async Task<SaveData> LoadAsync(CancellationToken ct = default)
{
foreach (var name in new[] { "save.json", "save.json.bak" })
{
var p = Path.Combine(dir, name);
if (!File.Exists(p)) continue;
try
{
await using var fs = File.OpenRead(p);
if (await JsonSerializer.DeserializeAsync<SaveData>(fs, _opt, ct)
is { } d) return d;
}
catch (JsonException ex)
{
log.LogWarning(ex, "세이브 손상 {File} — 백업을 시도합니다", name);
}
}
return SaveData.Default; // 전부 실패하면 새로 시작
}
}
치트시트 · 다음 단계
막힐 때 찾아보는 문법 색인과, 무엇을 만들지에 따른 학습 경로입니다.
자주 잊는 문법
| 하려는 일 | 이렇게 |
|---|---|
| null 이면 기본값 | x ?? 0 |
| null 이면 대입 | x ??= new() |
| null 이면 건너뛰기 | obj?.Method() |
| 타입 검사 + 변환 | if (o is Enemy e) |
| 범위 검사 | if (n is >= 1 and <= 9) |
| 여러 값 반환 | (int, string) Get() |
| 문자열 → 숫자 | int.TryParse(s, out var n) |
| 정수 나눗셈 주의 | 5 / 2 == 2 · 5f / 2 |
| 배열 복사 | arr.ToArray() · Array.Copy |
| 열거형 전체 | Enum.GetValues<T>() |
다음에 무엇을 볼 것인가
튜플 · 해체 · 다중 반환
"값 두 개를 돌려주고 싶다"에 클래스를 만들 필요가 없습니다. 튜플 한 줄이면 됩니다.
튜플 기본 — 이름을 붙이세요
// 이름 없는 튜플 — Item1·Item2 는 나중에 읽기 어렵습니다
(int, string) a = (1, "칼");
Console.WriteLine(a.Item1); // 1 ← 무엇의 1인지 알 수 없음
// ★ 이름 붙은 튜플 — 실무 기본형
(int Id, string Name) item = (1, "칼");
Console.WriteLine(item.Name); // "칼"
// var 로 받을 때는 오른쪽에 이름을
var pos = (X: 10, Y: 20);
var len = Math.Sqrt(pos.X * pos.X + pos.Y * pos.Y);
// 두 변수 맞바꾸기 — 임시 변수가 사라집니다
(min, max) = (max, min);
// 딕셔너리 순회 — KeyValuePair 를 바로 해체
foreach (var (id, player) in playersById)
Console.WriteLine($"{id}: {player.Name}");
다중 반환 — out 매개변수의 자리를 대신합니다
// 결과 + 성공 여부를 함께 — out 보다 읽기 좋습니다
(bool Ok, Player? Player, string? Error) TryLogin(string id, string pw)
{
if (Find(id) is not { } p) return (false, null, "없는 계정");
if (!p.Verify(pw)) return (false, null, "비밀번호 불일치");
return (true, p, null);
}
// 받는 쪽 — 해체(deconstruction)로 바로 풀어 씁니다
var (ok, player, error) = TryLogin(id, pw);
if (!ok) { Show(error); return; }
// 일부만 필요하면 _ (버림)
var (_, me, _) = TryLogin(id, pw);
// 통계처럼 여러 값을 한 번의 순회로
(int Min, int Max, double Avg) Stats(IReadOnlyList<int> xs)
{
int min = int.MaxValue, max = int.MinValue; long sum = 0;
foreach (var x in xs) { min = Math.Min(min, x); max = Math.Max(max, x); sum += x; }
return (min, max, (double)sum / xs.Count);
}
var (lo, hi, avg) = Stats(scores);
내 타입도 해체되게 — Deconstruct
public class Player
{
public string Name { get; init; } = "";
public int Level { get; init; }
// 이 메서드 하나면 해체 문법을 쓸 수 있습니다
public void Deconstruct(out string name, out int level)
=> (name, level) = (Name, Level);
}
var (name, level) = player; // 바로 풀립니다
// record 는 주 생성자가 Deconstruct 를 자동 생성합니다
public record ItemDto(int Id, string Name, int Price);
var (id, nm, price) = dto;
// switch 식과 조합 — 튜플 패턴 (c04 에서 본 것의 실전형)
string Judge(Player p) => (p.Level, p.IsVip) switch
{
( >= 50, true) => "VIP 라운지 입장",
( >= 50, false) => "일반 라운지 입장",
(_, true) => "VIP 대기열",
_ => "일반 대기열",
};
튜플로 충분한가, record 로 가야 하는가
| 상황 | 고를 것 |
|---|---|
| 한 메서드 안 · 반환 한 번에 쓰고 끝 | 튜플 — 타입 선언 0줄 |
| 같은 묶음이 여러 곳에서 반복 | record — 이름이 곧 문서 |
| 공개 API · DTO · 저장 형식 | record — 필드 추가·직렬화에 안전 |
| 요소가 4개 이상 | record — 튜플은 여기부터 읽기 힘듭니다 |
(int, string, int, bool, DateTime) 를 받는 쪽은
순서를 외워야 하고, 요소를 하나 추가하면 호출부가 전부 깨집니다.
내부에서는 튜플, 경계에서는 record — 이 한 줄이 기준입니다.
(int id, string name) 과 (int Id, string Name) 은
같은 타입입니다 — 이름은 컴파일러 힌트일 뿐, 타입의 일부가 아니기 때문입니다.
확장 메서드 — LINQ 의 비밀
남의 타입에 내 메서드를 붙인 것처럼 보이게 하는 문법. LINQ 전체가 이것으로 만들어져 있습니다.
만드는 법 — static 클래스 + this 매개변수
// 규칙: static 클래스 안의 static 메서드,
// 첫 매개변수에 this — 그게 전부입니다
public static class StringExtensions
{
public static bool IsBlank(this string? s)
=> string.IsNullOrWhiteSpace(s);
public static string Truncate(this string s, int max)
=> s.Length <= max ? s : s[..max] + "…";
}
// 쓰는 쪽 — 원래 있던 메서드처럼 보입니다
if (input.IsBlank()) return;
title = title.Truncate(20);
// 실체는 그냥 static 호출입니다 — 컴파일러가 바꿔 줄 뿐
StringExtensions.Truncate(title, 20); // 완전히 같은 코드
// 게임에서 자주 쓰는 형태 — Unity Vector3 는 struct 라
// x 만 바꿀 수 없는데, 확장 메서드로 관용구를 만듭니다
public static class VectorExtensions
{
public static Vector3 WithY(this Vector3 v, float y)
=> new(v.x, y, v.z);
}
transform.position = target.position.WithY(0); // 높이 무시하고 추적
s.IsBlank() 는 s 가 null 이어도
NullReferenceException 이 나지 않습니다 — 실체가 static 호출이라
null 이 그냥 인자로 전달되기 때문입니다. 위의 IsBlank 처럼
null 검사를 안에 품은 확장이 관용구인 이유입니다.
LINQ 의 정체 — 전부 확장 메서드입니다
// c10 에서 쓴 Where 의 실제 선언 (System.Linq.Enumerable)
public static IEnumerable<T> Where<T>(
this IEnumerable<T> source, // ← this: 모든 컬렉션에 붙습니다
Func<T, bool> predicate) { ... }
// 그래서 List·배열·Dictionary·yield 결과 무엇이든
// using System.Linq; 한 줄로 같은 메서드가 생깁니다
enemies.Where(e => e.Hp > 0);
"hello".Where(char.IsUpper); // string 도 IEnumerable<char>
// 내 프로젝트의 공통 질의도 같은 방식으로 쌓습니다
public static class QueryExtensions
{
public static IEnumerable<Enemy> Alive(this IEnumerable<Enemy> xs)
=> xs.Where(e => e.Hp > 0);
public static IEnumerable<Enemy> Near(
this IEnumerable<Enemy> xs, Vector3 pos, float r)
=> xs.Where(e => (e.Position - pos).sqrMagnitude < r * r);
}
// 체인이 문장처럼 읽힙니다
var target = enemies.Alive().Near(player.Position, 10f)
.OrderBy(e => e.Hp).FirstOrDefault();
남용하면 이렇게 됩니다
아무 데나 붙이기
// object 확장 — 모든 타입의
// 자동완성을 오염시킵니다
public static void Log(this object o)
=> Console.WriteLine(o);
// 상태를 바꾸는 확장 — 원본이
// 바뀌는지 아닌지 읽어서 알 수 없음
list.SortAndTrim(); // ???
좁고 순수하게
// 구체 타입에만, 값을 돌려주는
// 순수 함수로
public static string ToWon(this int n)
=> $"{n:N0}원";
// 이름에서 동작이 읽히게
var s = gold.ToWon(); // "1,234원"
언제 확장, 언제 다른 것
| 상황 | 답 |
|---|---|
| 내가 못 고치는 타입(string·Vector3)에 관용구 추가 | 확장 메서드 — 원래 용도 |
| 내 클래스에 기능 추가 | 그냥 메서드 — 확장으로 뺄 이유 없음 |
| 인터페이스 구현들이 공유할 로직 | 확장 메서드 — LINQ 가 정확히 이 패턴 |
| 내부 상태 접근이 필요 | 인스턴스 메서드 — 확장은 public 만 봅니다 |
| 없는 값 처리·변환 규칙 등 도메인 로직 | 일반 클래스 — 숨기지 말고 드러내세요 |
날짜와 시간 — DateTime 의 함정
"새벽에만 나는 버그"의 절반이 시간 처리입니다. 원칙은 하나 — 저장·계산은 UTC, 표시는 변환.
DateTime 이 위험한 이유
var now = DateTime.Now; // 이 컴퓨터의 로컬 시간
var utc = DateTime.UtcNow; // 세계 표준시
// 함정 ① 같은 순간인데 다른 값 — 서버를 옮기면 바뀝니다
// 한국 PC: 2026-08-11 14:00 / 미국 서버: 2026-08-11 01:00
// 함정 ② Kind 는 장식일 뿐 — 계산이 막아 주지 않습니다
var a = DateTime.Now; var b = DateTime.UtcNow;
var diff = a - b; // 9시간 — 예외 없이 틀린 값이 나옵니다
// 함정 ③ JSON 왕복에서 Kind 가 사라지거나 바뀝니다
// Unspecified 로 읽힌 값을 다시 변환하면 9시간이 밀립니다
// ★ 답: DateTimeOffset — 시각 + 그때의 오프셋을 함께 듭니다
var t = DateTimeOffset.UtcNow; // 2026-08-11T05:00:00+00:00
var kr = t.ToOffset(TimeSpan.FromHours(9)); // 표시용 변환
t.ToUnixTimeSeconds(); // DB·통신용 정수
DateTimeOffset.FromUnixTimeSeconds(1770000000);
DateTime.Now 를 쓸 일은 거의 없습니다.
저장·비교·계산은 전부 DateTimeOffset.UtcNow 로 하고,
로컬 시간은 화면에 그리는 마지막 순간에만 만드세요.
서버 이전·서머타임·해외 사용자 — 전부 이 원칙 하나로 정리됩니다.
시간대 변환 · 기간 계산
// 시간대는 TimeZoneInfo 로 명시합니다
var kst = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
// (Windows 전용 ID "Korea Standard Time" 도 .NET 6+ 는 자동 매핑)
var utcNow = DateTimeOffset.UtcNow;
var krLocal = TimeZoneInfo.ConvertTime(utcNow, kst);
// 기간은 TimeSpan
var remain = endAt - DateTimeOffset.UtcNow;
if (remain <= TimeSpan.Zero) Expire();
Console.WriteLine($"{remain.Days}일 {remain.Hours}시간 남음");
var cooldown = TimeSpan.FromSeconds(30);
var week = TimeSpan.FromDays(7);
// 포맷 — 저장·전송은 항상 O (ISO 8601, c03 에서 본 원칙)
t.ToString("O"); // 2026-08-11T05:00:00.0000000+00:00
$"{krLocal:yyyy-MM-dd HH:mm}" // 화면 표시용
// 날짜만·시간만 필요하면 (.NET 6+)
DateOnly birthday = new(2000, 5, 17); // 시간대 개념 자체가 없음
TimeOnly openAt = new(9, 30); // 매일 반복되는 시각
TimeProvider(.NET 8+)로 받으세요.
DateTimeOffset.UtcNow 를 코드 곳곳에서 직접 부르면
"자정을 넘기는 순간"을 테스트할 방법이 없습니다.
생성자에서 TimeProvider 를 주입받으면
가짜 시계로 시간을 감아 볼 수 있습니다.
게임에서 시간 다루기 — 기준은 항상 서버
// ✗ 클라이언트 시계로 판정 — 폰 시계를 돌리면 쿨타임·출석이 뚫립니다
if (DateTime.Now >= nextRewardAt) GiveReward();
// ✓ 판정은 서버 시각으로만 — 클라이언트는 표시만 합니다
public sealed class RewardService(TimeProvider clock)
{
public bool TryClaim(Player p)
{
var now = clock.GetUtcNow(); // 서버 UTC
if (now < p.NextRewardAtUtc) return false; // 아직입니다
p.NextRewardAtUtc = NextResetUtc(now);
Give(p);
return true;
}
// "매일 KST 오전 5시 리셋" — 규칙은 로컬로 정의하고 UTC 로 저장
static DateTimeOffset NextResetUtc(DateTimeOffset utcNow)
{
var kst = TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
var local = TimeZoneInfo.ConvertTime(utcNow, kst);
var reset = new DateTimeOffset(local.Year, local.Month, local.Day,
5, 0, 0, local.Offset);
if (local >= reset) reset = reset.AddDays(1); // 오늘 5시가 지났으면 내일
return reset.ToUniversalTime();
}
}
// 클라이언트는 서버가 준 시각과의 차이만 그립니다
var remain = serverNextResetUtc - serverNowUtc; // 응답에 함께 받은 값
timerText = $"{remain.Hours:D2}:{remain.Minutes:D2}:{remain.Seconds:D2}";
열거형과 플래그 — 상태를 안전하게
"상태를 문자열로 들고 다니는 코드"는 오타 하나에 무너집니다. enum 은 상태에 이름과 타입을 줍니다.
enum 기본 — 게임 상태 예제
// ★ 번호를 명시하세요 — 저장된 값과의 계약입니다
public enum GameState
{
Title = 0,
Loading = 1,
Playing = 2,
Paused = 3,
GameOver = 4,
}
// switch 식과 조합 — c04 의 규칙: _ 를 빼면
// 값을 추가했을 때 빠진 분기를 컴파일러가 경고합니다
// (정의 안 된 숫자 값 (GameState)99 때문에 CS8524 경고는 남습니다 —
// 이 경고만 프로젝트에서 끄는 팀이 많습니다)
string Bgm(GameState s) => s switch
{
GameState.Title => "bgm_title",
GameState.Loading => "bgm_none",
GameState.Playing => "bgm_field",
GameState.Paused => "bgm_field",
GameState.GameOver => "bgm_gameover",
};
// 상태 전환도 표로 — 잘못된 전환을 타입이 막아 줍니다
bool CanChange(GameState from, GameState to) => (from, to) switch
{
(GameState.Title, GameState.Loading) => true,
(GameState.Loading, GameState.Playing) => true,
(GameState.Playing, GameState.Paused or GameState.GameOver) => true,
(GameState.Paused, GameState.Playing) => true,
_ => false,
};
// 전체 값 순회 — 디버그 메뉴·드롭다운 만들 때
foreach (var s in Enum.GetValues<GameState>())
AddDebugButton(s.ToString());
[Flags] — 하나의 값에 여러 상태를
// 비트 하나가 상태 하나 — 반드시 2의 거듭제곱으로
[Flags]
public enum BuffState
{
None = 0,
Poison = 1 << 0, // 1
Stun = 1 << 1, // 2
Shield = 1 << 2, // 4
Haste = 1 << 3, // 8
Debuffs = Poison | Stun, // 조합에도 이름을 붙일 수 있습니다
}
var buff = BuffState.Poison | BuffState.Shield; // 켜기: OR
// 검사 — 비트 AND (뜨거운 경로에서는 HasFlag 보다 이쪽)
if ((buff & BuffState.Poison) != 0) TickPoison();
if (buff.HasFlag(BuffState.Stun)) return; // 읽기는 이쪽이 편합니다
buff &= ~BuffState.Poison; // 끄기: AND NOT
buff ^= BuffState.Haste; // 뒤집기: XOR
// [Flags] 를 붙이면 ToString 이 조합을 읽어 줍니다
Console.WriteLine(buff); // "Shield, Haste" (없으면 그냥 숫자)
Poison | Stun == 3 == 다른 값 같은
충돌이 조용히 일어납니다. 값이 5개를 넘보기 시작하면
1 << n 표기로만 적는 것을 규칙으로 두세요.
파싱 — 밖에서 온 값은 믿지 않습니다
// 설정 파일·패킷·DB 에서 문자열로 들어올 때
if (Enum.TryParse<GameState>(input, ignoreCase: true, out var state))
Apply(state);
// ★ 함정: TryParse 는 아무 숫자나 통과시킵니다
Enum.TryParse<GameState>("99", out var bad); // true! bad == (GameState)99
// 숫자 입력 가능성이 있으면 정의 여부까지 확인
if (Enum.TryParse<GameState>(input, true, out var s)
&& Enum.IsDefined(s))
Apply(s);
// 캐스팅도 검증이 없습니다 — DB 의 int 를 받을 때 주의
var fromDb = (GameState)row.State; // 99 여도 예외 없음
if (!Enum.IsDefined(fromDb)) fromDb = GameState.Title;
// JSON 은 기본이 숫자 — 문자열로 주고받으려면
var opt = new JsonSerializerOptions
{ Converters = { new JsonStringEnumConverter() } };
Enum.IsDefined 가 false 를 돌려줍니다.
Poison | Shield == 5 는 "정의된 이름"이 아니기 때문입니다.
플래그 검증은 (value & ~All) == 0 처럼
허용 비트 밖이 비었는지로 확인하세요.
저장은 문자열인가 숫자인가
| 숫자 저장 | 문자열 저장 | |
|---|---|---|
| 용량·속도 | 작고 빠름 — 패킷·랭킹에 | 큼 — 수백만 행이면 체감 |
| 이름 변경(rename) | 안전 — 번호만 유지하면 됨 | 기존 데이터가 못 읽힘 |
| 순서 변경·삽입 | 번호 명시 안 했으면 대참사 | 안전 |
| 사람이 읽기(로그·운영 쿼리) | 숫자 뜻을 찾아봐야 함 | 그대로 읽힘 |
IsDefined 검사)까지가 한 세트입니다.
돌아가는 코드에서
버티는 코드로
문법을 알면 기능은 만들어집니다. 하지만 동시에 1만 명이 들어오거나, 초당 60번 도는 게임 루프에서는 다른 지식이 필요합니다. 비동기·메모리·할당을 다룹니다. 여기까지 마치면 "왜 느린가"를 추측이 아니라 측정으로 답할 수 있습니다.
async / await 완전정복
"스레드를 하나 더 쓰는 것"이 아닙니다. 기다리는 동안 스레드를 반납하는 것 — 서버 처리량이 여기서 갈립니다.
기본형과 규칙
public async Task<Player> LoadAsync(int id, CancellationToken ct)
{
var row = await _db.FindAsync(id, ct); // 대기 = 스레드 반납
var meta = await _http.GetAsync(url, ct); // 순차로 필요하면 이렇게
return Map(row, meta);
}
// 서로 관계없는 작업은 동시에
var (profile, items) = (LoadProfileAsync(id, ct), LoadItemsAsync(id, ct));
await Task.WhenAll(profile, items);
return new View(profile.Result, items.Result);
// 타임아웃
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3));
await CallAsync(cts.Token);
- async 는 위로 전파됩니다 — 중간에서 끊지 말고 진입점까지 이어 주세요
- 반환은
Task·Task<T>.async void는 이벤트 핸들러에서만 - 취소 토큰은 맨 아래까지 전달 — 안 그러면 취소해도 일이 계속 돕니다
터지는 세 가지 패턴
.Result · .Wait()
var user = GetAsync().Result;
// UI·구형 ASP.NET 에서 데드락
// 서버에서는 스레드 고갈
끝까지 await
var user = await GetAsync();
// ② async void — 예외를 잡을 수 없어 프로세스가 죽습니다
async void Save() { await ...; } // ✗
async Task SaveAsync() { await ...; } // ✓
// ③ 루프 안에서 await — N번 왕복합니다
foreach (var id in ids) await LoadAsync(id, ct); // ✗ 느림
var tasks = ids.Select(id => LoadAsync(id, ct)); // ✓ 동시에
var all = await Task.WhenAll(tasks);
// 단, 동시 수를 제한해야 상대 서버가 죽지 않습니다
await Parallel.ForEachAsync(ids,
new ParallelOptions { MaxDegreeOfParallelism = 10, CancellationToken = ct },
async (id, token) => await LoadAsync(id, token));
await 뒤에 다른 스레드로 넘어가면 터집니다.
게임 코드에서는 코루틴이나 UniTask(할당 없는 async)를 쓰세요.
Task · ValueTask · 취소와 재시도
비동기의 나머지 절반 — 언제 멈추고, 실패하면 어떻게 하는가.
Task 와 ValueTask
// Task — 힙에 객체 하나. 기본은 이걸로 충분합니다.
public Task<int> GetAsync() => _db.CountAsync();
// ValueTask — '대부분 이미 값이 있는' 경우의 할당 제거
public ValueTask<Item> GetItemAsync(int id)
{
if (_cache.TryGetValue(id, out var hit))
return new ValueTask<Item>(hit); // 할당 0
return new ValueTask<Item>(LoadSlowAsync(id));
}
.AsTask() 로 바꾸세요.
애매하면 Task 를 쓰는 것이 안전하고, ValueTask 는
측정해서 병목으로 확인된 뒤 도입하세요.
// 오래 걸리는 CPU 작업은 스레드풀로 (UI·클라이언트 앱에서 — ASP.NET Core 요청 안에서는 이득이 없습니다)
var result = await Task.Run(() => HeavyCompute(data), ct);
// 진행률 보고
var progress = new Progress<int>(p => bar.Value = p);
await DownloadAsync(url, progress, ct);
취소 · 재시도 · 타임아웃
// 취소는 '요청'입니다 — 받은 쪽이 확인해야 멈춥니다
public async Task WorkAsync(CancellationToken ct)
{
while (true)
{
ct.ThrowIfCancellationRequested(); // 협조 지점
await StepAsync(ct);
}
}
// 재시도 — 지수 백오프 + 지터
static async Task<T> RetryAsync<T>(Func<Task<T>> act, int max = 3)
{
for (int i = 0; ; i++)
{
try { return await act(); }
catch (Exception e) when (i < max && IsTransient(e))
{
var wait = TimeSpan.FromMilliseconds(
Math.Pow(2, i) * 200 + Random.Shared.Next(100));
await Task.Delay(wait);
}
}
}
스레드 · 동기화 · Channel
게임 서버는 여러 스레드가 같은 데이터를 만집니다. 여기서 나는 버그는 재현이 안 됩니다.
경쟁 상태와 lock
private int _count;
public void Add() => _count++; // ✗ 원자적이지 않다 (읽기→더하기→쓰기)
// ① lock — 가장 쉽고 대부분 충분
private readonly object _gate = new();
public void Add() { lock (_gate) { _count++; } }
// ② Interlocked — 단순 연산은 락 없이
Interlocked.Increment(ref _count);
Interlocked.CompareExchange(ref _state, 1, 0); // 0이면 1로
// ③ 동시 컬렉션
private readonly ConcurrentDictionary<int, Session> _sessions = new();
_sessions.TryAdd(id, s);
_sessions.AddOrUpdate(id, s, (_, old) => old.Merge(s));
lock 안에서 await 는 불가능합니다.
비동기 구간을 보호해야 하면 SemaphoreSlim(1,1) 을 쓰세요.
그리고 락을 잡은 채 외부 호출(DB·HTTP)을 하지 마세요 —
전체가 그 속도에 묶입니다.
Channel — 생산자·소비자의 표준
// 게임 서버에서 '패킷 수신 → 처리' 를 잇는 가장 깔끔한 방법
var ch = Channel.CreateBounded<Packet>(new BoundedChannelOptions(10_000)
{
FullMode = BoundedChannelFullMode.DropOldest, // 밀리면 오래된 것부터 버림
SingleReader = true,
});
// 생산자 — 네트워크 스레드
await ch.Writer.WriteAsync(packet, ct);
// 소비자 — 로직 스레드 하나 (락이 필요 없어집니다)
await foreach (var p in ch.Reader.ReadAllAsync(ct))
Handle(p);
Span<T> · Memory<T> — 복사 없는 처리
배열을 자르지 않고 들여다보는 방법. 패킷 파싱·문자열 처리에서 할당을 0으로 만듭니다.
쓰는 법
// Substring 은 새 문자열을 만든다 → 할당
var head = text.Substring(0, 4); // ✗ 힙 할당
// Span 은 '창'만 만든다 → 할당 0
ReadOnlySpan<char> span = text.AsSpan(0, 4); // ✓
// 패킷 파싱 (게임 서버의 핵심 경로)
static bool TryParse(ReadOnlySpan<byte> buf, out Header h)
{
if (buf.Length < 8) { h = default; return false; }
h = new Header(
Id: BinaryPrimitives.ReadInt32LittleEndian(buf),
Size: BinaryPrimitives.ReadInt32LittleEndian(buf[4..]));
return true;
}
// 스택에 임시 버퍼 (작은 크기만!)
Span<byte> tmp = stackalloc byte[128];
규칙과 한계
Span<T>는 필드에 저장할 수 없습니다 — 스택 전용(ref struct)- async 메서드에서 쓸 수 없습니다 — 그때는
Memory<T> stackalloc은 루프 안에서 금지 — 스택이 넘칩니다- 큰 버퍼는
ArrayPool<T>.Shared에서 빌려 쓰고 돌려주세요
var buf = ArrayPool<byte>.Shared.Rent(64 * 1024);
try
{
var n = await stream.ReadAsync(buf.AsMemory(), ct);
Process(buf.AsSpan(0, n));
}
finally { ArrayPool<byte>.Shared.Return(buf); } // ★ 반드시 반납
Substring 이 읽기 좋고 충분히 빠릅니다.Span · Memory · 배열 — 언제 무엇을
| 타입 | 어디에 살 수 있나 | 쓸 곳 | 못 하는 것 |
|---|---|---|---|
Span<T> | 스택만(ref struct) | 동기 메서드 안의 파싱·슬라이스 | 필드 저장 · async · 컬렉션에 넣기 |
ReadOnlySpan<T> | 스택만 | 읽기 전용 파싱 — 기본으로 이것을 | 같음 |
Memory<T> | 힙 가능 | async 경계를 넘을 때 · 필드에 보관 | stackalloc 을 담을 수 없음 |
T[] | 힙 | 수명이 길거나 공유해야 할 때 | — |
ArrayPool<T> | 힙(재사용) | 큰 임시 버퍼(수 KB~수 MB) | 반납을 잊으면 누수 |
할당 없이 파싱하기 — 실전 형태
// CSV 한 줄을 문자열 하나도 안 만들고 읽기
static bool TryParseRow(ReadOnlySpan<char> line, out Row row)
{
row = default;
var i1 = line.IndexOf(',');
if (i1 < 0) return false;
var i2 = line[(i1 + 1)..].IndexOf(',');
if (i2 < 0) return false;
i2 += i1 + 1;
if (!int.TryParse(line[..i1], out var id)) return false; // 할당 0
var name = line[(i1 + 1)..i2]; // 창만
if (!decimal.TryParse(line[(i2 + 1)..], out var amount)) return false;
row = new Row(id, name.ToString(), amount); // 필요한 것만 문자열로
return true;
}
// 구분자로 나누기 — Split 은 배열 + 문자열들을 만듭니다
foreach (var part in line.Split(',')) // ✗ 할당 여러 개
{ }
foreach (var range in line.AsSpan().Split(',')) // ✓ .NET 9+ — 할당 0
{
var part = line.AsSpan()[range];
}
// 문자열 조립도 할당 없이 (길이를 아는 경우 — 8자리:7자리 = 16자)
var key = string.Create(16, (roomId, slot), static (dst, s) =>
{
s.roomId.TryFormat(dst, out var w, "D8"); // 00001234
dst[w] = ':';
s.slot.TryFormat(dst[(w + 1)..], out _, "D7"); // 길이가 모자라면 남은 칸이 '\0' 으로 남습니다
});
int.TryParse·decimal.TryParse·DateTime.TryParse 는
전부 ReadOnlySpan<char> 오버로드가 있습니다.
그래서 문자열로 만들지 않고 바로 숫자로 갈 수 있습니다.
반대 방향인 TryFormat 도 마찬가지여서,
파싱과 출력 양쪽 모두 할당 0 이 가능합니다.
여기서 실수합니다
// ✗ ① stackalloc 을 루프 안에서 — 스택이 쌓여 넘칩니다
for (var i = 0; i < 10000; i++)
{
Span<byte> buf = stackalloc byte[1024]; // 스택 오버플로
}
// ✓ 루프 밖에서 한 번, 또는 ArrayPool 로
Span<byte> buf = stackalloc byte[1024];
for (var i = 0; i < 10000; i++) { buf.Clear(); /* ... */ }
// ✗ ② 크기를 입력에서 받아 stackalloc — 공격 지점이 됩니다
Span<byte> b = stackalloc byte[header.Length]; // 위험
// ✓ 상한을 두고 넘으면 풀에서
byte[]? rented = null;
Span<byte> b2 = header.Length <= 256
? stackalloc byte[256]
: (rented = ArrayPool<byte>.Shared.Rent(header.Length));
try { /* 사용 */ }
finally { if (rented is not null) ArrayPool<byte>.Shared.Return(rented); }
// ✗ ③ 반납한 배열을 계속 씀 — 다른 곳이 그 배열을 빌려 갑니다
var arr = ArrayPool<byte>.Shared.Rent(1024);
ArrayPool<byte>.Shared.Return(arr);
Process(arr); // 내용이 이미 바뀌었을 수 있습니다
// ✗ ④ Rent 는 "요청한 크기 이상"을 줍니다 — Length 를 믿지 마세요
var a = ArrayPool<byte>.Shared.Rent(100);
// a.Length 는 128 일 수 있습니다
Process(a.AsSpan(0, 100)); // ✓ 실제 크기를 직접 잘라 쓰기
// ✗ ⑤ 민감 데이터를 담았다면 반납 전에 지우기
ArrayPool<byte>.Shared.Return(a, clearArray: true);
Return 을 건너뛰면 풀이 말라
이후 Rent 가 매번 새로 할당하게 됩니다 —
최적화하려다 오히려 더 많이 할당하는 상태가 됩니다.
반드시 try/finally 로 감싸세요.
GC 와 할당 줄이기
"게임이 가끔 뚝뚝 끊긴다"의 범인은 대개 GC 입니다. 메모리를 아끼는 게 아니라 새로 만드는 횟수를 줄이는 것이 핵심입니다.
어디서 몰래 할당되는가
void Update()
{
// ① 문자열 — 매 프레임 새 객체
label.text = "HP " + hp; // ✗
// ② 박싱 — 값 타입이 object 로 변할 때
object o = 3; // ✗
Debug.Log(string.Format("{0}", hp)); // ✗ 박싱
// ③ 람다가 바깥 변수를 잡을 때(클로저)
list.ForEach(x => x.Add(offset)); // ✗ offset 캡처
// ④ LINQ · foreach on interface
var alive = enemies.Where(e => e.Hp > 0); // ✗ 반복자 객체
// ⑤ params · 배열
Log("a", "b"); // params → 배열 생성
}
고치는 법
// ① 값이 바뀔 때만 갱신 + 캐시
private int _shownHp = -1;
void Update()
{
if (_shownHp == hp) return;
_shownHp = hp;
label.SetText("HP {0}", hp); // TMP 의 할당 없는 API
}
// ② 재사용 버퍼
private readonly Collider[] _buffer = new Collider[32];
int n = Physics.OverlapSphereNonAlloc(pos, r, _buffer); // NonAlloc 계열 — 앞 n 개만 유효
// ③ 구조체 + 정적 람다
list.ForEach(static x => x.Reset()); // 캡처 없음을 컴파일러가 보장
// ④ 오브젝트 풀
var bullet = _pool.Rent();
_pool.Return(bullet);
<ServerGarbageCollection>true</>)를 켜는 것만으로
처리량이 크게 오르는 경우가 많습니다(🔬 전문가 탭).
의존성 주입 · 설정
new 를 코드 안에 흩뿌리지 않고 한 곳에서 조립합니다. 테스트가 쉬워집니다.
기본형
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<IClock, SystemClock>(); // 앱 전체 1개
builder.Services.AddScoped<IUserRepository, UserRepo>(); // 요청마다 1개
builder.Services.AddTransient<IMailer, Mailer>(); // 쓸 때마다 새로
builder.Services.AddHttpClient<IPayApi, PayApi>();
// 설정을 타입으로 받기
builder.Services.Configure<GameOptions>(
builder.Configuration.GetSection("Game"));
var app = builder.Build();
// 생성자로 받기만 하면 됩니다
public sealed class MatchService(
IUserRepository users,
IOptions<GameOptions> opt,
ILogger<MatchService> log)
{
private readonly int _maxParty = opt.Value.MaxParty;
}
수명을 틀리면 생기는 일
| 수명 | 쓸 곳 | 주의 |
|---|---|---|
| Singleton | 캐시 · 설정 · 커넥션 풀 | 상태를 넣으면 경쟁 |
| Scoped | 요청 단위 작업 · DbContext | 싱글턴이 주입받으면 오류 |
| Transient | 가벼운 도우미 | 많이 만들면 할당 |
IServiceScopeFactory 로 그때그때 범위를 만드세요.
ScriptableObject 에 설정을 담아 주입하는 방식이 흔합니다.
수명(Lifetime) 세 가지 — 잘못 고르면 버그가 됩니다
| 등록 | 만들어지는 횟수 | 맞는 것 |
|---|---|---|
| Singleton | 앱 전체에 1개 | 설정 · 캐시 · HttpClient 팩토리 |
| Scoped | 요청마다 1개 | DbContext · 현재 사용자 정보 |
| Transient | 주입할 때마다 새로 | 가벼운 무상태 서비스 |
DbContext 를 주입하면
모든 요청이 하나의 DbContext 를 공유해
동시성 예외와 데이터 오염이 납니다.
.NET 이 기본으로 이 조합을 감지해 던지는 예외가
"Cannot consume scoped service from singleton" 입니다.
DI 가 있으면 테스트가 이렇게 됩니다
// 결제 서비스 — 외부 PG 사를 인터페이스 뒤로
public class OrderService(IPayGateway pay, IClock clock)
{
public async Task<Order> Checkout(Cart cart)
{
var paid = await pay.ChargeAsync(cart.Total);
return new Order(cart, paid.TxId, clock.Now);
}
}
// 테스트 — 진짜 결제 없이 실패 상황까지 재현
var fakePay = new FakeGateway { FailWith = "한도 초과" };
var fixedNow = new FixedClock(new DateTime(2026, 1, 1));
var svc = new OrderService(fakePay, fixedNow);
await Assert.ThrowsAsync<PaymentException>(
() => svc.Checkout(cart));
IClock)·랜덤·외부 API 를
전부 가짜로 갈아 끼울 수 있게 만듭니다 — DI 의 진짜 값어치입니다.
직렬화 — JSON · 바이너리
저장·전송의 형식을 고르는 일. 사람이 읽을 것인가, 빨라야 하는가로 갈립니다.
System.Text.Json
var opt = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() },
};
var json = JsonSerializer.Serialize(dto, opt);
var back = JsonSerializer.Deserialize<Dto>(json, opt);
// 소스 제너레이터 — 리플렉션 없이 (AOT · 성능)
[JsonSerializable(typeof(Dto))]
internal partial class AppJson : JsonSerializerContext { }
var fast = JsonSerializer.Serialize(dto, AppJson.Default.Dto);
게임 패킷은 바이너리로
| 형식 | 크기 | 쓸 곳 |
|---|---|---|
| JSON | 크다 | 설정 · REST API · 로그 |
| MessagePack | 작다 | 게임 패킷 · 캐시 |
| Protobuf | 작다 | 서버 간 통신 · gRPC |
| 직접 만든 이진 | 가장 작다 | 초당 수만 패킷 |
// MessagePack — 속성만 붙이면 됩니다
[MessagePackObject]
public class MovePacket
{
[Key(0)] public int Id { get; set; }
[Key(1)] public float X { get; set; }
[Key(2)] public float Y { get; set; }
}
var bytes = MessagePackSerializer.Serialize(p); // 20바이트 수준
var p2 = MessagePackSerializer.Deserialize<MovePacket>(bytes);
TypeNameHandling 등)은
원격 코드 실행으로 이어집니다(🔬 전문가 탭 보안).
받을 타입을 코드가 정하고, 크기 상한을 두세요.
리플렉션 · 소스 제너레이터
"코드가 코드를 다루는" 영역. 편하지만 느립니다 — 빌드 시점으로 옮기는 것이 요즘 방식입니다.
리플렉션
// 타입 정보를 런타임에 읽는다
var t = typeof(Player);
foreach (var p in t.GetProperties())
Console.WriteLine($"{p.Name} : {p.PropertyType.Name}");
// 속성(Attribute) 으로 표시하고 찾아 쓰기
[AttributeUsage(AttributeTargets.Method)]
public sealed class CommandAttribute(string name) : Attribute
{
public string Name { get; } = name;
}
var handlers = typeof(Program).Assembly.GetTypes()
.SelectMany(t => t.GetMethods())
.Where(m => m.GetCustomAttribute<CommandAttribute>() is not null);
소스 제너레이터 — 빌드 때 코드를 만든다
// 쓰는 쪽은 partial 하나면 됩니다
public partial class Player
{
[GeneratedRegex(@"^[a-zA-Z0-9_]{3,16}$")]
private static partial Regex NameRule(); // 컴파일 시점 생성
public bool IsValidName(string n) => NameRule().IsMatch(n);
}
// 로깅도 같은 방식 — 문자열 포맷을 미리 만들어 둡니다
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Warning,
Message = "매칭 실패 {RoomId} 사유={Reason}")]
public static partial void MatchFailed(ILogger l, int roomId, string reason);
}
무엇을 언제 — 리플렉션 · 제너레이터 · 명시적 등록
| 방식 | 비용 | AOT · IL2CPP | 쓸 곳 |
|---|---|---|---|
| 리플렉션 | 호출마다 느림 (캐시하면 완화) | 잘릴 수 있음 | 개발 도구 · 테스트 · 시작 시 한 번 |
| 표현식 트리 캐시 | 첫 회만 느림 | 불가(동적 코드 생성) | ORM · 매퍼 내부 |
| 소스 제너레이터 | 0 — 그냥 코드입니다 | 안전 | 정규식 · 로깅 · JSON · DI 등록 |
| 수동 등록 | 0 | 안전 | 플러그인 목록 · 핸들러 매핑 |
세 가지를 실제로 바꿔 보기
// ① 정규식 — new Regex 는 매번 파싱·컴파일합니다
// ✗
public bool IsValid(string n) => new Regex(@"^\w{3,16}$").IsMatch(n);
// ✓ 빌드 시점에 최적화된 코드가 생성됩니다
[GeneratedRegex(@"^\w{3,16}$", RegexOptions.CultureInvariant)]
private static partial Regex NameRule();
// ② 로깅 — 보간 문자열은 레벨이 꺼져 있어도 문자열을 만듭니다
// ✗ Debug 가 꺼져 있어도 ToString() 이 전부 실행됩니다
_log.LogDebug($"매칭 {roomId} {player.Name} {DateTime.UtcNow}");
// ✓ 실제로 찍힐 때만 포맷됩니다 + 구조화 로그로 검색 가능
[LoggerMessage(Level = LogLevel.Debug, Message = "매칭 {RoomId} {Player}")]
public static partial void Matched(ILogger l, int roomId, string player);
// ③ JSON — 기본 직렬화는 리플렉션으로 타입을 훑습니다
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(PlayerDto))]
[JsonSerializable(typeof(List<ItemDto>))]
internal partial class AppJson : JsonSerializerContext;
var s = JsonSerializer.Serialize(dto, AppJson.Default.PlayerDto);
// ASP.NET Core 전역 적용
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJson.Default));
partial 키워드 하나로 끝납니다.
내가 선언만 해 두면 컴파일러가 본체를 채워 넣습니다 —
생성된 코드는 IDE 에서 F12(정의로 이동)로 직접 열어 볼 수 있습니다.
"마법"이 아니라 읽을 수 있는 평범한 C# 코드가 만들어지는 것입니다.
리플렉션을 꼭 써야 한다면
// 시작할 때 한 번만 훑고 결과를 캐시합니다 — 매 호출 조회는 금물
public static class CommandRegistry
{
private static readonly FrozenDictionary<string, Action<string[]>> Map =
BuildMap();
private static FrozenDictionary<string, Action<string[]>> BuildMap()
{
var d = new Dictionary<string, Action<string[]>>(StringComparer.OrdinalIgnoreCase);
foreach (var m in typeof(Commands).GetMethods(
BindingFlags.Public | BindingFlags.Static))
{
if (m.GetCustomAttribute<CommandAttribute>() is not { } attr) continue;
// ★ 델리게이트로 만들어 두면 이후 호출은 리플렉션이 아닙니다
var fn = m.CreateDelegate<Action<string[]>>();
d[attr.Name] = fn;
}
return d.ToFrozenDictionary(); // .NET 8+ — 읽기 전용에 최적화
}
public static bool TryRun(string name, string[] args)
{
if (!Map.TryGetValue(name, out var fn)) return false;
fn(args); // 일반 델리게이트 호출 속도
return true;
}
}
GetMethods() 로만 참조되는 메서드는
“아무도 안 쓴다”고 판단되어 통째로 잘려 나갑니다.
핸들러가 하나도 안 찾아지는데 에디터에서는 잘 되는 상황이 되죠.
· .NET AOT —
[DynamicDependency] 로 보존 지정
· Unity IL2CPP —
link.xml 에 preserve="all"
· 가장 확실한 것 — 목록을 손으로 등록하거나 소스 제너레이터로 만들기
GetProperty 를 부르는 종류의 코드입니다.
네이티브 상호운용 — P/Invoke
C·C++ 로 된 기존 자산을 그대로 씁니다. 게임의 물리·오디오·플랫폼 SDK 연동에 필요합니다.
DllImport
using System.Runtime.InteropServices;
internal static partial class Native
{
// .NET 7+ — 소스 제너레이터 방식(빠르고 AOT 안전)
[LibraryImport("user32.dll", StringMarshalling = StringMarshalling.Utf16)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool MessageBeep(uint type);
[LibraryImport("mylib", EntryPoint = "compute")]
internal static partial int Compute(
ReadOnlySpan<float> input, Span<float> output, int len);
}
지켜야 할 것
- 구조체 레이아웃을
[StructLayout(LayoutKind.Sequential)]로 고정 - 포인터로 넘길 배열은 고정(
fixed)하거나Span사용 — GC 가 옮기면 터집니다 - 네이티브가 준 메모리는 누가 해제하는지 문서로 남기세요
- 플랫폼별 바이너리(
.dll·.so·.dylib)를 전부 배포해야 합니다
타입을 어떻게 넘기나 — 마셜링 대응표
| C / C++ | C# | 주의 |
|---|---|---|
int · float · double | int · float · double | 그대로 복사됩니다(blittable) |
bool | [MarshalAs(UnmanagedType.I1)] bool | C# bool 은 기본 4바이트 — 명시하지 않으면 어긋납니다 |
const char* (UTF-8) | string + StringMarshalling.Utf8 | 복사가 일어납니다 |
wchar_t* (UTF-16) | string + StringMarshalling.Utf16 | Windows API 는 대개 이쪽 |
float* · 배열 | Span<float> · ReadOnlySpan<T> | 복사 없음 — 가장 좋은 형태 |
struct | [StructLayout(LayoutKind.Sequential)] | 필드 순서·패딩이 정확히 같아야 합니다 |
| 콜백 함수 포인터 | delegate* · [UnmanagedCallersOnly] | GC 가 델리게이트를 수거하면 크래시 |
| 불투명 핸들 | SafeHandle 파생 | 해제를 구조적으로 보장합니다 |
안전하게 감싸는 형태
// ① 핸들은 SafeHandle 로 — Dispose 를 잊어도 파이널라이저가 해제합니다
internal sealed class AudioHandle() : SafeHandleZeroOrMinusOneIsInvalid(true)
{
protected override bool ReleaseHandle()
{
Native.AudioClose(handle);
return true;
}
}
internal static partial class Native
{
[LibraryImport("audio", StringMarshalling = StringMarshalling.Utf8)]
internal static partial AudioHandle AudioOpen(string device);
[LibraryImport("audio")]
internal static partial void AudioClose(nint h);
// ② 배열은 Span 으로 — 복사도 고정(pinning)도 필요 없습니다
[LibraryImport("audio")]
internal static partial int AudioMix(
ReadOnlySpan<float> input, Span<float> output, int frames);
}
// ③ 구조체는 레이아웃을 못 박습니다
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct Vec3
{
public float X, Y, Z;
}
// ④ 콜백은 정적 메서드 + 함수 포인터로 (GC 수거 걱정이 없습니다)
[UnmanagedCallersOnly(CallConvs = [typeof(CallConvCdecl)])]
private static void OnFrame(nint ctx, int frames) { /* ... */ }
unsafe
{
Native.AudioSetCallback(&OnFrame);
}
[UnmanagedCallersOnly] + 함수 포인터를 쓰세요.
배포와 진단
<!-- 플랫폼별 네이티브 바이너리를 전부 넣어야 합니다 -->
<ItemGroup>
<None Include="native/win-x64/audio.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>runtimes/win-x64/native/audio.dll</Link>
</None>
<None Include="native/linux-x64/libaudio.so">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Link>runtimes/linux-x64/native/libaudio.so</Link>
</None>
</ItemGroup>
// 이름이 플랫폼마다 다르면 직접 해석기를 등록합니다
static Native()
{
NativeLibrary.SetDllImportResolver(typeof(Native).Assembly, (name, asm, path) =>
{
if (name != "audio") return nint.Zero;
var file = OperatingSystem.IsWindows() ? "audio.dll"
: OperatingSystem.IsMacOS() ? "libaudio.dylib"
: "libaudio.so";
return NativeLibrary.Load(file, asm, path);
});
}
| 증상 | 원인 1순위 |
|---|---|
DllNotFoundException | 파일이 출력 폴더에 없음 · 의존 DLL 이 없음 |
EntryPointNotFoundException | 함수 이름 불일치 — C++ 이면 extern "C" 확인 |
AccessViolationException | 구조체 레이아웃·크기 불일치 · 해제된 포인터 |
| 값이 이상하게 들어옴 | bool 크기 · 패딩 · 엔디안 |
| 가끔 크래시 | 델리게이트 GC 수거 · 배열 미고정 |
| x86 에서만 실패 | 호출 규약(Cdecl vs StdCall) |
DllImport 대신 LibraryImport 를 쓰세요(C# 12+).
마셜링 코드를 빌드 시점에 생성하므로 런타임 비용이 없고
AOT·IL2CPP 에서도 안전합니다.
기존 DllImport 코드는 partial 로 바꾸고
분석기가 알려 주는 대로 고치면 대부분 그대로 전환됩니다.
성능 측정 — BenchmarkDotNet
추측하지 말고 재세요. "이게 더 빠를 것 같다"는 대부분 틀립니다.
벤치마크 작성
dotnet add package BenchmarkDotNet
dotnet run -c Release # ★ 반드시 Release
[MemoryDiagnoser] // 할당량도 함께 측정
[SimpleJob(RuntimeMoniker.Net100)]
public class ParseBench
{
private string _line = "12,34,56";
[Benchmark(Baseline = true)]
public int[] Split() => _line.Split(',').Select(int.Parse).ToArray();
[Benchmark]
public int SpanParse()
{
var s = _line.AsSpan(); int sum = 0;
foreach (var range in s.Split(','))
sum += int.Parse(s[range]);
return sum;
}
}
읽는 법 · 함정
| Method | Mean | Ratio | Allocated |
|---------- |----------:|------:|----------:|
| Split | 210.4 ns | 1.00 | 264 B |
| SpanParse | 38.7 ns | 0.18 | 0 B | ← 5배 · 할당 0
- Allocated 열을 먼저 보세요 — 게임에서는 이게 프레임 튐입니다
- Mean 차이가 20% 이내면 의미 없는 경우가 많습니다(측정 오차·환경)
- 실제 데이터 크기로 재세요. 10개와 10만 개는 결과가 뒤집힙니다
- Debug 빌드 측정은 전부 무효입니다
복사해 쓰는 벤치마크 — "어느 쪽이 빠른가" 논쟁을 끝내는 법
// dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
[MemoryDiagnoser] // ★ 할당량까지 측정
public class StringJoinBench
{
private readonly int[] _nums = Enumerable.Range(0, 1_000).ToArray();
[Benchmark(Baseline = true)]
public string Concat()
{
var s = "";
foreach (var n in _nums) s += n + ",";
return s;
}
[Benchmark]
public string Builder()
{
var sb = new StringBuilder();
foreach (var n in _nums) sb.Append(n).Append(',');
return sb.ToString();
}
[Benchmark]
public string Join() => string.Join(',', _nums);
}
BenchmarkRunner.Run<StringJoinBench>(); // ★ Release 로 실행!
| Method | Mean | Ratio | Allocated |
|---------|------------:|--------:|-----------:|
| Concat | 1,213.42 us | 1.00 | 3,785 KB | ← 기준
| Builder | 12.87 us | 0.01 | 33 KB | 94배 빠르고
| Join | 9.21 us | 0.008| 15 KB | 할당은 약 1/250
프로젝트 구조 · 아키텍처
파일이 200개를 넘으면 폴더 구조가 곧 설계가 됩니다.
솔루션 나누기
MyGame.sln
src/
MyGame.Domain/ 도메인 규칙 — 아무것도 참조하지 않는다
Player.cs Item.cs IInventory.cs
MyGame.Application/ 유스케이스 — Domain 만 참조
BuyItemHandler.cs
MyGame.Infrastructure/ DB·HTTP·Redis 구현 — Application 참조
EfInventory.cs RedisRanking.cs
MyGame.Api/ 진입점 — 전부 조립
Program.cs
tests/
MyGame.Domain.Tests/
MyGame.Api.IntegrationTests/
규칙을 코드로 강제하기
// NetArchTest — 의존 방향이 깨지면 테스트가 실패합니다
[Fact]
public void 도메인은_인프라를_모른다()
{
var result = Types.InAssembly(typeof(Player).Assembly)
.Should().NotHaveDependencyOn("MyGame.Infrastructure")
.GetResult();
Assert.True(result.IsSuccessful);
}
<!-- Directory.Build.props — 모든 프로젝트에 공통 설정 -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<AnalysisLevel>latest-recommended</AnalysisLevel>
</PropertyGroup>
</Project>
자주 하는 실수 모음
코드 리뷰에서 반복해서 지적하게 되는 것들을 모았습니다.
언어 · 런타임
DateTime.Now대신DateTimeOffset.UtcNow— 서버는 UTC 로 저장하고 표시할 때만 변환new Random()을 반복 생성 — 같은 값이 나옵니다.Random.Shared- IDisposable 을 안 닫음 —
using을 습관으로 - 예외를 흐름 제어로 사용 — 느리고 의도가 흐려집니다
==로 문자열 비교 시 문화권 이슈 —StringComparison명시- float 비교를
==로 — 오차 범위로 비교하세요
서버 · 게임
- 클라이언트를 믿는 것 — 좌표·데미지·재화는 서버가 계산합니다(🌐 게임 서버 탭)
- 동기 API 를 서버에서 호출 —
.Result하나로 처리량이 무너집니다 - Update 안의
GetComponent·Find—Awake에서 캐시 - 로그를 문자열로 조립 — 구조적 로깅(
log.Info("x {A}", a)) - 설정을 코드에 하드코딩 — 환경별로 갈라지는 순간 사고가 납니다
- 테스트 없는 리팩터링 — 성능 개선일수록 동작 보장이 먼저입니다
증상 → 원인 속답표
| 증상 | 원인 · 처방 |
|---|---|
| async 메서드가 UI 를 멈춤 | 어딘가의 .Result·.Wait() — 끝까지 await 로 |
| 가끔만 나는 컬렉션 수정 예외 | foreach 중 수정 또는 스레드 경합 — 스냅샷·Concurrent* 로 |
| 메모리가 계속 증가 | 이벤트 구독 해지 누락 · static 캐시 무한 증식 · Scoped 오용 |
| 배포하면 시작이 수 초 느림 | 런타임 리플렉션 다량 — 소스 제너레이터·AOT 검토 |
DateTime.Now 기준 버그가 새벽에만 | 서버 시간대 차이 — UtcNow + 표시 시점 변환으로 통일 |
| lock 을 걸었는데도 값이 어긋남 | lock 대상이 매번 새 객체 · 다른 lock 객체 — 하나의 readonly 객체로 |
| HttpClient 를 쓸수록 느려지고 소켓 고갈 | 매번 new — IHttpClientFactory 또는 static 재사용 |
Parallel · PLINQ — CPU 를 다 쓰기
async 가 대기를 겹치는 것이라면, Parallel 은 계산을 코어 수만큼 쪼개는 것입니다. 둘을 바꿔 쓰면 오히려 느려집니다.
Parallel.For / ForEach
// CPU 를 쓰는 무거운 계산을 코어 수만큼 나눕니다
Parallel.For(0, maps.Length, i =>
{
maps[i] = GenerateChunk(i); // 각자 다른 칸에 쓰므로 안전
});
Parallel.ForEach(images, new ParallelOptions
{
MaxDegreeOfParallelism = Environment.ProcessorCount,
CancellationToken = ct, // ★ 취소 연동 — 없으면 끝까지 돕니다
},
img => Resize(img));
// IO 가 섞인 작업은 ForEachAsync (a01 에서 본 그것)
await Parallel.ForEachAsync(urls,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (url, token) => await DownloadAsync(url, token));
// 취소되면 OperationCanceledException 으로 빠져나옵니다
try { Parallel.ForEach(...); }
catch (OperationCanceledException) { /* 정상 취소 경로 */ }
PLINQ — LINQ 에 AsParallel 한 줄
// 항목당 계산이 무거울 때 — 체인 그대로 병렬이 됩니다
var results = candidates
.AsParallel()
.WithDegreeOfParallelism(Environment.ProcessorCount)
.WithCancellation(ct)
.Where(c => HeavyScore(c) > threshold) // 여기가 무거워야 이득
.Select(c => Transform(c))
.ToList();
// ★ 순서가 필요하면 명시해야 합니다 — 기본은 순서 보장 없음
var ordered = xs.AsParallel().AsOrdered().Select(Heavy).ToList();
// 집계는 병렬 전용 연산자가 안전합니다
var total = xs.AsParallel().Sum(x => Heavy(x));
// 결과를 나오는 대로 소비 — 전부 끝나길 안 기다립니다
foreach (var r in xs.AsParallel().Select(Heavy))
Consume(r); // 순서는 뒤섞여 도착합니다
x * 2 수준의 가벼운 연산은 병렬이 오히려 몇 배 느립니다.
확신이 없으면 a10 의 BenchmarkDotNet 으로 재고 결정하는 것이 정답입니다.
언제 빨라지고 언제 느려지나
| 작업의 성격 | 고를 것 | 이유 |
|---|---|---|
| 항목마다 무거운 순수 계산 (이미지·경로 탐색·시뮬레이션) | Parallel · PLINQ | 코어 수만큼 거의 정직하게 빨라집니다 |
| DB·HTTP·파일 등 IO 대기 | async + WhenAll · ForEachAsync | 스레드를 늘려 봐야 전부 잠들어 있습니다 |
| 항목당 일이 마이크로초 미만 | 그냥 for | 분할·조율 비용이 본 작업보다 큽니다 |
| 항목들이 같은 데이터를 수정 | 구조를 먼저 바꾸기 | lock 경합으로 직렬화 — 병렬 의미가 사라집니다 |
| 게임 클라이언트의 대량 연산 | Job System·Burst (Unity 실전 탭) | 엔진 스케줄러와 싸우지 않는 길입니다 |
공유 상태 — 병렬 버그의 90%
모두가 한 컬렉션에 쓰기
var found = new List<Item>();
long sum = 0;
Parallel.ForEach(items, x =>
{
if (Match(x)) found.Add(x); // ✗ List 는 스레드 안전 X
sum += x.Price; // ✗ 덧셈도 경합 — 값이 사라짐
});
// 가끔만 틀리고, 디버거를 붙이면 재현이 안 됩니다
각자 모으고 마지막에 합치기
var found = new ConcurrentBag<Item>(); // 쓰기 잦으면 이쪽
long sum = 0;
Parallel.ForEach(items,
() => 0L, // ① 스레드별 지역 합
(x, _, local) =>
{
if (Match(x)) found.Add(x);
return local + x.Price; // ② 경합 없이 누적
},
local => Interlocked.Add(ref sum, local)); // ③ 마지막에 한 번 합류
// 또는: 그냥 PLINQ 의 .Sum() — 이 패턴을 대신해 줍니다
lock 을 자주 잡으면 병렬이 아닙니다.
전부 한 줄로 서서 기다리는 직렬 코드에 스레드 비용만 얹은 것이 됩니다.
원칙은 a03 과 같습니다 — 공유를 지우는 것이 락을 잘 잡는 것보다 먼저입니다.
스레드별 지역값 → 마지막 합류, 이 모양을 기본형으로 두세요.
정규식 · 텍스트 처리
로그 파싱·입력 검증의 표준 도구. 다만 잘못 쓴 패턴 하나가 서버를 세울 수 있습니다.
Regex 기본 · [GeneratedRegex]
using System.Text.RegularExpressions;
// ✗ 호출마다 new Regex — 파싱·컴파일을 매번 반복합니다
bool Bad(string s) => new Regex(@"^\d+$").IsMatch(s);
// ✓ .NET 7+ 표준: 소스 제너레이터 — 빌드 시점에 코드 생성
public static partial class Patterns
{
[GeneratedRegex(@"^[가-힣a-zA-Z0-9]{2,12}$")]
public static partial Regex Nickname();
[GeneratedRegex(@"^(?<ip>\S+) \S+ \S+ \[(?<time>[^\]]+)\] " +
@"""(?<method>\S+) (?<path>\S+)[^""]*"" (?<status>\d{3})")]
public static partial Regex AccessLog();
}
Patterns.Nickname().IsMatch(input); // 할당·컴파일 비용 없음
// 그룹 이름으로 꺼내기
var m = Patterns.AccessLog().Match(line);
if (m.Success)
Record(m.Groups["path"].Value, int.Parse(m.Groups["status"].Value));
new Regex(..., RegexOptions.Compiled) 를 두던
옛 관용구의 상위 호환입니다.
타임아웃 — ReDoS 를 막는 한 줄
// 이 패턴은 정상 입력에선 멀쩡하다가
// "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" 같은 입력에서
// 되추적(backtracking)이 폭발해 CPU 100% 로 멈춥니다
var evil = new Regex(@"^(a+)+$");
// ✓ 사용자 입력을 만지는 정규식에는 반드시 시간 제한
var safe = new Regex(@"^(a+)+$", RegexOptions.None,
TimeSpan.FromMilliseconds(100));
try { safe.IsMatch(userInput); }
catch (RegexMatchTimeoutException) { return BadRequest(); }
// ✓ 더 근본적인 해법 — 되추적 자체를 끕니다 (.NET 7+)
var nb = new Regex(@"^(a+)+$", RegexOptions.NonBacktracking);
// 실행 시간이 입력 길이에 비례하도록 보장됩니다
// [GeneratedRegex] 에도 지정할 수 있습니다
[GeneratedRegex(@"^\w+@\w+\.\w+$", RegexOptions.NonBacktracking)]
public static partial Regex SimpleEmail();
실무 예제 — 닉네임 검증 · 로그 집계
// 닉네임: 한글·영문·숫자 2~12자, 정규식 + 규칙 검사 조합
public static Result Validate(string nick)
{
if (!Patterns.Nickname().IsMatch(nick))
return Result.Fail("2~12자, 한글·영문·숫자만 가능합니다");
if (BannedWords.Any(w => nick.Contains(w, StringComparison.OrdinalIgnoreCase)))
return Result.Fail("사용할 수 없는 단어가 포함되어 있습니다");
return Result.Ok;
}
// 로그 파일에서 5xx 경로 집계 — 파싱은 정규식, 집계는 LINQ
var top = File.ReadLines(path) // 한 줄씩 (전체 로드 X)
.Select(l => Patterns.AccessLog().Match(l))
.Where(m => m.Success && m.Groups["status"].Value[0] == '5')
.GroupBy(m => m.Groups["path"].Value)
.OrderByDescending(g => g.Count())
.Take(10);
Split 이나 a04 의 Span 슬라이스가
훨씬 빠르고, JSON·HTML 은 전용 파서를 쓰세요 —
정규식으로 HTML 을 파싱하려는 시도는 유명한 함정입니다.
문자열 조립 — StringBuilder 너머
// 반복 조립의 기본은 StringBuilder (기초 c03 · a10 벤치마크 참고)
var sb = new StringBuilder(capacity: 4096); // ★ 크기를 알면 미리
foreach (var e in entries)
sb.Append(e.Time).Append('\t').Append(e.Msg).AppendLine();
// 최종 길이를 아는 뜨거운 경로 — string.Create 로 할당 1회
// 예: 8자리 초대 코드 수만 개 생성
static string InviteCode(char[] alphabet, Random rng)
=> string.Create(8, (alphabet, rng), (span, s) =>
{
for (int i = 0; i < span.Length; i++)
span[i] = s.alphabet[s.rng.Next(s.alphabet.Length)];
});
// 중간 배열·StringBuilder 없이 최종 문자열 버퍼에 직접 씁니다
// 보간 문자열도 조건부라면 지연시키세요
_log.LogDebug("state {State} tick {Tick}", state, tick);
// ✗ $"..." 로 만들면 로그 레벨이 꺼져 있어도 조립 비용을 냅니다
string.Create 는 마지막 수단입니다.
프로파일러가 문자열 할당을 병목으로 지목했을 때만 꺼내세요.
평소 코드의 기본값은 여전히 보간 문자열과 StringBuilder 입니다 —
읽기 어려운 최적화를 미리 깔아 두는 것이 가장 비싼 기술 부채입니다.
암호화 · 해시 실전
직접 발명하지 않는 것이 제 1 원칙입니다. 표준 라이브러리의 검증된 조합을 올바른 자리에 꽂는 법만 다룹니다.
해시(SHA-256) · HMAC 서명
using System.Security.Cryptography;
using System.Text;
// 해시 — 내용의 지문. 파일 무결성·중복 검사·캐시 키
byte[] hash = SHA256.HashData(File.ReadAllBytes(path)); // static 한 줄
string hex = Convert.ToHexString(hash); // "A3F2…"
// 대용량 파일은 스트림으로
await using var fs = File.OpenRead(path);
var h2 = await SHA256.HashDataAsync(fs, ct);
// HMAC — "비밀 키를 아는 쪽이 만들었다"는 서명.
// 웹훅 검증·클라이언트 요청 위조 방지의 표준입니다
static bool VerifyWebhook(string body, string signatureHex, byte[] secret)
{
var expected = HMACSHA256.HashData(secret, Encoding.UTF8.GetBytes(body));
var actual = Convert.FromHexString(signatureHex);
// ★ 일반 비교(SequenceEqual)는 틀린 위치에서 일찍 끝나
// 시간 차이로 서명이 새는 타이밍 공격에 노출됩니다
return CryptographicOperations.FixedTimeEquals(expected, actual);
}
// 게임 예: 결제 영수증·랭킹 제출에 서버 발급 HMAC 를 붙여
// 클라이언트가 값을 조작했는지 서버에서 검증합니다
대칭 암호화 — AES-GCM
// 암호화 + 변조 감지를 한 번에 하는 인증 암호화(AEAD)
public static byte[] Encrypt(byte[] plain, byte[] key)
{
var nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
var cipher = new byte[plain.Length];
var tag = new byte[AesGcm.TagByteSizes.MaxSize];
using var aes = new AesGcm(key, tag.Length);
aes.Encrypt(nonce, plain, cipher, tag);
// nonce ‖ tag ‖ cipher 를 붙여 저장 — 셋 다 비밀이 아닙니다
return [.. nonce, .. tag, .. cipher];
}
public static byte[] Decrypt(byte[] blob, byte[] key)
{
var nonce = blob.AsSpan(0, 12);
var tag = blob.AsSpan(12, 16);
var cipher = blob.AsSpan(28);
var plain = new byte[cipher.Length];
using var aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, cipher, tag, plain); // 변조됐으면 여기서 예외
return plain;
}
비밀번호 — 해시가 아니라 KDF
// 비밀번호는 "느리게" 해시해야 합니다 — 무차별 대입을 비싸게
// SHA256 은 초당 수십억 번 계산되므로 자격이 없습니다
// 표준 라이브러리만으로: PBKDF2
const int Iterations = 600_000; // OWASP 권고 수준
public static (byte[] Salt, byte[] Hash) HashPassword(string pw)
{
var salt = RandomNumberGenerator.GetBytes(16); // 사용자마다 다르게
var hash = Rfc2898DeriveBytes.Pbkdf2(
pw, salt, Iterations, HashAlgorithmName.SHA256, outputLength: 32);
return (salt, hash);
}
public static bool Verify(string pw, byte[] salt, byte[] stored)
{
var hash = Rfc2898DeriveBytes.Pbkdf2(
pw, salt, Iterations, HashAlgorithmName.SHA256, 32);
return CryptographicOperations.FixedTimeEquals(hash, stored);
}
// ASP.NET Core Identity 를 쓴다면 이미 내장되어 있습니다
// (PasswordHasher<T>) — 직접 만들 이유가 없습니다.
// BCrypt.Net-Next · Argon2 패키지도 검증된 선택지입니다
난수 — 뽑기(가챠)에 Random 을 쓰면
// Random / Random.Shared — 예측 가능한 의사 난수
var dmg = damage * Random.Shared.NextSingle(); // ✓ 연출·흔들림·AI 는 OK
// ✗ 보상·뽑기·쿠폰·토큰에 쓰면 안 됩니다
// 시드를 알면(또는 출력 몇 개를 보면) 다음 값이 예측됩니다
// ✓ 결과가 돈이 되는 곳은 암호학적 난수
int roll = RandomNumberGenerator.GetInt32(0, 10000); // 편향 없는 정수
if (roll < 30) GrantSSR(); // 0.3%
// 세션 토큰·쿠폰 코드
var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
// 셔플도 마찬가지 — 카드 게임 서버라면
for (int i = deck.Count - 1; i > 0; i--)
{
int j = RandomNumberGenerator.GetInt32(i + 1);
(deck[i], deck[j]) = (deck[j], deck[i]);
}
RandomNumberGenerator,
없으면(파티클 방향·크리티컬 연출) Random.Shared 로 충분합니다.
그리고 뽑기 판정은 반드시 서버에서 합니다 —
클라이언트 난수는 종류와 무관하게 조작 가능한 입력값입니다.
절대 하지 말 것 — 리뷰에서 바로 반려되는 목록
| 하지 말 것 | 왜 | 대신 |
|---|---|---|
| MD5 · SHA1 을 보안 용도로 | 충돌 공격이 실용화된 지 오래입니다 | SHA-256 · HMAC-SHA256 |
| 키·시크릿 하드코딩 | 리포지토리·디컴파일로 그대로 노출 | 환경 변수 · Key Vault (a06 설정 참고) |
| 비밀번호를 SHA256 한 번으로 | GPU 로 초당 수십억 번 대입됩니다 | PBKDF2 · BCrypt · Argon2 |
| 직접 설계한 암호 알고리즘 | 전문가의 설계도 수년 검증 후 깨집니다 | 표준 조합만 사용 |
보상 뽑기에 Random | 시드·출력 관찰로 예측 가능 | RandomNumberGenerator |
서명 비교에 ==·SequenceEqual | 타이밍 공격으로 한 바이트씩 유출 | FixedTimeEquals |
씬에 놓고
부품을 붙이면
움직인다
Unity 는 "오브젝트에 부품(컴포넌트)을 붙여" 게임을 만드는 엔진입니다. 이 구조 하나만 이해하면 나머지는 부품 사용법입니다. 여기까지 마치면 조작·물리·UI·저장이 들어간 작은 게임 하나를 혼자 완성할 수 있습니다.
설치하고 화면 익히기
Unity Hub 로 버전을 관리하고, 창 다섯 개의 역할만 알면 시작할 수 있습니다.
설치 순서
- Unity Hub 설치 → 여기서 에디터 버전을 깝니다
- LTS 버전을 고르세요 — 최신 베타는 플러그인이 안 붙습니다
- 모듈에서 빌드 대상 체크(Android · iOS · WebGL · Windows)
- 에디터는 Rider 또는 VS Code + C# Dev Kit
템플릿 고르기
2D (Built-in) 가장 단순 · 첫 게임에 좋음
Universal 3D (URP) 모바일·PC 대부분 ★ 기본값으로 권장
HDRP 고사양 PC·콘솔 전용
3D (Built-in) 옛 프로젝트 유지보수용
Library 폴더 충돌로 프로젝트가 망가집니다.
창 다섯 개
public 변수도 여기에 나타납니다.설치 끝났으면 — 5분 만에 첫 실행까지
따라 하기 — 굴러가는 공 하나
1. Hierarchy 우클릭 → 3D Object → Plane (바닥)
2. Hierarchy 우클릭 → 3D Object → Sphere (공)
3. Sphere 를 클릭 → Inspector 아래 [Add Component]
→ Rigidbody 추가 (중력을 받게)
4. Sphere 의 Position Y 를 5 로 (공중에 띄우기)
5. ▶ 재생 → 공이 떨어져 바닥에 멈추면 성공
코드 한 줄 없이 물리가 도는 장면이 만들어졌습니다. Unity 개발은 이 위에 스크립트라는 부품을 얹는 일입니다.
// 6. Project 우클릭 → Create → C# Script → "Roller"
// 7. 이 파일을 Sphere 로 드래그 (= 부품으로 붙이기)
using UnityEngine;
public class Roller : MonoBehaviour
{
[SerializeField] private float power = 10f;
private Rigidbody _rb;
private Vector3 _input;
void Awake() => _rb = GetComponent<Rigidbody>(); // 한 번만 찾아 둔다
void Update() // 입력은 Update 에서 읽고
{
float h = Input.GetAxis("Horizontal"); // A·D / ←→
float v = Input.GetAxis("Vertical"); // W·S / ↑↓
_input = new Vector3(h, 0, v);
}
void FixedUpdate() => _rb.AddForce(_input * power); // 힘은 물리 주기에
}
손이 빨라지는 필수 단축키
| 키 | 하는 일 |
|---|---|
Q W E R T | 손 · 이동 · 회전 · 크기 · 사각 도구 |
F | 선택한 오브젝트로 화면 이동 — 가장 많이 씁니다 |
Ctrl+P | 재생 / 정지 |
Ctrl+Shift+P | 일시정지 — 상태를 멈춰 놓고 Inspector 확인 |
Ctrl+D | 복제 |
V | 꼭짓점 스냅 — 바닥 타일을 딱 붙일 때 |
Ctrl+Shift+F | 씬 카메라 위치를 선택한 카메라에 적용 |
WASD 로 날아다니고,
Alt+좌클릭 드래그로 궤도 회전, 휠로 확대·축소.
이 세 개면 어디든 갑니다.
씬 · 게임오브젝트 · 컴포넌트
Unity 의 전부입니다. 이 세 단어의 관계를 잡으면 나머지는 사용법일 뿐입니다.
코드에서 다루기
using UnityEngine;
public class Player : MonoBehaviour // ← 컴포넌트가 되려면 상속
{
// Inspector 에 노출 (public 대신 이걸 권장 — 캡슐화 유지)
[SerializeField] private float speed = 5f;
[SerializeField] private Transform muzzle;
private Rigidbody _rb;
void Awake()
{
// ★ 매 프레임 찾지 말고 한 번만 캐시
_rb = GetComponent<Rigidbody>();
}
void Update()
{
var h = Input.GetAxis("Horizontal");
transform.Translate(Vector3.right * h * speed * Time.deltaTime);
}
}
GetComponent 는 검색입니다.
Update() 안에서 부르면 매 프레임 뒤집니다.
Awake·Start 에서 한 번 찾아 변수에 담아 두세요 —
Unity 성능 문제의 절반이 여기서 나옵니다.
찾기 · 만들기 · 지우기
// 자신·자식·부모에서 찾기
var col = GetComponent<Collider>();
var gun = GetComponentInChildren<Gun>();
var mgr = GetComponentInParent<RoomManager>();
// 씬 전체에서 찾기 — 느립니다. 시작할 때 한 번만.
var spawner = FindFirstObjectByType<Spawner>();
// 만들기 · 지우기
var go = Instantiate(bulletPrefab, muzzle.position, muzzle.rotation);
Destroy(go, 3f); // 3초 뒤 파괴
gameObject.SetActive(false); // 끄기(파괴보다 훨씬 쌉니다)
// 태그 · 레이어
if (other.CompareTag("Enemy")) { } // == "Enemy" 보다 빠름
[SerializeField] 로 슬롯을 만들고 드래그해 넣으면
실행 전에 빠진 참조를 눈으로 확인할 수 있고 검색 비용도 0입니다.
상속이 아니라 조합으로 생각하세요
Unity 식 설계 — 기능 하나 = 컴포넌트 하나
객체지향 수업식 상속
Character
└ Player : Character
└ FlyingPlayer : Player
└ ... // 조합이 늘수록 폭발
컴포넌트 조합
Player = Mover + Health + Shooter
Enemy = Mover + Health + ChaseAI
Turret = Health + Shooter
// 붙였다 뗐다로 조합 완성
// Health 는 "체력이 필요한 모든 것"에 그대로 재사용
public class Health : MonoBehaviour
{
[SerializeField] private int max = 100;
public int Current { get; private set; }
public event System.Action<int> OnDamaged;
public event System.Action OnDied;
void Awake() => Current = max;
public void TakeDamage(int dmg)
{
Current = Mathf.Max(0, Current - dmg);
OnDamaged?.Invoke(Current);
if (Current == 0) OnDied?.Invoke();
}
}
// Player 도 Enemy 도 상자도 전부 이 한 부품을 씁니다
자주 붙이는 내장 컴포넌트 지도
| 컴포넌트 | 역할 |
|---|---|
| Transform | 위치·회전·크기 — 모든 오브젝트 필수 |
| MeshRenderer / SpriteRenderer | 3D / 2D 를 화면에 그림 |
| Collider(2D) | 충돌 모양 |
| Rigidbody(2D) | 물리 — 중력·힘 |
| Animator | 애니메이션 상태 기계 |
| AudioSource | 소리 재생 |
| Camera | 화면을 찍는 눈 |
| Light | 조명 |
| Canvas + RectTransform | UI 전용 좌표계 |
| ParticleSystem | 폭발·연기 이펙트 |
MonoBehaviour 생명주기
언제 무엇을 하느냐. 순서를 모르면 "가끔 null 이 뜨는" 버그에 시달립니다.
호출 순서
Awake() 오브젝트 생성 직후 (자기 초기화 · 캐시)
OnEnable() 활성화될 때마다 (이벤트 구독)
Start() 첫 프레임 직전 (다른 오브젝트 참조 OK)
↓ 매 프레임
FixedUpdate() 물리 주기 (기본 0.02초) ← Rigidbody 는 여기서
Update() 화면 주기 (프레임마다) ← 입력·로직
LateUpdate() Update 끝난 뒤 ← 카메라 추적
↓
OnDisable() 비활성화 (이벤트 해지)
OnDestroy() 파괴 직전 (정리)
Awake 에서 남의 컴포넌트를 건드리지 마세요.
그 오브젝트의 Awake 가 아직 안 돌았을 수 있습니다.
자기 것은 Awake, 남의 것은 Start —
이 규칙만 지켜도 초기화 순서 버그가 사라집니다.
Update 세 형제 구분
| 주기 | 여기서 할 일 | |
|---|---|---|
| Update | 프레임마다 기기마다 다름 | 입력 · 게임 로직 · 타이머 |
| FixedUpdate | 고정 0.02초 | Rigidbody 힘·속도 |
| LateUpdate | Update 후 | 카메라 따라가기 |
void Update()
{
// ★ 프레임마다 시간이 다르므로 deltaTime 을 곱한다
transform.position += dir * speed * Time.deltaTime;
if (Input.GetKeyDown(KeyCode.Space)) _jump = true; // 입력은 Update
}
void FixedUpdate()
{
if (_jump) { _rb.AddForce(Vector3.up * power, ForceMode.Impulse); _jump = false; }
_rb.MovePosition(_rb.position + dir * speed * Time.fixedDeltaTime);
}
void LateUpdate()
{
cam.position = target.position + offset; // 대상이 다 움직인 뒤 따라간다
}
Time.deltaTime 을 빼먹으면 기기마다 속도가 달라집니다.
144Hz 모니터에서 두 배 빨리 움직이는 게임이 되는 이유입니다.
코루틴 — "3초 뒤에" · "다 될 때까지"를 코드로
기본 형태
// "기다렸다가 이어서" 를 함수 하나로 씁니다
IEnumerator RespawnRoutine()
{
ShowDeathEffect();
yield return new WaitForSeconds(3f); // 3초 대기
transform.position = spawnPoint.position;
yield return null; // 한 프레임 대기
SetInvincible(2f);
}
void Die() => StartCoroutine(RespawnRoutine());
// 자주 쓰는 yield 들
yield return null; // 다음 프레임
yield return new WaitForSeconds(1f); // 초 단위
yield return new WaitForFixedUpdate(); // 다음 물리 틱
yield return new WaitUntil(() => loaded); // 조건이 참이 될 때까지
yield return StartCoroutine(Other()); // 다른 코루틴이 끝날 때까지
함정과 대안
SetActive(false) 하는 순간 그 오브젝트의 코루틴은
소리 없이 중단됩니다 — "가끔 리스폰이 안 되는" 버그의 정체입니다.
전역 흐름은 꺼지지 않는 매니저 오브젝트에서 돌리세요.
// 반복 타이머 — Update 에서 시간 더하기보다 깔끔
IEnumerator SpawnLoop()
{
var wait = new WaitForSeconds(2f); // ★ 재사용 (매번 new 는 GC)
while (enabled)
{
Spawn();
yield return wait;
}
}
// 중단이 필요하면 핸들을 잡아 두세요
Coroutine _loop;
void OnEnable() => _loop = StartCoroutine(SpawnLoop());
void OnDisable() { if (_loop != null) StopCoroutine(_loop); }
Awaitable 로 async/await 도 됩니다 —
await Awaitable.WaitForSecondsAsync(3f);
C# 비동기에 익숙하다면 이쪽이 예외 처리·취소(CancellationToken)에서 더 낫습니다.
Transform · 좌표와 회전
위치·회전·크기. 부모 자식 관계와 로컬/월드 구분이 핵심입니다.
이동과 회전
// 위치
transform.position = new Vector3(0, 1, 0); // 월드 기준
transform.localPosition = Vector3.zero; // 부모 기준
transform.Translate(Vector3.forward * 2f); // 자기 방향으로
// 회전 — 각도(Euler)는 보기 편하고, 계산은 Quaternion 으로
transform.rotation = Quaternion.Euler(0, 90, 0);
transform.Rotate(Vector3.up, 90f * Time.deltaTime);
transform.LookAt(target);
// 부드럽게 회전
var want = Quaternion.LookRotation(dir);
transform.rotation = Quaternion.Slerp(transform.rotation, want, 8f * Time.deltaTime);
// 부모 자식
child.SetParent(parent, worldPositionStays: false);
eulerAngles 를 누적해서 더하지 마세요.
내부는 쿼터니언이라 0~360 으로 되돌아오면서 값이 튑니다.
각도를 따로 변수에 들고 매번 통째로 대입하는 편이 안전합니다.
자주 쓰는 벡터 계산
var to = target.position - transform.position;
var dist = to.magnitude; // 거리
var dir = to.normalized; // 방향(길이 1)
// ★ 거리 비교는 제곱으로 (제곱근 계산을 피한다)
if (to.sqrMagnitude < range * range) Attack();
var lerp = Vector3.Lerp(a, b, 0.5f); // 중간값
var moved = Vector3.MoveTowards(a, b, speed * Time.deltaTime);
var dot = Vector3.Dot(transform.forward, dir); // 앞에 있나? (>0)
var cross = Vector3.Cross(a, b); // 수직 벡터
// 2D 라면 z 를 무시하고 Vector2 를 쓰세요
Vector3.Dot(forward, dir) > Mathf.Cos(30f * Mathf.Deg2Rad) 로 끝납니다.
각도를 직접 계산하는 것보다 훨씬 빠릅니다.
2D 게임이라면
// 2D 는 z 를 안 쓰고, 회전은 z축 하나만 씁니다
transform.position = new Vector2(3, 1); // z=0 으로 들어감
// 스프라이트 좌우 반전 — 회전 대신 flipX
GetComponent<SpriteRenderer>().flipX = dir.x < 0;
// 마우스 방향으로 회전 (탑다운 슈터)
Vector2 to = mouseWorld - (Vector2)transform.position;
float deg = Mathf.Atan2(to.y, to.x) * Mathf.Rad2Deg;
transform.rotation = Quaternion.Euler(0, 0, deg - 90f);
SpriteRenderer 의 Sorting Layer · Order in Layer 로 정합니다.
z 를 만지면 물리·클릭 판정이 꼬입니다.
좌표 변환 — 화면 ↔ 월드
// 마우스 클릭 지점을 월드 좌표로 (2D)
Vector2 world = Camera.main.ScreenToWorldPoint(Input.mousePosition);
// 월드 좌표를 화면 좌표로 — 머리 위 체력바·이름표
Vector3 screen = Camera.main.WorldToScreenPoint(enemy.position);
nameTag.position = screen;
if (screen.z < 0) nameTag.gameObject.SetActive(false); // 뒤에 있으면 숨김
// 부모 기준 ↔ 월드 기준
Vector3 w = transform.TransformPoint(localOffset); // 로컬 → 월드
Vector3 l = transform.InverseTransformPoint(worldPos); // 월드 → 로컬
Camera.main 은 내부적으로 검색입니다.
Awake 에서 한 번 받아 변수에 두세요 —
GetComponent 캐시와 같은 이유입니다.
입력 — Input System
키보드·패드·터치를 같은 코드로 받습니다. 새 프로젝트는 새 Input System 을 쓰세요.
옛 방식 (Input Manager)
// 배우기 쉽고 예제가 많지만, 패드·터치 대응이 번거롭습니다
float h = Input.GetAxis("Horizontal"); // -1 ~ 1 (부드럽게)
float raw = Input.GetAxisRaw("Horizontal"); // -1 / 0 / 1 (즉시)
if (Input.GetKeyDown(KeyCode.Space)) Jump(); // 누른 순간
if (Input.GetKey(KeyCode.LeftShift)) Run(); // 누르고 있는 동안
if (Input.GetMouseButtonDown(0)) Fire();
// 마우스로 화면 좌표 → 월드
var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
if (Physics.Raycast(ray, out var hit)) Move(hit.point);
새 Input System (권장)
# Package Manager → Input System 설치 후 재시작
# Project Settings → Player → Active Input Handling: Both
using UnityEngine.InputSystem;
public class PlayerInputHandler : MonoBehaviour
{
private InputAction _move, _jump;
void Awake()
{
var map = new InputActionMap("Player");
_move = map.AddAction("Move", binding: "<Gamepad>/leftStick");
_move.AddCompositeBinding("2DVector")
.With("Up", "<Keyboard>/w").With("Down", "<Keyboard>/s")
.With("Left", "<Keyboard>/a").With("Right", "<Keyboard>/d");
_jump = map.AddAction("Jump", binding: "<Keyboard>/space");
map.Enable();
}
void Update()
{
var v = _move.ReadValue<Vector2>();
if (_jump.WasPressedThisFrame()) Jump();
}
}
모바일 터치
using UnityEngine.InputSystem;
using UnityEngine.InputSystem.EnhancedTouch;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;
void OnEnable() => EnhancedTouchSupport.Enable();
void OnDisable() => EnhancedTouchSupport.Disable();
void Update()
{
foreach (var t in Touch.activeTouches)
{
if (t.began) OnTap(t.screenPosition);
if (t.inProgress) OnDrag(t.delta);
}
// 핀치 줌 — 두 손가락 거리 변화
if (Touch.activeTouches.Count == 2)
{
var a = Touch.activeTouches[0];
var b = Touch.activeTouches[1];
float now = Vector2.Distance(a.screenPosition, b.screenPosition);
float prev = Vector2.Distance(a.screenPosition - a.delta,
b.screenPosition - b.delta);
Zoom((now - prev) * 0.01f);
}
}
"UI 를 뚫고 게임이 클릭되는" 문제
일시정지 버튼을 눌렀는데 그 밑의 캐릭터가 공격하는 사고 — 터치 게임의 단골 버그입니다. 입력이 UI 를 통과했는지 먼저 물어보세요.
using UnityEngine.EventSystems;
bool PointerOnUI()
{
#if UNITY_EDITOR || UNITY_STANDALONE
return EventSystem.current.IsPointerOverGameObject();
#else
// 모바일은 터치 id 를 넘겨야 정확합니다
foreach (var t in Touch.activeTouches)
if (EventSystem.current.IsPointerOverGameObject(t.touchId))
return true;
return false;
#endif
}
void HandleTap(Vector2 pos)
{
if (PointerOnUI()) return; // ★ UI 위면 게임 입력 무시
FireAt(pos);
}
프리팹 — 복제의 원본
총알·적·아이템처럼 같은 것을 여러 개 만들 때 쓰는 틀입니다.
만들고 쓰기
1. Hierarchy 에서 완성된 오브젝트를
2. Project 창의 Prefabs 폴더로 드래그
3. 파란색으로 바뀌면 프리팹 완성
4. 원본을 고치면 → 배치된 것 전부 반영
5. 개별 인스턴스만 다르게 하려면 → Override
public class Spawner : MonoBehaviour
{
[SerializeField] private GameObject enemyPrefab;
[SerializeField] private Transform[] points;
void Spawn()
{
var p = points[Random.Range(0, points.Length)];
var go = Instantiate(enemyPrefab, p.position, Quaternion.identity);
// 만든 직후 값 넣기
go.GetComponent<Enemy>().Init(level: _wave);
}
}
Instantiate 는 비쌉니다
총알마다 생성·파괴
Instantiate(bullet); // 매번 힙 할당
Destroy(go); // GC 대상 누적
// 초당 20발 → 프레임 튐
풀에서 빌려 쓰기
var b = _pool.Get();
b.SetActive(true);
// 다 쓰면
b.SetActive(false);
_pool.Release(b);
// Unity 내장 풀 (2021+)
using UnityEngine.Pool;
_pool = new ObjectPool<Bullet>(
createFunc: () => Instantiate(prefab),
actionOnGet: b => b.gameObject.SetActive(true),
actionOnRelease: b => b.gameObject.SetActive(false),
actionOnDestroy: b => Destroy(b.gameObject),
defaultCapacity: 50, maxSize: 200);
프리팹 변형(Variant) — 원본 + 차이점만
Enemy.prefab 원본 — 이동·체력·드랍 공통
├ EnemyFast.variant 속도만 2배로 오버라이드
├ EnemyTank.variant 체력만 5배로 오버라이드
└ EnemyBoss.variant 모델·크기·AI 추가
원본 Enemy 의 충돌 버그를 고치면
→ 변형 3종에 전부 반영 (오버라이드한 값만 유지)
만들기: Project 창에서 프리팹을 우클릭 → Create → Prefab Variant. 클래스 상속의 프리팹 버전이라고 생각하면 됩니다.
파란 글씨의 의미 — 오버라이드 관리
| Inspector 표시 | 뜻 |
|---|---|
| 이름이 파란색 | 프리팹 인스턴스다 |
| 값 앞에 파란 세로줄 | 이 값을 원본과 다르게 고쳤다(오버라이드) |
| Overrides ▾ 드롭다운 | 고친 것 목록 — Apply(원본에 반영) / Revert(되돌리기) |
물리 — Rigidbody · Collider
부딪히고 떨어지게 만드는 부품. 움직이는 방법을 틀리면 물리가 무너집니다.
두 부품의 역할
void OnCollisionEnter(Collision c) // 부딪힘 (둘 다 Collider, 하나는 Rigidbody)
{
if (c.gameObject.CompareTag("Ground")) _grounded = true;
var point = c.contacts[0].point; // 맞은 지점
}
void OnTriggerEnter(Collider other) // 통과 (isTrigger 체크)
{
if (other.TryGetComponent<Item>(out var item)) Pickup(item);
}
움직이는 방법 — 이걸 틀립니다
| 방법 | 쓸 곳 |
|---|---|
AddForce | 밀기·점프 — 가장 물리적 |
linearVelocity | 일정 속도 이동 (조작감 중시) |
MovePosition | 움직이는 발판 (Kinematic) |
transform.position | Rigidbody 가 있으면 쓰지 말 것 |
transform.position 으로 옮기면
물리 엔진이 이동을 모릅니다 — 벽을 뚫고 지나가거나
충돌이 씹힙니다. 반드시 FixedUpdate 에서 Rigidbody API 로 옮기세요.
// 빠른 총알이 벽을 통과할 때
rb.collisionDetectionMode = CollisionDetectionMode.ContinuousDynamic;
// 레이어로 충돌 규칙 정리 (Project Settings → Physics)
// 예: Player 는 PlayerBullet 과 충돌하지 않음 → 자기 총알에 안 맞음
// 레이캐스트로 미리 검사
if (Physics.Raycast(pos, dir, out var hit, dist, groundMask)) { }
2D 물리는 별도 세계입니다
// 2D 는 전부 2D 접미사 버전을 씁니다 — 서로 섞이지 않습니다
Rigidbody2D rb;
BoxCollider2D box;
void OnCollisionEnter2D(Collision2D c) { } // 2D 이벤트
void OnTriggerEnter2D(Collider2D other) { }
// 플랫포머 점프 + 바닥 체크
[SerializeField] private LayerMask groundMask;
bool Grounded() =>
Physics2D.OverlapCircle(feet.position, 0.15f, groundMask);
void Jump()
{
if (!Grounded()) return;
rb.linearVelocity = new Vector2(rb.linearVelocity.x, 0); // 낙하 중 점프 보정
rb.AddForce(Vector2.up * jumpPower, ForceMode2D.Impulse);
}
BoxCollider, 다른 쪽이 BoxCollider2D 인 경우입니다.
물리 재질 · 조작감 다듬기
| 증상 | 처방 |
|---|---|
| 벽에 닿으면 쭉 미끄러짐 | Physics Material — friction 조절 |
| 공이 안 튐 / 너무 튐 | Physics Material — bounciness |
| 캐릭터가 넘어짐 | Rigidbody → Constraints → Freeze Rotation |
| 경사에서 주르륵 | 정지 시 마찰 ↑ 또는 velocity 0 고정 |
| 낙하가 둥둥 뜬 느낌 | 떨어질 때만 중력 배수 ↑ (아래 코드) |
// "손맛 나는 점프" 공식 — 떨어질 때 중력을 더 세게
void FixedUpdate()
{
if (rb.linearVelocity.y < 0)
rb.linearVelocity += Vector2.up * Physics2D.gravity.y
* (fallMultiplier - 1) * Time.fixedDeltaTime;
}
애니메이션 · 사운드
움직임과 소리. 상태 기계로 애니메이션을 관리하는 방식만 잡으면 됩니다.
Animator
구성 요소
Animation Clip 실제 동작 (Idle · Run · Jump · Attack)
Animator Controller 상태와 전환 규칙 (.controller 파일)
Parameter 전환 조건 (float Speed · bool Grounded · trigger Attack)
Layer 상반신/하반신 분리 등
private Animator _anim;
// ★ 문자열 대신 해시로 (매 프레임 문자열 비교를 피한다)
private static readonly int Speed = Animator.StringToHash("Speed");
private static readonly int Grounded = Animator.StringToHash("Grounded");
private static readonly int AttackT = Animator.StringToHash("Attack");
void Update()
{
_anim.SetFloat(Speed, _rb.linearVelocity.magnitude);
_anim.SetBool(Grounded, _grounded);
if (Input.GetMouseButtonDown(0)) _anim.SetTrigger(AttackT);
}
// 애니메이션 중간에 이벤트 호출 (Animation Event 로 등록)
public void OnHitFrame() => DealDamage();
CrossFade 로 직접 제어하는 편이 관리하기 쉽습니다.
오디오
[SerializeField] private AudioClip shootClip;
private AudioSource _src;
void Awake() => _src = GetComponent<AudioSource>();
// 겹쳐 재생 (총소리처럼 빠르게 반복)
_src.PlayOneShot(shootClip, volumeScale: 0.7f);
// 한 지점에서 잠깐 나는 소리 — 오브젝트 없이
AudioSource.PlayClipAtPoint(explodeClip, pos);
// 음량 조절은 AudioMixer 로 (설정 화면과 연결)
mixer.SetFloat("BgmVolume", Mathf.Log10(Mathf.Max(v, 0.0001f)) * 20); // dB 변환 (0 이면 -∞ 라 하한 -80dB)
코드 없는 연출 — Timeline · Cinemachine
| 도구 | 맡는 일 |
|---|---|
| Timeline | 컷신 — 캐릭터 이동·카메라·사운드를 타임라인 트랙으로 편집 |
| Cinemachine | 카메라 — 추적·흔들림·시네마틱 전환을 컴포넌트로 |
| Animation Rigging | 손이 문고리를 잡는 등 절차적 자세 보정 |
// Cinemachine — 카메라 추적은 코드가 아니라 세팅입니다
// 1. Package Manager → Cinemachine 설치
// 2. Hierarchy 우클릭 → Cinemachine → Cinemachine Camera (2.x 는 Virtual Camera)
// 3. Follow 슬롯에 Player 드래그 — 끝
// 피격 시 화면 흔들기
using Unity.Cinemachine;
[SerializeField] private CinemachineImpulseSource impulse;
void OnHit() => impulse.GenerateImpulse();
코드 트윈 — "0.3초 만에 스르륵"
// 짧은 연출은 코루틴 트윈으로 충분합니다
IEnumerator PunchScale(Transform t, float dur = 0.25f)
{
Vector3 baseS = t.localScale;
for (float e = 0; e < dur; e += Time.deltaTime)
{
float k = e / dur;
// 튀어 올랐다가 돌아오는 곡선
float s = 1f + Mathf.Sin(k * Mathf.PI) * 0.25f;
t.localScale = baseS * s;
yield return null;
}
t.localScale = baseS;
}
// 버튼 클릭·아이템 획득·데미지 숫자에 이거 하나면 살아납니다
transform.DOScale(1.2f, 0.3f).SetEase(Ease.OutBack) 처럼
한 줄로 끝나고 GC 부담도 코루틴보다 적습니다.
UI — Canvas · uGUI
체력바·버튼·메뉴. 해상도가 달라도 안 깨지게 만드는 것이 핵심입니다.
Canvas 설정
- Render Mode — 대부분
Screen Space - Overlay - Canvas Scaler —
Scale With Screen Size로 두고 기준 해상도(예: 1920×1080) 지정 ★ - Match — 가로/세로 중 무엇에 맞출지 (모바일 세로 게임은 Width 쪽)
- Anchor — 화면 모서리에 붙이면 해상도가 바뀌어도 자리가 유지됩니다
using TMPro;
[SerializeField] private TMP_Text hpText;
[SerializeField] private Slider hpBar;
[SerializeField] private Button startBtn;
void Awake()
{
startBtn.onClick.AddListener(StartGame); // 코드로 연결
}
void OnDestroy() => startBtn.onClick.RemoveListener(StartGame);
void SetHp(int cur, int max)
{
hpBar.value = (float)cur / max;
hpText.SetText("{0}/{1}", cur, max); // 할당 없는 API
}
느려지는 이유와 대책
- 캔버스를 나누세요 — 자주 바뀌는 것(HUD)과 안 바뀌는 것(메뉴)을 분리
- Raycast Target 끄기 — 클릭이 필요 없는 이미지·텍스트는 전부 해제
- 텍스트는 TextMeshPro — 기본 Text 는 화질·성능 모두 불리합니다
- 비활성화보다 캔버스 그룹 —
alpha 0+blocksRaycasts false가 더 쌉니다 - 레이아웃 그룹 남용 금지 — 중첩되면 계산 비용이 급증합니다
한글이 네모(□)로 나올 때 — TMP 폰트 만들기
TextMeshPro 기본 폰트에는 한글이 없습니다.
1. 한글 폰트 준비 (예: Pretendard · 무료 상업 허용)
→ ttf/otf 를 Assets 에 넣기
2. Window → TextMeshPro → Font Asset Creator
3. Source Font: 넣은 폰트
Sampling Point Size: Auto
Atlas Resolution : 4096 × 4096
Character Set : Custom Characters 또는
Unicode Range (Hex)
한글 완성형 범위 : AC00-D7A3, 3130-318F
4. Generate → Save → TMP_Text 의 Font Asset 교체
자주 만드는 UI 패턴
// 부드럽게 차는 체력바
IEnumerator FillTo(float target)
{
float from = hpBar.value;
for (float e = 0; e < 0.25f; e += Time.deltaTime)
{
hpBar.value = Mathf.Lerp(from, target, e / 0.25f);
yield return null;
}
hpBar.value = target;
}
// 데미지 숫자 — 월드 위치를 따라가는 UI
void LateUpdate()
{
var screen = cam.WorldToScreenPoint(target.position + Vector3.up * 2f);
rect.position = screen;
}
// 페이드 인/아웃 — CanvasGroup 하나로
IEnumerator Fade(CanvasGroup g, float to, float dur)
{
float from = g.alpha;
for (float e = 0; e < dur; e += Time.unscaledDeltaTime) // 일시정지 중에도
{ g.alpha = Mathf.Lerp(from, to, e / dur); yield return null; }
g.alpha = to;
g.blocksRaycasts = to > 0.5f;
}
unscaledDeltaTime —
Time.timeScale = 0 으로 게임을 멈추면
deltaTime 도 0이 되어 UI 까지 멈춰 버립니다.
씬 전환 · 게임 흐름
타이틀 → 게임 → 결과. 그리고 씬이 바뀌어도 살아남아야 하는 것을 다룹니다.
씬 로딩
using UnityEngine.SceneManagement;
SceneManager.LoadScene("Game"); // 즉시 (멈칫함)
// 비동기 + 로딩바 (권장)
IEnumerator LoadAsync(string name)
{
var op = SceneManager.LoadSceneAsync(name);
op.allowSceneActivation = false; // 다 될 때까지 대기
while (op.progress < 0.9f)
{
bar.value = op.progress / 0.9f;
yield return null;
}
bar.value = 1f;
yield return new WaitForSeconds(0.3f); // 로딩바를 잠깐 보여 주기
op.allowSceneActivation = true;
}
// 겹쳐 올리기 (UI 씬 · 스테이지 추가)
SceneManager.LoadScene("Pause", LoadSceneMode.Additive);
씬을 넘어 살아남기
public class GameManager : MonoBehaviour
{
public static GameManager Instance { get; private set; }
void Awake()
{
if (Instance != null && Instance != this) { Destroy(gameObject); return; }
Instance = this;
DontDestroyOnLoad(gameObject); // 씬이 바뀌어도 유지
}
}
GameManager.Instance.어쩌고 가 코드 전역에 퍼지면
테스트도 재사용도 불가능해집니다.
정말 하나만 있어야 하는 것(오디오·세이브·씬 전환)만 두고,
나머지는 참조를 넘겨받게 만드세요.
ScriptableObject 가 더 깔끔합니다 —
씬에 속하지 않는 에셋이라 자동으로 살아남고,
인스펙터에서 값을 확인·수정할 수 있습니다(다음 섹션).
게임 상태 기계 — 흐름을 enum 하나로
public enum GameState { Title, Playing, Paused, GameOver }
public class GameFlow : MonoBehaviour
{
public GameState State { get; private set; } = GameState.Title;
public event System.Action<GameState> OnStateChanged;
public void SetState(GameState next)
{
if (State == next) return;
State = next;
Time.timeScale = next == GameState.Paused ? 0f : 1f;
OnStateChanged?.Invoke(next); // UI·사운드가 알아서 반응
}
}
// 구독하는 쪽 — UI 는 상태를 "물어보지" 않고 "통보받습니다"
void OnEnable() => flow.OnStateChanged += HandleState;
void OnDisable() => flow.OnStateChanged -= HandleState;
void HandleState(GameState s)
{
pausePanel.SetActive(s == GameState.Paused);
hud.SetActive(s == GameState.Playing);
}
왜 이렇게 하나
if (isPaused && !isGameOver && ...)지옥을 예방 — 상태가 하나의 enum 이면 조합 폭발이 없습니다- 전환 규칙이 한 곳에 — 일시정지 중 사망 처리 같은
엣지 케이스를
SetState한 군데서 막습니다 - 이벤트 구독 패턴 — UI·사운드·입력이 각자 반응하므로 GameFlow 는 그들의 존재를 몰라도 됩니다
Time.timeScale = 0 의 부작용을 기억하세요.
FixedUpdate 가 멈추고, WaitForSeconds 도 멈춥니다.
일시정지 중에도 돌아야 하는 것(메뉴 애니메이션)은
unscaledDeltaTime · WaitForSecondsRealtime 을 쓰세요.
데이터 — ScriptableObject · 세이브
아이템 표·능력치 같은 게임 데이터와, 진행 상황을 저장하는 법.
ScriptableObject — 데이터를 에셋으로
[CreateAssetMenu(fileName = "Item", menuName = "Game/Item")]
public class ItemData : ScriptableObject
{
public string displayName;
public Sprite icon;
public int price;
public int attack;
[TextArea] public string description;
}
// 쓰는 쪽
public class Shop : MonoBehaviour
{
[SerializeField] private ItemData[] stock; // 인스펙터에서 드래그
}
- 메모리에 한 벌만 존재합니다 — 프리팹마다 복사되지 않습니다
- 기획자가 직접 값을 수정할 수 있습니다(코드 수정·재빌드 불필요)
- 밸런스 표를 엑셀 → SO 자동 생성하는 파이프라인이 흔합니다
세이브
[Serializable]
public class SaveData
{
public int level;
public int gold;
public List<string> unlocked = new();
}
static string Path => System.IO.Path.Combine(
Application.persistentDataPath, "save.json"); // ★ 반드시 이 경로
public static void Save(SaveData d)
{
var json = JsonUtility.ToJson(d, prettyPrint: true);
var tmp = Path + ".tmp";
File.WriteAllText(tmp, json);
if (File.Exists(Path)) File.Replace(tmp, Path, null); // 쓰다 꺼져도 원본 보존
else File.Move(tmp, Path); // (Copy 는 덮어쓰는 도중 꺼지면 깨집니다)
}
public static SaveData Load() =>
File.Exists(Path) ? JsonUtility.FromJson<SaveData>(File.ReadAllText(Path))
: new SaveData();
PlayerPrefs 는 설정용입니다.
볼륨·해상도 정도만 넣으세요.
재화·진행도를 넣으면 사용자가 쉽게 수정할 수 있고
기기 이전도 안 됩니다. 온라인 게임이라면 서버에 저장이 정답입니다.
JsonUtility 가 못 하는 것 — 미리 알아 두세요
| 안 되는 것 | 우회 |
|---|---|
Dictionary | 키·값을 두 List 로 나눠 저장 |
| 최상위가 배열인 JSON | {"items":[...]} 로 감싸기 |
DateTime · decimal | string · long(틱)으로 변환 |
프로퍼티 { get; set; } | 필드만 직렬화됩니다 |
| 다형성(부모 타입 리스트) | [SerializeReference] 또는 타입 필드 |
com.unity.nuget.newtonsoft-json)으로 갈아타세요.
Dictionary·프로퍼티·다형성이 전부 됩니다.
세이브 버전 올리기 — 업데이트에도 안 깨지게
[Serializable]
public class SaveData
{
public int version = 2; // ★ 처음부터 넣어 두세요
// (이 필드가 없던 옛 세이브는 초기값 2 로 읽혀
// 아래 마이그레이션을 건너뜁니다)
public int level;
public int gold;
public List<string> unlocked = new();
}
public static SaveData Load()
{
if (!File.Exists(Path)) return new SaveData();
var d = JsonUtility.FromJson<SaveData>(File.ReadAllText(Path));
// 구버전 세이브를 단계적으로 끌어올린다
if (d.version < 2)
{
d.gold += 100; // v2 보상 지급 예시
d.version = 2;
}
return d;
}
[FormerlySerializedAs("oldName")] 로 다리를 놓으세요.
업데이트 후 "세이브가 날아갔어요" 리뷰의 대부분이 여기서 납니다.
처음 겪는 문제 사전
거의 모든 초심자가 같은 곳에서 막힙니다. 증상 → 원인 순으로 정리했습니다.
증상별 원인
| 증상 | 원인 1순위 |
|---|---|
| NullReferenceException | 인스펙터 슬롯이 비었거나 GetComponent 실패 |
| 물체가 벽을 통과 | 빠른 속도 + Discrete 충돌 검사 |
| 충돌이 안 됨 | 둘 다 Rigidbody 없음 · 레이어 매트릭스 해제 |
| 기기마다 속도가 다름 | Time.deltaTime 누락 |
| 클릭이 안 먹음 | UI 가 위를 덮음 · EventSystem 없음 |
| 빌드하면 다르게 동작 | 에디터 전용 코드 · 리소스 경로 |
| 가끔 프레임이 튐 | GC — Instantiate·문자열·LINQ |
| 스크립트가 안 붙음 | 파일명 ≠ 클래스명 · 컴파일 에러 |
습관으로 막기
- 파일명과 클래스명을 항상 같게 — Unity 규칙입니다
- 인스펙터 참조는 실행 전에 확인 — 비었으면
Awake에서 경고 로그 - 버전 관리(Git)에
Library/는 제외 —.gitignore는 Unity 템플릿 사용 - 씬은 자주 저장(Ctrl+S) — 크래시가 나면 그대로 날아갑니다
- 에디터 로그를 켜 두세요 — 경고를 무시하면 빌드에서 터집니다
void Awake()
{
if (muzzle == null)
Debug.LogError($"{name}: muzzle 이 비었습니다", this); // ← 클릭하면 그 오브젝트로 이동
}
눈으로 디버깅 — 로그보다 빠른 도구들
씬에 그려서 확인하기
void Update()
{
// Scene 창에 선이 그려집니다 (Game 창은 Gizmos 버튼 ON)
Debug.DrawRay(transform.position, transform.forward * 5f, Color.red);
Debug.DrawLine(a, b, Color.yellow, duration: 2f);
}
// 선택했을 때만 공격 범위 표시
void OnDrawGizmosSelected()
{
Gizmos.color = new Color(1, 0, 0, 0.3f);
Gizmos.DrawWireSphere(transform.position, attackRange);
}
일시정지 · 프레임 단위 관찰
- Pause + Step(▶ 옆 버튼) — 한 프레임씩 진행하며 Inspector 값 변화를 관찰
- Error Pause — Console 탭에서 켜면 에러가 난 그 프레임에서 자동 정지
- Debug.Break() — 코드에서 조건 걸어 정지:
if (hp < 0) Debug.Break(); - Console 의 로그를 클릭하면 해당 코드 줄로,
Debug.Log("msg", this)의 로그를 클릭하면 그 오브젝트가 하이라이트됩니다
// 로그는 세 단계 + 컨텍스트를 습관으로
Debug.Log($"[Spawner] wave {_wave} 시작", this);
Debug.LogWarning("풀 고갈 — maxSize 확인", this);
Debug.LogError("세이브 파일 손상", this);
// 빌드에서 로그 비용 제거
[System.Diagnostics.Conditional("UNITY_EDITOR")]
static void DevLog(string msg) => Debug.Log(msg);
코루틴과 비동기 — 시간을 다루기
"3초 뒤에" · "끝날 때까지"를 코드로 쓰는 법. 중단 관리까지 알아야 실전에서 안 터집니다.
코루틴 — 시간 흐름을 함수 하나로
using System.Collections;
using UnityEngine;
public class DashSkill : MonoBehaviour
{
[SerializeField] private float cooldown = 2f;
private bool _ready = true;
public void TryDash()
{
if (!_ready) return;
StartCoroutine(DashRoutine());
}
IEnumerator DashRoutine() // ★ 반환형이 IEnumerator
{
_ready = false;
// 0.2초 동안 프레임마다 이동 — 시간 흐름이 for 문처럼 읽힙니다
for (float e = 0; e < 0.2f; e += Time.deltaTime)
{
transform.Translate(Vector3.forward * 30f * Time.deltaTime);
yield return null; // 다음 프레임까지 양보
}
yield return new WaitForSeconds(cooldown);
_ready = true;
}
}
yield return 은 "여기서 멈췄다가, 조건이 되면 이어서" 입니다.
상태 변수·타이머를 Update 에 흩뿌리지 않고
절차를 위에서 아래로 쓸 수 있다는 것이 코루틴의 가치입니다.
WaitForSeconds 는 캐싱하세요
// 나쁨 — 루프마다 new → GC 쓰레기가 쌓여 가끔 프레임이 튑니다
IEnumerator SpawnLoopBad()
{
while (true) { Spawn(); yield return new WaitForSeconds(1f); }
}
// 좋음 — 한 번 만들어 재사용
IEnumerator SpawnLoop()
{
var wait = new WaitForSeconds(1f); // ★ 밖에서 한 번만
while (true) { Spawn(); yield return wait; }
}
// 일시정지(Time.timeScale = 0) 중에도 흘러야 한다면
yield return new WaitForSecondsRealtime(0.5f);
WaitForSeconds 는 Time.timeScale 의 영향을 받습니다.
일시정지 중 UI 연출·연타 방지 타이머가 같이 멈춰 버리는 버그의 원인 —
그런 곳엔 WaitForSecondsRealtime 을 쓰세요.
중단 관리 — 코루틴이 소리 없이 죽는 곳
// 핸들을 잡아 두고, 꺼질 때 반드시 정리
Coroutine _flow;
void OnEnable() => _flow = StartCoroutine(Flow());
void OnDisable()
{
if (_flow != null) StopCoroutine(_flow);
_flow = null;
}
// 전부 멈추기 — 이 컴포넌트가 시작한 것만
StopAllCoroutines();
SetActive(false) 되거나 파괴되면
그 위의 코루틴은 예외도 없이 그냥 멈춥니다.
"가끔 쿨다운이 안 풀려요" · "리스폰이 안 돼요"의 정체입니다.
씬 전환·페이드 같은 전역 흐름은 꺼지지 않는 매니저에서 돌리세요.
StartCoroutine("Flow") 문자열 버전은 쓰지 마세요.
오타를 컴파일러가 못 잡고, 이름을 바꾸면 조용히 깨집니다.
메서드를 직접 넘기는 버전만 쓰면 됩니다.
Invoke — 간단한 지연 한 방이면 충분할 때
Invoke(nameof(Explode), 3f); // 3초 뒤 Explode() 한 번
InvokeRepeating(nameof(Spawn), 1f, 2f); // 1초 뒤부터 2초마다 반복
CancelInvoke(nameof(Spawn)); // 예약 취소
if (IsInvoking(nameof(Explode))) { } // 예약돼 있나?
// ★ nameof 를 쓰면 이름 변경 시 컴파일 에러로 잡힙니다
// "Explode" 처럼 문자열로 쓰면 조용히 깨집니다
| Invoke | 코루틴 | |
|---|---|---|
| 지연 호출 한 번 | 딱 좋음 | 과함 |
| 인자 전달 | 불가 | 가능 |
| 순서 있는 흐름 | 불가 | 가능 |
| 중간 취소 | CancelInvoke | StopCoroutine |
async/await — C# 비동기를 Unity 에서 쓰는 규칙
Awaitable (Unity 6 내장)
// destroyCancellationToken — 오브젝트가 파괴되면 자동 취소 ★
async Awaitable Die() // async void 대신 Awaitable
{
try { await Awaitable.WaitForSecondsAsync(3f, destroyCancellationToken); }
catch (OperationCanceledException) { return; } // 파괴되면 조용히 끝
Respawn();
}
// 무거운 계산은 스레드 풀로 보냈다가 메인으로 복귀
async Awaitable BuildMapAsync()
{
await Awaitable.BackgroundThreadAsync();
var data = GenerateHeavyMap(); // 여기서 Unity API 호출 금지
await Awaitable.MainThreadAsync();
ApplyToTilemap(data); // Unity API 는 메인 스레드에서만
}
Task.Run 안에서 transform 을 만지면 예외가 납니다.
그리고 코루틴과 반대로 async 메서드는 오브젝트가 파괴돼도 계속 돕니다 —
파괴된 오브젝트를 건드리다 터지지 않게
destroyCancellationToken 을 습관처럼 넘기세요.
UniTask — 실무 표준 비동기 라이브러리
// 설치: Window → Package Manager → 좌상단 + →
// Add package from git URL →
// https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask
using Cysharp.Threading.Tasks;
async UniTask<int> RollDiceAsync()
{
await UniTask.Delay(1000,
cancellationToken: this.GetCancellationTokenOnDestroy());
return Random.Range(1, 7); // ★ 코루틴은 못 하는 "반환값"
}
// 여러 개를 동시에 기다리기
var (a, b) = await UniTask.WhenAll(LoadPlayerAsync(), LoadStageAsync());
- 무할당(zero allocation) —
Task보다 가볍고 GC 부담이 없습니다 - 반환값·예외·취소 — 코루틴이 못 하는 세 가지를 전부 합니다
- WebGL 포함 전 플랫폼 동작 · 코루틴의 모든 yield 대응물 제공
카메라 — Cinemachine으로 따라가기
카메라는 게임의 눈입니다. 직접 짜지 않고 Cinemachine 에 맡기는 것이 요즘 표준입니다.
Camera 컴포넌트 기본
Camera cam = Camera.main; // Awake 에서 캐시 (u04 참고)
// 3D — 원근(Perspective): 멀수록 작게
cam.fieldOfView = 60f; // 시야각 — 좁히면 망원, 넓히면 광각
// 2D — 직교(Orthographic): 거리와 무관하게 같은 크기
cam.orthographic = true;
cam.orthographicSize = 5f; // 화면 세로 "절반"이 5유닛
| Perspective | Orthographic | |
|---|---|---|
| 원근감 | 있음 — 멀면 작게 | 없음 — 항상 같은 크기 |
| 쓰는 곳 | 3D 전반 | 2D 전반 · 쿼터뷰 전략 |
| 확대/축소 | fieldOfView 또는 거리 | orthographicSize |
1080 / 100 / 2 = 5.4 로 계산합니다.
직접 짤 때의 함정 — LateUpdate
public class FollowCam : MonoBehaviour
{
[SerializeField] private Transform target;
[SerializeField] private Vector3 offset = new(0, 5, -8);
[SerializeField] private float smooth = 0.2f;
private Vector3 _vel;
void LateUpdate() // ★ Update 에서 하면 안 됩니다
{
transform.position = Vector3.SmoothDamp(
transform.position, target.position + offset,
ref _vel, smooth);
}
}
Update 에서 따라가면 화면이 덜덜 떨립니다.
카메라가 먼저 움직인 프레임과 대상이 먼저 움직인 프레임이 섞이기 때문 —
대상이 다 움직인 뒤인 LateUpdate 가 카메라의 자리입니다(u03).
Rigidbody 를 따라갈 때 그래도 떨리면
Rigidbody 의 Interpolate 를 켜세요.
Cinemachine — 세팅으로 끝내는 카메라
도입 5분 코스
1. Window → Package Manager → Unity Registry → Cinemachine 설치
2. Hierarchy 우클릭 → Cinemachine → Cinemachine Camera
(Unity 6 = Cinemachine 3.x. 2022 LTS 의 2.x 에서는
"Virtual Camera" 라는 이름이고 네임스페이스가 Cinemachine 입니다)
3. Main Camera 에 CinemachineBrain 이 자동으로 붙음
4. Cinemachine Camera 의 Follow 슬롯에 Player 드래그 — 끝
5. Position Control → Follow 방식과 Damping 으로 감을 조절
- 실제 카메라는 Main Camera 하나 — Cinemachine Camera 는 "여기서 이렇게 찍어라"는 지시서입니다
- 지시서를 여러 개 두고 Priority 가 높은 쪽을 따라갑니다 — 보스 등장 시 보스 카메라의 Priority 를 올리면 부드럽게 블렌드되며 전환
- Confiner 익스텐션 — 카메라가 맵 밖을 못 보게 제한. 2D 는 CinemachineConfiner2D + 경계용 PolygonCollider2D
화면 흔들림(Impulse) · 2D 와 3D
using Unity.Cinemachine; // 2.x 는 using Cinemachine;
// 흔드는 쪽 — 피격·폭발 지점에 ImpulseSource 붙이기
[SerializeField] private CinemachineImpulseSource impulse;
void OnHit() => impulse.GenerateImpulse(0.5f); // 세기
// 받는 쪽 — Cinemachine Camera 에
// Add Extension → CinemachineImpulseListener 추가하면 끝
| 2D 게임 | 3D 게임 | |
|---|---|---|
| 카메라 | Orthographic | Perspective |
| 추적 방식 | Position Composer | Follow · Third Person Follow |
| 경계 제한 | Confiner2D | Confiner3D |
| 회전 | 안 함 (위치만) | 마우스 궤도 — Orbital Follow |
2D 게임 — 스프라이트 · 타일맵
그림 한 장이 게임 캐릭터가 되는 과정. PPU 와 정렬만 잡으면 절반은 끝입니다.
Sprite 와 Pixels Per Unit
이미지를 Assets 에 넣으면 → Texture Type: Sprite (2D and UI)
Pixels Per Unit (PPU) = "몇 픽셀이 1유닛인가"
PPU 100, 이미지 200px → 씬에서 2유닛 크기
16×16 픽셀 아트라면 → PPU 16 으로 두면 타일 1칸 = 1유닛
한 장에 여러 캐릭터가 있는 스프라이트 시트라면
Sprite Mode: Multiple → Sprite Editor 에서 Slice
그리기 순서 — Sorting Layer
var sr = GetComponent<SpriteRenderer>();
// 큰 분류는 레이어로 (Project Settings → Tags and Layers 에서 추가)
// Background → Ground → Characters → Effects → 순서대로 위에 그려짐
sr.sortingLayerName = "Characters";
// 같은 레이어 안 순서는 숫자로 — 클수록 앞
sr.sortingOrder = 10;
sr.flipX = dir.x < 0; // 좌우 반전은 회전 대신 이걸로 (u04)
(0, 1, 0) 으로.
y 가 낮을수록 앞에 그려져 캐릭터가 나무 뒤로 자연스럽게 지나갑니다.
타일맵 — 맵을 브러시로 그리기
기본 흐름 + 충돌까지
1. Hierarchy 우클릭 → 2D Object → Tilemap → Rectangular
(Grid 오브젝트 밑에 Tilemap 이 생김)
2. Window → 2D → Tile Palette → Create New Palette
3. 잘라 둔 타일 스프라이트를 팔레트 창에 드래그
4. 브러시(B)로 씬에 그리기 · 지우개는 Shift+드래그
충돌 붙이기 — 타일 하나하나가 아니라 한 방에:
5. Tilemap 오브젝트에 Tilemap Collider 2D 추가
6. Composite Collider 2D 추가 (콜라이더를 하나로 합침)
→ 자동으로 붙는 Rigidbody2D 는 Body Type: Static
7. Tilemap Collider 2D 의 Composite Operation: Merge
Rule Tile — 지형이 스스로 이어 붙게
Window → Package Manager → 2D Tilemap Extras 설치
Project 우클릭 → Create → 2D → Tiles → Rule Tile
→ 규칙마다 "주변에 타일이 있으면/없으면 이 스프라이트" 지정
→ 팔레트에 Rule Tile 을 넣고 그리면
모서리·가장자리·안쪽 타일을 알아서 골라 줍니다
모서리 타일을 손으로 골라 깔던 작업이 브러시 한 번으로 끝납니다. 지형 종류마다 Rule Tile 하나씩 만들어 두는 것이 2D 맵 작업의 표준입니다.
2D 물리 — 3D 와 다른 점
| 3D | 2D | |
|---|---|---|
| 부품 | Rigidbody · BoxCollider | Rigidbody2D · BoxCollider2D |
| 중력 조절 | 전역 설정 위주 | gravityScale — 오브젝트별 배수 |
| 회전 | x·y·z 3축 | z 축 하나 (float) |
| 이벤트 | OnCollisionEnter | OnCollisionEnter2D |
| 레이캐스트 | Physics.Raycast | Physics2D.Raycast |
Rigidbody2D → Constraints →
Freeze Rotation Z 로 — 안 하면 캐릭터가 데굴데굴 구릅니다.
픽셀 아트가 흐릿할 때 — 임포트 3종 세트
스프라이트를 선택 → Inspector 에서
Filter Mode : Point (no filter) ← 뭉개짐 제거 ★
Compression : None ← 색 번짐 제거
Pixels Per Unit : 타일 크기와 동일 (16픽셀 타일이면 16)
여러 장을 한 번에: 전부 선택 후 같은 설정 → Apply
아예 Preset 으로 저장해 두면 새 에셋에 자동 적용됩니다
적 AI — NavMesh 길찾기
장애물을 돌아 플레이어를 쫓아오는 적. 길찾기는 엔진에 맡기고 우리는 행동만 설계합니다.
NavMesh 베이크 — "걸을 수 있는 곳" 지도 만들기
1. Window → Package Manager → AI Navigation 설치
(Unity 6 에는 기본 포함)
2. 바닥·벽이 담긴 오브젝트(또는 빈 부모)에
Add Component → Navigation → NavMeshSurface
3. NavMeshSurface 의 [Bake] 버튼
→ 씬에 파란 영역 = 에이전트가 걸을 수 있는 곳
4. 움직이는 장애물(문·상자)에는 NavMeshObstacle
→ Carve 체크 시 지도에 실시간으로 구멍을 팝니다
NavMeshAgent — 목적지만 주면 알아서
using UnityEngine.AI;
private NavMeshAgent _agent;
void Awake() => _agent = GetComponent<NavMeshAgent>();
void Chase()
{
_agent.speed = 3.5f;
_agent.stoppingDistance = 1.5f; // 공격 사거리 앞에서 멈춤
_agent.SetDestination(player.position);
}
// "도착했나" 판정 — pathPending 체크를 빼먹으면 오판합니다
bool Arrived =>
!_agent.pathPending &&
_agent.remainingDistance <= _agent.stoppingDistance;
transform.position 으로 옮기지 마세요.
Rigidbody 때(u07)와 같은 이유 — 내부 경로와 어긋나 순간이동하거나 떨립니다.
강제로 위치를 바꿀 때는 _agent.Warp(pos) 를 씁니다.
Rigidbody 와 같이 쓸 거면 isKinematic 을 켜서 물리와 싸우지 않게 하세요.
상태 머신 — 순찰 · 추적 · 공격을 enum 으로
복붙해서 시작하는 적 AI
using UnityEngine;
using UnityEngine.AI;
public class EnemyAI : MonoBehaviour
{
enum State { Patrol, Chase, Attack } // u10 의 게임 상태와 같은 패턴
[SerializeField] private Transform[] waypoints;
[SerializeField] private Transform player;
[SerializeField] private float sightRange = 8f;
[SerializeField] private float attackRange = 1.8f;
private NavMeshAgent _agent;
private State _state = State.Patrol;
private int _wp;
void Awake() => _agent = GetComponent<NavMeshAgent>();
void Update()
{
float dist = Vector3.Distance(transform.position, player.position);
// 1) 전환 규칙 — 한 곳에 모아 둡니다
switch (_state)
{
case State.Patrol:
if (CanSee(dist)) _state = State.Chase;
break;
case State.Chase:
if (dist <= attackRange) _state = State.Attack;
else if (dist > sightRange * 1.5f) _state = State.Patrol; // 놓침
break;
case State.Attack:
if (dist > attackRange) _state = State.Chase;
break;
}
// 2) 상태별 행동
switch (_state)
{
case State.Patrol:
_agent.isStopped = false;
if (!_agent.pathPending && _agent.remainingDistance < 0.3f)
{
_wp = (_wp + 1) % waypoints.Length; // 다음 지점 순환
_agent.SetDestination(waypoints[_wp].position);
}
break;
case State.Chase:
_agent.isStopped = false;
_agent.SetDestination(player.position);
break;
case State.Attack:
_agent.isStopped = true; // 멈춰서
transform.LookAt(player); // 바라보고
TryAttack(); // 쿨다운은 내부에서
break;
}
}
}
시야 판정 — "봤다"를 코드로
bool CanSee(float dist)
{
if (dist > sightRange) return false;
var dir = (player.position - transform.position).normalized;
// 시야각 120도 — 등 뒤는 못 봅니다 (u04 의 Dot 판정)
if (Vector3.Dot(transform.forward, dir)
< Mathf.Cos(60f * Mathf.Deg2Rad)) return false;
// 벽에 가렸나 — 눈높이에서 레이 하나
var eye = transform.position + Vector3.up * 1.6f;
if (Physics.Raycast(eye, dir, out var hit, sightRange)
&& !hit.transform.CompareTag("Player"))
return false; // 먼저 맞은 것이 벽
return true;
}
| NavMesh 함정 | 대처 |
|---|---|
| 절차 생성 맵 | 런타임에 surface.BuildNavMesh() — 비쌉니다. 로딩 중에 한 번만 |
| 낭떠러지·점프 구간 | OffMeshLink 로 잇기 — 이동은 자동, 점프 모션은 직접 처리 |
| 좁은 문을 못 지나감 | 베이크 설정의 Agent Radius 가 문보다 큰 것 — 줄이고 재베이크 |
| SetDestination 이 무시됨 | 목적지가 NavMesh 밖 — NavMesh.SamplePosition 으로 근처 지점 보정 |
OnDrawGizmosSelected 로 그려서 튜닝하세요(u12) —
숫자만 보고 조절하는 것보다 열 배 빠릅니다.
이펙트 — 파티클 · 셰이더 그래프 · 후처리
타격감과 때깔을 만드는 세 가지 도구. 핵심 모듈 몇 개만 알아도 게임이 달라 보입니다.
Particle System — 이 모듈만 먼저
Hierarchy 우클릭 → Effects → Particle System
| 모듈 | 정하는 것 |
|---|---|
| Main | 수명·크기·속도·색의 시작값 · Looping 여부 |
| Emission | 초당 몇 개 — 폭발은 Rate 0 + Burst 한 방 |
| Shape | 어느 방향으로 — Cone(분수) · Sphere(폭발) · Circle(2D) |
| Color over Lifetime | 수명에 따른 색 — 알파 1 → 0 이 자연스러운 소멸 |
| Size over Lifetime | 커졌다 작아지는 곡선 |
히트 이펙트 레시피 (5분)
Main : Duration 0.5 · Looping 끔 · Start Speed 3~6
Emission : Rate 0 · Bursts + → Count 20
Shape : Sphere · Radius 0.1
Color over Lifetime : 알파 1 → 0
Renderer : Material 을 파티클용으로 (URP/Particles/Unlit)
나머지 수십 개 모듈은 필요할 때 하나씩 켜 보면 됩니다. 전부 이해하고 시작하려는 순간 여기서 멈추게 됩니다.
재생과 풀링 — 이펙트도 부품입니다
[SerializeField] private ParticleSystem hitFx;
public void PlayHit(Vector3 pos)
{
hitFx.transform.position = pos;
hitFx.Play(); // 재생 중이면 겹쳐 방출
}
// 풀링과 연결 (u06 의 ObjectPool 그대로)
// 프리팹의 Main → Stop Action: Callback 으로 설정하면
// 파티클이 다 끝났을 때 이 콜백이 옵니다
public class PooledEffect : MonoBehaviour
{
public System.Action<PooledEffect> OnFinished;
void OnParticleSystemStopped() // ★ 반납 타이밍을 엔진이 알려 줌
=> OnFinished?.Invoke(this);
}
Play(), OnParticleSystemStopped 에서 반납이 정석입니다.
재사용 직전에는 Clear() 로 남은 파티클을 지우세요.
셰이더 그래프 · 후처리 — 코드 없이 때깔 올리기
Shader Graph — 노드로 셰이더 만들기
전제: URP 프로젝트 (u01 에서 권장한 그 템플릿)
Project 우클릭 → Create → Shader Graph → URP
→ Lit Shader Graph
→ 더블클릭으로 그래프 창 열기
→ 노드를 연결하면 그대로 셰이더 코드로 컴파일됩니다
입문 단골 예제
피격 플래시 Color 프로퍼티를 Base Color 에 Lerp
외곽선 발광 Fresnel Effect 노드 → Emission
디졸브 Simple Noise → Alpha Clip Threshold
// 그래프에서 노출한 프로퍼티를 코드로 제어
private static readonly int Flash = Shader.PropertyToID("_FlashAmount");
void OnHit() => _renderer.material.SetFloat(Flash, 1f);
.material 은 부르는 순간 머티리얼을 복제합니다.
개별로 바꿀 게 아니면 .sharedMaterial —
복제본이 쌓이면 메모리와 드로우콜이 같이 늘어납니다.
HLSL 을 몰라도 되는 것이 Shader Graph 의 가치지만,
노드 수가 곧 GPU 비용이라는 것은 기억하세요.
Post Processing — URP Volume
1. Hierarchy 우클릭 → Volume → Global Volume
2. Profile 옆 [New] → Add Override → Post-processing
3. Bloom : Threshold 1.1 · Intensity 0.6
밝은 부분만 번지게 — 네온·마법 이펙트의 그 빛
4. Vignette : Intensity 0.3
가장자리를 어둡게 — 화면 집중도가 올라감
5. 안 보이면 → Main Camera 의 Rendering →
Post Processing 체크 확인
Bloom 으로 빛나게 하려면 재질 쪽도 필요합니다 — 머티리얼의 Emission 을 켜고 HDR 색상 Intensity 를 1 이상으로. Threshold 를 넘는 밝기만 번집니다.
돌아가는 게임과
팔 수 있는 게임의
차이
프로토타입은 하루면 나옵니다. 그 뒤가 진짜입니다 — 60fps 유지 · 메모리 관리 · 로딩 · 빌드 · 결제 · 치트 방지. 상용 게임이 실제로 하는 일을 순서대로 다룹니다. 여기까지 마치면 스토어에 올릴 수 있는 상태까지 게임을 끌어올릴 수 있습니다.
프로젝트 구조 · 어셈블리 분리
스크립트가 300개를 넘으면 저장할 때마다 20초씩 기다리게 됩니다. 구조가 곧 개발 속도입니다.
폴더 규칙
Assets/
_Project/ ← 우리 코드·에셋 (밑줄로 맨 위 고정)
Scripts/
Core/ 공용 유틸 · 이벤트 버스
Gameplay/ 플레이어 · 적 · 전투
UI/
Data/ ScriptableObject 정의
Editor/ 에디터 확장 (빌드에 포함 안 됨)
Prefabs/ Scenes/ Art/ Audio/ Settings/
Plugins/ 외부 SDK
StreamingAssets/ 빌드 후에도 파일로 남는 것
_Project 안에 섞지 마세요.
업데이트할 때 우리 수정본이 덮어씌워집니다.
받은 폴더 구조를 그대로 두고 필요한 것만 참조하세요.
Assembly Definition — 컴파일 시간을 줄인다
// Scripts/Gameplay/Gameplay.asmdef
{
"name": "Game.Gameplay",
"references": ["Game.Core", "Unity.InputSystem"],
"autoReferenced": false
}
- 고친 어셈블리만 다시 컴파일됩니다 — 저장 후 대기 시간이 몇 배 줄어듭니다
- 의존 방향을 강제할 수 있습니다 (Core 가 UI 를 참조하면 컴파일 에러)
- 테스트 어셈블리를 따로 두면 빌드에서 자동 제외됩니다
오브젝트 풀링 — 프레임 튐의 1순위 대책
Instantiate/Destroy 를 반복하면 GC 가 돌고, 그때 화면이 멈칫합니다.
직접 만들기 (원리)
public class BulletPool : MonoBehaviour
{
[SerializeField] private Bullet prefab;
[SerializeField] private int prewarm = 50;
private readonly Stack<Bullet> _idle = new();
void Awake()
{
for (int i = 0; i < prewarm; i++) // ★ 로딩 때 미리 만든다
{
var b = Instantiate(prefab, transform);
b.gameObject.SetActive(false);
_idle.Push(b);
}
}
public Bullet Get(Vector3 pos, Quaternion rot)
{
var b = _idle.Count > 0 ? _idle.Pop() : Instantiate(prefab, transform);
b.transform.SetPositionAndRotation(pos, rot);
b.gameObject.SetActive(true);
return b;
}
public void Release(Bullet b)
{
b.gameObject.SetActive(false);
b.ResetState(); // ★ 상태 초기화를 잊지 마세요
_idle.Push(b);
}
}
놓치기 쉬운 것
- 상태 초기화 — 속도·남은 시간·트레일이 남아 있으면 "이상하게 동작하는 총알"이 됩니다
- 파티클은
Stop(true, ClearBehaviour.Clear)로 잔상 제거 - 코루틴은 비활성화되면 멈춥니다 — 재활성화 시 다시 시작해야 합니다
- 이벤트 구독은
OnEnable/OnDisable짝으로 - 최대 개수 상한을 두세요 — 무한히 늘면 그냥 메모리 누수입니다
풀 말고도 GC 를 만드는 것들
| 범인 | 대책 |
|---|---|
"HP: " + hp 문자열 연결 | TMP_Text.SetText("{0}", hp) · StringBuilder 재사용 |
루프 안 LINQ (Where·ToList) | 일반 for 문으로 풀기 |
GetComponents·Physics.RaycastAll | NonAlloc 버전 + 배열 재사용 |
| 람다가 지역 변수 캡처(클로저) | 캐시하거나 정적 람다로 |
yield return new WaitForSeconds | 필드에 만들어 재사용 |
박싱 — enum 키 Dictionary | IEqualityComparer 지정 · int 키 |
// RaycastAll → NonAlloc 패턴
static readonly RaycastHit[] s_hits = new RaycastHit[16];
int n = Physics.RaycastNonAlloc(ray, s_hits, dist, mask);
for (int i = 0; i < n; i++) Process(s_hits[i]);
풀 매니저 — 프리팹별 풀을 하나로
public class PoolHub : MonoBehaviour
{
static readonly Dictionary<GameObject, ObjectPool<GameObject>> s_pools = new();
public static GameObject Spawn(GameObject prefab, Vector3 pos, Quaternion rot)
{
if (!s_pools.TryGetValue(prefab, out var pool))
{
pool = new ObjectPool<GameObject>(
() => { var g = Instantiate(prefab); g.AddComponent<PoolTag>().Origin = prefab; return g; },
g => g.SetActive(true),
g => g.SetActive(false),
Destroy, defaultCapacity: 32, maxSize: 256);
s_pools[prefab] = pool;
}
var go = pool.Get();
go.transform.SetPositionAndRotation(pos, rot);
return go;
}
public static void Despawn(GameObject go) =>
s_pools[go.GetComponent<PoolTag>().Origin].Release(go);
}
// 사용처는 Instantiate/Destroy 를 Spawn/Despawn 으로 바꾸기만
Stop Action: Callback 을 걸고 OnParticleSystemStopped 에서
Despawn 하면 수명 관리 코드가 사라집니다.
프로파일링 — 추측 대신 측정
"느린 것 같다"로 고치면 엉뚱한 곳을 만집니다. 어디서 몇 ms 를 먼저 확인하세요.
Profiler 읽는 순서
- ① 실제 기기에 연결 — 에디터 수치는 참고용일 뿐입니다(Development Build 체크)
- ② CPU 그래프에서 튀는 구간을 클릭 → Timeline 으로 원인 함수 확인
- ③ GC Alloc 열을 정렬 — 0 B 가 아닌 것부터 없앱니다
- ④ Rendering — Batches · SetPass Calls · Tris 를 봅니다
- ⑤ Memory — 텍스처가 대부분입니다. Memory Profiler 패키지로 상세 분석
using Unity.Profiling;
// 내 코드 구간에 이름을 붙여 Profiler 에 표시
static readonly ProfilerMarker s_ai = new("Game.AI.Think");
void Think()
{
using (s_ai.Auto())
{
// ... 무거운 AI 계산
}
}
숫자 기준
| 지표 | 목표 | 비고 |
|---|---|---|
| 프레임 시간 | 16.6ms (60fps) | 모바일 33ms(30fps) 도 흔함 |
| GC Alloc / 프레임 | 0 B | 루프 안에서는 필수 |
| SetPass Calls | 모바일 50~100 | 드로우콜보다 이게 중요 |
| 텍스처 메모리 | 모바일 500MB 이하 | 기기별 상한 확인 |
| 씬 로딩 | 3초 이내 | 넘으면 로딩 연출 필요 |
Shader.WarmupAllShaders 나
Shader Variant Collection 을 로딩 때 미리 돌려 해결합니다.
"처음 폭발할 때만 멈칫한다"의 정체입니다.
그래픽 최적화 — 드로우콜 · 배칭
GPU 가 아니라 "몇 번 그리라고 시켰는가" 가 병목인 경우가 대부분입니다.
배칭 — 여러 개를 한 번에
- Static Batching — 안 움직이는 물체에
Static체크. 메모리를 쓰지만 효과가 큽니다 - SRP Batcher — URP 기본. 같은 셰이더면 재질이 달라도 묶입니다
- GPU Instancing — 같은 메시·재질을 수백 개 (풀·나무·적)
- 텍스처 아틀라스 — 여러 이미지를 한 장으로 합쳐 재질 수를 줄입니다
material.color = ... 로 재질을 건드리면
그 오브젝트만 별도 재질(인스턴스)이 되어 배칭에서 빠집니다.
색을 바꾸려면 MaterialPropertyBlock 을 쓰세요.
private static readonly int ColorId = Shader.PropertyToID("_BaseColor");
private MaterialPropertyBlock _mpb;
void Awake() => _mpb = new MaterialPropertyBlock();
void SetColor(Color c)
{
_renderer.GetPropertyBlock(_mpb);
_mpb.SetColor(ColorId, c);
_renderer.SetPropertyBlock(_mpb); // 재질 복제 없이 색만 변경
}
그 밖의 큰 것들
- 실시간 그림자를 줄이세요 — 모바일에서 가장 비쌉니다. 라이트맵 굽기로 대체
- 라이트는 굽고(Baked), 움직이는 것만 실시간
- Overdraw — 반투명(파티클·UI)이 겹치면 픽셀을 여러 번 그립니다
- LOD — 멀리 있는 모델은 저해상도로 교체
- 오클루전 컬링 — 벽 뒤 물체를 아예 안 그림
- 텍스처 압축 — Android
ASTC, iOSASTC, PCDXT
Addressables — 메모리와 다운로드 관리
모든 에셋을 앱에 넣으면 설치 용량이 폭발합니다. 필요할 때 받아서 쓰는 구조로 바꿉니다.
기본 사용
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
private AsyncOperationHandle<GameObject> _handle;
async void LoadBoss()
{
_handle = Addressables.LoadAssetAsync<GameObject>("Boss_Dragon");
var prefab = await _handle.Task;
Instantiate(prefab);
}
void OnDestroy()
{
// ★ 반드시 해제 — 안 하면 메모리에 계속 남습니다
if (_handle.IsValid()) Addressables.Release(_handle);
}
// 인스턴스까지 한 번에 (해제도 함께)
var go = await Addressables.InstantiateAsync("Boss_Dragon").Task;
Addressables.ReleaseInstance(go);
운영 관점
- 초기 설치 용량을 줄입니다 — 스토어 상한(구글 150MB 등)을 넘기지 않게
- 콘텐츠만 갱신 가능 — 앱 심사 없이 이벤트 에셋 교체(원격 카탈로그)
- 중복 제거 — 여러 번들이 같은 텍스처를 물면 메모리가 두 배가 됩니다
- 다운로드 진행률을 사용자에게 보여 주세요
// 다운로드 크기 확인 후 진행
var size = await Addressables.GetDownloadSizeAsync(label).Task;
if (size > 0)
{
var dl = Addressables.DownloadDependenciesAsync(label);
while (!dl.IsDone) { bar.value = dl.PercentComplete; await Task.Yield(); }
Addressables.Release(dl);
}
그룹 설계 — 무엇을 어디에 담나
| 그룹 | 담는 것 | 배포 |
|---|---|---|
| Local-Core | 첫 화면 · 튜토리얼 · 공용 UI | 앱에 포함 |
| Remote-Stage | 스테이지별 배경·적 (라벨 stage01…) | CDN — 진입 직전 다운로드 |
| Remote-Event | 시즌 이벤트 에셋 | CDN — 심사 없이 교체 |
| Local-Shared | 여러 스테이지가 공유하는 텍스처 | 중복 방지용 분리 ★ |
씬 전환과 메모리 수명
// 스테이지 진입 — 라벨 단위로 미리 받고, 나갈 때 통째로 해제
public class StageLoader
{
readonly List<AsyncOperationHandle> _held = new();
public async Task EnterAsync(string label)
{
var locs = await Addressables.LoadResourceLocationsAsync(label).Task;
foreach (var loc in locs)
{
var h = Addressables.LoadAssetAsync<Object>(loc);
_held.Add(h);
await h.Task;
}
}
public void Exit()
{
foreach (var h in _held) Addressables.Release(h);
_held.Clear();
// 씬 언로드 후 남은 것 정리
Resources.UnloadUnusedAssets();
}
}
DOTS · Job System · Burst
수천 개를 동시에 움직여야 할 때. 멀티코어를 실제로 쓰는 유일한 길입니다.
Job + Burst 부터
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
[BurstCompile] // ★ 네이티브 수준으로 컴파일
public struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Dir;
public NativeArray<float3> Pos;
public float Dt;
public void Execute(int i) => Pos[i] += Dir[i] * Dt;
}
// 실행
var job = new MoveJob { Pos = _pos, Dir = _dir, Dt = Time.deltaTime };
var handle = job.Schedule(_pos.Length, innerloopBatchCount: 64);
handle.Complete(); // 결과가 필요한 시점에
Transform·GameObject·Debug.Log 전부 불가.
순수한 숫자 계산만 가능하며,
데이터는 NativeArray 로 넘기고 반드시 Dispose 해야 합니다.
언제 도입할 것인가
효과가 큰 경우
- 같은 계산을 수천 개에 반복 (군중·탄막·파티클)
- 경로 탐색·시야 판정 대량 처리
- 절차적 생성 · 지형 계산
도입하면 손해
- 오브젝트가 수십 개뿐
- UI · 게임 로직 대부분
- 팀이 ECS 를 처음 접함 (학습 비용이 큽니다)
Unity.Mathematics 의 float3 을 쓰면
Burst 최적화(SIMD)가 훨씬 잘 먹습니다.
Transform 수천 개도 Job 으로 — TransformAccessArray
using UnityEngine.Jobs;
[BurstCompile]
struct SwarmJob : IJobParallelForTransform
{
[ReadOnly] public NativeArray<float3> Vel;
public float Dt;
public void Execute(int i, TransformAccess t) =>
t.position += (Vector3)(Vel[i] * Dt);
}
TransformAccessArray _tfs; // 스폰 시 Add, 파괴 시 RemoveAtSwapBack
void Update()
{
var job = new SwarmJob { Vel = _vel, Dt = Time.deltaTime };
_handle = job.Schedule(_tfs); // Transform 인데도 워커 스레드에서 돈다
}
void LateUpdate() => _handle.Complete();
"Job 은 Transform 을 못 쓴다"의 예외 통로입니다 — 군중 이동·총알 수천 발이 메인 스레드를 떠납니다.
Complete() 를 어디서 부를 것인가
Schedule 직후 Complete
var h = job.Schedule(n, 64);
h.Complete(); // 메인이 그냥 기다림
// = 멀티코어 효과 없음
한 프레임 벌려 주기
// Update 에서 Schedule
_h = job.Schedule(n, 64);
// LateUpdate 에서 회수
_h.Complete();
// 그 사이 워커가 일한다
OnDestroy 에서 Dispose() 를 빠뜨리면 메모리 누수 경고가
콘솔을 덮습니다. Allocator.TempJob 은 4프레임 안에,
Persistent 는 수동으로 — 수명 규칙을 정해 두세요.
모바일 최적화 · 발열 관리
PC 에서 잘 돌던 게임이 폰에서는 5분 만에 뜨거워지고 느려집니다.
먼저 할 것
void Awake()
{
Application.targetFrameRate = 60; // ★ 안 정하면 기기 맘대로
QualitySettings.vSyncCount = 0;
Screen.sleepTimeout = SleepTimeout.NeverSleep; // 필요할 때만
}
// 저사양 기기 자동 판별
if (SystemInfo.systemMemorySize < 3000)
{
QualitySettings.SetQualityLevel(0);
Application.targetFrameRate = 30; // 30 고정이 60 널뛰기보다 낫습니다
}
- 해상도를 낮추세요 —
Screen.SetResolution으로 70~80% 만 써도 체감 차이가 적습니다 - Texture Quality·Shadow Distance를 품질 레벨로 묶어 관리
- 배터리·발열은 GPU 부하가 주범 — 해상도와 Overdraw 부터
빌드 설정
| 항목 | 권장 |
|---|---|
| Scripting Backend | IL2CPP (필수 · 성능·보안) |
| Api Compatibility | .NET Standard 2.1 |
| Managed Stripping | Medium ~ High |
| Target Architectures | ARM64 (32비트는 이제 불필요) |
| Graphics API | Vulkan + GLES3 폴백 |
| Texture Compression | ASTC |
link.xml 로 보존하거나
소스 제너레이터 방식으로 바꾸세요(🧬 C# 고급 탭 08).
빌드 · 스토어 출시
빌드는 사람이 손으로 누르는 순간 사고가 납니다. 자동화가 목표입니다.
빌드 스크립트
// Assets/_Project/Scripts/Editor/BuildScript.cs
public static class BuildScript
{
public static void BuildAndroid()
{
PlayerSettings.Android.useCustomKeystore = true;
PlayerSettings.Android.keystorePass = Env("KEYSTORE_PASS");
PlayerSettings.bundleVersion = Env("BUILD_VERSION");
EditorUserBuildSettings.buildAppBundle = true; // .aab 로 (끄면 APK 가 나옵니다)
PlayerSettings.Android.bundleVersionCode = int.Parse(Env("BUILD_NUMBER"));
var opts = new BuildPlayerOptions
{
scenes = EditorBuildSettings.scenes
.Where(s => s.enabled).Select(s => s.path).ToArray(),
locationPathName = "build/game.aab",
target = BuildTarget.Android,
options = BuildOptions.None,
};
var report = BuildPipeline.BuildPlayer(opts);
if (report.summary.result != BuildResult.Succeeded)
EditorApplication.Exit(1); // CI 에 실패를 알린다
}
static string Env(string k) => System.Environment.GetEnvironmentVariable(k) ?? "";
}
Unity -quit -batchmode -nographics \
-projectPath . -executeMethod BuildScript.BuildAndroid \
-logFile build.log
출시 체크리스트
- ☐ 버전·빌드 번호 자동 증가 (수동은 반드시 실수합니다)
- ☐ 키스토어 백업 — 잃으면 앱을 영원히 업데이트할 수 없습니다
- ☐ 개발용 로그·치트 코드 제거 (
#if DEVELOPMENT_BUILD) - ☐ 권한 최소화 — 안 쓰는 권한은 심사에서 반려됩니다
- ☐ 개인정보 처리방침 URL · 연령 등급
- ☐ 저사양·구형 기기 실제 테스트
- ☐ 크래시 리포팅 연동 확인 (다음 섹션)
- ☐ 스토어 스크린샷 · 아이콘 (해상도별)
수익화 — 인앱결제 · 광고
결제는 돈이 오가는 코드입니다. 클라이언트만 믿으면 반드시 뚫립니다.
영수증 검증은 서버에서
public PurchaseProcessingResult ProcessPurchase(PurchaseEventArgs e)
{
// ★ 여기서 바로 아이템을 주지 않습니다
StartCoroutine(SendToServer(e.purchasedProduct.receipt,
e.purchasedProduct.transactionID));
return PurchaseProcessingResult.Pending; // 서버 확인 후 완료 처리
}
반드시 지킬 것
- 영수증 검증은 서버에서 — 스토어 서버 API 로 확인
- 중복 지급 방지 —
transactionID를 저장해 두 번 처리하지 않기 - 미완료 거래 복구 — 지급 전에 앱이 꺼지면 다음 실행 때 이어서 처리
- 가격은 스토어에서 받아 표시 — 지역·통화가 다릅니다
- 환불 처리를 설계해 두세요 — 나중에 붙이면 데이터가 꼬입니다
미완료 거래 복구 — 돈 받고 지급 안 되는 사고 막기
// "결제됐는데 아이템이 안 왔어요" — CS 문의 1순위
// 원인: 지급 처리 도중 앱 종료·네트워크 끊김
// 대책 1 — Pending 큐를 로컬에 남긴다
void OnPurchaseConfirmedByStore(string txId, string receipt)
{
PendingQueue.Add(txId, receipt); // ① 디스크에 먼저 기록
TryDeliver(txId); // ② 서버 검증 시도
}
// 대책 2 — 앱 시작 때마다 미처리분 재시도
async void Start()
{
foreach (var p in PendingQueue.All())
{
var ok = await Api.VerifyAndGrantAsync(p.TxId, p.Receipt);
if (ok) PendingQueue.Remove(p.TxId); // 서버가 중복은 걸러 준다
}
}
transactionID 기준으로 멱등하면
클라이언트는 몇 번을 다시 보내도 안전합니다 —
"일단 기록, 성공할 때까지 재시도"가 전부가 됩니다.
상품 설계 — 심사·정책에 걸리는 지점
| 항목 | 규칙 |
|---|---|
| 확률형 아이템 | 확률 공시 의무 — 국내법 · 스토어 정책 모두 |
| 가격 표시 | 스토어가 준 현지 통화 문자열 그대로 |
| 복원 버튼 | iOS 는 비소모성 구매 복원 버튼이 없으면 반려 |
| 구독 | 해지 방법 안내 필수 |
| 미성년자 | 결제 한도 · 보호자 동의 흐름 |
라이브 운영 — 원격 설정 · 크래시
출시가 끝이 아닙니다. 스토어 심사 없이 고칠 수 있는 범위를 미리 확보해 두세요.
원격으로 바꿀 수 있게
// 게임 시작 시 서버에서 설정을 받는다
[Serializable]
public class RemoteConfig
{
public bool maintenance; // 점검 중이면 안내 후 종료
public string minVersion; // 이 버전 미만은 강제 업데이트
public float dropRateMultiplier; // 이벤트 배율
public bool adsEnabled;
public string noticeUrl;
}
async Task BootAsync()
{
var cfg = await Api.GetConfigAsync();
if (cfg.maintenance) { ShowMaintenance(); return; }
if (IsOlder(Application.version, cfg.minVersion)) { ForceUpdate(); return; }
Balance.Apply(cfg);
}
크래시와 지표
- 크래시 리포팅 — Firebase Crashlytics · Unity Cloud Diagnostics. 심볼 파일 업로드를 빌드에 포함해야 스택이 읽힙니다
- 핵심 지표 — 크래시 없는 사용자 비율(99.5%+ 목표) · 세션 길이 · 이탈 구간
- 퍼널 로그 — 튜토리얼 단계별 이탈률이 가장 많은 것을 알려 줍니다
- A/B 테스트 — 밸런스는 감이 아니라 데이터로
치트 방지 · 보안
싱글 게임도 랭킹이 있으면 조작됩니다. 무엇을 지킬지 정하는 것이 먼저입니다.
공격 방법과 대응
| 공격 | 대응 |
|---|---|
| 메모리 값 조작 치트엔진 | 중요 값 난독화·이중 저장 · 서버 검증 |
| 속도 핵 시간 가속 | 서버 시각 기준 검증 · Time.timeScale 감시 |
| APK 리패킹 | 서명 검증 · IL2CPP · 무결성 체크 |
| 패킷 변조 | 서버 권위 · 암호화 · 재전송 방지 |
| 세이브 파일 수정 | 서버 저장 · 해시 서명 |
// 값 하나를 두 벌로 들고 대조 (간단하지만 효과가 있습니다)
public struct SecureInt
{
private int _value, _check;
public int Value
{
get { if (unchecked(_value * Key) != _check) OnTamper(); return _value; }
set { _value = value; _check = unchecked(value * Key); }
}
private const int Key = 0x5F3F; // 곱셈 대조 — XOR 와 달리 default(0·0)도 통과합니다
}
현실적인 기준
- 싱글 · 비경쟁 — 최소한의 난독화면 충분합니다. 과투자하지 마세요
- 랭킹 있음 — 점수 계산 근거(플레이 로그)를 서버가 검증
- 실시간 대전 — 서버 권위 필수(🌐 게임 서버 탭 06)
- 이상 탐지 — 통계적으로 불가능한 기록을 자동 플래그
출시 전 최종 점검
개발이 끝났다고 생각한 시점에 한 번 더 훑는 목록입니다.
성능 · 안정성
- ☐ 최저 사양 기기에서 30분 연속 플레이 (발열·스로틀링)
- ☐ Profiler 로 GC Alloc 0 B 확인 (전투 중)
- ☐ 씬 전환 10회 반복 후 메모리가 늘지 않는지
- ☐ 네트워크 끊김·비행기 모드에서 앱이 죽지 않는지
- ☐ 백그라운드 전환 → 복귀 시 정상 동작
- ☐ 세이브 파일 손상 시 기본값으로 시작되는지
운영 준비
- ☐ 강제 업데이트·점검 모드 스위치 동작 확인
- ☐ 크래시 리포팅에 심볼 업로드 완료
- ☐ 서버 부하 테스트 완료(예상 동시접속의 3배)
- ☐ 롤백 절차 문서화 — 누가 어떤 명령으로
- ☐ 고객 문의 창구 · 계정 복구 절차
- ☐ 첫 주 모니터링 당번 지정
사람이 놓치는 것 — 시나리오 QA 와 출시 당일 런북
악의적 시나리오 테스트 — 정상 플레이는 QA 가 아닙니다
- ☐ 결제 승인 화면에서 홈 버튼 → 앱 강제 종료 → 재시작 시 지급 복구되는지
- ☐ 보상 수령 버튼 연타 — 두 번 지급되지 않는지
- ☐ 기기 시계를 내일로 바꾸면 일일 보상이 뚫리는지
- ☐ 튜토리얼 도중 모든 화면에서 종료 → 재시작해도 진행 가능한지
- ☐ 저장 직전 강제 종료 10회 반복 — 세이브 파일이 깨지는지
- ☐ 언어를 아랍어(RTL)·독일어(긴 단어)로 — UI 가 터지는지
- ☐ 알림 권한 거부 상태로 전체 플레이 — 어딘가에서 막히는지
출시 당일 런북 — 미리 적어 두는 대응표
| 상황 | 즉시 행동 |
|---|---|
| 크래시율 > 1% | Crashlytics 상위 스택 확인 → 심하면 단계적 출시 중단 |
| 서버 응답 지연 | 대시보드 → 스케일 아웃 → 안 되면 점검 모드 ON |
| 결제 오류 문의 급증 | 스토어 콘솔 상태 확인 → 미지급분은 거래 ID 로 일괄 재지급 |
| 밸런스 파괴 버그 | 원격 설정으로 해당 기능 OFF → 핫픽스 빌드 |
| 스토어 노출 안 됨 | 심사 상태·국가 설정 확인 (전파에 몇 시간 걸림) |
해상도 · Safe Area — 모든 폰에서 멀쩡하게
개발 PC 에서는 완벽하던 UI 가 노치 있는 폰에서는 버튼이 가려집니다. 스토어 리뷰 1점의 단골 원인입니다.
Canvas Scaler — 첫 설정이 전부를 좌우합니다
| 항목 | 권장 |
|---|---|
| UI Scale Mode | Scale With Screen Size — 기본값(Constant Pixel Size)은 폰마다 UI 크기가 달라집니다 |
| Reference Resolution | 세로 게임 1080 × 1920 · 가로 게임 1920 × 1080 |
| Screen Match Mode | Match Width Or Height |
| Match | 세로 게임 0 (Width) · 가로 게임 1 (Height) |
앵커 · 피벗 실전 규칙
- 구석 UI 는 그 구석에 앵커 — 좌상단 메뉴는 좌상단, 우하단 공격 버튼은 우하단에
- 배경·전체 패널은 Stretch — 네 모서리를 늘려 어떤 비율에서도 빈틈이 없게
- 절대 좌표 배치 금지 — (960, 540) 고정은 다른 해상도에서 반드시 어긋납니다
- 피벗은 "커지는 기준점" — 버튼 펀치 스케일은 (0.5, 0.5), 말풍선은 꼬리 쪽으로
- 중앙 그룹은 빈 오브젝트로 묶어서 그룹째 앵커 — 낱개로 흩어 놓으면 수정할 때 지옥이 됩니다
Safe Area — 노치·펀치홀 대응 코드
// Canvas 바로 아래 "SafeArea" 패널을 하나 두고, 조작 UI 는 전부 그 안에
public class SafeAreaFitter : MonoBehaviour
{
RectTransform _rt;
Rect _applied;
void Awake() { _rt = (RectTransform)transform; Apply(); }
void Update() { if (Screen.safeArea != _applied) Apply(); } // 회전 감지
void Apply()
{
_applied = Screen.safeArea;
Vector2 min = _applied.position;
Vector2 max = _applied.position + _applied.size;
min.x /= Screen.width; min.y /= Screen.height; // 픽셀 → 0~1 비율
max.x /= Screen.width; max.y /= Screen.height;
_rt.anchorMin = min; // 앵커를 Safe Area 에 맞춰 이동
_rt.anchorMax = max;
_rt.offsetMin = Vector2.zero;
_rt.offsetMax = Vector2.zero;
}
}
테스트 — Device Simulator
- Game 뷰 좌상단에서 Simulator 로 전환 (2021+ 내장, 이전 버전은 패키지 설치)
- 실기기 프리셋으로 노치·펀치홀·Safe Area 가 그대로 재현됩니다
- 최소 확인 세트 — 노치 iPhone · 펀치홀 갤럭시 · 4:3 iPad · 20:9 장폰 네 가지
- 가로 모드는 노치가 좌우로 옵니다 — 왼쪽 끝에 둔 조이스틱이 가려지는지 확인
- 회전을 지원하면 회전 도중에도 UI 가 깨지지 않는지 확인 (위 코드의 Update 감지가 그 대비입니다)
Screen.SetResolution 으로 렌더 해상도를
낮추더라도(GAME 07) UI 캔버스는 선명하게 유지되는지 함께 확인하세요.
로컬라이제이션 — 글로벌 출시 준비
텍스트를 코드에 박아 두면 번역 시점에 전체 코드를 다시 뒤지게 됩니다. 출시 전 마지막이 아니라 개발 중간에 넣는 작업입니다.
Unity Localization 패키지 — 기본 흐름
- ① Locale 생성 — Window → Asset Management → Localization Tables 에서 ko · en · ja 등록
- ② String Table 생성 — 키(shop_buy)마다 언어별 번역을 한 줄씩
- ③ UI 에 연결 — TMP 텍스트에 Localize String Event 컴포넌트, 또는 아래 코드
- ④ 번역은 CSV 로 내보내 번역가에게 — 스프레드시트 왕복이 실무 표준입니다
using UnityEngine.Localization;
using UnityEngine.Localization.Settings;
// ① 인스펙터에서 테이블·키를 지정하는 방식 (권장)
[SerializeField] LocalizedString buyLabel; // UI_Table / shop_buy
void OnEnable() => buyLabel.StringChanged += OnBuyLabel;
void OnDisable() => buyLabel.StringChanged -= OnBuyLabel; // ★ 해지 (람다로 구독하면 못 뺍니다)
void OnBuyLabel(string t) => _text.text = t;
// 언어를 바꾸면 StringChanged 가 다시 불립니다 — 갱신 코드가 필요 없습니다
// ② 코드에서 직접 — 동적 키·인자 (Smart String: "{0} 획득!")
var s = await LocalizationSettings.StringDatabase
.GetLocalizedStringAsync("UI_Table", "quest_clear",
arguments: new object[] { questName }).Task;
// ③ 언어 전환 — 설정 화면에서
LocalizationSettings.SelectedLocale =
LocalizationSettings.AvailableLocales.Locales[index];
번역 키 설계 원칙
| 원칙 | 이유 |
|---|---|
| 화면_요소_역할 구조 shop_buy_button · quest_clear_title | 어디서 쓰는 텍스트인지 키만 보고 알 수 있게 |
| 문장을 키로 쓰지 않기 | "구매하시겠습니까"가 키면 원문 수정 = 전체 키 교체 |
| 같은 단어도 문맥별로 분리 ok_generic · ok_purchase | 한국어 "확인"이 영어로는 OK · Confirm · Accept 로 갈립니다 |
| 문장 조립 금지 | "{아이템}을 획득" 식 이어붙이기는 어순이 다른 언어에서 무너집니다 — Smart String 인자로 |
폰트 폴백 — CJK 와 TMP Font Asset
- 언어별 Font Asset 을 만들고 Fallback 체인으로 연결 — 라틴 → 한글 → 일본어 → 중국어 순
- Font Asset 에 없는 글자는 □(두부 글자)로 나옵니다 — 번역 QA 에서 가장 먼저 찾을 것
- CJK 전체를 Static 아틀라스로 구우면 수천 자 × 언어 수만큼 메모리 폭발 — 자주 쓰는 글자만 굽고 나머지는 Dynamic 으로
- 개발 중에는 Dynamic 이 편하지만, 출시 빌드는 Static + Dynamic 폴백 조합이 런타임 히칭을 줄입니다
// 날짜·숫자는 번역이 아니라 CultureInfo 의 일입니다
using System.Globalization;
var culture = CultureInfo.CurrentCulture; // 기기 언어 설정을 따름
priceText.text = gold.ToString("N0", culture); // 1,234,567 / 1.234.567(독일)
dateText.text = endTime.ToString("d", culture); // 2026. 8. 11.(한국) / 8/11/2026(미국)
// ★ 저장·서버 통신은 반드시 Invariant — 함정 1순위
// 독일 기기에서 float.Parse("1.5")가 15가 되는 사고를 막습니다
float hp = float.Parse(saved, CultureInfo.InvariantCulture);
string payload = value.ToString(CultureInfo.InvariantCulture);
텍스트가 UI 를 뚫는 문제
- 독일어는 한국어보다 30~40% 깁니다 — 버튼 폭을 한국어 기준으로 잡으면 반드시 뚫립니다
- TMP Auto Size(min~max) 로 줄이되 하한을 정해 가독성을 지키고, 그래도 넘치면 Ellipsis(말줄임)
- 아랍어·히브리어는 RTL(오른쪽→왼쪽) — 지원 계획이 없으면 출시 국가에서 빼는 것도 결정입니다
- Localization 패키지의 Pseudo-Locale 을 켜면 "일부러 길고 이상한 문자"로 전체 UI 를 자동 스트레스 테스트할 수 있습니다
절차적 생성 — 랜덤과 시드
게임의 랜덤은 "아무렇게나"가 아닙니다. 재현할 수 있는 랜덤이어야 버그를 잡고 치트를 막습니다.
두 개의 Random — 용도가 다릅니다
| UnityEngine.Random | System.Random | |
|---|---|---|
| 상태 | 전역 하나 — 누가 어디서든 소비 | 인스턴스별 — 시스템마다 독립 |
| 시드 제어 | InitState 가능하지만 전역이라 오염되기 쉬움 | 생성자에 시드 — 재현에 안전 |
| 스레드 | 메인 스레드 전용 | 인스턴스를 나누면 Job 에서도 사용 가능 |
| 적합한 곳 | 연출용 잔랜덤 (파티클 흩뿌리기 등) | 맵 생성 · 드랍 · 가챠 — 재현이 필요한 전부 |
Random.value 를 소비하면
그 뒤의 모든 값이 밀려 같은 시드인데 다른 맵이 나옵니다.
시스템마다 System.Random 인스턴스를 따로 드세요.
시드 고정 — 재현 가능한 랜덤
// 데일리 던전 — 전 세계 유저가 같은 날 같은 맵
int seed = int.Parse(DateTime.UtcNow.ToString("yyyyMMdd"));
// ★ 용도별로 스트림을 분리 — 서로 영향을 주지 않게
var mapRng = new System.Random(seed);
var lootRng = new System.Random(seed ^ 0x5EED);
var aiRng = new System.Random(seed ^ 0x0A1);
// 버그 제보 대응 — 생성 때 쓴 시드를 로그·세이브에 남긴다
Debug.Log($"[MapGen] seed={seed}");
// → "이상한 맵이 나왔어요" 제보에 시드만 받으면 즉시 재현됩니다
가중치 뽑기 — 가챠 확률표
// 확률표는 기획 데이터 — 합이 100 이 아니어도 동작합니다
static readonly (string id, float w)[] Table =
{
("SSR", 1.5f), ("SR", 8.5f), ("R", 30f), ("N", 60f)
};
public static string Pick(System.Random rng)
{
float total = 0f;
foreach (var e in Table) total += e.w;
double r = rng.NextDouble() * total; // 0 ≤ r < total
foreach (var e in Table)
{
r -= e.w;
if (r < 0) return e.id;
}
return Table[Table.Length - 1].id; // 부동소수 오차 대비
}
셔플 — Fisher-Yates 가 유일한 정답
public static void Shuffle<T>(IList<T> list, System.Random rng)
{
for (int i = list.Count - 1; i > 0; i--)
{
int j = rng.Next(i + 1); // ★ 0..i "포함" — i 로 쓰면 편향
(list[i], list[j]) = (list[j], list[i]);
}
}
// 카드 덱 — 한 번 섞고 위에서 뽑으면 "중복 없는 랜덤"이 공짜
Shuffle(deck, rng);
var hand = deck.Take(5);
OrderBy(x => Random.value) 셔플은 쓰지 마세요.
정렬 비교 중 값이 바뀌어 결과가 편향되고, LINQ 할당으로 GC 도 만듭니다.
여덟 줄짜리 Fisher-Yates 가 언제나 옳습니다.
맵 생성 입문 — 그리드 랜덤워크
// 64×64 그리드에 방 120칸짜리 동굴 파기 — 가장 단순한 절차적 맵
bool[,] map = new bool[64, 64];
var rng = new System.Random(seed);
int x = 32, y = 32, carved = 0;
while (carved < 120)
{
if (!map[x, y]) { map[x, y] = true; carved++; }
switch (rng.Next(4)) // 상하좌우 랜덤 이동
{
case 0: x = Math.Min(x + 1, 62); break;
case 1: x = Math.Max(x - 1, 1); break;
case 2: y = Math.Min(y + 1, 62); break;
case 3: y = Math.Max(y - 1, 1); break;
}
}
// 같은 seed → 언제나 같은 맵. 여기서 셀룰러 오토마타·BSP 로 발전합니다
랜덤워크는 반드시 이어진 동굴이 나온다는 것이 장점입니다 — 입문은 여기서 시작해 방 배치(BSP)·매끈한 동굴(셀룰러 오토마타)로 넓혀 가세요.
서버 검증과 시드 공유 — 치트 방지 연결
- 서버가 시드를 발급 — 던전 입장 때 시드를 주고 클라이언트가 맵·드랍을 생성
- 클리어 보고 때 서버가 같은 시드로 재계산 — 드랍 결과가 다르면 조작입니다
- 클라이언트가 시드를 고를 수 있으면 "좋은 시드"만 골라 리세마라합니다 — 발급은 반드시 서버가
- 데일리 콘텐츠 랭킹은 같은 시드 = 같은 조건이라 공정성 시비가 사라집니다
Time.deltaTime·프레임 순서·전역 Random 처럼
실행마다 달라지는 값을 섞으면 서버 재계산이 어긋납니다.
입력은 오직 시드와 데이터 테이블 — 이 규칙만 지키면 검증은 공짜입니다.
게임 필 — 주스와 접근성
같은 로직인데 어떤 게임은 "손맛"이 납니다. 수십 줄짜리 연출 코드가 체감 품질을 바꿉니다.
히트스톱 — 타격의 0.05초
// 타격 순간 시간을 잠깐 멈춰 "박히는 느낌"을 만듭니다
public class HitStop : MonoBehaviour
{
static Coroutine s_run;
public static void Play(MonoBehaviour host, float dur = 0.05f)
{
if (s_run != null) return; // 중첩 방지
s_run = host.StartCoroutine(Run(dur));
}
static IEnumerator Run(float dur)
{
Time.timeScale = 0.05f;
yield return new WaitForSecondsRealtime(dur); // ★ Realtime 필수
Time.timeScale = 1f;
s_run = null;
}
}
Time.timeScale 은 전역 하나뿐입니다.
일시정지·슬로모션 연출과 히트스톱이 서로 덮어쓰면
게임이 0.05 배속에 갇히는 사고가 납니다.
timeScale 을 만지는 코드는 한 매니저로 모으고,
복구값은 1이 아니라 "이전 값"으로 되돌리세요.
화면 흔들림은 카메라 좌표를 직접 만지지 말고 Cinemachine Impulse 로 — 추적·연출과 충돌하지 않고 감쇠도 공짜입니다.
DOTween 실전 — UI 가 살아나는 세 가지
// ① 버튼 눌림 — 펀치 스케일
button.transform.DOPunchScale(Vector3.one * 0.12f, 0.25f, vibrato: 6)
.SetUpdate(true); // ★ 일시정지(timeScale 0) 중에도 동작
// ② 팝업 열기 — 스케일 + 페이드
popup.transform.DOScale(1f, 0.25f).From(0.85f).SetEase(Ease.OutBack);
popupGroup.DOFade(1f, 0.2f).From(0f);
// ③ 데미지 텍스트 — 떠오르며 사라짐
DOTween.Sequence()
.Append(txt.transform.DOMoveY(pos.y + 0.8f, 0.5f))
.Join(txt.DOFade(0f, 0.35f).SetDelay(0.15f))
.SetLink(txt.gameObject); // ★ 오브젝트 파괴 시 트윈도 자동 정리
SetLink 를 습관으로 붙이거나 OnDestroy 에서
transform.DOKill() — 풀링(GAME 02)과 함께 쓸 때는
반납 전에 반드시 Kill 하세요. 재사용된 오브젝트를 옛 트윈이 움직입니다.
코요테 타임 · 입력 버퍼 — 플랫포머 관용
[SerializeField] float coyoteTime = 0.1f; // 발판을 벗어나도 잠깐 점프 허용
[SerializeField] float bufferTime = 0.15f; // 착지 직전 입력을 기억해 두기
float _coyote, _buffer;
void Update()
{
_coyote = IsGrounded() ? coyoteTime : _coyote - Time.deltaTime;
_buffer = JumpPressed() ? bufferTime : _buffer - Time.deltaTime;
if (_coyote > 0f && _buffer > 0f)
{
_coyote = 0f; _buffer = 0f; // ★ 소모 처리 — 이중 점프 방지
Jump();
}
}
"조작이 구리다"는 리뷰의 절반은 이 두 값이 0 이라서입니다. 엄격한 판정은 정확한 게 아니라 야박한 것입니다 — 유저의 의도를 0.1초만 관대하게 읽어 주면 같은 게임이 다르게 느껴집니다.
Handheld.Vibrate() 는 길고 투박한 진동 하나뿐이라 게임용으로는 부족합니다 —
iOS·Android 의 짧은 임팩트 햅틱은 플러그인(Nice Vibrations 등)으로 쓰고,
끄기 옵션을 반드시 함께 제공하세요.
접근성 — 옵션 몇 개가 유저를 넓힙니다
| 항목 | 구현 |
|---|---|
| 색약 대응 | 색만으로 구분 금지 — 모양·아이콘·패턴을 병행 (적/아군, 희귀도) |
| 폰트 크기 | 소·중·대 3단 — TMP Auto Size 상한을 옵션과 연동 |
| 화면 흔들림 | 강도 0~100% 슬라이더 — 멀미 유저에게는 접근성이 아니라 필수 |
| 감도 설정 | 조준·카메라 감도 슬라이더 + 축 반전 |
| 진동 · 플래시 | 끄기 옵션 — 점멸 연출은 광과민성 발작 위험까지 있습니다 |
| 자동 조준 보정 | 모바일 슈터는 없으면 게임이 성립하지 않습니다 |
같은 세계를
여러 명이
동시에 본다
게임 서버의 어려움은 성능이 아니라 "모두가 같은 것을 보게 만드는 일" 입니다. 지연은 반드시 있고, 패킷은 사라지며, 누군가는 반드시 조작을 시도합니다. 여기까지 마치면 실시간 대전이 가능한 서버를 설계하고 치트를 막을 수 있습니다.
구조 총정리 — 무엇을 만들 것인가
장르에 따라 필요한 서버가 완전히 다릅니다. 여기서 잘못 고르면 전부 다시 만들게 됩니다.
세 가지 구조
| 장르 | 구조 | 동기화 주기 |
|---|---|---|
| 턴제 · 보드 | 일반 API 로 충분 | 행동할 때만 |
| 캐주얼 협동 | Listen · P2P | 10~20Hz |
| MOBA · 슈터 | Dedicated | 20~60Hz |
| MMO | Dedicated + 분산 | 구역별 10~20Hz |
직접 만들 것인가
기성품을 쓰는 게 낫다
- 팀에 서버 전담이 없다
- 출시까지 시간이 짧다
- 동시접속 수천 명 수준
- → Photon · Mirror · Netcode
직접 만들 만하다
- 초당 수만 패킷 · 대규모 CCU
- 독자 프로토콜·물리 검증 필요
- 비용을 통제해야 한다
- → .NET 소켓 직접
TCP · UDP · 신뢰성 UDP
실시간 게임이 UDP 를 쓰는 이유는 속도가 아니라 "기다리지 않기 위해서" 입니다.
무엇을 어디로 보낼 것인가
| 데이터 | 전송 | 이유 |
|---|---|---|
| 이동 좌표 · 회전 | UDP 비신뢰 | 다음 것이 곧 온다 |
| 발사 · 스킬 사용 | UDP 신뢰 | 빠뜨리면 안 됨 |
| 채팅 · 아이템 획득 | TCP 또는 신뢰 UDP | 순서·보장 필요 |
| 로그인 · 결제 | TCP(HTTPS) | 정확성 최우선 |
신뢰성 UDP 의 최소 구성
// 헤더에 번호와 확인용 비트를 실어 보낸다 (라이브러리가 해 주는 일)
[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct Header
{
public ushort Seq; // 이 패킷의 번호
public ushort Ack; // 내가 마지막으로 받은 번호
public uint AckBits; // 그 앞 32개를 받았는지 비트로
public byte Channel; // 0=비신뢰 1=신뢰 2=순서보장
}
- 재전송 — Ack 가 안 오면 다시 보냅니다(신뢰 채널만)
- 순서 — 필요할 때만 (전부 순서를 지키면 TCP 와 같아집니다)
- 조각화 — MTU(약 1200바이트)를 넘으면 나눠 보냅니다
- 혼잡 제어 — 손실률이 높으면 전송 주기를 낮춥니다
소켓 서버 — .NET 으로 직접
수천 명을 한 프로세스에서 받는 서버의 뼈대. 할당 없는 수신 루프가 핵심입니다.
TCP 수신 루프
var listener = new TcpListener(IPAddress.Any, 7777);
listener.Start(backlog: 512);
while (!ct.IsCancellationRequested)
{
var client = await listener.AcceptTcpClientAsync(ct);
client.NoDelay = true; // ★ Nagle 끄기 (지연 40ms 제거)
_ = HandleAsync(client, ct); // 세션마다 독립 처리
}
static async Task HandleAsync(TcpClient c, CancellationToken ct)
{
var buf = ArrayPool<byte>.Shared.Rent(4 + 64 * 1024); // ★ 헤더 + 최대 메시지가 들어가야 함
try
{
var stream = c.GetStream();
var filled = 0;
while (true)
{
var n = await stream.ReadAsync(buf.AsMemory(filled), ct);
if (n == 0) break; // 연결 종료
filled += n;
// ★ TCP 는 '메시지 경계'가 없다 — 길이 헤더로 잘라야 한다
var offset = 0;
while (filled - offset >= 4)
{
var len = BinaryPrimitives.ReadInt32LittleEndian(
buf.AsSpan(offset));
if (len <= 0 || len > 64 * 1024) throw new InvalidDataException();
if (filled - offset - 4 < len) break; // 아직 덜 왔다
Dispatch(buf.AsSpan(offset + 4, len));
offset += 4 + len;
}
Buffer.BlockCopy(buf, offset, buf, 0, filled - offset);
filled -= offset;
}
}
finally { ArrayPool<byte>.Shared.Return(buf); c.Dispose(); }
}
반드시 챙길 것
- 메시지 경계 — TCP 는 스트림입니다. 길이 헤더로 직접 자르세요. 이걸 빼먹으면 "가끔 패킷이 깨진다" 가 됩니다
- 길이 상한 — 헤더의 길이를 그대로 믿고 버퍼를 잡으면 메모리 폭탄 공격에 당합니다
- NoDelay = true — 안 켜면 작은 패킷이 40ms 씩 지연됩니다
- 연결 상한 · 유휴 타임아웃 — 접속만 하고 아무것도 안 하는 연결을 끊으세요
- ArrayPool — 세션마다 버퍼를 새로 잡으면 GC 가 무너집니다
System.IO.Pipelines 를 보세요.
버퍼 관리·역압(backpressure)을 대신 해 주어
위 코드의 복잡한 부분이 사라지고 할당도 거의 0 이 됩니다.
패킷 설계 · 직렬화
초당 수만 개가 오갑니다. 1바이트를 줄이는 것이 대역폭 비용으로 돌아옵니다.
패킷 정의
// 공용 프로젝트에 두고 클라이언트·서버가 함께 참조합니다
public enum PacketId : ushort
{
None = 0,
C_Move = 1, C_Fire = 2, C_Chat = 3,
S_Spawn = 100, S_State = 101, S_Hit = 102,
}
[MessagePackObject]
public struct MovePacket
{
[Key(0)] public uint Tick; // ★ 시간 기준 (뒤에서 씁니다)
[Key(1)] public short X, Y; // 좌표를 정수로 압축 (cm 단위)
[Key(2)] public byte YawDeg; // 회전은 1바이트로 충분
[Key(3)] public byte Flags; // 점프·달리기 등을 비트로
}
- float 대신 정수 — 위치를 cm 단위
short로 담으면 4바이트→2바이트 - 불리언은 비트로 — 8개를 1바이트에
- 변하지 않는 값은 보내지 않기 — 처음 한 번만
- 델타 압축 — 직전과 같으면 생략
보내는 양 줄이기
// ① 관심 영역(AOI) — 내 주변만 보낸다
foreach (var viewer in room.Players)
{
var near = room.Grid.Query(viewer.Pos, radius: 30f); // 격자 검색
viewer.Send(BuildState(near)); // 전체가 아니라 주변 것만
}
// ② 우선순위 — 가까운 것은 자주, 먼 것은 가끔
var rate = dist < 10 ? 20 : dist < 30 ? 10 : 3; // Hz
// ③ 묶어 보내기 — 작은 패킷 여러 개보다 한 번에
_batch.Add(packet);
if (_batch.Count >= 16 || _timer > 0.05f) FlushBatch();
룸 · 세션 · 게임 루프
서버도 게임처럼 일정 주기로 도는 루프가 있습니다. 락을 없애는 구조가 여기서 나옵니다.
룸 하나 = 스레드 하나
public sealed class GameRoom
{
private readonly Channel<IPacket> _inbox =
Channel.CreateUnbounded<IPacket>(new() { SingleReader = true });
public ValueTask PushAsync(IPacket p) => _inbox.Writer.WriteAsync(p);
public async Task RunAsync(CancellationToken ct)
{
var tick = TimeSpan.FromMilliseconds(50); // 20Hz
var next = DateTime.UtcNow;
while (!ct.IsCancellationRequested)
{
// ① 들어온 입력을 전부 처리 (이 룸의 데이터는 나만 만진다 → 락 불필요)
while (_inbox.Reader.TryRead(out var p)) Handle(p);
// ② 게임 로직 한 틱
Simulate((float)tick.TotalSeconds);
// ③ 상태 방송
Broadcast();
next += tick;
var wait = next - DateTime.UtcNow;
if (wait > TimeSpan.Zero) await Task.Delay(wait, ct);
else next = DateTime.UtcNow; // 밀렸으면 따라잡지 않는다
}
}
}
이 구조의 장점
- 락이 사라집니다 — 룸 데이터는 그 루프만 만집니다
- 재현이 쉽습니다 — 입력 순서가 정해져 있어 버그 재현이 가능합니다
- 수평 확장이 자연스럽습니다 — 룸을 다른 서버로 옮기면 됩니다
- 틱 시간을 측정해 룸당 부하를 바로 알 수 있습니다
// 저장은 비동기로 흘려보낸다
_saveQueue.Enqueue(new SaveJob(playerId, snapshot));
// 세션 정리 — 끊긴 연결을 방치하면 룸이 안 닫힙니다
if (now - p.LastPacketAt > TimeSpan.FromSeconds(20)) Kick(p, "timeout");
권위 서버 — 클라이언트를 믿지 않기
멀티플레이 보안의 전부입니다. 이 원칙 하나로 대부분의 치트가 막힙니다.
무엇을 보내야 하는가
클라이언트가 결과를 보낸다
// "나 (100, 50)으로 이동했어"
Send(new Move { X = 100, Y = 50 });
// "적에게 9999 데미지 줬어"
Send(new Hit { Dmg = 9999 });
// → 메모리 수정 한 번이면 끝
클라이언트는 의도만
// "앞으로 가려고 함"
Send(new Input { Dir = 1, Tick = t });
// "이 방향으로 쐈음"
Send(new Fire { Dir = d, Tick = t });
// → 결과는 서버가 계산
// 서버: 입력을 받아 직접 계산하고 검증한다
void OnInput(Player p, InputPacket input)
{
// ① 시간 검증 — 미래·과거 입력은 버린다
if (input.Tick > _tick + 2 || input.Tick < _tick - 20) return;
// ② 이동은 서버가 계산 (속도 상한 적용)
var move = Clamp(input.Dir, p.MaxSpeed) * DeltaTime;
var next = p.Pos + move;
// ③ 지형 충돌도 서버가 판정
if (!_map.IsWalkable(next)) return;
p.Pos = next;
}
검증 항목
- 이동 속도 — 두 틱 사이 이동 거리가 상한을 넘으면 거부
- 쿨타임 — 스킬 사용 간격을 서버가 기록
- 사거리·시야 — 벽 너머의 적을 맞힐 수 없게
- 데미지 — 공식은 서버에만. 클라이언트는 표시만
- 인벤토리 — 가진 적 없는 아이템 사용 차단
- 입력 빈도 — 초당 입력 수 상한 (자동 연사 매크로)
클라이언트 예측 · 보간 · 지연 보상
서버가 진실을 갖되 조작은 즉각 반응해야 합니다. 상용 슈터가 쓰는 세 가지 기법입니다.
예측과 재조정
// 클라이언트 — 보낸 입력을 기억해 둔다
private readonly Queue<PendingInput> _pending = new();
void SendInput(InputPacket input)
{
_pending.Enqueue(new PendingInput(input, _tick));
ApplyLocally(input); // ★ 서버를 기다리지 않고 바로 움직임
net.Send(input);
}
// 서버 상태가 도착하면
void OnServerState(StatePacket s)
{
// 서버가 확인해 준 입력까지는 버린다
while (_pending.Count > 0 && _pending.Peek().Tick <= s.LastProcessedTick)
_pending.Dequeue();
if (Vector3.Distance(transform.position, s.Pos) > 0.05f)
{
transform.position = s.Pos; // 서버 위치로 되돌리고
foreach (var p in _pending) ApplyLocally(p.Input); // 남은 입력을 다시 적용
}
}
보간과 지연 보상
// ① 다른 플레이어 — 받은 상태 두 개 사이를 보간
const float InterpDelay = 0.1f; // 100ms 뒤처져 그린다
var renderTime = Now - InterpDelay;
var (a, b) = _buffer.Around(renderTime);
transform.position = Vector3.Lerp(a.Pos, b.Pos,
(renderTime - a.Time) / (b.Time - a.Time));
// ② 지연 보상 — 쏜 시점의 과거로 되돌려 판정 (히트스캔)
bool Hit(Player shooter, Ray ray, uint clientTick)
{
var rewind = _history.Snapshot(clientTick); // 그때의 모든 위치
return rewind.Raycast(ray, out var target);
}
매치메이킹 · 방 배정
"비슷한 실력끼리, 빠르게" — 두 목표는 서로 충돌합니다.
기본 구조
public sealed class MatchMaker
{
private readonly List<Ticket> _queue = new();
public void Enqueue(Ticket t) { lock (_queue) _queue.Add(t); }
// 주기적으로 돌며 짝을 맞춘다
public void Tick()
{
lock (_queue)
{
_queue.Sort((a, b) => a.Rating.CompareTo(b.Rating));
for (int i = 0; i + 1 < _queue.Count; )
{
var (a, b) = (_queue[i], _queue[i + 1]);
// ★ 기다린 시간만큼 실력 허용 범위를 넓힌다
var band = 50 + a.WaitSeconds * 20;
if (Math.Abs(a.Rating - b.Rating) <= band)
{
CreateRoom(a, b);
_queue.RemoveRange(i, 2);
}
else i++;
}
}
}
}
현실에서 고려할 것
- 지역(핑) — 실력보다 지연이 경험을 더 망칩니다. 지역별 큐가 기본
- 대기 시간 상한 — 오래 기다리면 조건을 풀어 주세요. 3분은 이탈입니다
- 파티 — 인원이 다른 그룹을 섞는 규칙이 필요합니다
- 재접속 — 끊긴 사람이 돌아올 자리를 남겨 둘지
- 봇 투입 — 새벽처럼 사람이 적을 때
- 신규 보호 — 초반 몇 판은 쉬운 상대와
기성 솔루션 — Mirror · Netcode · Photon
직접 만들지 않기로 했다면, 무엇을 고를 것인가.
비교
| 특징 | 맞는 곳 | |
|---|---|---|
| Mirror | 무료·오픈소스 자료 많음 | 중소 규모 · 전용 서버 직접 운영 |
| Netcode for GameObjects | 유니티 공식 엔진 통합 | 협동 게임 · 유니티 서비스 연계 |
| Photon Fusion | 상용·클라우드 예측 내장 | 빠른 출시 · 서버 운영 부담 최소 |
| 직접 구현 | 완전 통제 | 대규모 · 독자 프로토콜 |
// Netcode 예 — 서버에게 요청하고(RPC), 상태는 자동 동기화
public class PlayerNet : NetworkBehaviour
{
private NetworkVariable<int> _hp = new(100,
NetworkVariableReadPermission.Everyone,
NetworkVariableWritePermission.Server); // ★ 서버만 쓰기
[Rpc(SendTo.Server)]
public void FireRpc(Vector3 dir)
{
if (!IsServer) return;
if (Time.time - _lastFire < 0.2f) return; // 서버가 쿨타임 검증
_lastFire = Time.time;
SpawnBullet(dir);
}
}
고를 때 확인할 것
- 전용 서버(헤드리스) 지원 — Listen 서버만 되면 경쟁 게임에 못 씁니다
- 비용 모델 — CCU 과금은 성공할수록 부담이 커집니다. 미리 계산해 보세요
- 예측·지연 보상 내장 여부 — 직접 구현하려면 몇 달입니다
- 재접속·룸 이전 지원
- 플랫폼 — 콘솔 인증 지원 여부
NetworkVariable 의 쓰기 권한을 Everyone 으로 열어 두면
직접 만든 서버보다 더 쉽게 뚫립니다.
채팅 · 랭킹 · 길드
전투 밖의 실시간 기능들. Redis 와 SignalR 로 대부분 해결됩니다.
랭킹 — Redis Sorted Set
// 점수 갱신 (O(log n))
await db.SortedSetAddAsync("rank:season3", playerId, score);
// 내 순위 (0부터, 랭킹에 없으면 null)
var rank = await db.SortedSetRankAsync("rank:season3", playerId, Order.Descending);
// 상위 100명
var top = await db.SortedSetRangeByRankWithScoresAsync(
"rank:season3", 0, 99, Order.Descending);
// 내 주변 순위 (앞뒤 5명) — 음수 인덱스는 "끝에서부터"라 0 에서 자릅니다
if (rank is long r)
{
var around = await db.SortedSetRangeByRankAsync(
"rank:season3", Math.Max(0, r - 5), r + 5, Order.Descending);
}
(timestamp 역수) 를 소수점으로 섞는 방식이 흔합니다.
채팅 — 서버 간 전달
// 서버가 여러 대면 Redis Pub/Sub 으로 이어 준다
await sub.SubscribeAsync("chat:global", (_, msg) =>
{
var m = JsonSerializer.Deserialize<ChatMsg>(msg!);
hub.Clients.Group("global").SendAsync("Chat", m); // 내 서버 접속자에게
});
public async Task SendGlobal(ChatMsg m)
{
if (!_rateLimit.TryConsume(m.SenderId)) return; // 도배 방지
m.Text = _filter.Mask(m.Text); // 금칙어
await pub.PublishAsync("chat:global", JsonSerializer.Serialize(m));
}
- 도배 제한 — 초당 1~2회, 같은 문장 반복 차단
- 금칙어·신고 — 운영 도구가 반드시 필요합니다
- 로그 보관 — 분쟁 시 근거. 보관 기간을 정책으로 명시
- 차단 목록 — 서버에서 필터링(클라이언트 차단은 우회됩니다)
부하 테스트 — 봇으로 미리 겪기
출시 당일에 알게 되면 늦습니다. 봇 1만 개를 붙여 미리 무너뜨려 보세요.
봇 클라이언트
// 실제 프로토콜을 그대로 쓰는 가짜 클라이언트
static async Task RunBotAsync(int id, CancellationToken ct)
{
using var cli = new GameClient();
await cli.ConnectAsync(host, port, ct);
await cli.LoginAsync($"bot{id}", ct);
await cli.JoinRandomRoomAsync(ct);
var rnd = new Random(id);
while (!ct.IsCancellationRequested)
{
await cli.SendInputAsync(new Input {
Dir = rnd.Next(0, 8), Fire = rnd.Next(10) == 0 }, ct);
await Task.Delay(50, ct); // 20Hz
}
}
// 동시에 띄우기 — 한 프로세스에서 수천 개도 가능합니다
// ★ 봇은 끝나지 않는 루프라 Parallel.ForEachAsync(동시 200 제한)로 돌리면
// 앞의 200개만 영원히 돌고 나머지는 시작도 못 합니다 — 전부 한꺼번에 띄웁니다
await Task.WhenAll(Enumerable.Range(0, 5000).Select(i => RunBotAsync(i, ct)));
무엇을 볼 것인가
- 틱 시간 — 20Hz 라면 50ms 를 넘는 순간이 언제부터인지
- CPU · GC — Gen2 수집이 잦아지면 설계 문제입니다
- 대역폭 — 인당 KB/s × CCU = 월 트래픽 비용
- 접속 폭주 — 오픈 직후 동시에 몰리는 상황을 따로 테스트
- 재접속 폭풍 — 서버를 껐다 켰을 때 전원이 동시에 붙습니다
네 가지 부하 유형 — 목적이 다릅니다
| 유형 | 어떻게 | 답하는 질문 | 언제 |
|---|---|---|---|
| 부하 테스트 | 목표 CCU 를 30분 유지 | 목표에서 지표가 SLO 안에 드는가 | 배포 전 매번 |
| 스트레스 테스트 | 무너질 때까지 계속 올림 | 어디가 먼저 터지나 · 한계는 얼마인가 | 분기 1회 · 구조 변경 시 |
| 스파이크 테스트 | 0 → 목표를 30초 만에 | 점검 종료·이벤트 오픈을 견디나 | 출시 전 필수 |
| 내구 테스트 | 중간 부하를 6~24시간 | 메모리 누수 · 커넥션 누수 · 로그 폭증 | 출시 전 1회 이상 |
봇을 제대로 만들기 — 실제와 같아야 의미가 있습니다
// 봇은 "실제 사용자 행동"을 흉내 내야 합니다
// 모두가 같은 방에 들어가 같은 입력을 보내면 캐시가 100% 적중해
// 실제보다 훨씬 좋은 숫자가 나옵니다 — 가장 흔한 실수입니다
static async Task RunBotAsync(int id, ScenarioConfig cfg, CancellationToken ct)
{
var rnd = new Random(id); // ★ 봇마다 다른 시드
// ① 접속 타이밍을 흩뿌린다 (실제 사용자는 동시에 안 옵니다)
await Task.Delay(rnd.Next(0, cfg.RampUpMs), ct);
using var cli = new GameClient();
var sw = Stopwatch.StartNew();
await cli.ConnectAsync(cfg.Host, cfg.Port, ct);
Metrics.ConnectMs.Record(sw.Elapsed.TotalMilliseconds);
await cli.LoginAsync($"bot{id}", ct);
while (!ct.IsCancellationRequested)
{
// ② 행동 분포를 실제 로그에서 뽑아 씁니다
var roll = rnd.NextDouble();
if (roll < 0.60) await PlayMatchAsync(cli, rnd, ct); // 60% 전투
else if (roll < 0.80) await BrowseShopAsync(cli, ct); // 20% 상점
else if (roll < 0.92) await CheckMailAsync(cli, ct); // 12% 우편
else await ViewRankingAsync(cli, ct); // 8% 랭킹
// ③ 생각하는 시간 — 없으면 실제보다 10배 부하가 됩니다
await Task.Delay(rnd.Next(2000, 8000), ct);
}
}
static async Task PlayMatchAsync(GameClient cli, Random rnd, CancellationToken ct)
{
await cli.JoinRandomRoomAsync(ct);
var end = DateTime.UtcNow.AddMinutes(3);
while (DateTime.UtcNow < end && !ct.IsCancellationRequested)
{
var t0 = Stopwatch.GetTimestamp();
await cli.SendInputAsync(new Input {
Dir = rnd.Next(0, 8), Fire = rnd.Next(10) == 0 }, ct);
Metrics.InputRtt.Record(Stopwatch.GetElapsedTime(t0).TotalMilliseconds);
await Task.Delay(50, ct); // 20Hz
}
await cli.LeaveRoomAsync(ct);
}
무엇을 재고 어떻게 읽나
| 지표 | 정상 | 나쁘면 원인 |
|---|---|---|
| 틱 시간 p99 | < 주기의 50% | 룸 로직 · GC · 락 경합 |
| 입력 RTT p99 | < 100ms | 틱 지연 · 네트워크 · 큐 적체 |
| Gen2 GC 횟수 | 분당 < 1 | 루프에서 할당 — 가장 흔합니다 |
| GC 일시정지 비율 | < 2% | LOH · 큰 객체 · 서버 GC 미설정 |
| 인당 대역폭 | 목표 이하 | AOI 미적용 · 패킷 크기 |
| 접속 성공률 | 100% | accept 백로그 · 파일 디스크립터 한계 |
| 스레드풀 큐 | 0~5 | 동기 블로킹 → 🔬 고급 탭 06 |
| 메모리 기울기 | 평평 | 세션·룸 객체 누수 |
결과를 읽는 법 — 실제 예시
CCU 틱p99 RTTp99 CPU Gen2/분 판정
1,000 12ms 38ms 18% 0.1 여유
3,000 18ms 52ms 41% 0.3 정상
5,000 31ms 78ms 63% 0.8 정상
7,000 47ms 140ms 78% 2.4 ★ 주의 — 틱이 주기(50ms)에 근접
8,000 112ms 380ms 82% 6.1 붕괴 시작
9,000 340ms 900ms 85% 11.0 사실상 서비스 불가
→ 안전 운영선은 5,000. 7,000 이 한계.
CPU 는 82% 밖에 안 되는데 무너졌다는 것에 주목하세요.
범인은 CPU 가 아니라 GC 입니다 (Gen2 가 0.8 → 6.1 로 급증).
이 서버는 증설이 아니라 "루프에서 할당 없애기"가 답입니다.
- 평균은 거짓말을 합니다 — 평균 20ms 라도 p99 가 200ms 면 100명 중 1명은 매 순간 렉을 겪습니다. 항상 p95·p99 로 판단하세요
- 무너지기 직전 구간이 가장 중요한 정보입니다. "몇 명까지 되나"보다 "어떤 지표가 먼저 나빠졌나"가 개선 방향을 알려 줍니다
- 테스트마다 커밋 해시와 설정을 함께 기록하세요. 나중에 "언제부터 나빠졌나"를 추적할 수 있는 유일한 방법입니다
- CI 에 가벼운 부하 테스트를 넣으세요 — 소규모(500 CCU · 3분)라도 성능 회귀를 배포 전에 잡습니다
- 일반 REST API 는 k6·NBomber 로 충분합니다. 게임 프로토콜(UDP·커스텀 바이너리)은 직접 만든 봇 외에 방법이 없습니다
운영 · 장애 대응
서버는 반드시 죽습니다. 죽었을 때 무엇을 잃느냐를 설계해 두는 일입니다.
최소한의 준비
- 주기적 스냅샷 — 룸 상태를 30초마다 저장해 두면 재시작 시 복구 가능
- 재접속 유예 — 끊겨도 60초간 자리를 유지
- 점검 모드 — 새 접속만 막고 진행 중인 판은 끝내게(드레인)
- 버전 게이트 — 구버전 클라이언트 접속 차단
- 보상 지급 도구 — 장애 후 우편으로 일괄 지급
// 그레이스풀 셧다운 — 진행 중인 방을 끝내고 종료
app.Lifetime.ApplicationStopping.Register(() =>
{
_matchMaker.StopAccepting(); // 새 매칭 중단
foreach (var room in _rooms) room.NotifyShutdown(seconds: 60);
_drainCompleted.Wait(TimeSpan.FromSeconds(90));
});
// ★ 컨테이너라면 종료 유예(k8s terminationGracePeriodSeconds 기본 30초)를
// 90초보다 길게 — 아니면 드레인 도중 강제 종료됩니다
관측
// 게임 서버 고유 지표를 반드시 내보내세요
static readonly Meter Meter = new("Game.Server");
static readonly Histogram<double> TickMs =
Meter.CreateHistogram<double>("room.tick.ms");
static readonly UpDownCounter<int> Ccu =
Meter.CreateUpDownCounter<int>("player.ccu");
var sw = Stopwatch.StartNew();
Simulate(dt);
TickMs.Record(sw.Elapsed.TotalMilliseconds,
new KeyValuePair<string, object?>("room", room.Mode));
WebSocket 서버 — 웹 · 캐주얼 게임의 표준
브라우저에서도, 회사 방화벽 뒤에서도 그냥 연결됩니다. raw TCP 를 열기 전에 이것부터 검토하세요.
왜 raw TCP 가 아니라 WebSocket 인가
- 브라우저에서 쓸 수 있는 유일한 소켓입니다 — 웹 게임·WebGL 빌드는 raw TCP/UDP 를 열 방법 자체가 없습니다
- 방화벽·프록시를 통과합니다 — 443 포트의 HTTPS 로 시작해 업그레이드되므로 회사·학교 망에서도 막히지 않습니다
- 메시지 경계를 프로토콜이 지켜 줍니다 — raw TCP 처럼 길이 헤더로 직접 자르는 코드(03 섹션)가 필요 없습니다
- TLS 가 공짜에 가깝습니다 —
wss://는 기존 HTTPS 인증서·인프라를 그대로 씁니다
| raw TCP/UDP | WebSocket | |
|---|---|---|
| 브라우저 | 불가 | 가능 |
| 방화벽 통과 | 포트 열어야 함 | 443 그대로 |
| 메시지 경계 | 직접 구현 | 내장 |
| 지연 특성 | UDP 가 유리 | TCP 기반 — 재전송 대기 있음 |
| 인프라 | 전용 포트 · LB 설정 | 기존 HTTP 인프라 재사용 |
ASP.NET Core 최소 구현
var app = WebApplication.CreateBuilder(args).Build();
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(15), // 프로토콜 핑을 대신 보내 줌
});
app.Map("/ws", async (HttpContext ctx) =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{ ctx.Response.StatusCode = 400; return; }
// ★ 업그레이드 전에 인증 — 헤더를 못 붙이는 클라가 있어 쿼리스트링으로
var player = await AuthAsync(ctx.Request.Query["access_token"]);
if (player is null) { ctx.Response.StatusCode = 401; return; }
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var buf = new byte[16 * 1024];
try
{
while (ws.State == WebSocketState.Open)
{
var r = await ws.ReceiveAsync(buf.AsMemory(), ctx.RequestAborted);
if (r.MessageType == WebSocketMessageType.Close) break;
if (!r.EndOfMessage) // ★ 큰 메시지는 조각나서 온다
throw new InvalidDataException("메시지 상한 초과");
// ★ 복사해서 넘깁니다 — buf 는 다음 수신에 덮어씌워집니다
await _room.PushAsync(player, buf.AsSpan(0, r.Count).ToArray());
}
}
finally
{
await _room.LeaveAsync(player); // 예외로 끊겨도 반드시 정리
}
});
app.Run();
메시지 프레이밍 — 텍스트로 시작
// ① 텍스트(JSON) — 디버깅이 쉽다. 캐주얼 게임은 대부분 이걸로 충분
await ws.SendAsync(JsonSerializer.SerializeToUtf8Bytes(
new { type = "move", x, y, tick }),
WebSocketMessageType.Text, endOfMessage: true, ct);
// ② 바이너리(MessagePack) — 같은 내용이 1/3 크기. 트래픽이 커지면 전환
await ws.SendAsync(MessagePackSerializer.Serialize(new MovePacket(x, y, tick)),
WebSocketMessageType.Binary, true, ct);
type필드를 반드시 — 메시지 종류를 구분할 유일한 수단입니다. 04 섹션의 PacketId 와 같은 역할입니다- 텍스트 프레임은 브라우저 개발자 도구에 내용이 그대로 보입니다 — 개발 속도가 완전히 다릅니다
- 전환 기준은 감이 아니라 대역폭 청구서입니다 — 인당 KB/s × CCU 를 계산해 보고 바꾸세요
하트비트 · 재접속
// 프로토콜 핑만 믿지 말고 앱 레벨 하트비트를 따로 둡니다
// (중간 장비가 핑에 대신 응답해 '죽은 연결'이 살아 보일 수 있습니다)
if (now - s.LastMsgAt > TimeSpan.FromSeconds(30))
await s.CloseAsync(WebSocketCloseStatus.NormalClosure, "timeout", ct);
// 재접속 — 새 연결에 이전 세션을 이어 붙인다
var resume = ctx.Request.Query["session"];
var session = _sessions.TryResume(resume, out var old)
? old // ★ 60초 안에 돌아오면 방·상태 그대로
: _sessions.Create(player);
- 모바일에서 끊김은 일상입니다 — 화면 잠금·지하철. 재접속 유예 없이 방에서 빼면 항의가 옵니다
- 재접속 직후 전체 상태 스냅샷을 한 번 보내고 그 뒤부터 증분으로 — 놓친 것 복구를 클라가 고민하지 않게
- 클라이언트 재시도는 지수 백오프(1·2·4·8초)로 — 서버 복구 순간의 재접속 폭풍(11 섹션)을 줄입니다
raw TCP/UDP 대신 WebSocket 으로 충분한가
| 게임 | 판단 | 이유 |
|---|---|---|
| 턴제 · 보드 · 퍼즐 | 충분 | 지연 요구가 느슨 — 사실 HTTP + SignalR 로도 됩니다 |
| 캐주얼 실시간 (.io 류 · 파티 게임) | 충분 | 10~20Hz 동기화는 TCP 지연으로도 감당 — 수많은 웹 게임이 증명 |
| 웹 · WebGL 빌드 | 사실상 유일 | 브라우저는 raw 소켓 불가 |
| FPS · 격투 · 레이싱 | 부족 | 패킷 하나의 재전송이 뒤 전부를 막음 — 02 섹션의 UDP 로 |
시간 동기화 · 틱레이트 설계
클라와 서버의 시계는 항상 다릅니다. 그 차이를 재는 법과, 서버의 심장 박동을 정하는 기준입니다.
고정 시간 스텝 — 서버 틱의 표준형
// 시뮬레이션 dt 를 항상 같게 — 같은 입력이면 같은 결과가 됩니다
const double FixedDt = 1.0 / 20; // 20Hz = 50ms
var acc = 0.0;
var prev = Stopwatch.GetTimestamp();
while (!ct.IsCancellationRequested)
{
var now = Stopwatch.GetTimestamp();
acc += Stopwatch.GetElapsedTime(prev, now).TotalSeconds;
prev = now;
while (acc >= FixedDt) // 밀린 만큼 따라잡는다
{
Simulate(FixedDt); _tick++; // ★ dt 는 절대 변하지 않음
acc -= FixedDt;
if (acc >= FixedDt * 5) // ★ 죽음의 나선 방지
{ acc = 0; _log.Warn("틱 5개 이상 밀림 — 버림"); break; }
}
Broadcast();
await Task.Delay(1, ct); // CPU 양보
}
클라 - 서버 시간 오프셋 — 핑퐁으로
// 클라이언트 — 여러 번 재서 중앙값을 쓴다
async Task SyncClockAsync()
{
var samples = new List<double>();
for (var i = 0; i < 8; i++)
{
var t0 = LocalMs();
var serverMs = await PingAsync(); // 서버가 자기 시각을 돌려줌
var t1 = LocalMs();
var rtt = t1 - t0;
// ★ 편도 ≈ RTT / 2 로 가정 — 서버 시각 + 편도 = 지금의 서버 시각
samples.Add(serverMs + rtt / 2 - t1);
await Task.Delay(50);
}
samples.Sort();
_offset = samples[samples.Count / 2]; // 중앙값 — 튄 표본 제거
}
double ServerNowMs() => LocalMs() + _offset; // 이후 모든 시간 판정에 이것을
- 기기 시계를 신뢰하지 마세요 — 사용자 폰의
DateTime.Now는 몇 분씩 틀립니다. 항상 오프셋 보정값을 씁니다 - 주기적으로 다시 잽니다 — 기기 시계는 흐르면서 조금씩 어긋납니다. 접속 시 + 몇 분마다 한 번이면 충분합니다
- 회선이 비대칭이면 RTT/2 가정이 틀어집니다 — RTT 가 가장 작은 표본이 가장 정확한 표본입니다
틱레이트 선택 — 장르가 정합니다
| 틱레이트 | 틱 예산 | 장르 | 비고 |
|---|---|---|---|
| 10Hz | 100ms | 수집형 RPG 실시간 요소 · 캐주얼 협동 | 서버비 최소 |
| 20Hz | 50ms | MOBA · 배틀로얄 · 대부분의 액션 | 업계 기본값 |
| 30Hz | 33ms | 빠른 슈터 | 대역폭 · CPU 1.5배 |
| 60Hz | 16ms | 격투 · 경쟁 FPS | 서버비·최적화 부담 급증 |
지연 시간 예산 — 키 입력이 화면에 오기까지
| 구간 | 전형값 | 줄일 수 있나 |
|---|---|---|
| 입력 → 클라 송신 | ~16ms | 프레임에 묶임 |
| 네트워크 상행 편도 | 20~50ms | 지역 서버 배치가 유일 |
| 서버 틱 대기 | 0~50ms (평균 주기÷2) | 틱레이트 올리기 |
| 서버 처리 | 1~10ms | 로직 최적화 |
| 네트워크 하행 편도 | 20~50ms | 지역 서버 |
| 클라 보간 버퍼 | ~100ms | 줄이면 끊김과 교환 |
틱이 밀릴 때 (오버런)
- 증상 — 한 틱 처리가 주기(50ms)를 넘으면 다음 틱이 늦고, 밀린 틱을 따라잡느라 더 밀리는 죽음의 나선이 시작됩니다
- 1차 방어 — 위 코드처럼 밀린 틱 상한(5개)을 두고 넘으면 버립니다. 게임이 잠깐 느려지는 쪽이 서버가 영원히 못 따라잡는 쪽보다 낫습니다
- 원인 추적 — 틱 시간 p99 를 상시 기록(11 섹션). 범인은 대부분 루프 안의 할당(GC)과 동기 I/O 입니다
- 구조적 해법 — 룸당 인원 상한 · AOI(04 섹션) · 무거운 계산을 여러 틱에 나눠 실행
Task.Delay 는 정확하지 않습니다.
Windows 의 기본 타이머 해상도는 15.6ms 라 "50ms 대기"가 60ms 를 넘기도 합니다.
위처럼 누적기(acc) 방식이면 개별 오차가 쌓이지 않아 평균 틱레이트는 정확히 유지됩니다 —
"대기 시간을 믿지 말고 경과 시간을 재라"가 핵심입니다.
서버 아키텍처 사례 연구
코드가 아니라 구조 수준에서 장르별 정답을 봅니다. 우리 게임이 어디인지부터 정하세요.
① 턴제 · 비동기 — 수집형 RPG
- 다른 플레이어와 같은 순간에 있을 필요가 없는 게임 전부 — 수집형 RPG · 방치형 · 퍼즐
- 비동기 PvP(방어덱 침공)도 상대 데이터를 읽어 올 뿐입니다 — 실시간이 아닙니다
- 국내 매출 상위권의 상당수가 이 구조입니다. 실시간 서버 없이도 대작이 됩니다
② 실시간 룸 기반 — 대전 · 파티
- MOBA · 대전 액션 · 배틀로얄 · 파티 게임 — 판(세션)의 시작과 끝이 명확한 게임
- 룸 상태는 메모리에만 있다 사라집니다 — 저장할 게 없어서 수평 확장이 쉽습니다
- 룸 서버의 증설·회수는 오케스트레이터에 맡깁니다(09 섹션)
③ MMO형 — 영속 세계
- 룸과 달리 세계가 끝나지 않습니다 — 상태를 계속 저장해야 하고, 함부로 내릴 수도 없습니다
- 존 경계에서의 전투, 이관 도중의 아이템 복사 버그가 이 구조의 악명 높은 함정입니다
- 전담 서버팀 없이 시도하지 마세요 — ①②와는 난이도의 차원이 다릅니다
우리 게임은 어디인가 — 판정 기준
- 상대의 행동을 언제 봐야 하나? — 다음 접속 때 → ①, 1초 안에 → ②, 같은 공간에 수백 명 → ③
- "한 판"이 있는가? — 시작과 끝이 있으면 ②, 세계가 계속 흐르면 ③
- 끊겼다 들어와도 되는가? — 된다면 ① 쪽으로 당길 수 있습니다
- 애매하면 낮은 번호로 — ① 에서 ② 로 넓히기는 되지만, 반대는 재설계입니다
| ① 턴제·비동기 | ② 룸 기반 | ③ MMO형 | |
|---|---|---|---|
| 통신 | HTTPS | HTTP + UDP/WS | UDP + 서버 간 gRPC |
| 서버 구성 | API 서버만 | API + 매치 + 룸 | 게이트 + 존 + 공용 서비스 |
| 상태 위치 | 전부 DB | 판 동안만 메모리 | 메모리 + 상시 저장 |
| 서버 인력 | 백엔드 1명~ | + 게임서버 1~2명 | 전담팀 |
계정도 상점도
랭킹도
여기서 돈다
실시간 전투는 게임 서버가 맡고, 로그인 · 인벤토리 · 상점 · 우편 · 랭킹은 평범한 웹 API 가 맡습니다. 게임 백엔드의 80% 는 이쪽입니다. 여기까지 마치면 인증·DB·실시간·배포까지 갖춘 백엔드를 혼자 세울 수 있습니다.
5분 만에 API 띄우기
Minimal API 로 파일 하나부터 시작합니다. 커지면 그때 나눕니다.
첫 API
dotnet new webapi -o GameApi --use-minimal-apis
cd GameApi && dotnet run
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi(); // 문서 자동 생성
var app = builder.Build();
if (app.Environment.IsDevelopment()) app.MapOpenApi();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.MapGet("/players/{id:int}", async (int id, IPlayerRepo repo) =>
await repo.FindAsync(id) is {} p
? Results.Ok(p)
: Results.NotFound());
app.MapPost("/players", async (CreatePlayerDto dto, IPlayerRepo repo) =>
{
var p = await repo.CreateAsync(dto.Name);
return Results.Created($"/players/{p.Id}", p);
});
app.Run();
커지면 나눈다
// 기능별로 확장 메서드 하나씩
public static class PlayerEndpoints
{
public static IEndpointRouteBuilder MapPlayers(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/players")
.RequireAuthorization()
.WithTags("Player");
g.MapGet("/{id:int}", GetAsync);
g.MapPost("/", CreateAsync);
return app;
}
}
// Program.cs 는 조립만
app.MapPlayers().MapShop().MapRanking();
등록 순서 — 이 순서가 정답에 가깝습니다
Program.cs 미들웨어 순서
var app = builder.Build();
app.UseExceptionHandler("/error"); // 1. 가장 바깥 — 아래 전부를 감싼다
app.UseHsts(); // 2. 브라우저에 "https 로만 와라"
app.UseHttpsRedirection(); // 3. http → https
app.UseStaticFiles(); // 4. 정적 파일은 여기서 끝 (뒤로 안 감)
app.UseRouting(); // 5. 어느 엔드포인트인지 결정
app.UseCors("game"); // 6. 라우팅 뒤, 인증 앞
app.UseRateLimiter(); // 7. 인증 뒤가 더 좋을 때도 있다(아래 note)
app.UseAuthentication(); // 8. 토큰을 읽어 User 를 채운다
app.UseAuthorization(); // 9. 그 User 로 권한을 본다
app.MapPlayers().MapShop(); // 10. 끝점
app.Run();
User 가 아직 비어 있기 때문입니다.
증상은 "토큰이 맞는데 로그인이 안 돼요" 로 나타납니다.
직접 만들어 보면 구조가 보입니다
// 요청 하나에 걸린 시간을 재는 최소 미들웨어
app.Use(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
ctx.Response.OnStarting(() => // 헤더는 본문이 나가기 직전까지만 바꿀 수 있다
{
ctx.Response.Headers["X-Elapsed-ms"] = sw.ElapsedMilliseconds.ToString();
return Task.CompletedTask;
});
await next(ctx); // ★ 여기서 안쪽 전부가 실행된다
// next() 가 돌아온 뒤 = 응답이 나가는 길 — 이미 본문이 나갔다면
// 여기서 헤더를 고치면 예외입니다 (그래서 위의 OnStarting)
});
// next() 를 부르지 않으면 거기서 요청이 끝난다 — 이것이 "단락(short-circuit)"
app.Use(async (ctx, next) =>
{
if (ctx.Request.Headers["X-Client-Ver"] != "1.4.2")
{
ctx.Response.StatusCode = 426; // Upgrade Required
await ctx.Response.WriteAsJsonAsync(new { need = "1.4.2" });
return; // next 호출 없음 → 뒤로 안 감
}
await next(ctx);
});
응답을 어떻게 돌려줄까 — Results 정리
| 상황 | 쓸 것 | 상태 코드 | 실수하기 쉬운 점 |
|---|---|---|---|
| 조회 성공 | Results.Ok(x) | 200 | — |
| 없는 것 조회 | Results.NotFound() | 404 | 빈 배열은 404 가 아닙니다 — 200 + [] |
| 새로 만듦 | Results.Created(uri, x) | 201 | Location 헤더를 빼먹으면 201 의 의미가 없습니다 |
| 입력이 잘못됨 | Results.ValidationProblem(e) | 400 | 어느 필드가 왜 틀렸는지 담아야 클라가 씁니다 |
| 돈·재화 부족 | Results.Problem(...) | 400 / 409 | 200 에 success:false 는 피하세요 — 모니터링이 에러율을 못 셉니다 |
| 토큰 없음·만료 | 자동 | 401 | 403(권한 없음)과 구분 — 클라 대응이 다릅니다 |
| 남의 자원 | Results.Forbid() | 403 | 404 로 감추는 것도 전략입니다(존재 여부 숨기기) |
| 중복 요청 | Results.Conflict() | 409 | 멱등 키가 있으면 원래 결과를 200 으로 돌려주는 편이 낫습니다 |
| 너무 잦음 | 자동(RateLimiter) | 429 | Retry-After 헤더를 같이 주세요 |
builder.Services.AddProblemDetails() 한 줄이면
던지지 않은 에러까지 { type, title, status, detail } 로 통일됩니다.
클라이언트가 에러 파싱 코드를 한 번만 쓰게 되는 것이 실제 이득입니다.
설정 · 환경 분리
개발·스테이징·운영이 같은 코드, 다른 설정으로 돌아야 합니다.
설정 읽기
// appsettings.json (공통) → appsettings.Production.json (덮어쓰기)
{
"Game": {
"MaxPartySize": 4,
"DailyRewardHourUtc": 0,
"Shop": { "RefreshMinutes": 60 }
},
"ConnectionStrings": { "Db": "Host=...;Database=game" }
}
public sealed class GameOptions
{
public const string Section = "Game";
[Range(1, 8)] public int MaxPartySize { get; init; } = 4;
public int DailyRewardHourUtc { get; init; }
}
builder.Services.AddOptions<GameOptions>()
.Bind(builder.Configuration.GetSection(GameOptions.Section))
.ValidateDataAnnotations()
.ValidateOnStart(); // ★ 잘못된 설정이면 시작 자체를 막는다
// 쓰는 쪽
public class PartyService(IOptions<GameOptions> opt) { ... }
비밀은 설정 파일에 넣지 않는다
# 개발 — 사용자 비밀 저장소 (저장소에 안 올라감)
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Db" "Host=localhost;..."
# 운영 — 환경 변수 (: 는 __ 로)
ConnectionStrings__Db="Host=prod;..."
Game__MaxPartySize=6
같은 키가 여러 곳에 있으면 — 나중에 읽은 것이 이깁니다
| 읽는 순서 | 출처 | 어디에 쓰나 | 저장소에 올라가나 |
|---|---|---|---|
| 1 (가장 약함) | appsettings.json | 모든 환경의 기본값 | 올라감 |
| 2 | appsettings.{환경}.json | 환경별 덮어쓰기 | 올라감 |
| 3 | User Secrets | 개발 PC 에서만 | 안 올라감 |
| 4 | 환경 변수 | 운영의 기본 수단 | 안 올라감 |
| 5 (가장 셈) | 실행 인자 --Game:MaxPartySize=6 | 임시 실험 · 컨테이너 오버라이드 | 안 올라감 |
환경은 이렇게 갈립니다
# 환경 이름을 정하는 것은 이 변수 하나입니다
ASPNETCORE_ENVIRONMENT=Development # 기본값
ASPNETCORE_ENVIRONMENT=Staging
ASPNETCORE_ENVIRONMENT=Production
# 컨테이너에서는 보통 이렇게
docker run -e ASPNETCORE_ENVIRONMENT=Production \
-e ConnectionStrings__Db="Host=db;..." \
-e Game__MaxPartySize=6 gameapi:1.4.2
// 코드에서 갈라야 할 때 — 최소한으로만
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // 문서는 개발에서만
app.UseDeveloperExceptionPage(); // 스택 추적 노출 — 절대 운영 금지
}
else
{
app.UseExceptionHandler("/error"); // 운영은 내용을 감춘다
app.UseHsts();
}
// 커스텀 환경도 됩니다
if (app.Environment.IsEnvironment("LoadTest")) { /* 결제만 가짜로 */ }
ASPNETCORE_ENVIRONMENT 가 안 걸린 것입니다.
설정을 안전하게 쓰는 세 가지 방법
// ① IOptions — 대부분 이걸로. 시작 시 한 번 바인딩되고 안 바뀝니다
public class PartyService(IOptions<GameOptions> opt)
{
private readonly GameOptions _o = opt.Value;
}
// ② IOptionsSnapshot — 요청마다 다시 읽습니다 (Scoped 전용)
// appsettings 를 고치면 재시작 없이 반영됩니다
public class ShopService(IOptionsSnapshot<GameOptions> opt) { ... }
// ③ IOptionsMonitor — 싱글턴에서 변경을 감지할 때
public class RewardScheduler
{
public RewardScheduler(IOptionsMonitor<GameOptions> m)
=> m.OnChange(o => Reschedule(o.DailyRewardHourUtc));
}
| 쓸 것 | 언제 | 주의 |
|---|---|---|
| IOptions | 거의 전부 | 바뀌어도 재시작 전엔 그대로 |
| IOptionsSnapshot | 운영 중 조절할 값 (이벤트 배율·확률) | 싱글턴에 주입 못 합니다 |
| IOptionsMonitor | 싱글턴·백그라운드 작업 | 콜백이 여러 번 불릴 수 있습니다 |
ValidateOnStart() 는 반드시 켜세요.
오타 난 설정 때문에 배포 30분 뒤 첫 결제에서 터지는 것보다,
시작하다 죽는 것이 훨씬 낫습니다.
무중단 배포에서는 새 인스턴스가 준비 실패로 잡히고
기존 인스턴스가 그대로 트래픽을 받습니다 — 사고가 아예 안 납니다.
EF Core — DB 연동
C# 클래스로 테이블을 다룹니다. 느려지는 지점이 정해져 있으니 그것만 조심하면 됩니다.
기본
public class GameDb(DbContextOptions<GameDb> o) : DbContext(o)
{
public DbSet<Player> Players => Set<Player>();
public DbSet<Item> Items => Set<Item>();
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<Player>(e =>
{
e.HasKey(x => x.Id);
e.HasIndex(x => x.Nickname).IsUnique();
e.Property(x => x.Nickname).HasMaxLength(20).IsRequired();
e.HasMany(x => x.Items).WithOne(i => i.Owner)
.OnDelete(DeleteBehavior.Cascade);
});
}
}
builder.Services.AddDbContext<GameDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Db")));
dotnet ef migrations add Init
dotnet ef database update
느려지는 세 가지
N+1 쿼리
var players = await db.Players.ToListAsync();
foreach (var p in players)
Console.WriteLine(p.Items.Count);
// 지연 로딩을 켰다면 플레이어 수만큼 쿼리가 더 나간다
// (안 켰다면 Items 가 비어 조용히 0 이 찍힌다)
Include 로 한 번에
var players = await db.Players
.Include(p => p.Items)
.AsNoTracking() // 읽기 전용이면 필수
.ToListAsync();
- AsNoTracking — 조회 전용 쿼리에 붙이면 메모리·속도가 크게 개선됩니다
- 필요한 컬럼만 —
Select(p => new { p.Id, p.Nickname }) - 페이징 필수 —
Skip/Take없이 전체를 가져오지 마세요 - 인덱스 — 조회 조건에 쓰는 컬럼에는 반드시
o.LogTo(Console.WriteLine, LogLevel.Information) 를 개발 환경에 켜면
EF 가 만든 실제 쿼리가 보입니다.
DB 튜닝 전반은 🗄️ 데이터베이스 가이드에서 이어집니다.
인증 — JWT · 게스트 로그인
게임은 게스트 시작 → 나중에 계정 연동이 표준입니다. 그 흐름을 만듭니다.
토큰 발급과 검증
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new()
{
ValidateIssuer = true, ValidIssuer = cfg["Jwt:Issuer"],
ValidateAudience = true, ValidAudience = cfg["Jwt:Audience"],
ValidateLifetime = true,
ClockSkew = TimeSpan.FromSeconds(30), // 기본 5분은 너무 길다
IssuerSigningKey = new SymmetricSecurityKey(
Encoding.UTF8.GetBytes(cfg["Jwt:Key"]!)),
};
});
app.UseAuthentication();
app.UseAuthorization();
string IssueToken(Player p)
{
var claims = new[]
{
new Claim(JwtRegisteredClaimNames.Sub, p.Id.ToString()),
new Claim("nick", p.Nickname),
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
};
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(issuer, audience, claims,
expires: DateTime.UtcNow.AddMinutes(30), signingCredentials: creds);
return new JwtSecurityTokenHandler().WriteToken(token);
}
게스트 → 계정 연동
// ① 첫 실행 — 기기 식별자로 게스트 계정 생성
app.MapPost("/auth/guest", async (GuestDto d, GameDb db) =>
{
var p = await db.Players.FirstOrDefaultAsync(x => x.DeviceId == d.DeviceId)
?? db.Players.Add(new Player { DeviceId = d.DeviceId }).Entity;
await db.SaveChangesAsync();
return Results.Ok(new { token = IssueToken(p), refresh = IssueRefresh(p) });
});
// ② 나중에 소셜 계정 연결 (기기 분실 대비)
app.MapPost("/auth/link", async (LinkDto d, ClaimsPrincipal user, GameDb db) =>
{
var provider = await VerifyExternalAsync(d.Provider, d.IdToken); // ★ 서버 검증
if (provider is null) return Results.Unauthorized();
var me = await db.Players.FindAsync(user.PlayerId());
if (await db.Players.AnyAsync(x => x.ExternalId == provider.Id))
return Results.Conflict("이미 연결된 계정입니다");
me!.ExternalId = provider.Id;
await db.SaveChangesAsync();
return Results.NoContent();
}).RequireAuthorization();
idToken 은 반드시 서버가 제공자에 검증해야 합니다 —
클라이언트가 준 값을 믿으면 남의 계정을 가로챌 수 있습니다.
검증 · 에러 처리
클라이언트가 보낸 값은 전부 의심합니다. 그리고 에러 형식을 하나로 통일합니다.
입력 검증
// FluentValidation
public class BuyItemValidator : AbstractValidator<BuyItemDto>
{
public BuyItemValidator()
{
RuleFor(x => x.ItemId).GreaterThan(0);
RuleFor(x => x.Count).InclusiveBetween(1, 99);
RuleFor(x => x.Nickname).MaximumLength(20)
.Matches("^[가-힣a-zA-Z0-9]+$").WithMessage("특수문자는 쓸 수 없습니다");
}
}
app.MapPost("/shop/buy", async (BuyItemDto d, IValidator<BuyItemDto> v, IShop shop) =>
{
var r = await v.ValidateAsync(d);
if (!r.IsValid) return Results.ValidationProblem(r.ToDictionary());
// ★ 가격은 서버가 조회 — 클라이언트가 보낸 금액은 절대 쓰지 않는다
return await shop.BuyAsync(d.ItemId, d.Count) switch
{
BuyResult.Ok o => Results.Ok(o),
BuyResult.NoGold => Results.BadRequest(new { code = "NO_GOLD" }),
BuyResult.SoldOut => Results.Conflict(new { code = "SOLD_OUT" }),
_ => Results.Problem(),
};
});
에러 형식 통일
app.UseExceptionHandler(x => x.Run(async ctx =>
{
var ex = ctx.Features.Get<IExceptionHandlerFeature>()?.Error;
var (status, code) = ex switch
{
NotFoundException => (404, "NOT_FOUND"),
DomainRuleException d => (400, d.Code),
_ => (500, "INTERNAL"),
};
log.LogError(ex, "요청 실패 {Path}", ctx.Request.Path);
ctx.Response.StatusCode = status;
await ctx.Response.WriteAsJsonAsync(new
{
code,
traceId = Activity.Current?.Id ?? ctx.TraceIdentifier, // ★ 문의 대응용
});
}));
NO_GOLD 같은 코드로 분기하고 문구는 클라이언트가 현지화합니다.
SignalR — 실시간 알림
우편 도착·친구 접속·길드 채팅처럼 서버가 먼저 말을 거는 기능.
허브
[Authorize]
public class LobbyHub : Hub
{
public override async Task OnConnectedAsync()
{
var guildId = Context.User!.FindFirst("guild")?.Value;
if (guildId is not null)
await Groups.AddToGroupAsync(Context.ConnectionId, $"guild:{guildId}");
await base.OnConnectedAsync();
}
public async Task SendGuild(string text)
{
var gid = Context.User!.FindFirst("guild")!.Value;
await Clients.Group($"guild:{gid}")
.SendAsync("GuildChat", Context.UserIdentifier, text);
}
}
app.MapHub<LobbyHub>("/hub/lobby");
// 서버 다른 곳에서 밀어 넣기
await hub.Clients.User(playerId.ToString()).SendAsync("MailArrived", mail);
여러 대로 늘릴 때
// 서버가 2대가 되는 순간, 다른 서버에 붙은 사용자에게 못 보냅니다
builder.Services.AddSignalR()
.AddStackExchangeRedis(cfg.GetConnectionString("Redis")!); // ← 한 줄
- Sticky 세션이 필요합니다(로드밸런서 설정) — 협상 요청과 연결이 같은 서버로
- 재연결을 클라이언트에 붙이고, 재연결 직후 놓친 것 당겨오기
- Unity 클라이언트는 SignalR .NET 클라이언트를 그대로 씁니다
서버가 먼저 말을 거는 방법 — 무엇을 고를까
| 방식 | 지연 | 방향 | 맞는 곳 | 대가 |
|---|---|---|---|---|
| 폴링 (5초마다 GET) | ~5초 | 클라 → 서버 | 우편함 뱃지 · 공지 | 가장 단순 · 서버 요청 수가 인원에 비례 |
| 롱 폴링 | ~1초 | 서버 → 클라(흉내) | 구형 클라 호환 | 커넥션을 붙잡아 둠 |
| SignalR (WebSocket) | 50~200ms | 양방향 | 로비 · 채팅 · 알림 · 파티 초대 | 연결 유지 비용 · 서버 늘리면 백플레인 필요 |
| 푸시 알림 (FCM·APNs) | 수 초 | 서버 → 앱 | 앱이 꺼져 있을 때 | 도착 보장 없음 · OS 정책에 좌우 |
| UDP 커스텀 | 10~40ms | 양방향 | 실시간 전투 · 위치 동기화 | 직접 다 만들어야 함 → 🌐 게임 서버 탭 |
클라이언트 — 재연결과 놓친 것 당겨오기
// Unity·콘솔 공통 (Microsoft.AspNetCore.SignalR.Client)
var conn = new HubConnectionBuilder()
.WithUrl("https://api.game.com/hub/lobby", o =>
o.AccessTokenProvider = () => Task.FromResult(_token)!)
.WithAutomaticReconnect(new[] { // 재시도 간격을 직접
TimeSpan.Zero, TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15) })
.Build();
conn.On<string, string>("GuildChat", (who, text) => Ui.AddChat(who, text));
conn.On<MailDto>("MailArrived", m => Ui.MailBadge(m));
// ★ 재연결 직후가 핵심 — 끊겨 있던 동안의 것을 REST 로 당겨온다
conn.Reconnected += async _ =>
{
var missed = await _http.GetFromJsonAsync<MailDto[]>(
$"/mail?since={_lastSeenUtc:o}");
foreach (var m in missed!) Ui.MailBadge(m);
_lastSeenUtc = DateTime.UtcNow;
};
conn.Closed += async _ => // 전부 실패하면 수동 재시도
{
await Task.Delay(TimeSpan.FromSeconds(30));
await conn.StartAsync();
};
await conn.StartAsync();
서버 — 실전에서 걸리는 것들
builder.Services.AddSignalR(o =>
{
o.EnableDetailedErrors = builder.Environment.IsDevelopment();
o.MaximumReceiveMessageSize = 32 * 1024; // 채팅에 32KB 면 충분
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
o.KeepAliveInterval = TimeSpan.FromSeconds(10);
})
.AddStackExchangeRedis(cfg.GetConnectionString("Redis")!,
o => o.Configuration.ChannelPrefix = RedisChannel.Literal("game"));
// 쿼리스트링 토큰 — 웹소켓은 Authorization 헤더를 못 붙이는 클라가 있습니다
builder.Services.Configure<JwtBearerOptions>(
JwtBearerDefaults.AuthenticationScheme, o =>
{
o.Events = new JwtBearerEvents
{
OnMessageReceived = ctx =>
{
var t = ctx.Request.Query["access_token"];
if (!string.IsNullOrEmpty(t) &&
ctx.HttpContext.Request.Path.StartsWithSegments("/hub"))
ctx.Token = t;
return Task.CompletedTask;
}
};
});
// 허브에서 무거운 일을 하지 마세요 — 큐에 넣고 바로 반환
public async Task SendGuild(string text)
{
if (text.Length > 200) throw new HubException("너무 깁니다");
if (!_rate.TryAcquire(Context.UserIdentifier!)) // 도배 방지
throw new HubException("잠시 후 다시");
await _chatQueue.EnqueueAsync(new ChatMsg(Context.UserIdentifier!, text));
}
- 연결 수 = 메모리입니다 — 대략 연결 하나에 수십 KB. 10만 연결이면 인스턴스를 나눠야 합니다
- HubException 만 클라에 문구가 전달됩니다. 다른 예외는 "An unexpected error" 로 감춰집니다
- OnDisconnectedAsync 는 안 불릴 수 있습니다 — 프로세스가 죽으면 그냥 사라집니다. 접속 상태를 DB 에 쓰려면 TTL 있는 Redis 키로
캐시 · 백그라운드 작업
DB 를 덜 때리고, 요청 밖에서 도는 일을 만듭니다.
캐시
// 하이브리드 캐시 (.NET 9+) — 메모리 + 분산을 한 API 로
builder.Services.AddHybridCache();
public async Task<ShopData> GetShopAsync(CancellationToken ct) =>
await cache.GetOrCreateAsync(
"shop:daily",
async token => await db.LoadShopAsync(token),
new HybridCacheEntryOptions
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(1),
}, cancellationToken: ct);
// 무효화
await cache.RemoveAsync("shop:daily", ct);
백그라운드 작업
public class DailyResetService(IServiceScopeFactory scopes, ILogger<DailyResetService> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var next = NextUtcMidnight();
await Task.Delay(next - DateTimeOffset.UtcNow, ct);
using var scope = scopes.CreateScope(); // ★ Scoped 를 쓰려면 필수
var db = scope.ServiceProvider.GetRequiredService<GameDb>();
try { await ResetDailyAsync(db, ct); }
catch (Exception e) { log.LogError(e, "일일 초기화 실패"); }
}
}
}
builder.Services.AddHostedService<DailyResetService>();
게임 백엔드 설계 — 재화 · 인벤토리
여기서 실수하면 아이템이 복사되거나 사라집니다. 트랜잭션과 멱등성이 전부입니다.
구매 — 한 트랜잭션으로
public async Task<BuyResult> BuyAsync(int playerId, int itemId, int count,
string idempotencyKey, CancellationToken ct)
{
// ① 같은 요청이 두 번 오면 이전 결과를 그대로 (네트워크 재시도 대비)
if (await _db.Receipts.FindAsync([idempotencyKey], ct) is {} done)
return done.Result;
await using var tx = await _db.Database.BeginTransactionAsync(ct);
// ② 가격은 서버가 조회 — 클라이언트 값은 쓰지 않는다
var item = await _db.ItemDefs.FindAsync([itemId], ct)
?? throw new NotFoundException();
var cost = item.Price * count;
// ③ 재화 차감은 조건부 UPDATE 로 (동시 요청에도 음수가 되지 않게)
var affected = await _db.Players
.Where(p => p.Id == playerId && p.Gold >= cost)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Gold, p => p.Gold - cost), ct);
if (affected == 0) return BuyResult.NoGold;
// ④ 지급 · 영수증 · 로그를 같은 트랜잭션에
_db.Inventories.Add(new Inventory { PlayerId = playerId, ItemId = itemId, Count = count });
_db.Receipts.Add(new Receipt { Key = idempotencyKey, Result = BuyResult.Ok });
_db.GoldLogs.Add(new GoldLog { PlayerId = playerId, Delta = -cost, Reason = "buy" });
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return BuyResult.Ok;
}
지켜야 할 원칙
- 재화 변동은 반드시 로그 — 문의·환불·치트 조사의 유일한 근거입니다
- 조건부 UPDATE —
WHERE gold >= cost로 DB 가 판정하게 - 멱등 키 — 같은 요청이 두 번 와도 한 번만 처리
- 지급과 차감은 같은 트랜잭션 — 중간에 죽으면 둘 다 취소
- 수량 상한 — 인벤토리·우편함 한도를 서버가 강제
- 시간은 서버 기준 — 클라이언트 시각으로 일일 보상을 주면 조작됩니다
RowVersion)으로 막으세요.
로깅 · 관측성
장애가 났을 때 로그만으로 재현할 수 있어야 합니다.
구조적 로깅
// 문자열을 조립하지 말고 값을 넘기세요 (검색·집계가 됩니다)
log.LogInformation("아이템 구매 {PlayerId} {ItemId} {Cost}", pid, iid, cost); // ✓
log.LogInformation($"아이템 구매 {pid}"); // ✗
// 요청 전체에 공통 값 붙이기
using (log.BeginScope(new Dictionary<string, object>
{ ["PlayerId"] = pid, ["TraceId"] = Activity.Current?.Id! }))
{
await HandleAsync(); // 이 안의 모든 로그에 자동으로 붙습니다
}
// OpenTelemetry — 지표·추적을 표준 형식으로
builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter())
.WithTracing(t => t.AddAspNetCoreInstrumentation()
.AddEntityFrameworkCoreInstrumentation()
.AddOtlpExporter());
app.MapPrometheusScrapingEndpoint();
무엇을 남길 것인가
- 남겨야 — 재화 변동 · 아이템 획득/소모 · 결제 · 로그인 · 밴
- 남기지 말아야 — 비밀번호 · 토큰 · 주민번호 · 카드번호
- 레벨 기준 — Error 는 사람이 조치해야 하는 것만. 나머지는 Warning 이하로
- 보관 기간 — 재화 로그는 길게(분쟁), 일반 로그는 짧게(비용)
테스트 — 단위 · 통합
밸런스와 규칙은 자주 바뀝니다. 바꿔도 안 깨진다는 확신이 개발 속도입니다.
단위 테스트
public class DamageTests
{
[Theory]
[InlineData(100, 20, 0.0, 80)]
[InlineData(100, 20, 0.5, 90)] // 방어율 50%
[InlineData(100, 999, 0.0, 0)] // 체력은 0 미만이 되지 않는다
public void 데미지_계산(int hp, int atk, double def, int expected)
{
var result = Combat.Apply(hp, atk, def);
Assert.Equal(expected, result);
}
}
통합 테스트 — 진짜 DB 로
public class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _db =
new PostgreSqlBuilder().WithImage("postgres:17-alpine").Build();
public async Task InitializeAsync() => await _db.StartAsync();
async Task IAsyncLifetime.DisposeAsync() => await _db.DisposeAsync(); // (xUnit v2 기준)
protected override void ConfigureWebHost(IWebHostBuilder b) =>
b.UseSetting("ConnectionStrings:Db", _db.GetConnectionString());
}
public class ShopTests(ApiFixture f) : IClassFixture<ApiFixture>
{
[Fact]
public async Task 골드가_부족하면_구매_실패()
{
var client = f.CreateClient();
var res = await client.PostAsJsonAsync("/shop/buy", new { itemId = 1, count = 99 });
Assert.Equal(HttpStatusCode.BadRequest, res.StatusCode);
}
}
무엇을 어느 층에서 테스트할까
| 층 | 테스트할 것 | 속도 | 비율 | 가짜로 대체하는 것 |
|---|---|---|---|---|
| 단위 | 데미지 공식 · 확률 · 레벨 곡선 · 인벤 규칙 | ms | ~70% | 전부(순수 함수로 뽑아 두면 가짜조차 필요 없음) |
| 통합 | 엔드포인트 + 진짜 DB · 트랜잭션 · 동시성 | 초 | ~25% | 결제 · 푸시 · 외부 API 만 |
| E2E | 로그인→상점→구매→인벤 반영 한 흐름 | 수십 초 | ~5% | 없음 |
| 부하 | 동접 목표에서 p99 와 에러율 | 분 | 배포 전 1회 | 없음(운영과 같은 규모로) |
동시 요청 — 실제 사고를 재현하는 테스트
[Fact]
public async Task 한정_아이템은_재고만큼만_팔린다()
{
await Seed.ItemAsync(id: 77, stock: 10);
// 100명이 동시에 산다
var clients = Enumerable.Range(0, 100)
.Select(i => f.CreateAuthedClient(playerId: 1000 + i));
var results = await Task.WhenAll(clients.Select(c =>
c.PostAsJsonAsync("/shop/buy", new { itemId = 77, count = 1 })));
var ok = results.Count(r => r.IsSuccessStatusCode);
Assert.Equal(10, ok); // ★ 정확히 10명
Assert.Equal(0, await Db.StockOfAsync(77)); // 마이너스 금지
Assert.Equal(10, await Db.PurchaseLogCountAsync(77)); // 로그도 정확히
}
[Fact]
public async Task 같은_요청을_두_번_보내도_한_번만_처리된다()
{
var key = Guid.NewGuid().ToString();
var body = new { itemId = 1, count = 1 };
var c = f.CreateAuthedClient(playerId: 7);
c.DefaultRequestHeaders.Add("Idempotency-Key", key);
var r1 = await c.PostAsJsonAsync("/shop/buy", body);
var r2 = await c.PostAsJsonAsync("/shop/buy", body); // 재전송
Assert.True(r1.IsSuccessStatusCode && r2.IsSuccessStatusCode);
Assert.Equal(await r1.Content.ReadAsStringAsync(),
await r2.Content.ReadAsStringAsync()); // 같은 결과
Assert.Equal(1, await Db.PurchaseLogCountAsync(1)); // 차감은 한 번
}
외부 의존은 가짜로 — 그러나 DB 는 진짜로
public class ApiFixture : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder b) =>
b.ConfigureTestServices(s =>
{
// 결제·푸시만 가짜로 바꾼다
s.RemoveAll<IStoreVerifier>();
s.AddSingleton<IStoreVerifier, AlwaysValidVerifier>();
s.RemoveAll<IPushSender>();
s.AddSingleton<IPushSender, RecordingPushSender>();
// 시간도 고정 — "매일 0시 보상" 같은 것을 테스트하려면 필수
s.AddSingleton<TimeProvider>(
new FakeTimeProvider(new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
});
}
// 확률은 시드를 고정해서 검증합니다
[Fact]
public void 뽑기_확률이_표와_맞는다()
{
var rng = new Random(42);
var box = new GachaBox(rng);
var counts = Enumerable.Range(0, 100_000)
.Select(_ => box.Draw().Grade)
.GroupBy(g => g).ToDictionary(g => g.Key, g => g.Count());
Assert.InRange(counts["SSR"] / 100_000.0, 0.028, 0.032); // 표기 3%
}
TimeProvider(.NET 8+)를 쓰면DateTime.UtcNow를 테스트에서 갈아 끼울 수 있습니다 — 일일 보상·쿨타임·이벤트 기간 테스트가 가능해집니다- 확률 표기는 법적 고지 대상인 나라가 많습니다. 표와 코드가 어긋나지 않는지 테스트로 묶어 두세요
- 테스트마다 DB 를 트랜잭션으로 감싸 롤백하거나
Respawn으로 비웁니다 — 순서 의존은 반드시 언젠가 깨집니다 dotnet test --collect:"XPlat Code Coverage"로 커버리지를 봅니다. 숫자를 목표로 삼지 말고, 재화·결제 경로가 비어 있는지만 확인하세요
배포 — Docker · 무중단
한 대에서 여러 대로. 배포 중에도 서비스가 멈추지 않게 만듭니다.
Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.sln .
COPY src/GameApi/*.csproj src/GameApi/
# ★ 의존성 레이어를 먼저 (캐시) — 복사한 프로젝트만 복원
RUN dotnet restore src/GameApi/GameApi.csproj
COPY . .
RUN dotnet publish src/GameApi -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
# 루트로 돌리지 않는다 (Dockerfile 은 줄 끝 주석이 안 됩니다)
USER $APP_UID
ENTRYPOINT ["dotnet", "GameApi.dll"]
// 헬스체크 — 로드밸런서가 이걸 보고 트래픽을 넣습니다
builder.Services.AddHealthChecks()
.AddDbContextCheck<GameDb>()
.AddRedis(cfg.GetConnectionString("Redis")!);
app.MapHealthChecks("/health/live", // 살아 있나 (프로세스만 — 검사 0개)
new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready"); // 받을 준비가 됐나 (DB·캐시 포함)
무중단의 조건
- 그레이스풀 셧다운 — 종료 신호를 받으면 진행 중 요청을 마치고 종료
- readiness 와 liveness 분리 — 준비 안 됐는데 트래픽이 오면 에러가 납니다
- DB 마이그레이션은 하위 호환으로 — 컬럼 삭제는 다음 배포에서
- 무상태 — 세션을 메모리에 두면 확장할 수 없습니다
- 롤백 절차를 미리 문서로
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(30)); // 마무리 시간 확보
헬스체크 두 개를 갈라야 하는 이유
배포 전략 — 무엇을 고를까
| 전략 | 동작 | 롤백 | 비용 |
|---|---|---|---|
| 롤링 | 한 대씩 교체 | 느림(다시 롤링) | 낮음 — 기본값 |
| 블루·그린 | 새 세트를 통째로 띄우고 스위치 한 번 | 즉시(스위치 되돌리기) | 서버 2배(잠깐) |
| 카나리 | 5% → 25% → 100% | 빠름 | 지표 자동화 필요 |
// 그레이스풀 셧다운 — 진행 중인 요청을 마치고 죽는다
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromSeconds(30);
o.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.StopHost; // 조용히 죽는 것 방지
});
// 종료 신호가 오면 ready 를 먼저 내려 트래픽부터 끊는다
public class DrainService(IHostApplicationLifetime life) : IHostedService
{
public static volatile bool Draining;
public Task StartAsync(CancellationToken _)
{
life.ApplicationStopping.Register(() =>
{
Draining = true; // ready 가 503 을 뱉기 시작
Thread.Sleep(5000); // LB 가 눈치챌 시간 (체크 주기의 2배)
});
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken _) => Task.CompletedTask;
}
app.MapHealthChecks("/health/ready", new()
{
Predicate = c => c.Tags.Contains("ready"),
ResultStatusCodes = { [HealthStatus.Unhealthy] = 503 }
});
DB 마이그레이션 — 배포와 순서가 어긋나면 터집니다
배포 중에는 구버전과 신버전이 동시에 같은 DB 를 봅니다. 그래서 스키마 변경은 항상 두 번에 나눠 배포합니다.
| 하고 싶은 것 | 1차 배포 | 2차 배포(다음 주) |
|---|---|---|
| 컬럼 추가 | nullable 로 추가 + 신버전이 씀 | NOT NULL 로 조이기 |
| 컬럼 삭제 | 코드에서 안 쓰게만 바꾸기 | 실제 DROP |
| 컬럼 이름 변경 | 새 컬럼 추가 + 양쪽에 쓰기 | 옛 컬럼 DROP |
| 타입 변경 | 새 컬럼 + 백필 | 스위치 후 옛 컬럼 DROP |
# 마이그레이션은 앱 시작 시가 아니라 배포 파이프라인에서 따로
dotnet ef migrations bundle -o migrate # 실행 파일로 뽑아 두고
./migrate --connection "$DB" # 배포 직전 한 번만 실행
# 앱에서 자동 실행은 위험합니다 — 인스턴스 5대가 동시에 걸면 충돌
# app.Services.GetRequiredService<GameDb>().Database.Migrate(); ← 하지 마세요
- 큰 테이블에 인덱스를 그냥 걸면 테이블이 잠깁니다 — Postgres 는
CREATE INDEX CONCURRENTLY. 자세한 것은 🗄️ DB 가이드 - 롤백 스크립트를 미리 써 두고, 스테이징에서 실제로 되돌려 보세요. 안 해 본 롤백은 롤백이 아닙니다
- 배포 태그를 로그·메트릭에 심어 두면 "언제부터 에러가 늘었나"가 즉시 보입니다 —
ASPNETCORE_VERSION을 환경 변수로
백엔드 체크리스트
게임 백엔드를 열기 전에 확인할 것들을 모았습니다.
보안
- ☐ 모든 계산을 서버가 — 가격·데미지·보상·확률
- ☐ 인증 없는 엔드포인트가 없는지 (기본을
RequireAuthorization으로) - ☐ 남의 데이터 조회 차단 — 경로의 id 를 그대로 믿지 않기
- ☐ 속도 제한 — 로그인·구매·랭킹 조회
- ☐ HTTPS 강제 · 인증서 자동 갱신
- ☐ 의존 패키지 취약점 점검(
dotnet list package --vulnerable)
운영
- ☐ DB 백업·복구를 실제로 복구해 봤는지
- ☐ 재화 로그 보관 정책
- ☐ 점검 모드·강제 업데이트 스위치
- ☐ 운영 도구 — 계정 조회·아이템 지급·밴
- ☐ 대시보드 — 요청 수 · 에러율 · p99 · DB 커넥션
- ☐ 첫 주 모니터링 당번과 연락 체계
출시 첫날에 실제로 터지는 것들 — 증상 → 원인
| 증상 | 가장 흔한 원인 | 먼저 볼 것 | 미리 막는 법 |
|---|---|---|---|
| 재화가 마이너스 | 차감을 읽고-쓰기로 함(동시 요청) | 구매 로그의 같은 밀리초 중복 | DB 에서 UPDATE ... WHERE gold >= @c 한 문장으로 |
| 아이템이 두 배로 지급 | 클라 재전송 + 멱등 키 없음 | 같은 요청 본문 2건 | Idempotency-Key + 결과 캐시 |
| 응답이 갑자기 다 느려짐 | DB 커넥션 풀 고갈 | 대기 중인 커넥션 수 · 느린 쿼리 | 풀 크기 < DB 최대 접속, N+1 제거 |
| 메모리가 계속 오름 | 정적 컬렉션에 캐시를 쌓음 | GC 후에도 안 줄어드는 힙 | MemoryCache + 크기 제한과 만료 |
| 간헐적 500 | async void · 잡히지 않은 백그라운드 예외 | 스택 없는 예외 로그 | async Task 로, StopHost 설정 |
| 로그인만 몰려서 죽음 | 토큰 검증에서 매번 DB 조회 | 로그인 엔드포인트 p99 | JWT 자체 검증(DB 안 감) + 짧은 만료 |
| 랭킹 조회가 서버를 먹음 | 매 요청마다 ORDER BY 전체 정렬 | DB CPU | Redis Sorted Set · 결과 캐시 30초 |
| 배포하자 에러 폭증 | 설정 누락 · 마이그레이션 순서 | 배포 태그별 에러율 | ValidateOnStart + 카나리 |
출시 직전 — 이건 꼭 해 보고 여세요
- ☐ 목표 동접의 2배로 부하 테스트 — p99 와 에러율을 숫자로 적어 둘 것
- ☐ DB 를 일부러 끊어 보기 — 앱이 죽는지, ready 만 떨어지는지
- ☐ Redis 를 일부러 끊어 보기 — 캐시가 없으면 느려질 뿐이어야 정상
- ☐ 백업에서 실제로 복구 — 새 인스턴스에 붓고 게임이 도는지
- ☐ 롤백을 실제로 실행 — 몇 분 걸리는지 재 볼 것
- ☐ 점검 모드 켜고 끄기 — 클라이언트 문구까지 확인
- ☐ 강제 업데이트 스위치 — 구버전으로 접속해 확인
- ☐ 로그에 토큰·주민번호·카드번호가 안 찍히는지 grep
- ☐ 알림이 실제 담당자 휴대폰까지 오는지 (테스트 알람 한 번)
첫 주에 볼 대시보드 — 이 여섯 줄이면 됩니다
| 지표 | 정상 | 이러면 대응 |
|---|---|---|
| 에러율(5xx) | < 0.1% | 1% 넘으면 롤백 검토 |
| p99 응답 | < 300ms | 1초 넘으면 DB·캐시 확인 |
| DB 커넥션 사용률 | < 70% | 90% 넘으면 곧 고갈 |
| CPU | < 60% | 80% 지속이면 증설 |
| 동시 접속 | — | 증설 판단의 기준선 |
| 결제 성공률 | > 98% | 떨어지면 즉시 확인 — 돈 문제 |
// OpenTelemetry 한 번 붙이면 위 지표가 거의 다 나옵니다
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("game-api",
serviceVersion: Environment.GetEnvironmentVariable("APP_VERSION")))
.WithMetrics(m => m
.AddAspNetCoreInstrumentation() // 요청 수 · 지연 · 상태 코드
.AddRuntimeInstrumentation() // GC · 스레드풀 · 힙
.AddNpgsqlInstrumentation() // DB 커넥션 · 쿼리 시간
.AddOtlpExporter())
.WithTracing(t => t
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddOtlpExporter());
// 게임 고유 지표는 직접 셉니다
var purchases = meter.CreateCounter<long>("game.purchase.count");
purchases.Add(1, new KeyValuePair<string, object?>("result", "ok"));
gRPC — 서버끼리는 이걸로
클라이언트에게는 REST·SignalR 이 편하지만, 서버끼리(매치메이커 ↔ 게임 서버 ↔ 백엔드)는 gRPC 가 표준입니다 — 계약이 코드로 강제되고, HTTP/2 바이너리라 빠릅니다.
① 계약부터 — .proto 파일
// Protos/match.proto
syntax = "proto3";
option csharp_namespace = "Game.Match";
service Matchmaker {
// 단발 호출 — 요청 하나, 응답 하나
rpc FindMatch (FindMatchRequest) returns (MatchTicket);
// 서버 스트리밍 — 매칭 진행 상황을 밀어 준다
rpc WatchTicket (MatchTicket) returns (stream TicketUpdate);
}
message FindMatchRequest {
int64 player_id = 1; // 번호는 저장 형식 — 바꾸면 호환이 깨집니다
int32 mmr = 2;
string region = 3;
}
message MatchTicket { string ticket_id = 1; }
message TicketUpdate { string state = 1; string server_addr = 2; }
② 서버 구현 — 생성된 베이스를 상속
// dotnet add package Grpc.AspNetCore
// csproj: <Protobuf Include="Protos\match.proto" GrpcServices="Server" />
public class MatchmakerService : Matchmaker.MatchmakerBase
{
public override async Task<MatchTicket> FindMatch(
FindMatchRequest req, ServerCallContext ctx)
{
var id = await _queue.EnqueueAsync(req.PlayerId, req.Mmr, req.Region);
return new MatchTicket { TicketId = id };
}
public override async Task WatchTicket(MatchTicket t,
IServerStreamWriter<TicketUpdate> stream, ServerCallContext ctx)
{
await foreach (var u in _queue.Watch(t.TicketId, ctx.CancellationToken))
await stream.WriteAsync(u); // 상태가 바뀔 때마다 밀어 준다
}
}
// Program.cs
builder.Services.AddGrpc();
app.MapGrpcService<MatchmakerService>();
// ③ 호출하는 쪽 (게임 서버 등)
// csproj: GrpcServices="Client" 로 같은 proto 등록
using var ch = GrpcChannel.ForAddress("https://match.internal:5001");
var client = new Matchmaker.MatchmakerClient(ch); // 채널은 재사용!
var ticket = await client.FindMatchAsync(
new FindMatchRequest { PlayerId = 1001, Mmr = 1500, Region = "kr" },
deadline: DateTime.UtcNow.AddSeconds(3)); // 데드라인 = 타임아웃
REST 로 충분한가, gRPC 로 가야 하나
| 상황 | 선택 |
|---|---|
| 게임 클라이언트 → 백엔드 (로그인·상점·우편) | REST(HTTP/JSON) — 디버깅 쉽고 어디서나 통과 |
| 서버 ↔ 서버 내부 호출 (매치·전적·정산) | gRPC — 계약 강제 + 저지연 + 스트리밍 |
| 서버가 클라이언트에 미는 알림 | SignalR (w06) — 웹·모바일 호환이 우선 |
| Unity 클라이언트에서 gRPC 직접 호출 | 가능하지만 주의 — HTTP/2 지원이 플랫폼마다 달라 YetAnotherHttpHandler 같은 별도 핸들러가 필요합니다. 급하지 않으면 REST 유지 |
reserved 2; 로 번호를 봉인하고, 새 필드는 항상 새 번호로 추가하세요.
이 규칙만 지키면 서버를 순서 없이 배포해도 호환이 유지됩니다.
운영 도구 — GM 명령 · 어드민 API
게임을 열면 곧바로 필요해지는 것들이 있습니다 — 우편 발송 · 재화 지급 · 밴 · 공지 · 점검 모드. 이걸 DB 를 직접 UPDATE 해서 처리하는 순간부터 사고가 시작됩니다. 어드민 API 를 별도로 만드세요.
원칙 — 유저 API 와 완전히 분리
| 항목 | 이렇게 |
|---|---|
| 배포 단위 | 별도 프로젝트·별도 포트 — 유저 트래픽과 섞지 않음 |
| 접근 | 사내망·VPN 만 + IP 허용 목록 — 인터넷에 노출 금지 |
| 인증 | 운영자 개인 계정(SSO·2FA) — 공용 계정 금지, 권한 역할 분리(조회/지급/밴) |
| 기록 | 모든 호출을 감사 로그로 — 누가·언제·누구에게·무엇을·왜 |
지급 API — 실수 방지 장치까지가 한 세트
public record GrantRequest(
long PlayerId,
string ItemCode,
int Amount,
string Reason); // ★ 사유 필수 — 감사 로그에 남는다
admin.MapPost("/grant", async (GrantRequest req, ClaimsPrincipal op,
GameDb db, IAuditLog audit) =>
{
// ① 상한선 — 실수로 0 하나 더 붙는 사고를 기계가 막는다
if (req.Amount is <= 0 or > 10_000)
return Results.BadRequest(new { error = "amount 1~10000" });
if (string.IsNullOrWhiteSpace(req.Reason))
return Results.BadRequest(new { error = "reason required" });
// ② 지급은 유저 API 와 같은 서비스 로직을 태운다 (지급 이력 포함)
await _wallet.GrantAsync(req.PlayerId, req.ItemCode, req.Amount,
source: $"admin:{op.Identity!.Name}");
// ③ 감사 로그 — 조회만 빼고 전부 남긴다
await audit.WriteAsync(op.Identity!.Name!, "grant", req);
return Results.Ok();
}).RequireAuthorization("role:grant");
첫 달에 필요한 어드민 기능 목록
| 기능 | 핵심 포인트 |
|---|---|
| 유저 조회 | 계정·재화·인벤토리·최근 접속을 한 화면에 — CS 응대의 기본 |
| 우편 발송 | 개인/전체, 첨부 아이템, 예약 발송 — 보상·사과문의 표준 통로 |
| 재화 지급·회수 | 사유 필수 + 상한선 + 지급 이력 — 회수도 지급의 음수가 아니라 별도 기록 |
| 밴 · 제재 | 기간·사유·해제 예약, 접속 중이면 즉시 킥(SignalR·세션 무효화) |
| 공지 · 점검 모드 | 점검 토글 시 신규 접속 차단 + 클라이언트 안내 문구 — 운영자 IP 는 예외로 두고 점검 중 검증 |
| 확률·상수 조회 | 가챠 확률표·이벤트 기간을 운영자가 직접 확인 — 고시 확률 검증 요청 대응 |
한 대로 되던 것이
안 되기
시작할 때
출시 첫날 동시접속이 예상의 10배로 오면 서버는 반드시 무너집니다. 어디가 먼저 무너지는지, 그때 무엇을 늘려야 하는지를 미리 알아 둡니다. 여기까지 마치면 수만 동시접속을 견디는 구조를 설계하고 장애를 침착하게 처리할 수 있습니다.
규모의 감각 잡기
"동시접속 10만"이 실제로 초당 몇 요청이고 서버 몇 대인지 계산해 봅니다.
숫자로 환산하기
동시접속(CCU) 10,000 인 모바일 RPG 가정
일반 API (로비·상점·인벤토리)
사용자당 분당 6요청 → 10,000 × 6 / 60 = 1,000 RPS
한 요청 20ms → 서버 1대(4코어)가 대략 500~1,000 RPS
→ 앱 서버 2~4대 + 여유
실시간 전투 (20Hz 동기화)
방당 4명 · 초당 20틱 · 인당 200B
4 × 20 × 200 = 16 KB/s per room
방 2,500개(1만 명) → 40 MB/s = 320 Mbps ★ 대역폭이 먼저 한계
DB
쓰기 초당 200건 · 읽기 800건
→ 읽기는 캐시로 90% 흡수, 쓰기는 단일 인스턴스로도 가능
먼저 정할 것
- 목표 지표(SLO) — 예: "API p99 200ms · 가용성 99.5%"
- 예상 CCU 의 3배로 부하 테스트 — 오픈 첫날은 항상 예상을 넘습니다
- 비용 한도 — 무한 확장은 가능하지만 돈이 듭니다
- 줄일 수 있는 기능 — 부하가 폭주하면 무엇을 끌 것인가(랭킹·상세 통계)
규모별로 필요한 것 — 단계 표
| CCU | 구성 | DB | 이때 해야 할 일 | 대략 비용/월 |
|---|---|---|---|---|
| ~500 | 서버 1대 | 같은 서버에 DB | 백업 · 모니터링만 | 수만 원 |
| ~5,000 | 앱 2대 + LB | DB 분리 · Redis 캐시 | 무상태화 · 세션을 Redis 로 | 수십만 원 |
| ~30,000 | 앱 4~8대 · 오토스케일 | 읽기 복제본 1~2 | 캐시 전략 · 큐 도입 · 커넥션 풀러 | 수백만 원 |
| ~100,000 | 지역별 배치 | 복제 + 아카이빙 | CDN · 배치 분리 · 비용 최적화 | 천만 원대 |
| 100,000+ | 멀티 리전 | 샤딩 검토 | 전담 인프라 인원이 필요합니다 | — |
직접 계산해 보기 — 우리 서비스의 숫자
[1] RPS 구하기
RPS = CCU × (사용자당 분당 요청 수) / 60
예) CCU 30,000 · 분당 6요청 → 3,000 RPS
[2] 서버 대수
한 대가 감당하는 RPS ≈ 코어수 × 1000 / 평균응답ms
예) 4코어 · 평균 20ms → 4 × 1000 / 20 = 200... 이 아니라
비동기 IO 라 실제로는 500~1,500 RPS (CPU 를 안 씀)
※ 반드시 부하 테스트로 실측하세요. 계산은 자릿수 확인용입니다.
3,000 RPS ÷ 800 = 4대 → 여유 포함 6대
[3] DB 커넥션
필요 커넥션 = 앱 대수 × 풀 크기
6대 × 20 = 120 개 → Postgres max_connections 확인
※ 풀 크기는 (코어수 × 2 + 디스크수) 정도가 출발점.
크게 잡을수록 좋은 것이 절대 아닙니다.
[4] 대역폭 (실시간이면 여기가 먼저)
초당 바이트 = 인원 × 틱레이트 × 패킷크기
30,000 × 20 × 200B = 120 MB/s = 960 Mbps
→ 1Gbps 회선을 이미 넘습니다. 패킷을 줄이거나 서버를 나눠야 합니다.
[5] 저장 용량
일일 로그 = RPS × 86,400 × 로그당 바이트
3,000 × 86,400 × 500B ≈ 130 GB/일 → 보관 14일이면 1.8TB
※ 로그 비용이 서버 비용을 넘는 경우가 실제로 흔합니다.
처음부터 지켜야 하는 세 가지
// ① 무상태 — 어느 서버가 받아도 같은 결과
// ✗ 메모리에 세션
private static readonly Dictionary<long, Session> _sessions = new();
// ✓ 외부 저장소에
builder.Services.AddStackExchangeRedisCache(o =>
o.Configuration = cfg.GetConnectionString("Redis"));
// 또는 JWT 로 아예 상태를 두지 않기
// ② 멱등성 — 같은 요청이 두 번 와도 안전하게
public async Task<Result> BuyAsync(long pid, int itemId, string idemKey, CT ct)
{
// 이미 처리한 키면 그때 결과를 그대로
if (await _cache.GetAsync<Result>($"idem:{idemKey}", ct) is { } cached)
return cached;
var r = await _shop.BuyAsync(pid, itemId, ct);
await _cache.SetAsync($"idem:{idemKey}", r, TimeSpan.FromHours(24), ct);
return r;
}
// ③ 원자적 차감 — 읽고-쓰기는 동시 요청에서 반드시 깨집니다
// ✗ 이렇게 하면 골드가 마이너스가 됩니다
var p = await _db.Players.FindAsync(pid);
if (p.Gold >= cost) { p.Gold -= cost; await _db.SaveChangesAsync(ct); }
// ✓ DB 가 한 문장으로 판단하게
var affected = await _db.Players
.Where(x => x.Id == pid && x.Gold >= cost)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Gold, x => x.Gold - cost), ct);
if (affected == 0) return Result.NotEnoughGold;
수평 확장 — 서버를 늘릴 수 있는 상태
한 대를 키우는 것(수직)에는 한계가 있습니다. 여러 대로 나누려면 조건이 있습니다.
무상태로 만들기
메모리에 상태
static Dictionary<int, Session> _sessions;
// A 서버에 로그인 → B 서버로 요청
// → "로그인이 풀렸어요"
바깥에 상태
await redis.StringSetAsync(
$"sess:{token}", json,
TimeSpan.FromMinutes(30));
// 어느 서버로 가도 같다
- 세션·캐시는 Redis로
- 업로드 파일은 오브젝트 스토리지로(로컬 디스크 금지)
- 스케줄 작업은 한 대만 실행되게 락으로 보호
- 메모리 캐시는 써도 되지만 서버마다 다를 수 있음을 전제로
게임 서버는 사정이 다릅니다
- 방 배정 서버(매치메이커)가 어느 게임 서버로 갈지 정합니다
- 게임 서버는 자기 방만 책임집니다 — 서로 통신하지 않습니다
- 서버가 죽으면 그 방만 영향받습니다(스냅샷으로 복구)
- 방 수요에 따라 인스턴스를 늘렸다 줄입니다(Agones 등)
// 매치메이커가 여유 있는 서버를 골라 배정
var node = _nodes.Where(n => n.Rooms < n.Capacity)
.OrderBy(n => n.Rooms)
.FirstOrDefault()
?? await ScaleOutAsync(); // 없으면 새 인스턴스 요청
return new MatchResult(node.Host, node.Port, roomToken);
Redis — 캐시 · 랭킹 · 락
게임 백엔드에서 가장 많이 쓰이는 한 대. 자료 구조를 알면 대부분 해결됩니다.
자료 구조별 용도
| 구조 | 게임에서 |
|---|---|
| String | 세션 토큰 · 설정 캐시 |
| Hash | 플레이어 요약 정보 |
| Sorted Set | 랭킹 · 대기열 · 만료 예약 |
| Set | 온라인 목록 · 친구 |
| List | 간단한 작업 큐 |
| Pub/Sub | 서버 간 알림 · 채팅 |
| Stream | 이벤트 로그(재처리 가능) |
// 선착순 이벤트 — 원자적으로 감소
var left = await db.StringDecrementAsync("event:stock");
if (left < 0)
{
await db.StringIncrementAsync("event:stock"); // 되돌리기
return Result.SoldOut;
}
분산 락
// 여러 서버 중 하나만 실행해야 할 때 (일일 정산 · 스케줄)
public async Task<bool> TryRunOnceAsync(string key, TimeSpan ttl, Func<Task> work)
{
var token = Guid.NewGuid().ToString();
if (!await db.StringSetAsync(key, token, ttl, When.NotExists))
return false; // 다른 서버가 잡았다
try { await work(); return true; }
finally
{
// ★ 내가 잡은 락만 푼다 (TTL 만료 후 남의 락을 푸는 사고 방지)
const string lua = @"
if redis.call('get', KEYS[1]) == ARGV[1]
then return redis.call('del', KEYS[1]) else return 0 end";
await db.ScriptEvaluateAsync(lua, [key], [token]);
}
}
캐시 전략 · 스탬피드
캐시는 없앨 때가 어렵습니다. 그리고 만료되는 순간이 가장 위험합니다.
캐시 스탬피드
// ① 하나만 계산하게 만든다 (나머지는 기다렸다 결과를 공유)
private static readonly SemaphoreSlim _gate = new(1, 1);
async Task<T> GetAsync<T>(string key, Func<Task<T>> load, TimeSpan ttl)
{
if (_cache.TryGetValue(key, out T? hit)) return hit!;
await _gate.WaitAsync();
try
{
if (_cache.TryGetValue(key, out hit)) return hit!; // 이중 확인
var v = await load();
// ② 만료 시각을 흩뿌린다 (한꺼번에 만료되지 않게)
var jitter = TimeSpan.FromSeconds(Random.Shared.Next(0, 60));
_cache.Set(key, v, ttl + jitter);
return v;
}
finally { _gate.Release(); }
}
무효화 전략
| 방식 | 설명 | 주의 |
|---|---|---|
| TTL | 시간이 지나면 자동 삭제 | 가장 단순 · 잠깐 옛 값 |
| 쓰기 시 삭제 | 변경하면 키를 지움 | 지울 키를 빠뜨리기 쉬움 |
| 버전 키 | shop:v12 처럼 번호를 올림 | 옛 키는 TTL 로 정리 |
DB 확장 — 복제 · 샤딩
앱 서버는 쉽게 늘리지만 DB 는 아닙니다. 읽기부터, 그다음 쓰기 순으로 나눕니다.
① 읽기 복제본
// 읽기 전용 쿼리를 복제본으로 보낸다
builder.Services.AddDbContext<GameDb>(o => o.UseNpgsql(primary));
builder.Services.AddDbContext<GameReadDb>(o => o.UseNpgsql(replica));
// 랭킹·통계·목록 조회는 복제본
var top = await readDb.Rankings.AsNoTracking()
.OrderByDescending(r => r.Score).Take(100).ToListAsync();
② 샤딩 — 마지막 수단
// 플레이어 id 로 DB 를 나눈다
int ShardOf(long playerId) => (int)(playerId % ShardCount);
var db = _shards[ShardOf(playerId)];
var player = await db.Players.FindAsync(playerId);
- 샤드 간 조인·트랜잭션이 불가능합니다 — 설계가 크게 바뀝니다
- 샤드 수를 늘릴 때 재분배가 필요합니다(미리 넉넉히 잡거나 일관 해싱)
- 길드·랭킹처럼 가로지르는 데이터는 별도 저장소로
이 순서대로 시도하세요 — 위로 갈수록 싸고 효과가 큽니다
| 순서 | 수단 | 효과 | 드는 노력 | 언제 한계 |
|---|---|---|---|---|
| 1 | 인덱스 · N+1 제거 | 수십 배 | 수 시간 | 거의 항상 여기서 큰 몫이 나옵니다 |
| 2 | 캐시(Redis) | 읽기 90% 흡수 | 1~2일 | 무효화 설계가 어려워질 때 |
| 3 | 커넥션 풀러(PgBouncer) | 커넥션 한계 해소 | 반나절 | — |
| 4 | 인스턴스 스케일업 | 2~4배 | 재시작 한 번 | 가장 큰 인스턴스에 닿을 때 |
| 5 | 읽기 복제본 | 읽기 부하 분산 | 2~3일 | 쓰기가 병목일 때 |
| 6 | 아카이빙(옛 데이터 분리) | 인덱스·백업이 빨라짐 | 1주 | — |
| 7 | 기능별 DB 분리 | 쓰기 분산 | 2~4주 | 한 기능이 여전히 넘칠 때 |
| 8 | 샤딩 | 사실상 무한 | 수개월 · 설계 변경 | — |
복제 지연을 안전하게 다루는 법
// 문제: 산 직후 인벤토리를 복제본에서 읽으면 아직 없습니다
// 해결: "방금 쓴 사용자"는 잠깐 주 DB 로 보냅니다
public sealed class ReadPreference(IDistributedCache cache)
{
private static readonly TimeSpan Window = TimeSpan.FromSeconds(3);
public Task MarkWrittenAsync(long playerId, CancellationToken ct) =>
cache.SetStringAsync($"rw:{playerId}", "1",
new() { AbsoluteExpirationRelativeToNow = Window }, ct);
public async Task<bool> MustUsePrimaryAsync(long playerId, CancellationToken ct) =>
await cache.GetStringAsync($"rw:{playerId}", ct) is not null;
}
// 쓰기 직후
await _shop.BuyAsync(pid, itemId, ct);
await _readPref.MarkWrittenAsync(pid, ct);
// 읽을 때
var db = await _readPref.MustUsePrimaryAsync(pid, ct) ? _primary : _replica;
var inv = await db.Inventories.AsNoTracking()
.Where(i => i.PlayerId == pid).ToListAsync(ct);
| 이 조회는 | 어디로 | 왜 |
|---|---|---|
| 내 인벤토리 · 재화 | 주 DB | 방금 바뀌었을 수 있음 |
| 결제 · 구매 이력 | 주 DB | 돈 문제는 지연을 허용 못 함 |
| 랭킹 · 통계 | 복제본 | 몇 초 늦어도 아무도 모름 |
| 상점 목록 · 아이템 정보 | 복제본 + 캐시 | 거의 안 바뀜 |
| 친구 목록 · 길드원 | 복제본 | — |
| 운영 도구 조회 | 복제본 | 운영 쿼리가 서비스를 방해하지 않게 |
샤딩을 정말 한다면 — 미리 알아야 할 것
// ✗ 나머지 연산 샤딩 — 샤드를 늘릴 때 거의 전부가 이사해야 합니다
int ShardOf(long pid) => (int)(pid % 4); // 4 → 5 로 늘리면 80% 가 이동
// ✓ 논리 샤드를 넉넉히 만들고 물리 DB 에 매핑
// 논리 샤드 1024 개를 만들어 두고, 물리 DB 4개에 256개씩 배정
const int Logical = 1024;
int LogicalOf(long pid) => (int)(pid % Logical);
// 매핑 테이블(설정·Redis)만 바꾸면 물리 DB 를 늘릴 수 있습니다
string PhysicalOf(long pid) => _map[LogicalOf(pid)]; // 4 → 5: 논리 샤드 약 20% 만 통째로 이동
// 샤드 키는 "거의 모든 쿼리에 들어가는 것"으로 — 게임은 보통 playerId
// 길드처럼 여러 명이 함께 보는 데이터는 guildId 로 샤딩하거나 별도 저장소로
- 샤드 간 조인·트랜잭션이 사라집니다 — 애플리케이션에서 합치거나, 애초에 그런 쿼리를 안 만드는 설계로
- 랭킹·길드·매칭 처럼 가로지르는 데이터는 Redis 나 별도 DB로 빼는 것이 정석입니다
- 마이그레이션이 샤드 수만큼 늘어납니다 — 자동화가 없으면 운영이 불가능해집니다
- 핫 샤드가 생깁니다 — 인기 길드·이벤트 참여자가 한 샤드에 몰리면 그 샤드만 죽습니다
- 백업·복구가 N배 복잡해집니다. 한 샤드만 복구하면 데이터 시점이 어긋납니다
메시지 큐 — 나중에 해도 되는 일
요청 안에서 다 하려고 하면 느려집니다. 급하지 않은 일은 미룹니다.
무엇을 미룰 것인가
요청 안에서 전부
구매 요청
→ 결제 처리
→ 아이템 지급
→ 우편 발송
→ 통계 집계
→ 푸시 알림
= 800ms · 하나 실패하면 전부 실패
핵심만 즉시
구매 요청
→ 결제 · 지급 (트랜잭션)
→ 이벤트 발행
= 80ms
큐 소비자가 나머지를 처리
// 아웃박스 — 트랜잭션 안에 이벤트도 함께 저장
_db.Outbox.Add(new OutboxMessage
{
Type = nameof(ItemPurchased),
Payload = JsonSerializer.Serialize(evt),
});
await _db.SaveChangesAsync(ct); // 지급과 이벤트가 원자적으로 함께
// 별도 워커가 outbox 를 읽어 큐로 보냅니다 (전송 실패해도 유실 없음)
소비자 쪽 규칙
- 같은 메시지를 두 번 받을 수 있습니다 — 처리를 멱등하게 (처리한 id 기록)
- 순서 보장은 파티션 안에서만 — 플레이어 id 로 파티션을 나누세요
- 실패 메시지는 DLQ(죽은 편지 큐)로 — 무한 재시도로 큐를 막지 않게
- 소비 지연을 모니터링 — 밀리기 시작하면 소비자를 늘립니다
큐를 무엇으로 — 규모에 맞게
| 수단 | 처리량 | 순서 보장 | 운영 부담 | 맞는 곳 |
|---|---|---|---|---|
| DB 테이블 | ~수백/초 | 쉬움 | 없음(이미 있는 DB) | 시작은 여기 — 대부분 충분합니다 |
| Redis Stream | ~수만/초 | 스트림 단위 | 낮음 | 이미 Redis 를 쓰고 있다면 |
| RabbitMQ | ~수만/초 | 큐 단위 | 중간 | 복잡한 라우팅 · 재시도 정책 |
| Kafka | 수십만/초 | 파티션 단위 | 높음 | 로그·이벤트 스트림 · 재처리가 필요할 때 |
| 클라우드 관리형 (SQS·Pub/Sub) | 높음 | FIFO 옵션 | 낮음 | 운영 인력이 적을 때 가장 현실적 |
DB 테이블 큐 — 이것부터 시작하세요
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
attempts INT NOT NULL DEFAULT 0,
next_try_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 아직 안 보낸 것만 인덱스에 (부분 인덱스 — 테이블이 커져도 인덱스는 작게)
CREATE INDEX ix_outbox_pending ON outbox (next_try_at)
WHERE processed_at IS NULL;
// 발행 워커 — SKIP LOCKED 로 여러 워커가 안전하게 나눠 처리
public class OutboxPublisher(IServiceScopeFactory f, ILogger<OutboxPublisher> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(1));
while (await timer.WaitForNextTickAsync(ct))
{
try
{
using var scope = f.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<GameDb>();
// ★ 트랜잭션 필수 — 없으면 SELECT 가 끝나는 순간 행 잠금이 풀려
// 다른 워커가 같은 행을 또 집어 갑니다
await using var tx = await db.Database.BeginTransactionAsync(ct);
var batch = await db.Database.SqlQuery<OutboxRow>($"""
SELECT * FROM outbox
WHERE processed_at IS NULL AND next_try_at <= now()
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED
""").ToListAsync(ct);
foreach (var m in batch)
{
try
{
await _bus.PublishAsync(m.Type, m.Payload, ct);
await db.Database.ExecuteSqlAsync(
$"UPDATE outbox SET processed_at = now() WHERE id = {m.Id}", ct);
}
catch (Exception ex)
{
// 지수 백오프로 다시 시도 — 8회 넘으면 사람이 봐야 합니다
var delay = TimeSpan.FromSeconds(Math.Pow(2, m.Attempts));
await db.Database.ExecuteSqlAsync($"""
UPDATE outbox
SET attempts = attempts + 1,
next_try_at = now() + {delay}
WHERE id = {m.Id}
""", ct);
if (m.Attempts >= 8)
log.LogError(ex, "outbox 배달 실패 {Id} {Type}", m.Id, m.Type);
}
}
await tx.CommitAsync(ct); // 여기서 잠금이 풀립니다
}
catch (Exception ex) { log.LogError(ex, "outbox 루프 오류"); }
}
}
}
FOR UPDATE SKIP LOCKED 가 이 패턴의 핵심입니다 —
워커를 여러 대 띄워도 같은 행을 두 번 집지 않고,
느린 행 때문에 다른 워커가 막히지도 않습니다.
처리 끝난 행은 주기적으로 지우거나 파티션을 잘라 내세요 —
안 그러면 이 테이블이 DB 에서 가장 커집니다.
소비자 — 멱등하게 만드는 두 가지 방법
// ① 처리한 메시지 ID 를 기록 (가장 단순하고 확실)
public async Task HandleAsync(ItemPurchased e, string messageId, CT ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);
var inserted = await _db.Database.ExecuteSqlAsync($"""
INSERT INTO processed_messages (id, at) VALUES ({messageId}, now())
ON CONFLICT (id) DO NOTHING
""", ct);
if (inserted == 0) return; // ★ 이미 처리함 — 조용히 끝
await SendPushAsync(e, ct);
await _stats.RecordAsync(e, ct);
await tx.CommitAsync(ct);
}
// ② 연산 자체를 멱등하게 (더 좋습니다 — 기록이 필요 없음)
// ✗ 누적: 두 번 오면 두 배
await _db.Stats.Where(s => s.Day == d)
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Sales, x => x.Sales + amt), ct);
// ✓ 덮어쓰기: 몇 번 와도 같은 결과
await _db.MailBox.Upsert(new Mail { Id = e.MailId, ... }, ct);
| 운영 지표 | 정상 | 이러면 |
|---|---|---|
| 미처리 메시지 수 | 수십 | 계속 늘면 소비자 증설 |
| 가장 오래된 미처리 나이 | < 30초 | 5분 넘으면 알람 |
| DLQ 건수 | 0 | 1건이라도 사람이 확인 |
| 재시도 횟수 평균 | < 0.1 | 높으면 하위 시스템 장애 |
- DLQ(죽은 편지 큐)를 반드시 두세요 — 안 그러면 못 처리하는 메시지 하나가 큐 전체를 막습니다("독약 메시지")
- 순서가 필요한 것(같은 플레이어의 재화 변동)은 플레이어 ID 로 파티션을 나눠야 순서가 지켜집니다
- 소비자는 짧게 — 한 메시지 처리에 10초가 걸리면 재시도·타임아웃 설계가 전부 어려워집니다. 길면 다시 쪼개세요
- 이벤트에는 필요한 데이터를 다 담으세요. "ID 만 보내고 소비자가 다시 조회"는 편하지만, DB 부하를 큐로 옮긴 것일 뿐입니다
부하 제어 — 거절도 전략입니다
전부 받아 주다 전부 느려지는 것보다, 일부를 빨리 돌려보내는 편이 낫습니다.
속도 제한
builder.Services.AddRateLimiter(o =>
{
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
o.AddPolicy("per-user", ctx =>
RateLimitPartition.GetTokenBucketLimiter(
ctx.User.Identity?.Name ?? ctx.Connection.RemoteIpAddress!.ToString(),
_ => new TokenBucketRateLimiterOptions
{
TokenLimit = 20, // 순간 버스트 허용
TokensPerPeriod = 10, // 초당 10개 회복
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 0, // 대기 없이 즉시 거절
}));
});
app.MapPost("/shop/buy", BuyAsync).RequireRateLimiting("per-user");
Retry-After 를 넣으세요.
클라이언트가 언제 다시 시도할지 알 수 있어야
재시도 폭풍이 생기지 않습니다.
대기열 · 점진 오픈
- 대기열 — 오픈 첫날 접속을 순서대로 받습니다. "몇 번째, 예상 몇 분"을 보여 주는 것이 중요합니다
- 기능 차단 — 부하가 높으면 랭킹·통계 같은 부가 기능부터 끕니다
- 서킷 브레이커 — 외부 API 가 죽으면 빠르게 실패시키고 폴백
- 재시도 지터 — 클라이언트가 동시에 재접속하지 않게
// Polly — 서킷 브레이커 + 타임아웃
builder.Services.AddHttpClient<IPayApi, PayApi>()
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 2;
o.CircuitBreaker.FailureRatio = 0.5;
o.AttemptTimeout.Timeout = TimeSpan.FromSeconds(3);
});
게임 서버 스케일링 — 방 단위로
웹 서버와 달리 연결이 오래 유지되고 상태가 있습니다. 그래서 방식이 다릅니다.
인스턴스 관리
// 게임 서버가 자기 상태를 주기적으로 보고
public sealed class NodeReporter(IRedis redis) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await redis.Db.HashSetAsync($"node:{NodeId}",
[
new("rooms", RoomManager.Count),
new("cpu", Cpu.Percent()),
new("state", _draining ? "draining" : "ready"),
]);
await redis.Db.KeyExpireAsync($"node:{NodeId}", TimeSpan.FromSeconds(15));
await Task.Delay(5000, ct);
}
}
}
- TTL 로 죽은 노드를 자동 제거 — 보고가 끊기면 목록에서 사라집니다
- draining 상태 — 새 방은 안 받고 기존 방이 끝나면 종료
- 여유분을 미리 — 새 인스턴스가 뜨는 데 30초~2분이 걸립니다
축소가 더 어렵습니다
- 축소는 천천히 — 트래픽이 줄어도 여유를 두고 줄이세요
- 방 이전(마이그레이션)은 매우 어렵습니다. 대개 하지 않습니다
- 점검 공지 — 예정된 종료는 게임 안에서 미리 알립니다
- 스팟 인스턴스는 게임 서버에 위험합니다(갑자기 회수)
무중단 배포 · 점검
업데이트를 사용자가 모르게 하거나, 확실히 알리고 하거나. 둘 중 하나여야 합니다.
배포 전략
| 전략 | 설명 | 게임에서 |
|---|---|---|
| 롤링 | 한 대씩 교체 | API 서버에 적합 |
| 블루-그린 | 새 환경으로 한 번에 전환 | 롤백이 빠름 |
| 카나리 | 일부에게만 먼저 | 지표 비교 필요 |
| 점검 | 서비스 중단 후 교체 | DB 대변경 시 |
점검을 해야 한다면
- 사전 공지 — 게임 내 배너 + 커뮤니티. 최소 하루 전
- 점검 모드 스위치 — 새 접속 차단, 진행 중인 판은 마무리
- 롤백 계획 — 언제까지 안 되면 되돌린다를 미리 정하세요
- 점검 후 보상 — 우편 일괄 지급 도구를 준비
- 재접속 폭주 대비 — 점검이 끝나면 전원이 동시에 붙습니다
점검 없이 갈 수 있는가 — 판단 기준
| 바꾸는 것 | 점검 | 방법 |
|---|---|---|
| API 로직 · 버그 수정 | 불필요 | 롤링 배포 |
| 컬럼 추가(nullable) | 불필요 | 2단계 배포(추가 → 사용 → 조이기) |
| 인덱스 추가 | 보통 불필요 | CREATE INDEX CONCURRENTLY |
| 클라이언트 필수 업데이트 | 불필요 | 426 응답 + 스토어 배포 대기 |
| 컬럼 타입 변경 · 이름 변경 | 불필요(하기 나름) | 새 컬럼 + 양쪽 쓰기 + 백필 |
| 테이블 구조 대변경 · 데이터 이관 | 필요 | 점검 · 또는 무중단 이관 도구 |
| 재화 · 밸런스 일괄 조정 | 필요 | 점검 중 배치 실행 |
| DB 엔진 · 인프라 교체 | 필요 | 리허설을 반드시 먼저 |
점검 모드 — 스위치 하나로
// 상태는 Redis 에 — 재배포 없이 켜고 끕니다
public sealed class MaintenanceGate(IConnectionMultiplexer redis)
{
private const string Key = "sys:maintenance";
public async Task<MaintenanceInfo?> GetAsync()
{
var v = await redis.GetDatabase().StringGetAsync(Key);
return v.HasValue ? JsonSerializer.Deserialize<MaintenanceInfo>(v!) : null;
}
public Task SetAsync(MaintenanceInfo i) =>
redis.GetDatabase().StringSetAsync(Key, JsonSerializer.Serialize(i));
public Task ClearAsync() => redis.GetDatabase().KeyDeleteAsync(Key);
}
// 미들웨어 — 엔드포인트에 닿기 전에 자릅니다
app.Use(async (ctx, next) =>
{
// 헬스체크와 운영 API 는 통과시켜야 합니다 (안 그러면 점검 해제도 못 함)
if (ctx.Request.Path.StartsWithSegments("/health") ||
ctx.Request.Path.StartsWithSegments("/admin"))
{ await next(ctx); return; }
var m = await gate.GetAsync();
if (m is null) { await next(ctx); return; }
// 사내 IP · 테스터 계정은 통과 — 점검 중 검증에 필수입니다
if (m.AllowIps.Contains(ctx.Connection.RemoteIpAddress?.ToString()))
{ await next(ctx); return; }
ctx.Response.StatusCode = 503;
ctx.Response.Headers.RetryAfter = ((int)(m.EndsAt - DateTimeOffset.UtcNow)
.TotalSeconds).ToString();
await ctx.Response.WriteAsJsonAsync(new
{
code = "MAINTENANCE",
title = m.Title, // "정기 점검 중입니다"
endsAtUtc = m.EndsAt, // ★ 클라가 남은 시간을 표시
notice = m.NoticeUrl
});
});
/admin 과 /health 는 반드시 통과시키세요.
그리고 Redis 가 죽으면 어떻게 되는지 정해 두세요 —
읽기 실패를 "점검 중"으로 처리하면 Redis 장애가 전면 중단이 됩니다.
기본값은 통과여야 합니다.
점검 타임라인 — 실제로 이렇게 흘러갑니다
D-3 공지 (게임 내 배너 · 커뮤니티 · 푸시)
점검 절차 문서 작성 · 담당자 배정
스테이징에서 전체 리허설 ★ 여기서 소요 시간을 잰다
D-1 재공지 · 롤백 기준 합의
"몇 시까지 안 되면 되돌린다"를 숫자로 정합니다
00:00 점검 시작
① 신규 접속 차단 (점검 모드 ON)
② 진행 중인 전투가 끝날 때까지 5~10분 대기
③ 전체 세션 종료 · 저장 확인
④ ★ DB 백업 (스냅샷) — 되돌릴 유일한 수단
⑤ 마이그레이션 실행
⑥ 새 버전 배포
⑦ 스모크 테스트 (사내 IP 로 로그인 → 상점 → 전투)
⑧ 점검 모드 OFF
00:50 재접속 폭주 — 평소의 5~10배가 동시에 들어옵니다
★ 이 순간을 대비해 인스턴스를 미리 늘려 두세요
로그인·초기 데이터 로드가 가장 큰 부하입니다
01:30 안정화 확인 후 보상 우편 일괄 지급
D+1 사후 기록 — 실제 소요 시간을 다음 점검 계획에 반영
| 단계 | 흔한 사고 | 대비 |
|---|---|---|
| ④ 백업 | 백업이 끝나기 전에 다음 단계 진행 | 완료 확인을 절차에 명시 |
| ⑤ 마이그레이션 | 예상보다 10배 오래 걸림 | 리허설에서 실측(운영과 같은 크기로) |
| ⑦ 스모크 | 안 하고 열었다가 로그인 불가 | 체크리스트 5개 고정 |
| 재접속 | 열자마자 다시 죽음 | 미리 증설 · 로그인 속도 제한 |
| 보상 지급 | 중복 지급 · 누락 | 멱등 지급 도구 + 대상 수 먼저 확인 |
모니터링 — 무엇을 볼 것인가
지표가 100개면 아무도 안 봅니다. 다섯 개로 시작하세요.
핵심 지표
| 지표 | 의미 | 경보 기준 예 |
|---|---|---|
| 에러율 | 5xx 비율 | 1% 초과 5분 |
| 지연 p99 | 느린 1% | 500ms 초과 |
| CCU | 동시접속 | 급감 시(장애 신호) |
| DB 커넥션 | 풀 사용률 | 80% 초과 |
| 큐 지연 | 처리 밀림 | 1분 초과 |
static readonly Meter M = new("Game.Api");
static readonly Counter<long> Purchases = M.CreateCounter<long>("shop.purchase");
static readonly Histogram<double> Latency = M.CreateHistogram<double>("api.ms");
Purchases.Add(1, new KeyValuePair<string, object?>("item", itemId));
경보 규칙
- 사용자가 겪는 것만 경보 — CPU 70% 는 경보가 아니라 대시보드입니다
- 지속 시간을 조건에 — 순간 스파이크로 새벽에 깨지 않게
- 알림에 조치 방법을 링크 — 런북이 없으면 당황합니다
- 비즈니스 지표도 보세요 — 결제 성공률이 갑자기 떨어지면 장애입니다
세 가지 신호를 구분하세요 — 지표 · 로그 · 추적
| 종류 | 답하는 질문 | 비용 | 보관 | 도구 |
|---|---|---|---|---|
| 지표(metric) | 지금 이상한가? | 매우 쌈 | 1년+ | Prometheus · CloudWatch |
| 추적(trace) | 이 요청의 어디가 느린가? | 중간(샘플링) | 7~30일 | Jaeger · Tempo · X-Ray |
| 로그(log) | 무슨 일이 있었나? | 비쌈 | 14~30일 | Loki · Seq · CloudWatch |
경보를 이렇게 씁니다 — 실제 규칙
# Prometheus 알람 규칙 — 지속 시간과 심각도가 핵심입니다
groups:
- name: game-api
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_server_request_duration_seconds_count{http_response_status_code=~"5.."}[5m]))
/ sum(rate(http_server_request_duration_seconds_count[5m])) > 0.01
for: 5m # ★ 5분 지속돼야 울림 (순간 스파이크 무시)
labels: { severity: page } # page = 새벽에도 깨움
annotations:
summary: "5xx 비율 {{ $value | humanizePercentage }}"
runbook: "https://wiki/runbook/high-error-rate" # ★ 조치 방법 링크
- alert: PaymentSuccessRateDrop
expr: |
sum(rate(game_purchase_total{result="ok"}[10m]))
/ sum(rate(game_purchase_total[10m])) < 0.95
for: 10m
labels: { severity: page } # 돈 문제는 무조건 깨웁니다
annotations:
summary: "결제 성공률 {{ $value | humanizePercentage }}"
- alert: DbConnectionPoolHigh
expr: db_client_connections_usage / db_client_connections_max > 0.8
for: 10m
labels: { severity: ticket } # ticket = 업무 시간에 처리
annotations:
summary: "DB 커넥션 풀 80% 초과 — 곧 고갈됩니다"
- alert: CcuSuddenDrop
expr: |
game_ccu < 0.5 * avg_over_time(game_ccu[1h] offset 1h)
for: 5m
labels: { severity: page } # 접속자 급감 = 대개 장애 신호
| 심각도 | 뜻 | 예 |
|---|---|---|
| page | 지금 사람을 깨운다 | 에러율 · 결제 실패 · 전면 다운 |
| ticket | 업무 시간에 처리 | 디스크 70% · 커넥션 80% |
| dashboard | 알림 없음, 보기만 | CPU · 요청 수 · 캐시 히트율 |
게임 고유 지표 — 기술 지표만으로는 부족합니다
static readonly Meter M = new("Game.Api", "1.0");
// ① 비즈니스 카운터 — 결과별로 태그를 나눠야 성공률이 나옵니다
static readonly Counter<long> Purchases =
M.CreateCounter<long>("game.purchase", unit: "{count}");
Purchases.Add(1,
new KeyValuePair<string, object?>("result", ok ? "ok" : "fail"),
new KeyValuePair<string, object?>("reason", reason ?? "-"),
new KeyValuePair<string, object?>("store", "google"));
// ② 히스토그램 — 평균이 아니라 분포를 봐야 합니다
static readonly Histogram<double> MatchWait =
M.CreateHistogram<double>("game.match.wait", unit: "s");
MatchWait.Record(sw.Elapsed.TotalSeconds,
new KeyValuePair<string, object?>("mode", "ranked"));
// ③ 게이지(관측 콜백) — 현재 상태
M.CreateObservableGauge("game.ccu", () => _sessions.Count);
M.CreateObservableGauge("game.rooms.active", () => _rooms.Count);
// ④ 튜닝: 태그 카디널리티에 주의하세요
// ✗ 플레이어 ID 를 태그로 → 시계열이 CCU 만큼 생겨 수집기가 죽습니다
Purchases.Add(1, new KeyValuePair<string, object?>("playerId", pid)); // 금지
// ✓ 값의 종류가 수십 개 이하인 것만 태그로 (아이템 등급, 스토어, 결과)
| 지표 | 이상하면 의심할 것 |
|---|---|
| 결제 성공률 급락 | 스토어 장애 · 우리 검증 로직 · 서킷 열림 |
| 매칭 대기 시간 증가 | 동접 감소 · 매칭 서버 · 티어 분포 |
| 튜토리얼 이탈률 증가 | 클라 버그 · 로딩 실패 · 특정 기기 |
| CCU 급감 | 대개 장애입니다 — 서버는 멀쩡해 보여도 |
| 재화 지급량 급증 | 어뷰징 · 버그 — 즉시 확인 |
| 특정 아이템 구매 폭증 | 가격 오설정 · 확률 버그 |
장애 대응 — 시나리오별
터졌을 때 순서대로 확인할 목록이 있으면 복구 시간이 절반이 됩니다.
증상 → 원인 → 조치
| 증상 | 먼저 볼 곳 |
|---|---|
| 전체 느림 | DB 커넥션 풀 · 느린 쿼리 · 캐시 히트율 |
| 일부만 느림 | 특정 노드 · 특정 방 · 핫키 |
| 접속 실패 급증 | LB 헬스체크 · 인증서 만료 · 노드 수 |
| 메모리 계속 증가 | 이벤트 미해지 · 캐시 무제한 · 세션 누수 |
| 결제만 실패 | 외부 API 상태 · 서킷 열림 |
| 배포 직후 오류 | 즉시 롤백 후 원인 분석 |
사후 분석
- 타임라인 — 언제 시작 · 언제 인지 · 언제 복구
- 영향 범위 — 몇 명이 얼마 동안 무엇을 못 했는가
- 직접 원인과 왜 막지 못했는가를 구분
- 재발 방지 항목에 담당자와 기한
- 사람을 탓하지 않기 — 한 사람의 실수로 무너지는 시스템이 문제입니다
터졌을 때 — 처음 15분 절차
| 시각 | 할 일 | 하지 말 것 |
|---|---|---|
| 0~2분 | 영향 범위 확인 — 전체인가 일부인가, 무엇이 안 되는가 | 원인부터 찾기 |
| 2~3분 | 지휘자 한 명 지정 · 상황 채널 개설 · 대응 시작 공지 | 여러 명이 각자 손대기 |
| 3~5분 | 최근 변경 확인 — 배포 · 설정 · 마이그레이션 · 외부 공지 | — |
| 5~10분 | 복구 우선 — 배포 직후면 롤백, 아니면 기능 차단·증설 | 근본 원인 분석 |
| 10~15분 | 덤프·로그 확보 후 재시작 · 사용자 공지 | 증거 없이 재시작 |
| 복구 후 | 타임라인 기록 · 보상 검토 · 사후 분석 일정 | 바로 잊어버리기 |
시나리오별 대응 — 무엇을 먼저 보고 무엇을 하나
| 증상 | 확인 | 즉시 조치 |
|---|---|---|
| 전체가 느림 | DB 커넥션 사용률 · 느린 쿼리 상위 10 | 무거운 기능 차단(랭킹·통계) · 커넥션 풀 조정 |
| 전체 500 | 최근 배포 · 설정 변경 | 롤백 |
| 일부만 느림 | 노드별 지표 · 핫 키 · 특정 방 | 해당 노드 격리(LB 에서 제외) |
| 접속 실패 급증 | 인증서 만료 · LB 헬스체크 · 노드 수 | 인증서 갱신 · 헬스체크 기준 확인 |
| 메모리 계속 증가 | dotnet-counters Gen2 힙 | 덤프 확보 후 롤링 재시작 |
| 결제만 실패 | 외부 API 상태 페이지 · 서킷 상태 | 결제 기능만 점검 표시 · 재시도 큐에 적재 |
| DB 가 다운 | 복제본 상태 · 백업 시점 | 복제본 승격 · 읽기 전용 모드로 축소 운영 |
| Redis 다운 | — | 캐시 없이도 돌아야 정상 — 안 되면 그게 설계 결함 |
| 디스크 가득 | 로그 · 덤프 · 임시 파일 | 오래된 로그 삭제 → 보관 정책부터 고칠 것 |
| 어뷰징 폭주 | 같은 IP·계정의 요청 패턴 | 속도 제한 강화 · 해당 기능 일시 차단 |
// 기능 차단 스위치를 미리 만들어 두세요 — 사고 중에는 코드를 못 고칩니다
public sealed class FeatureFlags(IConnectionMultiplexer redis)
{
public async Task<bool> IsEnabledAsync(string key) =>
await redis.GetDatabase().StringGetAsync($"flag:{key}") != "off";
}
// 무거운 기능에 미리 걸어 둡니다
app.MapGet("/ranking/global", async (FeatureFlags f, IRankRepo r) =>
await f.IsEnabledAsync("ranking")
? Results.Ok(await r.TopAsync(100))
: Results.Ok(Array.Empty<RankRow>())) // 빈 값으로 우아하게 축소
.WithTags("Rank");
사후 분석 — 이 양식이면 충분합니다
## 개요
2026-08-05 02:14 ~ 03:02 (48분) 아이템 구매 실패
영향: 전체 사용자의 구매 요청 약 12,000건 실패 (결제된 건 없음)
## 타임라인 (전부 UTC)
02:10 v1.4.2 배포 시작 (롤링)
02:14 결제 성공률 경보 (page) ← 인지까지 4분
02:16 대응 시작 · 지휘자 지정
02:19 최근 배포 확인 → 롤백 결정
02:23 롤백 시작
02:41 롤백 완료 · 성공률 회복 확인
03:02 잔여 실패 건 재처리 완료
## 직접 원인
v1.4.2 에서 결제 검증 타임아웃을 10초 → 2초로 줄였는데,
스토어 API 의 p99 가 3.5초라 대부분 타임아웃으로 실패.
## 왜 막지 못했나 (사람이 아니라 시스템을 봅니다)
1. 부하 테스트에 외부 스토어 API 가 가짜로 들어가 있어 실제 지연이 재현 안 됨
2. 카나리 없이 전체 롤링 → 5%에서 발견할 기회를 놓침
3. 타임아웃 값 변경이 코드 리뷰에서 "설정 조정"으로 가볍게 통과
## 잘된 것
- 경보가 4분 만에 울렸고 정확했다
- 롤백 판단이 5분 만에 나왔다 (사전 합의 덕분)
- 결제된 건이 없어 환불 처리가 불필요했다
## 재발 방지 (담당자 · 기한)
□ 외부 API 타임아웃 값을 실측 p99 기반으로 문서화 @kim 8/12
□ 결제 경로에 카나리 배포 적용 @lee 8/20
□ 부하 테스트에 스토어 API 실제 지연 주입 @park 8/26
□ 타임아웃·재시도 값 변경은 리뷰 필수 항목에 추가 @kim 8/9
- 인지 시간(MTTD)과 복구 시간(MTTR)을 따로 기록하세요 — 개선 방향이 완전히 다릅니다
- 재발 방지 항목에 담당자와 기한이 없으면 실행되지 않습니다. 3개 이하로 줄이고 반드시 끝내는 편이 낫습니다
- 잘된 것도 적으세요 — 무엇을 계속할지 아는 것도 개선입니다
- 같은 유형이 두 번 나오면 재발 방지가 실패한 것입니다. 항목을 다시 여세요
비용 · 글로벌 서비스
기술적으로 가능한 것과 지속 가능한 것은 다릅니다.
비용이 새는 곳
- 대역폭 — 게임 서버에서 가장 큰 비용. 패킷 크기가 곧 요금입니다
- 과도한 인스턴스 — 새벽에도 최대치로 켜 두는 경우
- 로그·메트릭 보관 — 전부 무기한 보관하면 저장 비용이 서버를 넘습니다
- 리전 간 전송 — 같은 리전 안에서 처리하도록 배치
- 안 쓰는 리소스 — 테스트용 인스턴스·스냅샷이 남아 있습니다
글로벌 서비스
- 지연은 물리 법칙 — 서울↔미국 서부 왕복 130ms 는 줄일 수 없습니다. 실시간 대전이면 지역별 서버가 유일한 답입니다
- 계정은 글로벌, 전투는 지역 으로 나누는 구조가 일반적입니다
- 시간대·명절 — 이벤트 시각을 UTC 로 저장하고 표시할 때 변환
- 법규 — 개인정보 이전·연령 등급·확률 공시는 국가마다 다릅니다
비용이 어디로 나가는가 — 그리고 무엇부터 줄이나
| 항목 | 보통 비중 | 줄이는 법 | 절감폭 | 드는 노력 |
|---|---|---|---|---|
| 컴퓨트(서버) | 30~50% | 오토스케일 · 예약 인스턴스 · 스팟 | 30~70% | 낮음 |
| 네트워크 아웃바운드 | 10~40% (실시간 게임은 더) | 패킷 축소 · AOI · CDN · 같은 리전 배치 | 20~50% | 중간 |
| 로그 · 메트릭 | 10~30% | 보관 기간 단축 · 샘플링 · 레벨 조정 | 50~80% | 매우 낮음 |
| DB | 15~30% | 아카이빙 · 인스턴스 적정화 · 예약 | 20~40% | 중간 |
| 스토리지 · 스냅샷 | 5~15% | 수명 주기 정책 · 옛 스냅샷 삭제 | 50%+ | 낮음 |
| 안 쓰는 리소스 | 5~20% | 태그 강제 + 주기 점검 | 전액 | 낮음 |
새벽 시간대를 그냥 켜 두고 있지 않은지
모바일 게임의 전형적인 CCU 곡선 (한국 기준)
피크 21~23시 ████████████████████ 100%
낮 13~17시 ████████████ 60%
새벽 03~07시 ███ 15% ← 여기가 하루의 1/6
고정 대수로 피크에 맞추면:
10대 × 24시간 = 240 서버·시간
오토스케일(최소 3대, 최대 12대)이면:
대략 130 서버·시간 → 약 45% 절감
★ 게임은 시간대별 편차가 큰 대표적인 워크로드입니다.
오토스케일이 가장 잘 듣는 종류의 서비스인데,
"복잡할 것 같아서" 안 켜 두는 경우가 많습니다.
# 예: Kubernetes HPA — 지표 기반 + 시간 기반을 함께
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
spec:
minReplicas: 3
maxReplicas: 12
metrics:
- type: Pods
pods:
metric: { name: http_requests_per_second }
target: { type: AverageValue, averageValue: "600" }
behavior:
scaleUp:
stabilizationWindowSeconds: 0 # 늘릴 때는 즉시
policies: [{ type: Percent, value: 100, periodSeconds: 30 }]
scaleDown:
stabilizationWindowSeconds: 600 # ★ 줄일 때는 천천히 (10분)
policies: [{ type: Percent, value: 20, periodSeconds: 60 }]
글로벌 — 지연은 협상 대상이 아닙니다
| 구간 | 왕복 지연(RTT) | 실시간 대전 |
|---|---|---|
| 서울 ↔ 서울 | 5~20ms | 쾌적 |
| 서울 ↔ 도쿄 | 35~50ms | 가능 |
| 서울 ↔ 싱가포르 | 70~90ms | 장르에 따라 |
| 서울 ↔ 미국 서부 | 120~150ms | 어려움 |
| 서울 ↔ 유럽 | 230~280ms | 불가능 |
일반적인 글로벌 구조 — 무엇을 나누고 무엇을 합치나
[글로벌 공통] [지역별]
계정 · 인증 게임 서버 (전투 · 매칭)
결제 영수증 검증 지역 DB (플레이어 데이터)
상점 상품 정의 지역 캐시
운영 도구 지역 랭킹
전역 랭킹(집계는 비동기) CDN 엣지
원칙
① 지연에 민감한 것은 사용자 근처로 (전투 · 조회)
② 정합성이 중요한 것은 한 곳으로 (결제 · 계정)
③ 리전 간 통신을 요청 경로에 넣지 말 것
→ 로그인 한 번에 리전을 왕복하면 그것만 300ms 입니다
- 시간은 전부 UTC 로 저장하고 표시할 때만 변환하세요. 서머타임·시간대 버그는 이벤트 보상 사고로 직결됩니다
- 확률 공시는 한국·중국 등에서 법적 의무입니다. 표기와 실제 코드가 어긋나면 제재 대상입니다 — 테스트로 묶어 두세요
- 개인정보 국외 이전은 나라마다 규정이 다릅니다(GDPR 등). 계정 DB 를 한 리전에 모을 때 반드시 확인하세요
- 리전 간 데이터 전송료는 같은 리전 내부보다 훨씬 비쌉니다. 배치 집계를 리전 간에 돌리면 청구서에 바로 나타납니다
- 연령 등급·결제 한도·청소년 보호 시간대도 국가별로 다릅니다 — 기능 플래그로 국가별 분기를 미리 만들어 두면 나중이 편합니다
사내 업무 도구는
여전히
데스크탑이 빠르다
엑셀을 읽고, 로컬 장비를 다루고, 오프라인에서도 도는 프로그램. 웹으로 만들면 오히려 오래 걸리는 요구가 회사에는 아직 많습니다. C# 은 이 영역에서 가장 빠른 선택지입니다. 여기까지 마치면 배포·자동 업데이트까지 갖춘 사내 도구를 완성할 수 있습니다.
무엇으로 만들 것인가
네 가지가 있고 고르는 기준은 명확합니다. 여기서 잘못 고르면 되돌리기 어렵습니다.
비교
| 대상 | 고를 이유 | |
|---|---|---|
| WPF | Windows | 자료·컨트롤이 가장 많음 사내 도구의 사실상 표준 |
| WinUI 3 | Windows 11 | 최신 디자인 생태계는 아직 얇음 |
| MAUI | Win·Mac·모바일 | 모바일도 함께 필요할 때 |
| Avalonia | Win·Mac·Linux | 리눅스가 필요하면 WPF 와 문법이 가장 비슷 |
다른 선택지와 비교
WPF 시작 · XAML
화면은 XAML(마크업), 동작은 C#. HTML·CSS 와 비슷한 분리입니다.
첫 창
dotnet new wpf -o MyTool
cd MyTool && dotnet run
<Window x:Class="MyTool.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="사내 도구" Height="600" Width="900">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 내용만큼 -->
<RowDefinition Height="*"/> <!-- 남은 공간 전부 -->
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBox Grid.Row="0" x:Name="SearchBox" Padding="8"/>
<DataGrid Grid.Row="1" x:Name="Grid" Margin="0,8"/>
<StackPanel Grid.Row="2" Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Content="내보내기" Padding="16,6" Click="OnExport"/>
</StackPanel>
</Grid>
</Window>
레이아웃 패널
| 패널 | 쓸 곳 |
|---|---|
| Grid | 기본. 행·열로 나눔 ★ |
| StackPanel | 버튼 몇 개를 가로/세로로 |
| DockPanel | 상단 툴바 + 하단 상태바 |
| WrapPanel | 공간이 없으면 다음 줄로 |
Margin="10,200,0,0" 처럼 좌표로 맞추면
창 크기·DPI 가 바뀌는 순간 깨집니다.
Grid 의 *(비율)과 Auto 로 짜세요.
app.manifest 에서
PerMonitorV2 를 켜 두면 대부분 해결됩니다 —
노트북 + 외부 모니터 환경에서 글씨가 흐려지는 문제를 막습니다.
데이터 바인딩 · MVVM
WPF 의 핵심. 화면과 데이터를 연결해 두면 값만 바꿔도 화면이 따라옵니다.
ViewModel
dotnet add package CommunityToolkit.Mvvm
public partial class MainViewModel : ObservableObject
{
// ★ 값이 바뀌면 버튼 활성 여부를 다시 계산하게 알린다 (안 하면 계속 비활성)
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SearchCommand))]
private string _keyword = "";
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(SearchCommand))]
private bool _isBusy;
public ObservableCollection<OrderRow> Rows { get; } = new();
[RelayCommand(CanExecute = nameof(CanSearch))]
private async Task SearchAsync(CancellationToken ct)
{
IsBusy = true;
try
{
var rows = await _repo.FindAsync(Keyword, ct);
Rows.Clear();
foreach (var r in rows) Rows.Add(r);
}
finally { IsBusy = false; }
}
private bool CanSearch() => !IsBusy && Keyword.Length >= 2;
}
XAML 에서 연결
<Window.DataContext>
<vm:MainViewModel/>
</Window.DataContext>
<TextBox Text="{Binding Keyword, UpdateSourceTrigger=PropertyChanged}"/>
<Button Content="검색" Command="{Binding SearchCommand}"/>
<!-- CanExecute 가 false 면 버튼이 자동으로 비활성화됩니다 -->
<ProgressBar IsIndeterminate="True"
Visibility="{Binding IsBusy,
Converter={StaticResource BoolToVis}}"/>
<DataGrid ItemsSource="{Binding Rows}" AutoGenerateColumns="False">
<DataGrid.Columns>
<DataGridTextColumn Header="주문번호" Binding="{Binding OrderNo}"/>
<DataGridTextColumn Header="금액" Binding="{Binding Amount, StringFormat=N0}"/>
</DataGrid.Columns>
</DataGrid>
PresentationTraceSources.TraceLevel=High 로 자세히 보세요.
비동기 UI — 멈추지 않게
무거운 작업을 그대로 실행하면 창이 하얗게 굳습니다. 가장 흔한 사고입니다.
기본
UI 스레드에서 직접
var data = _api.GetAll(); // 5초
Grid.ItemsSource = data;
// 5초간 "응답 없음"
await 로 넘기기
var data = await _api.GetAllAsync(ct);
Grid.ItemsSource = data;
// 그동안 창이 살아 있다
// CPU 작업은 스레드로 넘긴다
var result = await Task.Run(() => HeavyParse(bytes), ct);
// 진행률 보고 — UI 스레드로 자동 복귀합니다
var progress = new Progress<int>(p => Bar.Value = p);
await ImportAsync(path, progress, ct);
// 다른 스레드에서 UI 를 만져야 할 때만
Application.Current.Dispatcher.Invoke(() => Status.Text = "완료");
취소와 중복 실행 막기
private CancellationTokenSource? _cts;
private async Task SearchAsync()
{
_cts?.Cancel(); // 이전 검색 취소
_cts = new CancellationTokenSource();
var ct = _cts.Token;
try
{
await Task.Delay(300, ct); // 디바운스 — 타이핑이 멈춘 뒤 실행
var rows = await _repo.FindAsync(Keyword, ct);
if (ct.IsCancellationRequested) return;
Apply(rows);
}
catch (OperationCanceledException) { /* 정상 취소 */ }
}
.Result·.Wait() 는 UI 에서 데드락을 일으킵니다.
UI 스레드가 결과를 기다리는데,
그 결과를 만들려면 UI 스레드로 돌아와야 하기 때문입니다.
끝까지 await 하세요.
어떤 작업을 어떻게 넘길까
| 작업 | 쓸 것 | 스레드를 쓰나 | 흔한 실수 |
|---|---|---|---|
| DB 조회 · HTTP 호출 · 파일 IO | await XxxAsync(ct) | 안 씁니다(IO 완료 대기) | Task.Run(() => File.ReadAllText()) — 스레드만 낭비 |
| 큰 CSV 파싱 · 이미지 처리 · 압축 | await Task.Run(...) | 스레드풀 하나 | UI 스레드에서 직접 → 굳음 |
| 진행률 표시 | IProgress<T> | — | 매 행마다 보고 → 보고가 더 느림 |
| 주기적 갱신(1초마다) | PeriodicTimer | — | System.Timers.Timer 는 UI 스레드가 아님 |
| 다른 스레드에서 UI 갱신 | Dispatcher.InvokeAsync | — | 동기 Invoke 남발 → 느려짐 |
| 여러 개를 동시에 | await Task.WhenAll(...) | 상황에 따라 | 순차 await → 합계 시간이 그대로 더해짐 |
진행률 · 취소 · 버튼 잠금을 한 묶음으로
public partial class ImportViewModel : ObservableObject
{
[ObservableProperty] private int _percent;
[ObservableProperty] private string _status = "대기";
[ObservableProperty] private bool _isBusy; // 버튼 IsEnabled 에 반대로 바인딩
private CancellationTokenSource? _cts;
[RelayCommand(CanExecute = nameof(CanImport))]
private async Task ImportAsync()
{
_cts = new CancellationTokenSource();
IsBusy = true;
ImportCommand.NotifyCanExecuteChanged(); // 버튼 즉시 잠금
// 보고를 100ms 로 묶는다 — 매 행 보고하면 UI 가 그것 때문에 느려집니다
var last = 0L;
var progress = new Progress<(int done, int total)>(p =>
{
var now = Environment.TickCount64;
if (now - last < 100 && p.done != p.total) return;
last = now;
Percent = p.done * 100 / p.total;
Status = $"{p.done:N0} / {p.total:N0} 행";
});
try
{
var rows = await Task.Run(
() => CsvParser.Parse(_path, progress, _cts.Token), _cts.Token);
Status = $"완료 — {rows.Count:N0} 행";
}
catch (OperationCanceledException) { Status = "취소했습니다"; }
catch (Exception ex)
{
Status = "실패";
_dialog.Error("가져오기에 실패했습니다.", ex.Message); // 사용자에게 보이기
_log.LogError(ex, "import failed: {Path}", _path);
}
finally
{
IsBusy = false;
ImportCommand.NotifyCanExecuteChanged();
_cts.Dispose(); _cts = null;
}
}
private bool CanImport() => !IsBusy;
[RelayCommand]
private void Cancel() => _cts?.Cancel();
}
IsBusy 하나로 버튼 잠금 · 진행률 표시 · 취소 버튼 노출을 한꺼번에 묶으세요.
실제로 겪는 함정 넷
// ① async void — 예외가 사라지고 앱이 그냥 죽습니다
private async void Button_Click(object s, RoutedEventArgs e) // ← 이벤트 핸들러만 예외
{
try { await DoAsync(); } // ★ 반드시 try 로 감싸기
catch (Exception ex) { _dialog.Error("실패", ex.Message); }
}
// 그 외에는 전부 async Task 로. async void 는 await 할 수도, catch 할 수도 없습니다
// ② 라이브러리에서는 ConfigureAwait(false)
public async Task<byte[]> LoadAsync(string p)
{
// UI 스레드로 돌아올 필요가 없는 코드 — 복귀 비용을 없앤다
return await File.ReadAllBytesAsync(p).ConfigureAwait(false);
}
// ★ ViewModel·코드비하인드에서는 쓰지 마세요. 돌아와야 컨트롤을 만집니다
// ③ 컬렉션은 UI 스레드에서만 건드린다
await Task.Run(() => { foreach (var r in rows) Items.Add(r); }); // ✗ 예외
var loaded = await Task.Run(() => Parse(path)); // ✓ 결과만 받아서
Items = new ObservableCollection<Row>(loaded); // UI 에서 교체
// ④ 창이 닫혀도 작업은 계속 돕니다
protected override void OnClosing(CancelEventArgs e)
{
_cts?.Cancel(); // 남은 작업 정리
_timer?.Dispose();
}
async void 는 크래시 원인 1위입니다.
예외가 try/catch 바깥으로 새어 나가
앱이 아무 메시지 없이 사라집니다.
이벤트 핸들러에서만 쓰고, 그 안은 통째로 try 로 감싸세요.
마지막 안전망으로 DispatcherUnhandledException 에
로그 남기고 사용자에게 알리는 처리를 붙여 두면 원인 추적이 됩니다.
대용량 목록 — 10만 행을 끊김 없이
사내 도구가 느려지는 이유는 대개 조회 결과를 전부 화면에 올려서입니다.
가상화 켜기
<DataGrid ItemsSource="{Binding Rows}"
EnableRowVirtualization="True"
EnableColumnVirtualization="True"
VirtualizingPanel.IsVirtualizing="True"
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.IsDeferredScrollingEnabled="True"/>
ScrollViewer 안에 DataGrid 를 넣으면
높이가 무한이 되어 전 행을 그립니다.
Height="Auto" 와 Grouping 도 같은 문제를 일으킵니다.
목록은 * 높이를 가진 Grid 행에 두세요.
그래도 느리면
- DB 에서 필터·정렬·페이징 — 10만 행을 가져오지 않는 것이 근본 해결입니다
- ObservableCollection 대량 추가는 느립니다 — 목록을 새로 만들어 한 번에 교체하세요
- 컨버터에서 무거운 계산 금지 — 셀마다 호출됩니다
- 이미지 열은
DecodePixelWidth로 축소해 로드
// 한 번에 교체 (알림이 한 번만 발생)
Rows = new ObservableCollection<Row>(loaded);
OnPropertyChanged(nameof(Rows));
// 또는 일시적으로 바인딩 해제
Grid.ItemsSource = null;
Grid.ItemsSource = loaded;
가상화를 깨뜨리는 것들 — 실측 표
| 이렇게 쓰면 | 왜 깨지나 | 대신 |
|---|---|---|
<ScrollViewer><DataGrid/></ScrollViewer> | 높이가 무한이 되어 전 행이 "보임" | Grid 행 하나에 직접 두고 Height="*" |
<StackPanel> 안 | 같은 이유(자식에게 무한 높이) | <DockPanel> · <Grid> |
Height="Auto" | 내용 전체를 재야 높이가 나옴 | * 또는 고정값 |
그룹핑(GroupStyle) | 기본 패널이 가상화 안 함 | VirtualizingStackPanel.IsVirtualizingWhenGrouping="True" |
| 행마다 다른 높이 | 스크롤바 계산에 전부 필요 | 고정 높이 권장 — RowHeight="28" |
CanContentScroll="False" | 픽셀 단위 스크롤 = 가상화 해제 | 기본값(True) 유지 |
<!-- 안전한 뼈대 — 목록은 * 높이 행에 -->
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/> <!-- 검색 바 -->
<RowDefinition Height="*"/> <!-- ★ 목록 -->
<RowDefinition Height="Auto"/> <!-- 상태 표시줄 -->
</Grid.RowDefinitions>
<DataGrid Grid.Row="1" ItemsSource="{Binding Rows}"
RowHeight="28"
EnableRowVirtualization="True"
VirtualizingPanel.VirtualizationMode="Recycling"
VirtualizingPanel.IsVirtualizingWhenGrouping="True"
ScrollViewer.IsDeferredScrollingEnabled="True"/>
</Grid>
근본 해결 — 화면에 필요한 것만 가져옵니다
// ① DB 에서 잘라 오기 — 가장 효과가 큽니다
public async Task<PagedResult<OrderRow>> SearchAsync(
OrderFilter f, int page, int size, CancellationToken ct)
{
var q = _db.Orders.AsNoTracking() // 읽기 전용 → 추적 비용 0
.Where(o => o.CreatedAt >= f.From && o.CreatedAt < f.To);
if (f.Keyword is { Length: > 0 } k)
q = q.Where(o => o.CustomerName.Contains(k));
var total = await q.CountAsync(ct);
var rows = await q.OrderByDescending(o => o.CreatedAt)
.Skip((page - 1) * size).Take(size)
.Select(o => new OrderRow( // ★ 필요한 열만
o.Id, o.CustomerName, o.Amount, o.CreatedAt))
.ToListAsync(ct);
return new(rows, total, page, size);
}
// ② 그래도 전부 봐야 한다면 — 한 번에 교체
// ObservableCollection 에 10만 번 Add 하면 알림도 10만 번 발생합니다
var loaded = await Task.Run(() => _repo.LoadAll(), ct);
Rows = new ObservableCollection<Row>(loaded); // 알림 1회
// CommunityToolkit 이면
// Rows = loaded.ToObservableCollection();
// ③ 필터·정렬은 CollectionView 대신 DB 나 LINQ 로
// CollectionViewSource 의 Filter 는 매 행마다 델리게이트를 호출합니다
Rows = new ObservableCollection<Row>(
all.Where(r => r.Name.Contains(kw, StringComparison.OrdinalIgnoreCase)));
- 컨버터·바인딩에서 계산하지 마세요 — 셀마다 불립니다. 값은 ViewModel 에서 미리 만들어 두는 것이 원칙입니다
- 합계·통계는 DB 에서 —
SUM한 줄이 10만 행 전송보다 천 배 쌉니다 - 엑셀 내보내기는 화면 목록이 아니라 쿼리에서 — 화면은 100행이어도 파일은 전체를 담아야 할 때가 많습니다
- 진짜 무한 스크롤이 필요하면 커서 페이징(마지막 키 이후)으로.
Skip(100000)은 DB 가 앞의 10만 행을 다 읽습니다
엑셀 · 파일 · 인쇄
사내 도구 요구사항의 절반은 "엑셀로 뽑아 주세요" 입니다.
엑셀 읽기·쓰기
dotnet add package ClosedXML # 쓰기 편함
# 또는 EPPlus · NPOI
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("주문");
ws.Cell(1, 1).InsertTable(rows, "Orders", createTable: true); // 한 줄로 표 생성
ws.Row(1).Style.Font.Bold = true;
ws.Column(3).Style.NumberFormat.Format = "#,##0";
ws.SheetView.FreezeRows(1);
ws.Columns().AdjustToContents();
wb.SaveAs(path);
// 읽기
using var read = new XLWorkbook(path);
foreach (var row in read.Worksheet(1).RowsUsed().Skip(1))
{
var no = row.Cell(1).GetString();
var amt = row.Cell(3).GetValue<decimal>();
}
파일 대화상자 · 인쇄
var dlg = new Microsoft.Win32.SaveFileDialog
{
FileName = $"주문_{DateTime.Now:yyyyMMdd}",
DefaultExt = ".xlsx",
Filter = "Excel 파일 (*.xlsx)|*.xlsx",
};
if (dlg.ShowDialog() == true) Export(dlg.FileName);
// 저장 후 폴더 열기 (사용자가 가장 자주 원하는 다음 동작)
Process.Start("explorer.exe", $"/select,\"{dlg.FileName}\"");
// 인쇄
var pd = new PrintDialog();
if (pd.ShowDialog() == true)
pd.PrintVisual(reportGrid, "주문 목록");
엑셀 라이브러리 고르기
| 라이브러리 | 라이선스 | xls(구형) | 메모리 | 고를 때 |
|---|---|---|---|---|
| ClosedXML | MIT | ✗ | 보통 | 기본 선택 — API 가 가장 읽기 쉽습니다 |
| EPPlus | 비상업 무료 / 상업은 유료 | ✗ | 보통 | 차트·피벗까지 필요할 때. 사내 도구도 상업 사용입니다 |
| NPOI | Apache 2.0 | ✓ | 보통 | 옛 .xls 를 읽어야 할 때 |
| MiniExcel | Apache 2.0 | ✗ | 매우 낮음 | 수십만 행을 스트리밍으로 읽고 쓸 때 |
| Office Interop | — | ✓ | — | 쓰지 마세요(아래 warn) |
사용자가 실제로 원하는 엑셀 — 다섯 가지
using var wb = new XLWorkbook();
var ws = wb.Worksheets.Add("주문");
// ① 표로 만들기 — 정렬·필터 버튼이 자동으로 붙습니다
var table = ws.Cell(1, 1).InsertTable(rows, "Orders", createTable: true);
table.Theme = XLTableTheme.TableStyleMedium2;
// ② 숫자·날짜 서식 — 문자열로 넣으면 엑셀에서 계산이 안 됩니다
ws.Column("C").Style.NumberFormat.Format = "#,##0"; // 금액
ws.Column("D").Style.NumberFormat.Format = "yyyy-mm-dd"; // 날짜
ws.Column("E").Style.NumberFormat.Format = "0.0%"; // 비율
// ③ 첫 행 고정 + 너비 자동
ws.SheetView.FreezeRows(1);
ws.Columns().AdjustToContents(maxWidth: 40);
// ④ 조건부 서식 — "빨간 건만 보고 싶어요"
ws.Range($"C2:C{rows.Count + 1}")
.AddConditionalFormat().WhenGreaterThan(1_000_000)
.Fill.SetBackgroundColor(XLColor.LightPink);
// ⑤ 합계 행
var last = rows.Count + 2;
ws.Cell(last, 1).Value = "합계";
ws.Cell(last, 3).FormulaA1 = $"SUM(C2:C{last - 1})"; // ★ 값이 아니라 수식으로
ws.Row(last).Style.Font.Bold = true;
wb.SaveAs(path);
CSV · 대용량 · 인쇄
// CSV 는 직접 만들지 마세요 — 쉼표·따옴표·줄바꿈이 값 안에 들어옵니다
// dotnet add package CsvHelper
await using var w = new StreamWriter(path,
false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); // ★ BOM
await using var csv = new CsvWriter(w, CultureInfo.GetCultureInfo("ko-KR"));
await csv.WriteRecordsAsync(rows, ct);
new UTF8Encoding(true) 한 번이면 끝납니다.
ID 앞의 0 이 사라진다는 문의도 같은 계열인데(엑셀이 숫자로 해석),
xlsx 로 내보내며 그 열을 문자열 서식으로 지정하는 것이 유일한 해결입니다.
// 수십만 행 — 전부 메모리에 올리지 않고 스트리밍
// dotnet add package MiniExcel
await MiniExcel.SaveByTemplateAsync(path, template, new { rows = _repo.Stream(ct) });
// 읽기도 스트리밍
foreach (var row in MiniExcel.Query<OrderRow>(path))
{
if (ct.IsCancellationRequested) break;
await ProcessAsync(row);
}
// 인쇄 — 화면 그대로 찍으면 잘립니다. 인쇄용 뷰를 따로 만드세요
var pd = new PrintDialog();
if (pd.ShowDialog() != true) return;
var doc = new FixedDocument();
foreach (var chunk in rows.Chunk(40)) // 한 장에 40행
{
var page = new FixedPage
{
Width = pd.PrintableAreaWidth,
Height = pd.PrintableAreaHeight
};
page.Children.Add(new PrintPageView { DataContext = chunk });
var content = new PageContent();
((IAddChild)content).AddChild(page);
doc.Pages.Add(content);
}
pd.PrintDocument(doc.DocumentPaginator, "주문 목록");
- 내보내기는 취소 가능하게 — 10만 행 엑셀은 수십 초 걸립니다
- 같은 이름 파일이 열려 있으면 저장이 실패합니다.
IOException을 잡아 "엑셀에서 파일을 닫아 주세요" 라고 알려 주세요 - PDF 가 필요하면 QuestPDF(무료 라이선스 조건 확인) — 인쇄 뷰를 만드는 것보다 훨씬 편합니다
로컬 저장 — SQLite · 설정
서버 없이도 도는 도구를 만듭니다. 어디에 저장하느냐가 중요합니다.
SQLite
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
public sealed class LocalDb : DbContext
{
public DbSet<CacheRow> Rows => Set<CacheRow>();
protected override void OnConfiguring(DbContextOptionsBuilder b)
{
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyTool");
Directory.CreateDirectory(dir);
b.UseSqlite($"Data Source={Path.Combine(dir, "local.db")}");
}
}
// 시작 시 스키마 반영
await using var db = new LocalDb();
await db.Database.MigrateAsync();
LocalApplicationData,
사용자 문서는 MyDocuments 에 두세요.
설정과 비밀
// 설정 — JSON 한 파일이면 충분합니다
public sealed record Settings(string ServerUrl, bool AutoStart, int PageSize)
{
static string Path => System.IO.Path.Combine(AppDir, "settings.json");
public static Settings Load() =>
File.Exists(Path)
? JsonSerializer.Deserialize<Settings>(File.ReadAllText(Path))!
: new Settings("https://api.example.com", false, 50);
public void Save() => File.WriteAllText(Path, JsonSerializer.Serialize(this));
}
// 비밀번호·토큰은 OS 에 맡긴다 (Windows DPAPI)
var enc = ProtectedData.Protect(
Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(TokenPath, enc);
트레이 · 단축키 · 자동 시작
상주형 도구를 만들 때 필요한 OS 통합 기능들입니다.
트레이 아이콘
// H.NotifyIcon.Wpf 패키지 사용
<tb:TaskbarIcon x:Name="Tray" IconSource="/app.ico" ToolTipText="사내 도구">
<tb:TaskbarIcon.ContextMenu>
<ContextMenu>
<MenuItem Header="열기" Click="OnShow"/>
<MenuItem Header="동기화" Click="OnSync"/>
<Separator/>
<MenuItem Header="종료" Click="OnExit"/>
</ContextMenu>
</tb:TaskbarIcon.ContextMenu>
</tb:TaskbarIcon>
// 닫기 = 종료가 아니라 트레이로 숨기기
private bool _reallyQuit;
protected override void OnClosing(CancelEventArgs e)
{
if (_reallyQuit) return;
e.Cancel = true;
Hide();
if (!_notified) { Tray.ShowBalloonTip("계속 실행 중", "트레이에서 열 수 있습니다", ...); _notified = true; }
}
단일 인스턴스 · 자동 시작
// 두 번 실행 막기 (설정 파일·DB 동시 접근 사고 방지)
private static Mutex? _mutex;
protected override void OnStartup(StartupEventArgs e)
{
_mutex = new Mutex(true, @"Global\MyTool.SingleInstance", out var isNew);
if (!isNew)
{
MessageBox.Show("이미 실행 중입니다.");
Shutdown();
return;
}
base.OnStartup(e);
}
// 로그인 시 자동 시작 — 사용자가 켜게 하세요 (기본값 끄기)
const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
void SetAutoStart(bool on)
{
using var key = Registry.CurrentUser.OpenSubKey(RunKey, writable: true)!;
if (on) key.SetValue("MyTool", $"\"{Environment.ProcessPath}\" --tray");
else key.DeleteValue("MyTool", throwOnMissingValue: false);
}
배포 — 단일 실행 파일 · MSIX
"이거 어떻게 설치해요?"가 나오지 않게 만드는 일.
단일 실행 파일
# .NET 런타임까지 포함해 exe 하나로 (설치 불필요)
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true \
-p:EnableCompressionInSingleFile=true
# 런타임이 이미 깔려 있다면 (용량 1/5)
dotnet publish -c Release -r win-x64 --self-contained false \
-p:PublishSingleFile=true
| 방식 | 용량 | 쓸 곳 |
|---|---|---|
| 단일 exe(포함) | ~70MB | USB 배포 · 권한 없는 PC |
| 단일 exe(미포함) | ~2MB | 런타임이 이미 있는 사내 |
| MSIX | 중간 | 사내 표준 배포 · 자동 업데이트 |
| ClickOnce | 작음 | 레거시 · 간단한 자동 업데이트 |
서명이 없으면 막힙니다
- MSIX — 설치·업데이트·제거가 깔끔하고 서명이 전제입니다
- 사내 배포는 인트라넷 공유 폴더 + 자동 업데이트 조합이 흔합니다
- 32/64비트 — 요즘은 x64 만으로 충분합니다(ARM64 는 필요 시)
- .NET 런타임 버전을 사내 표준으로 정해 두면 용량을 아낄 수 있습니다
자동 업데이트 · 크래시 수집
사내 PC 50대의 버전이 제각각이 되는 순간 "재현이 안 되는 버그"가 시작됩니다.
업데이트 확인
public sealed record VersionInfo(string Latest, string MinSupported, string Url, string Sha256);
async Task CheckUpdateAsync()
{
var info = await _http.GetFromJsonAsync<VersionInfo>(UpdateUrl);
var cur = Assembly.GetExecutingAssembly().GetName().Version!;
if (IsOlder(cur, Version.Parse(info!.MinSupported)))
{
// 서버 API 가 깨지는 변경 — 업데이트 전에는 못 쓰게
await ForceUpdateAsync(info);
return;
}
if (IsOlder(cur, Version.Parse(info.Latest)))
ShowUpdateBanner(info); // 작업을 방해하지 않는 배너
}
크래시 · 로그
protected override void OnStartup(StartupEventArgs e)
{
AppDomain.CurrentDomain.UnhandledException += (_, ev) =>
Fatal((Exception)ev.ExceptionObject);
DispatcherUnhandledException += (_, ev) =>
{
Fatal(ev.Exception);
ev.Handled = true; // 창은 살려 두고 안내
MessageBox.Show("오류가 발생했습니다. 로그가 저장되었습니다.");
};
TaskScheduler.UnobservedTaskException += (_, ev) => Fatal(ev.Exception);
}
static void Fatal(Exception ex)
{
var path = Path.Combine(LogDir, $"crash_{DateTime.Now:yyyyMMdd_HHmmss}.log");
File.WriteAllText(path, ex.ToString());
}
사내 시스템 연동
데스크탑 앱을 쓰는 진짜 이유. 로컬 장비와 사내 서버에 붙습니다.
자주 나오는 요구
| 요구 | 방법 |
|---|---|
| 바코드·시리얼 장비 | SerialPort · 벤더 SDK(P/Invoke) |
| 라벨 프린터 | ZPL 문자열을 직접 전송 |
| 사내 DB 직접 조회 | 읽기 전용 계정 + 뷰 |
| 공유 폴더 감시 | FileSystemWatcher |
| 사내 인증(AD) | Windows 통합 인증 |
// 폴더 감시 — 파일이 들어오면 자동 처리
var w = new FileSystemWatcher(dropDir, "*.csv") { EnableRaisingEvents = true };
w.Created += async (_, e) => // async void 핸들러 — 반드시 try 로
{
try
{
await WaitUntilReadyAsync(e.FullPath); // ★ 복사가 끝날 때까지 대기
await ImportAsync(e.FullPath);
}
catch (Exception ex) { _log.LogError(ex, "자동 가져오기 실패 {File}", e.FullPath); }
};
DB 직접 연결의 위험
- API 서버를 한 겹 두세요 — 권한·감사 로그를 그곳에서 처리
- 불가피하면 읽기 전용 계정 + 필요한 뷰만 노출
- Windows 통합 인증을 쓰면 비밀번호를 앱이 들고 있지 않아도 됩니다
- 감사 로그 — 누가 언제 무엇을 조회했는지 남기세요
사내 도구 체크리스트
배포 전에 한 번 훑어보세요. 대부분의 문의가 여기서 예방됩니다.
사용성
- ☐ 창 크기·위치 기억 — 모니터를 뽑았을 때 화면 밖으로 나가지 않는지
- ☐ 작업 중 창이 굳지 않는지 (모든 IO 를 async 로)
- ☐ 키보드만으로 주요 작업이 가능한지 (Tab 순서·Enter·Esc)
- ☐ 오류 메시지가 다음에 무엇을 하라고 알려 주는지
- ☐ 저장·내보내기 후 결과 파일을 열어 주는지
- ☐ 고DPI·125% 배율에서 레이아웃이 깨지지 않는지
배포 · 운영
- ☐ 서명 또는 보안팀 예외 등록
- ☐ 자동 업데이트·강제 업데이트 동작 확인
- ☐ 설정·DB 가 쓰기 가능한 경로에 있는지
- ☐ 비밀번호·토큰이 암호화되어 저장되는지
- ☐ 크래시 로그 저장 + 보내는 방법 안내
- ☐ 사내 프록시 환경에서 API 호출이 되는지
- ☐ 간단한 사용 설명서(한 장)
배포한 다음 날 오는 문의 — 증상과 원인
| 사용자가 하는 말 | 실제 원인 | 고치는 법 |
|---|---|---|
| "창이 안 보여요" | 기억한 창 위치가 지금 없는 모니터 좌표 | 복원 전에 Screen.AllScreens 범위 안인지 검사 |
| "글씨가 흐려요 / 잘려요" | 고DPI 미대응 · 125% 배율 | PerMonitorV2 DPI 인식, 고정 픽셀 크기 제거 |
| "실행하면 경고가 떠요" | 서명 없는 exe → SmartScreen | 코드 서명 인증서, 또는 보안팀 예외 등록 |
| "저장이 안 돼요" | 설정을 Program Files 아래에 씀 | %LOCALAPPDATA%(ApplicationData)로 |
| "서버 연결이 안 돼요" | 사내 프록시 · SSL 검사 장비 | HttpClient 에 시스템 프록시 + 사내 루트 CA 신뢰 |
| "그냥 꺼져요" | async void 예외 · 미처리 예외 | 전역 핸들러에서 로그 남기고 안내 후 종료 |
| "어제는 됐는데요" | 자동 업데이트가 반만 적용 | 버전을 화면에 표시 · 업데이트 실패 시 롤백 |
| "한글이 깨져요" | CSV BOM 누락 · 인코딩 | UTF8Encoding(true) 또는 xlsx 로 |
이 세 조각만 넣어도 문의가 절반으로 줍니다
// ① 창 위치 복원 — 없는 모니터로 가지 않게
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
var s = Settings.Default;
if (s.WinWidth <= 0) return;
var rect = new Rect(s.WinLeft, s.WinTop, s.WinWidth, s.WinHeight);
var visible = SystemParameters.VirtualScreenWidth > 0 &&
rect.Left > SystemParameters.VirtualScreenLeft - rect.Width + 80 &&
rect.Top >= SystemParameters.VirtualScreenTop &&
rect.Left < SystemParameters.VirtualScreenLeft +
SystemParameters.VirtualScreenWidth - 80;
if (!visible) { WindowStartupLocation = WindowStartupLocation.CenterScreen; return; }
Left = rect.Left; Top = rect.Top; Width = rect.Width; Height = rect.Height;
}
// ② 전역 예외 — 조용히 죽지 않게
public App()
{
DispatcherUnhandledException += (s, e) =>
{
Log.Fatal(e.Exception, "unhandled (ui)");
MessageBox.Show($"예기치 못한 오류가 발생했습니다.\n\n" +
$"로그: {LogPath}\n담당자에게 이 파일을 보내 주세요.",
"오류", MessageBoxButton.OK, MessageBoxImage.Error);
e.Handled = true; // 앱을 살려 둘지는 상황에 따라
};
TaskScheduler.UnobservedTaskException += (s, e) =>
{
Log.Error(e.Exception, "unhandled (task)");
e.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (s, e) =>
Log.Fatal(e.ExceptionObject as Exception, "unhandled (domain)");
}
// ③ 두 번 실행 방지 — 사내 도구에서 특히 자주 필요합니다
private static Mutex? _mutex;
protected override void OnStartup(StartupEventArgs e)
{
_mutex = new Mutex(true, @"Global\MyTool", out var isNew);
if (!isNew)
{
MessageBox.Show("이미 실행 중입니다.");
Shutdown(); return;
}
base.OnStartup(e);
}
배포 방식 · 저장 위치
| 배포 방식 | 업데이트 | 맞는 곳 |
|---|---|---|
단일 파일 exePublishSingleFile | 수동(파일 교체) | 도구 하나 · 사용자 수십 명 |
| MSIX | 자동 · 깔끔한 제거 | 사내 배포 · Store |
| ClickOnce | 자동 | 레거시지만 여전히 쉬움 |
| Velopack / Squirrel | 자동 · 델타 | 자주 배포하는 도구 |
| 네트워크 드라이브의 exe | — | 피하세요 — 느리고 파일 잠김 |
<!-- 자체 포함 단일 파일 — .NET 설치 없이 도는 exe -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>true</PublishSingleFile>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
<ApplicationHighDpiMode>PerMonitorV2</ApplicationHighDpiMode>
<Version>1.4.2</Version>
</PropertyGroup>
// 저장 위치 — Program Files 아래는 쓰기 금지입니다
var dir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"MyTool");
Directory.CreateDirectory(dir);
var dbPath = Path.Combine(dir, "cache.db"); // 로컬 캐시
var logPath = Path.Combine(dir, "logs"); // 로그
// 비밀번호·토큰은 DPAPI 로 — 같은 사용자 계정에서만 풀립니다
var cipher = ProtectedData.Protect(
Encoding.UTF8.GetBytes(token), null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(Path.Combine(dir, "token.bin"), cipher);
이미 만들어진
도구를
고르는 법
C# 생태계는 넓습니다. 문제는 "이 일에는 무엇을 쓰지" 를 아는 것입니다. 목적별로 표준 도구를 정리하고, 테스트·CI·배포까지 붙입니다. 여기까지 마치면 라이브러리를 찾느라 반나절씩 검색하는 일이 사라집니다.
목적별 도구 찾기
하려는 일을 한글로 적으면 쓸 라이브러리가 나옵니다.
| 하려는 일 | 도구 | 한 줄 메모 |
|---|---|---|
| 로그를 파일·서버로 | Serilog | 구조적 로깅 · 싱크가 많다 |
| 설정을 타입으로 | Options 패턴 | 내장 · 시작 시 검증 |
| 재시도 · 서킷 브레이커 | Polly | HttpClient 에 한 줄로 |
| REST API 호출 | Refit | 인터페이스만 적으면 구현 생성 |
| DTO ↔ 엔티티 변환 | Mapperly | 소스 제너레이터 · 리플렉션 없음 |
| 입력 검증 | FluentValidation | 규칙을 코드로 |
| JSON | System.Text.Json | 기본 · 소스 제너레이터 지원 |
| 바이너리 직렬화 | MessagePack | 게임 패킷 · 캐시 |
| DB 접근 (ORM) | EF Core | 표준 · 마이그레이션 포함 |
| SQL 을 직접 쓰고 싶다 | Dapper | 가볍고 빠름 |
| Redis | StackExchange.Redis | 사실상 표준 |
| 엑셀 읽기·쓰기 | ClosedXML · EPPlus | Interop 쓰지 말 것 |
| CSV | CsvHelper | 인코딩·따옴표 처리 |
| PDF 만들기 | QuestPDF | 코드로 레이아웃 |
| 이미지 처리 | ImageSharp | 크로스 플랫폼 |
| 단위 테스트 | xUnit | .NET 기본 선택 |
| 가짜 객체 | NSubstitute · Moq | 문법이 간결 |
| 통합 테스트 | Testcontainers | 진짜 DB 를 띄운다 |
| 읽기 좋은 단언 | Shouldly | 실패 메시지가 친절 |
| 성능 측정 | BenchmarkDotNet | 할당량도 함께 |
| 예약 작업 | Quartz.NET · Hangfire | 여러 서버면 락 필요 |
| 메시지 큐 | MassTransit | Rabbit·Kafka 추상화 |
| CLI 도구 | Spectre.Console | 표·진행바·프롬프트 |
| 지표·추적 | OpenTelemetry | 표준 · 벤더 중립 |
| WPF MVVM | CommunityToolkit.Mvvm | 속성·커맨드 자동 생성 |
| Unity DI | VContainer | 가볍고 빠름 |
| Unity 비동기 | UniTask | 할당 없는 async |
| 의존 규칙 검사 | NetArchTest | 테스트로 구조를 지킴 |
로깅 — Serilog
장애가 났을 때 로그만 보고 재현할 수 있어야 합니다.
설정
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("app", "game-api")
.WriteTo.Console(outputTemplate:
"[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}")
.WriteTo.File("logs/log-.txt",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 14, // ★ 오래된 것은 지운다
fileSizeLimitBytes: 100L * 1024 * 1024,
rollOnFileSizeLimit: true)); // ★ 없으면 한도에 닿은 뒤 그날 로그가 끊깁니다
// 값을 넘기면 검색·집계가 됩니다
log.LogInformation("구매 완료 {PlayerId} {ItemId} {Cost}", pid, iid, cost);
// 요청 단위 공통 값
using (LogContext.PushProperty("TraceId", Activity.Current?.Id))
{
await HandleAsync();
}
운영 규칙
- 레벨 — Error 는 사람이 조치할 것만. 나머지는 Warning 이하
- 보관 기간을 반드시 설정 — 디스크가 차서 서버가 죽는 사고가 흔합니다
- 민감정보 금지 — 비밀번호·토큰·카드번호·주민번호
- 운영에서 Debug 를 켜 두지 마세요 — I/O 가 응답을 느리게 합니다
- 수집기(Seq·Loki·CloudWatch)로 보내면 검색이 됩니다
레벨을 고르는 기준 — "누가 언제 보는가"
| 레벨 | 뜻 | 예 | 운영에서 | 알람 |
|---|---|---|---|---|
| Fatal | 서비스가 못 뜬다 | DB 접속 실패로 기동 중단 | 켬 | 즉시 호출 |
| Error | 사람이 조치해야 함 | 결제 승인 API 가 계속 실패 | 켬 | 비율로 알람 |
| Warning | 이상하지만 서비스는 됨 | 재시도로 회복 · 캐시 미스 급증 | 켬 | 대시보드만 |
| Information | 비즈니스 사건 | 구매 · 로그인 · 아이템 지급 | 켬 | — |
| Debug | 개발자 추적용 | 분기 진입 · 중간 값 | 끔 | — |
| Verbose | 전부 | 루프 안 값 | 끔 | — |
요청 하나를 끝까지 따라가기 — 상관관계 ID
// 요청마다 TraceId 를 심고, 모든 로그에 자동으로 붙게 합니다
app.Use(async (ctx, next) =>
{
var traceId = ctx.Request.Headers["X-Trace-Id"].FirstOrDefault()
?? Activity.Current?.TraceId.ToString()
?? Guid.NewGuid().ToString("N");
ctx.Response.Headers["X-Trace-Id"] = traceId; // ★ 클라에도 돌려준다
using (LogContext.PushProperty("TraceId", traceId))
using (LogContext.PushProperty("PlayerId",
ctx.User.FindFirst("sub")?.Value ?? "-"))
{
await next(ctx);
}
});
// 요청 요약 한 줄 — 수백 줄의 개별 로그보다 이게 더 유용합니다
builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));
app.UseSerilogRequestLogging(o =>
{
o.MessageTemplate =
"{RequestMethod} {RequestPath} → {StatusCode} in {Elapsed:0}ms";
o.GetLevel = (http, elapsed, ex) =>
ex is not null ? LogEventLevel.Error :
http.Response.StatusCode >= 500 ? LogEventLevel.Error :
elapsed > 1000 ? LogEventLevel.Warning :
http.Request.Path.StartsWithSegments("/health")
? LogEventLevel.Verbose // 헬스체크는 묻기
: LogEventLevel.Information;
o.EnrichDiagnosticContext = (diag, http) =>
{
diag.Set("ClientVer", http.Request.Headers["X-Client-Ver"].ToString());
diag.Set("Ip", http.Connection.RemoteIpAddress?.ToString());
};
});
민감정보 — 남기기 전에 지웁니다
// ① 아예 못 찍게 만드는 것이 가장 확실합니다
public sealed record LoginRequest(string Email, string Password)
{
// ToString 을 막아 실수로 통째로 찍히는 것을 방지
public override string ToString() => $"LoginRequest({Mask(Email)})";
private static string Mask(string s) =>
s.Length <= 3 ? "***" : s[..2] + new string('*', s.Length - 3) + s[^1];
}
// ② Serilog Destructure 로 특정 속성만 지우기
cfg.Destructure.ByTransforming<PaymentInfo>(p => new
{
p.OrderId,
Card = "**** **** **** " + p.CardNumber[^4..], // 뒤 4자리만
p.Amount
});
// ③ 마지막 안전망 — 나가는 로그에서 패턴을 지웁니다
public class RedactEnricher : ILogEventEnricher
{
private static readonly Regex Jwt =
new(@"eyJ[\w-]+\.[\w-]+\.[\w-]+", RegexOptions.Compiled);
public void Enrich(LogEvent e, ILogEventPropertyFactory f)
{
if (e.MessageTemplate.Text.Length > 0 &&
e.Properties.TryGetValue("Body", out var v) &&
v is ScalarValue { Value: string s } && Jwt.IsMatch(s))
e.AddOrUpdateProperty(f.CreateProperty("Body", Jwt.Replace(s, "[jwt]")));
}
}
| 절대 남기면 안 되는 것 | 대신 남길 것 |
|---|---|
| 비밀번호 · 토큰 · API 키 | 사용자 ID 만 |
| 카드번호 · 계좌번호 | 뒤 4자리 · 결제 ID |
| 주민번호 · 전화번호 · 주소 | 내부 식별자 |
| 요청 본문 통째로 | 필요한 필드만 골라서 |
| 쿼리스트링 원문 | 경로 템플릿(/players/{id}) |
- 디스크가 차서 서버가 죽는 사고가 정말 흔합니다 —
retainedFileCountLimit과fileSizeLimitBytes를 반드시 함께 설정하세요 - 파일 로그는 비동기 싱크로 감싸세요(
WriteTo.Async(...)) — 디스크가 느려질 때 응답이 같이 느려지는 것을 막습니다 - 운영 레벨은 설정 파일에서 바꿀 수 있게 — 재배포 없이
MinimumLevel.Override로 특정 네임스페이스만 Debug 로 올릴 수 있습니다 - 컨테이너에서는 파일 대신 stdout 으로. 수집은 플랫폼(Loki·CloudWatch)이 합니다
HTTP 클라이언트 · Refit · Polly
외부 API 를 부르는 코드는 실패를 전제로 써야 합니다.
Refit — 인터페이스만
public interface IPayApi
{
[Post("/v1/payments/{key}/confirm")]
Task<PayResult> ConfirmAsync(string key, [Body] ConfirmReq req, CancellationToken ct);
[Get("/v1/payments/{key}")]
Task<PayResult> GetAsync(string key, CancellationToken ct);
}
builder.Services.AddRefitClient<IPayApi>()
.ConfigureHttpClient(c =>
{
c.BaseAddress = new Uri(cfg["Pay:BaseUrl"]!);
c.Timeout = TimeSpan.FromSeconds(10);
})
.AddStandardResilienceHandler(); // 재시도·서킷·타임아웃 한 줄
new HttpClient() 를 매번 만들지 마세요.
소켓이 고갈됩니다(TIME_WAIT).
반드시 IHttpClientFactory 를 통해 받으세요 —
위 등록 방식이 그것입니다.
복원력 정책
.AddResilienceHandler("pay", b => b
.AddTimeout(TimeSpan.FromSeconds(3))
.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // ★ 동시 재시도 방지
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode is HttpStatusCode.ServiceUnavailable
or HttpStatusCode.RequestTimeout),
})
.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5, MinimumThroughput = 20,
BreakDuration = TimeSpan.FromSeconds(15),
}));
AddStandardResilienceHandler() 는 이 순서를 이미 맞춰 둔 것입니다 — 직접 조립할 때만 순서를 신경 쓰면 됩니다.재시도해도 되는가 — 판단표
| 상황 | 재시도 | 이유 | 대신 |
|---|---|---|---|
| 연결 실패 · DNS · 408 · 429 · 502 · 503 · 504 | 해도 됨 | 서버에 아직 안 닿았거나 일시적 | — |
| GET 조회가 타임아웃 | 해도 됨 | 읽기는 몇 번 해도 안전(멱등) | — |
| 400 · 401 · 403 · 404 · 422 | 하면 안 됨 | 같은 요청은 몇 번을 보내도 같은 결과 | 즉시 실패로 처리 |
| 결제 승인이 타임아웃 | 절대 금지 | 이미 승인됐을 수 있음 → 이중 결제 | 멱등 키 · 조회 API 로 결과 확인 |
| 아이템 지급 POST | 조건부 | 멱등 키가 있으면 안전 | Idempotency-Key 헤더 |
| 500 (서버 내부 오류) | 조건부 | 부분 처리됐을 수 있음 | 멱등이면 재시도, 아니면 조회 확인 |
결제처럼 되돌릴 수 없는 호출 — 안전한 패턴
public async Task<PayResult> ConfirmAsync(string orderId, CancellationToken ct)
{
var key = $"pay:{orderId}"; // 우리가 만든 멱등 키
try
{
// 재시도를 끈 클라이언트로 호출 (한 번만)
return await _payNoRetry.ConfirmAsync(key,
new ConfirmReq(orderId, _amount), ct);
}
catch (Exception ex) when (ex is TaskCanceledException or HttpRequestException)
{
// ★ 여기가 핵심 — 실패한 것이 아니라 "결과를 모르는" 상태입니다
_log.LogWarning(ex, "결제 응답 미확인 {OrderId} — 조회로 확인합니다", orderId);
for (var i = 0; i < 5; i++)
{
await Task.Delay(TimeSpan.FromSeconds(2 * (i + 1)), ct);
try
{
var actual = await _pay.GetAsync(key, ct); // 조회는 재시도 안전
if (actual.Status is "DONE" or "CANCELED") return actual;
}
catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound)
{
return PayResult.NotStarted; // 서버에 안 닿았음 → 안전하게 실패 처리
}
}
// 끝까지 모르면 사람이 봐야 합니다 — 자동으로 결정하지 마세요
await _alert.RaiseAsync($"결제 상태 불명 orderId={orderId}");
return PayResult.Pending;
}
}
HttpClient 실전 — 자주 걸리는 것
// ① 명명 클라이언트 — 대상마다 정책을 따로
builder.Services.AddHttpClient("pay", c =>
{
c.BaseAddress = new Uri(cfg["Pay:BaseUrl"]!);
c.Timeout = TimeSpan.FromSeconds(15); // 전체 상한
c.DefaultRequestHeaders.UserAgent.ParseAdd("game-api/1.4.2");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2), // ★ DNS 변경 반영
MaxConnectionsPerServer = 64,
EnableMultipleHttp2Connections = true
});
// ② 응답 본문을 스트리밍으로 — 큰 응답을 통째로 메모리에 올리지 않는다
using var res = await http.GetAsync(url,
HttpCompletionOption.ResponseHeadersRead, ct);
res.EnsureSuccessStatusCode();
await using var s = await res.Content.ReadAsStreamAsync(ct);
var data = await JsonSerializer.DeserializeAsync<Big>(s, JsonOpts, ct);
// ③ 실패 응답의 본문을 로그에 남기기 — 이게 없으면 원인을 못 찾습니다
if (!res.IsSuccessStatusCode)
{
var body = await res.Content.ReadAsStringAsync(ct);
_log.LogError("외부 API 실패 {Status} {Url} {Body}",
(int)res.StatusCode, url, body[..Math.Min(500, body.Length)]);
}
PooledConnectionLifetime을 안 주면 DNS 가 바뀌어도 옛 IP 로 계속 붙습니다. 상대가 서버를 교체했을 때 우리만 장애가 납니다- 타임아웃을 안 주면 기본 100초입니다 — 그동안 스레드와 커넥션이 묶입니다. 3~10초가 현실적입니다
- 서킷이 열렸을 때 무엇을 돌려줄지 미리 정하세요 — 캐시된 값 · 기본값 · 명확한 에러. "그냥 500" 은 사용자에게 최악입니다
Refit의ApiException에는 응답 본문이 들어 있습니다(Content) — 잡을 때 반드시 로그에 남기세요
테스트 — xUnit 기본기
밸런스와 규칙은 계속 바뀝니다. 바꿔도 안 깨진다는 확신이 속도를 만듭니다.
쓰는 법
public class InventoryTests
{
[Fact]
public void 아이템을_넣으면_수량이_는다()
{
var inv = new Inventory(capacity: 10);
inv.Add(itemId: 1, count: 3);
Assert.Equal(3, inv.CountOf(1));
}
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void 잘못된_수량은_거부한다(int count)
{
var inv = new Inventory(10);
Assert.Throws<ArgumentOutOfRangeException>(() => inv.Add(1, count));
}
[Fact]
public async Task 동시에_사도_골드가_음수가_되지_않는다()
{
var shop = new Shop(gold: 100);
var tasks = Enumerable.Range(0, 10).Select(_ => shop.BuyAsync(cost: 30));
var results = await Task.WhenAll(tasks);
Assert.Equal(3, results.Count(r => r.Ok)); // 100 / 30 = 3회만 성공
}
}
가짜 객체
var pay = Substitute.For<IPayApi>();
pay.ConfirmAsync(Arg.Any<string>(), Arg.Any<ConfirmReq>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new PayResult(Ok: false, Code: "LIMIT")));
var sut = new OrderService(pay, repo);
var r = await sut.PlaceAsync(userId: 1, amount: 50_000);
Assert.False(r.Ok);
await pay.Received(1).ConfirmAsync(Arg.Any<string>(), Arg.Any<ConfirmReq>(), default);
통합 테스트 — Testcontainers
인메모리 DB 로 통과한 코드가 운영 DB 에서 깨집니다. 진짜를 띄워 테스트합니다.
진짜 DB 로
public sealed class DbFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _pg =
new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.WithCleanUp(true)
.Build();
public string ConnectionString => _pg.GetConnectionString();
public async Task InitializeAsync()
{
await _pg.StartAsync();
await using var db = new GameDb(Options());
await db.Database.MigrateAsync(); // 실제 마이그레이션을 돌린다
}
public Task DisposeAsync() => _pg.DisposeAsync().AsTask();
}
UseInMemoryDatabase)를 믿지 마세요.
제약·트랜잭션·SQL 함수가 실제와 다릅니다.
"테스트는 통과했는데 배포하면 깨짐"의 단골 원인입니다.
API 전체 테스트
public class ShopApiTests(ApiFixture f) : IClassFixture<ApiFixture>
{
[Fact]
public async Task 멱등키가_같으면_한_번만_처리된다()
{
var c = f.CreateAuthedClient(playerId: 1);
var body = new { itemId = 1, count = 1 };
var key = Guid.NewGuid().ToString();
c.DefaultRequestHeaders.Add("Idempotency-Key", key);
var a = await c.PostAsJsonAsync("/shop/buy", body);
var b = await c.PostAsJsonAsync("/shop/buy", body); // 재시도
Assert.Equal(HttpStatusCode.OK, a.StatusCode);
Assert.Equal(HttpStatusCode.OK, b.StatusCode);
Assert.Equal(1, await f.CountInventoryAsync(playerId: 1, itemId: 1));
}
}
[Trait("Category","Slow")] 를 붙이고
CI 에서 빠른 것 먼저 → 통과하면 느린 것 순으로 돌리면
피드백이 훨씬 빨라집니다.
코드 품질 — Analyzer · 스타일
리뷰에서 사람이 지적하던 것을 도구가 대신하게 만듭니다.
프로젝트 공통 설정
<!-- Directory.Build.props -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AnalysisLevel>latest-recommended</AnalysisLevel>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CS1591</WarningsNotAsErrors>
</PropertyGroup>
</Project>
# .editorconfig — 스타일을 팀 규칙으로
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_style_var_when_type_is_apparent = true:suggestion
dotnet_diagnostic.CA2007.severity = none # ConfigureAwait (앱 코드는 불필요)
dotnet_diagnostic.CA1848.severity = warning # LoggerMessage 권장
경고를 에러로 두는 이유
TreatWarningsAsErrors 를 켜고
정말 무시할 것만 명시적으로 제외하는 편이 관리됩니다.
- 포맷은 자동으로 —
dotnet format을 CI 에서 검사 - 중앙 패키지 관리(
Directory.Packages.props)로 버전을 한 곳에서 - 취약점 점검 —
dotnet list package --vulnerable --include-transitive - 구식 패키지 점검 —
dotnet list package --outdated
<!-- Directory.Packages.props — 버전을 한 곳에서 -->
<Project>
<PropertyGroup><ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally></PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
</ItemGroup>
</Project>
CI / CD — GitHub Actions
사람이 손으로 빌드하는 순간 사고가 납니다. 같은 명령이 항상 같은 결과를 내게 만듭니다.
서버 · 라이브러리
name: ci
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- run: dotnet restore
- run: dotnet build -c Release --no-restore
- run: dotnet format --verify-no-changes # 포맷 검사
- run: | # 여러 줄 명령은 | 블록으로
dotnet test -c Release --no-build \
--filter "Category!=Slow" \
--collect:"XPlat Code Coverage"
- run: dotnet list package --vulnerable --include-transitive
docker:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3 # 로그인 없이 push 하면 거부됩니다
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
Unity 빌드
jobs:
unity:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with: { lfs: true } # ★ 에셋은 Git LFS
- uses: actions/cache@v4
with:
path: Library # ★ 캐시하면 빌드가 몇 배 빨라집니다
key: Library-${{ hashFiles('Assets/**','Packages/**') }}
- uses: game-ci/unity-builder@v4
with:
targetPlatform: Android
buildMethod: BuildScript.BuildAndroid
env:
UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
.gitattributes 를 처음부터 설정하세요.
파이프라인은 이 순서로 — 싼 검사부터
| 단계 | 명령 | 걸리는 시간 | 실패하면 |
|---|---|---|---|
| 1. 포맷 | dotnet format --verify-no-changes | 수 초 | 즉시 중단 — 리뷰 시간 낭비 방지 |
| 2. 빌드 + 경고 | dotnet build -warnaserror | 1~2분 | 중단 |
| 3. 단위 테스트 | dotnet test --filter Category!=Slow | 1분 | 중단 |
| 4. 통합 테스트 | dotnet test --filter Category=Integration | 3~5분 | 중단 |
| 5. 취약점 | dotnet list package --vulnerable | 수 초 | 경고만(막으면 배포가 마비) |
| 6. 이미지 빌드 | docker build | 2~3분 | 중단 |
| 7. 배포 | main 브랜치만 | 1~2분 | 자동 롤백 |
캐시와 시크릿 — 실전 워크플로
name: ci
on:
push: { branches: [main] }
pull_request:
concurrency: # ★ 같은 PR 에 새 푸시가 오면 이전 실행 취소
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
DOTNET_NOLOGO: 1
NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 20 # ★ 무한 대기 방지
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v4
with: { dotnet-version: '10.0.x' }
- uses: actions/cache@v4 # NuGet 복원을 몇 배 빠르게
with:
path: ${{ env.NUGET_PACKAGES }}
key: nuget-${{ hashFiles('**/packages.lock.json','**/Directory.Packages.props') }}
restore-keys: nuget-
- run: dotnet restore --locked-mode # ★ lock 파일과 다르면 실패
- run: dotnet format --verify-no-changes
- run: dotnet build -c Release --no-restore -warnaserror
- run: dotnet test -c Release --no-build --logger trx
--results-directory artifacts
- uses: actions/upload-artifact@v4
if: always() # 실패해도 결과는 올린다
with: { name: test-results, path: artifacts }
deploy:
needs: build
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production # ★ 승인 게이트를 걸 수 있습니다
permissions:
contents: read
packages: write
id-token: write # OIDC — 클라우드에 비밀번호 없이 로그인
steps:
- uses: actions/checkout@v5
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ github.repository }}:${{ github.sha }}
ghcr.io/${{ github.repository }}:latest
echo 하지 마세요.
GitHub 이 마스킹해 주지만 base64 로 바꾸거나 쪼개면 그대로 노출됩니다.
포크에서 온 PR 에는 시크릿이 전달되지 않으므로,
배포 잡은 반드시 브랜치 조건으로 막으세요 —
pull_request_target 은 특히 위험합니다.
재현 가능한 빌드 — 세 가지 장치
// ① global.json — SDK 버전을 고정합니다
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}
# ② 패키지 lock 파일 — 전이 의존까지 버전을 못 박습니다
# csproj 에 <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
dotnet restore # packages.lock.json 생성 → 커밋
dotnet restore --locked-mode # CI 에서는 이걸로 (다르면 실패)
<!-- ③ Directory.Packages.props — 버전을 한 곳에서 -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
</ItemGroup>
</Project>
<!-- 각 csproj 는 버전 없이 -->
<PackageReference Include="Serilog.AspNetCore" />
| 증상 | 원인 | 막는 법 |
|---|---|---|
| 로컬은 되는데 CI 실패 | Linux 는 대소문자 구분 | 파일명·using 확인 |
| 어제 되던 빌드가 실패 | 패키지가 새 버전으로 뜀 | lock 파일 |
| SDK 버전이 달라 다른 결과 | 러너 이미지 업데이트 | global.json |
| 프로젝트마다 다른 버전 | 개별 PackageReference | 중앙 버전 관리 |
| 테스트가 가끔 실패 | 시간·순서·병렬 의존 | TimeProvider · 격리된 DB |
CLI 도구 · 스크립팅
운영 스크립트를 셸 대신 C# 으로. 타입이 있어 실수가 줄어듭니다.
Spectre.Console
var table = new Table().Border(TableBorder.Rounded);
table.AddColumn("서버"); table.AddColumn("CCU"); table.AddColumn("상태");
foreach (var n in nodes)
table.AddRow(n.Id, n.Ccu.ToString(),
n.Healthy ? "[green]정상[/]" : "[red]장애[/]");
AnsiConsole.Write(table);
await AnsiConsole.Progress().StartAsync(async ctx =>
{
var t = ctx.AddTask("데이터 이관", maxValue: rows.Count);
foreach (var r in rows) { await MigrateAsync(r); t.Increment(1); }
});
var env = AnsiConsole.Prompt(
new SelectionPrompt<string>().Title("환경을 고르세요")
.AddChoices("dev", "staging", "prod"));
단일 파일 스크립트
# .NET 10 — csproj 없이 .cs 파일 하나를 바로 실행
dotnet run app.cs
#!/usr/bin/env dotnet
#:package Spectre.Console@0.49.*
using Spectre.Console;
var files = Directory.GetFiles(args[0], "*.log");
AnsiConsole.MarkupLine($"[yellow]{files.Length}[/]개 파일 정리");
foreach (var f in files)
if (File.GetLastWriteTime(f) < DateTime.Now.AddDays(-14))
File.Delete(f);
인자 파싱 — 손으로 하지 마세요
System.CommandLine
// dotnet add package System.CommandLine
var envOpt = new Option<string>("--env", "-e")
{
Description = "대상 환경",
Required = true
};
envOpt.AcceptOnlyFromAmong("dev", "staging", "prod");
var dryRun = new Option<bool>("--dry-run")
{
Description = "실제로 바꾸지 않고 결과만 보여 줍니다"
};
var since = new Option<DateOnly>("--since")
{
DefaultValueFactory = _ =>
DateOnly.FromDateTime(DateTime.Today.AddDays(-7))
};
var grant = new Command("grant", "유저에게 아이템을 지급합니다");
var userId = new Argument<long>("userId");
var itemId = new Option<int>("--item") { Required = true };
grant.Add(userId); grant.Add(itemId); grant.Add(envOpt); grant.Add(dryRun);
grant.SetAction(async (parse, ct) =>
{
var uid = parse.GetValue(userId);
var iid = parse.GetValue(itemId);
var env = parse.GetValue(envOpt)!;
var dry = parse.GetValue(dryRun);
if (env == "prod" && !dry &&
!AnsiConsole.Confirm($"[red]운영[/]에서 {uid} 에게 {iid} 를 지급합니다. 계속?", false))
return 1; // ★ 종료 코드로 실패를 알린다
await _svc.GrantAsync(uid, iid, dry, ct);
return 0;
});
var root = new RootCommand("게임 운영 도구") { grant, banCmd, statsCmd };
return await root.Parse(args).InvokeAsync();
--dry-run 과 확인 프롬프트를 반드시.
"10만 명에게 아이템을 지급하려다 0 을 하나 더 붙인" 사고는
실제로 자주 일어납니다.
운영일 때만 한 번 더 묻고, 대상 건수를 먼저 보여 준 뒤 실행하세요.
호스트를 얹으면 서비스 코드를 그대로 씁니다
// DI · 설정 · 로깅을 웹 API 와 똑같이 — 리포지토리를 그대로 재사용
var host = Host.CreateApplicationBuilder(args);
host.Configuration.AddUserSecrets<Program>();
host.Services.AddDbContext<GameDb>(o =>
o.UseNpgsql(host.Configuration.GetConnectionString("Db")));
host.Services.AddScoped<IGrantService, GrantService>();
host.Services.AddSerilog((_, c) => c.WriteTo.Console());
using var app = host.Build();
using var scope = app.Services.CreateScope();
var svc = scope.ServiceProvider.GetRequiredService<IGrantService>();
// Ctrl+C 를 취소 토큰으로 — 작업 중간에 죽지 않게
using var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); };
await svc.RunAsync(cts.Token);
// 진행 상황을 보여 주면서 대량 작업 — 중단해도 이어서 할 수 있게
await AnsiConsole.Progress()
.Columns(new TaskDescriptionColumn(), new ProgressBarColumn(),
new PercentageColumn(), new RemainingTimeColumn())
.StartAsync(async ctx =>
{
var t = ctx.AddTask("이관", maxValue: ids.Count);
foreach (var chunk in ids.Chunk(500))
{
if (cts.IsCancellationRequested) break;
await _repo.MigrateAsync(chunk, cts.Token);
await File.WriteAllTextAsync(".checkpoint",
chunk[^1].ToString(), cts.Token); // ★ 재개 지점 저장
t.Increment(chunk.Length);
}
});
AnsiConsole.MarkupLine("[green]완료[/] — 실패 " +
$"[red]{failed.Count}[/]건은 failed.csv 에 남겼습니다");
| 운영 스크립트의 규칙 | 왜 |
|---|---|
| 종료 코드를 정확히(0 성공) | 자동화가 성공 여부를 판단 |
| 재개 가능하게(체크포인트) | 10만 건 중 9만 건에서 끊기면? |
| 실패 목록을 파일로 | 화면에 흘려보내면 못 찾습니다 |
| 결과를 사람이 읽을 형태 + JSON | --json 옵션 하나로 파이프 가능 |
| 운영 실행은 감사 로그에 | "누가 언제 무엇을" 이 나중에 필요합니다 |
dotnet run app.cs 는 자리를 잘 고르면 굉장히 편합니다.
csproj·솔루션 없이 #:package 한 줄로 의존성을 쓰니
Python 스크립트를 쓰던 자리를 그대로 대체합니다.
다만 DB 를 건드리는 도구는 정식 프로젝트로 만드세요 —
테스트와 코드 리뷰가 필요한 종류의 코드입니다.
NuGet 패키지 만들기
여러 프로젝트가 같은 코드를 복사하고 있다면 패키지로 만들 때입니다.
만들고 올리기
<PropertyGroup>
<PackageId>Acme.Game.Protocol</PackageId>
<Version>1.2.0</Version>
<Authors>Acme</Authors>
<Description>게임 클라이언트·서버 공용 패킷 정의</Description>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
</PropertyGroup>
dotnet pack -c Release
dotnet nuget push bin/Release/*.nupkg \
--source https://nuget.pkg.github.com/acme/index.json \
--api-key $TOKEN
사내 공용 패키지의 규칙
- 버전 규칙(SemVer) — 깨지는 변경은 메이저를 올리세요
- 변경 이력을 패키지와 함께 (무엇이 바뀌었는지)
- 의존성을 최소로 — 공용 패키지가 무거우면 모두가 무거워집니다
- 심볼 포함 — 디버깅 시 소스로 들어갈 수 있습니다
- 게임이라면 클라이언트·서버 공용 프로토콜이 가장 좋은 후보입니다
버전을 언제 올리나 — SemVer 를 실제로 적용하면
| 바꾼 것 | 올릴 자리 | 예 | 쓰는 쪽이 할 일 |
|---|---|---|---|
| 메서드 시그니처 변경 · 삭제 | 메이저 1.2.0 → 2.0.0 | SendAsync(int) → SendAsync(long) | 코드 수정 필요 |
| 필드 이름·타입 변경 | 메이저 | 패킷 구조 변경 | 서버·클라 동시 배포 |
| 인터페이스에 멤버 추가 | 메이저 | IRepo 에 새 메서드 | 구현체 전부 수정 |
| 기능 추가(기존은 그대로) | 마이너 1.2.0 → 1.3.0 | 새 메서드 · 새 타입 | 없음 |
| 버그 수정 · 성능 개선 | 패치 1.2.0 → 1.2.1 | 계산 오류 수정 | 없음 |
| 기본 동작이 달라짐 | 메이저(문서상 마이너여도) | 기본 타임아웃 30초 → 3초 | 확인 필요 |
쓸 만한 패키지로 만드는 설정
<PropertyGroup>
<PackageId>Acme.Game.Protocol</PackageId>
<!-- Unity 와 서버 양쪽에서 쓰려면 멀티 타깃 -->
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<Description>게임 클라이언트·서버 공용 패킷 정의</Description>
<PackageTags>game;protocol;unity</PackageTags>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/acme/protocol</PackageProjectUrl>
<!-- ★ Source Link — 사용자가 F11 로 이 패키지 소스에 들어갈 수 있습니다 -->
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<IncludeSymbols>true</IncludeSymbols>
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<ContinuousIntegrationBuild
Condition="'$(GITHUB_ACTIONS)' == 'true'">true</ContinuousIntegrationBuild>
<!-- XML 문서를 함께 넣어 IntelliSense 에 설명이 보입니다
(공개 API 를 실수로 깨뜨리는 것은 EnablePackageValidation 이 잡습니다) -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="\" />
<PackageReference Include="Microsoft.SourceLink.GitHub"
Version="8.0.0" PrivateAssets="All" />
</ItemGroup>
# 태그를 붙이면 그 버전으로 배포되게 (CI 에서)
git tag v1.3.0 && git push --tags
dotnet pack -c Release -p:Version=${TAG#v}
dotnet nuget push "bin/Release/*.nupkg" \
--source https://nuget.pkg.github.com/acme/index.json \
--api-key $TOKEN --skip-duplicate
사내 패키지 운영 — 실전에서 생기는 문제
| 문제 | 왜 생기나 | 해결 |
|---|---|---|
| 같은 패키지 버전이 프로젝트마다 다름 | 각자 올림 | Directory.Packages.props 로 중앙 관리 |
| 이미 올린 버전을 고치고 싶다 | 실수로 배포 | 불가능합니다 — 새 패치 버전을 올리고 unlist |
| 공용 패키지가 무거워짐 | 편의 기능 계속 추가 | 패키지를 쪼갭니다(Core / Extensions) |
| Unity 에서 못 씀 | net10.0 만 타깃 | netstandard2.1 병행 |
| 패키지 안 예외의 원인을 못 찾음 | 심볼 없음 | Source Link + snupkg |
| 전이 의존이 충돌 | 서로 다른 버전 요구 | CentralPackageTransitivePinningEnabled |
<!-- Unity 호환을 위한 조건부 코드 -->
<ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.1'">
<!-- Unity 쪽에는 System.Text.Json 대신 가벼운 것을 -->
<PackageReference Include="MessagePack" />
</ItemGroup>
#if NETSTANDARD2_1
// Unity 에서는 못 쓰는 API 를 우회
public static string Join(char sep, IEnumerable<string> v)
=> string.Join(sep.ToString(), v);
#else
public static string Join(char sep, IEnumerable<string> v)
=> string.Join(sep, v);
#endif
- 패키지로 만들기 전에 물어보세요 — "복사본이 3곳 이상인가, 앞으로도 같이 바뀌어야 하는가". 둘 다 아니면 복사가 낫습니다
- 사내 피드는 GitHub Packages · Azure Artifacts · BaGet 중 아무거나.
nuget.config를 저장소에 커밋해 두면 팀원이 설정할 것이 없습니다 - 변경 이력(CHANGELOG)을 패키지 설명에 링크로 — 버전을 올릴지 말지 판단하는 유일한 근거입니다
- 공개 배포라면
dotnet pack결과를 NuGet Package Explorer 로 한 번 열어 보세요 — 의도치 않은 파일이 들어가 있는 경우가 많습니다
컨테이너 · 로컬 개발 환경
"내 PC 에선 되는데"를 없애는 가장 확실한 방법.
개발용 의존 서비스
# compose.yaml — 팀원 모두 같은 환경
services:
db:
image: postgres:17-alpine
environment: { POSTGRES_PASSWORD: dev, POSTGRES_DB: game }
ports: ["5432:5432"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
redis:
image: redis:7-alpine
ports: ["6379:6379"]
seq: # 로그 확인용
image: datalust/seq
environment: { ACCEPT_EULA: Y }
ports: ["5341:80"]
docker compose up -d # 개발 시작
docker compose down -v # 데이터까지 초기화
.NET Aspire
// 여러 서비스를 코드로 묶어 한 번에 띄웁니다
var builder = DistributedApplication.CreateBuilder(args);
var redis = builder.AddRedis("cache");
var db = builder.AddPostgres("pg").AddDatabase("game");
builder.AddProject<Projects.GameApi>("api")
.WithReference(redis)
.WithReference(db);
builder.AddProject<Projects.MatchServer>("match")
.WithReference(redis);
builder.Build().Run(); // 대시보드에서 로그·추적·지표를 한눈에
이미지를 작게 — 크기가 곧 배포 속도입니다
| 베이스 이미지 | 대략 크기 | 고를 때 | 주의 |
|---|---|---|---|
aspnet:10.0 (Debian) | ~220MB | 기본 선택 | — |
aspnet:10.0-alpine | ~110MB | 크기가 중요할 때 | musl libc — 일부 네이티브 라이브러리가 안 돕니다 |
aspnet:10.0-noble-chiseled | ~110MB | 운영 권장 — 셸조차 없어 공격면이 작음 | 디버깅용 셸이 없습니다 |
Native AOT + runtime-deps-chiseled | ~30MB | 기동 속도가 중요한 함수·CLI | 리플렉션 제약 → 🔬 고급 탭 |
Dockerfile — 캐시가 먹게 쓰는 법
# syntax=docker/dockerfile:1.7
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# ★ ① csproj 만 먼저 복사 — 소스가 바뀌어도 restore 캐시가 살아남습니다
COPY *.sln Directory.Packages.props Directory.Build.props ./
COPY src/GameApi/*.csproj src/GameApi/
COPY src/GameApi.Core/*.csproj src/GameApi.Core/
# (sln 째로 restore 하면 복사 안 한 테스트 프로젝트 때문에 실패 — 진입 프로젝트만)
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet restore src/GameApi/GameApi.csproj
# ② 그 다음에 소스 전체
COPY . .
RUN --mount=type=cache,target=/root/.nuget/packages \
dotnet publish src/GameApi -c Release -o /app --no-restore \
-p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_URLS=http://+:8080 \
DOTNET_gcServer=1 \
ASPNETCORE_FORWARDEDHEADERS_ENABLED=true
EXPOSE 8080
USER $APP_UID
ENTRYPOINT ["dotnet", "GameApi.dll"]
# .dockerignore — 이게 없으면 bin/obj 수백 MB 가 전송됩니다
cat > .dockerignore <<'EOF'
**/bin
**/obj
**/.vs
**/node_modules
.git
*.user
EOF
COPY . . 를 맨 앞에 두면 캐시가 전혀 안 먹습니다.
코드 한 줄만 고쳐도 restore 부터 다시 합니다 —
빌드가 20초에서 3분으로 늘어납니다.
덜 바뀌는 것을 앞에 두는 것이 Dockerfile 의 유일한 원칙에 가깝습니다.
컨테이너에서 .NET 이 헷갈리는 것들
# compose.yaml — 의존 순서를 헬스체크로 정확히
services:
db:
image: postgres:17-alpine
environment: { POSTGRES_PASSWORD: dev, POSTGRES_DB: game }
ports: ["5432:5432"]
volumes: [pgdata:/var/lib/postgresql/data]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
retries: 10
api:
build: .
depends_on:
db: { condition: service_healthy } # ★ 뜰 때까지 기다립니다
environment:
ASPNETCORE_ENVIRONMENT: Development
ConnectionStrings__Db: "Host=db;Database=game;Username=postgres;Password=dev"
ports: ["8080:8080"]
volumes: { pgdata: }
| 증상 | 원인 | 해결 |
|---|---|---|
| 컨테이너가 바로 종료 | localhost 에만 바인딩 | ASPNETCORE_URLS=http://+:8080 |
| DB 연결 실패 | localhost 를 씀 | 서비스 이름(Host=db)으로 |
| 메모리 초과로 강제 종료 | GC 가 호스트 메모리를 기준으로 판단 | 메모리 제한을 걸면 .NET 이 인식합니다 — mem_limit |
| CPU 를 다 못 씀 | 워크스테이션 GC | DOTNET_gcServer=1 |
| 클라이언트 IP 가 전부 같음 | 프록시 뒤 | ForwardedHeaders 활성화 |
한글이 ? 로 | 로캘 없음 | InvariantGlobalization 끄고 icu 포함 이미지 |
| 시간이 UTC 로만 나옴 | tzdata 없음 | 항상 UTC 로 저장하고 표시할 때만 변환 |
# 셸 없는 chiseled 이미지를 디버깅할 때
docker run --rm -it --pid container:myapi \
mcr.microsoft.com/dotnet/nightly/aspnet:10.0 bash
# 또는 SDK 이미지로 잠깐 바꿔서 dotnet-counters / dotnet-dump 사용
# 컨테이너 안 .NET 상태 보기
docker exec myapi dotnet-counters monitor -p 1 --counters System.Runtime
Unity 전용 도구
게임 프로젝트에서 거의 항상 넣게 되는 것들입니다.
필수급
| 도구 | 해결하는 문제 |
|---|---|
| UniTask | async 를 할당 없이 · 코루틴 대체 |
| VContainer | 의존성 주입 (싱글턴 남발 방지) |
| DOTween | UI·오브젝트 애니메이션 |
| Addressables | 에셋 로딩·메모리·다운로드 |
| Odin Inspector | 인스펙터 확장 (유료) |
| Newtonsoft Json | JsonUtility 의 한계 보완 |
// UniTask — Unity 에서 async 를 안전하게
async UniTaskVoid StartGameAsync(CancellationToken ct)
{
await UniTask.Delay(500, cancellationToken: ct);
var prefab = await Addressables.LoadAssetAsync<GameObject>("Boss")
.ToUniTask(cancellationToken: ct);
Instantiate(prefab);
}
// 오브젝트가 파괴되면 자동 취소
var ct = this.GetCancellationTokenOnDestroy();
에디터 확장 — 반복 작업 없애기
// Assets/_Project/Scripts/Editor/Tools.cs
public static class Tools
{
[MenuItem("Tools/데이터 다시 굽기 %#b")] // Ctrl+Shift+B
public static void RebuildData()
{
var rows = CsvLoader.Load("Assets/Data/items.csv");
var so = ScriptableObject.CreateInstance<ItemTable>();
so.rows = rows;
AssetDatabase.CreateAsset(so, "Assets/_Project/Data/ItemTable.asset");
AssetDatabase.SaveAssets();
Debug.Log($"아이템 {rows.Length}개 반영");
}
[MenuItem("Tools/PlayerPrefs 초기화")]
public static void ClearPrefs() => PlayerPrefs.DeleteAll();
}
에러 사전
자주 만나는 오류를 원인별로 모았습니다.
런타임
| 메시지 | 원인 1순위 |
|---|---|
| NullReferenceException | 초기화 전 접근 · Unity 인스펙터 미할당 |
| ObjectDisposedException | using 밖에서 사용 · DbContext 수명 |
| InvalidOperationException Collection was modified | foreach 중 컬렉션 변경 |
| TaskCanceledException | 타임아웃 · 취소 토큰 발동 |
| SocketException 10048 | 포트 이미 사용 중 |
| OutOfMemory | 대용량 로드 · 캐시 무제한 · 누수 |
| StackOverflow | 재귀 · 속성이 자기 자신 호출 |
빌드 · 배포
| 증상 | 원인 1순위 |
|---|---|
| 로컬은 되는데 CI 실패 | 대소문자 구분(Linux) · 파일 누락 |
| 패키지 버전 충돌 | 중앙 버전 관리 미적용 · 전이 의존 |
| IL2CPP 빌드만 실패 | 리플렉션·코드 스트리핑 |
| 배포 후 500 에러 | 환경 변수 누락 · 마이그레이션 미적용 |
| 컨테이너가 바로 종료 | 포트 바인딩 · 진입점 · 권한 |
| 느려짐(운영만) | Debug 로그 · 서버 GC 미설정 |
원인을 좁히는 순서 — 이 명령들로 시작합니다
| 증상 | 먼저 칠 명령 | 무엇이 보이나 |
|---|---|---|
| 메모리가 계속 오름 | dotnet-counters monitor -p PID --counters System.Runtime | Gen2 힙 · GC 횟수 · LOH 크기 |
| 어디서 새는지 모름 | dotnet-dump collect -p PID → dumpheap -stat | 타입별 인스턴스 수 · 총 바이트 |
| CPU 100% | dotnet-trace collect -p PID --profile cpu-sampling | 어느 메서드가 CPU 를 먹는지 |
| 응답이 멈춤(행) | dotnet-stack report -p PID | 모든 스레드가 어디서 대기 중인지 |
| 스레드풀 기아 의심 | dotnet-counters → threadpool-queue-length | 큐 길이가 계속 늘면 확정 |
| 비동기 작업이 안 끝남 | dotnet-dump → dumpasync | 대기 중인 async 상태 기계 체인 |
| 예외가 어디서 나는지 | dotnet-trace + Microsoft-DotNETCore-EventPipe | 던져진 예외 전부(잡힌 것 포함) |
| 패키지 취약점 | dotnet list package --vulnerable --include-transitive | 전이 의존까지 |
| 빌드가 느림 | dotnet build -bl → MSBuild Structured Log Viewer | 어느 타깃이 오래 걸리는지 |
dotnet tool install -g dotnet-counters dotnet-dump dotnet-trace dotnet-stack
네 개를 지금 깔아 두세요. 사고가 났을 때 설치부터 하려면
프로세스가 이미 재시작된 뒤입니다 — 증거가 사라집니다.
운영 이미지에도 넣어 두거나, 최소한 덤프를 뜨는 절차는 문서로 만들어 두세요.
컴파일 에러 — 자주 만나는 것
| 코드 | 메시지 | 원인 1순위 |
|---|---|---|
| CS8618 | null 이 아닌 필드가 초기화되지 않음 | Nullable 켠 뒤 — required 를 붙이거나 = null! |
| CS8602 | null 참조 역참조 가능 | ?. 또는 is not null 검사 |
| CS4014 | await 하지 않은 호출 | 진짜 버그입니다 — 예외가 사라집니다 |
| CS1998 | async 인데 await 가 없음 | async 를 떼거나 Task.FromResult |
| CS0246 | 형식·네임스페이스를 찾을 수 없음 | using 누락 · 패키지 미참조 · TFM 불일치 |
| CS0121 | 호출이 모호함 | 확장 메서드 이름 충돌 |
| NU1605 | 패키지 다운그레이드 감지 | 전이 의존 충돌 → 중앙 버전 관리 |
| NETSDK1045 | 현재 SDK 가 이 버전을 지원 안 함 | global.json 또는 SDK 설치 |
<WarningsAsErrors>CS4014</WarningsAsErrors> 로 올리면
"가끔 데이터가 저장 안 되는" 버그의 한 종류가 통째로 사라집니다.
의도적으로 기다리지 않을 때는 _ = Task.Run(...) 처럼
일부러 그랬다는 표시를 남기세요.
"운영에서만" 나는 문제 — 원인 지도
| 증상 | 원인 | 확인 |
|---|---|---|
| 느림(로컬은 빠름) | 워크스테이션 GC · Debug 로그 | DOTNET_gcServer=1 · 로그 레벨 |
| 일정 시간 뒤 멈춤 | 스레드풀 기아(동기 블로킹) | 큐 길이 · dumpasync |
| 메모리가 안 줄어듦 | 이벤트 핸들러 해제 누락 · 정적 캐시 | gcroot 로 참조 경로 추적 |
| 간헐적 타임아웃 | DB 커넥션 풀 고갈 | 풀 사용률 · 느린 쿼리 |
| 재시작 직후만 느림 | JIT 예열 · 캐시 비어 있음 | ReadyToRun · 워밍업 요청 |
| 특정 사용자만 오류 | 데이터에 예외 케이스(null·이모지·길이) | 로그에 입력 식별자를 남길 것 |
| 배포 직후만 에러 | 마이그레이션 순서 · 설정 누락 | 배포 태그별 에러율 |
| IL2CPP 빌드만 실패 | 리플렉션 · 코드 스트리핑 | link.xml 로 보존 지정 |
# 운영 컨테이너에서 덤프 뜨기 — 사고 직후 가장 먼저 할 일
kubectl exec -it game-api-xxx -- dotnet-dump collect -p 1 -o /tmp/d.dmp
kubectl cp game-api-xxx:/tmp/d.dmp ./d.dmp
# 로컬에서 분석
dotnet-dump analyze d.dmp
> dumpheap -stat # 어떤 타입이 메모리를 먹는가
> dumpheap -type MyApp.Player -stat # 그 타입 인스턴스 수
> gcroot <주소> # 왜 살아 있는가 (참조 경로)
> clrthreads # 스레드 상태
> dumpasync # 대기 중인 async 작업
> pstacks # 스택을 묶어서 보기
"왜 느린가"에
답할 수 있는
사람이 된다
여기서부터는 런타임이 무엇을 하고 있는지를 봅니다. GC 가 언제 도는지, JIT 이 무엇을 지우는지, async 가 실제로 어떤 코드로 변하는지. 여기까지 마치면 프로파일러 결과를 읽고 원인을 코드 한 줄까지 좁힐 수 있습니다.
CLR 구조 · JIT
C# 코드가 CPU 명령이 되기까지. 두 번 컴파일된다는 사실이 출발점입니다.
JIT 이 해 주는 일
- 인라이닝 — 작은 메서드 호출을 본문으로 펼칩니다(속성 접근이 공짜인 이유)
- 경계 검사 제거 —
for (i = 0; i < arr.Length; i++)형태를 알아보고 검사 제거 - 죽은 코드 제거 — 상수 조건 분기를 통째로 삭제
- 계층형 컴파일 — 자주 실행되면 더 좋은 코드로 교체(OSR)
- 제네릭 특수화 — 값 타입은 타입마다 전용 코드를 만듭니다
arr.Length 를 변수에 담는 최적화는 이제 손해입니다.
JIT 이 패턴을 인식해 경계 검사를 지우는데,
변수에 담으면 그 패턴이 깨져 오히려 검사가 남습니다.
직접 확인하기
# IL 보기
dotnet tool install -g ilspycmd
ilspycmd -il bin/Release/net10.0/App.dll
# JIT 이 실제로 만든 기계어 보기
DOTNET_JitDisasm="MyClass:Compute*" dotnet run -c Release
// 인라이닝 제어 (거의 쓸 일이 없지만 알아 두면 유용)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static int Fast(int a, int b) => a + b;
[MethodImpl(MethodImplOptions.NoInlining)] // 벤치마크에서 최적화 방지
public static void Measured() { }
메모리 배치 — 스택 · 힙 · 참조
"값 타입은 스택"은 절반만 맞습니다. 정확한 그림을 잡습니다.
실제 규칙
void M()
{
int a = 1; // 스택 (지역 변수)
var p = new Point(); // 스택 (struct 지역 변수)
var n = new Node(); // 참조는 스택, 객체 본체는 힙
var list = new List<Point>();
list.Add(p); // ★ 이 Point 는 힙에 있습니다 (배열 안)
}
class Holder
{
Point _p; // ★ 이 struct 는 힙에 있습니다 (객체의 일부)
}
객체 하나의 비용
64비트에서 class 인스턴스
객체 헤더(동기화 블록) 8 바이트
메서드 테이블 포인터 8 바이트
─────────────────────── 최소 24 바이트 (정렬 포함)
즉 int 하나만 든 class 도 24바이트,
같은 내용의 struct 는 4바이트.
→ 작고 많은 데이터일수록 struct 가 유리한 이유
// 메모리 레이아웃 확인 (ObjectLayoutInspector)
TypeLayout.PrintLayout<Player>();
// 필드 순서만 바꿔도 크기가 줄어듭니다 (패딩)
struct Bad { byte A; long B; byte C; } // 24바이트
struct Good { long B; byte A; byte C; } // 16바이트
in 매개변수로 복사를 막으세요.
struct 냐 class 냐 — 판단표
| 조건 | struct | class | 이유 |
|---|---|---|---|
| 크기 16바이트 이하 | ✓ | — | 복사가 참조보다 싸거나 비슷 |
| 크기 24바이트 초과 | — | ✓ | 복사 비용이 커집니다(in 으로 완화 가능) |
| 불변(만들고 안 바꿈) | ✓ | — | readonly struct 로 방어 복사도 방지 |
| 수백만 개를 배열로 | ✓✓ | — | 연속 메모리 · 객체 헤더 없음 · GC 부담 0 |
| 상속·다형성이 필요 | — | ✓ | struct 는 상속 불가 |
| null 이 의미 있음 | — | ✓ | Nullable<T> 는 크기가 늘고 박싱 위험 |
| 인터페이스로 다룸 | 주의 | ✓ | 박싱됩니다 — 제네릭 제약(where T : IFoo)으로 회피 |
| 컬렉션에 자주 넣고 뺌 | 주의 | ✓ | 복사가 반복됩니다 |
눈에 안 보이는 할당 — 이것들을 찾아야 합니다
// ① 박싱 — struct 를 object·인터페이스로 다루는 순간
int n = 42;
object o = n; // 힙에 24바이트
Console.WriteLine("값 " + n); // n.ToString() → 문자열 할당
list.Add(n); // List<object> 면 박싱, List<int> 면 안 함
// 인터페이스 호출도 박싱
IComparable c = n; // ✗ 박싱
static int Cmp<T>(T a, T b) where T : IComparable<T> => a.CompareTo(b); // ✓ 안 함
// ② 클로저 — 람다가 바깥 변수를 잡으면 클래스가 하나 생깁니다
for (var i = 0; i < 1000; i++)
items.Where(x => x.Id == i); // ✗ i 를 캡처 → 반복마다 클로저 객체
// 캡처를 없애면 할당이 사라집니다
items.Where(static x => x.Id > 0); // ✓ static 람다 (C# 9+)
// ③ params · LINQ · foreach 의 숨은 할당
void Log(params object[] a) { } // 호출마다 배열 할당
Log("a", 1, true); // + int·bool 박싱 2회
foreach (var x in (IEnumerable<int>)array) // ✗ 열거자가 박싱됨
foreach (var x in array) // ✓ 배열은 구조체 열거자
// ④ 문자열 — 게임 루프에서 가장 흔한 범인
label.Text = "HP: " + hp; // ✗ 매 프레임 문자열 할당
label.Text = string.Create(...) // ✓ 또는 값이 바뀔 때만 갱신
// ⑤ async 상태 기계 — 완료된 경우가 많으면 ValueTask
public async ValueTask<int> GetAsync(int id) // 캐시 적중이 잦으면 할당 회피
{
if (_cache.TryGetValue(id, out var v)) return v; // 동기 완료 → 할당 없음
return await LoadAsync(id);
}
레이아웃을 직접 확인하고 줄이기
// 패딩 때문에 필드 순서가 크기를 바꿉니다 (64비트, 8바이트 정렬)
struct Bad // 24바이트
{
byte A; // 1 + 패딩 7
long B; // 8
byte C; // 1 + 패딩 7
}
struct Good // 16바이트
{
long B; // 8
byte A; // 1
byte C; // 1 + 패딩 6
}
// 확인: dotnet add package ObjectLayoutInspector
TypeLayout.PrintLayout<Bad>();
// Size: 24 bytes. Paddings: 14 bytes (%58 of empty space)
// 큰 struct 를 복사 없이 넘기기
static float Dist(in Matrix4x4 a, in Matrix4x4 b) { ... } // 64바이트 × 2 복사 방지
// readonly struct — 방어 복사(defensive copy)까지 없앱니다
public readonly struct Vec3(float x, float y, float z)
{
public readonly float X = x, Y = y, Z = z;
public float Length => MathF.Sqrt(X * X + Y * Y + Z * Z);
}
// readonly 가 아니면 in 매개변수에서 메서드를 부를 때마다 복사본이 생깁니다
// ref struct — 스택에만 존재하도록 컴파일러가 강제
ref struct Reader
{
ReadOnlySpan<byte> _buf; // 힙에 올라갈 수 없으니 안전
}
| 기법 | 없애는 것 | 대가 |
|---|---|---|
readonly struct | 방어 복사 | 불변이어야 함 |
in 매개변수 | 인자 복사 | readonly 아니면 역효과 |
ref struct | 힙 승격 가능성 | 필드·async·컬렉션에 못 넣음 |
Span<T> | 배열 복사·슬라이스 할당 | 수명 제약 |
ArrayPool<T> | 큰 배열 반복 할당 | 반드시 Return · 내용 초기화 안 됨 |
stackalloc | 작은 임시 버퍼 할당 | 크기 상한 필수(스택 오버플로) |
ArrayPool<byte>.Shared 로 재사용하는 것이
AOT·GC 튜닝 어느 쪽보다 효과가 큽니다.
GC 내부 — 세대 · LOH · 서버 GC
언제 멈추고 얼마나 멈추는지. 게임의 프레임 튐과 서버의 p99가 여기서 결정됩니다.
세대 가설
// 수집 횟수 확인 — Gen2 가 자주 늘면 설계를 봐야 합니다
Console.WriteLine($"G0={GC.CollectionCount(0)} G1={GC.CollectionCount(1)} G2={GC.CollectionCount(2)}");
Console.WriteLine($"할당 총량 {GC.GetTotalAllocatedBytes() / 1024 / 1024} MB");
설정과 대책
<PropertyGroup>
<!-- 서버: 코어마다 힙과 스레드 → 처리량 ↑ (메모리 사용 ↑) -->
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
<ItemGroup>
<!-- 컨테이너에서는 힙 상한을 명시 (MSBuild 속성이 아니라 런타임 설정 —
메모리 제한이 걸린 컨테이너의 기본값도 75%) -->
<RuntimeHostConfigurationOption Include="System.GC.HeapHardLimitPercent" Value="75" />
</ItemGroup>
- 서버 GC — 처리량 우선. API·게임 서버에 권장
- 워크스테이션 GC — 지연 우선. 데스크탑 앱에 적합
- LOH 압축은 필요할 때만
GCSettings.LargeObjectHeapCompactionMode - 큰 버퍼는 ArrayPool 로 재사용 — LOH 단편화의 근본 대책
GC.Collect() 를 코드에 넣지 마세요.
런타임의 판단을 방해해 대개 더 느려집니다.
예외는 게임의 씬 전환 직후처럼
"지금 멈춰도 되는 순간"이 명확한 경우뿐입니다.
메모리 누수 추적
.NET 에도 누수가 있습니다. "아무도 안 쓰는데 누군가 붙잡고 있는" 상태입니다.
범인 다섯
- 이벤트 미해지 —
+=하고-=안 함 (1순위) - static 컬렉션 — 캐시에 넣고 안 지움
- 타이머·구독 —
Timer·IDisposable미해제 - 클로저 — 람다가 큰 객체를 잡고 있음
- Task 누적 — 완료되지 않는 작업이 쌓임
// 약한 참조로 캐시 (필요하면 GC 가 회수)
private readonly ConditionalWeakTable<Player, Stats> _stats = new();
// 이벤트를 안전하게 — 구독 해제를 IDisposable 로 반환
public IDisposable Subscribe(Action<int> h)
{
Changed += h;
return new Unsubscriber(() => Changed -= h);
}
찾는 절차
# ① 메모리가 실제로 느는지 확인
dotnet-counters monitor -p <PID> \
System.Runtime[dotnet.gc.last_collection.heap.size,dotnet.gc.collections]
# ② 시점을 달리해 덤프 두 개
dotnet-dump collect -p <PID> -o before.dmp
# ... 부하를 준 뒤 ...
dotnet-dump collect -p <PID> -o after.dmp
# ③ 무엇이 늘었는지 비교
dotnet-dump analyze after.dmp
> dumpheap -stat # 타입별 개수·크기
> gcroot <주소> # 누가 붙잡고 있는지 ★
> dumpobj <주소>
gcroot 가 핵심입니다.
"이 객체를 누가 참조하고 있는가"를 루트까지 거슬러 보여 줍니다.
대부분 여기서 이벤트 핸들러나 static 필드가 나옵니다.
async 의 정체 — 상태 기계
await 는 컴파일러가 메서드를 잘게 쪼개 상태 기계로 다시 씁니다.
무슨 코드로 바뀌는가
// 내가 쓴 코드
async Task<int> GetAsync()
{
var a = await LoadAsync(); // ← 여기서 잘린다
return a + 1;
}
// 컴파일러가 만드는 것 (개념)
struct StateMachine : IAsyncStateMachine
{
int _state;
TaskAwaiter<int> _awaiter;
public void MoveNext()
{
switch (_state)
{
case -1:
_awaiter = LoadAsync().GetAwaiter();
if (!_awaiter.IsCompleted) // ★ 이미 끝났으면 그냥 진행
{
_state = 0;
_builder.AwaitUnsafeOnCompleted(ref _awaiter, ref this);
return; // 스레드를 놓는다
}
goto case 0;
case 0:
var a = _awaiter.GetResult();
_builder.SetResult(a + 1);
return;
}
}
}
여기서 나오는 실무 결론
- 이미 완료된 작업은 상태 기계를 타지 않습니다 —
캐시 히트가 많으면
ValueTask가 유효합니다 - async 메서드마다 상태 기계 객체가 생깁니다 — 아주 뜨거운 경로에서는 이것도 비용입니다
- await 이후는 다른 스레드일 수 있습니다 —
ThreadStatic·UI 접근에 주의 - SynchronizationContext 가 있으면 원래 컨텍스트로 돌아옵니다 (UI·Unity). 서버(ASP.NET Core)에는 없습니다
// 라이브러리 코드라면 컨텍스트 복귀를 끄는 것이 표준
await SomethingAsync().ConfigureAwait(false);
// 앱 코드(ASP.NET Core)에서는 컨텍스트가 없어 붙일 필요가 없습니다
스레드 풀 · 기아 현상
서버가 갑자기 전부 느려지는 현상의 대표적인 원인입니다.
무슨 일이 일어나는가
.Result 로 비동기를 기다리는 코드가 여럿이면
스레드가 전부 대기 상태로 묶입니다.
풀은 1초에 1~2개씩만 스레드를 늘리므로
회복이 매우 느리고, 그동안 모든 요청이 지연됩니다.
// 증상 확인
ThreadPool.GetAvailableThreads(out var worker, out var io);
Console.WriteLine($"여유 스레드 {worker}");
// 지표로 감시 (급증하면 기아)
dotnet-counters monitor -p <PID> \
System.Runtime[dotnet.thread_pool.queue.length,dotnet.thread_pool.thread.count]
고치는 법
- 동기 차단 제거 —
.Result·.Wait()·lock안의 IO - 동기 API 는 동기 스레드로 — 레거시 라이브러리는 전용 큐로 분리
- 최소 스레드 상향은 임시방편 — 근본 원인을 고치세요
- CPU 작업을 요청 스레드에서 하지 말고
Task.Run으로
// 임시 완화 (진짜 해결이 아님을 알고 쓰세요)
ThreadPool.SetMinThreads(workerThreads: 200, completionPortThreads: 200);
Task.Run 으로 수백 개 띄우면
풀 스레드를 전부 점유합니다.
장시간 도는 루프는 전용 스레드
(new Thread { IsBackground = true })로 만드세요.
기아를 만드는 코드 — 실제로 자주 보는 것들
// ① 가장 흔함 — 동기 메서드 안에서 비동기 호출
public User Get(int id) => _repo.GetAsync(id).Result; // ✗
public Task<User> GetAsync(int id) => _repo.GetAsync(id); // ✓ 끝까지 async
// ② 생성자·프로퍼티에서 — async 를 못 쓰니 여기서 막습니다
public class Service
{
private readonly Config _cfg;
public Service(IConfigLoader l) => _cfg = l.LoadAsync().GetAwaiter().GetResult(); // ✗
}
// ✓ 팩토리 메서드나 IHostedService 로 시작할 때 한 번 로드
// ③ 이벤트 핸들러 — 인터페이스가 동기라서
public void OnMessage(Message m) => HandleAsync(m).Wait(); // ✗
public void OnMessage(Message m) => _channel.Writer.TryWrite(m); // ✓ 큐에 넣고 반환
// ④ lock 안에서 IO — 스레드가 묶이고 다른 스레드도 막힙니다
lock (_gate) { var r = _http.GetStringAsync(url).Result; } // ✗
await _semaphore.WaitAsync(ct); // ✓ SemaphoreSlim
try { var r = await _http.GetStringAsync(url, ct); }
finally { _semaphore.Release(); }
// ⑤ 동기 IO 를 요청 스레드에서
var text = File.ReadAllText(path); // ✗ 디스크가 느리면 묶임
var text = await File.ReadAllTextAsync(path, ct); // ✓
// ⑥ 병렬 처리에 Parallel.For 로 비동기를 섞기
Parallel.For(0, 100, i => DoAsync(i).Wait()); // ✗ 최악
await Parallel.ForEachAsync(items, ct, async (x, t) => await DoAsync(x, t)); // ✓
.Result 로 감싸지 말고 전용 스레드나 별도 큐로 격리하세요.
Task.Run(() => SyncCall()) 도 스레드풀을 쓰므로
동시 호출 수를 SemaphoreSlim 으로 제한해야
풀 전체로 번지지 않습니다.
진단과 격리
# ① 실시간 확인 — 큐 길이가 계속 늘면 기아
dotnet-counters monitor -p <PID> --counters \
System.Runtime[threadpool-thread-count,threadpool-queue-length,\
threadpool-completed-items-count],Microsoft.AspNetCore.Hosting
# 정상: queue-length 0~5, thread-count 안정
# 기아: queue-length 계속 증가, thread-count 가 천천히 오름
# ② 스레드들이 무엇을 기다리는지 — 원인 지점을 직접 봅니다
dotnet-dump collect -p <PID>
dotnet-dump analyze core_xxx
> clrthreads # 스레드 목록
> pstacks # 스택을 묶어서 — 같은 곳에 몰려 있으면 그게 범인
> syncblk # 락 경합
> dumpasync # 대기 중인 async 체인
// ③ 코드 레벨에서 미리 막기 — 분석기를 켜세요
// .editorconfig
// dotnet_diagnostic.CA1849.severity = error // 비동기 컨텍스트에서 동기 호출
// dotnet_diagnostic.VSTHRD002.severity = error // .Result / .Wait() 금지
// (Microsoft.VisualStudio.Threading.Analyzers 패키지)
// ④ 장기 루프는 스레드풀에 올리지 않습니다 (게임 서버)
// ✗ 룸 200개 = 풀 스레드 200개 점유
foreach (var room in rooms) _ = Task.Run(() => room.LoopForever());
// ✓ 전용 스레드 — 풀과 무관하게 돕니다
var t = new Thread(() => room.LoopForever())
{
IsBackground = true,
Name = $"room-{room.Id}",
Priority = ThreadPriority.AboveNormal
};
t.Start();
// ✓✓ 더 좋게 — 스레드 하나가 여러 룸의 틱을 돌립니다
// 룸 1000개에 스레드 1000개는 컨텍스트 스위칭만으로 죽습니다
var worker = new Thread(() =>
{
var sw = Stopwatch.StartNew();
while (!ct.IsCancellationRequested)
{
foreach (var r in myRooms) r.Tick(); // 코어 하나가 룸 수십~수백 개
var wait = 50 - (int)(sw.ElapsedMilliseconds % 50);
Thread.Sleep(wait);
}
}) { IsBackground = true };
SetMinThreads 를 쓰세요.
이것은 "풀이 늘리기를 주저하는 구간"을 건너뛰는 것이지
블로킹을 없애 주지 않습니다.
새벽에 급하게 서비스를 살릴 때는 유효하지만,
다음 날 반드시 원인을 찾아야 합니다 —
안 그러면 부하가 조금 더 늘었을 때 같은 일이 또 납니다.
메모리 모델 · 락 프리
CPU 와 컴파일러는 순서를 바꿉니다. 멀티스레드 버그의 근원입니다.
재배치와 가시성
// 이 코드는 '영원히 끝나지 않을 수' 있습니다
private bool _stop; // ✗
public void Run() { while (!_stop) { } } // JIT 이 레지스터에 캐시
public void Stop() => _stop = true;
// ① volatile — 항상 메모리에서 읽고 쓴다
private volatile bool _stop; // ✓
// ② Interlocked — 원자적 연산
Interlocked.Increment(ref _count);
Interlocked.Exchange(ref _state, 1);
var prev = Interlocked.CompareExchange(ref _state, 1, 0); // 0이면 1로
// ③ lock — 가장 쉽고 안전 (진입·탈출 시 메모리 장벽)
lock (_gate) { _count++; }
volatile 은 원자성을 주지 않습니다.
_count++ 는 읽기+더하기+쓰기 세 단계라
volatile 을 붙여도 경쟁이 그대로 남습니다.
수치 연산은 Interlocked, 복합 로직은 lock 입니다.
락 프리는 최후에
// CAS 루프 — 성공할 때까지 재시도
public void AddMax(ref long field, long value)
{
long cur;
do
{
cur = Volatile.Read(ref field);
if (value <= cur) return;
}
while (Interlocked.CompareExchange(ref field, value, cur) != cur);
}
// 카운터는 전용 타입이 훨씬 빠릅니다 (경합이 심할 때)
private readonly PaddedCounter _hits; // false sharing 방지 패딩
캐시 라인 · 데이터 지역성
요즘 CPU 에서 느린 것은 계산이 아니라 메모리에서 값을 가져오는 시간입니다.
배열 vs 객체 배열
// 객체 배열 — 메모리에 흩어져 있다 (캐시 미스)
class Enemy { public float X, Y; public int Hp; /* ... 그 밖에 많은 필드 */ }
Enemy[] enemies; // 참조 배열 → 실제 객체는 여기저기
foreach (var e in enemies) e.X += 1; // 매번 다른 메모리로 점프
// 구조 배열(SoA) — 붙어 있다
float[] xs, ys; // 좌표만 연속으로
for (int i = 0; i < xs.Length; i++) xs[i] += 1; // 순차 접근 · 캐시 적중
false sharing
// 서로 다른 스레드가 '같은 캐시 라인(64B)'의 값을 만지면
// 실제로는 관계없는 데이터인데도 캐시가 계속 무효화됩니다
class Counters
{
public long A; // 스레드 1이 증가
public long B; // 스레드 2가 증가 ← 같은 라인에 있으면 서로 방해
}
// 패딩으로 떼어 놓기
[StructLayout(LayoutKind.Explicit, Size = 128)]
struct PaddedLong { [FieldOffset(64)] public long Value; }
- 증상 — 코어를 늘렸는데 오히려 느려짐
- 확인 — 스레드별 카운터를 따로 두고 마지막에 합치기로 바꿔 비교
- 대안 —
ThreadLocal<T>· 파티션별 집계
Native AOT · 트리밍
JIT 없이 미리 기계어로. 시작이 즉시이고 메모리가 적습니다 — 대신 제약이 있습니다.
쓰는 법과 효과
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
</PropertyGroup>
일반 게시 시작 120ms · 메모리 40MB · 배포 70MB
Native AOT 시작 8ms · 메모리 14MB · 배포 12MB
→ 서버리스 · CLI 도구 · 컨테이너 밀집 배치에서 큰 이득
포기해야 하는 것
- 런타임 리플렉션이 제한됩니다 — 타입을 문자열로 찾는 코드가 깨집니다
- 동적 코드 생성(
Emit) 불가 — 일부 ORM·목 라이브러리가 못 돕니다 - 플러그인 로딩(
Assembly.LoadFrom) 불가 - 빌드 시간이 길어집니다
// AOT 에서 안전한 방식 — 소스 제너레이터로 미리 만들어 둔다
[JsonSerializable(typeof(PlayerDto))]
internal partial class AppJson : JsonSerializerContext { }
var json = JsonSerializer.Serialize(dto, AppJson.Default.PlayerDto);
어느 게시 방식을 고를까 — 실측 비교
| 방식 | 시작 | 메모리 | 배포 크기 | 최대 성능 | 제약 |
|---|---|---|---|---|---|
| 프레임워크 의존 | ~120ms | 40MB | 2MB | 최고(JIT 최적화) | 런타임 설치 필요 |
| 자체 포함 | ~120ms | 40MB | 70MB | 최고 | 크기 |
| + 트리밍 | ~110ms | 36MB | 25MB | 최고 | 리플렉션 주의 |
| + ReadyToRun | ~40ms | 45MB | 40MB | 최고 (핫 경로는 재JIT) | 크기 증가 |
| Native AOT | ~8ms | 14MB | 12MB | 약간 낮음 (동적 최적화 없음) | 리플렉션·Emit 불가 |
AOT 로 가는 길 — 경고부터 없앱니다
<PropertyGroup>
<!-- ★ 먼저 이것만 켜서 경고를 봅니다 (실제 AOT 게시 전에) -->
<IsAotCompatible>true</IsAotCompatible>
<EnableTrimAnalyzer>true</EnableTrimAnalyzer>
<EnableAotAnalyzer>true</EnableAotAnalyzer>
<EnableSingleFileAnalyzer>true</EnableSingleFileAnalyzer>
</PropertyGroup>
<!-- 경고가 0 이 된 뒤에 -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<StripSymbols>true</StripSymbols>
<OptimizationPreference>Speed</OptimizationPreference>
</PropertyGroup>
자주 나오는 경고와 뜻
IL2026 RequiresUnreferencedCode — 트리밍하면 깨질 수 있는 API
IL2091 제네릭 인자에 필요한 멤버 정보가 없음
IL3050 RequiresDynamicCode — AOT 에서 못 도는 API (Emit 계열)
IL2104 어셈블리 전체에 트리밍 경고 → 그 패키지가 AOT 미지원
→ 세 번째(IL3050)가 나오면 그 코드는 반드시 바꿔야 합니다.
첫 번째(IL2026)는 대개 소스 제너레이터로 대체할 수 있습니다.
// JSON — 리플렉션 대신 소스 제너레이터
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(PlayerDto))]
[JsonSerializable(typeof(List<ItemDto>))]
internal partial class AppJson : JsonSerializerContext;
var json = JsonSerializer.Serialize(dto, AppJson.Default.PlayerDto);
var back = JsonSerializer.Deserialize(json, AppJson.Default.PlayerDto);
// ASP.NET Core Minimal API 는 AOT 를 지원합니다
var builder = WebApplication.CreateSlimBuilder(args); // ★ Slim
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJson.Default));
var app = builder.Build();
app.MapGet("/players/{id:int}", (int id) => new PlayerDto(id, "홍길동"));
app.Run();
무엇이 되고 무엇이 안 되나
| 기능 · 패키지 | AOT | 대안 |
|---|---|---|
| Minimal API | ✓ | CreateSlimBuilder |
| MVC · Razor · Blazor Server | ✗ | — |
| gRPC 서버 | ✓ | — |
| System.Text.Json | ✓(제너레이터 필요) | JsonSerializerContext |
| Newtonsoft.Json | ✗ | System.Text.Json 으로 이전 |
| EF Core | 부분(컴파일된 모델) | Dapper · EF Core 컴파일 모델 |
| Dapper | ✓(AOT 버전) | Dapper.AOT |
| AutoMapper · Moq | ✗ | 수동 매핑 · NSubstitute 도 제한 |
| DI(Microsoft.Extensions) | ✓ | 단, 문자열 기반 등록은 ✗ |
| Serilog | ✓ | 일부 싱크는 확인 필요 |
Assembly.LoadFrom 플러그인 | ✗ | 구조를 바꿔야 합니다 |
// 리플렉션이 꼭 필요하면 — 트리머에게 보존을 알립니다
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(PluginA))]
static void KeepTypes() { }
// 또는 제네릭 인자에 요구사항을 명시
static T Create<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicConstructors)] T>()
=> Activator.CreateInstance<T>();
// Unity IL2CPP 에서도 같은 문제 — link.xml 로 보존
// <linker><assembly fullname="MyGame">
// <type fullname="MyGame.SaveData" preserve="all"/>
// </assembly></linker>
NullReference 나 MissingMethodException 이 납니다.
해결은 항상 같습니다 — 리플렉션을 소스 제너레이터로 바꾸거나,
보존할 타입을 명시하는 것입니다.
AOT 를 안 쓰더라도 이 방식으로 짜 두면 나중에 선택지가 열립니다.
프로파일링 실전
운영 중인 프로세스에서 재시작 없이 원인을 찾는 절차입니다.
도구 순서
dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-trace
dotnet tool install -g dotnet-dump
dotnet tool install -g dotnet-gcdump
# ① 무엇이 이상한지 (실시간 지표)
dotnet-counters monitor -p <PID> --counters System.Runtime,Microsoft.AspNetCore.Hosting
# ② CPU 가 어디서 쓰이는지 (30초 샘플)
dotnet-trace collect -p <PID> --duration 00:00:30 \
--providers Microsoft-DotNETCore-SampleProfiler
# ③ 멈춰 있다면 (스레드가 무엇을 기다리는지)
dotnet-dump collect -p <PID>
dotnet-dump analyze <덤프 파일>
> clrstack -all
> syncblk # 락 경합 확인
# ④ 메모리 (타입별 크기)
dotnet-gcdump collect -p <PID>
증상 → 도구
| 증상 | 먼저 볼 것 |
|---|---|
| CPU 100% | dotnet-trace → 뜨거운 함수 |
| CPU 낮은데 느림 | dump → clrstack(대기 지점) |
| 메모리 증가 | gcdump 두 개 비교 → gcroot |
| 주기적 멈칫 | counters → GC Gen2 · LOH |
| 요청 지연만 증가 | threadpool queue length |
dotnet-dump 는 프로세스를 잠시 멈춥니다(수 초).
트래픽이 적은 시간대에 하거나, 로드밸런서에서 빼고 수행하세요.
메모리 누수를 실제로 잡는 절차
두 시점을 비교해서 범인을 찾습니다
# ① 기준점 — 서비스가 안정된 뒤
dotnet-gcdump collect -p 1 -o base.gcdump
# ② 30분~1시간 뒤 (메모리가 눈에 띄게 오른 시점)
dotnet-gcdump collect -p 1 -o later.gcdump
# ③ Visual Studio 나 PerfView 에서 두 파일을 열고 diff
# "무엇이 늘었는가"가 바로 보입니다
# gcdump 는 dump 보다 훨씬 가볍고 프로세스 정지도 짧습니다
# dotnet-dump 로 더 깊이 볼 때
> dumpheap -stat # 타입별 개수·총 바이트
Statistics:
MT Count TotalSize Class Name
7ff8.. 482,133 38,570,640 MyGame.PlayerSession ← 이상하게 많다
7ff8.. 12,004 1,920,640 System.Byte[]
> dumpheap -type MyGame.PlayerSession # 인스턴스 주소 목록
> gcroot 00007f9c12345678 # ★ 왜 살아 있는가 — 참조 경로
-> MyGame.SessionManager::_all (static field)
-> System.Collections.Generic.Dictionary
-> MyGame.PlayerSession
# 답이 나왔습니다: 정적 딕셔너리에서 안 지우고 있습니다
| 누수의 전형 | 왜 안 죽나 | 고치는 법 |
|---|---|---|
| 정적 컬렉션 | 루트에서 계속 참조 | 만료·크기 제한 · MemoryCache |
| 이벤트 핸들러 | 발행자가 구독자를 붙잡음 | -= 로 해제 · 약한 참조 |
| 타이머 | 콜백이 객체를 캡처 | Dispose · PeriodicTimer |
| 취소 토큰 등록 | 장수 토큰에 계속 등록 | 등록 반환값을 Dispose |
| 캐시 무제한 | — | SizeLimit + 만료 |
| HttpClient 매번 생성 | 소켓·핸들러 누적 | IHttpClientFactory |
| LOH 단편화 | 큰 배열이 흩어짐 | ArrayPool · 압축 모드 |
_service.DataChanged += OnChanged; 만 하고 해제하지 않으면
서비스가 살아 있는 한 창(ViewModel)도 못 죽습니다.
창을 100번 열었다 닫으면 100개가 메모리에 남습니다.
구독한 자리마다 해제할 자리를 함께 만드는 습관이 유일한 예방책입니다.
운영 중 프로파일링 — 안전하게
| 도구 | 프로세스 정지 | 운영 사용 | 얻는 것 |
|---|---|---|---|
dotnet-counters | 없음 | 항상 안전 | 실시간 지표 |
dotnet-trace | 거의 없음(오버헤드 수%) | 안전 | CPU · 이벤트 |
dotnet-gcdump | 수백 ms | 조심 | 힙 그래프 |
dotnet-dump | 수 초 | LB 에서 빼고 | 전체 상태 |
dotnet-stack | 짧음 | 조심 | 모든 스레드 스택 |
# 컨테이너·k8s 에서 — 도구가 없는 이미지라면 사이드카로
kubectl debug -it game-api-xxx --image=mcr.microsoft.com/dotnet/sdk:10.0 \
--target=api --share-processes -- bash
# 안에서 dotnet-counters 설치 후 -p 1
# 자동 덤프 — 사고 순간을 놓치지 않으려면 미리 걸어 둡니다
export DOTNET_DbgEnableMiniDump=1
export DOTNET_DbgMiniDumpType=4 # 전체 힙 포함
export DOTNET_DbgMiniDumpName=/dumps/crash.%p.dmp
# 프로세스가 죽는 순간 자동으로 덤프가 남습니다
# 수동 전체 덤프 — 메모리가 수상할 때 직접 (임계값 자동 덤프는 dotnet-monitor 트리거로)
dotnet-dump collect -p 1 --type Full \
--diag # 진단 로그도 함께
// 코드로 진단 정보를 노출 — 운영 도구에서 바로 볼 수 있게
app.MapGet("/admin/diag", () =>
{
ThreadPool.GetAvailableThreads(out var w, out var io);
ThreadPool.GetMaxThreads(out var mw, out var mio);
var gc = GC.GetGCMemoryInfo();
return Results.Ok(new
{
threadPool = new { busyWorker = mw - w, busyIo = mio - io,
pending = ThreadPool.PendingWorkItemCount },
gc = new
{
heapMb = GC.GetTotalMemory(false) / 1_048_576,
gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1),
gen2 = GC.CollectionCount(2),
pauseTimePercent = gc.PauseTimePercentage,
fragmentedMb = gc.FragmentedBytes / 1_048_576
},
uptime = Environment.TickCount64 / 1000,
version = Environment.GetEnvironmentVariable("APP_VERSION")
});
}).RequireAuthorization("admin");
DOTNET_DbgEnableMiniDump 를 지금 켜 두면
다음 크래시의 원인을 단번에 알 수 있습니다.
덤프 저장 경로는 컨테이너 밖(볼륨)으로 잡으세요 —
컨테이너와 함께 사라지면 아무 소용이 없습니다.
보안 — 역직렬화 · 공급망
내가 쓴 코드보다 가져다 쓴 패키지와 남이 보낸 데이터가 더 위험합니다.
역직렬화
BinaryFormatter 는 .NET 9 에서 제거되었고,
TypeNameHandling.All(Newtonsoft) 도 같은 위험을 만듭니다.
// ✗ 절대 금지
var obj = new BinaryFormatter().Deserialize(stream);
JsonConvert.DeserializeObject(json,
new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All });
// ✓ 받을 타입을 코드가 정한다
var dto = JsonSerializer.Deserialize<PlayerDto>(json, _opt);
// 크기·깊이 상한도 두세요
var opt = new JsonSerializerOptions { MaxDepth = 16 };
- 게임 패킷도 같은 원칙 — 패킷 id 로 서버가 타입을 정합니다
- 세이브 파일도 남이 수정할 수 있는 입력으로 취급
공급망
# 취약 패키지 점검 (CI 에 넣으세요)
dotnet list package --vulnerable --include-transitive
dotnet list package --deprecated
# 잠금 파일로 버전 고정
dotnet restore --use-lock-file
- 전이 의존성까지 확인 — 내가 안 넣은 패키지가 위험할 수 있습니다
- 이름이 비슷한 가짜 패키지 주의(타이포스쿼팅)
- 사내 미러를 두면 외부 저장소 장애·삭제에 대비됩니다
- Unity 에셋스토어도 같습니다 — 소스가 있는 것을 고르고 훑어보세요
최적화의 순서
여기까지 온 도구를 어떤 순서로 쓸 것인가. 순서를 틀리면 시간을 버립니다.
단계
마무리 · 다음
- 최적화 전에 테스트 — 빨라졌는데 틀리면 의미가 없습니다
- 바꾼 뒤 다시 측정 — 예상과 반대인 경우가 흔합니다
- 수치를 기록하세요 — 다음 사람이 되돌리지 않도록 근거를 남깁니다
- 가독성을 잃는 최적화는 주석으로 이유와 수치를 남기세요
아무것도 없는 PC 에
C# 개발 환경을
처음부터 세웁니다
C# 을 시작하는 사람이 가장 먼저 막히는 곳은 문법이 아니라 이름입니다.
“.NET 이랑 .NET Framework 랑 다른 건가”, “SDK 를 받아야 하나 Runtime 을 받아야 하나”,
“'dotnet'은(는) 내부 또는 외부 명령이 아닙니다” —
이 탭은 그 벽을 통째로 없애기 위한 것입니다.
내려받을 정확한 주소, 설치 마법사에서 체크할 워크로드, PATH 에 무엇이 들어가는지,
설치가 됐는지 확인하는 명령, 안 될 때 증상별 해결책까지 하나도 건너뛰지 않고 적었습니다.
이 탭을 마치면 빈 PC 를 받아도 30분 안에 dotnet run 이 도는 상태로 만들 수 있습니다.
.NET 이 뭔지부터 — 이름 정리가 절반입니다
파이썬은 “파이썬을 깐다”가 끝입니다. C# 은 언어(C#)와 실행 판(.NET)이 따로이고, 그 .NET 이 역사적으로 세 갈래로 갈라져 있었습니다. 검색 결과가 서로 모순돼 보이는 이유가 여기 있습니다. 15년 치 이름의 지도를 먼저 그려 두면 나머지 설치는 순식간입니다.
이름 세 개 — 한 줄로 구분하기
| 이름 | 정체 | 2026 지금 |
|---|---|---|
| .NET = .NET Core 후신 | 현재의 표준. Win · macOS · Linux 전부 | 10 (LTS) ★ 새 프로젝트는 여기 |
| .NET Framework | 2002년의 윈도우 전용 OS 에 딸려 옴 | 4.8.1 에서 정지 보안 패치만 |
| Mono | 비-MS 진영이 만든 또 다른 구현 | Unity 안에서 계속 삽니다 |
| C# | 언어(문법) 자체 | C# 14 .NET 10 과 한 짝 |
<TargetFramework>net10.0</TargetFramework> 을 적으면
C# 14 가 자동으로 선택됩니다. 반대로 Unity 는 엔진이 언어 버전을 고정하기 때문에
같은 C# 이라도 못 쓰는 최신 문법이 있습니다 — 이것이 “Unity 에서만 컴파일이 안 되는” 코드의 정체입니다.
지금 내 PC 에 뭐가 깔려 있나
# ① 가장 먼저 이 한 줄. 아무 창(PowerShell·터미널)에서나.
dotnet --info
# 나오는 것 세 덩어리를 읽는 법
# .NET SDKs installed: ← 만들 수 있는 버전 (없으면 개발 불가)
# .NET runtimes installed: ← 실행만 되는 버전
# Host: Version / RID ← 지금 기본으로 쓰는 것
# ② 짧게 버전만
dotnet --version # 10.0.xxx 처럼 나오면 SDK 있음
# ③ 목록만 따로 보기
dotnet --list-sdks
dotnet --list-runtimes
# ④ 옛 .NET Framework 가 깔려 있는지 (윈도우 · 참고용)
reg query "HKLM\SOFTWARE\Microsoft\NET Framework Setup\NDP\v4\Full" /v Release
# 아무 것도 안 나오면 → 아직 아무것도 없는 상태입니다.
# 윈도우는 st02 · st03, 맥/리눅스는 st04 로 가세요.
dotnet --info 는 이 탭에서 열 번 넘게 씁니다.
설치가 됐는지, 어느 버전이 기본인지, 왜 프로젝트가 안 열리는지 —
C# 환경 문제의 8할은 이 출력에 답이 적혀 있습니다. 지금 외워 두세요.
한 단계 더 — SDK 와 Runtime 은 다릅니다 🟡 중급
LTS 와 STS — 어느 번호를 고르나
| 버전 | 종류 | 지원 | 지금 쓰나 |
|---|---|---|---|
| .NET 10 | LTS | 3년 ~2028말 | ★ 기본값 |
| .NET 8 | LTS | 3년 2026말 종료 | 이미 있는 프로젝트만 |
| .NET 9 | STS | 18개월 종료됨 | 올리세요 |
| 홀수 번호 | STS | 18개월 | 새 기능을 일찍 볼 때 |
짝수가 LTS(3년), 홀수가 STS(18개월) 입니다. 11월에 새 버전이 나오는 것도 고정입니다. 고민되면 짝수 = 회사, 홀수 = 취미 로 외우면 실무에서 틀리지 않습니다.
여러 버전이 같이 깔려도 됩니다
# .NET 은 8 · 9 · 10 이 나란히 공존합니다. 지우지 않아도 됩니다.
dotnet --list-sdks
# 10.0.100 [C:\Program Files\dotnet\sdk]
# 9.0.304 [C:\Program Files\dotnet\sdk]
# 8.0.412 [C:\Program Files\dotnet\sdk]
# 기본은 "가장 높은 SDK" 가 선택됩니다.
# 특정 폴더에서만 버전을 고정하려면 global.json (st05 에서 자세히)
dotnet new globaljson --sdk-version 8.0.412 --roll-forward latestFeature
# 실행 대상은 프로젝트의 TargetFramework 가 정합니다.
# SDK 10 으로 net8.0 프로젝트를 빌드하는 것도 정상입니다.
<!-- 여러 버전을 동시에 지원하는 라이브러리라면 -->
<PropertyGroup>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
</PropertyGroup>
<!-- 복수형 TargetFrameworkS 입니다. s 를 빼면 빌드가 조용히 하나만 만듭니다 -->
그래서 뭘 깔면 되나 — 만들 것으로 정합니다
| 만들 것 | 필요한 것 | 비고 |
|---|---|---|
| 콘솔 · 알고리즘 연습 | .NET SDK 만 | VS Code + C# Dev Kit 이면 충분합니다 (st03) |
| 웹 API · Blazor | .NET SDK | 템플릿이 SDK 에 이미 들어 있습니다. DB 도구는 st10 |
| WPF · WinForms | .NET SDK + Windows | 윈도우에서만 빌드·실행됩니다. VS 워크로드 권장 (st09) |
| WinUI 3 데스크톱 | SDK + Windows App SDK | VS 워크로드로 받는 것이 가장 쉽습니다 |
| MAUI (모바일) | SDK + maui 워크로드 | 안드로이드는 JDK·Android SDK 도 필요 (st09) |
| Unity 게임 | Unity Hub + 에디터 | .NET SDK 는 필수가 아닙니다 — 엔진이 자기 런타임을 씁니다 |
| Godot (C#) | Godot .NET 버전 + SDK | Godot 은 실제 .NET SDK 를 요구합니다 (st09) |
| 남이 만든 앱 실행만 | Runtime | SDK 는 필요 없습니다. 용량 1/10 |
dotnet 명령이 없어도 게임이 만들어집니다.
다만 게임 서버를 붙이거나 도구를 만드는 순간 .NET SDK 가 필요해집니다 —
그때 st03 으로 돌아오시면 됩니다.
윈도우 ① — Visual Studio 2026 설치
윈도우에서 C# 을 한다면 가장 편한 길입니다. 컴파일러 · 디버거 · 디자이너 · 프로파일러가 한 번에 들어오고, Community 판은 무료입니다. 화면에 뭐가 뜨는지, 어느 체크박스를 눌러야 하는지 하나씩 따라가겠습니다.
① 어디서 받나 — 정확한 경로
https://visualstudio.microsoft.com/ko/vs/
▸ Visual Studio 2026 Community ← ★ 무료. 이것을 받습니다
개인 개발자 · 학생 · 오픈소스 기여자
회사는 "직원 250명 미만 & 연매출 100만 달러 미만" 일 때만 무료
▸ Professional / Enterprise ← 유료 (프로파일러 · 라이브 공유 등)
▸ 페이지 아래 "Tools for Visual Studio"
· Build Tools for Visual Studio ← 편집기 없이 컴파일러만 (CI 서버용)
파일명: VisualStudioSetup.exe
── 명령 한 줄로 받고 싶다면 (윈도우 11 · 10 최신) ──
winget install --id Microsoft.VisualStudio.2026.Community -e
③ 체크할 워크로드 — 만들 것 기준
| 워크로드 | 언제 체크 | 대략 |
|---|---|---|
| .NET 데스크톱 개발 ★ | 콘솔 · WPF · WinForms 사실상 기본값 | ~8GB |
| ASP.NET 및 웹 개발 ★ | 웹 API · Blazor · MVC | ~12GB |
| Unity 를 사용한 게임 개발 | Unity 를 쓸 때 디버거 연결 도구 | ~3GB |
| .NET MAUI | 안드로이드 · iOS 앱 | ~15GB |
| 데이터 저장 및 처리 | SQL Server 도구 (st10) | ~2GB |
| Azure 개발 | 클라우드 배포 | ~5GB |
| C++ 를 사용한 데스크톱 | C# 만 할 거면 불필요 | ~8GB |
④ 용량과 설치 위치 — 실제 수치
[설치 위치] 탭에서 세 경로를 각각 지정합니다
Visual Studio IDE C:\Program Files\Microsoft Visual Studio\2026\Community
→ D: 로 옮길 수 있습니다
다운로드 캐시 C:\ProgramData\Microsoft\VisualStudio\Packages
→ 설치 후 삭제 가능. "설치 후 캐시 유지" 체크를 끄세요
공유 구성 요소·SDK C:\Program Files (x86)\Microsoft SDKs 등
→ ★ 여기는 옮길 수 없습니다 (10~15GB 는 C 에 남음)
권장 여유 공간
최소 설치(.NET 데스크톱만) 약 12GB
실무 기본(데스크톱+웹) 약 25~30GB
MAUI·Unity·C++ 까지 60GB 이상
.NET SDK 는 워크로드에 포함되어 함께 깔립니다 → C:\Program Files\dotnet
⑤ 첫 실행 — 로그인과 확인
1) "로그인" 창
Microsoft 계정으로 로그인 → Community 라이선스가 활성화됩니다
건너뛸 수 있지만 30일 뒤 "평가판이 만료되었습니다" 가 뜹니다
→ 그때 [계정으로 잠금 해제] 를 눌러 로그인하면 그대로 계속 쓸 수 있습니다
2) "개발 설정" 선택
Visual C# → 단축키가 C# 기준으로 잡힙니다 (나중에 변경 가능)
3) 색 테마
Dark 가 기본. 나중에 도구 > 옵션 > 환경 > 일반 에서 바꿉니다
4) 시작 창 → [새 프로젝트 만들기] → "콘솔 앱" 검색
→ C# / 모든 플랫폼 / 콘솔 을 고르고 다음
→ 프레임워크: .NET 10.0 (장기 지원) ← 이게 보이면 설치 성공입니다
# IDE 없이 확인하고 싶다면 — 새 PowerShell 창을 열고
dotnet --info
dotnet new console -o hello ; cd hello ; dotnet run
# Hello, World! 가 찍히면 전부 정상입니다
한 단계 더 — 설치 관리자를 명령으로 다루기 🟡 중급
워크로드 추가 · 제거 · 업데이트
# GUI: 시작 메뉴 → "Visual Studio Installer" → [수정]
# 워크로드 체크를 켜고 끄면 그 차이만 받아 옵니다 (전체 재설치 아님)
# 명령으로 (사내 PC 를 여러 대 세팅할 때 · 무인 설치)
$vsi = "C:\Program Files (x86)\Microsoft Visual Studio\Installer\vs_installer.exe"
& $vsi modify `
--installPath "C:\Program Files\Microsoft Visual Studio\2026\Community" `
--add Microsoft.VisualStudio.Workload.ManagedDesktop `
--add Microsoft.VisualStudio.Workload.NetWeb `
--quiet --norestart
# 지금 깔린 것 확인 (vswhere — CI 스크립트에서 자주 씁니다)
& "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" `
-latest -products * -format json
VS 가 들고 오는 것과 안 들고 오는 것
| 항목 | VS 설치에 포함? |
|---|---|
| .NET SDK (최신 LTS) | 포함 ✔ |
| MSBuild · Roslyn 컴파일러 | 포함 ✔ |
| NuGet 클라이언트 | 포함 ✔ |
| Git for Windows | 포함 ✔ (옵션) |
| SQL Server LocalDB | “데이터 저장” 워크로드에 |
| Windows App SDK (WinUI 3) | 개별 구성 요소 |
| Android SDK · JDK | MAUI 워크로드에 |
| Unity 에디터 | 포함 안 됨 — Hub 로 따로 |
| Docker · PostgreSQL | 따로 (st10) |
윈도우 ② — .NET SDK 단독 + VS Code
Visual Studio 는 무겁습니다. 콘솔 · 웹 API · 라이브러리만 만들 거라면 SDK 900MB + VS Code 100MB 로 충분하고, 이쪽이 실무 백엔드 개발자들이 실제로 더 많이 쓰는 조합입니다. 맥·리눅스와 완전히 같은 방식이라는 것도 큰 장점입니다.
① SDK 받기 — 두 가지 방법
[방법 A] 웹에서 (가장 확실)
https://dotnet.microsoft.com/ko-kr/download
▸ .NET 10.0 (장기 지원)
· SDK 10.0.1xx ← ★ 개발자는 이것
Windows x64 installer (인텔·AMD PC 대부분)
Windows Arm64 installer (Snapdragon 노트북)
· Runtime ← 실행만 할 때
· ASP.NET Core Runtime ← 웹 서버에
파일명: dotnet-sdk-10.0.1xx-win-x64.exe (약 250MB · 설치 후 약 900MB)
설치는 [다음] 만 누르면 끝납니다. 경로 선택 화면조차 없습니다.
# [방법 B] winget — 명령 한 줄 (윈도우 11 · 윈도우 10 최신 빌드)
winget install Microsoft.DotNet.SDK.10
# 뭐가 있는지 검색
winget search Microsoft.DotNet.SDK
# 나중에 업데이트
winget upgrade Microsoft.DotNet.SDK.10
# ★ 설치 후에는 반드시 '새 터미널 창' 을 여세요.
# 열려 있던 창은 옛 PATH 를 들고 있어서 dotnet 을 못 찾습니다 (st12 1번 증상)
BadImageFormatException 이 납니다.
설정 → 시스템 → 정보 → 시스템 종류 를 먼저 확인하세요.
② 설치 확인 — 이 세 줄
# 새 PowerShell 창에서
dotnet --info
# 나오는 출력의 핵심 부분
# .NET SDK:
# Version: 10.0.100
# Commit: ...
# Runtime Environment:
# OS Name: Windows OS Version: 10.0.19045
# RID: win-x64 ← 네이티브 패키지가 이 값으로 골라집니다
# Base Path: C:\Program Files\dotnet\sdk\10.0.100\
# 어디에 있는 dotnet 이 실행되고 있나 (★ PATH 문제 진단의 핵심)
where.exe dotnet
# C:\Program Files\dotnet\dotnet.exe
# 진짜로 도는지 5초 확인
dotnet new console -o C:\temp\hello
dotnet run --project C:\temp\hello
# Hello, World!
where.exe dotnet 이 두 줄 이상 나오면 주의하세요.
예전에 x86 SDK 를 깔았거나 Unity·Rider 가 자기 dotnet 을 PATH 에 넣은 경우입니다.
맨 위에 나오는 것이 실제로 실행됩니다 — 순서가 곧 우선순위입니다.
③ PATH 를 직접 확인하고 고치기
# 지금 PATH 를 줄 단위로 보기 (PowerShell)
$env:PATH -split ';' | Select-String dotnet
# GUI 로 여는 경로
# Win + R → sysdm.cpl → [고급] 탭 → [환경 변수]
# · 사용자 변수 Path ← 나만 쓸 것
# · 시스템 변수 Path ← 모든 사용자 (관리자 필요)
# 여기에 아래 두 줄이 있으면 정상입니다
# C:\Program Files\dotnet\
# %USERPROFILE%\.dotnet\tools
# 명령으로 사용자 PATH 에 영구 추가 (되돌리기 쉬운 쪽)
$p = [Environment]::GetEnvironmentVariable('Path','User')
[Environment]::SetEnvironmentVariable('Path', "$p;$env:USERPROFILE\.dotnet\tools", 'User')
# 지금 창에서만 임시로
$env:PATH += ";C:\Program Files\dotnet"
setx PATH "C:\dotnet" 처럼 쓰면
기존 값이 전부 날아가고 윈도우가 이상해집니다(게다가 setx 는 1024자에서 잘립니다).
위처럼 읽어서 뒤에 이어 붙이는 방식이나 GUI 편집기를 쓰세요.
④ VS Code + C# Dev Kit
# VS Code 설치
winget install Microsoft.VisualStudioCode
# 또는 https://code.visualstudio.com
# 확장 세 개 — 명령으로 한 번에
code --install-extension ms-dotnettools.csdevkit
code --install-extension ms-dotnettools.csharp
code --install-extension ms-dotnettools.vscode-dotnet-runtime
# 있으면 편한 것
code --install-extension ms-dotnettools.dotnet-maui # MAUI
code --install-extension ms-azuretools.vscode-docker # 도커
code --install-extension humao.rest-client # .http 파일로 API 테스트
# 확인 — 폴더를 열고
dotnet new console -o hellovs ; code hellovs
# Program.cs 를 열면 우측 하단에 "C# 프로젝트 로드 중" 이 뜨고
# F5 를 누르면 디버거가 붙습니다 (launch.json 은 자동 생성)
한 단계 더 — 관리자 권한 없이 · 여러 버전 골라 쓰기 🟡 중급
설치 스크립트로 사용자 폴더에만
# 회사 PC 라 관리자 권한이 없다? 공식 스크립트가 있습니다.
# 관리자 없이 %USERPROFILE%\.dotnet 에 깔립니다.
# 스크립트 받기
Invoke-WebRequest https://dot.net/v1/dotnet-install.ps1 -OutFile dotnet-install.ps1
# 최신 LTS 채널로
./dotnet-install.ps1 -Channel LTS
# 특정 버전만 콕 집어
./dotnet-install.ps1 -Version 10.0.100
# 런타임만 (서버용)
./dotnet-install.ps1 -Channel LTS -Runtime aspnetcore
# 설치 후 PATH 를 직접 넣어야 합니다 (스크립트는 안 건드립니다)
$env:PATH = "$env:USERPROFILE\.dotnet;$env:PATH"
$env:DOTNET_ROOT = "$env:USERPROFILE\.dotnet"
dotnet-install.sh 로 똑같이 동작합니다.
DOTNET_ROOT 는 표준 경로가 아닌 곳에 깔았을 때
호스트가 런타임을 찾도록 알려 주는 변수입니다 — 안 넣으면 실행 시점에 헤맵니다.
정리·제거와 재설치
# 뭐가 깔려 있는지 (제거 전 확인)
dotnet --list-sdks
dotnet --list-runtimes
# 제거 — 설정 > 앱 > 설치된 앱 에서
# "Microsoft .NET SDK 10.0.100 (x64)" 처럼 버전마다 항목이 따로 있습니다
# Visual Studio 가 깐 SDK 는 VS Installer 로 지워야 합니다
# 손상됐을 때: NuGet·빌드 캐시부터 지워 보세요 (재설치보다 빠릅니다)
dotnet nuget locals all --clear
Remove-Item -Recurse -Force .\bin, .\obj
dotnet build-server shutdown # 백그라운드 빌드 서버 종료
# 그래도 이상하면 공식 정리 도구
# dotnet-core-uninstall (MS 배포) → 옛 버전만 골라 지웁니다
dotnet --info 와 where.exe dotnet 두 줄로 확인하세요.
전역 도구를 쓸 거면 %USERPROFILE%\.dotnet\tools 가 PATH 에 있는지도 함께 봅니다.macOS · Linux — 여기서도 똑같이 됩니다
“C# 은 윈도우 언어”라는 말은 2019년에 끝났습니다. 맥북에서 웹 API 를 만들고 리눅스 서버에 그대로 올리는 것이 지금 실무의 기본형입니다. 다만 딱 하나, 데스크톱 GUI 만은 사정이 다릅니다 — 그 경계선을 정확히 그어 두겠습니다.
macOS — 두 가지 방법
# [방법 A] 공식 .pkg (가장 확실)
# https://dotnet.microsoft.com/ko-kr/download 에서
# macOS Arm64 installer ← M1 · M2 · M3 · M4 (애플 실리콘)
# macOS x64 installer ← 인텔 맥
# 받은 .pkg 를 더블클릭 → 다음만 누르면 끝
# 설치 경로: /usr/local/share/dotnet
# 심볼릭 링크: /usr/local/bin/dotnet ← PATH 에 이미 있는 폴더
# [방법 B] Homebrew
brew install --cask dotnet-sdk # 최신 SDK
# 여러 버전을 오가야 하면 A 방법이 더 편합니다
# 확인
dotnet --info
which -a dotnet # /usr/local/bin/dotnet 이 나와야 정상
# PATH 가 안 잡혔다면 (zsh 기준)
echo 'export PATH=$PATH:/usr/local/share/dotnet' >> ~/.zshrc
echo 'export DOTNET_ROOT=/usr/local/share/dotnet' >> ~/.zshrc
source ~/.zshrc
osx-arm64 네이티브 패키지와 충돌합니다.
Arm64 를 받으세요. 이미 둘 다 깔렸다면 /usr/local/share/dotnet/x64 폴더가 따로 생기며,
dotnet --info 의 RID 값으로 지금 무엇이 쓰이는지 확인할 수 있습니다.
Ubuntu · Debian
# [Ubuntu 24.04 이상] 우분투 기본 저장소에 이미 있습니다
sudo apt update
sudo apt install -y dotnet-sdk-10.0
# [그 외 · 최신 패치를 빨리 받고 싶을 때] 마이크로소프트 저장소 등록
wget https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb \
-O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
sudo apt update
sudo apt install -y dotnet-sdk-10.0
# 런타임만 필요한 서버라면
sudo apt install -y aspnetcore-runtime-10.0
# 확인
dotnet --info
which -a dotnet # /usr/bin/dotnet
dotnet --list-sdks 가 이상해지거나 업데이트가 깨집니다.
한쪽만 쓰세요. 섞였다면 sudo apt remove 'dotnet*' 'aspnetcore*' 로 정리 후 한쪽으로 다시 깝니다.
그 밖의 배포판 · 컨테이너
# Fedora / RHEL
sudo dnf install dotnet-sdk-10.0
# Arch
sudo pacman -S dotnet-sdk aspnet-runtime
# Alpine · 특이 환경 · 관리자 권한 없음 → 공식 스크립트
curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh --channel LTS # ~/.dotnet 에 설치
export PATH="$HOME/.dotnet:$PATH"
export DOTNET_ROOT="$HOME/.dotnet"
# 아예 안 깔고 도커로만 (실무에서 흔합니다)
docker run --rm -it -v "$PWD":/src -w /src \
mcr.microsoft.com/dotnet/sdk:10.0 dotnet build
# 리눅스 서버에 서비스로 올릴 때 — systemd 유닛
# /etc/systemd/system/myapi.service
[Unit]
Description=My ASP.NET Core API
[Service]
WorkingDirectory=/var/www/myapi
ExecStart=/usr/bin/dotnet /var/www/myapi/MyApi.dll
Restart=always
RestartSec=5
User=www-data
Environment=ASPNETCORE_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://localhost:5000
[Install]
WantedBy=multi-user.target
--self-contained 로 런타임까지 통째로 담아 배포합니다 —
그러면 서버에 .NET 이 아예 없어도 됩니다.
편집기 — 맥·리눅스의 선택지
| 도구 | 비용 | 강점 |
|---|---|---|
| Rider ★ | 비상업 무료 회사는 유료 | 맥·리눅스에서 가장 완성도 높음 Unity 지원 최고 |
| VS Code + C# Dev Kit | 무료 | 가볍고 원격 개발 도커·SSH 편집 |
| Neovim + OmniSharp/Roslyn LSP | 무료 | 터미널만으로 완결 |
| Visual Studio for Mac | 2024년 지원 종료 | 쓰지 마세요 |
Visual Studio for Mac 은 2024년 8월에 완전히 끝났습니다. 아직도 이걸 안내하는 글이 검색에 많이 나오는데, 지금 맥에서의 정답은 Rider 또는 VS Code 입니다.
dotnet new install Avalonia.Templates 후 dotnet new avalonia.app 로 바로 시작합니다.
WPF 를 리눅스에서 돌리는 방법은 없습니다 — Windows 전용 API 에 직접 묶여 있기 때문입니다.
brew install --cask dotnet-sdk, 우분투는 apt install dotnet-sdk-10.0.
다른 건 윈도우 전용 GUI(WPF · WinForms · WinUI) 뿐이고, 크로스 플랫폼 GUI 가 필요하면 Avalonia 입니다.dotnet CLI 기본기 — IDE 없이도 전부 됩니다
dotnet 명령 하나가 프로젝트 생성 · 빌드 · 실행 · 테스트 · 배포 · 패키지 관리를 전부 합니다.
Visual Studio 의 버튼들도 결국 이 명령을 부릅니다.
CLI 를 알면 IDE 가 왜 그렇게 동작하는지도 알게 됩니다 — 그래서 여기가 이 탭의 중심입니다.
.csproj)가 실제 설정입니다 — 이 구분이 빌드 문제 해결의 출발점입니다.① 만들기 — dotnet new
# 어떤 템플릿이 있나 (설치된 것 전부)
dotnet new list
# 자주 쓰는 것
dotnet new console -o src/MyShop.Cli # 콘솔 앱
dotnet new classlib -o src/MyShop.Core # 클래스 라이브러리
dotnet new webapi -o src/MyShop.Api # 웹 API (최소 API)
dotnet new mvc -o src/MyShop.Web # MVC 웹앱
dotnet new blazor -o src/MyShop.Ui # Blazor
dotnet new xunit -o tests/MyShop.Tests # 테스트
dotnet new worker -o src/MyShop.Job # 백그라운드 서비스
dotnet new wpf -o src/MyShop.Desktop # WPF (윈도우 전용)
# 부가 파일 템플릿 — 의외로 많이 씁니다
dotnet new gitignore # .NET 용 .gitignore
dotnet new editorconfig # 기본 .editorconfig
dotnet new globaljson --sdk-version 10.0.100
dotnet new nugetconfig # NuGet.Config
# 옵션 보기 (템플릿마다 다릅니다)
dotnet new webapi --help
dotnet new console -f net8.0 -o old # 대상 프레임워크 지정
-o 를 안 주면 현재 폴더에 만들고 폴더 이름을 프로젝트 이름으로 씁니다.
그래서 mkdir MyShop && cd MyShop && dotnet new console 이
가장 흔한 시작 방식입니다. 이름에 공백·한글은 피하세요 (st12).
② 묶기 — 솔루션과 참조
# 솔루션 만들기 (.slnx — 새 XML 형식, .NET 9+ · VS 2022 17.13+ 지원)
dotnet new sln -n MyShop --format slnx
# 옛 형식이 필요하면: dotnet new sln -n MyShop (MyShop.sln)
# 프로젝트를 솔루션에 등록
dotnet sln add src/MyShop.Api/MyShop.Api.csproj
dotnet sln add src/MyShop.Core/MyShop.Core.csproj
dotnet sln add tests/MyShop.Tests/MyShop.Tests.csproj
# 한 번에 (glob)
dotnet sln add (ls -r src/*.csproj) # PowerShell
dotnet sln add $(find . -name "*.csproj") # bash
# 등록된 것 보기 · 빼기
dotnet sln list
dotnet sln remove src/MyShop.Old/MyShop.Old.csproj
# 프로젝트끼리 참조 — Api 가 Core 를 쓴다
dotnet add src/MyShop.Api reference src/MyShop.Core
dotnet add tests/MyShop.Tests reference src/MyShop.Core
# 참조 목록 확인
dotnet list src/MyShop.Api reference
dotnet build 한 번으로 전부 빌드하고,
IDE 가 한 창에 열어 주기 위해 필요해집니다.
.slnx 는 옛 .sln 의 난해한 GUID 덩어리를 걷어낸 새 형식으로,
깃 충돌이 훨씬 덜 납니다.
③ 돌리기 — 매일 치는 명령
# 복원 → 빌드 → 실행. build/run 이 복원을 자동으로 합니다
dotnet restore # 보통 따로 칠 일이 없습니다
dotnet build # Debug 로 빌드
dotnet build -c Release # Release
dotnet run --project src/MyShop.Api # 빌드 + 실행
# 코드가 바뀌면 자동 재시작 (웹 개발 필수)
dotnet watch run --project src/MyShop.Api
# 테스트
dotnet test # 솔루션 전체
dotnet test --filter "FullyQualifiedName~Cart" # 이름으로 골라
dotnet test --collect:"XPlat Code Coverage" # 커버리지
# 인자 넘기기 — -- 뒤는 프로그램에게 갑니다
dotnet run --project src/MyShop.Cli -- --input data.csv --verbose
# 청소
dotnet clean
dotnet build-server shutdown # 백그라운드 컴파일러 종료
dotnet run 은 개발용입니다. 매번 빌드 검사를 하고 SDK 를 요구합니다.
서버에서는 절대 dotnet run 으로 서비스를 띄우지 마세요 —
dotnet publish 결과의 dotnet MyApi.dll 을 실행합니다.
④ 내보내기 — dotnet publish
# 기본 — 런타임이 서버에 있다는 전제 (가장 가볍습니다)
dotnet publish -c Release -o out
# out/MyApi.dll · MyApi.exe · 의존 dll · appsettings.json
# 런타임까지 통째로 담기 (서버에 .NET 이 없어도 됨)
dotnet publish -c Release -r linux-x64 --self-contained true -o out
# 약 70MB. 안 쓰는 코드를 잘라내려면
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishTrimmed=true -o out
# 단일 실행 파일 하나로
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true -o out
# 네이티브 AOT — 시작이 수 ms, 메모리 최소 (제약 있음)
dotnet publish -c Release -r linux-x64 -p:PublishAot=true -o out
# RID 값: win-x64 · win-arm64 · linux-x64 · linux-musl-x64 · osx-arm64
linux-x64 가 아니라
linux-musl-x64 여야 합니다 — C 라이브러리가 다르기 때문이고,
이걸 틀리면 컨테이너가 실행 즉시 죽습니다.
한 단계 더 — global.json 으로 SDK 를 고정합니다 🟡 중급
왜 필요한가
SDK 는 기본적으로 PC 에 깔린 가장 높은 버전이 선택됩니다.
그래서 내 PC 에서는 되는데 동료 PC 나 CI 에서 빌드 결과가 미묘하게 다른 일이 생깁니다.
global.json 은 이 폴더 아래에서는 이 SDK 를 써라 라고 못을 박습니다.
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature",
"allowPrerelease": false
}
}
# 만들기
dotnet new globaljson --sdk-version 10.0.100
# 지금 어느 SDK 가 선택됐는지 (global.json 반영된 결과)
dotnet --version
# rollForward 값이 하는 일
# disable 정확히 그 버전만. 없으면 에러
# patch 10.0.100 → 10.0.1xx 중 가장 높은 패치 허용
# latestFeature 10.0.1xx → 10.0.4xx 까지 허용 ★ 실무 기본
# latestMajor 11.x 까지도 허용 (사실상 고정 해제)
rollForward: "disable" 은 함정입니다.
팀원이 SDK 를 업데이트하는 순간 NETSDK1141 로 빌드가 막힙니다.
latestFeature 가 “버전은 고정하되 패치는 따라간다”는 실무의 균형점입니다.
증상별 대처는 st12 에 있습니다.
.csproj 를 읽는 법
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<InvariantGlobalization>true</InvariantGlobalization>
<RootNamespace>MyShop.Api</RootNamespace>
</PropertyGroup>
<!-- NuGet 패키지 -->
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
</ItemGroup>
<!-- 다른 프로젝트 참조 -->
<ItemGroup>
<ProjectReference Include="..\MyShop.Core\MyShop.Core.csproj" />
</ItemGroup>
<!-- 파일을 출력 폴더로 복사 -->
<ItemGroup>
<None Update="config\rules.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
.cs 를 전부 자동 포함합니다.
옛 .NET Framework 프로젝트 파일에는 <Compile Include="..." /> 가
수백 줄 있었는데, 그게 사라진 것이 지금 형식의 가장 큰 변화입니다.
| 하고 싶은 것 | 명령 | 메모 |
|---|---|---|
| 프로젝트 만들기 | dotnet new console -o 이름 | 템플릿 목록은 dotnet new list |
| 패키지 추가 | dotnet add package 이름 | 버전 지정 -v 9.0.0 (st06) |
| 프로젝트 참조 | dotnet add reference ... | 상대 경로로 적습니다 |
| 빌드 | dotnet build -c Release | 기본은 Debug |
| 실행 | dotnet run --project 경로 | 인자는 -- 뒤에 |
| 자동 재시작 | dotnet watch run | 웹 개발 필수 |
| 테스트 | dotnet test | --filter 로 골라 실행 |
| 배포 산출물 | dotnet publish -c Release -o out | RID·self-contained 옵션 |
| 서식 맞추기 | dotnet format | .editorconfig 기준 (st11) |
| 전역 도구 설치 | dotnet tool install -g 이름 | PATH 확인 필요 (st03) |
| 캐시 비우기 | dotnet nuget locals all --clear | 복원이 이상할 때 (st06) |
dotnet new 로 만들고, dotnet sln add 로 묶고,
dotnet run·dotnet test 로 돌리고, dotnet publish 로 내보냅니다.
솔루션은 목록, .csproj 가 실제 설정이며, 팀·CI 와 버전을 맞추려면 global.json 을 둡니다.NuGet — 패키지가 오는 길을 알아야 안 막힙니다
파이썬의 pip, Node 의 npm 에 해당하는 것이 NuGet 입니다.
평소에는 dotnet add package 한 줄이면 끝나지만, 회사 네트워크에 들어가는 순간
복원이 막히고 에러 코드(NU1101)가 쏟아집니다.
패키지가 어디서 어떻게 오는지를 그림 한 장으로 잡아 두면 그때 5분이면 해결됩니다.
NU 로 시작하면 네트워크·피드부터 봅니다.① 매일 쓰는 명령
# 추가 — 버전을 안 적으면 최신 안정판
dotnet add package Serilog.AspNetCore
dotnet add package Newtonsoft.Json --version 13.0.3
dotnet add src/MyShop.Api package Dapper # 프로젝트 지정
# 프리릴리즈까지 허용
dotnet add package Some.Lib --prerelease
# 목록 보기
dotnet list package # 이 프로젝트가 쓰는 것
dotnet list package --include-transitive # 딸려 온 것까지 전부
dotnet list package --outdated # 새 버전이 나온 것 ★
dotnet list package --vulnerable # 알려진 취약점이 있는 것 ★★
# 제거
dotnet remove package Newtonsoft.Json
# 복원 · 캐시
dotnet restore
dotnet restore --force --no-cache # 강제로 다시
dotnet nuget locals all --list # 캐시 위치 확인
dotnet nuget locals all --clear # 캐시 통째로 비우기
--vulnerable 은 분기에 한 번씩 꼭 돌려 보세요.
직접 넣은 패키지는 멀쩡한데 그 패키지가 끌고 온 하위 의존성에 취약점이 있는 경우가 대부분이라
--include-transitive 를 함께 붙입니다.
CI 에 넣어 두면(st11) 사람이 기억할 필요가 없어집니다.
② nuget.org 에서 고르는 법
검색은 nuget.org 에서 합니다. 이름이 비슷한 가짜·방치된 패키지가 많아 고르는 눈이 필요합니다. 페이지에서 이 다섯 가지만 보면 거의 틀리지 않습니다.
| 볼 것 | 기준 |
|---|---|
| 총 다운로드 | 수백만이면 사실상 표준 |
| 마지막 배포일 | 2년 이상 멈췄으면 다시 생각 |
| Frameworks 목록 | net10.0 또는 netstandard2.0 이 있어야 |
| Source repository | 깃허브 링크가 있고 이슈에 답이 달리는지 |
| 소유자 배지 | Microsoft · dotnetfoundation 이면 안심 |
Newtonsoft.Json 과 Newtonsoft.Json.Net 같은 식입니다.
검색 결과의 첫 줄을 반사적으로 누르지 말고 다운로드 수와 소유자를 한 번 보세요.
회사라면 아예 사내 피드만 허용하고 승인된 패키지만 통과시킵니다.
사내 네트워크 — NuGet.Config 와 프록시 🟡 중급
설정 파일은 위로 올라가며 합쳐집니다
NuGet 은 아래에서 위로 올라가며 설정을 겹칩니다 (아래쪽이 이깁니다)
C:\Users\나\AppData\Roaming\NuGet\NuGet.Config ← 사용자 전역
D:\work\NuGet.Config ← 상위 폴더
D:\work\MyShop\NuGet.Config ← 저장소 루트 ★ 여기에 둡니다
D:\work\MyShop\src\NuGet.Config ← 프로젝트별(드묾)
지금 어떤 설정이 적용됐는지 확인
dotnet nuget list source
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!-- ★ 부모 설정을 지우고 여기 적은 것만 씁니다 -->
<packageSources>
<clear />
<add key="company" value="https://nexus.mycorp.com/repository/nuget/index.json" />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
<!-- 사내 피드 인증 -->
<packageSourceCredentials>
<company>
<add key="Username" value="%NUGET_USER%" />
<add key="ClearTextPassword" value="%NUGET_TOKEN%" />
</company>
</packageSourceCredentials>
<!-- 회사 프록시를 타야 한다면 -->
<config>
<add key="http_proxy" value="http://proxy.mycorp.com:8080" />
<add key="globalPackagesFolder" value="D:\nuget-cache" />
</config>
</configuration>
%환경변수% 를 쓰거나, 명령으로 등록해
사용자 전역 설정에만 저장하세요 —
저장소에 커밋되는 NuGet.Config 에는 주소만 둡니다.
피드를 명령으로 관리하기
# 등록된 피드 보기
dotnet nuget list source
# 사내 피드 추가 (자격 증명은 사용자 전역에 저장)
dotnet nuget add source https://nexus.mycorp.com/repository/nuget/index.json \
--name company --username myid --password $env:NUGET_TOKEN \
--store-password-in-clear-text
# 잠시 끄기 · 켜기 · 지우기
dotnet nuget disable source nuget.org
dotnet nuget enable source nuget.org
dotnet nuget remove source company
# 특정 피드만 써서 복원 (진단할 때 유용)
dotnet restore --source https://api.nuget.org/v3/index.json
# 오프라인 · 폐쇄망 — 폴더를 피드처럼 씁니다
dotnet nuget add source D:\offline-packages --name local
# .nupkg 파일들을 그 폴더에 넣어 두면 그대로 복원됩니다
# 내가 만든 라이브러리를 올릴 때
dotnet pack -c Release
dotnet nuget push bin/Release/MyLib.1.0.0.nupkg \
--source https://api.nuget.org/v3/index.json --api-key $env:NUGET_KEY
dotnet restore --interactive 를 한 번 돌려 보세요.
인증이 필요하면 브라우저 로그인 창을 띄워 줍니다.
그래도 NU1301(피드에 접근 불가)이 나면 네트워크 차단이고,
NU1101(패키지를 못 찾음)이면 피드 목록 문제입니다.
실무 표준 — 중앙 패키지 관리(CPM) 🔴 실무
버전을 한 파일에 모읍니다
프로젝트가 열 개면 Serilog 버전이 열 군데 흩어집니다.
하나만 안 올려 두면 런타임에 어셈블리 버전 충돌이 나고, 원인 찾기가 아주 고약합니다.
중앙 패키지 관리는 버전을 저장소 루트 한 파일로 모읍니다.
<!-- 저장소 루트 · Directory.Packages.props -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Dapper" Version="2.1.66" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>
<!-- 각 프로젝트의 .csproj — 버전을 적지 않습니다 -->
<ItemGroup>
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Dapper" />
</ItemGroup>
.csproj 에 흩어진 변경보다 리뷰가 훨씬 쉽습니다.
버전을 재현 가능하게 못 박기
<!-- 버전 표기법 -->
<PackageReference Include="Dapper" Version="2.1.66" />
<!-- ↑ "2.1.66 이상 중 가장 낮은 것" 이라는 뜻입니다. 정확히 이 버전이 아닙니다 -->
<PackageReference Include="Dapper" Version="[2.1.66]" />
<!-- ↑ 대괄호 = 정확히 이 버전만 -->
<PackageReference Include="Dapper" Version="[2.1.0,3.0.0)" />
<!-- ↑ 2.1.0 이상 3.0.0 미만 -->
# 잠금 파일 — npm 의 package-lock.json 에 해당합니다
# csproj 에 이 한 줄을 넣으면 packages.lock.json 이 생깁니다
# <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
# CI 에서는 잠금 파일과 다르면 실패하게 만듭니다 (★ 재현성)
dotnet restore --locked-mode
# 잠금 파일을 갱신할 때만
dotnet restore --force-evaluate
Version="2.1.66" 은 상한이 없어서, 어느 날 새 버전이 나오면
아무도 아무것도 안 바꿨는데 빌드가 달라집니다.
잠금 파일 + --locked-mode 조합이 이걸 완전히 막아 줍니다.
project.assets.json 순서로 흐릅니다.
회사에서는 NuGet.Config 를 저장소 루트에 두되 비밀번호는 환경 변수로,
프로젝트가 여러 개면 Directory.Packages.props 로 버전을 한곳에 모읍니다.Visual Studio — 처음 30분에 해 둘 설정
Visual Studio 는 기본 설정 그대로도 돌아가지만, 딱 열 가지만 손보면 체감 생산성이 달라집니다. 테마와 글꼴, 저장할 때 서식 자동 정리, 그리고 디버거 — 특히 디버거는 “print 로 찍어 보는 습관”을 버리게 만드는 이 IDE 의 최대 장점입니다.
① 처음에 만지는 옵션 (도구 > 옵션)
| 경로 | 바꿀 것 |
|---|---|
| 환경 > 일반 | 색 테마 · 제목 표시줄에 솔루션 이름 |
| 환경 > 글꼴 및 색 | Cascadia Code 11~12pt 연결 글꼴(리거처) 지원 |
| 텍스트 편집기 > C# > 고급 | 저장 시 서식 지정 체크 ★ 사용하지 않는 using 제거 |
| 텍스트 편집기 > 모든 언어 | 줄 번호 표시 · 세로 안내선 |
| 프로젝트 및 솔루션 > 빌드 | 병렬 빌드 수 = CPU 코어 수 |
| 디버깅 > 일반 | 내 코드만 사용 체크 유지 남의 코드에서 멈추지 않게 |
| 디버깅 > 기호 | Microsoft 기호 서버 끄기 켜면 첫 디버깅이 매우 느립니다 |
| 소스 제어 | Git 선택 · 자동 인출 간격 |
.vssettings 로 저장해 두면
PC 를 옮기거나 재설치했을 때 30초면 원래대로 돌아옵니다.
계정 로그인 시 자동 동기화도 되지만 회사 계정에서는 막힌 경우가 많습니다.
② .editorconfig — 팀 전체의 서식 기준
# 저장소 루트 · .editorconfig (dotnet new editorconfig 로 생성)
root = true
[*.cs]
indent_style = space
indent_size = 4
end_of_line = crlf
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
# using 정렬 — System 을 맨 위로
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
# var 사용 정책 (팀에서 가장 자주 싸우는 항목)
csharp_style_var_for_built_in_types = false:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
# 최신 문법 권장
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
csharp_prefer_braces = true:warning
csharp_style_namespace_declarations = file_scoped:warning
# 규칙 심각도 — none / silent / suggestion / warning / error
dotnet_diagnostic.CA1822.severity = suggestion # static 으로 만들 수 있음
dotnet_diagnostic.IDE0005.severity = warning # 안 쓰는 using
[*.{xml,csproj,props,targets}]
indent_size = 2
.editorconfig 는 IDE 를 가리지 않습니다.
Visual Studio · Rider · VS Code 가 같은 파일을 읽습니다.
그래서 “누가 저장하면 파일 전체가 diff 로 뜨는” 팀의 고질병이 이 파일 하나로 사라집니다.
규칙을 강제하는 방법은 st11 에서 이어집니다.
③ 디버거 — 여기가 진짜 값어치입니다
// 조건부 중단점 — 중단점 우클릭 > 조건
// i == 9999 : 이 조건일 때만 멈춤 (1만 번 루프에서 필수)
// name == "홍길동" : 특정 데이터에서만
// 추적점(TracePoint) — 중단점 우클릭 > 작업
// "값: {order.Id} / {order.Total}" ← 멈추지 않고 출력만
// Console.WriteLine 을 코드에 심었다 지우는 일이 사라집니다
// 조사식 창에서 쓸 수 있는 것들
// order.Items.Count 속성 호출도 됩니다
// order.Items.Where(x => x.Qty > 3).ToList() LINQ 도 됩니다 (★)
// $exception 지금 잡힌 예외
// order, ac ← 서식 지정자: 정렬해서 보기
// 예외가 난 그 줄에서 멈추게 하기
// 디버그 > 창 > 예외 설정 > Common Language Runtime Exceptions 체크
// → try/catch 로 삼켜지기 전에 멈춥니다. 원인 추적의 결정타
// 핫 리로드 — 실행 중에 코드를 고쳐 바로 반영
// 실행 중 툴바의 🔥 아이콘 또는 Alt+F10
// 메서드 본문 수정은 되고, 시그니처·타입 추가는 재시작이 필요합니다
④ 단축키 다섯 개 · 확장
| 키 | 하는 일 |
|---|---|
Ctrl + . ★ | 빠른 작업 — 오류 자동 수정 · using 추가 · 이름 바꾸기 제안 |
Ctrl + T ★ | 모든 것 검색 — 파일·타입·멤버를 한 창에서 |
F12 / Alt+F12 | 정의로 이동 / 화면 안에서 엿보기 |
Shift + F12 | 이 멤버를 쓰는 곳 전부 찾기 |
Ctrl + K, D | 문서 서식 정리 (.editorconfig 기준) |
Ctrl + R, R | 이름 바꾸기 — 참조 전부 함께 |
Ctrl + F5 | 디버깅 없이 실행 (훨씬 빠름) |
Ctrl + K, C / U | 주석 처리 / 해제 |
넣어 두면 좋은 확장 (확장 > 확장 관리)
- ReSharper — 유료지만 리팩터링이 압도적. 대신 무거워집니다
- Roslynator — 무료. 분석기·리팩터링 500개 이상
- Fine Code Coverage — 테스트 커버리지를 편집기에 표시
- Markdown Editor v2 — README 미리보기
- GitHub Copilot — VS 2026 에는 기본 통합되어 있습니다
.editorconfig 를 먼저 켜고,
Ctrl+. · Ctrl+T · F12 · Shift+F12 · Ctrl+K,D 다섯 개를 손에 익히세요.
그다음이 조건부 중단점 · 추적점 · 예외 설정 입니다 — 이 세 개가 디버깅 실력의 대부분입니다.Rider · VS Code — 언제 무엇을 쓰나
IDE 는 종교 전쟁이 되기 쉽지만, 실제로는 상황별로 답이 꽤 명확합니다.
셋 다 같은 .csproj 를 읽으니 팀원끼리 달라도 아무 문제가 없습니다.
각자의 강점과, 그 도구를 제대로 쓰기 위한 세팅을 정리합니다.
Rider — 설치와 라이선스
# 설치 — JetBrains Toolbox 로 받는 것을 권합니다 (버전 관리가 편함)
# https://www.jetbrains.com/toolbox-app/
winget install JetBrains.Toolbox
# Toolbox 를 열고 Rider 옆의 [Install]
# 직접 받기
# https://www.jetbrains.com/rider/download/
brew install --cask rider # macOS
sudo snap install rider --classic # Ubuntu
# 라이선스 (2025년부터)
# 비상업 용도 — 무료 (개인 학습 · 취미 프로젝트 · 오픈소스)
# · 익명 사용 통계 전송에 동의해야 합니다
# · 회사 업무나 수익 창출 프로젝트에는 쓸 수 없습니다
# 상업 용도 — 유료 구독 (또는 All Products Pack)
# 학생 · 교사 — 무료
Rider 를 깔면 바로 해 둘 것
Settings > Editor > Code Style > C#
[Set from...] → EditorConfig ← .editorconfig 를 존중하게
Settings > Build > Toolset and Build
Use MSBuild version: 자동 (VS 가 있으면 그걸 씁니다)
Settings > Plugins
Unity Support (Unity 프로젝트를 열면 자동 권유)
Ctrl+Alt+S 로 설정 · Shift 두 번으로 무엇이든 검색
MonoBehaviour 메시지 자동 완성, 씬 안에서 이 스크립트를 쓰는 오브젝트 찾기,
플레이 모드에 디버거를 붙여 코루틴 안에서 멈추기 같은 것들이
Unity 전용 플러그인으로 들어 있습니다. 자세한 활용은 🎮 Unity 탭을 보세요.
VS Code — 제대로 쓰는 세팅
// .vscode/settings.json — 저장소에 커밋해 팀과 공유합니다
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.organizeImports": "explicit",
"source.fixAll": "explicit"
},
"files.eol": "\n",
"dotnet.defaultSolution": "MyShop.slnx",
"dotnet.server.useOmnisharp": false,
"csharp.inlayHints.enableInlayHintsForParameters": true,
"omnisharp.enableEditorConfigSupport": true,
"[csharp]": { "editor.defaultFormatter": "ms-dotnettools.csharp" }
}
// .vscode/launch.json — F5 로 디버깅
{
"version": "0.2.0",
"configurations": [
{
"name": "API 실행",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/MyShop.Api/bin/Debug/net10.0/MyShop.Api.dll",
"cwd": "${workspaceFolder}/src/MyShop.Api",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": { "ASPNETCORE_ENVIRONMENT": "Development" }
},
{
"name": "실행 중인 프로세스에 붙기",
"type": "coreclr",
"request": "attach"
}
]
}
launch.json 을 손으로 쓸 일은 거의 없습니다.
실행 및 디버그 패널에서 “launch.json 파일 만들기” → .NET 5+ and .NET Core 를 고르면
위 내용이 자동 생성됩니다. 손댈 것은 env 와 args 정도입니다.
VS Code 의 tasks.json
// .vscode/tasks.json — Ctrl+Shift+B 로 도는 작업들
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": ["build", "${workspaceFolder}/MyShop.slnx",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"],
"problemMatcher": "$msCompile",
"group": { "kind": "build", "isDefault": true }
},
{
"label": "watch",
"command": "dotnet",
"type": "process",
"args": ["watch", "run", "--project", "src/MyShop.Api"],
"problemMatcher": "$msCompile"
},
{
"label": "test",
"command": "dotnet",
"type": "process",
"args": ["test", "${workspaceFolder}/MyShop.slnx"],
"problemMatcher": "$msCompile",
"group": { "kind": "test", "isDefault": true }
}
]
}
"problemMatcher": "$msCompile" 이 핵심입니다.
이걸 넣으면 빌드 오류가 문제 패널에 클릭 가능한 목록으로 뜹니다.
빼면 터미널에 글자만 쏟아지고 어느 줄인지 직접 찾아 들어가야 합니다.
상황별 정답 — 표로
| 상황 | 고를 것 |
|---|---|
| WPF · WinForms 화면 작업 | Visual Studio 디자이너가 여기에만 |
| Unity 게임 | Rider 또는 VS + Unity 워크로드 |
| 맥 · 리눅스에서 개발 | Rider 또는 VS Code |
| 웹 API · 마이크로서비스 | VS Code 또는 Rider |
| SSH · 원격 서버 · WSL | VS Code Remote 확장 |
| 메모리 · 성능 프로파일링 | VS(무료) / dotMemory(유료) |
| 레거시 .NET Framework | Visual Studio 거의 유일한 선택 |
| CI 서버 · 도커 빌드 | IDE 없음 — dotnet CLI |
.editorconfig 하나뿐입니다.데스크톱 · 게임 — 추가로 필요한 것들
콘솔과 웹은 SDK 하나로 끝납니다. 그런데 창이 뜨는 프로그램과 게임은 각자 추가 부품을 요구합니다 — 워크로드, 안드로이드 SDK, 엔진 본체. 여기서는 무엇이 더 필요한지와 설치가 됐는지 확인하는 법만 정확히 짚습니다.
① WPF · WinForms — 바로 됩니다
# 워크로드 없이 CLI 만으로도 만들어집니다 (윈도우에서)
dotnet new wpf -o src/MyApp.Wpf
dotnet new winforms -o src/MyApp.Win
dotnet run --project src/MyApp.Wpf # 창이 뜹니다
# 맥·리눅스에서 같은 명령을 치면
# error NETSDK1100: 현재 플랫폼에서 Windows를 대상으로 지정할 수 없습니다
# → 정상입니다. 윈도우 전용 API 라 빌드 자체가 불가능합니다
<!-- WPF 프로젝트의 .csproj — 눈여겨볼 두 줄 -->
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<!-- ^^^^^^^^ OS 꼬리표. 이게 붙으면 윈도우 전용 -->
<UseWPF>true</UseWPF>
<!-- WinForms 는 <UseWindowsForms>true</UseWindowsForms> -->
</PropertyGroup>
② WinUI 3 · Windows App SDK
# 가장 쉬운 길 — Visual Studio Installer 에서
# [개별 구성 요소] 탭 → "Windows 앱 SDK C# 템플릿" 체크
# 그러면 새 프로젝트 목록에 "빈 앱, 패키지됨 (데스크톱의 WinUI 3)" 이 생깁니다
# CLI 로 템플릿만 받고 싶다면
dotnet new install Microsoft.WindowsAppSDK.ProjectTemplates
dotnet new winui -o src/MyApp.WinUI
# 확인 — 설치된 템플릿 목록에 나오는지
dotnet new list winui
| 고를 것 | 언제 |
|---|---|
| WPF | 사내 도구 · 자료가 압도적으로 많음 가장 안전한 선택 |
| WinForms | 화면이 단순한 유틸리티 배우기 가장 빠름 |
| WinUI 3 | 최신 윈도우 11 디자인 스토어 배포 |
| Avalonia | 맥·리눅스에서도 같은 창을 |
③ MAUI — 워크로드와 안드로이드
# 워크로드 설치 (관리자 권한 필요할 수 있음)
dotnet workload install maui
# 설치된 워크로드 확인 · 업데이트 · 제거
dotnet workload list
dotnet workload update
dotnet workload uninstall maui
# 환경이 갖춰졌는지 진단해 주는 도구 (★ 매우 유용)
dotnet tool install -g Redth.Net.Maui.Check
maui-check
# JDK · Android SDK · 에뮬레이터 · 라이선스까지 검사하고
# 빠진 것을 자동으로 받아 줍니다
# 프로젝트 만들고 안드로이드로 실행
dotnet new maui -o src/MyApp
dotnet build src/MyApp -t:Run -f net10.0-android
④ Unity · Godot
[Unity] — 개요만. 자세한 설치와 화면 설명은 🎮 Unity 탭 u01 참고
1. unity.com/download 에서 Unity Hub 설치
winget install Unity.UnityHub
2. Hub → Installs → Install Editor → LTS 버전 선택
3. 모듈 체크 (나중에 추가 가능)
Windows Build Support (IL2CPP)
Android Build Support (+ OpenJDK, Android SDK & NDK)
WebGL Build Support
4. Hub → Preferences → External Tools
External Script Editor: Rider 또는 Visual Studio
5. 라이선스: Personal(무료) — 매출 기준 조건 확인
★ .NET SDK 는 필요 없습니다. 에디터가 자기 컴파일러를 씁니다.
다만 서버·툴을 함께 만든다면 SDK 도 깔아 두는 게 좋습니다.
# [Godot 4 — C# 을 쓰려면 ".NET" 표기 빌드를 받습니다]
# godotengine.org/download → "Godot Engine - .NET"
# (일반 빌드에는 C# 이 아예 들어 있지 않습니다)
# Godot 은 진짜 .NET SDK 를 씁니다 — 없으면 스크립트가 안 붙습니다
dotnet --version # 반드시 나와야 합니다
# 에디터에서 Editor Settings → Dotnet → Editor
# 외부 편집기를 VS Code 나 Rider 로 지정
| 만들 것 | 추가로 필요한 것 | 확인 명령 |
|---|---|---|
| WPF · WinForms | 없음 (Windows 이기만 하면) | dotnet new wpf -o t && dotnet run --project t |
| WinUI 3 | Windows App SDK 템플릿 | dotnet new list winui |
| MAUI Android | maui 워크로드 · JDK 17 · Android SDK | dotnet workload list · maui-check |
| MAUI iOS | 맥 + Xcode (필수) | xcode-select -p |
| Unity | Unity Hub + 에디터 LTS | Hub 의 Installs 목록 |
| Godot C# | Godot .NET 빌드 + .NET SDK | dotnet --version |
| Avalonia | 템플릿만 (dotnet new install) | dotnet new list avalonia |
웹 · DB 준비 — API 하나가 뜨기까지
웹 API 는 dotnet new webapi 로 5초 만에 만들어집니다.
그런데 브라우저에서 열면 인증서 경고가 뜨고, DB 를 붙이려면 서버가 필요하고,
마이그레이션 명령은 설치가 안 되어 있습니다.
이 세 가지 걸림돌을 한 번에 치워 두면 다시는 막히지 않습니다.
dotnet ef 설치 · DB 서버.① HTTPS 개발 인증서 — 첫 관문
# 웹 API 를 만들고 실행하면
dotnet new webapi -o src/MyShop.Api
dotnet run --project src/MyShop.Api
# Now listening on: https://localhost:7134
# Now listening on: http://localhost:5134
# 브라우저로 열면 "이 연결은 비공개 연결이 아닙니다" 경고 ← 이걸 없앱니다
# ★ 이 한 줄 (윈도우·맥: 신뢰 저장소에 등록됩니다)
dotnet dev-certs https --trust
# 윈도우: "보안 경고 — 인증서를 설치하시겠습니까?" → 예
# 맥: 키체인 비밀번호를 묻습니다
# 상태 확인 · 초기화
dotnet dev-certs https --check --trust
dotnet dev-certs https --clean # 지우고
dotnet dev-certs https --trust # 다시 만들기 (1년 만료 시)
# 리눅스는 --trust 가 배포판마다 다릅니다. 파일로 뽑아 직접 등록하거나
# 개발 중에는 http 포트만 쓰는 것이 간단합니다
dotnet dev-certs https -ep ~/localhost.crt --format PEM
--clean 후 --trust 를 다시 돌리면 끝납니다.
도커 컨테이너 안에서 HTTPS 를 쓰려면 인증서를 .pfx 로 뽑아
볼륨으로 넣어 주어야 합니다 — 개발 중에는 컨테이너는 http 만 쓰는 편이 훨씬 편합니다.
② 포트와 실행 프로필
// Properties/launchSettings.json — 개발에서만 쓰이는 파일입니다
{
"profiles": {
"http": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "http://localhost:5134",
"environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
},
"https": {
"commandName": "Project",
"launchBrowser": true,
"applicationUrl": "https://localhost:7134;http://localhost:5134",
"environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }
}
}
}
# 프로필 골라 실행
dotnet run --launch-profile http
# 포트를 명령에서 덮어쓰기 (충돌할 때 가장 빠른 해결)
dotnet run --urls "http://localhost:5999"
$env:ASPNETCORE_URLS="http://localhost:5999"; dotnet run
# 그 포트를 누가 쓰고 있나 (윈도우)
netstat -ano | findstr :5134
Get-Process -Id (Get-NetTCPConnection -LocalPort 5134).OwningProcess
# 맥 · 리눅스
lsof -i :5134
launchSettings.json 은 배포에 전혀 반영되지 않습니다.
dotnet publish 결과에 들어가지도 않습니다.
운영 포트는 ASPNETCORE_URLS 환경 변수나 리버스 프록시(Nginx·IIS)가 정합니다 —
이 구분을 모르면 “로컬에서는 되는데 서버에서는 포트가 다른” 혼란이 생깁니다.
③ DB — 윈도우라면 LocalDB 가 가장 빠릅니다
# 설치: Visual Studio Installer → "데이터 저장 및 처리" 워크로드
# 또는 SQL Server Express 설치 시 LocalDB 옵션 체크
# winget install Microsoft.SQLServer.2022.Express
# 인스턴스 확인 · 시작
sqllocaldb info
sqllocaldb info MSSQLLocalDB
sqllocaldb start MSSQLLocalDB
sqllocaldb create MyDev -s # 새 인스턴스 만들고 시작
# 연결 문자열 (appsettings.Development.json)
# "Server=(localdb)\\MSSQLLocalDB;Database=MyShop;Trusted_Connection=True;
# MultipleActiveResultSets=true;TrustServerCertificate=True"
# GUI 로 들여다보려면
# SSMS (SQL Server Management Studio) — 무료, 기능 최다
# 또는 Azure Data Studio / VS 의 SQL Server 개체 탐색기
# winget install Microsoft.SQLServerManagementStudio
④ 도커로 PostgreSQL — 팀 표준
# compose.yaml — 저장소에 두고 팀원 전부 같은 DB 를 씁니다
services:
db:
image: postgres:17
environment:
POSTGRES_PASSWORD: devpass
POSTGRES_DB: myshop
ports: ["5432:5432"]
volumes: ["pgdata:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
# SQL Server 를 쓰고 싶다면
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
environment:
ACCEPT_EULA: "Y"
MSSQL_SA_PASSWORD: "Dev_Pass123!"
ports: ["1433:1433"]
volumes:
pgdata:
docker compose up -d # 띄우기
docker compose ps # 상태
docker compose logs -f db # 로그
docker compose down # 내리기 (데이터는 볼륨에 남습니다)
docker compose down -v # 데이터까지 삭제
# 연결 문자열 (Npgsql)
# "Host=localhost;Port=5432;Database=myshop;Username=postgres;Password=devpass"
wsl --install 한 번이면 되지만 재부팅과 가상화(BIOS 의 VT-x/AMD-V) 활성화가 전제입니다.
회사 PC 에서 가상화가 막혀 있다면 도커를 포기하고 LocalDB 나 SQLite 로 가는 편이 빠릅니다.
EF Core 도구 — dotnet ef 설치와 첫 마이그레이션 🟡 중급
설치 · 자주 나는 오류
# dotnet ef 는 SDK 에 들어 있지 않습니다. 따로 깝니다.
dotnet tool install --global dotnet-ef
dotnet tool update --global dotnet-ef # 버전 올리기
dotnet ef --version # 확인
# "dotnet ef 명령을 찾을 수 없습니다" → PATH 문제 (st03)
# %USERPROFILE%\.dotnet\tools 가 PATH 에 있어야 하고
# 설치 후 터미널을 새로 열어야 합니다
# 팀과 버전을 맞추려면 로컬 도구로 (권장 ★)
dotnet new tool-manifest # .config/dotnet-tools.json 생성
dotnet tool install dotnet-ef # -g 없이 → 저장소에 고정됩니다
dotnet tool restore # 새로 받은 사람은 이 한 줄
dotnet dotnet-ef --version # 로컬 도구는 dotnet 을 한 번 더
# 프로젝트에도 설계 시점 패키지가 필요합니다
dotnet add src/MyShop.Api package Microsoft.EntityFrameworkCore.Design
dotnet add src/MyShop.Api package Npgsql.EntityFrameworkCore.PostgreSQL
# SQL Server 라면 Microsoft.EntityFrameworkCore.SqlServer
Your startup project doesn't reference Microsoft.EntityFrameworkCore.Design —
이 메시지가 dotnet ef 첫 사용 시 가장 많이 나옵니다.
위의 Design 패키지를 시작 프로젝트(보통 API)에 추가하면 해결됩니다.
DbContext 가 다른 프로젝트에 있으면 --project 와 --startup-project 를 둘 다 지정하세요.
첫 마이그레이션까지
# 마이그레이션 만들기
dotnet ef migrations add InitialCreate \
--project src/MyShop.Core --startup-project src/MyShop.Api
# DB 에 적용
dotnet ef database update --startup-project src/MyShop.Api
# 실제로 어떤 SQL 이 나가는지 미리 보기 (★ 운영 반영 전 필수)
dotnet ef migrations script --idempotent -o migrate.sql
# 되돌리기 · 목록
dotnet ef migrations list
dotnet ef migrations remove # 아직 적용 안 한 마지막 것 삭제
dotnet ef database update PreviousName # 특정 시점으로
# 이미 있는 DB 에서 코드 뽑아내기 (레거시 연동)
dotnet ef dbcontext scaffold \
"Host=localhost;Database=myshop;Username=postgres;Password=devpass" \
Npgsql.EntityFrameworkCore.PostgreSQL -o Models
database update 를 직접 쏘지 마세요.
migrations script --idempotent 로 SQL 파일을 뽑아
검토하고 DBA 를 거쳐 반영하는 것이 실무 절차입니다.
개발 DB 에서만 update 를 쓰는 습관을 들이세요.
API 를 두드려 보는 도구
.http 파일 — VS 2022+ · VS Code(REST Client) · Rider 전부 지원
파일: MyShop.Api.http
@host = https://localhost:7134
### 목록 조회
GET {{host}}/api/products
Accept: application/json
### 추가
POST {{host}}/api/products
Content-Type: application/json
{ "name": "칼", "price": 19900 }
### 인증이 필요할 때
@token = eyJhbGciOi...
GET {{host}}/api/me
Authorization: Bearer {{token}}
Microsoft.AspNetCore.OpenApi 로 OpenAPI 문서만 생성하고,
화면이 필요하면 Scalar 나 Swashbuckle 을 따로 넣습니다.
가벼운 확인에는 위의 .http 파일이 가장 빠릅니다 — 저장소에 커밋해 팀과 공유할 수도 있습니다.
비밀 정보는 파일에 적지 않습니다
# 개발용 비밀 — 사용자 폴더에 저장되고 저장소에 안 들어갑니다
dotnet user-secrets init --project src/MyShop.Api
dotnet user-secrets set "ConnectionStrings:Default" \
"Host=localhost;Database=myshop;Username=postgres;Password=devpass" \
--project src/MyShop.Api
dotnet user-secrets list --project src/MyShop.Api
dotnet user-secrets remove "ConnectionStrings:Default" --project src/MyShop.Api
# 저장 위치 (사람이 읽는 평문 JSON — 암호화는 아닙니다)
# Windows: %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json
# macOS/Linux: ~/.microsoft/usersecrets/<id>/secrets.json
# 운영에서는 환경 변수로 (콜론 대신 __ 두 개)
# ConnectionStrings__Default=...
appsettings.json 에 비밀번호를 적어 커밋하는 사고가 정말 많습니다.
한 번 올라가면 히스토리에 영원히 남습니다 — 커밋을 지워도 소용없고 비밀번호를 바꿔야 합니다.
개발은 user-secrets, 운영은 환경 변수나 키 저장소가 원칙입니다.
dotnet dev-certs https --trust, dotnet tool install --global dotnet-ef,
그리고 DB 고르기(윈도우면 LocalDB, 팀이면 도커 PostgreSQL, 학습이면 SQLite).
비밀번호는 dotnet user-secrets 로 넣습니다.코드 품질 · 빌드 — 사람 대신 컴파일러가 잔소리하게
코드 리뷰에서 “여기 들여쓰기가”, “null 체크 빠졌는데요”를 사람이 말하고 있다면 설정이 부족한 것입니다. .NET 은 분석기가 컴파일러에 내장되어 있어서, 설정 몇 줄로 스타일 · 잠재적 버그 · 취약점을 빌드 단계에서 잡을 수 있습니다. 새 프로젝트를 만든 첫날 해 두는 것이 열 배 쉽습니다.
① nullable 켜기 — 가장 값싼 버그 예방
<PropertyGroup>
<Nullable>enable</Nullable>
<!-- string? 처럼 물음표를 붙인 것만 null 이 될 수 있게 됩니다 -->
</PropertyGroup>
// nullable 을 켜면 컴파일러가 이렇게 말합니다
string name = null; // CS8600 경고 — null 을 넣을 수 없는 타입
string? maybe = null; // OK — 물음표가 "없을 수 있다"는 선언
void Print(string? s)
{
Console.WriteLine(s.Length); // CS8602 경고 — null 일 수 있는데 접근
if (s is null) return;
Console.WriteLine(s.Length); // 여기서는 경고 없음. 컴파일러가 압니다
}
// 기존 코드가 경고 폭탄이라면 단계적으로
// <Nullable>warnings</Nullable> 경고만 (에러 아님)
// 파일 맨 위에 #nullable enable 파일 단위로 켜기
// 당장 어쩔 수 없는 곳은 value! null 아님을 단언 (남발 금지)
NullReferenceException 은 .NET 에서 가장 흔한 런타임 예외입니다.
nullable 참조 형식은 그걸 실행 전에 대부분 잡아 줍니다.
새 프로젝트 템플릿에는 이미 켜져 있습니다 — 끄지 마세요.
경고가 많다고 끄는 순간, 그 경고가 말하던 버그는 전부 그대로 남습니다.
② 분석기와 경고 정책
<!-- 저장소 루트 · Directory.Build.props → 모든 프로젝트에 자동 적용 -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- 분석기 -->
<EnableNETAnalyzers>true</EnableNETAnalyzers>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>Recommended</AnalysisMode>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- 경고를 에러로 — CI 에서만 켜는 팀도 많습니다 -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsNotAsErrors>CS1591</WarningsNotAsErrors>
<!-- 결정론적 빌드 · 소스 링크 -->
<Deterministic>true</Deterministic>
<ContinuousIntegrationBuild
Condition="'$(CI)' == 'true'">true</ContinuousIntegrationBuild>
</PropertyGroup>
</Project>
TreatWarningsAsErrors 를 기존 프로젝트에 갑자기 켜면 빌드가 죽습니다.
새 프로젝트에서는 첫날 켜고, 기존 프로젝트라면
<WarningsAsErrors>CS8600;CS8602</WarningsAsErrors> 처럼
중요한 것 몇 개만 골라 시작한 뒤 범위를 넓히세요.
③ dotnet format
# SDK 에 내장되어 있습니다. 따로 설치할 필요 없습니다.
dotnet format # 서식 · 스타일 · 분석기 수정 전부
dotnet format whitespace # 공백만
dotnet format style # .editorconfig 스타일 규칙만
dotnet format analyzers # 분석기가 고칠 수 있는 것만
# CI 에서는 "고치지 말고 검사만"
dotnet format --verify-no-changes --verbosity diagnostic
# 서식이 안 맞으면 0 이 아닌 코드로 종료 → 빌드 실패
# 특정 폴더 · 프로젝트만
dotnet format ./src/MyShop.Api --include Controllers/
# 커밋 전 자동 실행 (git hook)
# .git/hooks/pre-commit 에 dotnet format --verify-no-changes
# 또는 Husky.Net 을 쓰면 저장소에 커밋해 팀과 공유됩니다
dotnet tool install --global Husky
husky install
.editorconfig 를 정하고 dotnet format 을 CI 에 걸어 두면
“탭이냐 스페이스냐” 를 사람이 다시 말할 일이 없어집니다.
중요한 건 어떤 규칙이냐가 아니라 하나로 정해져 있느냐입니다.
④ CI — GitHub Actions
# .github/workflows/ci.yml
name: CI
on:
push: { branches: [main] }
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
# global.json 이 있으면 그 버전을 우선합니다
cache: true
cache-dependency-path: '**/packages.lock.json'
- run: dotnet restore --locked-mode
- run: dotnet format --verify-no-changes
- run: dotnet build --no-restore -c Release
- run: dotnet test --no-build -c Release --logger trx
- run: dotnet list package --vulnerable --include-transitive
- name: 배포 산출물
run: dotnet publish src/MyShop.Api -c Release -o out --no-build
- uses: actions/upload-artifact@v4
with: { name: api, path: out }
--no-restore · --no-build 를 붙이는 이유 —
안 붙이면 단계마다 복원과 빌드를 다시 합니다.
CI 시간이 두세 배가 되고, 더 나쁜 것은
test 가 방금 빌드한 것과 다른 결과물을 테스트할 수도 있다는 점입니다.
| 파일 | 두는 곳 | 하는 일 |
|---|---|---|
.editorconfig | 저장소 루트 | 서식 · 스타일 · 규칙 심각도. IDE 세 개가 모두 읽습니다 |
Directory.Build.props | 저장소 루트 | 모든 .csproj 에 공통 속성 주입 (앞에서 적용) |
Directory.Build.targets | 저장소 루트 | 공통 빌드 단계 (뒤에서 적용) |
Directory.Packages.props | 저장소 루트 | 패키지 버전 중앙 관리 (st06) |
global.json | 저장소 루트 | SDK 버전 고정 (st05) |
NuGet.Config | 저장소 루트 | 피드 목록 (st06) |
packages.lock.json | 프로젝트별 | 복원 결과 잠금. 커밋합니다 |
bin/ · obj/ | 프로젝트별 | 커밋하지 않습니다 — dotnet new gitignore |
dotnet format · CI 네 가지입니다.
공통 설정은 Directory.Build.props 한 파일에 모으고,
경고를 에러로 만드는 것은 새 프로젝트에서는 첫날, 기존 프로젝트에서는 골라서 적용하세요.설치 문제 사전 — 증상 → 원인 → 해결
환경 문제는 종류가 그리 많지 않습니다. 같은 열 몇 가지가 계속 반복될 뿐입니다. 여기서는 실제로 마주치는 에러 메시지를 그대로 적고, 왜 그런지와 어느 명령을 치면 끝나는지를 짝지어 두었습니다. 막혔을 때 이 섹션만 검색해도 대부분 5분 안에 풀립니다.
NETSDK·NU·CS 셋만 구분하면 절반은 끝납니다.| 증상 · 메시지 | 원인 | 해결 |
|---|---|---|
1. 'dotnet'은(는) 내부 또는 외부 명령이 아닙니다command not found: dotnet |
설치가 안 되었거나, 설치 전에 열어 둔 터미널이 옛 PATH 를 들고 있음 |
터미널을 완전히 닫고 새로 여세요(VS Code 도 재시작). 그래도 안 되면 where.exe dotnet 확인 →없으면 st03 으로 설치, 있으면 PATH 에 C:\Program Files\dotnet 추가 |
2. dotnet ef / dotnet-format 를찾을 수 없습니다 |
전역 도구 폴더가 PATH 에 없음%USERPROFILE%\.dotnet\tools |
터미널 새로 열기 → 그래도 안 되면 PATH 에 위 경로 추가(st03). 팀 프로젝트라면 dotnet tool restore 로 로컬 도구를 쓰세요 |
3. dotnet build 는 없는데dotnet MyApp.dll 은 됨 |
Runtime 만 설치된 상태 | dotnet --list-sdks 가 비어 있는지 확인 →SDK 를 설치합니다(st03). Runtime 은 지울 필요 없습니다 |
4. NETSDK1045: 현재 .NET SDK는 |
프로젝트가 요구하는 버전보다 SDK 가 낮음 (예: SDK 8 로 net10.0 빌드) |
dotnet --version 확인 → SDK 업그레이드.올릴 수 없으면 TargetFramework 를 낮추거나global.json 이 옛 SDK 를 고정하고 있는지 확인 |
5. NETSDK1141 / 요청한 SDK 버전을 |
global.json 이 없는 버전을 요구( rollForward: disable 인 경우 특히) |
global.json 의 버전을 깔린 것으로 바꾸거나"rollForward": "latestFeature" 로 완화.당장 급하면 파일을 잠시 이름만 바꿔도 됩니다 |
6. NETSDK1100: 현재 플랫폼에서 |
맥·리눅스에서 WPF·WinForms ( net10.0-windows)를 빌드 |
정상 동작입니다. 윈도우에서 빌드하거나, 크로스 플랫폼이 필요하면 Avalonia 로(st04·st09) |
7. NU1101: 패키지 Xxx을(를) |
피드 목록에 그 패키지가 없음. 사내 NuGet.Config 가 <clear/> 로nuget.org 를 지웠을 수 있음 |
dotnet nuget list source 로 피드 확인 →필요한 피드 추가. 이름 오타도 흔합니다(대소문자 무관) |
8. NU1301: 원본에 대한 서비스 |
네트워크·프록시 차단 또는 사내 피드 인증 실패 |
dotnet restore --interactive 로 로그인 시도 →프록시면 NuGet.Config 에 http_proxy 설정(st06).VPN 연결 여부부터 확인하는 것이 가장 빠릅니다 |
| 9. 복원이 계속 이상하다 · 지운 패키지가 살아 있다 |
캐시 손상 또는obj/ 의 옛 복원 결과 |
dotnet nuget locals all --clearbin·obj 삭제 후 dotnet restore --force |
10. 브라우저: 이 연결은 비공개 (localhost) |
개발 인증서 미신뢰 또는 만료 (유효 기간 1년) |
dotnet dev-certs https --cleandotnet dev-certs https --trust → 브라우저 재시작 |
11. Failed to bind to address |
포트 점유. 윈도우에서는 다른 앱·예약 포트 범위와 충돌하기도 |
netstat -ano | findstr :5000 로 범인 확인 →종료하거나 dotnet run --urls "http://localhost:5999".Hyper-V 예약 범위면 포트를 5900 대로 옮기는 편이 빠릅니다 |
| 12. Visual Studio 새 프로젝트에 .NET 10 이 안 보임 |
워크로드 누락 또는 VS 가 옛 버전 |
Visual Studio Installer → 수정 에서 “.NET 데스크톱 개발” 확인 + 업데이트. VS 버전이 SDK 보다 낮으면 최신 SDK 를 인식하지 못합니다 |
| 13. 경로에 한글·공백이 있을 때 알 수 없는 빌드 실패 |
일부 도구·네이티브 빌드가 비ASCII·공백 경로를 못 다룸 (MAUI·Unity·C++ 연동에서 특히) |
프로젝트를 D:\work\myshop 처럼영문·공백 없는 짧은 경로로 옮기세요. OneDrive 동기화 폴더도 피합니다 |
14. The type or namespace name (VS 에서만) |
IDE 의 캐시·언어 서버 상태가 실제 빌드와 어긋남 |
CLI 에서 dotnet build 가 되는지 먼저 확인 →되면 IDE 문제입니다. VS 종료 후 .vs 폴더 삭제,VS Code 는 Restart Language Server |
15. 실행 시 You must install .NET |
배포 대상에 런타임이 없음 또는 버전이 다름 |
서버에 해당 런타임 설치, 또는--self-contained true 로 다시 publish(st05) |
16. dotnet workload 설치가권한 오류로 실패 |
워크로드는 SDK 폴더에 씁니다 (관리자 권한 필요) |
관리자 터미널에서 다시 실행. 맥·리눅스는 sudo dotnet workload install ... |
막혔을 때 순서대로 치는 명령
# ① 지금 상태 전부 (이슈에 붙일 정보이기도 합니다)
dotnet --info
# ② 진짜로 어느 실행 파일이 쓰이나
where.exe dotnet # 윈도우
which -a dotnet # 맥 · 리눅스
# ③ 어떤 SDK 가 선택됐나 (global.json 반영)
dotnet --version
dotnet --list-sdks
# ④ 이 폴더에 SDK 를 강제하는 파일이 있나
Get-ChildItem -Recurse -Filter global.json .. | Select-Object FullName
# ⑤ 캐시·중간 산출물 청소 (가장 잘 듣는 한 방)
dotnet build-server shutdown
dotnet nuget locals all --clear
Get-ChildItem -Recurse -Include bin,obj -Directory | Remove-Item -Recurse -Force
dotnet restore --force
# ⑥ 그래도 안 되면 — 자세한 로그
dotnet build -v diagnostic > build.log
dotnet build -bl # msbuild.binlog → msbuildlog.com 에서 분석
dotnet build -bl 로 나오는 msbuild.binlog 는
빌드에서 실제로 무슨 일이 있었는지 전부 담고 있습니다.
MSBuild Structured Log Viewer 로 열면 어떤 속성이 어떤 값이었고
어느 파일이 어디서 왔는지까지 추적할 수 있습니다 —
“왜 이 dll 이 들어갔지” 같은 질문의 유일한 답입니다.
환경 변수 — 알아 두면 좋은 것들
| 변수 | 하는 일 |
|---|---|
DOTNET_ROOT | 표준 경로가 아닌 곳에 설치했을 때 런타임 위치를 알려 줌 |
DOTNET_CLI_TELEMETRY_OPTOUT=1 | 사용 통계 전송 끄기 |
DOTNET_NOLOGO=1 | 첫 실행 안내 문구 숨김 (CI 로그가 깔끔해집니다) |
DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 | 첫 실행 캐시 준비 생략 |
ASPNETCORE_ENVIRONMENT | Development / Production |
ASPNETCORE_URLS | 바인딩할 주소·포트 |
NUGET_PACKAGES | 전역 패키지 폴더 위치 변경 (C 드라이브가 좁을 때) |
MSBUILDTERMINALLOGGER=off | 새 빌드 출력이 불편할 때 옛 방식으로 |
$env:FOO="bar" 는 창을 닫으면 사라지고,
영구 설정은 시스템 속성 > 환경 변수 또는
[Environment]::SetEnvironmentVariable(...,'User') 입니다.
영구로 바꾼 뒤에는 터미널·IDE 를 새로 열어야 반영됩니다 — 이걸 몰라 두 번 헤매는 사람이 많습니다.
빈 PC 30분 세팅 — 최종 점검표
# 윈도우에서 명령만으로 (winget 이 있다면)
winget install Microsoft.DotNet.SDK.10
winget install Microsoft.VisualStudioCode
winget install Git.Git
winget install Docker.DockerDesktop # 선택
code --install-extension ms-dotnettools.csdevkit
# 새 터미널을 열고 — 여기서부터 확인
dotnet --info # ① SDK 가 보이나
dotnet dev-certs https --trust # ② 웹을 할 거면
dotnet tool install --global dotnet-ef # ③ DB 를 할 거면
# ④ 진짜로 도는지
mkdir D:\work\smoke; cd D:\work\smoke
dotnet new webapi -o api
dotnet run --project api
# Now listening on: https://localhost:7xxx ← 여기까지 오면 완료입니다
# ⑤ 정리
cd ..; Remove-Item -Recurse -Force smoke
도움을 요청할 때 붙일 정보
스택오버플로나 팀 채널에 물어볼 때, 아래 네 가지를 함께 올리면
답이 오는 속도가 완전히 달라집니다. 반대로 이게 없으면
“dotnet --info 결과 좀 주세요”라는 답만 돌아옵니다.
dotnet --info전체 출력 — SDK·런타임·RID 가 한 번에 보입니다- 에러 메시지 원문 — 번역하지 말고 코드(
NU1101등)까지 그대로 - 재현 명령 — 어느 폴더에서 무엇을 쳤는지
.csproj와global.json내용
dotnet --info → where dotnet → 에러 코드 앞 글자 순서로 좁힙니다.
NETSDK는 버전, NU는 패키지, CS는 코드입니다.
마지막 수단은 재설치가 아니라 캐시 정리(nuget locals --clear + bin/obj 삭제) 입니다.자바스크립트 없이
C# 하나로
웹 화면을 만듭니다
ASP.NET Core 탭에서 만든 것은 데이터를 주는 API 였습니다. 이 탭은 그 앞에 붙는 사람이 보는 화면을 다룹니다 — 그런데 React·Vue 가 아니라 C# 과 Razor 문법으로 만듭니다. 같은 언어 · 같은 모델 클래스 · 같은 검증 규칙을 서버와 화면이 그대로 공유하는 것이 Blazor 의 유일하고도 결정적인 이점입니다. 여기에 Aspire 를 얹으면 API · Blazor · Postgres · Redis 를 명령 하나로 띄우고 로그 · 트레이스 · 메트릭을 한 대시보드에서 봅니다. 이 탭을 마치면 로그인부터 실시간 갱신·배포까지 갖춘 웹 앱을 혼자 세울 수 있습니다.
Blazor 가 무엇인가 — 렌더 모드부터 고릅니다
Blazor 를 배우는 일의 절반은 렌더 모드 네 가지를 구분하는 것입니다.
같은 .razor 파일이 서버에서 도느냐 브라우저에서 도느냐에 따라
할 수 있는 일과 못 하는 일이 완전히 갈리기 때문입니다.
네 모드를 한 문장으로
- Static SSR — 서버가 HTML 을 만들어 보내고 끝. 버튼을 눌러도 아무 일이 없습니다(폼 제출은 됩니다). 블로그·상품 목록·랜딩 페이지.
- Interactive Server — 컴포넌트가 서버에서 돌고, 바뀐 화면 조각만 WebSocket 으로 내려옵니다. DB 를 바로 부를 수 있어 사내 관리 도구에 가장 잘 맞습니다.
- Interactive WebAssembly — .NET 런타임이 브라우저 안에서 돕니다. 첫 방문에 수 MB 를 받아야 하지만 그다음부터는 서버 없이도 반응합니다.
- Auto — 첫 방문은 Server 로 즉시 시작하고, 배경에서 WASM 을 내려받아 다음 방문부터 브라우저에서 돕니다. 둘의 단점을 서로 덮습니다.
@rendermode 를 다르게 줍니다.
목록 페이지는 Static SSR, 편집 화면만 Interactive Server —
이렇게 섞는 것이 기본값입니다.
React · Vue 를 아는 사람을 위한 대조표
| React / Vue | Blazor | |
|---|---|---|
| 언어 | JS / TS | C# 하나 |
| 컴포넌트 | .jsx · .vue | .razor |
| props | props | [Parameter] |
| children | children·slot | RenderFragment |
| 이벤트 위로 | 콜백 prop | EventCallback |
| 다시 그리기 | setState 자동 | StateHasChanged |
| 목록 키 | key | @key |
| 전역 상태 | Redux·Pinia | DI 서비스 |
| 빌드 도구 | Vite·webpack | dotnet run |
| 타입 공유 | DTO 를 또 씀 | 같은 클래스 재사용 |
npm install 도, 번들러 설정도, 노드 버전 충돌도 없습니다.
대신 npm 생태계의 방대한 UI 라이브러리도 그냥은 못 씁니다 —
붙이려면 JS 상호운용(b08)을 거쳐야 합니다.
무엇을 고를까 — 이 순서로 물어보면 됩니다 🟢 기초
언제 Blazor 를 쓰고 언제 안 쓰나 — 솔직한 기준 🟡 중급
Blazor 가 확실히 유리한 자리
- 팀이 C# 팀일 때 — 프런트 인력을 따로 안 뽑아도 됩니다. 이게 현실에서 가장 큰 이유입니다
- 사내 업무 도구 · 관리자 화면 — 첫 로딩 몇 백 ms 는 아무도 신경 안 쓰고, DB 를 바로 다루는 편의가 압도적입니다
- 서버와 화면이 같은 규칙을 써야 할 때 — 검증 속성, 계산 로직, 열거형을 한 곳에 두고 양쪽에서 씁니다
- 데스크톱·모바일로도 내보내야 할 때 — 같은 컴포넌트를 MAUI Blazor Hybrid 로 감쌉니다(b12)
- 오프라인 도구 — WASM + PWA 면 설치 없이 도는 오프라인 앱이 됩니다
Blazor 를 피하는 편이 나은 자리
- 공개 서비스의 첫 로딩이 매출을 좌우할 때 — WASM 초기 다운로드는 아무리 줄여도 1~2MB 대입니다
- 특정 JS 라이브러리에 크게 기대는 화면 — 지도·에디터·차트를 깊게 쓰면 상호운용 코드가 앱보다 커집니다
- 이미 React·Vue 팀이 있고 잘 돌 때 — 언어 통일이 주는 이득보다 재학습 비용이 큽니다
- SEO 가 핵심인데 상호작용도 많을 때 — 가능은 하지만(Static SSR + 부분 Interactive) React 진영의 도구가 더 성숙합니다
- 모바일 회선이 나쁜 사용자 대상 — Interactive Server 는 연결이 끊기면 화면이 멈춥니다(b09)
첫 앱 — dotnet new blazor 부터 핫 리로드까지
npm 도, 번들러 설정도 없습니다. 명령 두 줄이면 화면이 뜹니다. 대신 어떤 파일이 무슨 역할인지를 처음에 잡아 두면 뒤가 아주 편해집니다.
만들고 띄우기 — 두 줄
# 상호작용 모드를 Auto 로, 인증까지 포함해서 생성
dotnet new blazor -o Inventory.Web -int Auto -au Individual
cd Inventory.Web
dotnet watch # 브라우저가 열리고, 저장하면 즉시 반영됩니다
# 주요 옵션
# -int|--interactivity None | Server | WebAssembly | Auto
# None → 전부 Static SSR (가장 가벼움)
# Auto → Server 로 시작해 WASM 으로 넘어감 (프로젝트 2개 생성)
# -ai |--all-interactive 모든 페이지에 렌더 모드를 한꺼번에 적용
# -au |--auth None | Individual (Identity + 로그인 화면 포함)
# -e |--empty 예제 페이지 없이 뼈대만
# --no-https 사내망 전용일 때
# 어떤 템플릿이 있는지 확인
dotnet new list blazor
-int Auto 를 주면 프로젝트가 두 개 생깁니다 —
Inventory.Web(서버) 와 Inventory.Web.Client(브라우저에서 도는 쪽).
브라우저로 내려갈 컴포넌트는 반드시 .Client 프로젝트에 둬야 합니다.
이 규칙 하나를 몰라 "왜 내 컴포넌트가 안 살아나지" 로 몇 시간을 쓰는 사람이 많습니다.
폴더 구조 — 이 여섯 개만 알면 됩니다
Inventory.Web/
├─ Program.cs 서비스 등록 + 미들웨어 (API 탭과 동일)
├─ Components/
│ ├─ App.razor ★ HTML 문서 전체 (<html><head><body>)
│ ├─ Routes.razor ★ URL → 컴포넌트 연결 (라우터)
│ ├─ _Imports.razor ★ 모든 .razor 에 공통 적용될 @using
│ ├─ Layout/
│ │ ├─ MainLayout.razor 좌측 메뉴 + @Body
│ │ └─ NavMenu.razor
│ └─ Pages/
│ ├─ Home.razor @page "/"
│ └─ Counter.razor @page "/counter"
├─ wwwroot/ css · 이미지 · js (정적 파일)
└─ Inventory.Web.csproj
Inventory.Web.Client/ ← -int Auto/WebAssembly 일 때만
├─ Program.cs 브라우저 쪽 DI 등록
└─ Pages/… ★ WASM 으로 내려갈 컴포넌트는 여기
_Imports.razor 를 먼저 열어 보세요.
여기에 @using 을 한 번 적어 두면
모든 .razor 파일에서 그 네임스페이스를 쓸 수 있습니다.
"이 컴포넌트를 못 찾는다"는 에러의 90%는
해당 프로젝트의 _Imports.razor 에 using 이 없어서입니다
(서버·클라이언트 프로젝트가 각각 가지고 있습니다).
Program.cs — 렌더 모드를 켜는 곳
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents() // ① Razor 컴포넌트 엔진
.AddInteractiveServerComponents() // ② Server 모드를 쓰겠다
.AddInteractiveWebAssemblyComponents(); // ③ WASM 모드도 쓰겠다
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseAntiforgery(); // ★ 폼 위조 방지 — 반드시 필요
app.MapStaticAssets(); // .NET 9+ : 지문·압축 자동
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(Inventory.Web.Client._Imports).Assembly); // ★ 클라 프로젝트
app.Run();
AddAdditionalAssemblies 를 빠뜨리면
.Client 프로젝트에 있는 페이지의 URL 이 404 가 납니다.
라우터가 그 어셈블리를 뒤지지 않기 때문입니다.
템플릿으로 만들면 자동으로 들어가 있으니, 손으로 프로젝트를 나눌 때만 신경 쓰면 됩니다.
렌더 모드를 실제로 지정하는 세 자리
@* ① 페이지 전체에 — 파일 맨 위 *@
@page "/inventory"
@rendermode InteractiveServer
@* ② 다른 컴포넌트를 쓸 때 그 자리에서 *@
<ProductTable @rendermode="InteractiveWebAssembly" Items="list" />
@* ③ 사전 렌더링을 끄고 싶을 때 (JS 상호운용이 많은 화면) *@
@rendermode @(new InteractiveServerRenderMode(prerender: false))
@* ④ App.razor 최상단에 걸면 앱 전체가 상호작용 모드가 됩니다 *@
<Routes @rendermode="InteractiveAuto" />
@* ⑤ 지금 이 컴포넌트가 어느 모드로 도는지 알아내기 *@
@if (RendererInfo.IsInteractive) { <button @onclick="Save">저장</button> }
else { <button disabled>준비 중…</button> }
@code {
// RendererInfo.Name → "Static" | "Server" | "WebAssembly" | "WebView"
}
RendererInfo(.NET 9+)가 실전에서 아주 유용합니다.
사전 렌더링 중에는 IsInteractive 가 false 이므로
"아직 안 눌리는 버튼"을 회색으로 보여 줄 수 있습니다.
사용자가 눌렀는데 반응이 없어 두 번 누르는 사고를 여기서 막습니다.
개발 중 실제로 쓰는 것들 — 핫 리로드와 디버깅 🟡 중급
핫 리로드 · 진단
# 저장하면 브라우저가 알아서 갱신됩니다 (상태를 유지한 채)
dotnet watch
# 구조를 바꿔 핫 리로드가 안 될 때는 자동 재시작을 묻습니다
# → 항상 재시작하려면
dotnet watch --non-interactive
# WASM 쪽 로그를 자세히 보기 (Client/Program.cs)
# 브라우저 개발자 도구 콘솔에 렌더 배치까지 찍힙니다
# 게시 후 크기 확인 — 무엇이 큰지부터 봅니다
dotnet publish -c Release
du -h -d1 bin/Release/net10.0/publish/wwwroot/_framework | sort -h | tail
# 브라우저에서 C# 을 중단점으로 디버깅 (WASM)
# Chrome 개발자 도구 → Sources → file:// 아래 .cs 파일이 그대로 보입니다
처음 30분에 반드시 만나는 에러 넷
| 증상 | 원인과 처방 |
|---|---|
| 버튼을 눌러도 아무 일 없음 | @rendermode 가 없습니다. 그 페이지는 Static SSR 로 돌고 있습니다 |
JSRuntime 호출이 예외 | 사전 렌더링 중입니다. OnAfterRenderAsync 로 옮기세요(b08) |
| 클라 프로젝트 페이지가 404 | AddAdditionalAssemblies 누락 |
| 폼 제출이 400 | app.UseAntiforgery() 누락 또는 <form> 에 @formname 없음 |
| 컴포넌트를 못 찾음 | 해당 프로젝트의 _Imports.razor 에 @using 추가 |
| Auto 인데 DB 코드가 터짐 | WASM 단계에서는 DB 를 못 봅니다. API 호출로 바꾸세요(b06) |
@rendermode 이야기가 없어서
반드시 여기서 막힙니다. Blazor 자료를 볼 때는 .NET 8 이후인지부터 확인하세요.
dotnet new blazor -int Auto -au Individual 한 줄로 시작하고,
App.razor(문서) · Routes.razor(URL) · _Imports.razor(using)
세 파일의 역할만 잡아 두면 됩니다.
화면이 안 움직이면 가장 먼저 @rendermode 를 의심하세요.Razor 컴포넌트 문법 — 화면을 조각으로 나눕니다
.razor 파일 하나가 컴포넌트 하나입니다.
HTML 을 쓰다가 @ 를 만나면 C# 으로 넘어가고, 다시 태그를 만나면 HTML 로 돌아옵니다.
규칙은 이 한 줄이 전부입니다.
파일 하나의 구조
@* Components/Pages/Counter.razor *@
@page "/counter" @* 이 컴포넌트의 URL *@
@rendermode InteractiveServer @* 살아 있게 만들기 *@
@using Inventory.Shared.Models @* 이 파일에만 필요한 using *@
@inject ILogger<Counter> Log @* DI — 생성자 주입 대신 이렇게 *@
<PageTitle>카운터</PageTitle> @* <title> 을 바꿉니다 *@
<h1>현재 @count</h1> @* @변수 = 값을 출력 *@
@if (count > 10) @* @if / @foreach = 제어문 *@
{
<p class="warn">10 을 넘었습니다</p>
}
<button class="btn" @onclick="Increment">+1</button>
@code { @* 이 컴포넌트의 C# 코드 *@
private int count;
private void Increment()
{
count++;
Log.LogInformation("count={Count}", count);
} // ← 이벤트 처리기가 끝나면 자동으로 다시 그려집니다
}
@ 는 "여기부터 C#" 이라는 표시입니다.
@count 처럼 한 낱말이면 그대로 쓰고,
식이 길면 @(a + b) 처럼 괄호로 묶습니다.
이메일 주소처럼 진짜 @ 문자를 쓰고 싶으면 @@ 로 두 번 씁니다.
파라미터 — React 의 props 자리
@* Components/ProductCard.razor *@
<div class="card @(Selected ? "on" : "")" @attributes="Extra">
<h4>@Product.Name</h4>
<p>@Product.Price.ToString("N0") 원</p>
<button @onclick="() => OnPick.InvokeAsync(Product)">담기</button>
</div>
@code {
[Parameter, EditorRequired] // ★ 빠뜨리면 빌드 경고
public Product Product { get; set; } = default!;
[Parameter] public bool Selected { get; set; }
[Parameter] public EventCallback<Product> OnPick { get; set; }
// 나머지 속성을 통째로 받아 <div> 에 넘기기 (class, data-* …)
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? Extra { get; set; }
}
@* 쓰는 쪽 *@
<ProductCard Product="p" Selected="@(p.Id == pickedId)"
OnPick="Add" data-testid="card" />
[Parameter] 프로퍼티는 반드시 public 이고 setter 가 있어야 합니다.
그리고 내가 안에서 바꾸면 안 됩니다 —
부모가 다시 렌더할 때 원래 값으로 덮어써집니다.
바꿔야 할 값은 EventCallback 으로 부모에게 알리고 부모가 바꾸게 합니다
(React 의 "상태는 위로" 와 완전히 같은 규칙입니다).
props 대신 [Parameter] 입니다.자식을 끼워 넣기 — RenderFragment 🟡 중급
ChildContent — Vue 의 slot 자리
@* Components/Panel.razor — 껍데기만 제공하는 컴포넌트 *@
<section class="panel">
<header>
@if (Header is not null) { @Header }
else { <h3>@Title</h3> }
</header>
<div class="body">@ChildContent</div>
</section>
@code {
[Parameter] public string? Title { get; set; }
[Parameter] public RenderFragment? Header { get; set; } // 이름 있는 슬롯
[Parameter] public RenderFragment? ChildContent { get; set; } // 기본 슬롯
}
@* 쓰는 쪽 — 태그 안에 넣은 것이 ChildContent 로 들어갑니다 *@
<Panel Title="재고">
<Header><h3>재고 <small>@items.Count 건</small></h3></Header>
<p>여기가 ChildContent 입니다</p>
</Panel>
ChildContent 인 것은 약속입니다.
이 이름이면 태그 사이에 그냥 쓴 내용이 자동으로 들어가고,
다른 이름이면 <Header>…</Header> 처럼 이름 태그로 감싸야 합니다.
템플릿 — 항목마다 다른 모양을 부모가 정하게
@* Components/DataList.razor — 제네릭 컴포넌트 *@
@typeparam TItem
<ul class="list">
@foreach (var item in Items)
{
<li @key="KeyOf(item)">@RowTemplate(item)</li> @* ★ 한 항목을 넘김 *@
}
</ul>
@if (!Items.Any()) { @Empty }
@code {
[Parameter, EditorRequired] public IEnumerable<TItem> Items { get; set; } = [];
[Parameter, EditorRequired] public RenderFragment<TItem> RowTemplate { get; set; } = default!;
[Parameter] public RenderFragment? Empty { get; set; }
[Parameter] public Func<TItem, object> KeyOf { get; set; } = x => x!;
}
@* 쓰는 쪽 — context 가 그 항목입니다 *@
<DataList Items="products" KeyOf="p => p.Id">
<RowTemplate Context="p">
<b>@p.Name</b> — @p.Stock 개
</RowTemplate>
<Empty><p class="dim">재고가 없습니다</p></Empty>
</DataList>
RenderFragment<T> 는 사실 Func<T, RenderFragment> 입니다.
그래서 @RowTemplate(item) 처럼 함수를 부르듯 씁니다.
기본 변수 이름은 context 이고,
중첩하면 이름이 겹치므로 Context="p" 로 바꿔 주는 습관을 들이세요.
실무 문법 — 이벤트 · @key · 코드비하인드 🔴 실무
이벤트와 바인딩
@* ① 기본 이벤트 — 인자 없이도, 이벤트 객체를 받아도 됩니다 *@
<button @onclick="Save">저장</button>
<button @onclick="@(e => Remove(item.Id))">삭제</button>
<input @onkeydown="OnKey" />
@code { void OnKey(KeyboardEventArgs e) { if (e.Key == "Enter") Search(); } }
@* ② 기본 동작 막기 · 전파 막기 — JS 의 preventDefault 자리 *@
<a href="/x" @onclick="Go" @onclick:preventDefault>이동</a>
<div @onclick="Outer"><span @onclick="Inner" @onclick:stopPropagation>안</span></div>
@* ③ 양방향 바인딩 — @bind 는 value + onchange 의 축약 *@
<input @bind="query" /> @* 포커스가 빠질 때 반영 *@
<input @bind="query" @bind:event="oninput" /> @* 한 글자마다 반영 *@
<input @bind="price" @bind:format="N0" /> @* 표시 형식 *@
<input @bind="query" @bind:after="SearchAsync" /> @* 반영 뒤 실행 *@
@* ④ 자식 컴포넌트와 양방향 — 이름 규칙이 Value / ValueChanged *@
<NumberBox @bind-Value="qty" />
@code {
[Parameter] public int Value { get; set; }
[Parameter] public EventCallback<int> ValueChanged { get; set; }
}
@key 와 코드비하인드
@* @key 가 없으면 — 목록 중간에 하나를 지웠을 때 *@
@foreach (var t in todos)
{
<input @bind="t.Text" /> @* ❌ 요소가 재사용되어 포커스·입력 중 상태가 옆 항목으로 넘어갑니다 *@
}
@* @key 를 주면 Blazor 가 '같은 항목'을 알아봅니다 *@
@foreach (var t in todos)
{
<input @key="t.Id" @bind="t.Text" /> @* ✅ *@
}
@* 키는 안정적인 식별자여야 합니다 — 인덱스나 랜덤 값은 의미가 없습니다 *@
// Counter.razor.cs — 코드가 길어지면 부분 클래스로 분리
namespace Inventory.Web.Components.Pages;
public partial class Counter : ComponentBase // ★ partial 이 핵심
{
[Inject] private ILogger<Counter> Log { get; set; } = default!;
[Parameter] public int Start { get; set; }
private int count;
protected override void OnInitialized() => count = Start;
private void Increment() { count++; Log.LogInformation("{C}", count); }
}
// .razor 쪽에서는 @code 블록과 @inject 를 지우면 됩니다.
// 테스트(bUnit)를 붙일 때 이 분리가 훨씬 편합니다.
@code 가 50줄을 넘어가면 나눕니다.
디자이너·퍼블리셔와 파일을 나눠 만질 때도 유용합니다.
둘 중 어느 쪽에 써도 동작은 완전히 같습니다.
.razor 는 HTML + @ 부터는 C# 입니다.
부모는 [Parameter] 로 값을 내려보내고 자식은 EventCallback 으로 알립니다.
태그 사이의 내용은 RenderFragment ChildContent 로,
항목마다 다른 모양은 RenderFragment<T> 로 받습니다.
목록에는 반드시 @key 를 붙이세요.상태 · 수명주기 — 언제 다시 그려지나
React 는 setState 를 부르면 알아서 다시 그렸습니다.
Blazor 는 이벤트 처리기가 끝나면 자동으로 다시 그리지만,
그 밖에서 상태가 바뀌면 내가 직접 알려야 합니다. 이 경계가 초보의 첫 함정입니다.
IJSRuntime 호출은 반드시 5단계에서.StateHasChanged — 언제 부르나
@implements IDisposable
@inject NotificationService Notices
<p>남은 시간 @remain 초</p>
@code {
private Timer? timer;
private int remain = 60;
protected override void OnInitialized()
{
// ❌ 타이머 콜백은 '이벤트 처리기'가 아닙니다 — 자동 렌더가 안 됩니다
timer = new Timer(_ =>
{
remain--;
InvokeAsync(StateHasChanged); // ★ 이 한 줄이 필요합니다
}, null, 1000, 1000);
Notices.OnChange += Refresh; // 다른 서비스가 알려 줄 때도 마찬가지
}
private void Refresh() => InvokeAsync(StateHasChanged);
public void Dispose()
{
timer?.Dispose();
Notices.OnChange -= Refresh; // ★ 안 빼면 회로가 안 죽습니다
}
}
InvokeAsync 로 감싸는 이유 — Blazor 는 렌더 전용 스레드에서만
화면을 건드릴 수 있습니다. 타이머·백그라운드 작업·SignalR 콜백은 다른 스레드에서 옵니다.
그냥 StateHasChanged() 를 부르면
"The current thread is not associated with the Dispatcher" 예외가 납니다.
WPF 의 Dispatcher.Invoke 와 정확히 같은 이야기입니다.
자동으로 다시 그려지는 경우 · 아닌 경우
| 상황 | 자동? |
|---|---|
@onclick 등 UI 이벤트 처리기 | 예 |
| 수명주기 메서드(Initialized·ParametersSet) | 예 |
| 부모가 파라미터를 바꿔 넣음 | 예 |
EventCallback 을 통해 호출됨 | 예(양쪽 다) |
await 이 처음 멈추는 지점 | 예(중간 렌더) |
타이머 · System.Timers.Timer | 아니오 |
| 백그라운드 서비스가 이벤트 발생 | 아니오 |
| SignalR 허브 메시지 수신 | 아니오 |
Action 델리게이트 콜백 | 아니오 |
컬렉션 내용만 바꿈(list.Add) | 렌더는 되지만 같은 참조면 자식은 갱신 안 될 수 있음 |
InvokeAsync(StateHasChanged).
외우기 쉬운 기준 하나 — 사용자가 화면을 건드려서 생긴 일이면 자동,
서버·시간·외부가 만든 일이면 수동입니다.
여러 컴포넌트가 상태를 나눠 쓸 때 — 캐스케이딩과 상태 컨테이너 🟡 중급
캐스케이딩 파라미터 — 깊이 상관없이 내려보내기
@* MainLayout.razor — 한 번 감싸면 아래 전부가 받습니다 *@
<CascadingValue Value="theme" IsFixed="true">
<CascadingValue Value="this" Name="Layout">
@Body
</CascadingValue>
</CascadingValue>
@* 아무리 깊은 자손이든 받아 쓸 수 있습니다 *@
@code {
[CascadingParameter] public Theme Theme { get; set; } = default!;
[CascadingParameter(Name = "Layout")] public MainLayout? Layout { get; set; }
}
@* 서비스로 등록하는 방식도 있습니다 (Program.cs) — .NET 8+ *@
builder.Services.AddCascadingValue(sp => new Theme("dark"));
IsFixed="true" 를 꼭 붙이세요.
값이 바뀌지 않는다고 알려 주면 Blazor 가 구독 목록을 만들지 않습니다.
안 붙이면 자손이 수백 개일 때 부모가 렌더될 때마다 전부 검사합니다 —
큰 화면에서 눈에 띄게 느려지는 흔한 원인입니다.
상태 컨테이너 — Redux · Pinia 자리
// Shared/CartState.cs — 그냥 평범한 클래스입니다
public sealed class CartState
{
private readonly List<CartLine> lines = [];
public IReadOnlyList<CartLine> Lines => lines;
public event Action? OnChange; // 구독 지점
public void Add(Product p, int qty)
{
var line = lines.FirstOrDefault(l => l.ProductId == p.Id);
if (line is null) lines.Add(new CartLine(p.Id, p.Name, qty));
else line.Qty += qty;
OnChange?.Invoke(); // ★ 바뀌었다고 알림
}
}
// Program.cs — 등록 범위가 중요합니다 (아래 표 참고)
builder.Services.AddScoped<CartState>();
// 쓰는 쪽
@implements IDisposable
@inject CartState Cart
@code {
protected override void OnInitialized() => Cart.OnChange += Redraw;
private void Redraw() => InvokeAsync(StateHasChanged);
public void Dispose() => Cart.OnChange -= Redraw; // ★ 반드시
}
DI 수명 — Server 와 WASM 이 다릅니다 🔴 실무
같은 코드, 다른 의미
| Interactive Server | Interactive WASM | |
|---|---|---|
| Singleton | 서버 프로세스 전체 = 모든 사용자 공유 ⚠ | 브라우저 탭 하나 = 그 사용자만 |
| Scoped | 회로 하나 = 탭 하나 · 몇 시간 살아 있음 | Singleton 과 동일 = 앱이 살아 있는 동안 |
| Transient | 주입할 때마다 새로 | 주입할 때마다 새로 |
| DbContext | Factory 필수(b06) | 아예 못 씀 |
회로가 오래 사는 것의 대가
// ❌ Blazor Server 에서 이러면 안 됩니다
builder.Services.AddScoped<HttpClient>(); // 회로 내내 한 개 — 소켓 고갈
builder.Services.AddScoped<GameDb>(); // 몇 시간 살아 있는 DbContext
// ✅ 짧게 쓰고 버리는 형태로
builder.Services.AddHttpClient<ApiClient>(c =>
c.BaseAddress = new Uri("https://api.internal/")); // 팩토리가 관리
builder.Services.AddDbContextFactory<GameDb>(o =>
o.UseNpgsql(cfg.GetConnectionString("db")));
// 컴포넌트 하나가 쓰는 동안만 살아 있는 스코프가 필요할 때
@inherits OwningComponentBase<ReportService>
@code {
// Service 프로퍼티가 이 컴포넌트 전용 스코프에서 만들어지고
// 컴포넌트가 사라질 때 함께 Dispose 됩니다
protected override async Task OnInitializedAsync()
=> rows = await Service.BuildAsync();
}
AddScoped<DbContext>)을 그대로 옮기면
연결이 안 돌아오고 메모리가 새는 형태로 나타납니다.
InvokeAsync(StateHasChanged).
상태 공유는 DI 서비스 + 이벤트로 하고, 구독은 반드시 Dispose 에서 해제합니다.폼 · 검증 — EditForm 하나로 끝냅니다
웹 화면의 절반은 폼입니다. Blazor 는 서버에서 쓰던 검증 속성을 그대로 화면에 씁니다 — 규칙을 두 번 쓰지 않는 것이 이 프레임워크의 가장 실감 나는 이득입니다.
모델과 폼 — 이게 전부입니다
// Shared/ProductForm.cs — 서버 API 와 화면이 같이 씁니다
public sealed class ProductForm
{
[Required(ErrorMessage = "상품명은 필수입니다")]
[StringLength(60, MinimumLength = 2)]
public string Name { get; set; } = "";
[Range(0, 100_000_000, ErrorMessage = "0 이상이어야 합니다")]
public int Price { get; set; }
[Required] public string Category { get; set; } = "";
[EmailAddress] public string? ContactEmail { get; set; }
[DataType(DataType.Date)] public DateOnly? DiscontinuedAt { get; set; }
}
@page "/products/new"
@rendermode InteractiveServer
<EditForm Model="form" OnValidSubmit="SaveAsync" FormName="productNew">
<DataAnnotationsValidator /> @* 속성을 읽어 검증을 켭니다 *@
<ValidationSummary /> @* 위쪽에 오류 목록 *@
<label>상품명</label>
<InputText @bind-Value="form.Name" class="in" />
<ValidationMessage For="() => form.Name" /> @* 그 칸 아래 오류 *@
<label>가격</label>
<InputNumber @bind-Value="form.Price" class="in" />
<InputSelect @bind-Value="form.Category">
<option value="">선택</option>
@foreach (var c in cats) { <option value="@c">@c</option> }
</InputSelect>
<button type="submit" disabled="@saving">@(saving ? "저장 중…" : "저장")</button>
</EditForm>
@code {
[SupplyParameterFromForm] private ProductForm form { get; set; } = new();
private bool saving;
private async Task SaveAsync()
{
saving = true;
try { await Api.CreateAsync(form); Nav.NavigateTo("/products"); }
finally { saving = false; }
}
}
기본 입력 컴포넌트 정리
| 컴포넌트 | 맞는 타입 |
|---|---|
InputText | string |
InputTextArea | string(여러 줄) |
InputNumber<T> | int·long·decimal·double |
InputCheckbox | bool |
InputDate<T> | DateOnly·DateTime |
InputSelect<T> | 열거형·문자열 |
InputRadioGroup<T> | + InputRadio |
InputFile | 파일 업로드 |
<input> 대신 <InputText> 인가 —
Input* 계열은 EditContext 에 자기를 등록합니다.
그래서 오류가 난 칸에 자동으로 invalid CSS 클래스가 붙고,
ValidationMessage 가 그 필드의 오류만 골라 보여 줄 수 있습니다.
평범한 <input @bind> 를 쓰면 이 연결이 끊깁니다.
FormName 은 Static SSR 폼에서 필수입니다 —
한 페이지에 폼이 둘 이상일 때 누가 제출됐는지를 이 이름으로 구분합니다.
빠뜨리면 app.UseAntiforgery() 와 맞물려 400 이 납니다.
기본 검증으로 안 되는 것들 — 커스텀 · 서버 오류 🟡 중급
규칙을 직접 만들기
// ① 속성 하나 만들기 — 서버·화면 양쪽에서 그대로 동작합니다
public sealed class NotWeekendAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is DateOnly d &&
d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
return new ValidationResult("주말은 선택할 수 없습니다",
[ctx.MemberName!]);
return ValidationResult.Success;
}
}
// ② 여러 필드를 한꺼번에 보는 규칙은 IValidatableObject
public sealed class PeriodForm : IValidatableObject
{
public DateOnly From { get; set; }
public DateOnly To { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (To < From)
yield return new("종료일이 시작일보다 빠릅니다", [nameof(To)]);
}
}
서버가 돌려준 오류를 폼에 붙이기
@code {
private EditContext? ctx;
private ValidationMessageStore? store;
protected override void OnInitialized()
{
ctx = new EditContext(form);
store = new ValidationMessageStore(ctx);
}
private async Task SaveAsync()
{
store!.Clear(); // 이전 서버 오류 지우기
var res = await Api.CreateAsync(form);
if (res.IsSuccess) { Nav.NavigateTo("/products"); return; }
// 서버가 준 { "Name": ["이미 있는 이름입니다"] } 를 그 칸에 꽂습니다
foreach (var (field, msgs) in res.Errors)
foreach (var m in msgs)
store.Add(ctx!.Field(field), m);
ctx!.NotifyValidationStateChanged(); // ★ 화면에 반영
}
}
@* EditContext 를 직접 만들었으면 Model 대신 이걸 넘깁니다 *@
<EditForm EditContext="ctx" OnValidSubmit="SaveAsync"> … </EditForm>
파일 업로드 — 스트림으로 받습니다 🔴 실무
InputFile
<InputFile OnChange="OnFilesAsync" multiple accept="image/*" />
@if (progress > 0) { <progress value="@progress" max="100"></progress> }
@code {
private const long MaxSize = 10 * 1024 * 1024; // 10MB
private int progress;
private async Task OnFilesAsync(InputFileChangeEventArgs e)
{
foreach (var file in e.GetMultipleFiles(maximumFileCount: 5))
{
if (file.Size > MaxSize) { Toast("10MB 를 넘습니다"); continue; }
// ★ OpenReadStream 은 기본 512,000바이트(약 500KB) 제한 — 반드시 넘겨 줍니다
await using var src = file.OpenReadStream(MaxSize);
var safeName = $"{Guid.NewGuid():N}{Path.GetExtension(file.Name)}";
var path = Path.Combine(uploadRoot, safeName); // ★ 원본 이름 금지
await using var dst = File.Create(path);
await src.CopyToAsync(dst);
}
}
}
../../etc/passwd 같은 값이 들어오면 서버 어디든 덮어쓸 수 있습니다.
새 이름을 만들어 저장하고, 원래 이름은 DB 컬럼에만 보관합니다.
모드별로 갈리는 지점
- Interactive Server — 파일이 SignalR 회로를 타고 올라옵니다. 큰 파일은 회로를 막습니다. 수 MB 이상은 별도 HTTP 엔드포인트로 보내세요
- Interactive WASM — 브라우저에서
HttpClient로MultipartFormDataContent를 보냅니다. 회로 부담이 없습니다 - Static SSR — 평범한
<input type="file">처럼 동작합니다. 가장 안정적입니다 - 이미지는
file.RequestImageFileAsync("image/jpeg", 800, 800)로 브라우저에서 미리 줄여 올리면 전송량이 급감합니다
// 큰 파일은 전용 엔드포인트로 — 회로를 지나가지 않습니다
app.MapPost("/upload", async (IFormFile file, IStorage store) =>
{
if (file.Length > 200 * 1024 * 1024) return Results.BadRequest();
await using var s = file.OpenReadStream();
var key = await store.PutAsync(s, file.ContentType);
return Results.Ok(new { key });
}).DisableAntiforgery() // 쿠키가 아니라 Bearer 토큰으로 인증할 때만
.RequireAuthorization();
OpenReadStream(기본 약 500KB), Kestrel 의 요청 본문 한도(기본 30MB),
그리고 앞단 Nginx/IIS 의 한도입니다.
"업로드가 안 돼요" 는 대개 세 번째입니다.
<EditForm Model> + <DataAnnotationsValidator> +
Input* 컴포넌트, 이 세 개가 폼의 전부입니다.
검증 속성은 서버 모델에 한 번만 적고 화면과 API 가 같이 씁니다.
다만 브라우저 검증은 편의일 뿐이니 저장하는 쪽에서 반드시 다시 검사하세요.데이터 — DB 를 직접 쓸 때와 API 를 거칠 때
Interactive Server 는 DbContext 를 그 자리에서 쓸 수 있습니다 — 이게 가장 큰 매력입니다. 단, 회로가 몇 시간 살아 있다는 사실 때문에 평소 웹 API 에서 하던 방식이 그대로는 통하지 않습니다.
Interactive Server — IDbContextFactory 가 필수인 이유
// Program.cs — AddDbContext 가 아니라 AddDbContextFactory ★
builder.Services.AddDbContextFactory<InventoryDb>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("inventorydb")));
@inject IDbContextFactory<InventoryDb> DbFactory
@code {
private List<Product> items = [];
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
// ★ 필요할 때 만들고, 그 블록이 끝나면 즉시 반납합니다
await using var db = await DbFactory.CreateDbContextAsync();
items = await db.Products
.AsNoTracking() // 읽기 전용이면 항상
.OrderBy(p => p.Name)
.Take(200)
.ToListAsync();
}
private async Task SaveAsync(Product p)
{
await using var db = await DbFactory.CreateDbContextAsync();
db.Attach(p).State = EntityState.Modified;
await db.SaveChangesAsync();
}
}
AddScoped<DbContext> 를 쓰면 무슨 일이 벌어지나 —
Blazor Server 의 Scoped 는 회로 수명이라, DbContext 하나가 몇 시간 살아 있게 됩니다.
① 추적 엔티티가 쌓여 메모리가 계속 늘고,
② 사용자가 버튼을 빠르게 두 번 누르면 같은 DbContext 에 동시 접근해
A second operation was started on this context 예외가 나며,
③ 커넥션 풀이 고갈됩니다.
Factory 는 이 세 가지를 한 번에 해결합니다.
WASM · Auto — HttpClient 와 토큰
// Client/Program.cs — 브라우저 쪽 DI
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// 인증이 붙은 API 를 부를 때는 토큰을 붙이는 핸들러를 끼웁니다 (아래 BearerHandler)
builder.Services.AddTransient<BearerHandler>();
builder.Services.AddHttpClient("api", c =>
c.BaseAddress = new Uri("https://api.example.com/"))
.AddHttpMessageHandler<BearerHandler>();
// 핸들러 직접 만들기 — 매 요청에 토큰을 붙입니다
public sealed class BearerHandler(ITokenStore store) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage req, CancellationToken ct)
{
var token = await store.GetAsync(ct);
if (token is not null)
req.Headers.Authorization = new("Bearer", token);
return await base.SendAsync(req, ct);
}
}
@inject IHttpClientFactory Http
@code {
private Product[]? items; // null = 아직 로딩 중
private string? error;
protected override async Task OnInitializedAsync()
{
try
{
var c = Http.CreateClient("api");
items = await c.GetFromJsonAsync<Product[]>("products");
}
catch (HttpRequestException ex) { error = "목록을 불러오지 못했습니다"; }
}
}
@if (error is not null) { <p class="err">@error</p> }
else if (items is null) { <p>불러오는 중…</p> }
else if (items.Length == 0) { <p>결과가 없습니다</p> }
else { @foreach (var p in items) { <ProductCard Product="p" /> } }
items 를 = [] 로 초기화해 버리면
로딩 중과 "결과 없음"이 구분되지 않아
데이터가 있는데도 "없습니다" 가 잠깐 스쳐 지나갑니다. null 로 시작하세요.
10만 행을 끊김 없이 — Virtualize 와 QuickGrid 🟡 중급
Virtualize — 직접 구간을 가져올 때
<div class="tbl" style="height:600px; overflow-y:auto">
<Virtualize ItemsProvider="LoadRowsAsync" ItemSize="36"
OverscanCount="6" Context="row" @ref="grid">
<ItemContent>
<div class="tr" @key="row.Id">
<span>@row.Sku</span><span>@row.Name</span>
<span>@row.Stock</span>
</div>
</ItemContent>
<Placeholder>
<div class="tr skeleton"></div> @* 아직 안 온 구간 *@
</Placeholder>
</Virtualize>
</div>
@code {
private Virtualize<Row>? grid;
private async ValueTask<ItemsProviderResult<Row>> LoadRowsAsync(
ItemsProviderRequest req)
{
await using var db = await DbFactory.CreateDbContextAsync();
var total = await db.Products.CountAsync(req.CancellationToken);
var rows = await db.Products.AsNoTracking()
.OrderBy(p => p.Id)
.Skip(req.StartIndex).Take(req.Count)
.Select(p => new Row(p.Id, p.Sku, p.Name, p.Stock))
.ToListAsync(req.CancellationToken);
return new ItemsProviderResult<Row>(rows, total);
}
// 데이터가 바뀌었을 때 새로 읽게 하기
private async Task ReloadAsync() => await grid!.RefreshDataAsync();
}
req.CancellationToken 을 반드시 넘기세요.
사용자가 스크롤을 빠르게 굴리면 이전 구간 요청이 줄줄이 취소되는데,
토큰을 안 넘기면 취소된 쿼리가 DB 에서 계속 돕니다.
스크롤 한 번에 DB 부하가 튀는 흔한 원인입니다.
QuickGrid — 정렬 · 페이징까지 얹혀 옵니다
dotnet add package Microsoft.AspNetCore.Components.QuickGrid
dotnet add package Microsoft.AspNetCore.Components.QuickGrid.EntityFrameworkAdapter
@* Program.cs: builder.Services.AddQuickGridEntityFrameworkAdapter(); *@
@using Microsoft.AspNetCore.Components.QuickGrid
@inject IDbContextFactory<InventoryDb> DbFactory
@implements IAsyncDisposable
<input @bind="q" @bind:event="oninput" placeholder="검색" />
<QuickGrid Items="Filtered" Pagination="page" Virtualize="true">
<PropertyColumn Property="p => p.Sku" Title="코드" Sortable="true" />
<PropertyColumn Property="p => p.Name" Title="이름" Sortable="true" />
<PropertyColumn Property="p => p.Price" Title="가격" Format="N0"
Align="Align.Right" Sortable="true" />
<TemplateColumn Title="">
<a href="/products/@context.Id">수정</a>
</TemplateColumn>
</QuickGrid>
<Paginator State="page" />
@code {
private InventoryDb db = default!;
private PaginationState page = new() { ItemsPerPage = 50 };
private string q = "";
// ★ IQueryable 을 넘기면 정렬·페이징이 SQL 로 내려갑니다
private IQueryable<Product> Filtered =>
db.Products.AsNoTracking()
.Where(p => q == "" || p.Name.Contains(q));
protected override async Task OnInitializedAsync()
=> db = await DbFactory.CreateDbContextAsync();
public async ValueTask DisposeAsync() => await db.DisposeAsync();
}
IQueryable 로 넘기는 것이 핵심입니다.
ToList() 를 먼저 부르면 10만 행을 전부 메모리로 끌어온 뒤 자릅니다.
IQueryable 이면 정렬·페이징·검색이 전부
ORDER BY … LIMIT … OFFSET … 한 문장으로 DB 에서 처리됩니다.
사전 렌더링 때문에 API 를 두 번 부르는 문제 🔴 실무
결과를 실어 보내기 — PersistentComponentState
@implements IDisposable
@inject PersistentComponentState AppState
@code {
private Product[]? items;
private PersistingComponentStateSubscription sub;
protected override async Task OnInitializedAsync()
{
// 서버가 렌더할 때 저장해 두라고 등록
sub = AppState.RegisterOnPersisting(() =>
{
AppState.PersistAsJson("products", items);
return Task.CompletedTask;
});
// 브라우저에서 살아날 때: 이미 실려 온 값이 있으면 그걸 씁니다
if (AppState.TryTakeFromJson<Product[]>("products", out var cached))
items = cached;
else
items = await Api.GetProductsAsync(); // 없을 때만 진짜 호출
}
public void Dispose() => sub.Dispose();
}
[PersistentState] public Product[]? Items { get; set; }
로 선언하면 위 등록·복원 코드를 프레임워크가 대신합니다.
원리는 같으니 동작을 이해한 뒤 짧은 문법으로 넘어가세요.
표가 느릴 때 보는 순서
| 증상 | 먼저 확인할 것 |
|---|---|
| 목록이 늦게 뜬다 | N+1 쿼리 — Include 또는 Select 투영으로 한 방에 |
| 스크롤이 버벅인다 | Virtualize 미적용 · 행 높이 불균일 |
| 메모리가 계속 는다 | AsNoTracking 누락 · 회로 수명 DbContext |
| 검색이 느리다 | Contains 가 인덱스를 못 탑니다 — 전문 검색 · 접두 검색으로 |
| 클릭 반응이 느리다 | Server 회로의 왕복 지연 — 낙관적 UI 로 먼저 반영 |
| 같은 데이터를 두 번 부름 | 사전 렌더링 — 위 PersistentComponentState |
@bind:event="oninput" 검색은
디바운스(300ms 정도 모아 보내기)를 반드시 걸어 주세요.
IDbContextFactory 로 짧게 쓰고 버리기,
WASM·Auto 에서는 HttpClient + 토큰으로 API 를 부릅니다.
행이 많으면 Virtualize 또는 QuickGrid(IQueryable 로),
사전 렌더링이 API 를 두 번 부르면 PersistentComponentState 로 결과를 실어 보냅니다.인증 · 인가 — 누구인지가 화면까지 내려오는 길
ASP.NET Core 탭에서 만든 JWT 는 API 를 지키는 장치였습니다. 여기서는 그 정보가 화면 트리 구석구석까지 흘러가 버튼 하나를 보이거나 감추는 데까지 쓰입니다.
AuthenticationStateProvider 가 중심입니다 — 어디서 정보를 얻느냐만 다릅니다.템플릿으로 로그인을 통째로 받기
# 로그인·회원가입·비밀번호 재설정·2단계 인증 화면까지 전부 생성됩니다
dotnet new blazor -o Inventory.Web -int Auto -au Individual
# 생성된 것
# Components/Account/Pages/Login.razor, Register.razor, Manage/*
# Data/ApplicationDbContext.cs, ApplicationUser.cs
# IdentityRevalidatingAuthenticationStateProvider.cs
#
# 기본은 SQLite 입니다 — Postgres 로 바꾸려면
dotnet add package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet ef migrations add Init
dotnet ef database update
// Program.cs (템플릿이 넣어 주는 핵심 줄들)
builder.Services.AddCascadingAuthenticationState(); // ★ 트리에 내려보내기
builder.Services.AddScoped<IdentityUserAccessor>();
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = IdentityConstants.ApplicationScheme;
o.DefaultSignInScheme = IdentityConstants.ExternalScheme;
}).AddIdentityCookies();
builder.Services.AddIdentityCore<ApplicationUser>(o =>
o.SignIn.RequireConfirmedAccount = true)
.AddRoles<IdentityRole>() // 역할을 쓸 거라면
.AddEntityFrameworkStores<ApplicationDbContext>()
.AddSignInManager()
.AddDefaultTokenProviders();
Set-Cookie 로 인증 쿠키를 심을 수 있기 때문입니다.
SignalR 회로 위에서는 응답 헤더를 손댈 수 없습니다 —
템플릿의 Account 폴더에 @rendermode 가 없는 이유가 이것입니다.
화면에서 쓰는 네 가지 형태
@* ① 페이지 접근 자체를 막기 *@
@page "/admin/stock"
@attribute [Authorize(Roles = "Manager,Admin")]
@* ② 일부만 보이거나 감추기 *@
<AuthorizeView>
<Authorized>
<span>@context.User.Identity!.Name 님</span>
<button @onclick="Logout">로그아웃</button>
</Authorized>
<NotAuthorized>
<a href="Account/Login">로그인</a>
</NotAuthorized>
<Authorizing><span class="dim">확인 중…</span></Authorizing>
</AuthorizeView>
@* ③ 역할 · 정책으로 좁히기 *@
<AuthorizeView Roles="Admin"> <button>삭제</button> </AuthorizeView>
<AuthorizeView Policy="CanAdjustStock"><button>재고 조정</button></AuthorizeView>
@* ④ 코드에서 직접 알아내기 *@
@code {
[CascadingParameter] private Task<AuthenticationState>? AuthTask { get; set; }
private async Task<bool> IsManagerAsync()
{
var state = await AuthTask!;
return state.User.IsInRole("Manager");
}
}
// 정책은 Program.cs 에 한 번 정의하고 이름으로 씁니다
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanAdjustStock", p =>
p.RequireAuthenticatedUser()
.RequireClaim("dept", "logistics")
.RequireAssertion(c => c.User.IsInRole("Manager")
|| c.User.HasClaim("perm", "stock.write")));
});
Auto 모드의 인증 — 두 세계를 잇기 🟡 중급
서버 상태를 브라우저로 실어 보내기
// Client/PersistentAuthenticationStateProvider.cs (템플릿 생성물)
// 서버가 렌더할 때 사용자 정보를 실어 보내고,
// 브라우저 쪽은 그걸 받아 상태를 만듭니다 — 다시 물어보지 않습니다
internal sealed class PersistentAuthenticationStateProvider
: AuthenticationStateProvider
{
private static readonly Task<AuthenticationState> Anonymous =
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(
new ClaimsIdentity())));
private readonly Task<AuthenticationState> state = Anonymous;
public PersistentAuthenticationStateProvider(PersistentComponentState s)
{
if (!s.TryTakeFromJson<UserInfo>(nameof(UserInfo), out var u) || u is null)
return;
Claim[] claims = [
new(ClaimTypes.NameIdentifier, u.UserId),
new(ClaimTypes.Name, u.Email),
new(ClaimTypes.Email, u.Email)];
state = Task.FromResult(new AuthenticationState(new ClaimsPrincipal(
new ClaimsIdentity(claims, "PersistentAuth"))));
}
public override Task<AuthenticationState> GetAuthenticationStateAsync() => state;
}
외부 인증 제공자 (OIDC · Entra ID)
// WASM 단독 앱에서 OIDC 로그인
// dotnet add package Microsoft.AspNetCore.Components.WebAssembly.Authentication
builder.Services.AddOidcAuthentication(o =>
{
builder.Configuration.Bind("Oidc", o.ProviderOptions);
o.ProviderOptions.ResponseType = "code"; // PKCE 인가 코드 흐름
o.UserOptions.RoleClaim = "role";
});
// API 를 부르는 HttpClient 에 토큰 자동 첨부
builder.Services.AddHttpClient("api",
c => c.BaseAddress = new Uri("https://api.example.com/"))
.AddHttpMessageHandler(sp =>
sp.GetRequiredService<AuthorizationMessageHandler>()
.ConfigureHandler(
authorizedUrls: ["https://api.example.com"],
scopes: ["api.read", "api.write"]));
@* App.razor / Routes.razor — 권한 없는 접근을 로그인으로 보내기 *@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="route">
<AuthorizeRouteView RouteData="@route"
DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{ <RedirectToLogin /> }
else
{ <p>이 페이지에 접근할 권한이 없습니다.</p> }
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
</CascadingAuthenticationState>
AuthenticationStateProvider 에서 나와
CascadingAuthenticationState 를 타고 트리 전체로 갑니다.
화면에서는 <AuthorizeView>(부분) 와 @attribute [Authorize](페이지) 두 가지만 쓰면 되고,
진짜 검사는 언제나 서버 쪽 코드에서 한 번 더 합니다.JS 상호운용 — 기존 라이브러리를 데려오기
Blazor 로 JS 를 안 쓸 수는 있지만, 차트 · 지도 · 리치 에디터까지 직접 만들 수는 없습니다. 다행히 양방향으로 부르는 길이 잘 닦여 있고, 규칙은 세 가지뿐입니다.
가장 짧은 형태 — 전역 함수 부르기
@inject IJSRuntime JS
<button @onclick="CopyAsync">주문번호 복사</button>
@code {
private async Task CopyAsync()
{
// 반환값이 없으면 InvokeVoidAsync
await JS.InvokeVoidAsync("navigator.clipboard.writeText", orderNo);
// 반환값이 있으면 타입을 지정합니다
var ok = await JS.InvokeAsync<bool>("confirm", "정말 삭제할까요?");
if (!ok) return;
var w = await JS.InvokeAsync<int>("eval", "window.innerWidth");
}
}
// 브라우저 함수는 대부분 그냥 부를 수 있습니다
await JS.InvokeVoidAsync("localStorage.setItem", "theme", "dark");
var theme = await JS.InvokeAsync<string?>("localStorage.getItem", "theme");
await JS.InvokeVoidAsync("window.scrollTo", 0, 0);
await JS.InvokeVoidAsync("console.log", "여기까지 왔습니다");
// 타임아웃을 걸고 싶을 때 (Server 에서 응답이 안 올 수 있으므로)
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
try { await JS.InvokeVoidAsync("slowThing", cts.Token); }
catch (TaskCanceledException) { /* 조용히 넘어갑니다 */ }
localStorage 를 초기화 때 읽으려 하면 반드시 실패합니다.
사전 렌더링 중에는 브라우저가 아직 없기 때문입니다.
OnAfterRenderAsync(firstRender) 로 옮기고,
읽은 뒤에 StateHasChanged() 를 불러 다시 그리는 것이 정석 패턴입니다.
실전 형태 — 모듈로 격리하기
// wwwroot/js/sparkline.js — 전역을 더럽히지 않는 ES 모듈
let charts = new Map();
export function render(el, points, dotnetRef) {
const c = new SomeChartLib(el, { data: points });
c.on('pick', i => dotnetRef.invokeMethodAsync('OnPointPicked', i));
charts.set(el, c);
}
export function update(el, points) { charts.get(el)?.setData(points); }
export function dispose(el) {
charts.get(el)?.destroy();
charts.delete(el); // ★ 맵에서 지우지 않으면 새어 나갑니다
}
@implements IAsyncDisposable
@inject IJSRuntime JS
<div @ref="host" class="spark"></div>
@code {
private ElementReference host; // ★ DOM 요소를 넘길 때
private IJSObjectReference? module;
private DotNetObjectReference<Sparkline>? selfRef;
[Parameter] public double[] Points { get; set; } = [];
[Parameter] public EventCallback<int> OnPick { get; set; }
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!firstRender) return; // ★ 첫 번째만
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/sparkline.js"); // 경로는 wwwroot 기준
selfRef = DotNetObjectReference.Create(this);
await module.InvokeVoidAsync("render", host, Points, selfRef);
}
[JSInvokable] // ★ JS 가 부를 메서드
public async Task OnPointPicked(int index) => await OnPick.InvokeAsync(index);
public async ValueTask DisposeAsync()
{
if (module is not null)
{
try { await module.InvokeVoidAsync("dispose", host); }
catch (JSDisconnectedException) { } // 회로가 이미 끊긴 경우
await module.DisposeAsync();
}
selfRef?.Dispose();
}
}
실무에서 반드시 걸리는 것들 🔴 실무
증상과 원인
| 증상 | 원인 |
|---|---|
JSException: … is not defined | 스크립트가 아직 로드되지 않았습니다. 모듈 import 방식으로 바꾸세요 |
InvalidOperationException“JavaScript interop calls cannot be issued…” | 사전 렌더링 중 호출. OnAfterRenderAsync 로 |
JSDisconnectedException | 회로가 이미 끊긴 뒤 DisposeAsync 가 불렸습니다. try/catch 로 감싸세요 |
| 페이지를 옮겨 다니면 느려짐 | DotNetObjectReference 미해제 — 컴포넌트가 GC 되지 않습니다 |
| Server 에서만 매우 느림 | 호출 하나가 네트워크 왕복입니다. 반복문 안의 호출을 한 번에 배열로 |
| 큰 데이터 전달이 느림 | JSON 직렬화 비용. IJSStreamReference 로 스트림 전달 |
가능하면 JS 없이 — 대체 수단
- 포커스 —
await el.FocusAsync()(ElementReference확장 메서드) - 페이지 이동 —
NavigationManager.NavigateTo("/x", forceLoad: false) - 클래스 토글 — C# 상태로
class="@(open ? "on" : "")" - 키보드 · 마우스 —
@onkeydown·@onmousemove로 이벤트 인자를 그대로 받습니다 - 파일 다운로드 — 서버 엔드포인트로 링크를 걸면 JS 가 필요 없습니다
- 애니메이션 — CSS 트랜지션이 언제나 더 부드럽습니다
// WASM 에서만: 동기 호출이 가능합니다 (성능이 필요한 좁은 자리)
if (JS is IJSInProcessRuntime sync)
sync.InvokeVoid("doSomethingFast", value);
// 큰 파일을 JS → C# 으로 넘길 때는 스트림으로
var streamRef = await JS.InvokeAsync<IJSStreamReference>("getBigBlob");
await using var stream = await streamRef.OpenReadStreamAsync(
maxAllowedSize: 50 * 1024 * 1024);
await stream.CopyToAsync(destination);
IJSRuntime 으로 모듈을 import 해 참조를 보관하고,
DotNetObjectReference + [JSInvokable] 로 JS 가 C# 을 부르게 합니다.
호출은 반드시 OnAfterRenderAsync(firstRender) 부터,
정리는 IAsyncDisposable 에서 모듈과 참조 둘 다.실시간 · SignalR — 회로가 곧 화면입니다
Interactive Server 가 동작하는 원리 자체가 SignalR 입니다. 연결이 곧 화면의 생명줄이라, 끊김을 어떻게 다루느냐가 사용자 경험을 통째로 좌우합니다.
회로 옵션 — 실제로 만지는 값들
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents(o =>
{
// 끊긴 회로를 얼마나 보관할지 — 지하철 · 엘리베이터 대비
o.DisconnectedCircuitRetentionPeriod = TimeSpan.FromMinutes(3);
o.DisconnectedCircuitMaxRetained = 100;
// JS 호출이 안 돌아올 때 포기하는 시간
o.JSInteropDefaultCallTimeout = TimeSpan.FromSeconds(30);
// 개발 중에만 자세한 오류를 브라우저로 (운영에서는 절대 금지)
o.DetailedErrors = builder.Environment.IsDevelopment();
})
.AddHubOptions(o =>
{
o.ClientTimeoutInterval = TimeSpan.FromSeconds(30);
o.KeepAliveInterval = TimeSpan.FromSeconds(15);
o.MaximumReceiveMessageSize = 64 * 1024; // 파일은 여기로 보내지 마세요
});
// 여러 대로 늘릴 때 — 백플레인 + 고정 세션
builder.Services.AddSignalR()
.AddStackExchangeRedis(cfg.GetConnectionString("cache")!);
재연결 UI 를 우리 말로
@* App.razor — .NET 10 템플릿은 재연결 화면을 컴포넌트로 만들어 둡니다 *@
<body>
<Routes />
<ReconnectModal /> @* 기본 재연결 화면 *@
<script src="_framework/blazor.web.js"></script>
</body>
// 직접 만들려면 — 자동 시작을 끄고 콜백을 답니다
<script src="_framework/blazor.web.js" autostart="false"></script>
<script>
Blazor.start({
circuit: {
reconnectionOptions: {
maxRetries: 12,
retryIntervalMilliseconds: (n) => Math.min(1000 * 2 ** n, 20000),
},
reconnectionHandler: {
onConnectionDown: () => document.body.classList.add('offline'),
onConnectionUp: () => document.body.classList.remove('offline'),
},
},
});
</script>
// 회로가 열리고 닫히는 것을 서버에서 관찰하기 (접속자 수 · 정리)
public sealed class PresenceCircuitHandler(Presence presence) : CircuitHandler
{
public override Task OnCircuitOpenedAsync(Circuit c, CancellationToken ct)
=> presence.AddAsync(c.Id, ct);
public override Task OnCircuitClosedAsync(Circuit c, CancellationToken ct)
=> presence.RemoveAsync(c.Id, ct); // ★ 여기가 안 불릴 수도 있습니다
}
builder.Services.AddScoped<CircuitHandler, PresenceCircuitHandler>();
별도 허브로 알림 만들기 — 회로와는 다른 이야기 🟡 중급
서버 쪽 — 허브와 밀어 넣기
// Hubs/StockHub.cs — 재고가 바뀌면 보고 있는 사람 모두에게
[Authorize]
public sealed class StockHub : Hub
{
public async Task Watch(string sku)
=> await Groups.AddToGroupAsync(Context.ConnectionId, $"sku:{sku}");
public async Task Unwatch(string sku)
=> await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"sku:{sku}");
}
app.MapHub<StockHub>("/hub/stock");
// 다른 곳(주문 처리 서비스)에서 밀어 넣기
public sealed class StockNotifier(IHubContext<StockHub> hub)
{
public Task ChangedAsync(string sku, int left)
=> hub.Clients.Group($"sku:{sku}").SendAsync("StockChanged", sku, left);
}
클라이언트 쪽 — 컴포넌트에서 붙기
@implements IAsyncDisposable
@inject NavigationManager Nav
@inject IAccessTokenProvider TokenProvider @* WASM 인증일 때 *@
<p>남은 재고 @left 개 @if (live) { <span class="dot">●</span> }</p>
@code {
[Parameter] public string Sku { get; set; } = "";
private HubConnection? conn;
private int left; private bool live;
protected override async Task OnInitializedAsync()
{
conn = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/hub/stock"), o =>
o.AccessTokenProvider = async () =>
{
var r = await TokenProvider.RequestAccessToken();
return r.TryGetToken(out var t) ? t.Value : null;
})
.WithAutomaticReconnect([TimeSpan.Zero, TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15)])
.Build();
conn.On<string, int>("StockChanged", (sku, n) =>
{
if (sku != Sku) return;
left = n;
InvokeAsync(StateHasChanged); // ★ 허브 콜백은 자동 렌더 아님
});
conn.Reconnected += async _ =>
{
await conn.InvokeAsync("Watch", Sku); // 그룹은 다시 넣어야 합니다
left = await Api.GetStockAsync(Sku); // ★ 끊긴 동안의 변화를 당겨오기
live = true; await InvokeAsync(StateHasChanged);
};
conn.Reconnecting += _ => { live = false;
return InvokeAsync(StateHasChanged); };
await conn.StartAsync();
await conn.InvokeAsync("Watch", Sku);
live = true;
}
public async ValueTask DisposeAsync()
{
if (conn is not null) await conn.DisposeAsync();
}
}
InvokeAsync(StateHasChanged),
재연결 뒤에는 그룹 재등록 + 놓친 것 다시 조회가 한 세트입니다.성능 · 배포 — 번들 크기와 회로 한계
Blazor 의 성능 이야기는 두 갈래입니다. WASM 은 "얼마나 내려가느냐", Server 는 "동시에 몇 명이 붙느냐". 둘은 완전히 다른 문제라 대책도 다릅니다.
WASM 크기 줄이기 — csproj 설정
# AOT 를 쓰려면 워크로드가 필요합니다 (설치에 시간이 좀 걸립니다)
dotnet workload install wasm-tools
<PropertyGroup>
<!-- 트리밍: Release 에서는 기본으로 켜져 있습니다 -->
<PublishTrimmed>true</PublishTrimmed>
<TrimMode>full</TrimMode>
<!-- 지역화 데이터(ICU)를 통째로 뺍니다 — 약 300KB 절약 -->
<InvariantGlobalization>true</InvariantGlobalization>
<BlazorWebAssemblyLoadAllGlobalizationData>false
</BlazorWebAssemblyLoadAllGlobalizationData>
<!-- 브로틀리 압축본을 함께 만듭니다 -->
<CompressionEnabled>true</CompressionEnabled>
<!-- 계산이 무거운 앱만: 느려지는 빌드를 감수하고 -->
<RunAOTCompilation>true</RunAOTCompilation>
<WasmStripILAfterAOT>true</WasmStripILAfterAOT>
</PropertyGroup>
<!-- 잘 안 들어가는 화면은 나중에 불러오기 -->
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include="Inventory.Reports.wasm" />
</ItemGroup>
InvariantGlobalization 을 켜면 한국어 정렬·통화 서식이 달라집니다.
ToString("C") 가 원화 기호를 못 붙이고,
string.Compare 의 한글 정렬 순서가 바뀝니다.
날짜·금액 서식을 직접 지정해 쓰고 있는지 확인한 뒤 켜세요.
트리밍이 깨뜨리는 것 — 리플렉션
// ❌ 트리머는 이 타입이 쓰이는지 알 수 없어 통째로 지워 버립니다
var t = Type.GetType("Inventory.Reports." + name);
var obj = Activator.CreateInstance(t!);
// ✅ ① 소스 생성 JSON 직렬화 — 리플렉션을 아예 안 씁니다
[JsonSerializable(typeof(Product))]
[JsonSerializable(typeof(Product[]))]
internal partial class AppJson : JsonSerializerContext { }
var p = JsonSerializer.Deserialize(json, AppJson.Default.Product);
// ✅ ② 지우지 말라고 표시하기
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicConstructors)]
public Type ReportType { get; set; } = default!;
// ✅ ③ 그래도 안 되면 특정 어셈블리를 통째로 보존
// <ItemGroup><TrimmerRootAssembly Include="MyLib" /></ItemGroup>
MissingMethodException ·
"직렬화 결과가 빈 객체" 로 나타납니다.
CI 에서 dotnet publish -c Release 결과물로 한 번은 돌려 보는 절차를
반드시 넣으세요.
Blazor Server — 동시 사용자 한계를 재는 법 🔴 실무
무엇이 메모리를 먹나
- 회로 하나 = 렌더 트리 + 컴포넌트 인스턴스 + 그 안의 데이터입니다. 빈 화면은 수백 KB, 1000행짜리 표를 띄워 두면 수 MB가 됩니다
- 가장 흔한 사고는 컴포넌트 필드에 큰 리스트를 담아 두는 것 — 사용자 수만큼 곱해집니다
- 끊긴 회로도 기본 3분간 남습니다 — 대량 접속이 몰리면 그만큼 더 쌓입니다
- 탭을 켜 둔 채 자리를 비운 사용자의 회로는 영원히 살아 있습니다
# 실제로 재 보는 방법 — 짐작하지 말고 측정합니다
dotnet-counters monitor -n Inventory.Web \
--counters Microsoft.AspNetCore.Hosting,Microsoft.AspNetCore.Components.Server.Circuits,System.Runtime
# 볼 것
# Microsoft.AspNetCore.Components.Server.Circuits: 활성 회로 수 (.NET 10+ 메트릭)
# System.Runtime: gc-heap-size, working-set
# 부하를 걸어 회로를 늘려 보기 (WebSocket 을 여는 도구가 필요합니다)
# → 실제 브라우저 여러 개를 띄우는 것이 가장 정확합니다
# → 회로 200개 · 500개 · 1000개에서 working-set 을 기록해 기울기를 봅니다
부담을 줄이는 다섯 가지
| 처방 | 효과 |
|---|---|
목록에 Virtualize | 렌더 트리 크기가 화면 크기로 고정 |
| 큰 데이터를 필드에 두지 않기 | 필요할 때 조회하고 버립니다 |
ShouldRender 로 렌더 억제 | 파라미터가 그대로면 false |
| 정적 부분을 Static SSR 로 | 회로 자체를 안 만듭니다 |
| 유휴 회로 정리 | 보관 시간·개수를 낮춥니다 |
| Auto 로 전환 | 두 번째 방문부터 서버 부담이 0 |
// 렌더를 억제해도 되는 컴포넌트 (읽기 전용 표시용)
protected override bool ShouldRender()
{
var changed = lastValue != Value;
lastValue = Value;
return changed; // 값이 그대로면 다시 그리지 않습니다
}
// 낙관적 UI — 왕복 지연을 감추는 가장 효과적인 방법
private async Task ToggleAsync(Item it)
{
it.Done = !it.Done; // ① 화면부터 바꾸고
StateHasChanged();
try { await Api.SaveAsync(it); } // ② 저장은 뒤에서
catch { it.Done = !it.Done; Toast("저장 실패"); StateHasChanged(); }
}
배포 — Docker · Nginx · IIS · PWA 🔴 실무
Docker — Dockerfile 없이도 됩니다
# .NET 8+ 내장 컨테이너 게시 — Dockerfile 을 안 써도 이미지가 나옵니다
dotnet publish Inventory.Web -c Release \
-t:PublishContainer \
-p:ContainerRepository=inventory-web \
-p:ContainerImageTag=1.4.2
# 손으로 쓸 때 (다단계 빌드)
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY *.sln .
COPY Inventory.Web/*.csproj Inventory.Web/
COPY Inventory.Web.Client/*.csproj Inventory.Web.Client/
COPY Inventory.Shared/*.csproj Inventory.Shared/
RUN dotnet restore # ← 여기까지가 캐시됩니다
COPY . .
RUN dotnet publish Inventory.Web -c Release -o /app --no-restore
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
ENV ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080
ENTRYPOINT ["dotnet", "Inventory.Web.dll"]
.csproj 만 먼저 복사해 restore 를 돌리는 순서가 핵심입니다.
소스만 바뀌면 패키지 복원 계층이 캐시에서 재사용되어
빌드가 몇 분에서 몇 십 초로 줄어듭니다.
앞단 서버 설정 · PWA
# Nginx — WASM 정적 호스팅에서 반드시 필요한 세 가지
server {
root /var/www/app/wwwroot;
# ① 새로고침해도 404 가 안 나게 (클라이언트 라우팅)
location / { try_files $uri $uri/ /index.html =404; }
# ② .wasm 의 MIME 타입 (없으면 런타임이 시작되지 않습니다)
# server 에 types { } 를 새로 쓰면 기본 목록을 통째로 대체하므로 location 에 한정합니다
location ~* \.wasm$ { types { } default_type application/wasm; }
# ③ 미리 만들어 둔 압축본을 그대로 내보내기
brotli_static on;
gzip_static on;
}
# Blazor Server 를 프록시할 때는 WebSocket 업그레이드가 추가로 필요합니다
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # ★ 없으면 회로가 안 섭니다
proxy_set_header Connection "upgrade";
proxy_read_timeout 100s;
}
# PWA — 설치되고 오프라인에서도 도는 앱으로
dotnet new blazorwasm -o Field.App --pwa
# wwwroot/manifest.webmanifest 앱 이름 · 아이콘
# wwwroot/service-worker.js (개발용: 아무것도 안 함)
# wwwroot/service-worker.published.js (게시용: 실제 캐시 전략)
# 게시하면 service-worker-assets.js 에 파일 해시 목록이 만들어집니다
Virtualize 와 유휴 정리로 관리하고,
앞단에는 WebSocket 업그레이드와 .wasm MIME 설정을 잊지 마세요..NET Aspire — 여러 프로젝트를 한 번에 띄웁니다
웹 · API · DB · Redis 를 각각 켜고, 연결 문자열을 복사해 붙이고, 로그를 창 네 개로 보는 일. Aspire 는 이 전부를 C# 코드 한 파일로 바꿉니다 — docker compose 의 자리를 타입이 있는 코드가 대신한다고 보면 정확합니다.
시작하기
# 이미 있는 솔루션에 얹기 — Visual Studio 면 프로젝트 우클릭 → Add → Aspire
# CLI 로는 템플릿을 깔고 두 프로젝트를 추가합니다
dotnet new install Aspire.ProjectTemplates
dotnet new aspire-apphost -o Inventory.AppHost
dotnet new aspire-servicedefaults -o Inventory.ServiceDefaults
# 새로 만들 때는 통째로 (Blazor + API + AppHost 가 한 번에)
dotnet new aspire-starter -o Inventory
# 실행 — 브라우저에 대시보드가 열립니다
dotnet run --project Inventory.AppHost
// Inventory.AppHost/Program.cs — 이 파일이 전부입니다
var builder = DistributedApplication.CreateBuilder(args);
// ① 인프라 — 컨테이너를 알아서 받아 띄웁니다
var pg = builder.AddPostgres("postgres")
.WithDataVolume() // 껐다 켜도 데이터 유지
.WithPgAdmin(); // 관리 화면까지 덤으로
var db = pg.AddDatabase("inventorydb");
var cache = builder.AddRedis("cache");
// ② 내 프로젝트 — 참조를 걸면 연결 정보가 주입됩니다
var api = builder.AddProject<Projects.Inventory_Api>("api")
.WithReference(db).WaitFor(db) // DB 가 준비된 뒤 시작
.WithReference(cache);
builder.AddProject<Projects.Inventory_Web>("web")
.WithReference(api).WaitFor(api)
.WithExternalHttpEndpoints(); // 밖에서 접근 가능
builder.Build().Run();
Projects.Inventory_Api 는 소스 생성기가 만들어 주는 타입입니다.
AppHost 가 프로젝트를 참조하면 자동으로 생기므로,
프로젝트 이름을 오타 내면 컴파일이 안 됩니다 —
YAML 에 문자열로 적던 시절과 가장 크게 다른 점이 이것입니다.
받는 쪽 — 이름만으로 연결됩니다
// Inventory.Api/Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // ★ 관측성 · 헬스체크 · 재시도 · 서비스 디스커버리
// "inventorydb" 는 AppHost 에서 붙인 이름입니다 — 연결 문자열을 안 적습니다
builder.AddNpgsqlDbContext<InventoryDb>("inventorydb");
builder.AddRedisDistributedCache("cache");
var app = builder.Build();
app.MapDefaultEndpoints(); // /health · /alive
app.MapInventoryEndpoints();
app.Run();
// Inventory.Web/Program.cs — API 를 이름으로 부릅니다
builder.AddServiceDefaults();
builder.Services.AddHttpClient<InventoryApiClient>(c =>
{
// ★ "https+http://api" — 호스트 이름이 아니라 리소스 이름입니다
// 서비스 디스커버리가 실제 주소·포트로 바꿔 줍니다
c.BaseAddress = new Uri("https+http://api");
});
// ServiceDefaults/Extensions.cs 가 해 주는 일 (템플릿 생성물)
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder)
where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry(); // 로그 · 트레이스 · 메트릭
builder.AddDefaultHealthChecks(); // /health · /alive
builder.Services.AddServiceDiscovery(); // 이름 → 주소
builder.Services.ConfigureHttpClientDefaults(http =>
{
http.AddStandardResilienceHandler(); // 재시도 · 타임아웃 · 회로 차단
http.AddServiceDiscovery();
});
return builder;
}
/api/… 를 대신 받아 넘기게 하거나,
API 에 외부 엔드포인트를 열고 그 주소를 설정으로 내려보내는 방식을 씁니다.
Auto 렌더 모드에서 가장 자주 막히는 지점입니다.
대시보드 — 여기서 거의 다 해결됩니다 🟡 중급
다섯 개의 탭
| 탭 | 여기서 무엇을 봅니까 |
|---|---|
| Resources | 무엇이 떠 있고 무엇이 죽었는지 · 각 서비스의 URL · 환경 변수(주입된 연결 문자열 확인) |
| Console | 프로젝트별 표준 출력 — 창 네 개를 띄울 필요가 없습니다 |
| Structured | 구조적 로그를 서비스·수준·검색어로 걸러 봅니다 |
| Traces | 요청 하나가 web → api → DB 를 지나간 전 구간이 한 줄로. 어디서 300ms 를 썼는지 바로 보입니다 |
| Metrics | 요청 수 · 지연 분포 · GC · 커넥션 풀 |
더 쓸 만한 조각들
// 설정과 비밀 — 개발용 값은 코드로, 진짜 비밀은 파라미터로
var pwd = builder.AddParameter("db-password", secret: true);
var pg = builder.AddPostgres("postgres", password: pwd);
// 외부 서비스도 리소스로 다룹니다
var storage = builder.AddAzureStorage("storage").RunAsEmulator();
var mq = builder.AddRabbitMQ("mq");
var mail = builder.AddContainer("mail", "axllent/mailpit")
.WithHttpEndpoint(port: 8025, targetPort: 8025);
// npm 프런트엔드도 함께 띄울 수 있습니다
builder.AddNpmApp("admin", "../admin-ui", "dev")
.WithReference(api)
.WithHttpEndpoint(env: "PORT");
// 시드 데이터 · 마이그레이션 전용 프로젝트를 먼저 돌리기
var migrator = builder.AddProject<Projects.Inventory_Migrator>("migrator")
.WithReference(db).WaitFor(db);
api.WaitForCompletion(migrator); // 마이그레이션이 끝나야 API 시작
# 배포 — Azure 라면 두 줄이면 끝납니다
azd init # AppHost 를 읽어 배포 구성을 만듭니다
azd up # 컨테이너 앱 · DB · 캐시까지 한 번에 올립니다
# 다른 곳에 배포한다면 매니페스트를 뽑아 변환합니다
dotnet run --project Inventory.AppHost -- \
--publisher manifest --output-path aspire-manifest.json
# → 리소스 목록 · 이미지 · 환경 변수가 JSON 으로 나옵니다
# → 이걸 Kubernetes 매니페스트나 compose 로 옮기는 도구들이 있습니다
AddServiceDefaults() 한 줄로 관측성·헬스체크·재시도를 갖추고,
대시보드의 Traces 탭에서 요청 하나의 전 구간을 봅니다.MAUI Blazor Hybrid — 같은 컴포넌트를 앱에 담기
웹으로 만든 화면을 그대로 데스크톱·모바일 앱 안에 넣을 수 있습니다. 데스크탑 탭이 다루는 WPF·WinUI 와는 접근이 다릅니다 — 여기서는 Razor 컴포넌트를 재사용한다는 한 가지 관점만 봅니다.
시작하기 — 템플릿 두 가지
# 워크로드 설치
dotnet workload install maui
# ① 앱만 만들 때
dotnet new maui-blazor -o Inventory.App
# ② 웹과 앱을 같이 만들 때 ★ 이쪽이 재사용의 정석입니다
dotnet new maui-blazor-web -o Inventory
# Inventory/ MAUI 앱 (BlazorWebView 껍데기)
# Inventory.Web/ Blazor Web App (서버)
# Inventory.Web.Client/ WASM 쪽
# Inventory.Shared/ ★ 세 프로젝트가 함께 참조하는 RCL
dotnet build -t:Run -f net10.0-windows10.0.19041.0 # 윈도우에서 실행
dotnet build -t:Run -f net10.0-android # 안드로이드
// MauiProgram.cs — 껍데기 쪽 설정
public static MauiApp CreateMauiApp()
{
var b = MauiApp.CreateBuilder();
b.UseMauiApp<App>()
.ConfigureFonts(f => f.AddFont("OpenSans-Regular.ttf", "OpenSans"));
b.Services.AddMauiBlazorWebView(); // ★ 이 한 줄
#if DEBUG
b.Services.AddBlazorWebViewDeveloperTools(); // 웹뷰 개발자 도구
#endif
// 플랫폼 구현을 여기서 등록합니다
b.Services.AddSingleton<IFormFactor, MauiFormFactor>();
b.Services.AddSingleton<IFileSaver, MauiFileSaver>();
return b.Build();
}
<!-- MainPage.xaml — 앱 창 안에 웹뷰를 통째로 -->
<BlazorWebView HostPage="wwwroot/index.html">
<BlazorWebView.RootComponents>
<RootComponent Selector="#app"
ComponentType="{x:Type shared:Routes}" />
</BlazorWebView.RootComponents>
</BlazorWebView>
플랫폼 차이를 흡수하는 패턴
// Inventory.Shared/Services/IFormFactor.cs — 라이브러리는 이것만 압니다
public interface IFormFactor
{
string GetFormFactor(); // "Desktop" | "Phone" | "Web"
string GetPlatform();
}
// Inventory.Web/Services/WebFormFactor.cs
public sealed class WebFormFactor : IFormFactor
{
public string GetFormFactor() => "Web";
public string GetPlatform() => Environment.OSVersion.ToString();
}
// Inventory/Services/MauiFormFactor.cs
public sealed class MauiFormFactor : IFormFactor
{
public string GetFormFactor() => DeviceInfo.Idiom.ToString();
public string GetPlatform() => $"{DeviceInfo.Platform} {DeviceInfo.VersionString}";
}
// 공유 컴포넌트에서는 그냥 주입해 씁니다
@inject IFormFactor Form
@if (Form.GetFormFactor() == "Phone") { <CompactList … /> }
else { <WideTable … /> }
RendererInfo.Name 은 "WebView" 를 돌려줍니다.
공유가 잘 되는 코드 · 안 되는 코드 🟡 중급
그대로 공유되는 것
- 화면 컴포넌트 전체 — 목록·폼·모달·차트 래퍼
- 모델과 검증 속성 — 같은
[Required]가 웹과 앱에서 동일하게 동작 - 계산 · 포맷 로직 — 세금, 재고 산식, 표시 문자열
- API 클라이언트 —
HttpClient는 어디서나 같습니다 - CSS — 컴포넌트 격리 스타일(
Foo.razor.css)까지 그대로
갈라 놓아야 하는 것
- 파일 저장·열기 — 웹은 다운로드, 앱은 파일 시스템
- 카메라 · 위치 · 알림 — 앱만 가능하거나 권한 모델이 다릅니다
- 인증 — 웹은 쿠키·리디렉션, 앱은 시스템 브라우저 + 보안 저장소
- 오프라인 저장 — 앱은 SQLite, 웹은 IndexedDB 또는 서버
- 정적 자산 경로 — MAUI 는
wwwroot가 앱 번들 안에 들어갑니다
공유 라이브러리를 실제로 만들 때 — 걸리는 것 넷 🔴 실무
RCL 만들기와 정적 자산 경로
# Razor 클래스 라이브러리 — 컴포넌트를 담는 전용 프로젝트 형식
dotnet new razorclasslib -o Inventory.Shared.UI
dotnet add Inventory.Web reference Inventory.Shared.UI
dotnet add Inventory.Web.Client reference Inventory.Shared.UI
dotnet add Inventory reference Inventory.Shared.UI # MAUI 앱
<!-- Inventory.Shared.UI.csproj -->
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<!-- 브라우저에서 안 되는 API 를 쓰면 컴파일러가 경고하게 (항목이라 ItemGroup 안) -->
<SupportedPlatform Include="browser" />
</ItemGroup>
@* 라이브러리 안의 wwwroot 는 _content/<패키지이름>/ 아래로 나옵니다 *@
<img src="_content/Inventory.Shared.UI/img/logo.svg" />
@* JS 모듈도 같은 규칙입니다 *@
module = await JS.InvokeAsync<IJSObjectReference>(
"import", "./_content/Inventory.Shared.UI/js/sparkline.js");
@* CSS 격리 파일(Foo.razor.css)은 자동으로 묶여 나갑니다 *@
<link rel="stylesheet" href="Inventory.Web.styles.css" />
./js/x.js 가
라이브러리로 옮기는 순간 404 가 됩니다 —
_content/<어셈블리 이름>/ 접두사를 붙여야 하고,
이 접두사는 MAUI 안에서도 동일하게 동작합니다.
재사용할지 다시 만들지 — 판단표
| 상황 | 권장 |
|---|---|
| 웹이 이미 있고 앱은 같은 업무 | 공유 — 가장 큰 이득이 나는 자리 |
| 앱만 화면 구성이 크게 다름 | 모델·검증만 공유, 화면은 따로 |
| 앱이 오프라인 우선 | 공유 가능하지만 데이터 계층을 인터페이스로 갈라야 합니다 |
| 스크롤 · 애니메이션이 핵심 | 네이티브 UI — 웹뷰로는 마지막 20%가 안 채워집니다 |
| 앱 시작 1초 이내 요구 | 네이티브 — 웹뷰 초기화 비용이 있습니다 |
| 스토어 심사가 잦음 | 공유 — 화면 수정이 앱 업데이트 없이 되는 구조를 같이 검토 |
dotnet new maui-blazor-web 이 이 구조를 그대로 만들어 줍니다.실전 — 재고 관리 대시보드를 끝까지 만듭니다
앞의 열두 절을 한 프로젝트로 묶습니다. Auto 렌더 모드 · EF Core(Postgres) · Identity 로그인 · SignalR 실시간 갱신 · Virtualize 표 · Aspire 오케스트레이션 · Docker 배포 — 실제로 사내에 배포할 수 있는 형태까지 갑니다.
만들 것 — 요구사항
- 로그인 — 사내 계정, 역할은
Viewer·Staff·Manager - 재고 목록 — 상품 5만 건, 검색·정렬, 스크롤이 끊기지 않을 것
- 실시간 — 누군가 재고를 조정하면 보고 있는 사람 화면이 즉시 바뀔 것
- 입출고 등록 — 폼 검증, 음수 재고 금지, Manager 만 조정 가능
- 감사 로그 — 누가 언제 무엇을 얼마나 바꿨는지 남길 것
- 개발 환경 — 저장소를 받아 명령 한 줄로 전부 뜰 것
Inventory/
├─ Inventory.AppHost/ ★ Aspire — 무엇을 띄울지 선언
├─ Inventory.ServiceDefaults/ ★ 관측성 · 헬스체크 · 재시도 (공용)
├─ Inventory.Shared/ ★ 모델 · DTO · 검증 (전 프로젝트 참조)
│ ├─ Models/Product.cs, StockMove.cs
│ └─ Contracts/AdjustStockRequest.cs
├─ Inventory.Api/ API + EF Core + SignalR 허브
│ ├─ Data/InventoryDb.cs
│ ├─ Endpoints/ProductEndpoints.cs
│ ├─ Hubs/StockHub.cs
│ └─ Program.cs
├─ Inventory.Web/ Blazor Web App (서버) + Identity
│ ├─ Components/
│ │ ├─ App.razor, Routes.razor, _Imports.razor
│ │ ├─ Account/… 로그인 (Static SSR)
│ │ ├─ Layout/MainLayout.razor
│ │ └─ Pages/Dashboard.razor, Adjust.razor
│ └─ Program.cs
└─ Inventory.Web.Client/ WASM 으로 내려갈 컴포넌트
├─ Components/StockTable.razor
└─ Services/InventoryApiClient.cs
화면마다 렌더 모드를 어떻게 나눴나
| 화면 | 모드 | 이유 |
|---|---|---|
| 로그인 · 회원 관리 | Static SSR | 쿠키를 심어야 합니다 |
| 대시보드 요약 | Static SSR | 숫자 카드 몇 개 — 회로가 아깝습니다 |
| 재고 표 | Auto | 첫 방문은 빠르게, 이후는 서버 부담 0 |
| 재고 조정 폼 | Auto | 즉시 검증이 필요합니다 |
| 감사 로그 조회 | Static SSR | 페이지 링크로 넘기면 충분합니다 |
// Inventory.AppHost/Program.cs — 이 파일 하나로 전부 뜹니다
var builder = DistributedApplication.CreateBuilder(args);
var pwd = builder.AddParameter("pgpwd", secret: true);
var pg = builder.AddPostgres("postgres", password: pwd).WithDataVolume();
var db = pg.AddDatabase("inventorydb");
var cache = builder.AddRedis("cache");
var migrator = builder.AddProject<Projects.Inventory_Migrator>("migrator")
.WithReference(db).WaitFor(db);
var api = builder.AddProject<Projects.Inventory_Api>("api")
.WithReference(db).WithReference(cache)
.WaitForCompletion(migrator);
builder.AddProject<Projects.Inventory_Web>("web")
.WithReference(api).WithReference(db).WithReference(cache)
.WaitFor(api)
.WithExternalHttpEndpoints();
builder.Build().Run();
dotnet run --project Inventory.AppHost 를 치면
Postgres·Redis 컨테이너를 받아 띄우고, 마이그레이션을 돌리고,
api 와 web 을 차례로 시작한 뒤 대시보드를 브라우저에 엽니다.
① 공유 모델과 API — 규칙을 한 곳에 🟢 기초
Inventory.Shared — 세 프로젝트가 함께 봅니다
// Models/Product.cs
public sealed class Product
{
public int Id { get; set; }
public string Sku { get; set; } = "";
public string Name { get; set; } = "";
public int Stock { get; set; }
public int SafetyQty{ get; set; } // 안전 재고
public DateTimeOffset UpdatedAt { get; set; }
public bool IsLow => Stock < SafetyQty; // ★ 화면·API 가 같이 씁니다
}
// Contracts/AdjustStockRequest.cs — 폼 모델이자 API 요청 본문
public sealed class AdjustStockRequest
{
[Required] public string Sku { get; set; } = "";
[Range(-10_000, 10_000, ErrorMessage = "한 번에 1만 개까지만 조정할 수 있습니다")]
public int Delta { get; set; }
[Required(ErrorMessage = "사유는 필수입니다")]
[StringLength(200, MinimumLength = 2)]
public string Reason { get; set; } = "";
public Guid RequestId { get; set; } = Guid.NewGuid(); // ★ 중복 방지 키
}
IsLow 같은 계산 프로퍼티가 공유의 진짜 값어치입니다.
"부족 판정"이 화면 CSS 와 API 알림에서 어긋날 수가 없습니다.
React 로 만들었다면 이 규칙이 TypeScript 쪽에 한 번 더 있었을 것이고,
언젠가 둘이 달라졌을 것입니다.
Inventory.Api — 엔드포인트와 허브
// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults(); // Aspire 기본기
builder.AddNpgsqlDbContext<InventoryDb>("inventorydb");
builder.AddRedisDistributedCache("cache");
builder.Services.AddSignalR().AddStackExchangeRedis( // 여러 대 대비
builder.Configuration.GetConnectionString("cache")!);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapProducts();
app.MapHub<StockHub>("/hub/stock");
app.Run();
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static void MapProducts(this WebApplication app)
{
var g = app.MapGroup("/products").RequireAuthorization();
// 표가 구간 단위로 부르는 조회 — 총 개수를 헤더로 같이 줍니다
g.MapGet("/", async (InventoryDb db, HttpContext ctx,
string? q, int skip = 0, int take = 50) =>
{
var query = db.Products.AsNoTracking()
.Where(p => q == null || p.Name.Contains(q) || p.Sku.Contains(q));
ctx.Response.Headers["X-Total-Count"] =
(await query.CountAsync()).ToString();
return await query.OrderBy(p => p.Sku)
.Skip(skip).Take(take).ToListAsync();
});
g.MapPost("/adjust", AdjustAsync)
.RequireAuthorization("CanAdjustStock"); // ★ 여기가 진짜 방어선
}
static async Task<IResult> AdjustAsync(
AdjustStockRequest req, InventoryDb db,
IHubContext<StockHub> hub, ClaimsPrincipal user)
{
// ① 같은 요청이 두 번 오면 다시 처리하지 않고 성공으로 응답합니다
if (await db.Moves.AnyAsync(m => m.RequestId == req.RequestId))
return Results.Ok(new { duplicated = true });
await using var tx = await db.Database.BeginTransactionAsync();
var p = await db.Products.FirstOrDefaultAsync(x => x.Sku == req.Sku);
if (p is null) return Results.NotFound();
// ② 음수 재고 금지 — 서버에서만 판단합니다
if (p.Stock + req.Delta < 0)
return Results.Problem("재고가 부족합니다", statusCode: 409);
p.Stock += req.Delta;
p.UpdatedAt = DateTimeOffset.UtcNow;
db.Moves.Add(new StockMove { // ③ 감사 로그
RequestId = req.RequestId, Sku = req.Sku, Delta = req.Delta,
Reason = req.Reason,
ActorId = user.FindFirstValue(ClaimTypes.NameIdentifier)!, // JwtBearer 는 기본으로 sub 을 이 이름으로 바꿉니다
At = DateTimeOffset.UtcNow });
await db.SaveChangesAsync();
await tx.CommitAsync();
// ④ 보고 있는 사람 모두에게 알립니다
await hub.Clients.Group($"sku:{p.Sku}")
.SendAsync("StockChanged", p.Sku, p.Stock);
return Results.Ok(new { stock = p.Stock });
}
}
RequestId 로 중복을 막는 것이 실시간 화면에서 특히 중요합니다.
네트워크가 끊겼다 붙는 순간 사용자가 저장을 두 번 누르는 일이 흔하고,
재고는 두 번 빠지면 되돌리기 어려운 값입니다.
② 표 — 5만 건을 끊김 없이 + 실시간 갱신 🟡 중급
Client/Components/StockTable.razor
@* ★ .Client 프로젝트에 두어야 WASM 으로 내려갑니다 *@
@implements IAsyncDisposable
@inject InventoryApiClient Api
@inject NavigationManager Nav
<input class="in" placeholder="상품명 또는 코드"
@bind="q" @bind:event="oninput" @bind:after="OnQueryChangedAsync" />
<div class="tbl" style="height:640px; overflow-y:auto">
<Virtualize ItemsProvider="LoadAsync" ItemSize="36" OverscanCount="6"
Context="row" @ref="grid">
<ItemContent>
<div class="tr @(row.IsLow ? "low" : "")" @key="row.Id">
<span class="sku">@row.Sku</span>
<span class="nm">@row.Name</span>
<span class="qt">@row.Stock</span>
<AuthorizeView Policy="CanAdjustStock" Context="auth">
<a href="/adjust/@row.Sku">조정</a>
</AuthorizeView>
</div>
</ItemContent>
<Placeholder><div class="tr skeleton"></div></Placeholder>
</Virtualize>
</div>
@code {
private Virtualize<Product>? grid;
private HubConnection? hub;
private string q = "";
private CancellationTokenSource? debounce;
private async ValueTask<ItemsProviderResult<Product>> LoadAsync(
ItemsProviderRequest req)
{
var (rows, total) = await Api.GetPageAsync(
q, req.StartIndex, req.Count, req.CancellationToken);
return new ItemsProviderResult<Product>(rows, total);
}
// 한 글자마다 서버를 때리지 않도록 300ms 모아 보냅니다
private async Task OnQueryChangedAsync()
{
debounce?.Cancel();
debounce = new CancellationTokenSource();
try
{
await Task.Delay(300, debounce.Token);
await grid!.RefreshDataAsync();
}
catch (TaskCanceledException) { }
}
protected override async Task OnInitializedAsync()
{
hub = new HubConnectionBuilder()
.WithUrl(Nav.ToAbsoluteUri("/api/hub/stock"), // /api 프록시를 거쳐 Api 의 허브로
o => o.AccessTokenProvider = Api.GetTokenAsync)
.WithAutomaticReconnect()
.Build();
hub.On<string, int>("StockChanged", async (sku, stock) =>
{
await InvokeAsync(async () =>
{
// 지금 보이는 구간만 다시 읽습니다 — 표 전체를 다시 그리지 않습니다
await grid!.RefreshDataAsync();
Toast($"{sku} 재고가 {stock} 으로 바뀌었습니다");
});
});
await hub.StartAsync();
}
public async ValueTask DisposeAsync()
{
debounce?.Cancel();
if (hub is not null) await hub.DisposeAsync();
}
}
Client/Services/InventoryApiClient.cs
public sealed class InventoryApiClient(HttpClient http)
{
public async Task<(IReadOnlyList<Product> Rows, int Total)> GetPageAsync(
string? q, int skip, int take, CancellationToken ct)
{
var url = $"products?q={Uri.EscapeDataString(q ?? "")}&skip={skip}&take={take}";
using var res = await http.GetAsync(url, ct);
res.EnsureSuccessStatusCode();
var total = res.Headers.TryGetValues("X-Total-Count", out var v)
&& int.TryParse(v.FirstOrDefault(), out var n) ? n : 0;
var rows = await res.Content
.ReadFromJsonAsync<List<Product>>(ct) ?? [];
return (rows, total);
}
public async Task<AdjustResult> AdjustAsync(AdjustStockRequest req)
{
using var res = await http.PostAsJsonAsync("products/adjust", req);
if (res.StatusCode == HttpStatusCode.Conflict)
return AdjustResult.Fail("재고가 부족합니다");
if (res.StatusCode == HttpStatusCode.BadRequest)
{
var pd = await res.Content
.ReadFromJsonAsync<ValidationProblemDetails>();
return AdjustResult.Invalid(pd!.Errors); // 폼에 붙일 오류들
}
res.EnsureSuccessStatusCode();
return AdjustResult.Ok();
}
}
// Web/Program.cs — WASM 이 부를 수 있게 /api 를 그대로 넘겨 줍니다
// (서비스 디스커버리 이름은 브라우저에서 못 쓰기 때문입니다 — b11 참고)
app.MapForwarder("/api/{**rest}", "https+http://api", "/{**rest}")
.RequireAuthorization();
// Client/Program.cs — 브라우저 쪽은 자기 출처의 /api 만 압니다
builder.Services.AddHttpClient<InventoryApiClient>(c =>
c.BaseAddress = new Uri(builder.HostEnvironment.BaseAddress + "api/"));
InventoryApiClient 를 서버와 브라우저가 함께 씁니다.
서버 쪽 DI 에는 https+http://api(서비스 디스커버리),
브라우저 쪽에는 /api/(프록시)를 물려 주면
컴포넌트 코드는 어느 쪽에서 돌든 똑같습니다 —
이것이 Auto 렌더 모드를 실제로 굴리는 요령입니다.
③ 폼 · 권한 · 배포 🔴 실무
Pages/Adjust.razor — 폼과 서버 오류 연결
@page "/adjust/{Sku}"
@rendermode InteractiveAuto
@attribute [Authorize(Policy = "CanAdjustStock")]
@inject InventoryApiClient Api
@inject NavigationManager Nav
<h2>@Sku 재고 조정</h2>
<EditForm EditContext="ctx" OnValidSubmit="SubmitAsync" FormName="adjust">
<DataAnnotationsValidator />
<label>증감</label>
<InputNumber @bind-Value="form.Delta" class="in" />
<ValidationMessage For="() => form.Delta" />
<label>사유</label>
<InputTextArea @bind-Value="form.Reason" class="in" rows="3" />
<ValidationMessage For="() => form.Reason" />
<button type="submit" disabled="@busy">@(busy ? "처리 중…" : "적용")</button>
</EditForm>
@code {
[Parameter] public string Sku { get; set; } = "";
private AdjustStockRequest form = new();
private EditContext? ctx;
private ValidationMessageStore? store;
private bool busy;
protected override void OnInitialized()
{
form.Sku = Sku;
ctx = new EditContext(form);
store = new ValidationMessageStore(ctx);
}
private async Task SubmitAsync()
{
busy = true; store!.Clear();
try
{
var r = await Api.AdjustAsync(form);
if (r.Success) { Nav.NavigateTo("/"); return; }
foreach (var (field, msgs) in r.Errors)
foreach (var m in msgs) store.Add(ctx!.Field(field), m);
if (r.Message is not null)
store.Add(ctx!.Field(nameof(form.Delta)), r.Message);
ctx!.NotifyValidationStateChanged();
}
finally { busy = false; }
}
}
@attribute [Authorize] 는 화면 보호일 뿐입니다.
같은 정책을 API 의 RequireAuthorization("CanAdjustStock") 에도
걸어 두었기 때문에 안전한 것입니다.
둘 중 하나만 있으면 그건 안전하지 않은 앱입니다.
배포 — 순서가 전부입니다
# ① 마이그레이션을 실행 파일 하나로 뽑습니다 (SDK 없는 곳에서도 돕니다)
dotnet ef migrations bundle --project Inventory.Api \
--self-contained -r linux-x64 -o ./efbundle
# ② 이미지 두 개를 만듭니다
dotnet publish Inventory.Api -c Release -t:PublishContainer \
-p:ContainerRepository=inventory-api -p:ContainerImageTag=$TAG
dotnet publish Inventory.Web -c Release -t:PublishContainer \
-p:ContainerRepository=inventory-web -p:ContainerImageTag=$TAG
# ③ 배포 (순서 고정)
./efbundle --connection "$CONN" # 스키마 먼저
kubectl set image deploy/api api=inventory-api:$TAG
kubectl set image deploy/web web=inventory-web:$TAG
kubectl rollout status deploy/web
# 인그레스 — Blazor Server 회로가 살아 있으려면 이 세 가지
metadata:
annotations:
nginx.ingress.kubernetes.io/affinity: "cookie" # ① 고정 세션
nginx.ingress.kubernetes.io/session-cookie-name: "circuit"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" # ② 긴 연결 허용
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
# ③ 종료 유예 — 남은 회로가 정리될 시간을 줍니다 (이건 Deployment 쪽 설정)
spec:
template:
spec:
terminationGracePeriodSeconds: 60
azd up 을 쓸 수 있는 환경이라면 이 단계 전체가 자동화됩니다.
출시 전 체크리스트 🔴 실무
| 영역 | 확인할 것 | 안 하면 |
|---|---|---|
| 렌더 모드 | 모든 상호작용 화면에 @rendermode 가 있는가 | 버튼이 안 눌립니다 |
| 사전 렌더링 | 같은 API 를 두 번 부르고 있지 않은가 | 서버 부하 2배 · 화면 깜빡임 |
| 권한 | 화면과 API 양쪽에 같은 정책이 걸렸는가 | URL 직접 호출로 뚫립니다 |
| DbContext | Server 에서 IDbContextFactory 를 쓰는가 | 동시 접근 예외 · 커넥션 고갈 |
| 구독 해제 | Dispose 에서 이벤트·허브·JS 참조를 푸는가 | 회로가 안 죽고 메모리가 샙니다 |
| 목록 | Virtualize · @key · AsNoTracking | 1000행부터 버벅입니다 |
| 중복 요청 | 돈·재고를 바꾸는 API 에 멱등 키가 있는가 | 두 번 차감됩니다 |
| 끊김 | 재연결 UI 문구 · 재연결 후 다시 조회 | 멈춘 화면을 보며 새로고침 |
| 앞단 | WebSocket 업그레이드 · 고정 세션 · .wasm MIME | 회로가 안 서거나 WASM 이 안 뜹니다 |
| WASM 비밀 | 연결 문자열·API 키가 클라 프로젝트에 없는가 | 브라우저에서 그대로 읽힙니다 |
| 게시본 검증 | 트리밍된 Release 빌드로 한 번 돌려 봤는가 | 배포 후에만 나는 직렬화 오류 |
| 배포 순서 | 마이그레이션 → 이미지 → 옛 파드 종료 | 옛 코드가 새 스키마를 봅니다 |
스프링으로
짓던 시스템을
.NET 으로 짓습니다
이 가이드의 다른 서버 탭들은 게임 백엔드 이야기였습니다.
이 탭은 각도를 완전히 틀어 사내 업무 시스템 — 결재 · 인사 · 회계 · 재고 —
을 짓는 사람을 위한 탭입니다. 그리고 처음부터 끝까지
Spring Boot / JPA 를 아는 사람을 독자로 두고 씁니다.
@Component 는 어디로 갔는지, @Transactional 이 없는 세계에서
트랜잭션 경계를 어떻게 긋는지, 영속성 컨텍스트에 해당하는 것은 무엇인지를
모든 섹션에서 1:1 로 대조합니다.
다 읽고 나면 Spring Boot 로 짤 수 있는 시스템은 .NET 10 으로도 짤 수 있습니다.
Spring 을 .NET 으로 옮기는 지도 — 개념 대응표
새 프레임워크를 배울 때 가장 오래 걸리는 일은 문법이 아니라 "내가 알던 그것이 여기서는 뭐라고 불리는가" 를 찾는 일입니다. 그 지도를 먼저 펴 놓고 시작합니다. 이 표 하나만 책상에 붙여도 첫 두 주가 사라집니다.
| 하고 싶은 일 | Spring Boot | ASP.NET Core (.NET 10) |
|---|---|---|
| 서비스 빈 만들기 | @Service class OrderService | services.AddScoped<OrderService>() |
| 인터페이스에 구현 붙이기 | @Service class Impl implements Port | AddScoped<IPort, Impl>() |
| 패키지 통째로 스캔 | @ComponentScan | Scrutor .Scan(...) (기본 제공 없음) |
| 컨트롤러 | @RestController @GetMapping | [ApiController] [HttpGet] · Minimal API |
| 요청 본문 받기 | @RequestBody Dto dto | 매개변수에 그냥 Dto dto (자동 추론) |
| 경로 변수 | @PathVariable Long id | [HttpGet("{id:long}")] + long id |
| 트랜잭션 경계 | @Transactional | await db.SaveChangesAsync() 한 번 |
| 엔티티 | @Entity @Table @Column | POCO + IEntityTypeConfiguration<T> |
| 리포지토리 | interface R extends JpaRepository | DbSet<T> 를 직접 · 필요하면 손수 감쌈 |
| 쿼리 메서드 | findByNameAndStatus(...) | LINQ .Where(x => ...) |
| 검증 | @Valid @NotBlank | DataAnnotations · FluentValidation |
| 권한 | @PreAuthorize("hasRole('X')") | [Authorize(Policy = "X")] |
| 설정 바인딩 | @ConfigurationProperties | IOptions<T> + Bind() |
| 프로파일 | spring.profiles.active | ASPNETCORE_ENVIRONMENT |
| 스케줄러 | @Scheduled(cron=...) | Quartz.NET · Hangfire (🔷 f03 참고) |
| 비동기 실행 | @Async + 스레드풀 | async/await — 언어 차원 |
| 헬스체크 | Actuator /actuator/health | AddHealthChecks() + MapHealthChecks("/health") |
| 로깅 파사드 | SLF4J + Logback | ILogger<T> + Serilog (🔷 t02 참고) |
| 테스트 구동 | @SpringBootTest | WebApplicationFactory<Program> |
| 목 객체 | Mockito | NSubstitute · Moq |
| DTO 매핑 | MapStruct | Mapperly (둘 다 소스 생성기) |
| 서버 기동 | java -jar app.jar (내장 톰캣) | dotnet App.dll (내장 Kestrel) |
가장 먼저 부딪히는 벽 — 등록을 손으로 합니다 🟢 기초
Spring — 애너테이션 하나면 끝
@Service // 이 한 줄로 컨테이너에 등록됩니다
public class OrderService {
private final OrderRepository repo;
private final MailSender mail;
// @Autowired 는 생성자가 하나면 생략 가능
public OrderService(OrderRepository repo, MailSender mail) {
this.repo = repo;
this.mail = mail;
}
}
// @SpringBootApplication 이 이 패키지 아래를 통째로 스캔합니다.
// 클래스를 새로 만들어도 등록 코드를 따로 쓸 일이 없습니다.
.NET — 등록은 Program.cs 한 곳에
// OrderService.cs — 애너테이션이 없습니다. 그냥 클래스입니다.
public sealed class OrderService(
IOrderRepository repo, // C# 12+ 기본 생성자
IMailSender mail)
{
public async Task<Result> PlaceAsync(OrderDto dto) { /* ... */ }
}
// Program.cs — 여기가 "빈 설정 파일" 입니다
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<OrderService>();
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddSingleton<IMailSender, SmtpMailSender>();
var app = builder.Build();
Program.cs 를 열면 이 시스템이 무엇으로 이뤄졌는지 30초 만에 파악됩니다.
스캔이 없으니 기동도 빠릅니다(콜드 스타트 수백 ms 차이).
파일 대응 — 어느 파일이 어느 파일인가 🟢 기초
Spring 프로젝트
payroll/
├─ pom.xml 의존성 · 빌드
├─ src/main/java/com/acme/
│ ├─ PayrollApplication.java main() · @SpringBootApplication
│ ├─ controller/
│ ├─ service/
│ ├─ repository/
│ └─ domain/
└─ src/main/resources/
├─ application.yml
├─ application-prod.yml
└─ db/migration/V1__init.sql (Flyway)
.NET 프로젝트 — 같은 시스템
Payroll/
├─ Payroll.sln 솔루션(여러 프로젝트 묶음)
├─ src/Payroll.Api/
│ ├─ Payroll.Api.csproj 의존성 · 빌드 (= pom.xml)
│ ├─ Program.cs main() + 빈 등록 + 미들웨어
│ ├─ Controllers/
│ ├─ appsettings.json
│ └─ appsettings.Production.json
├─ src/Payroll.Application/ (= service 패키지)
├─ src/Payroll.Domain/ (= domain 패키지)
├─ src/Payroll.Infrastructure/ EF Core · SMTP · 파일
│ └─ Migrations/ EF Core 마이그레이션 (= Flyway)
└─ tests/Payroll.Tests/
.csproj 를 따로 두는 것이 관행입니다.
패키지는 마음만 먹으면 서로 참조할 수 있지만
프로젝트 참조는 컴파일러가 막아 줍니다 —
Domain 이 EF Core 를 못 쓰게 하려면 이 방법뿐입니다 (🔷 e04 에서 자세히).
설정 파일 대조 — yml 과 json 🟡 중급
application.yml
spring:
datasource:
url: jdbc:postgresql://db:5432/payroll
username: payroll
jpa:
hibernate:
ddl-auto: validate
approval:
max-line-depth: 5
auto-approve-under: 100000
logging:
level:
com.acme: DEBUG
appsettings.json — 같은 내용
{
"ConnectionStrings": {
"Payroll": "Host=db;Database=payroll;Username=payroll"
},
"Approval": {
"MaxLineDepth": 5,
"AutoApproveUnder": 100000
},
"Logging": {
"LogLevel": { "Acme": "Debug" }
}
}
APPROVAL_MAXLINEDEPTH,
.NET 은 계층을 이중 밑줄로 씁니다 —
Approval__MaxLineDepth=7.
도커·쿠버네티스에서 매일 쓰는 규약이니 지금 외워 두세요 (🔷 e09).
Bean — .NET 에는 그런 말이 없습니다. 그냥 서비스라고 부릅니다.
② Repository — Spring Data 는 인터페이스만 쓰면 구현을 만들어 주지만,
EF Core 의 DbSet<T> 자체가 이미 리포지토리입니다.
한 겹 더 감싸는 것은 선택 사항이고, 안 감싸는 팀도 많습니다.
③ @Configuration — .NET 의 IConfiguration 은
설정값 읽기일 뿐, 빈을 만드는 자바의 @Configuration 과 전혀 다릅니다.
빈을 만드는 자리는 Program.cs 와 확장 메서드입니다.
앱이 뜨는 순서 — 호스팅 모델과 미들웨어 파이프라인
Spring Boot 는 @SpringBootApplication 한 줄 뒤에서
수십 개의 AutoConfiguration 이 조건을 따져 가며 알아서 조립합니다.
.NET 은 반대입니다 — Program.cs 에 쓴 줄이 전부이고,
쓴 순서가 곧 실행 순서입니다. 마법이 없어서 처음엔 허전하고, 나중엔 편합니다.
Build() 가 경계선입니다. 이 선을 넘으면 등록이 끝나고 사용이 시작됩니다.엔터프라이즈 Program.cs 전문
var builder = WebApplication.CreateBuilder(args);
// ── ① 설정 · 로깅 ─────────────────────────────
builder.Host.UseSerilog((ctx, cfg) =>
cfg.ReadFrom.Configuration(ctx.Configuration));
// ── ② 서비스 등록 (= 빈 정의) ────────────────
builder.Services.AddDbContext<PayrollDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Payroll")));
builder.Services.AddScoped<IApprovalService, ApprovalService>();
builder.Services.AddScoped<IAuditLogger, DbAuditLogger>();
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddOptions<ApprovalOptions>()
.Bind(builder.Configuration.GetSection("Approval"))
.ValidateDataAnnotations()
.ValidateOnStart(); // 설정이 틀리면 기동 자체를 막습니다
builder.Services.AddControllers();
builder.Services.AddProblemDetails(); // 인자 없는 UseExceptionHandler() 는 이게 있어야 뜹니다
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorization();
builder.Services.AddHealthChecks()
.AddDbContextCheck<PayrollDbContext>();
builder.Services.AddHostedService<OutboxPublisher>(); // = @Scheduled 자리
// ── ③ 컨테이너 확정 ──────────────────────────
var app = builder.Build();
// ── ④ 미들웨어 (= 서블릿 필터 체인) ──────────
app.UseExceptionHandler(); // 가장 바깥
app.UseForwardedHeaders(); // 프록시 뒤라면 필수 (🔷 h06)
app.UseSerilogRequestLogging();
app.UseAuthentication();
app.UseAuthorization();
// ── ⑤ 끝점 · 실행 ────────────────────────────
app.MapControllers();
app.MapHealthChecks("/health/ready");
app.Run();
같은 일을 하는 Spring 쪽
@SpringBootApplication
public class PayrollApplication {
public static void main(String[] args) {
SpringApplication.run(PayrollApplication.class, args);
}
}
// 끝입니다. 나머지는 전부 어딘가에 흩어져 있습니다 —
// · 빈 등록 → 각 클래스의 @Service
// · 필터 순서 → @Order 또는 FilterRegistrationBean
// · 데이터소스 → application.yml + AutoConfiguration
// · 스케줄러 → @EnableScheduling + @Scheduled
// · 헬스체크 → spring-boot-starter-actuator 의존성 하나
ValidateOnStart() 를 꼭 켜세요.
Spring 의 @Validated @ConfigurationProperties 에 해당합니다.
이게 없으면 설정 오타가 새벽 3시 첫 요청에서 터집니다.
켜 두면 배포 즉시 기동에 실패해서 롤백이 자동으로 걸립니다.
미들웨어 vs 서블릿 필터 — 닮았지만 결정적으로 다릅니다 🟡 중급
직접 만든 미들웨어 — 상관 관계 ID
public sealed class CorrelationIdMiddleware(RequestDelegate next)
{
private const string Header = "X-Correlation-Id";
public async Task InvokeAsync(HttpContext ctx, ILogger<CorrelationIdMiddleware> log)
{
// ── 들어가는 길 ──────────────────────────
var id = ctx.Request.Headers[Header].FirstOrDefault()
?? Guid.NewGuid().ToString("N");
ctx.Items["cid"] = id;
using (log.BeginScope(new Dictionary<string, object> { ["cid"] = id }))
{
ctx.Response.OnStarting(() => // 응답 헤더는 "시작 직전"에
{
ctx.Response.Headers[Header] = id;
return Task.CompletedTask;
});
await next(ctx); // ← 여기가 chain.doFilter()
}
// ── 나오는 길 ────────────────────────────
// 여기서는 이미 응답이 나가는 중이라 헤더를 못 고칩니다
}
}
// 등록 — 확장 메서드로 감싸는 것이 관행입니다
public static class CorrelationIdExtensions
{
public static IApplicationBuilder UseCorrelationId(this IApplicationBuilder b)
=> b.UseMiddleware<CorrelationIdMiddleware>();
}
app.UseCorrelationId(); // UseSerilogRequestLogging 보다 앞에
Spring 필터로 쓰면
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorrelationIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws IOException, ServletException {
String id = Optional.ofNullable(req.getHeader("X-Correlation-Id"))
.orElse(UUID.randomUUID().toString());
MDC.put("cid", id);
res.setHeader("X-Correlation-Id", id);
try {
chain.doFilter(req, res);
} finally {
MDC.remove("cid"); // 스레드풀 재사용 때문에 반드시 지워야 합니다
}
}
}
MDC.remove 에 해당하는 실수가 .NET 엔 없습니다.
BeginScope 는 using 이 끝나면 자동으로 걷히고,
스코프는 스레드가 아니라 AsyncLocal 로 비동기 흐름을 따라 다닙니다.
await 뒤에 스레드가 바뀌어도 상관 ID 가 살아 있습니다.
OnStarting 을 쓰는 이유.
await next() 뒤에서 Response.Headers 를 건드리면
"Headers are read-only, response has already started" 예외가 납니다.
응답이 이미 소켓으로 나가기 시작했기 때문입니다.
엔터프라이즈 코드에서 가장 자주 보는 초보 예외 1위입니다.
앱 수명주기 — IHostedService 와 종료 신호 🔴 실무
기동·종료에 훅 걸기
// Spring 의 @PostConstruct / @PreDestroy / ApplicationRunner 자리
public sealed class WarmupService(
IServiceProvider sp,
IHostApplicationLifetime life,
ILogger<WarmupService> log) : IHostedService
{
public async Task StartAsync(CancellationToken ct)
{
// 기동 시 캐시 예열 — 첫 사용자가 느린 것을 막습니다
using var scope = sp.CreateScope(); // ★ Scoped 는 스코프를 열어야 씁니다
var db = scope.ServiceProvider.GetRequiredService<PayrollDbContext>();
await db.Departments.AsNoTracking().LoadAsync(ct);
log.LogInformation("부서 캐시 예열 완료");
life.ApplicationStopping.Register(() =>
log.LogInformation("종료 신호 수신 — 새 요청을 받지 않습니다"));
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
builder.Services.AddHostedService<WarmupService>();
// 종료 유예 시간 — 기본 30초(.NET 8+, 그 전엔 5초). 긴 배치가 도는 서버라면 더 늘립니다
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(90));
수명주기 대응표
| 시점 | Spring | .NET |
|---|---|---|
| 빈 생성 직후 | @PostConstruct | 생성자 (비동기 불가) |
| 기동 완료 후 | ApplicationRunner | IHostedService.StartAsync |
| 이벤트 수신 | @EventListener | IHostApplicationLifetime |
| 종료 시작 | @PreDestroy | ApplicationStopping |
| 종료 완료 | ContextClosedEvent | ApplicationStopped |
| 주기 실행 | @Scheduled | BackgroundService (🔷 f02) |
@PostConstruct 안에서 DB 를 읽던 코드는
IHostedService.StartAsync 로 옮겨야 합니다.
생성자에서 .Result 나 .Wait() 를 쓰면
기동 중 데드락이 납니다 — 실제로 겪습니다.
AddHostedService 는 등록한 순서대로 StartAsync 를 부르고,
종료할 때는 역순으로 StopAsync 를 부릅니다.
마이그레이션 실행기를 먼저, 배치 워커를 나중에 등록하면 순서가 맞습니다.
dotnet Payroll.Api.dll 을 실행하는 순간
그 프로세스가 곧 웹 서버입니다. Nginx 나 IIS 는 여전히 앞에 둘 수 있지만
그것은 서블릿 컨테이너가 아니라 리버스 프록시 역할입니다 (🔷 h01·h06 에서 끝까지 다룹니다).
Program.cs 는 빈 설정 파일 + 필터 등록 + main() 을 합친 파일입니다.
Build() 앞은 등록, 뒤는 사용. 미들웨어는 쓴 순서대로 들어갔다가 역순으로 나옵니다.
기동·종료 훅이 필요하면 IHostedService 를 씁니다.DI 컨테이너 — 수명 세 가지가 사고의 90%입니다
Spring 은 기본이 싱글턴이고 웹 요청 스코프는 일부러 켜야 합니다. .NET 은 반대로 Scoped(요청당 하나)를 기본으로 쓰는 문화입니다. 이 차이를 모르고 넘어오면 DbContext 를 싱글턴으로 등록하는 사고가 반드시 한 번은 납니다. 수명이 겹치면 무슨 일이 생기는지를 그림으로 먼저 보겠습니다.
| 수명 | Spring 대응 | 언제 쓰나 | 주의 |
|---|---|---|---|
Singleton | 기본 스코프 (singleton) | 설정 · 시계 · HttpClient 팩토리 · 캐시 · 순수 계산기 | 스레드 안전해야 합니다. 여러 요청이 동시에 들어옵니다 |
Scoped | @RequestScope | DbContext · 리포지토리 · 서비스 · 현재 사용자 정보 | 배경 작업에서 쓰려면 CreateScope() 를 직접 열어야 합니다 |
Transient | @Scope("prototype") | 가벼운 검증기 · 빌더 · 전략 객체 | IDisposable 이면 스코프가 끝날 때까지 안 버려집니다 |
Captive dependency — .NET 백엔드 사고 1위 🔴 실무
이렇게 하면 터집니다
// ❌ 싱글턴이 스코프드를 붙잡습니다
builder.Services.AddSingleton<IAuditCache, AuditCache>();
builder.Services.AddDbContext<PayrollDbContext>(...); // 기본 Scoped
public sealed class AuditCache(PayrollDbContext db) : IAuditCache
{
// db 는 "첫 요청이 만든 그 DbContext" 입니다.
// 그 요청이 끝나 Dispose 된 뒤에도 계속 들고 있습니다.
// → ObjectDisposedException
// → 또는 살아남아서 여러 스레드가 동시에 쓰다가 데이터가 섞임
}
"A second operation was started on this context"
같은 엉뚱한 예외로 나타납니다. 재현이 안 되는 버그의 전형입니다.
builder.Host.UseDefaultServiceProvider(o => { o.ValidateScopes = true;
o.ValidateOnBuild = true; }) —
Development 에서는 기본으로 켜져 있고, 운영에서도 켜 두는 편이 낫습니다.
기동할 때 한 번 검사하고 끝이라 비용이 거의 없습니다.
고치는 두 가지 방법
// ✅ 방법 1 — 필요할 때 스코프를 직접 연다
public sealed class AuditCache(IServiceScopeFactory scopeFactory) : IAuditCache
{
public async Task<int> CountAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<PayrollDbContext>();
return await db.AuditLogs.CountAsync(ct);
} // 스코프가 닫히면 DbContext 도 함께 Dispose 됩니다
}
// ✅ 방법 2 — 팩토리를 주입받는다 (EF Core 전용, 더 가볍다)
builder.Services.AddDbContextFactory<PayrollDbContext>(...);
public sealed class AuditCache(IDbContextFactory<PayrollDbContext> f) : IAuditCache
{
public async Task<int> CountAsync(CancellationToken ct)
{
await using var db = await f.CreateDbContextAsync(ct);
return await db.AuditLogs.CountAsync(ct);
}
}
BackgroundService 는 싱글턴이라 DbContext 를 직접 주입받을 수 없습니다.
루프 한 바퀴마다 스코프를 새로 열어야 메모리도 안 새고 변경 추적도 깨끗합니다 (🔷 f02).
@ComponentScan 이 없다 — Scrutor 로 되살리기 🟡 중급
규칙으로 한 번에 등록
dotnet add package Scrutor
builder.Services.Scan(scan => scan
.FromAssemblyOf<ApprovalService>() // 이 클래스가 있는 어셈블리에서
.AddClasses(c => c.Where(t => t.Name.EndsWith("Service")))
.AsImplementedInterfaces() // IApprovalService ↔ ApprovalService
.WithScopedLifetime()
.AddClasses(c => c.AssignableTo(typeof(IRequestHandler<,>)))
.AsImplementedInterfaces()
.WithScopedLifetime());
services.Decorate<IApprovalService, CachedApprovalService>();
원본을 감싼 캐시 계층이 자동으로 끼워집니다.
키드 서비스 — 같은 인터페이스 여러 구현
// .NET 8 부터 표준. Spring 의 @Qualifier 자리입니다.
builder.Services.AddKeyedScoped<INotifier, EmailNotifier>("email");
builder.Services.AddKeyedScoped<INotifier, SmsNotifier>("sms");
builder.Services.AddKeyedScoped<INotifier, KakaoNotifier>("kakao");
public sealed class ApprovalService(
[FromKeyedServices("email")] INotifier notifier)
{ /* ... */ }
// 런타임에 고를 때
public sealed class Dispatcher(IServiceProvider sp)
{
public INotifier Pick(string channel)
=> sp.GetRequiredKeyedService<INotifier>(channel);
}
// 전부 다 받고 싶다면 — Spring 의 List<INotifier> 주입과 같습니다
// 단, 키 없이 등록(AddScoped<INotifier, …>)한 것만 들어옵니다 — 위의 키드 등록은 안 잡힙니다
public sealed class Broadcaster(IEnumerable<INotifier> all) { }
IEnumerable<T> 주입은 그냥 됩니다.
같은 인터페이스를 여러 번 Add 하면 순서대로 다 들어옵니다.
전략 패턴·검증 규칙 목록·이벤트 핸들러 묶음에 그대로 씁니다.
마지막으로 등록한 것 하나만 필요하면 INotifier 를 단수로 받으면 됩니다.
설정 주입 — IOptions 3형제 🟡 중급
검증까지 붙인 설정 클래스
public sealed class ApprovalOptions
{
public const string Section = "Approval";
[Range(1, 10)] public int MaxLineDepth { get; init; } = 5;
[Range(0, 100_000_000)] public decimal AutoApproveUnder { get; init; }
[Required, EmailAddress] public string EscalationMail { get; init; } = "";
}
builder.Services.AddOptions<ApprovalOptions>()
.Bind(builder.Configuration.GetSection(ApprovalOptions.Section))
.ValidateDataAnnotations()
.Validate(o => o.AutoApproveUnder >= 0, "음수는 안 됩니다")
.ValidateOnStart();
셋 중 무엇을 주입받나
| 형태 | 갱신 | 수명 |
|---|---|---|
IOptions<T> | 안 됨 (기동 시 고정) | Singleton |
IOptionsSnapshot<T> | 요청마다 다시 읽음 | Scoped |
IOptionsMonitor<T> | 파일이 바뀌면 즉시 | Singleton |
// 싱글턴 · 배치 워커에서 실시간 반영이 필요하면 Monitor
public sealed class OutboxPublisher(IOptionsMonitor<BatchOptions> opt)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var o = opt.CurrentValue; // 매 바퀴 최신값
await Task.Delay(o.IntervalSeconds * 1000, ct);
}
}
}
IOptionsSnapshot 을 주입하면 예외입니다.
Scoped 를 Singleton 에 넣는 것이니 captive dependency 와 같은 사고입니다.
싱글턴에서는 IOptions 또는 IOptionsMonitor 둘 중 하나만 씁니다.
CreateScope(),
설정은 IOptions 3형제, 여러 구현은 키드 서비스나 IEnumerable<T> 로 받습니다.계층 아키텍처 — 참조 방향을 컴파일러가 지키게
자바에서는 controller 패키지가 repository 를 직접 부르는 것을
코드 리뷰로 막았습니다. .NET 에서는 프로젝트를 나누면 컴파일러가 막아 줍니다.
규칙을 사람이 아니라 빌드가 지키게 만드는 것 — 그것이 이 섹션의 목표입니다.
프로젝트 만들고 참조 걸기
dotnet new sln -n Payroll
dotnet new classlib -o src/Payroll.Domain
dotnet new classlib -o src/Payroll.Application
dotnet new classlib -o src/Payroll.Infrastructure
dotnet new webapi -o src/Payroll.Api
dotnet new xunit -o tests/Payroll.Tests
dotnet sln add (Get-ChildItem -r *.csproj) # PowerShell
# bash 라면: dotnet sln add $(find . -name "*.csproj")
# 참조는 안쪽으로만
dotnet add src/Payroll.Application reference src/Payroll.Domain
dotnet add src/Payroll.Infrastructure reference src/Payroll.Domain
dotnet add src/Payroll.Api reference src/Payroll.Application
dotnet add src/Payroll.Api reference src/Payroll.Infrastructure
Directory.Build.props 를 루트에 하나 두세요.
모든 .csproj 가 자동으로 상속합니다 — Maven 의 부모 POM 자리입니다.
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14.0</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
도메인은 정말 아무것도 모릅니다
// Payroll.Domain/Approvals/ApprovalDocument.cs
// using 문에 EF Core 도 ASP.NET Core 도 없습니다.
namespace Payroll.Domain.Approvals;
public sealed class ApprovalDocument
{
private readonly List<ApprovalStep> _steps = [];
public Guid Id { get; private set; } = Guid.CreateVersion7();
public string Title { get; private set; } = "";
public decimal Amount { get; private set; }
public DocumentStatus Status { get; private set; } = DocumentStatus.Draft;
public IReadOnlyList<ApprovalStep> Steps => _steps;
// 업무 규칙이 클래스 안에 있습니다 — 밖에서 상태를 못 바꿉니다
public Result Approve(Guid approverId, DateTimeOffset now)
{
if (Status is not DocumentStatus.InProgress)
return Result.Fail("진행 중인 문서만 결재할 수 있습니다");
var step = _steps.FirstOrDefault(s => s.ApproverId == approverId
&& s.State == StepState.Waiting);
if (step is null) return Result.Fail("결재 차례가 아닙니다");
step.Approve(now);
if (_steps.All(s => s.State == StepState.Approved))
Status = DocumentStatus.Completed;
return Result.Ok();
}
}
@Entity 는 기본 생성자와 public setter 를 요구해서
엔티티가 "속이 빈 자료 구조"가 되기 쉽습니다.
EF Core 는 private set 과 백킹 필드에 그대로 값을 넣을 수 있어서
캡슐화를 유지한 채 매핑됩니다. 도메인 모델을 제대로 짜기에는 오히려 유리합니다.
| 프로젝트 | Spring 대응 | 여기에 두는 것 | 절대 두지 않는 것 |
|---|---|---|---|
Domain | domain 패키지 | 엔티티 · 값 객체 · 도메인 이벤트 · 업무 규칙 · 리포지토리 인터페이스 | EF Core · HTTP · 파일 · 로거 — 참조 자체가 없습니다 |
Application | service 패키지 | 유스케이스 핸들러 · DTO · 검증기 · 트랜잭션 경계 결정 | SQL · HttpContext · 컨트롤러 속성 |
Infrastructure | repository · 어댑터 | DbContext · 매핑 설정 · SMTP · SFTP · 외부 API 클라이언트 | 업무 규칙 (여기 들어가면 테스트가 DB 를 요구합니다) |
Api | controller + main | 컨트롤러 · Program.cs · 미들웨어 · DI 등록 | 업무 로직 · 직접 SQL — 얇을수록 좋습니다 |
Tests | src/test/java | 도메인 단위 테스트 · 통합 테스트 · 테스트 빌더 | 운영 코드에서 참조 (방향이 반대입니다) |
NetArchTest 나 ArchUnitNET 으로
"Domain 네임스페이스의 어떤 타입도 Microsoft.EntityFrameworkCore 에 의존하지 않는다"
같은 규칙을 xUnit 테스트 한 줄로 씁니다.
자바의 ArchUnit 을 쓰던 팀이라면 그대로 옮겨 오면 됩니다.
프로젝트를 나눌 수 없는 상황(모놀리식 단일 어셈블리)에서 특히 값어치가 큽니다.
MediatR — 서비스 클래스가 비대해질 때 🟡 중급
유스케이스 하나 = 파일 하나
// Payroll.Application/Approvals/ApproveDocument.cs
public sealed record ApproveDocumentCommand(Guid DocumentId, Guid ApproverId)
: IRequest<Result>;
public sealed class ApproveDocumentHandler(
IApprovalRepository repo,
IUnitOfWork uow,
IClock clock) : IRequestHandler<ApproveDocumentCommand, Result>
{
public async Task<Result> Handle(ApproveDocumentCommand cmd, CancellationToken ct)
{
var doc = await repo.FindWithStepsAsync(cmd.DocumentId, ct);
if (doc is null) return Result.Fail("문서 없음");
var r = doc.Approve(cmd.ApproverId, clock.Now);
if (r.IsFailure) return r;
await uow.SaveChangesAsync(ct); // 여기가 트랜잭션 경계
return Result.Ok();
}
}
// 컨트롤러는 얇아집니다
[HttpPost("{id:guid}/approve")]
public async Task<IActionResult> Approve(Guid id, ISender sender, CancellationToken ct)
{
var r = await sender.Send(new ApproveDocumentCommand(id, User.GetUserId()), ct);
return r.IsSuccess ? NoContent() : BadRequest(r.ToProblemDetails());
}
파이프라인 동작 = AOP 자리
// Spring 의 @Transactional · @Validated 애스펙트에 해당합니다
public sealed class ValidationBehavior<TReq, TRes>(
IEnumerable<IValidator<TReq>> validators)
: IPipelineBehavior<TReq, TRes> where TReq : notnull
{
public async Task<TRes> Handle(TReq req,
RequestHandlerDelegate<TRes> next, CancellationToken ct)
{
var ctx = new ValidationContext<TReq>(req);
// ★ ValidateAsync — MustAsync 규칙(e08)이 있으면 동기 Validate 는 예외를 던집니다
var results = await Task.WhenAll(validators.Select(v => v.ValidateAsync(ctx, ct)));
var failures = results
.SelectMany(r => r.Errors)
.Where(f => f is not null).ToList();
if (failures.Count > 0) throw new ValidationException(failures);
return await next(ct);
}
}
builder.Services.AddMediatR(c => {
c.RegisterServicesFromAssemblyContaining<ApproveDocumentHandler>();
c.AddOpenBehavior(typeof(LoggingBehavior<,>));
c.AddOpenBehavior(typeof(ValidationBehavior<,>));
c.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
ApprovalService 하나로 충분합니다 —
"레이어를 위한 레이어" 는 유지보수 비용만 늘립니다.
(라이선스: 13.x 부터 상용 조건이 있으니 도입 전 확인하세요. 대안으로 얇은 자체 디스패처를 두는 팀도 많습니다.)
예외 대신 Result 패턴 — 언제 무엇을 🔴 실무
예외로 흐름을 만드는 코드
if (doc is null)
throw new NotFoundException("문서 없음");
if (doc.Status != InProgress)
throw new BusinessException("결재 불가");
업무상 흔히 일어나는 일을 예외로 던지면
스택 트레이스 비용을 계속 물고, 로그가 ERROR 로 뒤덮여
진짜 장애가 묻힙니다. 초당 수백 건이 도는 결재 목록 화면에서 체감됩니다.
예상되는 실패는 값으로
public readonly record struct Result(bool IsSuccess, string? Error)
{
public bool IsFailure => !IsSuccess;
public static Result Ok() => new(true, null);
public static Result Fail(string e) => new(false, e);
}
예상되는 실패는 Result, 진짜 예외 상황만 throw 가 기준입니다.
DB 커넥션 끊김·널 참조는 예외가 맞고,
"권한 없음 · 결재 차례 아님" 은 값이 맞습니다.
Java 의 checked exception 논쟁이 여기서는 이렇게 정리됩니다.
EF Core 10 vs JPA — 영속성 컨텍스트가 어디로 갔나
JPA 를 제대로 써 본 사람은 EF Core 를 아주 빨리 익힙니다. 영속성 컨텍스트 = ChangeTracker, flush = SaveChanges, JPQL = LINQ 로 거의 그대로 옮겨집니다. 다른 것은 지연 로딩이 기본이 아니라는 점과 변경 감지 방식 두 가지입니다. 그 두 가지가 N+1 문제의 얼굴을 바꿔 놓습니다. (EF Core 기초 사용법은 🔷 w03 에 있습니다. 여기서는 운영에서 터지는 것들만 다룹니다.)
SaveChanges 가 모든 전이의 방아쇠입니다. 그 전까지는 메모리에서만 일어납니다.JPA 습관과 다른 지점 넷
| 주제 | JPA / Hibernate | EF Core 10 |
|---|---|---|
| 지연 로딩 | 기본 켜짐 (프록시) | 기본 꺼짐 — 안 부르면 null |
| 변경 감지 | 스냅샷 비교 (flush 때) | 스냅샷 비교 (SaveChanges 때) |
| 1차 캐시 | 영속성 컨텍스트 | ChangeTracker (같은 개념) |
| flush 자동 | 쿼리 전에 자동 flush | 없음 — 명시적으로만 |
| ID 생성 | @GeneratedValue | 규약 (Id) + ValueGenerated |
| N+1 증상 | 모르는 사이에 발생 | 대개 예외로 먼저 드러남 |
LazyInitializationException 을 겪어 봤다면
EF Core 의 "안 부르면 그냥 null" 이 얼마나 예측 가능한지 알게 됩니다.
필요한 것을 Include 로 명시하는 습관이 붙습니다.
UseLazyLoadingProxies() 는 virtual 프로퍼티에 프록시를 씌우는데,
그 순간 화면 하나에서 쿼리가 400개 나가는 고전적인 사고로 돌아갑니다.
웹 API 에서는 특히 직렬화가 모든 탐색 속성을 건드리기 때문에 더 위험합니다.
조회 — 세 가지 모드를 구분해서 씁니다
// ① 읽기 전용 — 화면에 뿌리기만 할 때 (엔터프라이즈 조회의 90%)
var list = await db.ApprovalDocuments
.AsNoTracking() // 스냅샷을 안 뜹니다 → 메모리·CPU 절약
.Where(d => d.Status == DocumentStatus.InProgress)
.OrderByDescending(d => d.CreatedAt)
.Select(d => new DocumentListItem( // ★ 필요한 컬럼만 SELECT
d.Id, d.Title, d.Amount, d.Drafter.Name))
.Take(50)
.ToListAsync(ct);
// ② 수정할 것 — 추적해서 읽고 값만 바꾸면 UPDATE 가 나갑니다
var doc = await db.ApprovalDocuments
.Include(d => d.Steps) // JPA 의 fetch join
.FirstOrDefaultAsync(d => d.Id == id, ct);
doc!.Approve(userId, clock.Now);
await db.SaveChangesAsync(ct); // 여기서만 SQL 이 나갑니다
// ③ 통계 · 대량 — 엔티티를 아예 만들지 않습니다
var byDept = await db.ApprovalDocuments
.AsNoTracking()
.GroupBy(d => d.DepartmentId)
.Select(g => new { g.Key, Cnt = g.Count(), Sum = g.Sum(x => x.Amount) })
.ToListAsync(ct);
Select 로 좁히는 것이 AsNoTracking 보다 효과가 큽니다.
엔티티를 통째로 읽으면 안 쓰는 nvarchar(max) 본문까지 네트워크를 탑니다.
목록 화면은 무조건 DTO 로 투영하세요 — 응답 크기가 10분의 1이 되는 일이 흔합니다.
N+1 — 지연의 대부분이 여기서 나옵니다 🔴 실무
N+1 을 만드는 코드와 고친 코드
// ❌ 목록을 읽고 루프 안에서 탐색 속성을 건드림
var docs = await db.ApprovalDocuments.Where(...).ToListAsync(ct);
foreach (var d in docs)
total += d.Steps.Count; // 지연 로딩이 켜져 있으면 매번 SELECT
// 꺼져 있으면 null 이라 NullReferenceException,
// 컬렉션을 [] 로 초기화해 뒀다면 조용히 0 (더 위험)
// ✅ 한 번에 가져오기
var docs = await db.ApprovalDocuments
.Include(d => d.Steps)
.ThenInclude(s => s.Approver)
.Where(d => d.Status == DocumentStatus.InProgress)
.AsSplitQuery() // ★ 컬렉션 Include 가 둘 이상이면 필수급
.ToListAsync(ct);
// ✅✅ 더 좋은 방법 — 애초에 필요한 모양으로 투영
var rows = await db.ApprovalDocuments
.AsNoTracking()
.Where(d => d.Status == DocumentStatus.InProgress)
.Select(d => new DocRow(
d.Id, d.Title,
d.Steps.Count(), // SQL 로 번역됩니다
d.Steps.Count(s => s.State == StepState.Approved)))
.ToListAsync(ct);
AsSplitQuery 를 아는 것이 실력 차이입니다.
컬렉션 Include 를 두 개 이상 걸면 SQL 이 카티전 폭발을 일으킵니다 —
문서 50 × 결재선 5 × 첨부 4 = 1,000행을 받아 와 메모리에서 합칩니다.
AsSplitQuery() 는 이를 쿼리 3개로 쪼갭니다.
전역으로 켜려면 o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery).
단, 쪼갠 쿼리 사이에는 원자성이 없으니 필요하면 트랜잭션 안에서 읽으세요.
N+1 을 자동으로 잡는 장치
// ① 개발 환경에서 SQL 을 눈으로 봅니다
builder.Services.AddDbContext<PayrollDbContext>((sp, o) =>
{
o.UseNpgsql(cs);
if (builder.Environment.IsDevelopment())
{
o.EnableSensitiveDataLogging(); // 파라미터 값까지 (운영 금지)
o.EnableDetailedErrors();
o.LogTo(Console.WriteLine, LogLevel.Information);
}
});
// ② 인터셉터로 "한 요청에 쿼리 N개 이상" 을 경고 (JPA 의 통계 대용)
public sealed class QueryCountInterceptor(IHttpContextAccessor acc)
: DbCommandInterceptor
{
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand cmd, CommandEventData e, InterceptionResult<DbDataReader> r)
{
var ctx = acc.HttpContext;
if (ctx is null) return r;
var n = (int)(ctx.Items["qn"] ?? 0) + 1;
ctx.Items["qn"] = n;
if (n == 30)
Log.Warning("한 요청에서 쿼리 30개 — {Path}", ctx.Request.Path);
return r;
}
// ★ ToListAsync 같은 비동기 쿼리는 이쪽으로 옵니다 — 이걸 빼면 하나도 안 셉니다
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand cmd, CommandEventData e, InterceptionResult<DbDataReader> r,
CancellationToken ct = default)
=> ValueTask.FromResult(ReaderExecuting(cmd, e, r));
}
o.AddInterceptors(new QueryCountInterceptor(acc));
낙관적 동시성 — RowVersion 은 필수입니다 🔴 실무
@Version 에 해당하는 것
public sealed class ApprovalDocument
{
// PostgreSQL: xmin 시스템 컬럼(uint) / SQL Server: rowversion — 이때는 byte[] 로
public uint Version { get; private set; }
}
// 매핑 — 두 DB 모두 IsRowVersion()
// Npgsql 7+ 는 uint 속성을 xmin 에 매핑합니다
// (옛 UseXminAsConcurrencyToken() 은 더 이상 권장되지 않습니다)
builder.Property(d => d.Version).IsRowVersion();
// 충돌 처리
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var db_ = await entry.GetDatabaseValuesAsync(ct);
if (db_ is null) return Result.Fail("다른 사용자가 삭제했습니다");
return Result.Fail("다른 사용자가 먼저 결재했습니다. 새로고침 후 다시 시도하세요");
}
왜 결재 시스템에 특히 중요한가
결재·재고·좌석처럼 여러 사람이 같은 행을 동시에 건드리는 업무에서는 "마지막에 저장한 사람이 이깁니다" 가 곧 데이터 사고입니다. 팀장 두 명이 같은 문서를 동시에 열어 한 명은 반려, 한 명은 승인하면 버전 토큰이 없을 때 둘 다 성공하고 결과는 하나만 남습니다.
db.Documents.FromSql($"SELECT * FROM documents WHERE id = {id} FOR UPDATE")
같은 식으로 SELECT ... FOR UPDATE 를 직접 씁니다.
짧은 트랜잭션 안에서만 쓰세요 — 야간 배치와 겹치면 데드락으로 갑니다 (🔷 e06).
Version 을 클라이언트가 들고 있다가
저장 요청에 같이 보냅니다. 서버는 그 값을 엔티티에 세팅하고 저장합니다 —
entry.Property(x => x.Version).OriginalValue = dto.Version;
사용자가 커피를 마시고 온 30분 동안 DB 커넥션을 잡고 있을 수는 없습니다.
마이그레이션 운영 — 운영 DB 에 Update-Database 를 치지 않는 법 🔴 실무
개발은 명령, 운영은 스크립트
# 개발 — 마이그레이션 만들기
dotnet ef migrations add AddApprovalSteps \
-p src/Payroll.Infrastructure -s src/Payroll.Api
# 개발 DB 에 적용
dotnet ef database update -s src/Payroll.Api
# ★ 운영 — SQL 스크립트를 뽑아 DBA 검토를 거칩니다
dotnet ef migrations script LastApplied Target \
-s src/Payroll.Api -o deploy/2026-08-28.sql --idempotent
# 번들 실행 파일로 뽑는 방법도 있습니다 (SDK 없는 서버에서 실행 가능)
dotnet ef migrations bundle -s src/Payroll.Api \
-o deploy/migrate --self-contained -r linux-x64
Database.Migrate() 를 앱 기동 코드에 넣지 마세요.
인스턴스 3대가 동시에 뜨면 세 번 동시에 DDL 을 칩니다.
무중단 배포 중이라면 구버전 코드가 신버전 스키마를 만나기도 합니다.
마이그레이션은 배포 파이프라인의 독립 단계여야 합니다.
하위 호환 스키마 — 배포 순서
| 바꾸는 것 | 안전한 순서 |
|---|---|
| 컬럼 추가 | NULL 허용으로 추가 → 코드 배포 → 채우기 → NOT NULL |
| 컬럼 삭제 | 코드에서 안 쓰게 배포 → 한 사이클 대기 → DROP |
| 컬럼 이름 변경 | 새 컬럼 추가 → 양쪽 쓰기 → 읽기 전환 → 옛 컬럼 삭제 |
| 인덱스 추가 | PostgreSQL 은 CONCURRENTLY · 배치 창에 |
| 대량 UPDATE | 마이그레이션에 넣지 말고 배치로 (🔷 f05) |
Up() 안에서 데이터 변환 로직을 쓸 수 있습니다.
편하지만 대량 데이터에는 쓰지 마세요 —
마이그레이션이 30분 돌면 배포가 30분 멈춥니다.
정 필요하면 migrationBuilder.Sql("...") 로 순수 SQL을 두는 편이 낫습니다.
EF.CompileAsyncQuery 는 LINQ → SQL 번역 결과를 캐시합니다.
EF Core 는 이미 쿼리 캐시를 갖고 있어서 일반적으로는 체감이 없습니다.
다만 대시보드처럼 같은 쿼리를 초당 수백 번 부르는 자리에서는
번역 오버헤드(요청당 수십 μs)가 쌓여 의미가 생깁니다.
먼저 측정하고, 그 다음에 쓰세요. 안 그러면 읽기 어려운 코드만 남습니다.
AsNoTracking + Select, 수정은 Include 후 SaveChanges,
컬렉션이 둘 이상이면 AsSplitQuery. 동시 수정이 있는 테이블에는 반드시 RowVersion,
마이그레이션은 앱이 아니라 배포 파이프라인이 적용합니다.트랜잭션 — @Transactional 이 없는 세계에서 경계 긋기
Spring 개발자가 .NET 에서 가장 크게 당황하는 지점입니다.
애너테이션 하나로 열리던 트랜잭션이 없습니다.
대신 SaveChanges() 한 번이 곧 트랜잭션 하나라는 아주 단순한 규칙이 있고,
그 규칙만으로 부족할 때 BeginTransaction 과 TransactionScope 가 있습니다.
그리고 분산 트랜잭션은 사실상 없습니다 — 그 자리를 아웃박스 패턴이 메웁니다.
기본 — SaveChanges 가 곧 트랜잭션
// 이 안의 변경은 전부 한 트랜잭션입니다. 명시적 선언이 필요 없습니다.
doc.Approve(userId, now); // Modified
db.AuditLogs.Add(new AuditLog(...)); // Added
db.Notifications.Add(new Notify(...)); // Added
await db.SaveChangesAsync(ct);
// → BEGIN; UPDATE documents...; INSERT audit_logs...;
// INSERT notifications...; COMMIT;
// 하나라도 실패하면 전부 롤백됩니다.
@Transactional 의 80%를 대체합니다.
유스케이스 핸들러가 SaveChanges 를 한 번만 부르도록 짜면
트랜잭션 경계 고민이 사라집니다.
"핸들러 하나 = 트랜잭션 하나" 를 팀 규칙으로 두세요.
SaveChanges 를 루프 안에서 부르지 마세요.
Spring 에서 saveAll 을 부르던 자리에 반복 저장을 넣으면
트랜잭션이 N개가 됩니다 — 중간에 실패하면 절반만 반영된 상태가 남습니다.
모으고 마지막에 한 번 부르는 것이 정답입니다.
명시적 트랜잭션이 필요한 세 경우
// ① SaveChanges 를 여러 번 불러야 할 때 (생성된 ID 가 다음 단계에 필요)
await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
db.Documents.Add(doc);
await db.SaveChangesAsync(ct); // 여기서 doc.Id 확정
db.Steps.AddRange(BuildSteps(doc.Id));
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
}
catch { await tx.RollbackAsync(ct); throw; }
// ② EF Core 와 Dapper 를 한 트랜잭션에 묶을 때
await using var tx2 = await db.Database.BeginTransactionAsync(ct);
var conn = db.Database.GetDbConnection();
await conn.ExecuteAsync("UPDATE budgets SET used = used + @a WHERE dept_id = @d",
new { a = doc.Amount, d = doc.DepartmentId },
transaction: tx2.GetDbTransaction()); // ★ 같은 트랜잭션을 넘겨야 합니다
await db.SaveChangesAsync(ct);
await tx2.CommitAsync(ct);
// ③ 격리 수준을 올려야 할 때 (중복 신청 방지 등)
await using var tx3 = await db.Database
.BeginTransactionAsync(IsolationLevel.Serializable, ct);
@Transactional 전파 속성은 어떻게 대응하나 🔴 실무
| Spring 전파 | 뜻 | .NET 대응 |
|---|---|---|
REQUIRED (기본) | 있으면 참여, 없으면 시작 | 기본값 — 같은 DbContext 를 쓰면 자동으로 참여 |
REQUIRES_NEW | 기존 것을 멈추고 새로 시작 | TransactionScope(RequiresNew) 또는 별도 DbContext (권장) |
SUPPORTS | 있으면 참여, 없으면 그냥 | 기본 동작과 같음 (신경 쓸 것 없음) |
NOT_SUPPORTED | 트랜잭션 없이 실행 | TransactionScope(Suppress) · 새 커넥션 |
MANDATORY | 없으면 예외 | db.Database.CurrentTransaction is null 검사 |
NEVER | 있으면 예외 | 같은 방식으로 직접 검사 |
NESTED | 세이브포인트 | tx.CreateSavepointAsync("s1") / RollbackToSavepointAsync |
가장 흔한 REQUIRES_NEW — 감사 로그
// "본 작업은 롤백되더라도 감사 로그는 남겨야 한다"
// Spring 이라면 @Transactional(propagation = REQUIRES_NEW)
public sealed class AuditLogger(IDbContextFactory<PayrollDbContext> factory)
{
public async Task WriteAsync(AuditEntry e, CancellationToken ct)
{
// 완전히 다른 커넥션 · 다른 트랜잭션입니다
await using var db = await factory.CreateDbContextAsync(ct);
db.AuditLogs.Add(e.ToEntity());
await db.SaveChangesAsync(ct);
}
}
TransactionScope 대신 이 방식인가.
TransactionScope 는 암시적이라 어디서 트랜잭션이 열렸는지 추적이 어렵고,
커넥션이 둘이 되는 순간 분산 트랜잭션 승격을 시도합니다.
.NET 10 에서도 승격은 Windows + MSDTC 에서만 됩니다 —
리눅스·컨테이너에서는 PlatformNotSupportedException 이 납니다.
"DbContext 를 하나 더 만든다" 가 가장 안전한 대응입니다.
데드락 · 일시 오류 재시도
// EF Core 내장 재시도 — 커넥션 끊김 · 교착 상태를 자동으로 다시
builder.Services.AddDbContext<PayrollDbContext>(o =>
o.UseNpgsql(cs, npg => npg.EnableRetryOnFailure(
maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(10),
errorCodesToAdd: null)));
"The configured execution strategy does not support user-initiated transactions" —
재시도 전략이 트랜잭션 전체를 다시 돌려야 하는데 그 범위를 모르기 때문입니다.
해결은 실행 전략으로 감싸는 것입니다.
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync(ct);
/* ... 작업 ... */
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
});
아웃박스 패턴 — DB 와 메시지를 한꺼번에 커밋할 수 없을 때 🧬 전문가
아웃박스 테이블과 저장
CREATE TABLE outbox_messages (
id uuid PRIMARY KEY,
occurred_at timestamptz NOT NULL,
type varchar(200) NOT NULL,
payload jsonb NOT NULL,
published_at timestamptz NULL,
attempts int NOT NULL DEFAULT 0,
last_error text NULL
);
-- 미발행 행만 빠르게 찾는 부분 인덱스
CREATE INDEX ix_outbox_pending ON outbox_messages (occurred_at)
WHERE published_at IS NULL;
// SaveChanges 를 가로채 도메인 이벤트를 아웃박스 행으로 바꿉니다
public override async Task<int> SaveChangesAsync(CancellationToken ct = default)
{
var events = ChangeTracker.Entries<IHasDomainEvents>()
.SelectMany(e => e.Entity.PopEvents())
.ToList();
foreach (var ev in events)
OutboxMessages.Add(new OutboxMessage(
Guid.CreateVersion7(), DateTimeOffset.UtcNow,
ev.GetType().FullName!, JsonSerializer.Serialize(ev, ev.GetType())));
return await base.SaveChangesAsync(ct); // 업무 변경 + 아웃박스가 한 트랜잭션
}
발행기 — BackgroundService
public sealed class OutboxPublisher(
IDbContextFactory<PayrollDbContext> factory,
IMessageBus bus,
ILogger<OutboxPublisher> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await using var db = await factory.CreateDbContextAsync(ct);
// ★ 행 잠금은 트랜잭션이 끝날 때까지만 유지됩니다 — 발행·저장까지 한 트랜잭션으로
await using var tx = await db.Database.BeginTransactionAsync(ct);
// 여러 인스턴스가 같은 행을 집지 않도록 잠급니다 (FromSql 은 DbSet 에서 바로 시작)
var batch = await db.OutboxMessages
.FromSql($@"SELECT * FROM outbox_messages
WHERE published_at IS NULL AND attempts < 10
ORDER BY occurred_at LIMIT 100
FOR UPDATE SKIP LOCKED")
.ToListAsync(ct);
foreach (var m in batch)
{
try
{
await bus.PublishAsync(m.Type, m.Payload, ct);
m.MarkPublished(DateTimeOffset.UtcNow);
}
catch (Exception ex)
{
m.MarkFailed(ex.Message); // attempts++ · last_error
log.LogWarning(ex, "아웃박스 발행 실패 {Id}", m.Id);
}
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); // 여기서 잠금이 풀립니다
await Task.Delay(TimeSpan.FromSeconds(5), ct);
}
}
}
FOR UPDATE SKIP LOCKED 가 핵심입니다.
인스턴스 3대가 동시에 돌면 같은 메시지를 세 번 보냅니다.
이 힌트를 주면 이미 잡힌 행은 건너뜁니다.
SQL Server 라면 WITH (UPDLOCK, READPAST) 입니다.
그래도 소비 쪽은 반드시 멱등하게 짜세요 — 중복은 언젠가 옵니다 (🔷 f10).
SaveChanges 한 번이 트랜잭션 하나입니다.
핸들러 하나 = SaveChanges 하나 를 규칙으로 두면 대부분 끝납니다.
REQUIRES_NEW 는 DbContext 를 하나 더, 재시도를 켰다면 실행 전략으로 감싸고,
DB 와 메시지를 함께 보내야 하면 아웃박스를 씁니다.Dapper · 저장 프로시저 · 멀티 DB — ORM 이 안 되는 자리
엔터프라이즈 시스템에는 ORM 으로 짜면 안 되는 쿼리가 반드시 있습니다. 조인 8개짜리 정산 조회, 윈도 함수로 순위를 매기는 통계, 수백만 건 적재. 자바에서 MyBatis 로 하던 그 일을 .NET 에서는 Dapper 로 합니다. 그리고 대부분의 사내 시스템은 DB 가 하나가 아닙니다 — 그 이야기까지 합니다.
Dapper — MyBatis 를 쓰던 감각으로
dotnet add package Dapper
dotnet add package Npgsql # 또는 Microsoft.Data.SqlClient
public sealed class SettlementQueries(NpgsqlDataSource ds)
{
private const string Sql = """
SELECT d.dept_code AS DeptCode,
d.dept_name AS DeptName,
COUNT(*) AS DocCount,
SUM(a.amount) AS TotalAmount,
RANK() OVER (ORDER BY SUM(a.amount) DESC) AS Rank
FROM approval_documents a
JOIN departments d ON d.id = a.department_id
WHERE a.completed_at >= @from AND a.completed_at < @to
AND a.status = 'Completed'
GROUP BY d.dept_code, d.dept_name
HAVING SUM(a.amount) > @minAmount
ORDER BY Rank
""";
public async Task<IReadOnlyList<DeptSettlement>> GetAsync(
DateOnly from, DateOnly to, decimal minAmount, CancellationToken ct)
{
await using var conn = await ds.OpenConnectionAsync(ct);
var rows = await conn.QueryAsync<DeptSettlement>(
new CommandDefinition(Sql,
new { from, to, minAmount }, cancellationToken: ct));
return rows.AsList();
}
}
// record 하나면 매핑 끝 — 이름과 타입이 맞아야 합니다
// (생성자 매핑은 타입까지 봅니다 — COUNT(*) · RANK() 는 bigint 라 long)
public sealed record DeptSettlement(
string DeptCode, string DeptName, long DocCount, decimal TotalAmount, long Rank);
""")이 SQL 을 살렸습니다.
이스케이프도 문자열 이어붙이기도 없이 SQL 을 그대로 붙여 넣습니다.
DBeaver 에서 튜닝한 쿼리를 복사해서 그대로 코드에 넣고,
문제가 생기면 그대로 복사해서 DB 도구로 돌려 볼 수 있습니다.
MyBatis XML 을 오가던 왕복이 사라집니다.
MyBatis 와 Dapper 대응
| MyBatis | Dapper |
|---|---|
| Mapper XML | C# 안의 원시 문자열 (파일 분리도 가능) |
#{param} | @param + 익명 객체 |
${param} (문자열 치환) | 없습니다 — SQL 인젝션 방지 |
<if> 동적 SQL | SqlBuilder 또는 C# 문자열 조립 |
resultMap | 이름 규약 · splitOn · 다중 매핑 |
selectList | QueryAsync<T> |
selectOne | QuerySingleOrDefaultAsync<T> |
| 배치 insert | ExecuteAsync 에 컬렉션 전달 |
저장 프로시저 호출
var p = new DynamicParameters();
p.Add("@deptId", deptId);
p.Add("@yearMonth", "202608");
p.Add("@resultCount", dbType: DbType.Int32,
direction: ParameterDirection.Output);
await conn.ExecuteAsync("usp_settle_monthly", p,
commandType: CommandType.StoredProcedure,
commandTimeout: 600); // 배치 프로시저는 타임아웃을 넉넉히
int count = p.Get<int>("@resultCount");
commandTimeout 을 반드시 지정하세요.
여러 DB 를 쓰는 현실 — 읽기 복제본 · 레거시 · 멀티 테넌트 🔴 실무
읽기 전용 복제본으로 라우팅
// 쓰기용 · 읽기용 DbContext 를 나눕니다 (같은 모델, 다른 커넥션)
builder.Services.AddDbContext<PayrollDbContext>(o =>
o.UseNpgsql(cfg.GetConnectionString("Primary")));
builder.Services.AddDbContext<PayrollReadDbContext>(o =>
o.UseNpgsql(cfg.GetConnectionString("Replica"))
.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));
public sealed class PayrollReadDbContext(DbContextOptions<PayrollReadDbContext> o)
: PayrollDbContext(o)
{
public override int SaveChanges() =>
throw new InvalidOperationException("읽기 전용 컨텍스트입니다");
public override Task<int> SaveChangesAsync(CancellationToken ct = default) =>
throw new InvalidOperationException("읽기 전용 컨텍스트입니다");
}
멀티 테넌시 — 세 가지 방식
| 방식 | 장점 | 단점 |
|---|---|---|
| 테넌트별 DB | 완전 격리 · 백업·복구 단순 · 고객 요구 충족 | 마이그레이션을 N번 · 커넥션 풀 N배 |
| 스키마 분리 | DB 하나 · 격리도 적당 | 도구 지원이 얇고 관리 스크립트 필요 |
공유 테이블 + TenantId | 운영이 가장 단순 · 자원 효율 최고 | 필터를 한 번만 빠뜨려도 유출 |
// 공유 테이블이면 전역 쿼리 필터를 반드시 겁니다
public sealed class PayrollDbContext(
DbContextOptions o, ITenantContext tenant) : DbContext(o)
{
protected override void OnModelCreating(ModelBuilder b)
{
b.Entity<ApprovalDocument>()
.HasQueryFilter(d => d.TenantId == tenant.CurrentId);
// 저장할 때도 자동으로 채웁니다
}
public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
foreach (var e in ChangeTracker.Entries<ITenantOwned>()
.Where(e => e.State == EntityState.Added))
e.Entity.TenantId = tenant.CurrentId;
return base.SaveChangesAsync(ct);
}
}
IgnoreQueryFilters() 로 뚫립니다.
관리자 화면에서 편하다고 쓰기 시작하면 언젠가 고객 데이터가 섞여 나갑니다.
Dapper 로 쓴 SQL 에는 필터가 아예 안 걸린다는 점도 기억하세요 —
원시 SQL 마다 tenant_id = @tenantId 를 손으로 넣어야 합니다.
테넌트별 DB 를 고를 이유가 여기 있습니다.
SqlBulkCopy 는 다음 탭에서 끝까지 다룹니다.
수백만 건 적재, 키셋 페이징, ExecuteUpdate/ExecuteDelete,
임시 테이블 활용은 배치 탭의 주제라 그쪽에 모아 두었습니다 (🔷 f05).
이 섹션은 "온라인 트랜잭션 처리 중에 무엇을 고르나" 까지입니다.
검증 · 매핑 · 표준 에러 응답 — 화면과의 계약
Bean Validation 으로 @NotBlank 를 붙이던 자리, @ControllerAdvice 로
예외를 한곳에서 잡던 자리, MapStruct 로 DTO 를 만들던 자리가 각각 무엇으로 바뀌는지 봅니다.
특히 에러 응답은 RFC 9457 ProblemDetails 라는 표준이 있고,
.NET 은 그것을 기본으로 씁니다. 프론트엔드와의 협의 시간을 통째로 줄여 줍니다.
ProblemDetails 는 표준이라 문서가 필요 없습니다.전역 예외 핸들러 — @ControllerAdvice 자리
// .NET 8+ 표준 인터페이스입니다
public sealed class BusinessExceptionHandler(
IProblemDetailsService pds,
ILogger<BusinessExceptionHandler> log) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext ctx, Exception ex, CancellationToken ct)
{
var (status, title) = ex switch
{
ValidationException => (400, "입력값이 올바르지 않습니다"),
NotFoundException => (404, "대상을 찾을 수 없습니다"),
BusinessRuleException => (409, "업무 규칙 위반"),
UnauthorizedAccessException => (403, "권한이 없습니다"),
DbUpdateConcurrencyException => (409, "다른 사용자가 먼저 처리했습니다"),
_ => (0, "") // 우리가 모르는 예외
};
if (status == 0)
{
log.LogError(ex, "처리되지 않은 예외");
return false; // 다음 핸들러(기본 500)로 넘깁니다
}
ctx.Response.StatusCode = status;
return await pds.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = ctx,
ProblemDetails = new ProblemDetails
{
Title = title,
Detail = ex.Message,
Status = status,
Type = $"https://wiki.acme/errors/{ex.GetType().Name}"
}
});
}
}
// 등록 — 순서대로 시도합니다
builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = c =>
c.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id);
builder.Services.AddExceptionHandler<BusinessExceptionHandler>();
app.UseExceptionHandler();
FluentValidation — Bean Validation 자리
public sealed class CreateDocumentValidator
: AbstractValidator<CreateDocumentCommand>
{
public CreateDocumentValidator(IDepartmentReader depts)
{
RuleFor(x => x.Title)
.NotEmpty().WithMessage("제목은 필수입니다")
.MaximumLength(200);
RuleFor(x => x.Amount)
.GreaterThan(0).WithMessage("0보다 커야 합니다")
.LessThanOrEqualTo(1_000_000_000);
RuleFor(x => x.DepartmentId)
.MustAsync(async (id, ct) => await depts.ExistsAsync(id, ct))
.WithMessage("존재하지 않는 부서입니다");
// 조건부 규칙 — @AssertTrue 로 억지로 짜던 것
When(x => x.Amount >= 10_000_000, () =>
RuleFor(x => x.Attachments)
.NotEmpty().WithMessage("1천만원 이상은 첨부가 필요합니다"));
}
}
| Bean Validation | .NET |
|---|---|
@NotBlank | [Required] · NotEmpty() |
@Size(max=200) | [MaxLength(200)] · MaximumLength |
@Min @Max | [Range] · GreaterThan |
@Email | [EmailAddress] · EmailAddress() |
| 커스텀 검증기 | MustAsync (DI 주입 가능) |
@Valid (자동 실행) | 파이프라인 동작에서 실행 (🔷 e04) |
DTO 매핑 — MapStruct 자리는 Mapperly 🟡 중급
소스 생성기 — 런타임 비용 0
dotnet add package Riok.Mapperly
[Mapper]
public static partial class DocumentMapper
{
// 구현은 빌드 때 생성됩니다. 리플렉션이 없습니다.
public static partial DocumentDto ToDto(ApprovalDocument src);
[MapProperty("Drafter.Name", nameof(DocumentDetailDto.DrafterName))] // nameof(a.b.c) 는 "c" 만 남습니다
public static partial DocumentDetailDto ToDetail(ApprovalDocument src);
// 이름이 다르거나 계산이 필요한 것만 손으로
private static string MapStatus(DocumentStatus s) => s switch
{
DocumentStatus.Draft => "임시저장",
DocumentStatus.InProgress => "진행중",
DocumentStatus.Completed => "완결",
DocumentStatus.Rejected => "반려",
_ => "알 수 없음"
};
}
그냥 손으로 쓰는 것도 훌륭합니다
// 프로젝션은 아예 쿼리에 넣는 것이 최선입니다 (SQL 이 좁아집니다)
public static IQueryable<DocumentDto> ToDto(this IQueryable<ApprovalDocument> q)
=> q.Select(d => new DocumentDto(
d.Id, d.Title, d.Amount,
d.Drafter.Name,
d.Steps.Count(s => s.State == StepState.Approved),
d.Steps.Count));
// 사용
var rows = await db.ApprovalDocuments
.AsNoTracking()
.Where(d => d.DepartmentId == deptId)
.ToDto() // ← 여기서 SELECT 컬럼이 정해집니다
.ToListAsync(ct);
IQueryable 위에서 하는 투영은 SQL 로 번역됩니다.
객체를 다 읽어 온 뒤 매핑하는 방식과는 네트워크 트래픽 자체가 다릅니다.
엔터프라이즈 목록 화면에서는 이 차이가 응답 시간의 절반입니다.
record 로.
값 비교 · with 식 복사 · 불변성이 공짜로 옵니다.
Java 의 record 와 같은 개념이고 문법도 거의 같습니다.
엔티티를 그대로 응답으로 내보내지 마세요 —
순환 참조로 직렬화가 터지고, 내부 컬럼이 유출됩니다.
ProblemDetails 표준을 그대로 쓰고
IExceptionHandler 로 한곳에서 변환합니다.
검증은 단순 규칙 DataAnnotations · 복잡 규칙 FluentValidation,
매핑은 Mapperly 또는 IQueryable 투영. AutoMapper 는 피하세요.설정 · 프로파일 · 시크릿 — 나중에 읽은 것이 이깁니다
Spring 의 application-{profile}.yml 과 PropertySource 우선순위에
정확히 대응하는 구조가 .NET 에도 있습니다.
규칙은 딱 하나 — 나중에 등록한 소스가 앞의 값을 덮어씁니다.
이 한 줄만 알면 "왜 운영에서 개발 DB 로 붙지?" 같은 사고가 사라집니다.
환경 이름 — spring.profiles.active 자리
# 웹 앱
ASPNETCORE_ENVIRONMENT=Production
# 워커 · 콘솔 앱 (웹이 아니면 이 이름입니다 — 자주 틀립니다)
DOTNET_ENVIRONMENT=Production
# 계층 키는 이중 밑줄. 콜론(:) 은 리눅스 환경 변수에서 못 씁니다.
ConnectionStrings__Payroll="Host=db;Database=payroll;Username=app"
Approval__MaxLineDepth=7
Serilog__MinimumLevel__Default=Warning
# 배열은 인덱스로
AllowedOrigins__0=https://portal.acme.co.kr
AllowedOrigins__1=https://admin.acme.co.kr
Development · Staging · Production 은 관례일 뿐이고
QA, UAT, DR 같은 사내 환경을 그대로 써도 됩니다.
다만 app.Environment.IsDevelopment() 는
정확히 "Development" 일 때만 참입니다 —
오타 하나로 개발용 상세 오류 페이지가 운영에 노출됩니다.
// 사내 환경을 쓰면 확장 메서드를 하나 만들어 두는 편이 안전합니다
public static bool IsUat(this IHostEnvironment e) => e.IsEnvironment("UAT");
시크릿 — 절대 파일에 두지 않기
# 개발 PC — 소스 트리 밖(홈 폴더)에 저장됩니다
dotnet user-secrets init -p src/Payroll.Api
dotnet user-secrets set "ConnectionStrings:Payroll" \
"Host=localhost;Database=payroll;Password=dev1234" -p src/Payroll.Api
dotnet user-secrets list -p src/Payroll.Api
// 운영 — 클라우드 금고를 설정 소스로 붙입니다
if (!builder.Environment.IsDevelopment())
{
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential()); // 관리 ID — 비밀번호가 코드에 없습니다
}
// 사내 온프레미스라면 — 파일 권한으로 지킵니다
builder.Configuration.AddJsonFile(
"/etc/payroll/secrets.json", optional: false, reloadOnChange: true);
// chown payroll:payroll /etc/payroll/secrets.json && chmod 600
appsettings.json 에 비밀번호를 넣지 마세요.
지우고 커밋해도 히스토리에 영원히 남습니다.
사고가 났다면 파일 삭제가 아니라 비밀번호 교체가 대응입니다.
git-secrets · GitHub 시크릿 스캐닝을 CI 에 넣어 두세요 (🔷 t07).
cfg.GetConnectionString("Payroll") 은
ConnectionStrings:Payroll 을 읽는 지름길입니다.
환경 변수로 넘길 때는 ConnectionStrings__Payroll 입니다.
| 하고 싶은 일 | Spring Boot | ASP.NET Core |
|---|---|---|
| 환경 고르기 | spring.profiles.active=prod | ASPNETCORE_ENVIRONMENT=Production |
| 환경별 파일 | application-prod.yml | appsettings.Production.json |
| 값 하나 읽기 | @Value("${approval.max}") | cfg["Approval:Max"] · cfg.GetValue<int>(...) |
| 묶어서 바인딩 | @ConfigurationProperties | IOptions<T> + Bind() |
| 바인딩 검증 | @Validated + JSR-380 | ValidateDataAnnotations().ValidateOnStart() |
| 계층 키 (환경 변수) | APPROVAL_MAX (완화된 바인딩) | Approval__Max (이중 밑줄) |
| 목록 · 배열 | list[0]=a | Origins__0=a |
| 개발 PC 시크릿 | application-local.yml (gitignore) | User Secrets (소스 트리 밖) |
| 운영 시크릿 | Vault · Config Server | Key Vault · Secrets Manager · 파일 권한 |
| 런타임 갱신 | @RefreshScope + Actuator | IOptionsMonitor<T> + reloadOnChange |
| 중앙 설정 서버 | Spring Cloud Config | Azure App Configuration · Consul 공급자 |
| 기동 시 설정 덤프 | --debug 자동 설정 리포트 | 없음 — 직접 로깅 (아래 참고) |
값을 직접 읽을 때와 묶어 받을 때
// ① 기동 코드에서 한 번만 쓰는 값 — 직접 읽습니다
var cs = cfg.GetConnectionString("Payroll");
var timeout = cfg.GetValue("Erp:TimeoutSeconds", 30); // 없으면 기본값 30
var origins = cfg.GetSection("AllowedOrigins").Get<string[]>() ?? [];
// ② 서비스가 계속 쓰는 값 — 반드시 IOptions 로 묶습니다
// 직접 IConfiguration 을 주입받으면 오타가 런타임까지 살아남습니다
public sealed class ApprovalService(IOptions<ApprovalOptions> opt)
{
private readonly ApprovalOptions _o = opt.Value; // 타입도 이름도 컴파일러가 지킴
}
// ❌ 이렇게 쓰면 "Aproval:MaxDepth" 오타가 조용히 기본값(0)이 됩니다
public sealed class Bad(IConfiguration cfg)
{
private readonly int _max = cfg.GetValue<int>("Aproval:MaxDepth"); // 0 이 됩니다
}
"Approval:MaxLineDepth",
셸·도커 컴포즈·쿠버네티스에서는 Approval__MaxLineDepth.
윈도우에서는 콜론도 되지만 리눅스에서는 안 되므로
항상 이중 밑줄로 통일하는 편이 사고가 없습니다.
기동할 때 "무엇으로 떴는지" 남기기
// Spring 의 자동 설정 리포트에 해당하는 것이 없으므로 직접 남깁니다.
// 장애 조사 때 "그 서버가 어떤 설정으로 떠 있었나" 를 5초 만에 답합니다.
var log = app.Services.GetRequiredService<ILogger<Program>>();
log.LogInformation(
"기동 — 환경 {Env} · 버전 {Ver} · DB {Host} · 결재선 최대 {Depth}단계",
app.Environment.EnvironmentName,
typeof(Program).Assembly.GetName().Version,
new NpgsqlConnectionStringBuilder(cs).Host, // ★ 비밀번호는 빼고 호스트만
app.Services.GetRequiredService<IOptions<ApprovalOptions>>().Value.MaxLineDepth);
NpgsqlConnectionStringBuilder 나
SqlConnectionStringBuilder 로 호스트·DB 이름만 뽑아 남깁니다.
"어느 DB 에 붙었나" 만 알면 되고, 비밀번호는 알 필요가 없습니다.
((IConfigurationRoot)cfg).GetDebugView() 를 찍으면
키마다 어느 소스에서 왔는지 전부 나옵니다 —
"이 값이 왜 이렇지?" 를 즉시 해결합니다.
운영에서는 절대 노출하지 마세요(시크릿이 평문으로 나옵니다).
런타임 갱신과 기동 실패 전략 🔴 실무
파일이 바뀌면 즉시 반영
public sealed class RateLimitGate
{
private readonly IOptionsMonitor<GateOptions> opt;
public RateLimitGate(IOptionsMonitor<GateOptions> opt)
{
this.opt = opt;
// 값이 바뀔 때마다 콜백 — 로그를 남겨 두면 추적이 쉽습니다
opt.OnChange(o => Log.Information(
"설정 변경 감지 — 분당 한도 {Limit}", o.PerMinute));
}
public bool Allow(string user) => Count(user) < opt.CurrentValue.PerMinute;
}
reloadOnChange 는 파일 시스템 이벤트에 의존하는데
도커 볼륨·쿠버네티스 컨피그맵 마운트에서는 이벤트가 안 오거나 늦습니다.
컨테이너 환경의 정답은 설정을 바꾸고 파드를 다시 띄우는 것입니다 —
불변 인프라가 원래 그런 것이니 억지로 핫 리로드를 만들지 마세요.
틀린 설정으로는 뜨지 않게
// ① 옵션 검증 — 기동 즉시 실패
builder.Services.AddOptions<ApprovalOptions>()
.Bind(cfg.GetSection("Approval"))
.ValidateDataAnnotations()
.ValidateOnStart();
// ② 필수 커넥션 문자열 — 없으면 즉시 예외
var cs = cfg.GetConnectionString("Payroll")
?? throw new InvalidOperationException(
"ConnectionStrings:Payroll 이 없습니다. 환경 변수를 확인하세요.");
// ③ 기동 시 실제로 붙어 보는 헬스체크 (준비 완료 판정용)
builder.Services.AddHealthChecks()
.AddNpgSql(cs, name: "db", tags: ["ready"])
.AddCheck<SftpHealthCheck>("sftp", tags: ["ready"]);
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
app.MapHealthChecks("/health/ready", new() { Predicate = c => c.Tags.Contains("ready") });
live 와 ready 를 나누는 이유도 같습니다 —
준비 안 된 인스턴스에 트래픽이 가지 않게 로드밸런서가 판단합니다 (🔷 h10).
ASPNETCORE_ENVIRONMENT(웹) /
DOTNET_ENVIRONMENT(워커). 시크릿은 개발은 User Secrets · 운영은 금고,
그리고 ValidateOnStart 로 틀린 설정에는 아예 뜨지 않게 만듭니다.인증 · 인가 — 사내 시스템의 현실은 조금 다릅니다
게임 백엔드의 JWT 로그인은 🔷 w04 에 있습니다. 여기서는 사내 업무 시스템 이야기입니다 —
이미 있는 AD 나 Keycloak 에 붙어야 하고, 권한은 역할 하나로 안 끝나며
("팀장은 1천만원까지, 본부장은 1억까지, 단 본인 부서만"),
감사 로그에 누가 무엇을 왜 승인했는지가 남아야 합니다.
@PreAuthorize 로 하던 일을 .NET 의 정책 기반 인가로 옮깁니다.
사내 IdP 에 붙기 — OIDC
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(o =>
{
// Keycloak · Entra ID · 사내 IdP 모두 같은 모양입니다
o.Authority = cfg["Auth:Authority"]; // https://sso.acme.co.kr/realms/acme
o.Audience = cfg["Auth:Audience"]; // payroll-api
o.RequireHttpsMetadata = !env.IsDevelopment();
o.TokenValidationParameters = new()
{
ValidateIssuer = true, ValidateAudience = true,
ValidateLifetime = true, ClockSkew = TimeSpan.FromSeconds(30),
NameClaimType = "preferred_username",
RoleClaimType = "roles" // ★ 이것을 안 맞추면 역할이 안 붙습니다
};
o.MapInboundClaims = false; // ★ 클레임 이름을 토큰 그대로 둡니다 (켜 두면 roles 가 긴 URI 로 바뀌어 위 설정과 어긋남)
o.Events = new JwtBearerEvents
{
OnAuthenticationFailed = c =>
{
Log.Warning("토큰 검증 실패 {Reason}", c.Exception.Message);
return Task.CompletedTask;
}
};
});
RoleClaimType 이 1순위 함정입니다.
[Authorize(Roles = "Manager")] 가 안 먹는 사고의 90%가 이것입니다.
.NET 은 기본으로 http://schemas.microsoft.com/ws/2008/06/identity/claims/role 을 보는데
Keycloak 은 realm_access.roles, Entra ID 는 roles 에 담습니다.
토큰을 jwt.io 에 붙여 실제 클레임 이름을 확인하고 맞추세요.
인증 방식 선택 — 사내 시스템 기준
| 상황 | 고르는 것 |
|---|---|
| 사내 SSO 가 이미 있다 | OIDC + JWT Bearer — 거의 항상 정답 |
| 도메인 PC 에서만 접속 | Windows 인증(Negotiate) — 로그인 화면조차 없음 |
| 서버 대 서버 호출 | 클라이언트 자격 증명 흐름 · mTLS · API Key |
| 배치·스크립트 | API Key + IP 제한 (짧은 수명 토큰이 더 좋음) |
| SSO 가 없는 소규모 | ASP.NET Core Identity (사용자 테이블 직접 관리) |
| 외부 협력사 포털 | OIDC + 별도 realm · 강제 MFA |
Windows 인증 — 사내에서 가장 편한 방법
// Kestrel 에서 AD 계정을 그대로 씁니다 (IIS·HTTP.sys 라면 서버의 Windows 인증에 맡겨집니다)
builder.Services.AddAuthentication(NegotiateDefaults.AuthenticationScheme)
.AddNegotiate();
builder.Services.AddAuthorization(o =>
o.FallbackPolicy = o.DefaultPolicy); // 전체를 기본 보호
// AD 그룹이 그대로 역할이 됩니다
[Authorize(Roles = @"ACME\재무팀")]
public sealed class BudgetController : ControllerBase { }
정책 기반 인가 — @PreAuthorize 를 넘어서 🔴 실무
역할만으로는 부족한 순간
// 요구사항: "팀장은 1천만원까지, 본부장은 1억까지 결재할 수 있다"
public sealed record ApprovalLimitRequirement(decimal Amount) : IAuthorizationRequirement;
public sealed class ApprovalLimitHandler(IUserGradeReader grades)
: AuthorizationHandler<ApprovalLimitRequirement, ApprovalDocument>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext ctx,
ApprovalLimitRequirement req,
ApprovalDocument resource) // ★ 자원을 함께 받습니다
{
var userId = ctx.User.GetUserId();
var grade = await grades.GetAsync(userId);
var limit = grade.Level switch // grade = { Level, DeptId }
{
Grade.TeamLead => 10_000_000m,
Grade.Director => 100_000_000m,
Grade.Executive => decimal.MaxValue,
_ => 0m
};
if (resource.Amount <= limit && resource.DepartmentId == grade.DeptId)
ctx.Succeed(req); // 부르지 않으면 자동으로 실패입니다
}
}
builder.Services.AddScoped<IAuthorizationHandler, ApprovalLimitHandler>();
builder.Services.AddAuthorization(o =>
{
o.AddPolicy("CanApprove", p => p.RequireAuthenticatedUser()
.AddRequirements(new ApprovalLimitRequirement(0)));
o.AddPolicy("FinanceOnly", p => p.RequireRole("Finance", "Admin"));
o.AddPolicy("MfaRequired", p => p.RequireClaim("amr", "mfa"));
});
자원 기반 검사 — 컨트롤러에서 호출
[HttpPost("{id:guid}/approve")]
[Authorize] // ① 인증만 여기서
public async Task<IActionResult> Approve(
Guid id, IAuthorizationService auth, CancellationToken ct)
{
var doc = await repo.FindAsync(id, ct);
if (doc is null) return NotFound(); // 존재 여부를 흘리지 않습니다
// ② 자원을 알아야 판단할 수 있는 권한은 여기서
var r = await auth.AuthorizeAsync(User, doc, "CanApprove");
if (!r.Succeeded) return NotFound(); // 403 대신 404 로 숨김
return await sender.Send(new ApproveDocumentCommand(id, User.GetUserId()), ct)
is { IsSuccess: true } ? NoContent() : Conflict();
}
| Spring Security | ASP.NET Core |
|---|---|
@PreAuthorize("hasRole('X')") | [Authorize(Roles = "X")] |
@PreAuthorize("hasAuthority(...)") | [Authorize(Policy = "...")] |
@PostAuthorize("returnObject.owner == ...") | 자원 기반 AuthorizeAsync(User, res, policy) |
PermissionEvaluator | AuthorizationHandler<TReq, TRes> |
SecurityContextHolder | HttpContext.User · ClaimsPrincipal |
@EnableGlobalMethodSecurity | FallbackPolicy 로 전체 보호 |
o.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser().Build(); —
[Authorize] 를 깜빡한 엔드포인트가 열려 있는 사고가
보안 점검에서 가장 자주 나옵니다.
공개해야 하는 곳에만 [AllowAnonymous] 를 붙이는 편이 훨씬 안전합니다.
다중 스킴 — 사람과 기계가 같이 들어올 때 🟡 중급
JWT + API Key 를 함께
builder.Services.AddAuthentication(o =>
{
o.DefaultScheme = "Smart"; // 요청을 보고 스킴을 고릅니다
})
.AddPolicyScheme("Smart", "JWT 또는 API Key", o =>
{
o.ForwardDefaultSelector = ctx =>
ctx.Request.Headers.ContainsKey("X-Api-Key")
? "ApiKey"
: JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(...)
.AddScheme<AuthenticationSchemeOptions, ApiKeyHandler>("ApiKey", null);
// 배치 서버만 부르는 엔드포인트
[Authorize(AuthenticationSchemes = "ApiKey", Policy = "BatchOnly")]
[HttpPost("internal/settle")]
public Task<IActionResult> Settle(...) => ...;
감사 로그 — 결재 시스템의 법적 요구
// "누가 · 언제 · 무엇을 · 어디서 · 왜" 다섯 개를 다 남깁니다
public sealed record AuditEntry(
Guid Id,
string Actor, // 사번 (표시 이름이 아니라 불변 식별자)
string Action, // Document.Approve
string TargetType,
string TargetId,
string? Reason, // 반려 사유 · 대결 사유
string Ip,
string CorrelationId,
DateTimeOffset At);
SaveChanges 안에서 저장하세요 (🔷 e06).
반대로 "실패한 시도" 는 별도 컨텍스트로 남깁니다 — 그건 롤백되면 안 됩니다.
AuthorizationHandler 로,
자원을 봐야 하는 판단은 AuthorizeAsync(User, resource, policy) 로.
기본을 막힘으로 두고, 감사 로그는 업무 트랜잭션과 함께 커밋합니다.관측 · 헬스체크 — "어제 오후 3시에 뭐가 있었냐"에 답하기
업무 시스템의 장애 대응은 대개 이렇게 시작합니다 — "김 대리가 어제 3시쯤 결재를 올렸는데 안 됐대요." 로그가 초당 수천 줄 쌓이는 곳에서 그 요청 하나를 찾아내려면 상관 관계 ID 하나면 됩니다. 그 값이 로그·트레이스·감사·에러에 다 붙어 있어야 합니다. (Serilog 기본 설정은 🔷 t02, 인프라 수준 관측은 서버기술 가이드 쪽으로 넘깁니다.)
구조화 로그 — 문자열을 조립하지 않습니다
// ❌ 자바에서 하던 습관
log.LogInformation($"문서 {docId} 를 {userId} 가 승인했습니다");
// → 검색이 불가능한 문자열 하나가 남습니다
// ✅ 구조화 로그 — 필드로 남습니다
log.LogInformation("문서 승인 {DocumentId} {ApproverId} {Amount}",
doc.Id, userId, doc.Amount);
// → { "DocumentId": "...", "ApproverId": "...", "Amount": 3200000 }
// → Seq · Kibana 에서 Amount > 10000000 같은 검색이 됩니다
// 스코프 — 이 블록 안의 모든 로그에 필드가 자동으로 붙습니다
using (log.BeginScope(new Dictionary<string, object>
{
["DocumentId"] = doc.Id,
["TenantId"] = tenant.CurrentId
}))
{
log.LogInformation("결재선 계산 시작");
await BuildLineAsync(doc, ct);
log.LogInformation("결재선 {Count} 단계", doc.Steps.Count);
} // 두 줄 모두 DocumentId · TenantId 를 달고 나갑니다
Destructure.ByTransforming 이나
마스킹 인리처를 걸어 두고, 운영에서는 EnableSensitiveDataLogging 금지입니다.
OpenTelemetry — 표준으로 한 번만
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("payroll-api", serviceVersion: ver))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health"))
.AddHttpClientInstrumentation()
.AddNpgsql()
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddMeter("Payroll.Approval")
.AddOtlpExporter());
// 업무 메트릭 — 기술 지표만으로는 장애를 못 봅니다
public sealed class ApprovalMetrics
{
private readonly Counter<long> _approved;
private readonly Histogram<double> _leadTime;
public ApprovalMetrics(IMeterFactory f)
{
var m = f.Create("Payroll.Approval");
_approved = m.CreateCounter<long>("approval.completed");
_leadTime = m.CreateHistogram<double>("approval.lead_time_hours");
}
public void Completed(string dept, double hours)
{
_approved.Add(1, new KeyValuePair<string, object?>("dept", dept));
_leadTime.Record(hours);
}
}
헬스체크 — Actuator 자리 🟡 중급
세 종류를 구분합니다
builder.Services.AddHealthChecks()
.AddNpgSql(cs, name: "db", tags: ["ready"])
.AddRedis(redisCs, name: "cache",tags: ["ready"])
.AddUrlGroup(new Uri(erpUrl), name: "erp", tags: ["dependency"])
.AddCheck<OutboxBacklogCheck>("outbox", tags: ["ready"]);
// 살아 있나 — 프로세스가 응답하면 OK (재시작 판단용)
app.MapHealthChecks("/health/live", new() { Predicate = _ => false });
// 트래픽 받을 준비가 됐나 — DB·캐시 확인 (로드밸런서 판단용)
app.MapHealthChecks("/health/ready", new()
{
Predicate = c => c.Tags.Contains("ready"),
ResponseWriter = UIResponseWriter.WriteHealthCheckUIResponse
});
// 남의 시스템까지 — 사람이 보는 상태판 (LB 판단에 쓰면 안 됩니다)
app.MapHealthChecks("/health/all").AllowAnonymous();
ready 에 넣지 마세요.
ERP 가 점검으로 30분 내려가면 우리 서버 전부가 로드밸런서에서 빠집니다 —
결재 조회는 멀쩡히 되는데도 말이죠.
실제로 아주 흔한 자해 장애입니다.
업무 관점 헬스체크
// "아웃박스가 밀리고 있다" 는 DB 가 살아 있어도 장애입니다
public sealed class OutboxBacklogCheck(
IDbContextFactory<PayrollDbContext> f) : IHealthCheck
{
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext ctx, CancellationToken ct = default)
{
await using var db = await f.CreateDbContextAsync(ct);
var oldest = await db.OutboxMessages
.Where(m => m.PublishedAt == null)
.MinAsync(m => (DateTimeOffset?)m.OccurredAt, ct);
if (oldest is null) return HealthCheckResult.Healthy("대기 없음");
var lag = DateTimeOffset.UtcNow - oldest.Value;
var data = new Dictionary<string, object> { ["lagSeconds"] = lag.TotalSeconds };
return lag > TimeSpan.FromMinutes(10)
? HealthCheckResult.Unhealthy($"아웃박스 {lag.TotalMinutes:F0}분 지연", data: data)
: lag > TimeSpan.FromMinutes(2)
? HealthCheckResult.Degraded($"아웃박스 지연 중", data: data)
: HealthCheckResult.Healthy("정상", data);
}
}
| Actuator | ASP.NET Core |
|---|---|
/actuator/health | MapHealthChecks("/health") |
/actuator/health/liveness | 태그로 분리 (Predicate) |
/actuator/metrics | OpenTelemetry · Prometheus 익스포터 |
/actuator/info | 직접 엔드포인트 하나 (버전·빌드 번호) |
/actuator/loggers | Serilog LoggingLevelSwitch + 관리 API |
/actuator/env | 만들지 마세요 — 시크릿이 그대로 노출됩니다 |
/health/live 와 /health/ready 는
본문 없이 200/503 만 돌려주면 충분합니다.
구성 요소 이름·버전·커넥션 문자열이 담긴 상세 JSON 은
사내망에서만 보이도록 IP 제한이나 인증을 겁니다 —
"어떤 DB 를 쓰는지" 는 공격자에게 훌륭한 정찰 정보입니다.
live / ready / 상태판 셋으로 나눕니다.
업무 메트릭이 CPU 그래프보다 먼저 장애를 알려 줍니다.테스트 — @SpringBootTest 를 대신하는 것과 격리
xUnit 기본기와 Testcontainers 설치는 🔷 t04·t05 에 있습니다. 여기서는 업무 시스템 테스트의 진짜 어려움을 다룹니다 — 테스트끼리 데이터가 섞이는 문제, 인증을 어떻게 우회하나, 결재선 같은 복잡한 준비 데이터를 어떻게 만드나. 이 셋을 해결하면 통합 테스트가 실제로 유지됩니다.
@SpringBootTest 자리 — 팩토리
public sealed class PayrollFactory : WebApplicationFactory<Program>,
IAsyncLifetime
{
private readonly PostgreSqlContainer _db = new PostgreSqlBuilder()
.WithImage("postgres:17-alpine")
.WithDatabase("payroll_test")
.Build();
public async Task InitializeAsync()
{
await _db.StartAsync();
using var scope = Services.CreateScope();
var ctx = scope.ServiceProvider.GetRequiredService<PayrollDbContext>();
await ctx.Database.MigrateAsync(); // 실제 마이그레이션을 돌립니다
}
protected override void ConfigureWebHost(IWebHostBuilder b)
{
b.UseEnvironment("Testing");
b.ConfigureTestServices(s =>
{
// 진짜 DB 는 컨테이너로 바꿉니다
s.RemoveAll<DbContextOptions<PayrollDbContext>>();
s.AddDbContext<PayrollDbContext>(o => o.UseNpgsql(_db.GetConnectionString()));
// 남의 시스템은 가짜로 (= @MockBean)
s.RemoveAll<IErpClient>();
s.AddSingleton<IErpClient>(new FakeErpClient());
// 인증 우회 핸들러
s.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>("Test", null);
s.PostConfigure<AuthenticationOptions>(o => o.DefaultScheme = "Test");
});
}
public new async Task DisposeAsync() => await _db.DisposeAsync();
}
EnsureCreated() 로 스키마를 만들면
마이그레이션 자체의 버그를 못 잡습니다 —
운영에 배포하고 나서야 알게 됩니다.
컨테이너 기동이 몇 초 늘어도 MigrateAsync() 를 쓰세요.
인증 우회 핸들러
public sealed class TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> o,
ILoggerFactory lf, UrlEncoder enc)
: AuthenticationHandler<AuthenticationSchemeOptions>(o, lf, enc)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
// 테스트가 헤더로 "누구로 로그인할지" 를 지정합니다
var user = Context.Request.Headers["X-Test-User"].FirstOrDefault()
?? "E20240001";
var roles = Context.Request.Headers["X-Test-Roles"].FirstOrDefault()
?? "Employee";
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, user) };
claims.AddRange(roles.Split(',').Select(r => new Claim("roles", r)));
var id = new ClaimsIdentity(claims, "Test", ClaimTypes.Name, "roles");
var ticket = new AuthenticationTicket(
new ClaimsPrincipal(id), "Test");
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
// 테스트에서
client.DefaultRequestHeaders.Add("X-Test-User", "E20200311");
client.DefaultRequestHeaders.Add("X-Test-Roles", "TeamLead");
Testing 환경에서만 등록되도록
ConfigureTestServices 안에서만 부르세요.
운영 어셈블리에 TestAuthHandler 클래스 자체가
존재하지 않도록 하는 것이 가장 안전합니다.
테스트 격리 — 데이터가 섞이는 문제 🔴 실무
세 가지 전략과 선택
| 방법 | 속도 | 문제 |
|---|---|---|
| 테스트마다 컨테이너 | 매우 느림 | 150개 테스트에 20분 |
| 트랜잭션 롤백 | 빠름 | HTTP 테스트에는 못 씁니다 (커넥션이 다름) |
| Respawn 으로 초기화 | 빠름 | 병렬 실행 불가 (같은 DB 를 지움) |
| 테스트마다 스키마 | 보통 | 설정이 조금 복잡 · 병렬 가능 |
// 가장 실용적인 조합 — 컬렉션 하나 + Respawn 초기화
[CollectionDefinition("db")]
public sealed class DbCollection : ICollectionFixture<PayrollFactory> { }
[Collection("db")]
public abstract class IntegrationTest(PayrollFactory f) : IAsyncLifetime
{
protected HttpClient Client { get; private set; } = default!;
public async Task InitializeAsync()
{
await f.ResetDatabaseAsync(); // Respawn — 시드 테이블은 제외
Client = f.CreateClient();
}
public Task DisposeAsync() => Task.CompletedTask;
}
Payroll.Domain 만 참조하는 테스트로 밀리초 단위에 끝납니다.
계층 분리(🔷 e04)의 가장 큰 실익이 여기서 나옵니다.
데이터 빌더 — 준비 코드를 줄입니다
// 결재 문서 하나를 만들려면 부서·사용자·결재선이 다 있어야 합니다.
// 그걸 테스트마다 쓰면 20줄이 반복됩니다.
public sealed class DocumentBuilder
{
private decimal _amount = 1_000_000m;
private DocumentStatus _status = DocumentStatus.InProgress;
private readonly List<Guid> _approvers = [];
public DocumentBuilder Amount(decimal a) { _amount = a; return this; }
public DocumentBuilder Status(DocumentStatus s){ _status = s; return this; }
public DocumentBuilder ApprovedBy(Guid id) { _approvers.Add(id); return this; }
public ApprovalDocument Build()
{
// e13 의 도메인 모델 그대로: Draft(제목, 금액, 기안자, 부서, 시각) → Submit(결재선, 시각)
var doc = ApprovalDocument.Draft("테스트 문서", _amount,
TestIds.Drafter, TestIds.Dept, TestClock.Now);
if (_status is DocumentStatus.InProgress) doc.Submit(_approvers, TestClock.Now);
return doc;
}
}
// 테스트가 읽힙니다
[Fact]
public async Task 결재_한도를_넘으면_거부된다()
{
var doc = new DocumentBuilder().Amount(50_000_000m)
.ApprovedBy(TestIds.TeamLead).Build();
await Seed(doc);
Client.AsUser(TestIds.TeamLead, "TeamLead");
var res = await Client.PostAsync($"/api/documents/{doc.Id}/approve", null);
res.StatusCode.Should().Be(HttpStatusCode.NotFound); // 한도 초과 → 숨김
}
결재_한도를_넘으면_거부된다 는
업무 담당자가 읽고 검토할 수 있는 사양서가 됩니다.
업무 규칙이 복잡한 시스템일수록 효과가 큽니다.
WebApplicationFactory 가 @SpringBootTest 자리이고,
ConfigureTestServices 가 @MockBean 자리입니다.
실제 마이그레이션을 돌리고, 인증은 테스트 스킴으로 우회하며,
격리는 Respawn 초기화가 가장 실용적입니다.
가장 중요한 것은 도메인을 떼어 놓아 DB 없는 테스트를 두껍게 쌓는 것입니다.실전 — 사내 결재 시스템 API를 끝까지
앞의 12개 섹션을 하나로 꿰는 실전입니다. 기안 → 결재선 생성 → 승인 → 감사 로그 → 회계 연계 까지 도는 API 를 프로젝트 구조부터 Docker 이미지까지 만듭니다. 결정적인 대목마다 같은 기능의 Spring Boot 코드를 나란히 두었습니다 — "이 자리에서 자바는 뭐라고 썼더라" 를 떠올리며 읽으면 훨씬 빨리 붙습니다.
① 프로젝트 만들기 — 5분 🟢 기초
뼈대
dotnet new sln -n Payroll
dotnet new classlib -o src/Payroll.Domain
dotnet new classlib -o src/Payroll.Application
dotnet new classlib -o src/Payroll.Infrastructure
dotnet new webapi -o src/Payroll.Api
dotnet new xunit -o tests/Payroll.IntegrationTests
dotnet new xunit -o tests/Payroll.DomainTests
# 참조는 안쪽으로만 (🔷 e04)
dotnet add src/Payroll.Application reference src/Payroll.Domain
dotnet add src/Payroll.Infrastructure reference src/Payroll.Domain
dotnet add src/Payroll.Api reference src/Payroll.Application
dotnet add src/Payroll.Api reference src/Payroll.Infrastructure
# 패키지
dotnet add src/Payroll.Infrastructure package Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add src/Payroll.Infrastructure package Dapper
dotnet add src/Payroll.Api package Serilog.AspNetCore
dotnet add src/Payroll.Api package Microsoft.AspNetCore.Authentication.JwtBearer
dotnet add src/Payroll.Api package FluentValidation.DependencyInjectionExtensions
dotnet add tests/Payroll.IntegrationTests package Testcontainers.PostgreSql
Directory.Packages.props 한 곳에서.
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> 를 켜면
각 .csproj 에서는 버전을 안 씁니다 —
Maven 의 <dependencyManagement> 자리입니다.
Spring Boot 라면
# start.spring.io 에서 고르는 것들
Spring Web · Spring Data JPA · PostgreSQL Driver
Spring Security · OAuth2 Resource Server
Validation · Actuator · Testcontainers · Flyway
| Spring 스타터 | .NET 패키지 |
|---|---|
| spring-boot-starter-web | 내장 (webapi 템플릿) |
| spring-boot-starter-data-jpa | Npgsql.EntityFrameworkCore.PostgreSQL |
| spring-boot-starter-security | 내장 + Authentication.JwtBearer |
| spring-boot-starter-validation | 내장 + FluentValidation |
| spring-boot-starter-actuator | 내장 AddHealthChecks |
| Flyway | EF Core Migrations (내장) |
| Lombok | 필요 없습니다 — record · 자동 속성 |
webapi 템플릿 하나로 대부분 갖춰집니다.
Lombok 처럼 언어를 보강하는 라이브러리는 아예 필요가 없습니다.
② 도메인 모델 — 규칙을 클래스 안에 🟡 중급
문서와 결재선
namespace Payroll.Domain.Approvals;
public enum DocumentStatus { Draft, InProgress, Completed, Rejected, Canceled }
public enum StepState { Waiting, Approved, Rejected, Skipped }
public sealed class ApprovalDocument : IHasDomainEvents
{
private readonly List<ApprovalStep> _steps = [];
private readonly List<IDomainEvent> _events = [];
public Guid Id { get; private set; } = Guid.CreateVersion7();
public string Title { get; private set; } = "";
public decimal Amount { get; private set; }
public Guid DrafterId { get; private set; }
public Guid DepartmentId { get; private set; }
public DocumentStatus Status { get; private set; } = DocumentStatus.Draft;
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? CompletedAt { get; private set; }
public uint Version { get; private set; } // 낙관적 동시성 (🔷 e05)
public IReadOnlyList<ApprovalStep> Steps => _steps;
public IReadOnlyList<IDomainEvent> Events => _events;
private ApprovalDocument() { } // EF Core 용
public static ApprovalDocument Draft(
string title, decimal amount, Guid drafterId, Guid deptId, DateTimeOffset now)
{
if (string.IsNullOrWhiteSpace(title)) throw new ArgumentException("제목 필수");
if (amount <= 0) throw new ArgumentException("금액은 0보다 커야 합니다");
return new ApprovalDocument
{
Title = title.Trim(), Amount = amount,
DrafterId = drafterId, DepartmentId = deptId, CreatedAt = now
};
}
// 결재선을 붙이고 상신 — 여기까지가 한 덩어리입니다
public Result Submit(IReadOnlyList<Guid> approverIds, DateTimeOffset now)
{
if (Status is not DocumentStatus.Draft) return Result.Fail("이미 상신된 문서입니다");
if (approverIds.Count is 0) return Result.Fail("결재선이 비어 있습니다");
if (approverIds.Count > 5) return Result.Fail("결재선은 5단계까지입니다");
for (var i = 0; i < approverIds.Count; i++)
_steps.Add(ApprovalStep.Create(Id, i + 1, approverIds[i]));
Status = DocumentStatus.InProgress;
_events.Add(new DocumentSubmitted(Id, DrafterId, approverIds[0], now));
return Result.Ok();
}
public Result Approve(Guid approverId, DateTimeOffset now)
{
if (Status is not DocumentStatus.InProgress)
return Result.Fail("진행 중인 문서만 결재할 수 있습니다");
var current = _steps.Where(s => s.State == StepState.Waiting)
.MinBy(s => s.Order);
if (current is null) return Result.Fail("대기 중인 단계가 없습니다");
if (current.ApproverId != approverId) return Result.Fail("결재 차례가 아닙니다");
current.Approve(now);
var next = _steps.Where(s => s.State == StepState.Waiting).MinBy(s => s.Order);
if (next is null)
{
Status = DocumentStatus.Completed;
CompletedAt = now;
_events.Add(new DocumentCompleted(Id, DepartmentId, Amount, now));
}
else _events.Add(new StepArrived(Id, next.ApproverId, now));
return Result.Ok();
}
public Result Reject(Guid approverId, string reason, DateTimeOffset now)
{
if (Status is not DocumentStatus.InProgress)
return Result.Fail("진행 중인 문서만 반려할 수 있습니다"); // 이미 반려된 문서를 다음 결재자가 또 반려하지 못하게
if (string.IsNullOrWhiteSpace(reason)) return Result.Fail("반려 사유는 필수입니다");
var current = _steps.Where(s => s.State == StepState.Waiting).MinBy(s => s.Order);
if (current?.ApproverId != approverId) return Result.Fail("결재 차례가 아닙니다");
current.Reject(reason, now);
Status = DocumentStatus.Rejected;
_events.Add(new DocumentRejected(Id, DrafterId, reason, now));
return Result.Ok();
}
public IReadOnlyList<IDomainEvent> PopEvents()
{
var copy = _events.ToArray();
_events.Clear();
return copy;
}
}
Submit · Approve · Reject 세 개뿐입니다.
"결재 차례가 아닌데 승인됨" 같은 버그가 구조적으로 불가능해집니다.
그리고 이 클래스는 DB 도 HTTP 도 모릅니다 — 테스트에 3ms 걸립니다.
Spring 이라면 이렇게 씁니다
@Entity
@Table(name = "approval_documents")
@Access(AccessType.FIELD)
public class ApprovalDocument {
@Id private UUID id = UuidCreator.getTimeOrderedEpoch();
@Column(nullable = false) private String title;
@Column(nullable = false) private BigDecimal amount;
@Enumerated(EnumType.STRING)
private DocumentStatus status = DocumentStatus.DRAFT;
@OneToMany(mappedBy = "document",
cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("stepOrder")
private final List<ApprovalStep> steps = new ArrayList<>();
@Version private Long version;
protected ApprovalDocument() { } // JPA 용 기본 생성자
public Result approve(UUID approverId, OffsetDateTime now) {
if (status != DocumentStatus.IN_PROGRESS)
return Result.fail("진행 중인 문서만 결재할 수 있습니다");
// ... 같은 로직
}
}
| JPA | EF Core |
|---|---|
@Entity @Table | ToTable("...") (설정 클래스에서) |
@Id | 규약: Id 속성 · HasKey |
@Column(nullable=false) | IsRequired() · non-nullable 타입 |
@Enumerated(STRING) | HasConversion<string>() |
@OneToMany(mappedBy) | HasMany().WithOne() |
orphanRemoval = true | OnDelete(DeleteBehavior.Cascade) |
@Version | IsRowVersion() (PostgreSQL 은 uint → xmin) |
@Embedded | ComplexProperty · OwnsOne |
Payroll.Domain 프로젝트가 EF Core 를 참조하지 않아도 되는 이유입니다.
③ EF Core 매핑 — 설정은 별도 클래스에 🟡 중급
매핑 설정
// Payroll.Infrastructure/Persistence/ApprovalDocumentConfig.cs
public sealed class ApprovalDocumentConfig
: IEntityTypeConfiguration<ApprovalDocument>
{
public void Configure(EntityTypeBuilder<ApprovalDocument> b)
{
b.ToTable("approval_documents");
b.HasKey(d => d.Id);
b.Property(d => d.Title).HasMaxLength(200).IsRequired();
b.Property(d => d.Amount).HasPrecision(18, 2);
b.Property(d => d.Status).HasConversion<string>().HasMaxLength(20);
b.Property(d => d.Version).IsRowVersion(); // uint → PostgreSQL xmin (Npgsql 7+)
// 백킹 필드로 매핑 — private List 에 직접 채웁니다
b.HasMany(d => d.Steps)
.WithOne()
.HasForeignKey(s => s.DocumentId)
.OnDelete(DeleteBehavior.Cascade);
b.Navigation(d => d.Steps)
.HasField("_steps")
.UsePropertyAccessMode(PropertyAccessMode.Field);
// 도메인 이벤트는 저장하지 않습니다
b.Ignore(d => d.Events);
// 목록 화면이 쓰는 인덱스
b.HasIndex(d => new { d.DepartmentId, d.Status, d.CreatedAt });
}
}
// DbContext — 설정 클래스를 어셈블리에서 모아 옵니다
public class PayrollDbContext(DbContextOptions<PayrollDbContext> o) : DbContext(o)
{
public DbSet<ApprovalDocument> ApprovalDocuments => Set<ApprovalDocument>();
public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
protected override void OnModelCreating(ModelBuilder b)
=> b.ApplyConfigurationsFromAssembly(typeof(PayrollDbContext).Assembly);
}
감사 로그 · 아웃박스 자동 채우기
public sealed class AuditSaveChangesInterceptor(
ICurrentUser user, IClock clock) : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData e, InterceptionResult<int> r, CancellationToken ct = default)
{
var ctx = e.Context!;
var now = clock.Now;
// ① 도메인 이벤트 → 아웃박스 행 (🔷 e06)
// ★ ToList() — 순회 중에 ctx.Add 를 하면 "Collection was modified" 예외가 납니다
foreach (var ev in ctx.ChangeTracker.Entries<IHasDomainEvents>()
.SelectMany(x => x.Entity.PopEvents()).ToList())
ctx.Add(OutboxMessage.From(ev, now));
// ② 변경된 엔티티 → 감사 로그
foreach (var entry in ctx.ChangeTracker.Entries()
.Where(x => x.State is EntityState.Added
or EntityState.Modified
or EntityState.Deleted).ToList())
{
if (entry.Entity is OutboxMessage or AuditLog) continue; // 무한 루프 방지
ctx.Add(new AuditLog(
Guid.CreateVersion7(), user.EmployeeNo,
$"{entry.Entity.GetType().Name}.{entry.State}",
entry.Entity.GetType().Name,
entry.Properties.First(p => p.Metadata.IsPrimaryKey())
.CurrentValue?.ToString() ?? "",
user.Ip, user.CorrelationId, now));
}
return base.SavingChangesAsync(e, r, ct);
}
}
builder.Services.AddDbContext<PayrollDbContext>((sp, o) => o
.UseNpgsql(cs, n => n.EnableRetryOnFailure(5, TimeSpan.FromSeconds(10), null))
.AddInterceptors(sp.GetRequiredService<AuditSaveChangesInterceptor>()));
@EntityListeners 자리입니다.
업무 코드 어디에도 "감사 로그를 남긴다" 는 줄이 없는데 자동으로 남습니다.
같은 SaveChanges 안이라 트랜잭션이 하나입니다 —
승인은 됐는데 감사 로그가 없는 상황이 생길 수 없습니다.
④ 유스케이스와 권한 — 한 트랜잭션에 묶기 🔴 실무
핸들러 — SaveChanges 는 딱 한 번
public sealed record ApproveCommand(Guid DocumentId, Guid ApproverId)
: IRequest<Result>;
public sealed class ApproveHandler(
PayrollDbContext db,
IAuthorizationService auth,
ICurrentUser user,
IClock clock,
ApprovalMetrics metrics) : IRequestHandler<ApproveCommand, Result>
{
public async Task<Result> Handle(ApproveCommand c, CancellationToken ct)
{
var doc = await db.ApprovalDocuments
.Include(d => d.Steps) // N+1 방지 (🔷 e05)
.FirstOrDefaultAsync(d => d.Id == c.DocumentId, ct);
if (doc is null) return Result.Fail("문서 없음");
// 자원 기반 권한 — 금액 한도 · 부서 검사 (🔷 e10)
var authz = await auth.AuthorizeAsync(user.Principal, doc, "CanApprove");
if (!authz.Succeeded) return Result.Fail("결재 권한이 없습니다");
var r = doc.Approve(c.ApproverId, clock.Now);
if (r.IsFailure) return r;
// 문서 변경 + 결재선 변경 + 감사 로그 + 아웃박스 = 한 트랜잭션
await db.SaveChangesAsync(ct);
if (doc.Status is DocumentStatus.Completed)
metrics.Completed(doc.DepartmentId.ToString(),
(clock.Now - doc.CreatedAt).TotalHours);
return Result.Ok();
}
}
엔드포인트 · Spring 대조
[ApiController]
[Route("api/documents")]
[Authorize] // 기본은 인증 필요
public sealed class DocumentsController(ISender sender) : ControllerBase
{
[HttpPost("{id:guid}/approve")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
public async Task<IActionResult> Approve(Guid id, CancellationToken ct)
{
var r = await sender.Send(new ApproveCommand(id, User.GetUserId()), ct);
return r.IsSuccess
? NoContent()
: Problem(title: "결재 처리 실패", detail: r.Error, statusCode: 409);
}
[HttpGet]
public async Task<IReadOnlyList<DocRow>> List(
[FromQuery] DocumentQuery q, CancellationToken ct)
=> await sender.Send(new ListDocumentsQuery(q, User.GetUserId()), ct);
}
@RestController
@RequestMapping("/api/documents")
@PreAuthorize("isAuthenticated()")
public class DocumentsController {
@PostMapping("/{id}/approve")
@ResponseStatus(HttpStatus.NO_CONTENT)
@Transactional // ← .NET 엔 이 줄이 없습니다
public void approve(@PathVariable UUID id, Principal principal) {
approvalService.approve(id, UUID.fromString(principal.getName()));
}
}
⑤ 통합 테스트 — 진짜 DB 로 🔴 실무
시나리오 하나를 끝까지
[Collection("db")]
public sealed class ApprovalFlowTests(PayrollFactory f) : IntegrationTest(f)
{
[Fact]
public async Task 결재선_두_단계를_모두_통과하면_완결된다()
{
// given — 팀장 → 본부장 2단계 결재선
var doc = await Seed(new DocumentBuilder()
.Amount(5_000_000m)
.Line(TestIds.TeamLead, TestIds.Director)
.Submitted());
// when — 팀장 승인
Client.AsUser(TestIds.TeamLead, "TeamLead");
(await Client.PostAsync($"/api/documents/{doc.Id}/approve", null))
.StatusCode.Should().Be(HttpStatusCode.NoContent);
// then — 아직 진행 중
(await Get(doc.Id)).Status.Should().Be("InProgress");
// when — 본부장 승인
Client.AsUser(TestIds.Director, "Director");
(await Client.PostAsync($"/api/documents/{doc.Id}/approve", null))
.StatusCode.Should().Be(HttpStatusCode.NoContent);
// then — 완결 · 아웃박스에 이벤트 · 감사 로그 2건
var after = await Get(doc.Id);
after.Status.Should().Be("Completed");
await using var db = f.NewDbContext();
db.OutboxMessages.Count(m => m.Type.EndsWith("DocumentCompleted"))
.Should().Be(1);
db.AuditLogs.Count(a => a.TargetId == doc.Id.ToString())
.Should().BeGreaterThanOrEqualTo(2);
}
[Fact]
public async Task 차례가_아닌_사람은_결재할_수_없다()
{
var doc = await Seed(new DocumentBuilder()
.Line(TestIds.TeamLead, TestIds.Director).Submitted());
Client.AsUser(TestIds.Director, "Director"); // 팀장을 건너뜀
var res = await Client.PostAsync($"/api/documents/{doc.Id}/approve", null);
res.StatusCode.Should().Be(HttpStatusCode.Conflict);
var pd = await res.Content.ReadFromJsonAsync<ProblemDetails>();
pd!.Detail.Should().Contain("차례가 아닙니다");
}
}
동시성까지 검증합니다
[Fact]
public async Task 두_명이_동시에_승인해도_한_번만_반영된다()
{
var doc = await Seed(new DocumentBuilder()
.Line(TestIds.TeamLead).Submitted());
var c1 = f.CreateClient().AsUser(TestIds.TeamLead, "TeamLead");
var c2 = f.CreateClient().AsUser(TestIds.TeamLead, "TeamLead");
var results = await Task.WhenAll(
c1.PostAsync($"/api/documents/{doc.Id}/approve", null),
c2.PostAsync($"/api/documents/{doc.Id}/approve", null));
results.Count(r => r.StatusCode == HttpStatusCode.NoContent).Should().Be(1);
results.Count(r => r.StatusCode == HttpStatusCode.Conflict).Should().Be(1);
await using var db = f.NewDbContext();
db.AuditLogs.Count(a => a.Action.StartsWith("ApprovalStep")).Should().Be(1);
}
RowVersion 의 값어치를 증명합니다.
낙관적 동시성 토큰이 없으면 이 테스트는 둘 다 204 로 실패합니다.
운영에서 연 1~2회 나는 데이터 사고를 CI 가 매번 막아 줍니다.
DateTimeOffset.UtcNow 를 직접 부르지 마세요.
IClock 을 주입해 두면 "마감 시각 이후 결재" 같은 규칙을
시간을 조작해 검증할 수 있습니다.
Java 의 Clock.fixed() 와 같은 발상이고, 효과도 같습니다.
⑥ Docker 이미지 — 배포 준비 🔴 실무
멀티스테이지 Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# ① 프로젝트 파일만 먼저 복사 — 캐시가 살아납니다
COPY Directory.Build.props Directory.Packages.props ./
COPY src/Payroll.Domain/*.csproj src/Payroll.Domain/
COPY src/Payroll.Application/*.csproj src/Payroll.Application/
COPY src/Payroll.Infrastructure/*.csproj src/Payroll.Infrastructure/
COPY src/Payroll.Api/*.csproj src/Payroll.Api/
RUN dotnet restore src/Payroll.Api/Payroll.Api.csproj
# ② 소스 전체
COPY . .
RUN dotnet publish src/Payroll.Api/Payroll.Api.csproj \
-c Release -o /app --no-restore
# ③ 런타임 — chiseled 이미지는 셸조차 없어 공격면이 작습니다
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
COPY --from=build /app .
USER $APP_UID
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_gcServer=1
ENTRYPOINT ["dotnet", "Payroll.Api.dll"]
eclipse-temurin:25-jre-alpine + fat jar 가 비슷한 수준입니다.
NativeAOT 로 뽑으면 20 MB 대까지 내려가지만
EF Core 와 리플렉션 사용 라이브러리 때문에 업무 API 에서는 잘 안 씁니다 (🔷 h09).
사내 서버에 올릴 때 확인할 것
| 항목 | 확인 |
|---|---|
| 시간대 | 컨테이너는 UTC — 저장은 UTC, 표시만 KST |
| 한글 인코딩 | 로그·파일 출력에 UTF-8 확인 (🔷 f06) |
| 메모리 한도 | 한도를 주면 GC 가 맞춰 동작합니다 (🔷 h11) |
| 마이그레이션 | 앱이 아니라 배포 잡이 실행 (🔷 e05) |
| 프록시 뒤 | UseForwardedHeaders 필수 (🔷 h06) |
| 헬스체크 | ready 를 LB 에 연결 (🔷 e11) |
| 시크릿 | 이미지에 넣지 말고 환경 변수 (🔷 e09) |
DateTimeOffset(UTC),
집계 기준일은 명시적으로 KST 로 변환 —
TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul").
리눅스와 윈도우에서 같은 ID 로 동작합니다(.NET 6+).
SaveChanges 를 한 번만 부릅니다.
통합 테스트는 진짜 DB + 동시성까지 검증하고, 배포는 멀티스테이지 이미지 + 외부 마이그레이션 잡입니다.야간 배치
3 GB 파일
그리고 연계
Spring Batch 도 Quartz 도 없는 세계에서 같은 일을 어떻게 하는지 끝까지 봅니다. Worker Service · Hangfire · Quartz.NET 중 무엇을 고르고, 수백만 행을 메모리 40 MB 안에서 흘려보내고, EUC-KR 로 온 고정폭 파일을 읽고, SFTP 로 받은 3 GB 를 체크포인트를 남기며 적재합니다. 여기까지 마치면 사내 정산 배치 한 벌을 혼자 설계하고 운영 이관까지 할 수 있습니다. Java 로 하던 방식과 계속 나란히 놓고 봅니다 — 대응이 없는 자리는 "없다"고 분명히 말합니다.
배치 서버 선택지 지도 — Spring Batch 자리에 무엇을 놓나
자바에서 배치라고 하면 Spring Batch + Quartz 가 거의 정답이었습니다. .NET 에는 그 자리를 통째로 채우는 프레임워크가 없습니다. 대신 조각들이 있고, 조각을 고르는 기준이 있습니다. 이 섹션이 그 기준입니다.
다섯 선택지를 한 표로 🟢 기초
| 선택지 | Java 대응 | 스케줄 | 재시도 · 이력 | 운영 화면 | 이럴 때 |
|---|---|---|---|---|---|
| Worker Service (BackgroundService) | @Scheduled 상주 빈 · 무한 루프 스레드 | 직접 Task.Delay · PeriodicTimer | 직접 구현 | 없음 | 큐 · 폴더를 계속 지켜보는 일. 상시 대기형 |
| Hangfire | Spring Batch Admin + Quartz JDBC 잡스토어 | 크론 · 지연 · 연속(continuation) | 내장 — 자동 재시도 · 실행 이력 DB | 내장 대시보드 | 사내 배치의 기본값. DB 하나만 더 주면 됨 |
| Quartz.NET | Quartz (그대로) | 크론 · 미스파이어 정책 · 캘린더 | 잡스토어(ADO.NET) 필요 | 없음(오픈소스 CrystalQuartz 를 붙임) | 영업일 · 공휴일 · 밀린 실행 처리 규칙이 중요할 때 |
| 작업 스케줄러 (Windows) / cron | OS crontab | OS 가 담당 | 없음 — 종료 코드만 | OS 도구 | 하루 한 번 · 실패해도 다음날 다시 돌면 되는 일 |
| 클라우드 함수 | AWS Lambda + EventBridge | 서비스가 담당 | 서비스가 담당 | 콘솔 | 서버를 켜 두기 아까운 드문 실행. 실행 시간 제한 주의 |
Hangfire — 3분이면 붙습니다
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer
// Program.cs
builder.Services.AddHangfire(cfg => cfg
.SetDataCompatibilityLevel(CompatibilityLevel.Version_180)
.UseSimpleAssemblyNameTypeSerializer()
.UseRecommendedSerializerSettings()
.UseSqlServerStorage(cs, new SqlServerStorageOptions
{
// 큐 폴링 간격 — 기본 15초는 배치엔 느립니다
QueuePollInterval = TimeSpan.FromSeconds(5),
UseRecommendedIsolationLevel = true,
DisableGlobalLocks = true
}));
// 이 프로세스가 잡을 "실행"까지 할 것인가
builder.Services.AddHangfireServer(o =>
{
o.Queues = ["settle", "default"]; // 큐 우선순위 (앞이 높음)
o.WorkerCount = 4; // 동시 실행 수
o.ServerName = $"{Environment.MachineName}-batch";
});
var app = builder.Build();
app.UseHangfireDashboard("/jobs"); // 운영 화면
RecurringJob.AddOrUpdate<SettleJob>(
"daily-settle",
j => j.RunAsync(CancellationToken.None),
"0 10 2 * * *", // 매일 02:10 (초 6자리 크론)
new RecurringJobOptions { TimeZone = KstTz });
"0 10 2 * * *" 는 초를 포함한 6자리(02:10:00),
"10 2 * * *" 는 5자리(분부터)입니다.
자바의 Spring 크론도 6자리라 헷갈리지 않지만,
표준 유닉스 crontab 은 5자리입니다 — 문서를 보고 자리 수부터 세세요.
Quartz.NET — 미스파이어가 필요할 때
builder.Services.AddQuartz(q =>
{
q.UsePersistentStore(s => // 잡스토어를 DB 로
{
s.UseSqlServer(cs);
s.UseNewtonsoftJsonSerializer();
s.UseProperties = true;
s.UseClustering(); // 여러 대여도 한 번만 실행
});
var key = new JobKey("settle");
q.AddJob<SettleJob>(o => o.WithIdentity(key));
q.AddTrigger(t => t
.ForJob(key)
.WithIdentity("settle-trigger")
.WithCronSchedule("0 10 2 * * ?",
x => x.InTimeZone(KstTz)
// 서버가 꺼져 있어 놓친 실행을 어떻게 할지
.WithMisfireHandlingInstructionFireAndProceed()));
});
builder.Services.AddQuartzHostedService(o =>
o.WaitForJobsToComplete = true); // 종료 시 진행 중 잡을 기다림
IgnoreMisfires) ·
한 번만 실행하고 정상 궤도로(FireAndProceed) ·
그냥 건너뛰기(DoNothing) 세 가지.
정산 배치는 대개 FireAndProceed 입니다 — 3일치가 한꺼번에 돌면 안 되니까요.
Hangfire 는 기본값이 "서버가 뜨면 즉시 한 번"이고,
1.8 부터는 RecurringJobOptions.MisfireHandling(Relaxed · Strict · Ignorable)으로 고를 수 있습니다.
Spring Batch 를 .NET 으로 옮기면 무엇이 어디로 가나 🟡 중급
| Spring Batch 구성 요소 | 하는 일 | .NET 에서는 |
|---|---|---|
Job / JobLauncher | 실행 단위와 기동 | Hangfire RecurringJob 또는 BackgroundService 메서드 하나 |
Step · Flow | 단계 분할 · 조건 분기 | 직접 — 메서드로 나누고 if 로 분기 (f04) |
ItemReader | 한 건씩 읽기 | IAsyncEnumerable<T> + yield return (f06) |
ItemProcessor | 변환 · 필터 | LINQ Select / Where 또는 Channel 스테이지 (f11) |
ItemWriter | 묶어서 쓰기 | SqlBulkCopy · ExecuteUpdate (f05) |
chunk(1000) | 커밋 단위 | Chunk(1000) 확장 메서드 + 트랜잭션 (f04) |
JobRepository | 실행 이력 · 재시작 지점 | 직접 — BatchRun/BatchCheckpoint 테이블 (f04) |
skip-limit · retry-limit | 실패 허용치 | 직접 세거나 Polly 재시도 (f09) |
JobParameters | 실행 파라미터 · 중복 방지 키 | 잡 인자 + BatchRun 유니크 인덱스 |
| Spring Batch Admin | 운영 화면 | Hangfire 대시보드 — 여기만 .NET 이 더 편합니다 |
이렇게 시작하면 후회합니다
"Spring Batch 같은 걸 먼저 만들자"며 범용 배치 프레임워크부터 짓는 것. Job 메타 테이블 12개, XML 설정, 리스너 인터페이스 8개… 정작 도는 배치는 두 개뿐인데 프레임워크 유지보수가 더 무거워집니다. 실제로 사내 프로젝트에서 흔히 벌어지는 일입니다.
이 순서가 안전합니다
① Hangfire 로 스케줄과 이력·화면을 사 옵니다(직접 만들지 않음). ② 배치 본문은 그냥 평범한 서비스 클래스로 씁니다. ③ 청크·체크포인트가 실제로 필요해진 배치에만 f04 의 300줄을 붙입니다. ④ 같은 패턴이 세 번째 나오면 그때 공통 클래스로 뽑습니다.
BackgroundService,
수동 재실행은 Hangfire 대시보드의 "Trigger now".
이 조합이면 새로 배울 것이 Hangfire 하나뿐입니다.
Worker Service — 상주하는 배치 프로세스 만들기
스프링에서 @Scheduled 를 단 빈이나 ApplicationRunner 로 하던 일입니다.
.NET 에서는 BackgroundService 한 클래스가 그 자리를 다 맡습니다.
중요한 것은 어떻게 멈추는가 와 두 번 뜨지 않게 하는가 입니다.
가장 작은 Worker
dotnet new worker -o Settle.Batch
cd Settle.Batch && dotnet run
public sealed class SettleWorker(
ILogger<SettleWorker> log,
IServiceScopeFactory scopes) : BackgroundService
{
// 5분마다 한 바퀴. PeriodicTimer 가 Task.Delay 보다 정확합니다
private readonly PeriodicTimer _tick =
new(TimeSpan.FromMinutes(5));
protected override async Task ExecuteAsync(CancellationToken ct)
{
log.LogInformation("워커 기동 {Machine}", Environment.MachineName);
// 첫 바퀴를 바로 돌지, 5분 뒤에 돌지 — 의도적으로 고르세요
while (await _tick.WaitForNextTickAsync(ct))
{
// ★ 스코프를 매 바퀴 새로 엽니다 (아래 note 참고)
using var scope = scopes.CreateScope();
var svc = scope.ServiceProvider
.GetRequiredService<ISettleService>();
try
{
await svc.RunOnceAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw; // 종료 신호는 그대로 올려보냅니다
}
catch (Exception ex)
{
// ★ 삼키지 않으면 프로세스가 죽습니다
log.LogError(ex, "한 바퀴 실패 — 다음 주기에 다시 시도합니다");
}
}
}
public override async Task StopAsync(CancellationToken ct)
{
log.LogInformation("종료 신호 수신 — 정리 시작");
_tick.Dispose();
await base.StopAsync(ct);
}
}
등록과 종료 시간 조정
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<SettleWorker>();
builder.Services.AddScoped<ISettleService, SettleService>();
// 종료 유예 시간 — 기본 30초. 긴 배치는 늘립니다
builder.Services.Configure<HostOptions>(o =>
{
o.ShutdownTimeout = TimeSpan.FromMinutes(3);
// .NET 8+ : 한 워커가 죽어도 호스트를 내리지 않게
o.BackgroundServiceExceptionBehavior =
BackgroundServiceExceptionBehavior.Ignore;
});
builder.Services.AddWindowsService(o => // Windows 서비스로도 뜨게
o.ServiceName = "SettleBatch");
builder.Services.AddSystemd(); // Linux systemd 알림 지원
var host = builder.Build();
await host.RunAsync();
BackgroundServiceExceptionBehavior.Ignore 는 신중히.
기본값 StopHost 는 워커가 예외로 죽으면 프로세스 전체를 내립니다.
컨테이너·systemd 환경에서는 이게 오히려 정답입니다 — 죽으면 재시작해 주니까요.
Ignore 로 두면 워커만 조용히 사라지고 프로세스는 살아 있어
헬스체크는 초록인데 배치는 안 도는 최악의 상태가 됩니다.
Ignore 를 쓸 거면 반드시 "루프가 살아 있는지" 헬스체크를 따로 두세요.
스코프 — DbContext 를 워커 필드에 두면 안 되는 이유 🟡 중급
BackgroundService 는 싱글턴입니다. 생성자에 DbContext 를 받으면
그 인스턴스가 프로세스가 사는 내내 유지됩니다.
스프링에서 @Scheduled 메서드에 EntityManager 를 필드로 물고 있는 것과 같은 사고입니다 —
ChangeTracker 가 계속 커지고, 커넥션이 붙잡히고, 한 번 예외가 나면
그 컨텍스트가 오염되어 이후 전부 실패합니다.
이것을 captive dependency(가둔 의존성) 라고 부릅니다.
❌ 생성자에서 받기
public class BadWorker(AppDbContext db) // Scoped 를 Singleton 이 붙잡음
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
// 24시간 뒤 이 db 의 ChangeTracker 에는
// 추적 엔티티 400만 개가 쌓여 있습니다
var rows = await db.Orders.ToListAsync(ct);
...
}
}
}
런타임에 예외조차 안 납니다. 그냥 메모리가 새고 느려집니다 — 제일 찾기 어려운 종류.
✅ 바퀴마다 스코프 열기
public class GoodWorker(IServiceScopeFactory scopes)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
using var scope = scopes.CreateScope(); // ★
var db = scope.ServiceProvider
.GetRequiredService<AppDbContext>();
...
// using 이 끝나면 DbContext 도 커넥션도 반납
}
}
}
스프링의 요청 스코프를 배치 한 바퀴에 수동으로 여는 것이라고 보면 정확합니다.
단일 실행 보장 — 두 대가 동시에 돌면 정산이 두 배 🟡 중급
배치 서버를 이중화하면 같은 배치가 두 번 도는 사고가 반드시 한 번은 납니다. 막는 방법은 셋인데, 상황에 따라 정답이 다릅니다.
| 방법 | 범위 | 서버가 죽으면 | Java 대응 | 쓸 곳 |
|---|---|---|---|---|
Mutex (Windows 전역) | 한 대 안에서만 | OS 가 자동 해제 | 파일 락 · FileLock | 같은 서버에서 두 번 실행 방지(작업 스케줄러 중복 기동) |
DB 애플리케이션 락sp_getapplock | DB 를 공유하는 모든 서버 | 세션이 끊기면 자동 해제 | ShedLock · Quartz 클러스터 | 가장 안전. 사내 배치의 기본값 |
락 테이블 UPDATE | 모든 서버 | 안 풀립니다 — 만료 시각 필요 | ShedLock JDBC | DB 가 애플리케이션 락을 지원 안 할 때(PostgreSQL 은 pg_advisory_lock) |
Redis SET NX PX | 모든 서버 | TTL 로 만료 | Redisson | 이미 Redis 를 쓰고 있고 배치가 짧을 때 |
Hangfire DisableConcurrentExecution | 모든 서버 | 타임아웃으로 해제 | Quartz @DisallowConcurrentExecution | Hangfire 를 쓰면 이거면 끝 |
SQL Server 애플리케이션 락 — 실제 코드
public sealed class SqlAppLock : IAsyncDisposable
{
private readonly SqlConnection _cn;
private SqlAppLock(SqlConnection cn) => _cn = cn;
/// <summary>못 잡으면 null — 이미 다른 서버가 돌고 있다는 뜻</summary>
public static async Task<SqlAppLock?> TryAcquireAsync(
string cs, string name, TimeSpan wait, CancellationToken ct)
{
var cn = new SqlConnection(cs);
await cn.OpenAsync(ct);
await using var cmd = cn.CreateCommand();
cmd.CommandText = "sp_getapplock";
cmd.CommandType = CommandType.StoredProcedure;
cmd.Parameters.AddWithValue("@Resource", name);
cmd.Parameters.AddWithValue("@LockMode", "Exclusive");
cmd.Parameters.AddWithValue("@LockOwner", "Session");
cmd.Parameters.AddWithValue("@LockTimeout", (int)wait.TotalMilliseconds);
var rv = cmd.Parameters.Add("@rv", SqlDbType.Int);
rv.Direction = ParameterDirection.ReturnValue;
await cmd.ExecuteNonQueryAsync(ct);
// 0 = 즉시 획득, 1 = 대기 후 획득, 음수 = 실패
if ((int)rv.Value < 0) { await cn.DisposeAsync(); return null; }
return new SqlAppLock(cn);
}
// 커넥션을 닫으면 락도 풀립니다 — 서버가 죽어도 자동 해제
public ValueTask DisposeAsync() => _cn.DisposeAsync();
}
워커에서 쓰기
await using var mine = await SqlAppLock.TryAcquireAsync(
cs, "batch:daily-settle", TimeSpan.Zero, ct);
if (mine is null)
{
log.LogInformation("다른 서버가 이미 정산 중 — 이번 바퀴는 건너뜁니다");
return; // ★ 예외를 던지지 마세요. 정상 상황입니다
}
await settle.RunAsync(ct); // 여기부터는 나 혼자입니다
LockOwner = "Session" 이 핵심입니다.
"Transaction" 으로 잡으면 트랜잭션이 끝날 때 풀리는데,
배치는 트랜잭션을 여러 번 나눠 커밋하므로 중간에 락이 사라집니다.
세션(커넥션) 수명 = 배치 수명 으로 맞추는 것이 맞습니다.
Mutex 는 서버 간 중복을 못 막습니다.
new Mutex(true, @"Global\SettleBatch") 는 그 머신 안에서만 유일합니다.
이중화한 배치 서버 2대는 서로를 전혀 모릅니다.
"뮤텍스 걸었는데 두 번 돌았다"는 사고가 여기서 나옵니다.
종료 신호를 끝까지 전파하기 🔴 고급
CancellationToken 은 인자로 계속 넘기지 않으면 아무 일도 안 합니다.
자바의 Thread.interrupt() 와 달리 스레드를 건드리는 것이 아니라
"멈춰 달라"는 값일 뿐이라, 받는 쪽이 확인해야 멈춥니다.
아래 세 곳을 빠뜨리면 Ctrl+C 를 눌러도 안 죽는 배치가 됩니다.
// ① 모든 async 호출에 토큰을 넘긴다 — 하나라도 빠지면 거기서 멈춥니다
await db.SaveChangesAsync(ct);
await http.GetAsync(url, ct);
await stream.ReadAsync(buf, ct);
await foreach (var row in ReadCsvAsync(path).WithCancellation(ct)) { ... }
// ② 루프 안에서 스스로 확인한다 (CPU 바운드 구간엔 await 가 없으므로)
foreach (var chunk in chunks)
{
ct.ThrowIfCancellationRequested(); // 여기서 던져 나갑니다
Process(chunk);
}
// ③ 긴 배치는 "안전 지점"에서만 나간다 — 청크 경계
foreach (var chunk in chunks)
{
await ProcessAndCommitAsync(chunk, CancellationToken.None); // 이 안은 끝까지
await SaveCheckpointAsync(chunk.LastId, CancellationToken.None);
if (ct.IsCancellationRequested) // 커밋 직후에만 빠져나옵니다
{
log.LogWarning("종료 요청 — {Id} 까지 처리하고 중단", chunk.LastId);
break; // 다음 실행이 여기서 이어받습니다
}
}
CancellationToken.None 을 쓰나요?
청크 한 개를 처리하는 도중에 취소되면 DB 는 커밋됐는데 체크포인트는 안 남는
어중간한 상태가 됩니다. 그래서 "한 청크는 무조건 끝까지, 대신 청크 경계에서만 나간다"로 설계합니다.
ShutdownTimeout 을 청크 하나의 최대 소요 시간보다 길게 잡아야 하는 이유이기도 합니다.
Spring Batch 의 StepExecution 종료 처리도 같은 원리로 청크 경계에서만 멈춥니다.
sc.exe create · 유닛 파일 전문과 함께 다룹니다.
여기서는 AddWindowsService() / AddSystemd() 한 줄이면
같은 실행 파일이 콘솔로도 서비스로도 뜬다는 것만 기억하세요 —
개발 중에는 dotnet run, 운영에서는 서비스입니다.
BackgroundService 는 싱글턴이니 바퀴마다 스코프를 열고, 종료 토큰을 끝까지 인자로 넘기며, 이중화했다면 sp_getapplock 으로 한 대만 돌게 합니다.스케줄링 — 크론 · 미스파이어 · 시간대
"매일 새벽 2시 10분"을 적는 것은 쉽습니다. 어려운 것은 서버가 두 대일 때 · 그 시각에 서버가 꺼져 있었을 때 · 서머타임이 낀 나라일 때 입니다. 운영 사고는 전부 이 세 곳에서 납니다.
크론 표현식 — 자리 수부터 세기 🟢 기초
| 표현식 | 자리 | 뜻 | Spring 대응 |
|---|---|---|---|
0 10 2 * * * | 초 분 시 일 월 요일 (6) | 매일 02:10:00 | @Scheduled(cron="0 10 2 * * *") — 동일 |
10 2 * * * | 분 시 일 월 요일 (5) | 매일 02:10 (표준 crontab) | 없음 — Spring 은 항상 6자리 |
0 */10 * * * * | 6 | 10분마다 | 동일 |
0 0 3 1 * * | 6 | 매월 1일 03:00 | 동일 |
0 0 3 L * ? | Quartz 전용 | 매월 말일 03:00 | Quartz 동일 (L = Last) |
0 0 3 ? * MON-FRI | Quartz 전용 | 평일만 03:00 | Quartz 동일 |
0 0 3 ? * 6#3 | Quartz 전용 | 매월 셋째 금요일 | Quartz 동일 |
L · W · # 는 표준 crontab 에 없는 확장 문법입니다.
Quartz.NET 도, Hangfire 가 쓰는 Cronos 라이브러리도 받아 주지만
요일 번호가 다릅니다 — Quartz 는 1=일요일이라 6#3 이 셋째 금요일,
Cronos 는 0=일요일이라 같은 식이 셋째 토요일입니다. 다음 실행 시각을 꼭 찍어 보세요.
"말일 정산" · "셋째 금요일 마감" 같은 사내 규칙은
매일 돌리고 코드에서 날짜를 판단하는 편이 안전합니다 —
공휴일 테이블을 봐야 하는 순간 어차피 크론으로는 못 적습니다.
시간대 — 한국은 서머타임이 없지만
// ★ .NET 6+ 부터 Windows/Linux 양쪽에서 이 ID 가 다 통합니다
static readonly TimeZoneInfo Kst =
TimeZoneInfo.FindSystemTimeZoneById("Asia/Seoul");
// Hangfire
RecurringJob.AddOrUpdate<SettleJob>(
"daily-settle", j => j.RunAsync(default),
"0 10 2 * * *",
new RecurringJobOptions { TimeZone = Kst });
// Quartz
.WithCronSchedule("0 10 2 * * ?", x => x.InTimeZone(Kst))
// 배치 안에서 "오늘"을 정할 때 — 이게 정답입니다
var kstNow = TimeZoneInfo.ConvertTimeFromUtc(
DateTime.UtcNow, Kst);
var bizDate = kstNow.AddHours(-3).Date; // 02:10 실행 → 전날 마감분
// DateTime.Now 는 서버 설정에 따라 값이 달라집니다. 쓰지 마세요.
Hangfire — 재시도 · 큐 · 연속 작업
public sealed class SettleJob(ISettleService svc)
{
// 실패하면 20초 · 5분 · 30분 뒤 세 번 더
[AutomaticRetry(Attempts = 3,
DelaysInSeconds = [20, 300, 1800],
OnAttemptsExceeded = AttemptsExceededAction.Fail)]
[DisableConcurrentExecution(timeoutInSeconds: 3 * 60 * 60)]
[Queue("settle")]
public async Task RunAsync(CancellationToken ct)
=> await svc.RunAsync(ct);
}
// 연속 작업 — A 가 성공해야 B 가 돕니다 (Spring Batch 의 Step 연결)
var loadId = BackgroundJob.Enqueue<LoadJob>(j => j.RunAsync(default));
var aggId = BackgroundJob.ContinueJobWith<AggJob>(
loadId, j => j.RunAsync(default));
BackgroundJob.ContinueJobWith<SendJob>(
aggId, j => j.RunAsync(default));
[DisableConcurrentExecution] 의 timeout 은 "락을 기다리는 시간"입니다.
3시간짜리 배치에 60초를 주면 두 번째 호출이 60초 기다리다 예외로 실패합니다.
배치 최장 소요 시간보다 길게 잡거나,
건너뛰는 것이 정상인 배치라면 f02 의 sp_getapplock 을 직접 쓰세요.
놓친 실행을 어떻게 처리할 것인가 🟡 중급
배치 서버를 3일간 점검하느라 내렸습니다. 다시 켰습니다. 밀린 3번의 실행을 어떻게 할까요? 이 질문에 답을 정해 두지 않으면 복구하자마자 두 번째 사고가 납니다. 정산이 3일치 겹쳐서 도는 것이 대표적입니다.
| 정책 | Quartz.NET | Hangfire | 결과 | 이럴 때 |
|---|---|---|---|---|
| 밀린 것 전부 실행 | WithMisfireHandlingInstructionIgnoreMisfires() | MisfireHandling = Strict (1.8+) | 3번 연속 실행 | 날짜별로 독립인 집계 (일별 통계) |
| 한 번만 실행하고 정상 궤도 | ...FireAndProceed() | 기본 동작 | 1번 실행 | 대부분의 정산 배치. 안전한 기본값 |
| 건너뛰기 | ...DoNothing() | MisfireHandling = Ignorable (1.8+) | 0번, 다음 정시부터 | 실시간성이 전부인 알림 발송 |
| 날짜를 인자로 받아 직접 루프 | 두 엔진 모두 이 방식이 가능 | 내가 정한 만큼 | 실무 권장. 아래 코드 | |
// ★ 실무에서 가장 안전한 형태 — 스케줄러는 "깨우기만" 하고
// 무엇을 처리할지는 배치가 DB 를 보고 스스로 판단합니다
public async Task RunAsync(CancellationToken ct)
{
var today = KstToday();
// 아직 성공하지 않은 영업일을 오래된 것부터 가져옵니다
var pending = await db.BusinessDays
.Where(d => d.Date < today && d.SettleState != SettleState.Done)
.OrderBy(d => d.Date)
.Take(7) // ★ 한 번에 최대 7일치만 (폭주 방지)
.ToListAsync(ct);
if (pending.Count == 0) { log.LogInformation("처리할 영업일 없음"); return; }
foreach (var day in pending)
{
log.LogInformation("정산 시작 {Date} (밀린 {N}일 중)", day.Date, pending.Count);
await SettleOneDayAsync(day.Date, ct);
if (ct.IsCancellationRequested) break; // 남은 날은 다음 실행이 맡습니다
}
}
JobParameters 유니크 제약으로 중복 실행을 막던 것을
업무 데이터 자신의 상태로 옮긴 형태입니다.
배치를 언제 눌러도 안전하다는 것은 운영 이관 때 가장 크게 칭찬받는 성질입니다.
청크 처리 — Spring Batch 를 300줄로 다시 짓기
Reader-Processor-Writer, 커밋 단위, 재시작 지점. Spring Batch 가 대신 해 주던 것을 직접 만드는 섹션입니다. 만들고 나면 오히려 "이게 다였나" 싶어집니다 — 대신 내 도메인에 정확히 맞습니다.
이력 · 체크포인트 스키마 — JobRepository 를 손으로 🟢 기초
-- 배치 실행 1회 = 1행. Spring Batch 의 BATCH_JOB_EXECUTION 에 해당
CREATE TABLE BatchRun (
RunId BIGINT IDENTITY PRIMARY KEY,
JobName VARCHAR(60) NOT NULL,
BizDate DATE NOT NULL, -- 처리 대상 영업일
Status VARCHAR(12) NOT NULL, -- Running / Success / Failed / Stopped
StartedAt DATETIME2(3) NOT NULL,
FinishedAt DATETIME2(3) NULL,
ReadCount INT NOT NULL DEFAULT 0,
WriteCount INT NOT NULL DEFAULT 0,
SkipCount INT NOT NULL DEFAULT 0,
LastError NVARCHAR(2000) NULL,
MachineName VARCHAR(60) NOT NULL
);
-- ★ 같은 잡 + 같은 영업일은 성공 1건만 — 중복 정산을 DB 가 막습니다
-- UNIQUE (JobName, BizDate, Status) 로 걸면 실패 이력도 하루 1건밖에 못 남기므로
-- 성공 행에만 거는 필터 인덱스를 씁니다
CREATE UNIQUE INDEX UX_BatchRun_Success ON BatchRun (JobName, BizDate)
WHERE Status = 'Success';
-- 재시작 지점. Spring Batch 의 ExecutionContext 에 해당
CREATE TABLE BatchCheckpoint (
JobName VARCHAR(60) NOT NULL,
BizDate DATE NOT NULL,
StepName VARCHAR(60) NOT NULL,
LastKey VARCHAR(120) NOT NULL, -- 키셋 페이징의 마지막 키
DoneCount BIGINT NOT NULL,
UpdatedAt DATETIME2(3) NOT NULL,
PRIMARY KEY (JobName, BizDate, StepName)
);
-- 스킵된 건. 버리지 말고 반드시 남깁니다 (f12 재처리 화면의 원천)
CREATE TABLE BatchSkip (
SkipId BIGINT IDENTITY PRIMARY KEY,
RunId BIGINT NOT NULL,
LineNo BIGINT NOT NULL,
RawLine NVARCHAR(MAX) NULL, -- 원문 그대로 보관
Reason NVARCHAR(400) NOT NULL,
CreatedAt DATETIME2(3) NOT NULL
);
LastKey 를 문자열로 두나요?
단계마다 재시작 키가 다르기 때문입니다 —
파일 읽기 단계는 바이트 오프셋, DB 처리 단계는 마지막 PK,
API 호출 단계는 페이지 토큰.
BIGINT 로 못 박으면 첫 번째 예외에서 스키마를 바꿔야 합니다.
Spring Batch 의 ExecutionContext 가 키-값 문자열 맵인 것과 같은 이유입니다.
Reader-Processor-Writer — C# 다운 형태 🟡 중급
자바에서는 인터페이스 세 개를 구현했습니다. C# 에서는 인터페이스가 하나도 필요 없습니다 —
IAsyncEnumerable<T> 하나로 Reader 가 되고, LINQ 가 Processor 가 되고,
Writer 만 메서드로 남습니다.
Reader — yield return 이 곧 ItemReader
// ★ 400만 행이어도 메모리에 1건씩만 올라옵니다
public static async IAsyncEnumerable<TradeRow> ReadAsync(
string path,
long skipLines, // 체크포인트에서 이어받기
[EnumeratorCancellation] CancellationToken ct = default)
{
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read,
FileShare.Read, 64 * 1024,
FileOptions.SequentialScan);
using var sr = new StreamReader(fs, Encoding.UTF8);
long lineNo = 0;
while (await sr.ReadLineAsync(ct) is { } line)
{
lineNo++;
if (lineNo <= skipLines) continue; // 이미 처리한 구간은 건너뜀
if (line.Length == 0) continue;
yield return TradeRow.Parse(line, lineNo);
}
}
[EnumeratorCancellation] 를 빠뜨리면
호출부의 .WithCancellation(ct) 가 조용히 무시됩니다.
컴파일러가 CS8425 경고를 내 주지만, 경고 목록에 묻혀 흘려보내기 쉽습니다.
IAsyncEnumerable 을 쓸 때 가장 흔한 실수입니다.
Chunk — 청크 자르기 확장 메서드
public static async IAsyncEnumerable<List<T>> ChunkAsync<T>(
this IAsyncEnumerable<T> src, int size,
[EnumeratorCancellation] CancellationToken ct = default)
{
var buf = new List<T>(size);
await foreach (var item in src.WithCancellation(ct))
{
buf.Add(item);
if (buf.Count < size) continue;
yield return buf;
buf = new List<T>(size); // ★ 재사용하지 말 것 — 아래 warn
}
if (buf.Count > 0) yield return buf;
}
buf.Clear() 로 재사용하면 버그입니다.
소비자가 그 리스트를 비동기로 붙잡고 있는 사이
생산자가 지워 버립니다. 실제로 "가끔 몇 백 건이 사라지는" 사고가
이 한 줄에서 납니다. GC 압박이 걱정되면
ArrayPool<T> 로 명시적으로 빌리고 반납하세요 (f06).
// 동기 IEnumerable 은 .NET 6 부터 표준 Chunk 가 있습니다
// (.NET 10 은 IAsyncEnumerable 용 LINQ — System.Linq.AsyncEnumerable — 도 기본 제공합니다)
foreach (var page in list.Chunk(1000)) { ... }
전체 청크 루프 — 재시작 가능한 배치 본문 🔴 고급
public sealed class ChunkRunner(
IDbContextFactory<AppDbContext> dbf,
IBatchJournal journal,
ILogger<ChunkRunner> log)
{
public async Task<BatchResult> RunAsync(
string job, DateOnly bizDate, string step,
Func<string?, IAsyncEnumerable<TradeRow>> readFrom,
int chunkSize, int skipLimit, CancellationToken ct)
{
var run = await journal.StartAsync(job, bizDate, ct);
var last = await journal.LoadCheckpointAsync(job, bizDate, step, ct);
long read = 0, wrote = 0, skipped = 0;
log.LogInformation("재시작 지점 {Key} 부터 시작", last?.LastKey ?? "(처음)");
try
{
await foreach (var chunk in readFrom(last?.LastKey)
.ChunkAsync(chunkSize, ct))
{
read += chunk.Count;
// ── ② 변환 · 검증 : 실패 건은 버리지 말고 모읍니다
var good = new List<Trade>(chunk.Count);
var bad = new List<(TradeRow Row, string Why)>();
foreach (var row in chunk)
{
if (Validate(row) is { } why) bad.Add((row, why));
else good.Add(Map(row));
}
// ── ③ + ④ 쓰기와 체크포인트를 한 트랜잭션으로
await using var db = await dbf.CreateDbContextAsync(ct);
await using var tx = await db.Database
.BeginTransactionAsync(ct);
await db.BulkInsertAsync(good, ct); // f05
await journal.AppendSkipsAsync(db, run.RunId, bad, ct);
await journal.SaveCheckpointAsync(
db, job, bizDate, step,
lastKey: chunk[^1].Key, done: read, ct); // ★ 같은 tx
await tx.CommitAsync(ct); // 여기서 원자적 확정
wrote += good.Count;
skipped += bad.Count;
if (skipped > skipLimit)
throw new BatchAbortException(
$"스킵 한도 초과 {skipped} > {skipLimit}");
if (read % 100_000 == 0)
log.LogInformation("진행 {Read}건 · 적재 {Wrote} · 스킵 {Skip}",
read, wrote, skipped);
if (ct.IsCancellationRequested) // 청크 경계에서만 탈출
{
await journal.StopAsync(run.RunId, read, wrote, skipped, ct);
return BatchResult.Stopped(read, wrote, skipped);
}
}
await journal.SucceedAsync(run.RunId, read, wrote, skipped, ct);
return BatchResult.Success(read, wrote, skipped);
}
catch (Exception ex)
{
// 체크포인트는 이미 커밋되어 있으므로 다음 실행이 이어받습니다
await journal.FailAsync(run.RunId, ex, read, wrote, skipped,
CancellationToken.None);
throw;
}
}
}
SaveCheckpointAsync 가 BulkInsert 와 같은 트랜잭션 안에 있다는 것,
② 예외가 나도 이미 커밋된 청크는 되돌리지 않는다는 것.
나머지는 로깅과 카운팅입니다.
Spring Batch 도 정확히 이 두 가지를 JobRepository 로 보장하고 있었습니다.
| 결정 | 작게(100) | 보통(1,000) | 크게(10,000) | 기준 |
|---|---|---|---|---|
| 커밋 횟수 | 많음 — 트랜잭션 로그 부담 | 적정 | 적음 | DB 로그 디스크 여유 |
| 재시작 손실 | 거의 없음 | 최대 1,000건 재처리 | 최대 1만 건 재처리 | 1건 처리 비용이 비싸면 작게 |
| 메모리 | 수 KB | 수 MB | 수십 MB | 행 하나가 크면(BLOB) 작게 |
| 락 유지 시간 | 짧음 | 보통 | 길다 — 온라인 서비스 방해 | 야간 전용이면 크게 가능 |
| 권장 | 외부 API 호출이 낀 단계 | 기본값. 여기서 시작 | 순수 벌크 적재 전용 | 재 보고 조정 |
IAsyncEnumerable, Processor 는 LINQ, Writer 는 벌크. 커밋과 체크포인트를 한 트랜잭션으로 묶고, 스킵 건은 버리지 말고 테이블에 남깁니다.대용량 DB 처리 — 400만 행을 넣고 고치고 지우기
JPA 로 saveAll() 을 돌려 본 적 있다면 결말을 압니다.
EF Core 도 똑같이 느립니다. 대용량에서는 ORM 을 비켜 가는 길이 따로 있고,
.NET 에는 그 길이 아주 잘 닦여 있습니다.
SqlBulkCopy — .NET 이 가진 가장 강한 카드 🟢 기초
자바에는 rewriteBatchedStatements · LOAD DATA INFILE 같은
드라이버별 우회로가 있었지만 표준이 없었습니다.
.NET 은 SqlBulkCopy 가 BCL 에 들어 있습니다(Microsoft.Data.SqlClient).
SQL Server 의 대량 복사 프로토콜을 그대로 씁니다 — INSERT 문을 만들지 않습니다.
객체 목록(IEnumerable)을 그대로 붓기
public static async Task BulkCopyAsync(
string cs, IEnumerable<Trade> rows, CancellationToken ct)
{
await using var cn = new SqlConnection(cs);
await cn.OpenAsync(ct);
using var bulk = new SqlBulkCopy(cn, SqlBulkCopyOptions.TableLock, null)
{
DestinationTableName = "dbo.Trade_Stage",
BatchSize = 10_000, // 한 번에 서버로 넘길 행 수
BulkCopyTimeout = 0, // 0 = 무제한 (배치에서는 이게 맞습니다)
EnableStreaming = true // ★ 전체를 메모리에 안 올립니다
};
// 열 이름이 다르면 반드시 매핑 — 순서만 믿으면 언젠가 깨집니다
bulk.ColumnMappings.Add(nameof(Trade.TradeNo), "TRADE_NO");
bulk.ColumnMappings.Add(nameof(Trade.TradedAt), "TRADED_AT");
bulk.ColumnMappings.Add(nameof(Trade.Amount), "AMT");
// FastMember 로 객체 목록을 IDataReader 로 감쌉니다
using var reader = ObjectReader.Create(rows,
nameof(Trade.TradeNo), nameof(Trade.TradedAt), nameof(Trade.Amount));
await bulk.WriteToServerAsync(reader, ct);
}
ObjectReader 는 FastMember 패키지(NuGet)의 것입니다.
DataTable 을 만들어 넣어도 되지만, 400만 행짜리 DataTable 은 그 자체로 수 GB 입니다.
ObjectReader + EnableStreaming 조합이면
메모리 사용량이 행 수와 무관해집니다.
스테이징 → MERGE 패턴 (실무 표준)
-- ① 스테이징 테이블은 인덱스도 제약도 없이 비워 둡니다
TRUNCATE TABLE dbo.Trade_Stage;
-- ② SqlBulkCopy 로 붓기 (위 코드) — 41초
-- ③ 한 문장으로 본 테이블에 반영. 멱등합니다
MERGE dbo.Trade AS t
USING (SELECT TRADE_NO, MAX(TRADED_AT) TRADED_AT, SUM(AMT) AMT
FROM dbo.Trade_Stage GROUP BY TRADE_NO) AS s
ON t.TradeNo = s.TRADE_NO
WHEN MATCHED AND t.Amount <> s.AMT THEN
UPDATE SET t.Amount = s.AMT, t.UpdatedAt = SYSUTCDATETIME()
WHEN NOT MATCHED BY TARGET THEN
INSERT (TradeNo, TradedAt, Amount, CreatedAt)
VALUES (s.TRADE_NO, s.TRADED_AT, s.AMT, SYSUTCDATETIME());
SqlBulkCopy 는 기본적으로 트리거와 CHECK 제약을 건너뜁니다.
빠른 이유가 그것입니다. 필요하면 옵션으로 켜야 하는데
(SqlBulkCopyOptions.FireTriggers | CheckConstraints)
켜는 만큼 느려집니다.
본 테이블에 직접 부으면서 트리거를 끄면 감사 로그가 통째로 빕니다 —
사내 시스템에서 실제로 자주 나는 사고입니다.
스테이징 테이블에는 트리거를 두지 않고, 트리거가 필요한 반영은 MERGE 로 하세요.
EF Core 로 대량 UPDATE · DELETE 🟡 중급
EF Core 7 부터 ExecuteUpdate / ExecuteDelete 가 들어왔습니다.
JPA 의 @Modifying @Query 에 해당하는데, LINQ 로 쓴다는 점이 다릅니다.
엔티티를 메모리에 올리지 않고 SQL 한 문장으로 내려갑니다.
❌ 예전 방식 — 400만 건을 올렸다 내림
var olds = await db.Trades
.Where(t => t.TradedAt < cutoff)
.ToListAsync(ct); // 메모리에 400만 개
foreach (var t in olds) t.Archived = true;
await db.SaveChangesAsync(ct); // UPDATE 400만 번
메모리 3 GB · 52분 · 트랜잭션 로그 폭발.
✅ ExecuteUpdate — SQL 한 문장
var n = await db.Trades
.Where(t => t.TradedAt < cutoff && !t.Archived)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Archived, true)
.SetProperty(t => t.UpdatedAt, DateTime.UtcNow), ct);
log.LogInformation("{N}건 보관 처리", n);
메모리 수 KB · 8초. 단, ChangeTracker 를 우회하므로 이미 추적 중인 엔티티는 낡은 값을 갖습니다.
// 대량 삭제는 반드시 "나눠서" — 한 방에 지우면 락이 테이블 전체로 승격됩니다
int deleted, total = 0;
do
{
deleted = await db.Trades
.Where(t => t.TradedAt < cutoff)
.OrderBy(t => t.Id)
.Take(50_000) // ★ 5만 건씩
.ExecuteDeleteAsync(ct);
total += deleted;
await Task.Delay(200, ct); // 온라인 서비스에 숨통을 틔워 줍니다
}
while (deleted > 0 && !ct.IsCancellationRequested);
키셋 페이징 — OFFSET 이 뒤로 갈수록 느려지는 이유 🔴 고급
Skip(n).Take(1000) 은 SQL 의 OFFSET n ROWS 가 됩니다.
DB 는 n 개를 실제로 읽어서 버린 다음 1,000개를 줍니다.
400만 행의 마지막 페이지를 읽으려면 400만 행을 읽고 버립니다.
총합으로 보면 O(n²) — 이것이 "배치가 뒤로 갈수록 느려지는" 정체입니다.
| 페이지 | OFFSET 방식 읽는 행 | 키셋 방식 읽는 행 | OFFSET 소요 | 키셋 소요 |
|---|---|---|---|---|
| 1페이지 | 1,000 | 1,000 | 0.01초 | 0.01초 |
| 1,000페이지 | 1,001,000 | 1,000 | 1.4초 | 0.01초 |
| 4,000페이지 | 4,001,000 | 1,000 | 5.8초 | 0.01초 |
| 전체 4,000페이지 합 | 80억 행 읽음 | 400만 행 | 3시간 12분 | 47초 |
키셋 페이징 — 마지막 키를 기억
public static async IAsyncEnumerable<List<Trade>> ReadByKeysetAsync(
AppDbContext db, long startAfterId, int size,
[EnumeratorCancellation] CancellationToken ct = default)
{
var lastId = startAfterId; // 체크포인트에서 받은 값
while (true)
{
var page = await db.Trades
.AsNoTracking() // ★ 배치 읽기는 항상 이것
.Where(t => t.Id > lastId) // ★ OFFSET 대신 부등호
.OrderBy(t => t.Id) // ★ 인덱스와 같은 순서
.Take(size)
.ToListAsync(ct);
if (page.Count == 0) yield break;
yield return page;
lastId = page[^1].Id; // 다음 페이지의 시작점
}
}
복합 키셋 — 정렬 키가 유일하지 않을 때
// (TradedAt, Id) 로 정렬 — TradedAt 이 같은 행이 여럿 있습니다
var page = await db.Trades.AsNoTracking()
.Where(t => t.TradedAt > lastAt
|| (t.TradedAt == lastAt && t.Id > lastId))
.OrderBy(t => t.TradedAt).ThenBy(t => t.Id)
.Take(size)
.ToListAsync(ct);
// EF Core 9+ 에서는 튜플 비교가 그대로 번역됩니다
// C# 튜플에는 > 연산자가 없어서 위처럼 풀어 씁니다.
// (PostgreSQL 이라면 Npgsql 의 EF.Functions.GreaterThan(ValueTuple.Create(...), ValueTuple.Create(...))
// 로 행 값 비교를 쓸 수 있습니다 — SQL Server 에는 이 문법이 없습니다)
TradedAt 만으로 자르면 같은 시각의 행 3개가
페이지 경계에 걸릴 때 일부가 사라집니다.
키셋 페이징의 마지막 정렬 키는 반드시 유일해야 합니다 —
보통 PK 를 맨 뒤에 붙입니다.
(TradedAt, Id) 복합 인덱스가 없으면 소용없습니다.
키셋 페이징의 전제는 정렬 순서 = 인덱스 순서 입니다.
인덱스가 없으면 매 페이지마다 전체 정렬이 일어나 OFFSET 보다 느려집니다.
인덱스 · 로그 · 임시 테이블 🔴 고급
| 기법 | 효과 | 대가 | 쓸 때 |
|---|---|---|---|
| 적재 전 비클러스터 인덱스 DISABLE → 적재 → REBUILD | 적재 2~4배 빠름 | 재구성 중 그 인덱스로 조회 불가 | 스테이징이 아닌 본 테이블에 대량 적재할 때 |
| 복구 모델 BULK_LOGGED | 트랜잭션 로그 급감 | 시점 복구 불가 구간 발생 | DBA 합의 필수. 초기 이관에만 |
TABLOCK 힌트 | 최소 로깅 조건 충족 | 동시 접근 차단 | 야간 전용 배치 |
| 파티션 스위칭 | 수억 행 삭제가 메타데이터 변경으로 끝남 | 파티션 설계 필요 | 일별 파티션 + 보관 정책 |
임시 테이블 #tmp | 중간 결과를 세션 안에 격리 | tempdb 부담 | 여러 단계 조인이 필요한 집계 |
| 테이블 반환 파라미터(TVP) | 수천 건을 한 번에 프로시저로 | 1만 건 넘으면 벌크가 나음 | 중간 규모(1천~1만) 배치 갱신 |
// TVP — MyBatis 의 foreach INSERT 대신 쓰는 .NET 방식
var tvp = new DataTable();
tvp.Columns.Add("TradeNo", typeof(string));
tvp.Columns.Add("Amount", typeof(decimal));
foreach (var t in batch) tvp.Rows.Add(t.TradeNo, t.Amount);
await using var cmd = cn.CreateCommand();
cmd.CommandText = "dbo.usp_ApplyTrades";
cmd.CommandType = CommandType.StoredProcedure;
var p = cmd.Parameters.AddWithValue("@Trades", tvp);
p.SqlDbType = SqlDbType.Structured;
p.TypeName = "dbo.TradeTableType"; // CREATE TYPE ... AS TABLE
await cmd.ExecuteNonQueryAsync(ct);
ExecuteUpdate/ExecuteDelete 를 나눠서, 페이징은 OFFSET 이 아니라 키셋.대용량 파일 — 3 GB 를 40 MB 안에서 읽기
사내 인터페이스 파일은 여전히 고정폭 텍스트거나 CP949 CSV 입니다.
File.ReadAllLines() 한 줄이면 서버가 죽습니다.
스트리밍으로 읽고, 인코딩을 정확히 지정하고, 메모리를 실제로 재는 방법을 봅니다.
한글 인코딩 — EUC-KR · CP949 · BOM 🟢 기초
Encoding.GetEncoding(949) 를 그냥 부르면
NotSupportedException 이 납니다.
패키지 하나를 넣고 한 줄을 등록해야 합니다 — 사내 파일 연계에서 100% 만나는 함정입니다.
(.NET Framework 시절에는 그냥 됐기 때문에 이관할 때 반드시 걸립니다.)
CP949 등록 — 앱 시작 시 딱 한 번
dotnet add package System.Text.Encoding.CodePages
// Program.cs 맨 앞
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
// 이제 이게 됩니다
var cp949 = Encoding.GetEncoding(949); // = EUC-KR 확장
var euckr = Encoding.GetEncoding("euc-kr"); // ★ 이건 51949(순수 EUC-KR) — 949 와 다릅니다
using var sr = new StreamReader(path, cp949);
949 를 쓰면 자동으로 CP949 이므로
EUC-KR 파일도 문제없이 읽힙니다. 반대(CP949 파일을 순수 EUC-KR 로 읽기)는 깨집니다.
인코딩 자동 판별 — 실무에서 쓰는 방식
static Encoding DetectEncoding(string path)
{
Span<byte> head = stackalloc byte[4];
using (var fs = File.OpenRead(path)) fs.ReadExactly(head);
// ① BOM 이 있으면 그 말을 믿습니다
if (head[0] == 0xEF && head[1] == 0xBB && head[2] == 0xBF)
return new UTF8Encoding(false);
if (head[0] == 0xFF && head[1] == 0xFE) return Encoding.Unicode;
if (head[0] == 0xFE && head[1] == 0xFF) return Encoding.BigEndianUnicode;
// ② BOM 이 없으면 — UTF-8 로 엄격하게 디코딩을 시도해 봅니다
var strict = new UTF8Encoding(false, throwOnInvalidBytes: true);
try
{
using var sr = new StreamReader(path, strict, false);
for (int i = 0; i < 200 && sr.ReadLine() is not null; i++) { }
return strict; // 200줄이 멀쩡하면 UTF-8
}
catch (DecoderFallbackException)
{
return Encoding.GetEncoding(949); // 아니면 CP949 로 봅니다
}
}
UTF.Unknown 패키지
(Mozilla 의 universalchardet 이식)를 씁니다.
하지만 인터페이스 규약서에 인코딩을 못 박는 것이 진짜 해법입니다 —
판별은 언젠가 틀립니다. f08 의 규약 설계를 보세요.
// 쓸 때 — BOM 을 넣을지 말지는 받는 쪽 규약에 맞춥니다
// ★ new UTF8Encoding(false) 를 쓰세요. Encoding.UTF8 은 BOM 을 붙입니다
using var sw = new StreamWriter(outPath, false, new UTF8Encoding(false));
// 대외 기관에 CP949 로 내보낼 때 — 표현 못 하는 글자를 어떻게 할지 정합니다
var out949 = Encoding.GetEncoding(949,
EncoderFallback.ExceptionFallback, // 못 쓰면 예외 (조용히 '?' 되는 것 방지)
DecoderFallback.ExceptionFallback);
using var sw949 = new StreamWriter(outPath, false, out949);
Encoding.UTF8 은 BOM 을 붙입니다.
이 파일을 자바의 BufferedReader 로 읽으면 첫 줄 맨 앞에 가 붙어
"HDR" 이 되고, equals("HDR") 이 실패합니다.
"헤더 검증만 계속 틀린다"는 연계 사고의 단골 원인입니다.
대외 송신 파일은 new UTF8Encoding(false) 로 BOM 없이 쓰세요.
CsvHelper 로 수백만 행 🟡 중급
읽기 — 스트리밍 그대로
dotnet add package CsvHelper
var cfg = new CsvConfiguration(CultureInfo.InvariantCulture)
{
HasHeaderRecord = true,
Delimiter = "|", // 사내 파일은 파이프가 많습니다
TrimOptions = TrimOptions.Trim,
BadDataFound = null, // 예외 대신 직접 처리
MissingFieldFound = null,
DetectColumnCountChanges = true
};
using var sr = new StreamReader(path, Encoding.GetEncoding(949));
using var csv = new CsvReader(sr, cfg);
csv.Context.RegisterClassMap<TradeRowMap>();
// ★ GetRecordsAsync 는 지연 스트리밍입니다 — ToListAsync 를 붙이지 마세요
await foreach (var row in csv.GetRecordsAsync<TradeRow>(ct))
{
// 여기서 한 건씩 흘러옵니다
}
public sealed class TradeRowMap : ClassMap<TradeRow>
{
public TradeRowMap()
{
Map(m => m.TradeNo).Name("거래번호"); // 한글 헤더도 됩니다
Map(m => m.TradedAt).Name("거래일시")
.TypeConverterOption.Format("yyyyMMddHHmmss");
Map(m => m.Amount).Name("금액")
.TypeConverterOption.NumberStyles(NumberStyles.Number); // 천 단위 쉼표 + 소수점 + 부호
}
}
고정폭 레코드 — 사내 파일의 절반
// 규약: 거래번호(20) 일시(14) 금액(15) 상태(2) = 51바이트
// ★ 한글이 섞이면 "글자 수"가 아니라 "바이트 수"입니다
public static TradeRow ParseFixed(ReadOnlySpan<byte> line, Encoding enc)
{
static string Cut(ReadOnlySpan<byte> s, int off, int len, Encoding e)
=> e.GetString(s.Slice(off, len)).TrimEnd();
return new TradeRow
{
TradeNo = Cut(line, 0, 20, enc),
TradedAt = DateTime.ParseExact(Cut(line, 20, 14, enc),
"yyyyMMddHHmmss", CultureInfo.InvariantCulture),
Amount = decimal.Parse(Cut(line, 34, 15, enc)) / 100m, // 소수점 없음
Status = Cut(line, 49, 2, enc)
};
}
string.Substring(0, 20) 은 글자 수로 자릅니다.
CP949 에서 한글 한 글자는 2바이트라, 이름 칸에 한글이 들어오면
그 뒤 칸이 전부 밀립니다. 파일을 byte[] 로 읽어 Span 으로 자르고,
그 다음에 디코딩하는 순서가 맞습니다.
자바에서 new String(bytes, off, len, "EUC-KR") 로 하던 것과 같습니다.
엑셀 · ArrayPool · 메모리 실측 🔴 고급
| 라이브러리 | 라이선스 | 대용량 | Java 대응 | 판단 |
|---|---|---|---|---|
| ClosedXML | MIT (무료) | △ 전체를 메모리에 (10만 행 = 1 GB+) | Apache POI (XSSF) | 읽기/쓰기 편의성 최고. 10만 행 이하 |
| EPPlus 8 | 상용 — 비상업만 무료 | △ 비슷 | POI | 사내라도 회사 업무면 유료. 법무 확인 필수 |
| OpenXmlWriter (DocumentFormat.OpenXml) | MIT | ◎ 스트리밍 쓰기 | POI SXSSF | 100만 행 내보내기는 이것뿐 |
| MiniExcel | Apache 2.0 | ◎ 읽기·쓰기 모두 스트리밍 | EasyExcel | 대용량이면 1순위 후보 |
| CsvHelper | MS-PL/Apache | ◎ | OpenCSV | 가능하면 엑셀 대신 CSV 로 규약을 바꾸세요 |
ArrayPool — GC 를 안 건드리고 버퍼 쓰기
// 400만 번 new byte[64*1024] 하면 Gen2 가 계속 커집니다
var pool = ArrayPool<byte>.Shared;
byte[] buf = pool.Rent(64 * 1024); // 빌리고
try
{
int n;
while ((n = await fs.ReadAsync(buf.AsMemory(), ct)) > 0)
Process(buf.AsSpan(0, n)); // ★ 빌린 길이보다 클 수 있으니 Slice
}
finally { pool.Return(buf); } // 반드시 반납 (clearArray 는 기본 false)
Rent(65536) 이 정확히 65,536바이트를 주지 않습니다.
더 큰 배열이 올 수 있으므로 항상 buf.Length 가 아니라 실제 읽은 길이로 다루세요.
여기서 나오는 버그는 파일 끝에 쓰레기 데이터가 붙는 형태로 나타납니다.
메모리를 실제로 재기
// 배치 안에 이걸 심어 두면 운영에서 그래프를 그릴 수 있습니다
var proc = Process.GetCurrentProcess();
log.LogInformation(
"메모리 관리힙 {Heap:N0} MB · 작업집합 {WS:N0} MB · Gen2 {G2}회",
GC.GetTotalMemory(false) / 1024 / 1024,
proc.WorkingSet64 / 1024 / 1024,
GC.CollectionCount(2));
# 밖에서 붙어서 보기 (설치: dotnet tool install -g dotnet-counters)
dotnet-counters monitor -p 12345 \
--counters System.Runtime[gc-heap-size,gen-2-gc-count,working-set]
# 힙 덤프 떠서 무엇이 살아 있는지
dotnet-gcdump collect -p 12345
ToListAsync() 를 하나 끼워 넣은 날 숫자가 바로 튑니다.
코드 리뷰보다 이 로그 한 줄이 잘 잡습니다.
파일 업로드 · 다운로드 — 1 GB 를 메모리에 안 올리고
스프링에서 MultipartFile 을 받으면 이미 디스크나 메모리에 다 올라온 뒤였습니다.
ASP.NET Core 도 기본은 같습니다. 그 기본을 끄고 직접 스트림을 받는 방법,
그리고 크기 제한이 걸려 있는 세 곳을 봅니다.
크기 제한이 걸려 있는 세 곳 🟢 기초
"500 MB 업로드했더니 413 이 떨어져요" — 원인이 셋 중 어디인지 모르면 반나절이 갑니다. 셋을 모두 풀어야 통과합니다.
| 층 | 설정 | 기본값 | 넘으면 | Java 대응 |
|---|---|---|---|---|
| ① Kestrel | KestrelServerLimits.MaxRequestBodySize | 30 MB | 413 Payload Too Large | 톰캣 maxSwallowSize |
| ② IIS / ANCM | web.config 의 maxAllowedContentLength | ~28.6 MB | 404.13 (IIS 가 먼저 끊음) | Nginx client_max_body_size |
| ③ 폼 바인딩 | FormOptions.MultipartBodyLengthLimit | 128 MB | InvalidDataException | spring.servlet.multipart.max-file-size |
| ④ 리버스 프록시 | Nginx client_max_body_size | 1 MB | 413 | 동일 |
셋 다 풀기
// ① Kestrel — null 이면 무제한 (프록시가 막는다면 이게 편합니다)
builder.WebHost.ConfigureKestrel(o =>
{
o.Limits.MaxRequestBodySize = 2L * 1024 * 1024 * 1024; // 2 GB
o.Limits.MinRequestBodyDataRate = new MinDataRate(
bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(30));
});
// ③ 폼 — 엔드포인트별로 거는 편이 안전합니다
builder.Services.Configure<FormOptions>(o =>
{
o.MultipartBodyLengthLimit = 2L * 1024 * 1024 * 1024;
o.ValueLengthLimit = int.MaxValue;
o.MultipartHeadersLengthLimit = int.MaxValue;
});
<!-- ② IIS 아래에서 돌 때 web.config -->
<configuration>
<system.webServer>
<security><requestFiltering>
<!-- 바이트 단위. 2 GB -->
<requestLimits maxAllowedContentLength="2147483648" />
</requestFiltering></security>
</system.webServer>
</configuration>
MinRequestBodyDataRate 를 잊지 마세요.
기본은 초당 240바이트 미만이면 끊습니다.
느린 사내망이나 VPN 에서 대용량을 올리면
"아무 이유 없이 중간에 끊긴다"가 여기서 납니다.
스트리밍 업로드 — 버퍼링을 끕니다
[HttpPost("upload")]
[DisableFormValueModelBinding] // ★ 자동 폼 파싱을 끕니다
[RequestSizeLimit(2L * 1024 * 1024 * 1024)]
public async Task<IActionResult> UploadAsync(CancellationToken ct)
{
if (!MultipartRequestHelper.IsMultipartContentType(Request.ContentType))
return BadRequest("multipart/form-data 가 아닙니다");
var boundary = MultipartRequestHelper.GetBoundary(
MediaTypeHeaderValue.Parse(Request.ContentType), lengthLimit: 128);
var reader = new MultipartReader(boundary, Request.Body);
var section = await reader.ReadNextSectionAsync(ct);
while (section is not null)
{
var cd = section.GetContentDispositionHeader();
if (cd is not null && cd.IsFileDisposition())
{
var name = Path.GetRandomFileName();
var tmp = Path.Combine(_tempDir, name + ".part");
// ★ 여기가 핵심 — 요청 스트림에서 파일 스트림으로 바로 흘립니다
await using (var fs = new FileStream(tmp, FileMode.Create,
FileAccess.Write, FileShare.None,
1024 * 1024, useAsync: true))
{
await section.Body.CopyToAsync(fs, 1024 * 1024, ct);
}
await _scanner.ScanAsync(tmp, ct); // 바이러스 검사 (아래)
await _store.CommitAsync(tmp, cd.FileName.Value!, ct);
}
section = await reader.ReadNextSectionAsync(ct);
}
return Ok();
}
IFormFile 을 파라미터로 받는 순간 버퍼링됩니다.
64 KB 를 넘으면 임시 디스크에 저장되므로 OOM 은 안 나지만,
디스크에 한 번 다 쓰고 다시 읽는 왕복이 생깁니다.
수십 MB 까지는 IFormFile 로 충분하고,
수백 MB 부터 MultipartReader 로 갑니다.
청크 업로드 · 이어받기(Range) 🟡 중급
// ── 서버: 청크 하나를 받아 지정 위치에 씁니다 ──────────────────
// PUT /uploads/{uploadId}/chunks/{index}
app.MapPut("/uploads/{id}/chunks/{index:int}", async (
string id, int index, HttpRequest req, IUploadStore store,
CancellationToken ct) =>
{
var meta = await store.GetAsync(id, ct);
if (meta is null) return Results.NotFound();
// 이미 받은 청크면 그냥 성공 — ★ 멱등성
if (meta.Received.Contains(index)) return Results.Ok(meta.Progress());
var path = store.ChunkPath(id, index);
await using (var fs = File.Create(path))
await req.Body.CopyToAsync(fs, 1024 * 1024, ct);
// 체크섬으로 무결성 확인 (클라이언트가 헤더로 보냅니다)
if (req.Headers.TryGetValue("X-Chunk-SHA256", out var want))
{
string got;
await using (var check = File.OpenRead(path)) // ★ 닫고 나서 지워야 합니다 (Windows 는 열린 파일 삭제 불가)
got = Convert.ToHexString(await SHA256.HashDataAsync(check, ct));
if (!got.Equals(want!, StringComparison.OrdinalIgnoreCase))
{ File.Delete(path); return Results.StatusCode(422); }
}
await store.MarkReceivedAsync(id, index, ct);
return Results.Ok(meta.Progress());
});
// 클라이언트가 재개 전에 물어보는 곳 — "어디까지 받았니?"
app.MapGet("/uploads/{id}", async (string id, IUploadStore store, CancellationToken ct)
=> await store.GetAsync(id, ct) is {} m
? Results.Ok(new { m.Total, Received = m.Received.Order(), m.Missing })
: Results.NotFound());
// 모두 받으면 합칩니다
app.MapPost("/uploads/{id}/complete", async (
string id, IUploadStore store, CancellationToken ct) =>
{
var meta = await store.GetAsync(id, ct);
if (meta!.Missing.Count > 0)
return Results.BadRequest(new { meta.Missing }); // 빠진 청크를 알려 줌
var final = store.FinalPath(id);
await using (var outFs = File.Create(final))
for (var i = 0; i < meta.Total; i++)
await using (var inFs = File.OpenRead(store.ChunkPath(id, i)))
await inFs.CopyToAsync(outFs, ct);
store.CleanupChunks(id);
return Results.Ok(new { path = final });
});
다운로드 — Range 를 그냥 켜기
app.MapGet("/files/{id}", async (string id, IFileStore store, CancellationToken ct) =>
{
var f = await store.OpenAsync(id, ct); // Stream 을 그대로
return Results.File(
f.Stream,
contentType: "application/octet-stream",
fileDownloadName: f.Name,
lastModified: f.ModifiedAt,
entityTag: new EntityTagHeaderValue($"\"{f.ETag}\""),
enableRangeProcessing: true); // ★ 이 한 줄이 이어받기
});
enableRangeProcessing: true 만 켜면
프레임워크가 Accept-Ranges · 206 Partial Content ·
Content-Range 를 전부 처리합니다.
스프링에서 ResourceHttpRequestHandler 가 해 주던 것과 같습니다.
직접 파싱하지 마세요 — 다중 범위 요청까지 정확히 구현하는 것은 까다롭습니다.
저장소 추상화 — 로컬 · S3 · Blob
public interface IFileStore
{
Task<string> PutAsync(Stream src, string key, CancellationToken ct);
Task<Stream> GetAsync(string key, CancellationToken ct);
Task<Uri> GetPresignedUrlAsync(string key, TimeSpan ttl, CancellationToken ct);
Task DeleteAsync(string key, CancellationToken ct);
}
// 구현 셋 — DI 로 갈아 끼웁니다
builder.Services.AddSingleton<IFileStore>(sp =>
cfg["Storage:Kind"] switch
{
"s3" => new S3FileStore(sp.GetRequiredService<IAmazonS3>(), bucket),
"blob" => new BlobFileStore(sp.GetRequiredService<BlobServiceClient>()),
_ => new LocalFileStore(cfg["Storage:Root"]!)
});
..\..\web.config 같은 이름이 옵니다(Path.GetFileName() 으로 정제).
② 저장 이름은 서버가 만든 GUID, 원래 이름은 DB 컬럼에.
③ 확장자가 아니라 매직 넘버로 형식을 확인.
④ 정적 파일로 서빙되는 폴더에 쓰지 않기.
⑤ 검사 전 파일은 .part 로 두고, 통과한 것만 rename (f08 의 원자적 이동).
⑥ 임시 폴더는 매일 청소하는 배치를 따로 두기 — 실패한 업로드가 디스크를 채웁니다.
// 바이러스 검사 — 사내는 대개 ClamAV 나 백신 에이전트 폴더 감시입니다
public sealed class ClamAvScanner(IOptions<ScanOptions> opt) : IFileScanner
{
public async Task ScanAsync(string path, CancellationToken ct)
{
var clam = new ClamClient(opt.Value.Host, opt.Value.Port); // IDisposable 아님 — 요청마다 소켓을 엽니다
await using var fs = File.OpenRead(path);
var r = await clam.SendAndScanFileAsync(fs, ct);
if (r.Result == ClamScanResults.VirusDetected)
{
File.Delete(path);
throw new SecurityException($"악성코드 탐지: {r.InfectedFiles![0].VirusName}");
}
if (r.Result != ClamScanResults.Clean) // Error · Unknown 모두
throw new IOException($"검사 실패 — 파일을 통과시키지 않습니다: {r.RawResult}");
// ★ "검사 실패"를 통과로 처리하지 마세요. 열려 있는 문이 됩니다
}
}
MultipartReader 로 직접 흘리고, 이어받기는 청크 + 받은 목록으로 만듭니다.파일 연계 · EAI — SFTP · 감시 · 원자적 이동
사내 시스템 사이의 데이터 교환은 2026년에도 파일이 가장 많습니다. API 보다 느리지만 망 분리를 넘고, 감사 흔적이 남고, 재전송이 쉽기 때문입니다. 여기서 사고가 나는 지점은 언제나 "덜 써진 파일을 읽었다" 하나입니다.
SFTP — SSH.NET 으로 받고 보내기 🟢 기초
수신 — 호스트 키까지 검증
dotnet add package SSH.NET
var auth = new PrivateKeyAuthenticationMethod(
user, new PrivateKeyFile(keyPath, passphrase));
var cinfo = new ConnectionInfo(host, port, user, auth)
{
Timeout = TimeSpan.FromMinutes(5)
};
using var sftp = new SftpClient(cinfo);
// ★ 호스트 키 검증 — 이걸 안 하면 중간자 공격에 그대로 노출됩니다
sftp.HostKeyReceived += (_, e) =>
e.CanTrust = Convert.ToHexString(e.FingerPrint)
.Equals(expectedFingerprint, StringComparison.OrdinalIgnoreCase);
sftp.Connect();
sftp.OperationTimeout = TimeSpan.FromMinutes(30);
sftp.BufferSize = 64 * 1024; // 기본 32KB — 올리면 눈에 띄게 빨라집니다
foreach (var f in sftp.ListDirectory(remoteDir))
{
if (f.IsDirectory || !f.Name.EndsWith(".dat")) continue;
var tmp = Path.Combine(localDir, f.Name + ".downloading");
await using (var fs = File.Create(tmp))
await Task.Factory.FromAsync(
sftp.BeginDownloadFile(f.FullName, fs), sftp.EndDownloadFile);
// ★ 크기 검증 후 정식 이름으로 — 여기도 원자적 이동
if (new FileInfo(tmp).Length != f.Length)
{ File.Delete(tmp); throw new IOException($"크기 불일치 {f.Name}"); }
File.Move(tmp, Path.Combine(localDir, f.Name), overwrite: false);
sftp.RenameFile(f.FullName, $"{remoteDone}/{f.Name}"); // 원격도 정리
}
FTP vs SFTP vs FTPS — 고르기
| 포트 | 암호화 | 방화벽 | .NET 지원 | |
|---|---|---|---|---|
| SFTP (SSH 위) | 22 | ◎ | 단일 포트 — 쉬움 | SSH.NET (외부) |
| FTPS (FTP+TLS) | 21 + 데이터 | ◎ | △ 패시브 포트 범위 필요 | FluentFTP (외부) |
| FTP | 21 + 데이터 | ✕ 평문 | △ | FtpWebRequest (사용 중단) |
| 공유 폴더 (SMB) | 445 | △ | 사내망 전용 | 그냥 파일 API |
\\\\nas\\if\\in)가 여전히 가장 많습니다.
이 경우 코드는 그냥 File.Move 이고 별도 라이브러리가 없습니다.
다만 서비스 계정에 그 공유의 권한이 있어야 합니다 —
개발 PC 에서는 되는데 Windows 서비스로 띄우면
UnauthorizedAccessException 이 나는 사고의 원인입니다.
FtpWebRequest 는 .NET 6부터 공식 사용 중단(obsolete)입니다.
새로 쓰지 마세요. FTPS 가 필요하면 FluentFTP, SFTP 는 SSH.NET 입니다.
FileSystemWatcher 의 함정과 폴링 대안 🟡 중급
FileSystemWatcher 하나만 믿는 연계는 반드시 사고가 납니다.
① 이벤트를 놓칩니다 — 내부 버퍼(기본 8 KB)가 넘치면 Error 이벤트만 오고 그 사이 파일들은 사라집니다.
② 중복으로 옵니다 — 한 파일에 Created 와 Changed 가 여러 번.
③ 다 써지기 전에 옵니다 — 파일을 만드는 순간 알려 주지, 다 쓴 순간이 아닙니다.
④ 네트워크 드라이브에서 불안정합니다 — SMB 알림은 유실이 흔합니다.
⑤ 프로세스가 잠깐 죽어 있던 사이의 파일은 영원히 모릅니다.
❌ Watcher 만
var w = new FileSystemWatcher(dir, "*.dat");
w.Created += async (_, e) => await Process(e.FullPath);
w.EnableRaisingEvents = true;
// 놓친 파일은 아무도 모릅니다
✅ 폴링이 본체, Watcher 는 촉진제
// 30초마다 폴더를 훑는 것이 "정답"입니다.
// Watcher 는 "지금 바로 한 번 더 훑어라" 신호로만 씁니다
var wake = new SemaphoreSlim(0, 1);
w.Created += (_, _) => { try { wake.Release(); } catch { } };
while (!ct.IsCancellationRequested)
{
await ScanFolderAsync(ct); // ★ 이게 진짜 감지
await wake.WaitAsync(TimeSpan.FromSeconds(30), ct);
}
// 폴더 스캔 — "다 써졌는가"를 판정하는 두 가지 방법
async Task ScanFolderAsync(CancellationToken ct)
{
foreach (var path in Directory.EnumerateFiles(_in, "*.dat"))
{
// ① 배타 열기 시도 — 송신측이 아직 쓰고 있으면 실패합니다 (Windows)
if (!TryOpenExclusive(path, out var fs)) continue;
fs!.Dispose();
// ② 크기가 두 번 연속 같은지 (리눅스·NFS 에서는 이 방법이 필요)
var len1 = new FileInfo(path).Length;
await Task.Delay(1000, ct);
if (new FileInfo(path).Length != len1) continue; // 아직 자라는 중
// ③ 내가 맡는다 — work 폴더로 이동. 실패하면 남이 가져간 것
var work = Path.Combine(_work, Path.GetFileName(path));
try { File.Move(path, work, overwrite: false); }
catch (IOException) { continue; } // 정상 — 경쟁에서 짐
await HandleAsync(work, ct);
}
}
static bool TryOpenExclusive(string path, out FileStream? fs)
{
try { fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.None); return true; }
catch (IOException) { fs = null; return false; } // 잠겨 있음
}
TRD_20260828.dat 를 다 쓰면 TRD_20260828.ok 를 만든다) —
송신측을 못 고칠 때 차선. .ok 를 보고 .dat 를 읽습니다.
Spring Integration 의 FileReadingMessageSource 에서도
AcceptOnceFileListFilter + LastModifiedFileListFilter 로
같은 문제를 다룹니다 — 프레임워크가 달라도 문제는 동일합니다.
인터페이스 파일 규약 설계 🔴 고급
연계 규약서에 아래 항목이 다 적혀 있지 않으면 반드시 나중에 다툽니다. "우리는 다 보냈는데요"와 "우리는 못 받았는데요"가 반복되는 이유는 완료의 정의가 문서에 없기 때문입니다.
| 항목 | 정해야 할 것 | 예시 |
|---|---|---|
| 파일명 규칙 | 시스템코드 · 업무코드 · 영업일 · 일련번호 | ERP_TRD_20260828_001.dat |
| 인코딩 · 개행 | 못 박기. "자동 판별"은 규약이 아님 | CP949 · CRLF · BOM 없음 |
| 레코드 구조 | 고정폭이면 바이트 오프셋표, 구분자면 구분자와 이스케이프 | 파이프 | · 값 안 파이프 금지 |
| 헤더 레코드 | 파일 유형 · 생성일시 · 송신 시스템 | H|ERP|20260828021000 |
| 트레일러 레코드 | 총 건수 · 금액 합계 — 검증의 핵심 | T|4000000|123456789.00 |
| 완료 판정 | 임시 확장자 rename 또는 .ok 파일 | .tmp → .dat |
| 전송 시각 · SLA | 몇 시까지 · 늦으면 누구에게 연락 | 매일 01:30 까지 · 02:00 미착시 알림 |
| 재전송 규약 | 같은 이름 재전송인가, 일련번호 증가인가 | 일련번호 +1 · 이전 건은 무효 처리 |
| 중복 판정 키 | 수신측이 무엇으로 중복을 거르는가 | 파일명 + SHA-256 |
| 보관 기간 | 원본 · 처리 결과 각각 | 원본 90일 · 결과 5년 |
// 트레일러 검증 — 이 30줄이 "데이터가 잘렸다"를 100% 잡습니다
public static async Task<FileStats> VerifyAsync(
string path, Encoding enc, CancellationToken ct)
{
string? header = null, trailer = null;
long body = 0; decimal sum = 0m;
using var sr = new StreamReader(path, enc);
while (await sr.ReadLineAsync(ct) is { } line)
{
if (line.Length == 0) continue;
switch (line[0])
{
case 'H': header = line; break;
case 'T': trailer = line; break;
default:
body++;
sum += decimal.Parse(line.Split('|')[3],
CultureInfo.InvariantCulture);
break;
}
}
if (header is null) throw new BatchAbortException("헤더 레코드 없음 — 파일이 잘렸습니다");
if (trailer is null) throw new BatchAbortException("트레일러 없음 — 전송이 완료되지 않았습니다");
var t = trailer.Split('|');
var declaredCount = long.Parse(t[1]);
var declaredSum = decimal.Parse(t[2], CultureInfo.InvariantCulture);
if (declaredCount != body)
throw new BatchAbortException($"건수 불일치 — 선언 {declaredCount:N0} / 실제 {body:N0}");
if (declaredSum != sum)
throw new BatchAbortException($"합계 불일치 — 선언 {declaredSum:N2} / 실제 {sum:N2}");
return new FileStats(body, sum);
}
(FileName, Sha256, ReceivedAt, Status) 를 남기면
"이 파일 처리했나요?"라는 문의에 1초 만에 답할 수 있습니다.
운영 이관 후 가장 자주 오는 문의가 정확히 그것입니다.
HTTP 클라이언트 — 외부 API 를 수만 번 부를 때
배치는 API 를 한 번 부르지 않습니다. 4만 번 부릅니다.
그때 드러나는 것이 소켓 고갈 · DNS 캐시 · 상대방 레이트 리밋 입니다.
new HttpClient() 한 줄이 왜 운영 장애가 되는지부터 봅니다.
①
using var http = new HttpClient() 를 요청마다 —
소켓 고갈. 닫은 커넥션이 TIME_WAIT 로 240초 남아
수천 번 호출하면 SocketException: 사용 중인 포트 가 납니다.②
static readonly HttpClient 싱글턴 —
소켓 고갈은 없지만 DNS 를 영원히 캐시합니다.
상대 시스템이 IP 를 바꾸면 앱을 재시작할 때까지 옛 IP 로 붙습니다.
사내 DR 전환·로드밸런서 교체 때 실제로 겪는 사고입니다.정답은
IHttpClientFactory — 핸들러를 풀링하되 주기적으로 갈아 줍니다.
등록과 기본 설정 🟢 기초
builder.Services
.AddHttpClient("bank", c =>
{
c.BaseAddress = new Uri("https://api.bank.example/");
c.Timeout = TimeSpan.FromSeconds(30); // ★ 전체 시도의 총합 시간
c.DefaultRequestHeaders.Add("X-System-Id", "SETTLE-BATCH");
c.DefaultRequestVersion = HttpVersion.Version20;
})
.SetHandlerLifetime(TimeSpan.FromMinutes(2)) // 핸들러 회전 주기
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
// 커넥션당 최대 수명 — 로드밸런서 뒤에서 분산이 쏠리는 것을 막습니다
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
MaxConnectionsPerServer = 32, // ★ 배치는 이걸 올립니다
AutomaticDecompression = DecompressionMethods.All,
EnableMultipleHttp2Connections = true
});
// 쓸 때
var http = factory.CreateClient("bank"); // 매번 새로 만들어도 됩니다
Refit — 인터페이스로 선언 (FeignClient 대응)
public interface IBankApi
{
[Get("/v1/settlements/{date}")]
Task<SettlementDto> GetAsync(string date, CancellationToken ct);
[Post("/v1/settlements")]
Task<ApiResponse<ResultDto>> SubmitAsync(
[Body] SubmitDto body, CancellationToken ct);
[Get("/v1/trades/export")]
Task<HttpResponseMessage> ExportAsync( // 대용량은 이 형태로 받습니다
[Query] string date, CancellationToken ct);
}
builder.Services
.AddRefitClient<IBankApi>()
.ConfigureHttpClient(c => c.BaseAddress = new Uri(baseUrl))
.AddStandardResilienceHandler(); // ★ .NET 8+ 표준 회복력 파이프라인
회복력 — 배치용으로 조정하기
.AddResilienceHandler("batch", b =>
{
// ① 전체 시도의 총 시간 — 이게 없으면 무한정 매답니다
b.AddTimeout(TimeSpan.FromMinutes(2));
// ② 재시도 — 배치는 웹보다 넉넉하게 잡아도 됩니다
b.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 5,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true, // ★ 동시 재시도 쏠림 방지
Delay = TimeSpan.FromSeconds(2),
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Result?.StatusCode is
HttpStatusCode.TooManyRequests or // 429
HttpStatusCode.ServiceUnavailable or // 503
HttpStatusCode.GatewayTimeout // 504
|| args.Outcome.Exception is HttpRequestException
or TimeoutRejectedException) // ④ 1회 타임아웃도 재시도
});
// ③ 서킷 브레이커 — 상대가 죽었는데 4만 번 두드리지 않게
b.AddCircuitBreaker(new HttpCircuitBreakerStrategyOptions
{
FailureRatio = 0.5,
SamplingDuration = TimeSpan.FromSeconds(30),
MinimumThroughput = 20,
BreakDuration = TimeSpan.FromSeconds(60)
});
// ④ 시도 1회의 시간
b.AddTimeout(TimeSpan.FromSeconds(20));
});
HttpClient.Timeout 과 겹칩니다 —
회복력 파이프라인을 쓰면 HttpClient.Timeout 은
Timeout.InfiniteTimeSpan 으로 두는 편이 헷갈리지 않습니다.
대용량 응답을 스트리밍으로 받기 🟡 중급
❌ 800 MB 를 문자열로
var json = await http.GetStringAsync(url, ct);
var list = JsonSerializer
.Deserialize<List<TradeDto>>(json);
// 문자열 1.6 GB(UTF-16) + 객체 800 MB
// = LOH 폭발 → OutOfMemoryException
✅ 헤더만 받고 흘려보내기
using var res = await http.GetAsync(url,
HttpCompletionOption.ResponseHeadersRead, ct); // ★
res.EnsureSuccessStatusCode();
await using var s = await res.Content
.ReadAsStreamAsync(ct);
// ① 파일로 바로 흘리기
await using var fs = File.Create(path);
await s.CopyToAsync(fs, 1024 * 1024, ct);
// ② 또는 JSON 배열을 한 건씩
await foreach (var dto in JsonSerializer
.DeserializeAsyncEnumerable<TradeDto>(s, cancellationToken: ct)) // 두 번째 인자는 옵션이라 이름을 붙입니다
{ ... }
HttpCompletionOption.ResponseHeadersRead 는
"본문을 다 받기 전에 응답 객체를 돌려 달라"는 뜻입니다.
기본값(ResponseContentRead)은 본문 전체가 메모리에 올 때까지 await 가 안 끝납니다.
자바의 HttpResponse.BodyHandlers.ofInputStream() 과 같은 역할입니다.
다만 반드시 using 으로 응답을 폐기해야 합니다 —
안 그러면 커넥션이 풀로 안 돌아옵니다.
동시성 · 레이트 리밋 · 인증서 🔴 고급
// ── 상대방이 "초당 20건" 이라고 했을 때 ──────────────────────
// .NET 7+ 의 표준 레이트 리미터를 클라이언트 쪽에 겁니다
var limiter = new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions
{
TokenLimit = 20,
TokensPerPeriod = 20,
ReplenishmentPeriod = TimeSpan.FromSeconds(1),
QueueLimit = 1000,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
AutoReplenishment = true
});
await Parallel.ForEachAsync(ids,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = ct },
async (id, token) =>
{
using var lease = await limiter.AcquireAsync(1, token);
if (!lease.IsAcquired) throw new BatchAbortException("레이트 리밋 큐 초과");
var dto = await api.GetAsync(id, token);
await sink.WriteAsync(dto, token);
});
| 증상 | 진짜 원인 | 확인 | 해결 |
|---|---|---|---|
SocketException 포트 부족 | new HttpClient() 반복 | netstat -an | find "TIME_WAIT" | IHttpClientFactory 로 |
| 배포했는데 옛 서버로 감 | 싱글턴 HttpClient 의 DNS 캐시 | nslookup 결과와 실제 접속 IP 비교 | PooledConnectionLifetime 설정 |
| 동시 호출이 안 늘어남 | MaxConnectionsPerServer 기본값 | 동시 진행 수를 세어 보기 | 32~64 로 올림 |
TaskCanceledException | 대개 타임아웃(취소가 아님) | ex.InnerException is TimeoutException | 타임아웃 조정 · 스트리밍 |
| 사내 API 만 SSL 오류 | 사내 루트 CA 미신뢰 | openssl s_client -connect | OS 신뢰 저장소에 CA 등록 |
| 프록시 뒤에서 407 | 프록시 인증 | HTTP_PROXY 환경 변수 확인 | WebProxy + 자격 증명 명시 |
// mTLS — 대외 기관 연계에서 자주 요구합니다
.ConfigurePrimaryHttpMessageHandler(() =>
{
var h = new SocketsHttpHandler();
h.SslOptions.ClientCertificates = new X509Certificate2Collection(
X509CertificateLoader.LoadPkcs12FromFile(pfxPath, pfxPassword));
// ★ 인증서 검증을 통째로 끄지 마세요. 특정 지문만 허용합니다
h.SslOptions.RemoteCertificateValidationCallback = (_, cert, _, errors) =>
errors == SslPolicyErrors.None ||
(cert is X509Certificate2 c2 &&
c2.Thumbprint.Equals(pinnedThumbprint, StringComparison.OrdinalIgnoreCase));
return h;
});
// 사내 프록시
h.Proxy = new WebProxy("http://proxy.corp:8080")
{ Credentials = CredentialCache.DefaultNetworkCredentials };
h.UseProxy = true;
RemoteCertificateValidationCallback = (_,_,_,_) => true 는 금지입니다.
개발 중에 넣었다가 그대로 운영에 나가는 것이 보안 점검에서 가장 많이 지적되는 항목입니다.
사내 CA 를 OS 신뢰 저장소에 등록하는 것이 정답이고,
그게 정말 불가능할 때만 지문 고정(pinning) 으로 좁혀서 허용하세요.
#if DEBUG 로 감싸는 것도 한 방법입니다.
IHttpClientFactory + 회복력 파이프라인이 기본이고, 대용량 응답은 ResponseHeadersRead 로 흘리며, 상대의 레이트 리밋은 내 쪽에서 먼저 지킵니다.gRPC · 메시징 — 아웃박스와 멱등 소비
서버끼리 대량으로 주고받을 때 REST 는 낭비입니다. gRPC 스트리밍으로 밀어 넣고, 비동기 연계는 메시지 브로커로 끊습니다. 그리고 반드시 만나는 질문 — "DB 커밋과 메시지 발행 중 하나만 성공하면?"
아웃박스 패턴 — 왜 필요한가 🟡 중급
주문을 DB 에 저장하고 Kafka 에 이벤트를 발행합니다. 둘은 다른 시스템이라 하나의 트랜잭션으로 묶을 수 없습니다(분산 트랜잭션 · XA 는 사실상 죽었습니다). 그래서 둘 중 하나만 성공하는 창이 반드시 존재합니다.
| 순서 | 중간에 죽으면 | 결과 | 판정 |
|---|---|---|---|
| DB 커밋 → 발행 | 커밋 직후 | 주문은 있는데 이벤트 없음 | 배송이 안 나감 — 치명적 |
| 발행 → DB 커밋 | 발행 직후 | 이벤트는 갔는데 주문 없음 | 없는 주문을 배송 — 더 치명적 |
| DB 커밋(주문+아웃박스) → 릴레이가 발행 | 어디서 죽어도 | 아웃박스 행이 남아 다음에 발행 | 안전 — 발행이 늦어질 뿐 |
아웃박스 테이블과 저장
CREATE TABLE OutboxMessage (
Id UNIQUEIDENTIFIER PRIMARY KEY, -- = MessageId
OccurredAt DATETIME2(3) NOT NULL,
AggregateId VARCHAR(60) NOT NULL, -- 파티션 키
Type VARCHAR(120) NOT NULL,
Payload NVARCHAR(MAX) NOT NULL,
PublishedAt DATETIME2(3) NULL, -- NULL = 아직 미발행
Attempts INT NOT NULL DEFAULT 0,
LastError NVARCHAR(1000) NULL
);
-- 미발행 행만 훑는 필터 인덱스 — 테이블이 커져도 폴링이 안 느려집니다
CREATE INDEX IX_Outbox_Pending ON OutboxMessage(OccurredAt)
WHERE PublishedAt IS NULL;
// EF Core 의 SaveChanges 하나에 둘 다 들어갑니다
db.Orders.Add(order);
db.OutboxMessages.Add(new OutboxMessage
{
Id = Guid.CreateVersion7(), // ★ 시간 순서가 있는 GUID (.NET 9+)
OccurredAt = DateTime.UtcNow,
AggregateId = order.OrderNo,
Type = nameof(OrderPlaced),
Payload = JsonSerializer.Serialize(new OrderPlaced(order))
});
await db.SaveChangesAsync(ct); // ★ 하나의 트랜잭션
릴레이 — 미발행 행을 밀어냅니다
while (await timer.WaitForNextTickAsync(ct))
{
using var scope = scopes.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var bus = scope.ServiceProvider.GetRequiredService<IPublishEndpoint>();
// ★ UPDLOCK 은 트랜잭션이 끝날 때까지만 유지됩니다 — 트랜잭션 없이 돌리면 SELECT 직후 풀립니다
await using var tx = await db.Database.BeginTransactionAsync(ct);
// ★ 여러 릴레이가 같은 행을 집지 않도록 잠급니다 (SQL Server 힌트)
var batch = await db.OutboxMessages
.FromSqlRaw("""
SELECT TOP (200) * FROM OutboxMessage WITH (UPDLOCK, READPAST)
WHERE PublishedAt IS NULL ORDER BY OccurredAt
""")
.ToListAsync(ct);
foreach (var m in batch)
{
try
{
await bus.Publish(Deserialize(m), c =>
{
c.MessageId = m.Id; // ★ 소비자 멱등의 근거
c.SetRoutingKey(m.AggregateId);
}, ct);
m.PublishedAt = DateTime.UtcNow;
}
catch (Exception ex)
{
m.Attempts++;
m.LastError = ex.Message[..Math.Min(900, ex.Message.Length)];
// 발행 실패는 그대로 둡니다 — 다음 바퀴에 다시 시도
}
}
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct); // 여기서 잠금이 풀립니다
}
READPAST 가 핵심입니다 —
다른 릴레이가 잠근 행을 기다리지 않고 건너뜁니다.
이게 없으면 릴레이 2대가 서로를 기다리며 처리량이 1대만도 못해집니다.
PostgreSQL 이면 FOR UPDATE SKIP LOCKED 입니다.
멱등 소비 · DLQ 재처리 🔴 고급
// MassTransit — @KafkaListener 에 해당합니다
public sealed class OrderPlacedConsumer(
AppDbContext db, IShippingService ship,
ILogger<OrderPlacedConsumer> log) : IConsumer<OrderPlaced>
{
public async Task Consume(ConsumeContext<OrderPlaced> ctx)
{
var mid = ctx.MessageId!.Value;
// ★ 멱등 — 처리 기록과 실제 처리를 한 트랜잭션으로
await using var tx = await db.Database.BeginTransactionAsync(ctx.CancellationToken);
var already = await db.ProcessedMessages
.AnyAsync(p => p.MessageId == mid, ctx.CancellationToken);
if (already)
{
log.LogInformation("이미 처리한 메시지 {Id} — 건너뜁니다", mid);
return; // ACK 는 정상적으로
}
await ship.CreateAsync(ctx.Message, ctx.CancellationToken);
db.ProcessedMessages.Add(new ProcessedMessage(mid, DateTime.UtcNow));
await db.SaveChangesAsync(ctx.CancellationToken);
await tx.CommitAsync(ctx.CancellationToken);
}
}
// 등록 — 재시도와 DLQ 정책
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<OrderPlacedConsumer>();
x.UsingRabbitMq((ctx, cfg) =>
{
cfg.Host(rabbitUri, h => { h.Username(user); h.Password(pass); });
cfg.ReceiveEndpoint("order-placed", e =>
{
e.PrefetchCount = 32;
e.ConcurrentMessageLimit = 8;
// 즉시 재시도 → 지연 재시도 → 그래도 안 되면 _error 큐로
e.UseMessageRetry(r => r.Exponential(
retryLimit: 5,
minInterval: TimeSpan.FromSeconds(1),
maxInterval: TimeSpan.FromMinutes(5),
intervalDelta: TimeSpan.FromSeconds(3)));
e.ConfigureConsumer<OrderPlacedConsumer>(ctx);
});
});
});
| 주제 | Spring / Java | .NET | 차이 |
|---|---|---|---|
| 소비자 선언 | @KafkaListener · @RabbitListener | IConsumer<T> + AddConsumer | 거의 동일. 타입으로 라우팅 |
| 추상화 계층 | Spring Cloud Stream | MassTransit · Wolverine | MassTransit 이 사실상 표준 |
| DLQ | DeadLetterPublishingRecoverer | _error 큐 자동 생성 | .NET 이 기본 제공 |
| 재시도 | RetryTemplate | UseMessageRetry | 동일 개념 |
| 아웃박스 | 직접 or Debezium CDC | AddEntityFrameworkOutbox 내장 | MassTransit 이 릴레이까지 제공 |
| 스케줄 메시지 | Quartz 연동 | ScheduleSend (Delayed Exchange) | 동일 |
x.AddEntityFrameworkOutbox<AppDbContext>(o => { o.UseSqlServer(); o.UseBusOutbox(); })
한 줄이면 위의 릴레이 코드를 직접 안 짜도 됩니다.
직접 만드는 코드를 먼저 보인 이유는, 라이브러리가 내부에서 무엇을 하는지 알아야
"발행이 3분 늦게 되는데요" 같은 문의에 답할 수 있기 때문입니다.
메시지 큐 자체의 개념(순서·파티션·컨슈머 그룹)은 🔷 k06 에서 다룹니다.
gRPC 스트리밍 — 서버 간 대량 전송 🟡 중급
// trade.proto — 클라이언트 스트리밍: 수백만 건을 한 커넥션으로 밀어 넣습니다
service TradeIngest {
rpc PushTrades (stream TradeMessage) returns (IngestResult);
rpc ExportTrades (ExportRequest) returns (stream TradeMessage);
}
// 서버 — 흘러오는 것을 청크로 모아 벌크 적재
public override async Task<IngestResult> PushTrades(
IAsyncStreamReader<TradeMessage> req, ServerCallContext ctx)
{
var buf = new List<Trade>(10_000);
long total = 0;
await foreach (var m in req.ReadAllAsync(ctx.CancellationToken))
{
buf.Add(Map(m));
if (buf.Count < 10_000) continue;
await _bulk.CopyAsync(buf, ctx.CancellationToken);
total += buf.Count;
buf.Clear(); // ★ 여기선 안전 — 위에서 await 가 끝났습니다
}
if (buf.Count > 0) { await _bulk.CopyAsync(buf, ctx.CancellationToken); total += buf.Count; }
return new IngestResult { Accepted = total };
}
// 클라이언트
using var call = client.PushTrades(cancellationToken: ct);
await foreach (var row in ReadCsvAsync(path).WithCancellation(ct))
await call.RequestStream.WriteAsync(Map(row), ct);
await call.RequestStream.CompleteAsync();
var res = await call.ResponseAsync;
MaxReceiveMessageSize 를 설정하되,
메모리에 그만큼 올라온다는 뜻임을 기억하세요.
gRPC 의 기본기(proto · 코드 생성 · 인증)는 🔷 w13 에서 다뤘습니다.
병렬 파이프라인 — Channels 와 백프레셔
읽기는 디스크, 변환은 CPU, 쓰기는 DB. 셋을 순서대로 하면
항상 둘은 놀고 하나만 일합니다.
System.Threading.Channels 로 셋을 동시에 돌리고,
빨라진 생산자를 일부러 막아 메모리를 지킵니다.
Channel 기본 — BlockingQueue 의 async 판 🟢 기초
만들기 — Bounded 가 기본입니다
var ch = Channel.CreateBounded<TradeRow>(
new BoundedChannelOptions(capacity: 1_000)
{
// 가득 차면 어떻게 할지 — 이게 백프레셔의 정체
FullMode = BoundedChannelFullMode.Wait, // ★ 생산자를 기다리게
SingleWriter = true, // 생산자 1개 → 내부 락 생략
SingleReader = false, // 소비자 여럿
AllowSynchronousContinuations = false
});
// Unbounded 는 "메모리가 무한하다"는 가정입니다 — 배치에서는 쓰지 마세요
// var bad = Channel.CreateUnbounded<TradeRow>();
| FullMode | 동작 | 쓸 때 |
|---|---|---|
Wait | 생산자가 대기 | 배치의 기본값 — 데이터를 잃으면 안 됨 |
DropOldest | 가장 오래된 것 버림 | 최신 상태만 중요한 모니터링 |
DropWrite | 새 것을 버림 | 부하 시 샘플링 로그 |
DropNewest | 가장 최근 것 버림 | 드묾 |
생산자 · 소비자
// 생산자 — 파일을 읽어 채널에 밀어 넣습니다
var producer = Task.Run(async () =>
{
try
{
await foreach (var row in ReadCsvAsync(path).WithCancellation(ct))
await ch.Writer.WriteAsync(row, ct); // ★ 가득 차면 여기서 대기
}
finally
{
ch.Writer.Complete(); // ★ 반드시 닫습니다
}
}, ct);
// 소비자 N개 — 같은 채널에서 나눠 가져갑니다
var consumers = Enumerable.Range(0, 4).Select(_ => Task.Run(async () =>
{
var buf = new List<Trade>(1_000);
await foreach (var row in ch.Reader.ReadAllAsync(ct))
{
buf.Add(Transform(row));
if (buf.Count < 1_000) continue;
await bulk.CopyAsync(buf, ct);
buf = new List<Trade>(1_000);
}
if (buf.Count > 0) await bulk.CopyAsync(buf, ct);
}, ct)).ToArray();
await producer;
await Task.WhenAll(consumers);
Writer.Complete() 를 finally 에 두지 않으면
생산자가 예외로 죽었을 때 소비자들이 영원히 기다립니다.
배치가 "끝나지도 실패하지도 않고 멈춰 있는" 상태의 1순위 원인입니다.
예외를 전파하려면 ch.Writer.Complete(ex) 로 예외를 담아 닫으세요 —
소비자 쪽 ReadAllAsync 가 그 예외를 다시 던집니다.
3단 파이프라인 — 읽기 · 변환 · 쓰기를 동시에 🟡 중급
public async Task RunPipelineAsync(string path, CancellationToken ct)
{
// 채널 두 개로 3단을 잇습니다
var raw = Channel.CreateBounded<TradeRow>(new BoundedChannelOptions(2_000)
{ FullMode = BoundedChannelFullMode.Wait, SingleWriter = true });
var done = Channel.CreateBounded<Trade>(new BoundedChannelOptions(2_000)
{ FullMode = BoundedChannelFullMode.Wait });
// ── ① 읽기 (IO 바운드) — 1개면 충분합니다. 디스크는 하나니까요
var read = Task.Run(async () =>
{
try
{
await foreach (var r in ReadCsvAsync(path).WithCancellation(ct))
await raw.Writer.WriteAsync(r, ct);
}
catch (Exception ex) { raw.Writer.Complete(ex); throw; }
raw.Writer.Complete();
}, ct);
// ── ② 변환 (CPU 바운드) — 코어 수만큼
var workers = Environment.ProcessorCount;
var xform = Enumerable.Range(0, workers).Select(_ => Task.Run(async () =>
{
await foreach (var r in raw.Reader.ReadAllAsync(ct))
{
if (Validate(r) is { } why) { await skips.WriteAsync((r, why), ct); continue; }
await done.Writer.WriteAsync(Map(r), ct);
}
}, ct)).ToArray();
// 변환기가 전부 끝나야 done 을 닫을 수 있습니다
// ★ 변환기가 예외로 죽어도 done 을 닫아야 ③ 이 영원히 기다리지 않습니다
var closeDone = Task.Run(async () =>
{
try { await Task.WhenAll(xform); done.Writer.Complete(); }
catch (Exception ex) { done.Writer.Complete(ex); throw; }
});
// ── ③ 쓰기 (IO 바운드) — DB 커넥션 수에 맞춥니다
var write = Task.Run(async () =>
{
var buf = new List<Trade>(5_000);
await foreach (var t in done.Reader.ReadAllAsync(ct))
{
buf.Add(t);
if (buf.Count < 5_000) continue;
await bulk.CopyAsync(buf, ct);
buf = new List<Trade>(5_000);
}
if (buf.Count > 0) await bulk.CopyAsync(buf, ct);
}, ct);
await Task.WhenAll(read, closeDone, write); // 변환기 예외도 여기서 올라옵니다
}
| 단계 | 성격 | 병렬도 기준 | 늘려도 안 빨라지는 이유 |
|---|---|---|---|
| 파일 읽기 | IO (순차) | 1 | 디스크 헤드가 하나. 여럿이 읽으면 느려집니다(랜덤 액세스) |
| 파싱 · 검증 | CPU | ProcessorCount | 코어보다 많으면 컨텍스트 스위칭 비용만 증가 |
| 외부 API 호출 | IO (대기) | 8~32 (상대 허용치) | 상대가 레이트 리밋을 걸면 429 만 늘어남 |
| DB 벌크 적재 | IO + 서버 CPU | 2~4 | 같은 테이블에 여럿이 쓰면 락 경합이 이득을 먹음 |
| DB 커넥션 풀 | — | 기본 100 | 병렬도 > 풀 크기면 풀 고갈 타임아웃 |
Parallel.ForEachAsync · IAsyncEnumerable 🔴 고급
간단한 경우는 이걸로 충분합니다
// 채널을 만들 것도 없는 "N개를 병렬로" 는 이 한 줄
await Parallel.ForEachAsync(
source: ReadIdsAsync(ct), // IAsyncEnumerable 도 받습니다
parallelOptions: new ParallelOptions
{
MaxDegreeOfParallelism = 8,
CancellationToken = ct
},
body: async (id, token) =>
{
using var scope = scopes.CreateScope(); // ★ 각 작업이 자기 스코프
var svc = scope.ServiceProvider.GetRequiredService<IItemService>();
await svc.HandleAsync(id, token);
});
Parallel.For(비동기 아님) 안에서 async 람다를 쓰지 마세요.
Parallel.ForEach(items, async x => await F(x)) 는
async void 가 되어 즉시 반환하고,
예외가 사라지고 실제로는 병렬도 제어도 안 됩니다.
비동기가 섞이면 반드시 Parallel.ForEachAsync 입니다.
Channel vs Parallel — 언제 무엇을
| Channel | Parallel.ForEachAsync | |
|---|---|---|
| 단계 수 | 여러 단계를 잇기 | 한 단계 |
| 백프레셔 | 있음(Bounded) | 병렬도로 간접 제어 |
| 입력 | 스트리밍 · 무한 | 열거 가능한 집합 |
| 단계별 병렬도 | 따로 지정 | 하나만 |
| 코드 양 | 많음 | 세 줄 |
| 판단 | 3 GB 파일 파이프라인 | API 4만 번 호출 |
BlockingQueue + ExecutorService,
Parallel.ForEachAsync 는 parallelStream()
(다만 parallelStream 은 공용 ForkJoinPool 을 쓰고 병렬도 지정이 까다롭습니다).
.NET 쪽이 병렬도를 명시적으로 주는 것이 쉽습니다.
async 로 감싼다고 빨라지지 않습니다.
await Task.Run(() => HeavyCalc()) 는 스레드풀 스레드로 옮길 뿐입니다.
IO 대기(await http.GetAsync)는 스레드를 반납하지만,
CPU 계산은 어느 스레드에서 하든 코어를 점유합니다.
배치에서 "async 로 바꿨는데 안 빨라져요"의 답은 대개 이것입니다 —
병목이 CPU 인지 IO 인지 먼저 재세요. dotnet-counters 의
cpu-usage 와 threadpool-queue-length 를 함께 보면 바로 압니다.
Parallel.ForEachAsync. 병렬도는 단계마다 다르게, 재서 정합니다.배치 운영 — 야간 창 · 알림 · 재처리
배치는 만드는 것보다 넘기는 것이 어렵습니다. "실패하면 누가 어떻게 아는가" · "다시 돌리려면 무엇을 누르는가" · "5시까지 안 끝나면 어떻게 되는가" 에 문서로 답이 있어야 운영 이관이 끝납니다.
알림 — 무엇을 언제 누구에게 🟢 기초
| 사건 | 심각도 | 알림 | 받는 사람 | 대응 |
|---|---|---|---|---|
| 배치 실패(예외 종료) | 🔴 높음 | 슬랙 + 메일 + (야간은) 전화 | 담당자 · 당직 | 즉시 원인 확인 · 재실행 판단 |
| 정시에 시작조차 안 함 | 🔴 높음 | 슬랙 + 전화 | 인프라 · 담당자 | 서버·스케줄러 상태 확인. 가장 놓치기 쉬운 알림 |
| 마감 초과(05:00 미완) | 🟠 중간 | 슬랙 | 담당자 · 업무팀 | 온라인 영향 확인 · 다음날 창 조정 |
| 스킵 건수 임계 초과 | 🟠 중간 | 슬랙 | 업무팀 | 송신처에 데이터 품질 문의 |
| 파일 미착(01:30 까지) | 🟠 중간 | 슬랙 + 메일 | 연계 담당자 | 송신 시스템에 확인 |
| 정상 완료 | 🟢 정보 | 슬랙(요약 1줄) | 채널 | 없음 — 다만 조용하면 이상하다는 감각을 만듭니다 |
감시 잡 — 30줄이면 됩니다
// 웹 서버 쪽에서 10분마다 도는 잡
public async Task CheckAsync(CancellationToken ct)
{
var kstNow = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, Kst);
var today = DateOnly.FromDateTime(kstNow);
foreach (var w in _watch) // 설정 파일에서 읽습니다
{
if (kstNow.TimeOfDay < w.ExpectedBy) continue; // 아직 기한 전
var ok = await db.BatchRuns.AnyAsync(r =>
r.JobName == w.JobName &&
r.BizDate == today.AddDays(w.BizDateOffset) &&
r.Status == "Success", ct);
if (ok) continue;
// 하루에 한 번만 울리게 (중복 알림은 알림을 무시하게 만듭니다)
if (await _alerts.AlreadySentAsync(w.JobName, today, ct)) continue;
await _notify.SendAsync(new Alert(
Level : AlertLevel.High,
Title : $"[배치 미완료] {w.JobName}",
Body : $"{w.ExpectedBy:hh\\:mm} 까지 성공 기록이 없습니다. "
+ $"마지막 실행: {await LastRunSummaryAsync(w.JobName, ct)}"), ct);
await _alerts.MarkSentAsync(w.JobName, today, ct);
}
}
슬랙 · 메일 발송
public sealed class SlackNotifier(IHttpClientFactory f,
IOptions<SlackOptions> opt) : INotifier
{
public async Task SendAsync(Alert a, CancellationToken ct)
{
var http = f.CreateClient("slack");
var color = a.Level switch
{
AlertLevel.High => "#e11d48",
AlertLevel.Medium => "#f59e0b",
_ => "#10b981"
};
var payload = new
{
attachments = new[] { new {
color, title = a.Title, text = a.Body,
fields = new[] {
new { title = "서버", value = Environment.MachineName, @short = true },
new { title = "환경", value = _env.EnvironmentName, @short = true }
},
ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}}
};
// ★ 알림 실패가 배치를 죽이면 안 됩니다
try { await http.PostAsJsonAsync(opt.Value.WebhookUrl, payload, ct); }
catch (Exception ex) { _log.LogWarning(ex, "슬랙 알림 실패 — 무시하고 진행"); }
}
}
try/catch 로 감싸고 로그만 남기세요.
같은 이유로 알림은 트랜잭션 밖에서 보냅니다.
재처리 화면 — 운영자가 스스로 할 수 있게 🟡 중급
운영 이관의 실질적 기준은 하나입니다 — 개발자를 부르지 않고 처리할 수 있는가. 아래 네 가지가 화면에 있으면 대부분의 야간 호출이 사라집니다.
| 기능 | 화면 요소 | 서버 동작 | 안전장치 |
|---|---|---|---|
| 실행 이력 조회 | 날짜 · 잡 · 상태 · 건수 · 소요 시간 | BatchRun 조회 | 읽기 전용 |
| 수동 재실행 | 영업일 선택 + 실행 버튼 | Hangfire Enqueue | 중복 확인 · 권한 · 감사 로그 |
| 스킵 건 조회 · 다운로드 | 사유별 건수 + CSV 내려받기 | BatchSkip 조회 | 개인정보 마스킹 |
| 스킵 건 재처리 | 수정 후 재투입 / 무시 처리 | 단건 파이프라인 재실행 | 재처리 이력 남기기 |
| 진행 상황 | 현재 처리 건수 · 예상 완료 | 체크포인트 DoneCount 조회 | — |
// 수동 재실행 엔드포인트 — 안전장치가 코드의 절반입니다
app.MapPost("/admin/batch/{job}/rerun", async (
string job, [FromBody] RerunRequest req,
AppDbContext db, ClaimsPrincipal user, IAuditLog audit,
CancellationToken ct) =>
{
// ① 권한
if (!user.IsInRole("BatchOperator")) return Results.Forbid();
// ② 이미 도는 중인가
var running = await db.BatchRuns.AnyAsync(r =>
r.JobName == job && r.Status == "Running", ct);
if (running) return Results.Conflict(new { message = "이미 실행 중입니다" });
// ③ 이미 성공했는데 또 돌리려는가 — 확인 문구를 요구합니다
var done = await db.BatchRuns.AnyAsync(r =>
r.JobName == job && r.BizDate == req.BizDate && r.Status == "Success", ct);
if (done && !req.ForceConfirmed)
return Results.BadRequest(new {
message = $"{req.BizDate:yyyy-MM-dd} 는 이미 성공했습니다. "
+ "재실행하려면 confirm 을 보내세요",
requiresConfirm = true });
// ④ 누가 무엇을 왜 눌렀는지 남깁니다
await audit.WriteAsync(user.Identity!.Name!, "BATCH_RERUN",
$"{job} {req.BizDate:yyyy-MM-dd} 사유:{req.Reason}", ct);
var id = BackgroundJob.Enqueue<SettleJob>(j => j.RunAsync(req.BizDate, default));
return Results.Accepted($"/admin/batch/runs/{id}", new { jobId = id });
});
운영 이관 체크리스트 🔴 고급
문서에 반드시 적을 것
| 항목 | 예시 |
|---|---|
| 실행 시각 · 소요 시간 | 매일 02:10 · 평균 47분 · 최대 1시간 20분 |
| 선행 조건 | ERP 파일이 01:30 까지 도착 |
| 후행 영향 | 05:30 온라인 조회 화면이 이 결과를 씀 |
| 실패 시 조치 | ① 로그 확인 위치 ② 재실행 방법 ③ 에스컬레이션 대상 |
| 재실행 가능 여부 | 멱등 — 몇 번 돌려도 안전 |
| 중단 가능 여부 | 청크 경계에서 안전하게 중단, 재실행 시 이어짐 |
| 데이터 보관 | 원본 90일 · 스킵 1년 · 결과 5년 |
| 연락처 | 담당 · 백업 담당 · 송신처 담당 |
성능 로그 — 매 실행 남기기
var sw = Stopwatch.StartNew();
var mem0 = GC.GetTotalAllocatedBytes();
// ... 배치 본문 ...
log.LogInformation(
"배치완료 job={Job} date={Date} 읽기={Read} 적재={Write} 스킵={Skip} " +
"소요={Sec:F1}초 처리율={Rate:N0}건/초 할당={Alloc:N0}MB 최대힙={Heap:N0}MB",
job, bizDate, read, wrote, skipped,
sw.Elapsed.TotalSeconds, read / sw.Elapsed.TotalSeconds,
(GC.GetTotalAllocatedBytes() - mem0) / 1024 / 1024,
GC.GetTotalMemory(false) / 1024 / 1024);
실전 — 일별 정산 배치 를 처음부터 끝까지
SFTP 로 3.1 GB · 400만 행 거래 파일을 받아 검증하고, 청크로 처리해 벌크 적재하고, 집계해 결과 파일을 만들어 되돌려 보냅니다. 앞의 12개 섹션이 여기서 한 덩어리로 합쳐집니다. 코드 · 실측 수치 · 운영 절차까지.
프로젝트 구조와 잡 정의 🟢 기초
Settle.sln
├─ Settle.Domain/ 엔티티 · 값 객체 · 정산 규칙 (참조 없음)
├─ Settle.Application/ 유스케이스 · 인터페이스 (Domain 만 참조)
│ └─ Batch/ SettleJob · ChunkRunner · IBatchJournal
├─ Settle.Infrastructure/ EF Core · SFTP · 파일 저장소 · 슬랙
├─ Settle.Batch/ Worker Service + Hangfire 서버 ← 배포 대상 ①
└─ Settle.Admin/ ASP.NET Core 관리 화면 ← 배포 대상 ②
// Settle.Batch/Program.cs
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); // ★ CP949
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromMinutes(5)); // 청크 하나보다 길게
builder.Services
.AddInfrastructure(builder.Configuration)
.AddApplication()
.AddHangfire(cfg => cfg.UseSqlServerStorage(cs))
.AddHangfireServer(o => { o.Queues = ["settle"]; o.WorkerCount = 2; });
builder.Services.AddWindowsService(o => o.ServiceName = "SettleBatch");
var host = builder.Build();
// ★ 대시보드가 없는 워커에서는 정적 RecurringJob 대신 DI 의 매니저를 씁니다
// (정적 API 는 JobStorage.Current 가 아직 비어 있어 예외가 날 수 있습니다)
host.Services.GetRequiredService<IRecurringJobManager>().AddOrUpdate<SettleJob>("daily-settle",
"settle", j => j.RunAsync(null, CancellationToken.None),
"0 10 2 * * *", new RecurringJobOptions { TimeZone = Kst });
await host.RunAsync();
잡 본문 — 여섯 단계 🟡 중급
[DisableConcurrentExecution(timeoutInSeconds: 4 * 60 * 60)]
[AutomaticRetry(Attempts = 0)] // ★ 재시도는 우리가 통제합니다
public sealed class SettleJob(
ISftpGateway sftp, IFileVerifier verify, ChunkRunner chunks,
ISettleAggregator agg, IResultWriter writer, IBatchJournal journal,
INotifier notify, ILogger<SettleJob> log)
{
public async Task RunAsync(DateOnly? forDate, CancellationToken ct)
{
var bizDate = forDate ?? KstToday().AddDays(-1);
var sw = Stopwatch.StartNew();
// 다른 서버가 돌고 있으면 조용히 물러납니다 (f02)
await using var mine = await SqlAppLock.TryAcquireAsync(
_cs, $"batch:settle:{bizDate:yyyyMMdd}", TimeSpan.Zero, ct);
if (mine is null) { log.LogInformation("다른 서버가 처리 중"); return; }
var run = await journal.StartAsync("daily-settle", bizDate, ct);
try
{
// ── ① 수신 : SFTP → 로컬 (임시 확장자 → rename)
var local = await sftp.DownloadAsync(
$"/out/TRD_{bizDate:yyyyMMdd}.dat", ct);
log.LogInformation("수신 완료 {Bytes:N0} 바이트 ({Sec:F0}초)",
new FileInfo(local).Length, sw.Elapsed.TotalSeconds);
// ── ② 검증 : 헤더 · 트레일러 · 건수 · 합계 (f08)
var stats = await verify.VerifyAsync(local, Cp949, ct);
log.LogInformation("검증 통과 {Rows:N0}행 합계 {Sum:N2}",
stats.Rows, stats.Sum);
// ── ③④ 청크 처리 + 벌크 적재 (f04 · f05)
var r = await chunks.RunAsync(
job: "daily-settle.load", bizDate, step: "load", // ★ 바깥 잡과 다른 이름 — ChunkRunner 도 BatchRun 행을 따로 만들므로 성공 행이 겹치지 않게
readFrom: key => TradeFileReader.ReadAsync(local, key, Cp949),
chunkSize: 5_000, skipLimit: 10_000, ct);
if (r.Kind == ResultKind.Stopped) // 종료 신호로 중단
{
// 바깥 실행 이력도 Stopped 로 닫아 둡니다 — 안 그러면 Running 으로 남아 재실행이 막힙니다
await journal.StopAsync(run.RunId, r.Read, r.Wrote, r.Skipped, CancellationToken.None);
log.LogWarning("중단됨 — 다음 실행이 이어받습니다"); return;
}
// ── ⑤ 집계 : 스테이징 → 본 테이블 MERGE + 수수료 계산
var summary = await agg.AggregateAsync(bizDate, ct);
// ── ⑥ 송신 : 결과 파일 생성 후 SFTP 업로드
var outPath = await writer.WriteAsync(bizDate, summary, ct);
await sftp.UploadAsync(outPath, $"/in/STL_{bizDate:yyyyMMdd}.dat", ct);
await journal.SucceedAsync(run.RunId, r.Read, r.Wrote, r.Skipped, ct);
await notify.SendAsync(Alert.Info("정산 완료",
$"{bizDate:yyyy-MM-dd} · {r.Wrote:N0}건 · {sw.Elapsed.TotalMinutes:F0}분"), ct);
}
catch (Exception ex)
{
await journal.FailAsync(run.RunId, ex, CancellationToken.None);
await notify.SendAsync(Alert.High("정산 실패",
$"{bizDate:yyyy-MM-dd}\n{ex.Message}"), CancellationToken.None);
throw; // Hangfire 대시보드에 빨갛게
}
}
}
집계 · 결과 파일 · 실측 수치 🔴 고급
집계 — 한 문장으로 끝냅니다
-- 가맹점별 일 정산. 400만 행 → 8,200행. 인덱스가 없으면 12분 (있으면 아래 표의 5분 40초)
INSERT INTO dbo.SettleDaily
(BizDate, MerchantId, TxCount, GrossAmt, FeeAmt, NetAmt, CreatedAt)
SELECT @BizDate,
t.MerchantId,
COUNT_BIG(*),
SUM(t.Amount),
SUM(ROUND(t.Amount * m.FeeRate, 0)),
SUM(t.Amount - ROUND(t.Amount * m.FeeRate, 0)),
SYSUTCDATETIME()
FROM dbo.Trade t WITH (INDEX(IX_Trade_BizDate_Merchant))
JOIN dbo.Merchant m ON m.Id = t.MerchantId
WHERE t.BizDate = @BizDate
AND t.Status = 'N' -- 정상 거래만
AND m.FeeExempt = 0 -- 면제 가맹점 제외
GROUP BY t.MerchantId
OPTION (MAXDOP 4); -- 병렬도 제한 — 온라인에 여유를 남깁니다
DELETE 를 먼저 두세요.
DELETE FROM SettleDaily WHERE BizDate=@BizDate; 를
같은 트랜잭션 앞에 놓으면 몇 번 돌려도 결과가 같아집니다.
이것이 f04 에서 말한 멱등성의 가장 실용적인 형태입니다.
결과 파일 — 헤더 · 본문 · 트레일러
public async Task<string> WriteAsync(
DateOnly bizDate, IAsyncEnumerable<SettleRow> rows, CancellationToken ct)
{
var tmp = Path.Combine(_out, $"STL_{bizDate:yyyyMMdd}.dat.tmp");
var final = tmp[..^4]; // .tmp 떼기
long n = 0; decimal sum = 0m;
// ★ 대외 규약: CP949 · CRLF · BOM 없음
await using (var fs = File.Create(tmp))
await using (var sw = new StreamWriter(fs, Cp949) { NewLine = "\r\n" })
{
await sw.WriteLineAsync(
$"H|SETTLE|{DateTime.UtcNow.AddHours(9):yyyyMMddHHmmss}|{bizDate:yyyyMMdd}");
await foreach (var r in rows.WithCancellation(ct))
{
// 고정폭: 가맹점(10) 건수(9) 총액(15) 수수료(15) 정산액(15)
await sw.WriteLineAsync(
$"D{r.MerchantId,10}{r.TxCount,9:D9}" +
$"{(long)(r.GrossAmt * 100),15:D15}" +
$"{(long)(r.FeeAmt * 100),15:D15}" +
$"{(long)(r.NetAmt * 100),15:D15}");
n++; sum += r.NetAmt;
}
await sw.WriteLineAsync($"T|{n}|{sum:F2}");
}
File.Move(tmp, final, overwrite: true); // ★ 원자적 공개 (f08)
return final;
}
| 단계 | 규모 | 소요 | 메모리 | 핵심 기법 | 실패 시 |
|---|---|---|---|---|---|
| ① SFTP 수신 | 3.1 GB | 6분 20초 | 64 KB | SSH.NET · 크기 검증 · rename | 재시도 3회 → 미착 알림 |
| ② 검증 | 400만 행 | 2분 10초 | 8 MB | 스트리밍 · 트레일러 대조 | 즉시 중단 — 송신처 문의 |
| ③ 청크 처리 | 400만 행 | 31분 | 42 MB | 5,000건 청크 · 체크포인트 | 체크포인트에서 재시작 |
| ④ 벌크 적재 | 394만 행 | 41초 | 포함 | SqlBulkCopy → 스테이징 | 스테이징 비우고 재실행 |
| ⑤ 집계 | 8,200행 산출 | 5분 40초 | — | MERGE · MAXDOP 4 | DELETE 후 재실행(멱등) |
| ⑥ 결과 송신 | 1.2 MB | 18초 | 2 MB | CP949 · 트레일러 · rename | 재업로드(동일 파일) |
| 합계 | — | 46분 29초 | 최대 42 MB | — | — |
Java · Spring 으로 짰다면 무엇이 달랐을까 🔴 고급
| 부분 | Spring Batch | .NET 10 | 체감 |
|---|---|---|---|
| Job/Step 정의 | @Bean Job · StepBuilder | 메서드 하나 + Hangfire 등록 | .NET 이 코드가 적음. 대신 규칙도 없음 |
| Reader | FlatFileItemReader (설정으로) | IAsyncEnumerable (코드로) | 비슷. .NET 이 디버깅하기 쉬움 |
| 재시작 | 무료 — JobRepository | 직접 — 체크포인트 테이블 | Spring 승. 다만 300줄이면 만듦 |
| 스킵 · 재시도 정책 | faultTolerant().skipLimit() | 직접 세기 | Spring 승 |
| 대량 적재 | JdbcBatchItemWriter (드라이버 의존) | SqlBulkCopy 표준 | .NET 승. 성능 차이가 큼 |
| 비동기 스트리밍 | 리액티브를 따로 도입 | IAsyncEnumerable·Channels 표준 | .NET 승 |
| 운영 화면 | Spring Batch Admin(사실상 방치) | Hangfire 대시보드 | .NET 승 |
| 인코딩(CP949) | 기본 지원 | 패키지 + 등록 한 줄 | Spring 승(사소함) |
| 스케줄 클러스터링 | Quartz JDBC 잡스토어 | Hangfire / Quartz.NET 동일 | 무승부 |
SqlBulkCopy · IAsyncEnumerable · Channels 가
표준 라이브러리에 있고, 메모리 제어(Span · ArrayPool)가 훨씬 정밀합니다.
실무 결론은 이렇습니다 — Spring Batch 를 그리워하지 말고,
f04 의 300줄을 사내 공통 라이브러리로 한 번 만들어 두세요.
그 뒤로는 .NET 쪽이 더 빠르고 더 가볍습니다.
톰캣이
없는
세상
자바는 Nginx/Apache + 톰캣 이면 끝났습니다. 그런데 C# 서버는 무엇을 깔아야 할까요. 답은 "거의 아무것도 안 깝니다" 입니다 — 웹 서버가 앱 안에 이미 들어 있습니다. 이 탭은 그 낯선 구조를 자바와 1:1 로 맞춰 놓고, Windows(IIS) 와 Linux(systemd + Nginx) 두 갈래를 화면 단계 · 설정 파일 전문 · 명령까지 끝까지 따라갑니다. 여기까지 마치면 빈 서버 한 대를 받아 사내 업무 시스템을 올리고, 인증서를 걸고, 무중단으로 재배포하고, 502.5 가 떴을 때 5분 안에 원인을 짚을 수 있습니다.
톰캣이 없는 세상 — .NET 호스팅 지도
자바 개발자가 .NET 배포에서 가장 먼저 부딪히는 벽은 기술이 어려워서가 아닙니다. 찾는 물건이 아예 없기 때문입니다. 톰캣이 없고, WAR 이 없고, 배포 디스크립터가 없습니다. 먼저 무엇이 무엇으로 바뀌었는지 지도를 그립니다.
자바 ↔ .NET 대응표 — 이 표 하나가 이 탭의 목차입니다
| 자바에서 쓰던 것 | .NET 에서는 | 어디가 다른가 | 다루는 곳 |
|---|---|---|---|
| 톰캣 · Jetty · Undertow | Kestrel (NuGet 라이브러리) | 설치가 아니라 참조. 앱 프로세스 안에서 뜹니다 | h02 |
| WAR / EAR | publish 출력 폴더 | 압축 규격이 아니라 그냥 폴더. zip 으로 옮겨도 되고 그대로 복사해도 됩니다 | h09 |
| web.xml · 배포 디스크립터 | Program.cs 코드 | XML 선언이 아니라 실행되는 코드. 순서가 곧 동작 | h02 |
| server.xml Connector | Kestrel:Endpoints (appsettings) | 포트 · 프로토콜 · 인증서를 JSON 으로 | h02 |
| catalina.sh start / systemd | dotnet MyApp.dll + systemd | 실행 파일 하나를 서비스로 등록합니다 | h05 |
| Windows 서비스 래퍼 (procrun) | AddWindowsService() | 런타임에 내장 — 래퍼가 필요 없습니다 | h04 |
| Nginx · Apache 리버스 프록시 | 그대로 Nginx · Apache 또는 IIS | 바뀌는 게 없습니다. 설정만 조금 다릅니다 | h06 |
| mod_jk · AJP 커넥터 | 그냥 HTTP (또는 유닉스 소켓) | AJP 같은 전용 프로토콜이 없습니다 | h06 |
| Spring Cloud Gateway | YARP | C# 코드로 게이트웨이를 짭니다 | h07 |
| keytool · JKS 키스토어 | dotnet dev-certs · PFX · 인증서 저장소 | Windows 는 OS 저장소를 씁니다 | h08 |
| -Xmx · -Xms · G1GC 플래그 | ServerGarbageCollection 등 runtimeconfig | 힙 상한을 직접 안 정합니다 (컨테이너 제한을 봅니다) | h12 |
| jstack · jmap · jcmd | dotnet-dump · dotnet-trace · dotnet-counters | 이름만 다르고 하는 일은 같습니다 | h12 · h13 |
| catalina.out · logback 파일 | stdout → journald · IIS 로그 · Serilog | 기본은 표준 출력. 파일 로그는 선택입니다 | h05 · h13 |
| Tomcat Manager 무중단 배포 | app_offline.htm · 앱 풀 오버랩 · 롤링 | 핫 디플로이 개념이 없습니다 — 프로세스를 갈아 끼웁니다 | h10 |
서블릿 컨테이너는 왜 사라졌나
톰캣의 존재 이유는 "여러 개의 WAR 을 한 프로세스에 얹어 함께 운영" 하는 것이었습니다. 2000년대에는 서버 한 대가 비쌌으니 합리적이었습니다. 지금은 컨테이너와 프로세스 격리가 싸졌기 때문에, 앱 하나 = 프로세스 하나 가 기본이 되었습니다. 그러면 컨테이너가 할 일이 없습니다 — 그 자리에 라이브러리 한 개만 남습니다.
java -jar app.jar 로 임베디드 톰캣을 띄우는 그 방식이
.NET 에서는 선택이 아니라 유일한 방식이라고 보면 정확합니다.
그래서 서버에 실제로 까는 것
# Ubuntu 24.04 LTS — 이 한 줄이 "톰캣 설치"에 해당합니다
# (24.04 는 ppa:dotnet/backports 를 먼저 추가해야 10.0 이 보입니다 — h05)
sudo apt install -y aspnetcore-runtime-10.0
# Windows Server — IIS 뒤에 둘 때는 Hosting Bundle 하나
# dotnet-hosting-10.0.x-win.exe (런타임 + ANCM 이 함께 들어 있음)
# 확인
dotnet --info
dotnet --list-runtimes
# Microsoft.AspNetCore.App 10.0.0 [/usr/share/dotnet/shared/...]
# Microsoft.NETCore.App 10.0.0 [/usr/share/dotnet/shared/...]
무엇을 고를 것인가 — 배포 형태 결정 🟡 중급
publish 결과가 WAR 자리에 옵니다
dotnet publish -c Release -o ./publish
# ./publish 안을 열어 보면
# Payroll.dll ← 진짜 코드 (자바의 classes/)
# Payroll.exe ← Windows 실행 파일 (얇은 런처)
# Payroll ← Linux 실행 파일
# Payroll.deps.json ← 의존 그래프 (pom.xml 의 해석 결과)
# Payroll.runtimeconfig.json← 런타임 버전 · GC 설정
# appsettings.json ← application.yml 자리
# appsettings.Production.json
# web.config ← IIS 로 게시할 때만 생깁니다
# wwwroot/ ← 정적 파일 (src/main/webapp 자리)
# *.dll ← NuGet 의존성들 (WEB-INF/lib 자리)
rsync · robocopy · scp 면 충분합니다.
실행해 보기 — 이게 "톰캣 기동" 입니다
cd publish
DOTNET_ENVIRONMENT=Production \
ASPNETCORE_URLS="http://0.0.0.0:5000" \
dotnet Payroll.dll
# 출력
# info: Microsoft.Hosting.Lifetime[14]
# Now listening on: http://0.0.0.0:5000
# info: Microsoft.Hosting.Lifetime[0]
# Application started. Press Ctrl+C to shut down.
# info: Microsoft.Hosting.Lifetime[0]
# Hosting environment: Production
# info: Microsoft.Hosting.Lifetime[0]
# Content root path: /opt/payroll
Kestrel 파고들기
톰캣의 server.xml 에서 하던 일 — 포트 열기, 스레드 수, 커넥션 제한, 타임아웃 —
을 .NET 에서는 어디서 하는지 정확히 짚습니다. Kestrel 은 톰캣보다 설정 항목이 적습니다.
적은 이유도 함께 봅니다.
코드로 여는 방법 — 우선순위가 더 높습니다
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(o =>
{
// ① 평문 HTTP — 리버스 프록시 뒤라면 이것만 열면 됩니다
o.ListenAnyIP(5000);
// ② TLS 직접 종료 — 프록시 없이 단독 노출할 때
o.ListenAnyIP(5001, l =>
{
l.UseHttps("/etc/ssl/payroll.pfx", "비밀번호");
l.Protocols = HttpProtocols.Http1AndHttp2;
});
// ③ 유닉스 도메인 소켓 — Nginx 와 같은 서버에 있을 때 가장 빠름
o.ListenUnixSocket("/var/run/payroll.sock");
// ④ 루프백만 — 외부에서 직접 못 들어오게 잠급니다
o.ListenLocalhost(5002);
o.Limits.MaxConcurrentConnections = 20_000;
o.Limits.MaxRequestBodySize = 300L * 1024 * 1024;
o.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(2);
o.Limits.RequestHeadersTimeout = TimeSpan.FromSeconds(30);
o.Limits.MinRequestBodyDataRate =
new MinDataRate(bytesPerSecond: 100, gracePeriod: TimeSpan.FromSeconds(10));
o.AddServerHeader = false; // Server: Kestrel 헤더를 숨깁니다
});
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok("ok"));
app.Run();
설정이 겹칠 때 누가 이기나
| 순위 | 어디서 | 예 |
|---|---|---|
| 1 (최강) | ConfigureKestrel 코드 | ListenAnyIP(5000) |
| 2 | appsettings 의 Kestrel:Endpoints | JSON 블록 — 있으면 아래 URL 설정은 무시됩니다 |
| 3 | --urls 명령행 인자 | dotnet app.dll --urls ... |
| 4 | ASPNETCORE_URLS 환경 변수 | http://0.0.0.0:5000 |
| 5 | launchSettings.json | 개발 전용 — 서버에는 배포조차 안 됩니다 |
launchSettings.json 은 Visual Studio 와 dotnet run 전용이고
publish 폴더에 들어가지 않습니다. 운영 포트는 환경 변수나 appsettings 로 정하세요.
HTTP/2 · HTTP/3 🟡 중급
프로토콜 켜기
builder.WebHost.ConfigureKestrel(o =>
{
o.ListenAnyIP(443, l =>
{
l.UseHttps();
// HTTP/3 는 QUIC(UDP 443) 위에서 돕니다.
// Http1AndHttp2AndHttp3 로 켜면 브라우저가 알아서 협상합니다.
l.Protocols = HttpProtocols.Http1AndHttp2AndHttp3;
});
});
{
"Kestrel": {
"EndpointDefaults": { "Protocols": "Http1AndHttp2" }
}
}
Protocols = Http2 로 TLS 없이도 열 수 있는데,
Nginx 뒤에서 gRPC 를 중계할 때 이 조합을 씁니다.
HTTP/3 를 켜기 전 확인할 것
# 1) Linux 는 libmsquic 패키지가 필요합니다
sudo apt install -y libmsquic
# 2) 방화벽에 UDP 443 을 열어야 합니다 (TCP 만 열면 조용히 HTTP/2 로 떨어집니다)
sudo ufw allow 443/udp
# 3) 응답에 알림 헤더가 나가야 브라우저가 다음 요청부터 QUIC 을 씁니다
# alt-svc: h3=":443"; ma=86400
# 4) 확인
curl --http3 -I https://payroll.corp.local/healthz
Kestrel 을 단독으로 노출해도 되나 🔴 고급
단독 노출이 괜찮은 경우
- 클라이언트가 사내 시스템뿐이고, 앞에 이미 L4/L7 로드밸런서(F5 · ALB)가 있는 경우
- 컨테이너로 띄우고 Ingress 컨트롤러가 앞을 지키는 경우
- Microsoft 공식 입장도 "인터넷 직접 노출도 지원한다" 입니다 — 보안 검토를 마쳤다면 문제 없습니다
앞에 무언가를 두는 편이 나은 경우
- 한 포트에 여러 앱을 호스트명으로 나눠야 할 때 (Kestrel 도 SNI 로 되지만 관리가 번거롭습니다)
- 정적 파일이 많을 때 — Nginx 가 훨씬 잘합니다
- 인증서 갱신 · WAF · 접근 로그 · IP 차단을 앱 밖에서 하고 싶을 때
- 느린 클라이언트 공격(Slowloris)을 앱 프로세스 앞에서 흡수하고 싶을 때
스레드 모델 — 톰캣과 결정적으로 다른 점
톰캣은 maxThreads="200" 처럼 요청 하나에 스레드 하나를 배정했습니다
(가상 스레드가 나오기 전까지는). Kestrel 은 비동기 I/O 완성 포트 위에서 돌기 때문에
동시 요청 수와 스레드 수가 연결되어 있지 않습니다.
그래서 Kestrel 에는 maxThreads 에 해당하는 설정이 없습니다.
// ❌ 이런 코드 한 줄이 그 장점을 통째로 날립니다
app.MapGet("/report", () =>
{
var data = _http.GetStringAsync(url).Result; // 스레드를 붙잡고 대기
Thread.Sleep(2000); // 스레드풀 고갈의 시작
return data;
});
// ✅ 끝까지 async
app.MapGet("/report", async (CancellationToken ct) =>
await _http.GetStringAsync(url, ct));
.Result · .Wait() · Thread.Sleep 세 개가
.NET 서버 장애 원인 1위입니다. 자바의 Future.get() 남용과 같은 문제인데,
Kestrel 은 스레드가 훨씬 적기 때문에 훨씬 빨리 터집니다 (h12 · h13).
요청 상한 세 곳 — 하나만 고치면 안 됩니다
// ① Kestrel 전역
builder.WebHost.ConfigureKestrel(o =>
o.Limits.MaxRequestBodySize = 300L * 1024 * 1024);
// ② 엔드포인트 개별 (전역보다 우선)
app.MapPost("/upload", Upload)
.WithMetadata(new RequestSizeLimitAttribute(300 * 1024 * 1024));
// ③ 폼 데이터는 별도 상한이 또 있습니다
builder.Services.Configure<FormOptions>(o =>
{
o.MultipartBodyLengthLimit = 300L * 1024 * 1024;
o.ValueLengthLimit = int.MaxValue;
});
<!-- ④ IIS 뒤라면 web.config 에 하나 더 (바이트 단위, 기본 30 MB) -->
<security>
<requestFiltering>
<requestLimits maxAllowedContentLength="314572800" />
</requestFiltering>
</security>
client_max_body_size (h06).
업로드가 실패하는데 앱 로그에 아무것도 안 남으면 가장 앞단에서 잘린 것입니다.
maxThreads 에 해당하는 항목은 존재하지 않습니다. 대신 코드가 끝까지 async 인지가 그 자리를 대신합니다.Windows ① — IIS 에 올리기
사내 시스템에서 가장 흔한 구성입니다. 이미 IIS 로 다른 사이트를 서비스하고 있고, 거기에 .NET 앱 하나를 얹는 그림입니다. 화면 단계와 web.config 전문까지 갑니다.
1단계 — 서버에 딱 하나 설치
ASP.NET Core 10.0 Hosting Bundle
https://dotnet.microsoft.com/download/dotnet/10.0
→ dotnet-hosting-10.0.x-win.exe
이 하나에 세 가지가 들어 있습니다
① .NET 런타임 (콘솔 앱도 돌게)
② ASP.NET Core 런타임 (웹 앱이 돌게)
③ ASP.NET Core Module (IIS 와 앱을 잇는 다리) ← 이게 핵심
# 설치 후 반드시 IIS 를 다시 읽혀야 모듈이 잡힙니다
net stop was /y
net start w3svc
# 설치 확인 — 모듈 목록에 AspNetCoreModuleV2 가 보여야 합니다
%windir%\system32\inetsrv\appcmd list modules | findstr AspNetCore
자바 개발자를 위한 한 줄 요약
IIS 는 톰캣이 아닙니다. Apache 입니다.
IIS 는 내 코드를 실행하지 않습니다 — 별도 프로세스로 앱을 띄우고 요청을 넘겨줄 뿐입니다.
Apache 앞단 + mod_jk 조합에 가깝고,
차이는 Apache 는 톰캣을 직접 켜지 않지만 IIS 는 앱 프로세스를 직접 띄우고 감시한다는 점입니다.
| 톰캣 개념 | IIS 에서 |
|---|---|
| 톰캣 인스턴스 | 앱 풀 (Application Pool) |
| Host / Context | 사이트 / 응용 프로그램 |
| Connector port | 바인딩 (Binding) |
| catalina.sh 재시작 | 앱 풀 재활용 (Recycle) |
| 실행 사용자 (tomcat) | 앱 풀 ID IIS AppPool\이름 |
| web.xml | web.config (ANCM 설정만) |
그래도 out-of-process 를 고르는 경우
- x86 / x64 가 섞일 때 — 앱 풀 비트와 앱 비트가 달라야 하는 레거시 상황
- 같은 앱 풀에 여러 앱을 넣어야 할 때 (in-process 는 앱 풀 하나에 앱 하나가 원칙입니다)
- Kestrel 고유 기능이 필요할 때 — HTTP/3 · 유닉스 소켓 · 커스텀 전송
- 앱이 죽어도 IIS 작업자는 살아 있어야 할 때
<aspNetCore ... hostingModel="OutOfProcess" />
UseKestrel 설정이 대부분 무시됩니다.
웹 서버가 Kestrel 이 아니라 IIS HTTP 서버(IISHttpServer) 로 바뀌기 때문입니다.
포트 · 인증서 · 프로토콜은 전부 IIS 바인딩 에서 정합니다.
앱 쪽 준비 — csproj 한 줄
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<!-- publish 할 때 web.config 를 자동 생성합니다 (기본 true) -->
<IsTransformWebConfigDisabled>false</IsTransformWebConfigDisabled>
<!-- IIS 통합 -->
<AspNetCoreHostingModel>InProcess</AspNetCoreHostingModel>
</PropertyGroup>
</Project>
# IIS 용 게시 — win-x64 로 못박는 편이 사고가 적습니다
dotnet publish -c Release -r win-x64 --self-contained false ^
-o C:\inetpub\payroll
UseIIS() 를 코드에 쓸 필요가 없습니다.
CreateBuilder 가 환경 변수(ASPNETCORE_IIS_HTTPAUTH 등)를 보고
IIS 아래에서 도는지 스스로 판단합니다. 그래서 같은 빌드가 Linux 에서도 그대로 돕니다.
web.config — 전문과 줄별 주석 🟢 기초
publish 폴더에 생기는 web.config (손으로 고쳐도 됩니다)
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<!-- IIS 는 이 파일을 보고 "이 폴더는 ANCM 이 맡는다"를 압니다.
web.xml 처럼 서블릿 매핑을 적는 곳이 아닙니다 — 그건 코드에 있습니다. -->
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<!-- 모든 경로(*)의 모든 메서드(*)를 ANCM 에게 넘깁니다.
정적 파일도 앱이 처리합니다(UseStaticFiles). IIS 가 먼저 채가지 않습니다. -->
<add name="aspNetCore" path="*" verb="*"
modules="AspNetCoreModuleV2" resourceType="Unspecified" />
</handlers>
<aspNetCore
processPath="dotnet"
arguments=".\Payroll.dll"
hostingModel="inprocess"
stdoutLogEnabled="false"
stdoutLogFile=".\logs\stdout"
startupTimeLimit="120"
shutdownTimeLimit="30"
requestTimeout="00:20:00">
<!-- 환경 변수 — application-prod.yml 을 고르는 스위치에 해당합니다 -->
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT" value="Production" />
<environmentVariable name="DOTNET_gcServer" value="1" />
<!-- 밑줄 두 개가 JSON 의 콜론(:) 을 뜻합니다 -->
<environmentVariable name="ConnectionStrings__Main"
value="Server=db;Database=payroll;..." />
</environmentVariables>
</aspNetCore>
<security>
<requestFiltering>
<!-- 업로드 상한 300 MB. 이 줄이 없으면 약 28.6 MB(30,000,000 바이트)에서 404.13 이 납니다 -->
<requestLimits maxAllowedContentLength="314572800" />
</requestFiltering>
</security>
<httpProtocol>
<customHeaders>
<!-- 정보 노출 줄이기 — 서버 종류를 알려 줄 이유가 없습니다 -->
<remove name="X-Powered-By" />
</customHeaders>
</httpProtocol>
</system.webServer>
</location>
</configuration>
각 속성이 실제로 하는 일
| 속성 | 기본 | 언제 손대나 |
|---|---|---|
startupTimeLimit | 120초 | 기동 중 마이그레이션·캐시 예열이 오래 걸리면 늘립니다. 초과 시 502.5 |
shutdownTimeLimit | 10초 | 진행 중 요청을 끝까지 처리하고 내려가려면 30~60 으로 |
requestTimeout | 2분 | 대용량 파일 업로드·리포트 생성이 있으면 늘립니다. 초과 시 502.3 — out-of-process 전용, in-process 에서는 무시됩니다 |
stdoutLogEnabled | false | 기동 실패를 볼 때만 잠깐 true. 켜 두면 로그가 무한히 자랍니다 |
hostingModel | inprocess | 위 대조표의 사유가 있을 때만 outofprocess |
processPath | dotnet | self-contained 게시면 .\Payroll.exe 로 바꾸고 arguments 는 비웁니다 |
web.config 를 직접 두고
publish 가 그것을 변환하게 하거나, appsettings 로 옮기세요.
앱 풀 설정 — 여기서 사고가 가장 많이 납니다
set APPCMD=%windir%\system32\inetsrv\appcmd
REM 앱 풀 만들기 — .NET CLR 버전은 "No Managed Code" (공식 권장)
%APPCMD% add apppool /name:PayrollPool ^
/managedRuntimeVersion:"" /managedPipelineMode:Integrated
REM 유휴 종료 끄기 — 기본 20분이면 새벽에 첫 요청이 느려집니다
%APPCMD% set apppool /apppool.name:PayrollPool ^
/processModel.idleTimeout:00:00:00
REM 정기 재활용 끄기 — 기본 1740분(29시간)마다 프로세스를 바꿉니다
%APPCMD% set apppool /apppool.name:PayrollPool ^
/recycling.periodicRestart.time:00:00:00
REM 32비트 끄기 (x64 앱)
%APPCMD% set apppool /apppool.name:PayrollPool ^
/enable32BitAppOnWin64:false
REM 시작 모드 AlwaysRunning — 서버 부팅 직후 미리 띄웁니다
%APPCMD% set apppool /apppool.name:PayrollPool ^
/startMode:AlwaysRunning
사이트 만들기 · 권한 🟡 중급
사이트 · 바인딩 (화면 단계와 명령 둘 다)
IIS 관리자 화면으로 할 때
1. 시작 → inetmgr
2. 왼쪽 트리 [서버] → 사이트 → 우클릭 → "웹 사이트 추가"
3. 사이트 이름 : Payroll
실제 경로 : C:\inetpub\payroll ← publish 폴더
응용 프로그램 풀 : PayrollPool ← "선택" 버튼으로 지정
4. 바인딩
형식 : https IP : 미할당 포트 : 443
호스트 이름 : payroll.corp.local
SSL 인증서 : (h08 에서 넣습니다)
5. [확인] → 오른쪽 "웹 사이트 찾아보기" 로 확인
REM 같은 일을 명령으로
%APPCMD% add site /name:Payroll /physicalPath:"C:\inetpub\payroll" ^
/bindings:"http/*:8080:payroll.corp.local"
%APPCMD% set app "Payroll/" /applicationPool:PayrollPool
REM 바인딩 추가 (https)
%APPCMD% set site /site.name:Payroll ^
/+bindings.[protocol='https',bindingInformation='*:443:payroll.corp.local']
폴더 권한 — 계정 이름이 낯섭니다
REM 앱 풀은 "IIS AppPool\<앱풀이름>" 이라는 가상 계정으로 돕니다.
REM 로컬 사용자 목록에 안 보이지만 실제로 존재합니다.
REM 읽기 + 실행 (배포 폴더)
icacls "C:\inetpub\payroll" /grant "IIS AppPool\PayrollPool":(OI)(CI)RX /T
REM 쓰기가 필요한 폴더만 따로 (로그 · 임시 파일 · DataProtection 키)
icacls "C:\inetpub\payroll\logs" /grant "IIS AppPool\PayrollPool":(OI)(CI)M
icacls "D:\payroll-data\keys" /grant "IIS AppPool\PayrollPool":(OI)(CI)M
REM 확인
icacls "C:\inetpub\payroll"
webapps 전체 쓰기를 주지 않는 것과 같은 이유입니다.
읽기+실행이 기본, 쓰기는 로그 · 업로드 임시 · 데이터 보호 키 폴더만 예외로 둡니다.
tomcat 계정에 Kerberos 키탭을 주던 것과 같은 그림입니다.
app_offline.htm 을 사이트 루트에 놓는다(즉시 앱이 내려갑니다) →
② publish 폴더 복사(robocopy) →
③ app_offline.htm 삭제 → ④ 첫 요청으로 예열 → ⑤ /healthz 확인.
자세한 무중단 절차는 h10 에 있습니다.
IIS AppPool\이름 계정에 읽기+실행 권한.Windows ② — Windows 서비스로 띄우기
IIS 가 필요 없는 경우가 많습니다 — 내부 전용 API, 배치 서버, gRPC 백엔드.
이때는 앱을 Windows 서비스로 등록합니다. 자바에서 procrun(Apache Commons Daemon)
으로 하던 일인데, .NET 은 런타임에 들어 있습니다.
코드 — 한 줄이면 끝납니다
dotnet add package Microsoft.Extensions.Hosting.WindowsServices
var builder = WebApplication.CreateBuilder(new WebApplicationOptions
{
Args = args,
// 서비스로 돌면 작업 디렉터리가 C:\Windows\System32 입니다.
// 이 한 줄이 없으면 appsettings.json 을 못 찾습니다 — 가장 흔한 사고.
ContentRootPath = AppContext.BaseDirectory
});
// ① 서비스 수명주기 연결 + 이벤트 로그 자동 등록
builder.Host.UseWindowsService(o => o.ServiceName = "Payroll API");
// ② 콘솔로 직접 돌릴 때도 그대로 동작합니다 (서비스가 아니면 자동으로 무시)
builder.Services.AddWindowsService();
builder.Services.AddHostedService<NightlyJob>(); // 배치도 같이 태울 수 있습니다
var app = builder.Build();
app.MapGet("/healthz", () => Results.Ok("ok"));
app.Run();
WindowsServiceHelpers.IsWindowsService() 로 스스로 판단하기 때문입니다.
개발 중에는 그냥 dotnet run, 서버에서는 서비스 — 빌드를 따로 만들 필요가 없습니다.
등록 · 실행 계정 · 자동 시작
REM 반드시 관리자 명령 프롬프트에서
REM binPath= 다음의 공백과 따옴표 위치가 정확해야 합니다 (등호 뒤 공백 필수)
sc.exe create PayrollApi ^
binPath= "\"C:\svc\payroll\Payroll.exe\" --contentRoot C:\svc\payroll" ^
DisplayName= "Payroll API" ^
start= auto ^
obj= "CORP\svc_payroll" password= "비밀번호"
REM 설명 달기 (운영자가 서비스 목록에서 알아볼 수 있게)
sc.exe description PayrollApi "사내 급여 시스템 API — .NET 10 · 담당 정보시스템팀"
REM 지연 자동 시작 — DB 가 먼저 뜨도록 부팅 직후를 피합니다
sc.exe config PayrollApi start= delayed-auto
REM 의존 서비스 지정 (같은 서버에 SQL Server 가 있을 때)
sc.exe config PayrollApi depend= MSSQLSERVER
sc.exe start PayrollApi
sc.exe query PayrollApi
binPath= 뒤의 공백을 빠뜨리면 조용히 실패합니다.
sc.exe 는 키= 값 형식이라 등호 뒤에 공백이 필요합니다.
경로에 공백이 있으면 안쪽 따옴표를 \" 로 이스케이프해야 합니다.
복구 옵션 — 죽었을 때 누가 다시 띄우나 🟡 중급
systemd 의 Restart=always 에 해당하는 설정
REM 1차 실패 → 5초 뒤 재시작
REM 2차 실패 → 10초 뒤 재시작
REM 3차 이후 → 60초 뒤 재시작
REM reset= 86400 : 24시간 동안 문제없으면 카운터를 0으로
sc.exe failure PayrollApi ^
reset= 86400 ^
actions= restart/5000/restart/10000/restart/60000
REM 프로세스가 갑자기 죽을 때뿐 아니라, 서비스가 오류 코드(0 이 아님)로 스스로 멈출 때도 복구를 적용
REM (코드 0 으로 정상 종료한 경우에는 다시 띄우지 않습니다)
sc.exe failureflag PayrollApi 1
# PowerShell 로 상태 보기
Get-Service PayrollApi | Format-List *
Get-Process -Name Payroll | Select-Object Id, WS, CPU, StartTime
# 최근 이벤트 로그 20건 (앱이 남긴 것)
Get-WinEvent -LogName Application -MaxEvents 200 |
Where-Object { $_.ProviderName -eq 'Payroll API' } |
Select-Object TimeCreated, LevelDisplayName, Message -First 20
이벤트 로그 · 파일 로그 함께 쓰기
builder.Logging.AddEventLog(new EventLogSettings
{
SourceName = "Payroll API", // sc.exe DisplayName 과 맞춥니다
LogName = "Application",
// 이벤트 로그는 비쌉니다 — 경고 이상만 보냅니다
Filter = (cat, lvl) => lvl >= LogLevel.Warning
});
// 일상 로그는 파일로 (Serilog — 🔷 t02 참고)
builder.Host.UseSerilog((ctx, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.WriteTo.File(
path: @"C:\svc\payroll\logs\app-.log",
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 30,
encoding: System.Text.Encoding.UTF8)); // 한글 깨짐 방지 (h13)
UseWindowsService() 가 서비스 이름으로 자동 등록하지만,
이름을 바꾸면 첫 기록에서 권한 오류가 납니다. 이때는 미리 만들어 두세요 —
New-EventLog -LogName Application -Source "Payroll API"
언제 IIS 를 건너뛰나 🟡 중급
| 상황 | IIS | Windows 서비스 | 왜 |
|---|---|---|---|
| 사내 사용자가 브라우저로 접속 | ◎ 권장 | △ | Windows 통합 인증 · 인증서 관리 · 로그가 통합됩니다 |
| 다른 서버가 부르는 내부 API | △ | ◎ 권장 | IIS 층이 없어 단순하고, 포트를 마음대로 씁니다 |
| 야간 배치 · 상시 워커 | ✗ | ◎ 권장 | HTTP 가 아예 없어도 됩니다 (🔷 f02 참고) |
| gRPC 서버 | △ | ◎ 권장 | IIS 의 HTTP/2 제약을 피합니다 |
| 같은 서버에 사이트가 여럿 | ◎ 권장 | ✗ | 80/443 을 호스트명으로 나눠 써야 합니다 |
| WebSocket 다수 연결 | △ | ◎ 권장 | IIS 는 연결당 오버헤드가 있습니다 |
방화벽 인바운드 — 잊으면 "연결이 안 돼요"
# 특정 포트만, 특정 대역만 여는 것이 원칙입니다
New-NetFirewallRule -DisplayName "Payroll API 5001" `
-Direction Inbound -Protocol TCP -LocalPort 5001 `
-RemoteAddress 10.10.0.0/16 `
-Action Allow -Profile Domain
# 확인
Get-NetFirewallRule -DisplayName "Payroll API*" |
Get-NetFirewallPortFilter
# 서버 안에서 실제로 열려 있는지
netstat -ano | findstr :5001
Get-NetTCPConnection -LocalPort 5001 -State Listen
netstat 의 왼쪽 주소를 먼저 보세요 (h13).
갱신 배포 스크립트 (서비스 방식)
$svc = 'PayrollApi'
$dir = 'C:\svc\payroll'
$new = 'C:\deploy\payroll-publish'
# 1) 내리기 — 진행 중 요청이 끝날 때까지 기다립니다
Stop-Service $svc
(Get-Service $svc).WaitForStatus('Stopped','00:01:00')
# 2) 백업 (롤백용) — 날짜 폴더로
$bak = "C:\svc\_bak\payroll-$(Get-Date -f yyyyMMdd-HHmmss)"
Copy-Item $dir $bak -Recurse
# 3) 교체 — /MIR 로 지운 파일까지 맞추되 로그는 제외
robocopy $new $dir /MIR /XD logs /R:2 /W:2 /NFL /NDL
# 4) 올리기
Start-Service $svc
(Get-Service $svc).WaitForStatus('Running','00:01:00')
# 5) 확인 — 실패하면 즉시 롤백
# ★ Invoke-WebRequest 는 503 · 연결 거부면 예외를 던집니다 — 잡지 않으면 롤백 전에 스크립트가 끝납니다
$ok = $false
try { $ok = (Invoke-WebRequest http://localhost:5001/healthz -UseBasicParsing -TimeoutSec 10).StatusCode -eq 200 } catch { }
if (-not $ok) {
Stop-Service $svc
robocopy $bak $dir /MIR /R:2 /W:2
Start-Service $svc
throw "헬스체크 실패 — 롤백했습니다"
}
Write-Host "배포 완료"
UseWindowsService() 한 줄, 등록은 sc.exe create 한 줄이고, 진짜 중요한 것은 ContentRootPath 지정(작업 디렉터리가 System32 라서)과 복구 옵션입니다.Linux 에 올리기 — systemd 로 지킵니다
자바 개발자가 가장 편하게 느끼는 갈래입니다. 톰캣을 systemd 로 띄우던 그 방식 그대로
이고, 바뀌는 것은 ExecStart 한 줄뿐입니다. 유닛 파일 전문을 줄마다 뜯어봅니다.
1) 런타임 설치 (Ubuntu 24.04 LTS)
# 우분투 24.04 기본 저장소에는 .NET 8 까지 들어 있습니다.
# .NET 10 은 Canonical 의 backports 저장소를 한 번 추가해야 받을 수 있습니다
sudo add-apt-repository -y ppa:dotnet/backports
sudo apt update
sudo apt install -y aspnetcore-runtime-10.0
# RHEL / Rocky 9
sudo dnf install -y aspnetcore-runtime-10.0
# 확인 — ASP.NET Core 런타임이 목록에 있어야 합니다
dotnet --list-runtimes
# Microsoft.AspNetCore.App 10.0.0 [/usr/lib/dotnet/shared/Microsoft.AspNetCore.App]
# Microsoft.NETCore.App 10.0.0 [/usr/lib/dotnet/shared/Microsoft.NETCore.App]
/usr/share/dotnet 에 풀고 /usr/bin/dotnet 심볼릭 링크만 걸면 됩니다.
아예 못 깔면 self-contained 게시(h09) 로 런타임을 앱에 넣어 가세요.
2) 계정 · 폴더 — 루트로 돌리지 않습니다
# 로그인 못 하는 전용 계정 (자바의 tomcat 계정과 같은 역할)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin payroll
sudo mkdir -p /opt/payroll # 배포 폴더
sudo mkdir -p /var/lib/payroll # 쓰기가 필요한 데이터 (키 · 임시)
sudo mkdir -p /var/log/payroll # 파일 로그를 쓴다면
# 배포 폴더는 읽기+실행만, 데이터 폴더만 쓰기 허용
sudo chown -R root:payroll /opt/payroll
sudo chmod -R 750 /opt/payroll
sudo chown -R payroll:payroll /var/lib/payroll /var/log/payroll
# 3) 파일 올리기
rsync -avz --delete ./publish/ deploy@srv:/tmp/payroll-new/
sudo rsync -a --delete /tmp/payroll-new/ /opt/payroll/
ExecStart 한 줄만 톰캣과 다릅니다. 나머지는 자바와 똑같습니다.유닛 파일 전문 🟢 기초
/etc/systemd/system/payroll.service — 줄마다 주석
[Unit]
Description=Payroll API (.NET 10)
Documentation=https://wiki.corp.local/payroll
# 네트워크가 준비된 뒤에 뜨게 합니다. After 는 "순서"만 보장합니다.
After=network-online.target
# Wants 는 "그 대상도 같이 켜라"는 뜻 — After 만 쓰면 순서만 있고 기동은 안 합니다.
Wants=network-online.target
# 같은 서버에 DB 가 있다면 (원격 DB 면 필요 없습니다)
# After=postgresql.service
# 60초 안에 5번 넘게 기동하면 포기합니다(무한 재시작 폭주 방지).
# ★ 이 두 줄은 [Unit] 소속입니다 — [Service] 에 두면 무시됩니다.
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
# ── 실행 방식 ─────────────────────────────────────────────
# notify 를 쓰려면 앱이 sd_notify 를 호출해야 합니다.
# .NET 은 UseSystemd() 를 쓰면 자동으로 notify 를 지원합니다.
Type=notify
# UseSystemd() 를 안 썼다면 Type=simple 로 두세요.
ExecStart=/usr/bin/dotnet /opt/payroll/Payroll.dll
# self-contained 게시라면 실행 파일을 직접 지정합니다
# ExecStart=/opt/payroll/Payroll
# ExecReload(SIGHUP) 는 두지 않습니다 — .NET 은 SIGHUP 으로 설정을 다시 읽지 않습니다.
# 설정 파일 변경은 reloadOnChange 가 감지하고, 그 밖에는 systemctl restart 입니다.
# ExecReload=/bin/kill -HUP $MAINPID
# ── 정체성 ────────────────────────────────────────────────
User=payroll
Group=payroll
# 작업 디렉터리. 지정하지 않으면 / 가 되어 상대 경로가 전부 깨집니다.
WorkingDirectory=/opt/payroll
# ── 재시작 정책 ───────────────────────────────────────────
Restart=always
RestartSec=5
# (재시작 횟수 한도는 위 [Unit] 의 StartLimit* 두 줄)
# 기동 완료를 기다리는 시간. 마이그레이션이 오래 걸리면 늘립니다.
TimeoutStartSec=120
# 종료 신호(SIGTERM) 후 강제 종료까지의 시간 — 진행 중 요청을 끝낼 여유.
TimeoutStopSec=60
KillSignal=SIGTERM
# ── 환경 변수 (application-prod.yml 을 고르는 스위치) ──────
Environment=DOTNET_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=DOTNET_PrintTelemetryMessage=false
# 비밀번호는 유닛 파일에 적지 말고 별도 파일에서 읽습니다(권한 600).
EnvironmentFile=-/etc/payroll/secrets.env
# ── 로그 ──────────────────────────────────────────────────
# 표준 출력이 그대로 journald 로 들어갑니다 (catalina.out 자리).
StandardOutput=journal
StandardError=journal
SyslogIdentifier=payroll
# ── 자원 한계 ─────────────────────────────────────────────
# 기본 1024 로는 동시 연결 수천에서 "Too many open files" 가 납니다.
LimitNOFILE=65535
# 메모리 상한 — 넘으면 커널이 프로세스를 죽입니다. 컨테이너의 memory limit 자리.
MemoryMax=4G
# CPU 상한 (200% = 코어 2개분)
# CPUQuota=200%
# ── 보안 강화 (있으면 좋고, 문제가 생기면 하나씩 끄며 확인) ─
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ProtectHome=true
# 쓰기가 필요한 폴더만 예외로 허용합니다
ReadWritePaths=/var/lib/payroll /var/log/payroll
[Install]
WantedBy=multi-user.target
등록 · 기동 · 로그
# 유닛을 고칠 때마다 반드시 daemon-reload
sudo systemctl daemon-reload
sudo systemctl enable --now payroll # 부팅 자동 시작 + 지금 시작
sudo systemctl status payroll # 상태 · 최근 로그 10줄
sudo systemctl restart payroll
sudo systemctl stop payroll
# 로그 — catalina.out 대신 journalctl 입니다
sudo journalctl -u payroll -f # tail -f
sudo journalctl -u payroll --since "10 min ago"
sudo journalctl -u payroll -p err -n 100 # 에러 이상만 100줄
sudo journalctl -u payroll --since today -o json-pretty | less
sudo journalctl -u payroll --disk-usage # 로그가 얼마나 쌓였나
# 기동 실패 원인 보기 (여기 안 나오면 앱이 아니라 systemd 문제입니다)
sudo systemctl status payroll -l --no-pager
sudo journalctl -xeu payroll
/etc/systemd/journald.conf 의 SystemMaxUse=2G ·
MaxRetentionSec=30day 로 맞춰 두면 로그 로테이션을 따로 안 해도 됩니다.
앱 쪽 준비 — systemd 통합 · 우아한 종료
dotnet add package Microsoft.Extensions.Hosting.Systemd
var builder = WebApplication.CreateBuilder(args);
// Type=notify 지원 + 로그를 journald 형식(<6> 접두)으로 씁니다.
// 이걸 켜면 journalctl -p err 필터가 제대로 동작합니다.
builder.Host.UseSystemd();
// 데이터 보호 키를 디스크에 고정 — 재시작해도 세션이 유지됩니다
builder.Services.AddDataProtection()
.PersistKeysToFileSystem(new DirectoryInfo("/var/lib/payroll/keys"))
.SetApplicationName("payroll");
// 종료 신호를 받고 나서 진행 중 요청을 끝낼 여유
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(45));
var app = builder.Build();
// 종료 순서를 로그로 남겨 두면 무중단 배포를 디버깅하기 쉽습니다
var life = app.Services.GetRequiredService<IHostApplicationLifetime>();
life.ApplicationStopping.Register(() => Log.Information("SIGTERM 수신 — 새 요청 거절 시작"));
life.ApplicationStopped .Register(() => Log.Information("모든 요청 처리 완료 — 종료"));
app.MapGet("/healthz", () => Results.Ok("ok"));
app.Run();
권한 · SELinux · 흔한 걸림돌 🟡 중급
1024 미만 포트는 루트만 열 수 있습니다
# 방법 A (권장) — 앱은 5000 에서 듣고 Nginx 가 443 을 맡습니다 (h06)
# 방법 B — 앱에 포트 바인딩 능력만 부여 (루트 권한 없이 80/443)
# 유닛 파일에 아래 두 줄을 추가합니다
# AmbientCapabilities=CAP_NET_BIND_SERVICE
# CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# 방법 C — 커널 설정으로 허용 하한을 낮춤 (서버 전체에 영향, 비권장)
sudo sysctl -w net.ipv4.ip_unprivileged_port_start=80
# SELinux(RHEL 계열) 가 켜져 있으면 Nginx 가 앱으로 못 붙습니다
# 증상: nginx 에러 로그에 (13: Permission denied)
sudo setsebool -P httpd_can_network_connect 1
# 배포 폴더에 라벨 붙이기
sudo semanage fcontext -a -t bin_t "/opt/payroll(/.*)?"
sudo restorecon -Rv /opt/payroll
# 무엇이 막혔는지 보기
sudo ausearch -m avc -ts recent | audit2why
파일 디스크립터 · 스레드 한계
# 지금 프로세스의 실제 한계 확인
pid=$(systemctl show -p MainPID --value payroll)
cat /proc/$pid/limits | grep -E "open files|processes"
# 현재 열려 있는 FD 수
ls /proc/$pid/fd | wc -l
# 소켓 상태 요약 — TIME_WAIT 이 많으면 커넥션을 재사용하지 않는 것입니다
ss -s
ss -tanp | grep 5000 | awk '{print $1}' | sort | uniq -c
ulimit -n 을 셸에서 올려도 서비스에는 반영되지 않습니다.
systemd 가 띄우는 프로세스는 유닛 파일의 LimitNOFILE 만 봅니다.
/etc/security/limits.conf 는 로그인 세션 전용입니다 —
자바에서도 똑같이 겪는 함정입니다.
Couldn't find a valid ICU package)가 나면
컨테이너나 최소 설치 이미지입니다. sudo apt install -y libicu-dev 로 해결하거나,
전역화가 필요 없다면 InvariantGlobalization=true 로 게시하세요.
단, 그러면 한국어 정렬·날짜 형식이 깨집니다.
배포 스크립트 — 무중단은 h10, 여기서는 가장 단순한 형태
#!/usr/bin/env bash
set -euo pipefail # 오류 즉시 중단 · 미정의 변수 금지 · 파이프 실패 감지
APP=payroll
DIR=/opt/$APP
SRC=${1:?사용법: deploy.sh /tmp/payroll-publish}
BAK=/opt/_bak/$APP-$(date +%Y%m%d-%H%M%S)
echo "1) 백업 → $BAK"
sudo mkdir -p "$(dirname "$BAK")"
sudo cp -a "$DIR" "$BAK"
echo "2) 서비스 중지"
sudo systemctl stop $APP
echo "3) 파일 교체 (로그·키 폴더는 건드리지 않음)"
sudo rsync -a --delete --exclude 'logs/' --exclude 'keys/' "$SRC"/ "$DIR"/
sudo chown -R root:$APP "$DIR"
sudo chmod -R 750 "$DIR"
echo "4) 서비스 시작"
sudo systemctl start $APP
echo "5) 헬스체크 (최대 30초 대기)"
for i in $(seq 1 30); do
if curl -fsS http://127.0.0.1:5000/healthz >/dev/null 2>&1; then
echo "배포 완료 (${i}초)"; exit 0
fi
sleep 1
done
echo "헬스체크 실패 — 롤백합니다" >&2
sudo systemctl stop $APP
sudo rsync -a --delete "$BAK"/ "$DIR"/
sudo systemctl start $APP
exit 1
ExecStart 한 줄뿐이고, 나머지 함정(LimitNOFILE · SELinux · 1024 미만 포트)은 자바에서 겪던 것과 완전히 같습니다.리버스 프록시 — Nginx · IIS ARR
여기는 자바와 거의 아무것도 바뀌지 않는 구간입니다. 다만 .NET 에는
반드시 켜야 하는 스위치가 하나 있습니다 — 안 켜면 접속자 IP 가 전부 127.0.0.1 로 찍힙니다.
/etc/nginx/sites-available/payroll.conf — 전문과 줄별 주석
# ── 업스트림: 앱 서버 목록 ─────────────────────────────────
upstream payroll_app {
# keepalive 를 쓰려면 server 뒤에 포트, 그리고 아래 keepalive 지시자가 필요합니다.
server 127.0.0.1:5000 max_fails=3 fail_timeout=10s;
# 2대로 늘릴 때는 여기에 줄을 추가합니다 (h10 롤링 배포)
# server 127.0.0.1:5001 max_fails=3 fail_timeout=10s;
# 유닉스 소켓이 가장 빠릅니다 (같은 서버일 때)
# server unix:/var/run/payroll.sock;
# 커넥션 재사용 — 없으면 요청마다 TCP 핸드셰이크가 일어납니다
keepalive 64;
keepalive_timeout 60s;
}
# ── http → https 강제 ─────────────────────────────────────
server {
listen 80;
listen [::]:80;
server_name payroll.corp.local;
# Let's Encrypt 갱신용 경로만 예외로 둡니다 (h08)
location /.well-known/acme-challenge/ { root /var/www/certbot; }
location / { return 308 https://$host$request_uri; }
}
# ── 본 서버 ───────────────────────────────────────────────
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # nginx 1.25+ 문법
server_name payroll.corp.local;
ssl_certificate /etc/letsencrypt/live/payroll.corp.local/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/payroll.corp.local/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# 업로드 상한 — 앱 설정(h02)보다 이쪽이 먼저 자릅니다
client_max_body_size 300m;
# 느린 클라이언트가 헤더를 질질 끄는 공격을 여기서 막습니다
client_body_timeout 60s;
client_header_timeout 20s;
# 접근 로그에 응답 시간과 업스트림 시간을 같이 남깁니다
access_log /var/log/nginx/payroll.access.log combined;
error_log /var/log/nginx/payroll.error.log warn;
# ── 정적 파일은 Nginx 가 직접 (앱까지 안 갑니다) ───────
location /static/ {
alias /opt/payroll/wwwroot/;
expires 30d;
add_header Cache-Control "public, immutable";
access_log off;
}
# ── 나머지는 전부 앱으로 ───────────────────────────────
location / {
proxy_pass http://payroll_app;
# keepalive 를 쓰려면 이 두 줄이 필수입니다 (기본은 HTTP/1.0)
proxy_http_version 1.1;
proxy_set_header Connection "";
# ★ 앱이 진짜 요청을 알 수 있게 알려 주는 네 줄 ★
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# WebSocket / SignalR 업그레이드 (🔷 w06 참고)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 타임아웃 — 리포트 생성이 오래 걸리면 여기서 504 가 납니다
proxy_connect_timeout 5s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
# 버퍼링 — 스트리밍(SSE · 대용량 다운로드)에서는 꺼야 합니다
proxy_buffering on;
proxy_buffers 16 16k;
proxy_busy_buffers_size 64k;
}
# 서버가 보내는 이벤트 스트림은 버퍼링을 끕니다
location /events {
proxy_pass http://payroll_app;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
}
# 압축 — 앱에서 하지 말고 여기서 합니다 (CPU 절약)
gzip on;
gzip_types text/plain text/css application/json application/javascript;
gzip_min_length 1024;
# 보안 헤더
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Strict-Transport-Security "max-age=31536000" always;
}
# WebSocket 업그레이드용 map 은 http 블록(nginx.conf)에 한 번만 둡니다
map $http_upgrade $connection_upgrade {
default upgrade;
# ★ 일반 요청은 빈 값 — close 로 두면 업스트림 keepalive 가 매 요청 끊깁니다
'' '';
}
적용 · 검증
sudo ln -s /etc/nginx/sites-available/payroll.conf /etc/nginx/sites-enabled/
sudo nginx -t # 문법 검사 — 이거 없이 reload 하지 마세요
sudo systemctl reload nginx # 무중단 재적용 (restart 아님)
# 프록시가 앱까지 실제로 닿는지
curl -i http://127.0.0.1:5000/healthz # 앱 직접
curl -ik https://payroll.corp.local/healthz # 프록시 경유
# 앱이 받은 헤더를 그대로 되돌려 주는 진단 엔드포인트를 잠깐 켜면 편합니다
curl -s https://payroll.corp.local/debug/headers | jq
누가 무엇을 맡을 것인가
| 일 | Nginx | 앱(Kestrel) |
|---|---|---|
| TLS 종료 | ◎ | △ (LB 가 없을 때만) |
| 정적 파일 | ◎ 훨씬 빠릅니다 | △ SPA 라면 앱도 무방 |
| gzip · brotli | ◎ | △ CPU 를 앱이 씁니다 |
| 접근 로그 | ◎ 통합 로그 | ◎ 구조화 로그 (🔷 t02) |
| 인증 · 인가 | ✗ | ◎ |
| 속도 제한 | ◎ IP 단위 | ◎ 사용자 단위 |
| 점검 페이지 | ◎ 앱이 죽어도 뜹니다 | ✗ |
ForwardedHeaders — 안 켜면 IP 가 사라집니다 🔴 고급
켜는 법 — 순서가 중요합니다
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor
| ForwardedHeaders.XForwardedProto
| ForwardedHeaders.XForwardedHost;
// ★ 신뢰할 프록시를 반드시 지정합니다.
// 기본값은 루프백만 신뢰인데, 도커·쿠버네티스에서는 프록시가 다른 IP 라
// 조용히 무시됩니다 — "켰는데 안 돼요"의 원인 1위.
// .NET 10 은 KnownIPNetworks(System.Net.IPNetwork) 를 씁니다
// (예전 KnownNetworks 는 사용 중단 — IPNetwork 이름이 두 네임스페이스에 겹쳐 헷갈리던 것)
o.KnownProxies.Clear();
o.KnownIPNetworks.Clear();
o.KnownProxies.Add(IPAddress.Parse("10.10.0.5")); // Nginx 서버
o.KnownIPNetworks.Add(System.Net.IPNetwork.Parse("10.42.0.0/16")); // 파드 대역
// 프록시가 몇 겹인지 (LB → Nginx → 앱 이면 2)
o.ForwardLimit = 2;
});
var app = builder.Build();
// ★ 반드시 파이프라인 맨 앞. 인증·리다이렉트·로깅보다 먼저여야
// 그 뒤 미들웨어들이 고쳐진 값을 봅니다.
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
X-Forwarded-For 는 클라이언트가 위조할 수 있는 헤더입니다.
신뢰 목록을 비워 두고 전부 믿게 하면 IP 기반 차단·감사 로그가 통째로 무력화됩니다.
반드시 KnownProxies 나 KnownIPNetworks 로 좁히세요.
확인 · IIS ARR 로 할 때
// 진단용 — 운영에서는 인증 뒤에 두거나 지웁니다
app.MapGet("/debug/headers", (HttpContext c) => Results.Ok(new
{
RemoteIp = c.Connection.RemoteIpAddress?.ToString(),
Scheme = c.Request.Scheme,
Host = c.Request.Host.Value,
IsHttps = c.Request.IsHttps,
Fwd = c.Request.Headers["X-Forwarded-For"].ToString(),
Proto = c.Request.Headers["X-Forwarded-Proto"].ToString()
}));
IIS ARR (Application Request Routing) 로 프록시할 때
1. IIS 관리자 → 서버 노드 → "Application Request Routing Cache"
→ 오른쪽 "Server Proxy Settings" → Enable proxy 체크
2. "Reverse rewrite host in response headers" 는 대개 해제
3. 사이트 → URL 재작성(URL Rewrite) → 규칙 추가
패턴 : (.*)
동작 : 다시 쓰기(Rewrite)
URL : http://127.0.0.1:5000/{R:1}
4. 서버 변수 허용 목록에 HTTP_X_FORWARDED_PROTO 추가 후
서버 변수 설정 : HTTP_X_FORWARDED_PROTO = https
주의 — IIS + ANCM(h03) 조합에서는 ARR 이 필요 없습니다.
ANCM 이 이미 원본 정보를 앱에 전달합니다.
proxy_pass 에 해당합니다. 다만 사내 Windows 환경이라면
ANCM 방식(h03)이 훨씬 단순합니다 — 프록시 층 자체가 사라집니다.
UseForwardedHeaders() 를 파이프라인 맨 앞에 두고 신뢰할 프록시를 명시하는 것입니다.YARP — C# 으로 만드는 게이트웨이
Spring Cloud Gateway 자리에 오는 것이 YARP(Yet Another Reverse Proxy) 입니다. Nginx 와 달리 설정이 아니라 C# 코드로 라우팅을 바꿀 수 있습니다. 언제 Nginx 대신 이걸 고르는지가 이 섹션의 핵심입니다.
가장 작은 YARP 서버
dotnet new web -o Gateway
cd Gateway
dotnet add package Yarp.ReverseProxy
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.UseForwardedHeaders(); // h06 — 게이트웨이도 프록시 뒤일 수 있습니다
app.UseAuthentication();
app.UseAuthorization();
app.MapReverseProxy(); // 이 한 줄이 전부입니다
app.Run();
routes(어디로) + clusters(누구에게) 두 개념이고,
Spring 의 predicates/filters 가
YARP 의 Match/Transforms 에 해당합니다.
appsettings.json — 라우트와 클러스터
{
"ReverseProxy": {
"Routes": {
"payroll": {
"ClusterId": "payroll-cluster",
"AuthorizationPolicy": "employee",
"RateLimiterPolicy": "per-user",
"Match": { "Path": "/pay/{**rest}" },
"Transforms": [
{ "PathRemovePrefix": "/pay" },
{ "RequestHeader": "X-Gateway", "Set": "yarp" },
{ "RequestHeadersCopy": "true" },
{ "X-Forwarded": "Set" }
]
},
"legacy": {
"ClusterId": "legacy-cluster",
"Order": 100,
"Match": { "Path": "{**catch-all}" }
}
},
"Clusters": {
"payroll-cluster": {
"LoadBalancingPolicy": "PowerOfTwoChoices",
"HealthCheck": {
"Active": {
"Enabled": true,
"Interval": "00:00:10",
"Timeout": "00:00:05",
"Policy": "ConsecutiveFailures",
"Path": "/healthz"
}
},
"Destinations": {
"d1": { "Address": "http://10.10.1.11:5000/" },
"d2": { "Address": "http://10.10.1.12:5000/" }
}
},
"legacy-cluster": {
"Destinations": {
"old": { "Address": "http://10.10.9.9:8080/" }
}
}
}
}
}
변환 · 로드밸런싱 · 헬스체크 🟡 중급
자주 쓰는 Transform
| Transform | 하는 일 | Nginx 대응 |
|---|---|---|
PathRemovePrefix | 앞부분을 떼고 넘김 | rewrite ^/pay/(.*) /$1 |
PathPrefix | 앞에 붙여 넘김 | proxy_pass .../api/ |
PathPattern | 경로를 통째로 재구성 | rewrite |
RequestHeader Set/Append | 헤더 추가·교체 | proxy_set_header |
ResponseHeader | 응답 헤더 손대기 | add_header |
X-Forwarded | 표준 전달 헤더 세트 | 네 줄짜리 proxy_set_header |
ClientCert | mTLS 클라이언트 인증서를 헤더로 | $ssl_client_cert |
// 설정으로 안 되는 것은 코드로 — 여기가 Nginx 와 갈리는 지점입니다
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
.AddTransforms(ctx =>
{
ctx.AddRequestTransform(async t =>
{
// 사내 SSO 클레임을 백엔드가 아는 헤더로 번역
var sabun = t.HttpContext.User.FindFirst("employee_no")?.Value;
if (sabun is not null)
t.ProxyRequest.Headers.Add("X-Employee-No", sabun);
// 테넌트별로 목적지를 런타임에 바꾸기 (설정으로는 불가능)
if (t.HttpContext.Request.Headers.TryGetValue("X-Tenant", out var tn)
&& tn == "hq")
t.ProxyRequest.RequestUri =
new Uri("http://10.10.2.20:5000" + t.Path);
await ValueTask.CompletedTask;
});
});
로드밸런싱 정책 · 헬스체크
| 정책 | 고르는 기준 |
|---|---|
PowerOfTwoChoices | 기본값이자 대부분의 정답. 무작위 둘 중 덜 바쁜 쪽 |
RoundRobin | 요청 비용이 균일할 때 |
LeastRequests | 요청 비용 편차가 클 때 (리포트 · 파일) |
FirstAlphabetical | 액티브-스탠바이 구성 |
Random | 서버가 많고 상태가 균일할 때 |
// 세션 어피니티 — 상태를 가진 레거시 앱을 감쌀 때만 씁니다
"SessionAffinity": {
"Enabled": true,
"Policy": "Cookie",
"FailurePolicy": "Redistribute",
"Cookie": { "Name": ".Yarp.Affinity", "SecurePolicy": "Always" }
}
/healthz 를 주기적으로 두드리고, Passive 는 별도 트래픽이 없습니다.
둘 다 켜는 것이 안전합니다.
언제 Nginx 대신 YARP 인가 🟡 중급
YARP 이 나은 경우
- 라우팅 규칙이 C# 로직일 때 — 사번·부서·권한으로 목적지가 갈립니다
- 인증을 게이트웨이에서 끝내고 싶을 때 — 사내 Entra ID / AD 를 그대로 씁니다
- 레거시를 점진적으로 교체할 때 — 경로 하나씩 새 서비스로 넘깁니다 (Strangler Fig)
- 운영팀이 Nginx 설정을 못 만지는 조직일 때 — 배포 파이프라인 안에서 관리됩니다
- Windows 서버뿐이고 Nginx 를 깔 수 없을 때
Nginx 를 그대로 두는 편이 나은 경우
- 하는 일이 단순 프록시 + TLS 종료 뿐일 때 — 프로세스를 하나 더 둘 이유가 없습니다
- 정적 파일이 많을 때 — Nginx 가 압도적으로 빠릅니다
- 이미 운영팀이 Nginx 로 표준화되어 있을 때
- 앱과 게이트웨이가 같이 죽으면 안 될 때 — 런타임이 같으면 같은 사고에 함께 넘어집니다
HTTPS · 인증서
keytool 과 JKS 에 익숙하다면 여기가 가장 낯섭니다.
.NET 은 Windows 인증서 저장소와 PFX 파일 두 세계를 오가고,
리눅스에서는 PEM 을 그대로 씁니다. TLS 를 어디서 끝낼지 정하는 것이 첫 단추입니다.
개발 인증서 — 로컬에서만
# 자기 서명 인증서를 만들고 OS 신뢰 목록에 넣습니다
dotnet dev-certs https --trust
# 상태 확인 / 삭제 후 재발급 (만료되면 브라우저가 경고를 냅니다)
dotnet dev-certs https --check --verbose
dotnet dev-certs https --clean
dotnet dev-certs https --trust
# 컨테이너에서 쓰려고 PFX 로 뽑기
dotnet dev-certs https -ep ${HOME}/.aspnet/https/dev.pfx -p "개발용비번"
dev-certs 를 절대 쓰지 마세요.
이름 그대로 개발 전용이고, 유효 기간이 짧으며 SAN 에 localhost 밖에 없습니다.
자바의 keytool -genkeypair 자체 서명과 같은 위치입니다.
Kestrel 에 인증서 물리기
{
"Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://0.0.0.0:443",
"Certificate": {
"Path": "/etc/ssl/payroll/fullchain.pfx",
"Password": "환경변수로빼세요"
}
}
}
}
}
// PEM 두 장을 그대로 쓰는 방법 (리눅스에서 가장 흔합니다)
builder.WebHost.ConfigureKestrel(o =>
o.ListenAnyIP(443, l => l.UseHttps(
X509Certificate2.CreateFromPemFile(
"/etc/letsencrypt/live/payroll/fullchain.pem",
"/etc/letsencrypt/live/payroll/privkey.pem"))));
// Windows 인증서 저장소에서 주체(subject) 이름으로 찾기 (JKS 대신 OS 저장소)
builder.WebHost.ConfigureKestrel(o =>
o.ListenAnyIP(443, l => l.UseHttps(StoreName.My, "payroll.corp.local",
allowInvalid: false, location: StoreLocation.LocalMachine)));
// 호스트명마다 다른 인증서 (SNI) — 톰캣의 SSLHostConfig 자리
builder.WebHost.ConfigureKestrel(o =>
o.ListenAnyIP(443, l => l.UseHttps(https =>
https.ServerCertificateSelector = (ctx, name) => name switch
{
"pay.corp.local" => payCert,
"appr.corp.local" => apprCert,
_ => defaultCert
})));
파일 형식 대조 — keytool 을 쓰던 사람에게 🟡 중급
| 자바 | .NET / OS | 내용물 | 변환 명령 |
|---|---|---|---|
.jks 키스토어 | .pfx / .p12 | 개인키 + 인증서 체인 (비번으로 보호) | keytool -importkeystore -destkeystore x.p12 -deststoretype PKCS12 |
.jks 트러스트스토어 | OS 신뢰 저장소 | 믿을 루트 CA 목록 | Windows: certlm.msc · Linux: /usr/local/share/ca-certificates |
.crt / .cer | 같음 | 인증서 한 장 (공개 정보) | openssl x509 -in x.cer -text -noout |
.key | 같음 | 개인키 (절대 유출 금지) | chmod 600 |
| fullchain | fullchain | 서버 인증서 + 중간 CA | cat cert.pem chain.pem > fullchain.pem |
PEM ↔ PFX 변환
# PEM(리눅스) → PFX(Windows·Kestrel)
openssl pkcs12 -export \
-out payroll.pfx \
-inkey privkey.pem \
-in cert.pem \
-certfile chain.pem \
-passout pass:비밀번호
# PFX → PEM
openssl pkcs12 -in payroll.pfx -nocerts -nodes -out privkey.pem
openssl pkcs12 -in payroll.pfx -clcerts -nokeys -out cert.pem
openssl pkcs12 -in payroll.pfx -cacerts -nokeys -out chain.pem
# 내용 확인 — SAN 에 실제 접속 도메인이 들어 있는지 반드시 봅니다
openssl x509 -in cert.pem -noout -subject -issuer -dates -ext subjectAltName
# 서버가 실제로 무엇을 보내는지 (중간 인증서 누락 진단)
openssl s_client -connect payroll.corp.local:443 -servername payroll.corp.local \
-showcerts < /dev/null 2>/dev/null | grep -E "^(subject|issuer)"
Windows 인증서 저장소 · IIS 바인딩
# PFX 를 로컬 컴퓨터 개인 저장소로 가져오기
$pw = ConvertTo-SecureString "비밀번호" -AsPlainText -Force
Import-PfxCertificate -FilePath C:\certs\payroll.pfx `
-CertStoreLocation Cert:\LocalMachine\My -Password $pw
# 지문 확인
Get-ChildItem Cert:\LocalMachine\My |
Select-Object Thumbprint, Subject, NotAfter
# IIS 바인딩에 붙이기
Import-Module WebAdministration
New-WebBinding -Name Payroll -Protocol https -Port 443 `
-HostHeader payroll.corp.local -SslFlags 1 # 1 = SNI 사용
$t = (Get-ChildItem Cert:\LocalMachine\My |
Where-Object Subject -like "*payroll*").Thumbprint
Get-Item "Cert:\LocalMachine\My\$t" |
New-Item -Path "IIS:\SslBindings\!443!payroll.corp.local" -Force
# 앱 풀 계정이 개인키를 읽을 수 있어야 합니다 (안 그러면 기동 실패)
# certlm.msc → 개인 → 인증서 우클릭 → 모든 작업 → 개인 키 관리
# → IIS AppPool\PayrollPool 에 "읽기" 부여
Keyset does not exist 가 남습니다.
자동 갱신 · 사내 CA 🟡 중급
Let's Encrypt (인터넷에 노출된 도메인)
# Linux — Nginx 플러그인이 설정까지 고쳐 줍니다
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d payroll.corp.com -m admin@corp.com --agree-tos
# 갱신은 systemd 타이머가 자동으로 (하루 두 번 검사, 30일 남으면 갱신)
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
# 갱신 후 Nginx 를 다시 읽히는 훅
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
# Windows — win-acme (wacs.exe) 가 IIS 바인딩까지 갱신해 줍니다
.\wacs.exe --target iis --siteid 2 --installation iis `
--emailaddress admin@corp.com --accepttos
# 예약 작업으로 자동 등록됩니다
Get-ScheduledTask | Where-Object TaskName -like "*win-acme*"
사내 CA · 자체 서명 (폐쇄망)
# 1) 사내 루트 CA (한 번만 · 개인키를 금고에)
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout corpCA.key -out corpCA.crt \
-subj "/C=KR/O=Corp/CN=Corp Internal Root CA"
# 2) 서버 인증서 요청 — SAN 이 반드시 있어야 합니다(브라우저가 CN 을 무시합니다)
cat > san.cnf <<'EOF'
[req]
distinguished_name=dn
req_extensions=ext
prompt=no
[dn]
CN=payroll.corp.local
[ext]
subjectAltName=DNS:payroll.corp.local,DNS:payroll,IP:10.10.1.11
EOF
openssl req -newkey rsa:2048 -nodes -keyout payroll.key \
-out payroll.csr -config san.cnf
# 3) 사내 CA 로 서명
openssl x509 -req -in payroll.csr -CA corpCA.crt -CAkey corpCA.key \
-CAcreateserial -out payroll.crt -days 825 -sha256 \
-extfile san.cnf -extensions ext
# 4) 클라이언트·서버에 루트 CA 를 신뢰시킵니다 (이걸 안 하면 전부 실패)
# Linux
sudo cp corpCA.crt /usr/local/share/ca-certificates/corp-root.crt
sudo update-ca-certificates
# Windows (GPO 로 전사 배포하는 것이 정석)
certutil -addstore -f Root corpCA.crt
update-ca-certificates 를 하면
HttpClient 가 자동으로 따릅니다 —
자바처럼 애플리케이션 전용 트러스트스토어를 따로 지정할 필요가 없습니다.
TLS 를 어디서 끝낼 것인가 🔴 고급
| 종료 지점 | 구성 | 장점 | 주의 |
|---|---|---|---|
| L4 로드밸런서 | LB(TLS) → 평문 → 앱 | 인증서 한 곳 관리 · 앱 부담 0 | LB~앱 구간이 평문 — 같은 신뢰망 안이어야 합니다 |
| Nginx / IIS | 프록시(TLS) → 루프백 평문 → Kestrel | 사내 표준 구성 · 갱신 자동화가 쉬움 | UseForwardedHeaders 필수 (h06) |
| Kestrel 직접 | 클라이언트 → Kestrel(TLS) | 층이 하나 줄어 단순 | 인증서 갱신 때 앱 재시작이 필요할 수 있습니다 |
| 양단 TLS | 프록시(TLS) → TLS → 앱 | 구간 전부 암호화 · 규정 준수 | 인증서가 두 벌 · 성능 손해 |
| mTLS | 서버·클라이언트 상호 인증 | 서버 간 통신 인증에 최적 | 인증서 배포·폐기 체계가 필요합니다 |
HSTS · 리다이렉트
builder.Services.AddHsts(o =>
{
o.MaxAge = TimeSpan.FromDays(365);
o.IncludeSubDomains = true;
o.Preload = false; // preload 목록 등록은 되돌리기가 매우 어렵습니다
});
builder.Services.AddHttpsRedirection(o => o.HttpsPort = 443);
var app = builder.Build();
app.UseForwardedHeaders();
if (!app.Environment.IsDevelopment()) app.UseHsts(); // 개발에서는 켜지 마세요
app.UseHttpsRedirection();
localhost 가 계속 https 로 강제되어 원인을 못 찾고 헤맵니다.
chrome://net-internals/#hsts 에서 지울 수 있습니다.
mTLS — 서버 간 인증
builder.WebHost.ConfigureKestrel(o =>
o.ListenAnyIP(5443, l => l.UseHttps(h =>
{
h.ServerCertificate = serverCert;
h.ClientCertificateMode = ClientCertificateMode.RequireCertificate;
h.ClientCertificateValidation = (cert, chain, errors) =>
// ★ OS 가 체인을 검증했고(사내 루트를 신뢰 저장소에 넣어 둠) 발급자도 사내 CA 인지
// errors 를 안 보면 같은 이름으로 자체 서명한 인증서도 통과합니다
errors == SslPolicyErrors.None &&
cert.Issuer.Contains("Corp Internal Root CA");
})));
builder.Services.AddAuthentication(CertificateAuthenticationDefaults.AuthenticationScheme)
.AddCertificate(o =>
{
o.AllowedCertificateTypes = CertificateTypes.Chained;
o.RevocationMode = X509RevocationMode.NoCheck; // 폐쇄망이면 NoCheck
o.Events = new CertificateAuthenticationEvents
{
OnCertificateValidated = ctx =>
{
var cn = ctx.ClientCertificate.GetNameInfo(X509NameType.SimpleName, false);
ctx.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, cn)], ctx.Scheme.Name));
ctx.Success();
return Task.CompletedTask;
}
};
});
게시(publish) 옵션 — 무엇을 서버에 올릴 것인가
자바에서 WAR 을 만들 때는 고민이 없었습니다. .NET 은 다섯 가지 형태로 게시할 수 있고, 각각 크기 · 시작 시간 · 서버 요구사항이 다릅니다. 사내 서버 상황에 맞는 하나를 고릅니다.
| 옵션 | 서버에 필요한 것 | 크기 | 시작 | 제약 | 언제 |
|---|---|---|---|---|---|
| FDD (기본) | .NET 10 런타임 설치 | 가장 작음 | 보통 | 런타임 버전이 맞아야 함 | 사내 서버 표준 — 런타임을 깔 수 있으면 이것 |
| SCD 자체 포함 | 없음 | 큼 | 보통 | OS·아키텍처별로 따로 빌드 | 서버에 아무것도 못 깔 때 · 폐쇄망 |
| 단일 파일 | 없음 | 가장 큼 | 느림 | 첫 실행 때 임시 폴더에 풀림 | 배포 단위를 exe 하나로 하고 싶을 때 |
| ReadyToRun | SCD 와 같음 | +10% | 빠름 | 플랫폼별 빌드 필수 | 기동 시간이 중요할 때 (배치 · 서버리스) |
| NativeAOT | 없음 | 작음 | 가장 빠름 | 리플렉션 · 동적 코드 생성 불가 | 최소 API · 짧은 CLI. EF Core 는 제약이 큽니다 |
명령 다섯 개
# ① FDD — 사내 서버 표준. 서버에 aspnetcore-runtime-10.0 이 있어야 합니다
dotnet publish -c Release -o out/fdd
# ② SCD — 런타임을 통째로 담아 갑니다
dotnet publish -c Release -r linux-x64 --self-contained true -o out/scd
# ③ 단일 파일 — 실행 파일 하나로
dotnet publish -c Release -r win-x64 --self-contained true \
-p:PublishSingleFile=true \
-p:IncludeNativeLibrariesForSelfExtract=true -o out/single
# ④ ReadyToRun — 미리 기계어로 컴파일해 기동을 앞당깁니다
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishReadyToRun=true -o out/r2r
# ⑤ NativeAOT — 완전 네이티브. 리눅스에서는 clang·zlib 이 필요합니다
dotnet publish -c Release -r linux-x64 -p:PublishAot=true -o out/aot
# RID(런타임 식별자) — 서버 OS 에 맞게
# win-x64 · win-arm64 · linux-x64 · linux-musl-x64(알파인) · linux-arm64 · osx-arm64
csproj 에 고정해 두기
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<!-- 기동 최적화 -->
<PublishReadyToRun>true</PublishReadyToRun>
<TieredPGO>true</TieredPGO>
<!-- 운영 진단을 위해 심볼은 반드시 남깁니다 -->
<DebugType>portable</DebugType>
<IncludeSymbolsInSingleFile>true</IncludeSymbolsInSingleFile>
<!-- 버전 — 배포된 것이 무엇인지 알 수 있어야 합니다 -->
<Version>2026.8.3</Version>
<SourceRevisionId>$(GIT_SHA)</SourceRevisionId>
<!-- 게시 폴더에서 같은 이름의 파일이 겹치면 경고 대신 오류로 (.NET 6+ 기본값 true) -->
<ErrorOnDuplicatePublishOutputFiles>true</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
/version 엔드포인트 하나가 장애 대응 시간을 크게 줄입니다 —
"지금 서버에 올라간 게 몇 번 빌드인가"를 묻지 않아도 됩니다.
트리밍과 NativeAOT — 함정 🔴 고급
트리밍이 무엇을 지우나
dotnet publish -c Release -r linux-x64 --self-contained true \
-p:PublishTrimmed=true -p:TrimMode=full -o out/trim
# 빌드 로그에 이런 경고가 나오면 반드시 읽으세요
# IL2026: Using member 'JsonSerializer.Serialize' which has
# 'RequiresUnreferencedCodeAttribute' can break when trimming
트리밍은 정적으로 참조되지 않은 코드를 지웁니다. 문제는
리플렉션으로만 부르는 코드는 참조가 안 보인다는 점입니다.
자바의 native-image(GraalVM) 에서 겪는 문제와 정확히 같습니다.
JsonSerializer 리플렉션 기반 직렬화 · EF Core 마이그레이션 ·
Activator.CreateInstance · AutoMapper · 일부 DI 스캔.
운영에 올린 뒤 특정 화면에서만 터지는 형태로 나타나므로 가장 위험합니다.
깨지지 않게 만드는 방법
// ① JSON 은 소스 생성기로 — 리플렉션을 아예 안 씁니다
[JsonSerializable(typeof(PayrollDto))]
[JsonSerializable(typeof(List<PayrollDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
// ② 지워지면 안 되는 타입을 못박기
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(LegacyPlugin))]
static void KeepAlive() { }
<!-- ③ 특정 어셈블리는 트리밍에서 제외 -->
<ItemGroup>
<TrimmerRootAssembly Include="Payroll.Domain" />
</ItemGroup>
<!-- ④ 경고를 에러로 올려 놓고 하나씩 잡습니다 -->
<PropertyGroup>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<WarningsAsErrors>IL2026;IL2075;IL3050</WarningsAsErrors>
</PropertyGroup>
runtimeconfig.json — 자바의 JVM 플래그 자리
{
"runtimeOptions": {
"tfm": "net10.0",
"framework": { "name": "Microsoft.AspNetCore.App", "version": "10.0.0" },
"configProperties": {
"System.GC.Server": true,
"System.GC.Concurrent": true,
"System.GC.RetainVM": true,
"System.Threading.ThreadPool.MinThreads": 32,
"System.Globalization.Invariant": false
}
}
}
<!-- csproj 에서 지정하면 위 파일이 자동 생성됩니다 -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
<ThreadPoolMinThreads>32</ThreadPoolMinThreads>
</PropertyGroup>
롤 포워드 — 런타임 버전이 안 맞을 때
# 앱은 10.0.0 으로 빌드했는데 서버에 10.0.4 만 있을 때
# 기본 정책(Minor)은 패치 버전 차이를 자동으로 흡수합니다.
# 정책을 명시하려면
dotnet --roll-forward LatestPatch Payroll.dll # ★ 호스트 옵션은 dll 앞에 (뒤에 쓰면 앱 인자가 됩니다)
# Disable | LatestPatch | Minor(기본) | LatestMinor | Major | LatestMajor
# 환경 변수로도 됩니다
export DOTNET_ROLL_FORWARD=LatestMinor
# 서버에 어떤 런타임이 있는지 확인
dotnet --list-runtimes
--roll-forward 를 조정하세요.
자바의 "Unsupported major.minor version" 과 같은 자리입니다.
무중단 배포 — 끊기지 않게 갈아 끼우기
톰캣의 핫 디플로이 같은 개념이 .NET 에는 없습니다. 프로세스를 통째로 바꾸는 것이 배포입니다. 그래도 사용자가 끊김을 못 느끼게 만드는 방법이 있습니다. (게임 서버 점검 · 세션 이전 관점은 🔷 k09 에 있습니다.)
서버가 1대일 때 — IIS app_offline.htm
사이트 루트에 app_offline.htm 파일을 놓으면
① ANCM 이 즉시 앱을 종료 신호로 내리고
② 이후 모든 요청에 그 파일의 내용을 503 으로 돌려줍니다
③ 파일을 지우면 다음 요청에서 앱이 다시 뜹니다
톰캣의 점검 페이지를 Apache 가 대신 띄우던 것과 같은 그림입니다.
<!-- app_offline.htm — 512바이트를 넘겨야 IE 가 자체 오류 페이지를 안 냅니다 -->
<!doctype html>
<html lang="ko"><head><meta charset="utf-8">
<title>시스템 점검 중</title></head>
<body style="font-family:sans-serif;text-align:center;padding:80px">
<h1>급여 시스템 점검 중입니다</h1>
<p>예상 완료 시각: 오늘 02:30</p>
<p>문의: 정보시스템팀 내선 1234</p>
</body></html>
$site = 'C:\inetpub\payroll'
Copy-Item C:\deploy\app_offline.htm "$site\app_offline.htm"
Start-Sleep -Seconds 3 # 진행 중 요청이 끝날 여유
robocopy C:\deploy\publish $site /MIR /XF app_offline.htm /XD logs
Remove-Item "$site\app_offline.htm"
Invoke-WebRequest http://localhost/healthz -UseBasicParsing | Out-Null # 예열
IIS 앱 풀 오버랩 재활용
IIS 는 앱 풀을 재활용할 때 새 작업자 프로세스를 먼저 띄우고 준비가 끝나면 새 요청을 그쪽으로 보냅니다. 옛 프로세스는 진행 중 요청을 마치고 내려갑니다. 파일만 안 바뀌면 이것만으로 무중단 재시작이 됩니다.
REM 오버랩 켜기 (기본 true 지만 확인)
%APPCMD% set apppool /apppool.name:PayrollPool ^
/recycling.disallowOverlappingRotation:false
REM 예열 — 새 프로세스가 첫 요청을 받기 전에 미리 초기화
%APPCMD% set app "Payroll/" /preloadEnabled:true
%APPCMD% set config -section:system.applicationHost/sites ^
/+"[name='Payroll'].applicationDefaults.preloadEnabled:true"
REM 재활용
%APPCMD% recycle apppool /apppool.name:PayrollPool
*.dll 이 잠겨 있어 복사가 실패하기도 합니다.
그래서 파일 교체에는 app_offline.htm 이 필요하고,
오버랩 재활용은 설정만 바뀔 때 유용합니다.
Linux — systemd 재시작 중 요청은 어떻게 되나 🟡 중급
소켓 활성화로 연결을 잃지 않기
# /etc/systemd/system/payroll.socket
[Unit]
Description=Payroll API socket
[Socket]
ListenStream=127.0.0.1:5000
# 앱이 죽어 있는 동안에도 systemd 가 소켓을 잡고 연결을 큐에 쌓아 둡니다.
Accept=no
[Install]
WantedBy=sockets.target
sudo systemctl enable --now payroll.socket
sudo systemctl restart payroll.service
# → 재시작하는 몇 초 동안 들어온 연결은 커널 큐에서 기다렸다가 처리됩니다.
# "연결 거부(ECONNREFUSED)" 대신 "조금 느림" 이 됩니다.
# ★ 앱도 이 소켓을 넘겨받아야 합니다 — ConfigureKestrel(o => o.UseSystemd()).
# ASPNETCORE_URLS 로 같은 127.0.0.1:5000 을 직접 열면 EADDRINUSE 로 뜨지 않습니다.
Nginx 2대 롤링 — 실제 스크립트
#!/usr/bin/env bash
set -euo pipefail
SRC=$1
PORTS=(5000 5001)
for p in "${PORTS[@]}"; do
echo "── 포트 $p 교체 ──"
# 1) LB 에서 빼기 — 앱이 헬스체크에 실패하도록 만듭니다
curl -fsS -X POST "http://127.0.0.1:$p/admin/drain" \
-H "X-Admin-Key: $ADMIN_KEY"
# 2) 앞단이 이 인스턴스를 뺄 때까지 대기
# ★ 오픈소스 Nginx 는 /readyz 를 스스로 찌르지 않습니다(능동 헬스체크는 Plus 전용).
# LB 가 /readyz 를 보는 구성이거나, 여기서 upstream 의 해당 server 를 down 으로 바꾸고 reload 해야 합니다
sleep 12
# 3) 교체 · 재시작
sudo rsync -a --delete "$SRC"/ "/opt/payroll-$p"/
sudo systemctl restart "payroll@$p"
# 4) 살아날 때까지 대기 (최대 60초)
for i in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:$p/healthz" >/dev/null 2>&1; then
echo "포트 $p 정상 (${i}초)"; break
fi
[ "$i" -eq 60 ] && { echo "기동 실패 — 중단"; exit 1; }
sleep 1
done
done
echo "롤링 배포 완료"
드레인 · 헬스체크를 앱이 지원해야 합니다
// 준비 상태를 뒤집을 수 있는 플래그
public sealed class ReadinessState { public volatile bool Ready = true; }
builder.Services.AddSingleton<ReadinessState>();
builder.Services.AddHealthChecks()
// liveness — 프로세스가 살아 있나 (재시작 판단용)
.AddCheck("self", () => HealthCheckResult.Healthy(), tags: ["live"])
// readiness — 트래픽을 받아도 되나 (LB 판단용)
.AddCheck<ReadyCheck>("ready", tags: ["ready"])
.AddDbContextCheck<PayrollDb>("db", tags: ["ready"]);
var app = builder.Build();
app.MapHealthChecks("/healthz",
new() { Predicate = c => c.Tags.Contains("live") });
app.MapHealthChecks("/readyz",
new() { Predicate = c => c.Tags.Contains("ready") });
// 배포 스크립트가 부르는 드레인 — 준비 상태만 내리고 진행 중 요청은 마칩니다
app.MapPost("/admin/drain", (ReadinessState s) => { s.Ready = false; return Results.Ok(); })
.RequireAuthorization("admin");
// 종료 순서를 로그로
var life = app.Services.GetRequiredService<IHostApplicationLifetime>();
life.ApplicationStopping.Register(() =>
app.Services.GetRequiredService<ReadinessState>().Ready = false);
DB 마이그레이션과 배포 순서 🔴 고급
확장-축소(Expand–Contract) — 두 번에 나눠 배포
-- ❌ 한 번에 하면 무중단이 불가능합니다
ALTER TABLE Employee RENAME COLUMN Name TO FullName;
-- 구버전 앱은 Name 을, 신버전은 FullName 을 찾습니다.
-- 롤링 중에는 두 버전이 동시에 살아 있으므로 반드시 한쪽이 깨집니다.
-- ✅ 1차 배포 (확장) — 새 컬럼을 추가만 합니다
ALTER TABLE Employee ADD FullName NVARCHAR(100) NULL;
UPDATE Employee SET FullName = Name WHERE FullName IS NULL;
-- 앱 v2: 쓸 때는 둘 다, 읽을 때는 FullName 우선
-- ✅ 2차 배포 (축소) — v2 가 전부 뜬 뒤 다음 배포에서
ALTER TABLE Employee DROP COLUMN Name;
-- 앱 v3: FullName 만 사용
dotnet ef database update 를 앱 기동 중에 돌리지 마세요.
롤링 중이면 여러 인스턴스가 동시에 마이그레이션을 시도합니다.
SQL 스크립트를 뽑아 배포 파이프라인에서 한 번만 실행하는 것이 정석입니다.
스크립트로 뽑아서 적용
# 현재 DB 버전에서 최신까지의 SQL 을 파일로 생성
dotnet ef migrations script --idempotent -o migrate.sql
# --idempotent 는 "이미 적용된 것은 건너뛰는" SQL 을 만듭니다.
# 어떤 상태의 DB 에 돌려도 안전합니다.
# 검토 후 DBA 승인 → 배포 창에서 적용
sqlcmd -S dbsrv -d payroll -i migrate.sql -b
# 되돌릴 스크립트도 미리 뽑아 둡니다
dotnet ef migrations script 20260801_Add 20260715_Prev -o rollback.sql
배포 순서 (하위 호환 스키마 원칙)
1. 마이그레이션 적용 ← 구버전 앱이 계속 돌 수 있어야 함
2. 앱 롤링 배포 (v2)
3. 확인 · 관찰 (하루~일주일)
4. 다음 릴리스에서 축소 마이그레이션
롤백은 역순이 아니라 "앱만 되돌리기" 입니다.
스키마를 되돌리는 건 마지막 수단 — 그래서 1번이 하위 호환이어야 합니다.
app_offline.htm(IIS) 또는 소켓 활성화(systemd)로 중단 시간을 초 단위로 줄이는 것이 현실적인 목표입니다.컨테이너로 올리기
게임 API 를 Docker 로 띄우는 기본기는 🔷 w11 에 있습니다. 여기서는 사내 업무 시스템 운영 관점 — 이미지 태그 고르기, 비루트 실행, 컨테이너 메모리 제한과 GC 의 관계에 집중합니다.
멀티스테이지 Dockerfile — 줄별 주석
# ── 1단계: 빌드 ────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# csproj 만 먼저 복사해서 restore — 소스가 바뀌어도 이 층은 캐시가 듣습니다
COPY ["Payroll.Api/Payroll.Api.csproj", "Payroll.Api/"]
COPY ["Payroll.Domain/Payroll.Domain.csproj", "Payroll.Domain/"]
COPY ["Directory.Packages.props", "nuget.config", "./"]
RUN dotnet restore "Payroll.Api/Payroll.Api.csproj"
# 이제 전체 소스
COPY . .
RUN dotnet publish "Payroll.Api/Payroll.Api.csproj" \
-c Release -o /app/publish \
/p:UseAppHost=false # 컨테이너에서는 네이티브 런처가 필요 없습니다
# ── 2단계: 실행 ────────────────────────────────────────────
# chiseled = 셸·패키지매니저 없음 · 비루트 기본 · CVE 표면이 작습니다
FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
# 비루트 사용자(app · UID 1654) 로 실행 — chiseled 이미지는 기본이 비루트입니다
USER $APP_UID
COPY --from=build /app/publish .
# 8080 은 .NET 8 부터의 기본 포트입니다 (예전 80 에서 바뀌었습니다)
EXPOSE 8080
ENV ASPNETCORE_HTTP_PORTS=8080 \
DOTNET_ENVIRONMENT=Production \
# 컨테이너에서는 stdout 로그가 정석입니다
Logging__Console__FormatterName=json
ENTRYPOINT ["dotnet", "Payroll.Api.dll"]
베이스 이미지 태그 고르기
| 태그 | 크기 | 셸 | 언제 |
|---|---|---|---|
10.0 (Debian) | ~215 MB | 있음 | 디버깅이 잦은 초기 · 네이티브 의존성이 많을 때 |
10.0-noble-chiseled | ~110 MB | 없음 | 운영 기본값 — 취약점 표면이 가장 작습니다 |
10.0-alpine | ~105 MB | 있음(ash) | musl 기반 · 일부 네이티브 라이브러리가 안 맞습니다 |
...-chiseled-extra | ~125 MB | 없음 | ICU·시간대 데이터가 필요할 때 (한글 정렬) |
10.0-composite | ~120 MB | 없음 | 기동이 조금 더 빠릅니다 (미리 링크됨) |
docker exec -it ... bash 가 안 됩니다.
진단은 사이드카 컨테이너나 dotnet-monitor 로 합니다.
운영 안정성과 디버깅 편의를 맞바꾸는 선택입니다.
chiseled-extra 를 쓰거나
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=0 과 함께 ICU 를 넣어야 합니다.
기본 chiseled 는 ICU 가 없어 invariant 모드로 떨어집니다.
SDK 없이 이미지 만들기
# .NET SDK 컨테이너 지원 — Dockerfile 이 아예 없어도 됩니다
dotnet publish -c Release /t:PublishContainer \
-p:ContainerRepository=corp/payroll \
-p:ContainerImageTag=2026.8.3 \
-p:ContainerFamily=noble-chiseled \
-p:ContainerUser=app # .NET 8+ 이미지의 비루트 사용자 (UID 1654)
# 사내 레지스트리로 바로 밀기
dotnet publish -c Release /t:PublishContainer \
-p:ContainerRegistry=registry.corp.local:5000
# compose — 사내 단일 서버에서 가장 흔한 형태
services:
api:
image: registry.corp.local:5000/corp/payroll:2026.8.3
restart: unless-stopped
environment:
DOTNET_ENVIRONMENT: Production
ConnectionStrings__Main: ${DB_CONN} # .env 파일에서
ports: ["127.0.0.1:5000:8080"] # 루프백만 — Nginx 가 앞에
deploy:
resources:
limits: { memory: 2G, cpus: "2.0" }
reservations: { memory: 512M }
healthcheck:
# chiseled 엔 curl 이 없습니다 — --health 인자를 받으면 /healthz 를 찔러 보고
# 종료 코드로 답하는 처리를 앱에 직접 넣어 둔 경우의 예입니다
test: ["CMD", "/app/Payroll.Api", "--health"]
interval: 15s
timeout: 3s
retries: 3
start_period: 40s
logging:
driver: json-file
options: { max-size: "50m", max-file: "5" }
컨테이너 메모리 제한과 GC 🔴 고급
.NET 은 cgroup 제한을 읽습니다
자바에서 -XX:MaxRAMPercentage 를 안 주면 컨테이너 밖의 물리 메모리를 보고
힙을 잡아 OOMKill 이 나던 문제가 있었습니다.
.NET Core 3.0 부터는 cgroup 제한을 자동으로 읽습니다 — 기본 동작이 안전합니다.
# 컨테이너 안에서 런타임이 인식한 값 확인
docker exec payroll dotnet-counters monitor \
--counters System.Runtime --process-id 1
# GCHeapHardLimit 은 기본적으로 컨테이너 제한의 75% 로 잡힙니다
# 예: limits.memory = 2G → 힙 상한 약 1.5G
# 필요하면 명시적으로 조정
DOTNET_GCHeapHardLimitPercent=0x32 # = 50%. ★ GC 환경 변수 값은 16진수 (50 이라 쓰면 0x50 = 80%)
DOTNET_GCConserveMemory=5 # 0~9 · 높을수록 메모리를 아낍니다
DOTNET_gcServer=1 # 서버 GC (h12)
DOTNET_GCHeapCount=2 # 서버 GC 힙 개수 = CPU 제한과 맞춤
CPU 제한과 서버 GC — 가장 흔한 함정
판단 기준
CPU 제한 < 1 코어 → 워크스테이션 GC (DOTNET_gcServer=0)
CPU 제한 1~2 코어 → 재 보고 결정. 대개 워크스테이션이 유리
CPU 제한 ≥ 2 코어 → 서버 GC + DOTNET_GCHeapCount 를 코어 수에 맞춤
참고: .NET 은 cgroup 의 cpu.max 를 읽어 Environment.ProcessorCount 를
보정하지만, 소수점 코어(0.5)는 1로 올림됩니다.
사내 레지스트리 · 설정 주입 🟡 중급
폐쇄망 레지스트리 · 이미지 반입
# 사내 레지스트리에 로그인 · 밀기
docker login registry.corp.local:5000
docker tag corp/payroll:2026.8.3 registry.corp.local:5000/corp/payroll:2026.8.3
docker push registry.corp.local:5000/corp/payroll:2026.8.3
# 인터넷이 아예 없는 망으로 옮길 때 — 파일로 반입
docker save registry.corp.local:5000/corp/payroll:2026.8.3 \
| gzip > payroll-2026.8.3.tar.gz
# (USB · 승인된 반입 경로로 이동)
gunzip -c payroll-2026.8.3.tar.gz | docker load
# 베이스 이미지도 미리 당겨 사내 레지스트리에 미러링해 둡니다
docker pull mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled
docker tag mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled \
registry.corp.local:5000/dotnet/aspnet:10.0-noble-chiseled
docker push registry.corp.local:5000/dotnet/aspnet:10.0-noble-chiseled
latest 태그를 운영에 쓰지 마세요.
어제 뜬 컨테이너와 오늘 뜬 컨테이너가 다른 코드일 수 있고,
장애가 났을 때 무엇이 올라가 있었는지 증명할 수 없습니다.
태그는 빌드 번호나 커밋 해시로 고정합니다.
설정과 비밀은 이미지에 굽지 않습니다
# ❌ 이렇게 하면 이미지에 비밀번호가 영원히 남습니다
# (레이어 히스토리에 그대로 보입니다 — docker history 로 확인 가능)
# ENV ConnectionStrings__Main="Server=db;Password=1234"
# ✅ 실행 시 주입 — 밑줄 두 개가 JSON 의 콜론(:) 을 뜻합니다
docker run -d --name payroll \
-e DOTNET_ENVIRONMENT=Production \
-e ConnectionStrings__Main="$DB_CONN" \
-e Jwt__Secret="$JWT_SECRET" \
-p 127.0.0.1:5000:8080 \
registry.corp.local:5000/corp/payroll:2026.8.3
# ✅ 파일로 마운트 — 값이 길거나 여러 개일 때
docker run -d \
-v /etc/payroll/appsettings.Production.json:/app/appsettings.Production.json:ro \
-v /var/lib/payroll/keys:/app/keys \
registry.corp.local:5000/corp/payroll:2026.8.3
/readyz, 라이브니스는 /healthz
로 나누고(h10), terminationGracePeriodSeconds 를 앱의
ShutdownTimeout 보다 크게 잡습니다. 그러지 않으면 진행 중 요청이 SIGKILL 로 잘립니다.
쿠버네티스 자체의 구조는 서버기술 가이드 쪽이 더 자세합니다.
성능 튜닝 — 무엇을 재고 무엇을 돌리나
자바에서 -Xmx · -XX:+UseG1GC 를 만지던 자리입니다.
.NET 은 손댈 스위치가 훨씬 적은데, 그 몇 개가 결정적입니다.
먼저 재는 법부터 익히고, 그다음에 돌립니다.
GC 모드 — 자바의 GC 선택에 해당합니다
| 워크스테이션 GC | 서버 GC | |
|---|---|---|
| 힙 개수 | 1개 | 코어당 1개 |
| GC 스레드 | 1개(백그라운드 포함 2) | 코어당 1개 |
| 메모리 사용 | 낮음 | 높음 |
| 처리량 | 보통 | 높음 |
| 일시 정지 | 짧고 잦음 | 길고 드묾 |
| 기본값 | 콘솔·데스크톱 | ASP.NET Core (자동 켜짐) |
| 자바 비유 | SerialGC 에 가까움 | G1/Parallel 에 가까움 |
# 확인 — 지금 어느 모드로 돌고 있나
dotnet-counters monitor -n Payroll --counters System.Runtime
# 바꾸기 (환경 변수가 가장 확실합니다)
DOTNET_gcServer=1 # 서버 GC
DOTNET_gcConcurrent=1 # 백그라운드 수거 (일시 정지 단축)
DOTNET_GCConserveMemory=0 # 0~9 · 메모리가 빡빡하면 5 정도
ThreadPool · TieredPGO
// 기동 직후 스파이크에 대비해 최소 스레드를 올려 둡니다.
// 기본값은 코어 수 — 부팅 직후 동시 요청이 몰리면 500ms 씩 지연이 생깁니다.
ThreadPool.SetMinThreads(workerThreads: 64, completionPortThreads: 64);
// 지금 몇 개나 쓰고 있나 (진단용)
ThreadPool.GetAvailableThreads(out var w, out var io);
ThreadPool.GetMaxThreads(out var mw, out var mio);
Console.WriteLine($"worker {mw - w}/{mw} · io {mio - io}/{mio}");
<PropertyGroup>
<!-- 실행 프로파일을 보고 재컴파일 — .NET 8 부터 기본 켜짐 -->
<TieredPGO>true</TieredPGO>
<!-- 기동 직후 성능이 중요하면 R2R 과 함께 -->
<PublishReadyToRun>true</PublishReadyToRun>
<!-- 벤치마크 전용: 계층화 컴파일을 꺼서 편차를 없앱니다(운영에서는 켜 두세요) -->
<!-- <TieredCompilation>false</TieredCompilation> -->
</PropertyGroup>
SetMinThreads 는 증상을 늦출 뿐 치료가 아닙니다.
커넥션 풀 · 압축 · 캐싱 🟡 중급
DB · HTTP 커넥션 풀
# SQL Server 연결 문자열 — HikariCP 설정에 해당합니다
Server=dbsrv;Database=payroll;
Min Pool Size=10; # 미리 열어 둘 연결 (기동 후 첫 요청 지연 방지)
Max Pool Size=200; # 기본 100 · DB 의 max_connections 와 맞춰야 합니다
Connect Timeout=5; # 연결 수립 대기 (기본 15초는 너무 깁니다)
Command Timeout=30;
Pooling=true;
Application Name=PayrollApi; # DB 세션 목록에서 누가 붙었는지 보입니다
TrustServerCertificate=false;
Encrypt=true;
// HttpClient — IHttpClientFactory 를 쓰는 이유는 소켓 고갈 방지입니다 (🔷 f09 참고)
builder.Services.AddHttpClient("erp", c =>
{
c.BaseAddress = new Uri("https://erp.corp.local/");
c.Timeout = TimeSpan.FromSeconds(20);
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(5), // DNS 변경 반영
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
MaxConnectionsPerServer = 100, // 기본은 사실상 무제한
EnableMultipleHttp2Connections = true
});
응답 압축 · 출력 캐싱
// 프록시가 앞에 있으면 압축은 거기서 하는 편이 낫습니다 (h06).
// 프록시가 없을 때만 앱에서 켭니다.
builder.Services.AddResponseCompression(o =>
{
o.EnableForHttps = true; // 기본은 false (BREACH 공격 우려)
o.Providers.Add<BrotliCompressionProvider>();
o.Providers.Add<GzipCompressionProvider>();
o.MimeTypes = ["application/json", "text/plain", "text/csv"];
});
// 출력 캐싱 — 같은 응답을 서버 메모리에 저장합니다 (.NET 7+)
builder.Services.AddOutputCache(o =>
{
o.AddBasePolicy(b => b.Expire(TimeSpan.FromSeconds(10)));
o.AddPolicy("codes", b => b
.Expire(TimeSpan.FromHours(6))
.SetVaryByQuery("type")
.Tag("master-data"));
});
var app = builder.Build();
app.UseResponseCompression();
app.UseOutputCache();
// 공통 코드처럼 거의 안 바뀌는 조회에 딱 맞습니다
app.MapGet("/codes", GetCodes).CacheOutput("codes");
// 마스터 데이터가 바뀌면 무효화
app.MapPost("/admin/codes", async (IOutputCacheStore s, CancellationToken ct) =>
{
await s.EvictByTagAsync("master-data", ct);
return Results.Ok();
});
재기 — 부하 테스트와 진단 도구 🔴 고급
부하 걸기
# bombardier — 설치가 가장 간단합니다 (단일 실행 파일)
bombardier -c 200 -d 60s -l https://payroll.corp.local/api/employees
# -c 동시 연결 200 · -d 60초 · -l 지연 분포 출력
# k6 — 시나리오가 있는 부하 시험
cat > load.js <<'EOF'
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 50 }, // 서서히 올리고
{ duration: '2m', target: 200 }, // 유지하며 관찰
{ duration: '30s', target: 0 }, // 내립니다
],
thresholds: { http_req_duration: ['p(95)<500'] },
};
export default function () {
const r = http.get('https://payroll.corp.local/api/employees');
check(r, { 'status 200': (x) => x.status === 200 });
sleep(1);
}
EOF
k6 run load.js
진단 도구 설치와 사용
# 전역 도구로 설치 (자바의 jcmd·jstack·jmap 자리)
dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-trace
dotnet tool install -g dotnet-dump
dotnet tool install -g dotnet-gcdump
dotnet tool install -g dotnet-monitor
# ① 실시간 지표 — 가장 먼저 이걸 봅니다
dotnet-counters monitor -n Payroll \
--counters System.Runtime,Microsoft.AspNetCore.Hosting
# 주목할 것
# ThreadPool Queue Length : 0 이 아니면 스레드 부족
# GC Heap Size : 계속 오르면 누수
# Gen 2 GC Count : 자주 오르면 큰 객체 남용
# Requests/sec · Current Requests
# ② 30초 추적 — 어디서 시간을 쓰는지
dotnet-trace collect -n Payroll --duration 00:00:30 \
--providers Microsoft-DotNETCore-SampleProfiler
# 결과 .nettrace 를 Visual Studio 나 PerfView 로 엽니다
# ③ 힙 스냅샷 — 무엇이 메모리를 잡고 있나 (jmap 자리)
dotnet-gcdump collect -n Payroll
# ④ 전체 덤프 — 마지막 수단 (프로세스가 잠깐 멈춥니다)
dotnet-dump collect -n Payroll --type Full -o /tmp/payroll.dmp
dotnet-dump analyze /tmp/payroll.dmp
# > clrstack -all (jstack 자리)
# > dumpheap -stat (jmap -histo 자리)
# > gcroot <주소> (무엇이 이 객체를 붙잡고 있나)
운영 · 문제 해결 사전
새벽에 전화를 받았을 때 펼쳐 놓고 쓰는 표입니다. 증상 → 원인 → 해결 순으로 정리했고, 뒤에 진단 흐름도와 도구 선택도를 붙였습니다. (게임 서버 장애 시나리오는 🔷 k11, C# 언어·컴파일 오류는 🔷 t12 에 있습니다.)
증상 → 원인 → 해결 🟢 기초
| 증상 (화면·로그에 뜨는 것) | 원인 | 해결 |
|---|---|---|
| HTTP 502.5 — Process Failure (IIS) | ANCM 이 앱 프로세스를 못 띄웠습니다. 대개 런타임 없음 · processPath 오타 · 시작 시간 초과 | stdoutLogEnabled="true" 로 잠깐 켜고 logs\stdout*.log 를 봅니다. dotnet --list-runtimes 로 버전 확인. startupTimeLimit 상향 |
| HTTP 500.30 — In-Process Start Failure | 앱은 떴는데 Program.cs 에서 예외로 죽었습니다 (연결 문자열 · 필수 설정 누락 · DI 등록 실패) | 이벤트 뷰어 → 응용 프로그램 로그에 스택이 남습니다. 서버에서 dotnet Payroll.dll 을 직접 실행해 보면 즉시 보입니다 |
| HTTP 500.19 — Configuration Error | web.config 문법 오류, 또는 Hosting Bundle 미설치로 AspNetCoreModuleV2 가 없음 | Hosting Bundle 재설치(복구) 후 net stop was /y && net start w3svc. XML 문법 확인 |
| HTTP 500.21 — Module Not Recognized | IIS 가 ANCM(AspNetCoreModuleV2) 을 모릅니다 — Hosting Bundle 미설치, 또는 IIS 보다 먼저 설치 | Hosting Bundle 설치 · 복구 후 net stop was /y → net start w3svc (h03) |
| HTTP 504 Gateway Timeout | 프록시가 기다리다 포기. Nginx proxy_read_timeout · IIS requestTimeout 초과 | 느린 요청 자체를 고치는 게 우선. 리포트라면 비동기 작업 + 폴링으로 바꾸고, 불가피하면 타임아웃 상향 (h06) |
| HTTP 413 Payload Too Large | 업로드 상한. 잘리는 지점이 네 군데 — Nginx · IIS requestLimits · Kestrel Limits · FormOptions | 네 곳을 모두 올립니다. 앱 로그에 아무것도 없으면 가장 앞단(프록시)에서 잘린 것 (h02) |
| Address already in use / EADDRINUSE | 포트를 다른 프로세스가 잡고 있습니다. 이전 인스턴스가 안 죽었거나 IIS 가 점유 | Linux ss -ltnp | grep 5000 · Windows netstat -ano | findstr :5000 후 tasklist /fi "pid eq 1234" |
| Permission denied (Linux, 포트 80/443) | 1024 미만 포트는 루트 권한이 필요합니다 | Nginx 를 앞에 두거나 유닛에 AmbientCapabilities=CAP_NET_BIND_SERVICE (h05) |
| Access to the path ... is denied | 배포 폴더 · 로그 폴더 쓰기 권한 없음 | Windows icacls ... /grant "IIS AppPool\이름":(OI)(CI)M · Linux chown -R payroll:payroll /var/log/payroll |
| Keyset does not exist (기동 실패) | 인증서 개인 키를 앱 풀 계정이 못 읽습니다 | certlm.msc → 인증서 → 모든 작업 → 개인 키 관리 → 앱 풀 ID 에 읽기 부여 (h08) |
| The SSL connection could not be established | 상대 서버 인증서를 신뢰하지 않음. 사내 CA 루트 미설치 또는 중간 인증서 누락 | 루트 CA 설치(update-ca-certificates · certutil -addstore Root). openssl s_client -showcerts 로 체인 확인 (h08) |
| 인증서 이름 불일치 (RemoteCertificateNameMismatch) | SAN 에 접속 도메인이 없음. CN 만 있고 SAN 이 없는 옛날 인증서 | SAN 을 포함해 재발급. openssl x509 -ext subjectAltName 로 확인 (h08) |
| 무한 리다이렉트 루프 | 프록시가 TLS 를 끝냈는데 앱이 http 로 인식해 계속 https 로 돌려보냄 | UseForwardedHeaders() 를 파이프라인 맨 앞에, KnownProxies 지정 (h06) |
| 로그의 클라이언트 IP 가 전부 127.0.0.1 | 같은 원인 — X-Forwarded-For 를 앱이 읽지 않음 | 위와 동일. IP 기반 차단·감사 로그가 무력화된 상태이므로 보안 이슈이기도 합니다 |
| Too many open files | 파일 디스크립터 한계(기본 1024). 소켓·파일 스트림 누수도 원인 | 유닛에 LimitNOFILE=65535. ls /proc/<pid>/fd | wc -l 로 누수 여부 확인 (h05) |
| CPU 는 한가한데 응답이 느림 | ThreadPool 고갈. .Result · .Wait() · Thread.Sleep 로 스레드를 붙잡고 있음 | dotnet-counters 로 ThreadPool Queue Length 확인 → 0 이 아니면 확정. 동기 호출을 await 로 (h02 · h12) |
| 메모리가 계속 오르고 안 내려옴 | 이벤트 핸들러 미해제 · 정적 컬렉션 누적 · HttpClient 를 매번 생성 · 캐시에 만료가 없음 | dotnet-gcdump collect 두 번 떠서 증가한 타입을 비교. dumpheap -stat → gcroot 로 붙잡은 주체 추적 (h12) |
| Gen2 GC 가 초당 여러 번 | 대형 객체(85KB 초과)를 자주 만듭니다 — 큰 배열 · 문자열 결합 · 전체 파일 로드 | 스트리밍으로 바꾸고 ArrayPool·RecyclableMemoryStream 사용 (🔷 f06 참고) |
| CPU 100% 가 지속 | 무한 루프 · 정규식 폭주(백트래킹) · JSON 대용량 직렬화 · 스핀 대기 | dotnet-trace collect 30초 → PerfView 로 핫 경로 확인. 정규식에는 RegexOptions.NonBacktracking |
| DB 커넥션 풀 타임아웃 (Timeout expired ... pool) | 풀 크기 부족, 또는 DbContext 를 Dispose 안 함 · 긴 트랜잭션 | Max Pool Size 확인 후 DB 의 최대 연결과 대조. 긴 트랜잭션을 찾아 쪼갭니다 (h12) |
| 한글 로그가 ????? 로 나옴 | 콘솔·파일 인코딩이 UTF-8 이 아님. Windows 기본 코드페이지(949) 문제 | 파일 싱크에 encoding: Encoding.UTF8. 콘솔은 chcp 65001 · [Console]::OutputEncoding = [Text.Encoding]::UTF8 |
| 한글 파일 이름이 깨져 저장됨 | 업로드 파일명 인코딩. 또는 Linux 로케일이 C 로 잡혀 있음 | 유닛에 Environment=LANG=ko_KR.UTF-8. 파일명은 저장 시 GUID 로 바꾸고 원본명은 DB 에 두는 것이 정석 |
| 날짜·정렬이 이상함 (컨테이너) | ICU 없음 → invariant 전역화 모드로 동작 | chiseled-extra 이미지로 바꾸거나 libicu 설치. InvariantGlobalization=false 확인 (h11) |
| 재시작할 때마다 전원 로그아웃 | Data Protection 키가 임시 폴더에 있어 재기동 때 새로 생성됨 | PersistKeysToFileSystem 으로 고정 경로 지정 + SetApplicationName (h05) |
| It was not possible to find any compatible framework version | 서버 런타임 버전이 앱보다 낮음 (FDD 배포) | 런타임 업그레이드 또는 --roll-forward LatestMinor. 아예 SCD 로 게시 (h09) |
| 특정 화면에서만 TypeInitialization 예외 | 트리밍이 리플렉션으로만 쓰던 타입을 지웠습니다 | 빌드 로그의 IL2026 경고를 확인. TrimmerRootAssembly 로 제외하거나 트리밍 해제 (h09) |
| WebSocket / SignalR 이 계속 끊김 | 프록시가 업그레이드 헤더를 안 넘기거나 proxy_read_timeout 이 짧음 | Nginx 에 Upgrade·Connection 헤더와 긴 타임아웃 설정 (h06) |
가장 먼저 치는 명령 다섯 줄 (Linux)
# 1) 살아 있나 · 언제부터
systemctl status payroll --no-pager
# 2) 왜 죽었나 (마지막 100줄, 에러만)
journalctl -u payroll -p err -n 100 --no-pager
# 3) 포트를 잡고 있나
ss -ltnp | grep -E '5000|443'
# 4) 자원 상태
top -b -n1 -p "$(systemctl show -p MainPID --value payroll)"
free -h; df -h /opt /var
# 5) 앱이 직접 대답하나 (프록시를 건너뛰고)
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' \
http://127.0.0.1:5000/healthz
가장 먼저 치는 명령 다섯 줄 (Windows)
# 1) 서비스 · 앱 풀 상태
Get-Service PayrollApi
Import-Module WebAdministration; Get-ChildItem IIS:\AppPools
# 2) 최근 오류 이벤트
Get-WinEvent -LogName Application -MaxEvents 300 |
Where-Object { $_.LevelDisplayName -eq 'Error' } |
Select-Object TimeCreated, ProviderName, Message -First 10 |
Format-List
# 3) 포트 점유
Get-NetTCPConnection -LocalPort 5000 -State Listen |
Select-Object LocalAddress, OwningProcess
Get-Process -Id (Get-NetTCPConnection -LocalPort 5000).OwningProcess
# 4) 자원
Get-Process -Name Payroll,w3wp |
Select-Object Name, Id, @{n='MB';e={[int]($_.WS/1MB)}}, CPU
# 5) 로컬에서 직접
Invoke-WebRequest http://localhost:5000/healthz -UseBasicParsing |
Select-Object StatusCode, Content
diag.ps1 · diag.sh 로 서버에 넣어 두세요.
새벽에 명령을 떠올리는 것보다 스크립트 하나를 실행하는 것이 훨씬 빠릅니다.
stdoutLogEnabled="true" 를 켜 둔 채 방치(디스크가 찹니다) ·
② 원인을 모른 채 서버 재부팅(증거가 사라집니다 — 최소한 dotnet-gcdump 는 뜨고 재시작) ·
③ 운영 중 dotnet-dump collect --type Full(수 초간 프로세스가 멈춥니다) ·
④ 로그 레벨을 Debug 로 올려 놓고 잊기.
실전 — 사내 서버에 올리기 처음부터 끝까지
빈 서버 한 대를 받았다고 가정합니다. (A) Windows Server + IIS 와 (B) Ubuntu + Nginx + systemd 두 벌을 순서대로 따라 하면 끝나도록 적었습니다. 마지막에 인수인계용 체크리스트를 붙였습니다.
(A) Windows Server 2025 + IIS 🟢 기초
처음부터 끝까지 — 복사해서 순서대로
# ── 0) 사전 정보 (시작 전에 확보) ──────────────────────────
# 도메인 : payroll.corp.local (사내 DNS 에 A 레코드 등록 요청)
# 인증서 : 사내 CA 발급 요청 (SAN 에 위 도메인 포함) ← 첫날에 신청
# DB 계정 : 정보시스템팀에 요청
# 서비스 계정 : CORP\svc_payroll (로그온 배치 권한)
# ── 1) IIS 설치 ────────────────────────────────────────────
Install-WindowsFeature -Name Web-Server, Web-Mgmt-Console, `
Web-Http-Redirect, Web-Windows-Auth, Web-Dyn-Compression -IncludeManagementTools
# ── 2) Hosting Bundle 설치 (반드시 IIS 다음) ───────────────
Invoke-WebRequest -Uri "https://아이알엘/dotnet-hosting-10.0.x-win.exe" `
-OutFile C:\temp\hosting.exe
Start-Process C:\temp\hosting.exe -ArgumentList "/quiet /norestart" -Wait
net stop was /y; net start w3svc
dotnet --list-runtimes # Microsoft.AspNetCore.App 10.0.x 확인
# ── 3) 폴더 · 앱 풀 ────────────────────────────────────────
New-Item -ItemType Directory C:\inetpub\payroll, C:\inetpub\payroll\logs -Force
Import-Module WebAdministration
New-WebAppPool -Name PayrollPool
Set-ItemProperty IIS:\AppPools\PayrollPool managedRuntimeVersion ""
Set-ItemProperty IIS:\AppPools\PayrollPool processModel.idleTimeout "00:00:00"
Set-ItemProperty IIS:\AppPools\PayrollPool recycling.periodicRestart.time "00:00:00"
Set-ItemProperty IIS:\AppPools\PayrollPool startMode "AlwaysRunning"
# ── 4) 배포 ────────────────────────────────────────────────
robocopy \\build\artifacts\payroll\2026.8.3 C:\inetpub\payroll /MIR /XD logs
icacls C:\inetpub\payroll /grant "IIS AppPool\PayrollPool:(OI)(CI)RX" /T
icacls C:\inetpub\payroll\logs /grant "IIS AppPool\PayrollPool:(OI)(CI)M" /T
# ── 5) 사이트 · 인증서 ─────────────────────────────────────
New-Website -Name Payroll -PhysicalPath C:\inetpub\payroll `
-ApplicationPool PayrollPool -Port 80 -HostHeader payroll.corp.local
$pw = ConvertTo-SecureString "인증서비번" -AsPlainText -Force
Import-PfxCertificate -FilePath C:\temp\payroll.pfx `
-CertStoreLocation Cert:\LocalMachine\My -Password $pw
New-WebBinding -Name Payroll -Protocol https -Port 443 `
-HostHeader payroll.corp.local -SslFlags 1
$t = (Get-ChildItem Cert:\LocalMachine\My | Where-Object Subject -like "*payroll*").Thumbprint
Get-Item "Cert:\LocalMachine\My\$t" |
New-Item -Path "IIS:\SslBindings\!443!payroll.corp.local" -Force
# ★ certlm.msc 에서 개인 키 읽기 권한을 앱 풀 계정에 주는 것을 잊지 마세요 (h08)
# ── 6) 환경 변수 · 비밀 ────────────────────────────────────
# web.config 의 environmentVariables 또는 앱 풀 환경 변수로 주입 (h03)
# 비밀번호는 저장소에 넣지 말고 Windows 자격 증명 관리자나 Key Vault 로
# ── 7) 방화벽 ──────────────────────────────────────────────
New-NetFirewallRule -DisplayName "Payroll HTTPS" -Direction Inbound `
-Protocol TCP -LocalPort 443 -RemoteAddress 10.0.0.0/8 -Action Allow
# ── 8) 검증 ────────────────────────────────────────────────
Invoke-WebRequest https://payroll.corp.local/healthz -UseBasicParsing
Invoke-WebRequest https://payroll.corp.local/version -UseBasicParsing
Get-WinEvent -LogName Application -MaxEvents 20 |
Where-Object ProviderName -like "*IIS*"
(B) Ubuntu 24.04 + Nginx + systemd 🟢 기초
처음부터 끝까지 — 복사해서 순서대로
#!/usr/bin/env bash
set -euo pipefail
# ── 1) 기본 패키지 · 런타임 ────────────────────────────────
sudo add-apt-repository -y ppa:dotnet/backports # 24.04 에서 .NET 10 을 받으려면 (h05)
sudo apt update
sudo apt install -y nginx aspnetcore-runtime-10.0 libicu-dev unzip curl
dotnet --list-runtimes
# ── 2) 계정 · 폴더 ─────────────────────────────────────────
sudo useradd --system --no-create-home --shell /usr/sbin/nologin payroll || true
sudo mkdir -p /opt/payroll /var/lib/payroll/keys /var/log/payroll /etc/payroll
sudo chown -R payroll:payroll /var/lib/payroll /var/log/payroll
# ── 3) 배포 ────────────────────────────────────────────────
sudo rsync -a --delete /tmp/payroll-publish/ /opt/payroll/
sudo chown -R root:payroll /opt/payroll
sudo chmod -R 750 /opt/payroll
# ── 4) 비밀 (권한 600 — 유닛 파일에 직접 쓰지 않습니다) ────
sudo tee /etc/payroll/secrets.env >/dev/null <<'EOF'
ConnectionStrings__Main=Server=dbsrv;Database=payroll;User Id=payroll;Password=...;Encrypt=true
Jwt__Secret=...
EOF
sudo chown root:root /etc/payroll/secrets.env # EnvironmentFile 은 systemd(root)가 읽습니다
sudo chmod 600 /etc/payroll/secrets.env
# ── 5) systemd 유닛 (h05 전문 참고) ────────────────────────
sudo tee /etc/systemd/system/payroll.service >/dev/null <<'EOF'
[Unit]
Description=Payroll API (.NET 10)
After=network-online.target
Wants=network-online.target
[Service]
Type=notify
User=payroll
Group=payroll
WorkingDirectory=/opt/payroll
ExecStart=/usr/bin/dotnet /opt/payroll/Payroll.dll
Restart=always
RestartSec=5
TimeoutStartSec=120
TimeoutStopSec=60
Environment=DOTNET_ENVIRONMENT=Production
Environment=ASPNETCORE_URLS=http://127.0.0.1:5000
Environment=LANG=ko_KR.UTF-8
EnvironmentFile=-/etc/payroll/secrets.env
StandardOutput=journal
StandardError=journal
SyslogIdentifier=payroll
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=full
ReadWritePaths=/var/lib/payroll /var/log/payroll
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now payroll
sudo systemctl status payroll --no-pager
# ── 6) Nginx (h06 전문 참고) ───────────────────────────────
sudo cp payroll.conf /etc/nginx/sites-available/
sudo ln -sf /etc/nginx/sites-available/payroll.conf /etc/nginx/sites-enabled/
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
# ── 7) 인증서 ──────────────────────────────────────────────
# 사내 CA 발급본을 쓰는 경우
sudo mkdir -p /etc/ssl/payroll
sudo cp payroll.crt corpCA.crt /etc/ssl/payroll/
sudo cat /etc/ssl/payroll/payroll.crt /etc/ssl/payroll/corpCA.crt \
| sudo tee /etc/ssl/payroll/fullchain.pem >/dev/null
sudo cp payroll.key /etc/ssl/payroll/ && sudo chmod 600 /etc/ssl/payroll/payroll.key
sudo cp corpCA.crt /usr/local/share/ca-certificates/ && sudo update-ca-certificates
# ── 8) 방화벽 ──────────────────────────────────────────────
sudo ufw allow from 10.0.0.0/8 to any port 443 proto tcp
sudo ufw allow from 10.0.0.0/8 to any port 22 proto tcp
sudo ufw --force enable
# ── 9) 로그 보존 ───────────────────────────────────────────
sudo sed -i 's/^#\?SystemMaxUse=.*/SystemMaxUse=2G/;s/^#\?MaxRetentionSec=.*/MaxRetentionSec=30day/' \
/etc/systemd/journald.conf
sudo systemctl restart systemd-journald
# ── 10) 검증 ───────────────────────────────────────────────
curl -sS http://127.0.0.1:5000/healthz # 앱 직접
curl -sSk https://payroll.corp.local/healthz # 프록시 경유
curl -sSk https://payroll.corp.local/version
sudo journalctl -u payroll -n 30 --no-pager
echo "구축 완료"
인수인계 체크리스트 🟡 중급
넘기기 전에 확인 (구축)
| 항목 | 확인 방법 |
|---|---|
| 서버 재부팅 후 자동 기동 | 실제로 재부팅해 보고 /healthz 확인. 이걸 안 하고 넘긴 사고가 가장 많습니다 |
| 프로세스 강제 종료 후 복구 | kill -9 · 작업 관리자로 죽여 보고 5초 뒤 살아나는지 |
| 인증서 만료일 · 갱신 담당 | openssl x509 -noout -dates · 캘린더에 만료 60일 전 알림 등록 |
| DB 연결 · 계정 만료 정책 | 계정 비밀번호 만료가 있으면 만료일도 인수인계 문서에 |
| 로그 위치와 보존 기간 | 디스크 사용량이 늘어나는 속도를 하루치로 재 두기 |
| 디스크 여유 | 로그·업로드 폴더가 찼을 때 무슨 일이 나는지 확인 (앱이 죽는지) |
| 백업 대상 | DB · 업로드 파일 · Data Protection 키 · 인증서 · 설정 파일 |
| 롤백 절차 | 실제로 한 번 롤백해 보고 걸린 시간을 문서에 적기 |
운영 문서에 반드시 넣을 것
| 항목 | 내용 |
|---|---|
| 서버 정보 | IP · 도메인 · OS · 스펙 · 접속 방법 · 담당자 |
| 구성도 | 클라이언트 → 프록시 → 앱 → DB 한 장 (h06 그림 형태) |
| 기동 · 중지 명령 | 복사해서 바로 쓸 수 있는 형태로 |
| 배포 절차 | 스크립트 경로 + 실행 예시 + 소요 시간 + 롤백 |
| 모니터링 | 무엇을 보고 언제 연락하나 (임계값을 숫자로) |
| 장애 대응 | h13 표를 그대로 복사해 두기 |
| 정기 작업 | 인증서 갱신 · 런타임 보안 패치 · 로그 정리 · 백업 확인 |
| 연락처 | 1차 · 2차 담당자 · DBA · 네트워크팀 · 벤더 |
/healthz 를 1분마다 두드리는 외부 감시(Zabbix · UptimeKuma) ·
② 디스크 사용률 80% 알림 · ③ 프로세스 재시작 횟수 알림 ·
④ 5xx 응답 비율 알림. 이 넷이면 사내 업무 시스템의 장애 대부분을
사용자보다 먼저 알 수 있습니다. 관측 스택을 제대로 세우는 이야기는
서버기술 가이드의 관측 탭이 더 자세합니다.
장애가 났을 때
남아 있는 것은
로그뿐입니다
디버거는 내 PC 에서만 붙습니다. 운영 서버에서 새벽 3시에 벌어진 일을 다시 볼 방법은
그때 남긴 로그 하나뿐입니다. 그런데 로그는 공짜가 아닙니다 —
문자열을 만들고, 디스크를 때리고, 저장 비용을 만들고, 잘못 쓰면 개인정보까지 흘립니다.
이 탭은 .NET 의 로깅 추상화(ILogger<T>)가 왜 먼저인지부터 시작해
레벨 · 구조적 로깅 · Serilog/NLog/ZLogger 선택 · 소스 제너레이터 · 분산 추적 · 비용 · 민감정보 · 배포 환경별 설정까지
한 번에 잇습니다.
이 탭을 마치면 "로그를 어떻게 남길지"를 설계로 말할 수 있게 됩니다.
왜 추상화가 먼저인가 — ILogger<T> 의 구조
로깅 라이브러리를 먼저 고르면 앱 코드가 그 라이브러리에 묶입니다.
.NET 은 반대로 갑니다 — 앱은 ILogger<T> 만 알고,
어디로 내보낼지는 시작 시점에 따로 정합니다. 이 순서가 로깅 설계의 전부입니다.
등장인물 셋만 외우면 됩니다
// ① ILogger<T> — 앱 코드가 보는 유일한 얼굴
public sealed class OrderService(ILogger<OrderService> log)
{
public async Task PlaceAsync(int orderId)
{
log.LogInformation("주문 {OrderId} 처리 시작", orderId);
// ...
}
}
// ② ILoggerFactory — ILogger 를 만들어 주는 공장
// 직접 쓸 일은 드뭅니다. DI 가 알아서 ILogger<T> 를 꽂아 줍니다.
ILogger log2 = factory.CreateLogger("Batch.NightlyJob"); // 카테고리 직접 지정
// ③ ILoggerProvider — 실제로 어딘가에 쓰는 사람
// Console · Debug · EventLog · Serilog · NLog · OpenTelemetry …
ILogger<T> 의 T 는 무엇인가 —
카테고리 이름입니다. ILogger<OrderService> 를 주입받으면
카테고리가 MyApp.Services.OrderService 가 되고,
이 문자열이 그대로 필터링의 키가 됩니다(다음 섹션).
그래서 클래스마다 자기 타입을 넣는 것이 정석입니다.
DI 로 어떻게 꽂히나 — 실제 배선
// Program.cs — .NET 10 최소 호스트
var builder = WebApplication.CreateBuilder(args);
// CreateBuilder 가 이미 다음을 해 둡니다:
// · appsettings.json 의 "Logging" 섹션을 읽고
// · Console / Debug / EventSource provider 를 등록
// 그래서 아무것도 안 해도 ILogger<T> 가 동작합니다.
builder.Services.AddScoped<OrderService>(); // 이게 전부입니다
var app = builder.Build();
app.Run();
// 직접 배선하고 싶다면 (콘솔 앱 · 테스트)
using var factory = LoggerFactory.Create(b =>
{
b.SetMinimumLevel(LogLevel.Debug);
b.AddSimpleConsole(o => { o.SingleLine = true; o.TimestampFormat = "HH:mm:ss "; });
});
ILogger<Program> log = factory.CreateLogger<Program>();
log.LogInformation("직접 만든 팩토리 {Now}", DateTime.Now);
// 테스트에서는 아무것도 안 하는 로거를 넣습니다
var svc = new OrderService(NullLogger<OrderService>.Instance);
Console.WriteLine 을 쓰지 마세요 — 레벨도, 카테고리도, 시각도,
구조도 없습니다. 컨테이너에서는 그나마 보이지만
윈도우 서비스에서는 아무 데도 안 남습니다.
"빠르게 확인만" 이라도 log.LogDebug(...) 를 쓰세요 — 지우는 것도 설정 한 줄입니다.
왜 이 순서인가 — 라이브러리를 먼저 고르면 생기는 일 🟡 중급
라이브러리를 직접 부르는 코드
using Serilog; // ← 앱 코드가 Serilog 를 압니다
public class OrderService
{
public void Place(int id)
{
Log.Information("주문 {OrderId}", id); // 전역 정적 로거
}
}
- 단위 테스트에서 로그를 가로챌 방법이 없습니다(전역 정적)
- 라이브러리를 만들어 배포하면 쓰는 쪽도 Serilog 를 강제당합니다
- NLog 로 바꾸려면 모든 파일을 고쳐야 합니다
추상화를 부르는 코드
using Microsoft.Extensions.Logging; // ← 표준 인터페이스
public class OrderService(ILogger<OrderService> log)
{
public void Place(int id)
{
log.LogInformation("주문 {OrderId}", id);
}
}
- 테스트에서 가짜 로거를 넣어 호출을 검증할 수 있습니다
- Serilog·NLog·ZLogger 어느 것으로도 Program.cs 한 줄로 교체
- NuGet 라이브러리를 만들 때 의존성이 인터페이스뿐
Host.UseSerilog() 는 Serilog 를 ILoggerProvider 로 등록할 뿐입니다.
앱 코드는 계속 ILogger<T> 를 쓰고, 그 호출이 Serilog 의 파이프라인으로 흘러 들어갑니다.
"앱은 MEL, 뒤는 Serilog" 가 2026년 현재 .NET 진영의 사실상 표준 조합입니다.
실무에선 이렇게 — 라이브러리를 만드는 쪽의 규칙 🔴 실무
내 라이브러리에 로깅을 넣을 때
<!-- MyLib.csproj — 로깅 의존성은 이 하나면 충분합니다 -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions"
Version="10.0.0" />
</ItemGroup>
<!-- ★ .Abstractions 는 인터페이스만 들어 있습니다(구현 없음).
Serilog·NLog 를 참조하면 쓰는 쪽에 강요하게 됩니다. -->
public sealed class RetryClient
{
private readonly ILogger _log;
// 로거를 안 넘겨도 동작하게 — NullLogger 가 기본값
public RetryClient(ILogger<RetryClient>? log = null)
=> _log = (ILogger?)log ?? NullLogger.Instance;
}
NullLogger.Instance 인가 — _log 가 null 이면
호출할 때마다 ?. 를 붙여야 하고, 빼먹으면 NullReferenceException 입니다.
아무 일도 안 하는 구현을 기본값으로 두면 호출부가 단순해집니다
(널 오브젝트 패턴).
테스트에서 로그를 검증하기
// 1) 가장 쉬운 방법 — 메모리에 모으는 provider 를 직접 만듭니다
public sealed class ListLoggerProvider : ILoggerProvider
{
public List<string> Lines { get; } = new();
public ILogger CreateLogger(string category) => new L(this, category);
public void Dispose() { }
private sealed class L(ListLoggerProvider p, string cat) : ILogger
{
public IDisposable? BeginScope<TState>(TState s) where TState : notnull => null;
public bool IsEnabled(LogLevel level) => true;
public void Log<TState>(LogLevel level, EventId id, TState state,
Exception? ex, Func<TState, Exception?, string> fmt)
=> p.Lines.Add($"{level}|{cat}|{fmt(state, ex)}");
}
}
// 2) 테스트
var sink = new ListLoggerProvider();
using var factory = LoggerFactory.Create(b => b.AddProvider(sink));
var svc = new OrderService(factory.CreateLogger<OrderService>());
svc.Place(10423);
Assert.Contains(sink.Lines, l => l.Contains("주문 10423"));
ILogger<T>) → 팩토리 → provider 세 층입니다.
앱 코드는 맨 앞만 알면 되고, 뒤의 두 층은 Program.cs 에서 갈아 끼웁니다.
라이브러리를 먼저 고르지 말고 추상화를 먼저 쓰세요 — 그래야 나중에 고를 수 있습니다.레벨 6단계와 카테고리 필터 — 가장 긴 접두사가 이깁니다
로그를 다 남기면 비용과 소음이, 안 남기면 장애 때 아무것도 없습니다. 그 사이를 정하는 손잡이가 레벨과 카테고리별 규칙 둘뿐입니다. 규칙이 겹칠 때 어느 것이 이기는지만 알면 됩니다.
레벨 6단계 + None
| 레벨 | 값 | 언제 쓰나 | 운영에서 |
|---|---|---|---|
| Trace | 0 | 루프 안 값 · 바이트 덤프. 민감정보가 섞이기 쉬움 | 끔 |
| Debug | 1 | 분기 진입 · 중간 계산값 | 끔 |
| Information | 2 | 업무 사건 — 주문 · 로그인 · 지급 | 켬 |
| Warning | 3 | 이상하지만 회복됨 — 재시도 성공 | 켬 |
| Error | 4 | 사람이 조치할 것 — 처리 실패 | 켬 |
| Critical | 5 | 서비스가 못 뜸 · 데이터 손상 | 켬 |
| None | 6 | 전부 끔 (필터 값으로만 씀) | — |
Serilog 레벨과의 대응
MEL (LogLevel) | Serilog (LogEventLevel) | NLog |
|---|---|---|
| Trace | Verbose | Trace |
| Debug | Debug | Debug |
| Information | Information | Info |
| Warning | Warning | Warn |
| Error | Error | Error |
| Critical | Fatal | Fatal |
Verbose·Fatal 이 나오면
Trace·Critical 로 읽으면 됩니다.
appsettings.json 의 Logging:LogLevel 에는 MEL 이름을,
Serilog:MinimumLevel 에는 Serilog 이름을 씁니다 — 여기서 자주 헷갈립니다.
실무에서 그대로 쓰는 appsettings
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting.Diagnostics": "Information",
"Microsoft.EntityFrameworkCore.Database.Command": "Warning",
"System.Net.Http.HttpClient": "Warning",
"MyApp.Orders": "Debug"
},
"Console": {
"LogLevel": { "Default": "Warning" },
"FormatterName": "json"
}
}
}
Logging:LogLevel 은 모든 provider 공통,
Logging:Console:LogLevel 은 그 provider 에만 적용됩니다.
"파일에는 Debug 까지, 콘솔에는 Warning 부터" 같은 조합을 이렇게 만듭니다.
provider 규칙이 있으면 공통 규칙 대신 그것이 쓰입니다.
개발 · 운영을 파일로 가르기
// appsettings.Development.json — 로컬에서만 시끄럽게
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
}
}
}
# 어느 파일이 겹쳐 적용될지는 이 환경 변수가 정합니다
DOTNET_ENVIRONMENT=Development # 콘솔 · 워커
ASPNETCORE_ENVIRONMENT=Production # 웹 앱
# 환경 변수로 한 항목만 덮어쓰기 (컨테이너에서 자주 씁니다)
Logging__LogLevel__MyApp.Orders=Trace
# (이름에 점이 있어 셸의 export 로는 못 넣습니다 — docker -e · k8s env 로 넣습니다)
# __ 두 개가 JSON 의 : 한 개에 해당합니다
Development 설정이 뜨는 것.
EntityFrameworkCore.Database.Command 가 Information 이면
모든 SQL 과 파라미터가 로그에 찍힙니다. 개인정보가 통째로 새는 대표 경로입니다.
코드로 거르기 — AddFilter 와 동적 변경 🔴 실무
AddFilter — 설정보다 우선하는 코드 규칙
builder.Logging
// 카테고리 접두사 + 최소 레벨
.AddFilter("Microsoft", LogLevel.Warning)
.AddFilter("MyApp.Batch", LogLevel.Debug)
// provider 를 지정해서 (Console 에만 적용)
.AddFilter<ConsoleLoggerProvider>("MyApp", LogLevel.Information)
// 임의 조건 — 카테고리와 레벨을 보고 직접 판단
.AddFilter((provider, category, level) =>
category?.StartsWith("Microsoft.AspNetCore.Mvc") == true
? level >= LogLevel.Warning
: level >= LogLevel.Information);
Default → ④ SetMinimumLevel.
같은 길이면 나중에 등록된 것이 이깁니다.
헷갈리면 규칙을 줄이는 것이 답입니다 — 실무에서 6~8줄을 넘길 일은 거의 없습니다.
재시작 없이 레벨 바꾸기
// ① appsettings.json 은 기본이 reloadOnChange: true 입니다.
// 파일을 고치면 몇 초 안에 로깅 필터가 다시 계산됩니다. 재배포 불필요.
// ② Serilog 는 스위치를 직접 들고 흔들 수 있습니다
public static class LogSwitch
{
public static readonly LoggingLevelSwitch Level =
new(Serilog.Events.LogEventLevel.Information);
}
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(LogSwitch.Level)
.WriteTo.Console()
.CreateLogger();
// 운영 중 관리자 엔드포인트로 30분만 Debug 를 켭니다
app.MapPost("/admin/loglevel/{lv}", (string lv) =>
{
LogSwitch.Level.MinimumLevel = Enum.Parse<Serilog.Events.LogEventLevel>(lv, true);
return Results.Ok($"level = {lv}");
}).RequireAuthorization("admin");
Microsoft.* 를 Warning 으로 눌러 두는 것만으로 로그 양이 크게 줄어듭니다.라이브러리 비교 — 내장 · Serilog · NLog · log4net · ZLogger
다섯 개를 다 알 필요는 없습니다. 다만 왜 이 중 하나를 고르는지는 말할 수 있어야 합니다. 기준은 넷입니다 — 구조적 로깅을 제대로 하는가 · 내보낼 곳(sink)이 있는가 · 설정을 바꾸기 쉬운가 · 초당 몇 줄까지 견디는가.
| 라이브러리 | 한 줄 정체 | 장점 | 단점 | 채택 정도 (2026년 8월 기준) |
|---|---|---|---|---|
| 내장 provider Console · Debug · EventLog |
MEL 이 기본으로 들고 오는 것 | 의존성 0 · 컨테이너와 궁합 · JSON 포매터 내장 · OpenTelemetry 와 바로 연결 | 파일 sink 가 없습니다 · 롤링/보관을 직접 만들어야 함 · 필터 표현력이 낮음 | 모든 프로젝트의 출발점 — 안 쓰는 앱이 없습니다 |
| Serilog | 구조적 로깅을 처음부터 전제로 만든 라이브러리 | sink 가 압도적으로 많음(파일·Seq·Elastic·Splunk·S3…) · enricher · Serilog.Expressions 필터 · 설정을 JSON 으로 |
패키지를 여러 개 붙여야 함 · 할당이 0 은 아님 · 설정을 잘못하면 종료 시 로그 유실 | 사실상 표준 — 신규 .NET 백엔드에서 가장 흔한 선택 |
| NLog | log4j 계보의 성숙한 로깅 프레임워크 | nlog.config 하나로 재빌드 없이 대상 변경 · 대상(target)/규칙 문법이 강력 · DB·메일 target 이 기본 제공 |
XML 설정이 길어짐 · 구조적 로깅은 되지만 Serilog 만큼 자연스럽지 않음 | 널리 씀 — 특히 기존 .NET Framework 자산이 있는 조직 |
| log4net | 가장 오래된 레거시. Apache log4j 의 이식판 | 오래된 코드베이스에 이미 들어 있음 · 자료가 많음 | 구조적 로깅이 사실상 없음(문자열 위주) · 설정이 번거로움 · 생태계가 정체 | 신규 채택은 드묾 — 유지보수 대상으로만 만납니다 |
| ZLogger | 할당을 없애는 데 특화된 고성능 로거 (Cysharp) | UTF8 로 바로 써서 문자열 할당이 거의 0 · 소스 제너레이터 기반 · Unity·게임 서버에서 강함 |
sink 종류가 적음 · 자료가 적음 · 문법이 조금 다름(ZLogInformation) |
특정 상황에서만 — 초당 수십만 줄, 지연에 민감한 서버 |
dotnet list package --outdated 로 우리 조직이 실제로 무엇을 쓰는지부터 세어 보세요 —
남의 통계보다 이게 정확합니다.
이 표의 "사실상 표준 / 널리 씀 / 드묾" 은 순위가 아니라 상대적 위치입니다.
고르는 기준 세 줄 🟢 기초
이렇게 정하면 거의 틀리지 않습니다
- 컨테이너에 배포하고 로그를 수집기가 걷어 간다
→ 내장 Console(JSON 포매터) 로 충분합니다. 패키지 0개. - 파일로 남겨야 한다 · 여러 곳으로 동시에 보낸다 · 설정을 자주 바꾼다
→ Serilog. 가장 안전한 기본값입니다. - 기존 코드가 이미 NLog/log4net 이다
→ 그대로 두고ILogger<T>로 감쌉니다. 교체는 나중에. - 초당 수십만 줄 · GC 를 1ms 도 못 준다
→ ZLogger 또는[LoggerMessage]+ 내장 provider.
패키지 이름만 봐도 되는 정리
# 내장만 — 아무것도 설치할 필요 없음 (WebApplication.CreateBuilder 에 포함)
# Serilog — 보통 이 조합
dotnet add package Serilog.AspNetCore # Host 연동 + 요청 로깅
dotnet add package Serilog.Sinks.Seq # 수집기로 보낼 때
dotnet add package Serilog.Expressions # 조건 필터 · 표현식 템플릿
# NLog
dotnet add package NLog.Web.AspNetCore # ASP.NET Core 연동
dotnet add package NLog.Extensions.Logging # 콘솔 · 워커 서비스
# log4net (레거시 유지보수용)
dotnet add package Microsoft.Extensions.Logging.Log4Net.AspNetCore
# ZLogger
dotnet add package ZLogger
Serilog.AspNetCore 하나가 여러 패키지를 끌고 옵니다 —
Serilog 본체 · Serilog.Extensions.Hosting ·
Serilog.Sinks.Console · Serilog.Sinks.File ·
Serilog.Settings.Configuration.
그래서 웹 앱이면 이거 하나로 시작하면 됩니다.
공존시키기 — 라이브러리를 바꾸는 중일 때 🟡 중급
// 레거시 코드가 log4net 을, 새 코드가 ILogger<T> 를 쓰는 과도기
// → 둘 다 MEL 파이프라인으로 모읍니다. 출력은 한 곳으로 나갑니다.
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // ★ 기본 Console 을 먼저 지웁니다
builder.Logging.AddLog4Net("log4net.config"); // 레거시 설정 그대로
builder.Logging.AddJsonConsole(); // 새 표준 출력
// 새 코드는 그냥 ILogger<T> 를 씁니다. 레거시는 손대지 않습니다.
// 레거시 파일이 하나씩 사라질 때 AddLog4Net 줄을 지우면 이사가 끝납니다.
ClearProviders() 를 빠뜨리면 로그가 두 번 찍힙니다.
Serilog 를 붙일 때도 마찬가지입니다 — UseSerilog() 는 기본 provider 를 지워 주지만,
AddSerilog() 는 지우지 않습니다.
"로그가 중복된다"는 신고의 대부분이 이 한 줄입니다.
구조적 로깅 — 문자열을 합치지 마세요
log.LogInformation($"주문 {id} 실패") 와
log.LogInformation("주문 {OrderId} 실패", id) 는
화면에 같아 보입니다. 그런데 저장되는 것이 완전히 다릅니다 —
하나는 문장 한 덩어리, 다른 하나는 문장 + 이름 붙은 값입니다.
문자열 보간 · 결합
log.LogInformation($"주문 {id} 실패 · 사유 {why}");
log.LogInformation("주문 " + id + " 실패");
- 저장되는 것은 완성된 문장 하나뿐입니다
OrderId = 10423으로 검색이 안 됩니다 — 문자열 부분 일치만- "이 오류가 몇 건" 을 세려면 정규식을 써야 합니다
- 레벨이 꺼져 있어도 문자열을 먼저 만듭니다(비용 발생)
- 분석 도구가 중복 이벤트를 묶지 못합니다(문장이 매번 다름)
메시지 템플릿
log.LogInformation("주문 {OrderId} 실패 · 사유 {Reason}", id, why);
log.LogError(ex, "결제 승인 실패 {OrderId} {Gateway}", id, gw);
OrderId:10423로 필드 검색이 됩니다@mt로 묶어 "이 오류 상위 10건" 을 바로 냅니다- 레벨이 꺼져 있으면 포맷을 아예 안 합니다
- 수집기가 OrderId 로 그래프를 그릴 수 있습니다
- 예외는 첫 번째 인자로 — 스택트레이스가 따로 저장됩니다
템플릿 문법 — 네 가지만
// ① 이름은 PascalCase 로. 순서대로 인자와 짝지어집니다(이름이 아니라 순서!)
log.LogInformation("사용자 {UserId} 가 {ItemId} 를 구매", userId, itemId);
// ② @ — 객체를 통째로 구조화해서 저장 (Serilog 의 destructure)
log.LogInformation("주문 접수 {@Order}", order);
// → { "Order": { "Id": 10423, "Total": 39000, "Items": 3 } }
// ③ $ — 무조건 ToString() 으로 (구조화하지 않음)
log.LogInformation("키 {$Key}", complexKey);
// ④ 포맷 지정자 — 표준 .NET 포맷과 같습니다
log.LogInformation("경과 {Elapsed:0.00} ms · 금액 {Total:N0}원", ms, total);
"{A} {B}", b, a 라고 쓰면 값이 뒤바뀐 채로 조용히 기록됩니다.
분석기 CA2017 · 로깅 분석기(Microsoft.CodeAnalysis.NetAnalyzers) 를 켜면
인자 개수 불일치를 컴파일 시점에 잡아 줍니다.
@ 를 쓸 때의 함정
// ❌ 엔티티를 통째로 구조화 — 지뢰입니다
log.LogInformation("회원 저장 {@User}", user);
// → 이메일 · 전화번호 · 해시된 비밀번호까지 전부 로그로
// ✅ 남길 것만 골라 익명 타입으로
log.LogInformation("회원 저장 {@User}",
new { user.Id, user.Grade, Region = user.Address?.Region });
// ✅ 또는 전용 record 를 만들어 둡니다 (재사용 · 리뷰 대상이 됨)
public sealed record UserLogView(int Id, string Grade, string? Region);
log.LogInformation("회원 저장 {@User}", UserLogView.From(user));
{@} 를 붙이면 특히 위험합니다 —
내비게이션 속성을 따라가며 연관 테이블까지 직렬화하려 들 수 있고,
지연 로딩이 켜져 있으면 로그 한 줄이 쿼리 수십 개를 날립니다.
로그용 뷰 타입을 따로 두는 것이 정답입니다.
문장을 어떻게 쓸 것인가 — 사람이 아니라 검색이 읽습니다 🟡 중급
좋은 템플릿의 조건
| 이렇게 | 이유 |
|---|---|
문장은 고정, 변하는 것은 전부 {} | 같은 사건을 묶어 셀 수 있습니다 |
| 주어 · 동사 · 결과를 넣습니다 | "재고 부족" 보다 "주문 접수 실패 — 재고 부족" |
| 식별자를 반드시 하나 | OrderId · UserId 없이는 추적이 안 됩니다 |
| 단위를 이름에 넣습니다 | {ElapsedMs} · {SizeBytes} |
| 예외는 첫 인자로 | LogError(ex, "...") — 문자열에 넣지 않습니다 |
| 같은 뜻이면 같은 이름 | OrderId / orderNo / order_id 가 섞이면 검색이 깨집니다 |
TraceId · UserId · OrderId · TenantId · ElapsedMs · StatusCode
여섯 개만 통일해도 수집기에서 대시보드를 만들 수 있게 됩니다.
수집기에서 실제로 던지는 질문
# Seq 쿼리 — 구조적 로깅이라서 되는 것들
@Level = 'Error' and OrderId = 10423
# 이 오류가 오늘 몇 건인가
select count(*) from stream
where @mt = '주문 {OrderId} 실패 · 사유 {Reason}'
group by Reason
# 느린 요청 상위
select ElapsedMs from stream
where ElapsedMs > 1000 order by ElapsedMs desc
# 문자열을 합쳐 놨다면 위 질문 전부
# "주문 10423 실패" 같은 부분 문자열 검색으로 바뀝니다 — 집계 불가
$"..." 를 로그에 쓰지 마세요.
문장은 고정하고 값은 {이름} 으로 빼면, 같은 코드에서
사람이 읽을 문장과 기계가 셀 필드가 동시에 나옵니다. 비용은 같습니다.[LoggerMessage] — 소스 제너레이터가 만드는 무할당 로깅
log.LogInformation("...", a, b) 한 줄은 실행 중에
배열을 만들고 값 타입을 박싱합니다. 초당 몇백 줄이면 아무 문제 없지만,
초당 수십만 줄이면 그것만으로 GC 가 돕니다.
[LoggerMessage] 는 그 코드를 컴파일 시점에 미리 만들어 둡니다.
쓰는 법 — partial 메서드 선언 하나
// .NET 6 부터 들어온 소스 제너레이터입니다. 패키지 설치 없음.
public static partial class OrderLog
{
[LoggerMessage(
EventId = 1001,
Level = LogLevel.Information,
Message = "주문 {OrderId} 처리 완료 · {ElapsedMs} ms")]
public static partial void Completed(
ILogger logger, int orderId, long elapsedMs);
[LoggerMessage(EventId = 1002, Level = LogLevel.Error,
Message = "주문 {OrderId} 실패 · 사유 {Reason}")]
public static partial void Failed(
ILogger logger, Exception ex, int orderId, string reason);
}
// 호출
OrderLog.Completed(_log, orderId, sw.ElapsedMilliseconds);
OrderLog.Failed(_log, ex, orderId, "재고 부족");
partial, 메서드에 partial, 본문 없음 — 이 세 가지가 조건입니다.
빌드하면 제너레이터가 나머지를 채웁니다.
Exception 매개변수는 이름과 무관하게 타입으로 알아봅니다(템플릿에 안 넣어도 됩니다).
인스턴스 메서드로 더 깔끔하게
// 로거를 필드로 들고 있으면 첫 인자를 생략할 수 있습니다
public sealed partial class OrderService(ILogger<OrderService> logger)
{
private readonly ILogger _logger = logger; // ★ ILogger 형식 필드가 하나 있으면 자동 인식 (이름 무관)
[LoggerMessage(Level = LogLevel.Warning,
Message = "재고 부족 {Sku} — 남은 수량 {Left}")]
private partial void LogOutOfStock(string sku, int left);
public void Place(Order o)
{
if (o.Left <= 0) LogOutOfStock(o.Sku, o.Left); // 인자 두 개뿐
}
}
// 레벨을 호출 시점에 정하고 싶다면 Level 을 빼고 매개변수로
[LoggerMessage(Message = "외부 호출 {Url} → {Status}")]
public static partial void Called(
ILogger logger, LogLevel level, string url, int status);
Called(_log, status >= 500 ? LogLevel.Error : LogLevel.Debug, url, status);
ILogger 형식의 필드를 하나 찾습니다
(.NET 8+ 는 기본 생성자 매개변수도 봅니다).
없거나 둘 이상이면 컴파일 에러가 납니다(런타임 실패가 아니라서 다행입니다).
찾지 못하면 첫 인자로 ILogger 를 명시하세요.
언제 필요한가 — 전부 바꿀 필요는 없습니다 🟡 중급
쓸 자리 · 안 쓸 자리
| 상황 | 권장 |
|---|---|
| 초당 수천 줄 이상 도는 핫 패스 | [LoggerMessage] |
| 게임 서버 틱 루프 · 패킷 핸들러 | [LoggerMessage] 또는 ZLogger |
| 운영에서 꺼 두는 Debug/Trace 로그 | [LoggerMessage] (조기 반환 이득이 큼) |
| 요청 하나에 몇 줄 남기는 일반 API | 그냥 LogInformation 으로 충분 |
| 기동 · 종료 · 설정 출력 | 그냥 쓰세요. 몇 번 안 돕니다 |
| 예외 처리 블록 | 그냥 쓰세요. 이미 예외가 훨씬 비쌉니다 |
dotnet-counters monitor --counters System.Runtime 으로
Gen0 GC 횟수와 할당률을 보고, 로깅을 끄면 그 수치가 눈에 띄게 떨어질 때만
손을 대세요. 대부분의 앱에서는 DB 왕복 한 번이 로그 천 줄보다 비쌉니다.
직접 재는 법
[MemoryDiagnoser] // ★ 할당량이 같이 나옵니다
public class LogBench
{
private readonly ILogger _log =
LoggerFactory.Create(b => b.SetMinimumLevel(LogLevel.Warning))
.CreateLogger("bench");
// 최소 레벨이 Warning → Information 로그는 전부 버려집니다.
// "꺼진 로그의 비용" 을 재는 설정입니다.
[Benchmark(Baseline = true)]
public void Interpolated() => _log.LogInformation($"주문 {42} 처리 {17} ms");
[Benchmark]
public void Template() => _log.LogInformation("주문 {Id} 처리 {Ms} ms", 42, 17);
[Benchmark]
public void Generated() => BenchLog.Done(_log, 42, 17);
}
ILogger 의 LogInformation 에는
그런 오버로드가 없어 지연 평가도 안 되고, 구조적 필드도 사라집니다.
성능 이전에 검색 가능성 때문에 템플릿을 써야 합니다.
[LoggerMessage] 는 partial 메서드 선언 하나로
박싱과 배열 할당을 없앱니다. 전부 바꿀 필요는 없고, 많이 도는 곳과 평소에 꺼 두는 로그부터 적용하세요.
이득이 있는지는 MemoryDiagnoser 로 확인하고 판단합니다.Serilog 실전 — 파이프라인 · sink · enricher
Serilog 를 이해하는 가장 빠른 길은 "이벤트 하나가 지나가는 파이프라인" 으로 보는 것입니다. 만들고 → 정보를 덧붙이고 → 거르고 → 형식을 정하고 → 여러 곳으로 보냅니다. 설정 파일의 항목 이름이 전부 이 순서와 맞습니다.
배선 — Program.cs 는 이 정도면 끝
using Serilog;
// ① 기동 중 터지는 예외도 잡으려면 부트스트랩 로거를 먼저 만듭니다
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// ② 실제 설정은 appsettings.json 에서 읽습니다
builder.Host.UseSerilog((ctx, sp, cfg) => cfg
.ReadFrom.Configuration(ctx.Configuration)
.ReadFrom.Services(sp) // DI 로 등록한 enricher/sink
.Enrich.FromLogContext());
var app = builder.Build();
app.UseSerilogRequestLogging(); // 요청 요약 한 줄
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "기동 실패");
}
finally
{
Log.CloseAndFlush(); // ★ 버퍼를 비우고 끝냅니다
}
Log.CloseAndFlush() 를 빼면 마지막 로그가 사라집니다.
특히 비동기 sink 를 쓰면 종료 직전 몇 초치가 통째로 유실됩니다.
컨테이너가 SIGTERM 을 보내고 몇 초 뒤 SIGKILL 하는 환경에서
가장 보고 싶은 로그(종료 원인)가 없어지는 이유가 이것입니다.
설정을 JSON 으로 — 재배포 없이 바꾸기
{
"Serilog": {
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File",
"Serilog.Sinks.Seq" ],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
},
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId" ],
"Properties": { "App": "order-api", "Env": "prod" },
"WriteTo": [
{ "Name": "Console",
"Args": { "formatter":
"Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact" } },
{ "Name": "File",
"Args": {
"path": "logs/app-.ndjson",
"rollingInterval": "Day",
"retainedFileCountLimit": 14,
"fileSizeLimitBytes": 104857600,
"rollOnFileSizeLimit": true
} },
{ "Name": "Seq",
"Args": { "serverUrl": "http://seq:5341",
"restrictedToMinimumLevel": "Information" } }
]
}
}
Using 배열을 빠뜨리면 sink 를 못 찾습니다 —
"설정은 맞는데 파일이 안 생긴다"의 1순위 원인입니다.
retainedFileCountLimit 과 fileSizeLimitBytes 는
반드시 함께 주세요. 디스크가 차면 서버가 죽습니다.
enricher — 모든 로그에 자동으로 붙는 값 🟡 중급
직접 만드는 enricher
public sealed class TenantEnricher(IHttpContextAccessor acc) : ILogEventEnricher
{
public void Enrich(LogEvent e, ILogEventPropertyFactory f)
{
var tenant = acc.HttpContext?.User.FindFirst("tenant")?.Value;
if (tenant is not null)
e.AddPropertyIfAbsent(f.CreateProperty("TenantId", tenant));
}
}
// 등록 (ReadFrom.Services(sp) 가 이걸 집어 갑니다)
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ILogEventEnricher, TenantEnricher>();
// 코드 블록 범위에만 값을 붙이기 — LogContext
using (LogContext.PushProperty("BatchId", batchId))
using (LogContext.PushProperty("FileName", file.Name))
{
await ProcessAsync(file); // 이 안의 모든 로그에 두 값이 붙습니다
} // using 을 벗어나면 자동으로 빠집니다
Enrich.FromLogContext() 를 켜야 PushProperty 가 동작합니다.
이걸 빼놓고 "값이 안 붙는다"고 하는 경우가 흔합니다.
ILogger.BeginScope 로 넣은 값도 Serilog 에서는 같은 통로로 흘러갑니다.
Serilog.Expressions — 조건 필터
{
"Serilog": {
"Using": [ "Serilog.Expressions" ],
"Filter": [
{ "Name": "ByExcluding",
"Args": { "expression":
"RequestPath like '/health%' or RequestPath like '/metrics%'" } },
{ "Name": "ByExcluding",
"Args": { "expression":
"SourceContext = 'Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware'" } }
],
"WriteTo": [
{ "Name": "Console",
"Args": { "formatter": {
"type": "Serilog.Templates.ExpressionTemplate, Serilog.Expressions",
"template":
"[{@t:HH:mm:ss} {@l:u3}] {TraceId} {@m}\n{@x}" } } }
]
}
}
/health 를 찌릅니다.
1초 간격이면 파드 하나에 하루 8만 6,400 번(기본 10초 간격이어도 프로브 둘이면 1만 7천 번 남짓)이고,
파드 수만큼 곱해져 그게 수집기 요금이 됩니다.
필터는 성능이 아니라 비용과 가독성의 문제입니다.
UseSerilog + ReadFrom.Configuration 만 두고, 나머지는 전부 JSON 으로 뺍니다.
Log.CloseAndFlush() 와 파일 보관 한도 두 가지는 절대 빠뜨리지 마세요.NLog 실전과 고르는 순서 — 언제 Serilog 대신 쓰나
NLog 는 설정 파일 하나로 로그의 목적지와 규칙을 통째로 바꾸는 데 강합니다. 코드를 못 건드리는 운영 환경, 이미 XML 설정 문화가 있는 조직에서 여전히 좋은 선택입니다. 반대로 수집기로 구조적 이벤트를 보내는 것이 목적이면 Serilog 가 편합니다.
nlog.config — 이 파일이 전부입니다
<?xml version="1.0" encoding="utf-8" ?>
<nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
autoReload="true" throwConfigExceptions="true">
<!-- ↑ xsi 선언이 없으면 아래 xsi:type 이 XML 오류가 됩니다 -->
<targets>
<!-- 파일 — 날짜별 롤링 + 보관 30일 -->
<target xsi:type="File" name="file"
fileName="logs/app-${shortdate}.log"
archiveEvery="Day" maxArchiveDays="30"
archiveAboveSize="104857600"
layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}" />
<!-- 구조적 JSON — 수집기로 보낼 때 -->
<target xsi:type="File" name="json" fileName="logs/app.ndjson">
<layout xsi:type="JsonLayout" includeEventProperties="true">
<attribute name="t" layout="${date:universalTime=true:format=o}" />
<attribute name="l" layout="${level}" />
<attribute name="m" layout="${message}" />
</layout>
</target>
<target xsi:type="ColoredConsole" name="con"
layout="${time}|${level:uppercase=true}|${message}" />
</targets>
<rules>
<logger name="Microsoft.*" maxlevel="Info" final="true" />
<logger name="*" minlevel="Debug" writeTo="file" />
<logger name="*" minlevel="Info" writeTo="con,json" />
</rules>
</nlog>
autoReload="true" 가 NLog 의 진짜 장점입니다 —
운영 서버에서 이 XML 만 고치면 재시작 없이 로그 레벨과 목적지가 바뀝니다.
final="true" 규칙으로 여기서 처리를 끝내라고 지정할 수 있어
소음 차단이 간결합니다.
배선과 주의점
using NLog;
using NLog.Web;
var logger = NLog.LogManager.Setup()
.LoadConfigurationFromAppSettings() // 또는 nlog.config
.GetCurrentClassLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders(); // ★ 중복 방지
builder.Host.UseNLog(); // MEL → NLog 로 연결
var app = builder.Build();
app.Run();
}
catch (Exception ex) { logger.Error(ex, "기동 실패"); throw; }
finally { NLog.LogManager.Shutdown(); } // ★ 버퍼 flush
nlog.config 를 출력 폴더로 복사해야 합니다.
<None Update="nlog.config"><CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory></None>
를 .csproj 에 넣지 않으면 배포본에서 로그가 조용히 사라집니다.
throwConfigExceptions="true" 를 켜 두면 이런 실수가 기동 시 바로 터집니다.
Serilog 와 나란히 놓고 보기 🟡 중급
| 항목 | Serilog | NLog |
|---|---|---|
| 설정 위치 | appsettings.json (또는 코드) | nlog.config XML — 코드와 완전히 분리 |
| 재시작 없이 변경 | reloadOnChange · LoggingLevelSwitch | autoReload="true" 가 기본 |
| 구조적 로깅 | 처음부터 전제 — 템플릿이 곧 이벤트 | 가능(JsonLayout) · 레이아웃을 직접 짜야 함 |
| 내보낼 곳 | 가장 많음 — Seq · Elastic · Splunk · S3 · Datadog… | 파일 · DB · 메일 · 이벤트로그가 기본 내장 |
| 레이아웃 표현 | ExpressionTemplate | ${...} 레이아웃 렌더러 — 종류가 매우 많음 |
| 고르는 한마디 | 수집기로 이벤트를 보낸다면 이쪽 | 파일·DB 로 남기고 XML 로 운영한다면 이쪽 |
ILogger<T> 뒤에 있습니다. 앱 코드는 어느 쪽을 쓰든 똑같습니다.
그래서 이 선택은 되돌릴 수 있는 결정입니다 — Program.cs 몇 줄과 설정 파일만 바뀝니다.
되돌릴 수 있는 결정에 회의를 오래 하지 마세요.
반대로 문자열을 합쳐서 로그를 남기는 습관은 되돌릴 수 없습니다(쌓인 로그가 못 쓰게 됩니다).
nlog.config 하나로 운영이 끝나는 것이 장점입니다.
autoReload · final 규칙 · 풍부한 레이아웃 렌더러가 강점이고,
수집기 중심이라면 Serilog 가 손이 덜 갑니다. 어느 쪽이든 앱 코드는 ILogger<T> 입니다.ASP.NET Core 연동 — 요청 로그와 스코프
웹 앱에서 로그의 90%는 요청과 함께 나옵니다. 문제는 두 가지입니다 — 기본 요청 로그가 너무 시끄럽고, 줄마다 어느 요청인지 알 수 없다는 것. 전자는 필터로, 후자는 스코프로 해결합니다.
기본 요청 로그의 소음 줄이기
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.Hosting.Diagnostics": "Information",
"Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning",
"Microsoft.AspNetCore.Mvc.Infrastructure": "Warning",
"Microsoft.AspNetCore.StaticFiles": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
Microsoft.AspNetCore 를 Warning 으로 누르고
필요한 하나만 되살리는 것이 정석입니다.
Serilog 를 쓴다면 UseSerilogRequestLogging() 이 이 전부를 한 줄로 대체합니다.
HTTP 로깅 미들웨어 (내장)
builder.Services.AddHttpLogging(o =>
{
o.LoggingFields = HttpLoggingFields.RequestPath
| HttpLoggingFields.RequestMethod
| HttpLoggingFields.ResponseStatusCode
| HttpLoggingFields.Duration; // ★ 본문은 빼세요
o.RequestHeaders.Add("X-Client-Version"); // 허용 목록 방식
o.ResponseHeaders.Add("X-Trace-Id");
o.CombineLogs = true; // 요청/응답을 한 줄로
});
var app = builder.Build();
app.UseHttpLogging();
// 특정 엔드포인트만 본문까지 (진단용 · 임시)
app.MapPost("/orders", Handler)
.WithHttpLogging(HttpLoggingFields.RequestBody);
Authorization·Cookie 는
[Redacted] 로 나옵니다. 허용 목록에 직접 추가하지 마세요.
RequestBody·ResponseBody 는 운영에서 켜지 마세요 —
비밀번호·카드번호가 그대로 남고, 큰 본문은 메모리도 먹습니다.
스코프 — 요청 하나를 끝까지 따라가기 🟡 중급
BeginScope 미들웨어
app.Use(async (ctx, next) =>
{
var log = ctx.RequestServices.GetRequiredService<ILogger<Program>>();
var traceId = Activity.Current?.TraceId.ToString()
?? ctx.TraceIdentifier;
ctx.Response.Headers["X-Trace-Id"] = traceId; // ★ 클라이언트에도 돌려줍니다
using (log.BeginScope(new Dictionary<string, object?>
{
["TraceId"] = traceId,
["UserId"] = ctx.User.FindFirst("sub")?.Value,
["TenantId"] = ctx.Request.Headers["X-Tenant"].ToString()
}))
{
await next(ctx);
}
});
Console 은 IncludeScopes(기본 false),
JSON 포매터도 FormatterOptions.IncludeScopes(역시 기본 false), Serilog 는 Enrich.FromLogContext().
"스코프를 넣었는데 안 보인다"의 원인은 대부분 여기입니다.
스코프를 켜는 설정
{
"Logging": {
"Console": {
"FormatterName": "json",
"FormatterOptions": {
"IncludeScopes": true,
"JsonWriterOptions": { "Indented": false },
"TimestampFormat": "yyyy-MM-ddTHH:mm:ss.fffZ",
"UseUtcTimestamp": true
}
}
}
}
// 코드로 켤 때
builder.Logging.AddJsonConsole(o =>
{
o.IncludeScopes = true;
o.UseUtcTimestamp = true;
});
// 사람이 보는 콘솔이라면 SimpleConsole 이 읽기 좋습니다
builder.Logging.AddSimpleConsole(o =>
{
o.SingleLine = true;
o.IncludeScopes = true;
o.TimestampFormat = "HH:mm:ss ";
});
감사 로그가 필요할 때 — W3CLogger 🔴 실무
// 애플리케이션 로그와 별개로, 웹 서버 형식(W3C Extended)의 접근 로그를
// 파일로 남깁니다. 보안 감사·트래픽 분석 요구가 있을 때 씁니다.
builder.Services.AddW3CLogging(o =>
{
o.LoggingFields = W3CLoggingFields.All & ~W3CLoggingFields.UserName;
o.LogDirectory = @"C:\logs\w3c";
o.FlushInterval = TimeSpan.FromSeconds(2);
o.FileSizeLimit = 50 * 1024 * 1024;
o.RetainedFileCountLimit = 30;
o.FileName = "access-";
o.AdditionalRequestHeaders.Add("X-Tenant");
});
var app = builder.Build();
app.UseW3CLogging();
Microsoft.AspNetCore 를 Warning 으로 누르고 요약 한 줄만 남기고,
BeginScope 로 TraceId · UserId 를 붙여 두면 조사 시간이 열 배 줄어듭니다.Activity 와 분산 추적 — TraceId 는 어떻게 자동으로 붙나
서비스가 하나면 로그만으로 충분합니다. 둘만 되어도 "이 요청이 저쪽에서 뭘 했나"를
이어 붙일 방법이 필요합니다. .NET 은 System.Diagnostics.Activity 로
표준 W3C Trace Context 를 처리하고, 로거는 그 값을 알아서 읽어 씁니다.
원리 — 헤더 한 줄이 전부입니다
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
│ │ │ │
│ │ │ └ 플래그(샘플링됨)
│ │ └ SpanId — 이 구간 하나의 ID
│ └ TraceId — 요청 전체에 하나. 서비스를 넘어가도 같습니다
└ 버전
# .NET 은 이 헤더를 자동으로 읽고(수신), 자동으로 붙입니다(HttpClient 송신).
# 코드를 한 줄도 안 써도 이미 동작합니다 — .NET 5 부터 기본이 W3C 형식입니다.
// 지금 요청의 추적 값 읽기
var act = Activity.Current;
string? traceId = act?.TraceId.ToString(); // 32자리 hex
string? spanId = act?.SpanId.ToString(); // 16자리 hex
// 내 코드 구간을 하나의 span 으로 만들기
private static readonly ActivitySource Source = new("MyApp.Orders", "1.0.0");
using var activity = Source.StartActivity("주문 검증");
activity?.SetTag("order.id", orderId);
activity?.SetTag("order.total", total);
// ... 작업 ...
activity?.SetStatus(ActivityStatusCode.Error, "재고 부족");
ActivitySource 는 static readonly 로 한 번만 만듭니다.
이름은 보통 어셈블리 이름을 씁니다 — 나중에 OpenTelemetry 에서
AddSource("MyApp.Orders") 로 이 이름을 지정해야 수집됩니다.
StartActivity 는 아무도 안 듣고 있으면 null 을 돌려줍니다(그래서 ?.).
로그에 TraceId 를 자동으로 넣기
// ① 내장 Console — 스코프만 켜면 됩니다
builder.Logging.AddJsonConsole(o => o.IncludeScopes = true);
// 호스트 기본값이 ActivityTrackingOptions = TraceId|SpanId|ParentId 라
// 이 값들이 "스코프"로 들어갑니다 — IncludeScopes 를 안 켜면 출력에 안 보입니다.
// ② 스코프에 명시적으로 넣기
builder.Logging.Configure(o => o.ActivityTrackingOptions =
ActivityTrackingOptions.TraceId
| ActivityTrackingOptions.SpanId
| ActivityTrackingOptions.ParentId
| ActivityTrackingOptions.Baggage);
// ③ Serilog — enricher 하나면 끝
// dotnet add package Serilog.Enrichers.Span (또는 직접 작성)
Log.Logger = new LoggerConfiguration()
.Enrich.FromLogContext()
.Enrich.WithProperty("service.name", "order-api")
.Enrich.With<TraceEnricher>()
.WriteTo.Seq("http://seq:5341")
.CreateLogger();
public sealed class TraceEnricher : ILogEventEnricher
{
public void Enrich(LogEvent e, ILogEventPropertyFactory f)
{
var a = Activity.Current;
if (a is null) return;
e.AddPropertyIfAbsent(f.CreateProperty("TraceId", a.TraceId.ToString()));
e.AddPropertyIfAbsent(f.CreateProperty("SpanId", a.SpanId.ToString()));
}
}
OpenTelemetry 로 내보내기 🔴 실무
배선 — 로그·추적·지표를 한 번에
// dotnet add package OpenTelemetry.Extensions.Hosting
// dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
// dotnet add package OpenTelemetry.Instrumentation.AspNetCore
// dotnet add package OpenTelemetry.Instrumentation.Http
builder.Services.AddOpenTelemetry()
.ConfigureResource(r => r.AddService("order-api", serviceVersion: "1.4.2"))
.WithTracing(t => t
.AddAspNetCoreInstrumentation(o =>
o.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health"))
.AddHttpClientInstrumentation()
.AddSource("MyApp.Orders") // ★ 내 ActivitySource 이름
.AddOtlpExporter())
.WithMetrics(m => m
.AddAspNetCoreInstrumentation()
.AddRuntimeInstrumentation()
.AddOtlpExporter());
// 로그도 같은 파이프라인으로
builder.Logging.AddOpenTelemetry(o =>
{
o.IncludeScopes = true;
o.IncludeFormattedMessage = true;
o.ParseStateValues = true;
o.AddOtlpExporter();
});
OTEL_EXPORTER_OTLP_ENDPOINT 환경 변수만 바꾸면 목적지가 바뀝니다.
벤더 SDK 를 코드에 심지 마세요 — 그것이 OpenTelemetry 의 존재 이유입니다.
로그와 추적, 무엇을 어디에 담나
| 담을 것 | 어디에 |
|---|---|
| 업무 사건 (주문 접수) | 로그 — 사람이 읽을 문장 + 필드 |
| 구간 소요 시간 | 추적(span) — 로그로 재지 마세요 |
| 초당 처리량 · 오류율 | 지표(metric) — 로그를 세는 것보다 훨씬 쌉니다 |
| 예외 스택 | 로그 + span 에 SetStatus(Error) |
| 요청 식별자 | 양쪽 모두 — TraceId 로 잇습니다 |
| 개인정보 | 어느 쪽에도 안 됩니다 |
count(*) 를 돌리는 것.
같은 일을 Meter 카운터 하나로 하면
저장 비용이 자릿수 단위로 줄어듭니다.
로그는 세는 도구가 아니라 읽는 도구입니다.
traceparent 헤더 한 줄이 서비스 사이를 잇고,
.NET 은 그것을 Activity.Current 로 들고 있습니다.
로거가 그 값을 읽어 붙이므로 우리가 할 일은 켜 두는 것뿐입니다.
시간은 span, 사건은 로그, 개수는 지표 — 담을 곳을 나누면 비용이 내려갑니다.성능과 비용 — 로그는 공짜가 아닙니다
로그 한 줄에는 문자열 할당 · 직렬화 · 파일 I/O · 네트워크 · 저장 요금이 붙습니다. 대부분은 무시해도 되지만, 어디서 대가를 치르는지는 알아야 "로그를 더 남길까 말까"를 감이 아니라 선택으로 할 수 있습니다.
비싼 자리 넷
- 문자열 만들기 — 보간 ·
ToString()·JsonSerializer. 레벨이 꺼져 있어도 인자를 준비하면 이미 비용입니다. - 박싱 —
int·DateTime을object[]에 담을 때.[LoggerMessage]로 없앱니다. - 동기 I/O — 파일
flush나 네트워크 전송이 요청 스레드를 붙잡는 경우. - 저장 · 색인 요금 — 수집기는 수집량으로 과금합니다. 로그 한 줄이 곧 돈이 되는 구간입니다.
// 인자를 만드는 비용이 큰 경우에만 IsEnabled 로 감쌉니다
if (_log.IsEnabled(LogLevel.Debug))
{
// 이 직렬화가 비싼 것이지, 로그 호출이 비싼 게 아닙니다
_log.LogDebug("응답 원문 {Body}", JsonSerializer.Serialize(dto));
}
// 반대로 이런 건 감쌀 필요 없습니다 (인자 준비 비용이 0)
_log.LogDebug("주문 {OrderId} 검증 통과", orderId);
IsEnabled 를 모든 로그에 붙이지 마세요.
로거 내부가 이미 같은 검사를 합니다. 인자를 만드는 데 비용이 드는 경우에만 감쌉니다 —
직렬화 · 큰 컬렉션 순회 · 문자열 결합 같은 것들입니다.
비동기 sink 와 유실
// dotnet add package Serilog.Sinks.Async
Log.Logger = new LoggerConfiguration()
.WriteTo.Async(a => a.File("logs/app-.ndjson",
rollingInterval: RollingInterval.Day),
bufferSize: 10_000,
blockWhenFull: false) // ★ 여기가 선택 지점
.CreateLogger();
// blockWhenFull: false → 큐가 차면 로그를 버립니다 (앱은 안 느려짐)
// blockWhenFull: true → 큐가 찰 때까지 요청 스레드가 기다립니다 (로그는 안 잃음)
//
// ※ 어느 쪽이든 프로세스가 강제 종료되면 큐에 남은 것은 사라집니다.
// Log.CloseAndFlush() 를 반드시 호출하세요.
// 종료 유예 시간 — 컨테이너의 유예(쿠버네티스 기본 30초)보다 짧게 맞춥니다
// ★ 서비스 등록은 Build() 전에 — 뒤에서 하면 읽기 전용 컬렉션이라 예외가 납니다
builder.Services.Configure<HostOptions>(o =>
o.ShutdownTimeout = TimeSpan.FromSeconds(15));
// 종료 시 확실히 비우기 — 호스트 종료 훅에 연결
var app = builder.Build();
app.Lifetime.ApplicationStopped.Register(Log.CloseAndFlush);
WriteTo.Logger(l => l.Filter.ByIncludingOnly(...).WriteTo.File(...))).
재는 법 — 감이 아니라 숫자로 🔴 실무
돌고 있는 프로세스에서 바로 보기
dotnet tool install -g dotnet-counters
dotnet tool install -g dotnet-trace
# ① 할당률·GC 를 실시간으로 — 로그를 껐다 켜며 차이를 봅니다
dotnet-counters monitor --process-id 1234 \
--counters System.Runtime[alloc-rate,gen-0-gc-count,time-in-gc]
# ② 로깅이 실제로 얼마나 도는지 이벤트로 세기
dotnet-trace collect --process-id 1234 \
--providers Microsoft-Extensions-Logging:4:4
# ③ 결과를 speedscope 로 열어 봅니다
dotnet-trace convert trace.nettrace --format speedscope
양을 줄이는 실제 수단
| 수단 | 효과 · 주의 |
|---|---|
| 카테고리 필터 | 가장 싸고 확실합니다. Microsoft.* 를 Warning 으로 |
| 헬스체크·정적파일 제외 | 수집량이 눈에 띄게 줄어듭니다 |
| 요약 한 줄 | 요청당 6줄 → 1줄. UseSerilogRequestLogging |
| 샘플링 | 성공 요청은 10%만, 오류는 100% |
| 지표로 대체 | "세기"는 Meter 로. 로그보다 훨씬 쌉니다 |
| 보관 기간 단축 | 90일 → 14일. 대부분 7일이면 충분합니다 |
| 압축 · 아카이브 | 오래된 것은 객체 스토리지로 내리기 |
IsEnabled 로 감싸고,
비동기 sink 를 쓴다면 종료 시 flush 를 반드시 챙기세요.
그리고 줄이기 전에 재세요 — 대개 로깅은 병목이 아닙니다.민감정보 — 한 번 새면 지울 수 없습니다
로그는 복제되기 위해 존재합니다 — 파일로, 수집기로, 백업으로, 감사 시스템으로. 그래서 개인정보나 비밀이 한 줄 섞이면 지워야 할 곳이 네 군데가 됩니다. 마스킹보다 애초에 안 넣는 설계가 유일하게 통합니다.
실제로 자주 새는 다섯 경로
| 경로 | 무엇이 새나 |
|---|---|
EF Core 쿼리 로그Database.Command = Information | SQL 파라미터 값 전체 — 주민번호 · 이메일 |
| 기동 시 설정 덤프 | 커넥션 문자열 · API 키 |
{@Object} 통째 구조화 | 엔티티의 모든 속성 |
| HTTP 본문 로깅 | 비밀번호 · 카드번호 · 토큰 |
| 예외 메시지 | 실패한 SQL · URL 의 쿼리스트링 토큰 |
// ❌ 이 한 줄이 사고의 시작입니다
_log.LogInformation("DB 연결 {Conn}", cfg.GetConnectionString("Main"));
// → Server=...;User Id=sa;Password=P@ssw0rd! 가 그대로 남습니다
// ✅ 비밀을 뺀 부분만
var b = new SqlConnectionStringBuilder(cfg.GetConnectionString("Main"));
_log.LogInformation("DB 연결 {Server}/{Db}", b.DataSource, b.InitialCatalog);
? 로 가립니다.
그런데 EnableSensitiveDataLogging() 을 켜면 전부 나옵니다.
개발 편의로 켜 두고 운영에 그대로 나가는 것이 가장 흔한 유출 경로입니다.
#if DEBUG 로 감싸세요.
막는 법 — 세 겹으로
// ① 타입 자체가 로그에 안 나오게 (가장 확실)
public readonly struct Secret(string value)
{
private readonly string _v = value;
public string Reveal() => _v;
public override string ToString() => "***"; // 어디에 찍혀도 ***
}
// ② Serilog destructure 정책 — 특정 타입/속성을 통째로 가리기
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<User>(u => new { u.Id, u.Grade })
.Destructure.ByTransforming<PaymentRequest>(p => new
{
p.OrderId,
Card = Mask(p.CardNumber), // 1234-****-****-5678
p.Amount
})
.CreateLogger();
static string Mask(string s) =>
s.Length <= 8 ? "****" : s[..4] + new string('*', s.Length - 8) + s[^4..];
// ③ .NET 8+ 표준 — [LogProperties] + 데이터 분류 리댁션
// dotnet add package Microsoft.Extensions.Compliance.Redaction
// dotnet add package Microsoft.Extensions.Telemetry (EnableRedaction)
// ★ PersonalData · SensitiveData 는 기본 제공이 아니라 우리 팀이 정의하는 분류 특성입니다
// (DataClassificationAttribute 를 상속하고, DataClassifications 도 직접 만든 정적 클래스)
public sealed class Customer
{
public int Id { get; init; }
[PersonalData] public string Email { get; init; } = "";
[SensitiveData] public string Ssn { get; init; } = "";
}
builder.Services.AddRedaction(r =>
r.SetRedactor<ErasingRedactor>(new DataClassificationSet(
DataClassifications.SensitiveData)));
builder.Logging.EnableRedaction(); // ★ 이게 있어야 로그 파이프라인에 리댁터가 걸립니다
string 으로 들고 다니지 말고
전용 타입으로 감싸면, 실수로 로그에 넣어도 *** 만 나옵니다.
사고가 났을 때 — 대응 순서 🔴 실무
유출을 발견했다면
- 먼저 흘려보내는 코드를 막습니다 — 설정 한 줄이면 즉시 배포. 로그를 지우는 것보다 더 새지 않게 하는 것이 먼저입니다.
- 노출된 비밀을 무효화합니다 — 커넥션 문자열이 샜다면 비밀번호를 바꾸는 것이 로그를 지우는 것보다 빠르고 확실합니다. 토큰·API 키도 마찬가지로 회전시킵니다.
- 범위를 셉니다 — 어느 기간, 어느 파일, 어느 색인, 어느 백업. 수집기의 보관 정책을 확인하면 자동 삭제 시점이 나옵니다.
- 지울 수 있는 곳부터 지웁니다 — 색인은 대개 지원하지만, 백업 테이프와 외부 감사 시스템은 못 지우는 경우가 많습니다.
- 기록을 남깁니다 — 개인정보라면 법적 통지 의무가 걸립니다. 보안 담당자에게 즉시 알리세요. 혼자 조용히 처리하려는 것이 최악입니다.
사전에 막는 장치
// 테스트로 막기 — 금지 패턴이 로그에 나오면 실패
[Fact]
public void 로그에_비밀이_없어야_한다()
{
var sink = new ListLoggerProvider();
using var f = LoggerFactory.Create(b => b.AddProvider(sink));
new PaymentService(f.CreateLogger<PaymentService>())
.Pay(new PaymentRequest { CardNumber = "4111111111111111" });
Assert.DoesNotContain(sink.Lines, l => l.Contains("4111111111111111"));
}
# CI 에서 소스 자체를 검사 — 위험한 호출을 금지 목록으로
# (#if DEBUG 는 보통 윗줄에 있어 줄 단위 grep -v 로는 못 거릅니다 — 허용할 파일만 --exclude 로)
grep -rn "EnableSensitiveDataLogging" --include=*.cs \
--exclude=DevDbOptions.cs src/ && exit 1
# 로그 샘플을 정규식으로 훑기 (주민번호 · 카드번호 형태)
grep -Ern "[0-9]{6}-[1-4][0-9]{6}|[0-9]{16}" logs/ && exit 1
{@User} 와 설정 덤프의 대부분을 걸러 냅니다.
{@엔티티} · HTTP 본문 넷만 막아도 사고의 대부분이 사라집니다.
막는 순서는 타입 → 정책 → 리뷰이고, 새면 로그를 지우기 전에 비밀을 회전시키세요.배포 환경별 — 콘솔 · 파일 · EventLog 와 로테이션
같은 앱이라도 어디에 배포하느냐에 따라 로그를 남기는 곳이 달라야 합니다. 컨테이너에 파일을 남기면 컨테이너가 죽을 때 같이 사라지고, 윈도우 서비스에서 콘솔에만 쓰면 아무 데도 안 남습니다.
| 배포 형태 | 어디에 남기나 | 왜 | 빠뜨리기 쉬운 것 |
|---|---|---|---|
| 컨테이너 · 쿠버네티스 | 표준 출력(stdout) JSON 한 줄 | 런타임이 stdout 을 걷어 수집기로 보냅니다. 파일을 쓰면 컨테이너와 함께 사라지고 레이어만 부풀립니다 | 여러 줄 스택트레이스가 줄마다 다른 이벤트로 쪼개짐 → JSON 한 줄로 |
| 윈도우 서비스 | 파일 + Error 이상만 EventLog | 콘솔이 없습니다. 운영자가 이벤트 뷰어로 먼저 봅니다 | retainedFileCountLimit 누락 → 디스크 100% |
| IIS 호스팅 | 파일 + stdout 로그(기동 실패용) |
앱이 못 뜨는 경우는 앱 로그가 없습니다. web.config 의 stdout 로그가 유일한 단서 |
진단 후 stdout 로그를 끄지 않아 파일이 계속 쌓임 |
| Linux systemd | stdout → journald | journalctl -u myapp -f 로 바로 봅니다. 로테이션을 OS 가 합니다 |
SystemdConsole 포매터를 안 써서 우선순위가 전부 info 로 |
| 설치형 데스크탑 (WPF) | %LOCALAPPDATA% 아래 파일 + EventLog |
사용자가 로그를 첨부해 보내야 합니다. 경로가 예측 가능해야 함 | Program Files 아래에 쓰려다 권한 오류 |
| 콘솔 도구 · 배치 | 콘솔 + 실행별 파일 하나 | 스케줄러가 돌린 결과를 나중에 봐야 합니다 | 실행마다 파일이 늘어 수만 개가 됨 |
환경별로 설정을 가르는 실제 코드
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
if (Environment.GetEnvironmentVariable("KUBERNETES_SERVICE_HOST") is not null
|| builder.Environment.IsProduction() && OperatingSystem.IsLinux())
{
// 컨테이너 · 리눅스 — 한 줄 JSON 만
builder.Logging.AddJsonConsole(o => { o.IncludeScopes = true;
o.UseUtcTimestamp = true; });
}
else if (OperatingSystem.IsWindows() && !Environment.UserInteractive)
{
// 윈도우 서비스 — 파일 + 이벤트 로그
builder.Logging.AddEventLog(new EventLogSettings
{
SourceName = "OrderService", // ★ 관리자 권한으로 미리 등록 필요
LogName = "Application",
Filter = (_, lv) => lv >= LogLevel.Error
});
// ★ writeToProviders: true — 기본(false)이면 Serilog 가 로거 팩토리를 차지해
// 위 EventLog provider 로는 아무것도 안 갑니다
builder.Services.AddSerilog((_, cfg) => cfg
.ReadFrom.Configuration(builder.Configuration), writeToProviders: true);
}
else
{
// 개발 PC — 사람이 읽기 좋게
builder.Logging.AddSimpleConsole(o => { o.SingleLine = true;
o.TimestampFormat = "HH:mm:ss "; });
}
EventLog 소스는 미리 등록해야 합니다 —
New-EventLog -LogName Application -Source OrderService 를
설치 스크립트에서 관리자 권한으로 한 번 실행합니다.
앱이 실행 중에 만들려 하면 권한 오류가 나고,
그 오류조차 로그로 못 남깁니다.
로테이션 · 보관 — 반드시 넣을 네 항목
{ "Name": "File",
"Args": {
"path": "logs/app-.ndjson",
"rollingInterval": "Day", // ① 날짜별로 새 파일
"rollOnFileSizeLimit": true, // ② 크기 넘으면 _001, _002 …
"fileSizeLimitBytes": 104857600, // ③ 파일 하나 100 MB
"retainedFileCountLimit": 14, // ④ 오래된 것부터 삭제
"shared": false, // 여러 프로세스가 같은 파일을 쓰면 true
"flushToDiskInterval": "00:00:02"
} }
# 리눅스에서 OS 에 맡길 때 (/etc/logrotate.d/myapp)
/var/log/myapp/*.ndjson {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate # 앱이 파일 핸들을 잡고 있어도 안전
}
# journald 를 쓴다면 용량만 정하면 됩니다
# /etc/systemd/journald.conf → SystemMaxUse=2G
로그가 안 보일 때 — 진단 순서 🔴 실무
이 순서로 확인하면 대부분 잡힙니다
- 레벨 —
appsettings.Production.json이 덮어쓰고 있지 않은가. 환경 변수Logging__LogLevel__Default도 확인. - provider 가 등록됐나 —
ClearProviders()뒤에 아무것도 안 넣은 경우가 흔합니다. - 설정 파일이 배포됐나 —
nlog.config·appsettings.json의CopyToOutputDirectory. - 쓸 권한이 있나 — 서비스 계정이 로그 폴더에 쓸 수 있는지.
Program Files아래는 대개 못 씁니다. - flush 됐나 — 비동기 sink +
CloseAndFlush()누락. - 필터에 걸렸나 — Serilog
Filter.ByExcluding표현식이 의도보다 넓게 잡는 경우.
로깅 자체를 디버깅하는 도구
// Serilog 내부 오류를 콘솔로 (sink 가 조용히 죽는 경우를 잡습니다)
Serilog.Debugging.SelfLog.Enable(Console.Error);
// 지금 적용된 필터 규칙을 찍어 보기
var opts = app.Services.GetRequiredService<IOptions<LoggerFilterOptions>>().Value;
foreach (var r in opts.Rules)
Console.WriteLine($"{r.ProviderName} | {r.CategoryName} | {r.LogLevel}");
# 프로세스에 붙어 로깅 이벤트만 실시간으로 보기
dotnet-trace collect -p 1234 \
--providers Microsoft-Extensions-Logging:4:5:EventSourceFilter=*
# 파일이 실제로 커지고 있는지 (윈도우)
Get-Item C:\logs\app-*.ndjson | Select Name,Length,LastWriteTime
SelfLog 는 아껴 두지 말고 개발 환경에서 항상 켜 두세요.
Serilog 는 sink 가 실패해도 앱을 멈추지 않습니다(그게 맞습니다).
대신 조용히 사라지므로, "Seq 로 안 간다" 의 원인이
URL 오타 하나인 경우가 아주 많습니다.
넣은 순서가
곧 처리 순서인
자료구조
C# 에서 스택·큐는 "어떤 타입을 고르느냐"가 이야기의 절반입니다.
자바는 Stack 을 버리고 ArrayDeque 로 갔지만, C# 은 반대로 Stack<T> · Queue<T> 가 그대로 정답이고,
대신 표준 덱(Deque)이 없다는 구멍이 있습니다.
이 탭은 내부 원형 배열(_head · _tail) · Try 패턴 · PriorityQueue 의 함정 · ConcurrentQueue 와 Channel 의 차이 · 배압까지
2026 년 현업에서 실제로 쓰는 타입과 관용구를 소스 수준으로 뜯어 봅니다.
끝나면 작업 큐 · 최근 N개 버퍼 · 레이트 리미터 · 다익스트라를 "왜 이 타입인지" 설명하며 짤 수 있습니다.
한 장 지도 — C# 은 무엇으로 쓰나
스택 · 큐 · 덱 · 우선순위 큐 · 스레드 안전 큐 · 비동기 채널 — 여섯 가지 일을 .NET 이 각각 어떤 타입으로 하는지, 그리고 무엇을 새 코드에서 쓰면 안 되는지를 먼저 한 장으로 정리합니다. 이 표 하나만 외워 두면 나머지 섹션은 "왜 그런가"를 확인하는 과정입니다.
2026 권장 vs 쓰지 말 것
| 하려는 일 | 권장 (.NET 10) | 쓰지 말 것 |
|---|---|---|
| 스택 (LIFO) | Stack<T> | System.Collections.Stack (object · 박싱) |
| 큐 (FIFO) | Queue<T> | List<T>.RemoveAt(0) (O(n)) · 비제네릭 Queue |
| 덱 (양쪽 끝) | 직접 만든 원형 버퍼 · 급하면 LinkedList<T> | List<T>.Insert(0, x) (O(n)) |
| 우선순위 큐 | PriorityQueue<TElement, TPriority> .NET 6+ | SortedSet 로 흉내 (중복 키가 사라짐) |
| 스레드 안전 큐 · 스택 | ConcurrentQueue<T> · ConcurrentStack<T> | Queue.Synchronized(...) · lock + Queue 남발 |
| 생산자 · 소비자 (기다림 포함) | Channel<T> (System.Threading.Channels) | BlockingCollection<T> 레거시 쪽 |
| 불변 스냅숏 · undo | ImmutableStack<T> · ImmutableQueue<T> | 매번 new Stack<T>(old) 로 복사 (뒤집힘 함정) |
Stack 은 Vector 를 상속한 탓에 버려졌지만,
C# 의 Stack<T> 는 처음부터 배열 하나 + 개수 하나인 깔끔한 설계라 지금도 1순위입니다.
C# 에서 버려진 것은 제네릭 이전(.NET 1.x)의 비제네릭 Stack · Queue 쪽입니다.
여섯 가지를 한 화면에서
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Threading.Channels;
var stack = new Stack<int>(); // LIFO
stack.Push(1); stack.Push(2);
Console.WriteLine(stack.Pop()); // 2
var queue = new Queue<string>(); // FIFO
queue.Enqueue("a"); queue.Enqueue("b");
Console.WriteLine(queue.Dequeue()); // a
var pq = new PriorityQueue<string, int>(); // 작은 우선순위가 먼저
pq.Enqueue("나중", 5); pq.Enqueue("먼저", 1);
Console.WriteLine(pq.Dequeue()); // 먼저
var cq = new ConcurrentQueue<int>(); // 여러 스레드가 동시에
cq.Enqueue(42);
Console.WriteLine(cq.TryDequeue(out var v) ? v : -1); // 42
var ch = Channel.CreateBounded<int>(100); // async 로 기다리는 큐
await ch.Writer.WriteAsync(7);
Console.WriteLine(await ch.Reader.ReadAsync()); // 7
var undo = ImmutableStack<string>.Empty.Push("v1").Push("v2");
Console.WriteLine(undo.Peek()); // v2 (undo 자체는 안 바뀜)
Stack · Queue · PriorityQueue · LinkedList 는
System.Collections.Generic 이라 ImplicitUsings 로 이미 들어와 있습니다.
Concurrent · Immutable · Channels 세 네임스페이스만 직접 적습니다.
세 가지 모두 .NET 10 에 기본 포함이라 NuGet 설치가 필요 없습니다.
어디에 들어 있나 — 네임스페이스 · 도입 버전 · Unity 🟡 중급
| 타입 | 네임스페이스 | 도입 | Unity 6 (.NET Standard 2.1) |
|---|---|---|---|
| Stack<T> · Queue<T> | System.Collections.Generic | .NET Framework 2.0 | ✅ (TryPop · TryDequeue 포함) |
| LinkedList<T> | System.Collections.Generic | .NET Framework 2.0 | ✅ |
| PriorityQueue<TElement, TPriority> | System.Collections.Generic | .NET 6 | ❌ 없음 — 직접 힙을 짜거나 SortedSet 대체 |
| ConcurrentQueue<T> · ConcurrentStack<T> | System.Collections.Concurrent | .NET Framework 4.0 | ✅ |
| BlockingCollection<T> | System.Collections.Concurrent | .NET Framework 4.0 | ✅ (스레드를 막으므로 메인 스레드 금지) |
| Channel<T> | System.Threading.Channels | .NET Core 3.0 부터 기본 포함 | ⚠️ 기본 없음 — DLL 을 직접 넣어야 함 |
| ImmutableStack<T> · ImmutableQueue<T> | System.Collections.Immutable | .NET Core 부터 기본 포함 | ⚠️ 기본 없음 — DLL 을 직접 넣어야 함 |
System.Collections.Stack · Queue | System.Collections | .NET 1.0 | 있지만 쓰지 말 것 레거시 |
PriorityQueue 를 쓰려고 하면 컴파일 에러가 납니다 —
Unity 6 의 API 호환 수준은 .NET Standard 2.1 이고, PriorityQueue 는 .NET 6 에서 생긴 타입이라 그 안에 없습니다.
A* 경로 탐색처럼 힙이 필요하면 SQ 05 의 원리로 이진 힙을 직접 짜는 것이 가장 흔한 해법입니다.
스택 — Stack<T> 하나면 됩니다
Stack<T> 의 속은 배열 _array 와 개수 _size 두 필드가 전부입니다.
그래서 빠르고 단순하지만, 열거 순서가 꼭대기부터라는 점과 빈 스택에서 Pop 하면 예외라는 점은 꼭 알고 써야 합니다.
기본 사용 — 출력까지
// Program.cs (.NET 10 · top-level)
var s = new Stack<int>();
s.Push(10);
s.Push(20);
s.Push(30);
Console.WriteLine(s.Peek()); // 30 (꺼내지 않고 보기)
Console.WriteLine(s.Count); // 3
Console.WriteLine(string.Join(",", s)); // 30,20,10 ← 꼭대기부터!
Console.WriteLine(s.Pop()); // 30
Console.WriteLine(s.Contains(10)); // True (O(n) 선형 탐색)
// ★ 비울 때까지 꺼내기 — Count 확인 + Pop 대신 TryPop 한 번
while (s.TryPop(out var top))
Console.Write($"{top} "); // 20 10
Console.WriteLine();
// 빈 스택에서 Pop / Peek → 예외
try { s.Pop(); }
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message); // Stack empty.
}
TryPop(out T) · TryPeek(out T) 는 비어 있으면 false 를 돌려줍니다.
"비어 있을 수 있다"가 정상 흐름이면 Try, "비어 있으면 버그다"면 Pop 으로 예외를 내게 둡니다 —
예외를 흐름 제어에 쓰지 않는다는 .NET 의 규칙 그대로입니다.
API 한 장 — 비용까지
| 메서드 | 비어 있으면 | 비용 |
|---|---|---|
Push(x) | — | 분할 상환 O(1) · 꽉 차면 2배로 늘리며 O(n) 복사 |
Pop() · Peek() | InvalidOperationException | O(1) |
TryPop(out x) · TryPeek(out x) | false | O(1) .NET Core 2.0+ |
Count | 0 | O(1) — 필드 하나 |
Contains(x) | false | O(n) — 위에서부터 훑음 |
ToArray() | 빈 배열 | O(n) · 꼭대기가 [0] |
EnsureCapacity(n) | — | 미리 늘리기 .NET 6+ |
TrimExcess() | — | 사용률 90% 미만일 때만 줄임 |
Clear() | — | 참조형이면 O(n) 으로 칸을 비움 |
s[0] 은 컴파일되지 않습니다. 중간 요소를 봐야 한다면
그건 스택이 아니라 List<T> 를 스택처럼 쓰는 상황입니다
(list.Add = Push, list[^1] = Peek, list.RemoveAt(list.Count - 1) = Pop — 셋 다 O(1)).
자바 Stack 이 비판받은 "중간 삽입이 된다"는 문제가 C# 에는 애초에 없습니다.
비제네릭 System.Collections.Stack 이 레거시가 된 이유 🟡 중급
System.Collections.Stack (.NET 1.0)
using System.Collections;
var s = new Stack(); // 요소 타입이 object
s.Push(1); // int → object 박싱 (힙 할당 24바이트)
s.Push("문자열"); // 아무거나 들어감 — 컴파일러가 못 막음
int n = (int)s.Pop(); // 💥 InvalidCastException (런타임!)
// 꺼낸 건 "문자열" 이었음
Stack<T> (.NET 2.0~)
var s = new Stack<int>(); // T 가 int 로 고정
s.Push(1); // int 배열에 그대로 — 박싱 없음
// s.Push("문자열"); // ❌ 컴파일 에러 CS1503
int n = s.Pop(); // 캐스트 불필요 · 실패할 일 없음
무엇이 다른가
- 박싱 — 비제네릭은 내부가
object[]라 값 타입을 넣을 때마다 힙에 상자를 만듭니다. 100만 번 Push 면 100만 번 할당입니다. - 타입 안전 — 잘못된 타입이 섞여도 꺼낼 때 런타임에야 터집니다.
Stack.Synchronized(s)— 메서드마다 락을 거는 래퍼인데, "Count 확인 후 Pop" 같은 두 동작 사이는 보호하지 못해 실제로는 안전하지 않았습니다. 지금은ConcurrentStack<T>가 그 자리를 대신합니다.- 타입은 지금도 남아 있고
[Obsolete]도 아닙니다 — 옛 코드 호환용일 뿐입니다.
new Stack(collection) · new Stack<T>(collection) 모두
컬렉션을 앞에서부터 차례로 Push 합니다. 그래서 스택을 스택으로 복사하면 순서가 뒤집힙니다 — 바로 아래에서 봅니다.
열거 순서와 복사 — 꼭대기부터가 만드는 함정 🟡 중급
var s = new Stack<int>([1, 2, 3]); // 1,2,3 차례로 Push → 꼭대기 3
// 컬렉션 식을 생성자 인자로 넘김 (C# 12+)
// Stack<int> t = [1, 2, 3]; // ❌ Stack 에는 Add 가 없어 안 됨
Console.WriteLine(string.Join(",", s)); // 3,2,1
Console.WriteLine(string.Join(",", s.ToArray())); // 3,2,1 (ToArray 도 꼭대기부터)
var copy = new Stack<int>(s); // ⚠ 3,2,1 순서로 Push → 꼭대기 1
Console.WriteLine(copy.Peek()); // 1 ← 뒤집혔다!
var same = new Stack<int>(s.Reverse()); // LINQ Reverse: 1,2,3 → 꼭대기 3
Console.WriteLine(same.Peek()); // 3 ← 원본과 같음
왜 이렇게 설계됐나
열거를 Pop 할 순서와 맞춘 것입니다. foreach 로 훑은 순서가 그대로 앞으로 꺼낼 순서라
"다음에 처리될 것부터 보여 주기"에는 자연스럽습니다. 문제는 생성자는 앞에서부터 Push 한다는 점이 겹칠 때입니다.
ImmutableStack<T>(SQ 08)를 쓰세요 — Push 가 새 스택을 돌려주고 꼬리를 공유하므로
복사 비용이 0 이고 순서 함정도 없습니다.
Unity — 오브젝트 풀은 Stack<T> 🟡 중급
using UnityEngine;
public sealed class BulletPool : MonoBehaviour
{
[SerializeField] private Bullet prefab;
private readonly Stack<Bullet> _idle = new(64); // 미리 64칸
public Bullet Rent(Vector3 pos)
{
// 남은 게 있으면 재사용, 없으면 새로 — Try 패턴으로 한 번에
var b = _idle.TryPop(out var cached) ? cached : Instantiate(prefab);
b.transform.position = pos;
b.gameObject.SetActive(true);
return b;
}
public void Return(Bullet b)
{
b.gameObject.SetActive(false);
_idle.Push(b); // 방금 쓴 것이 다음에 먼저 나감 (LIFO)
}
}
왜 큐가 아니라 스택인가
- 방금 반납한 객체가 CPU 캐시에 아직 남아 있을 확률이 높습니다 — LIFO 가 FIFO 보다 유리합니다.
- 스택은 꼭대기 하나만 만지므로 인덱스 계산이 가장 단순합니다.
- Unity 2021.1+ 의
UnityEngine.Pool.ObjectPool<T>도 같은 LIFO 방식으로 재사용합니다 — 직접 짜기 싫으면 그것을 쓰세요.
Stack.Contains 로 막으면 O(n) 이니, 객체에 bool InPool 플래그를 두고 검사하세요.
(ObjectPool<T> 는 collectionCheck 옵션이 에디터에서 이를 잡습니다.)
Stack<T> + TryPop. 열거와 ToArray 는 꼭대기부터, 생성자는 앞에서부터 Push — 둘이 만나면 뒤집힙니다.큐 (FIFO) — Queue<T> 와 원형 배열
Queue<T> 는 배열 하나를 원형으로 돌려 쓰는 구조입니다. 앞에서 꺼내도 뒤 요소를 당기지 않으므로
Enqueue · Dequeue 가 모두 O(1) 입니다. 이 점이 List<T>.RemoveAt(0) 과의 결정적 차이입니다.
기본 사용 — 예외형과 Try 형
var q = new Queue<string>();
q.Enqueue("1번 손님");
q.Enqueue("2번 손님");
q.Enqueue("3번 손님");
Console.WriteLine(q.Peek()); // 1번 손님 (꺼내지 않음)
Console.WriteLine(q.Dequeue()); // 1번 손님
Console.WriteLine(q.Count); // 2
Console.WriteLine(string.Join(" / ", q)); // 2번 손님 / 3번 손님 (앞부터)
// 처리 루프 — 표준 관용구
while (q.TryDequeue(out var who))
Console.WriteLine($"처리: {who}");
// 처리: 2번 손님
// 처리: 3번 손님
Console.WriteLine(q.TryPeek(out _)); // False
// q.Dequeue(); // 💥 InvalidOperationException: Queue empty.
"실패하면 예외" vs "실패하면 false"
| 동작 | 예외형 | Try 형 | 자바 Queue 대응 |
|---|---|---|---|
| 넣기 | Enqueue(x) | — (실패 안 함) | add / offer |
| 꺼내기 | Dequeue() | TryDequeue(out x) | remove / poll |
| 앞 보기 | Peek() | TryPeek(out x) | element / peek |
자바는 "특수값"으로 null 을 돌려주므로 null 을 넣을 수 없지만, C# 의 Try 형은 bool + out 이라
Queue<string?> 에 null 도 정상 요소로 넣고 뺄 수 있습니다. 반환값과 "없음"이 섞이지 않습니다.
Queue<T> 의 Enqueue 는 실패하지 않습니다 — 크기 제한이 없기 때문입니다.
"꽉 차면 거절 / 기다림" 이 필요하면 그건 유한(bounded) 큐이고, C# 에서는 Channel.CreateBounded(SQ 07)가 그 역할입니다.
큐로 쓰면 안 되는 것 — List.RemoveAt(0) 🟡 중급
List 를 큐처럼 — 매번 전체를 당김
var jobs = new List<Job>();
jobs.Add(job); // 뒤에 추가 O(1)
var next = jobs[0];
jobs.RemoveAt(0); // ⚠ 나머지 n-1 개를 한 칸씩 앞으로 복사 O(n)
// 10만 건을 이렇게 비우면 약 50억 번의 요소 이동 (n²/2)
// LINQ 로 흉내 — 더 나쁨
jobs = jobs.Skip(1).ToList(); // 매번 새 리스트 할당 + 전체 복사
Queue<T> — 인덱스만 한 칸
var jobs = new Queue<Job>();
jobs.Enqueue(job); // O(1) 분할 상환
if (jobs.TryDequeue(out var next)) // _head 만 +1 → O(1)
Run(next);
// 10만 건을 비워도 요소 이동 0번
list.pop(0), 자바스크립트 Array.prototype.shift() 도 O(n) 입니다.
셋 다 "배열의 앞을 지우면 뒤를 당긴다"는 같은 이유입니다. 원형 배열(Queue<T> · collections.deque · ArrayDeque)이 정답인 것도 같습니다.
용량 관리 — EnsureCapacity · TrimExcess 🟡 중급
var q = new Queue<int>(); // 처음엔 길이 0 배열 (할당 없음)
q.Enqueue(1); // 길이 4 로 할당
// 이후 4 → 8 → 16 → 32 … 로 2배씩 (최소 +4)
// 크기를 안다면 처음부터
var batch = new Queue<int>(10_000); // 재할당 0회
// 나중에 알게 되면 (.NET 6+)
int cap = q.EnsureCapacity(5_000); // 반환값 = 실제 용량
Console.WriteLine(cap); // 5000
// 대량 처리 후 메모리 돌려주기
for (int i = 0; i < 10; i++) q.Enqueue(i);
q.TrimExcess(); // 사용률이 90% 미만이면 Count 로 줄임
// 계속 쓸 큐라면 TrimExcess 는 오히려 손해 — 다시 늘어나며 복사
늘어나는 규칙 (dotnet/runtime 소스 기준)
| Stack<T> | Queue<T> | |
|---|---|---|
| 기본 생성 | 길이 0 배열 | 길이 0 배열 |
| 첫 추가 | 4 | 4 (2배 = 0 이라 최소 +4) |
| 그다음 | 2배 | 2배 (최소 +4) |
| 늘릴 때 복사 | Array.Resize 한 번 | 두 토막 Array.Copy (감긴 경우) |
| 상한 | Array.MaxLength (약 21억) | |
TrimExcess() 를 부르거나,
애초에 유한 큐로 상한을 거세요(SQ 06 · 07).
열거 중 수정 — _version 이 잡아냅니다 🔴 고급
var q = new Queue<int>([1, 2, 3]);
try
{
foreach (var x in q)
if (x == 2) q.Enqueue(99); // 열거 중에 변경
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
// Collection was modified; enumeration operation may not execute.
}
// ✅ 처리하면서 새 일이 생기는 구조 → foreach 가 아니라 while + TryDequeue
var work = new Queue<int>([1, 2, 3]);
while (work.TryDequeue(out var x))
{
if (x == 2) work.Enqueue(99); // 안전 — 열거자가 없음
Console.Write($"{x} "); // 1 2 3 99
}
어떻게 감지하나
Stack<T> · Queue<T> 는 변경할 때마다 _version 정수를 1 올립니다.
열거자는 시작할 때 그 값을 복사해 두고, MoveNext 때마다 비교해 다르면 예외를 던집니다.
잘못된 결과를 조용히 내는 대신 즉시 실패(fail-fast) 하는 설계입니다.
while (q.TryDequeue(...)) 는
열거자를 쓰지 않으므로 안전하고, 이것이 C# BFS 의 표준 형태입니다(SQ 08).
반면 ConcurrentQueue<T> 의 열거는 스냅숏이라 예외가 나지 않습니다(SQ 06).
Queue<T> + TryDequeue. 원형 배열이라 앞에서 꺼내도 O(1) 이고,
크기 제한이 없으며 저절로 줄지 않는다는 두 가지를 기억하세요.덱 — .NET 에는 표준 Deque 가 없다
자바는 ArrayDeque, 파이썬은 collections.deque, C++ 은 std::deque 가 있지만
.NET 10 의 BCL 에는 양쪽 끝 O(1) 덱이 없습니다. 그래서 현업 코드는 LinkedList<T> 로 때우거나,
원형 버퍼 덱을 직접 한 파일로 들고 다닙니다. 이 섹션은 둘의 비용을 비교하고 완전한 구현을 드립니다.
덱이 필요할 때 C# 개발자가 고르는 것
| 방법 | 앞 넣기 · 빼기 | 인덱스 접근 | 판정 |
|---|---|---|---|
| 직접 만든 원형 버퍼 | O(1) 분할 상환 | O(1) | 권장 |
LinkedList<T> | O(1) · 노드 할당 | O(n) | 요소 수가 적고 급할 때 |
List<T> + Insert(0, x) | O(n) | O(1) | 쓰지 말 것 |
| 스택 두 개로 흉내 | 분할 상환 O(1) | 복잡 | 면접용 · 실무 비추천 |
| "뒤에서만 빼면 되는" 경우 | — | — | List<T> 를 스택처럼 |
Queue<T> 와 겹치고
수요가 알고리즘 문제 쪽에 몰려 있어 들어오지 않았습니다. Queue<T> 가 내부적으로는 이미 원형 버퍼라
앞쪽 넣기 · 뒤쪽 빼기 메서드만 빠진 덱이라고 보면 정확합니다.
LinkedList<T> 로 때우기 — 되긴 하지만
var d = new LinkedList<int>();
d.AddLast(2); // 뒤에 넣기 → 노드 객체 new
d.AddLast(3);
d.AddFirst(1); // 앞에 넣기 → 노드 객체 new
Console.WriteLine(d.First!.Value); // 1
Console.WriteLine(d.Last!.Value); // 3
d.RemoveFirst(); // 앞에서 빼기 (값을 돌려주지 않음!)
d.RemoveLast(); // 뒤에서 빼기
Console.WriteLine(string.Join(",", d)); // 2
// 빈 리스트의 First 는 null → RemoveFirst() 는 InvalidOperationException
// d.ElementAt(5) 같은 인덱스 접근은 O(n) 순회
- Add 할 때마다
LinkedListNode<T>객체 하나를 힙에 만듭니다 — 64비트에서 int 하나 담는 데 약 48바이트. RemoveFirst가 값을 돌려주지 않아var x = d.First.Value; d.RemoveFirst();두 줄이 됩니다.- 노드가 힙에 흩어져 순회가 느립니다(아래 그림).
복사해서 쓰는 Deque<T> — 완전한 구현 🔴 고급
// Deque.cs — .NET 10 과 Unity(C# 9 · .NET Standard 2.1) 양쪽에서 컴파일되도록 작성
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
/// <summary>원형 버퍼 덱. 양 끝 넣기 · 빼기 분할 상환 O(1), 인덱스 접근 O(1). 스레드 안전하지 않음.</summary>
public sealed class Deque<T> : IReadOnlyList<T>
{
private T[] _buf;
private int _head; // 맨 앞 요소가 있는 실제 칸
private int _count;
private int _version; // 열거 중 변경 감지 — Queue<T> 와 같은 방식
public Deque() : this(4) { }
public Deque(int capacity)
{
if (capacity < 0) throw new ArgumentOutOfRangeException(nameof(capacity));
_buf = capacity == 0 ? Array.Empty<T>() : new T[capacity];
}
public int Count => _count;
public int Capacity => _buf.Length;
// 논리 위치(0 = 맨 앞) → 실제 칸. 나머지 연산(%) 대신 비교 한 번 (Queue<T> 와 같음)
private int Phys(int i)
{
int p = _head + i;
return p >= _buf.Length ? p - _buf.Length : p;
}
public T this[int index]
{
get
{
if ((uint)index >= (uint)_count) throw new ArgumentOutOfRangeException(nameof(index));
return _buf[Phys(index)];
}
}
public void PushBack(T item)
{
if (_count == _buf.Length) Grow();
_buf[Phys(_count)] = item;
_count++; _version++;
}
public void PushFront(T item)
{
if (_count == _buf.Length) Grow();
_head = _head == 0 ? _buf.Length - 1 : _head - 1; // 한 칸 뒤로 감기
_buf[_head] = item;
_count++; _version++;
}
public bool TryPopFront([MaybeNullWhen(false)] out T item)
{
if (_count == 0) { item = default!; return false; }
item = _buf[_head];
_buf[_head] = default!; // 참조를 끊어 GC 가 회수하게
_head = _head + 1 == _buf.Length ? 0 : _head + 1;
_count--; _version++;
return true;
}
public bool TryPopBack([MaybeNullWhen(false)] out T item)
{
if (_count == 0) { item = default!; return false; }
int last = Phys(_count - 1);
item = _buf[last];
_buf[last] = default!;
_count--; _version++;
return true;
}
public bool TryPeekFront([MaybeNullWhen(false)] out T item)
{
if (_count == 0) { item = default!; return false; }
item = _buf[_head];
return true;
}
public bool TryPeekBack([MaybeNullWhen(false)] out T item)
{
if (_count == 0) { item = default!; return false; }
item = _buf[Phys(_count - 1)];
return true;
}
public T PopFront() => TryPopFront(out var x) ? x : throw Empty();
public T PopBack() => TryPopBack(out var x) ? x : throw Empty();
public T PeekFront() => TryPeekFront(out var x) ? x : throw Empty();
public T PeekBack() => TryPeekBack(out var x) ? x : throw Empty();
private static InvalidOperationException Empty() => new("Deque empty.");
public void Clear()
{
Array.Clear(_buf, 0, _buf.Length);
_head = 0; _count = 0; _version++;
}
private void Grow()
{
int newCap = _buf.Length == 0 ? 4 : _buf.Length * 2;
var next = new T[newCap];
int first = Math.Min(_count, _buf.Length - _head); // [_head .. 끝]
Array.Copy(_buf, _head, next, 0, first);
Array.Copy(_buf, 0, next, first, _count - first); // [0 .. 나머지]
_buf = next;
_head = 0;
}
public IEnumerator<T> GetEnumerator()
{
int version = _version;
for (int i = 0; i < _count; i++)
{
if (version != _version)
throw new InvalidOperationException("Collection was modified.");
yield return _buf[Phys(i)];
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
써 보기 — 늘어나는 순간까지 따라가기
var d = new Deque<int>(4);
d.PushBack(1); // 칸: [1, _, _, _] head 0
d.PushBack(2); // 칸: [1, 2, _, _]
d.PushFront(0); // 칸: [1, 2, _, 0] head 3 (감김)
d.PushFront(-1); // 칸: [1, 2, -1, 0] head 2 · 꽉 참
d.PushBack(3); // Grow → 8칸: [-1, 0, 1, 2, 3, _, _, _] head 0
Console.WriteLine(string.Join(",", d)); // -1,0,1,2,3
Console.WriteLine(d[1]); // 0 (인덱스 O(1))
Console.WriteLine(d.PopFront()); // -1
Console.WriteLine(d.PopBack()); // 3
Console.WriteLine($"{d.Count}/{d.Capacity}");// 3/8
설계 메모
- 용량을 2의 거듭제곱으로 강제하지 않았습니다. 자바
ArrayDeque는& (n - 1)마스크를 쓰지만, .NET 의Queue<T>는 비교 한 번으로 감습니다. 분기 예측이 잘 맞아 차이가 거의 없습니다. - 빈 칸을
default로 지웁니다. 안 지우면 꺼낸 객체를 배열이 계속 붙잡아 GC 가 못 치웁니다(BCL 은RuntimeHelpers.IsReferenceOrContainsReferences<T>()로 값 타입일 때 이 대입을 건너뜁니다). IReadOnlyList<T>를 구현해 LINQ ·foreach· 인덱서가 다 됩니다.- 스레드 안전하지 않습니다 —
Stack<T>·Queue<T>와 같은 계약입니다.
연산별 시간 복잡도 — 무엇이 어디서 O(n) 인가 🟡 중급
| 연산 | Deque<T> (위 구현) | Queue<T> | Stack<T> | LinkedList<T> | List<T> |
|---|---|---|---|---|---|
| 뒤에 넣기 | O(1)* | O(1)* Enqueue | O(1)* Push | O(1) + 할당 | O(1)* Add |
| 앞에 넣기 | O(1)* | — | — | O(1) + 할당 | O(n) Insert(0) |
| 앞에서 빼기 | O(1) | O(1) Dequeue | — | O(1) | O(n) RemoveAt(0) |
| 뒤에서 빼기 | O(1) | — | O(1) Pop | O(1) | O(1) |
| i 번째 보기 | O(1) | — (ElementAt 은 O(n)) | — | O(n) | O(1) |
| Contains | O(n) | O(n) | O(n) | O(n) | O(n) |
| 요소당 추가 메모리 | 0 (빈 칸만) | 0 | 0 | 노드 ≈ 48 B (int 기준) | 0 |
* 분할 상환 O(1) — 평소에는 O(1), 꽉 찼을 때 한 번 O(n) 복사가 일어나지만 2배씩 늘리므로 n 번 연산의 총비용이 O(n) 입니다.
Dictionary<K, LinkedListNode<V>> 로 노드를 찾고 list.Remove(node) · list.AddFirst(node) 로
앞으로 옮깁니다. "양 끝만 쓰는 덱"이라면 원형 버퍼가 언제나 낫습니다.
Deque<T> 한 파일을 프로젝트에 넣는 것이 표준 해법이고,
LinkedList<T> 는 "노드를 쥐고 중간에서 뗄 때"만 고릅니다.우선순위 큐 — PriorityQueue<TElement, TPriority>
.NET 6 에서야 들어온 표준 힙입니다. 요소와 우선순위를 따로 받는 설계라 요소 타입을 고치지 않고도 쓸 수 있지만, 같은 우선순위끼리의 순서 비보장 · 우선순위 변경 불가 · 정렬되지 않은 열거 세 가지 함정이 있습니다.
기본 — 작은 값이 먼저 (최소 힙)
var pq = new PriorityQueue<string, int>();
pq.Enqueue("설거지", 3);
pq.Enqueue("서버 장애", 1);
pq.Enqueue("메일 답장", 2);
Console.WriteLine(pq.Peek()); // 서버 장애
Console.WriteLine(pq.Count); // 3
// 요소와 우선순위를 함께 꺼내기 — TryDequeue 는 out 이 두 개
while (pq.TryDequeue(out var job, out var pri))
Console.WriteLine($"{pri} {job}");
// 1 서버 장애
// 2 메일 답장
// 3 설거지
// 한꺼번에 넣기
pq.EnqueueRange([("a", 2), ("b", 1)]);
Console.WriteLine(pq.Dequeue()); // b
Dequeue() 는 요소만 돌려줍니다. 우선순위(예: 다익스트라의 거리)도 필요하면
TryDequeue(out e, out p) 를 쓰세요 — 그래서 실무 코드에서는 TryDequeue 가 훨씬 자주 보입니다.
API 와 비용
| 메서드 | 비용 | 메모 |
|---|---|---|
Enqueue(e, p) | O(log n) | 위로 올리기(sift-up) |
Dequeue() · TryDequeue | O(log n) | 마지막 요소를 루트로 내린 뒤 sift-down |
Peek() · TryPeek | O(1) | 루트 = 배열 [0] |
EnqueueDequeue(e, p) | O(log n) | 넣고 바로 최소를 꺼냄 — 따로 하는 것보다 빠름 |
DequeueEnqueue(e, p) | O(log n) | 꺼내고 넣기 (EnqueueDequeue 보다 나중 버전에 추가) |
Remove(e, out …) | O(n) | 임의 요소 삭제 .NET 9+ |
UnorderedItems | O(n) 순회 | 힙 배열 순서 — 정렬 아님 |
new(items) 생성자 | O(n) | 한 번에 힙 만들기(heapify) |
foreach (var x in pq) 는 컴파일되지 않습니다. PriorityQueue 자체는 IEnumerable 이 아닙니다.
일부러 그렇게 만든 것입니다 — 우선순위 순서로 열거된다고 착각하지 않도록 UnorderedItems 라는 이름을 붙였습니다.
함정 ① — 같은 우선순위는 넣은 순서대로 나오지 않는다 🔴 고급
var pq = new PriorityQueue<string, int>();
foreach (var name in new[] { "첫째", "둘째", "셋째", "넷째", "다섯째", "여섯째" })
pq.Enqueue(name, 1); // 전부 우선순위 1
while (pq.TryDequeue(out var n, out _))
Console.Write($"{n} ");
// 첫째 여섯째 다섯째 넷째 셋째 둘째 ← FIFO 가 아님!
Dequeue 는 마지막 칸 요소를 루트로 옮긴 뒤 아래로 내립니다. 동점이면 내려가지 않으므로 맨 뒤에 넣은 "여섯째"가 루트로 올라와 두 번째로 나옵니다. 이것은 버그가 아니라 힙의 성질이고, 문서에도 순서를 보장하지 않는다고 적혀 있습니다.
해결 — 순번을 두 번째 키로
// 우선순위를 (등급, 순번) 튜플로 — ValueTuple 은 앞 항목부터 비교
var pq = new PriorityQueue<string, (int Level, long Seq)>();
long seq = 0;
foreach (var name in new[] { "첫째", "둘째", "셋째", "넷째", "다섯째", "여섯째" })
pq.Enqueue(name, (1, seq++));
while (pq.TryDequeue(out var n, out _))
Console.Write($"{n} ");
// 첫째 둘째 셋째 넷째 다섯째 여섯째 ← 안정적(FIFO)
long 순번은 초당 10억 건을 넣어도 수백 년간 넘치지 않습니다.
최대 힙 · 여러 키 — IComparer 로 방향을 정한다 🟡 중급
// 최대 힙: 비교를 뒤집은 Comparer 를 생성자에
var maxPq = new PriorityQueue<string, int>(
Comparer<int>.Create((a, b) => b.CompareTo(a)));
maxPq.Enqueue("브론즈", 10);
maxPq.Enqueue("다이아", 90);
maxPq.Enqueue("골드", 50);
Console.WriteLine(maxPq.Dequeue()); // 다이아
// ⚠ (a, b) => b - a 는 쓰지 마세요 — int.MinValue 근처에서 오버플로로 부호가 뒤집힘
// 여러 키: 마감이 빠른 순, 같으면 중요도가 높은 순
public readonly record struct TaskKey(DateTime Due, int Importance)
: IComparable<TaskKey>
{
public int CompareTo(TaskKey o)
{
int c = Due.CompareTo(o.Due); // 오름차순
return c != 0 ? c : o.Importance.CompareTo(Importance); // 내림차순
}
}
var todo = new PriorityQueue<string, TaskKey>(); // Comparer 생략 → IComparable 사용
var d = new DateTime(2026, 10, 1);
todo.Enqueue("보고서", new(d, 1));
todo.Enqueue("장애 회고", new(d, 5));
Console.WriteLine(todo.Dequeue()); // 장애 회고 (마감 같고 중요도 높음)
함정 ② — 우선순위를 바꾸는 API 가 없다 → 지연 삭제 🔴 고급
다익스트라 — "낡은 항목은 꺼낼 때 버린다"
var graph = new Dictionary<char, (char To, int W)[]>
{
['A'] = [('B', 4), ('C', 1)],
['C'] = [('B', 2), ('D', 5)],
['B'] = [('D', 1)],
['D'] = [],
};
var dist = new Dictionary<char, int> { ['A'] = 0 };
var pq = new PriorityQueue<char, int>();
pq.Enqueue('A', 0);
int stale = 0;
while (pq.TryDequeue(out var u, out var d))
{
if (d > dist[u]) { stale++; continue; } // ★ 이미 더 짧은 길로 처리됨 → 버림
foreach (var (v, w) in graph[u])
{
int nd = d + w;
if (nd < dist.GetValueOrDefault(v, int.MaxValue))
{
dist[v] = nd;
pq.Enqueue(v, nd); // decrease-key 대신 "새로 하나 더"
}
}
}
Console.WriteLine(string.Join(" ", dist.OrderBy(kv => kv.Key)
.Select(kv => $"{kv.Key}={kv.Value}"))); // A=0 B=3 C=1 D=4
Console.WriteLine($"버린 낡은 항목 {stale}개"); // 버린 낡은 항목 2개
왜 이렇게 하나
PriorityQueue 는 요소의 위치를 기억하지 않으므로 "B 의 우선순위를 4 → 3 으로" 같은 decrease-key 가 불가능합니다.
대신 새 우선순위로 한 번 더 넣고, 꺼낼 때 dist 와 비교해 낡은 것을 버립니다.
힙에 최대 간선 수만큼 항목이 쌓이지만 복잡도는 O(E log E) = O(E log V) 로 같습니다.
Remove .NET 9+ — pq.Remove(element, out var removed, out var priority) 로
특정 요소를 지울 수 있게 되었지만 배열을 처음부터 훑는 O(n) 입니다. 지우고 다시 넣어 우선순위를 바꾸는 방식은
항목이 많으면 지연 삭제보다 느립니다. 가끔 취소하는 용도로만 쓰세요.
job.Priority = 0 처럼 바꿔도 위치가 그대로입니다. 바꿔야 한다면 "다시 넣고 낡은 것은 무시" 하세요.
top-k — EnqueueDequeue 로 크기 k 힙 유지 🟡 중급
// 큰 수 k 개 — "최소 힙에 k 개만 남긴다"
int[] nums = [5, 1, 9, 3, 7, 8, 2];
const int k = 3;
var heap = new PriorityQueue<int, int>(k);
foreach (var x in nums)
{
if (heap.Count < k) heap.Enqueue(x, x);
else heap.EnqueueDequeue(x, x); // x 가 최소보다 작으면 x 를 그대로 돌려주고 끝
}
// UnorderedItems — 힙 배열 순서 그대로 (정렬 아님)
Console.WriteLine(string.Join(",", heap.UnorderedItems.Select(t => t.Element))); // 7,8,9
var top = new List<int>();
while (heap.TryDequeue(out var v, out _)) top.Add(v); // 작은 것부터 나옴
top.Reverse();
Console.WriteLine(string.Join(",", top)); // 9,8,7
비용과 함정
- n 개를 다 정렬하면 O(n log n), k 크기 힙은 O(n log k) 에 메모리 O(k) — 스트림에도 됩니다.
EnqueueDequeue는 새 값이 루트 이하면 힙을 건드리지 않고 그대로 돌려줍니다. Enqueue 후 Dequeue 를 따로 부르는 것보다 sift 가 한 번 적습니다.- 위 예에서
UnorderedItems가 7,8,9 로 정렬돼 보이는 것은 우연입니다. 개수가 늘면 바로 흐트러집니다 — 정렬이 필요하면 꼭 Dequeue 로 꺼내세요.
List 에 넣고 매번 Sort 하는 코드가 흔한데 프레임마다 O(n log n) 입니다.
위 4-ary 힙의 부모 · 자식 공식으로 50줄짜리 이진 힙을 만들어 두면 해결됩니다.
PriorityQueue 는 최소 힙 · 동점 순서 없음 · 우선순위 변경 없음. 동점에는 순번 키, 변경에는 지연 삭제,
방향 반전에는 Comparer.Create 가 정답입니다.스레드 안전 큐 — Concurrent* 와 BlockingCollection
Queue<T> 를 두 스레드가 동시에 만지면 예외 없이 데이터가 사라지거나 중복됩니다.
ConcurrentQueue<T> 는 락 없이 그 문제를 풀지만 "비었을 때 기다리기"는 하지 않습니다.
기다림과 상한(배압)이 필요해지는 순간 BlockingCollection 이나 Channel 로 넘어갑니다.
ConcurrentQueue<T> — Try 만 있는 큐
using System.Collections.Concurrent;
var q = new ConcurrentQueue<int>();
// 8개 스레드가 동시에 1만 개씩 넣기
Parallel.For(0, 8, t =>
{
for (int i = 0; i < 10_000; i++) q.Enqueue(i);
});
Console.WriteLine(q.Count); // 80000 (Queue<T> 였다면 들쭉날쭉)
// 꺼내기는 Try 하나뿐 — Dequeue() 메서드 자체가 없음
long sum = 0;
Parallel.For(0, 4, _ =>
{
long local = 0;
while (q.TryDequeue(out var x)) local += x; // 비면 false → 루프 종료
Interlocked.Add(ref sum, local);
});
Console.WriteLine(sum); // 399960000 (= 8 × 0~9999 합)
Console.WriteLine(q.IsEmpty); // True
if (q.Count > 0) q.TryDequeue(...) 는 의미가 없습니다. 확인과 꺼내기 사이에 다른 스레드가 가져갈 수 있습니다.
TryDequeue 의 반환값만 믿으세요. 비었는지 볼 때는 Count == 0 대신 IsEmpty — Count 는 세그먼트를 훑는 더 비싼 연산입니다.
속은 어떻게 생겼나
| ConcurrentQueue<T> | ConcurrentStack<T> | |
|---|---|---|
| 구조 | 고정 크기 배열 세그먼트를 연결 리스트로 이음 (처음 32칸, 다음 세그먼트는 2배씩 · 상한 있음) | 노드 연결 리스트 (Treiber 스택) |
| 동기화 | 칸 예약은 Interlocked (락 없음) · 새 세그먼트 붙일 때만 짧은 락 | 꼭대기 포인터를 CompareExchange 로 교체 |
| 요소당 할당 | 없음 (세그먼트 단위) | Push 마다 노드 1개 |
| 열거 | 시점 스냅숏 — 예외 없음 | 시점 스냅숏 |
| 묶음 연산 | — | PushRange · TryPopRange |
var cs = new ConcurrentStack<int>();
cs.PushRange([1, 2, 3]); // 한 번의 CAS 로 3개
var buf = new int[2];
int got = cs.TryPopRange(buf); // 꼭대기부터 최대 2개
Console.WriteLine($"{got}: {buf[0]},{buf[1]}"); // 2: 3,2
BlockingCollection — 생산자 · 소비자 완전한 예제 🟡 중급
using System.Collections.Concurrent;
// 내부 저장소는 기본이 ConcurrentQueue → FIFO. 3개 차면 Add 가 멈춤 (배압)
using var bc = new BlockingCollection<int>(boundedCapacity: 3);
var producer = Task.Run(() =>
{
for (int i = 1; i <= 10; i++)
bc.Add(i); // 꽉 차면 이 스레드가 블록
bc.CompleteAdding(); // ★ "더 없음" — poison pill 대신 쓰는 종료 신호
});
var consumers = Enumerable.Range(1, 2).Select(id => Task.Run(() =>
{
int sum = 0;
// 비면 블록, CompleteAdding 후 다 비면 foreach 가 자연히 끝남
foreach (var x in bc.GetConsumingEnumerable())
sum += x;
return sum;
})).ToArray();
await producer;
int[] sums = await Task.WhenAll(consumers);
Console.WriteLine($"합계 {sums.Sum()}"); // 합계 55 (두 소비자의 몫은 실행마다 다름)
Console.WriteLine(bc.IsCompleted); // True
종료 처리 — poison pill vs 완료 신호
고전적인 방법은 특수값(poison pill)을 넣어 "이걸 받으면 끝내라"고 하는 것입니다.
소비자가 N 명이면 알약도 N 개 넣어야 하고, 정상 데이터와 구분되는 값이 필요합니다.
CompleteAdding() 은 그 일을 컬렉션 상태로 대신합니다 — 이후 Add 는 InvalidOperationException,
모든 GetConsumingEnumerable 은 남은 것을 다 꺼낸 뒤 끝납니다.
Take · Add 가 스레드를 막고 기다립니다.
ASP.NET Core 처럼 스레드 풀을 공유하는 곳에서 소비자 여럿이 막혀 있으면 스레드 풀 고갈로 이어집니다.
같은 구조를 await 로 하는 것이 Channel<T>(SQ 07)이고, 새 코드는 그쪽입니다.
콘솔 · 전용 스레드 배치처럼 막아도 되는 곳에서는 여전히 간단하고 좋은 도구입니다.
new BlockingCollection<int>(new ConcurrentStack<int>(), 3) 이면 LIFO 입니다.
Unity — 메인 스레드 디스패치 큐 🟡 중급
using System;
using System.Collections.Concurrent;
using UnityEngine;
// 다른 스레드(소켓 수신 · Task.Run)에서 Unity API 를 부르면 예외 →
// 할 일을 큐에 넣고, 메인 스레드의 Update 가 꺼내 실행
public sealed class MainThread : MonoBehaviour
{
private static readonly ConcurrentQueue<Action> _q = new();
private const int BudgetPerFrame = 64; // 한 프레임에 너무 많이 하지 않게
public static void Post(Action work) => _q.Enqueue(work); // 아무 스레드에서나
private void Update()
{
for (int i = 0; i < BudgetPerFrame && _q.TryDequeue(out var work); i++)
{
try { work(); }
catch (Exception e) { Debug.LogException(e); } // 하나가 터져도 나머지는 계속
}
}
}
// 사용 — 네트워크 스레드에서
// MainThread.Post(() => hpBar.value = packet.Hp);
왜 ConcurrentQueue 인가
- 생산자는 여러 스레드, 소비자는 메인 스레드 하나 — 기다릴 필요가 없습니다. Update 가 어차피 매 프레임 옵니다.
- Unity 메인 스레드에서
BlockingCollection.Take를 부르면 게임이 멈춥니다. 절대 금지입니다. - 프레임 예산을 두어 한 프레임에 큐가 몰려도 프레임이 튀지 않게 합니다. 남은 것은 다음 프레임에 처리됩니다.
await Awaitable.MainThreadAsync() 로 메인 스레드에 돌아오는 방법도 제공합니다.
콜백이 많고 순서가 중요하면 위 큐, async 흐름 안에서 잠깐 돌아올 때는 Awaitable 이 편합니다.
비동기 큐 — System.Threading.Channels
Channel<T> 는 async 세계의 BlockingCollection 입니다. 비었으면 await 로 기다리고,
꽉 차면 await 로 생산자를 늦추며(배압), Writer.Complete() 로 끝을 알립니다.
그 사이 스레드는 막히지 않고 풀로 돌아갑니다. 🧬 고급 탭 A 03 에서 한 장으로 소개한 것을 여기서 옵션 · 종료 · 함정까지 뜯어 봅니다.
생산자 2 · 소비자 1 — 완전한 예제
using System.Threading.Channels;
var channel = Channel.CreateBounded<Order>(new BoundedChannelOptions(capacity: 4)
{
FullMode = BoundedChannelFullMode.Wait, // 꽉 차면 WriteAsync 가 대기 (기본값)
SingleReader = true, // 소비자 1명 → 내부 동기화를 줄인 구현 선택
SingleWriter = false, // 생산자는 여럿
});
async Task ProduceAsync(string source, int count, CancellationToken ct)
{
for (int i = 1; i <= count; i++)
{
// 자리가 없으면 여기서 await — 소비자가 느리면 생산자도 느려짐 (배압)
await channel.Writer.WriteAsync(new Order(source, i), ct);
await Task.Delay(5, ct);
}
}
async Task<int> ConsumeAsync(CancellationToken ct)
{
int handled = 0;
// Complete() 된 뒤 남은 것까지 다 읽으면 foreach 가 끝남
await foreach (var order in channel.Reader.ReadAllAsync(ct))
{
await Task.Delay(10, ct); // 처리가 생산보다 느림
handled++;
}
return handled;
}
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
var consumer = ConsumeAsync(cts.Token); // 소비자 먼저 시작
await Task.WhenAll(ProduceAsync("웹", 10, cts.Token),
ProduceAsync("앱", 10, cts.Token));
channel.Writer.Complete(); // ★ 모든 생산자가 끝난 뒤 딱 한 번
Console.WriteLine($"처리 {await consumer}건"); // 처리 20건
record Order(string Source, int No);
옵션 한 장
| 옵션 | 뜻 | 언제 |
|---|---|---|
CreateBounded(n) | 최대 n 개 | 기본값으로 이것 — 메모리 상한 = 배압 |
CreateUnbounded() | 무제한 | 생산 속도가 구조적으로 제한될 때만 |
FullMode = Wait | WriteAsync 대기 · TryWrite 는 false | 유실 불가 (주문 · 결제) |
DropOldest | 가장 오래된 항목을 버리고 넣음 | 최신 값만 의미 (시세 · 위치) |
DropNewest | 채널 안 가장 최근 항목을 버리고 넣음 | 드묾 |
DropWrite | 새로 쓰는 항목을 버림 (TryWrite 는 true) | 과부하 시 신규 무시 (로그 샘플링) |
SingleReader · SingleWriter | "한 명뿐"이라는 약속 | 지키면 더 빠른 구현 · 어기면 오동작 |
AllowSynchronousContinuations | 쓰는 쪽 스레드에서 읽는 쪽 이어 실행 | 지연 최소화 · 기본 false 가 안전 |
꽉 찼을 때 — Wait 와 DropOldest 를 눈으로 🟡 중급
using System.Threading.Channels;
// ① Wait (기본) — TryWrite 는 자리가 없으면 false
var w = Channel.CreateBounded<int>(2);
Console.WriteLine(w.Writer.TryWrite(1)); // True
Console.WriteLine(w.Writer.TryWrite(2)); // True
Console.WriteLine(w.Writer.TryWrite(3)); // False ← WriteAsync 였다면 await 대기
// ② DropOldest — 항상 들어가고, 밀려난 것은 콜백으로 (.NET 6+)
var dropped = new List<int>();
var d = Channel.CreateBounded<int>(
new BoundedChannelOptions(3) { FullMode = BoundedChannelFullMode.DropOldest },
itemDropped: x => dropped.Add(x));
for (int i = 1; i <= 5; i++)
d.Writer.TryWrite(i); // 5번 모두 true
d.Writer.Complete();
await foreach (var x in d.Reader.ReadAllAsync())
Console.Write($"{x} "); // 3 4 5
Console.WriteLine($"| 버림 {string.Join(",", dropped)}"); // | 버림 1,2
무엇을 고를까
- 잃으면 안 되는 데이터(주문 · 이메일 발송 · 결제 이벤트) →
Wait. 생산자가 느려지는 것을 받아들입니다. - 최신 값만 의미 있는 데이터(플레이어 위치 · 센서 값 · 시세) →
DropOldest. 오래된 위치를 처리하는 것은 낭비입니다. - HTTP 요청에서 바로 쓰는 경우 →
TryWrite로 시도하고 false 면 503 / 429 로 거절. 요청 스레드를 붙잡고 기다리게 하지 않습니다(SQ 12).
itemDropped 콜백으로 버린 개수를 메트릭으로 내보내세요. "조용히 버려지는" 데이터는 장애 분석 때 가장 찾기 어렵습니다.
종료와 오류 — Complete(ex) 는 소비자에게 예외를 전한다 🔴 고급
var ch = Channel.CreateUnbounded<int>();
_ = Task.Run(async () =>
{
try
{
await ch.Writer.WriteAsync(1);
await ch.Writer.WriteAsync(2);
throw new IOException("원본 파일이 잘렸음");
}
catch (Exception ex)
{
ch.Writer.Complete(ex); // ★ 예외를 실어서 완료
}
});
try
{
await foreach (var x in ch.Reader.ReadAllAsync())
Console.Write($"{x} "); // 1 2 (남은 것은 다 받은 뒤)
}
catch (IOException e)
{
Console.WriteLine($"| 생산자 실패: {e.Message}"); // | 생산자 실패: 원본 파일이 잘렸음
}
// 완료 후 쓰기
Console.WriteLine(ch.Writer.TryWrite(3)); // False
// await ch.Writer.WriteAsync(3); // 💥 ChannelClosedException
규칙 네 가지
- Complete 는 "마지막 생산자"가 한 번만. 두 번 부르면
ChannelClosedException(InvalidOperationException 의 파생) — 확실하지 않으면TryComplete(). - 생산자가 여럿이면
Task.WhenAll(producers)뒤에 Complete 합니다(위 예제 패턴). 각 생산자가 Complete 하면 첫 번째가 나머지를 막습니다. - 오류는 Complete(ex) 로 넘기면 소비자의
await foreach가 남은 항목을 다 받은 뒤 같은 예외를 던지고,Reader.Completion도 실패 상태가 됩니다. - 취소(CancellationToken)는 "그만 기다리기"일 뿐 채널을 닫지 않습니다. 종료 절차는 Complete, 강제 중단은 토큰 — 둘을 구분하세요.
소비자 여럿 — 나눠 갖기이지 방송이 아니다 🟡 중급
var jobs = Channel.CreateBounded<int>(new BoundedChannelOptions(100)
{
SingleReader = false, // ★ 소비자가 여럿이면 반드시 false
});
// 워커 4개가 같은 Reader 를 읽음 → 항목 하나는 워커 하나에게만
var workers = Enumerable.Range(1, 4).Select(id => Task.Run(async () =>
{
int mine = 0;
await foreach (var job in jobs.Reader.ReadAllAsync())
mine++;
return mine;
})).ToArray();
for (int i = 0; i < 1_000; i++) await jobs.Writer.WriteAsync(i);
jobs.Writer.Complete();
int[] counts = await Task.WhenAll(workers);
Console.WriteLine(counts.Sum()); // 1000 (각 워커 몫은 실행마다 다름)
- Channel 은 작업 분배(competing consumers) 구조입니다. 모든 구독자에게 같은 메시지를 뿌리는 pub/sub 이 필요하면 구독자마다 채널을 하나씩 두고 복사해 넣어야 합니다.
SingleReader = true인데 실제로 둘이 읽으면 예외 없이 항목이 꼬일 수 있습니다. 성능 옵션이 아니라 계약입니다.- 순서: 소비자가 하나면 FIFO 가 보장되지만, 여럿이면 처리 완료 순서는 보장되지 않습니다.
Channel.CreateUnboundedPrioritized<T>() 가 추가되었습니다
.NET 9+. 이름처럼 Unbounded 뿐이므로 상한은 따로 관리해야 합니다.
Unity 에는 Channels 가 기본으로 없어 DLL 을 직접 넣거나 SQ 06 의 ConcurrentQueue 방식을 씁니다.
Channel.CreateBounded + ReadAllAsync + Writer.Complete() 세 줄이 뼈대입니다.
꽉 찼을 때의 정책(Wait · Drop)을 정하는 것이 배압 설계입니다.알고리즘 패턴 — C# 관용 코드로
스택 · 큐 · 덱이 실제로 빛나는 여섯 가지 패턴입니다. 이론은 CS 가이드에 맡기고, 여기서는 C# 에서 어떤 타입과 어떤 메서드로 쓰는지, 그리고 Try 패턴이 루프를 얼마나 짧게 만드는지에 집중합니다. 출력은 모두 손으로 추적해 맞춘 값입니다.
① 괄호 검사 — Stack<char>
static bool IsBalanced(string s)
{
var st = new Stack<char>();
foreach (var c in s)
{
switch (c)
{
case '(' or '[' or '{':
st.Push(c);
break;
case ')' or ']' or '}':
{
char open = c switch { ')' => '(', ']' => '[', _ => '{' };
// 비어 있으면 TryPop 이 false → 짝 없는 닫는 괄호
if (!st.TryPop(out var top) || top != open) return false;
break;
}
}
}
return st.Count == 0; // 남은 여는 괄호가 있으면 실패
}
foreach (var s in new[] { "{[()]}", "([)]", "((", "a[b]{c}" })
Console.WriteLine($"{s} {IsBalanced(s)}");
// {[()]} True
// ([)] False
// (( False
// a[b]{c} True
② 단조 스택 — 다음 큰 수
static int[] NextGreater(int[] a)
{
var res = new int[a.Length];
Array.Fill(res, -1);
var st = new Stack<int>(); // 아직 답을 못 찾은 '인덱스'
for (int i = 0; i < a.Length; i++)
{
// 나보다 작은 값들은 지금 답을 찾았다
while (st.TryPeek(out var j) && a[j] < a[i])
res[st.Pop()] = a[i];
st.Push(i);
}
return res; // 스택에 남은 것은 답 없음(-1)
}
Console.WriteLine(string.Join(",", NextGreater([2, 7, 3, 5, 4, 6, 8])));
// 7,8,5,6,6,8,-1
while (st.TryPeek(out var j) && …) — "비어 있지 않고, 꼭대기가 조건을 만족하는 동안"을 한 줄로 씁니다.
st.Count > 0 && a[st.Peek()] < a[i] 보다 짧고 Peek 를 두 번 하지 않습니다. 주식 가격 span · 히스토그램 최대 직사각형도 같은 뼈대입니다.
③ BFS 최단 거리 — Queue<(int R, int C)>
string[] grid = ["S.#.", "..#.", "#...", ".#.G"];
int H = grid.Length, W = grid[0].Length;
var dist = new int[H, W];
for (int r = 0; r < H; r++)
for (int c = 0; c < W; c++) dist[r, c] = -1;
(int dr, int dc)[] dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)];
var q = new Queue<(int R, int C)>(); // 값 튜플 → 박싱 · 할당 없음
q.Enqueue((0, 0));
dist[0, 0] = 0;
while (q.TryDequeue(out var cur)) // ★ foreach 가 아니라 while
{
foreach (var (dr, dc) in dirs)
{
int nr = cur.R + dr, nc = cur.C + dc;
if (nr < 0 || nc < 0 || nr >= H || nc >= W) continue;
if (grid[nr][nc] == '#' || dist[nr, nc] != -1) continue;
dist[nr, nc] = dist[cur.R, cur.C] + 1; // 넣을 때 방문 표시
q.Enqueue((nr, nc));
}
}
Console.WriteLine(dist[3, 3]); // 6 (S → G 최단 칸 수)
④ 슬라이딩 윈도우 최댓값 — 단조 덱
// SQ 04 의 Deque<T> 사용
static List<int> MaxSliding(int[] a, int k)
{
var dq = new Deque<int>(); // 인덱스 — 값이 앞에서 뒤로 내림차순
var res = new List<int>();
for (int i = 0; i < a.Length; i++)
{
if (dq.TryPeekFront(out var f) && f <= i - k)
dq.PopFront(); // 창 밖으로 나간 인덱스
while (dq.TryPeekBack(out var b) && a[b] <= a[i])
dq.PopBack(); // 나보다 작으면 다시는 최대가 못 됨
dq.PushBack(i);
if (i >= k - 1) res.Add(a[dq.PeekFront()]);
}
return res;
}
Console.WriteLine(string.Join(",", MaxSliding([1, 3, -1, -3, 5, 3, 6, 7], 3)));
// 3,3,5,5,6,7
int[] buf = new int[n]; int head = 0, tail = 0;
배열 하나에 두 포인터로 충분합니다(감을 필요가 없음). 앞에서 빼기는 head++, 뒤에서 빼기는 tail--.
⑤ 0-1 BFS — 비용 0 은 앞에, 1 은 뒤에
var adj = new List<(int To, int W)>[5];
for (int i = 0; i < 5; i++) adj[i] = [];
foreach (var (u, v, w) in new[] { (0, 1, 1), (0, 2, 0), (2, 1, 0),
(1, 3, 1), (2, 3, 1), (3, 4, 0) })
adj[u].Add((v, w));
var dist = Enumerable.Repeat(int.MaxValue, 5).ToArray();
dist[0] = 0;
var dq = new Deque<int>();
dq.PushBack(0);
while (dq.TryPopFront(out var u))
{
foreach (var (v, w) in adj[u])
{
if (dist[u] + w >= dist[v]) continue;
dist[v] = dist[u] + w;
if (w == 0) dq.PushFront(v); // 같은 거리 → 지금 바로 처리
else dq.PushBack(v); // 거리 +1 → 다음 차례
}
}
Console.WriteLine(string.Join(",", dist)); // 0,0,0,1,1
가중치가 0 과 1 뿐이면 다익스트라의 O(E log V) 대신 덱으로 O(V + E) 입니다. 덱이 없는 .NET 에서 SQ 04 구현이 필요한 대표적 이유입니다.
⑥ undo / redo — 스택 두 개
var ed = new Editor();
ed.Type("안녕");
ed.Type("하세요");
ed.Undo(); Console.WriteLine(ed.Text); // 안녕
ed.Redo(); Console.WriteLine(ed.Text); // 안녕하세요
ed.Undo(); ed.Type("!");
Console.WriteLine(ed.Text); // 안녕!
Console.WriteLine(ed.CanRedo); // False (새 입력이 redo 를 지움)
sealed class Editor
{
private readonly Stack<string> _undo = new();
private readonly Stack<string> _redo = new();
public string Text { get; private set; } = "";
public bool CanRedo => _redo.Count > 0;
public void Type(string s)
{
_undo.Push(Text);
Text += s;
_redo.Clear(); // 새 편집은 '미래'를 무효화
}
public void Undo()
{
if (!_undo.TryPop(out var prev)) return;
_redo.Push(Text);
Text = prev;
}
public void Redo()
{
if (!_redo.TryPop(out var next)) return;
_undo.Push(Text);
Text = next;
}
}
불변 스택 · 큐 — ImmutableStack · ImmutableQueue 로 스냅숏 🔴 고급
using System.Collections.Immutable;
var h0 = ImmutableStack<string>.Empty;
var h1 = h0.Push("v1");
var h2 = h1.Push("v2"); // h1 은 그대로 — 새 노드 하나만 생김
var h3 = h2.Pop(out var top);
Console.WriteLine(top); // v2
Console.WriteLine(string.Join(",", h2)); // v2,v1 (h2 는 안 바뀜)
Console.WriteLine(ReferenceEquals(h3, h1)); // True — Pop 은 꼬리를 그대로 돌려줌
var q1 = ImmutableQueue<int>.Empty.Enqueue(1).Enqueue(2);
var q2 = q1.Dequeue(out var first);
Console.WriteLine($"{first} | {string.Join(",", q1)} | {string.Join(",", q2)}");
// 1 | 1,2 | 2
언제 쓰나
- ImmutableStack 은 단일 연결 리스트입니다. Push 는 새 머리 노드 하나, Pop 은 꼬리를 돌려줄 뿐 — 버전마다 전체를 복사하지 않고 꼬리를 공유합니다.
- 상태 이력 · 시간 여행 디버깅 · Redux 식 상태처럼 "모든 과거 버전을 들고 있어야" 할 때, 버전 하나당 비용이 O(1) 입니다.
- 여러 스레드에 락 없이 넘기기 — 바뀌지 않으므로 그냥 공유하면 됩니다. 갱신은
ImmutableInterlocked.Push(ref stack, x)로 원자적으로. - ImmutableQueue 는 내부적으로 불변 스택 두 개(앞 · 뒤)로 만든 큐입니다.
Stack<T> 보다 몇 배 느립니다.
"과거 버전을 공유해야 한다"는 요구가 있을 때만 고르세요.
성능과 메모리 — 어디서 느려지나
스택 · 큐 연산은 전부 O(1) 이라 복잡도로는 차이가 안 보입니다. 실제 속도를 가르는 것은 할당(박싱 · 노드) · 요소 크기 · 재할당 · 캐시 지역성 · 동기화 비용 다섯 가지입니다. 아래 수치는 예시이며, 반드시 자기 환경에서 BenchmarkDotNet 으로 다시 재야 합니다.
직접 재기 — BenchmarkDotNet
dotnet new console -o QBench && cd QBench
dotnet add package BenchmarkDotNet
dotnet run -c Release # Debug 로 재면 수치가 의미 없음
using System.Collections;
using System.Collections.Concurrent;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<QueueBench>();
[MemoryDiagnoser] // 연산당 할당 바이트도 표시
public class QueueBench
{
private const int N = 10_000_000;
[Benchmark(Baseline = true)]
public long GenericQueue()
{
var q = new Queue<int>(); long s = 0;
for (int i = 0; i < N; i++) { q.Enqueue(i); s += q.Dequeue(); }
return s;
}
[Benchmark]
public long LinkedListAsQueue()
{
var q = new LinkedList<int>(); long s = 0;
for (int i = 0; i < N; i++) { q.AddLast(i); s += q.First!.Value; q.RemoveFirst(); }
return s;
}
[Benchmark]
public long NonGenericQueue()
{
var q = new Queue(); long s = 0;
for (int i = 0; i < N; i++) { q.Enqueue(i); s += (int)q.Dequeue()!; } // 박싱 · 언박싱
return s;
}
[Benchmark]
public long Concurrent()
{
var q = new ConcurrentQueue<int>(); long s = 0;
for (int i = 0; i < N; i++) { q.Enqueue(i); q.TryDequeue(out var x); s += x; }
return s;
}
}
비용은 어디서 오나
| 원인 | 어디서 | 대책 |
|---|---|---|
| 박싱 | 비제네릭 Stack · Queue 에 값 타입 | 제네릭으로 — JIT 가 값 타입마다 전용 코드를 만듦 |
| 노드 할당 | LinkedList · ConcurrentStack · Immutable* | 배열 기반 타입 · 원형 버퍼 |
| 재할당 · 복사 | 용량 부족 시 2배 확장 | 생성자에 용량 · EnsureCapacity |
| 큰 struct 복사 | Enqueue · Dequeue · Peek 마다 값 복사 | 요소를 작게 · 큰 데이터는 class 나 인덱스로 |
| 동기화 | Concurrent* 를 단일 스레드에서 | 공유하지 않는 큐는 그냥 Queue<T> |
| 커스텀 Comparer | PriorityQueue 의 인터페이스 호출 | 기본 비교자 경로가 가장 빠름 (아래) |
Comparer<T>.Default 를
직접 부르는 경로를 타서 JIT 가 인라인할 수 있습니다. 정수 최대 힙이라면 비교자 대신 우선순위를 음수로(-score) 넣는 것도 방법입니다 —
단 int.MinValue 는 부호를 뒤집을 수 없으니 범위를 확인하세요.
Queue<T> · Stack<T> 에는 List<T> 의
CollectionsMarshal.AsSpan 같은 내부 참조 접근이 없습니다. 64바이트를 넘는 struct 를 대량으로 돌린다면 class 로 바꾸거나 배열 인덱스만 큐에 넣으세요.
메모리 — 요소 하나가 실제로 차지하는 바이트 🟡 중급
| 컨테이너 (64비트) | int 요소 하나 | string 요소 하나 (문자열 자체 제외) | 비고 |
|---|---|---|---|
| Stack<int> · Queue<int> | 4 B (+ 빈 칸 여유) | 8 B (참조) | 배열 하나 — 최대 약 2배까지 빈 칸 |
| PriorityQueue<E, P> | (E, P) 튜플 — int, int 면 8 B | 참조 8 + 우선순위 | 요소와 우선순위를 나란히 저장 |
| LinkedList<int> | ≈ 48 B (노드 객체) | ≈ 48 B | + 할당 1회 · GC 추적 대상 1개 |
| 비제네릭 Queue | 8 B 참조 + 24 B 박스 | 8 B | 값 타입이면 넣을 때마다 상자 |
| ConcurrentQueue<int> | 4 B + 칸마다 순번 4 B (+ 정렬) | 8 B + 순번 | 세그먼트 칸마다 상태용 순번을 함께 둠 |
| ImmutableStack<int> | ≈ 32 B (노드) | ≈ 32 B | 버전 간 공유 — 복사보다는 쌈 |
Queue<int> 는 한 줄에 16개 요소가 들어 있어 순회 · 연속 Dequeue 때
다음 요소가 이미 캐시에 있습니다. 연결 리스트는 노드마다 다른 줄이라 거의 매번 메모리까지 다녀옵니다.
new Queue<T>() 를 만들지 말고 필드로 두고 Clear() 해서 재사용하세요.
Clear 는 배열을 유지하므로 다음 프레임에 할당이 0 입니다. GC 스파이크로 인한 프레임 튐의 흔한 원인입니다.
흔한 실수 모음 — 코드 리뷰에서 매번 나오는 것
스택 · 큐 버그는 대부분 예외 없이 조용히 틀립니다 — 순서가 뒤집히거나, 항목이 사라지거나, 메모리가 서서히 찹니다. 아래는 실제 리뷰에서 자주 보는 순서대로 모은 것입니다. 왼쪽이 흔히 쓰는 코드, 오른쪽이 고친 코드입니다.
① 확인하고 꺼내기 — 멀티스레드에서는 경쟁 상태 🟡 중급
Count / IsEmpty 확인 → 꺼내기
// 여러 워커가 공유하는 큐
if (!queue.IsEmpty)
{
// ← 이 틈에 다른 스레드가 마지막 항목을 가져감
queue.TryDequeue(out var job);
Process(job!); // 💥 job 은 null (default)
}
// Queue<T> + lock 에서도 같은 실수
if (q.Count > 0) { lock (_gate) { q.Dequeue(); } } // 확인이 락 밖
확인과 꺼내기를 한 동작으로
if (queue.TryDequeue(out var job))
Process(job); // 성공했을 때만 — 원자적
lock (_gate)
{
if (q.TryDequeue(out var item)) Process(item); // 확인도 락 안에서
}
② foreach 로 큐 비우기
foreach (var job in queue)
{
Run(job);
queue.Dequeue(); // 💥 Collection was modified
}
while + TryDequeue
while (queue.TryDequeue(out var job))
Run(job); // 처리 중 Enqueue 해도 안전
③ 스택 복사
var backup = new Stack<Cmd>(history);
// 꼭대기부터 열거 → 앞에서부터 Push
// → backup 은 뒤집혀 있음
Reverse 하거나 불변 스택
var backup = new Stack<Cmd>(history.Reverse());
// 또는 처음부터 ImmutableStack<Cmd> — 복사가 필요 없음
④ 힙 안의 객체 우선순위를 직접 수정
var pq = new PriorityQueue<Job, int>();
pq.Enqueue(job, job.Priority);
job.Priority = 0; // 힙은 모름 — 위치 그대로
// 우선순위는 Enqueue 순간의 값으로 복사돼 저장됨
새로 넣고 낡은 것은 버림
// 요소에 '넣을 때의 버전'을 함께 담는다
var pq = new PriorityQueue<(Job Job, int Ver), int>();
job.Version++;
pq.Enqueue((job, job.Version), 0); // 새 우선순위로 한 번 더
// 꺼낼 때: if (e.Ver != e.Job.Version) continue; // 낡은 항목 버림 (SQ 05)
⑤ UnorderedItems 를 정렬 결과로
var top3 = pq.UnorderedItems
.Take(3) // ❌ 힙 배열 앞 3칸일 뿐
.Select(x => x.Element);
Dequeue 로 꺼내거나 정렬
var top3 = pq.UnorderedItems
.OrderBy(x => x.Priority) // 원본은 그대로 · O(n log n)
.Take(3).Select(x => x.Element);
⑥ Peek 결과(struct)를 고치기
struct Unit { public int Hp; }
var s = new Stack<Unit>();
s.Push(new Unit { Hp = 10 });
s.Peek().Hp = 5; // ❌ CS1612 — 복사본이라 수정 불가
꺼내서 고치고 다시 넣기
var u = s.Pop();
u.Hp = 5;
s.Push(u);
// 자주 고친다면 Unit 을 class 로, 또는 인덱스를 담기
⑦ 채널 Complete 누락
foreach (var f in files)
await ch.Writer.WriteAsync(f);
// Complete() 없음
await consumerTask; // ⏳ 영원히 안 끝남
finally 에서 TryComplete
try
{
foreach (var f in files) await ch.Writer.WriteAsync(f);
}
finally { ch.Writer.TryComplete(); } // 예외가 나도 닫힘
await consumerTask;
⑧ 상한 없는 큐
static readonly Queue<LogLine> Pending = new();
// 디스크가 느려지는 날 → 수백만 건 적재 → OOM
var ch = Channel.CreateUnbounded<LogLine>();
상한 + 넘칠 때의 정책
var ch = Channel.CreateBounded<LogLine>(
new BoundedChannelOptions(10_000)
{ FullMode = BoundedChannelFullMode.DropOldest });
// 버린 수는 itemDropped 콜백으로 메트릭에
⑨ 컬렉션 초기화자
var s = new Stack<int> { 1, 2, 3 }; // ❌ CS1061: Add 없음
Stack<int> t = [1, 2, 3]; // ❌ 컬렉션 식도 안 됨
IEnumerable 생성자
var s = new Stack<int>([1, 2, 3]); // 꼭대기 3
var q = new Queue<int>([1, 2, 3]); // 맨 앞 1
한 장 요약 — 증상에서 원인으로 🟡 중급
| 증상 | 원인 | 처방 |
|---|---|---|
| 가끔 null · default 를 처리함 | 확인 후 꺼내기 경쟁 (①) | TryDequeue 반환값만 믿기 |
| Collection was modified | 열거 중 Enqueue · Push (②) | while + Try 패턴 |
| 되돌리기 순서가 거꾸로 | 스택을 생성자로 복사 (③) | Reverse() · ImmutableStack |
| 다익스트라 결과가 틀림 · 느림 | 우선순위 변경을 기대 (④) · 낡은 항목 미처리 | 지연 삭제 + 꺼낼 때 비교 |
| "상위 N개"가 가끔 틀림 | UnorderedItems 순서 의존 (⑤) | Dequeue 로 꺼내기 |
| 같은 우선순위의 처리 순서가 뒤죽박죽 | 힙은 안정적이지 않음 | (우선순위, 순번) 튜플 |
| 소비자 태스크가 안 끝남 | Complete 누락 (⑦) | finally { TryComplete() } |
| 메모리가 계속 오르고 안 내려감 | 상한 없는 큐 (⑧) · 피크 후 배열 유지 | Bounded · TrimExcess |
| 값 타입인데 GC 가 잦음 | 비제네릭 컬렉션 박싱 · LinkedList 노드 | Stack<T> · Queue<T> |
| 큐를 도는 루프가 n² 으로 느림 | queue.ElementAt(i) 를 루프에서 | ToArray 한 번 또는 List |
| 앱이 멈춤 (UI · Unity) | 메인 스레드에서 BlockingCollection.Take | ConcurrentQueue + 프레임 처리 · Channel + await |
| 여러 소비자인데 항목이 꼬임 | SingleReader = true 계약 위반 | 소비자 수에 맞게 옵션 |
_tail 을 두 스레드가 동시에 읽고 같은 칸에 써서
한 항목이 조용히 덮어써지거나, _size 가 실제와 달라집니다. "스레드 안전하지 않음"은 "가끔 예외"가 아니라 "가끔 데이터가 틀림"입니다.
다른 언어와 비교 — 같은 일, 다른 이름
여러 언어를 오가는 팀이라면 "자바의 ArrayDeque 는 C# 에서 무엇인가"를 빨리 찾을 수 있어야 합니다. 특히 힙의 방향(최소 / 최대)과 "레거시 함정"이 언어마다 달라서 옮겨 적을 때 버그가 납니다.
| 하려는 일 | C# (.NET 10) | Java 21+ | Python 3.13 | JS / TS | C++23 | Rust |
|---|---|---|---|---|---|---|
| 스택 | Stack<T> | ArrayDeque (push/pop) | list (append/pop) | Array (push/pop) | std::stack · vector | Vec |
| 큐 | Queue<T> | ArrayDeque (offer/poll) | collections.deque | 직접 구현 (head 인덱스) | std::queue · deque | VecDeque |
| 덱 | 없음 — 직접 · LinkedList | ArrayDeque | collections.deque | 없음 — 직접 | std::deque | VecDeque |
| 우선순위 큐 | PriorityQueue — 최소 | PriorityQueue — 최소 | heapq — 최소 (함수) | 없음 — 직접 | priority_queue — 최대 | BinaryHeap — 최대 |
| 스레드 안전 큐 | ConcurrentQueue<T> | ConcurrentLinkedQueue | queue.Queue | — (워커 간 postMessage) | 표준 없음 (mutex + queue) | crossbeam · Mutex<VecDeque> |
| 블로킹 유한 큐 | BlockingCollection · Channel | ArrayBlockingQueue | queue.Queue(maxsize) | — | 표준 없음 | mpsc::sync_channel |
| async 채널 | Channel<T> | — (가상 스레드 + BlockingQueue) | asyncio.Queue | — (스트림 · 이벤트) | — | tokio::sync::mpsc |
| 버려진 것 | 비제네릭 Stack · Queue | Stack (Vector 상속) | list.pop(0) 로 큐 | Array.shift 로 큐 (O(n)) | — | — |
같은 "최대 힙 top-1" 을 각 언어로
// C# — 최소 힙이 기본 → 비교자를 뒤집음
var pq = new PriorityQueue<int, int>(Comparer<int>.Create((a, b) => b.CompareTo(a)));
foreach (var x in new[] { 3, 9, 4 }) pq.Enqueue(x, x);
Console.WriteLine(pq.Peek()); // 9
// Java — 최소 힙이 기본
var pq = new PriorityQueue<Integer>(Comparator.reverseOrder());
pq.addAll(List.of(3, 9, 4));
System.out.println(pq.peek()); // 9
# Python — 최소 힙 함수뿐 → 부호를 뒤집는 관용구
import heapq
h = [-x for x in (3, 9, 4)]
heapq.heapify(h)
print(-h[0]) # 9
// C++ — 최대 힙이 기본! (C# 에서 옮길 때 가장 자주 틀림)
std::priority_queue<int> pq;
for (int x : {3, 9, 4}) pq.push(x);
std::cout << pq.top(); // 9
C# 개발자가 옮겨 갈 때 / 옮겨 올 때
- 자바 → C#: ArrayDeque 를 찾다가 없어서 LinkedList 로 가기 쉽습니다. 스택 · 큐로만 쓰던 것이면 Stack<T> · Queue<T> 로 나눠 옮기세요. 진짜 양쪽이 필요하면 SQ 04.
- 자바 → C#: 자바
Stack이 레거시라고 C#Stack<T>까지 피할 필요는 없습니다. 이름만 같고 설계가 다릅니다. - C++ · Rust → C#: 힙 방향이 반대입니다.
priority_queue코드를 옮기면 결과가 정확히 거꾸로 나옵니다. - 파이썬 → C#:
deque(maxlen=N)의 "자동으로 밀어내기"는 C# 에 없습니다. SQ 12 의 RecentLog 처럼 Count 를 확인해 Dequeue 하세요. - C# → 파이썬:
queue.Queue는 스레드용이라 단일 스레드 BFS 에서는 락 비용만 듭니다.collections.deque가 맞습니다. - C# → JS:
shift()로 큐를 만들면 O(n) 입니다. 큰 BFS 는 배열 + head 인덱스로.
자바 개발자를 위한 1:1 번역 🟡 중급
// Java 21
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); stack.push(2);
int top = stack.pop(); // 2
Integer maybe = stack.peek(); // 1 (비면 null)
Queue<String> q = new ArrayDeque<>();
q.offer("a"); q.offer("b");
String head = q.poll(); // "a" (비면 null)
BlockingQueue<Job> jobs = new ArrayBlockingQueue<>(100);
jobs.put(job); // 꽉 차면 스레드 블록
Job next = jobs.take(); // 비면 스레드 블록
// C# 14 · .NET 10
var stack = new Stack<int>();
stack.Push(1); stack.Push(2);
int top = stack.Pop(); // 2
bool has = stack.TryPeek(out var maybe); // true, 1 (비면 false)
var q = new Queue<string>();
q.Enqueue("a"); q.Enqueue("b");
q.TryDequeue(out var head); // "a" (비면 false)
var jobs = Channel.CreateBounded<Job>(100);
await jobs.Writer.WriteAsync(job); // 꽉 차면 await — 스레드는 반납
Job next = await jobs.Reader.ReadAsync(); // 비면 await
| 동작 | C# | Java (Deque) | Python | JS | C++ | Rust |
|---|---|---|---|---|---|---|
| 스택에 넣기 | Push | push | append | push | push / push_back | push |
| 스택에서 빼기 | Pop · TryPop | pop | pop | pop | top + pop (pop 은 void) | pop → Option |
| 큐에 넣기 | Enqueue | offer / addLast | append | push | push / push_back | push_back |
| 큐에서 빼기 | Dequeue · TryDequeue | poll / pollFirst | popleft | shift (O(n)) | front + pop | pop_front → Option |
| 앞 보기 | Peek · TryPeek | peek | q[0] | q[0] | front / top | front → Option |
실무 사례 — 어떤 타입으로 어떻게 짜나
작업 큐 · 최근 N개 보관 · 레이트 리미터 · 재시도 큐 · 이벤트 묶음 처리 · 게임 이벤트 큐 — 현업에서 실제로 만나는 여섯 가지를 타입 선택의 이유와 함께 완전한 코드로 정리합니다.
① ASP.NET Core 백그라운드 작업 큐 — Channel + BackgroundService 🔴 고급
// Program.cs — dotnet new web (.NET 10)
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(new BackgroundTaskQueue(capacity: 100));
builder.Services.AddHostedService<QueuedWorker>();
var app = builder.Build();
app.MapPost("/reports/{id:int}", (int id, BackgroundTaskQueue queue, ILogger<Program> log) =>
{
bool accepted = queue.TryEnqueue(async ct =>
{
await Task.Delay(TimeSpan.FromSeconds(2), ct); // 무거운 보고서 생성 자리
log.LogInformation("보고서 {Id} 완료", id);
});
return accepted
? Results.Accepted($"/reports/{id}") // 202 — 접수 완료
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable); // 큐가 꽉 참
});
app.Run();
public sealed class BackgroundTaskQueue
{
private readonly Channel<Func<CancellationToken, ValueTask>> _ch;
public BackgroundTaskQueue(int capacity) =>
_ch = Channel.CreateBounded<Func<CancellationToken, ValueTask>>(
new BoundedChannelOptions(capacity)
{
FullMode = BoundedChannelFullMode.Wait, // 꽉 차면 TryWrite 가 false
SingleReader = true, // 워커 하나
});
// HTTP 요청처럼 기다리면 안 되는 쪽
public bool TryEnqueue(Func<CancellationToken, ValueTask> work) => _ch.Writer.TryWrite(work);
// 배치 · 내부 호출처럼 기다려도 되는 쪽
public ValueTask EnqueueAsync(Func<CancellationToken, ValueTask> work, CancellationToken ct) =>
_ch.Writer.WriteAsync(work, ct);
public IAsyncEnumerable<Func<CancellationToken, ValueTask>> ReadAllAsync(CancellationToken ct) =>
_ch.Reader.ReadAllAsync(ct);
}
public sealed class QueuedWorker(BackgroundTaskQueue queue, ILogger<QueuedWorker> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var work in queue.ReadAllAsync(stoppingToken))
{
try
{
await work(stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break; // 종료 중 — 조용히 빠져나감
}
catch (Exception ex)
{
log.LogError(ex, "백그라운드 작업 실패"); // ★ 하나가 실패해도 워커는 계속
}
}
}
}
BackgroundService 에서 처리되지 않은 예외가 나면 호스트 전체가 종료됩니다
(BackgroundServiceExceptionBehavior.StopHost 가 기본). 작업 하나의 실패가 서버를 내리지 않도록 항목 단위로 감쌉니다.
SingleReader = false 로 바꿉니다. 또는 워커 안에서
Parallel.ForEachAsync(queue.ReadAllAsync(ct), new ParallelOptions { MaxDegreeOfParallelism = 4 }, …) 처럼 동시 실행 수를 제한합니다.
② 최근 N개 보관 · ③ 레이트 리미터 🟡 중급
최근 N개 — 넘치면 가장 오래된 것을 밀어냄
public sealed class RecentLog<T>(int capacity)
{
private readonly Queue<T> _q = new(capacity); // 미리 N칸 → 재할당 0
public void Add(T item)
{
if (_q.Count == capacity) _q.Dequeue(); // 파이썬 deque(maxlen) 과 같은 동작
_q.Enqueue(item);
}
public IReadOnlyCollection<T> Items => _q; // 오래된 것 → 최신 순
}
var recent = new RecentLog<string>(3);
foreach (var e in new[] { "a", "b", "c", "d", "e" }) recent.Add(e);
Console.WriteLine(string.Join(",", recent.Items)); // c,d,e
최근 에러 50건 · 최근 채팅 100줄 · 이동 평균용 샘플 같은 곳에 씁니다. Queue<T> 가 원형 배열이므로
용량이 N 으로 고정된 뒤에는 할당 없이 칸만 돌려 씁니다. 여러 스레드가 쓰면 lock 으로 감싸세요.
슬라이딩 윈도우 로그 — 최근 1초 안에 3번까지
public sealed class SlidingWindowLog(int limit, long windowMs)
{
private readonly Queue<long> _hits = new(); // 허용된 요청의 시각
private readonly Lock _gate = new(); // .NET 9+ 전용 락 타입
public bool TryAcquire() => TryAcquire(Environment.TickCount64);
public bool TryAcquire(long nowMs) // 테스트용으로 시각을 주입
{
lock (_gate)
{
// 창 밖으로 나간 기록을 앞에서부터 버림 — 큐가 시간순이라 가능
while (_hits.TryPeek(out var t) && nowMs - t >= windowMs)
_hits.Dequeue();
if (_hits.Count >= limit) return false;
_hits.Enqueue(nowMs);
return true;
}
}
}
var rl = new SlidingWindowLog(limit: 3, windowMs: 1000);
foreach (var t in new long[] { 0, 100, 200, 300, 1050, 1150, 1200, 1300 })
Console.Write($"{t}:{(rl.TryAcquire(t) ? "허용" : "거절")} ");
// 0:허용 100:허용 200:허용 300:거절 1050:허용 1150:허용 1200:허용 1300:거절
AddRateLimiter 와 System.Threading.RateLimiting 의
SlidingWindowRateLimiter · TokenBucketRateLimiter 가 있습니다. 서버가 여러 대면 Redis 같은 공유 저장소가 필요합니다.
위 코드는 원리 이해 · 단일 프로세스 내부 보호용입니다.
④ 재시도 큐 · ⑤ 이벤트 묶음 처리 🟡 중급
재시도 큐 — 우선순위 = 다음 시도 시각
var retry = new PriorityQueue<string, DateTimeOffset>();
var now = DateTimeOffset.Parse("2026-10-01T09:00:00Z");
retry.Enqueue("메일#7", now.AddSeconds(30));
retry.Enqueue("웹훅#3", now.AddSeconds(5));
retry.Enqueue("메일#9", now.AddSeconds(10));
var at = now.AddSeconds(12); // 지금이 09:00:12 라면
// 시각이 된 것만 꺼냄 — 루트가 가장 이른 시각
while (retry.TryPeek(out var job, out var due) && due <= at)
{
retry.Dequeue();
Console.WriteLine($"재시도 {job} (예정 {due:HH:mm:ss})");
}
Console.WriteLine($"남은 {retry.Count}건");
// 재시도 웹훅#3 (예정 09:00:05)
// 재시도 메일#9 (예정 09:00:10)
// 남은 1건
// 다음 시각 계산 — 지수 백오프 + 지터 (동시에 몰리지 않게)
static DateTimeOffset NextDue(DateTimeOffset now, int attempt) =>
now + TimeSpan.FromSeconds(Math.Min(300, Math.Pow(2, attempt)))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));
이벤트 묶음 처리 — 채널에서 최대 N개씩
using System.Runtime.CompilerServices;
using System.Threading.Channels;
static async IAsyncEnumerable<List<T>> ReadBatchesAsync<T>(
ChannelReader<T> reader, int maxBatch,
[EnumeratorCancellation] CancellationToken ct = default)
{
// 하나라도 생길 때까지 기다린 뒤, 지금 쌓인 것을 최대 maxBatch 개까지 한꺼번에
while (await reader.WaitToReadAsync(ct))
{
var batch = new List<T>(maxBatch);
while (batch.Count < maxBatch && reader.TryRead(out var item))
batch.Add(item);
if (batch.Count > 0) yield return batch;
}
}
var events = Channel.CreateBounded<int>(1_000);
for (int i = 1; i <= 7; i++) await events.Writer.WriteAsync(i);
events.Writer.Complete();
await foreach (var batch in ReadBatchesAsync(events.Reader, maxBatch: 3))
Console.WriteLine(string.Join(",", batch));
// 1,2,3
// 4,5,6
// 7
로그 · 분석 이벤트를 DB 나 외부 API 로 보낼 때 한 건씩 보내면 왕복 비용이 지배합니다. 쌓인 만큼 묶어서 보내면 한가할 때는 1건씩 빠르게, 바쁠 때는 큰 묶음으로 효율적으로 — 부하에 따라 저절로 조절됩니다.
⑥ Unity — 프레임 단위 이벤트 큐 🟡 중급
using System.Collections.Generic;
using UnityEngine;
public readonly struct GameEvent
{
public readonly int Type, EntityId;
public GameEvent(int type, int entityId) { Type = type; EntityId = entityId; }
}
public sealed class EventBus : MonoBehaviour
{
// 큐 두 개를 번갈아 씀 — 처리 중에 생긴 이벤트는 다음 프레임으로
private Queue<GameEvent> _current = new Queue<GameEvent>(256);
private Queue<GameEvent> _next = new Queue<GameEvent>(256);
public void Raise(GameEvent e) => _next.Enqueue(e); // 메인 스레드 전용
private void LateUpdate()
{
(_current, _next) = (_next, _current); // 교체 — 할당 없음
while (_current.TryDequeue(out var e))
Dispatch(e); // 여기서 Raise 해도 _next 로
}
private void Dispatch(in GameEvent e) { /* 구독자에게 전달 */ }
}
왜 이렇게 하나
- 이벤트 처리 중에 새 이벤트가 생기는 연쇄(피격 → 사망 → 드롭)를 한 큐로 돌리면 한 프레임이 끝나지 않을 수 있습니다. 두 큐를 번갈아 쓰면 프레임마다 끝이 보장됩니다.
GameEvent를 readonly struct 로 두면Queue<GameEvent>안에 값으로 들어가 이벤트마다 할당이 0 입니다. class 이벤트는 프레임마다 쓰레기를 만듭니다.- 용량 256 으로 미리 잡고 계속 재사용 — 큐는 비워도 배열을 유지하므로 GC 스파이크가 없습니다.
- 다른 스레드에서 올리는 이벤트는 SQ 06 의
MainThread.Post를 거쳐 들어오게 하세요.
record struct · 컬렉션 식 [] · 기본 생성자(primary constructor)를 쓸 수 없습니다.
그래서 이 카드의 코드는 일부러 옛 문법으로 적었습니다. TryDequeue 는 .NET Standard 2.1 에 있어 그대로 됩니다.
사례별 타입 선택 — 한 장 요약 🟡 중급
| 사례 | 타입 | 핵심 이유 |
|---|---|---|
| 웹 요청 → 백그라운드 작업 | Channel (Bounded) + BackgroundService | async 대기 · 배압 · TryWrite 로 즉시 거절 |
| 최근 N개 보관 | Queue<T> (용량 N) | 원형 배열 재사용 · 할당 0 |
| 레이트 리미터 (로그 방식) | Queue<long> + lock | 시간순이라 앞에서만 지움 |
| 재시도 · 예약 작업 | PriorityQueue<T, DateTimeOffset> | 가장 이른 시각이 루트 |
| 이벤트 묶음 전송 | Channel + WaitToReadAsync / TryRead | 쌓인 만큼 한 번에 |
| 게임 이벤트 · 명령 처리 | Queue<struct> 두 개 교대 | 프레임마다 끝 보장 · 할당 0 |
| 다른 스레드 → Unity 메인 스레드 | ConcurrentQueue<Action> | 기다릴 필요 없음 · 프레임 예산 |
| 오브젝트 풀 | Stack<T> | LIFO — 방금 쓴 것이 캐시에 따뜻함 |
| 편집기 undo / 상태 이력 | Stack<T> 두 개 · ImmutableStack | 되돌리기 = Pop · 이력 공유 = 불변 |
| LRU 캐시 | LinkedList<T> + Dictionary | 쥔 노드를 중간에서 O(1) 로 이동 |
솔루션은
규모를 따라
한 칸씩 자란다
프로젝트가 하나일 때는 아무렇게나 둬도 찾을 수 있습니다. 문제는 프로젝트가 열 개, 사람이 다섯 명, 실행 파일이 세 개가 될 때입니다. 이 탭은 C# 프로젝트를 어떤 솔루션 · 프로젝트 · 폴더로 나누는지, 그리고 언제 다음 단계로 넘어가는지를 유형별로 한 장씩 비교합니다. .NET 공통 바탕(솔루션 · Directory.Build.props · 중앙 패키지 관리)에서 시작해 ASP.NET Core, Clean Architecture, 테스트, WPF · MAUI, Unity, Blazor · Aspire, 워커 · 배치, NuGet 라이브러리까지 보고, 마지막에 구조 규칙을 빌드와 테스트로 강제하는 법으로 끝냅니다. 깊은 내용은 이미 다른 탭에 있으니 여기서는 비교와 판단만 하고 링크로 넘깁니다.
.NET 의 공통 바탕 — 솔루션 · 프로젝트 · 루트 설정 파일
.NET 은 어떤 앱을 만들든 바탕이 같습니다. 솔루션(.sln / .slnx)은 프로젝트 목록이고, 실제 설정은 .csproj 에 있으며, 저장소 루트의 파일 몇 개가 모든 프로젝트에 공통으로 적용됩니다. 각 명령과 파일의 상세는 설치 탭 SETUP 05 · 06 · 11 에 있으니, 여기서는 어디에 무엇을 두는지와 커지면 어떻게 바뀌는지만 봅니다.
저장소 루트 — 사실상 표준 배치
Shop/
├─ Shop.slnx # 솔루션 = 프로젝트 목록 (설정은 없음)
├─ global.json # 이 폴더 아래에서 쓸 SDK 버전 고정
├─ Directory.Build.props # 모든 .csproj 에 공통 속성 주입
├─ Directory.Packages.props # 패키지 버전 한곳 (CPM)
├─ .editorconfig # 코드 스타일 · 분석기 심각도 (STR 10)
├─ nuget.config # 사내 피드가 있으면
├─ src/
│ ├─ Shop.Api/
│ │ └─ Shop.Api.csproj # 폴더 = 프로젝트 = 어셈블리 = 루트 네임스페이스
│ └─ Shop.Core/
│ └─ Shop.Core.csproj
└─ tests/
├─ Directory.Build.props # 테스트 프로젝트만의 공통 설정 (STR 04)
└─ Shop.Core.Tests/
규칙 다섯 가지
| 규칙 | 이유 |
|---|---|
이름을 한 줄로Shop.Api | 폴더 · csproj · 어셈블리 · 루트 네임스페이스가 같으면 어느 쪽에서 찾아도 바로 이어집니다 (RootNamespace 기본값이 프로젝트 이름) |
| 네임스페이스 = 폴더 경로 | Orders/PlaceOrder.cs → Shop.Api.Orders. 분석기 IDE0130 이 어긋남을 알려 줍니다 |
| src/ · tests/ 분리 | 테스트 폴더에만 다른 Directory.Build.props 를 둘 수 있고, CI 가 tests/ 만 골라 돌리기 쉽습니다 |
| 공통은 루트 파일로 | TargetFramework · Nullable · 경고 정책을 csproj 마다 적지 않습니다 |
| 프로젝트 폴더를 겹치지 않기 | SDK 스타일 csproj 는 자기 폴더 아래 **/*.cs 를 전부 컴파일합니다 (아래 ❌) |
루트 Directory.Build.props — 한 번만 적기
<!-- 저장소 루트 · MSBuild 가 프로젝트 폴더에서 위로 올라가며 찾아 자동으로 읽음 -->
<Project>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<!-- bin/obj 를 루트 artifacts/ 한곳으로 (.NET 8+, 이 파일에만 둘 수 있음) -->
<UseArtifactsOutput>true</UseArtifactsOutput>
</PropertyGroup>
</Project>
그러면 각 csproj 는 이만큼만
<!-- src/Shop.Api/Shop.Api.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<ProjectReference Include="..\Shop.Core\Shop.Core.csproj" />
<!-- 버전 없음 — Directory.Packages.props 가 정함 -->
<PackageReference Include="Serilog.AspNetCore" />
</ItemGroup>
</Project>
tests/ 에 따로 두면 루트 것은 무시되므로
그 파일 첫 줄에서 위의 것을 직접 Import 해야 합니다 — 방법은 STR 04.
.sln 은 옛 형식, .slnx 는 GUID 를 걷어낸 XML 형식이며 dotnet sln migrate 로 바꿉니다(SETUP 05).
다음 단계로 넘어가는 신호 🟢 기초
| 지금 | 이런 일이 생기면 | 다음 단계 |
|---|---|---|
| 프로젝트 1개 | 테스트 프로젝트가 필요 · 콘솔 도구나 워커 같은 두 번째 실행 파일이 생김 | 솔루션 + src/ · tests/ · Directory.Build.props |
| 솔루션 (2~5개) | 같은 패키지 버전이 csproj 3곳 이상에 흩어짐 · "Api 가 DB 코드를 직접 부르면 안 된다"는 말이 리뷰마다 나옴 | CPM (SETUP 06) · 계층 프로젝트 (STR 03) |
| 계층 + CPM | 솔루션 열기가 1분을 넘음 · 팀 둘이 같은 릴리스를 기다림 · 공용 코드의 변경 주기가 따로 놂 | 솔루션 · 저장소 분리, 공용 코드는 사내 NuGet (STR 09) |
❌ 프로젝트 안에 프로젝트
Shop.Api/
├─ Shop.Api.csproj
├─ Program.cs
└─ Shop.Core/ # 여기에 다른 csproj 를 만들면
├─ Shop.Core.csproj
└─ Order.cs # Shop.Api 도 이 파일을 컴파일 → 타입 중복 오류
# 바깥 csproj 는 자기 폴더 아래 *.cs 를 전부 줍습니다
✅ 프로젝트는 형제로
src/
├─ Shop.Api/
│ ├─ Shop.Api.csproj # Shop.Core 를 ProjectReference
│ └─ Program.cs
└─ Shop.Core/
├─ Shop.Core.csproj
└─ Order.cs
# 폴더가 겹치지 않으면 glob 도 겹치지 않습니다
Directory.Build.props · Directory.Packages.props · global.json 에.
프로젝트는 src/ · tests/ 아래 형제로 두고, 신호가 올 때만 다음 단계로 넘어갑니다.ASP.NET Core — Controllers 계층형 vs 기능별(Vertical Slice)
dotnet new webapi 가 만드는 폴더는 시작점일 뿐입니다. 엔드포인트가 스무 개를 넘으면
Controllers · Services · Models 를 오가는 시간이 늘어납니다. 이 섹션은 두 배치를 나란히 놓고,
Minimal API 에서 엔드포인트를 기능 폴더 안 확장 메서드로 모으는 법을 봅니다. 앱이 뜨는 순서와 DI 는 엔터프라이즈 탭 ENT 02 · 03.
계층형 (Controllers 템플릿 그대로)
Shop.Api/
├─ Program.cs
├─ Controllers/ OrdersController · MembersController · …
├─ Services/ OrderService · IOrderService · …
├─ Models/ 주문 · 회원 DTO 가 한 폴더에 섞임
├─ Data/ AppDbContext · Migrations/
└─ appsettings.json
# 기능 하나를 고치려면 폴더 4개를 오갑니다
기능별 (Vertical Slice)
Shop.Api/
├─ Program.cs # 조립만 — 기능마다 Map 한 줄
├─ Features/
│ ├─ Orders/
│ │ ├─ OrderEndpoints.cs # MapOrders() — 이 기능의 라우트 전부
│ │ ├─ PlaceOrder.cs # 요청 · 응답 · 처리 한 파일
│ │ ├─ CancelOrder.cs
│ │ └─ Order.cs # 이 기능만 쓰는 엔티티
│ └─ Members/
├─ Infrastructure/ # AppDbContext · 외부 클라이언트
└─ Common/ # 정말 공통인 것만 (에러 응답 · 필터)
엔드포인트 그룹 확장 메서드는 기능 폴더 안에 🟡 중급
// Features/Orders/OrderEndpoints.cs
namespace Shop.Api.Features.Orders;
public static class OrderEndpoints
{
public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/orders").WithTags("Orders");
g.MapPost("/", PlaceOrder.Handle);
g.MapPost("/{id:long}/cancel", CancelOrder.Handle);
return app;
}
}
// Program.cs — 기능이 늘어도 한 줄씩만 늘어납니다
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration); // Infrastructure/ 의 확장 메서드
var app = builder.Build();
app.MapOrders();
app.MapMembers();
app.Run();
// Features/Orders/PlaceOrder.cs — 요청 · 응답 · 처리가 한 파일
using Microsoft.AspNetCore.Http.HttpResults;
using Shop.Api.Infrastructure;
namespace Shop.Api.Features.Orders;
public static class PlaceOrder
{
public sealed record Request(string Item, int Quantity);
public sealed record Response(long Id);
public static async Task<Results<Created<Response>, ValidationProblem>> Handle(
Request req, AppDbContext db, CancellationToken ct)
{
if (req.Quantity <= 0)
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
["quantity"] = ["수량은 1 이상이어야 합니다"]
});
var order = new Order { Item = req.Item, Quantity = req.Quantity };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return TypedResults.Created($"/orders/{order.Id}", new Response(order.Id));
}
}
Features/Orders/OrdersController.cs 로 옮겨도 그대로 동작합니다. 계층형 vs 기능별은 Minimal API 냐 Controllers 냐와 별개의 선택입니다.
| 무엇을 | 어디에 | 금지 |
|---|---|---|
| 라우트 묶음 | Features/<기능>/<기능>Endpoints.cs 의 Map<기능>() | Program.cs 에 MapPost 수십 줄 |
| DI 등록 | 그 계층 · 기능의 Add<무엇>() 확장 메서드 | Program.cs 200줄 |
| 기능끼리 공유 | Common/ 또는 도메인 프로젝트(STR 03) | Orders 가 Members 내부 클래스를 직접 사용 |
| DbContext | Infrastructure/ 하나 — 기능마다 DbSet | 기능마다 DbContext (트랜잭션이 갈라짐) |
Map<기능>() 에,
Program.cs 는 조립만 하게 둡니다.Clean Architecture 솔루션 — 네 프로젝트와 참조 방향, 그리고 비용
업무 규칙이 복잡하고 오래 살아남을 시스템이면 규칙을 프레임워크와 DB 로부터 떼어 두는 값어치가 있습니다. .NET 에서는 이것을 프로젝트 분리 + 참조 방향으로 컴파일러가 지키게 합니다. 만드는 명령 · 각 층의 책임표 · MediatR 은 엔터프라이즈 탭 ENT 04, 짧은 개요는 고급 탭 ADV 11 에 있으니 여기서는 모양과 비용 판단만 봅니다.
네 프로젝트 + 테스트
src/
├─ Shop.Domain/ # 엔티티 · 값 객체 · 규칙 — 참조 0개
│ └─ Orders/Order.cs
├─ Shop.Application/ # 유스케이스 · 포트(인터페이스) → Domain
│ └─ Orders/
│ ├─ PlaceOrder.cs # 기능별 폴더는 여기서도 그대로
│ └─ IOrderRepository.cs
├─ Shop.Infrastructure/ # EF Core · SMTP · 외부 API → Application
│ ├─ Persistence/AppDbContext.cs
│ └─ DependencyInjection.cs
└─ Shop.Api/ # 엔드포인트 · Program.cs → Application, Infrastructure
tests/
├─ Shop.Domain.Tests/
├─ Shop.Application.Tests/
└─ Shop.Api.IntegrationTests/
조립은 각 층의 확장 메서드로
// Shop.Infrastructure/DependencyInjection.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Shop.Application.Orders;
using Shop.Infrastructure.Persistence;
namespace Shop.Infrastructure;
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(
this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<AppDbContext>(o =>
o.UseNpgsql(config.GetConnectionString("Shop")));
// 인터페이스는 Application 에, 구현은 여기에
services.AddScoped<IOrderRepository, EfOrderRepository>();
return services;
}
}
AppDbContext 를 직접 쓰기 시작하면
이 구조의 이점이 사라지니, 그것을 막는 테스트를 STR 10 에서 겁니다.
이점과 비용 — 작은 서비스엔 과합니다 🟡 중급
| 항목 | 얻는 것 | 내는 것 |
|---|---|---|
| 참조 방향 | Domain 이 EF · HTTP 를 쓰면 컴파일 오류 — 리뷰 대신 빌드가 지킴 | 프로젝트 4개 + 테스트 3개, 솔루션 관리 비용 |
| 테스트 | 규칙 테스트가 DB · 웹 서버 없이 수 초 안에 | 인터페이스와 가짜 구현이 늘어남 |
| 교체 | DB · 메일 · 외부 API 를 바꿔도 Domain · Application 은 그대로 | 실제로 바꾸는 일은 드묾 — "언젠가"를 위한 비용 |
| 새 기능 하나 | 어디에 무엇을 둘지 팀 전체가 같은 답 | DTO → 커맨드 → 엔티티 매핑 2~3번, 파일 4~6개 |
❌ 엔드포인트 8개짜리 CRUD 에 네 프로젝트
Shop.Domain/ Order.cs # 속성만 있는 클래스
Shop.Application/ OrderService.cs # 리포지토리를 그대로 전달
Shop.Infrastructure/ OrderRepository.cs
Shop.Api/ OrdersController.cs
# 규칙이 없으니 층마다 같은 필드를 옮겨 적기만 합니다
✅ 프로젝트 하나 + 기능별 폴더로 시작
Shop.Api/
└─ Features/Orders/ PlaceOrder.cs · Order.cs
# 규칙이 자라는 기능이 생기면 그때
# Shop.Domain 을 떼어 내고 (1단계)
# 외부 연동이 늘면 Infrastructure 를 뗍니다 (2단계)
테스트 프로젝트 — 미러링 · 단위/통합 분리 · 공유 픽스처
자바는 src/test/java 가 같은 모듈 안에 있지만, .NET 은 테스트도 별도 프로젝트입니다.
그래서 "어느 테스트 프로젝트가 어느 운영 프로젝트를 보느냐"가 곧 구조입니다. xUnit 사용법은 도구 탭 TOOL 04 · 05,
Spring 테스트와의 대응은 엔터프라이즈 탭 ENT 12 에 있으니 여기서는 배치와 경계만 봅니다.
운영 프로젝트마다 거울 하나
tests/
├─ Directory.Build.props # 테스트 공통 (아래)
├─ Shop.Domain.Tests/ # src/Shop.Domain 의 거울
│ └─ Orders/OrderTests.cs # ↔ src/Shop.Domain/Orders/Order.cs
├─ Shop.Application.Tests/ # 가짜 리포지토리로 유스케이스
├─ Shop.Api.IntegrationTests/ # 앱을 메모리에 띄워 HTTP 로
│ ├─ ShopApiFactory.cs # 공유 픽스처 — WebApplicationFactory
│ └─ Orders/PlaceOrderTests.cs
└─ Shop.Testing/ # (선택) 빌더 · 가짜 시계 — 테스트끼리 공유
# 이름 규칙: 단위 = <대상>.Tests · 통합 = <대상>.IntegrationTests
tests/Directory.Build.props — 루트 것을 이어 받기
<Project>
<!-- 가장 가까운 파일 하나만 자동으로 읽히므로 루트 것을 직접 가져옵니다 -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Build.props', '$(MSBuildThisFileDirectory)../'))" />
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<!-- 이름이 Tests 로 끝나는 프로젝트만 — Shop.Testing 같은 도우미 classlib 은 제외 -->
<PropertyGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<OutputType>Exe</OutputType> <!-- xUnit v3 테스트 프로젝트는 실행 파일 -->
</PropertyGroup>
<ItemGroup Condition="$(MSBuildProjectName.EndsWith('Tests'))">
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<Using Include="Xunit" />
</ItemGroup>
</Project>
통합 테스트 위치와 공유 픽스처 🟡 중급
// Shop.Api.IntegrationTests/ShopApiFactory.cs
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
public sealed class ShopApiFactory : WebApplicationFactory<Program>
{
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.UseEnvironment("Testing");
builder.ConfigureTestServices(services => // 앱 등록이 끝난 뒤 실행
{
services.RemoveAll<IPaymentGateway>();
services.AddSingleton<IPaymentGateway, FakePaymentGateway>();
});
}
}
// Shop.Api.IntegrationTests/Orders/PlaceOrderTests.cs
using System.Net;
using System.Net.Http.Json;
public sealed class PlaceOrderTests(ShopApiFactory factory)
: IClassFixture<ShopApiFactory> // 이 클래스의 테스트가 앱 하나를 공유
{
[Fact]
public async Task 주문하면_201()
{
var client = factory.CreateClient();
var res = await client.PostAsJsonAsync("/orders",
new { item = "pen", quantity = 2 },
TestContext.Current.CancellationToken); // xUnit v3
Assert.Equal(HttpStatusCode.Created, res.StatusCode);
}
}
픽스처를 얼마나 넓게 공유하나
| xUnit 장치 | 공유 범위 · 쓰는 곳 |
|---|---|
IClassFixture<T> | 테스트 클래스 하나 — 기본값 |
ICollectionFixture<T> + [CollectionDefinition] | 같은 컬렉션의 여러 클래스 — DB 컨테이너처럼 비싼 것 (컬렉션 안은 순차 실행) |
| 어셈블리 픽스처 (xUnit v3) | 테스트 프로젝트 전체에 하나 |
Shop.Testing classlib | 여러 테스트 프로젝트가 같은 빌더 · 가짜를 씀 |
WebApplicationFactory<Program> 는 Program 이 보여야 합니다.
최상위 문 앱의 Program 은 internal 로 만들어지므로 예전에는 Program.cs 끝에 public partial class Program; 한 줄이 필요했고,
.NET 10 에서는 소스 생성기가 대신 만들어 줍니다. 한 줄을 남겨 둬도 문제는 없습니다.
Microsoft.AspNetCore.Mvc.Testing 패키지를 추가합니다.
운영 프로젝트가 테스트 프로젝트를 참조하는 일은 없어야 합니다 — 화살표는 tests → src 한 방향입니다.
.Tests)와 통합(.IntegrationTests)은 프로젝트로 나눕니다.
공통 설정은 tests/Directory.Build.props 가 루트 것을 Import 해서 이어 받습니다.WPF · WinUI · MAUI — MVVM 폴더와 Host 빌더
데스크톱 · 모바일 앱도 뼈대는 같습니다. View 는 화면, ViewModel 은 화면의 상태와 명령, Service 는 바깥 세계. 바인딩과 MVVM 자체는 데스크탑 앱 탭 DESK 03 에 있으니, 여기서는 폴더를 어떻게 나누고 DI 를 어디서 조립하는지와 화면이 많아질 때 무엇으로 넘어가는지를 봅니다.
WPF 기본 — 역할별 폴더
Shop.Desktop/ # WPF · net10.0-windows
├─ App.xaml · App.xaml.cs # Host 빌더 · DI · 첫 창 띄우기
├─ Views/ MainWindow.xaml · OrderListView.xaml
├─ ViewModels/ MainViewModel.cs · OrderListViewModel.cs
├─ Models/ Order.cs # 화면과 무관한 데이터
├─ Services/ IOrderService.cs · DialogService.cs
├─ Converters/ BoolToVisibilityConverter.cs
└─ Resources/
├─ Styles/ Colors.xaml · Buttons.xaml # App.xaml 에서 병합
└─ Images/
화면 15개를 넘으면 — 기능별 + Core 분리
src/
├─ Shop.Desktop/
│ ├─ App.xaml(.cs)
│ ├─ Features/
│ │ ├─ Orders/ OrderListView.xaml · OrderListViewModel.cs
│ │ └─ Settings/ SettingsView.xaml · SettingsViewModel.cs
│ └─ Resources/Styles/
└─ Shop.Core/ # net10.0 — WPF 참조 없음
├─ Models/ · Services/ # 서버 · 콘솔 도구와 공유 가능
└─ (ViewModel 도 여기에 두면 UI 없이 테스트됨)
System.Windows 를 몰라야 합니다. 그래야 Core 로 옮겨 UI 없이 단위 테스트하고,
WPF → WinUI · MAUI 로 갈아탈 때도 ViewModel 을 그대로 씁니다.
Host 빌더로 DI 조립 · CommunityToolkit.Mvvm 🟡 중급
// App.xaml.cs — App.xaml 의 StartupUri 는 지웁니다
using System.Windows;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public partial class App : Application
{
private IHost? _host;
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
var builder = Host.CreateApplicationBuilder(e.Args);
builder.Services.AddSingleton<IOrderService, OrderService>();
builder.Services.AddTransient<MainViewModel>();
builder.Services.AddTransient<MainWindow>(); // 생성자에서 MainViewModel 을 받음
_host = builder.Build();
_host.Services.GetRequiredService<MainWindow>().Show();
}
protected override void OnExit(ExitEventArgs e)
{
_host?.Dispose(); // 싱글턴 서비스의 Dispose 호출
base.OnExit(e);
}
}
// ViewModels/MainViewModel.cs
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
public partial class MainViewModel(IOrderService orders) : ObservableObject
{
[ObservableProperty]
private string _keyword = ""; // → Keyword 속성 + 변경 알림 생성
public ObservableCollection<Order> Items { get; } = [];
[RelayCommand] // → SearchCommand 생성
private async Task SearchAsync()
{
Items.Clear();
foreach (var o in await orders.SearchAsync(Keyword))
Items.Add(o);
}
}
| 앱 | 구조에서 다른 점 |
|---|---|
| WPF | net10.0-windows + UseWPF. 루트 props 의 net10.0 은 csproj 에서 덮어씀 |
| WinUI 3 | Windows App SDK · Package.appxmanifest · Assets/. 첫 창은 OnLaunched |
| MAUI | MauiProgram.cs 가 Host 자리 · Platforms/(플랫폼별 코드) · Resources/(Fonts · Images · Styles) |
Unity 프로젝트 — _Project 폴더와 asmdef 경계
Unity 는 솔루션 · csproj 를 에디터가 만들어 냅니다 — 손으로 고쳐도 다시 덮어씁니다. 그래서 Unity 에서 프로젝트 경계 역할을 하는 것은 Assembly Definition(asmdef) 입니다. 기본 폴더 규칙과 asmdef 의 첫걸음은 Unity 실전 탭 GAME 01 에 있으니, 여기서는 규모가 커질 때의 배치 · 컴파일 범위 · 서드파티 격리를 봅니다.
영역마다 asmdef 하나
Assets/
├─ _Project/ # 우리 것 — 밑줄로 맨 위에
│ ├─ Scripts/
│ │ ├─ Core/ Game.Core.asmdef # 이벤트 · 유틸 — 참조 0
│ │ ├─ Gameplay/ Game.Gameplay.asmdef # → Core
│ │ ├─ UI/ Game.UI.asmdef # → Gameplay · Core
│ │ └─ Editor/ Game.Editor.asmdef # 에디터 전용
│ ├─ Tests/
│ │ ├─ EditMode/ Game.Tests.EditMode.asmdef
│ │ └─ PlayMode/ Game.Tests.PlayMode.asmdef
│ ├─ Art/ · Audio/ · Prefabs/ · Scenes/ · Settings/
│ └─ Addressables/ # 묶어서 내려받을 에셋 (선택)
├─ ThirdParty/ # 에셋스토어 원본 — 수정 금지
└─ Plugins/ # 네이티브 dll · SDK
스크립트 1,000개를 넘으면 — 기능별 패키지 모양
_Project/Features/
├─ Inventory/
│ ├─ Runtime/ Game.Inventory.asmdef
│ ├─ Editor/ Game.Inventory.Editor.asmdef
│ └─ Tests/ Game.Inventory.Tests.asmdef
└─ Quest/
├─ Runtime/ · Editor/ · Tests/
# Unity 패키지 레이아웃(Runtime · Editor · Tests)과 같은 모양
# → 나중에 사내 UPM 패키지로 떼어 내기 쉽습니다
Plugins/ 안이면 firstpass)로 컴파일되므로 우리 asmdef 에서 안 보입니다.
그 폴더에 asmdef 를 하나 만들어 주거나 업체가 asmdef 를 제공하는 버전을 쓰세요.
에디터 · 테스트 asmdef 와 Addressables 그룹 🟡 중급
// Scripts/Editor/Game.Editor.asmdef — 빌드에 안 들어감
{
"name": "Game.Editor",
"rootNamespace": "Game.Editor",
"references": ["Game.Gameplay", "Game.Core"],
"includePlatforms": ["Editor"],
"autoReferenced": false
}
// Tests/EditMode/Game.Tests.EditMode.asmdef — Test Runner 가 만드는 모양
{
"name": "Game.Tests.EditMode",
"references": ["Game.Gameplay", "Game.Core",
"UnityEngine.TestRunner", "UnityEditor.TestRunner"],
"includePlatforms": ["Editor"],
"overrideReferences": true,
"precompiledReferences": ["nunit.framework.dll"],
"defineConstraints": ["UNITY_INCLUDE_TESTS"],
"autoReferenced": false
}
| 규칙 | 이유 |
|---|---|
autoReferenced: false | Assembly-CSharp 가 자동으로 참조하지 않게 — 의존을 명시적으로만 |
| 순환 금지 | asmdef 끼리 순환 참조는 불가능합니다. 서로 필요하면 인터페이스를 Core 로 |
| ThirdParty 는 원본 그대로 | 업데이트 때 덮어써지므로 우리 수정은 래퍼(어댑터)로 우리 asmdef 안에 |
Resources/ 최소화 | 그 안의 에셋은 쓰든 안 쓰든 빌드에 전부 들어갑니다 — 동적 로딩은 Addressables 로 |
_Project/ 아래 영역(나중엔 기능)마다 Runtime · Editor · Tests asmdef 를 두고,
자주 고치는 코드를 의존 그래프의 잎에 두면 재컴파일 범위가 줄어듭니다.Blazor · Aspire — 컴포넌트 폴더와 AppHost 배치
Blazor 템플릿의 파일 하나하나는 Blazor 탭 BLAZOR 02, Aspire 의 동작과 코드는 BLAZOR 11 에 있습니다. 여기서는 컴포넌트가 백 개를 넘을 때 폴더를 어떻게 나누는지와, 여러 서비스를 한 솔루션에서 Aspire 로 묶을 때 AppHost · ServiceDefaults 를 어디에 두고 누가 누구를 참조하는지만 봅니다.
Blazor — Pages 를 기능별로, 공용 컴포넌트는 RCL 로
Shop.Web/
├─ Program.cs
├─ Components/
│ ├─ App.razor · Routes.razor · _Imports.razor
│ ├─ Layout/ MainLayout.razor · NavMenu.razor
│ ├─ Pages/
│ │ ├─ Orders/
│ │ │ ├─ OrderList.razor # @page "/orders"
│ │ │ ├─ OrderList.razor.cs # 코드 비하인드 (partial class)
│ │ │ └─ OrderList.razor.css # CSS 격리 — 이 컴포넌트에만
│ │ └─ Members/
│ └─ Shared/ Pager.razor · ConfirmDialog.razor
└─ wwwroot/
Shop.Web.Client/ # 렌더 모드 Auto · WebAssembly 일 때만 (BLAZOR 02)
Shop.UI/ # Razor 클래스 라이브러리 — 앱 2개가 같은 컴포넌트를 쓸 때
Aspire — AppHost 와 ServiceDefaults 는 형제 프로젝트
src/
├─ Shop.AppHost/ # 오케스트레이션 — 개발 때 실행, 배포 안 됨
│ └─ AppHost.cs # (템플릿에 따라 Program.cs)
├─ Shop.ServiceDefaults/ # OTel · 헬스체크 · 재시도 · 서비스 디스커버리
├─ Shop.Api/ # → ServiceDefaults
├─ Shop.Web/ # → ServiceDefaults (Blazor)
└─ Shop.Worker/ # → ServiceDefaults
# 참조 방향: AppHost → 서비스들 → ServiceDefaults
# Domain · Application 같은 classlib 은 ServiceDefaults 를 참조하지 않음
docker compose 로 충분합니다(TOOL 10).
AppHost 한 파일이 솔루션 지도 🟡 중급
// Shop.AppHost/AppHost.cs — Aspire.Hosting.PostgreSQL · Redis 패키지
var builder = DistributedApplication.CreateBuilder(args);
var db = builder.AddPostgres("pg").AddDatabase("shopdb");
var cache = builder.AddRedis("cache");
var api = builder.AddProject<Projects.Shop_Api>("api")
.WithReference(db).WaitFor(db)
.WithReference(cache);
builder.AddProject<Projects.Shop_Web>("web")
.WithExternalHttpEndpoints()
.WithReference(api); // Web 은 "http://api" 로 부름
builder.AddProject<Projects.Shop_Worker>("worker")
.WithReference(db);
builder.Build().Run();
| 프로젝트 | 참조하는 것 | 두지 않는 것 |
|---|---|---|
AppHost | 실행할 서비스 프로젝트들 (Projects.* 타입이 생김) | 업무 코드 · 공용 classlib |
ServiceDefaults | OTel · 헬스체크 패키지 | 업무 코드 · 설정 값 |
| 서비스 (Api · Web · Worker) | ServiceDefaults · 자기 계층 프로젝트 | AppHost · 다른 서비스 프로젝트 |
Blazor .Client | 브라우저에서 돌 컴포넌트 · 공용 DTO | DB · 서버 전용 서비스 |
Components/Pages/<기능>/ 로 나누고 두 앱이 쓰는 컴포넌트는 RCL 로 뺍니다.
Aspire 는 AppHost(띄우기) · ServiceDefaults(공통 관측성) 를 서비스 프로젝트의 형제로 두고, 참조는 AppHost → 서비스 → ServiceDefaults 한 방향입니다.워커 · 배치 — 실행 프로젝트 여럿을 한 솔루션에
API 옆에 큐를 소비하는 워커, 밤에 도는 정산 배치가 붙는 순간 실행 파일이 셋이 됩니다. .NET 에서 실행 프로젝트 하나 = 배포 단위 하나이고, 업무 코드는 classlib 으로 빼서 셋이 같이 씁니다. Worker Service 작성법은 배치 탭 BATCH 02, Windows 서비스 · systemd 로 올리는 법은 서버 구축 탭 HOST 04 · 05.
실행 3 + 공유 라이브러리 2
Shop/
├─ Shop.slnx
├─ src/
│ ├─ Shop.Core/ # 업무 규칙 · 공용 — classlib
│ ├─ Shop.Infrastructure/ # DB · 파일 · 외부 API — classlib
│ ├─ Shop.Api/ # 실행 ① 웹 — Sdk.Web
│ │ └─ appsettings.json
│ ├─ Shop.Worker/ # 실행 ② 상주 — Sdk.Worker · BackgroundService
│ │ └─ appsettings.json
│ └─ Shop.Batch/ # 실행 ③ 한 번 돌고 끝 — console
│ └─ appsettings.json
└─ tests/
# 설정 파일은 실행 프로젝트마다 — 라이브러리에는 두지 않습니다
규칙
| 규칙 | 이유 |
|---|---|
| 실행 프로젝트끼리 참조 금지 | Worker 가 Api 를 참조하면 ASP.NET 전체와 Api 의 Program 까지 딸려 옵니다. 공유할 것은 Core 로 |
| DI 등록도 공유 | AddInfrastructure() 를 셋이 같이 호출 — 등록이 갈라지지 않음 |
| 실행 프로젝트는 얇게 | Program.cs · 호스트 설정 · 진입 클래스만. 로직은 classlib 에 |
| 배포 단위 = 실행 프로젝트 | 각자 dotnet publish → 이미지 · 서비스 · 예약 작업 하나씩 |
세 Program.cs 가 같은 등록을 재사용 🟡 중급
// Shop.Worker/Program.cs — 상주 프로세스
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration); // Api 와 같은 확장 메서드
builder.Services.AddHostedService<OutboxRelayWorker>();
// Windows 서비스로 띄울 때 (Microsoft.Extensions.Hosting.WindowsServices 패키지)
builder.Services.AddWindowsService();
builder.Build().Run();
// Shop.Batch/Program.cs — 한 번 돌고 종료 코드로 결과를 알림
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddTransient<DailySettlementJob>();
using var host = builder.Build();
var date = args.Length > 0
? DateOnly.Parse(args[0]) // 예: Shop.Batch 2026-10-01
: DateOnly.FromDateTime(DateTime.Today.AddDays(-1)); // 기본은 어제
var job = host.Services.GetRequiredService<DailySettlementJob>();
return await job.RunAsync(date, CancellationToken.None); // Task<int> — 0 성공 · 1 실패
언제 솔루션을 나누나
| 상황 | 구조 |
|---|---|
| 같은 팀 · 같은 DB · 같이 배포해도 됨 | 한 솔루션에 실행 프로젝트 여럿 (이 섹션) |
| 배포 주기만 다름 | 한 솔루션 유지, CI 에서 바뀐 실행 프로젝트만 빌드 · 배포 |
| 팀이 다르고 DB 도 따로 | 솔루션 · 저장소 분리 — 공유 코드는 사내 NuGet (STR 09) |
| 공유 코드가 계속 같이 바뀜 | 나누지 마세요 — 패키지 버전 맞추기가 일이 됩니다 |
라이브러리 · NuGet — 공개 API · internal · 다중 타깃
앱은 아무 클래스나 public 이어도 사고가 작지만, 라이브러리는 public 이 곧 약속입니다. 한 번 내보낸 public 멤버는 쓰는 쪽을 깨지 않고는 지울 수 없습니다. 패키지 만들기 · 올리기 · SemVer 는 도구 탭 TOOL 09 에 있으니 여기서는 무엇을 공개하고 그 경계를 어떻게 지키는지를 봅니다.
라이브러리 저장소
Acme.Text/
├─ Acme.Text.slnx
├─ Directory.Build.props # 패키지 메타데이터 공통 (Authors · License · Repo)
├─ README.md # 패키지 페이지에 그대로 (PackageReadmeFile)
├─ src/Acme.Text/
│ ├─ Acme.Text.csproj
│ ├─ PublicAPI.Shipped.txt # 이미 내보낸 공개 API 목록
│ ├─ PublicAPI.Unshipped.txt # 이번 버전에 새로 추가하는 것
│ ├─ Slug.cs # public — 약속
│ └─ Internal/ # internal — 언제든 바꿀 수 있는 것
├─ tests/Acme.Text.Tests/
└─ samples/Acme.Text.Sample/ # 사용 예 — IsPackable=false
| 규칙 | 이유 |
|---|---|
기본은 internal sealed | 공개는 꼭 필요한 타입만 — 나중에 여는 건 쉽고 닫는 건 깨는 변경 |
InternalsVisibleTo 는 테스트만 | 다른 운영 어셈블리에 열면 internal 이 사실상 public 이 됩니다 |
| 의존 패키지 최소 | 내 패키지의 의존은 쓰는 쪽의 의존이 됩니다 — 버전 충돌의 씨앗 |
csproj — 공개 경계를 적는 계약서
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<LangVersion>latest</LangVersion> <!-- netstandard2.0 의 기본 C# 7.3 을 올림 -->
<Nullable>enable</Nullable>
<IsPackable>true</IsPackable>
<Description>한글 슬러그 · 문자열 유틸</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- 직전 버전과 비교해 깨지는 변경이면 pack 실패 -->
<EnablePackageValidation>true</EnablePackageValidation>
<PackageValidationBaselineVersion>1.2.0</PackageValidationBaselineVersion>
</PropertyGroup>
<ItemGroup>
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
<InternalsVisibleTo Include="Acme.Text.Tests" />
<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers"
PrivateAssets="all" />
</ItemGroup>
</Project>
TargetFramework(단수)를 넣었다면 다중 타깃 라이브러리 csproj 에서
<TargetFramework></TargetFramework> 로 비워야 합니다. 단수가 값을 가지고 있으면 그 하나만 빌드됩니다.
다중 타깃 코드와 PublicAPI 파일 🟡 중급
// src/Acme.Text/Slug.cs
using System.Text.RegularExpressions;
namespace Acme.Text;
/// <summary>제목을 URL 조각으로 바꿉니다.</summary>
public static class Slug
{
public static string From(string title)
{
#if NET8_0_OR_GREATER
ArgumentNullException.ThrowIfNull(title); // .NET 6+ 에만 있는 API
#else
if (title is null) throw new ArgumentNullException(nameof(title));
#endif
return Internal.Whitespace.Collapse(title.Trim().ToLowerInvariant());
}
}
// src/Acme.Text/Internal/Whitespace.cs — 바꿔도 아무도 안 깨짐
namespace Acme.Text.Internal;
internal static class Whitespace
{
internal static string Collapse(string s) => Regex.Replace(s, @"\s+", "-");
}
# PublicAPI.Shipped.txt — 1.2.0 에 이미 나간 것
#nullable enable
Acme.Text.Slug
static Acme.Text.Slug.From(string! title) -> string!
# public 멤버를 새로 만들고 여기 안 적으면 → RS0016 경고
# 코드 수정(전구)으로 Unshipped.txt 에 추가 → PR diff 에 그대로 보임
# 릴리스할 때 Unshipped 의 줄을 Shipped 로 옮깁니다
| 타깃 | 언제 |
|---|---|
net10.0 하나 | 사내 .NET 10 앱들만 씀 — 가장 단순 |
net8.0;net10.0 | LTS 두 개를 다 지원 — 새 API 는 #if NET10_0_OR_GREATER |
+ netstandard2.0 | .NET Framework 4.x · Unity 에서도 써야 할 때 |
Shipped.txt 의 한 줄 형식은 분석기가 만들어 주는 대로 두면 됩니다 — 손으로 쓰지 말고 코드 수정으로 채우세요.
한 장 요약 — 유형 · 규모별 권장 구조와 규칙 강제
구조는 문서로 정하면 석 달 안에 무너집니다. 리뷰어가 매번 "여기서 DbContext 쓰면 안 돼요"라고 말해야 하기 때문입니다. 마지막 섹션은 앞의 아홉 섹션을 표 한 장으로 줄이고, 그 규칙을 프로젝트 참조 · 아키텍처 테스트 · 분석기로 빌드가 지키게 만듭니다.
| 유형 · 규모 | 권장 구조 | 경계를 지키는 것 | 다음 단계 신호 |
|---|---|---|---|
| 학습 · 도구 · PoC | 프로젝트 1 · 폴더 몇 개 · 솔루션 선택 | 없음 | 테스트 · 두 번째 실행 파일 |
| ASP.NET API · 1~5명 | Api 1 + tests · Features/<기능>/ (STR 02) | NetArchTest 2~3개 | 업무 규칙이 복잡해짐 |
| 핵심 업무 도메인 | Domain · Application · Infrastructure · Api (STR 03), 안쪽은 기능별 폴더 | 프로젝트 참조 방향 | 팀이 둘로 갈라짐 |
| API + 워커 + 배치 | 실행 프로젝트 n + 공유 classlib, 한 솔루션 (STR 08) | 실행끼리 참조 금지 | 배포 · 팀이 갈라짐 |
| 서비스 3개+ 로컬 개발 | + AppHost · ServiceDefaults (STR 07) | AppHost 만 서비스를 앎 | — |
| Blazor | Components/Pages/<기능>/ · 공용은 RCL (STR 07) | .Client 에 서버 코드 금지 | 앱 2개가 컴포넌트 공유 |
| WPF · WinUI · MAUI | MVVM 역할 폴더 → 기능 폴더 + Core classlib (STR 05) | ViewModel 이 UI 타입을 모름 | 화면 15개+ |
| Unity | _Project/ + 영역 · 기능별 asmdef (STR 06) | asmdef 참조 | 저장 후 대기가 길어짐 |
| 라이브러리 · NuGet | src · tests · samples, internal 기본 (STR 09) | PublicApiAnalyzers · 패키지 검증 | — |
① 아키텍처 테스트 — NetArchTest 🟡 중급
// tests/Shop.ArchitectureTests/ArchitectureTests.cs — NetArchTest.Rules 패키지
using System.Reflection;
using NetArchTest.Rules;
public sealed class ArchitectureTests
{
private static readonly Assembly Domain = typeof(Shop.Domain.Orders.Order).Assembly;
private static readonly Assembly Api = typeof(Program).Assembly;
[Fact]
public void 도메인은_EF_와_ASPNET_을_모른다()
{
var r = Types.InAssembly(Domain)
.ShouldNot()
.HaveDependencyOnAny("Microsoft.EntityFrameworkCore", "Microsoft.AspNetCore")
.GetResult();
Assert.True(r.IsSuccessful, string.Join(", ", r.FailingTypeNames ?? []));
}
[Fact]
public void 주문_기능은_회원_기능_내부를_직접_쓰지_않는다()
{
var r = Types.InAssembly(Api)
.That().ResideInNamespace("Shop.Api.Features.Orders")
.ShouldNot().HaveDependencyOn("Shop.Api.Features.Members")
.GetResult();
Assert.True(r.IsSuccessful, string.Join(", ", r.FailingTypeNames ?? []));
}
[Fact]
public void 엔드포인트는_DbContext_를_직접_쓰지_않는다() // STR 03 의 Clean 구조일 때
{
var r = Types.InAssembly(Api)
.That().ResideInNamespace("Shop.Api.Features")
.ShouldNot().HaveDependencyOn("Shop.Infrastructure.Persistence")
.GetResult();
Assert.True(r.IsSuccessful, string.Join(", ", r.FailingTypeNames ?? []));
}
}
AppDbContext 를 직접 쓰는 구조라면 빼야 합니다.
② 분석기 — .editorconfig 와 금지 API
# .editorconfig (저장소 루트)
root = true
[*.cs]
# 네임스페이스 = 폴더 경로 (빌드에서 보려면 EnforceCodeStyleInBuild=true)
dotnet_style_namespace_match_folder = true
dotnet_diagnostic.IDE0130.severity = warning
# 파일 범위 네임스페이스로 통일
csharp_style_namespace_declarations = file_scoped:warning
dotnet_diagnostic.IDE0161.severity = warning
# 금지 API — BannedApiAnalyzers + BannedSymbols.txt
dotnet_diagnostic.RS0030.severity = error
# BannedSymbols.txt — Directory.Build.props 에서 AdditionalFiles 로 연결
P:System.DateTime.Now;TimeProvider 를 주입받아 쓰세요
T:System.Data.SqlClient.SqlConnection;Microsoft.Data.SqlClient 를 쓰세요
③ 프로젝트 참조 — 가장 싸고 가장 강한 강제
<!-- Shop.Api.csproj — 직접 참조한 프로젝트의 타입만 쓰게 -->
<PropertyGroup>
<DisableTransitiveProjectReferences>true</DisableTransitiveProjectReferences>
</PropertyGroup>
| 강제 수단 | 무엇을 막나 | 비용 |
|---|---|---|
| 프로젝트 분리 · 참조 | 참조 안 한 프로젝트의 타입 사용 · 순환 (컴파일 오류) | 프로젝트 관리 비용 |
internal | 어셈블리 밖에서의 접근 | 0 — InternalsVisibleTo 남용이면 무력화 |
| NetArchTest · ArchUnitNET | 네임스페이스 · 이름 · 의존 규칙 무엇이든 (한 어셈블리 안 기능끼리도) | 테스트 파일 하나 · 규칙 유지 |
| .editorconfig · 분석기 | 폴더-네임스페이스 어긋남 · 금지 API · 스타일 | 기존 코드 정리 비용 |
| asmdef (Unity) | 참조 안 한 어셈블리 · 순환 | asmdef 파일 관리 |
| PublicApiAnalyzers · 패키지 검증 | 모르게 바뀌는 공개 API | 릴리스마다 파일 정리 |