C++ Small String Optimization (SSO) | string 성능 최적화 원리

C++ Small String Optimization (SSO) | string 성능 최적화 원리

이 글의 핵심

C++ Small String Optimization (SSO)에 대한 실전 가이드입니다. string 성능 최적화 원리 등을 예제와 함께 상세히 설명합니다.

들어가며: “짧은 문자열이 긴 문자열보다 훨씬 빠른 이유는?"

"string이 힙 할당을 안 하는 경우가 있어요”

C++의 std::string짧은 문자열힙이 아닌 객체 내부에 저장하는 Small String Optimization(SSO)를 사용합니다.

// 짧은 문자열 (SSO)
std::string short_str = "Hello";  // 힙 할당 없음!

// 긴 문자열
std::string long_str = "This is a very long string that exceeds SSO limit";  // 힙 할당

이 글에서 다루는 것:

  • SSO란?
  • string 내부 구조
  • 성능 측정
  • 실전 활용

목차

  1. SSO란?
  2. string 내부 구조
  3. SSO 확인 방법
  4. 성능 측정
  5. 실전 활용
  6. 정리

1. SSO란?

Small String Optimization

SSO짧은 문자열string 객체 내부 버퍼에 저장하는 최적화입니다.

// 개념적 구조
class string {
    union {
        // 짧은 문자열: 내부 버퍼 사용
        struct {
            char buffer[16];  // 15자 + null
            uint8_t size;
        } short_string;
        
        // 긴 문자열: 힙 할당
        struct {
            char* data;
            size_t size;
            size_t capacity;
        } long_string;
    };
};

SSO 임계값

컴파일러플랫폼SSO 크기
GCCx6415자
Clangx6422자
MSVCx6415자
GCCx8610자
// GCC/MSVC: 15자까지 SSO
std::string s1 = "123456789012345";  // 15자 → SSO
std::string s2 = "1234567890123456";  // 16자 → 힙 할당

2. string 내부 구조

짧은 문자열 (SSO)

// 짧은 문자열
std::string str = "Hello";

// 메모리 레이아웃 (GCC, x64)
// [H][e][l][l][o][\0][...][5]
//  ↑ 내부 버퍼 (16바이트)    ↑ 크기

긴 문자열 (힙 할당)

// 긴 문자열
std::string str = "This is a very long string";

// 메모리 레이아웃 (GCC, x64)
// [포인터(8)][크기(8)][용량(8)]
//     ↓
//  [힙 메모리: "This is a very long string\0"]

3. SSO 확인 방법

방법 1: 주소 비교

#include <iostream>
#include <string>

void checkSSO(const std::string& str) {
    const void* strAddr = &str;
    const void* dataAddr = str.data();
    
    std::cout << "문자열: \"" << str << "\"\n";
    std::cout << "길이: " << str.size() << "\n";
    std::cout << "string 주소: " << strAddr << "\n";
    std::cout << "data() 주소: " << dataAddr << "\n";
    
    if (strAddr == dataAddr || 
        (dataAddr >= strAddr && 
         dataAddr < (const char*)strAddr + sizeof(std::string))) {
        std::cout << "→ SSO (내부 버퍼)\n\n";
    } else {
        std::cout << "→ 힙 할당\n\n";
    }
}

int main() {
    checkSSO("Hi");                    // SSO
    checkSSO("Hello World");           // SSO
    checkSSO("123456789012345");       // SSO (15자)
    checkSSO("1234567890123456");      // 힙 할당 (16자)
    checkSSO("This is a very long string that exceeds SSO");  // 힙 할당
}

// 출력 (GCC):
// 문자열: "Hi"
// 길이: 2
// string 주소: 0x7ffc...
// data() 주소: 0x7ffc...
// → SSO (내부 버퍼)
//
// 문자열: "1234567890123456"
// 길이: 16
// string 주소: 0x7ffc...
// data() 주소: 0x55a8... (다른 주소)
// → 힙 할당

방법 2: sizeof 확인

#include <iostream>
#include <string>

int main() {
    std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';
    
    // GCC/Clang: 32바이트
    // MSVC: 32바이트 (x64)
}

4. 성능 측정

벤치마크: 생성/소멸

#include <benchmark/benchmark.h>

// 짧은 문자열 (SSO)
static void BM_ShortString(benchmark::State& state) {
    for (auto _ : state) {
        std::string str = "Hello";  // SSO
        benchmark::DoNotOptimize(str);
    }
}
BENCHMARK(BM_ShortString);

// 긴 문자열 (힙 할당)
static void BM_LongString(benchmark::State& state) {
    for (auto _ : state) {
        std::string str = "This is a very long string that exceeds SSO limit";
        benchmark::DoNotOptimize(str);
    }
}
BENCHMARK(BM_LongString);

결과 (GCC 13, -O3):

BM_ShortString      1 ns   (SSO - 힙 할당 없음)
BM_LongString      50 ns   (힙 할당 오버헤드)

벤치마크: 복사

// 짧은 문자열 복사 (SSO)
static void BM_CopyShort(benchmark::State& state) {
    std::string str = "Hello";
    for (auto _ : state) {
        std::string copy = str;  // 내부 버퍼 복사
        benchmark::DoNotOptimize(copy);
    }
}
BENCHMARK(BM_CopyShort);

// 긴 문자열 복사 (힙 할당)
static void BM_CopyLong(benchmark::State& state) {
    std::string str = "This is a very long string that exceeds SSO limit";
    for (auto _ : state) {
        std::string copy = str;  // 힙 할당 + 메모리 복사
        benchmark::DoNotOptimize(copy);
    }
}
BENCHMARK(BM_CopyLong);

결과 (GCC 13, -O3):

BM_CopyShort        2 ns   (버퍼 복사)
BM_CopyLong       100 ns   (힙 할당 + 복사)

5. 실전 활용

활용 1: 짧은 문자열 선호

// ✅ SSO 활용
std::vector<std::string> names;
names.reserve(1000);

for (int i = 0; i < 1000; ++i) {
    // 짧은 이름 → SSO
    names.emplace_back("User" + std::to_string(i));  // "User123" → SSO
}

// ❌ 긴 문자열 → 힙 할당
for (int i = 0; i < 1000; ++i) {
    names.emplace_back("This is a very long user name: " + std::to_string(i));
}

활용 2: 문자열 연결 최소화

// ❌ 여러 번 연결 → SSO 초과
std::string buildPath(const std::string& dir, const std::string& file) {
    std::string path = dir;  // 복사
    path += "/";             // SSO 초과 가능
    path += file;            // 힙 할당
    return path;
}

// ✅ 한 번에 생성
std::string buildPath(const std::string& dir, const std::string& file) {
    std::string path;
    path.reserve(dir.size() + 1 + file.size());  // 미리 공간 확보
    path += dir;
    path += "/";
    path += file;
    return path;
}

활용 3: 임시 문자열 회피

// ❌ 임시 문자열 생성
void log(const std::string& msg) {
    std::cout << "[LOG] " + msg + "\n";  // 임시 string 생성
}

// ✅ string_view 사용 (C++17)
void log(std::string_view msg) {
    std::cout << "[LOG] " << msg << "\n";  // 복사 없음
}

SSO 구현 예시

간단한 SSO 구현

class SmallString {
    static constexpr size_t SSO_SIZE = 15;
    
    union {
        // 짧은 문자열
        struct {
            char buffer[SSO_SIZE + 1];  // +1 for null
            uint8_t size;
        } sso;
        
        // 긴 문자열
        struct {
            char* data;
            size_t size;
            size_t capacity;
        } heap;
    };
    
    bool isSSO() const {
        return sso.size <= SSO_SIZE;
    }
    
public:
    SmallString(const char* str) {
        size_t len = std::strlen(str);
        
        if (len <= SSO_SIZE) {
            // SSO 사용
            std::memcpy(sso.buffer, str, len + 1);
            sso.size = static_cast<uint8_t>(len);
            std::cout << "SSO 사용 (길이: " << len << ")\n";
        } else {
            // 힙 할당
            heap.size = len;
            heap.capacity = len + 1;
            heap.data = new char[heap.capacity];
            std::memcpy(heap.data, str, len + 1);
            std::cout << "힙 할당 (길이: " << len << ")\n";
        }
    }
    
    ~SmallString() {
        if (!isSSO()) {
            delete[] heap.data;
        }
    }
    
    const char* c_str() const {
        return isSSO() ? sso.buffer : heap.data;
    }
    
    size_t size() const {
        return isSSO() ? sso.size : heap.size;
    }
};

int main() {
    SmallString s1("Hello");           // SSO 사용 (길이: 5)
    SmallString s2("123456789012345"); // SSO 사용 (길이: 15)
    SmallString s3("1234567890123456");// 힙 할당 (길이: 16)
    
    std::cout << s1.c_str() << '\n';
    std::cout << s2.c_str() << '\n';
    std::cout << s3.c_str() << '\n';
}

실전 예시

예시 1: 로그 메시지

// ✅ SSO 활용: 짧은 로그 레벨
enum class LogLevel {
    DEBUG, INFO, WARN, ERROR
};

std::string getLevelString(LogLevel level) {
    switch (level) {
        case LogLevel::DEBUG: return "DEBUG";  // SSO
        case LogLevel::INFO:  return "INFO";   // SSO
        case LogLevel::WARN:  return "WARN";   // SSO
        case LogLevel::ERROR: return "ERROR";  // SSO
    }
}

void log(LogLevel level, const std::string& msg) {
    std::string levelStr = getLevelString(level);  // SSO → 힙 할당 없음
    std::cout << "[" << levelStr << "] " << msg << '\n';
}

int main() {
    for (int i = 0; i < 1000000; ++i) {
        log(LogLevel::INFO, "메시지");  // 빠름!
    }
}

예시 2: 키-값 저장

// ✅ SSO 활용: 짧은 키
std::map<std::string, int> scores;

// 짧은 키 → SSO
scores["Alice"] = 100;  // SSO
scores["Bob"] = 90;     // SSO
scores["Charlie"] = 85; // SSO

// 긴 키 → 힙 할당
scores["VeryLongUserNameThatExceedsSSO"] = 80;  // 힙 할당

예시 3: 문자열 파싱

// ✅ SSO 활용: 토큰 파싱
std::vector<std::string> tokenize(const std::string& str, char delim) {
    std::vector<std::string> tokens;
    std::stringstream ss(str);
    std::string token;
    
    while (std::getline(ss, token, delim)) {
        tokens.push_back(token);  // 짧은 토큰 → SSO
    }
    
    return tokens;
}

int main() {
    auto tokens = tokenize("a,b,c,d,e,f,g", ',');
    // 모든 토큰이 SSO → 힙 할당 없음!
    
    for (const auto& token : tokens) {
        std::cout << token << '\n';
    }
}

성능 최적화 팁

팁 1: 짧은 문자열 유지

// ✅ 짧은 문자열
std::string status = "OK";        // SSO
std::string code = "200";         // SSO
std::string method = "GET";       // SSO

// ❌ 불필요하게 긴 문자열
std::string status = "Status: OK";  // 힙 할당 가능

팁 2: reserve() 사용

// ✅ reserve()로 재할당 방지
std::string buildUrl(const std::string& host, const std::string& path) {
    std::string url;
    url.reserve(host.size() + path.size() + 10);  // "https://" + "/"
    url = "https://";
    url += host;
    url += "/";
    url += path;
    return url;
}

팁 3: string_view 사용

// ✅ string_view: 복사 없음
void process(std::string_view str) {
    // 문자열 복사 없음
    if (str.starts_with("http")) {
        std::cout << "URL\n";
    }
}

int main() {
    std::string url = "https://example.com";
    process(url);  // 복사 없음
}

정리

SSO 활용 가이드

상황권장
짧은 문자열 (≤15자)SSO 자동 적용
긴 문자열reserve() 사용
문자열 연결reserve() + +=
읽기만string_view
임시 문자열회피

핵심 규칙

  1. 짧은 문자열 선호 (SSO 활용)
  2. reserve()로 재할당 방지
  3. string_view로 복사 회피
  4. 문자열 연결 최소화

체크리스트

  • 문자열이 15자 이하인가?
  • reserve()를 사용하는가?
  • 불필요한 문자열 복사가 있는가?
  • string_view를 사용할 수 있는가?

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ string 기초 | 완벽 가이드
  • C++ string vs string_view | 비교
  • C++ 성능 최적화 | 병목 찾기
  • C++ 메모리 최적화 | 할당 최소화

이 글에서 다루는 키워드 (관련 검색어)

Small String Optimization, SSO, string 성능, 힙 할당, string 내부 구조 등으로 검색하시면 이 글이 도움이 됩니다.

자주 하는 실수

실수 1: 불필요한 문자열 연결

// ❌ 실수: 여러 번 연결 → SSO 초과
std::string buildMessage() {
    std::string msg = "Error: ";  // SSO
    msg += "File not found: ";    // SSO 초과
    msg += "/very/long/path/to/file.txt";  // 힙 재할당
    return msg;
}

// ✅ 한 번에 생성
std::string buildMessage() {
    return "Error: File not found: /very/long/path/to/file.txt";
    // 또는 reserve() 사용
}

실수 2: 임시 문자열 생성

// ❌ 실수: 임시 문자열
void log(const std::string& msg) {
    std::cout << "[LOG] " + msg + "\n";  // 임시 string 2개 생성
}

// ✅ string_view 사용
void log(std::string_view msg) {
    std::cout << "[LOG] " << msg << "\n";  // 복사 없음
}

실수 3: 긴 문자열 리터럴

// ❌ 실수: 긴 리터럴을 string으로
const std::string ERROR_MSG = "This is a very long error message...";
// 프로그램 시작 시 힙 할당

// ✅ string_view 또는 const char* 사용
constexpr std::string_view ERROR_MSG = "This is a very long error message...";
// 또는
constexpr const char* ERROR_MSG = "This is a very long error message...";

실무 트러블슈팅

문제: 예상보다 많은 힙 할당

증상:

# Valgrind로 힙 할당 확인
$ valgrind --tool=massif ./myapp
# 예상보다 많은 malloc 호출

진단:

// 문자열 길이 확인
std::string str = "Hello";
std::cout << "길이: " << str.size() << '\n';
std::cout << "용량: " << str.capacity() << '\n';

// SSO 확인
const void* strAddr = &str;
const void* dataAddr = str.data();
if (strAddr == dataAddr || 
    (dataAddr >= strAddr && dataAddr < (const char*)strAddr + sizeof(std::string))) {
    std::cout << "SSO 사용\n";
} else {
    std::cout << "힙 할당\n";
}

해결:

// 1. 문자열 짧게 유지
// 2. reserve() 사용
// 3. string_view 활용

문제: 문자열 복사 성능 저하

증상: 문자열 복사가 느림

원인: SSO 초과로 힙 할당 발생

해결:

// ✅ 짧은 키 사용
std::map<std::string, int> cache;
cache["usr"] = 1;  // SSO
cache["cfg"] = 2;  // SSO

// ❌ 긴 키
cache["very_long_configuration_key_name"] = 3;  // 힙 할당

컴파일러별 SSO 크기

상세 비교

컴파일러플랫폼SSO 크기sizeof(string)비고
GCC 11+x6415자32바이트표준
Clang 14+x6422자24바이트더 큼
MSVC 2022x6415자32바이트GCC와 동일
GCCx8610자24바이트32비트
libc++x6422자24바이트Clang 표준 라이브러리
// 컴파일러 확인
#ifdef __GNUC__
    std::cout << "GCC " << __GNUC__ << '\n';
#elif defined(_MSC_VER)
    std::cout << "MSVC " << _MSC_VER << '\n';
#elif defined(__clang__)
    std::cout << "Clang " << __clang_major__ << '\n';
#endif

std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';

베스트 프랙티스

1. 문자열 길이 가이드

// ✅ SSO 활용
std::string status = "OK";          // 2자 - SSO
std::string method = "GET";         // 3자 - SSO
std::string code = "200";           // 3자 - SSO
std::string type = "application";   // 11자 - SSO

// ⚠️ SSO 경계
std::string path = "/api/users/123";  // 14자 - SSO (GCC)
std::string uuid = "550e8400-e29b";   // 16자 - 힙 할당 (GCC)

2. 문자열 빌더 패턴

// ✅ 효율적인 문자열 빌더
class StringBuilder {
    std::string buffer_;
public:
    StringBuilder& append(std::string_view str) {
        buffer_ += str;
        return *this;
    }
    
    StringBuilder& reserve(size_t size) {
        buffer_.reserve(size);
        return *this;
    }
    
    std::string build() {
        return std::move(buffer_);
    }
};

// 사용
auto str = StringBuilder()
    .reserve(100)  // 미리 공간 확보
    .append("Hello")
    .append(" ")
    .append("World")
    .build();

3. 코드 리뷰 체크포인트

// 🔍 리뷰 시 확인사항

// 1. 문자열 연결
std::string msg = a + b + c;  // ⚠️ 임시 객체 2개

// 2. 반복문 내 문자열 생성
for (int i = 0; i < 1000; ++i) {
    std::string temp = "prefix_" + std::to_string(i);  // ⚠️ 힙 할당
}

// 3. 함수 파라미터
void process(std::string s);  // ⚠️ 복사 발생
void process(std::string_view s);  // ✅ 복사 없음

실무 시나리오

시나리오 1: HTTP 헤더 파싱

// ✅ 실무 예시: HTTP 헤더
class HttpHeaders {
    std::map<std::string, std::string> headers_;
    
public:
    void parse(const std::string& headerLine) {
        auto pos = headerLine.find(':');
        if (pos != std::string::npos) {
            // 짧은 헤더 이름 → SSO
            std::string name = headerLine.substr(0, pos);  // "Host", "Accept" 등
            std::string value = headerLine.substr(pos + 2);
            
            headers_[name] = value;
        }
    }
};

// 대부분의 HTTP 헤더 이름은 15자 이하
// Host, Accept, Content-Type, User-Agent 등

시나리오 2: 로그 시스템

// ✅ 실무 예시: 로그 레벨
enum class LogLevel {
    DEBUG, INFO, WARN, ERROR, FATAL
};

std::string_view getLevelString(LogLevel level) {
    switch (level) {
        case LogLevel::DEBUG: return "DEBUG";  // 5자 - SSO
        case LogLevel::INFO:  return "INFO";   // 4자 - SSO
        case LogLevel::WARN:  return "WARN";   // 4자 - SSO
        case LogLevel::ERROR: return "ERROR";  // 5자 - SSO
        case LogLevel::FATAL: return "FATAL";  // 5자 - SSO
    }
}

// 로그 메시지
void log(LogLevel level, std::string_view msg) {
    // 짧은 레벨 문자열 → SSO
    std::string levelStr(getLevelString(level));
    std::cout << "[" << levelStr << "] " << msg << '\n';
}

시나리오 3: 설정 키

// ✅ 실무 예시: 설정 키
class Config {
    std::map<std::string, std::string> values_;
    
public:
    // 짧은 키 사용 → SSO
    void set(std::string_view key, std::string_view value) {
        values_[std::string(key)] = value;
    }
    
    std::optional<std::string> get(std::string_view key) const {
        auto it = values_.find(std::string(key));
        if (it != values_.end()) {
            return it->second;
        }
        return std::nullopt;
    }
};

// 사용 - 짧은 키 선호
config.set("port", "8080");      // 4자 - SSO
config.set("host", "localhost"); // 4자 - SSO
config.set("debug", "true");     // 5자 - SSO

성능 프로파일링

힙 할당 추적

// 커스텀 allocator로 추적
template <typename T>
class TrackingAllocator {
public:
    using value_type = T;
    
    T* allocate(size_t n) {
        std::cout << "할당: " << n * sizeof(T) << " 바이트\n";
        return static_cast<T*>(::operator new(n * sizeof(T)));
    }
    
    void deallocate(T* p, size_t n) {
        std::cout << "해제: " << n * sizeof(T) << " 바이트\n";
        ::operator delete(p);
    }
};

// 사용
using TrackedString = std::basic_string<char, std::char_traits<char>, 
                                        TrackingAllocator<char>>;

TrackedString s1 = "Hello";  // SSO - 할당 없음
TrackedString s2 = "This is a very long string";  // 힙 할당 출력

마치며

Small String Optimization(SSO)짧은 문자열의 힙 할당을 제거하는 강력한 최적화입니다.

핵심 원칙:

  1. 짧은 문자열 선호 (≤15자)
  2. reserve()로 재할당 방지
  3. string_view로 복사 회피

실무 팁:

  • HTTP 헤더, 로그 레벨, 설정 키는 짧게
  • 문자열 연결 시 reserve() 사용
  • 프로파일러로 힙 할당 확인

짧은 문자열을 사용하면 SSO가 자동으로 적용되어 성능이 크게 향상됩니다.

다음 단계: SSO를 이해했다면, C++ string_view 가이드에서 더 깊이 배워보세요.


관련 글

  • C++ 시리즈 전체 보기
  • C++ Adapter Pattern 완벽 가이드 | 인터페이스 변환과 호환성
  • C++ ADL |
  • C++ Aggregate Initialization |