C++ 성능 최적화 실전 사례 | API 응답 시간 200ms → 20ms 개선기
이 글의 핵심
C++ API 응답 시간 10배 개선 실전 사례 - 프로파일링, 알고리즘, 메모리, 멀티스레딩 최적화
들어가며
“API가 너무 느려요”라는 제보를 받고 시작한 성능 최적화 여정입니다. 측정 → 분석 → 최적화 → 검증의 과정을 거쳐 응답 시간을 200ms에서 20ms로 10배 개선한 사례를 공유합니다.
일상에 빗대면, “느리다”는 말만 듣고 어느 방 문이 막혔는지 확인하지 않고 도색부터 하는 것과 같습니다. 이 사례에서는 먼저 벤치마크와 프로파일러로 병목 문을 연 뒤에만 손을 댔습니다.
이 글을 읽으면
- 성능 병목을 찾는 체계적인 방법을 배웁니다
- 프로파일링 도구(perf, gprof, Valgrind)를 실전에서 활용하는 법을 익힙니다
- 알고리즘, 메모리, 멀티스레딩 최적화 기법을 이해합니다
- 최적화 효과를 정량적으로 측정하는 방법을 습득합니다
목차
- 문제: API 응답이 너무 느림
- 측정: 벤치마크 기준선 설정
- 프로파일링: perf로 핫스팟 찾기
- 병목 1: O(n²) 알고리즘
- 최적화 1: 해시맵으로 O(n) 개선
- 병목 2: 문자열 복사
- 최적화 2: string_view와 이동 의미론
- 병목 3: JSON 직렬화
- 최적화 3: 멀티스레딩과 객체 풀
- 최종 결과: 10배 성능 향상
- 마무리
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
발견
findUsersByRole이 전체 시간의 65% 차지- 문자열 복사가 18%
- 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.3ms | 2.4배 |
| 2 | 참조 반환 (복사 제거) | 42.1ms | 2.0배 |
| 3 | 문자열 예약 + 병렬화 | 20.3ms | 2.1배 |
| 최종 | 누적 | 20.3ms | 10.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. 교훈과 베스트 프랙티스
핵심 교훈
- 측정 없이 최적화하지 마라: 추측이 아닌 프로파일링 데이터로 결정
- 알고리즘이 우선: 작은 상수 최적화보다 복잡도 개선이 효과적
- 불필요한 복사 제거: 참조, 이동, string_view 활용
- 병렬화는 마지막: 직렬 최적화 후 멀티스레딩 고려
최적화 우선순위
graph TD
A[성능 문제 발견] --> B[측정 및 프로파일링]
B --> C{병목 지점 파악}
C --> D[알고리즘 복잡도 개선]
D --> E[메모리 최적화]
E --> F[컴파일러 최적화]
F --> G[멀티스레딩]
G --> H[검증 및 배포]
프로파일링 도구 선택
| 도구 | 용도 | 명령어 |
|---|---|---|
| perf | CPU 핫스팟 | 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 압축 구현
// ...
}
마무리
성능 최적화는 측정 → 분석 → 최적화 → 검증의 반복입니다. 이 사례에서는:
- perf 프로파일링으로 병목 지점을 정확히 파악했습니다
- 알고리즘 개선으로 가장 큰 효과를 얻었습니다 (2.4배)
- 메모리 최적화로 추가 개선했습니다 (2배)
- 병렬화로 마무리했습니다 (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, 이동 의미론, 멀티스레딩, 병렬화, 벤치마크, 실전 사례