C++ std::regex 실전: regex_match vs regex_search, 캡처 그룹, 토큰화와 탐욕적 매칭 함정
이 글의 핵심
std::regex로 문자열 검증·URL 파싱·로그 분석·치환·토큰화를 구현하는 방법과 regex_match/regex_search 차이, 캡처 그룹, 이스케이프, 성능 문제를 예제로 정리합니다.
기본 사용법
문자열 처리를 find, substr 같은 개별 메서드 조합으로 하려면 “숫자가 몇 개 연속으로 나오는 패턴”처럼 조금만 복잡해져도 코드가 조건문 범벅이 되기 쉽습니다. std::regex는 이런 패턴 자체를 하나의 선언적인 문자열("\\d+" — “숫자가 1개 이상 연속”)로 표현하게 해 주며, regex_search는 그 패턴이 대상 문자열 어딘가에 존재하는지만 확인합니다. regex 객체를 한 번 생성해 두면 그 컴파일된 패턴을 여러 문자열에 반복해서 적용할 수 있다는 점도 중요한데, 이는 정규식 엔진이 패턴 문자열을 파싱해 내부적으로 상태 기계(오토마타)를 구성하는 비용이 상당하기 때문이며, 이 비용을 매칭마다 반복하지 않도록 설계된 것입니다 — 이 점은 뒤의 “성능” 함정에서 다시 다룹니다.
#include <regex>
#include <iostream>
using namespace std;
int main() {
regex pattern("\\d+"); // 숫자 패턴
string text = "abc123def456";
// 검색
if (regex_search(text, pattern)) {
cout << "숫자 발견" << endl;
}
}
regex_match vs regex_search
이 둘의 차이는 정규식 엔진이 암묵적으로 문자열 앞뒤에 시작(^)과 끝($) 앵커를 붙이는지 여부로 이해하면 명확해집니다 — regex_match는 패턴이 대상 문자열 전체를 처음부터 끝까지 정확히 설명해야 성공하므로 "abc123"처럼 숫자 앞에 다른 문자가 있으면 실패하지만, regex_search는 문자열 어딘가에 패턴과 일치하는 부분이 있는지만 확인하므로 "abc123" 안의 "123"을 찾아 성공합니다. 이 구분을 혼동하면 흔히 두 가지 방향의 버그가 생깁니다 — “정확히 이 형식이어야 한다”는 검증(이메일, 전화번호 형식 검증 등)에 regex_search를 쓰면 잘못된 형식의 문자열도 그 안에 우연히 패턴과 일치하는 부분만 있으면 통과해 버리고, 반대로 “문자열 안에 이런 게 있는지”를 확인하려는데 regex_match를 쓰면 대상 문자열에 패턴 이외의 문자가 조금이라도 섞여 있으면 항상 실패합니다.
regex pattern("\\d+");
string s1 = "123";
string s2 = "abc123";
// regex_match: 전체 문자열 매칭
cout << regex_match(s1, pattern) << endl; // 1 (true)
cout << regex_match(s2, pattern) << endl; // 0 (false)
// regex_search: 부분 문자열 매칭
cout << regex_search(s1, pattern) << endl; // 1
cout << regex_search(s2, pattern) << endl; // 1
캡처 그룹
패턴 안의 괄호 ()는 “이 부분에 해당하는 문자열도 별도로 꺼내 달라”는 요청이며, smatch 객체는 전체 매칭 결과(match[0])와 각 괄호 그룹의 매칭 결과(match[1], match[2], …)를 인덱스로 구분해 담아 둡니다. 정규식 안에서 괄호가 등장하는 순서대로 인덱스가 1부터 매겨진다는 규칙만 알면, 복잡한 패턴에서도 “이 그룹이 몇 번째 결과에 대응하는가”를 정확히 예측할 수 있습니다 — 전화번호 패턴의 세 그룹(지역, 중간, 끝)이 각각 match[1], match[2], match[3]에 순서대로 대응하는 것이 그 예입니다. 이 인덱스 기반 접근은 그룹 수가 늘어날수록 어떤 숫자가 무엇을 의미하는지 추적하기 어려워지는데, C++11 이후로는 명명된 그룹을 직접 지원하지 않으므로(일부 다른 언어의 (?<name>...)처럼) 그룹이 많은 패턴에서는 순서를 주석으로 명확히 남겨두는 것이 실무적으로 유용합니다.
regex pattern("(\\d{3})-(\\d{4})-(\\d{4})");
string phone = "010-1234-5678";
smatch match;
if (regex_match(phone, match, pattern)) {
cout << "전체: " << match[0] << endl; // 010-1234-5678
cout << "지역: " << match[1] << endl; // 010
cout << "중간: " << match[2] << endl; // 1234
cout << "끝: " << match[3] << endl; // 5678
}
실전 예시
예시 1: 이메일 검증
이메일 형식을 완벽하게 검증하는 정규식은 RFC 5322 표준의 복잡성 때문에 사실상 실용적이지 않을 정도로 길어지므로, 이 패턴처럼 “실무에서 대부분의 정상적인 이메일을 걸러내되 완벽을 추구하지 않는” 근사적 검증이 일반적입니다. ^와 $ 앵커가 패턴 양 끝에 명시되어 있는 것도 중요한데, isValidEmail은 regex_match를 쓰므로 사실 이 앵커가 없어도 전체 매칭이 강제되지만, 명시적으로 앵커를 표기해 두면 이 패턴이 나중에 regex_search로 바뀌더라도 의도(전체 문자열 검증)가 코드에 그대로 남아있어 실수를 방지할 수 있습니다. 실무에서 이메일 형식 검증이 필요하다면, 이런 정규식은 “명백히 잘못된 입력을 빠르게 걸러내는” 1차 필터로만 쓰고, 실제 유효성은 확인 이메일 발송 같은 더 확실한 방법으로 검증하는 것이 일반적입니다.
bool isValidEmail(const string& email) {
regex pattern(R"(^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$)");
return regex_match(email, pattern);
}
int main() {
cout << isValidEmail("[email protected]") << endl; // 1
cout << isValidEmail("invalid.email") << endl; // 0
}
예시 2: URL 파싱
(?::(\d+))?처럼 그룹 전체를 ?로 감싼 것이 “포트 번호는 있을 수도, 없을 수도 있다”는 URL의 실제 구조를 정확히 반영합니다 — ?:는 캡처하지 않는 그룹(비캡처 그룹)을 의미하고, 그 뒤의 ?는 그 그룹 전체가 0번 또는 1번 나타날 수 있다는 뜻입니다. 이 구조 덕분에 "https://example.com:8080/path"와 "https://example.com/path"(포트 없음) 둘 다 같은 패턴으로 매칭할 수 있으며, 포트가 없는 경우 match[3]은 빈 문자열이 됩니다. 실무에서는 URL 파싱에 정규식보다 std::filesystem::path나 전용 URI 파싱 라이브러리를 쓰는 것이 더 견고한데, 이 예시가 보여주는 정규식 기반 접근은 쿼리 문자열, 인코딩된 특수문자, IPv6 호스트 등 실제 URL 스펙의 복잡한 예외 사례들을 다루지 못하는 단순화된 버전입니다.
struct URL {
string protocol;
string host;
string port;
string path;
};
URL parseURL(const string& url) {
regex pattern(R"(^(\w+)://([^:/]+)(?::(\d+))?(/.*)?$)");
smatch match;
if (regex_match(url, match, pattern)) {
return {
match[1], // protocol
match[2], // host
match[3], // port
match[4] // path
};
}
return {};
}
int main() {
auto url = parseURL("https://example.com:8080/path/to/page");
cout << "프로토콜: " << url.protocol << endl;
cout << "호스트: " << url.host << endl;
cout << "포트: " << url.port << endl;
cout << "경로: " << url.path << endl;
}
예시 3: 문자열 치환
regex_replace가 단순 문자열 치환(std::string::replace)보다 강력한 이유는 치환 대상을 정확한 문자열이 아니라 패턴으로 지정할 수 있다는 것입니다 — 예를 들어 "Hello"라는 고정 문자열 대신 "[Hh]ello" 같은 패턴을 쓰면 대소문자가 다른 여러 변형을 한 번에 치환할 수 있고, 캡처 그룹을 활용하면 "$1" 같은 참조로 매칭된 부분을 치환 결과에 재사용할 수도 있습니다. format_first_only 플래그가 기본 동작(전체 치환)과 다른 동작을 명시적으로 요청하는 것도 눈여겨볼 만합니다 — regex_replace의 기본값이 “모두 치환”이므로, 첫 번째 발견만 바꾸고 싶다면 이런 플래그로 그 의도를 명확히 표현해야 합니다.
#include <regex>
int main() {
string text = "Hello World, Hello C++";
regex pattern("Hello");
// 모두 치환
string result = regex_replace(text, pattern, "Hi");
cout << result << endl; // Hi World, Hi C++
// 첫 번째만 치환
result = regex_replace(text, pattern, "Hi", regex_constants::format_first_only);
cout << result << endl; // Hi World, Hello C++
}
예시 4: 로그 파싱
로그 파일 처리는 정규표현식이 실무에서 가장 흔하게 쓰이는 용도 중 하나이며, 이 패턴이 [타임스탬프] [레벨] 메시지라는 일관된 로그 형식을 세 개의 캡처 그룹으로 정확히 분해하는 것을 보여줍니다. (.+)가 메시지 부분을 탐욕적으로(가능한 한 길게) 매칭하는 것도 의도적인데, 로그 메시지 자체에 대괄호나 특수문자가 포함될 수 있으므로 그 내용을 더 세밀하게 제한하는 패턴을 쓰면 오히려 정상적인 메시지를 파싱하지 못할 위험이 있습니다. getline으로 로그를 한 줄씩 읽어 각 줄에 독립적으로 regex_match를 적용하는 구조는, 여러 줄에 걸친 복잡한 로그 형식(스택 트레이스 등)에는 적합하지 않지만 한 줄에 한 이벤트가 담기는 표준적인 로그 형식에는 충분히 실용적인 접근입니다.
struct LogEntry {
string timestamp;
string level;
string message;
};
vector<LogEntry> parseLog(const string& log) {
vector<LogEntry> entries;
regex pattern(R"(\[([\d\-: ]+)\] \[(\w+)\] (.+))");
istringstream iss(log);
string line;
while (getline(iss, line)) {
smatch match;
if (regex_match(line, match, pattern)) {
entries.push_back({
match[1], // timestamp
match[2], // level
match[3] // message
});
}
}
return entries;
}
int main() {
string log = R"([2026-03-11 10:30:00] [INFO] 서버 시작
[2026-03-11 10:30:05] [ERROR] 연결 실패
[2026-03-11 10:30:10] [WARN] 재시도 중)";
auto entries = parseLog(log);
for (const auto& entry : entries) {
cout << entry.timestamp << " | "
<< entry.level << " | "
<< entry.message << endl;
}
}
반복자
regex_search가 첫 번째 매칭 하나만 찾아주는 반면, 한 문자열 안의 모든 매칭을 순서대로 순회하고 싶을 때는 sregex_iterator가 필요합니다 — 이는 표준 라이브러리의 다른 반복자들(벡터의 begin()/end() 등)과 동일한 반복자 관용구를 따르도록 설계되어, while (it != end)와 ++it로 순회하는 익숙한 패턴을 그대로 재사용할 수 있습니다. 기본 생성된 sregex_iterator end가 “매칭이 더 이상 없는 상태”를 나타내는 종료 반복자 역할을 하는 것도 표준 라이브러리 반복자 관례(end()가 실제 끝을 넘어선 위치를 가리키는 감시병 값)를 그대로 따른 것입니다. it->str()로 현재 매칭된 부분 문자열을 꺼낼 수 있으며, it->position()으로 그 매칭이 원본 문자열의 몇 번째 위치에서 시작하는지도 조회할 수 있습니다.
// 변수 선언 및 초기화
string text = "abc123def456ghi789";
regex pattern("\\d+");
// 모든 매칭 찾기
sregex_iterator it(text.begin(), text.end(), pattern);
sregex_iterator end;
while (it != end) {
cout << it->str() << endl; // 123, 456, 789
++it;
}
토큰화
sregex_token_iterator의 마지막 인자로 넘긴 -1이 이 코드의 핵심이자 가장 헷갈리기 쉬운 부분입니다 — 일반적으로 이 인자는 “어느 캡처 그룹을 결과로 낼 것인가”를 지정하지만, -1이라는 특수값은 “구분자와 일치하지 않는 부분”, 즉 구분자로 쪼개진 나머지 조각들을 결과로 내라는 뜻입니다. 이 발상을 뒤집으면, sregex_token_iterator는 원래 “패턴과 일치하는 부분들”을 순회하도록 설계되었는데(앞서 다룬 sregex_iterator처럼), -1을 지정하면 정반대로 “패턴과 일치하지 않는 부분들”을 순회하게 되어, 결과적으로 std::string::split이 없는 C++에서 ,를 구분자로 문자열을 토큰화하는 표준적인 관용구가 됩니다. std::getline으로 단순 단일 문자 구분자를 처리할 수도 있지만, sregex_token_iterator는 구분자 자체가 정규식 패턴(여러 문자, 가변 길이 공백 등)일 때도 동일한 방식으로 확장됩니다.
string text = "apple,banana,cherry";
regex delimiter(",");
// 토큰 반복자
sregex_token_iterator it(text.begin(), text.end(), delimiter, -1);
sregex_token_iterator end;
while (it != end) {
cout << *it << endl; // apple, banana, cherry
++it;
}
자주 발생하는 문제
문제 1: 이스케이프
이 문제는 정규식 자체의 문법이 아니라 C++ 문자열 리터럴의 이스케이프 규칙과 정규식 이스케이프 규칙이 겹쳐서 생깁니다 — "\d+"를 컴파일러가 먼저 해석할 때, \d는 C++ 문자열 리터럴이 인식하는 표준 이스케이프 시퀀스(\n, \t 등)가 아니므로 처리가 정의되지 않거나 그냥 d로 남는 등 의도한 정규식 문법 \d(숫자 문자 클래스)로 정규식 엔진에 전달되지 않습니다. "\\d+"처럼 백슬래시를 두 번 쓰면, 첫 번째 단계(C++ 컴파일러)에서 \\가 리터럴 백슬래시 하나로 해석되어 최종적으로 정규식 엔진은 \d+라는 올바른 패턴을 받게 됩니다. 이 이중 이스케이프가 패턴이 복잡해질수록 급격히 읽기 어려워지므로(\\(, \\), \\.처럼 특수문자마다 백슬래시가 겹겹이 쌓입니다), Raw 문자열 리터럴 R"(...)"이 실무에서 사실상 표준으로 권장되는 이유입니다 — Raw 문자열 안에서는 C++ 자체의 이스케이프 처리가 전혀 개입하지 않아, 정규식 엔진이 보는 패턴과 사람이 코드에서 읽는 패턴이 정확히 일치합니다.
// ❌ 잘못된 이스케이프
regex pattern("\d+"); // \d가 이스케이프 안 됨
// ✅ 이중 백슬래시
regex pattern("\\d+");
// ✅ Raw 문자열 (권장)
regex pattern(R"(\d+)");
문제 2: 성능
regex 생성자는 패턴 문자열을 파싱해 내부적으로 유한 오토마타(상태 기계)를 구성하는데, 이 컴파일 과정은 단순 문자열 비교보다 훨씬 무거운 연산입니다 — 반복문 안에서 매번 새 regex 객체를 만들면, 실제로 텍스트를 검색하는 시간보다 패턴을 매번 다시 컴파일하는 시간이 더 클 수 있습니다. regex 객체를 반복문 밖에서 한 번만 생성해 재사용하면, 그 컴파일된 상태 기계를 여러 입력 문자열에 대해 계속 재사용할 수 있어 반복 횟수가 많을수록 성능 차이가 극적으로 벌어집니다. 이는 이 글에서 이미 여러 번 다룬 “매번 새로 만들지 말고 재사용하라”는 원칙이 정규식 객체에도 그대로 적용되는 사례이며, 정규식을 다루는 코드를 작성할 때 가장 먼저 확인해야 할 최적화 지점입니다.
// ❌ 매번 regex 생성
for (const string& text : texts) {
regex pattern("\\d+"); // 비효율
regex_search(text, pattern);
}
// ✅ regex 재사용
regex pattern("\\d+");
for (const string& text : texts) {
regex_search(text, pattern);
}
문제 3: 탐욕적 매칭
*, + 같은 수량자는 기본적으로 “탐욕적(greedy)“이어서, 전체 매칭이 성립하는 한 가능한 한 많은 문자를 소비하려 합니다 — <.*>에서 .*는 “아무 문자나 0개 이상”을 뜻하는데, 탐욕적 특성 때문에 첫 번째 <를 만난 뒤 가능한 한 멀리까지 진행하다가, 문자열 전체를 소비한 뒤에도 매칭이 안 되면 그제야 한 글자씩 되돌아오며(백트래킹) 마지막 >를 찾습니다. 그 결과 <div>content</div>처럼 여는 태그와 닫는 태그가 둘 다 있는 문자열에서 .*는 첫 <부터 마지막 >까지, 즉 전체 문자열을 한 번에 삼켜버립니다. .*?처럼 수량자 뒤에 ?를 붙이면 “비탐욕적(lazy)“으로 바뀌어, 가능한 한 적은 문자만 소비하고 매칭이 성립하는 즉시 멈추므로 <div>와 </div>가 각각 별도의 매칭으로 분리됩니다. HTML/XML처럼 태그가 중첩되거나 반복되는 구조를 다룰 때 탐욕적 매칭을 그대로 쓰면 의도와 전혀 다른 넓은 범위가 매칭되는 것이 정규식 초보자가 가장 자주 마주치는 함정 중 하나입니다.
string html = "<div>content</div>";
// ❌ 탐욕적
regex greedy("<.*>");
// 매칭: <div>content</div> (전체)
// ✅ 비탐욕적
regex nonGreedy("<.*?>");
// 매칭: <div>, </div> (각각)
문제 4: 잘못된 패턴과 regex_error
std::regex는 생성 시점에 패턴을 컴파일하고, 문법이 틀리면 std::regex_error를 던집니다. 코드에 박힌 리터럴 패턴이라면 테스트에서 바로 드러나지만, 설정 파일이나 사용자 입력으로 패턴을 받는 기능(검색 필터, 로그 룰 등)에서는 괄호 하나만 빠져도 예외가 처리되지 않은 채 프로세스가 죽습니다. 외부에서 들어오는 패턴은 반드시 try로 감싸고, e.code()로 원인(error_brack, error_paren 등)을 구분해 사용자에게 돌려주는 편이 좋습니다. 또 사용자 패턴은 (a+)+b 같은 식으로 백트래킹을 폭발시켜 CPU를 붙잡을 수 있으므로, 신뢰할 수 없는 입력이라면 길이 제한을 두거나 선형 시간을 보장하는 RE2를 쓰는 편이 안전합니다.
try {
std::regex userPattern(input); // 외부 입력
} catch (const std::regex_error& e) {
std::cerr << "잘못된 패턴: " << e.what() << " (code " << e.code() << ")\n";
}
문제 5: 플래그와 지원하지 않는 문법
대소문자를 무시하려면 패턴 앞에 (?i)를 붙이는 대신 std::regex::icase 플래그를 넘겨야 합니다. std::regex의 기본 문법(ECMAScript)은 JavaScript 정규식의 오래된 부분집합이라 (?i) 같은 인라인 플래그, 이름 있는 캡처 (?<name>...), lookbehind (?<=...)를 지원하지 않습니다. 다른 언어에서 가져온 패턴이 여기서 막히는 경우가 많으니, 그룹은 번호(m[1], m[2])로 접근하고 필요한 경우 코드에서 이름을 붙여 다루면 됩니다. 같은 패턴을 반복해서 쓴다면 std::regex::optimize를 함께 주면 생성 비용이 늘어나는 대신 매칭이 빨라질 수 있습니다.
std::regex re("hello", std::regex::icase | std::regex::optimize);
std::regex_search("Hello World", re); // true
정규표현식 문법
이 글에서 다룬 모든 패턴(\d+, (?::(\d+))?, <.*?> 등)은 결국 이 표에 있는 몇 가지 기본 요소의 조합입니다 — 문자 클래스가 “어떤 종류의 문자를 매칭할지”를, 수량자가 “그것이 몇 번 반복될지”를, 앵커가 “문자열의 어느 위치에서 매칭이 시작/끝나야 하는지”를, 그룹이 “매칭된 부분을 어떻게 구조화해서 꺼낼지”를 각각 담당합니다. C++의 std::regex는 기본적으로 ECMAScript(자바스크립트) 문법을 따르므로, 웹 개발에서 정규식을 다뤄본 경험이 있다면 이 문법 대부분이 그대로 적용됩니다 — 다만 POSIX 문법으로 전환하고 싶다면 regex_constants::extended나 basic 플래그를 생성자에 넘겨야 하며, 그 경우 그룹·이스케이프 규칙이 다소 달라진다는 점은 유의해야 합니다.
// 문자 클래스
\d // 숫자 [0-9]
\w // 단어 [a-zA-Z0-9_]
\s // 공백
. // 모든 문자
// 수량자
* // 0회 이상
+ // 1회 이상
? // 0 또는 1회
{n} // 정확히 n회
{n,m} // n~m회
// 앵커
^ // 시작
$ // 끝
\b // 단어 경계
// 그룹
() // 캡처 그룹
(?:) // 비캡처 그룹
FAQ
Q1: 정규표현식은 언제 사용하나요?
A:
- 문자열 검증
- 파싱
- 검색/치환
- 데이터 추출
Q2: 성능은?
A: 복잡한 패턴은 느릴 수 있습니다. 간단한 경우 string 메서드가 더 빠릅니다.
Q3: Raw 문자열은?
A: R”(…)”로 백슬래시 이스케이프 불필요.
Q4: ECMAScript vs POSIX?
A: 기본은 ECMAScript. regex_constants로 변경 가능.
Q5: 정규표현식 디버깅은?
A:
- regex101.com
- regexr.com
- 간단한 패턴부터 테스트
Q6: Regex 학습 리소스는?
A:
- cppreference.com
- “Mastering Regular Expressions”
- regex101.com