문자열은 프로그래밍에서 가장 기본적인 자료구조다. 하지만 그 "기본적임" 뒤에는 놀라울 정도로 복잡한 설계 결정이 숨어있다. C 언어가 왜 null-terminated 방식을 선택했는지, std::string은 어떻게 작은 문자열을 최적화하는지, Copy-on-Write가 왜 C++11에서 금지되었는지를 이해하는 것은 단순히 문자열을 넘어서 시스템 프로그래밍의 핵심 원칙을 이해하는 과정이다.
이 글에서는 문자열의 역사부터 현대 C++의 최적화 기법, 그리고 BSTR 같은 Windows 특화 length-prefix 구현까지, 깊이 있는 "왜"와 "어떻게"를 풍부한 코드 예제와 함께 탐구한다. 본문이 끝날 무렵에는 strcat이 왜 인지, SSO가 왜 효과적인지, 그리고 BSTR이 length-prefix를 선택한 실용적 이유를 모두 설명할 수 있게 된다.
C 언어의 문자열은 null 문자(\0)로 끝나는 문자 배열이다. 이것이 얼마나 자연스러워 보이는지와 별개로, 이는 여러 대안 중 하나의 설계 결정이었다.
// C String: Null-terminated
char str[] = "Hello";
// 메모리: ['H', 'e', 'l', 'l', 'o', '\0']
// strlen()은 '\0'을 찾을 때까지 순회해야 한다
대조적으로, Pascal은 길이를 문자열 앞에 저장하는 length-prefixed 방식을 사용했다.
// Pascal String: Length-prefixed
// 메모리: [5, 'H', 'e', 'l', 'l', 'o']
이 두 접근법의 차이는 단순한 구현 디테일이 아니다. 각각은 근본적으로 다른 트레이드오프를 내포한다.
Null-terminated의 장점:
Null-terminated의 단점:
이 선택을 이해하려면 1970년대 초반의 상황을 봐야 한다. Dennis Ritchie가 C를 설계할 때 그가 다룬 하드웨어는 PDP-11이었다. 이 기계는 상상하는 것보다 훨씬 제한적이었다.
이런 환경에서 null-terminated 방식은 이상적이었다.
char* 하나로 문자열 표현 가능\0을 추가할 수 있다하지만 이 선택은 대가를 치렀다. 그리고 그 대가는 50년이 지난 지금도 지불되고 있다.
2001년, Joel Spolsky는 그의 유명한 블로그 글에서 strcat의 문제를 Shlemiel the Painter라는 비유로 설명했다.
Shlemiel은 페인트통과 붓을 들고 도로에 선을 그린다. 매일 출발점에서 시작해 페인트를 칠하다가, 페인트가 떨어지면 다시 출발점까지 돌아가 페인트를 채운다. 매일 작업 거리가 멀어질수록 왕복 시간이 기하급수적으로 늘어난다.
이것이 정확히 strcat이 하는 일이다.
char* strcat(char* dest, const char* src) {
char* ptr = dest;
// 1단계: dest의 끝을 찾는다 - O(n)
while (*ptr != '\0') {
ptr++;
}
// 2단계: src를 복사한다 - O(m)
while (*src != '\0') {
*ptr++ = *src++;
}
*ptr = '\0';
return dest;
}
문제는 1단계에 있다. strcat을 호출할 때마다, 함수는 이미 알고 있어야 할 정보(문자열의 끝 위치)를 다시 계산해야 한다.
이제 실제 사용 예를 보자.
char buffer[1000] = "";
for (int i = 0; i < 100; i++) {
strcat(buffer, "x"); // 매번 buffer 전체를 순회
}
각 호출에서 일어나는 일은 다음과 같다.
총 시간 복잡도는 다음과 같다.
이것은 이론이 아니다. 실제로 측정해보자.
#include <chrono>
#include <iostream>
#include <cstring>
int main() {
constexpr int N = 10000;
char buffer[N * 2] = "";
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < N; i++) {
strcat(buffer, "x"); // O(n²)
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "Time: " << duration.count() << "ms\n";
// N=10000일 때 수 초 소요
}
실제 측정 환경에서 이 코드는 약 3초가 걸렸다. 단지 10,000개의 문자를 추가하는데 말이다.
가장 직관적인 해결책은 문자열의 길이를 명시적으로 저장하는 것이다.
typedef struct {
char* data;
size_t length;
size_t capacity;
} String;
void string_append(String* s, const char* src) {
size_t src_len = strlen(src);
// 길이를 이미 알고 있으므로 끝 위치를 즉시 계산 - O(1)
if (s->length + src_len >= s->capacity) {
// 재할당 필요
size_t new_capacity = (s->capacity + src_len) * 2;
char* new_data = (char*)malloc(new_capacity);
memcpy(new_data, s->data, s->length);
free(s->data);
s->data = new_data;
s->capacity = new_capacity;
}
memcpy(s->data + s->length, src, src_len);
s->length += src_len;
s->data[s->length] = '\0';
}
이제 연결 연산은 상각 시간을 갖는다 (재할당을 고려하면 amortized).
C 표준 라이브러리에는 실제로 이 문제를 해결하는 함수가 있다. stpcpy다.
char* stpcpy(char* dest, const char* src) {
while ((*dest = *src) != '\0') {
dest++;
src++;
}
return dest; // '\0' 위치 반환 (strcpy는 dest를 반환)
}
// 사용 예
char buffer[1000];
char* end = buffer;
for (int i = 0; i < 100; i++) {
end = stpcpy(end, "x"); // O(n)으로 개선
}
stpcpy는 복사가 끝난 위치의 포인터를 반환하므로, 다음 호출에서 다시 처음부터 스캔할 필요가 없다.
하지만 stpcpy는 POSIX 확장이고, 표준 strcpy/strcat보다 훨씬 덜 알려져 있다. 이것이 API 설계가 중요한 이유다. 잘못된 인터페이스는 사용자가 비효율적인 코드를 작성하도록 유도한다.
C++는 문자열을 클래스로 캡슐화해서 C의 문제를 해결하려 했다. std::string의 핵심 설계를 이해하기 위해, 간단한 버전을 만들어보자.
class String {
private:
char* data_; // 힙에 할당된 문자 배열 포인터
size_t length_; // 현재 문자열 길이 (null 제외)
size_t capacity_; // 할당된 버퍼 크기 (null 포함)
public:
String() : data_(new char[1]), length_(0), capacity_(1) {
data_[0] = '\0';
}
String(const char* str) {
length_ = std::strlen(str);
capacity_ = length_ + 1;
data_ = new char[capacity_];
std::memcpy(data_, str, capacity_);
}
~String() {
delete[] data_;
}
size_t length() const { return length_; }
const char* c_str() const { return data_; }
};
이미 C 문자열보다 개선된 점이 보인다.
length_와 capacity_를 따로 관리하는 것은 중요한 최적화다. 재할당은 비용이 크므로, 미리 여유 공간을 확보해두면 연속된 추가 연산에서 재할당 횟수를 줄일 수 있다.
void reserve(size_t new_capacity) {
if (new_capacity <= capacity_) return;
char* new_data = new char[new_capacity];
std::memcpy(new_data, data_, length_ + 1);
delete[] data_;
data_ = new_data;
capacity_ = new_capacity;
}
void append(const String& other) {
size_t new_length = length_ + other.length_;
if (new_length >= capacity_) {
// 재할당 필요
reserve(new_length + 1);
}
std::memcpy(data_ + length_, other.data_, other.length_ + 1);
length_ = new_length;
}
하지만 정확히 얼마나 여유 공간을 확보해야 할까?
버퍼가 부족할 때 얼마나 증가시킬지는 중요한 설계 결정이다.
void reserve(size_t new_capacity) {
if (new_capacity <= capacity_) return;
size_t new_cap = capacity_;
while (new_cap < new_capacity) {
new_cap *= 2; // 2배씩 증가
}
char* new_data = new char[new_cap];
std::memcpy(new_data, data_, length_ + 1);
delete[] data_;
data_ = new_data;
capacity_ = new_cap;
}
장점:
단점:
new_cap = capacity_ + capacity_ / 2; // 1.5배
장점:
단점:
성장률이 황금비 이하이면 과거에 해제한 모든 buffer 크기의 합이 언젠가 새 buffer 크기에 도달할 수 있다는 이상화된 산술이 성립한다. 그러나 allocator가 그 block들을 하나의 연속 영역으로 합칠 수 있다는 보장은 없고 size class와 다른 allocation이 개입하므로, 1.5배가 실제 주소 재사용을 보장하는 것은 아니다.
특정 64비트 release ABI의 source에서 관찰되는 대표적인 경향은 다음과 같다. basic_string의 growth policy는 표준 보장이 아니며 library version, 요청 크기와 ABI에 따라 달라질 수 있다.
| 구현체 | Growth Factor | 이유 |
|---|---|---|
| GCC libstdc++ | 대체로 기하 성장 | source version에서 확인 |
| LLVM libc++ | 대체로 기하 성장 | source version에서 확인 |
| MSVC STL | 대체로 1.5배 계열 | source version에서 확인 |
문자열 연결 연산자를 구현할 때 미묘하지만 중요한 차이가 있다.
class String {
public:
// operator+=: 좌변을 수정 (in-place)
String& operator+=(const String& other) {
append(other);
return *this; // 자기 자신 반환
}
// operator+: 새 객체 생성 (임시 객체)
String operator+(const String& other) const {
String result;
result.reserve(length_ + other.length_ + 1);
std::memcpy(result.data_, data_, length_);
std::memcpy(result.data_ + length_, other.data_, other.length_ + 1);
result.length_ = length_ + other.length_;
return result; // 복사 발생 (RVO 가능)
}
};
성능 차이는 명확하다.
String s1 = "Hello";
String s2 = " World";
// Case 1: operator+= (권장)
s1 += s2; // 1번 할당 (필요시), 1번 복사
// Case 2: operator+ (비효율)
String s3 = s1 + s2; // 2번 할당, 2번 복사 (임시 객체 생성)
더 심각한 문제는 연쇄 호출이다.
String result = s1 + s2 + s3 + s4;
// 1. temp1 = s1 + s2 (할당 1, s1+s2 크기)
// 2. temp2 = temp1 + s3 (할당 2, s1+s2+s3 크기)
// 3. result = temp2 + s4 (할당 3, s1+s2+s3+s4 크기)
// 총 3번 할당, 6번 복사 - O(n²) 퇴화
C++11의 move semantics는 이 문제를 크게 완화한다.
String operator+(String&& lhs, const String& rhs) {
lhs += rhs; // 이미 임시 객체면 재사용
return std::move(lhs);
}
이제 컴파일러는 임시 객체를 감지해서 불필요한 복사를 제거할 수 있다.
지금까지의 설계는 합리적이지만, 치명적인 문제가 하나 있다. 모든 문자열이 힙 할당을 한다는 것이다.
힙 할당은 생각보다 훨씬 비싸다.
malloc 호출: 수백 CPU 사이클 (메모리 관리자 오버헤드)그런데 통계를 보면 다음과 같은 패턴이 드러난다.
이것이 SSO (Small String Optimization)가 탄생한 배경이다.
SSO의 핵심 아이디어는 간단하다. 문자열이 충분히 짧으면, 포인터를 저장하는 공간에 문자열 자체를 저장하자.
class String {
private:
static constexpr size_t SSO_SIZE = 15; // 15자 + '\0'
struct LongString {
char* data;
size_t length;
size_t capacity;
};
struct ShortString {
char buffer[SSO_SIZE + 1]; // 스택에 직접 저장
unsigned char length; // 1바이트 길이
};
union {
LongString long_;
ShortString short_;
} storage_;
bool is_short() const {
// 최상위 비트로 구분 (0 = short, 1 = long)
return (storage_.short_.buffer[SSO_SIZE] & 0x80) == 0;
}
public:
const char* c_str() const {
return is_short() ? storage_.short_.buffer : storage_.long_.data;
}
size_t length() const {
return is_short() ? storage_.short_.length : storage_.long_.length;
}
};
이 구현의 핵심은 union이다. Union은 여러 멤버가 같은 메모리 공간을 공유하게 해주므로, 작은 문자열일 때는 ShortString으로, 큰 문자열일 때는 LongString으로 같은 공간을 사용한다.
64비트 플랫폼에서 포인터는 8바이트다. 따라서 LongString은 다음과 같이 24바이트를 차지한다.
| 필드 | 크기 |
|---|---|
data* | 8 bytes |
length | 8 bytes |
capacity | 8 bytes |
| 합계 | 24 bytes |
같은 24바이트를 ShortString에 사용하면 15자(+ null)를 직접 저장할 수 있다.
| 필드 | 크기 |
|---|---|
buffer[16] | 16 bytes |
length | 1 byte |
| 플래그/패딩 | 7 bytes |
| 합계 | 24 bytes |
아래 표는 char를 쓰는 특정 64비트 release ABI의 대표적인 snapshot이다. 표준은 SSO 자체, 객체 크기와 inline 문자 수를 보장하지 않는다. debug iterator, hardening, alternate string layout, ABI version과 CharT가 바뀌면 숫자도 바뀐다.
| 구현체 | SSO 한계 | 객체 크기 | 비고 |
|---|---|---|---|
| GCC libstdc++ | 15자 | 32바이트 | 패딩 포함 |
| Clang libc++ | 22자 | 24바이트 | 공격적 비트 인코딩 |
| MSVC | 15자 | 32바이트 | - |
libc++가 같은 24바이트로 더 긴 SSO를 달성한 비결은 비트 인코딩이다. short 모드에서는 첫 바이트에 길이를 1비트 시프트해 저장하고 최하위 비트를 short/long 플래그로 쓰며, long 모드에서는 capacity 필드의 최하위 비트가 플래그 역할을 한다. 두 모드가 같은 바이트의 같은 비트를 판별자로 공유하는 구조다.
std::string filename = "config.txt";
// allocation 발생 여부는 실제 library/ABI에서 측정한다.
SSO 범위 안에서는 문자열별 heap allocation과 간접 접근을 피할 수 있다. 전체 성능 차이는 allocator, 복사·이동 패턴, 문자열 처리 자체와 cache pressure에 따라 달라지므로 고정 배수로 표현하지 않는다.
실제로 SSO가 작동하는지 확인해보자.
#include <iostream>
#include <string>
int main() {
std::string s1 = "short"; // SSO 적용
std::string s2 = "this is a very long string that exceeds SSO threshold";
std::cout << "s1.data() = " << static_cast<const void*>(s1.data()) << "\n";
std::cout << "&s1 = " << &s1 << "\n";
std::cout << "s2.data() = " << static_cast<const void*>(s2.data()) << "\n";
std::cout << "&s2 = " << &s2 << "\n";
// s1.data()가 &s1 근처 주소 → SSO (객체 내부)
// s2.data()가 먼 주소 → 힙 할당
}
MSVC 시스템에서의 출력 예시는 다음과 같다.
s1.data() = 0000008F7AFFF958
&s1 = 0000008F7AFFF958
s2.data() = 000001E3B8D55420
&s2 = 0000008F7AFFF990
s1.data와 &s1이 정확히 같다. 즉, 문자열 데이터가 객체 자체 안에 저장되어 있다. 반면 s2.data는 완전히 다른 주소를 가리킨다.
2000년대 초반, 많은 std::string 구현체는 Copy-on-Write (COW)라는 최적화를 사용했다. GCC의 기본 구현도 COW였다. 하지만 C++11은 COW를 사실상 금지했다. 무슨 일이 있었던 걸까?
COW의 아이디어는 우아하다. 복사가 일어날 때 실제로 데이터를 복사하지 말고, 같은 데이터를 가리키게 하자. 실제 쓰기가 일어날 때만 복사하면 된다.
class String_COW {
private:
struct StringData {
int ref_count; // 참조 카운트
size_t length;
size_t capacity;
char data[1]; // Flexible Array Member
};
StringData* ptr_;
public:
// 복사 생성자: 데이터 공유
String_COW(const String_COW& other) {
ptr_ = other.ptr_;
++ptr_->ref_count; // 카운트 증가만
}
// 수정 전 복사 발생
void modify() {
if (ptr_->ref_count > 1) {
// 다른 객체가 공유 중이면 복사
StringData* new_ptr = allocate_copy(ptr_);
--ptr_->ref_count;
ptr_ = new_ptr;
ptr_->ref_count = 1;
}
// 이제 안전하게 수정 가능
}
char& operator[](size_t i) {
modify(); // 쓰기 가능성이 있으므로 미리 복사
return ptr_->data[i];
}
};
C++03 시대의 장점은 다음과 같다.
ref_count++)// C++03 COW string (GCC 구버전)
std::string s1 = "Hello";
std::string s2 = s1; // COW: 데이터 공유
char& ref = s1[0]; // s1의 첫 문자 참조
s2[0] = 'J'; // s2 수정 → COW 발동, s2는 새 버퍼 할당
// 문제: ref는 여전히 유효할까?
// COW 구현에서는 미정의 동작이 발생할 수 있다
C++11 표준 (N2668 제안서)은 명확히 요구한다.
std::string의operator[]비-const 버전은 진짜 참조를 반환해야 하며, 다른 객체의 수정이 해당 참조를 무효화해서는 안 된다.
COW는 이 요구사항을 만족할 수 없다.
std::string shared = "data";
// Thread 1
std::string s1 = shared; // ref_count++
s1[0] = 'D'; // ref_count--, 새 버퍼 할당
// Thread 2
std::string s2 = shared; // ref_count++ (동시 접근)
s2[0] = 'X'; // ref_count--
ref_count가 여러 스레드에서 동시에 수정되면 데이터 경합이 발생한다. 이를 해결하려면 다음과 같이 한다.
std::atomic<int> ref_count; // 원자적 연산
원자적 참조 수는 일반 정수 연산과 다른 비용 구조를 가진다.
C++11의 가장 중요한 기능 중 하나는 move semantics다.
String(String&& other) noexcept {
ptr_ = other.ptr_;
other.ptr_ = nullptr; // 소유권 이전
}
Move semantics의 핵심은 "소유권 이전"이다. 원본은 무효화되고, 대상이 리소스를 가져간다.
하지만 COW는 "공유"를 가정한다. 원본과 복사본이 모두 유효하게 유지된다.
이 두 개념은 근본적으로 양립할 수 없다.
이론적 이점과 달리, 벤치마크는 C++11 이후 COW가 실제로 느리다는 것을 보여줬다.
2008년, WG21 표준위원회는 N2668 "Concurrency Modifications to Basic String" 제안서를 채택했다.
핵심은 참조·이터레이터 무효화 규칙과 데이터 경합 금지 조항을 강화해, COW 구현이 표준을 만족할 수 없게 만든 것이다. 구현체는 즉시 복사(eager copy)로 전환해야 했고, GCC libstdc++도 GCC 5에서 새 ABI와 함께 COW 문자열을 버렸다.
이것은 표준의 진화가 때로는 "실수를 인정하고 수정하는 과정"임을 보여준다. COW는 20년간 유효했던 최적화였지만, 새로운 요구사항(멀티스레딩, move semantics)과 충돌했다.
흥미롭게도, Qt의 QString은 여전히 COW를 사용한다. 왜일까?
Qt의 상황:
QMutex, 암묵적 공유)Qt의 해결책은 다음과 같다.
class QString {
QStringData* d;
public:
void detach() {
if (d->ref.loadRelaxed() != 1) {
// 공유 중이면 복사
reallocData(d->size, d->alloc);
}
}
QChar& operator[](int i) {
detach(); // 쓰기 가능성이 있으므로 미리 분리
return d->data()[i];
}
};
Qt는 명시적으로 detach를 호출해서 쓰기 전에 복사한다. 이것은 std::string과 다른 계약이고, Qt 문서에서 명시적으로 설명된다.
하지만 Qt도 변화 중이다.
QStringView 도입 (C++17의 std::string_view와 유사) — 공유/복사 없이 보기만 하는 경로 확대C++17은 완전히 새로운 접근법을 도입했다. std::string_view다. 이것은 문자열을 소유하지 않고, 단지 "보기만" 한다.
class string_view {
private:
const char* data_; // 포인터만 저장
size_t size_; // 길이만 저장
public:
constexpr string_view(const char* s, size_t count)
: data_(s), size_(count) {}
constexpr string_view(const char* s)
: data_(s), size_(std::strlen(s)) {}
// std::string에서 암묵적 변환
string_view(const std::string& s)
: data_(s.data()), size_(s.size()) {}
constexpr size_t size() const { return size_; }
constexpr const char* data() const { return data_; }
};
특징:
string_view의 진가는 함수 매개변수에서 드러난다.
// 나쁜 예: 복사 발생
void process(std::string s) { // 매개변수 복사
if (s.find("error") != std::string::npos) {
// ...
}
}
std::string log = "error: file not found";
process(log); // log 전체 복사 (SSO가 아니라면 힙 할당)
// 좋은 예: Zero-copy
void process(std::string_view sv) { // 포인터+길이만 복사
if (sv.find("error") != std::string_view::npos) {
// ...
}
}
process(log); // 복사 없음
process("literal"); // 복사 없음
process(std::string_view(log).substr(0, 5)); // 복사 없음
// 주의: log.substr(0, 5)는 std::string을 새로 만들어 복사가 발생한다
std::string::substr는 새 문자열을 생성하지만, std::string_view::substr는 단지 포인터와 길이만 조정한다.
std::string s = "Hello World";
std::string_view sv = s;
std::string_view hello = sv.substr(0, 5); // "Hello" (복사 없음)
// hello.data() == s.data()
// hello.size() == 5
string_view의 가장 큰 위험은 수명 관리다. string_view는 소유권이 없으므로, 가리키는 데이터가 소멸되면 즉시 댕글링 포인터가 된다.
std::string_view dangerous() {
std::string temp = "temporary";
return temp; // UB: temp는 함수 종료 시 소멸
}
std::string_view sv = dangerous();
std::cout << sv; // 미정의 동작
std::string s = "Hello";
std::string_view sv = s; // s의 버퍼 참조
s += " World"; // 재할당 발생 (capacity 초과 시)
// sv는 이제 해제된 메모리 가리킴
std::string_view sv;
{
char buffer[100];
snprintf(buffer, sizeof(buffer), "format %d", 42);
sv = buffer; // buffer 참조
} // buffer 소멸
// sv는 댕글링 포인터
// 패턴 1: 문자열 리터럴 (정적 저장소)
std::string_view sv1 = "literal"; // OK: 프로그램 전체 생명주기
// 패턴 2: 함수 매개변수 (호출자의 생명주기 보장)
void log(std::string_view message) {
std::cout << message << '\n'; // OK: message는 호출 동안 유효
}
// 패턴 3: 짧은 스코프
void parse(const std::string& input) {
std::string_view first_line = input.substr(0, input.find('\n'));
process(first_line); // OK: input은 함수 종료까지 유효
}
// 피해야 할 패턴: 멤버 변수 저장
class Logger {
std::string_view prefix_; // 위험: 생명주기 불명확
public:
Logger(std::string_view prefix) : prefix_(prefix) {}
// prefix가 가리키는 데이터가 언제 소멸될지 알 수 없음
};
| 상황 | 권장 타입 | 이유 |
|---|---|---|
| 함수 매개변수 (읽기 전용) | string_view | 복사 없음, C 문자열도 지원 |
| 멤버 변수 | std::string | 생명주기 명확 |
| 반환 타입 | std::string | 소유권 이전 보장 |
| 임시 객체 허용 안 됨 | const string& | 수명 연장 규칙 활용 |
| Null 종료 필요 | const char* | string_view는 Null 보장 안 함 |
string_view는 강력하지만 위험한 도구다. Rust의 차용 검사기 같은 컴파일 타임 보증이 없는 C++에서는, 프로그래머가 생명주기를 수동으로 관리해야 한다.
지금까지 length-prefix가 이론적으로 어떤 이점이 있는지 살펴봤다. Windows의 COM(Component Object Model)에는 이를 실제로 구현한 표준 타입이 있다. BSTR이다.
C 스타일 문자열의 근본적 문제와 BSTR의 해결책을 비교하면 다음과 같다.
| 문제 | C 문자열 | BSTR |
|---|---|---|
| 길이 확인 | - null 탐색 | - 앞 4바이트 |
| null 포함 | 불가능 | 가능 (바이너리 안전) |
| 크기 정보 | 없음 | 앞에 저장됨 |
| 언어 중립 | C/C++ 전용 | VB, JS, Python 등 연동 |
COM은 언어 중립적 바이너리 인터페이스를 목표로 했다. Visual Basic, JavaScript, Python 등 다양한 언어가 COM 객체를 사용하는데, 각 언어의 문자열 표현이 다르다. BSTR은 이 문제를 해결한다.
BSTR bstr = SysAllocString(L"Hello");
| 오프셋 | 크기 | 내용 | 값 (예시) |
|---|---|---|---|
| -4 | 4 bytes | 바이트 길이 (null 제외) | 10 (5문자 × 2바이트) |
| 0 | 가변 | 문자열 데이터 (UTF-16LE) | H e l l o |
| +10 | 2 bytes | null terminator | \0\0 |
핵심은 BSTR 포인터는 문자열 시작을 가리키고, 길이는 포인터 - 4 위치에 있다는 것이다.
BSTR bstr = SysAllocString(L"Hello");
// 길이 접근 (내부 동작)
UINT len = *((UINT*)bstr - 1); // 10 (바이트 단위)
// 안전한 방법
UINT len = SysStringLen(bstr); // 5 (문자 단위)
UINT bytes = SysStringByteLen(bstr); // 10 (바이트 단위)
둘 다 wchar_t*로 정의되지만, 의미가 다르다.
typedef WCHAR* BSTR; // 사실상 wchar_t*
typedef WCHAR* LPWSTR; // 역시 wchar_t*
| 특성 | BSTR | LPWSTR |
|---|---|---|
| 길이 정보 | 포인터 앞 4바이트에 저장 | 없음 (null로 판단) |
| 할당 | SysAllocString | malloc, new, 스택 등 |
| 해제 | SysFreeString | 할당 방식에 따름 |
| null 허용 | 문자열 중간에 null 가능 | null이 끝을 의미 |
| COM 마샬링 | 자동 지원 | 수동 처리 필요 |
타입은 같아도 의미와 계약이 다르므로, 잘못된 코드는 힙 손상으로 이어진다.
// 잘못된 코드
BSTR bstr = L"Hello"; // 컴파일은 되지만 BSTR이 아님
SysFreeString(bstr); // 크래시. 힙 손상
// 올바른 코드
BSTR bstr = SysAllocString(L"Hello");
SysFreeString(bstr);
| 값 | 의미 | SysStringLen 반환 |
|---|---|---|
NULL | 빈 문자열 (유효) | 0 |
SysAllocString(L"") | 빈 문자열 (할당됨) | 0 |
SysAllocString(L"Hello") | "Hello" | 5 |
COM에서 NULL BSTR은 빈 문자열로 취급된다. 에러가 아니다.
void ProcessString(BSTR bstr) {
// NULL 체크 후 사용
if (bstr && SysStringLen(bstr) > 0) {
// 비어있지 않은 문자열
}
// 또는 안전하게
UINT len = bstr ? SysStringLen(bstr) : 0;
}
length-prefix만으로는 부족하다. 실제로 BSTR을 만들고 해제하려면 별도의 할당자가 필요하다. 왜인가?
COM 객체는 프로세스 경계와 모듈 경계를 넘어 통신한다. DLL A에서 할당한 메모리를 DLL B에서 해제해야 하는 경우가 발생한다.
| 상황 | 문제 | 해결책 |
|---|---|---|
DLL A: malloc → DLL B: free | 힙 손상 (각 DLL이 다른 CRT 힙 사용 가능) | 공유 할당자 사용 |
EXE: new → COM 객체: delete | 같은 문제 | CoTaskMemAlloc |
| 함수 | 할당자 | 용도 |
|---|---|---|
malloc / free | CRT 힙 | 모듈 내부 전용 |
new / delete | CRT 힙 | 모듈 내부 전용 |
HeapAlloc / HeapFree | 프로세스 힙 | 저수준 Windows API |
CoTaskMemAlloc / CoTaskMemFree | COM 힙 | COM 인터페이스 경계 |
SysAllocString / SysFreeString | COM 힙 (BSTR 전용) | BSTR만 |
// COM 인터페이스에서 문자열 반환
HRESULT GetName(BSTR* pbstrName) {
// 호출자가 SysFreeString으로 해제
*pbstrName = SysAllocString(L"MyObject");
return S_OK;
}
// COM 인터페이스에서 배열 반환
HRESULT GetData(BYTE** ppData, ULONG* pcb) {
*pcb = 100;
// 호출자가 CoTaskMemFree로 해제
*ppData = (BYTE*)CoTaskMemAlloc(*pcb);
return S_OK;
}
| 함수 | 용도 | 반환값 |
|---|---|---|
SysAllocString(LPCOLESTR) | null-terminated 문자열로부터 생성 | BSTR |
SysAllocStringLen(LPCOLESTR, UINT) | 길이 지정, null 포함 가능 | BSTR |
SysAllocStringByteLen(LPCSTR, UINT) | 바이트 배열로부터 생성 | BSTR |
SysReAllocString(BSTR*, LPCOLESTR) | 재할당 | BOOL |
SysFreeString(BSTR) | 해제 | void |
SysStringLen(BSTR) | 문자 수 반환 | UINT |
SysStringByteLen(BSTR) | 바이트 수 반환 | UINT |
내부 구현은 본질적으로 다음과 같다.
BSTR SysAllocStringLen(const OLECHAR* psz, UINT len) {
// 할당: 길이 필드(4) + 문자열(len*2) + null(2)
UINT bytes = len * sizeof(OLECHAR);
BYTE* raw = (BYTE*)CoTaskMemAlloc(4 + bytes + 2);
if (!raw) return NULL;
// 길이 저장
*((UINT*)raw) = bytes;
// 문자열 복사
BSTR bstr = (BSTR)(raw + 4);
if (psz) {
memcpy(bstr, psz, bytes);
} else {
memset(bstr, 0, bytes);
}
// null terminator
bstr[len] = L'\0';
return bstr;
}
void SysFreeString(BSTR bstr) {
if (bstr) {
BYTE* raw = (BYTE*)bstr - 4;
CoTaskMemFree(raw);
}
}
UINT SysStringLen(BSTR bstr) {
if (!bstr) return 0;
return *((UINT*)bstr - 1) / sizeof(OLECHAR);
}
length-prefix 4바이트와 trailing null 2바이트를 합쳐 6바이트의 오버헤드가 있다. 그 대가로 길이 조회와 바이너리 안전성을 얻는다.
| 실수 | 원인 | 해결 |
|---|---|---|
| BSTR 누수 | SysFreeString 누락 | 래퍼 클래스 사용 |
| 이중 해제 | 소유권 혼란 | 규칙 명확화 |
| 잘못된 할당자 | malloc + SysFreeString 혼용 | API 문서 확인 |
| 리터럴 해제 | BSTR b = L"Hello"; SysFreeString(b); | 항상 SysAllocString 사용 |
| 버퍼 오버런 | 길이 필드 덮어쓰기 | SysReAllocStringLen 사용 |
디버거에서 BSTR 길이를 직접 확인할 수도 있다.
BSTR bstr = SysAllocString(L"Test");
UINT* pLen = (UINT*)bstr - 1; // Watch: *pLen = 8
// bstr-4 위치에서 시작하면 길이 필드 포함된 메모리 덤프 가능
SysFreeString 누락은 흔한 실수다. 현대 C++에서는 std::string이 RAII로 메모리를 관리하듯, BSTR과 COM 포인터에도 RAII 래퍼가 있다.
_bstr_t (comutil.h)#include <comutil.h>
_bstr_t bstr1(L"Hello"); // 복사 생성
_bstr_t bstr2 = L"World"; // 암시적 변환
_bstr_t bstr3 = bstr1 + L" " + bstr2; // 연결
// BSTR로 변환 (소유권 유지)
BSTR raw = bstr3; // 또는 bstr3.GetBSTR()
// BSTR로 변환 (소유권 이전)
BSTR detached = bstr3.Detach(); // bstr3는 이제 비어있음
SysFreeString(detached); // 수동 해제 필요
| 메서드 | 동작 |
|---|---|
GetBSTR | 내부 BSTR 반환 (소유권 유지) |
GetAddress | BSTR* 반환 (out 파라미터용) |
Detach | 소유권 이전 |
Attach(BSTR) | 기존 BSTR 소유권 획득 |
copy | 복사본 생성 (호출자 해제 책임) |
CComBSTR (atlbase.h)ATL 기반 코드에서는 CComBSTR을 사용한다.
#include <atlbase.h>
CComBSTR bstr1(L"Hello");
CComBSTR bstr2(10); // 10문자 공간 할당
bstr1.Append(L" World");
bstr1.AppendBSTR(bstr2);
// 대소문자 무시 비교
if (bstr1.CompareNoCase(L"HELLO WORLD") == 0) {
// 같음
}
// out 파라미터로 전달
HRESULT hr = pObj->GetName(&bstr1); // 기존 값 자동 해제 후 받음
ComPtr (wrl/client.h) - Modern C++COM 객체 자체에 대해서도 RAII가 필요하다. WRL의 ComPtr이 가장 현대적인 선택이다.
#include <wrl/client.h>
using Microsoft::WRL::ComPtr;
ComPtr<IUnknown> pUnk;
HRESULT hr = CoCreateInstance(CLSID_Something, nullptr,
CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&pUnk));
// 자동 Release
// 범위 벗어나면 Release 호출
// QueryInterface
ComPtr<IDispatch> pDisp;
hr = pUnk.As(&pDisp);
// out 파라미터
ComPtr<IStream> pStream;
hr = SHCreateStreamOnFile(L"test.txt", STGM_READ, &pStream);
| 래퍼 | 헤더 | 용도 |
|---|---|---|
_bstr_t | comutil.h | BSTR 관리 (가벼움) |
CComBSTR | atlbase.h | BSTR 관리 (ATL) |
CComPtr<T> | atlbase.h | COM 포인터 관리 (ATL) |
ComPtr<T> | wrl/client.h | COM 포인터 관리 (Modern) |
새 코드를 작성한다면 ATL 의존성이 없는 _bstr_t와 ComPtr<T> 조합이 가장 가볍다. 기존 ATL 프로젝트라면 CComBSTR/CComPtr을 그대로 사용한다.
문자열은 단순해 보이지만 표현, 성장, 소유권과 ABI가 한데 묶인 자료구조다. 여기서 끌어낼 통찰은 다음과 같다.
null-terminated 표현은 길이 field 없이 C 배열과 pointer 연산에 맞지만, 길이 조회와 반복 append에서 선형 탐색 비용을 만든다. strcat을 반복하면 매번 끝을 다시 찾아 로 퇴화할 수 있으며, 현재 end와 capacity를 유지하는 builder가 이를 피한다. SSO는 짧은 값의 별도 allocation을 피하지만 inline capacity와 객체 크기의 교환이다. COW 문자열은 공유와 detach 비용을 선택하는 모델이며, 현대 std::basic_string의 iterator·reference와 동시성 요구에는 맞지 않는다.
소유권의 명확성은 안전성을 좌우한다. std::string은 소유권이 명시적이라 안전하지만 비용이 따른다. std::string_view는 빠르지만 댕글링 위험이 있다. BSTR과 COM 객체는 길이 정보와 모듈 경계 안전성을 위해 별도의 할당자(SysAllocString, CoTaskMemAlloc)를 도입했고, 그 비용을 RAII 래퍼(_bstr_t, ComPtr)로 다시 추상화했다.
strcat의 문제