C++ 문자열 파싱 완벽 가이드 | stringstream·getline·제로카피·성능 벤치마크

C++ 문자열 파싱 완벽 가이드 | stringstream·getline·제로카피·성능 벤치마크

이 글의 핵심

C++ 문자열 파싱 완벽 가이드에 대해 정리한 개발 블로그 글입니다. 입출력 최적화(#32-1)로 cin/cout 속도를 올렸는데도 시간 초과가 난다면, 원인은 문자열 파싱일 가능성이 큽니다. 백준·프로그래머스에서 "1,2,3,4,5" 같은 CSV 한 줄을 vector<int>로 바꾸거나,… 개념과 예제 코드를 단계적으로 다루며, 실무·학습에 참고할 수 있도록 구성했습니다. 관련 키워드: …

들어가며: “문자열 파싱만 하면 TLE가 나요"

"입력은 빠른데, split 하고 나서 시간 초과예요”

입출력 최적화(#32-1)cin/cout 속도를 올렸는데도 시간 초과가 난다면, 원인은 문자열 파싱일 가능성이 큽니다. 백준·프로그래머스에서 "1,2,3,4,5" 같은 CSV 한 줄을 vector<int>로 바꾸거나, "apple banana cherry"를 공백 기준으로 나누는 작업을 매 입력마다 하다 보면, 잘못된 방식 하나로 전체가 느려집니다.

실제 겪는 문제 시나리오:

  1. 10만 줄 로그 파싱: getline으로 한 줄씩 읽은 뒤 ,로 split → stringstream 매번 생성으로 힙 할당 폭발
  2. JSON 키 추출: "key":"value" 패턴에서 find + substr 반복 → 불필요한 string 복사 누적
  3. 대용량 CSV: 1,2,3,...,1000 같은 긴 줄을 vector<string>으로 쪼개기 → 임시 객체 생성으로 메모리·시간 모두 초과

이 글에서는 문제 원인 분석부터 완전한 파싱 기법, 제로카피 파싱, 자주 하는 실수, 성능 벤치마크, 프로덕션 패턴까지 한 번에 정리합니다.

flowchart TB
  subgraph problem["문제 시나리오"]
    P1[10만 줄 로그]
    P2[JSON 키 추출]
    P3[대용량 CSV]
  end
  subgraph solution["해결 기법"]
    S1[stringstream + getline]
    S2[string_view 제로카피]
    S3[find + substr 최적화]
  end
  P1 --> S1
  P2 --> S2
  P3 --> S3

이 글에서 다루는 것:

  • 문제 시나리오: 왜 문자열 파싱이 병목이 되는지
  • 완전한 파싱 기법: stringstream, getline, strtok, find/substr, 정규식
  • 제로카피 파싱: std::string_view, std::span 활용
  • 자주 하는 실수: 메모리 누수, 반복자 무효화, 인코딩
  • 성능 벤치마크: 기법별 속도·메모리 비교
  • 프로덕션 패턴: 로그 파서, CSV 파서, 재사용 가능한 유틸리티

개념을 잡는 비유

C++에서 자주 쓰는 비유로, 템플릿은 붕어빵 틀, 스마트 포인터는 자동 청소 로봇, RAII는 자동문처럼 스코프를 나가며 자원을 정리한다고 기억해 두면 다른 글과도 연결하기 쉽습니다.


목차

  1. 문제 시나리오: 왜 파싱이 병목인가
  2. 기본 파싱 기법
  3. 고급 파싱 기법
  4. 제로카피 파싱
  5. 자주 하는 실수와 해결법
  6. 성능 벤치마크
  7. 프로덕션 패턴
  8. 문자열 파싱 베스트 프랙티스
  9. 정리 및 체크리스트

1. 문제 시나리오: 왜 파싱이 병목인가

시나리오 1: CSV 한 줄을 정수 벡터로

입력 예

5
1,2,3,4,5
10,20,30,40,50

나쁜 예 (매번 stringstream 생성)

#include <iostream>
#include <sstream>
#include <vector>
#include <string>

int main() {
    int n;
    std::cin >> n;
    std::cin.ignore();

    for (int i = 0; i < n; ++i) {
        std::string line;
        std::getline(std::cin, line);
        std::istringstream iss(line);  // 매 반복마다 새 스트림 생성 → 힙 할당
        std::vector<int> nums;
        int x;
        while (iss >> x) {
            char comma;
            if (iss >> comma) {}  // ',' 건너뛰기
            nums.push_back(x);
        }
    }
}

문제점: istringstream 생성 시 내부 버퍼를 힙에 할당합니다. 10만 줄이면 10만 번 할당·해제가 발생해 캐시 미스할당 오버헤드가 누적됩니다.

시나리오 2: 공백 구분 문자열 split

입력 예

apple banana cherry
dog cat bird

나쁜 예 (substr 남발)

std::vector<std::string> split_bad(const std::string& s) {
    std::vector<std::string> result;
    size_t start = 0;
    while (true) {
        size_t pos = s.find(' ', start);
        if (pos == std::string::npos) {
            result.push_back(s.substr(start));  // 매번 새 string 복사
            break;
        }
        result.push_back(s.substr(start, pos - start));  // 또 복사
        start = pos + 1;
    }
    return result;
}

문제점: substr매번 새 string을 반환합니다. 토큰이 1000개면 1000번의 힙 할당과 복사가 발생합니다.

시나리오 3: JSON 스타일 키 추출

입력 예

{"name":"홍길동","age":30,"city":"서울"}

나쁜 예 (find + substr 반복)

std::string get_value(const std::string& json, const std::string& key) {
    std::string pattern = "\"" + key + "\":";  // 매번 문자열 연결
    size_t pos = json.find(pattern);
    if (pos == std::string::npos) return "";
    pos += pattern.size();
    size_t end = json.find("\"", pos);
    return json.substr(pos, end - pos);  // 불필요한 복사
}

문제점: pattern 생성, substr 반환 모두 **임시 string**을 만듭니다. string_view를 쓰면 복사를 줄일 수 있습니다.

시나리오 4: trim/replace 누락으로 파싱 실패

입력 예: 사용자가 " apple , banana , cherry "처럼 앞뒤 공백을 넣은 경우

  apple , banana , cherry

나쁜 예: trim 없이 split만 하면 " apple ", " banana " 같은 토큰이 생겨 비교·검색이 실패합니다.

// ❌ trim 없이 split
auto tokens = split_by_delim(user_input, ',');
// tokens[0] == "  apple "  → "apple"과 비교 시 실패

해결: split 전에 trim을 적용하거나, 토큰 처리 시 각각 trim합니다.

시나리오 5: 대용량 로그에서 메모리 폭발

상황: 100만 줄 로그를 vector<vector<string>>로 전부 메모리에 올리면, 토큰마다 string 복사로 수 GB 메모리를 사용할 수 있습니다.

해결: string_view로 제로카피 파싱하거나, 한 줄씩 파싱 후 처리하고 버리는 스트리밍 방식을 사용합니다.


2. 기본 파싱 기법

2.1 stringstream + getline (구분자 지정)

용도: ,| 같은 구분자로 split할 때 가장 흔히 쓰는 방법입니다.

#include <sstream>
#include <string>
#include <vector>

// 구분자로 split (getline의 세 번째 인자 활용)
std::vector<std::string> split_by_delim(const std::string& s, char delim) {
    std::vector<std::string> result;
    std::istringstream iss(s);
    std::string token;
    while (std::getline(iss, token, delim)) {
        result.push_back(token);
    }
    return result;
}

// 사용 예
// split_by_delim("a,b,c", ',') → {"a", "b", "c"}

장점: 코드가 짧고, 구분자를 바꾸기 쉽습니다.
단점: istringstream 생성 비용이 있어, 반복 횟수가 많으면 느려질 수 있습니다.

2.2 getline만으로 공백 구분 (cin과 함께)

용도: cin으로 한 줄을 읽고, 그 안의 공백 구분 토큰을 파싱할 때.

#include <iostream>
#include <sstream>
#include <vector>
#include <string>

int main() {
    std::string line;
    std::getline(std::cin, line);
    std::istringstream iss(line);
    std::vector<std::string> tokens;
    std::string token;
    while (iss >> token) {  // 공백·탭으로 자동 분리
        tokens.push_back(token);
    }
}

주의: iss >> token공백·탭·개행을 구분자로 사용합니다. ,는 구분자가 아니므로 getline(iss, token, ',')를 써야 합니다.

2.3 find + substr (수동 파싱)

용도: 구분자가 하나가 아니거나, 위치 기반으로 잘라야 할 때.

#include <string>
#include <vector>

std::vector<std::string> split_find_substr(const std::string& s, char delim) {
    std::vector<std::string> result;
    size_t start = 0;
    while (start < s.size()) {
        size_t pos = s.find(delim, start);
        if (pos == std::string::npos) {
            result.push_back(s.substr(start));
            break;
        }
        result.push_back(s.substr(start, pos - start));
        start = pos + 1;
    }
    return result;
}

장점: 스트림 없이 순수 string 연산만 사용합니다.
단점: substr이 매번 새 string을 만들므로, 토큰이 많으면 할당이 많아집니다.

2.4 strtok (C 스타일, 문자열 수정)

용도: C 스타일 문자열제자리에서 수정해도 될 때. 가장 빠른 편이지만, 원본이 바뀝니다.

#include <cstring>
#include <vector>
#include <string>

std::vector<std::string> split_strtok(std::string s, const char* delim) {
    std::vector<std::string> result;
    char* token = std::strtok(&s[0], delim);
    while (token) {
        result.push_back(token);
        token = std::strtok(nullptr, delim);
    }
    return result;
}

주의:

  • std::strtok원본 문자열에 \0을 삽입합니다. const std::string&을 받으면 수정 불가이므로, 복사본을 넘겨야 합니다.
  • C++17 이상에서는 &s[0]이 연속 메모리를 보장합니다. C++11에서는 s.data()const이므로 &s[0]을 쓰되, s가 비어 있지 않아야 합니다.
  • 스레드 안전하지 않습니다. strtok_r(POSIX)을 쓰면 스레드 안전하게 할 수 있습니다.

2.5 정규식 (std::regex)

용도: 복잡한 패턴(이메일, URL, 숫자만 추출 등)을 다룰 때.

#include <regex>
#include <string>
#include <vector>

std::vector<std::string> split_regex(const std::string& s, const std::string& pattern) {
    std::regex re(pattern);
    std::sregex_token_iterator it(s.begin(), s.end(), re, -1);
    std::sregex_token_iterator end;
    std::vector<std::string> result;
    while (it != end) {
        result.push_back(*it);
        ++it;
    }
    return result;
}

// 사용 예: 공백 하나 이상으로 split
// split_regex("a  b   c", "\\s+") → {"a", "b", "c"}

장점: 복잡한 패턴에 강합니다.
단점: 매우 느립니다. 단순 split에는 비추천입니다.

2.6 trim (앞뒤 공백 제거)

용도: 사용자 입력, 로그 파싱 시 앞뒤 공백·탭·개행을 제거할 때 필수입니다.

#include <string>
#include <algorithm>
#include <cctype>

std::string trim(std::string s) {
    s.erase(s.begin(), std::find_if(s.begin(), s.end(),
         { return !std::isspace(c); }));
    s.erase(std::find_if(s.rbegin(), s.rend(),
         { return !std::isspace(c); }).base(), s.end());
    return s;
}

// string_view 버전 (제로카피, C++17)
std::string_view trim_sv(std::string_view s) {
    auto start = s.find_first_not_of(" \t\n\r");
    if (start == std::string_view::npos) return "";
    auto end = s.find_last_not_of(" \t\n\r");
    return s.substr(start, end - start + 1);
}

2.7 replace (문자열 치환)

용도: 특정 패턴을 다른 문자열로 교체할 때.

#include <string>

std::string replace_all(const std::string& s, const std::string& from,
                        const std::string& to) {
    std::string result = s;
    for (size_t pos = 0; (pos = result.find(from, pos)) != std::string::npos; ) {
        result.replace(pos, from.size(), to);
        pos += to.size();
    }
    return result;
}
// replace_all("a-b-c", "-", "_") → "a_b_c"

2.8 정규식 활용 (패턴 매칭·검증·추출)

용도: 이메일, URL, 숫자 추출 등 복잡한 패턴이 필요할 때.

#include <regex>
#include <string>
#include <vector>

// 이메일 형식 검증
bool is_valid_email(const std::string& email) {
    std::regex pattern(R"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})");
    return std::regex_match(email, pattern);
}

// 숫자만 추출
std::vector<int> extract_numbers(const std::string& s) {
    std::vector<int> result;
    std::regex num_re(R"(\d+)");
    for (std::sregex_iterator it(s.begin(), s.end(), num_re), end; it != end; ++it)
        result.push_back(std::stoi(it->str()));
    return result;
}

3. 고급 파싱 기법

3.1 스트림 재사용 (할당 최소화)

코테에서 10만 줄을 파싱할 때, istringstream한 번만 생성하고 str()으로 내용만 바꿔 재사용합니다.

#include <sstream>
#include <string>
#include <vector>

std::vector<int> parse_csv_line_reuse(std::istringstream& iss, const std::string& line) {
    iss.clear();
    iss.str(line);
    std::vector<int> result;
    int x;
    char comma;
    while (iss >> x) {
        result.push_back(x);
        if (!(iss >> comma)) break;
    }
    return result;
}

int main() {
    std::istringstream iss;  // 한 번만 생성
    std::string line;
    while (std::getline(std::cin, line)) {
        auto nums = parse_csv_line_reuse(iss, line);
        // ...
    }
}

핵심: iss.clear()로 에러 플래그를 초기화하고, iss.str(line)으로 새 내용을 넣습니다. 스트림 객체는 재사용되므로 힙 할당이 줄어듭니다.

3.2 reserve로 vector 재할당 감소

split 결과를 vector에 넣을 때, 대략적인 크기를 미리 예상하면 push_back 시 재할당을 줄일 수 있습니다.

std::vector<std::string> split_reserved(const std::string& s, char delim) {
    std::vector<std::string> result;
    size_t count = 0;
    for (char c : s) if (c == delim) ++count;
    result.reserve(count + 1);  // 토큰 개수만큼 미리 예약

    std::istringstream iss(s);
    std::string token;
    while (std::getline(iss, token, delim)) {
        result.push_back(token);
    }
    return result;
}

3.3 파싱과 변환 동시에 (숫자 파싱)

stringstream으로 split과 int/double 변환을 한 번에 처리합니다.

#include <sstream>
#include <vector>
#include <string>

std::vector<int> parse_ints(const std::string& s, char delim) {
    std::vector<int> result;
    std::istringstream iss(s);
    std::string token;
    while (std::getline(iss, token, delim)) {
        result.push_back(std::stoi(token));
    }
    return result;
}

// stol, stod, stof 등도 동일하게 사용 가능

주의: std::stoi예외를 던질 수 있습니다. 코테에서 입력이 항상 유효하다고 가정할 수 있으면 괜찮지만, 실무에서는 try-catchstd::from_chars(C++17)를 고려합니다.

3.4 완전한 CSV 파싱 (따옴표 필드 지원)

실제 CSV는 "Hello, World"처럼 쉼표가 포함된 필드를 따옴표로 감쌀 수 있습니다.

#include <string>
#include <vector>

std::vector<std::string> parse_csv_quoted(const std::string& line, char delim = ',') {
    std::vector<std::string> result;
    std::string field;
    bool in_quotes = false;

    for (size_t i = 0; i < line.size(); ++i) {
        char c = line[i];
        if (c == '"') {
            if (in_quotes && i + 1 < line.size() && line[i + 1] == '"') {
                field += '"'; ++i;  // "" → "
            } else in_quotes = !in_quotes;
        } else if (!in_quotes && c == delim) {
            result.push_back(std::move(field));
            field.clear();
        } else {
            field += c;
        }
    }
    result.push_back(std::move(field));
    return result;
}
// "Alice","Hello, World",42 → {"Alice", "Hello, World", "42"}

3.5 간단한 JSON 파싱 (키-값 추출)

완전한 JSON 파서는 nlohmann/json 등 라이브러리를 쓰는 것이 좋습니다. 단순 flat JSON은 수동 파싱도 가능합니다.

#include <string_view>
#include <optional>
#include <map>

std::optional<std::map<std::string, std::string>> parse_simple_json(std::string_view json) {
    std::map<std::string, std::string> result;
    size_t i = 0;
    while (i < json.size() && (json[i] == ' ' || json[i] == '{')) ++i;
    if (i >= json.size()) return std::nullopt;

    while (i < json.size() && json[i] != '}') {
        size_t key_start = json.find('"', i);
        if (key_start == std::string_view::npos) break;
        size_t key_end = json.find('"', key_start + 1);
        if (key_end == std::string_view::npos) break;
        std::string key(json.substr(key_start + 1, key_end - key_start - 1));

        size_t val_start = json.find('"', json.find(':', key_end));
        if (val_start == std::string_view::npos) break;
        size_t val_end = json.find('"', val_start + 1);
        if (val_end == std::string_view::npos) break;
        result[std::move(key)] = std::string(json.substr(val_start + 1, val_end - val_start - 1));
        i = val_end + 1;
        while (i < json.size() && (json[i] == ' ' || json[i] == ',')) ++i;
    }
    return result;
}

4. 제로카피 파싱

4.1 std::string_view (C++17)

string_view문자열을 복사하지 않고 “보기만” 합니다. substr 대신 substrstring_view 버전을 쓰면 할당 없이 구간을 나타낼 수 있습니다.

#include <string_view>
#include <vector>

std::vector<std::string_view> split_string_view(std::string_view s, char delim) {
    std::vector<std::string_view> result;
    size_t start = 0;
    while (start < s.size()) {
        size_t pos = s.find(delim, start);
        if (pos == std::string_view::npos) {
            result.push_back(s.substr(start));
            break;
        }
        result.push_back(s.substr(start, pos - start));
        start = pos + 1;
    }
    return result;
}

// 사용 예
// std::string line = "a,b,c";
// auto tokens = split_string_view(line, ',');  // 복사 없음

주의:

  • string_view가 가리키는 원본 문자열이 파괴되면, string_view댕글링됩니다. 반환된 vector<string_view>원본보다 오래 보관하지 않도록 합니다.
  • string이 필요할 때만 std::string(tokens[i])로 변환합니다.

4.2 string_view로 키-값 추출 (제로카피)

std::optional<std::string_view> get_value_sv(std::string_view json, std::string_view key) {
    std::string pattern = "\"" + std::string(key) + "\":\"";
    size_t pos = json.find(pattern);
    if (pos == std::string_view::npos) return std::nullopt;
    pos += pattern.size();
    size_t end = json.find('"', pos);
    if (end == std::string_view::npos) return std::nullopt;
    return json.substr(pos, end - pos);
}

값이 필요할 때만 std::string(tokens[i])로 변환합니다.

4.3 범위 기반 루프로 토큰 순회 (람다 활용)

토큰을 저장하지 않고 순회만 할 때는 람다로 콜백을 넘기는 방식이 메모리를 아낍니다.

#include <string_view>

template<typename Func>
void for_each_token(std::string_view s, char delim, Func&& f) {
    size_t start = 0;
    while (start < s.size()) {
        size_t pos = s.find(delim, start);
        std::string_view token;
        if (pos == std::string_view::npos) {
            token = s.substr(start);
            start = s.size();
        } else {
            token = s.substr(start, pos - start);
            start = pos + 1;
        }
        if (!token.empty()) {
            f(token);
        }
    }
}

// 사용 예
// for_each_token("a,b,c", ',',  {
//     std::cout << tok << "\n";
// });

4.4 std::span (C++20)과 연계

string_view읽기 전용입니다. char 배열을 수정 가능한 범위로 다룰 때는 std::span<char>를 사용합니다. 로그 파서에서 버퍼를 직접 다룰 때 유용합니다.

#include <span>
#include <cstring>

void parse_in_place(std::span<char> buffer, char delim,
                    void (*on_token)(std::span<const char>)) {
    char* start = buffer.data();
    char* end = buffer.data() + buffer.size();
    char* p = start;
    while (p != end) {
        if (*p == delim || *p == '\0') {
            *p = '\0';
            if (p > start) on_token({start, static_cast<size_t>(p - start)});
            start = p + 1;
        }
        ++p;
    }
    if (p > start) on_token({start, static_cast<size_t>(p - start)});
}

5. 자주 하는 실수와 해결법

5.1 strtok에 const string 전달

문제: strtok은 첫 인자로 수정 가능한 문자열을 받습니다.

// ❌ 잘못된 예
void bad(const std::string& s) {
    char* token = std::strtok(const_cast<char*>(s.c_str()), ",");  // 미정의 동작!
}

해결: 복사본을 넘깁니다.

// ✅ 올바른 예
std::vector<std::string> split_ok(const std::string& s) {
    std::string copy = s;
    std::vector<std::string> result;
    char* token = std::strtok(&copy[0], ",");
    while (token) {
        result.emplace_back(token);
        token = std::strtok(nullptr, ",");
    }
    return result;
}

5.2 string_view 댕글링

문제: string_view가 가리키는 원본이 먼저 파괴되면, string_view 사용은 미정의 동작입니다.

// ❌ 잘못된 예
std::string_view get_first_token() {
    std::string line = read_line();  // 지역 변수
    return std::string_view(line).substr(0, line.find(','));  // line 파괴 후 댕글링
}

해결: string_view원본과 같은 수명으로 쓰거나, string으로 복사해 반환합니다.

// ✅ 올바른 예
std::string get_first_token(const std::string& line) {
    size_t pos = line.find(',');
    return (pos == std::string::npos) ? line : line.substr(0, pos);
}

5.3 getline 후 cin >> 혼용 시 버퍼 잔여

문제: cin >> n 후 개행이 버퍼에 남아 있고, 바로 getline을 쓰면 빈 줄을 읽습니다.

// ❌ 잘못된 예
int n;
std::cin >> n;
std::string line;
std::getline(std::cin, line);  // 개행만 읽고 line은 ""

해결: cin.ignore()로 개행을 제거합니다.

// ✅ 올바른 예
int n;
std::cin >> n;
std::cin.ignore();  // 또는 std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n');
std::string line;
std::getline(std::cin, line);

5.4 stoi 예외 처리 누락

문제: std::stoi는 변환 실패 시 std::invalid_argument 또는 std::out_of_range를 던집니다.

// ❌ 위험한 예
int x = std::stoi("abc");  // 예외 발생

해결: try-catch 또는 std::from_chars(C++17) 사용.

// ✅ 안전한 예 (C++17)
#include <charconv>

std::optional<int> safe_stoi(std::string_view s) {
    int value;
    auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
    if (ec != std::errc{} || ptr != s.data() + s.size()) {
        return std::nullopt;
    }
    return value;
}

5.5 반복문 내 매번 istringstream 생성

문제: 루프 안에서 매번 istringstream을 만들면 할당이 반복됩니다.

// ❌ 비효율
for (const auto& line : lines) {
    std::istringstream iss(line);  // 매번 새로 생성
    // ...
}

해결: 스트림을 루프 밖에서 한 번만 생성하고 str()으로 재사용합니다. (위 3.1 참고)

5.6 빈 토큰 처리

문제: "a,,b",로 split할 때 getline은 빈 토큰도 반환합니다. "a,"["a", ""]가 됩니다.

// 빈 토큰 제외가 필요할 때
while (std::getline(iss, token, delim)) {
    if (!token.empty()) {
        result.push_back(token);
    }
}

5.7 정규식 컴파일 비용

문제: 루프 안에서 std::regex를 매번 생성하면 매우 느립니다. 해결: std::regex를 루프 밖에서 한 번만 생성하고 재사용합니다.

5.8 substr 인덱스 오류

문제: findnpos를 반환할 때 substr에 잘못된 범위를 넘기면 미정의 동작 또는 예외가 발생합니다.

// ❌ 위험: pos가 npos일 때
size_t pos = s.find(',');
std::string token = s.substr(0, pos);  // pos가 npos면 매우 큰 값 전달

해결: npos 체크 후 처리합니다.

// ✅ 안전
size_t pos = s.find(',');
std::string token = (pos == std::string::npos) ? s : s.substr(0, pos);

5.9 인코딩 혼동 (UTF-8 vs ASCII)

문제: UTF-8 한글은 여러 바이트로 구성됩니다. substr(i, 1)로 “한 글자”를 자르면 깨질 수 있습니다. 해결: UTF-8 코드 포인트 단위 처리가 필요하면 std::mbrtoc32 또는 utf8-cpp를 사용합니다. 단순 구분자 split은 바이트 단위로 해도 됩니다.

5.10 반복자 무효화 (문자열 수정 중 순회)

문제: for 루프에서 erasestring을 수정하면 인덱스/반복자가 무효화됩니다. 해결: erase-remove 관용구 사용.

s.erase(std::remove(s.begin(), s.end(), ' '), s.end());

6. 성능 벤치마크

6.1 측정 환경 가정

  • 입력: "1,2,3,...,1000" 형태의 CSV 한 줄 (토큰 1000개)
  • 반복: 10,000줄 파싱
  • 컴파일: g++ -O2 -std=c++17

6.2 기법별 상대 속도 (예상)

기법상대 시간메모리 할당비고
strtok (복사본 1회)1.0x (기준)낮음원본 수정 가능해야 함
find + string_view1.2x거의 없음제로카피
find + substr (string)2.5x높음토큰마다 string 생성
stringstream 재사용2.0x중간스트림 1개만 사용
stringstream 매번 생성4.0x높음비추천
std::regex15.0x 이상높음단순 split에 부적합

6.3 벤치마크 예시 코드

#include <chrono>
#include <sstream>
#include <string>
#include <vector>

// stringstream 매번 생성 vs 재사용 비교
void benchmark() {
    std::vector<std::string> lines(10000);
    for (int i = 0; i < 10000; ++i) {
        std::ostringstream oss;
        for (int j = 0; j < 1000; ++j) {
            if (j) oss << ',';
            oss << j;
        }
        lines[i] = oss.str();
    }

    auto t1 = std::chrono::high_resolution_clock::now();
    for (const auto& line : lines) {
        std::istringstream iss(line);
        std::vector<int> nums;
        int x; char c;
        while (iss >> x) { nums.push_back(x); if (!(iss >> c)) break; }
    }
    auto t2 = std::chrono::high_resolution_clock::now();

    std::istringstream iss;
    std::vector<int> nums;
    auto t3 = std::chrono::high_resolution_clock::now();
    for (const auto& line : lines) {
        iss.clear(); iss.str(line); nums.clear();
        int x; char c;
        while (iss >> x) { nums.push_back(x); if (!(iss >> c)) break; }
    }
    auto t4 = std::chrono::high_resolution_clock::now();

}

예상: 재사용 방식이 약 2배 빠름.


7. 프로덕션 패턴

7.1 재사용 가능한 CSV 파서 클래스

#include <sstream>
#include <string>
#include <vector>

class CsvParser {
public:
    explicit CsvParser(char delim = ',') : delim_(delim) {}

    std::vector<std::string> split(const std::string& line) {
        tokens_.clear();
        iss_.clear();
        iss_.str(line);
        std::string token;
        while (std::getline(iss_, token, delim_)) {
            tokens_.push_back(token);
        }
        return tokens_;
    }

    std::vector<int> parse_as_int(const std::string& line) {
        split(line);
        std::vector<int> result;
        result.reserve(tokens_.size());
        for (const auto& t : tokens_) {
            result.push_back(std::stoi(t));
        }
        return result;
    }

    std::vector<double> parse_as_double(const std::string& line) {
        split(line);
        std::vector<double> result;
        result.reserve(tokens_.size());
        for (const auto& t : tokens_) {
            result.push_back(std::stod(t));
        }
        return result;
    }

private:
    char delim_;
    std::istringstream iss_;
    std::vector<std::string> tokens_;
};

7.2 로그 라인 파서 (타임스탬프, 레벨, 메시지)

#include <string>
#include <string_view>
#include <optional>

struct LogEntry {
    std::string_view timestamp;
    std::string_view level;
    std::string_view message;
};

std::optional<LogEntry> parse_log_line(std::string_view line) {
    // 형식: "2026-03-10 12:00:00 [INFO] message here"
    size_t t_end = line.find(' ');
    if (t_end == std::string_view::npos) return std::nullopt;
    std::string_view ts = line.substr(0, t_end);

    size_t level_start = line.find('[', t_end);
    if (level_start == std::string_view::npos) return std::nullopt;
    size_t level_end = line.find(']', level_start);
    if (level_end == std::string_view::npos) return std::nullopt;
    std::string_view level = line.substr(level_start + 1, level_end - level_start - 1);

    size_t msg_start = line.find(' ', level_end);
    std::string_view msg = (msg_start == std::string_view::npos)
        ? std::string_view{}
        : line.substr(msg_start + 1);

    return LogEntry{ts, level, msg};
}

7.3 대용량 입력용 스트리밍 파서

한 줄씩 읽어 콜백으로 처리. 전체를 메모리에 올리지 않아 대용량 로그에 적합합니다.

template<typename Func>
void parse_streaming(std::istream& in, Func&& on_line) {
    std::string line;
    line.reserve(4096);
    while (std::getline(in, line)) on_line(line);
}

7.4 에러 처리와 로깅이 있는 파서

#include <optional>
#include <string>
#include <sstream>
#include <vector>

struct ParseResult {
    std::vector<int> values;
    bool ok;
    std::string error;
};

ParseResult parse_csv_safe(const std::string& line, char delim = ',') {
    ParseResult result{};
    std::istringstream iss(line);
    std::string token;
    while (std::getline(iss, token, delim)) {
        try {
            result.values.push_back(std::stoi(token));
        } catch (const std::exception& e) {
            result.ok = false;
            result.error = "Invalid number: " + token;
            return result;
        }
    }
    result.ok = true;
    return result;
}

7.5 프로덕션용 trim + split 파이프라인

std::vector<std::string> parse_line_production(const std::string& line, char delim = ',') {
    auto start = line.find_first_not_of(" \t\n\r");
    if (start == std::string::npos) return {};
    auto end = line.find_last_not_of(" \t\n\r");
    std::string_view trimmed(line.data() + start, end - start + 1);
    std::vector<std::string> result;
    for (size_t pos = 0; pos <= trimmed.size(); ) {
        size_t next = trimmed.find(delim, pos);
        if (next == std::string_view::npos) {
            result.push_back(std::string(trimmed.substr(pos)));
            break;
        }
        result.push_back(std::string(trimmed.substr(pos, next - pos)));
        pos = next + 1;
    }
    return result;
}

8. 문자열 파싱 베스트 프랙티스

8.1 상황별 기법 선택

상황권장 기법이유
코테 단순 splitgetline + stringstream 재사용구현 간단, 충분히 빠름
대량 로그/스트리밍string_view + find제로카피, 메모리 절약
원본 수정 가능strtok최고 속도
복잡한 패턴std::regex (루프 밖 생성)유연성
CSV 따옴표 필드전용 CSV 파서RFC 4180 준수
JSONnlohmann/json 등 라이브러리정확성·유지보수

8.2 성능·안전성 체크리스트

  • 스트림 재사용: istringstream 루프 밖에서 한 번만 생성
  • reserve: vector::reserve(예상_크기)로 재할당 감소
  • string_view: 읽기만 할 때 복사 대신 뷰 사용
  • npos 체크: find 결과 확인 후 substr
  • from_chars: 외부 입력은 stoi 대신 from_chars 또는 try-catch

9. 정리 및 체크리스트

요약 표

상황권장 기법비고
코테 CSV/공백 splitgetline + stringstream 재사용스트림 한 번만 생성
대량 로그 파싱string_view + find/substr제로카피
C 스타일, 원본 수정 가능strtok가장 빠름
복잡한 패턴std::regex단순 split에는 비추천
프로덕션 CSV전용 CsvParser 클래스에러 처리·재사용

구현 체크리스트

  • getline 전에 cin.ignore() 필요 여부 확인
  • istringstream은 루프 밖에서 생성 후 재사용
  • vector::reserve로 토큰 개수 예상 시 재할당 감소
  • string_view 사용 시 원본 수명 주의 (댕글링 방지)
  • strtok 사용 시 복사본 전달, 원본 수정 가능 여부 확인
  • stoi/stod 실패 가능성 있으면 from_chars 또는 try-catch
  • 빈 토큰("a,,b") 처리 방식 결정

한 줄 요약

stringstream 재사용string_view 제로카피로 문자열 파싱 시 할당과 복사를 줄이면, 대량 입력에서 TLE와 메모리 초과를 피할 수 있습니다.


자주 묻는 질문 (FAQ)

Q. 코테에서 가장 빠른 split 방법은?

A. 원본을 수정해도 된다면 strtok이 가장 빠릅니다. 수정이 안 되면 stringstream한 번만 생성하고 str()으로 재사용하는 방식을 권장합니다.

Q. string_view를 반환해도 되나요?

A. string_view가 가리키는 원본 문자열이 호출자에서 더 오래 유지되는 경우에만 반환합니다. 함수 내 지역 string을 가리키는 string_view를 반환하면 댕글링이 발생합니다.

Q. 정규식은 언제 쓰나요?

A. 이메일, URL, 복잡한 포맷 검증 등 패턴이 복잡할 때 사용합니다. 단순 구분자 split에는 getline이나 find가 훨씬 빠릅니다.

Q. UTF-8 한글 파싱은?

A. std::string은 바이트 시퀀스이므로, UTF-8의 경우 코드 포인트 단위로 자르려면 std::mbrtoc32 또는 ICU 같은 라이브러리를 사용합니다. 단순 바이트 구분자(,, 공백) split은 기존 방법 그대로 사용 가능합니다.


한 줄 요약: stringstream 재사용·string_view 제로카피로 문자열 파싱을 빠르게 할 수 있습니다. 다음으로 STL 치트시트(#32-3)를 읽어보면 좋습니다.

다음 글: [C++ 코테 압축 #32-3] 코테용 STL 컨테이너/알고리즘 시간복잡도 치트시트

이전 글: [C++ 코테 압축 #32-1] 백준/프로그래머스 C++ 세팅과 입출력 최적화 완벽 정리


같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ 문자열 알고리즘 완벽 가이드 | split·join·trim·replace·정규식 [실전]
  • C++ std::string_view·std::span 완벽 가이드 | 제로카피 뷰·댕글링 방지
  • C++ 문자열 기초 완벽 가이드 | std::string·C 문자열·string_view와 실전 패턴

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

C++, 문자열파싱, stringstream, getline, string_view, 제로카피, 코딩테스트, 성능최적화 등으로 검색하시면 이 글이 도움이 됩니다.


관련 글

  • C++ 백준/프로그래머스 C++ 세팅과 입출력 최적화 한 번에 정리 [#32-1]
  • C++ 코테용 STL 컨테이너/알고리즘 시간복잡도 치트시트 [#32-3]
  • C++ 메모리 풀 완벽 가이드 | 객체 풀·슬랩·아레나·std::pmr 실전 [#32-2]
  • C++ 채팅 서버 만들기 | 다중 클라이언트와 메시지 브로드캐스트 완벽 가이드 [#31-1]
  • C++ REST API 서버 완벽 가이드 | Beast 라우팅·JSON·미들웨어 [#31-2]