C++ · 심층 가이드
C++ 성능 완전 정리
사전 할당과 복사 제거, 캐시 친화적인 자료구조 선택을 포함한 15개 예제로 C++ 코드에서 시간이 실제로 어디에 쓰이는지 찾아내 줄여 나갑니다.
C++ 성능 작업의 대부분은 알고리즘이 아니라 메모리 이동에 관한 것입니다. 값 의미론 덕분에 복사가 어디서 일어나는지 코드에 드러나지만, 반대로 무심코 쓴 한 줄이 컨테이너 전체를 통째로 복사하기도 합니다. 여기에 캐시 지역성이 더해집니다. 같은 계산량이라도 연속된 메모리를 훑는 코드와 포인터를 따라다니는 코드는 체감이 다릅니다. 그래서 최적화의 출발점은 대개 자료구조 배치를 다시 보는 일입니다.
사전 할당과 문자열 버퍼링은 재할당 횟수를 줄이는 같은 아이디어를 컨테이너와 문자열에 각각 적용한 것입니다. 불필요한 복사 줄이기와 소유권 또는 참조 재사용은 이동 의미론과 참조 전달을 다루는 이 편의 중심이고, 조회 맵 사용과 Set 조회 최적화는 선형 탐색을 해시 조회로 바꾸는 전형적인 교체입니다. 시간 측정은 순서상 맨 앞에 두고 읽어도 좋습니다. 무엇을 고칠지 정하는 항목이기 때문입니다.
복사를 줄이려다 새로 만드는 버그가 있습니다. std::string_view는 문자를 소유하지 않고 남의 버퍼를 가리키기만 하므로, 임시 std::string을 돌려주는 표현식에 뷰를 붙여 보관하면 다음 줄에서 이미 해제된 메모리를 읽습니다. 함수 매개변수처럼 호출이 끝나면 사라지는 자리에는 좋지만, 멤버 변수에 담을 때는 원본 수명을 반드시 확인해야 합니다. 그리고 reserve로 미리 잡아 둔 용량은 shrink_to_fit을 불러도 반드시 줄어들지 않습니다. 표준은 이를 구속력 없는 요청으로 규정합니다.
01문자열 버퍼링
buffer.reserve(32)로 용량을 먼저 잡은 뒤 +=로 접두사와 숫자를 이어 붙입니다. 문자열을 조각조각 만들 때 중간 재할당을 줄이려는 목적입니다. 다만 대부분 구현이 15자 안팎까지는 힙 없이 객체 안에 담으므로, 32를 예약하는 순간 오히려 힙 할당이 앞당겨질 수 있습니다.
C++ code
// 문자열 버퍼링
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(1);
std::cout << buffer << "\n";
}buffer.capacity()를 찍어 보면 구현이 실제로 잡아 둔 용량을 알 수 있어, 짧은 문자열 최적화가 걸리는 경계를 직접 확인할 수 있습니다.
reserve를 반복문 안에서 조금씩 키우며 부르면 그때마다 재할당과 복사가 일어나므로, 필요한 최종 크기를 한 번에 잡아야 합니다.
02사전 할당
steady_clock으로 앞뒤 시각을 찍고 그 사이에서 1000번 더한 뒤 경과 시간을 마이크로초로 출력합니다. 어떤 구간이 실제로 얼마나 걸리는지 감을 잡을 때 쓰는 최소 구성입니다. volatile이 없으면 결과를 쓰지 않는 이 루프는 통째로 제거되어 0이 찍힙니다.
C++ code
// 사전 할당
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}측정은 한 번으로 끝내지 말고 여러 번 반복해 중앙값을 보아야, 다른 프로세스의 방해로 튄 값에 속지 않습니다.
volatile은 최적화를 막을 뿐 스레드 사이의 동기화를 제공하지 않으므로, 이를 원자 연산 대신 쓰면 데이터 경합이 그대로 남습니다.
03지연 처리
실제 동작은 문자열을 미리 넓혀 두고 필요한 조각을 이어 붙이는 것으로, 계산을 미루는 장치는 들어 있지 않습니다. 결과가 쓰일지 모를 때 생성을 늦추면 낭비를 줄일 수 있습니다. std::to_string은 임시 std::string을 만들어 반환하므로, 이 한 줄에서도 별도 할당이 일어날 수 있습니다.
C++ code
// 지연 처리
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(3);
std::cout << buffer << "\n";
}std::to_string 대신 std::format_to나 std::to_chars로 기존 버퍼에 직접 쓰면 임시 문자열을 하나 줄일 수 있습니다.
계산을 미루면서 그 판단을 반복문 안에 두면, 미룰지 결정하는 검사 비용이 아낀 계산보다 커지는 경우가 있습니다.
04메모이제이션 캐시
이전 결과를 보관하는 저장소는 없고, 매번 새로 계산한 합계를 측정 구간 안에서 만들어 냅니다. 같은 입력이 반복된다면 결과를 남겨 두는 편이 낫습니다. volatile int는 매 반복 메모리에 읽고 쓰도록 강제하므로, 여기서 재는 시간은 최적화가 허용된 코드보다 느리게 나옵니다.
C++ code
// 메모이제이션 캐시
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}캐시가 실제로 효과를 내는지 보려면 적중과 실패 횟수를 함께 세어야 하며, 적중률이 낮으면 조회 비용만 더해집니다.
volatile을 붙인 채 성능을 재면 최적화된 코드보다 훨씬 느린 값이 나오므로, 이 수치를 실제 성능이라고 보고하면 안 됩니다.
05조회 맵 사용
키로 값을 찾는 자료구조는 없고, 문자열 하나를 예약 후 조립하는 것이 전부입니다. 분기가 늘어날 때 조건문 대신 표를 쓰면 코드가 짧아집니다. reserve는 size가 아니라 capacity만 바꾸므로, 이 시점의 buffer.size()는 여전히 붙인 글자 수만큼입니다.
C++ code
// 조회 맵 사용
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(5);
std::cout << buffer << "\n";
}shrink_to_fit은 요청일 뿐 구현이 따를 의무가 없으므로, 메모리를 확실히 줄이려면 필요한 크기의 새 문자열로 교체해야 합니다.
예약해 둔 용량을 크기로 착각해 인덱스로 접근하면 아직 존재하지 않는 문자를 읽게 되므로, resize와는 뜻이 다르다는 점을 구분해야 합니다.
06배치 I/O
출력은 마지막에 한 번만 일어나고, 루프 안에서는 입출력을 전혀 건드리지 않습니다. 작업을 모아 한 번에 내보내면 호출 횟수 자체가 줄어듭니다. 반대로 루프 안에서 std::cout을 쓰면 그 비용이 측정값을 뒤덮어 정작 재려던 연산이 보이지 않게 됩니다.
C++ code
// 배치 I/O
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}출력을 모아 보낼 때는 한 문자열에 다 담아 한 번에 내보내면 스트림 잠금과 flush 횟수가 함께 줄어듭니다.
측정 구간 안에 입출력이나 로그가 섞이면 그 비용이 대부분을 차지해, 정작 최적화하려던 부분의 변화가 숫자에 나타나지 않습니다.
07불필요한 복사 줄이기
+=는 새 문자열을 만들지 않고 기존 버퍼 뒤에 이어 붙이므로 중간 임시 객체가 생기지 않습니다. 여러 조각을 합칠 때 이 방식이 a + b + c보다 낭비가 적습니다. 다만 용량이 모자라 재할당되면 이전에 얻어 둔 포인터와 반복자는 모두 무효가 됩니다.
C++ code
// 불필요한 복사 줄이기
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(7);
std::cout << buffer << "\n";
}여러 조각을 붙일 때 append는 길이를 함께 넘길 수 있어, 널 문자를 찾는 과정 없이 필요한 만큼만 복사합니다.
덧셈 연산자로 여러 문자열을 이어 붙이면 중간 결과마다 임시 객체가 만들어지므로, 반복문 안에서 쓰면 할당 횟수가 조각 수만큼 늘어납니다.
08시간 측정
시작과 끝 시각의 차를 duration_cast로 마이크로초로 바꿔 출력합니다. 개선 전후를 비교할 때 이 정도 계측이 출발점이 됩니다. duration_cast는 반올림 없이 잘라 버리므로, 1000회 덧셈처럼 짧은 구간은 0으로 표시되기 쉽고 그럴 때는 나노초 단위로 받아야 합니다.
C++ code
// 시간 측정
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}경과 시간을 실수 기반 duration으로 받으면 잘림 없이 소수점까지 볼 수 있어 짧은 구간도 비교할 수 있습니다.
시계의 실제 해상도는 steady_clock::period가 알려 주며, 이보다 짧은 구간을 재면 값이 0이나 해상도 단위로만 튀어 의미가 없습니다.
09병렬 처리
스레드도 실행 정책도 등장하지 않으며, 한 흐름에서 문자열을 순서대로 만듭니다. 서로 독립적인 작업이 많을 때 비로소 나눠 돌릴 여지가 생깁니다. 짧은 문자열 조립처럼 작업 단위가 작으면 스레드를 만드는 비용이 이득보다 커집니다.
C++ code
// 병렬 처리
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(9);
std::cout << buffer << "\n";
}작업을 나눌 때는 std::thread::hardware_concurrency()를 참고하되, 이 함수가 0을 돌려줄 수 있으므로 기본값을 함께 준비해야 합니다.
여러 스레드가 같은 캐시 라인에 있는 서로 다른 변수를 갱신하면 거짓 공유로 오히려 느려지므로, 스레드별 결과는 충분히 떨어뜨려 두어야 합니다.
10객체 재사용
루프 밖에서 total을 한 번만 만들고 반복 안에서는 값만 갱신하므로, 매 회차마다 새 객체가 생기지 않습니다. 반복문 안의 할당을 밖으로 끌어내는 것이 이 방식의 핵심입니다. 다만 volatile 때문에 컴파일러는 이 변수를 레지스터에 올려 두지 못하고 매번 메모리를 오갑니다.
C++ code
// 객체 재사용
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}반복문에서 문자열이나 벡터를 재사용할 때는 clear()로 비우면 용량이 유지되어 다음 회차의 할당을 피할 수 있습니다.
재사용하는 버퍼를 비우지 않고 이어 쓰면 이전 회차의 내용이 뒤에 남아, 결과가 조용히 누적된 채 출력됩니다.
11Set 조회 최적화
집합 자료구조는 쓰이지 않고, 예약된 버퍼에 두 조각을 붙여 하나의 문자열을 만듭니다. 포함 여부를 자주 물어야 할 때 배열 순회 대신 해시 집합을 고려하게 됩니다. std::unordered_set은 평균 상수 시간이지만 원소가 늘어 재해시가 일어나면 기존 반복자가 무효화됩니다.
C++ code
// Set 조회 최적화
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(11);
std::cout << buffer << "\n";
}원소가 수십 개 수준이면 정렬된 벡터에 std::binary_search를 쓰는 편이 해시 집합보다 캐시 효율 덕분에 빠른 경우가 많습니다.
사용자 정의 해시가 값을 고르게 흩지 못하면 원소들이 같은 버킷에 몰려 조회가 선형으로 떨어지므로, 분포를 반드시 확인해야 합니다.
12청크 스트리밍
데이터를 나눠 흘려보내는 대신, 완성된 문자열을 마지막에 통째로 출력합니다. 결과가 커서 메모리에 다 담기 곤란할 때 조각 단위 처리로 바꿉니다. 미리 reserve해 둔 용량을 넘어서면 대부분 구현이 용량을 배수로 늘리며 기존 내용을 새 메모리로 옮깁니다.
C++ code
// 청크 스트리밍
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}문자열의 용량은 대개 배수로 늘어나 재할당 횟수는 많지 않지만, 옮기는 총 바이트 수는 최종 길이에 비례해 쌓입니다.
큰 결과를 한 번에 메모리에 담으면 최대 사용량이 입력 크기에 비례하므로, 스트리밍으로 바꾸지 않으면 큰 입력에서 그대로 실패합니다.
13정렬 비용 비교
정렬 호출은 없고 문자열을 앞에서 뒤로 이어 붙이는 선형 작업만 있습니다. 순서가 필요한 순간이 오면 그때 정렬 비용을 따져 봅니다. std::sort는 평균 n log n이지만 비교 함수가 무거우면 그 상수 배가 실제 시간을 좌우하므로, 정렬 키를 미리 계산해 두는 편이 유리할 때가 많습니다.
C++ code
// 정렬 비용 비교
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(13);
std::cout << buffer << "\n";
}정렬 키 계산이 무겁다면 키와 인덱스를 미리 만들어 한 번씩만 계산하고 그 쌍을 정렬하면, 비교 횟수만큼의 재계산이 사라집니다.
상위 몇 개만 필요한데 전체를 정렬하면 낭비이므로, std::partial_sort나 std::nth_element로 필요한 만큼만 정리해야 합니다.
14소유권 또는 참조 재사용
buffer는 함수 안에서 만들어져 그 자리에서 쓰이고 끝나므로 소유권이 이동하거나 공유되지 않습니다. 큰 데이터를 넘길 때는 복사 대신 참조나 이동을 택합니다. 함수 인자로 const std::string&을 받으면 복사가 없지만, 받은 쪽이 보관까지 해야 한다면 값으로 받아 std::move하는 편이 낫습니다.
C++ code
// 소유권 또는 참조 재사용
#include <chrono>
#include <iostream>
int main() {
auto start = std::chrono::steady_clock::now();
volatile int total = 0;
for (int i = 0; i < 1000; ++i) total += i;
auto end = std::chrono::steady_clock::now();
std::cout << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << "\n";
}인자를 값으로 받아 이동하는 방식은 호출자가 임시를 넘길 때 복사가 아예 사라지고, 좌변값을 넘길 때만 한 번 복사됩니다.
이동한 뒤의 객체는 유효하지만 값이 정해지지 않은 상태이므로, 크기를 다시 물어보고 그 값에 기대는 코드는 예측할 수 없게 됩니다.
15페이지네이션 슬라이스
구간을 잘라 내는 코드는 없고, 인덱스 값을 문자열에 붙여 식별자 하나를 만듭니다. 목록을 나눠 보낼 때 이런 키 생성이 자주 함께 쓰입니다. C++17의 std::string_view를 쓰면 부분 문자열을 복사 없이 가리킬 수 있지만, 원본이 살아 있는 동안만 유효하다는 제약이 붙습니다.
C++ code
// 페이지네이션 슬라이스
#include <iostream>
#include <string>
int main() {
std::string buffer;
buffer.reserve(32);
buffer += "item-";
buffer += std::to_string(15);
std::cout << buffer << "\n";
}std::string_view는 포인터와 길이만 담아 복사가 사실상 공짜이므로, 소유하지 않고 읽기만 하는 매개변수에 적합합니다.
임시 문자열에서 만든 string_view는 그 줄이 끝나면 매달린 상태가 되고, 널 종료도 보장되지 않아 C 함수에 그대로 넘길 수 없습니다.
정리하며
- string_view는 대상을 소유하지 않으므로 임시 string을 가리킨 채 보관하면 안 됩니다
- 예상 크기를 아는 vector와 string은 reserve로 재할당과 복사를 미리 없앱니다
- 반복 조회는 선형 탐색 대신 해시 컨테이너로 바꾸는 것이 가장 큰 이득을 냅니다
- shrink_to_fit은 구속력 없는 요청이라 용량이 줄어든다고 보장되지 않습니다
더 깊이 들어가고 싶다면 C++ 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.