본문 바로가기
개발2026년 9월 16일8분 읽기

.NET 11 성능 개선 정리 — 탈출 분석 확장, NativeAOT 인터페이스 디스패치, 네이티브 async

YS
김영삼
조회 73
.NET 11 성능 개선 정리 — 탈출 분석 확장, NativeAOT 인터페이스 디스패치, 네이티브 async

마이크로소프트가 .NET 11의 성능 개선 사항을 정리한 연례 장문 포스트를 공개했다. 매년 그렇듯 내용은 방대하지만, 올해의 줄기는 세 가지다. 조건부 탈출 분석(CEA)의 패턴 확장, NativeAOT의 인터페이스 디스패치 개선, 그리고 런타임 수준으로 내려간 async.

요약하면 경계 검사가 사라지고, 할당이 일어나지 않고, 락이 걸리지 않고, 루프가 더 적은 사이클로 돌고, 비교가 상수로 접히고, 중복 검사가 루프 밖으로 끌어올려지고, 명령이 하나로 합쳐지고, 시스템콜이 우회되고, 배열 복사가 SIMD로 넘어간다.

나는 이 시리즈를 매년 기다리는 부류다. 프레임워크 성능 개선 글이 재미있는 이유는, 대부분의 항목이 내가 코드를 한 줄도 안 바꿔도 적용된다는 데 있다. 런타임을 올리는 것만으로 벤치마크가 좋아진다. 물론 공짜처럼 보이는 것에는 늘 조건이 붙는다. 어떤 개선은 특정 코드 모양에서만 발동하고, 그 모양을 알면 코드를 그쪽으로 유도할 수 있다.

탈출 분석 확장 — 할당이 사라지는 조건

탈출 분석은 객체가 메서드 밖으로 새지 않는다는 걸 컴파일러가 증명하면, 힙 대신 스택에 두거나 아예 제거하는 최적화다. .NET 10에서 도입된 조건부 탈출 분석(CEA)은 "어떤 경로에서만 탈출하는" 경우까지 다뤘고, .NET 11은 그 패턴 집합을 넓혔다.

실무에서 가장 크게 체감되는 곳은 열거자(enumerator)다. foreach는 구조체 열거자를 쓰더라도 인터페이스로 박싱되는 순간 할당이 생긴다. 이 할당이 제거되면 뜨거운 루프에서 GC 압력이 눈에 띄게 준다.

// 할당이 사라질 수 있는 모양
static int Sum(List<int> items)
{
    int total = 0;
    foreach (var x in items)   // List<T>.Enumerator (구조체)
        total += x;
    return total;
}

// 할당이 남기 쉬운 모양 — 인터페이스로 받으면 박싱 경로
static int Sum(IEnumerable<int> items)
{
    int total = 0;
    foreach (var x in items)   // IEnumerator<int> 박싱
        total += x;
    return total;
}

// 팁: 뜨거운 경로에서는 구체 타입으로 받고,
//     범용 API는 오버로드를 나눠 두는 편이 낫다.
측정 없이 믿지 말 것 이런 최적화는 코드 모양·인라인 여부·호출 지점에 따라 발동하거나 안 한다. BenchmarkDotNet의 MemoryDiagnoser할당 바이트 수를 직접 확인하는 게 유일하게 신뢰할 방법이다. "이론상 제거될 것"은 성능 보고서에 쓸 문장이 아니다.

NativeAOT — 인터페이스 디스패치와 바이너리 크기

NativeAOT는 인터페이스 디스패치에 공유 디스패치 헬퍼를 쓰도록 바뀌었다. 호출 지점마다 생성되던 코드가 줄어 바이너리 크기가 작아지고, 인터페이스 호출이 많은 워크로드의 처리량이 올라간다. 컨테이너 이미지 크기와 콜드 스타트가 중요한 서버리스·CLI 도구에 직접적인 이득이다.

CEA 확장
열거자 등에서 할당 제거 패턴 증가
공유 디스패치
NativeAOT 인터페이스 호출 크기·처리량 개선
네이티브 async
스택 추적 단순화, 오버헤드 감소
SIMD 확대
배열 복사·비교 경로의 벡터화

런타임 네이티브 async — 스택 추적이 읽힌다

C#의 async/await는 오랫동안 컴파일러가 상태 기계를 생성하는 방식이었다. 동작은 훌륭했지만 두 가지 대가가 있었다. 예외 스택 추적에 컴파일러 생성 프레임이 잔뜩 끼고, 상태 기계 객체 할당이 뒤따랐다. 런타임 수준 async는 이 둘을 개선한다. 디버깅 경험이 좋아지는 쪽이 체감상 더 크다.

서버 애플리케이션에서 예외 로그를 읽어 본 사람이라면 MoveNext로 가득한 스택을 기억할 것이다. 원인 프레임을 찾으려고 눈으로 스크롤하던 그 시간이 줄어든다.

업그레이드 판단 — 무엇을 재고 옮길 것인가

1
현재 병목 확인
성능 개선이 GC 압력에서 오는지, I/O 대기에서 오는지 먼저 본다. 후자라면 런타임 업그레이드 효과는 제한적이다.
2
대표 벤치마크 고정
업그레이드 전후를 비교할 수 있게, 실제 트래픽 모양을 닮은 벤치마크를 먼저 만들어 둔다.
3
할당 지표 중심으로 비교
처리량보다 Gen0 수집 횟수와 할당 바이트가 변화를 더 또렷하게 보여 준다.
4
NativeAOT는 별도 트랙
리플렉션·동적 로딩 의존성이 있으면 전환 비용이 크다. CLI 도구·소형 서비스부터 시도한다.
5
배포는 점진적으로
런타임 메이저 업그레이드는 카나리로 5~10% 트래픽부터. 회귀는 대개 성능이 아니라 동작 차이에서 온다.

출처

자주 묻는 질문

.NET 11로 올리면 코드 수정 없이 빨라지나요?

상당 부분은 그렇습니다. JIT·런타임·라이브러리 수준의 개선은 재컴파일만으로 적용됩니다. 다만 개선 폭은 워크로드에 따라 크게 다르고, I/O 대기가 지배적인 서비스에서는 체감이 작을 수 있습니다. 반드시 자체 벤치마크로 확인하세요.

탈출 분석이 뭔가요?

객체가 생성된 메서드 밖으로 빠져나가지 않는다는 것을 컴파일러가 증명하면, 힙 할당을 스택 할당으로 바꾸거나 아예 제거하는 최적화입니다. .NET 11은 조건부 탈출 분석이 인식하는 패턴을 넓혀 열거자 같은 흔한 경우에서 할당을 더 많이 제거합니다.

할당이 줄었는지 어떻게 확인하나요?

BenchmarkDotNet에 MemoryDiagnoser를 붙이면 호출당 할당 바이트와 GC 수집 횟수를 볼 수 있습니다. 프로덕션에서는 Gen0 수집 빈도와 할당률 카운터를 추적하세요. 최적화 발동 여부는 코드 모양에 따라 달라지므로 측정이 유일한 확인 수단입니다.

NativeAOT를 꼭 써야 하나요?

콜드 스타트와 배포 크기가 중요한 경우에 유리합니다. 반면 리플렉션·런타임 코드 생성·플러그인 동적 로딩에 의존하는 애플리케이션은 전환 비용이 큽니다. CLI 도구나 소형 서비스부터 적용해 보고 확대하는 방식을 권합니다.

런타임 네이티브 async는 기존 async 코드와 호환되나요?

기존 async/await 코드는 그대로 동작합니다. 바뀌는 것은 내부 구현으로, 스택 추적이 간결해지고 오버헤드가 줄어드는 쪽의 개선입니다. 코드 작성 방식을 바꿀 필요는 없습니다.

댓글 0

아직 댓글이 없습니다.
Ctrl+Enter로 등록