45. std::string_view 의 내부 표현과 수명
난이도 중해설 — std::string_view 의 내부 표현과 수명
난이도: 중상
요약
std::string_view 는 (포인터, 길이) 쌍일 뿐이며 아무것도 소유하지 않는다. 복사가 저렴하고
부분 문자열 추출에 할당이 없다는 것이 장점이자, 가리키는 대상이 사라지거나 이동하면 즉시
댕글링이 된다는 위험의 근원이다. 또한 널 종료를 보장하지 않으므로 C API 에 그대로 넘기면 안 된다.
핵심 개념
- 비소유 뷰(non-owning view): 내부는
const char* data_와size_t size_두 개뿐. 소멸자가 하는 일이 없다. - 널 종료 미보장:
data()는 뷰의 시작을 가리킬 뿐,data()[size()]가'\0'이라는 보장이 없다. substr은 O(1): 포인터를 옮기고 길이를 줄일 뿐 복사하지 않는다.std::string::substr이 새 문자열을 할당하는 것과 정반대다.- 수명 규칙: 뷰는 원본보다 오래 살면 안 된다. 원본이 재할당되어도(문자열 성장, 컨테이너 재할당) 무효가 된다.
단계별 내부 동작
A — sizeof(std::string_view)
포인터 + size_t = 64비트 플랫폼에서 16. std::string 의 32(libstdc++, SSO 버퍼 포함)와
대비된다. 값으로 전달해도 레지스터 두 개로 끝나기 때문에 매개변수 타입으로 선호된다.
B — 유효한 사용
std::string_view tok = firstToken(chat);
firstToken 은 인자로 받은 const std::string& 의 내부 버퍼를 가리키는 뷰를 반환한다.
호출 측의 chat 이 살아 있고 수정되지 않았으므로 유효하다. B: hello (len=5).
다만 이 함수 시그니처는 위험하다. firstToken(makeName(1)) 처럼 임시를 넘기면 함수가 반환하는
순간 뷰가 댕글링이 된다. 매개변수를 std::string&& 오버로드로 = delete 해서 임시 전달을
컴파일 타임에 막는 것이 방어책이다.
std::string_view firstToken(const std::string& s);
std::string_view firstToken(std::string&&) = delete; // 임시 전달 금지
C — 임시 문자열에 대한 뷰 (정의되지 않은 동작)
std::string_view danger = makeName(42);
makeName 이 반환한 std::string 임시는 이 선언문의 끝(세미콜론)에서 소멸한다.
string_view 로의 변환은 참조 바인딩이 아니라 변환 함수 호출이므로 수명 연장이 적용되지
않는다. const std::string& r = makeName(42); 였다면 수명이 연장되지만, string_view 는 아니다.
이 차이가 가장 흔한 함정이다.
따라서 C 는 해제된 메모리를 읽는 UB 다. "player_42" 는 짧아서 SSO 로 임시 객체의 스택
버퍼에 들어 있었고, 그 스택 영역이 아직 덮이지 않으면 우연히 맞는 값이 출력된다. 최적화
빌드나 다른 코드 배치에서는 쓰레기가 나온다. "디버그에서는 되는데 릴리스에서 깨진다"의
전형이다. ASAN(-fsanitize=address)은 힙 할당된 긴 문자열의 경우 use-after-free 로 잡아준다.
D — 널 종료 미보장
const char packet[] = {'s','w','o','r','d','_','0','1','X','Y','Z'}; // '\0' 없음
std::string_view field(packet, 8);
std::strlen(field.data());
%.*s 로 길이를 명시한 출력은 sword_01 로 정상이다. 그러나 std::strlen(field.data()) 은
널 바이트를 만날 때까지 읽으므로 'X','Y','Z' 를 지나 배열 밖으로 넘어간다. 이것도 UB 이며
D 의 두 번째 값은 8이 아니라 예측 불가능한 값이다.
패킷 버퍼처럼 널 종료가 없는 데이터에서 뷰를 만드는 것이 정확히 string_view 의 주 용도이므로,
게임서버 파서에서는 이 함정을 매일 마주친다. C API 에 넘겨야 한다면 std::string(sv) 로
복사하거나, 길이를 받는 API(fwrite, printf("%.*s"), send)를 쓴다.
E — 원본 재할당에 의한 무효화
std::string owner = "guild_master"; // 12자, SSO 버퍼에 저장
std::string_view sv = owner;
owner += "_of_the_north"; // 26자 → 힙 재할당
+= 로 용량을 넘기면 새 버퍼를 할당하고 옛 버퍼를 해제한다(SSO 였다면 스택 버퍼가 더 이상
쓰이지 않는다). sv 는 여전히 옛 위치를 가리키므로 댕글링이다. E 는 UB.
핵심은 "원본 객체가 살아 있다"만으로는 부족하다는 것. 원본의 버퍼가 그대로여야 한다.
std::vector 의 반복자 무효화 규칙과 정확히 같은 사고방식이 필요하다.
흔한 오해·함정
- "
const std::string&대신 무조건string_view를 쓰면 된다" — 아니다. 함수 안에서 널 종료가 필요하거나(C API 호출), 인자를 저장해 두어야 하면std::string값 매개변수가 맞다. - 멤버 변수로
string_view를 들고 있기 — 객체 수명과 원본 수명의 관계를 문서화·보장할 수 없다면 위험하다. 캐시 키를string_view로 저장했다가 원본이 사라지는 사고가 흔하다. string_view를 반환 타입으로 — 인자로 받은 뷰/참조의 일부를 반환하는 것은 괜찮지만, 지역std::string의 뷰를 반환하면 항상 댕글링이다.std::map<std::string_view, T>조회 — 조회 키로는 훌륭하지만(할당 없는 lookup), 키 자체를 뷰로 저장하면 원본 문자열의 수명을 맵보다 길게 유지해야 한다.- 비교 연산 —
sv == "abc"는 길이 + 내용 비교라 안전하다.sv.data() == "abc"는 포인터 비교.
면접 포인트
const std::string& r = makeTemp();는 수명이 연장되는데std::string_view v = makeTemp();는 왜 안 되는가. 참조 바인딩과 변환 함수의 차이.string_view를 매개변수 타입으로 쓸 때의 이득(호출 측const char*/std::string/부분 문자열 모두 무할당 전달)과, 저장·널종료가 필요한 경우의 판단 기준.- 패킷 버퍼 파싱에서
string_view로 필드를 잘라 쓸 때, 뷰의 수명을 버퍼(수신 버퍼 풀 반납, 링버퍼 컴팩션) 수명과 어떻게 묶어야 하는가. 비동기 처리로 넘길 때 왜 복사가 필요한가.