콘텐츠 대표 이미지 - .NET 성능 벤치마킹의 기술: BenchmarkDotNet으로 코드의 진짜 속도를 측정하고 최적화하기
C# · 성능 엔지니어링

.NET 성능 벤치마킹의 기술: BenchmarkDotNet으로 코드의 진짜 속도를 측정하고 최적화하기

"빠른 것 같은데요?"는 근거가 아닙니다.
숫자가 근거입니다. 그 숫자를 제대로 뽑는 방법을 이야기합니다.

1. Stopwatch로 잰 성능이 거짓말을 하는 이유

개발자라면 누구나 한 번쯤 이런 코드를 써봤습니다.

var sw = Stopwatch.StartNew();
DoSomething();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);

결과는 매번 다릅니다. 42ms, 17ms, 38ms... 그리고 어느새 우리는 "대충 30ms쯤 되나 보다"라고 믿어버립니다.
문제는 이 숫자가 사실상 측정값이 아니라 소음(noise)이라는 점입니다.

.NET 런타임이 측정을 방해하는 4가지 요인

① JIT 컴파일 지연
.NET의 메서드는 처음 호출되는 순간 IL에서 네이티브 코드로 컴파일됩니다. 첫 호출에는 컴파일 비용이 그대로 포함되죠. 게다가 .NET Core 3.0부터 기본 활성화된 Tiered Compilation은 처음엔 빠르게 컴파일되는 저품질 코드(Tier0)를 만들고, 호출 횟수가 30회를 넘으면 백그라운드에서 최적화된 Tier1 코드로 다시 컴파일합니다. 즉 같은 메서드가 실행 도중에 더 빨라집니다.

② GC의 개입
측정 구간 안에서 Gen0 컬렉션이 한 번 터지면 수십 마이크로초가 그냥 사라집니다. 반대로 운 좋게 GC를 피하면 비현실적으로 빠른 숫자가 나옵니다.

③ CPU 주파수 변동과 스케줄링
Intel Turbo Boost, AMD Precision Boost, 그리고 OS 스레드 스케줄러는 측정 도중에도 계속 상태를 바꿉니다. 노트북에서는 발열로 인한 스로틀링까지 겹칩니다.

④ Dead Code Elimination
가장 무서운 함정입니다. 결과를 사용하지 않는 계산은 JIT가 통째로 삭제할 수 있습니다. 그래서 "0.3 나노초"라는 말도 안 되는 결과가 나옵니다. 실제로는 아무것도 실행되지 않은 것이죠.

현실 사례
문자열 연결 벤치마크에서 a + b가 StringBuilder보다 100배 빠르게 나왔다면, 십중팔구 상수 폴딩(constant folding)으로 컴파일 타임에 결과가 확정되어 버린 것입니다.

2. BenchmarkDotNet: 마이크로벤치마킹의 사실상 표준

BenchmarkDotNet은 .NET 재단(.NET Foundation)이 후원하는 오픈소스 라이브러리로, .NET 런타임 팀과 ASP.NET Core 팀이 자신들의 성능 회귀 테스트에 실제로 사용하는 도구입니다. dotnet/runtime 저장소의 성능 테스트 대부분이 이 위에서 돌아갑니다.

이 도구가 대신 해주는 일

Pilot 단계 — 한 번의 측정이 유의미해지려면 몇 번을 반복해야 하는지 자동 계산합니다.

Warmup 단계 — JIT 컴파일과 Tier1 승격이 끝날 때까지 미리 돌립니다.

Actual 단계 — 통계적으로 충분한 횟수만큼 실제 측정합니다.

Overhead 측정 — 빈 메서드를 호출하는 비용을 따로 재서 결과에서 빼줍니다.

프로세스 격리 — 각 벤치마크를 별도 프로세스에서 실행해 상호 오염을 막습니다.

통계 처리 — 평균, 중앙값, 표준편차, 신뢰구간, 이상치(outlier) 제거까지 자동입니다.

BenchmarkDotNet 실행 파이프라인 Pilot 반복 횟수 탐색 Warmup JIT / Tier1 안정화 Actual 본 측정 수집 Result 통계 · 이상치 제거 측정값 안정화 곡선 (Warmup이 필요한 이유) 느림 빠름 반복 횟수 → Tier1 승격 완료 지점 ← 이 구간 데이터를 쓰면 오차 발생

3. 첫 벤치마크 작성하기

설치는 NuGet 한 줄이면 됩니다. 중요한 전제 조건이 하나 있습니다. 반드시 콘솔 앱 + Release 빌드여야 합니다. Debug 빌드는 JIT 최적화가 꺼져 있어 측정 자체가 무의미합니다. (BenchmarkDotNet은 Debug로 실행하면 경고를 띄우고 멈춥니다.)

dotnet add package BenchmarkDotNet
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text;

[MemoryDiagnoser]                 // 할당량 + GC 횟수 측정
[SimpleJob(RuntimeMoniker.Net80)] // 대상 런타임 지정
public class StringConcatBenchmark
{
    [Params(10, 100, 1000)]       // 입력 크기별 반복 실행
    public int N;

    private string[] _words = null!;

    [GlobalSetup]                 // 측정에서 제외되는 준비 코드
    public void Setup()
    {
        _words = Enumerable.Range(0, N)
                           .Select(i => $"item-{i}")
                           .ToArray();
    }

    [Benchmark(Baseline = true)]
    public string PlusOperator()
    {
        var result = "";
        foreach (var w in _words) result += w;
        return result;            // 반드시 return! (DCE 방지)
    }

    [Benchmark]
    public string StringBuilderWay()
    {
        var sb = new StringBuilder();
        foreach (var w in _words) sb.Append(w);
        return sb.ToString();
    }

    [Benchmark]
    public string StringJoin() => string.Concat(_words);
}

public class Program
{
    public static void Main() =>
        BenchmarkRunner.Run<StringConcatBenchmark>();
}
dotnet run -c Release

결과 읽는 법

MethodNMeanRatioAllocated
PlusOperator10001,842.5 μs1.008.21 MB
StringBuilderWay100014.3 μs0.00844.1 KB
StringConcat10006.9 μs0.00419.5 KB

핵심은 Mean만 보는 게 아니라는 점입니다.

Ratio는 Baseline 대비 배수입니다. 절대 시간은 머신마다 다르지만 비율은 상대적으로 안정적이라 보고서에 쓰기 좋은 지표입니다.

Allocated는 [MemoryDiagnoser]가 켜져 있을 때만 나옵니다. 위 표에서 += 방식이 8MB를 할당한 이유는, string이 불변이라 매 루프마다 새 문자열이 생성되기 때문입니다. 이 8MB는 곧 GC 압박으로 이어지고, 서버 환경에서는 처리량 저하로 직결됩니다.

Error / StdDev도 확인하세요. StdDev가 Mean의 10%를 넘는다면 측정 환경이 불안정하다는 신호입니다. 백신 실시간 검사, 다른 빌드 프로세스, 노트북 배터리 모드를 의심해야 합니다.

4. 함정을 피하는 실전 규칙

규칙 1 — 결과를 반드시 소비하라

메서드가 void이고 내부 계산 결과를 아무 데도 쓰지 않으면 JIT가 통째로 제거할 수 있습니다. 해법은 두 가지입니다. 값을 return하거나(BenchmarkDotNet이 내부적으로 consume 처리), Consumer를 사용하는 것입니다.

private readonly Consumer _consumer = new Consumer();

[Benchmark]
public void IterateWithLinq()
    => _source.Where(x => x > 10).Consume(_consumer);

규칙 2 — Setup과 측정 코드를 분리하라

어트리뷰트실행 시점용도
[GlobalSetup]벤치마크 시작 전 1회대용량 데이터 준비, DB 커넥션
[IterationSetup]매 반복마다상태 초기화 (오버헤드 주의)
[GlobalCleanup]종료 후 1회리소스 해제

[IterationSetup]은 측정 시간에 포함되지 않지만, 실행 전략을 InvocationCount=1 모드로 강제하기 때문에 나노초 단위 벤치마크에서는 정확도가 크게 떨어집니다. 마이크로벤치마크에서는 가급적 피하세요.

규칙 3 — 상수를 하드코딩하지 마라

// 나쁨: JIT가 컴파일 타임에 계산해버림
[Benchmark]
public double Bad() => Math.Sqrt(144.0);

// 좋음: 필드에서 읽어와 상수 폴딩 차단
private double _value = 144.0;
[Benchmark]
public double Good() => Math.Sqrt(_value);

규칙 4 — 마이크로벤치마크의 결과를 확대 해석하지 마라

1ns 차이가 나는 두 메서드가 있어도, 실제 애플리케이션에서 그 메서드가 요청당 한 번만 호출된다면 아무 의미가 없습니다. 벤치마킹 이전에 프로파일링으로 병목을 먼저 찾아야 합니다. 측정 없는 최적화는 도박이고, 병목 파악 없는 측정은 낭비입니다.

5. Diagnoser: 숫자 뒤의 원인을 보는 도구들

[MemoryDiagnoser]
할당 바이트 수와 Gen0/Gen1/Gen2 컬렉션 횟수를 표시합니다. 가장 자주 쓰는 진단기이며, 부하가 거의 없어 기본 장착을 권합니다.

[ThreadingDiagnoser]
Lock Contention 횟수와 Completed Work Items를 측정합니다. 병렬 코드의 락 경합 문제를 찾을 때 유용합니다.

[DisassemblyDiagnoser(maxDepth: 2)]
JIT가 생성한 실제 어셈블리 코드를 출력합니다. 인라이닝 여부, 바운드 체크 제거(bounds check elimination), SIMD 명령어 사용 여부를 눈으로 확인할 수 있습니다.

[EtwProfiler] / [EventPipeProfiler]
실행 트레이스를 수집해 PerfView나 SpeedScope로 열어볼 수 있는 파일을 남깁니다.

[HardwareCounters]
캐시 미스, 분기 예측 실패 같은 CPU 카운터를 읽습니다. Windows 한정이며 관리자 권한이 필요합니다.

여러 런타임 비교하기

[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net80)]
[SimpleJob(RuntimeMoniker.Net90, baseline: true)]
[MemoryDiagnoser]
public class RuntimeComparison { /* ... */ }

이 설정 하나로 동일한 코드를 .NET 6/8/9에서 각각 실행해 비교합니다. 실제로 .NET 7~9를 거치며 Dynamic PGO 기본 활성화, 정규식 소스 생성기, LINQ 벡터화 등이 도입되어 코드 한 줄 안 바꾸고도 상당한 성능 향상을 얻은 사례가 많습니다. 업그레이드 타당성을 경영진에게 설득할 때 이만한 자료가 없습니다.

6. 케이스 스터디: 할당을 줄이는 최적화 패턴

Span<T>로 문자열 파싱 개선하기

private const string Input = "2024-05-17";

[Benchmark(Baseline = true)]
public int ParseWithSubstring()
{
    var y = int.Parse(Input.Substring(0, 4));
    var m = int.Parse(Input.Substring(5, 2));
    var d = int.Parse(Input.Substring(8, 2));
    return y + m + d;
}

[Benchmark]
public int ParseWithSpan()
{
    ReadOnlySpan<char> s = Input;
    var y = int.Parse(s[..4]);
    var m = int.Parse(s.Slice(5, 2));
    var d = int.Parse(s.Slice(8, 2));
    return y + m + d;
}

결과는 전형적으로 이렇게 나옵니다. Substring 방식은 힙에 3개의 문자열을 새로 만들지만(약 80바이트), Span 방식은 할당이 0바이트입니다. 속도도 30~50% 빨라집니다. 스택 위에서 기존 메모리를 가리키기만 하기 때문입니다.

구조체 열거자와 LINQ의 트레이드오프

List<T>를 foreach로 순회하면 구조체 열거자가 사용되어 할당이 없습니다. 그러나 IEnumerable<T>로 캐스팅되는 순간 박싱이 발생합니다. LINQ 체인은 각 연산자마다 이터레이터 객체를 하나씩 생성하죠.

방식상대 속도할당가독성
for 인덱스 루프1.00x0 B낮음
foreach (List 직접)≈1.0x0 B좋음
foreach (IEnumerable)≈1.5x40 B좋음
LINQ Where+Select2~4x100~200 B매우 좋음

중요한 건 LINQ가 나쁘다는 결론이 아닙니다. 초당 수백만 번 도는 핫 패스가 아니라면 LINQ의 가독성이 훨씬 큰 가치입니다. 벤치마킹의 목적은 "어디서 타협할지 데이터로 결정하는 것"이지, 모든 코드를 원시적으로 만드는 게 아닙니다.

실무 판단 기준
① 프로파일러로 전체 시간의 5% 이상을 차지하는 구간인가?
② 호출 빈도가 요청당 1,000회 이상인가?
③ 최적화 후 코드를 6개월 뒤의 내가 이해할 수 있는가?
세 질문에 모두 "예"라면 최적화하세요. 아니라면 그대로 두는 게 정답입니다.

7. CI에 벤치마크를 붙여 성능 회귀 막기

한 번 측정하고 끝내면 반년 뒤엔 다시 느려져 있습니다. 성능은 지속적으로 지켜야 하는 속성입니다.

방법 1 — 기준선 비교 도구

BenchmarkDotNet은 결과를 JSON, CSV, Markdown, HTML로 내보냅니다. 이 JSON을 커밋 단위로 저장해두고 ResultsComparer(dotnet/performance 저장소 제공)로 비교하면, 특정 임계값 이상 느려진 벤치마크만 추려낼 수 있습니다.

dotnet run -c Release -- --exporters json --artifacts ./perf-results

방법 2 — 필터와 짧은 실행 모드

전체 벤치마크는 수십 분이 걸립니다. PR 파이프라인에서는 핵심 항목만 --filter로 골라 ShortRunJob으로 돌리고, 야간 빌드에서 전체를 정밀 실행하는 2단 전략이 현실적입니다.

dotnet run -c Release -- --filter *Serializer* --job short

CI 환경의 한계
GitHub Actions나 Azure Pipelines의 공유 러너는 이웃 VM의 부하에 영향을 받습니다(noisy neighbor). 절대 시간 기준으로 실패 판정을 내리면 거짓 경보가 쏟아집니다. Baseline 대비 Ratio를 기준으로 삼거나, 전용 머신을 쓰거나, 최소 20% 이상 악화된 경우에만 알림을 보내는 식으로 임계값을 여유 있게 잡으세요.

8. 벤치마킹과 프로파일링, 그리고 부하 테스트의 역할 분담

성능 측정 도구의 계층 구조 프로파일링 Visual Studio Profiler dotnet-trace / PerfView 질문: 어디가 느린가? 전체 앱 실행 중 측정 호출 트리 · 핫패스 식별 → 최적화 시작점 발견 벤치마킹 BenchmarkDotNet Crank (ASP.NET) 질문: 얼마나 빠른가? 격리된 코드 조각 측정 통계적 신뢰도 확보 → 개선 효과 정량 검증 부하 테스트 k6 / JMeter NBomber / bombardier 질문: 얼마나 버티나? 시스템 전체 처리량 p95 / p99 지연시간 → 운영 용량 산정 프로파일링으로 찾고 → 벤치마킹으로 증명하고 → 부하 테스트로 확인한다

세 가지는 경쟁 관계가 아니라 순서입니다. 많은 팀이 이 순서를 건너뛰고 곧바로 "LINQ를 for문으로 바꾸자" 같은 미시적 최적화에 들어갑니다. 결과는 대개 가독성만 나빠지고 응답 시간은 그대로인 상황이죠. 실제 병목은 대부분 N+1 쿼리, 동기 I/O 블로킹, 불필요한 직렬화에 있습니다.

9. 자주 마주치는 질문들

Q. 벤치마크 실행이 너무 오래 걸립니다.
A. [ShortRunJob]을 쓰면 Warmup 3회, Iteration 3회로 줄어듭니다. 정확도는 떨어지지만 개발 중 빠른 확인용으로 충분합니다. 최종 수치는 기본 Job으로 다시 뽑으세요.

Q. async 메서드도 측정 가능한가요?
A. 가능합니다. 메서드 시그니처를 Task<T>로 두면 BenchmarkDotNet이 자동으로 await 처리합니다. 단, .Result나 .Wait()를 직접 쓰면 데드락과 측정 왜곡이 생기니 절대 피하세요.

Q. 결과가 매 실행마다 다릅니다.
A. StdDev와 Outliers 항목을 확인하세요. 배경 프로세스를 정리하고, 전원 설정을 '고성능'으로 바꾸고, [InvocationCount]와 [UnrollFactor]를 조정해보세요. 그래도 흔들린다면 측정 대상이 너무 짧거나(1ns 미만) 외부 I/O에 의존하고 있을 가능성이 큽니다.

Q. Docker 안에서 돌려도 되나요?
A. 됩니다만 CPU 제한(cgroup quota)과 공유 호스트 부하 때문에 절대값 신뢰도가 낮습니다. 상대 비교 용도로만 쓰세요.

10. 정리: 측정하는 개발자가 되기

성능 최적화의 역사에서 가장 자주 인용되는 문장은 도널드 커누스의 "섣부른 최적화는 만악의 근원"입니다. 하지만 이 문장의 앞뒤 맥락을 보면, 커누스는 같은 글에서 "그 결정적인 3%에 대해서는 기회를 놓치지 말아야 한다"고도 말했습니다.

핵심은 3%를 찾아내는 능력입니다. 그리고 그 능력의 절반은 측정 도구를 제대로 다루는 데서 나옵니다.

오늘부터 적용할 체크리스트

✔ 성능 논쟁이 생기면 추측 대신 벤치마크 프로젝트를 하나 만든다
✔ Release 빌드, 콘솔 앱, [MemoryDiagnoser]는 기본값으로 둔다
✔ Baseline을 지정해 Ratio로 이야기한다
✔ 프로파일링으로 병목을 먼저 찾고 벤치마킹으로 개선을 증명한다
✔ 중요한 벤치마크는 CI에 넣어 회귀를 자동 감시한다

기술 콘텐츠와 실무 노하우를 나누는 재능넷의 '지식인의 숲'에는 이처럼 코드 한 줄 뒤에 숨은 원리를 다루는 글들이 쌓이고 있습니다. 성능 엔지니어링은 특별한 재능이 아니라 습관입니다. 오늘 여러분이 가장 의심스러웠던 메서드 하나를 골라 벤치마크로 감싸보세요.

숫자가 나오는 순간, 그동안 막연했던 코드가 갑자기 선명해지는 경험을 하게 될 겁니다. 그리고 그 선명함이 바로 엔지니어링과 감(感)의 차이입니다.

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.