한 주 동안 .NET 생태계에서 있었던 주요 이슈와 아티클, 기술 트렌드를 정리해 소개합니다.
Guid.CreateVersion7은 SQL Server에서 순차 GUID가 아니다
- 저자: Bart Wullems
- 태그: dotnet9 #guid #sqlserver #performance
주요 내용
- 클러스터형 인덱스 키가 랜덤 GUID이면 삽입이 임의 페이지에 떨어져 페이지 분할과 인덱스 단편화가 발생
- SQL Server는
uniqueidentifier를 왼쪽부터 비교하지 않고 바이트 10-15, 8-9, 6-7, 4-5, 0-3 순으로 비교. UUIDv7의 48비트 타임스탬프가 비교 우선순위가 가장 낮은 위치(바이트 0-3, 4-5)에 놓여 랜덤 GUID처럼 동작 - 대안 3가지: EF Core SQL Server 공급자의 기본
SequentialGuidValueGenerator,NEWSEQUENTIALID()기본값, RT.Comb·SequentialGuid(GuidV7.NewSqlGuid()) 같은 라이브러리 - 바이트를 재배치해 v7 타임스탬프를 SQL Server 정렬 우선 위치로 옮기는
CreateVersion7ForSqlServer()구현.SqlGuid비교로 1ms 간격 5,000회 삽입을 모의한 결과 끝에 추가되는 비율은NewGuid0.1%,CreateVersion70.2%, 재배치 버전 100.0% - 생성 방식을 바꿔도 기존 단편화는 남으므로
sys.dm_db_index_physical_stats조회와ALTER INDEX ... REBUILD필요. 재배치 값은 RFC 9562 레이아웃이 아님
linker substitution으로 .NET NativeAOT 앱의 불필요한 의존성 제거하기
- 저자: Andrew Lock
- 태그: nativeaot #trimming #ilcompiler #sizoscope
주요 내용
- Datadog
dd-dotnetCLI에서 ClrMD 3.2.527301을 4.1.745802로, .NET 8을 .NET 10으로 올린 뒤 바이너리가 win-x64 3.519MB에서 3.956MB(+12.42%)로 증가 - Sizoscope CLI(
IlcGenerateMstatFile,IlcGenerateDgmlFile)로 분석한 결과,SymbolServer생성자의IsSymweb(호스트 문자열 비교)가 런타임 비교여서InteractiveBrowserCredential이 도달 가능으로 남고 Azure.Identity·MSAL·System.Text.Json이 따라 포함됨 ILLink.Substitutions.xml에서get_IsSymweb()를body="stub" value="false"로 치환. 프로젝트 임베드 방식은 NativeAOT에서 동작하지 않아<IlcArg Include="--substitution:...">로 전달- 치환 후 win-x64 3.956MB에서 2.949MB(-25.43%), linux 계열 약 -22%. 원래 .NET 8/ClrMD 3.x 대비 -13.9~16.2%이며 감소 대부분은 .NET 10 업그레이드 효과
- 분석에 Claude Code를 사용했고 LLM 결과는 검증이 필요하다는 단서를 제시
발동하지 않은 타임아웃
- 저자: Josef Ottosson
- 태그: csharp #cancellationtoken #polly #httpclient
주요 내용
- Polly 정책으로 감싼
HttpClient.SendAsync에서 CancellationToken이 없을 때 5초 타임아웃용CancellationTokenSource를 만드는 코드에 버그 2개 - 버그 1:
using var의 범위가 람다가 아니라if블록이라 요청을 보내기 전에 CTS가 Dispose되고 타이머도 함께 해제됨. 남는 타임아웃은HttpClient.Timeout기본값 100초뿐 - 버그 2: 람다가 메서드 매개변수
cancellationToken을 캡처해 재할당하므로 Polly 재시도 시== CancellationToken.None조건이 거짓이 되어 이미 Dispose된 CTS의 토큰을 재사용.using var만 밖으로 옮겨서는 해결되지 않음 - 재현 코드: 타임아웃 1초,
Task.Delay3초가Completed after 3.0s로 끝남. 수정 후TaskCanceledException after 1.0s - 수정: 시도마다 지역 변수
timeoutCts와token을 만들고TimeProvider를 CTS에 전달해FakeTimeProvider로 5초를 기다리지 않고 테스트
C# 14가 조용히 EF Core 쿼리를 깨뜨렸다: 범인은 array.Contains()
- 저자: Owais
- 태그: #csharp14 #efcore dotnet10 #breakingchange #span
주요 내용
- 프로젝트를 net8.0에서 net10.0으로 올린 뒤
filter.Statuses.Contains(o.Status)(string)가InvalidOperationException(내부 예외:ReadOnlySpan<string>이FuncCallInstruction<T0,TRet>제약 위반)을 던짐 - 원인은 C# 14 first-class span 기능으로, 배열 receiver의
Contains가Enumerable.Contains가 아니라MemoryExtensions.Contains(ReadOnlySpan<T>, T)로 바인딩되어 식 트리에 배열→span 변환이 들어감 - EF Core가 파라미터 추출 단계에서 해당 하위 식을 식 인터프리터로 평가하는데 ref struct는 제네릭 인자가 될 수 없어 실패
- 수정안:
List<T>receiver,Enumerable.Contains(arr, x),AsEnumerable()캐스트,LangVersion13 고정, EF Core 패치 업그레이드(EF Core 10, 8.0·9.0 서비싱 백포트) - 기본 C# 버전은 SDK가 아니라 프로젝트 TargetFramework로 결정되어 net8.0 데이터 계층은 정상이고 net10.0 서비스 계층에서만 실패. 위험 조건은 T receiver, IQueryable 내부, C# 14 3가지
Polly v8은 처■■터 다시 쓰였다: 실제로 무엇이 바뀌었나
- 저자: Adrian Bailador
- 태그: #polly #resilience dotnet #testing
주요 내용
- v7의
Policy.Handle<T>().WaitAndRetryAsync(...)체인(IAsyncPolicy)이 v8에서는ResiliencePipelineBuilder로 만든ResiliencePipeline으로 대체.Policy.Handle<T>()는 각 전략 옵션의ShouldHandle(PredicateBuilder)로 이동 - 내장 전략 6종: Retry, Circuit Breaker, Fallback, Hedging(반응형)과 Timeout, Rate Limiter(선제형). Rate Limiter는
System.Threading.RateLimiting의 얇은 래퍼 ResiliencePipelineBuilder.TimeProvider에FakeTimeProvider를 넣어 재시도 지연과 서킷 브레이커BreakDuration을 실제 대기 없이 검증(2초 지연 테스트 63ms)- 전략 순서가 의미를 바꿈: Retry 바깥/Timeout 안쪽은 시도별 타임아웃, Timeout 바깥/Retry 안쪽은 전체 데드라인. 30ms 내부 타임아웃은 3회 시도 모두 완료(215ms), 70ms 외부 데드라인은 5회 재시도를 2~3회에서 중단
AddStandardResilienceHandler()가 전체 타임아웃, 재시도, 서킷 브레이커, 시도별 타임아웃을 미리 조합해HttpClient에 연결
Cloudflare와 nginx 뒤에서 모든 사용자가 같은 IP였다
- 저자: Vasyl
- 태그: aspnetcore #ratelimiting #forwardedheaders #security
주요 내용
- 로그인 제한(분당 5회)이 9회 시도에도 차단되지 않은 원인은
UseRateLimiter()가UseRouting()보다 앞에 있어 엔드포인트별 제한이 적용되지 않은 것. 순서를 바꿔 해결 - nginx의
$proxy_add_x_forwarded_for가X-Forwarded-For: 203.0.113.42, 127.0.0.1을 만들고 기본 설정은 마지막 항목(127.0.0.1)만 읽어 전 사용자가 같은 IP로 집계됨 ForwardLimit = null로 전체 목록을 읽고KnownIPNetworks를 loopback·사설망(127.0.0.0/8, ::1/128, 10/8, 172.16/12, 192.168/16)으로 한정. 신뢰 범위를 비우면 클라이언트가 헤더에 가짜 IP를 넣어 카운터를 우회 가능- 로그인 제한이 IP별이 아닌 사이트 전역 카운터(분당 10회)여서 오답 10회로 전원의 로그인과 비밀번호 재설정을 막을 수 있었고 IP별 파티션으로 변경
- 제한이 동작하자 통합 테스트가 실패해 제한값을 설정화하고, 한도 초과 시 429 반환 및 사용자별 카운터 분리를 검증하는 테스트 2개 추가
가벼운 읽을거리
후보 항목 중 이슈로 선정되지 않은 가벼운 읽을거리들
내 오픈소스 프로젝트: Netpack
- C#으로 작성한 웹 번들러. NativeAOT 단일 바이너리 CLI(
npx netpack bundle src/index.html --minify),NetPack.BuildMSBuild 태스크,NetPack.Core라이브러리로 사용 - 정규식 기반 가짜 번들러에서 Acornima·AngleSharp 기반 파싱을 거쳐 자체 tokenizer·parser·printer·minifier·tree-shaker 구현. 2026년 7월 10일 0.1.0에서 8월 19일 0.8.2까지 15개 버전
- 0.6.0 기준 라이브러리 빌드 141ms 대 esbuild 150ms, 대규모는 약 595ms로 rspack·Vite보다 느림. 1.0 이전이며 프로덕션 비권장, 리플렉션 불가
모든 .NET 라이브러리는 제대로 된 출발이 필요하다
dotnet new install DotNetLibraryPackageTemplates로 설치하는 템플릿. OSS/사내용, 바이너리/소스 전용 조합별 4종과 azdo- 변형- StyleCop·Roslynator·Meziantou·CSharpGuidelinesAnalyzer를 .editorconfig로 전부 활성화하고 Verify로 공개 API를 텍스트 스냅샷으로 저장해 PR diff에서 변경 확인
- NuGet 감사로 취약 의존성이면 restore 실패, GitVersion으로 버전 계산, Fallout 기반 C# 빌드 스크립트. 지난해 글 이후 Nuke에서 Fallout로 이동,
.slnx사용
대규모 실시간 주가 시스템 구축기
- 거래소 코어 → C++ 수집 → Kafka(Protobuf) → Redis Pub/Sub → .NET HostedService → 사용자별 Channel → SSE로 이어지는 파이프라인. 평시 약 1,900 msg/s, 변동성 높은 날은 2~3배
- TPL Dataflow 대신
System.Threading.Channels를 선택하고 용량 100k의BoundedChannel과DropOldest, 사용자 연결마다 별도 Channel 사용 - Pod 6개, Pod당 약 55MB RAM, 피크 CPU 약 20%, Channel 깊이 최대 15. Unleash로 30/50/70/100% 단계 롤아웃
Aspire에 풍부한 터미널 경험 도입
- Aspire 13.5의
WithTerminal()이 프로젝트·실행 파일·컨테이너 리소스에 PTY를 연결하고 대시보드의 콘솔 로그 뷰를 대화형 터미널로 전환. Kitty Graphics Protocol, Sixel 지원 - Aspire 13.6: 대시보드 하단 도킹 터미널, Redis·Mongo·SQL Server·Postgres 등 통합의
WithRepl(),aspire terminal attach,TerminalService의SendTextAsync/WaitForTextAsync로 자동화 - 터미널 구현은 오픈소스 Hex1b 기반이며 관련 API는 실험적(
ASPIRETERMINAL001)
