C++ REST API 성능 최적화 사례: perf로 병목 찾고 해시맵 인덱스·string_view로 응답 시간 줄이기

이 글의 핵심

감으로 최적화하지 않고 벤치마크 기준선부터 세운 뒤 perf로 핫스팟을 확인하는 순서를 따릅니다. 병목마다 문제 코드와 개선 코드를 나란히 두고 단계별 성능 변화를 비교하며, 프로파일링 도구 선택 기준과 캐싱·DB 인덱스·응답 압축 같은 다음 최적화 후보도 짚습니다.

들어가며

“API가 너무 느려요”라는 제보를 받고 시작한 성능 최적화 여정입니다. 측정 → 분석 → 최적화 → 검증의 과정을 거쳐, 이 사례의 벤치마크(사용자 100명 조회, p50 기준)에서 응답 시간이 약 200ms에서 20ms로 줄어든 과정을 단계별로 공유합니다. 수치는 이 코드와 환경에서의 측정값이므로, 자기 서비스에서는 같은 순서로 직접 측정해 보는 것이 중요합니다. 일상에 빗대면, “느리다”는 말만 듣고 어느 방 문이 막혔는지 확인하지 않고 도색부터 하는 것과 같습니다. 이 사례에서는 먼저 벤치마크와 프로파일러로 병목 문을 연 뒤에만 손을 댔습니다.

이 글을 읽으면

  • 성능 병목을 찾는 체계적인 방법을 배웁니다
  • 프로파일링 도구(perf, gprof, Valgrind)를 실전에서 활용하는 법을 익힙니다
  • 알고리즘, 메모리, 멀티스레딩 최적화 기법을 이해합니다
  • 최적화 효과를 정량적으로 측정하는 방법을 습득합니다

문제: API 응답이 너무 느림

상황

문제의 본질은 “특정 기능이 가끔 느리다”가 아니라, 대표 API가 목표 SLA를 계속 밑돌았다는 점이었습니다. 사용자 목록을 반환하는 REST API의 응답 시간이 느리다는 제보가 들어왔으며, 아래처럼 한 요청에 200ms가 걸리는 것이 확인되었습니다.

# 100명 사용자 조회
$ curl -w "@curl-format.txt" http://api.example.com/users
time_total: 0.203s  # 200ms

요구사항

  • 목표: 응답 시간 50ms 이하
  • 제약: 기존 API 스펙 유지
  • 환경: Ubuntu 22.04, g++ 11, 4 CPU 코어

측정: 벤치마크 기준선 설정

벤치마크 도구 작성

// benchmark.cpp
#include <algorithm>
#include <chrono>
#include <iostream>
#include <string>
#include <vector>
using namespace std::chrono;
class Benchmark {
    std::vector<double> samples_;
    
public:
    template<typename Func>
    void run(const std::string& name, Func&& func, int iterations = 100) {
        samples_.clear();
        
        // 워밍업
        for (int i = 0; i < 10; ++i) {
            func();
        }
        
        // 측정
        for (int i = 0; i < iterations; ++i) {
            auto start = steady_clock::now();
            func();
            auto end = steady_clock::now();
            
            auto duration = duration_cast<microseconds>(end - start).count();
            samples_.push_back(duration / 1000.0); // ms
        }
        
        // 통계
        std::sort(samples_.begin(), samples_.end());
        double p50 = samples_[samples_.size() / 2];
        double p95 = samples_[samples_.size() * 95 / 100];
        double p99 = samples_[samples_.size() * 99 / 100];
        
        std::cout << name << ":\n"
                  << "  p50: " << p50 << "ms\n"
                  << "  p95: " << p95 << "ms\n"
                  << "  p99: " << p99 << "ms\n";
    }
};

측정 도구를 직접 만들 때 몇 가지를 의식해야 결과를 믿을 수 있습니다. 워밍업을 두는 이유는 첫 호출에서 발생하는 페이지 폴트, 캐시 콜드 미스, 지연 초기화(정적 객체, 커넥션 풀) 비용이 평균을 오염시키기 때문입니다. 평균 대신 p50/p95/p99를 보는 이유는 API 지연 시간 분포가 긴 꼬리를 갖는 경우가 많아서, 평균 하나로는 “대부분은 빠른데 가끔 매우 느린” 상황을 놓치기 때문입니다. 다만 샘플이 100개면 p99는 사실상 최댓값 하나이므로, 꼬리 지연을 논하려면 반복 횟수를 수천 회 이상으로 늘려야 합니다. 또 컴파일러가 결과를 쓰지 않는 계산을 통째로 제거할 수 있으니, 함수 반환값을 어딘가에 누적하거나 Google Benchmark의 benchmark::DoNotOptimize 같은 장치를 쓰는 편이 안전합니다.

기준선 측정

$ ./benchmark
getUserList (100 users):
  p50: 203.5ms
  p95: 215.2ms
  p99: 223.1ms

프로파일링: perf로 핫스팟 찾기

perf 프로파일링

# 프로파일링 빌드 (최적화 + 디버그 심볼)
$ g++ -O2 -g -std=c++17 *.cpp -o server
# perf로 프로파일링
$ perf record -g ./server
$ perf report
# 결과
Samples: 10K of event 'cycles'
  65.23%  server  [.] UserManager::findUsersByRole
  18.45%  server  [.] std::string::string(std::string const&)
  12.34%  server  [.] json::serialize
   2.98%  server  [.] other

발견

  1. findUsersByRole 이 전체 시간의 65% 차지
  2. 문자열 복사가 18%
  3. JSON 직렬화가 12%

-O2 -g 조합으로 빌드한 점이 중요합니다. -O0로 프로파일링하면 실제 배포 바이너리와 전혀 다른 곳이 핫스팟으로 잡히고, -g가 없으면 심볼이 주소로만 보입니다. perf record -g의 기본 콜 스택 수집은 프레임 포인터에 의존하는데, -O2는 기본적으로 프레임 포인터를 생략하므로 콜 그래프가 끊겨 보일 수 있습니다. 그럴 때는 -fno-omit-frame-pointer로 다시 빌드하거나 perf record --call-graph dwarf를 사용합니다. 두 번째 항목처럼 std::string::string(const std::string&)가 상위에 뜨면 “어디선가 복사가 많다”는 신호일 뿐이므로, perf report에서 해당 심볼을 펼쳐 호출자(caller)를 확인해야 실제로 고칠 코드가 나옵니다.


병목 1: 요청마다 전체 선형 탐색

문제 코드

class UserManager {
    std::vector<User> users_; // 10,000명
public:
    std::vector<User> findUsersByRole(const std::string& role) {
        std::vector<User> result;
        
        // 🚨 O(n×m): 요청마다 전체 사용자와 역할 목록 순회
        for (const auto& user : users_) {
            for (const auto& r : user.roles) {
                if (r == role) {
                    result.push_back(user);
                    break;
                }
            }
        }
        
        return result;
    }
};

복잡도 분석

  • 사용자 수: n = 10,000
  • 역할 수: m = 5 (평균)
  • 시간 복잡도: O(n × m) ≈ O(n)이지만, 문자열 비교가 느림

엄밀히 말하면 이 코드는 O(n²)가 아니라 O(n × m)입니다. 문제의 핵심은 복잡도 표기보다 요청마다 전체 사용자 10,000명을 훑는다는 데 있습니다. 결과가 100명이어도 비교는 약 50,000번 일어나고, 각 비교는 문자열 길이만큼 메모리를 읽습니다. 요청 빈도가 데이터 변경 빈도보다 훨씬 높다면, 변경 시점에 인덱스를 한 번 만들어 두고 조회는 결과 크기(k)에 비례하게 만드는 것이 정석입니다.


최적화 1: 역할 인덱스(해시맵)로 조회 비용 줄이기

개선 코드

class UserManager {
    std::vector<User> users_;
    // 역할 → 사용자 인덱스 매핑
    std::unordered_map<std::string, std::vector<size_t>> roleIndex_;
public:
    void buildIndex() {
        roleIndex_.clear();
        for (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) {
        std::vector<User> result;
        
        // O(1) 해시 조회
        if (auto it = roleIndex_.find(role); it != roleIndex_.end()) {
            result.reserve(it->second.size());
            for (size_t idx : it->second) {
                result.push_back(users_[idx]);
            }
        }
        
        return result;
    }
};

결과

$ ./benchmark
getUserList (100 users):
  p50: 85.3ms  # 203.5ms → 85.3ms (2.4배 개선)

개선: 프로파일에서 전체 시간의 65%를 차지하던 findUsersByRole이 더 이상 최상위 핫스팟이 아니게 되었고, 다음 병목인 문자열 복사가 드러났습니다.

인덱스는 공짜가 아닙니다. 사용자가 추가·삭제되거나 역할이 바뀔 때마다 roleIndex_를 함께 갱신해야 하고, 여기서는 users_의 인덱스(size_t) 를 저장하므로 users_에서 중간 원소를 지우면 뒤쪽 인덱스가 전부 어긋납니다. 삭제가 잦다면 사용자 ID를 키로 쓰거나 삭제 후 buildIndex()를 다시 호출하는 식으로 불변식을 지켜야 합니다. 여러 스레드가 동시에 조회하고 갱신한다면 std::shared_mutex로 읽기/쓰기 락을 나누는 것도 고려해야 합니다.


병목 2: 문자열 복사

문제 코드

std::vector<User> findUsersByRole(const std::string& role) {
    std::vector<User> result;
    // ...
    for (size_t idx : it->second) {
        result.push_back(users_[idx]); // 🚨 User 전체 복사
    }
    return result;
}
struct User {
    std::string id;        // 복사
    std::string name;      // 복사
    std::string email;     // 복사
    std::vector<std::string> roles; // 복사
    // 총 4번의 문자열 복사
};

User 하나를 복사하면 문자열 3개와 역할 벡터(벡터 자체 할당 + 원소 문자열마다 할당)가 따라 복사됩니다. 짧은 문자열은 SSO(Small String Optimization) 덕분에 힙 할당 없이 복사되지만, 이메일처럼 libstdc++ 기준 15자를 넘는 문자열은 매번 malloc을 부릅니다. perf에서 복사 생성자와 함께 malloc/free가 상위에 보인다면 이 경우입니다.


최적화 2: string_view와 이동 의미론

개선 1: 참조 반환

// 복사 대신 참조 반환
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 (size_t idx : it->second) {
            result.push_back(&users_[idx]); // 포인터만 복사
        }
    }
    
    return result;
}

포인터 벡터를 반환하는 방식은 복사를 없애는 대신 수명 계약을 만듭니다. 반환된 const User*는 users_가 재할당되는 순간(예: push_back으로 capacity를 넘는 경우) 전부 댕글링 포인터가 됩니다. 조회 결과를 곧바로 직렬화하고 버리는 이 API에서는 괜찮지만, 결과를 캐시에 오래 들고 있거나 다른 스레드가 users_를 수정할 수 있다면 std::shared_ptr<const User>를 저장하거나 불변 스냅샷을 쓰는 설계가 필요합니다.

개선 2: string_view 활용

// JSON 직렬화 시 string_view 사용
std::string serializeUser(const User& user) {
    std::ostringstream oss;
    oss << "{\"id\":\"" << user.id << "\","
        << "\"name\":\"" << user.name << "\"}";
    return oss.str();
}
// 개선: string_view로 불필요한 복사 제거
std::string serializeUserView(std::string_view id, std::string_view name) {
    std::ostringstream oss;
    oss << "{\"id\":\"" << id << "\","
        << "\"name\":\"" << name << "\"}";
    return oss.str();
}

솔직히 말하면 serializeUserView는 원래 serializeUser(const User&)보다 복사를 더 줄이지 않습니다. 원래 함수도 참조로 받아 ostringstream에 바로 쓰기 때문에 문자열 복사가 없었습니다. 이 단계의 개선 효과는 거의 전부 앞의 “참조 반환”에서 나온 것이고, string_view가 효과를 내는 곳은 const std::string& 매개변수에 문자열 리터럴이나 부분 문자열을 넘기느라 임시 std::string이 만들어지던 경우입니다. 오히려 여기서 더 큰 비용은 std::ostringstream 자체(로케일 처리, 내부 버퍼 할당)이므로, 핫 경로라면 std::string::append나 std::format/fmt::format_to로 바꾸는 편이 효과가 큽니다.

결과

$ ./benchmark
getUserList (100 users):
  p50: 42.1ms  # 85.3ms → 42.1ms (2배 개선)

병목 3: JSON 직렬화

문제

std::string toJson(const std::vector<const User*>& users) {
    std::string json = "[";
    for (size_t i = 0; i < users.size(); ++i) {
        json += serializeUser(*users[i]); // 🚨 string += 반복
        if (i < users.size() - 1) {
            json += ",";
        }
    }
    json += "]";
    return json;
}

문제점: serializeUser가 매 원소마다 임시 std::string과 ostringstream을 만들고, 결과 문자열도 커지면서 여러 번 재할당됩니다.

흔히 “string +=를 반복하면 O(n²)“라고 말하지만 std::string에는 맞지 않습니다. 표준 라이브러리 구현은 용량을 기하급수적으로(대략 1.5~2배) 늘리므로 뒤에 덧붙이는 연산은 분할 상환 O(1)이고, 전체는 O(n)입니다. O(n²)가 되는 것은 json = json + x처럼 매번 새 문자열을 만드는 경우나, 앞쪽에 insert하는 경우입니다. 그래도 재할당과 복사가 log(n)번 일어나고 원소마다 임시 문자열이 생기는 비용은 남기 때문에, 아래처럼 reserve로 한 번에 공간을 잡는 것이 의미가 있습니다.


최적화 3: 멀티스레딩과 객체 풀

개선 1: 문자열 예약

std::string toJson(const std::vector<const User*>& users) {
    std::string json;
    json.reserve(users.size() * 100); // 예상 크기 예약
    
    json = "[";
    for (size_t i = 0; i < users.size(); ++i) {
        json += serializeUser(*users[i]);
        if (i < users.size() - 1) {
            json += ",";
        }
    }
    json += "]";
    return json;
}

이 코드에는 작은 함정이 하나 있습니다. json.reserve(...) 다음 줄의 json = "[";는 대입이라 구현에 따라 용량이 유지되긴 하지만, 의도를 분명히 하려면 json += '['; 또는 json.push_back('[');로 쓰는 편이 낫습니다. 예상 크기(* 100)는 실제 평균 JSON 길이를 한 번 측정해서 정하는 것이 좋고, 너무 작게 잡아도 정확성에는 문제가 없으며 재할당이 몇 번 더 일어날 뿐입니다.

개선 2: 병렬 직렬화

#include <thread>
#include <future>
std::string toJsonParallel(const std::vector<const User*>& users) {
    if (users.size() < 100) {
        return toJson(users); // 작은 경우 오버헤드 방지
    }
    
    // 청크로 분할
    size_t numThreads = std::thread::hardware_concurrency();
    size_t chunkSize = (users.size() + numThreads - 1) / numThreads;
    
    std::vector<std::future<std::string>> futures;
    
    for (size_t i = 0; i < numThreads; ++i) {
        size_t start = i * chunkSize;
        size_t end = std::min(start + chunkSize, users.size());
        
        if (start >= users.size()) break;
        
        futures.push_back(std::async(std::launch::async, [&, start, end]() {
            std::string chunk;
            chunk.reserve((end - start) * 100);
            
            for (size_t j = start; j < end; ++j) {
                chunk += serializeUser(*users[j]);
                if (j < end - 1) chunk += ",";
            }
            return chunk;
        }));
    }
    
    // 결과 합치기
    std::string result = "[";
    for (size_t i = 0; i < futures.size(); ++i) {
        result += futures[i].get();
        if (i < futures.size() - 1) result += ",";
    }
    result += "]";
    
    return result;
}

결과

$ ./benchmark
getUserList (100 users):
  p50: 20.3ms  # 42.1ms → 20.3ms (2배 개선)
getUserList (1000 users):
  p50: 45.2ms  # 병렬화로 선형 증가 억제

요청 하나 안에서 스레드를 띄우는 병렬화는 벤치마크와 실서비스의 결과가 가장 크게 갈리는 최적화입니다. 단일 요청 벤치마크에서는 4코어를 혼자 쓰니 빨라 보이지만, 서버가 이미 요청마다 워커 스레드를 쓰고 있다면 동시 요청이 몰릴 때 각 요청이 다시 코어 수만큼 스레드를 만들어 서로 경쟁하고, 전체 처리량은 오히려 떨어질 수 있습니다. std::async(std::launch::async, ...)는 대부분의 구현에서 호출마다 새 스레드를 만들기 때문에 수십 마이크로초의 생성 비용도 붙습니다. 저라면 이 단계는 부하 테스트(동시 요청 수를 실제 트래픽 수준으로)로 처리량과 p99를 함께 확인한 뒤에만 채택하고, 채택하더라도 요청당 스레드 생성 대신 공유 스레드 풀을 쓰겠습니다. 또 람다가 users를 [&]로 참조 캡처하므로, 모든 future의 get()이 끝나기 전에 함수가 반환되면 안 된다는 점도 유지해야 할 불변식입니다.


최종 결과: p50 203ms → 20ms

개선 단계별 성능

단계최적화 내용p50 응답 시간개선율
0초기 상태203.5ms-
1해시맵 인덱싱 (전체 탐색 → 결과 크기 k에 비례)85.3ms2.4배
2참조 반환 (복사 제거)42.1ms2.0배
3문자열 예약 + 병렬화20.3ms2.1배
최종누적20.3ms10.0배

CPU 프로파일 비교

# 최적화 전
65.23%  findUsersByRole (전체 선형 탐색)
18.45%  string 복사
12.34%  JSON 직렬화
# 최적화 후
35.12%  JSON 직렬화 (병렬)
28.34%  네트워크 I/O
15.23%  해시맵 조회
21.31%  기타

교훈과 베스트 프랙티스

핵심 교훈

  1. 측정 없이 최적화하지 마라: 추측이 아닌 프로파일링 데이터로 결정
  2. 알고리즘이 우선: 작은 상수 최적화보다 복잡도 개선이 효과적
  3. 불필요한 복사 제거: 참조, 이동, string_view 활용
  4. 병렬화는 마지막: 직렬 최적화 후 멀티스레딩 고려

최적화 우선순위

graph TD
    A[성능 문제 발견] --> B[측정 및 프로파일링]
    B --> C{병목 지점 파악}
    C --> D[알고리즘 복잡도 개선]
    D --> E[메모리 최적화]
    E --> F[컴파일러 최적화]
    F --> G[멀티스레딩]
    G --> H[검증 및 배포]

프로파일링 도구 선택

도구용도명령어
perfCPU 핫스팟perf record -g ./app && perf report
gprof함수별 시간g++ -pg ... && ./app && gprof app
Valgrind Callgrind상세 콜 그래프valgrind --tool=callgrind ./app
Heaptrack메모리 할당heaptrack ./app

추가 최적화 아이디어

캐싱

class UserManager {
    std::unordered_map<std::string, std::vector<const User*>> cache_;
    std::chrono::steady_clock::time_point cacheTime_;
    
public:
    std::vector<const User*> findUsersByRole(const std::string& role) {
        auto now = std::chrono::steady_clock::now();
        
        // 캐시 유효성 (5초)
        if (now - cacheTime_ < std::chrono::seconds(5)) {
            if (auto it = cache_.find(role); it != cache_.end()) {
                return it->second;
            }
        }
        
        // 캐시 미스: 실제 조회
        auto result = findUsersByRoleImpl(role);
        cache_[role] = result;
        cacheTime_ = now;
        
        return result;
    }
};

이 캐시 예제는 아이디어를 보여 주기 위한 것이라 그대로 쓰면 문제가 됩니다. cacheTime_이 역할별이 아니라 하나뿐이어서, 어떤 역할이 캐시 미스로 갱신되면 5초가 지난 다른 역할의 오래된 항목까지 다시 유효해집니다. 항목마다 만료 시각을 저장해야 하고, 여러 요청 스레드가 동시에 cache_를 수정하므로 락도 필요합니다. 무엇보다 사용자 데이터가 바뀌어도 최대 5초 동안 옛 결과를 돌려준다는 점이 비즈니스적으로 허용되는지를 먼저 확인해야 합니다.

데이터베이스 인덱스

-- 역할 조회가 빈번하면 DB 인덱스 추가
CREATE INDEX idx_user_roles ON users USING GIN(roles);

응답 압축

// gzip 압축으로 네트워크 전송 시간 단축
#include <zlib.h>
std::string compressJson(const std::string& json) {
    // gzip 압축 구현
    // ...
}

마무리

성능 최적화는 측정 → 분석 → 최적화 → 검증의 반복입니다. 이 사례에서는:

  1. perf 프로파일링으로 병목 지점을 정확히 파악했습니다
  2. 알고리즘 개선으로 가장 큰 효과를 얻었습니다 (2.4배)
  3. 메모리 최적화로 추가 개선했습니다 (2배)
  4. 병렬화로 마무리했습니다 (2배) 이 사례의 측정 기준으로 p50이 약 203ms에서 20ms로 줄었습니다. 개선 폭 대부분이 알고리즘 교체에서 나왔다는 점, 그리고 각 단계마다 다시 측정했기 때문에 어떤 변경이 효과가 있었는지 구분할 수 있었다는 점이 핵심입니다.

FAQ

Q1. 최적화는 언제 해야 하나요? 측정 가능한 성능 문제가 있을 때만 하세요. “느낄 것 같아서” 최적화하면 코드만 복잡해집니다.

Q2. -O3 최적화 옵션을 쓰면 안 되나요? -O2로 충분한 경우가 많고, -O3는 코드 크기가 커져 캐시 미스가 증가할 수 있습니다. 측정해보고 결정하세요. Q3. 멀티스레딩을 먼저 적용하면 안 되나요? 직렬 코드를 먼저 최적화하세요. 느린 알고리즘을 병렬화하면 “빠르게 느린 코드”가 됩니다.


같이 보면 좋은 글


성능 최적화·코드 리뷰 체크리스트

성능 최적화 체크리스트

  • 성능 목표 설정 (구체적 숫자)
  • 벤치마크 기준선 측정
  • 프로파일링 (perf, gprof, Valgrind)
  • 병목 지점 파악 (상위 3개)
  • 알고리즘 복잡도 분석
  • 최적화 적용
  • 벤치마크로 검증
  • 회귀 테스트
  • 프로덕션 배포 및 모니터링

코드 리뷰 체크리스트

  • 요청마다 전체 데이터를 훑는 탐색이 있는가?
  • 불필요한 복사가 있는가?
  • 원소마다 임시 문자열·스트림을 만드는가?
  • reserve()를 사용했는가?
  • 병렬화 가능한 부분이 있는가?
  • 캐싱을 고려했는가?