C++ std::string 자주 쓰는 함수 정리: 검색·치환·분할과 흔한 실수, 성능 팁

이 글의 핵심

std::string의 기본 사용법과 자주 쓰는 멤버 함수, 실전 예시, 초보자가 자주 겪는 문제와 불필요한 복사를 줄이는 성능 팁을 정리합니다.

string 기본 사용법

std::string은 char의 동적 배열을 감싼 클래스입니다. C 문자열(char*)과 달리 길이를 따로 저장하므로 size()가 O(1)이고, 메모리를 스스로 관리하므로 버퍼 크기를 계산하거나 free를 호출할 필요가 없습니다. 내부 버퍼 끝에는 항상 널 문자('\0')가 유지되어 c_str()로 C API에 그대로 넘길 수 있습니다. 한 가지 기억할 점은 std::string이 바이트열이라는 것입니다. UTF-8 한글 “가”는 3바이트이므로 size()는 3을 돌려주고, s[0]은 글자가 아니라 첫 번째 바이트입니다.

선언과 초기화

#include <string>
#include <iostream>
using namespace std;

string s1 = "Hello";
string s2("World");
string s3 = s1;  // 복사
string s4(5, 'A');  // "AAAAA"

string s4(5, 'A')와 string s4{5, 'A'}는 결과가 다릅니다. 중괄호 초기화는 initializer_list<char> 생성자를 우선하므로, 후자는 문자 코드 5와 'A' 두 글자로 된 문자열이 됩니다. 반복 문자를 만들 때는 괄호를 써야 합니다.

문자열 연결

string s1 = "Hello";
string s2 = "World";

// + 연산자
string s3 = s1 + " " + s2;  // "Hello World"

// += 연산자
s1 += " World";  // "Hello World"

// append()
s1.append(" !");  // "Hello World !"

+는 새 문자열을 만들어 돌려주고, +=와 append는 기존 버퍼 뒤에 붙입니다. 따라서 결과를 같은 변수에 누적한다면 s = s + x가 아니라 s += x를 써야 합니다. 전자는 매번 전체 복사본을 새로 만들기 때문에 루프 안에서 쓰면 O(n²)이 됩니다. 또 "Hello" + " World"처럼 리터럴끼리 더하면 컴파일 에러가 납니다. 두 피연산자가 모두 const char*라서 string의 operator+가 선택되지 않기 때문이며, 한쪽을 string("Hello")나 "Hello"s(C++14 리터럴)로 만들어야 합니다.

문자열 비교

string s1 = "apple";
string s2 = "banana";

if (s1 == s2) cout << "같음" << endl;
if (s1 < s2) cout << "s1이 사전순으로 앞" << endl;  // 출력됨
if (s1.compare(s2) == 0) cout << "같음" << endl;

< 비교는 바이트 값 기준의 사전순입니다. 그래서 대문자('Z' = 90)가 소문자('a' = 97)보다 앞에 오고, 한글 정렬도 로케일 규칙이 아닌 UTF-8 바이트 순서를 따릅니다. 대소문자 무시 비교가 필요하면 양쪽을 소문자로 바꾼 뒤 비교하거나 비교 함수를 직접 작성해야 합니다. compare()는 음수·0·양수를 돌려주므로 세 갈래 분기가 필요한 정렬 비교자에서 유용합니다.

문자열 길이와 접근

string s = "Hello";

cout << s.length() << endl;  // 5
cout << s.size() << endl;    // 5
cout << s[0] << endl;        // 'H'
cout << s.at(0) << endl;     // 'H' (범위 체크)

length()와 size()는 완전히 같은 함수입니다. size()는 다른 STL 컨테이너와 이름을 맞추기 위해 존재하므로 제네릭 코드에서는 size()를 씁니다. s[i]는 범위를 검사하지 않아 범위 밖 접근이 정의되지 않은 동작(UB)이고, at(i)는 std::out_of_range 예외를 던집니다. 디버그 빌드에서만 검사하고 싶다면 MSVC의 디버그 반복자나 libstdc++의 _GLIBCXX_ASSERTIONS 매크로를 켜는 방법이 있습니다.

부분 문자열

string s = "Hello World";

// substr(시작위치, 길이)
string sub1 = s.substr(0, 5);  // "Hello"
string sub2 = s.substr(6);     // "World"

substr의 두 번째 인자는 끝 위치가 아니라 길이입니다. Java의 substring(begin, end)나 Python 슬라이스에 익숙하면 여기서 자주 틀립니다. 시작 위치가 size()보다 크면 out_of_range 예외가 나지만, 길이가 남은 문자 수보다 크면 끝까지만 잘라 줍니다. 그리고 substr은 항상 새 string을 할당해 복사합니다. 읽기만 할 부분 문자열이라면 C++17의 std::string_view로 받아 복사 없이 처리할 수 있습니다.

문자열 검색

string s = "Hello World";

// find() - 첫 번째 위치 반환
size_t pos = s.find("World");  // 6
if (pos != string::npos) {
    cout << "찾음: " << pos << endl;
}

// rfind() - 마지막 위치
pos = s.rfind("o");  // 7

// find_first_of() - 문자 집합 중 하나
pos = s.find_first_of("aeiou");  // 1 ('e')

find_first_of는 이름 때문에 오해하기 쉽습니다. 인자로 받은 문자열 전체를 찾는 것이 아니라, 그 안의 문자 중 아무거나 처음 나오는 위치를 찾습니다. 구분자가 여러 개인 토큰 분리(" ,;\t")에 쓰입니다. 부분 문자열 여부만 알고 싶다면 C++23의 contains()가 가장 읽기 좋고, C++20의 starts_with()/ends_with()도 find(...) == 0 같은 관용구를 대체합니다.

문자열 치환

string s = "Hello World";

// replace(시작위치, 길이, 새문자열)
s.replace(6, 5, "C++");  // "Hello C++"

// 전체 치환 함수
void replaceAll(string& str, const string& from, const string& to) {
    size_t pos = 0;
    while ((pos = str.find(from, pos)) != string::npos) {
        str.replace(pos, from.length(), to);
        pos += to.length();
    }
}

std::string에는 “모두 바꾸기” 멤버가 없어서 위와 같은 루프를 직접 씁니다. 여기서 pos += to.length()가 중요합니다. 이 줄이 없으면 to가 from을 포함할 때(예: “a”를 “aa”로) 방금 넣은 문자열을 다시 찾아 무한 루프에 빠집니다. 또 from이 빈 문자열이면 find("")가 매번 현재 위치를 돌려주므로, 함수 앞에서 if (from.empty()) return;으로 막아야 합니다. 이 함수는 치환할 때마다 뒤쪽 문자를 밀어 옮기므로 치환이 많고 문자열이 길면 O(n·k)가 됩니다. 그런 경우에는 새 문자열에 조각을 이어 붙여 한 번에 만드는 방식이 빠릅니다.

문자열 삽입/삭제

string s = "Hello World";

// insert(위치, 문자열)
s.insert(5, " Beautiful");  // "Hello Beautiful World"

// erase(시작위치, 길이)
s.erase(5, 10);  // "Hello World"

// clear()
s.clear();  // ""

insert와 중간 위치의 erase는 뒤쪽 문자를 모두 이동시키므로 O(n)입니다. clear()는 길이만 0으로 만들고 이미 확보한 용량(capacity())은 보통 그대로 둡니다. 그래서 루프 안에서 같은 string을 clear() 후 재사용하면 재할당이 일어나지 않아 효율적이고, 반대로 메모리를 정말 돌려주고 싶다면 shrink_to_fit()을 호출해야 합니다(구속력 없는 요청입니다).

실전 예시

예시 1: 문자열 분할 (split)

#include <iostream>
#include <string>
#include <vector>
#include <sstream>
using namespace std;

// 방법 1: stringstream 사용
vector<string> split(const string& str, char delimiter) {
    vector<string> tokens;
    stringstream ss(str);
    string token;
    
    while (getline(ss, token, delimiter)) {
        tokens.push_back(token);
    }
    
    return tokens;
}

// 방법 2: find 사용
vector<string> split2(const string& str, const string& delimiter) {
    vector<string> tokens;
    size_t start = 0;
    size_t end = str.find(delimiter);
    
    while (end != string::npos) {
        tokens.push_back(str.substr(start, end - start));
        start = end + delimiter.length();
        end = str.find(delimiter, start);
    }
    
    tokens.push_back(str.substr(start));
    return tokens;
}

int main() {
    string csv = "apple,banana,cherry,date";
    vector<string> fruits = split(csv, ',');
    
    for (const string& fruit : fruits) {
        cout << fruit << endl;
    }
    
    return 0;
}

C++ 표준 라이브러리에는 여전히 split 멤버 함수가 없어서 두 방식 중 하나를 직접 구현하는 경우가 많습니다. 두 방식은 빈 토큰을 다루는 방식이 다릅니다. "a,,b,"를 넣으면 getline 방식은 ["a", "", "b"]로 마지막 빈 토큰을 버리고, find 방식은 ["a", "", "b", ""]로 끝의 빈 토큰까지 보존합니다. CSV의 마지막 열이 비어 있는 데이터를 다룰 때 이 차이로 열 개수가 한 칸씩 어긋나는 버그가 생기기 쉬워서, 저는 열 개수가 중요한 입력에는 find 방식을 씁니다. 또 getline 방식은 구분자로 한 글자만 받을 수 있고, stringstream 생성 비용이 있어 짧은 문자열을 대량으로 나눌 때 상대적으로 느립니다.

주의할 점은 이 코드가 “진짜 CSV”를 파싱하지 못한다는 것입니다. "Seoul, Korea",100처럼 따옴표 안에 쉼표가 들어간 필드는 잘못 분리됩니다. 외부에서 받은 CSV라면 RFC 4180 규칙을 처리하는 전용 파서를 쓰는 편이 안전합니다. C++20 이상이라면 std::views::split으로 복사 없이 string_view 조각을 순회하는 방법도 있습니다.

예시 2: 문자열 트림 (공백 제거)

#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

// 왼쪽 공백 제거
string ltrim(string str) {
    str.erase(str.begin(), find_if(str.begin(), str.end(), [](unsigned char ch) {
        return !isspace(ch);
    }));
    return str;
}

// 오른쪽 공백 제거
string rtrim(string str) {
    str.erase(find_if(str.rbegin(), str.rend(), [](unsigned char ch) {
        return !isspace(ch);
    }).base(), str.end());
    return str;
}

// 양쪽 공백 제거
string trim(string str) {
    return ltrim(rtrim(str));
}

int main() {
    string s = "   Hello World   ";
    cout << "[" << s << "]" << endl;
    cout << "[" << trim(s) << "]" << endl;
    
    return 0;
}

람다의 매개변수가 char가 아니라 unsigned char인 데는 이유가 있습니다. isspace, isdigit, toupper 같은 <cctype> 함수는 EOF나 unsigned char 범위의 값만 받도록 정의되어 있습니다. char가 부호 있는 타입인 플랫폼(x86의 GCC·MSVC 대부분)에서 UTF-8 한글 바이트(0x80 이상)를 그대로 넘기면 음수가 되어 정의되지 않은 동작이 됩니다. MSVC 디버그 빌드에서는 이 경우 assertion 창이 뜨기도 합니다.

rtrim의 .base()는 역방향 반복자를 정방향으로 되돌리는 호출인데, 가리키는 위치가 한 칸 뒤로 밀린다는 점 덕분에 “마지막 비공백 문자 다음”을 정확히 가리킵니다. 함수들이 string을 값으로 받는 것은 의도된 설계입니다. 인자로 넘긴 원본은 건드리지 않으면서, 임시 객체를 넘기면 이동으로 받아 복사가 생기지 않습니다.

예시 3: 대소문자 변환 및 검증

#include <iostream>
#include <string>
#include <algorithm>
#include <cctype>
using namespace std;

string toUpper(string str) {
    transform(str.begin(), str.end(), str.begin(), ::toupper);
    return str;
}

string toLower(string str) {
    transform(str.begin(), str.end(), str.begin(), ::tolower);
    return str;
}

bool isValidEmail(const string& email) {
    size_t at = email.find('@');
    size_t dot = email.find('.', at);
    
    return at != string::npos && 
           dot != string::npos && 
           at > 0 && 
           dot > at + 1 && 
           dot < email.length() - 1;
}

bool isNumeric(const string& str) {
    return !str.empty() && all_of(str.begin(), str.end(), ::isdigit);
}

int main() {
    string text = "Hello World";
    cout << "대문자: " << toUpper(text) << endl;
    cout << "소문자: " << toLower(text) << endl;
    
    string email = "[email protected]";
    cout << email << "는 " << (isValidEmail(email) ? "유효" : "무효") << endl;
    
    string num = "12345";
    cout << num << "는 " << (isNumeric(num) ? "숫자" : "숫자 아님") << endl;
    
    return 0;
}

::toupper를 transform에 직접 넘기는 코드는 ASCII 문자열에서는 잘 동작하지만, 앞의 트림 예시에서 설명한 것과 같은 이유로 한글이 섞인 문자열에서는 UB가 될 수 있습니다. 안전하게 쓰려면 [](unsigned char c) { return static_cast<char>(toupper(c)); } 같은 람다로 감싸야 합니다. 그리고 이 방식은 영문 알파벳만 바꿉니다. 독일어 ß나 터키어 i처럼 유니코드 규칙이 필요한 대소문자 변환은 바이트 단위로는 불가능하며, ICU 같은 라이브러리가 필요합니다.

isValidEmail은 “@가 있고 그 뒤에 점이 있다” 수준의 형식 점검입니다. 이메일 주소 문법(RFC 5322)은 이보다 훨씬 복잡하고, 형식이 맞아도 실제로 존재하는 주소인지는 알 수 없습니다. 실무에서는 이런 간단한 점검으로 명백한 오타만 걸러 내고, 최종 확인은 인증 메일로 하는 것이 일반적입니다. isNumeric도 부호(-12)나 소수점, 자릿수 초과를 고려하지 않으므로, 실제로 숫자로 변환할 거라면 std::from_chars의 결과 코드로 검증하는 편이 정확합니다.

자주 발생하는 문제

문제 1: C 문자열과 string 혼동

증상: char*와 string 사이 변환 시 에러 또는 예상과 다른 동작

원인: C 문자열과 C++ string의 차이를 이해하지 못함

해결법:

// ❌ 잘못된 코드
char* cstr = "Hello";  // C++11부터 컴파일 에러 (구형 컴파일러는 경고만)
string str = cstr;
cstr[0] = 'h';  // 문자열 리터럴 수정은 UB (보통 세그폴트)

// ✅ 올바른 코드
const char* cstr = "Hello";  // const 사용
string str = cstr;  // OK

// string을 C 문자열로
cout << str.c_str() << endl;  // const char* 반환

// 수정 가능한 C 문자열
char cstr2[100];
strcpy(cstr2, str.c_str());  // <cstring> 필요, str이 99자를 넘으면 버퍼 오버플로
cstr2[0] = 'h';  // OK

문자열 리터럴의 타입은 const char[N]이고 보통 읽기 전용 메모리 영역에 배치됩니다. C++11부터는 const를 떼고 char*로 받는 변환 자체가 금지되어 GCC는 ISO C++ forbids converting a string constant to 'char*' 경고(-pedantic-errors에서는 에러)를, MSVC는 /permissive-에서 에러를 냅니다. 오래된 C 라이브러리가 char*를 요구해서 이 경고를 억지로 없애려다 리터럴을 수정하는 코드가 생기는데, 그럴 때는 위 예시처럼 수정 가능한 버퍼로 복사하거나 C++17부터 non-const data()를 제공하는 std::string을 넘기면 됩니다.

strcpy 예시는 고정 크기 버퍼에 길이 검사 없이 복사하므로, 입력 길이를 통제할 수 없다면 쓰지 말아야 합니다. c_str()이 돌려준 포인터도 원본 string이 수정되거나(+=로 재할당될 때 포함) 소멸하면 무효가 됩니다. const char* p = getName().c_str();처럼 임시 string의 포인터를 저장하는 코드는 다음 줄에서 이미 댕글링 포인터입니다.

문제 2: string::npos 비교

증상: find() 결과를 int로 받아서 비교 시 오작동

원인: npos는 size_t 타입의 최댓값 (unsigned)

해결법:

// ❌ 잘못된 코드
string s = "Hello";
int pos = s.find("x");  // npos가 int로 좁혀져 대입됨 (구현 의존)
if (pos == -1) {  // 64비트에서 흔히 true가 되지만 우연에 기대는 코드
    cout << "못 찾음" << endl;
}

// ✅ 올바른 코드
string s = "Hello";
size_t pos = s.find("x");
if (pos == string::npos) {
    cout << "못 찾음" << endl;
}

// 또는 auto 사용
auto pos2 = s.find("x");
if (pos2 == string::npos) {
    cout << "못 찾음" << endl;
}

npos는 size_t(-1), 즉 64비트 환경에서 18446744073709551615입니다. 이를 int에 넣으면 좁히기 변환이 일어나는데, 대부분의 구현에서 결과가 -1이 되어 pos == -1이 우연히 참이 됩니다. 그래서 이 버그는 테스트에서 잘 드러나지 않습니다. 진짜 문제는 반대 방향입니다. unsigned로 받거나 pos + 1 > 0 같은 산술을 섞으면 비교가 엉뚱하게 뒤집히고, if (s.find("x"))처럼 불리언으로 쓰면 “못 찾음”(npos)이 참으로, “0번 위치에서 찾음”이 거짓으로 판정됩니다. -Wsign-compare와 -Wconversion 경고를 켜 두면 이런 코드를 컴파일 단계에서 대부분 잡을 수 있습니다.

문제 3: 문자열 연결 성능

증상: 루프에서 문자열을 이어 붙일 때 프로그램이 느려짐

원인: 용량이 부족할 때마다 재할당·복사가 일어나고, 특히 s = s + x 형태는 매번 전체를 복사함

해결법:

// ⚠️ 동작은 괜찮지만 재할당이 여러 번 일어나는 코드
string result;
for (int i = 0; i < 10000; i++) {
    result += "a";  // 용량이 찰 때마다 재할당 (용량은 배수로 늘어나 분할상환 O(1))
}

// ✅ 최종 크기를 알면 reserve로 재할당 제거
string result;
result.reserve(10000);  // 미리 공간 확보
for (int i = 0; i < 10000; i++) {
    result += "a";
}

// (대안) stringstream: 숫자 등 여러 타입을 섞어 조립할 때 편리, 순수 연결 속도는 보통 reserve + += 보다 느림
#include <sstream>
stringstream ss;
for (int i = 0; i < 10000; i++) {
    ss << "a";
}
string result = ss.str();

+=는 vector::push_back처럼 용량이 부족할 때 용량을 1.5~2배로 늘리므로, 루프에서 +=를 쓰는 것만으로 성능이 무너지지는 않습니다. 10,000번 붙여도 재할당은 20번 안팎입니다. 성능 문제가 실제로 터지는 곳은 result = result + "a"처럼 매번 새 문자열을 만드는 패턴이나, 함수 인자로 string을 값으로 넘기며 매 호출마다 복사하는 경우입니다. 프로파일러에서 memcpy와 operator new가 상위에 올라온다면 이 두 가지를 먼저 확인해 보세요.

stringstream은 로케일 처리와 가상 함수 호출이 섞여 있어서 단순 연결에서는 +=보다 느린 경우가 많습니다. 정수·실수를 문자열로 바꿔 붙이는 작업이라면 std::to_string, 성능이 중요하면 std::to_chars, C++20 이상이라면 std::format이 더 적합합니다.

성능 최적화

최적화 전략

  1. 읽기 전용 인자는 const string& 또는 string_view로 받기

    • void f(string s)는 호출할 때마다 복사(힙 할당 포함)가 일어납니다. 읽기만 한다면 const string&, 리터럴이나 부분 문자열까지 복사 없이 받고 싶다면 C++17의 std::string_view를 씁니다. 단 string_view는 소유권이 없으므로 멤버 변수로 저장하거나 임시 string에서 만든 뷰를 반환하면 댕글링이 됩니다.
  2. SSO(Small String Optimization) 이해하기

    • 주요 구현은 짧은 문자열을 객체 안에 직접 저장해 힙 할당을 생략합니다. 기준 길이는 libstdc++ 15자, MSVC 15자, libc++ 22자(64비트) 정도입니다. 그래서 짧은 키 문자열이 많은 unordered_map<string, ...>은 생각보다 빠르고, 반대로 16자를 넘는 순간 할당 비용이 드러납니다. 벤치마크를 할 때 테스트 문자열 길이를 실제 데이터와 맞춰야 하는 이유입니다.
  3. 소유권을 넘길 때는 이동하기

    • 더 이상 쓰지 않는 string을 컨테이너에 넣거나 멤버에 저장할 때는 std::move로 넘기면 버퍼 포인터만 옮겨지고 복사가 생기지 않습니다. 이동된 원본은 “유효하지만 지정되지 않은” 상태이므로 다시 값을 대입하기 전에는 읽지 않아야 합니다.
  4. 컴파일러 최적화

    • -O2 이상에서는 짧은 문자열 연산이 상당 부분 인라인됩니다. 디버그 빌드(-O0)에서 측정한 문자열 성능은 릴리스와 크게 다를 수 있으므로 판단 근거로 쓰지 않아야 합니다.

같이 보면 좋은 글