Featured image of post [Optimization(C++) 05] 문자열 최적화

[Optimization(C++) 05] 문자열 최적화

std::string의 SSO(Small String Optimization), string_view 활용, 문자열 처리 시 불필요한 할당·복사를 줄이는 기법을 다룹니다. 파싱·포맷팅 등 핫패스에서의 문자열 비용을 정량적으로 다루며, API 경계와 내부 버퍼 관리 기준을 정리합니다.

문자열 최적화란 파싱·포맷팅·전달 과정에서 불필요한 할당과 복사를 줄이는 것을 말합니다. 본 챕터에서는 SSO(Small String Optimization), std::string_view, 연결·파싱·포맷팅 시 비용을 정량적으로 다루고, 핫패스에서 “읽기 전용은 뷰, 소유·누적은 string + reserve” 패턴을 적용하는 방법을 정리합니다.

이 장을 읽기 전에

완전한 초보자? 이 장은 04장: STL 컨테이너 비용에서 다룬 “힙 할당과 캐시 효율” 개념을 전제로 합니다. std::string이 동적 메모리를 쓴다는 점과 std::string_view가 “소유하지 않는 뷰"라는 것만 알면 충분합니다.

이 장의 깊이: 이 장은 중급~전문가를 포괄합니다. SSO(Small String Optimization)와 string_view의 원리부터 시작해, 전문가 구간에서는 연결·파싱·포맷팅 경로에서 할당을 제거하는 패턴과 string_view의 수명 함정을 다룹니다. 다루지 않는 것: std::span 일반론(뷰의 안전성은 14장)과 타입 소거 기반 버퍼(16장)입니다.

당신의 수준에 맞는 경로

수준읽을 부분핵심 목표
초보자“SSO (Small String Optimization)” ~ “std::string_view”SSO·string_view가 할당을 줄이는 원리 이해
중급자“문자열 연결·파싱 최적화” ~ “포맷팅 비용”핫패스 문자열 처리에서 할당 제거
전문가“판단 기준” ~ “비판적 시각”string vs string_view 선택과 수명 위험 판단

SSO와 string_view 도입 (역사·배경)

**SSO(Small String Optimization)**는 표준이 요구하는 것이 아니라 구현체가 선택한 최적화로, 여러 표준 라이브러리(libstdc++, libc++, MSVC STL)에서 오래 전부터 사용되어 왔습니다. 짧은 문자열을 객체 내부에 넣어 힙 할당을 피하는 방식이며, 구현마다 임계값(보통 15~24바이트)이 다릅니다. std::string_view는 C++17에서 표준에 추가되었고, “문자열을 소유하지 않고 참조만 하는” 수요를 표준화한 타입입니다.

“The class template basic_string_view describes an object that can refer to a constant contiguous sequence of char-like objects with the first element of the sequence at position zero.” — ISO C++ Standard (std::basic_string_view). 뷰는 “참조만” 하므로 수명 관리가 호출자·설계자의 책임입니다.

SSO (Small String Optimization)

많은 std::string 구현은 짧은 문자열을 힙에 올리지 않고 객체 내부 버퍼에 저장합니다. 문자열 길이가 임계값(보통 15~24바이트) 이하이면 할당이 없고, 그 이상일 때만 동적 할당을 사용합니다.

  • 구현체별 차이: GCC libstdc++, Clang libc++, MSVC STL마다 SSO 임계값과 내부 레이아웃이 다릅니다. 크로스 플랫폼 코드에서는 “짧은 문자열"의 정의가 달라질 수 있으므로 보수적으로 가정합니다.
  • 한계를 넘을 때: 길이가 임계를 넘는 순간 힙 할당이 한 번 발생하고, 이후 확장 시에는 vector와 비슷하게 재할당이 일어날 수 있습니다. 반복 연결로 길이가 늘어나면 처음부터 **reserve(예상_길이)**를 호출해 한 번만 할당하도록 하는 것이 좋습니다.

SSO 덕분에 짧은 문자열은 객체 내부에만 있어 캐시에 유리하고 할당/해제 비용이 없습니다.

std::string_view

std::string_view어딘가에 있는 연속 문자 시퀀스를 가리키는 non-owning 뷰입니다. 복사나 할당 없이 읽기 전용으로 문자열을 다룰 수 있어, 함수 인자나 반환값으로 쓰면 임시 std::string 생성을 줄일 수 있습니다.

  • 수명: 뷰는 참조만 하므로, 가리키는 메모리가 뷰보다 먼저 파괴되면 안 됩니다. 로컬 std::string을 만든 뒤 그 string_view를 반환하거나, 임시 문자열의 뷰를 저장하면 미정의 동작입니다.
  • API 경계: 리터럴("hello")이나 다른 버퍼(char[], 파싱 결과)를 넘길 때 string_view를 받으면 임시 string 생성이 필요 없습니다. 소유권이 필요할 때(저장·수정·null 종료 보장)는 std::string을 사용합니다.
  • null 종료: string_view는 null 종료를 보장하지 않습니다. C API에 넘기려면 별도로 null을 붙인 버퍼가 필요합니다.

문자열 연결·파싱 최적화

반복 연결s1 + s2 + s3처럼 operator+를 쓰면 각 단계마다 임시 std::string이 생깁니다. 대신 reserve(예상_총_길이)append 또는 **operator+=**로 한 문자열에 붙이면 재할당과 임시를 크게 줄일 수 있습니다.

1
2
3
4
5
6
7
8
9
// 비권장: 매번 임시 string 생성
std::string a = s1 + s2 + s3;

// 권장: reserve 후 append
std::string result;
result.reserve(s1.size() + s2.size() + s3.size());
result += s1;
result += s2;
result += s3;

읽기 전용 인자로는 std::string_view를 받으면 리터럴·버퍼·string을 모두 복사 없이 받을 수 있습니다.

1
2
3
4
5
void process(std::string_view sv) {
  // sv는 소유하지 않음; 호출자가 넘긴 버퍼를 참조만 함
}
// process("hello");      // 임시 string 없음
// process(my_string);    // string을 뷰로 전달

파싱: std::vector<std::string_view>로 분할

구분자로 나눈 결과를 std::string 대신 **std::vector<std::string_view>**로 담으면 원본 버퍼 하나만 있으면 되고, 할당은 vector 확장 시에만 발생합니다. 각 토큰은 원본 문자열의 구간을 가리키는 뷰일 뿐이라 복사가 없습니다. 아래는 그대로 컴파일·실행할 수 있습니다(-std=c++17).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <string>
#include <string_view>
#include <vector>
#include <iostream>

std::vector<std::string_view> split(std::string_view s, char delim) {
  std::vector<std::string_view> out;
  size_t start = 0;
  while (start <= s.size()) {
    size_t pos = s.find(delim, start);
    if (pos == std::string_view::npos) {
      out.push_back(s.substr(start));   // substr도 뷰: 복사 없음
      break;
    }
    out.push_back(s.substr(start, pos - start));
    start = pos + 1;
  }
  return out;
}

int main() {
  // backing(원본)이 토큰 뷰보다 오래 살아 있어야 함: 아래에서 tokens 사용 동안 유효.
  std::string backing = "id,name,score,flag";
  auto tokens = split(backing, ',');
  for (std::string_view t : tokens) std::cout << t << '\n';
  // 주의: backing이 파괴되거나 재할당되면 tokens의 모든 뷰가 무효화됨.
}

해당 토큰을 저장·수정해야 할 때만 std::string(views[i])처럼 복사해 만들면 됩니다. 정리하면, 읽기만 하면 string_view, 누적하거나 소유해야 하면 string + reserve/append 패턴을 기본으로 합니다.

포맷팅 비용

  • snprintf: C 스타일. 버퍼 크기를 넘기지 않도록 snprintf를 쓰고, 반복 호출 시 버퍼 재사용이 쉽습니다. 타입 안전성은 없습니다.
  • iostream: 유연하지만 코드 생성량과 간접 호출이 많아 핫패스에서는 상대적으로 무거운 편입니다. 로깅·디버그 출력에 적합합니다.
  • std::format (C++20): 포맷 문자열 기반이고 타입 안전하며 충분히 최적화되어 있지만, 플랫폼별 지원·성능 차이가 있으므로 핫패스 도입 전 벤치마크하는 것이 좋습니다.

핫패스에서 정수·문자열 변환만 필요하면 전용 경량 경로(예: 정수 → 작은 버퍼에 직접 쓰기)를 두고 포맷 라이브러리 호출 횟수를 줄입니다.

평가 기준 (학습 성과 목표)

  • SSO의 동작(짧은 문자열은 객체 내부 버퍼, 임계 초과 시 힙)과 구현체별 차이를 설명할 수 있다.
  • string_view의 non-owning 의미와 수명·null 종료 주의사항을 설명하고, 읽기 전용 vs 소유 필요 시 선택할 수 있다.
  • 연결·파싱 시 reserve + appendstring_view 슬라이스로 할당·복사를 줄일 수 있다.
  • 포맷팅(snprintf, iostream, std::format) 비용을 구분하고, 핫패스에서는 경량 경로를 선택할 수 있다.

판단 기준 (언제 쓰고 언제 피할지)

상황권장비권장
읽기 전용 인자·리터럴/버퍼 전달string_view임시 string 생성
누적 연결string + reserve + append/+=반복 operator+
파싱 슬라이스만 전달vector<string_view>매번 string 복사
C API·null 종료 필요string 또는 별도 버퍼string_view.data()만 믿기
핫패스 포맷팅경량 전용 경로·최소 포맷iostream·무거운 포맷

자주 하는 실수

  • string_view가 로컬 string을 가리키게 반환: 미정의 동작. 소유가 필요하면 string 반환 또는 out 인자 사용.
  • string_view.data()를 C API에 그대로 전달: null 종료가 보장되지 않음. null이 필요하면 string 또는 별도 버퍼.
  • 연결 루프에서 reserve 없이 반복 +=: 재할당이 여러 번 발생. reserve(예상 길이) 후 append.
  • string_view를 멤버로 저장: 뷰가 가리키는 버퍼보다 객체가 오래 살면 수명 오류. 멤버로 보관할 문자열은 string으로 소유한다.

리팩토링 시 주의

string을 string_view로 바꾸면 기존에 “복사로 안전하던” 코드가 수명 오류에 노출될 수 있습니다. 호출 체인에서 버퍼 수명을 추적하고, 테스트·AddressSanitizer로 검증한 뒤 적용합니다.

비판적 시각: 한계와 트레이드오프

  • string_view: 수명 오류는 미정의 동작으로 이어지므로, API 계약을 명확히 하고 범위를 짧게 유지하는 것이 안전하다.
  • SSO: 플랫폼마다 임계값이 달라 “짧은 문자열"이 환경에 따라 다르다. 크로스 플랫폼 코드에서는 보수적으로 가정한다.
  • std::format: C++20 지원·성능이 구현마다 다르므로, 핫패스 도입 전 해당 환경에서 벤치마크하는 것이 좋다.

핵심 요약

항목요약
SSO짧은 문자열은 내부 버퍼, 임계 초과 시 힙; reserve로 재할당 최소화
string_viewnon-owning 뷰, 수명·null 종료 주의, 읽기 전용에 사용
연결·파싱reserve+append, 슬라이스는 string_view
포맷팅핫패스는 경량 경로, iostream은 로깅·디버그용

용어 정리

용어설명
SSOSmall String Optimization; 짧은 문자열을 객체 내부 버퍼에 저장해 힙 할당 회피
string_viewnon-owning 읽기 전용 뷰; 수명은 호출자가 보장해야 함
null 종료C 문자열처럼 끝에 \0이 있는 것; string_view는 보장하지 않음
non-owning메모리를 소유하지 않고 참조만 함; string_view가 해당

자주 묻는 질문 (FAQ)

Q: string_view를 반환해도 되나요?
A: 가리키는 버퍼의 수명이 호출 반환 후에도 유지될 때만 안전합니다. 로컬 string을 string_view로 반환하면 미정의 동작이므로, 소유가 필요하면 string을 반환하거나 인자로 받아 채우세요.

Q: SSO 임계값은 얼마인가요?
A: 구현체마다 다릅니다(예: libstdc++ 15자, MSVC 15 등). 크로스 플랫폼에서는 보수적으로 가정하고, 매우 짧은 문자열만 SSO에 의존합니다.

Q: C API에 string_view를 넘겨도 되나요?
A: string_view는 null 종료를 보장하지 않습니다. null이 필요하면 string을 쓰거나, 뷰 범위를 복사해 null을 붙인 버퍼를 사용하세요.

Q: 연결을 모두 reserve+append로 바꿔야 하나요?
A: 핫패스에서 반복 연결이 많을 때 효과가 큽니다. 한두 번 연결이면 차이가 작을 수 있으므로 프로파일러로 확인한 뒤 적용하세요.

적용 체크리스트 (실무용)

  • 읽기 전용 인자는 string_view로 받았는가? (수명 확인 후)
  • 연결 시 reserve 후 append/+=를 사용했는가?
  • string_view 반환 시 가리키는 버퍼 수명이 유지되는가?
  • C API·null 종료가 필요할 때 string 또는 별도 버퍼를 사용했는가?
  • 핫패스 포맷팅을 경량 경로로 줄였는가?
  • 변경 후 할당 횟수·실행 시간으로 회귀 검증했는가? (AddressSanitizer로 수명 오류 검사)

다음 장에서는

이전 장: STL 컨테이너 비용 (챕터 04)

객체 수명 최적화를 다룹니다. Copy Elision, RVO/NRVO, 이동 의미론을 심화하고 객체 수명·복사/이동 비용을 줄이는 방법을 정리합니다. string을 값으로 반환할 때 RVO/NRVO가 적용되므로, 03(연결 결과 반환)과 04(값 반환 최적화)를 함께 적용하면 문자열 경로의 비용을 줄일 수 있습니다.

객체 수명 최적화 (챕터 06)