수정 이력(9건, 최종 수정 2026년 09월 03일)
이 글에 적용한 변경 사항의 기록입니다. 보관해 둔 수정 전 버전은 DOI가 부여된 고정 URL에서 읽을 수 있습니다.
- permalink·저자 표기·지식 맵 래퍼·깨진 내부 링크 등 CI가 지적한 표시용 수정을 반영했습니다. 본문의 기술적인 주장은 바꾸지 않았습니다.
- 일본어 원문의 완역으로 다시 번역했습니다. 기존 한국어판은 원문의 일부만 옮긴 축약본이라 절, 표, Mermaid 그림, 그림 설명, FAQ가 빠져 있었습니다. 일본어 원문에 맞춰 이들을 모두 복원했고, 기술적인 주장은 일본어판과 같습니다. 수정 전 버전 보기 (DOI: 10.5281/zenodo.21635179)
- 측정 설계의 생각이나 결과를 읽는 흐름을 그림으로도 따라갈 수 있도록, Mermaid 그림을 16점 추가했습니다(본문 500〜750자당 1그림 규약에 맞춘 것입니다). 본문 문장은 바꾸지 않았습니다.
- 글 맨 앞에 「이 글의 지식 맵」 절을 추가했습니다. 본문에서 다루는 개념과 그 관계를 요약·그림·상세 페이지 링크로 정리한 것입니다. 본문의 주장은 바꾸지 않았습니다.
- 외부 리뷰(1283건) 대응으로 본문을 갱신했습니다. 개별 변경 내용은 아래 이력을 참조하십시오.
- 측정 스크립트가 부모 디렉터리가 없는 출력 경로(`-OutCsv raw.csv`)에서 실패하고 있었습니다. `Split-Path -Parent`가 빈 문자열을 반환하고, `New-Item`이 이를 거부합니다. 실패하는 시점은 모든 run이 끝난 뒤라, 측정 결과를 통째로 잃습니다. 부모가 비어 있지 않을 때만 디렉터리를 만들도록 했습니다.
- 구현이 `inner_ms`를 빠뜨려도 종료 코드 0에 checksum만 있으면 통과해, `NaN`인 채로 CSV에 들어가던 것을 고쳤습니다. 안쪽 시간 비교가 조용히 깨지므로, 값이 없거나 잘못된 경우를 걸러내도록 했습니다.
- 공통 러너의 본측정 루프에서 checksum을 대조하지 않던 것을 고쳤습니다. 1의 확인은 버리는 warm run에 대한 것이므로, 기록하는 run도 하나씩 대조하지 않으면, 도중에 다른 일을 하기 시작한 구현의 시간이 CSV에 섞입니다.
- 바깥의 공통 러너와 안쪽의 언어별 하네스라는 2층 구성, 실행 절차, cold와 warm의 측정 범위 그림 3개를 추가했습니다. 공통 러너 골격을 PowerShell로, 최소 하네스를 BenchmarkDotNet과 JMH로 보이고, 용어표와 기록 템플릿 기입 방법, 평균만 보면 무엇을 놓치는지의 설명을 더했습니다. 비유로 적혀 있던 3곳을 구체적인 서술로 고쳤습니다.
- 최초 공개
이 글을 인용하기(DOI: 10.5281/zenodo.21635178)
이 글은 Zenodo에 보관되어 있습니다. 항상 최신 버전으로 연결되는 DOI와 지금 보고 있는 버전에 고정된 DOI를 아래에 함께 제시합니다.
Go Komura (2026). 「C#/C++/Java/Go 실행 속도를 공정하게 비교하는 방법」. 합동회사 코무라소프트. https://doi.org/10.5281/zenodo.21635178 https://comcomponent.com/ko/blog/language-benchmark-csharp-cpp-java-go/
- DOI(최신 버전)
- 10.5281/zenodo.21635178
- DOI(이 버전)
- 10.5281/zenodo.22217460
「C++는 빠르다고 한다」 「Go는 실제 운영에서 가볍다」 「Java는 오래 돌리면 꽤 빠르다」 「C#도 .NET의 JIT이 있어서 의외로 강하다」
이런 이야기는 자주 나옵니다. 다만 여기서 가장 해서는 안 되는 일은, 서로 다른 사람이 서로 다른 환경에서 측정한 숫자를 나열한 뒤, 그대로 언어의 우열로 단정하는 것입니다.
C#과 Java는 JIT이나 warm-up의 영향을 받기 쉽고, C++와 Go는 보통 미리 컴파일되어 있습니다. GC의 유무와 특성도 다릅니다. 표준 라이브러리와 주변 라이브러리의 구현 차이도 상당히 큽니다. 게다가 같은 머신이라도 전원 설정, 발열, 백그라운드 처리, 입력 데이터의 치우침만으로 결과는 쉽게 흔들립니다. 꽤 지저분한 세계입니다.
flowchart TB
accTitle: 결과가 흔들리는 요인
accDescr: JIT과 warm-up의 영향, GC의 유무와 특성 차이, 표준 라이브러리와 주변 라이브러리의 구현 차이, 전원 설정과 발열과 백그라운드 처리와 입력 데이터의 치우침이 겹쳐 측정 결과는 쉽게 흔들린다는 점을 나타내는 그림.
n1["JIT과 warm-up의 영향"] --> n4["결과는 쉽게 흔들린다"]
n2["GC와 라이브러리의 구현 차이"] --> n4
n3["전원·발열·노이즈·입력의 치우침"] --> n4
n4 -.-> n5["다른 환경의 숫자를 나열해 우열을 정하지 않는다"]
그림 1: 흔들림의 원인이 많기 때문에, 남의 숫자를 나열하는 비교는 성립하지 않습니다.
이 글에서는 C# / C++ / Java / Go를 가능한 한 공정하게 비교하기 위한 측정 방법을 정리합니다. 결론만 먼저 말하면, 「어느 언어가 가장 빠른가」를 숫자 하나로 결정하려 하지 않는 것이 가장 중요합니다.
이 글의 주제는 어디까지나 비교 방법의 정리입니다. 환경에 의존하는 숫자를 늘어놓아도, 조건이 다르면 쉽게 뒤집힙니다. 그래서 여기서는 실측 순위를 쓰지 않습니다. 대신 어떻게 설계해야 비교에 가치가 생기는지에만 초점을 맞춥니다.
대상 독자
여러 언어 후보가 있고, 성능 면에서 어느 언어로 구현할지 판단하려는 개발자와 기술 리더, 그리고 사내나 보고서에서 「속도를 비교했다」고 말할 수 있는 측정을 구성하려는 분을 대상으로 합니다. 특정 언어를 깊게 파는 글이 아니라 4개 언어를 아우르는 비교 설계가 주제이므로, 한 언어만 다루는 분이 읽어도 되도록 썼습니다.
코드 예는 공통 러너가 PowerShell, 언어 내부 하네스가 C#의 BenchmarkDotNet과 Java의 JMH입니다. C++와 Go는 조건을 맞추는 방법만 다룹니다.
먼저 알아 둘 용어
본문에 영어 그대로 나오는 용어가 몇 가지 있습니다. 처음 나올 때 막히지 않도록 미리 정리합니다.
| 용어 | 의미 |
|---|---|
| p95 / p99 | 퍼센타일. 전체 run을 빠른 순으로 늘어놓았을 때, 아래에서 95% / 99% 위치에 있는 값입니다. 「100번 가운데 5번은 이보다 느리다」가 p95에 해당합니다 |
| RSS (Resident Set Size) | 프로세스가 실제로 물리 메모리에 올려 둔 크기입니다. 확보한 가상 메모리량이 아니라 「지금 실제 메모리를 얼마나 차지하고 있는가」를 나타냅니다 |
| LTO (Link Time Optimization) | 링크 시점에 번역 단위를 넘나들며 최적화하는 메커니즘입니다. GCC / Clang의 -flto, MSVC의 /GL과 /LTCG가 해당합니다 |
| PGO (Profile-Guided Optimization) | 한 번 실행해 모은 분기와 호출 프로파일을, 다음 빌드의 최적화 판단에 쓰는 메커니즘입니다 |
| Tiered Compilation | .NET의 JIT이 먼저 빠르게 컴파일할 수 있는 코드로 돌리고, 자주 호출되는 메서드만 나중에 다시 최적화하는 메커니즘입니다. cold와 warm의 차이가 나는 주된 원인 가운데 하나입니다 |
| Server GC / Workstation GC | .NET의 GC 동작 모드입니다. Server GC는 논리 프로세서마다 힙과 GC용 스레드를 두고 throughput 쪽에 가깝고, Workstation GC는 응답성 쪽에 가깝게 둡니다 |
| GOMAXPROCS | Go 런타임이 Go 코드를 동시에 실행하는 OS 스레드 수의 상한입니다. 병렬 벤치에서는 여기를 고정하지 않으면 결과를 비교할 수 없습니다 |
| cgo | Go에서 C 코드를 호출하는 메커니즘입니다. 켜면 호출 비용, 빌드 조건, 정적 링크 가능 여부가 바뀝니다 |
먼저 결론
C# / C++ / Java / Go의 속도 비교에서 실제로 먹히는 것은 다음 7가지입니다.
-
무엇의 속도를 비교하고 싶은지를 먼저 정한다 시작 시간인지, 정상 상태의 throughput인지, p95 지연인지, 메모리 효율인지에 따라 측정 방법이 바뀝니다.
-
벤치 하나로 결론을 내지 않는다 CPU 계산, 메모리 할당, 병렬 처리, 시작 시간에서는 강한 언어나 런타임이 다르게 보입니다.
-
C#과 Java는 cold와 warm을 나눈다 첫 실행을 포함하는 비교와, warm-up 뒤의 정상 상태 비교를 섞으면 이야기가 꼬입니다.
-
같은 알고리즘, 같은 입력, 같은 올바름 확인으로 잰다 빠른 구현이 아니라 다른 문제를 풀고 있었을 뿐, 은 벤치에서 흔한 일입니다.
-
언어 안의 microbenchmark와, 언어를 넘나드는 end-to-end 벤치를 나눈다 각 언어의 전용 하네스는 편리하지만, 언어를 넘나드는 비교는 바깥의 공통 러너로 돌리는 편이 맞습니다.
-
평균만이 아니라 중앙값과 분포를 본다 GC나 백그라운드 처리가 한 번만 끼어도 평균은 망가집니다.
-
숫자만이 아니라 조건을 남긴다 벤치 결과는 속도의 기록이면서 동시에 실험 조건의 기록입니다. 조건이 적혀 있지 않은 결과는 나중에 꽤 힘듭니다.
이 글의 지식 맵
이 글은 C#·C++·Java·Go의 실행 속도를 하나의 숫자로 비교하는 것의 위험성을 지적하고, 공평한 비교를 위한 측정 설계를 정리합니다. C#과 Java는 JIT 컴파일과 Tiered Compilation의 영향으로 cold와 warm의 차이가 크고, C++와 Go는 보통 AOT로 사전 컴파일되므로, 양쪽을 같은 표에 섞지 말 것을 요구합니다. C++에서는 컴파일러가 사용하지 않는 계산을 지워버리는, 즉 최적화로 처리가 사라지는 문제에 대한 대책으로 Google Benchmark나 checksum에 의한 정확성 확인이 필요해지며, 언어 내 측정에는 BenchmarkDotNet·JMH·go test -bench·Google Benchmark 같은 전용 하네스를 쓰고, 언어를 넘나드는 비교는 외측의 공통 러너에서 실행 순서 랜덤화와 checksum 검증을 수행하는 2층 구성이 권장됩니다. 게다가 평균뿐 아니라 중앙값이나 p95/p99 같은 tail latency의 분포까지 보는 것이, 결과를 올바르게 해석하는 조건으로 정리되어 있습니다.
flowchart LR
accTitle: C#/C++/Java/Go 언어 횡단 벤치마크
accDescr: C#과 Java의 JIT 컴파일과 Tiered Compilation, C++와 Go의 사전 컴파일이라는 차이가 cold와 warm의 구별을 요구한다는 것, C++에서 최적화로 처리가 사라지는 문제에 대응하는 Google Benchmark와 checksum 검증, 언어별 하네스와 외측의 공통 러너에 의한 2층 구성, tail latency의 분포 평가까지의 관계를 보여주는 그림.
cross_language_benchmarking["언어 간 벤치마크 설계"]
csharp["C#"]
jit_compilation["JIT(Just-In-Time) 컴파일"]
java_lang["Java"]
cpp["C++"]
ahead_of_time_compilation["AOT(사전) 컴파일"]
golang["Go"]
tiered_compilation["Tiered Compilation"]
gomaxprocs["GOMAXPROCS"]
cgo["cgo"]
benchmarkdotnet["BenchmarkDotNet"]
jmh["JMH(Java Microbenchmark Harness)"]
go_benchmark_testing["go test -bench 및 benchstat"]
google_benchmark["Google Benchmark"]
warm_up_measurement["워밍업 후 안정 상태 측정(warm)"]
dead_code_elimination["데드 코드 제거(최적화로 인한 처리 소멸)"]
checksum_verification["체크섬 검증"]
tail_latency_percentile["테일 레이턴시 백분위수(p95/p99)"]
garbage_collection["가비지 컬렉션(GC)"]
csharp -.->|"이용한다"| jit_compilation
java_lang -.->|"이용한다"| jit_compilation
cpp -->|"이용한다"| ahead_of_time_compilation
golang -->|"이용한다"| ahead_of_time_compilation
csharp -->|"이용한다"| tiered_compilation
golang -->|"이용한다"| gomaxprocs
golang -.->|"이용한다"| cgo
csharp -.->|"이용한다"| benchmarkdotnet
java_lang -.->|"이용한다"| jmh
golang -.->|"이용한다"| go_benchmark_testing
cpp -.->|"이용한다"| google_benchmark
benchmarkdotnet -->|"이용한다"| warm_up_measurement
jmh -->|"이용한다"| warm_up_measurement
google_benchmark -.->|"방지한다"| dead_code_elimination
cpp -.->|"원인이 될 수 있다"| dead_code_elimination
checksum_verification -->|"방지한다"| dead_code_elimination
cross_language_benchmarking -->|"이용한다"| tail_latency_percentile
csharp -->|"이용한다"| garbage_collection
java_lang -->|"이용한다"| garbage_collection
golang -->|"이용한다"| garbage_collection
cross_language_benchmarking -.->|"이용한다"| warm_up_measurement
cross_language_benchmarking -->|"이용한다"| checksum_verification
cross_language_benchmarking -->|"이용한다"| jit_compilation
cross_language_benchmarking -->|"이용한다"| ahead_of_time_compilation
그림의 실선은 항상 성립하는 관계, 점선은 조건이 붙는 관계입니다(성립 조건은 상세 페이지의 관계별 설명에 적혀 있습니다). 관계 전체 목록(총 24건, 근거와 확신도 포함)과 주요 개념의 정의는 지식 맵 상세 페이지에 정리되어 있습니다(일본어). 데이터: JSON-LD / Turtle
처음에 정해야 할 것
「빠르다」를 한 단어로 넘기면, 대개 사고가 납니다. 우선 무엇을 빠르다고 부를지를 정합니다.
예를 들어, 같은 프로그램이라도 보고 싶은 것은 꽤 다릅니다.
1. 시작 시간을 보고 싶은가
CLI 도구, 수명이 짧은 배치, 한 번 기동해 바로 끝나는 보조 도구라면 cold start나 process startup이 먹힙니다. 이 축에서는 JIT이나 클래스 로드의 초기화 비용을 포함하는지에 따라 결과가 크게 바뀝니다.
2. 장시간 실행의 throughput을 보고 싶은가
서버, 상주 프로세스, 워커, 오래 돌아가는 변환 처리라면 steady-state의 throughput이 중요합니다. 이 경우 첫 실행만 느린 것 자체는 본질이 아니고, warm-up 뒤에 어디까지 안정적으로 늘어나는지가 주제가 됩니다.
3. tail latency를 보고 싶은가
API, UI, 실시간 쪽 처리에서는 평균보다 p95 / p99가 더 중요할 때가 있습니다. 평균이 빨라도 가끔 크게 멈추면, 사용자 경험이나 SLA 관점에서는 힘듭니다.
4. 메모리 효율까지 포함해 보고 싶은가
CPU 시간만이 아니라 최대 RSS, 할당량, GC 횟수, GC pause도 보지 않으면, 실제 운영의 무게를 잘못 읽습니다. 「빠르지만 메모리를 꽤 먹는다」와 「조금 느리지만 안정적으로 가볍다」는 용도에 따라 평가가 뒤집힙니다.
결국, 처음에 정해야 할 질문은
이 비교에서 알고 싶은 것은, 어느 언어가 빠른가가 아니라, 어느 workload를, 어떤 조건에서, 어떤 지표에서 빠르게 처리할 수 있는가
입니다.
여기를 모호하게 둔 채 숫자를 모으면, 마지막에 정리되지 않습니다.
flowchart TB
accTitle: 무엇을 빠르다고 부를지를 먼저 정한다
accDescr: 시작 시간, 정상 상태의 throughput, 테일 레이턴시, 메모리 효율 가운데 무엇을 보고 싶은지에 따라 측정 방법이 바뀌므로, 비교 전에 어느 workload를 어떤 조건에서 어떤 지표로 볼지를 정한다는 점을 나타내는 그림.
q1["이 비교에서 알고 싶은 것은 무엇인가"] --> a1["시작 시간"]
q1 --> a2["정상의 throughput"]
q1 --> a3["p95 / p99의 지연"]
a3 -.-> a4["메모리 효율도 다른 축"]
a1 -.-> a5["축마다 측정 방법이 바뀐다"]
그림 2: 「빠르다」의 정의를 정하기 전에는, 숫자를 모으기 시작하지 않습니다.
왜 언어 비교는 어려운가
JIT과 AOT를 섞으면 다른 실험이 된다
C#과 Java는 보통 JIT의 영향을 받습니다. 반면 C++와 Go는 보통 미리 컴파일되어 있습니다.
즉, 첫 실행을 재면 프로그램 본체의 속도만이 아니라 런타임 기동·클래스 로드·JIT 준비까지 함께 재게 됩니다. 반대로, 충분히 warm-up한 뒤만 보면, 이번에는 정상 상태의 최적화가 어디까지 먹히는지의 비교가 됩니다.
둘 다 의미는 있습니다. 다만 같은 의미는 아닙니다.
flowchart TB
accTitle: JIT 쪽과 AOT 쪽의 차이
accDescr: C#과 Java는 보통 JIT의 영향을 받으므로 첫 실행에는 런타임 기동이나 클래스 로드나 JIT 준비가 섞이고, C++와 Go는 보통 미리 컴파일되어 있으므로, 첫 실행을 재는 비교와 warm-up 뒤를 재는 비교는 같은 의미가 되지 않는다는 점을 나타내는 그림.
j1["C#과 Java〔보통 JIT〕"] --> j2["첫 실행에는 기동과 JIT 준비가 섞인다"]
g1["C++와 Go〔보통 AOT〕"] --> g2["미리 컴파일된 채로 움직인다"]
j2 --> mix["첫 실행 비교와 정상 비교는 다른 실험"]
g2 --> mix
그림 3: 실행 모델이 다른 언어끼리는, 어디부터 재느냐가 실험의 내용을 정합니다.
언어 차이보다 구현 차이가 더 큰 일이 흔하다
같은 「정렬」이라도,
- 한쪽은 표준 라이브러리를 쓴다
- 한쪽은 자체 구현
- 한쪽은 여분의 복사를 한다
- 한쪽은 입력을 매번 다시 생성한다
이것만으로 결과는 꽤 바뀝니다.
게다가 JSON, 압축, 암호, 정규 표현 같은 처리가 되면, 언어 그 자체보다 라이브러리 구현의 차이가 상당히 큽니다. 그래서 무엇을 재고 있는지를 명시하지 않으면, 「언어 비교」의 의도가 「라이브러리 비교」가 됩니다.
flowchart TB
accTitle: 언어 비교가 라이브러리 비교로 바뀐다
accDescr: 같은 처리라도 표준 라이브러리인지 자체 구현인지, 여분 복사나 입력 재생성의 유무로 결과는 바뀌고, JSON이나 압축이나 암호나 정규 표현에서는 언어 그 자체보다 라이브러리 구현의 차이가 먹히므로, 무엇을 재고 있는지를 명시하지 않으면 언어 비교의 의도가 라이브러리 비교가 된다는 점을 나타내는 그림.
d1["같은 처리여야 할 구현들"] --> d2["구현과 라이브러리의 차이가 섞인다"]
d2 --> d3{"무엇을 재고 있는지 명시했는가"}
d3 -->|"하지 않았다"| d4["언어 비교의 의도가 라이브러리 비교"]
d3 -->|"했다"| d5["비교로 해석할 수 있다"]
그림 4: 재고 있는 대상의 이름을 올바르게 붙이기만 해도, 오해의 대부분은 사라집니다.
C++는 최적화로 처리가 사라지는 함정이 있다
특히 microbenchmark에서는, 컴파일러가 「이 계산 결과는 아무도 쓰지 않는다」고 판단하면 처리를 지워 버리기도 합니다. 그러면 빠른 것이 아니라, 애초에 아무것도 하지 않은 결과를 재게 됩니다. 루프 전체가 사라져 실행 시간이 거의 0이 되는 것이 전형적인 모습입니다.
C++에서는 이 문제가 특히 노골적으로 나오기 쉬우므로, 결과를 사용하거나 checksum을 출력하거나, 아니면 benchmark 프레임워크의 최적화 억제 기능이 꽤 중요합니다.
flowchart TB
accTitle: 최적화로 처리가 사라지는 함정
accDescr: microbenchmark에서는 계산 결과를 아무도 쓰지 않는다고 컴파일러가 판단하면 처리 자체가 사라지고, 빠른 것이 아니라 아무것도 하지 않은 결과를 재게 되므로, 결과의 사용이나 checksum 출력, 최적화 억제 기능이 중요해진다는 점을 나타내는 그림.
o1["계산 결과를 아무도 쓰지 않는다"] --> o2["컴파일러가 처리를 지운다"]
o2 --> o3["실행 시간이 거의 0으로 보인다"]
o3 -.-> o4["빠른 것이 아니라 아무것도 하지 않았다"]
o3 --> o5["checksum 출력이나 억제 기능으로 막는다"]
그림 5: 이상하리만치 빠른 결과는, 먼저 사라지지 않았는지를 의심합니다.
GC의 존재는 「불리」도 「유리」도 아니고, 특성이다
C#, Java, Go에는 GC가 있습니다. 이를 단순히 「GC가 있으니 느리다」고 하면 너무 거칠습니다.
실제로는,
- 대량의 짧은 수명 객체를 어떻게 처리하는가
- 힙 크기 설정
- GC의 빈도와 pause
- 객체 레이아웃
- 라이브러리의 할당 버릇
쪽이 더 먹힙니다.
반대로 C++는 수동 관리나 RAII로 세밀하게 제어할 수 있지만, 그만큼 설계와 구현의 차이가 나오기 쉽습니다. 즉, 관리 방식의 차이가 곧 선악이나 우열은 아닙니다.
flowchart TB
accTitle: GC는 불리가 아니라 특성
accDescr: GC가 있는 언어에서는 짧은 수명 객체의 처리 방식이나 힙 설정, GC의 빈도와 pause, 할당 버릇 쪽이 먹히고, C++는 세밀하게 제어할 수 있는 만큼 설계와 구현의 차이가 나오기 쉬우므로, 관리 방식의 차이가 곧 우열은 아니라는 점을 나타내는 그림.
gc1["GC가 있는 언어"] --> gc2["설정과 할당 버릇이 먹힌다"]
gp1["C++의 수동 관리와 RAII"] --> gp2["제어할 수 있지만 구현 차이가 나오기 쉽다"]
gc2 --> gv1["관리 방식의 차이는 우열이 아니다"]
gp2 --> gv1
그림 6: 「GC가 있으니 느리다」는 한 마디로 넘기지 않기 위한 정리입니다.
비교에서 해서는 안 되는 일
1. Debug와 Release를 섞는다
이건 논외입니다. 비교 대상은 반드시 실제 운영에 가까운 최적화 빌드로 맞춥니다.
2. 같은 문제를 풀고 있지 않다
입력 형식이 다르다, 출력이 다르다, 오류 처리가 한쪽에만 없다, 메모리 재사용 방침이 다르다. 이 부분을 내버려 두면, 속도가 아니라 요구사항 차이를 재게 됩니다.
3. 한 번만 실행하고 결론을 낸다
한 번만의 실행은, 대개 노이즈입니다.
- JIT
- 페이지 캐시
- CPU의 부스트
- 발열
- 백그라운드 작업
- GC
- 첫 파일 읽기
이런 것들이 한 번에 전부 섞입니다.
flowchart TB
accTitle: 한 번만의 실행에 섞이는 것
accDescr: 한 번만의 실행에는 JIT이나 페이지 캐시, CPU의 부스트나 발열, 백그라운드 작업이나 GC, 첫 파일 읽기가 전부 섞이므로, 한 번의 결과는 대개 노이즈라는 점을 나타내는 그림.
x1["JIT과 첫 읽기"] --> x4["한 번의 실행에 전부 섞인다"]
x2["캐시와 CPU 부스트"] --> x4
x3["발열과 백그라운드 처리"] --> x4
x4 --> x5["한 번만의 결과는 대개 노이즈"]
그림 7: 단 한 번의 숫자에는, 재고 싶지 않은 것이 가장 많이 들어 있습니다.
4. warm-up을 섞는다
C#과 Java를 잴 때, 첫 실행을 포함할지, warm-up 뒤만 볼지를 모호하게 두면 논의가 무너집니다. cold와 warm은 별개로 다룹니다.
5. 올바름 확인을 하지 않는다
벤치는 「빠르다」보다 먼저 「같은 결과를 반환한다」가 필요합니다. 비교 대상의 모든 구현에서, 같은 입력에서 같은 checksum이나 같은 출력이 나온다는 것을 반드시 확인합니다.
6. microbenchmark 하나로 세계관을 정한다
tight loop만으로 이겨도, 실제 서비스 전체에서 이긴다는 보장은 없습니다. 반대로, 시작 시간에서 져도 장시간 실행에서는 충분히 강한 일도 있습니다.
C# / C++ / Java / Go를 비교할 때의 기본 방침
여기는 꽤 중요합니다. 추천은 2층 구성입니다.
flowchart TB
subgraph outer["바깥 층: 언어를 넘나드는 공통 러너"]
direction TB
R["공통 러너<br/>실행 순 무작위화 / cold와 warm의 분리<br/>checksum 검증 / raw data 저장"]
R --> E1["bench 실행 파일<br/>C++"]
R --> E2["bench 실행 파일<br/>C#"]
R --> E3["bench 실행 파일<br/>Java"]
R --> E4["bench 실행 파일<br/>Go"]
end
subgraph inner["안쪽 층: 언어 안의 파고들기"]
direction TB
H1["Google Benchmark"]
H2["BenchmarkDotNet"]
H3["JMH"]
H4["go test -bench 와 benchstat"]
end
E1 -.->|"같은 처리를 언어 안에서 자세히 잰다"| H1
E2 -.->|"같은 처리를 언어 안에서 자세히 잰다"| H2
E3 -.->|"같은 처리를 언어 안에서 자세히 잰다"| H3
E4 -.->|"같은 처리를 언어 안에서 자세히 잰다"| H4
그림 8: 언어를 넘나드는 비교는 바깥의 공통 러너로, 언어 안의 파고들기는 전용 하네스로 합니다.
바깥 층에서 나오는 숫자가 언어를 넘나들며 비교해도 되는 숫자, 안쪽 층에서 나오는 숫자가 그 언어 안에서 개선을 쫓기 위한 숫자입니다. 이 둘을 한 표에 섞지 않는 것이 요점입니다.
1. 언어 안의 측정은, 그 언어에 맞는 하네스를 쓴다
각 언어에는, 그 언어의 사정을 받아 주는 benchmark 도구가 있습니다.
- C#: BenchmarkDotNet
- Java: JMH
- Go:
go test -bench와benchstat - C++: Google Benchmark
이들은 각각의 런타임 사정이나 통계 처리, 측정의 함정을 어느 정도 맡아 줍니다. 언어 안의 비교나 구현의 파고들기에는 꽤 유효합니다.
예를 들어 「1,000만 건의 int32를 정렬한다」를 BenchmarkDotNet으로 재면, 최소 구성은 이렇게 됩니다.
// C# / .NET 8 + BenchmarkDotNet 0.13 계열
// dotnet add package BenchmarkDotNet
// dotnet run -c Release
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<SortBench>();
[MemoryDiagnoser] // 할당량과 GC 횟수도 함께 낸다
public class SortBench
{
private int[] _source = Array.Empty<int>();
private int[] _work = Array.Empty<int>();
[GlobalSetup]
public void Setup()
{
var rng = new Random(12345); // seed를 고정해 매번 같은 입력으로 만든다
_source = new int[10_000_000];
for (int i = 0; i < _source.Length; i++)
{
_source[i] = rng.Next();
}
_work = new int[_source.Length];
}
[IterationSetup] // 매번, 미정렬 상태로 되돌린다
public void ResetInput() => Array.Copy(_source, _work, _source.Length);
[Benchmark]
public long SortInt32()
{
Array.Sort(_work);
long checksum = 0;
foreach (int v in _work)
{
checksum = checksum * 31 + v;
}
return checksum; // 결과를 반환해 최적화로 사라지지 않게 한다
}
}
[IterationSetup]에는 제약이 있습니다. BenchmarkDotNet 공식 문서는, microbenchmark에서 이를 쓰면 결과를 더럽히므로 권하지 않고, 100ms 이상 걸리는 macrobenchmark라면 유용하다고 합니다. 1,000만 건 정렬은 이 조건을 충족하지만, 짧은 처리를 잴 때는 [GlobalSetup] 쪽에서 들고 다니는 형태로 바꿔 주세요.
Java의 JMH에서도, 생각할 일은 같습니다.
// Java / JMH. pom.xml에 jmh-core와 jmh-generator-annprocess를 넣습니다
// mvn clean verify
// java -jar target/benchmarks.jar SortBench
package bench;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3) // JVM을 나눠 JIT의 운을 고르게 한다
public class SortBench {
private int[] source;
private int[] work;
@Setup(Level.Trial)
public void setUp() {
Random rng = new Random(12345); // seed를 고정해 매번 같은 입력으로 만든다
source = new int[10_000_000];
for (int i = 0; i < source.length; i++) {
source[i] = rng.nextInt();
}
work = new int[source.length];
}
@Setup(Level.Invocation) // 매번, 미정렬 상태로 되돌린다
public void resetInput() {
System.arraycopy(source, 0, work, 0, source.length);
}
@Benchmark
public long sortInt32() {
Arrays.sort(work);
long checksum = 0;
for (int v : work) {
checksum = checksum * 31 + v;
}
return checksum; // 반환값은 JMH가 소비하므로 최적화로 사라지지 않는다
}
}
Level.Invocation에도 같은 종류의 제약이 있습니다. JMH의 javadoc은, 이 레벨은 한 번의 @Benchmark 메서드 호출이 1밀리초를 넘는 벤치에서만 쓸 수 있다고 명시합니다. 호출마다 타임스탬프를 찍기 때문에, 짧은 처리에서는 계측 자체가 병목이 되기 때문입니다.
BenchmarkDotNet과 JMH의 @Warmup / @Measurement / @Fork 값은, 그대로 실험 조건입니다. 결과와 함께 반드시 남겨 주세요.
2. 언어를 넘나드는 비교는, 바깥에 공통 러너를 둔다
한편, C#의 BenchmarkDotNet 결과와 Java의 JMH 결과를 그대로 가로로 나란히 두는 것은 조금 위험합니다. 하네스 자체의 관행이 다르기 때문입니다.
그래서 언어를 넘나들 때는, 각 구현을 같은 CLI 계약으로 호출할 수 있는 실행 파일로 만들고, 바깥에서 같은 조건으로 돌리는 것을 권합니다.
예를 들어 각 언어에서, 이런 형태의 실행 파일을 준비합니다.
bench --scenario sort_int32 --dataset data/sort_10m.bin --mode warm
bench --scenario group_words --dataset data/words_100mb.txt --mode cold
bench --scenario parallel_hash --dataset data/blob_1gb.bin --threads 8
출력 쪽도 계약으로 합니다. 어느 언어 구현이든, 표준 출력에 이 두 줄만 내는 약속으로 두면, 러너 쪽 파싱이 정규 표현 한 줄로 끝납니다.
checksum=[16진 문자열]
inner_ms=[소수 밀리초]
checksum은 올바름 확인용, inner_ms는 구현이 스스로 잰 본체 처리 시간입니다. 러너 쪽은 프로세스 전체의 wall-clock을 따로 재므로, 기동을 포함한 시간과 본체만의 시간이 둘 다 남습니다.
flowchart TB
accTitle: CLI 계약과 출력 계약
accDescr: 각 언어 구현을 같은 CLI 계약으로 호출할 수 있는 실행 파일로 만들고, 표준 출력에 checksum과 inner_ms 두 줄만 내는 계약으로 하면, 러너는 프로세스 전체 시간을 따로 재므로 기동을 포함한 시간과 본체만의 시간이 둘 다 남는다는 점을 나타내는 그림.
ct1["같은 CLI 계약의 실행 파일"] --> ct2["checksum과 inner_ms 두 줄을 낸다"]
ct2 --> ct3["러너는 전체 wall-clock을 잰다"]
ct3 --> ct4["기동 포함과 본체만의 시간이 둘 다 남는다"]
그림 9: 호출 방식과 출력을 계약으로 하면, 4개 언어를 같은 무대에서 돌릴 수 있습니다.
그리고 공통 러너 쪽에서,
- 실행 순서를 무작위화한다
- cold / warm을 나눈다
- 같은 데이터셋을 넘긴다
- checksum을 검증한다
- wall-clock과 메모리를 잰다
- CSV / JSON에 raw data를 남긴다
는 흐름으로 합니다. 골격만 쓰면, 이렇게 됩니다.
# run-bench.ps1 : 언어를 넘나드는 공통 러너(골격)
# PowerShell 7.4에서 동작합니다.
# pwsh ./run-bench.ps1 -Scenario sort_int32 -Dataset ./data/sort_10m.bin -Runs 15
param(
[Parameter(Mandatory = $true)][string]$Scenario,
[Parameter(Mandatory = $true)][string]$Dataset,
[int]$Runs = 15,
[int]$WarmupRuns = 3,
[string]$OutCsv = "./results/raw.csv"
)
# 각 언어의 실행 파일. CLI 계약은 4개 구현 모두 완전히 같게 맞춥니다.
# Java는 java -jar를 감싸기만 하는 얇은 래퍼를 두고, 호출 방식을 통일합니다.
$Implementations = @(
@{ Language = "cpp"; Exe = "./build/cpp/bench.exe" },
@{ Language = "csharp"; Exe = "./build/csharp/bench.exe" },
@{ Language = "java"; Exe = "./build/java/bench.cmd" },
@{ Language = "go"; Exe = "./build/go/bench.exe" }
)
function Invoke-OneRun {
param(
[Parameter(Mandatory = $true)][hashtable]$Impl,
[Parameter(Mandatory = $true)][string]$Mode,
[Parameter(Mandatory = $true)][int]$Index
)
# $Scenario와 $Dataset은, 맨 앞 param 블록에서 정의한 것을 씁니다
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$stdout = & $Impl.Exe --scenario $Scenario --dataset $Dataset --mode $Mode
$sw.Stop()
$exitCode = $LASTEXITCODE
# 구현과의 출력 계약을, 여기서 한 곳만 파싱합니다
$checksum = ""
$innerMs = [double]::NaN
foreach ($line in $stdout) {
if ($line -match '^checksum=(\S+)$') { $checksum = $Matches[1] }
if ($line -match '^inner_ms=(\S+)$') { $innerMs = [double]$Matches[1] }
}
if ($exitCode -ne 0 -or [string]::IsNullOrEmpty($checksum)) {
throw "$($Impl.Language) / $Mode / run $Index 가 실패했습니다. exit=$exitCode"
}
# inner_ms를 빠뜨린 구현은, 종료 코드 0으로 checksum만 돌려줍니다.
# 여기서 걸러내지 않으면 NaN인 채로 CSV에 들어가, 안쪽 시간 비교가 조용히 깨집니다
if ([double]::IsNaN($innerMs) -or [double]::IsInfinity($innerMs) -or $innerMs -lt 0) {
throw "$($Impl.Language) / $Mode / run $Index 가 inner_ms를 반환하지 않았습니다(값: $innerMs)." +
"출력 계약은 checksum= 과 inner_ms= 의 두 줄입니다"
}
return [pscustomobject]@{
timestamp = (Get-Date).ToString("o")
language = $Impl.Language
scenario = $Scenario
cold_or_warm = $Mode
run_index = $Index
process_ms = [math]::Round($sw.Elapsed.TotalMilliseconds, 3)
inner_ms = $innerMs
checksum = $checksum
}
}
# 1. 먼저 올바름 확인. 모든 구현이 같은 checksum을 반환하지 않으면, 속도를 잴 의미가 없습니다
$expected = $null
foreach ($impl in $Implementations) {
for ($i = 1; $i -le $WarmupRuns; $i++) {
$r = Invoke-OneRun -Impl $impl -Mode "warm" -Index $i
if ($null -eq $expected) {
$expected = $r.checksum
}
elseif ($r.checksum -ne $expected) {
throw "checksum이 일치하지 않습니다. $($impl.Language) 는 $($r.checksum), 기준은 $expected"
}
}
}
# 2. 본측정. run마다 실행 순을 셔플해, 발열과 시간대의 치우침을 고르게 합니다
# 1의 확인은 버리는 warm run에 대한 것이므로, 기록하는 run도 하나씩
# checksum을 대조합니다. 여기를 빼면, 도중에 다른 일을 하기 시작한
# 구현의 시간이 CSV에 섞여, 비교 자체가 무효가 됩니다
$rows = [System.Collections.Generic.List[object]]::new()
foreach ($mode in @("cold", "warm")) {
for ($i = 1; $i -le $Runs; $i++) {
$shuffled = $Implementations | Get-Random -Count $Implementations.Count
foreach ($impl in $shuffled) {
$r = Invoke-OneRun -Impl $impl -Mode $mode -Index $i
if ($r.checksum -ne $expected) {
throw "checksum이 일치하지 않습니다. $($impl.Language) 의 $mode run #$i 는 $($r.checksum), 기준은 $expected"
}
$rows.Add($r)
}
}
}
# 3. 생데이터는 반드시 남긴다. 집계는 이 CSV에서 나중에 합니다
# -OutCsv raw.csv 처럼 부모 디렉터리가 없는 이름을 넘기면
# Split-Path -Parent는 빈 문자열을 반환하고, New-Item이 이를 거부합니다.
# 여기서 실패하는 것은 모든 run이 끝난 뒤이므로, 측정 결과를 통째로 잃습니다.
$outDir = Split-Path -Parent $OutCsv
if ($outDir) { New-Item -ItemType Directory -Force -Path $outDir | Out-Null }
$rows | Export-Csv -Path $OutCsv -NoTypeInformation -Encoding utf8
Write-Host "raw data: $OutCsv / $($rows.Count) 행"
여기서 의도적으로 하는 일이 세 가지 있습니다.
- 올바름 확인을 속도 측정보다 앞에 둔다. checksum이 어긋난 채로 속도만 비교해도 의미가 없습니다
- 1 run마다 셔플한다. A를 전부 돌린 뒤 B, 라는 순서로 두지 않는 것이 목적입니다
- 집계하지 않고 raw data만 쓴다. 평균이나 중앙값은, 나중에 CSV에 대해 냅니다
이렇게 하면, 각 언어 안의 모범 사례와 언어를 넘나드는 공정함을 나눠 다루기 쉬워집니다.
flowchart TB
accTitle: 공통 러너의 세 가지 의도
accDescr: 공통 러너에서는, checksum으로 올바름 확인을 속도 측정보다 앞에 두고, 1 run마다 실행 순을 셔플하며, 집계하지 않고 raw data만 쓴다는 세 가지를 의도적으로 한다는 점을 나타내는 그림.
rn1["올바름 확인을 속도보다 앞에"] --> rn4["공정하게 비교할 수 있는 측정"]
rn2["1 run마다 셔플"] --> rn4
rn3["집계하지 않고 raw data를 쓴다"] --> rn4
rn2 -.-> rn5["발열과 시간대의 치우침을 고르게 한다"]
그림 10: 러너의 일은 빨리 도는 것이 아니라, 의심을 지우는 일입니다.
구체 예: 어떤 벤치 항목을 준비해야 하는가
「C# / C++ / Java / Go를 비교하고 싶다」는 말을 들었을 때, 하나만 한다면 오해하기 어려운 단순한 CPU 계열을, 여러 개를 한다면 성격이 다른 workload를 3~4개 준비하는 것을 권합니다.
추천 구성
1. sort_int32_10m
목적: CPU + 메모리 대역 + 임시 영역의 쓰임새를 본다
- 입력: 고정 seed로 생성한 1,000만 건의
int32 - 처리: 배열을 sort하고 checksum을 반환한다
- 주의점: 매번 같은 미정렬 입력으로 되돌릴 것
이는 비교적 알기 쉽습니다. 다만 표준 정렬 구현의 차이도 포함하므로, 언어 그 자체라기보다 표준 라이브러리 포함 비교가 됩니다.
2. hash_group_count
목적: 해시 테이블, 문자열 처리, 할당, GC의 경향을 본다
- 입력: 고정 텍스트 데이터
- 처리: 단어별 출현 횟수를 센다
- 출력: 상위 N건과 checksum
이는 실무에 가까운 반면, 문자열 라이브러리나 map 구현의 차이도 상당히 먹힙니다. 그만큼 현실에 가까운 비교가 됩니다.
3. parallel_sha256
목적: 병렬 처리, 스케줄러, 워커 풀, 동기화의 버릇을 본다
- 입력: 고정 크기의 바이너리 청크 열
- 처리: N 스레드로 차례로 해시하고, 최종 checksum을 반환한다
- 조건: 스레드 수를 1 / 2 / 4 / 8처럼 단계화
단순한 tight loop보다, 병렬 실행 시의 늘어남이 잘 보입니다.
4. startup_noop 또는 startup_parse_small
목적: 시작 시간을 본다
noop: 기동해서 바로 종료parse_small: 작은 입력을 한 번만 처리하고 종료
여기서는 C# / Java의 JIT이나 초기화 비용이 잘 보이고, C++ / Go의 모습과 꽤 달라집니다. 바꿔 말하면, 여기서 차이가 나도 장시간 처리의 승패와는 별개입니다.
flowchart TB
accTitle: 성격이 다른 4개의 벤치 항목
accDescr: 정렬은 CPU와 메모리 대역, 단어 카운트는 해시나 문자열과 할당, 병렬 해시는 병렬 실행 시의 늘어남, 기동 벤치는 시작 시간과, 각각 보이는 것이 다른 4개를 준비하는 구성을 나타내는 그림.
w1["sort_int32_10m"] -.-> v1["CPU와 대역과 임시 영역"]
w2["hash_group_count"] -.-> v2["문자열·map·GC의 경향"]
w3["parallel_sha256"] -.-> v3["병렬 실행 시의 늘어남"]
w4["startup 계열"] -.-> v4["기동과 초기화의 비용"]
w1 --> w2
w2 --> w3
w3 --> w4
그림 11: 하나로 전부를 볼 수 없으므로, 보고 싶은 것마다 항목을 나눕니다.
JSON이나 HTTP 벤치는 어떻게 하는가
JSON이나 HTTP는 실무에 가까우므로, 물론 의미는 있습니다. 다만 그 경우는 언어 비교라기보다, 라이브러리·프레임워크·에코시스템 포함 비교가 됩니다.
그 자체는 나쁘지 않습니다. 오히려 실무에서는 그쪽이 더 중요한 일도 많습니다. 다만 글이나 보고서에서는
이는 언어의 비교가 아니라, 표준적인 구현과 주요 라이브러리 포함 비교입니다
라고 명기하는 편이 오해가 적습니다.
언어마다 맞춰야 할 조건
C++
- 최적화 빌드로 맞춘다
- 컴파일러를 고정한다
- 표준 라이브러리 구현을 고정한다
-O3//O2, LTO, PGO 등의 조건을 명시한다- 결과가 최적화로 사라지지 않게 주의한다
- 미정의 동작으로 빠르게 보이지 않는지를 의심한다
C++는 자유도가 높은 만큼, 조건 차이가 그대로 크게 나옵니다. 그래서 어떤 컴파일러로, 어떤 플래그로, 어떤 STL로 쟀는지는 꽤 중요합니다.
C#
- Release 빌드로 맞춘다
- .NET 버전을 고정한다
- Server GC / Workstation GC 등의 조건을 기록한다
- Tiered Compilation, ReadyToRun, Native AOT의 유무를 명시한다
- cold와 warm을 나눈다
C#은 .NET 설정 차이가 보이는 모습을 바꿉니다.
특히 JIT의 C#과 Native AOT의 C#은, 같은 「C#」이라도 다른 축입니다.
여기를 섞으면, 비교 대상이 언어가 아니라 배포 형태가 됩니다.
Java
- JDK 벤더와 버전을 고정한다
- GC를 명시한다
- warm-up / measurement / fork를 고정한다
- 힙 크기나 JVM 옵션을 기록한다
- cold start와 steady-state를 나눈다
Java는 JIT의 혜택을 받기 쉬운 반면, 첫 실행의 모습은 꽤 바뀝니다. 그래서 수명이 짧은 프로세스의 비교와 장시간 실행의 비교를 나누는 일이 필수입니다.
Go
- Go 버전을 고정한다
GOMAXPROCS를 고정한다CGO_ENABLED를 명시한다GOGC를 건드리면 반드시 기록한다- 가능하면 benchmark 형식의 출력을 남긴다
Go는 비교적 다루기 쉽지만, 병렬 벤치에서는 GOMAXPROCS의 영향이 큽니다.
또한 cgo를 쓰는지에 따라 세계가 바뀌므로, 그곳은 반드시 조건에 남깁니다.
실행 환경을 맞추는 방법
어느 언어든, 환경을 맞추지 않은 비교는, 대개 환경을 비교하고 있습니다.
flowchart TB
accTitle: 환경을 맞추지 않은 비교의 정체
accDescr: CPU나 OS, 전원 조건, 입력 데이터, 우선도나 코어 수 등의 환경이 맞춰져 있지 않은 비교에서는, 결과의 차이가 언어의 차이가 아니라 환경의 차이가 되어 버린다는 점을 나타내는 그림.
en1["환경이 맞춰져 있지 않은 두 측정"] --> en2["결과에 차이가 난다"]
en2 --> en3{"그 차이는 무엇의 차이인가"}
en3 -->|"환경이 맞춰져 있으면"| en4["구현이나 언어 쪽 차이로 읽을 수 있다"]
en3 -->|"맞춰져 있지 않으면"| en5["환경을 비교하고 있을 뿐이다"]
그림 12: 차이를 언어 탓으로 돌릴 수 있는 것은, 환경의 차이를 지운 뒤뿐입니다.
맞춰야 할 것
- 같은 CPU / 메모리 / 스토리지
- 같은 OS 버전
- 같은 전원 조건
- 같은 실온에 가까운 조건
- 같은 입력 데이터
- 같은 프로세스 우선도
- 같은 코어 수 조건
- 같은 컨테이너 또는 베어메탈 조건
특히 먹히는 것
전원 설정과 CPU 주파수
노트북이면, AC 연결인지 배터리인지 그것만으로도 다른 세계가 됩니다. CPU governor나 power mode가 맞춰져 있지 않으면, 비교 결과가 꽤 흔들립니다.
Windows에서의 전원 조건, 알림, 백그라운드 노이즈, 발열, 실행 순서를 맞추는 방법에 대해서는, 다른 글의 Windows에서 다른 버전의 프로그램 실행 속도를 어떻게 비교하는가 에서 자세히 정리했습니다. Windows에서 잰다면, 여기는 꽤 먹힙니다.
발열
처음 몇 번만 빠르고 후반에 떨어지면, 발열이나 스로틀링을 의심합니다. A를 전부 돌린 뒤 B를 전부 도는 것보다, A / B / A / B처럼 번갈아 도는 편이 치우침을 줄일 수 있습니다.
flowchart TB
accTitle: 발열의 치우침과 실행 순서
accDescr: 처음 몇 번만 빠르고 후반에 떨어지면 발열이나 스로틀링을 의심하고, A를 전부 돌린 뒤 B를 도는 것이 아니라 A와 B를 번갈아 돌아 치우침을 줄인다는 점을 나타내는 그림.
th1["후반만 결과가 떨어진다"] --> th2["발열이나 스로틀링을 의심한다"]
th2 --> th3["A를 전부 돌린 뒤 B로 가지 않는다"]
th3 --> th4["A와 B를 번갈아 돈다"]
그림 13: 발열은 지울 수 없지만, 순서를 궁리하면 둘에게 공정하게 나눌 수 있습니다.
백그라운드 처리
업데이트, 인덱스, 동기화, 바이러스 스캔, 브라우저, 채팅 도구. 이 부분은 눈에 잘 띄지 않지만, 흔히 끼어듭니다.
무엇을 재어야 하는가
언어 비교에서는, 적어도 이 네 가지를 나눠 보는 것을 권합니다.
1. wall-clock time
사용자가 기다리는 실제 시간입니다. 먼저 봐야 할 지표는 이것입니다.
2. CPU time
「실제로 CPU를 얼마나 썼는가」입니다. wall-clock만 빨라도 CPU time이 바뀌지 않으면, 대기 시간이나 I/O의 영향일 수 있습니다.
3. memory / allocations
- 최대 RSS
- 총 할당량
- alloc 횟수
- GC 횟수
- GC pause
이 부분을 보면, 속도 뒤에 있는 비용이 보입니다.
4. 분포
- 중앙값
- p95 / p99
- min / max
- 표준 편차나 흩어짐
평균만으로 말하면, 가끔 튀는 처리의 정체가 보이지 않습니다.
flowchart TB
accTitle: 나눠 보는 네 가지 지표
accDescr: 언어 비교에서는, 사용자가 기다리는 실제 시간인 wall-clock, 실제로 CPU를 쓴 시간, 최대 RSS나 할당 등의 메모리, 중앙값이나 퍼센타일 등의 분포라는 네 가지를 나눠 본다는 점을 나타내는 그림.
ms1["wall-clock time"] --> ms5["네 가지를 나눠 본다"]
ms2["CPU time"] --> ms5
ms3["메모리와 할당"] --> ms5
ms5 -.-> ms4["분포〔중앙값이나 p95〕도 별도로 본다"]
그림 14: 속도의 숫자는 한 종류가 아니라, 뒤의 비용까지 나란히 두어야 비로소 읽을 수 있습니다.
실행 절차의 추천
실제 운영에 쓰기 쉬운 흐름은, 대개 이 순서입니다.
flowchart TB
a1["1. workload를 정한다"] --> a2["2. 공통 데이터셋을 고정한다"]
a2 --> a3["3. 올바름 확인을 먼저 통과시킨다"]
a3 --> a4{"모든 구현의 checksum이<br/>일치했는가"}
a4 -- 아니요 --> a3
a4 -- 예 --> a5["4. build 조건을 고정한다"]
a5 --> a6["5. cold와 warm을 나눈다"]
a6 --> a7["6. 실행 순서를 무작위화해 돈다"]
a7 --> a8{"필요한 횟수에<br/>도달했는가"}
a8 -- 아니요 --> a7
a8 -- 예 --> a9["8. raw data를 저장한다"]
a9 --> a10{"의미 있는 차이가<br/>났는가"}
a10 -- 아니요 --> a11["조건과 횟수를 기록하고 끝낸다"]
a10 -- 예 --> a12["9. profile을 찍어 원인을 파헤친다"]
그림 15: workload 결정부터 올바름 확인, 무작위화한 본측정, raw data 저장까지의 실행 절차입니다.
1. workload를 정한다
먼저, 무엇을 비교하고 싶은지를 명확히 합니다.
- 시작 시간
- 정상 throughput
- tail latency
- 메모리 효율
- 병렬 스케일
2. 공통 데이터셋을 고정한다
입력 데이터는 고정 seed나 고정 파일로 맞춥니다. 데이터 생성까지 포함한다면, 그것도 각 언어에서 같은 조건으로 해야 합니다.
3. 올바름 확인을 먼저 통과시킨다
작은 데이터와 큰 데이터에서, 모든 구현이 같은 결과를 반환하는지를 확인합니다. checksum이나 해시를 내게 하면 다루기 쉽습니다.
4. build 조건을 고정한다
각 언어에서 Release / 최적화된 실행 형식을 만들고, 버전과 플래그를 기록합니다.
5. cold와 warm을 나눈다
특히 C#과 Java는 여기가 중요합니다.
- cold: 프로세스 기동 직후를 포함
- warm: 몇 번 실행한 뒤의 안정 상태
어디까지를 재고 있는지를 그림으로 보면, 이 둘이 별개임이 분명해집니다.
flowchart LR
subgraph coldrange["cold로 재는 범위"]
direction LR
s1["프로세스 기동"] --> s2["런타임 초기화<br/>클래스 로드"]
s2 --> s3["JIT의 첫 컴파일"] --> s4["본체 처리 1회째"]
end
s4 --> s5["본체 처리 2회째 이후<br/>Tiered Compilation이 진행된다"]
subgraph warmrange["warm으로 재는 범위"]
direction LR
s6["안정 상태의 본체 처리"]
end
s5 --> s6
그림 16: cold는 프로세스 기동부터 JIT의 첫 컴파일까지를 포함하고, warm은 안정 상태만 잽니다.
C++와 Go에는, 미리 컴파일되어 있는 이상 「JIT의 첫 컴파일」에 해당하는 단계가 없고, 런타임 초기화도 비교적 가볍게 끝납니다. cold 차이의 상당수는 여기서 생깁니다. 그래서 이 둘은 같은 표에 섞지 않는 편이 깔끔합니다.
6. 실행 순서를 번갈아 또는 무작위화한다
예:
cpp -> csharp -> java -> go
go -> java -> cpp -> csharp
csharp -> go -> java -> cpp
...
이렇게 하면 발열이나 노이즈의 치우침이 줄어듭니다.
7. 횟수를 확보한다
가벼운 microbenchmark라면 꽤 많이, end-to-end라면 적어도 10회 이상은 필요합니다. 차이가 작은데 횟수가 적으면, 해석이 꽤 위험해집니다.
8. raw data를 저장한다
집계 결과만이 아니라, 각 run의 생데이터를 남깁니다. 나중에 보면, 이상치나 warm-up의 버릇을 읽을 수 있습니다.
9. 차이가 나면 profile을 찍는다
차이가 났을 때는, 그때 비로소 원인을 파헤칩니다.
- CPU profile
- allocation profile
- GC 로그
- flame graph
- OS 쪽 트레이스
여기까지 가면, 「빠르다 / 느리다」가 아니라, 왜 그렇게 되는가를 말할 수 있게 됩니다.
결과를 읽는 방법
숫자가 나온 뒤에도, 읽는 방법을 잘못하면 역시 위험합니다.
첫 실행만 C# / Java가 느리다
JIT, 클래스 로드, 초기화의 영향을 의심합니다. 이 경우는,
- 시작 시간이 중요하면 의미 있는 차이
- 장시간 실행이 주제면 다른 표로 나눠야 할 차이
입니다.
C++가 tight loop에서 강하다
저수준 최적화, 객체 배치, 최소한의 런타임 오버헤드가 먹히고 있을 가능성이 있습니다. 다만 그것만 보고 「그래서 실제 서비스에서도 가장 빠르다」고 말하는 것은 비약입니다.
Go가 시작 시간이나 배포하기 쉬움에서 유리해 보인다
단일 바이너리, 비교적 가벼운 기동, 다루기 쉬운 병렬 모델이 먹힐 때가 있습니다. 다만 모든 CPU 계열 workload에서 유리한 것은 아닙니다.
C# / Java가 steady-state에서 상당히 따라잡거나, 뒤집힌다
JIT 최적화가 먹히고 있을 가능성이 있습니다. 이 또한 드문 이야기가 아닙니다. 그래서 기동을 포함한 비교와 정상 상태의 비교를 섞지 않는 일이 중요합니다.
allocation-heavy한 처리에서 차이가 크다
이 경우는, 언어 이름보다
- 메모리 레이아웃
- 문자열이나 map을 다루는 방식
- GC의 거동
- 여분의 복사
쪽이 먹히고 있는 일이 많습니다.
flowchart TB
accTitle: 차이가 났을 때 읽는 방법
accDescr: 첫 실행만 C#이나 Java가 느리면 JIT이나 초기화의 영향을 의심하고, 시작 시간이 중요하면 의미 있는 차이, 장시간 실행이 주제면 다른 표로 나눠야 할 차이로 다루며, 차이가 나면 원인을 profile로 파헤친다는 점을 나타내는 그림.
rd1["차이가 났다"] --> rd2{"어느 조건에서의 차이인가"}
rd2 -->|"기동 포함의 차이"| rd3["시작 시간이 주제면 의미가 있다"]
rd2 -->|"정상 상태의 차이"| rd4["장시간 실행의 비교로 다룬다"]
rd3 --> rd5["profile로 원인을 파헤친다"]
rd4 --> rd5
그림 17: 숫자의 크고 작음보다 먼저, 그 차이가 어느 무대의 차이인지를 확인합니다.
기록 템플릿
벤치 결과에는, 적어도 이 정도의 항목을 남겨 두면 나중에 도움이 됩니다.
timestamp,language,scenario,run_kind,cold_or_warm,elapsed_ms,cpu_ms,max_rss_mb,alloc_bytes,gc_count,checksum
compiler_or_runtime,compiler_version,flags,os,cpu,threads,input_id,notes
예를 들어 run_kind는 이런 식으로 나눌 수 있습니다.
micromacrostartupparallel
cold_or_warm은, 어느 쪽인지를 반드시 명시하고 싶습니다.
coldwarm
각 열에 무엇을 넣을지는, 먼저 정해 두면 흔들리지 않습니다.
| 열 | 넣을 것 | 형식의 예 |
|---|---|---|
timestamp |
run의 시작 시각. 시간대에 따른 변동을 나중에 보기 위해 씁니다 | ISO 8601 형식. 2026-03-17T10:00:00+09:00 |
language |
구현의 식별자. 표기 흔들림을 막기 위해 고정 어휘로 합니다 | cpp / csharp / java / go |
scenario |
벤치 항목 이름 | sort_int32_10m |
run_kind |
측정의 종류 | micro / macro / startup / parallel |
cold_or_warm |
기동을 포함하는지 | cold / warm |
elapsed_ms |
wall-clock. 소수점 셋째 자리까지 남기면 나중에 곤란하지 않습니다 | 소수의 밀리초 |
cpu_ms |
프로세스의 CPU 시간. user와 system을 더한 값 | 소수의 밀리초 |
max_rss_mb |
최대 RSS | 정수 또는 소수의 MB |
alloc_bytes |
총 할당 바이트 수. 잡을 수 없는 언어에서는 빈칸으로 두고, 빈칸이라는 것 자체를 남깁니다 | 정수, 또는 빈칸 |
gc_count |
GC 횟수. C++에서는 항상 빈칸 | 정수, 또는 빈칸 |
checksum |
올바름 확인용. 모든 구현에서 일치하는지를 따로 검사합니다 | 16진 문자열 |
compiler_or_runtime |
컴파일러·런타임의 종류 | msvc / dotnet / temurin / go |
compiler_version |
컴파일러·런타임 버전. 마이너까지 적습니다 | 해당 도구가 내는 버전 문자열 |
flags |
최적화 조건. /O2, -O3 -flto, Server GC, GOMAXPROCS=8 등 |
공백으로 구분한 문자열 |
os / cpu / threads |
실행 환경 | OS 이름과 빌드 번호, CPU 형번, 사용 스레드 수 |
input_id |
데이터셋의 식별자. 파일 해시로 두면 확실합니다 | 파일 이름과 해시 |
notes |
이상이 있던 run의 메모 | 자유 기술 |
측정값 열은 빈칸을 허용하고, 빈칸이라는 것 자체를 남기는 것이 요점입니다. 「C++이니까 gc_count는 없다」는 정보이지만, 열 자체를 지워 버리면 나중에 알 수 없습니다.
평균만 보면 무엇을 놓치는가
집계할 때, 평균 하나로 묶으면 정보가 날아갑니다. 다음은 산수를 설명하기 위한 가상의 숫자이며, 어느 언어의 실측값도 아닙니다. 어떤 구현 하나의 10 run을 빠른 순으로 늘어놓은 elapsed_ms라고 합시다.
98, 99, 100, 101, 101, 102, 103, 104, 106, 720
이 10개에서 나오는 대표값은 이렇게 됩니다.
| 지표 | 값 | 읽는 법 |
|---|---|---|
| 평균 | 163.4 | 마지막 한 번에 끌려가, 실제 상용 구간에서 6할 정도 위로 어긋나 있습니다 |
| 중앙값 | 101.5 | 10번 가운데 9번의 체감에 가까운 값입니다 |
| min / max | 98 / 720 | 차이가 7배 이상이므로, 먼저 이상치의 원인을 조사하라는 신호가 됩니다 |
| p95 / p99 | 낼 수 없다 | 10샘플에서는 95 퍼센타일도 99 퍼센타일도 의미를 갖지 않습니다 |
즉, 평균만 실은 표는 「가끔 크게 멈추는 구현」과 「안정적으로 조금 느린 구현」을 같은 얼굴로 만듭니다. 표를 만드는 방식으로는, 다음 차이가 먹힙니다.
| 관점 | 약한 결과 표 | 쓸 수 있는 결과 표 |
|---|---|---|
| 대표값 | 평균만 | 중앙값을 주인공으로 하고, min / max와 흩어짐을 병기 |
| 시행 횟수 | 적혀 있지 않다 | run 수와, 이상치를 빼었는지의 방침을 명시 |
| cold / warm | 섞여 있다, 또는 구분 없음 | 다른 표, 또는 다른 행으로 분리 |
| 올바름 | 건드리지 않았다 | 모든 구현에서 checksum이 일치했음을 명시 |
| 조건 | 「같은 PC에서 쟀습니다」 | OS, CPU, 컴파일러·런타임 버전, 최적화 플래그, 스레드 수까지 기재 |
| 생데이터 | 집계값만 | raw CSV의 저장 위치를 병기 |
p95나 p99를 싣고 싶다면, 애초에 run 수를 늘려야 합니다. 분포를 말하려면, 분포가 보이는 수의 샘플이 필요하다는 이야기일 뿐입니다.
벤치는, 재는 일보다 나중에 해석할 수 있는 일 쪽이 더 중요하기도 합니다.
flowchart TB
accTitle: 평균 하나가 숨기는 것
accDescr: 한 번의 큰 이상치가 있으면 평균은 상용 구간에서 크게 어긋나고, 중앙값 쪽이 체감에 가까워지며, min과 max의 차이가 크면 이상치의 원인을 조사하라는 신호가 되므로, 평균만의 표는 위험하다는 점을 나타내는 그림.
av1["한 번만 큰 이상치가 나온다"] --> av2["평균이 상용 구간에서 위로 어긋난다"]
av2 --> av3["평균만의 표는 실태를 숨긴다"]
av3 --> av4["중앙값과 min과 max를 병기한다"]
av4 -.-> av5["이상치는 원인을 조사하라는 신호"]
그림 18: 평균은 거짓말을 하지 않지만, 이상치의 존재를 침묵합니다.
정리
C# / C++ / Java / Go의 속도 비교에서 정말로 중요한 것은, 어느 언어가 가장 빠른가라는 거친 질문을, 어느 workload를, 어떤 조건에서, 어떤 지표로 비교할 것인가 라는 실험의 형태로 바꾸는 일입니다.
특히 놓치지 말아야 할 것은, 이 부근입니다.
- 시작 시간과 정상 상태를 나눈다
- 같은 알고리즘, 같은 입력, 같은 올바름 확인으로 잰다
- 벤치 하나로 결론을 내지 않는다
- 언어 안의 benchmark와 언어를 넘나드는 benchmark를 나눈다
- 평균보다 중앙값과 분포를 본다
- 조건과 raw data를 남긴다
그리고 마지막에 가장 중요한 것은, 언어 이름으로 승패를 정하려 하지 않는 것입니다. 현실의 성능은, 언어, 런타임, 라이브러리, 빌드 조건, 데이터, OS, 하드웨어의 조합으로 결정됩니다.
「C++가 빠르다」「Java가 강하다」「Go가 가볍다」「C#이어도 충분히 빠르다」는 이야기는, 전부 어떤 의미에서는 맞습니다. 다만, 어떤 조건에서 그렇게 말하고 있는지가 빠지면, 논의가 맞물리지 않은 채로 끝납니다.
조건을 맞추고, 여러 workload에서, cold / warm을 나누고, 분포까지 본다. 평범해 보이지만, 결국 이것이 가장 강합니다.
flowchart TB
accTitle: 거친 질문을 실험의 형태로 바꾼다
accDescr: 어느 언어가 가장 빠른가라는 거친 질문을, 어느 workload를 어떤 조건에서 어떤 지표로 비교할 것인가라는 실험의 형태로 바꾸고, 조건을 맞춰 여러 workload에서 cold와 warm을 나눠 분포까지 보는 일이 가장 강하다는 점을 나타내는 그림.
sq1["어느 언어가 가장 빠른가"] --> sq2["실험의 형태로 다시 바꾼다"]
sq2 --> sq3["workload와 조건과 지표를 정한다"]
sq3 --> sq4["cold와 warm을 나눠 돈다"]
sq4 --> sq5["분포까지 보고 조건과 함께 남긴다"]
그림 19: 질문을 다시 세우는 일이야말로, 이 글의 가장 큰 결론입니다.
참고 자료
-
BenchmarkDotNet Getting Started https://benchmarkdotnet.org/articles/guides/getting-started.html
-
BenchmarkDotNet Setup and Cleanup(
[IterationSetup]를 써도 되는 조건) https://benchmarkdotnet.org/articles/features/setup-and-cleanup.html -
OpenJDK JMH Project https://openjdk.org/projects/code-tools/jmh/
-
JMH
Leveljavadoc(Level.Invocation의 제약과 경고) https://javadoc.io/doc/org.openjdk.jmh/jmh-core/latest/org/openjdk/jmh/annotations/Level.html -
JMH GitHub Repository / README https://github.com/openjdk/jmh
-
Go
testingpackage https://pkg.go.dev/testing -
Go
benchstathttps://pkg.go.dev/golang.org/x/perf/cmd/benchstat -
Google Benchmark User Guide https://google.github.io/benchmark/user_guide.html
-
Windows에서 다른 버전의 프로그램 실행 속도를 어떻게 비교하는가 https://comcomponent.com/ko/blog/windows-benchmark-comparing-program-versions/
관련 토픽
이 글과 함께 보면 이해하기 쉬운 페이지입니다.
이 주제의 상담처
성능 비교의 설계, 측정 조건을 맞추는 방법, 결과의 해석, 원인의 파고들기는, 다음 서비스와 잘 맞는 주제입니다.
관련 기사
같은 태그를 공유하는 최신 기사입니다. 더 가까운 주제로 지식을 넓힐 수 있습니다.
Named Pipe 실무 ── Windows 프로세스 간 통신의 정석을 설계부터 보안까지
Windows의 프로세스 간 통신의 정석인 Named Pipe를 실무 관점에서 해설합니다. 바이트/메시지 모드 선택, 여러 클라이언트를 처리하는 서버 설계, ACL과 impersonation 보안, .NET NamedPipeStream까지 1차 ...
spurious wakeup ── 조건 변수가 「알림 없이 깨어나는」 이유와 Windows에서 올바르게 기다리는 방법
조건 변수의 wait는 알림이 오지 않아도 깨어날 수 있습니다(spurious wakeup). 사양이 이를 허용하는 이유를 Windows 구현에서 밝히고, while과 predicate로 쓰는 올바른 대기 방법을 Win32·C++·C# 코드로 보...
DLL・COM 인터페이스의 하위 호환성 ── 어떤 변경이 호출 측을 깨뜨리는지의 판단표
DLL이나 COM 컴포넌트의 어떤 변경이 호출 측을 깨뜨리는지. 바이너리 호환・소스 호환・동작 호환의 3계층을 정리하고, 변경 내용별 판단표, COM 인터페이스 불변의 철칙, semver 운용까지를 실무 가이드로 정리합니다.
Windows 앱에서 자식 프로세스를 안전하게 다루는 체크리스트
Windows 앱에서 자식 프로세스를 안전하게 다루려면, 시작 API보다 프로세스 트리의 소유권과 종료 절차 설계가 중요합니다. Job Object, 종료 전파, 표준 입출력, watchdog을 정리합니다.
공유 메모리의 함정과 실무 베스트 프랙티스
공유 메모리를 실무에서 쓸 때의 함정과, 동기, 가시성, 수명, ABI, 권한까지 포함해 사고율을 낮추는 설계를 정리합니다.
관련 토픽
이 기사와 가까운 토픽 페이지입니다. 기사를 출발점 삼아 관련 서비스와 다른 기사로 이어집니다.
Windows 기술 토픽
Windows 개발, 장애 조사, 기존 자산 활용에 관한 KomuraSoft LLC 기사를 모은 토픽 허브입니다.
이 주제와 연결되는 서비스
이 기사는 다음 서비스 페이지로 이어집니다. 가까운 입구부터 확인해 주세요.
기술 상담 & 설계 리뷰
성능 비교의 설계, 측정 조건을 맞추는 방법, warm-up이나 통계를 읽는 법까지 포함해, 기술 상담·설계 리뷰와 잘 맞는 주제입니다.
장애 조사 & 원인 분석
언어나 버전을 넘나드는 성능 차의 원인 분리, 병목 특정, 측정 절차의 타당성 확인은, 장애 조사·원인 분석으로 진행하기 쉽습니다.
자주 묻는 질문
이 기사 주제에 대해 상담 시 자주 나오는 질문을 모았습니다.
- C#·C++·Java·Go 가운데 어느 쪽이 가장 빠른가요?
- 숫자 하나로는 정할 수 없습니다. 실제 성능은 언어, 런타임, 라이브러리, 빌드 조건, 데이터, OS, 하드웨어의 조합으로 결정되기 때문입니다. 중요한 것은 「어느 언어가 가장 빠른가」라는 질문을 「어느 workload를, 어떤 조건에서, 어떤 지표로 비교할 것인가」라는 실험의 형태로 바꾸는 일입니다. 시작 시간에서는 C#/Java의 JIT과 초기화 비용이 잘 보이는 반면, steady-state에서는 JIT 최적화가 먹혀 상당히 따라잡거나 뒤집히는 일도 드물지 않습니다.
- C#이나 Java 벤치마크에서 warm-up을 나누는 이유는 무엇인가요?
- C#과 Java는 보통 JIT의 영향을 받으므로, 첫 실행을 재면 프로그램 본체의 속도뿐 아니라 런타임 기동·클래스 로드·JIT 준비까지 함께 재게 되기 때문입니다. 반면 C++와 Go는 보통 미리 컴파일되어 있습니다. cold와 warm은 둘 다 의미가 있지만 같은 의미는 아니므로, 프로세스 기동 직후를 포함하는 cold와 몇 번 실행한 뒤의 안정 상태인 warm은 별개로 다루고 같은 표에 섞지 않는 편이 깔끔합니다.
- 언어를 넘나드는 벤치마크는 어떻게 설계해야 하나요?
- 2층 구성을 권합니다. 언어 안의 측정은 BenchmarkDotNet(C#), JMH(Java), go test -bench와 benchstat(Go), Google Benchmark(C++)처럼 그 언어에 맞는 하네스를 씁니다. 언어를 넘나드는 비교에서는 각 하네스 결과를 그대로 나란히 두는 것은 위험하므로, 각 구현을 같은 CLI 계약으로 호출할 수 있는 실행 파일로 만들고, 바깥의 공통 러너에서 실행 순서 무작위화, cold/warm 분리, 동일 데이터셋 사용, checksum 검증, raw data 저장을 하는 형태가 맞습니다.
- C++ 마이크로벤치마크에서 주의할 점은 무엇인가요?
- 최적화로 처리가 사라지는 함정을 조심해야 합니다. 컴파일러가 「이 계산 결과는 아무도 쓰지 않는다」고 판단하면 처리 자체를 지워 버려, 빠른 것이 아니라 애초에 아무것도 하지 않은 결과가 나오기도 합니다. 그래서 결과를 사용하거나 checksum을 출력하고, benchmark 프레임워크의 최적화 억제 기능이 중요합니다. 또한 C++는 조건 차이가 그대로 크게 나오므로, 어떤 컴파일러로, 어떤 플래그(-O3/O2, LTO, PGO 등)로, 어떤 STL로 쟀는지를 명시하는 일이 꽤 중요합니다.