코드 리뷰에서 “문자열은 +로 이어 붙이지 마세요, 느립니다”라는 말을 듣는다. 그런데 왜 느린지, 얼마나 느린지, 대신 뭘 쓰라는 건지는 대개 안 따라온다.
재보면 통념과 다른 게 나온다. 짧은 문자열에서는 아무 차이도 없고, 흔히 대안으로 권하는 +=가 오히려 더 나쁜 경우가 있다. 이 글은 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 + d | 0회 |
s = a; s += b; s += c; s += d; | 0회 |
reserve 후 append | 0회 |
std::format("{}{}{}{}", ...) | 0회 |
전부 0이다. SSO 안에서는 무엇을 쓰든 힙 할당이 없다. 이 구간에서 +를 피하라는 조언은 아무 의미가 없다.
조각이 40자씩 (결과 160자, SSO 초과)
| 방식 | 할당 | 총 바이트 |
|---|---|---|
a + b + c + d | 2회 | 242 |
s = a; s += b; s += c; s += d; | 3회 | 283 |
reserve 후 append | 1회 | 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 = a는 a의 크기(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::format | C++20 | 포맷해서 std::string으로 반환 |
std::format_to | C++20 | 출력 반복자에 직접 쓴다 |
std::format_to_n | C++20 | 개수 제한을 두고 쓴다 |
std::formatted_size | C++20 | 결과 크기만 미리 계산 |
std::vformat | C++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회의 차이는 대부분의 코드에서 측정되지 않는다. 이 글의 숫자도 할당만 세느라 만든 것이지 실제 프로그램의 프로파일이 아니다.
+를 피해야 하는 진짜 지점은 뜨거운 루프 안이다. 거기서는 재할당이 쌓인다. 그 밖에서는 읽기 좋은 걸 쓰면 된다.
참고
- Abseil Tip of the Week #3: String Concatenation and operator+ vs. StrCat() —
foo + bar + baz가 임시 객체를 거친다는 지적과 우변값 오버로드가 두 번째 연결을 제자리에서 처리한다는 설명을 참고했다. 이 팁이 권하는absl::StrCat은 Abseil 라이브러리의 함수라 이 글에서는 다루지 않았다. - cppreference — std::format —
std::format과 동반 함수들의 시그니처, 각각이 도입된 표준 버전을 확인했다. - cppreference — std::basic_string::reserve —
reserve가 용량에 미치는 영향을 확인했다.