C++ vs Go: 성능, 동시성 모델, 프로젝트별 선택 기준
들어가며: 두 언어가 같은 문제를 푸는 방식
C++와 Go는 둘 다 서버와 시스템 소프트웨어에 쓰이고 둘 다 동시성을 내세우지만, 동시성을 다루는 방식은 정반대에 가깝습니다. C++는 OS 스레드와 이벤트 루프(Asio 등)를 개발자가 직접 설계하고, 메모리 해제 시점까지 통제합니다. Go는 고루틴(런타임이 관리하는 경량 실행 단위)과 채널을 언어에 넣고, 스케줄링과 메모리 회수를 런타임과 GC에 맡깁니다.
언어 선택이 잘못되면 대개 둘 중 하나로 나타납니다. 지연 요구가 수 ms 수준인 CRUD API를 C++로 만들면서 HTTP·JSON·DB 계층을 직접 쌓느라 출시가 늦어지거나, 마이크로초 단위 지연 스파이크도 허용되지 않는 시스템을 Go로 만들었다가 GC와 런타임 스케줄링이 만드는 꼬리 지연을 통제하지 못하는 경우입니다. 이 글은 설계 철학, 동시성 모델, 메모리 모델, 성능 특성, 자주 하는 실수를 차례로 비교한 뒤 선택 기준을 정리합니다. 관련 글: C++ 실전 가이드 #7 스레드, C++ 개발자의 뇌 구조로 이해하는 Go.
언어 설계 철학
C++: 쓰지 않는 것에는 비용을 내지 않는다
C++는 1979년 Bjarne Stroustrup이 C에 Simula의 클래스 개념을 접목한 “C with Classes”에서 출발했고, 1983년 C++라는 이름이 붙었습니다. 핵심 원칙은 “쓰지 않는 기능에는 비용을 치르지 않고, 쓰는 기능은 손으로 짠 것보다 나쁘지 않아야 한다”는 제로 오버헤드 원칙입니다. 1998년 첫 ISO 표준(STL, 템플릿, 예외), 2011년 이동 의미론·람다·스마트 포인터·메모리 모델, 2017년 optional·variant·병렬 알고리즘, 2020년 concepts·코루틴·모듈·ranges, 2023년 std::expected·std::print로 이어지며 기능이 계속 늘었습니다. C++가 복잡한 큰 이유 중 하나는 수십 년치 코드와의 하위 호환성을 유지하기 때문입니다.
Go: 대규모 코드베이스의 생산성
Go는 2007년 Google에서 Robert Griesemer, Rob Pike, Ken Thompson이 시작했습니다. Pike는 대형 C++ 바이너리의 긴 빌드를 기다리던 중 새 언어 구상을 시작했다고 회고하는데, 그만큼 빌드 속도·의존성 관리·동시성 코드의 복잡도가 동기였습니다. 그래서 순환 import를 금지해 의존성 그래프를 단순하게 유지하고, 키워드를 25개로 줄였으며, 고루틴과 채널을 언어에 넣고, GC로 수동 메모리 관리를 없앴고, 포매터(gofmt)·테스트·의존성 관리를 표준 도구로 제공합니다. 예외 대신 에러를 값으로 반환하고, 클래스와 상속 대신 구조체와 인터페이스를 조합하며, 암시적 타입 변환이 없습니다. 제네릭은 오랫동안 빠져 있다가 2022년 Go 1.18에서 추가되었습니다.
동시성 모델
C++: OS 스레드와 이벤트 루프
std::thread는 OS 스레드와 1:1로 대응하고, 스케줄링과 선점은 커널이 합니다. C++ 표준은 그 위에 mutex, condition_variable, atomic 같은 동기화 도구만 제공합니다. 동시 작업 수가 수만 개로 늘면 스레드마다 하나씩 만드는 방식은 커널 스케줄링과 컨텍스트 스위칭 비용, 스레드당 커널 자원 때문에 버티기 어렵습니다. 그래서 대규모 동시 연결은 Asio 같은 이벤트 루프로 소수의 스레드가 수만 개 소켓을 논블로킹 I/O로 처리하도록 설계합니다. 즉 C++에서 “작업 수 ≫ 스레드 수” 구조는 언어가 아니라 라이브러리와 아키텍처로 직접 만듭니다. C++20 코루틴을 Asio와 함께 쓰면 콜백 대신 순차 코드처럼 비동기 흐름을 쓸 수 있습니다.
Go: 고루틴과 G·M·P 스케줄러
Go 런타임은 많은 고루틴을 적은 OS 스레드 위에 올리는 M:N 스케줄러입니다.
- G(goroutine): 실행 단위입니다. 스택은 작게(현재 구현은 2KB) 시작해 필요하면 더 큰 스택으로 복사하며 늘어납니다. 함수 프롤로그에 스택 여유를 검사하는 코드가 들어가 이를 가능하게 합니다.
- M(machine): OS 스레드입니다. 시스템 콜에서 막힌 M이 있으면 런타임이 다른 M을 만들어 쓰므로, M의 수는
GOMAXPROCS보다 많아질 수 있습니다. - P(processor): 스케줄링 컨텍스트로, 실행 대기 중인 G의 로컬 큐를 가집니다. P의 수가
GOMAXPROCS이고, 동시에 Go 코드를 실행하는 스레드 수의 상한이 됩니다. 기본값은 사용 가능한 CPU 수입니다(Go 1.25부터는 컨테이너의 CPU 제한도 반영).
M은 P를 하나 붙잡고 그 로컬 큐에서 G를 꺼내 실행합니다. 로컬 큐가 비면 전역 큐와 네트워크 폴러를 확인하고, 다른 P의 큐에서 절반을 훔쳐 옵니다(work stealing). 네트워크 I/O를 기다리는 고루틴은 epoll·kqueue·IOCP 기반 폴러에 등록되고 OS 스레드를 붙잡지 않으므로, 연결마다 고루틴 하나를 만들어도 스레드는 늘지 않습니다. Go 1.14부터는 신호 기반 비동기 선점이 들어가, 함수 호출이 없는 긴 루프도 다른 고루틴을 굶기지 않게 되었습니다.
// 연결마다 고루틴 하나: Go의 관용적인 서버 구조
for {
conn, err := ln.Accept()
if err != nil {
continue
}
go handleConn(conn) // 블로킹 I/O처럼 작성하지만 OS 스레드는 막히지 않음
}
같은 작업, 다른 스타일
여러 URL을 동시에 요청해 결과를 모으는 코드를 비교하면 다음과 같습니다.
#include <future>
#include <string>
#include <vector>
std::vector<std::string> fetchAll(const std::vector<std::string>& urls) {
std::vector<std::future<std::string>> futures;
for (const auto& url : urls) {
futures.push_back(std::async(std::launch::async, [url] { return fetchUrl(url); }));
}
std::vector<std::string> results;
for (auto& f : futures) results.push_back(f.get());
return results;
}
func fetchAll(urls []string) []string {
results := make([]string, len(urls))
var wg sync.WaitGroup
for i, u := range urls {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = fetchURL(u) // 각 고루틴이 서로 다른 인덱스에만 쓰므로 레이스 없음
}()
}
wg.Wait()
return results
}
std::async(std::launch::async, ...)는 호출마다 스레드를 하나씩 만드는 구현이 많아, URL이 수천 개라면 직접 스레드 풀을 두어야 합니다. Go는 고루틴 수천 개가 문제가 되지 않으므로 같은 모양 그대로 확장됩니다. Go 1.22부터는 for 루프 변수가 반복마다 새로 만들어지므로, 클로저가 i와 u를 그대로 캡처해도 안전합니다. 1.21 이하에서는 go func(i int, u string) {...}(i, u)처럼 인자로 넘겨야 했습니다.
스레드와 고루틴의 비용
OS 스레드의 스택은 Linux에서 기본 8MB, Windows에서 1MB를 예약합니다. 다만 이것은 가상 주소 공간 예약이고, 실제 물리 메모리는 사용한 페이지만큼만 소비됩니다. 그래서 스레드 1만 개가 곧바로 80GB의 RAM을 쓰지는 않습니다. 스레드를 많이 만들 때의 진짜 비용은 스레드마다 생기는 커널 자료구조, 커널을 거치는 생성·전환 비용, 그리고 실행 가능한 스레드가 많을 때의 스케줄링·캐시 오염입니다. 시스템 설정(ulimit -u, threads-max, 32비트 주소 공간)에 따라서는 스레드 생성 자체가 실패하기도 합니다.
고루틴은 작은 스택으로 시작하고 전환이 사용자 공간에서 일어나므로, 같은 수의 동시 작업을 훨씬 적은 메모리와 전환 비용으로 유지합니다. 고루틴 10만 개도 일반적인 서버에서 다룰 수 있는 규모입니다. 대신 Go 런타임과 GC가 항상 함께 동작하므로, 고루틴 수가 적은 단순 프로그램에서도 런타임 비용은 사라지지 않습니다.
메모리 모델
C++11은 메모리 모델을 표준에 명시했습니다. std::atomic<T>와 여섯 가지 std::memory_order(relaxed, consume, acquire, release, acq_rel, seq_cst)로 원자 연산 사이의 동기화 관계를 세밀하게 표현할 수 있고, 데이터 레이스가 있는 프로그램은 미정의 동작입니다. 컴파일러는 레이스가 없다고 가정하고 최적화하므로, 레이스가 있는 코드는 “가끔 이상한 값”이 아니라 어떤 일이든 일어날 수 있는 코드입니다. volatile은 스레드 동기화를 보장하지 않습니다.
Go는 The Go Memory Model 문서가 채널 송수신, 뮤텍스, sync/atomic, sync.Once 등이 만드는 happens-before 관계를 정의합니다. 동기화 없는 동시 쓰기는 데이터 레이스이고, 이 문서는 C++보다 레이스의 결과를 좁게 규정합니다. 단일 워드 크기 값에 대한 레이스는 대체로 이전에 쓰인 값 중 하나를 읽는 정도로 끝나지만, 인터페이스·슬라이스·문자열처럼 여러 워드로 된 값에 대한 레이스는 찢어진 값을 읽어 메모리 손상이나 크래시로 이어질 수 있다고 명시합니다. 즉 Go에서도 레이스는 “안전한 버그”가 아닙니다. go test -race와 go run -race로 실행 중 레이스를 찾을 수 있고, C++에서는 ThreadSanitizer(-fsanitize=thread)가 같은 역할을 합니다.
실무에서의 차이는 표현력입니다. 락프리 큐 같은 자료구조를 메모리 순서까지 조정해 만들어야 한다면 C++ 쪽 도구와 문헌이 훨씬 풍부합니다. Go는 대부분의 코드를 채널과 sync.Mutex로 쓰고, sync/atomic은 카운터·플래그 같은 단순한 용도에 두는 편이 안전합니다.
컴파일 모델과 성능
빌드 속도
C++는 #include로 헤더를 텍스트 그대로 붙여 넣기 때문에 번역 단위가 커지고, 템플릿은 쓰이는 타입마다 인스턴스화되며, LLVM·GCC는 시간을 들여 공격적으로 최적화합니다. 큰 프로젝트에서 헤더 하나를 고치면 많은 파일이 다시 컴파일되므로, PCH, ccache, C++20 모듈, 헤더 의존성 정리 같은 대책이 필요합니다. Go는 패키지 단위로 컴파일하고, 의존 패키지의 컴파일 결과(export 데이터)만 읽으며, 최적화도 컴파일 속도와 균형을 맞춰 보수적으로 합니다. 그래서 같은 규모에서 빌드가 훨씬 빠른 편이고, 이 빠른 피드백 루프가 Go의 큰 장점입니다.
실행 성능
순수 연산에서는 C++가 유리한 경우가 많습니다. C++ 컴파일러는 인라이닝, 루프 자동 벡터화(SIMD), 프로파일 기반 최적화를 적극적으로 하고, 메모리 레이아웃과 할당 시점을 개발자가 통제할 수 있습니다. Go 컴파일러는 자동 벡터화를 하지 않고, 이스케이프 분석에서 힙으로 빠진 객체는 GC 대상이 됩니다. 다만 차이의 크기는 작업마다 크게 달라 “Go가 몇 퍼센트 느리다” 같은 일반적인 숫자는 의미가 없습니다. 웹 API처럼 대부분의 시간이 네트워크·DB 대기에 쓰이는 서비스에서는 언어의 연산 속도 차이가 응답 시간에 거의 드러나지 않습니다.
제네릭도 비용 구조가 다릅니다. C++ 템플릿은 타입마다 별도 코드를 만드는 단형화(monomorphization)라 실행 시 추상화 비용이 거의 없지만 컴파일 시간과 바이너리 크기가 늘어납니다. Go 제네릭은 메모리 표현이 같은 타입끼리 코드를 공유하고 타입 정보를 사전(dictionary)으로 넘기는 방식(GC shape stenciling)이라 컴파일이 가볍고 바이너리가 덜 커지지만, 상황에 따라 간접 호출 비용이 남습니다. Go 인터페이스 호출은 동적 디스패치이고, 인터페이스에 값을 담으면 힙 할당이 생길 수 있어 핫 루프에서는 주의가 필요합니다.
GC와 꼬리 지연
Go GC는 애플리케이션과 동시에 동작하는 마크-스윕 방식이고, 전체를 멈추는 STW 구간은 짧게(일반적으로 1ms 미만) 유지되도록 설계되어 있습니다. 하지만 GC가 쓰는 CPU, 할당이 많은 고루틴이 마킹을 돕는 mark assist, 힙 크기에 따른 GC 빈도가 p99·p999 지연에 영향을 줄 수 있습니다. GOGC와 GOMEMLIMIT로 GC 빈도와 메모리 상한을 조절하고, GODEBUG=gctrace=1, runtime/metrics, pprof로 할당과 GC를 관찰하며, 버퍼 재사용으로 할당 자체를 줄이는 것이 기본 대응입니다.
C++는 해제 시점이 코드에 드러나므로(RAII, 아레나 할당, 미리 할당한 풀) 지연 분포를 더 엄격하게 통제할 수 있습니다. 그 대가로 해제 후 사용, 이중 해제, 수명 버그를 막는 책임이 개발자와 도구(ASan, 정적 분석)로 넘어옵니다. 판단 기준은 결국 “드물게 생기는 수백 마이크로초~수 ms의 지연 스파이크가 서비스 계약상 허용되는가”입니다.
바이너리와 배포
Go는 기본적으로 런타임과 의존성을 모두 포함한 정적 바이너리 하나를 만들고, GOOS·GOARCH 환경 변수만으로 크로스 컴파일되어 컨테이너 이미지를 scratch 위에 바이너리 하나로 만들 수 있습니다. 대신 런타임과 GC가 늘 포함되므로 아주 작은 프로그램도 수 MB 단위가 됩니다(-ldflags="-s -w"로 디버그 정보를 빼면 줄어듭니다). C++는 바이너리를 매우 작게 만들 수 있지만, 동적 라이브러리 의존성과 대상 시스템의 libstdc++·glibc 버전을 신경 써야 하고 크로스 컴파일에는 대상별 툴체인이 필요합니다.
자주 하는 실수
C++: 연결마다 detach한 스레드
// 잘못된 예: 연결 수만큼 스레드가 생기고, detach라 종료·예외 처리도 어려움
void on_accept(int fd) {
std::thread([fd] { process_request(fd); }).detach();
}
연결 수가 늘면 스레드 수가 그대로 늘어 스케줄링 비용과 커널 자원 한계에 부딪힙니다. 이벤트 루프 하나에 소수의 스레드를 두고 비동기 I/O로 처리하는 것이 일반적입니다.
// 이벤트 루프 + 스레드 풀
boost::asio::io_context ioc;
auto guard = boost::asio::make_work_guard(ioc); // 할 일이 없어도 run()이 바로 끝나지 않게
std::vector<std::thread> threads;
for (unsigned i = 0; i < std::max(1u, std::thread::hardware_concurrency()); ++i) {
threads.emplace_back([&ioc] { ioc.run(); });
}
// ... acceptor와 세션을 ioc에 등록, 종료 시 guard.reset() 후 join
for (auto& t : threads) t.join();
멀티스레드 Asio 서버의 strand와 세션 수명 관리는 멀티스레드 서버 글에서 자세히 다룹니다.
C++: 습관적인 shared_ptr
모든 객체를 shared_ptr로 넘기면 복사마다 원자적 참조 카운트 연산이 일어나고, 소유권이 어디 있는지 코드에서 사라집니다. 소유자가 하나면 unique_ptr, 소유하지 않고 쓰기만 하면 참조나 원시 포인터를 넘기고, 비동기 작업처럼 수명이 정말 공유될 때만 shared_ptr을 씁니다.
Go: CPU 바운드 작업을 고루틴으로 쏟아붓기
// 잘못된 예: 작업마다 고루틴 → 동시에 도는 건 GOMAXPROCS개뿐, 나머지는 대기하며 메모리만 차지
for _, img := range images {
go resize(img)
}
CPU 바운드 작업은 코어 수만큼의 워커가 작업 채널에서 꺼내 가도록 합니다.
jobs := make(chan Image)
var wg sync.WaitGroup
for w := 0; w < runtime.NumCPU(); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for img := range jobs {
resize(img)
}
}()
}
for _, img := range images {
jobs <- img
}
close(jobs) // 워커들의 range가 끝나도록 반드시 닫음
wg.Wait()
Go: 고루틴 누수
채널을 닫지 않으면 range로 기다리는 고루틴이 영원히 남고, 아무도 받지 않는 채널에 보내는 고루틴도 영원히 막힙니다. 이런 고루틴은 GC로 회수되지 않으므로 서버에서 천천히 메모리가 늘어나는 원인이 됩니다. 채널은 보내는 쪽이 끝났을 때 닫고, 취소가 필요한 고루틴에는 context.Context를 넘겨 select로 ctx.Done()을 함께 기다리게 합니다.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.example.com", nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // 타임아웃이면 errors.Is(err, context.DeadlineExceeded)
}
defer resp.Body.Close() // 에러가 없을 때만 Body가 있음
양쪽 공통: 데이터 레이스와 데드락
두 언어 모두 공유 변수를 동기화 없이 동시에 쓰면 레이스이고, 서로 다른 순서로 락을 잡거나(뮤텍스 A→B, B→A) 서로의 채널을 기다리면 데드락입니다. 락은 항상 같은 순서로 잡고(C++는 std::scoped_lock으로 여러 락을 한 번에), 채널은 방향을 단순하게 유지하며, 테스트는 ThreadSanitizer나 -race를 켠 빌드로 돌립니다. Go 격언 “메모리를 공유해 통신하지 말고, 통신해 메모리를 공유하라”는 데이터 흐름이 있는 곳에 채널을 쓰라는 뜻이지, 뮤텍스를 피하라는 뜻은 아닙니다. 캐시나 카운터처럼 상태를 보호하는 데는 뮤텍스가 더 단순합니다.
선택 기준
flowchart TD
A[요구사항 분석] --> B{지연 스파이크 허용 범위?}
B -->|마이크로초 단위도 계약 위반| C[C++ 또는 Rust]
B -->|수 ms 이상 허용| D{동시 연결 수?}
D -->|수만 개 이상| E{개발 속도 우선?}
E -->|예| F[Go]
E -->|아니오| G[C++ Asio]
D -->|수천 개 이하| H{팀 역량·기존 자산?}
H -->|C++ 숙련·C++ 자산| I[C++]
H -->|그 외| J[Go]
| 시나리오 | 권장 | 이유 |
|---|---|---|
| REST API·마이크로서비스 | Go | 표준 라이브러리, 빠른 빌드, 단일 바이너리 |
| Kubernetes 생태계 도구·운영 도구 | Go | 생태계 대부분이 Go, 크로스 컴파일 용이 |
| 채팅·알림처럼 연결이 많은 I/O 서버 | Go | 연결당 고루틴 모델이 단순 |
| 게임 서버·게임 엔진 | C++ | 메모리·지연 통제, 기존 엔진·SDK |
| 초저지연 트레이딩 | C++ | GC 없음, 할당·스레드 배치를 직접 통제 |
| 임베디드·리소스 제약 장치 | C++ | 런타임 없이 동작, 하드웨어 직접 제어 |
| 이미지·영상 처리 코어 | C++ (주변 서비스는 Go도 가능) | SIMD·메모리 레이아웃 통제 |
현실에서는 두 언어를 함께 쓰는 경우도 많습니다. 성능이 결정적인 코어(엔진, 코덱, 매칭 엔진)는 C++로 두고, API·설정·관측 같은 제어 평면은 Go로 만들어 gRPC나 HTTP로 연결합니다. 프로세스를 나누면 빌드·배포·장애가 서로 격리되기 때문입니다. Go에서 cgo로 C/C++ 라이브러리를 직접 호출할 수도 있지만, 호출마다 Go 스택에서 C 스택으로 전환하는 비용이 있고 C 코드가 실행되는 동안 그 OS 스레드를 점유하므로, 호출 빈도가 낮고 경계가 얇을 때만 쓰는 편이 낫습니다.
결정 순서는 서비스 수준 목표(지연의 p99, 피크 처리량, 가용성)를 먼저 숫자로 정하고, 그다음 팀의 역량과 채용, 기존 코드와 라이브러리, 빌드·배포·보안 요구를 따지는 것이 좋습니다. 언어는 이 조건들이 정해진 뒤에 고르는 결과에 가깝습니다.
측정 도구
C++는 perf, VTune, valgrind --tool=callgrind로 CPU 프로파일을, AddressSanitizer·ThreadSanitizer로 메모리 오류와 레이스를 봅니다. Go는 go tool pprof(CPU·힙·고루틴·블록 프로파일), go tool trace(스케줄러와 GC 동작), -race가 기본 도구입니다. 언어 비교 벤치마크를 직접 한다면 같은 알고리즘과 같은 자료구조로 작성하고, 평균보다 p99 지연과 메모리 사용량을 함께 봐야 공정합니다.
다음 단계
- C++ 심화: C++ 스레드 기초, Data Race·Mutex·Atomic
- Go 입문: C++ 개발자의 뇌 구조로 이해하는 Go
- 시스템 설계: C++ 시스템 디자인
다음 글: [C++ vs 타 언어 #47-2] C++ 개발자의 뇌 구조로 이해하는 Go 언어
참고 자료
- cppreference - Thread support library
- cppreference - std::memory_order
- Boost.Asio 공식 문서
- The Go Memory Model
- Effective Go
- A Guide to the Go Garbage Collector