C++ 성능 최적화 실전 사례 | API 응답 시간 200ms → 20ms 개선기

C++ 성능 최적화 실전 사례 | API 응답 시간 200ms → 20ms 개선기

이 글의 핵심

C++ API 응답 시간 10배 개선 실전 사례 - 프로파일링, 알고리즘, 메모리, 멀티스레딩 최적화

들어가며

“API가 너무 느려요”라는 제보를 받고 시작한 성능 최적화 여정입니다. 측정 → 분석 → 최적화 → 검증의 과정을 거쳐 응답 시간을 200ms에서 20ms로 10배 개선한 사례를 공유합니다.

일상에 빗대면, “느리다”는 말만 듣고 어느 방 문이 막혔는지 확인하지 않고 도색부터 하는 것과 같습니다. 이 사례에서는 먼저 벤치마크와 프로파일러로 병목 문을 연 뒤에만 손을 댔습니다.

이 글을 읽으면

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

목차

  1. 문제: API 응답이 너무 느림
  2. 측정: 벤치마크 기준선 설정
  3. 프로파일링: perf로 핫스팟 찾기
  4. 병목 1: O(n²) 알고리즘
  5. 최적화 1: 해시맵으로 O(n) 개선
  6. 병목 2: 문자열 복사
  7. 최적화 2: string_view와 이동 의미론
  8. 병목 3: JSON 직렬화
  9. 최적화 3: 멀티스레딩과 객체 풀
  10. 최종 결과: 10배 성능 향상
  11. 마무리

1. 문제: 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 코어

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

벤치마크 도구 작성

// benchmark.cpp
#include <chrono>
#include <iostream>
#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";
    }
};

기준선 측정

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

3. 프로파일링: 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%

4. 병목 1: O(n²) 알고리즘

문제 코드

class UserManager {
    std::vector<User> users_; // 10,000명

public:
    std::vector<User> findUsersByRole(const std::string& role) {
        std::vector<User> result;
        
        // 🚨 O(n²): 각 사용자마다 전체 역할 목록 순회
        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)이지만, 문자열 비교가 느림

5. 최적화 1: 해시맵으로 O(n) 개선

개선 코드

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% → 28% (CPU 사용률)


6. 병목 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번의 문자열 복사
};

7. 최적화 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;
}

개선 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();
}

결과

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

8. 병목 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;
}

문제점: string += 는 매번 재할당 가능성 → O(n²)


9. 최적화 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;
}

개선 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  # 병렬화로 선형 증가 억제

10. 최종 결과: 10배 성능 향상

개선 단계별 성능

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

CPU 프로파일 비교

# 최적화 전
65.23%  findUsersByRole (O(n²) 순회)
18.45%  string 복사
12.34%  JSON 직렬화

# 최적화 후
35.12%  JSON 직렬화 (병렬)
28.34%  네트워크 I/O
15.23%  해시맵 조회
21.31%  기타

11. 교훈과 베스트 프랙티스

핵심 교훈

  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

12. 추가 최적화 아이디어

캐싱

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;
    }
};

데이터베이스 인덱스

-- 역할 조회가 빈번하면 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배)

총 10배 성능 향상을 달성했고, 사용자 경험이 크게 개선되었습니다.


FAQ

Q1. 최적화는 언제 해야 하나요?

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

Q2. -O3 최적화 옵션을 쓰면 안 되나요?

-O2로 충분한 경우가 많고, -O3는 코드 크기가 커져 캐시 미스가 증가할 수 있습니다. 측정해보고 결정하세요.

Q3. 멀티스레딩을 먼저 적용하면 안 되나요?

직렬 코드를 먼저 최적화하세요. 느린 알고리즘을 병렬화하면 “빠르게 느린 코드”가 됩니다.


관련 글

  • C++ 프로파일링 완벽 가이드
  • C++ 알고리즘 복잡도 분석
  • C++ 멀티스레딩 최적화
  • C++ string_view 활용법

실전 체크리스트

성능 최적화 체크리스트

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

코드 리뷰 체크리스트

  • O(n²) 이상 알고리즘이 있는가?
  • 불필요한 복사가 있는가?
  • string += 반복이 있는가?
  • reserve()를 사용했는가?
  • 병렬화 가능한 부분이 있는가?
  • 캐싱을 고려했는가?

키워드

C++, 성능 최적화, Performance, 프로파일링, perf, gprof, 병목, 알고리즘, 복잡도, 해시맵, string_view, 이동 의미론, 멀티스레딩, 병렬화, 벤치마크, 실전 사례