C++ sregex_iterator·sregex_token_iterator: 모든 매치 순회와 문자열 분할

이 글의 핵심

sregex_iterator로 문자열 안의 모든 정규식 매치를 순회하고, sregex_token_iterator로 구분자 기준 토큰화를 하는 방법을 정리합니다. 임시 문자열에 반복자를 걸어 생기는 수명 문제와 로그 파싱 예제, 성능 주의점도 다룹니다.

regex_iterator란?

std::regex_search나 std::regex_match는 문자열에서 한 번의 매치만 찾아줍니다. 하지만 로그 파일에서 모든 숫자를 뽑거나, 텍스트에서 모든 단어를 추출하는 것처럼 문자열 안에 존재하는 모든 매치를 순서대로 순회해야 하는 경우가 훨씬 많습니다. std::sregex_iterator(C++11)는 이런 요구를 충족하기 위한 반복자로, 내부적으로 이전 매치가 끝난 지점부터 다시 regex_search를 호출하는 방식으로 동작하며, 표준 반복자 인터페이스(begin/end, operator++, operator*)를 그대로 제공하기 때문에 range-based for나 STL 알고리즘과도 자연스럽게 어울립니다. 아래 예제는 문자열에서 숫자로 이루어진 모든 토큰을 순서대로 출력합니다.

#include <regex>

std::string text = "C++ 11, C++ 14, C++ 17";
std::regex pattern{R"(\d+)"};

auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
auto end = std::sregex_iterator();

for (auto it = begin; it != end; ++it) {
    std::cout << it->str() << std::endl;
}
// 11
// 14
// 17

기본 사용

sregex_iterator를 사용하는 기본 패턴은 항상 동일합니다. 순회를 시작할 반복자(begin)는 대상 문자열의 시작·끝 이터레이터와 정규식 객체를 넘겨 생성하고, 순회를 끝낼 반복자(end)는 인자 없이 기본 생성합니다. 이 “기본 생성된 반복자가 곧 끝을 의미한다”는 관용구는 C++ 표준 라이브러리 여러 곳(istream_iterator 등)에서 반복되는 패턴이므로 한 번 익혀두면 다른 곳에서도 그대로 적용할 수 있습니다. 역참조한 값은 std::smatch 타입으로, 전체 매치와 캡처 그룹 정보를 모두 담고 있습니다.

#include <regex>
#include <string>

std::string text = "abc 123 def 456";
std::regex pattern{R"(\d+)"};

// 반복자 생성
auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
auto end = std::sregex_iterator();

// 순회
for (auto it = begin; it != end; ++it) {
    std::smatch match = *it;
    std::cout << match.str() << std::endl;
}

실전 예시

아래 네 가지 예시는 로그 분석, 텍스트 마이닝, 사용자 입력 검증 같은 실무에서 sregex_iterator가 실제로 어떻게 쓰이는지 보여줍니다. 패턴 자체의 문법보다는 “반복자로 순회하며 결과를 어떻게 모으는가”에 초점을 맞춰 읽으면 됩니다.

예시 1: 단어 추출

\b\w+\b는 단어 경계(\b)로 감싸인 영숫자 시퀀스를 찾는 패턴으로, 텍스트에서 구두점을 제외한 순수 단어만 뽑아낼 때 흔히 쓰입니다. extractWords는 반복자로 순회하며 매치된 문자열을 벡터에 차곡차곡 쌓아 반환하는데, 이렇게 “순회하며 컨테이너에 누적”하는 패턴은 정규식 반복자를 다루는 함수 대부분에서 반복적으로 등장합니다.

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

std::vector<std::string> extractWords(const std::string& text) {
    std::regex pattern{R"(\b\w+\b)"};
    
    auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
    auto end = std::sregex_iterator();
    
    std::vector<std::string> words;
    for (auto it = begin; it != end; ++it) {
        words.push_back(it->str());
    }
    
    return words;
}

int main() {
    auto words = extractWords("Hello, World! C++ 2026");
    
    for (const auto& word : words) {
        std::cout << word << std::endl;
    }
    // Hello
    // World
    // C
    // 2026
}

예시 2: 캡처 그룹

패턴에 괄호 ()로 표시한 부분은 캡처 그룹이 되어, 매치된 전체 문자열뿐 아니라 그 안의 부분 문자열도 개별적으로 접근할 수 있게 됩니다. match[0]은 항상 전체 매치를, match[1]부터는 괄호가 등장한 순서대로 각 그룹을 가리킵니다. 이 예제는 이메일 주소에서 사용자명과 도메인을 각각의 그룹으로 분리해 추출하는데, 로그나 설정 파일처럼 정해진 형식의 텍스트에서 특정 필드만 뽑아낼 때 이 패턴이 매우 유용합니다.

#include <iostream>
#include <string>
#include <regex>

int main() {
    std::string text = "user1@example.com, user2@test.org";
    std::regex pattern{R"((\w+)@(\w+\.\w+))"};
    
    auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
    auto end = std::sregex_iterator();
    
    for (auto it = begin; it != end; ++it) {
        std::smatch match = *it;
        std::cout << "이메일: " << match[0] << std::endl;
        std::cout << "사용자: " << match[1] << std::endl;
        std::cout << "도메인: " << match[2] << std::endl;
        std::cout << std::endl;
    }
}

예시 3: URL 파싱

여러 개의 캡처 그룹을 조합하면 구조화된 데이터를 하나의 정규식으로 파싱할 수 있습니다. 이 예제는 프로토콜(http/https), 호스트, 경로를 각각 별도의 그룹으로 캡처해 URL 구조체에 담아 반환합니다. 텍스트 안에 여러 개의 URL이 섞여 있어도 sregex_iterator가 각각을 순서대로 찾아주므로, 한 번의 순회로 문서 전체에서 URL 목록을 뽑아낼 수 있습니다. 다만 실무에서 URL 파싱이 중요하다면 이런 정규식보다 전용 URL 파싱 라이브러리를 쓰는 편이 엣지 케이스(퍼센트 인코딩, IPv6 호스트 등) 처리에 더 안전합니다.

#include <regex>

struct URL {
    std::string protocol;
    std::string host;
    std::string path;
};

std::vector<URL> extractURLs(const std::string& text) {
    std::regex pattern{R"((https?)://([^/]+)(/[^\s]*))"};
    
    auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
    auto end = std::sregex_iterator();
    
    std::vector<URL> urls;
    for (auto it = begin; it != end; ++it) {
        std::smatch match = *it;
        urls.push_back({
            match[1].str(),  // protocol
            match[2].str(),  // host
            match[3].str()   // path
        });
    }
    
    return urls;
}

예시 4: 토큰 분할

sregex_token_iterator는 매치 자체가 아니라 매치 사이사이에 남은 부분을 순회하고 싶을 때 사용합니다. 생성자의 마지막 인자로 -1을 넘기면 “매치되지 않은 나머지 구간”을 토큰으로 반환하도록 지시하는 것인데, 여기서는 공백 패턴 \s+을 구분자로 삼아 사실상 split 함수처럼 동작합니다. std::string에는 표준 split 함수가 없기 때문에, 이 패턴은 문자열을 구분자 기준으로 나누는 실용적인 대안으로 자주 쓰입니다.

다만 입력이 공백으로 시작하면 첫 토큰으로 빈 문자열이 나온다는 점을 기억해야 합니다. tokenize(" Hello World")는 ["", "Hello", "World"]를 돌려주는데, 첫 매치(앞쪽 공백) 앞의 빈 구간도 “매치 사이의 텍스트”로 취급되기 때문입니다. 끝에 붙은 공백은 빈 토큰을 만들지 않아서 앞뒤 동작이 비대칭이라는 점도 헷갈리기 쉽습니다. 사용자 입력을 나눌 때는 결과에서 빈 문자열을 걸러 내거나, 구분자가 아니라 원하는 토큰 자체(\S+)를 sregex_iterator로 찾는 쪽이 더 단순합니다. 단순 공백 분리라면 C++20의 std::views::split이나 std::istringstream이 정규식보다 훨씬 가볍습니다.

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

std::vector<std::string> tokenize(const std::string& text) {
    std::regex pattern{R"(\s+)"};  // 공백
    
    std::sregex_token_iterator begin(text.begin(), text.end(), pattern, -1);
    std::sregex_token_iterator end;
    
    return {begin, end};
}

int main() {
    auto tokens = tokenize("Hello   World  C++");
    
    for (const auto& token : tokens) {
        std::cout << "[" << token << "]" << std::endl;
    }
    // [Hello]
    // [World]
    // [C++]
}

regex_token_iterator

sregex_token_iterator의 세 번째 생성자 인자로 넘기는 값은 “어떤 그룹을 토큰으로 뽑을지”를 지정합니다. -1은 특별한 의미로, 매치되는 부분이 아니라 매치와 매치 사이에 남는 텍스트(즉 구분자를 제외한 나머지)를 토큰으로 취급하라는 뜻입니다. 아래 예제처럼 쉼표를 구분자로 지정하면 CSV 한 줄을 필드 단위로 분리하는 것과 같은 효과를 얻을 수 있습니다. 0 이상의 값을 넘기면 반대로 해당 캡처 그룹에 매치된 텍스트만 토큰으로 반환합니다.

std::string text = "a,b,c,d";
std::regex pattern{","};

// -1: 구분자 제외
std::sregex_token_iterator begin(text.begin(), text.end(), pattern, -1);
std::sregex_token_iterator end;

for (auto it = begin; it != end; ++it) {
    std::cout << *it << std::endl;
}
// a
// b
// c
// d

자주 발생하는 문제

문제 1: 반복자 수명

sregex_iterator는 대상 문자열을 복사해서 갖고 있지 않고, 원본 문자열을 가리키는 이터레이터만 내부에 저장합니다. 따라서 반복자가 살아 있는 동안 원본 문자열도 반드시 살아 있어야 합니다. 아래 getIterator 함수처럼 지역 변수 text를 함수 안에서 만들고 그 문자열을 가리키는 반복자를 반환하면, 함수가 끝나는 순간 text가 소멸되면서 반환된 반복자는 이미 해제된 메모리를 가리키는 댕글링 상태가 됩니다. 이후 이 반복자를 역참조하면 정의되지 않은 동작(UB)이 발생합니다.

// 댕글링
auto getIterator() {
    std::string text = "hello 123";
    std::regex pattern{R"(\d+)"};
    return std::sregex_iterator(text.begin(), text.end(), pattern);
    // text와 pattern이 모두 소멸
}

// 문자열과 정규식이 반복자보다 오래 살도록 바깥에 둠
std::string text = "hello 123";
std::regex pattern{R"(\d+)"};
auto it = std::sregex_iterator(text.begin(), text.end(), pattern);

같은 문제는 정규식 객체 쪽에서도 생깁니다. 반복자는 문자열뿐 아니라 std::regex도 포인터로만 붙잡고 있기 때문에, std::sregex_iterator(text.begin(), text.end(), std::regex{R"(\d+)"})처럼 임시 정규식을 넘기면 반복자가 살아 있는 동안 정규식이 먼저 사라집니다. 이 실수가 워낙 흔해서 C++14부터 표준은 임시 regex(rvalue)를 받는 생성자를 삭제(= delete)해 두었고, 이런 코드는 use of deleted function 컴파일 에러가 납니다. 같은 이유로 std::regex_search(getLine(), m, re)처럼 임시 문자열과 smatch를 함께 넘기는 오버로드도 삭제되어 있습니다. smatch에 담긴 결과는 원본 문자열의 반복자 쌍이라, 임시 문자열이 사라지면 결과도 함께 무효가 되기 때문입니다. 에러 메시지가 길고 낯설지만, “삭제된 함수”라는 말이 보이면 인자로 넘긴 임시 객체부터 확인하면 됩니다.

문제 2: 성능

std::regex 객체를 생성하는 것은 단순한 값 초기화가 아니라 패턴 문자열을 파싱하고 내부 상태 기계(state machine)로 컴파일하는 상대적으로 무거운 연산입니다. 반복문 안에서 매번 같은 패턴으로 std::regex를 새로 만들면 이 컴파일 비용이 루프 횟수만큼 반복되어 심각한 성능 저하로 이어집니다. 패턴이 바뀌지 않는다면 반드시 루프 바깥(또는 static)에서 한 번만 컴파일하고, 그 객체를 계속 재사용해야 합니다.

// 매번 컴파일
for (const auto& text : texts) {
    std::regex pattern{R"(\d+)"};  // 반복 컴파일
    std::regex_search(text, pattern);
}

// 한 번만 컴파일
std::regex pattern{R"(\d+)"};
for (const auto& text : texts) {
    std::regex_search(text, pattern);
}

libstdc++(GCC)의 std::regex에는 성능 말고도 알아 둘 한계가 하나 더 있습니다. 매칭을 재귀로 구현한 부분이 있어서, (a|b)* 같은 패턴을 수만~수십만 글자 길이의 문자열 하나에 적용하면 스택 오버플로로 세그멘테이션 오류가 날 수 있습니다. 로그 파일을 한 줄씩이 아니라 통째로 문자열 하나에 올려 정규식을 돌리다가 입력이 커진 날에만 크래시가 나는 증상이 대표적입니다. 입력을 줄 단위로 나눠 처리하거나, 이런 규모의 텍스트 처리는 백트래킹 없이 선형 시간을 보장하는 RE2 같은 엔진을 쓰는 편이 안전합니다.

문제 3: 빈 매치

\d*처럼 “0개 이상”을 의미하는 수량자를 쓰면 아무 문자도 소비하지 않는 빈 매치가 발생할 수 있습니다. sregex_iterator는 빈 매치도 하나의 유효한 매치로 순회 결과에 포함시키기 때문에, 숫자가 없는 위치마다 빈 문자열 매치가 끼어들어 결과 목록이 예상보다 훨씬 많아집니다. 아래처럼 빈 문자열을 걸러내는 방어 코드를 추가하거나, 애초에 \d+(1개 이상)처럼 빈 매치가 나올 수 없는 패턴을 쓰는 것이 더 안전합니다.

std::string text = "hello";
std::regex pattern{R"(\d*)"};  // 0개 이상

// 빈 매치 가능
auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
auto end = std::sregex_iterator();

for (auto it = begin; it != end; ++it) {
    if (!it->str().empty()) {
        std::cout << it->str() << std::endl;
    }
}

문제 4: 그룹 인덱스

캡처 그룹 인덱스는 패턴에서 여는 괄호 (가 등장하는 순서대로 1부터 매겨지며, 인덱스 0은 항상 전체 매치를 가리키는 예약된 값입니다. 그룹이 여러 개 중첩되어 있으면 어떤 괄호가 몇 번 그룹인지 헷갈리기 쉬운데, std::regex는 C++20·C++23에서도 이름 있는 캡처 그룹((?<name>...))을 지원하지 않으므로, 주석으로 각 그룹의 의미를 적어 두거나 그룹 번호를 이름 있는 상수(constexpr int kUser = 1;)로 정의해 두는 방법이 현실적입니다. 괄호를 묶기만 하고 캡처는 필요 없다면 (?:...) 비캡처 그룹을 써서 번호가 밀리지 않게 할 수 있습니다. 이름 있는 그룹이 꼭 필요하다면 Boost.Regex나 PCRE2를 검토해야 합니다. 존재하지 않는 인덱스에 접근하면 예외가 아니라 빈 sub_match가 반환되므로 matched 멤버로 유효성을 확인하는 습관도 중요합니다.

std::string text = "user@example.com";
std::regex pattern{R"((\w+)@(\w+)\.(\w+))"};

std::smatch matches;
if (std::regex_match(text, matches, pattern)) {
    // matches[0]: 전체 매치
    // matches[1]: 첫 번째 그룹
    // matches[2]: 두 번째 그룹
    // ...
}

활용 패턴

<regex> 헤더가 제공하는 함수와 반복자는 목적에 따라 명확히 구분해서 써야 합니다. 전체 문자열이 패턴과 정확히 일치하는지 검사할 때는 regex_match, 문자열 어딘가에 패턴이 존재하는지(그리고 그 위치의 캡처 그룹)를 알고 싶을 때는 regex_search, 매치된 부분을 다른 문자열로 바꾸고 싶을 때는 regex_replace를 사용합니다. 그리고 지금까지 다룬 것처럼 문자열 안의 모든 매치를 순서대로 훑어야 한다면 sregex_iterator가 적합합니다. 아래 코드는 이 네 가지 용도를 한눈에 비교할 수 있도록 정리한 것입니다.

// 1. 검증
bool isValid = std::regex_match(text, pattern);

// 2. 검색
std::smatch matches;
std::regex_search(text, matches, pattern);

// 3. 치환
auto result = std::regex_replace(text, pattern, replacement);

// 4. 모든 매치
auto it = std::sregex_iterator(begin, end, pattern);

겹치는 매치와 반복자 타입

반복자는 다음 검색을 이전 매치의 끝에서 시작하므로, 겹치는 매치는 나오지 않습니다. "aaaa"에서 aa를 찾으면 0번과 2번 위치의 두 개만 나오고 1번 위치는 건너뜁니다. 겹치는 매치까지 모두 필요하다면 전방 탐색 안에 캡처 그룹을 넣은 (?=(aa))처럼 길이가 0인 패턴을 쓰고 (*it)[1]을 읽으면 됩니다. 빈 매치 뒤에는 반복자가 한 글자씩 전진하므로 모든 시작 위치가 검사됩니다.

std::regex_iterator는 문자 반복자 타입을 템플릿 인자로 받고, 자주 쓰는 조합에 별칭이 있습니다. sregex_iterator는 std::string::const_iterator, cregex_iterator는 const char*, wsregex_iterator는 std::wstring::const_iterator용입니다.

종료 반복자는 std::sregex_iterator() 기본 생성으로 둡니다. smatch는 ready()로 유효한 매치를 담고 있는지, size()로 캡처 그룹 개수(그룹 0 포함)를 확인할 수 있으며, str(n)으로 n번째 그룹의 문자열을 바로 얻을 수 있어 matches[n].str()보다 간결하게 쓸 수 있습니다.

auto begin = std::sregex_iterator(text.begin(), text.end(), pattern);
auto end   = std::sregex_iterator();
for (auto it = begin; it != end; ++it) {
    const std::smatch& m = *it;
    // m.ready(), m.size(), m.str(n)
}

실전: 로그 파싱 예시

아래는 타임스탬프·레벨·메시지를 한 줄에서 뽑는 예시입니다(패턴은 로그 포맷에 맞게 조정).

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

struct LogLine {
    std::string timestamp;
    std::string level;
    std::string message;
};

// 예: "2026-03-30 12:00:00 ERROR something failed"
bool parseLogLine(const std::string& line, LogLine& out) {
    static const std::regex re(
        R"((\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) (\w+) (.*))");
    std::smatch m;
    if (!std::regex_match(line, m, re)) {
        return false;
    }
    out.timestamp = m[1].str();
    out.level = m[2].str();
    out.message = m[3].str();
    return true;
}

// 여러 줄에서 특정 레벨만 모으기
std::vector<std::string> extractErrors(const std::string& text) {
    static const std::regex levelLine(R"(\b(ERROR|CRITICAL)\b.*)");  // .은 줄바꿈과 매치되지 않아 줄 끝에서 멈춤
    auto begin = std::sregex_iterator(text.begin(), text.end(), levelLine);
    auto end = std::sregex_iterator();
    std::vector<std::string> errors;
    for (auto it = begin; it != end; ++it) {
        errors.push_back(it->str());
    }
    return errors;
}

큰 로그 파일을 처리한다면 파일 전체를 문자열 하나에 올려 정규식을 돌리기보다, 줄 단위로 읽어 parseLogLine처럼 한 줄씩 매칭하는 편이 낫습니다. 앞에서 본 libstdc++의 재귀 깊이 문제를 피할 수 있고, 줄 경계가 곧 레코드 경계가 되어 패턴도 단순해집니다. smatch 자체는 원본 문자열의 반복자 쌍만 들고 있어 매치마다 문자열을 복사하지는 않지만, str()이나 m[n].str()을 호출할 때마다 새 std::string이 만들어집니다. 매치 결과를 즉시 소비한다면 C++20부터는 std::string_view(m[n].first, m[n].second)처럼 뷰로 다루어 할당을 줄일 수 있습니다. std::regex_constants::optimize 플래그는 구현에 매칭 속도를 우선하라는 힌트일 뿐 효과가 보장되지 않으므로, 실제로 측정해 보고 판단해야 합니다.

참고 자료


같이 보면 좋은 글