코드 리뷰에서 “문자열은 +로 이어 붙이지 마세요, 느립니다”라는 말을 듣는다. 그런데 왜 느린지, 얼마나 느린지, 대신 뭘 쓰라는 건지는 대개 안 따라온다.

재보면 통념과 다른 게 나온다. 짧은 문자열에서는 아무 차이도 없고, 흔히 대안으로 권하는 +=가 오히려 더 나쁜 경우가 있다. 이 글은 g++ 13.2(libstdc++)에서 실제 힙 할당 횟수를 세어본 기록이다.

먼저 SSO를 알아야 한다

std::string은 짧은 문자열을 힙에 두지 않는다. 객체 안에 작은 버퍼를 품고 있다가 거기 담는다. 이걸 **SSO(Small String Optimization, 작은 문자열 최적화)**라고 한다.

말로 하면 추상적이니 주소로 확인하면 된다. data()가 가리키는 곳이 객체 자신의 범위 안이면 SSO고, 밖이면 힙이다.

static void probe(const char* label, const std::string& s) {
  auto obj  = reinterpret_cast<const char*>(&s);
  auto data = s.data();
  bool inside = (data >= obj) && (data < obj + sizeof(std::string));
  std::printf("%s size=%zu cap=%zu  data()가 객체 %s\n",
              label, s.size(), s.capacity(), inside ? "안" : "밖");
}
sizeof(std::string) = 32바이트
빈 문자열 capacity  = 15

15자   size=15 cap=15   data()가 객체 안   → 힙 할당 없음
16자   size=16 cap=16   data()가 객체 밖   → 힙 할당됨

std::string이 32바이트인 건 이 버퍼 때문이다. 포인터·길이·용량만 담으면 24바이트면 되는데, 나머지를 문자 담는 데 쓴다. libstdc++에서 그 한계가 15자다.

이 15자가 이 글 전체의 분기점이다. 넘느냐 마느냐에 따라 답이 달라진다.

15라는 숫자는 libstdc++ 기준이다. 표준이 정한 게 아니라 구현이 정하는 값이라 다른 표준 라이브러리에서는 다를 수 있다.

할당 횟수를 세어보자

전역 operator new를 가로채면 할당이 몇 번 일어나는지 정확히 셀 수 있다.

static int allocs = 0;
void* operator new(std::size_t n) { ++allocs; return std::malloc(n); }
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }  // 이것도 반드시 필요하다

함정: sized delete(operator delete(void*, std::size_t))를 빼먹으면 프로그램이 실행조차 안 된다. operator new를 대체할 거면 delete 두 형태를 다 대체해야 한다.

네 조각을 이어 붙이는 네 가지 방법을 재봤다.

조각이 3자씩 (결과 12자, SSO 안)

방식할당
a + b + c + d0회
s = a; s += b; s += c; s += d;0회
reserveappend0회
std::format("{}{}{}{}", ...)0회

전부 0이다. SSO 안에서는 무엇을 쓰든 힙 할당이 없다. 이 구간에서 +를 피하라는 조언은 아무 의미가 없다.

조각이 40자씩 (결과 160자, SSO 초과)

방식할당총 바이트
a + b + c + d2회242
s = a; s += b; s += c; s += d;3회283
reserveappend1회161
std::format("{}{}{}{}", ...)1회161

여기서 눈에 걸리는 게 있다.

+=+보다 나쁘다

+ 대신 +=를 써라”는 흔한 조언인데, 이 경우엔 반대다. capacity를 따라가 보면 이유가 나온다.

s = a        size=40  cap=40     ← 할당 1
s += b       size=80  cap=80     ← 재할당 (2)
s += c       size=120 cap=160    ← 재할당 (3)
s += d       size=160 cap=160    ← 들어감

s = aa의 크기(40)만큼만 잡고 시작한다. 그 뒤로 붙일 때마다 모자라서 늘린다. 40 → 80 → 160으로 두 번 재할당한다.

반면 a + b + c + d는 첫 a + b처음부터 80을 잡는다. 두 조각을 합친 크기를 알고 만들기 때문이다. 그다음부터는 임시 객체를 우변값으로 물려받아 제자리에 이어 붙인다. 160으로 한 번 늘리면 끝이라 총 2회다.

결과 capacity도 다르다. a+b+c+d로 만든 문자열은 cap=160으로 딱 맞는데, +=로 만든 것도 cap=160이지만 오는 길에 버린 버퍼가 하나 더 있다(283바이트 대 242바이트).

+는 조각 정보를 더 많이 알고 시작하기 때문에 덜 헤맨다. += 연쇄가 무조건 낫다는 건 사실이 아니다.

”최악의 경우 O(n)번 재할당”은 사실인가

+ 연쇄가 O(n)번 재할당한다는 설명을 흔히 본다. 조각 수를 늘려가며 재봤다.

40자 조각 n개를 operator+ 로 이어붙일 때
  n=2  : 1회
  n=4  : 2회
  n=8  : 3회
  n=16 : 4회

log₂ n이다. libstdc++이 버퍼를 늘릴 때 필요한 만큼이 아니라 배로 늘리기 때문이다. 이론상 최악은 O(n)이 맞지만, 기하급수적으로 성장하는 실제 구현에서는 그 최악이 나오지 않는다.

그러니 “+는 O(n)이라 위험하다”는 겁주기에 가깝다. 진짜 문제는 횟수가 아니라 필요 없는 할당이 있다는 것이다. 1회면 될 걸 2회 한다.

표준의 답 — std::format

가장 좋은 건 크기를 미리 알고 한 번에 잡는 것이다. 손으로 하면 이렇다.

std::string s;
s.reserve(a.size() + b.size() + c.size() + d.size());
s.append(a).append(b).append(c).append(d);   // 할당 1회, 161바이트

정확히 1회다. 그런데 이걸 매번 손으로 쓰는 건 번거롭고, 조각이 숫자라면 변환까지 해야 한다.

C++20의 std::format이 그걸 대신한다.

std::string s = std::format("{}{}{}{}", a, b, c, d);   // 할당 1회, 161바이트
std::string t = std::format("올해는 {}년", 2026);      // 숫자 변환도 알아서

손으로 reserve한 것과 정확히 같은 1회·161바이트다. 크기를 계산해 한 번에 잡아 쓰기 때문이다.

<format>에 있는 것들은 이렇다.

함수표준하는 일
std::formatC++20포맷해서 std::string으로 반환
std::format_toC++20출력 반복자에 직접 쓴다
std::format_to_nC++20개수 제한을 두고 쓴다
std::formatted_sizeC++20결과 크기만 미리 계산
std::vformatC++20런타임 포맷 문자열용

C++23에서 범위·튜플·페어용 포매터와 std::formattable 개념이 추가됐고, C++26에는 std::dynamic_format이 들어간다.

이미 std::string이 손에 있고 조각 수가 고정이라면 reserve + append가 더 직설적이다. 숫자나 다른 타입이 섞이면 std::format이 낫다.

그래서 언제 신경 쓰나

상황
결과가 15자 이하아무거나. 할당이 없다.
조각 몇 개를 한 번 잇는다+도 괜찮다. 2회 정도다.
숫자·다른 타입이 섞인다std::format
크기를 알고 성능이 중요하다reserve + append
루프 안에서 계속 붙인다루프 밖에서 reserve

가장 중요한 건 이게 대개 문제가 아니라는 것이다. 160자를 이어 붙이는 데 할당 2회와 1회의 차이는 대부분의 코드에서 측정되지 않는다. 이 글의 숫자도 할당만 세느라 만든 것이지 실제 프로그램의 프로파일이 아니다.

+를 피해야 하는 진짜 지점은 뜨거운 루프 안이다. 거기서는 재할당이 쌓인다. 그 밖에서는 읽기 좋은 걸 쓰면 된다.

참고