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

이 글의 핵심

같은 string 연산인데 짧은 문자열은 빠르고 긴 문자열은 갑자기 느려지는 이유는, 일정 길이를 넘는 순간 힙 할당이 시작되기 때문입니다. 임계값은 표준이 아니라 구현마다 다르므로 직접 확인하는 방법을 먼저 보고, 로그 메시지와 키-값 저장, 문자열 파싱에서 SSO를 살리는 방법, reserve와 string_view로 불필요한 할당을 줄이는 팁을 정리했습니다.

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

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가 중요한 이유는 힙 할당이 생각보다 비싸기 때문입니다. malloc/new는 할당기 내부의 자유 목록을 탐색하고, 멀티스레드 환경에서는 동기화 비용까지 들며, 할당된 메모리는 string 객체와 떨어진 곳에 있어 문자열을 읽을 때 캐시 미스가 한 번 더 생길 수 있습니다. 프로그램에서 다루는 문자열 대부분이 식별자, 키, 짧은 토큰처럼 짧다는 관찰에서 출발해, 표준 라이브러리 구현들은 짧은 문자열을 객체 안에 직접 넣는 방식을 택했습니다. 표준은 SSO를 요구하지 않지만, C++11이 copy-on-write 방식의 string을 사실상 금지한 뒤로 주요 구현 세 곳(libstdc++, libc++, MSVC STL)이 모두 SSO를 씁니다.


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 크기
libstdc++ (GCC, Linux 기본 Clang)x6415자
libc++ (macOS Clang 등)x6422자
MSVC STLx6415자
libstdc++x86 (32비트)15자

임계값은 컴파일러가 아니라 표준 라이브러리 구현이 정합니다. Linux에서 Clang으로 빌드하면 기본적으로 GCC의 libstdc++를 링크하므로 15자이고, -stdlib=libc++를 주거나 macOS에서 빌드하면 22자가 됩니다. 같은 코드를 플랫폼별로 벤치마크했을 때 결과가 달라지는 흔한 이유입니다.

// libstdc++/MSVC: 15자까지 SSO
std::string s1 = "123456789012345";  // 15자 → SSO
std::string s2 = "1234567890123456";  // 16자 → 힙 할당

string 내부 구조

짧은 문자열 (SSO)

// 짧은 문자열
std::string str = "Hello";
// 메모리 레이아웃 (libstdc++, x64, 32바이트)
// [data 포인터(8)][크기(8)][H][e][l][l][o][\0][... 16바이트 내부 버퍼]
//      ↓ 자기 자신의 내부 버퍼를 가리킴

libstdc++는 짧은 문자열일 때도 data 포인터를 두고, 그 포인터가 객체 안의 16바이트 버퍼를 가리키게 합니다. 그래서 data()가 항상 포인터 하나만 읽으면 되는 대신 객체가 32바이트로 큽니다. libc++는 반대로 24바이트 전체를 짧은 문자열 모드에서는 문자 저장에 쓰고, 크기를 저장하는 바이트의 한 비트로 모드를 구분합니다. 그래서 22자까지 들어가는 대신 data()를 호출할 때마다 모드를 확인하는 분기가 필요합니다. 어느 쪽이 더 낫다기보다 객체 크기와 접근 비용 사이의 서로 다른 선택입니다.

libstdc++ 방식에는 부작용이 하나 있습니다. 객체가 자기 내부를 가리키는 포인터를 갖고 있으므로, std::string은 memcpy로 옮기면 안 되는 타입입니다. 복사된 객체의 data 포인터가 원본의 버퍼를 가리키게 되기 때문입니다. 이 때문에 libstdc++의 string 이동은 짧은 문자열일 때 포인터를 새 객체의 버퍼로 다시 맞추는 작업을 합니다.

긴 문자열 (힙 할당)

// 긴 문자열
std::string str = "This is a very long string";
// 메모리 레이아웃 (libstdc++, x64)
// [포인터(8)][크기(8)][용량(8)][사용 안 함(8)]
//     ↓
//  [힙 메모리: "This is a very long string\0"]

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... (다른 주소)
// → 힙 할당

libstdc++에서는 내부 버퍼가 객체 시작 주소에서 16바이트 뒤에 있으므로 두 주소가 정확히 같지 않습니다. 위 함수가 “같은가”뿐 아니라 “객체 범위 안에 있는가”까지 확인하는 이유입니다. 이 확인 방법은 구현의 내부 구조에 기대는 진단용 코드이므로 테스트나 실험에만 쓰고 운영 로직에 넣으면 안 됩니다. 할당이 일어나는지 확실히 보려면 뒤에 나오는 추적 할당기나 operator new를 재정의해 호출 횟수를 세는 방법이 더 신뢰할 만합니다.

방법 2: sizeof 확인

#include <iostream>
#include <string>
int main() {
    std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';
    
    // libstdc++ (x64): 32바이트
    // libc++ (x64): 24바이트
    // MSVC (x64): 32바이트 (디버그 빌드에서는 더 클 수 있음)
}

성능 측정

벤치마크: 생성/소멸

#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);

이 벤치마크를 돌려 보면 짧은 문자열 쪽이 한 자릿수 나노초 수준, 긴 문자열 쪽이 할당·해제 비용만큼 몇 배에서 수십 배 느리게 나오는 것이 일반적입니다. 정확한 배율은 할당기(glibc malloc, jemalloc, mimalloc, Windows 힙)에 따라 크게 달라서, 같은 코드라도 Linux와 Windows의 결과가 다릅니다. 반복문 안에서 같은 크기를 할당·해제하는 패턴은 할당기의 캐시가 가장 잘 동작하는 경우라, 실제 프로그램에서는 차이가 이보다 더 클 수도 있습니다.

벤치마크: 복사

// 짧은 문자열 복사 (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);

복사에서도 경향은 같습니다. 짧은 문자열 복사는 객체 크기만큼의 바이트 복사로 끝나고, 긴 문자열 복사는 새 힙 할당 + 문자 복사 + 나중의 해제가 따라옵니다. 긴 문자열에서 복사 대신 이동을 쓰면 포인터만 옮기므로 할당이 사라집니다. 반대로 짧은 문자열은 이동해도 버퍼를 복사해야 하므로 이동과 복사의 차이가 거의 없습니다(맨 아래 FAQ 참고).


실전 활용

활용 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));
}

이 예제를 “문자열을 짧게 만들라”는 뜻으로 읽으면 곤란합니다. 데이터의 길이는 도메인이 정하는 것이고, 성능을 위해 사용자 이름을 자를 수는 없습니다. 실무에서 SSO를 의식하는 경우는 주로 설계 단계의 선택입니다. 예를 들어 대량의 식별자를 std::string으로 저장할지, 정수 ID로 저장할지 고를 때, 식별자가 15자 이하라면 string으로 둬도 할당 부담이 거의 없다는 판단 근거가 됩니다. 또 "User" + std::to_string(i) 자체도 임시 문자열을 만들지만, 결과가 짧아 할당 없이 끝난다는 점도 SSO 덕분입니다.

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

reserve의 효과는 SSO와 반대 방향에서 나옵니다. 결과가 SSO 한계를 넘는다면 할당은 어차피 한 번은 일어나야 하는데, reserve 없이 +=를 반복하면 용량이 모자랄 때마다 더 큰 버퍼를 새로 할당하고 복사하는 재할당이 여러 번 일어날 수 있습니다. 미리 전체 크기를 확보하면 할당이 정확히 한 번으로 줄어듭니다. 결과가 짧다면 reserve가 SSO 용량 이하를 요청하므로 할당 없이 무시됩니다. C++17 이상이라면 std::filesystem::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;
    bool is_heap_ = false;  // 현재 활성 union 멤버를 별도 플래그로 기록

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

public:
    // 복사를 막아 두지 않으면 기본 복사가 heap.data 포인터를 공유해 이중 해제 발생
    SmallString(const SmallString&) = delete;
    SmallString& operator=(const SmallString&) = delete;

    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);
            is_heap_ = true;
            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';
}

이 구현에서 가장 중요한 부분은 “지금 어느 모드인가”를 판별하는 방법입니다. 처음 떠오르는 방법은 sso.size 값을 보고 판단하는 것인데, 힙 모드에서는 그 자리에 heap.capacity의 바이트가 겹쳐 있어서, 용량 값의 하위 바이트가 우연히 15 이하이면(예: 용량 257) 힙 문자열을 SSO로 착각합니다. 그래서 위 코드는 별도의 is_heap_ 플래그를 둡니다. 실제 구현들은 이 플래그를 따로 두지 않으려고 크기 필드의 최상위 비트나 최하위 비트를 모드 표시로 빌려 씁니다. libc++가 22자를 넣을 수 있는 것도 이런 비트 절약 덕분입니다.

실전 구현이라면 복사 생성자·이동 생성자·대입 연산자를 모두 구현해야 합니다. 예제는 길이를 줄이려고 복사를 = delete로 막았는데, 이것이 없으면 컴파일러가 만든 기본 복사가 heap.data 포인터를 그대로 복사하고, 두 객체가 소멸할 때 같은 메모리를 두 번 delete[]합니다. std::string을 직접 다시 만드는 일은 드물지만, 고정 크기 버퍼를 가진 small vector나 small function 같은 타입을 만들 때 같은 함정이 반복됩니다.


실전 예시

예시 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, "메시지");  // "메시지"는 UTF-8로 9바이트 → SSO
    }
}

getLevelString이 std::string을 반환해도 레벨 이름이 모두 짧아 할당이 없으므로 이 코드는 충분히 빠릅니다. 하지만 이것은 우연히 길이 조건이 맞은 결과입니다. 아래 “시나리오 2”처럼 std::string_view를 반환하면 길이와 상관없이 할당이 없고, 문자열 리터럴은 프로그램 전체 수명 동안 살아 있으므로 string_view로 가리켜도 안전합니다. 한글처럼 UTF-8에서 글자당 3바이트인 문자열은 “15자”가 아니라 “15바이트” 기준이라 다섯 글자만 넘어도 SSO를 벗어난다는 점도 알아 두면 좋습니다.

예시 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';
    }
}

토큰은 SSO 덕분에 할당이 없지만, std::stringstream 생성 자체와 vector의 성장은 할당을 일으킵니다(이 예제에는 <sstream> include가 필요합니다). 입력 문자열이 토큰보다 오래 살아 있다면 std::vector<std::string_view>로 원본을 가리키기만 하는 편이 더 가볍습니다. 다만 그 경우 원본 문자열이 사라지면 모든 string_view가 댕글링이 되므로 수명 관계를 확실히 해야 합니다.


성능 최적화 팁

팁 1: 짧은 문자열 유지

// ✅ 짧은 문자열
std::string status = "OK";        // SSO
std::string code = "200";         // SSO
std::string method = "GET";       // SSO
// "Status: OK"는 10자라 여전히 SSO. 30자처럼 한계를 넘을 때만 힙 할당
std::string status2 = "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);  // 복사 없음
}

같이 보면 좋은 글


자주 하는 실수

실수 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";
}

해결: 할당이 어디서 오는지 먼저 확인합니다. massif나 heaptrack으로 할당 호출 스택을 보면 std::string 생성자·operator+·substr·to_string 중 어디가 많은지 드러납니다. 복사 인자가 원인이면 const std::string&나 std::string_view로 바꾸고, 반복 연결이 원인이면 reserve를 쓰고, substr 결과를 잠깐 비교만 한다면 string_view::substr로 바꿉니다. 문자열 길이 자체를 줄이는 것은 대개 마지막 선택지입니다.

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

증상: 문자열 복사가 느림 원인: 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)비고
libstdc++ (GCC 5+)x6415자32바이트Linux Clang 기본값도 이것
libc++x6422자24바이트macOS, -stdlib=libc++
MSVC STLx6415자32바이트릴리스 빌드 기준
libstdc++x8615자24바이트32비트
libc++x8610자12바이트32비트

libstdc++는 GCC 5부터 C++11 규칙에 맞춰 copy-on-write string을 SSO 방식으로 바꿨습니다. 오래된 배포판에서 _GLIBCXX_USE_CXX11_ABI=0로 빌드된 라이브러리와 링크하면 옛 COW string이 쓰이고, 두 ABI의 string을 섞으면 undefined reference to ...std::__cxx11::basic_string... 같은 링크 에러가 납니다.

// 컴파일러 확인 (Clang도 __GNUC__를 정의하므로 __clang__을 먼저 검사)
#if defined(__clang__)
    std::cout << "Clang " << __clang_major__ << '\n';
#elif defined(__GNUC__)
    std::cout << "GCC " << __GNUC__ << '\n';
#elif defined(_MSC_VER)
    std::cout << "MSVC " << _MSC_VER << '\n';
#endif
std::cout << "sizeof(std::string): " << sizeof(std::string) << '\n';

SSO 길이에 맞추기보다 복사를 줄여야 하는 이유

SSO는 코드 변경 없이 자동으로 적용되는 최적화이므로, 대부분의 경우 할 일은 “문자열을 SSO 한계 안에 맞추는 것”보다 “불필요한 복사와 임시 문자열을 만들지 않는 것”입니다. 앞의 표에서 봤듯 64비트 기준으로 libstdc++와 MSVC는 15자, libc++는 22자까지 내부 버퍼에 담기 때문에, 14자짜리 경로가 한 플랫폼에서는 힙 할당 없이 처리되고 길이가 몇 글자만 늘어도 다른 결과가 나옵니다. 특정 길이에 의존하는 설계는 이식성이 없고, 성능 판단은 대상 플랫폼에서 할당 횟수를 직접 세어 봐야 합니다.

코드 리뷰에서 실제로 할당을 늘리는 곳은 대개 아래 세 군데입니다.

// 🔍 리뷰 시 확인사항
// 1. 문자열 연결
std::string msg = a + b + c;  // ⚠️ 중간 결과 임시 객체가 생김
// 2. 반복문 내 문자열 생성
for (int i = 0; i < 1000; ++i) {
    std::string temp = "prefix_" + std::to_string(i);  // ⚠️ 여기선 10자 이하라 SSO 안이지만, 접두사가 길어져 한계를 넘으면 반복마다 힙 할당
}
// 3. 함수 파라미터
void process(std::string s);  // ⚠️ 호출할 때마다 복사
void process(std::string_view s);  // ✅ 복사 없음

세 번째 경우 std::string_view로 바꿀 때는 함수가 받은 문자열을 멤버에 저장하거나 비동기로 넘기지 않는지 확인해야 합니다. string_view는 소유권이 없어서, 임시 std::string에서 만든 뷰를 저장해 두면 원본이 파괴된 뒤 댕글링 참조가 됩니다. 저장이 목적이라면 값으로 받아 std::move하는 편이 맞습니다.


실무 시나리오

시나리오 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;
    TrackingAllocator() = default;
    template <typename U> TrackingAllocator(const TrackingAllocator<U>&) {}
    template <typename U> bool operator==(const TrackingAllocator<U>&) const { return true; }
    
    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";  // 힙 할당 출력

할당기를 바꾼 TrackedString은 std::string과 다른 타입이라 기존 함수에 넘길 수 없으므로, 실험용 코드에서 할당 패턴을 확인하는 데만 적합합니다. 프로그램 전체의 할당을 세려면 전역 operator new를 교체해 호출 횟수를 세거나, Linux에서 heaptrack, valgrind --tool=massif, macOS에서 Instruments의 Allocations를 쓰는 편이 코드 수정 없이 가능합니다. 처음 이런 측정을 해 보면, 문자열 할당의 상당 부분이 SSO 한계가 아니라 const std::string& 대신 값으로 넘긴 인자, 반복문 안의 substr, 불필요한 std::to_string 같은 복사에서 나온다는 것을 알게 되는 경우가 많습니다.


자주 묻는 질문 (FAQ)

Q. 짧은 문자열은 std::move해도 복사와 비용이 비슷한 이유는 무엇인가요?

A. SSO 버퍼에 들어 있는 짧은 문자열은 힙 포인터 없이 객체 내부에 문자를 저장하므로, 이동할 때도 결국 그 문자 바이트를 새 객체의 내부 버퍼로 복사해야 합니다. 이동이 포인터만 옮기는 이점은 힙에 할당된 긴 문자열에서만 크게 나타납니다. 그래서 짧은 문자열 위주의 코드에서 std::move를 추가했는데 벤치마크 차이가 거의 없는 것은 정상이며, 이동된 원본은 유효하지만 내용은 미지정 상태이므로 다시 쓰기 전에 값을 대입해야 합니다.