← 문제로

45. std::string_view 의 내부 표현과 수명

난이도 중
내 리뷰 · C++
해설 · C++

해설 — 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" 는 포인터 비교.

면접 포인트

  1. const std::string& r = makeTemp(); 는 수명이 연장되는데 std::string_view v = makeTemp(); 는 왜 안 되는가. 참조 바인딩과 변환 함수의 차이.
  2. string_view 를 매개변수 타입으로 쓸 때의 이득(호출 측 const char*/std::string/부분 문자열 모두 무할당 전달)과, 저장·널종료가 필요한 경우의 판단 기준.
  3. 패킷 버퍼 파싱에서 string_view 로 필드를 잘라 쓸 때, 뷰의 수명을 버퍼(수신 버퍼 풀 반납, 링버퍼 컴팩션) 수명과 어떻게 묶어야 하는가. 비동기 처리로 넘길 때 왜 복사가 필요한가.