C++ 문자열 파싱이 병목일 때: stringstream·getline 대신 string_view 제로카피, 벤치마크

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

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

흔히 부딪히는 상황은 이렇습니다. 10만 줄 로그를 getline으로 읽고 줄마다 stringstream을 새로 만들어 split하면 할당이 줄 수만큼 반복됩니다. "key":"value" 패턴에서 find와 substr을 반복하면 필요 없는 string 복사가 쌓입니다. 토큰이 1000개인 긴 CSV 줄을 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·정규식 같은 기본 기법, string_view를 이용한 제로카피 파싱, 자주 하는 실수, 기법별 성능 차이를 재는 방법, 실무에서 재사용할 수 있는 파서 순서로 정리합니다.


CSV 한 줄, split, 대용량 로그: 파싱이 병목이 되는 경우

시나리오 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만 번 반복되고, 줄마다 새로 만드는 nums 벡터의 할당도 함께 쌓입니다.

시나리오 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을 만들어 돌려줍니다. 짧은 토큰은 SSO(작은 문자열 최적화) 덕분에 힙 할당이 없을 수도 있지만, 복사 자체는 여전히 토큰마다 일어납니다. 토큰을 잠깐 보고 버릴 거라면 이 복사는 전부 낭비입니다.

시나리오 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이 생깁니다. 게다가 이 코드는 값이 따옴표로 시작한다고 가정하므로 "age":30 같은 숫자 값에서는 엉뚱한 결과를 냅니다. 뒤에서 string_view로 복사를 줄이는 방법을 보지만, 실제 JSON이라면 라이브러리를 쓰는 편이 맞습니다.

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

사용자가 " 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 객체와 복사본이 생겨, 원본 파일 크기의 몇 배를 메모리에 쓰게 됩니다. string_view로 원본을 가리키기만 하거나, 한 줄씩 파싱해서 처리하고 버리는 스트리밍 방식을 쓰면 메모리 사용량이 원본 크기나 한 줄 크기 수준으로 줄어듭니다.


기본 파싱 기법

stringstream + getline (구분자 지정)

,나 | 같은 구분자로 나눌 때 가장 흔히 쓰는 방법입니다.

#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"}

코드가 짧고 구분자를 바꾸기 쉽습니다. 대신 호출할 때마다 스트림을 만드는 비용이 있어서 반복 횟수가 많으면 느려질 수 있습니다.

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, ',')를 써야 합니다.

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을 만들기 때문에 토큰이 많으면 할당과 복사가 늘어납니다. 참고로 이 구현은 "a,"처럼 구분자로 끝나는 입력에서 마지막 빈 토큰을 만들지 않습니다. getline 방식과 결과가 다를 수 있으니 빈 토큰을 어떻게 다룰지 먼저 정해 두어야 합니다.

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;
    if (s.empty()) return result;
    char* token = std::strtok(&s[0], delim);
    while (token) {
        result.push_back(token);
        token = std::strtok(nullptr, delim);
    }
    return result;
}

strtok은 구분자 자리에 '\0'을 써 넣어 원본을 수정합니다. 그래서 const std::string&을 그대로 넘길 수 없고, 이 예제처럼 값으로 받은 복사본을 넘겨야 합니다. std::string의 메모리가 연속이라는 보장은 C++11부터 있으므로 &s[0]을 쓸 수 있고, C++17부터는 s.data()도 char*를 돌려줍니다.

strtok은 다음 호출을 위한 위치를 함수 내부의 정적 상태에 저장하므로 스레드 안전하지 않고, 두 문자열을 번갈아 파싱할 수도 없습니다. POSIX의 strtok_r(Windows는 strtok_s)을 쓰면 상태를 호출자가 관리하게 됩니다. 연속된 구분자를 하나로 취급해서 빈 토큰을 만들지 않는다는 점도 getline과 다릅니다.

정규식 (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"}

복잡한 패턴을 짧게 표현할 수 있지만, 표준 라이브러리의 std::regex 구현은 대부분 느린 편이라 단순 split에는 맞지 않습니다. 성능이 중요한 곳에서 정규식이 꼭 필요하다면 RE2나 CTRE 같은 라이브러리를 검토합니다.

trim (앞뒤 공백 제거)

사용자 입력이나 로그를 파싱할 때 앞뒤의 공백, 탭, 개행을 제거합니다.

#include <string>
#include <string_view>
#include <algorithm>
#include <cctype>
std::string trim(std::string s) {
    s.erase(s.begin(), std::find_if(s.begin(), s.end(),
        [](unsigned char c) { return !std::isspace(c); }));
    s.erase(std::find_if(s.rbegin(), s.rend(),
        [](unsigned char c) { 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);
}

std::isspace에 char를 그대로 넘기면 한글처럼 최상위 비트가 켜진 바이트가 음수가 되어 정의되지 않은 동작이 됩니다. 람다가 unsigned char로 받는 이유가 이것입니다.

replace (문자열 치환)

#include <string>
std::string replace_all(const std::string& s, const std::string& from,
                        const std::string& to) {
    std::string result = s;
    if (from.empty()) return result;  // 빈 패턴이면 무한 루프
    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"

치환 후 pos를 to.size()만큼 옮기는 이유는, to 안에 from이 들어 있을 때(예: "a"를 "aa"로) 방금 넣은 문자열을 다시 찾는 무한 루프를 막기 위해서입니다.

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

#include <regex>
#include <string>
#include <vector>
// 이메일 형식 검증
bool is_valid_email(const std::string& email) {
    static const 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;
    static const 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;
}

정규식 객체를 static const로 두면 함수를 여러 번 호출해도 패턴 컴파일은 한 번만 일어납니다. std::stoi는 숫자가 int 범위를 넘으면 std::out_of_range를 던지므로, 긴 숫자가 섞일 수 있는 입력이라면 예외 처리가 필요합니다.


고급 파싱 기법

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

코딩 테스트에서 10만 줄을 파싱할 때는 istringstream을 한 번만 만들고 str()로 내용만 바꿔 가며 재사용합니다.

#include <iostream>
#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()로 이전 줄을 읽다가 켜진 EOF·실패 플래그를 지우고, iss.str(line)으로 새 내용을 넣습니다. clear()를 빠뜨리면 첫 줄 이후로는 아무것도 읽히지 않는 버그가 됩니다. 스트림 객체의 생성과 소멸은 사라지지만, str(line)이 문자열을 내부 버퍼로 복사하는 비용은 남습니다.

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;
}

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

#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는 변환할 수 없는 입력에서 예외를 던지고, "12abc"처럼 앞부분만 숫자인 입력은 조용히 12로 받아들입니다. 코딩 테스트처럼 입력이 항상 유효하다고 가정할 수 있으면 괜찮지만, 실무에서는 std::from_chars로 끝까지 소비됐는지 확인하는 편이 안전합니다. 정수용 from_chars는 C++17부터, 부동소수점용은 GCC 11·MSVC 2019 16.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"}

이 파서는 한 줄 단위로 동작하므로, RFC 4180이 허용하는 “따옴표 안의 줄바꿈”은 처리하지 못합니다. 엑셀에서 내보낸 CSV에는 셀 안 줄바꿈이 종종 들어 있어서, getline으로 줄을 먼저 나누는 순간 레코드가 깨집니다. 이런 파일을 다뤄야 한다면 따옴표가 닫힐 때까지 다음 줄을 이어 붙이도록 확장하거나 검증된 CSV 라이브러리를 씁니다.

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

완전한 JSON 파서는 nlohmann/json 같은 라이브러리를 쓰는 것이 좋습니다. 값이 모두 문자열인 평평한 JSON이라면 수동으로도 파싱할 수 있습니다.

#include <string_view>
#include <optional>
#include <map>
#include <string>
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;
}

이 코드는 이스케이프된 따옴표(\"), 숫자·불리언·null 값, 중첩 객체를 처리하지 못합니다. 입력 형식을 직접 통제할 수 있는 테스트 데이터 정도에만 쓰고, 외부에서 오는 JSON에는 라이브러리를 씁니다.


제로카피 파싱

std::string_view (C++17)

string_view는 포인터와 길이만 가진 객체로, 문자열을 복사하지 않고 원본의 일부를 가리킵니다. string_view::substr은 새 view를 돌려줄 뿐 할당을 하지 않으므로, split 결과를 string_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, ',');  // 복사 없음

view가 가리키는 원본 문자열이 파괴되면 view는 댕글링 상태가 됩니다. 반환된 vector<string_view>를 원본보다 오래 보관하면 안 되고, 원본인 line을 다음 줄 읽기로 덮어써도 이전 토큰들이 전부 무효가 됩니다. 값을 오래 가지고 있어야 할 때만 std::string(tokens[i])로 복사합니다.

string_view로 키-값 추출

#include <optional>
#include <string>
#include <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);
}

반환값은 원본 JSON을 가리키는 view라서 복사가 없습니다. 검색 패턴을 만드는 pattern에는 여전히 할당이 있는데, 같은 키를 반복해서 찾는다면 패턴을 미리 만들어 두고 재사용하면 됩니다.

토큰을 저장하지 않고 순회 (콜백)

토큰을 모아 둘 필요 없이 순서대로 처리만 한다면, 콜백을 넘기는 방식이 벡터 할당까지 없애 줍니다.

#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::string_view tok) {
//     std::cout << tok << "\n";
// });

토큰이 숫자라면 콜백 안에서 바로 std::from_chars로 변환해 누적하면, 문자열 복사도 벡터도 없이 한 번 훑는 것으로 파싱이 끝납니다. 대량 숫자 입력에서 가장 빠른 조합입니다.

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

string_view는 읽기 전용입니다. 버퍼를 제자리에서 수정하며 파싱하려면 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)});
}

strtok에 const 전달, string_view 댕글링, stoi 예외 누락 같은 실수

strtok에 const string 전달

strtok은 첫 인자로 수정 가능한 문자열을 받습니다. c_str()의 결과를 const_cast로 넘기면 const 객체를 수정하는 것이라 정의되지 않은 동작입니다.

// ❌ 잘못된 예
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;
    if (copy.empty()) return result;
    char* token = std::strtok(&copy[0], ",");
    while (token) {
        result.emplace_back(token);
        token = std::strtok(nullptr, ",");
    }
    return result;
}

string_view 댕글링

view가 가리키는 원본이 먼저 사라지면 그 view를 쓰는 것은 정의되지 않은 동작입니다. 컴파일러가 경고해 주지 않는 경우가 많고, 테스트에서는 우연히 동작하다가 운영에서만 이상한 값이 나오기도 해서 찾기 어렵습니다.

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

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);
}

임시 std::string에서 view를 만드는 경우도 조심해야 합니다. std::string_view sv = get_name();처럼 함수가 std::string을 값으로 반환하면, 그 임시 객체는 문장이 끝나는 순간 사라지고 sv는 곧바로 댕글링이 됩니다.

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::numeric_limits<std::streamsize>::max(), '\n');  // 줄 끝까지 버림
std::string line;
std::getline(std::cin, line);

인자 없는 cin.ignore()는 한 글자만 버리므로, 숫자 뒤에 공백이 있거나 Windows 형식 개행(\r\n)이면 부족합니다. 위처럼 줄 끝까지 버리는 형태가 안전합니다.

stoi 예외 처리 누락

std::stoi는 변환에 실패하면 std::invalid_argument, 범위를 넘으면 std::out_of_range를 던집니다.

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

try-catch로 감싸거나 C++17의 std::from_chars를 씁니다. from_chars는 예외를 던지지 않고 할당도 하지 않으며 로케일의 영향도 받지 않습니다.

// ✅ 안전한 예 (C++17)
#include <charconv>
#include <optional>
#include <string_view>
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;
}

ptr이 끝까지 왔는지 확인해야 "12abc" 같은 입력을 거부할 수 있습니다. from_chars는 앞의 공백이나 + 부호를 허용하지 않으므로, 필요하면 trim을 먼저 합니다.

반복문 내 매번 istringstream 생성

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

스트림을 루프 밖에서 한 번만 만들고 clear()와 str()로 재사용합니다. 앞의 “스트림 재사용” 절을 참고하세요.

빈 토큰 처리

getline으로 "a,,b"를 ,로 나누면 가운데 빈 토큰도 나옵니다. 반면 "a,"는 ["a"]만 나오고 끝의 빈 토큰은 생기지 않습니다. 빈 필드가 의미 있는 데이터(CSV의 빈 칸 등)라면 이 차이가 버그가 되므로, 어떤 결과를 원하는지 정하고 테스트로 고정해 둡니다.

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

정규식 컴파일 비용

std::regex 객체를 만들 때 패턴을 내부 자료구조로 컴파일하는데, 이 비용이 매칭보다 큰 경우가 많습니다. 루프 안에서 매번 만들면 매우 느려지므로 루프 밖이나 static으로 한 번만 만듭니다.

substr 인덱스 오류

find가 npos를 반환했을 때 길이 인자로 넘기는 것은 “끝까지”라는 뜻이라 안전하지만, 시작 위치로 넘기면 std::out_of_range 예외가 납니다. 또 pos + 1처럼 npos에 값을 더하면 0으로 되돌아가 엉뚱한 위치에서 다시 시작합니다.

// ❌ 위험: pos가 npos일 때
size_t pos = s.find(',');
std::string rest = s.substr(pos + 1);  // npos + 1 == 0 → 문자열 전체
// ✅ 안전
size_t pos2 = s.find(',');
std::string rest2 = (pos2 == std::string::npos) ? std::string{} : s.substr(pos2 + 1);

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

UTF-8에서 한글 한 글자는 3바이트입니다. substr(i, 1)로 “한 글자”를 자르면 바이트 하나만 잘려 깨진 문자가 됩니다. 코드 포인트 단위로 다뤄야 한다면 utfcpp나 ICU 같은 라이브러리를 씁니다. 반면 ,나 공백처럼 ASCII 구분자로 나누는 것은 바이트 단위로 해도 안전합니다. UTF-8에서는 멀티바이트 문자의 어떤 바이트도 ASCII 범위의 값을 갖지 않기 때문입니다.

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

인덱스나 반복자로 순회하면서 erase로 문자를 지우면, 지운 위치 뒤의 인덱스와 반복자가 무효화되거나 한 칸씩 밀려 문자를 건너뜁니다. 조건에 맞는 문자를 지울 때는 erase-remove 관용구를 씁니다.

s.erase(std::remove(s.begin(), s.end(), ' '), s.end());
// C++20: std::erase(s, ' ');

기법별 성능 차이와 측정 방법

대략적인 순서

같은 입력을 파싱할 때 기법별 비용은 대체로 다음 순서입니다. 할당과 복사가 적을수록 빠릅니다.

기법메모리 할당특징
string_view + find + from_chars거의 없음제로카피, 가장 빠른 축
strtok (복사본 1회)복사본 1회 + 토큰 저장원본 수정 필요
stringstream 재사용줄마다 버퍼 복사구현이 간단
find + substr (string)토큰마다 복사토큰이 많으면 불리
stringstream 매번 생성줄마다 스트림 생성짧은 줄이 많을수록 불리
std::regex높음단순 split에는 부적합

실제 배율은 줄 길이, 토큰 수, 컴파일러와 표준 라이브러리 구현에 따라 크게 달라지므로, 자신의 입력으로 재 보는 것이 가장 정확합니다.

측정 코드 예시

#include <chrono>
#include <iostream>
#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();
    }
    long long sink = 0;  // 결과를 사용해 최적화로 루프가 사라지지 않게 함
    auto t1 = std::chrono::steady_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; }
        sink += nums.size();
    }
    auto t2 = std::chrono::steady_clock::now();
    std::istringstream iss;
    std::vector<int> nums;
    auto t3 = std::chrono::steady_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; }
        sink += nums.size();
    }
    auto t4 = std::chrono::steady_clock::now();
    using ms = std::chrono::milliseconds;
    std::cout << "new: " << std::chrono::duration_cast<ms>(t2 - t1).count() << "ms\n"
              << "reuse: " << std::chrono::duration_cast<ms>(t4 - t3).count() << "ms\n"
              << sink << "\n";
}

재사용 방식이 빠르게 나오지만 격차는 줄 길이에 달려 있습니다. 재사용이 줄여 주는 것은 istringstream 생성·소멸 비용(로케일 초기화 등)과 nums의 재할당인데, 이 예제처럼 한 줄에 숫자가 1000개씩 있으면 그 비용이 파싱 시간에 묻혀 차이가 작게 나올 수 있습니다. 짧은 줄을 아주 많이 파싱할 때 재사용의 효과가 가장 큽니다. iss.str(line)도 문자열을 복사하므로, 그 비용까지 없애려면 std::from_chars처럼 스트림을 쓰지 않는 방식이 필요합니다. 측정할 때는 반드시 -O2 이상으로 컴파일하고, 결과 값을 어딘가에 사용해서 컴파일러가 루프를 통째로 없애지 않게 해야 합니다.


재사용 CSV 파서, 로그 라인 파서, 스트리밍 파서

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

#include <sstream>
#include <string>
#include <vector>
class CsvParser {
public:
    explicit CsvParser(char delim = ',') : delim_(delim) {}
    const 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_;
};

split이 내부 벡터의 참조를 돌려주므로 호출할 때마다 벡터를 복사하지 않습니다. 대신 다음 split 호출이 내용을 덮어쓰므로, 결과를 보관하려면 호출한 쪽에서 복사해야 합니다. 스트림과 벡터를 멤버로 가지고 있어 한 객체를 여러 스레드가 동시에 쓰면 안 됩니다.

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

#include <string>
#include <string_view>
#include <optional>
struct LogEntry {
    std::string_view date;
    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 d_end = line.find(' ');
    if (d_end == std::string_view::npos) return std::nullopt;
    std::string_view date = line.substr(0, d_end);  // 날짜 부분만 (시각은 level 앞까지)
    size_t level_start = line.find('[', d_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{date, level, msg};
}

LogEntry의 필드가 모두 string_view이므로 파싱에 할당이 없습니다. 대신 결과는 line이 가리키는 버퍼가 살아 있는 동안만 유효합니다. 로그를 집계용으로 모아 둔다면 필요한 필드만 std::string으로 복사해 저장합니다.

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

한 줄씩 읽어 콜백으로 처리하므로 전체를 메모리에 올리지 않습니다.

#include <istream>
#include <string>
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);
}

line 버퍼를 루프 밖에 두면 getline이 기존 용량을 재사용하므로 줄마다 할당하지 않습니다. 콜백에 넘긴 line은 다음 줄을 읽을 때 덮어쓰이므로, 콜백 안에서 만든 string_view를 밖으로 내보내면 안 됩니다.

에러 처리가 있는 파서

#include <string>
#include <sstream>
#include <vector>
struct ParseResult {
    std::vector<int> values;
    bool ok = false;
    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 {
            size_t used = 0;
            int v = std::stoi(token, &used);
            if (used != token.size()) throw std::invalid_argument("trailing chars");
            result.values.push_back(v);
        } catch (const std::exception&) {
            result.error = "Invalid number: " + token;
            return result;
        }
    }
    result.ok = true;
    return result;
}

std::stoi의 두 번째 인자로 몇 글자를 소비했는지 받아 토큰 전체가 숫자였는지 확인합니다. 이 확인이 없으면 "12abc"가 12로 조용히 통과합니다.

trim + split 파이프라인

#include <string>
#include <string_view>
#include <vector>
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;
}

상황별 파싱 기법 선택

상황권장 기법이유
코테 단순 splitgetline + stringstream 재사용구현 간단, 대개 충분히 빠름
대량 숫자 입력string_view + from_chars복사와 할당이 없음
대량 로그/스트리밍string_view + find제로카피, 메모리 절약
원본 수정 가능strtok_r빠르고 단순
복잡한 패턴std::regex (한 번만 생성)유연성
CSV 따옴표 필드전용 CSV 파서따옴표·셀 내 줄바꿈 처리
JSONnlohmann/json 등 라이브러리정확성·유지보수

자주 묻는 질문 (FAQ)

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

숫자 입력이라면 줄을 string_view로 두고 find로 자르며 from_chars로 바로 변환하는 방식이 복사와 할당이 없어 가장 빠른 축입니다. 원본을 수정해도 된다면 strtok도 빠릅니다. 구현 시간이 더 중요하다면 stringstream을 한 번만 만들고 str()로 재사용하는 방식으로도 대부분 통과합니다.

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

view가 가리키는 원본 문자열이 호출한 쪽에서 더 오래 살아 있는 경우에만 반환합니다. 함수 안의 지역 string이나 임시 string을 가리키는 view를 반환하면 댕글링이 발생합니다.

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

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

Q. UTF-8 한글 파싱은?

std::string은 바이트 시퀀스이므로 UTF-8 문자열을 코드 포인트 단위로 자르려면 utfcpp나 ICU 같은 라이브러리를 씁니다. ,나 공백 같은 ASCII 구분자로 나누는 것은 기존 방법을 그대로 써도 됩니다.


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

이전 글: C++ I/O 병목 줄이기


같이 보면 좋은 글