C++ REST API 성능 최적화 예제: perf로 병목 찾고 해시맵 인덱스·참조 반환으로 응답 시간 줄이기
이 글의 핵심
응답이 느린 C++ API를 앞에 두고 감으로 고치지 않고, 벤치마크 기준선을 세운 뒤 perf로 핫스팟을 확인하는 순서를 예제 코드로 따라갑니다. 병목마다 문제 코드와 개선 코드를 나란히 두고 각 변경이 줄이는 연산과 할당을 따져 보며, 요청 단위 병렬화와 캐싱처럼 벤치마크에서만 좋아 보이기 쉬운 최적화의 함정도 짚습니다.
들어가며
이 글은 특정 서비스의 장애 기록이 아니라, C++ API에서 흔히 보이는 성능 문제 세 가지를 하나의 예제 코드에 모아 놓고 측정 → 분석 → 수정 → 재측정 순서로 풀어 보는 연습입니다. 실행 시간은 하드웨어, 컴파일러, 데이터에 따라 크게 달라지므로 구체적인 밀리초 수치 대신, 각 변경이 요청 하나에서 줄이는 비교 횟수와 메모리 할당 횟수로 효과를 가늠합니다. 같은 순서를 자기 코드에 적용할 때는 반드시 직접 측정한 값으로 판단해야 합니다.
예제 상황
사용자 목록을 역할(role)로 걸러 JSON으로 돌려주는 REST API가 있고, 응답이 목표 지연 시간보다 느리다는 보고가 들어왔다고 가정해 봅시다. 메모리에는 사용자 10,000명이 있고, 한 사용자는 평균 5개의 역할을 가지며, 대표 요청 하나는 약 100명을 반환합니다. API 스펙은 바꿀 수 없다는 제약도 둡니다.
외부에서 응답 시간을 보는 가장 간단한 방법은 curl의 -w 옵션입니다.
curl -s -o /dev/null -w 'connect=%{time_connect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
http://localhost:8080/users?role=admin
time_starttransfer(첫 바이트까지)와 time_total의 차이가 작고 time_connect도 작다면 시간 대부분이 서버 안의 처리에 쓰이고 있다는 뜻이므로, 네트워크가 아니라 서버 코드를 들여다봐야 합니다. 반대로 time_total만 크다면 응답 본문이 크거나 전송이 느린 것이므로 압축이나 페이지네이션이 먼저입니다.
측정: 벤치마크 기준선 설정
서버 전체를 띄우지 않고 핸들러 함수만 반복 호출하는 작은 벤치마크를 만들면, 변경 하나의 효과를 빠르게 확인할 수 있습니다.
// benchmark.cpp
#include <algorithm>
#include <chrono>
#include <iostream>
#include <string>
#include <vector>
class Benchmark {
std::vector<double> samples_;
public:
template <typename Func>
void run(const std::string& name, Func&& func, int iterations = 1000) {
using clock = std::chrono::steady_clock;
samples_.clear();
samples_.reserve(iterations);
for (int i = 0; i < 10; ++i) func(); // 워밍업
std::size_t sink = 0; // 결과를 사용해 컴파일러가 호출을 없애지 못하게 함
for (int i = 0; i < iterations; ++i) {
auto start = clock::now();
sink += func().size();
auto end = clock::now();
samples_.push_back(std::chrono::duration<double, std::milli>(end - start).count());
}
std::sort(samples_.begin(), samples_.end());
auto pct = [&](double p) { return samples_[static_cast<std::size_t>(p * (samples_.size() - 1))]; };
std::cout << name << ": p50=" << pct(0.50) << "ms p95=" << pct(0.95)
<< "ms p99=" << pct(0.99) << "ms (sink=" << sink << ")\n";
}
};
직접 만든 측정 도구를 믿으려면 몇 가지를 의식해야 합니다. 워밍업을 두는 이유는 첫 호출에서 발생하는 페이지 폴트, 캐시 콜드 미스, 지연 초기화 비용이 분포를 오염시키기 때문입니다. 평균 대신 p50/p95/p99를 보는 이유는 지연 시간 분포가 긴 꼬리를 갖는 경우가 많아, 평균 하나로는 “대부분은 빠른데 가끔 매우 느린” 상황을 놓치기 때문입니다. 샘플이 100개뿐이면 p99는 사실상 최댓값 하나이므로 반복 횟수를 넉넉히 잡습니다. 또 결과를 쓰지 않는 계산은 컴파일러가 통째로 제거할 수 있으므로, 위 코드처럼 반환값을 누적하거나 Google Benchmark의 benchmark::DoNotOptimize를 사용합니다. 여기서 func는 JSON 문자열을 반환하는 핸들러라고 가정합니다.
프로파일링: perf로 핫스팟 찾기
# 최적화 + 디버그 심볼, 콜 스택이 끊기지 않도록 프레임 포인터 유지
g++ -O2 -g -fno-omit-frame-pointer -std=c++17 *.cpp -o bench
perf record -g ./bench
perf report --stdio --sort symbol
-O2 -g 조합이 중요합니다. -O0로 프로파일링하면 배포 바이너리와 전혀 다른 곳이 핫스팟으로 잡히고, -g가 없으면 심볼이 주소로만 보입니다. perf record -g는 기본적으로 프레임 포인터를 따라 콜 스택을 수집하는데, x86-64의 -O2는 프레임 포인터를 생략하므로 콜 그래프가 끊겨 보일 수 있습니다. -fno-omit-frame-pointer로 빌드하거나 perf record --call-graph dwarf를 쓰면 해결됩니다.
이 예제 코드라면 perf report의 상위에는 대략 다음과 같은 심볼이 보일 것으로 예상할 수 있습니다. 비율은 데이터와 환경에 따라 달라지므로 여기서는 순서와 의미만 봅니다.
UserManager::findUsersByRole: 요청마다 전체 사용자를 훑는 루프와 그 안의 문자열 비교std::__cxx11::basic_string<...>::basic_string(const basic_string&),malloc,free:User를 결과 벡터에 복사하면서 생기는 문자열 복사와 할당std::ostringstream관련 심볼: 원소마다 스트림을 만들어 JSON을 조립하는 비용
복사 생성자가 상위에 뜨는 것은 “어디선가 복사가 많다”는 신호일 뿐이므로, perf report에서 해당 심볼을 펼쳐 호출자(caller)를 확인해야 실제로 고칠 코드가 나옵니다. 함수가 인라인되면 호출자 쪽 함수로 시간이 합쳐져 보인다는 점도 기억해 둡니다.
병목 1: 요청마다 전체 선형 탐색
struct User {
std::string id;
std::string name;
std::string email;
std::vector<std::string> roles;
};
class UserManager {
std::vector<User> users_; // 10,000명
public:
std::vector<User> findUsersByRole(const std::string& role) const {
std::vector<User> result;
for (const auto& user : users_) { // n = 10,000
for (const auto& r : user.roles) { // 평균 m = 5
if (r == role) {
result.push_back(user); // User 전체 복사
break;
}
}
}
return result;
}
};
이 코드의 복잡도는 O(n × m)입니다. 표기 자체보다 중요한 것은 결과가 100명이어도 요청마다 사용자 10,000명을 모두 훑는다는 사실입니다. 역할 비교가 요청당 최대 약 50,000번 일어나고, std::string의 ==는 길이가 같으면 내용을 memcmp로 비교하므로 각 비교마다 메모리를 읽습니다. 사용자 데이터는 가끔 바뀌는데 조회는 훨씬 자주 일어난다면, 변경 시점에 인덱스를 한 번 만들어 두고 조회 비용을 결과 크기 k에 비례하게 만드는 것이 정석입니다.
최적화 1: 역할 인덱스(해시맵)
#include <unordered_map>
class UserManager {
std::vector<User> users_;
std::unordered_map<std::string, std::vector<std::size_t>> roleIndex_; // 역할 → users_ 인덱스
public:
void buildIndex() {
roleIndex_.clear();
for (std::size_t i = 0; i < users_.size(); ++i) {
for (const auto& role : users_[i].roles) {
roleIndex_[role].push_back(i);
}
}
}
std::vector<User> findUsersByRole(const std::string& role) const {
std::vector<User> result;
if (auto it = roleIndex_.find(role); it != roleIndex_.end()) { // 평균 O(1)
result.reserve(it->second.size());
for (std::size_t idx : it->second) {
result.push_back(users_[idx]);
}
}
return result;
}
};
조회는 이제 해시 한 번과 결과 k명만큼의 작업으로 끝나고, 요청당 50,000번이던 문자열 비교가 사라집니다. 이 변경 후 다시 프로파일링하면 findUsersByRole의 루프는 핫스팟에서 내려가고, 그 아래에 가려져 있던 다음 병목인 User 복사가 드러날 것입니다. 큰 병목 하나를 고치면 순위가 바뀌므로 변경마다 다시 측정하는 습관이 필요합니다.
인덱스에는 유지 비용이 있습니다. 사용자가 추가·삭제되거나 역할이 바뀔 때마다 roleIndex_를 함께 갱신해야 하고, 여기서는 users_의 위치(size_t)를 저장하므로 users_ 중간 원소를 지우면 뒤쪽 인덱스가 전부 어긋납니다. 삭제가 잦다면 사용자 ID를 키로 쓰거나 변경 후 buildIndex()를 다시 호출해 불변식을 지켜야 합니다. 여러 스레드가 동시에 조회하고 갱신한다면 std::shared_mutex로 읽기와 쓰기 락을 나누는 방법도 고려합니다.
병목 2: User 복사
result.push_back(users_[idx])는 User 하나마다 문자열 3개와 역할 벡터를 복사합니다. 역할 벡터는 벡터 버퍼 할당 하나와 원소 문자열 5개의 복사가 따라옵니다. 짧은 문자열은 SSO(Small String Optimization) 덕분에 힙 할당 없이 복사되지만, libstdc++에서는 15자를 넘는 문자열(이메일 주소가 대표적)마다 malloc이 일어납니다. 결과 100명이면 요청 하나에 수백 번의 할당과 해제가 생길 수 있고, 이것이 perf에서 복사 생성자와 malloc/free가 함께 보이는 이유입니다.
최적화 2: 복사 대신 참조 반환
std::vector<const User*> findUsersByRole(const std::string& role) const {
std::vector<const User*> result;
if (auto it = roleIndex_.find(role); it != roleIndex_.end()) {
result.reserve(it->second.size());
for (std::size_t idx : it->second) {
result.push_back(&users_[idx]); // 포인터만 저장
}
}
return result;
}
이제 요청당 할당은 결과 벡터 하나뿐입니다. 대신 이 방식은 수명 계약을 만듭니다. 반환된 const User*는 users_가 재할당되는 순간(capacity를 넘는 push_back 등) 모두 댕글링 포인터가 됩니다. 조회 결과를 곧바로 직렬화하고 버리는 API라면 괜찮지만, 결과를 캐시에 오래 들고 있거나 다른 스레드가 users_를 수정할 수 있다면 std::shared_ptr<const User>나 불변 스냅샷 같은 설계가 필요합니다.
std::string_view도 흔히 함께 언급되지만, 여기서는 효과가 없다는 점을 짚고 넘어가겠습니다. 직렬화 함수가 이미 const User&로 받아 스트림에 바로 쓰고 있다면 문자열 복사는 일어나지 않으므로, 매개변수를 std::string_view로 바꿔도 줄어드는 것이 없습니다. string_view가 의미 있는 곳은 const std::string& 매개변수에 문자열 리터럴이나 부분 문자열을 넘기느라 임시 std::string이 만들어지던 경우입니다.
병목 3: JSON 직렬화
#include <sstream>
std::string serializeUser(const User& user) {
std::ostringstream oss; // 원소마다 스트림 생성
oss << "{\"id\":\"" << user.id << "\",\"name\":\"" << user.name << "\"}";
return oss.str(); // 임시 문자열 반환
}
std::string toJson(const std::vector<const User*>& users) {
std::string json = "[";
for (std::size_t i = 0; i < users.size(); ++i) {
if (i > 0) json += ',';
json += serializeUser(*users[i]);
}
json += ']';
return json;
}
여기서 비용은 두 군데에서 나옵니다. 원소마다 std::ostringstream을 생성하면 로케일 객체 처리와 내부 버퍼 할당이 따라오고, oss.str()은 다시 문자열을 복사해 반환합니다. 결과 문자열 json은 커지면서 몇 번 재할당됩니다.
흔히 “string +=를 반복하면 O(n²)“라고 하지만 std::string에는 맞지 않습니다. 표준 라이브러리 구현은 용량을 기하급수적으로 늘리므로 뒤에 덧붙이는 연산은 분할 상환 O(1)이고 전체는 O(n)입니다. O(n²)가 되는 것은 json = json + x처럼 매번 새 문자열을 만드는 경우입니다. 그래도 재할당이 O(log n)번 일어나고 원소마다 임시 문자열이 생기는 비용은 남습니다.
또 하나, 이 예제는 이스케이프를 하지 않습니다. 이름에 "나 \, 제어 문자가 들어 있으면 깨진 JSON이 나오므로 실제 코드에서는 이스케이프 처리를 하거나 nlohmann/json, RapidJSON 같은 검증된 라이브러리를 쓰는 것이 맞습니다.
최적화 3: 버퍼 하나에 직접 쓰기
void appendJsonString(std::string& out, const std::string& s) {
out += '"';
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
default: out += c; break; // 나머지 제어 문자 처리는 생략
}
}
out += '"';
}
std::string toJson(const std::vector<const User*>& users) {
std::string json;
json.reserve(2 + users.size() * 64); // 사용자 하나당 대략적인 JSON 길이
json += '[';
for (std::size_t i = 0; i < users.size(); ++i) {
if (i > 0) json += ',';
json += "{\"id\":";
appendJsonString(json, users[i]->id);
json += ",\"name\":";
appendJsonString(json, users[i]->name);
json += '}';
}
json += ']';
return json;
}
원소마다 생기던 스트림과 임시 문자열이 없어지고, 예약 크기가 충분하면 요청당 할당은 json 버퍼 한 번으로 줄어듭니다. 예약 크기는 실제 평균 JSON 길이를 한 번 재서 정하면 되고, 작게 잡아도 정확성에는 문제가 없으며 재할당이 몇 번 더 일어날 뿐입니다.
요청 안에서의 병렬화는 신중하게
결과가 많을 때 std::async로 청크를 나눠 직렬화하는 방법도 떠올릴 수 있습니다. 하지만 요청 하나 안에서 스레드를 띄우는 병렬화는 벤치마크와 실서비스의 결과가 가장 크게 갈리는 최적화입니다. 단일 요청 벤치마크에서는 모든 코어를 혼자 쓰니 빨라 보이지만, 서버가 이미 요청마다 워커 스레드를 쓰고 있다면 동시 요청이 몰릴 때 각 요청이 다시 코어 수만큼 스레드를 만들어 서로 경쟁하고, 전체 처리량은 오히려 떨어질 수 있습니다. std::async(std::launch::async, ...)는 주요 구현에서 호출마다 새 스레드를 만들기 때문에 스레드 생성 비용도 붙습니다. 결과 100명을 직렬화하는 작업은 위처럼 할당을 없애면 스레드 생성 비용과 비교할 만큼 짧아질 가능성이 크므로, 병렬화는 실제 트래픽 수준의 동시 요청으로 처리량과 p99를 함께 확인한 뒤에만 고려하고, 채택하더라도 공유 스레드 풀을 쓰는 편이 안전합니다. 참고로 std::thread::hardware_concurrency()는 값을 알 수 없을 때 0을 반환할 수 있으므로, 이 값으로 청크 크기를 나누는 코드는 std::max(1u, ...)로 보호해야 합니다.
단계별로 무엇이 줄었나
| 단계 | 변경 | 요청 하나에서 줄어드는 것 |
|---|---|---|
| 0 | 초기 코드 | 역할 비교 약 50,000번, User 100명 복사(수백 번의 할당), 원소마다 스트림 생성 |
| 1 | 역할 인덱스 | 역할 비교가 해시 조회 1번으로 바뀜. 조회 비용이 전체 n이 아니라 결과 k에 비례 |
| 2 | 참조 반환 | User 복사와 그에 따른 문자열 할당이 사라지고 포인터 벡터 할당 1번만 남음 |
| 3 | 버퍼 직접 쓰기 | 원소마다의 스트림·임시 문자열 제거, 예약이 맞으면 출력 버퍼 할당 1번 |
어느 단계가 실제로 가장 효과가 큰지는 데이터 크기와 문자열 길이에 따라 달라집니다. 사용자 수 n이 클수록 1단계의 비중이 커지고, 결과 k가 크고 문자열이 길수록 2, 3단계의 비중이 커집니다. 그래서 단계마다 벤치마크와 perf를 다시 돌려, 어떤 변경이 효과가 있었는지 구분할 수 있게 해 둡니다.
그 밖의 선택지
캐싱은 역할별 결과를 일정 시간 보관하는 방식으로 쉽게 추가할 수 있지만, 만료 시각을 항목마다 따로 저장해야 하고, 여러 요청 스레드가 동시에 캐시를 수정하므로 락이 필요하며, 데이터가 바뀐 뒤에도 만료될 때까지 옛 결과를 돌려준다는 점이 허용되는지 먼저 확인해야 합니다. 인덱스로 조회가 이미 결과 크기에 비례하게 줄었다면 캐시가 더 줄여 주는 것은 직렬화 비용 정도입니다.
데이터가 메모리가 아니라 PostgreSQL에 있고 roles가 text[] 배열 열이라면, GIN 인덱스가 같은 역할을 합니다.
CREATE INDEX idx_users_roles ON users USING GIN (roles);
-- GIN 인덱스를 쓰려면 배열 포함 연산자로 질의합니다
SELECT id, name FROM users WHERE roles @> ARRAY['admin'];
응답 본문이 커서 curl의 time_total과 time_starttransfer 차이가 크다면, 직접 zlib을 호출하기보다 앞단의 nginx 같은 리버스 프록시에서 gzip을 켜는 편이 간단합니다.
프로파일링 도구는 목적에 따라 고릅니다. perf는 샘플링 방식이라 오버헤드가 작아 CPU 핫스팟을 찾는 첫 도구로 적합하고, gprof는 -pg로 다시 빌드해야 하며 멀티스레드 프로그램을 제대로 다루지 못합니다. Valgrind의 callgrind는 정확한 명령어 수를 세지만 수십 배 느리게 실행되고, heaptrack은 어떤 호출 경로가 할당을 많이 하는지 보여 주므로 2단계 같은 복사 문제를 확인하기 좋습니다.