콘텐츠로 이동

Rust 정규 표현식 (regex 크레이트)

정규 표현식(regular expression, 줄여서 regex)은 텍스트를 위한 작은 패턴 언어예요. “cat이라는 단어를 찾아”라고 말하는 대신 “숫자 네 개, 하이픈, 숫자 두 개, 하이픈, 숫자 두 개를 찾아”라고 말할 수 있어서, 2024-05-17 같은 날짜를 한꺼번에 전부 찾아낼 수 있어요.

Rust 표준 라이브러리에는 contains, split, find 같은 편리한 문자열 메서드가 있어요(문자열 참고). 정해진 텍스트 조각만으로는 부족하고 텍스트의 모양을 찾아야 할 때는 regex (영어) 크레이트를 쓰세요. Rust 프로젝트에서 관리하는 크레이트이고, 아주 빨라요.

regex 추가하고 처음으로 매칭해 보기

섹션 제목: “regex 추가하고 처음으로 매칭해 보기”

프로젝트 폴더에서 다음을 실행하세요.

Terminal window
cargo add regex

그러면 Cargo.toml의 [dependencies] 아래에 regex = "1"이 추가돼요. (크레이트가 처음이라면 Cargo와 크레이트를 참고하세요.) “Try it” 버튼은 Rust Playground를 여는데, Playground에는 이미 regex가 들어 있어요.

use regex::Regex;
fn main() {
let re = Regex::new(r"\d{4}-\d{2}-\d{2}").unwrap();
println!("{}", re.is_match("2024-05-17에 출시됐어요."));
println!("{}", re.is_match("지난봄에 출시됐어요."));
}

실행 결과:

true
false
▶ 직접 실행해 보기

\d는 “아무 숫자”를, {4}는 “바로 앞의 것이 정확히 네 개”를 뜻해요. is_match는 한 가지 질문에 답해요. 텍스트 어딘가에 매칭되는 부분이 있나요?

패턴 앞의 r을 눈여겨보세요. r"\d{4}"처럼요. 이것은 원시 문자열(raw string)이에요. 일반 Rust 문자열에서 \는 \n(줄바꿈) 같은 이스케이프를 시작해요. 정규 표현식 패턴에는 백슬래시가 가득하기 때문에, 원시 문자열을 쓰면 "\\d{4}"처럼 쓰지 않아도 돼요. 패턴에는 항상 원시 문자열을 쓰세요.

Regex::new가 Result를 반환하는 이유

섹션 제목: “Regex::new가 Result를 반환하는 이유”

패턴은 작은 프로그램이라서, 닫는 괄호를 빠뜨리는 것 같은 실수가 있을 수 있어요. Regex::new는 패턴을 검사해서 Result를 반환해요. 올바르면 Ok(Regex)를, 아니면 도움이 되는 메시지가 담긴 Err를 돌려줘요.

use regex::Regex;
fn main() {
match Regex::new(r"(\d+") {
Ok(_) => println!("올바른 패턴이에요"),
Err(e) => println!("잘못된 패턴이에요:\n{e}"),
}
}

실행 결과:

잘못된 패턴이에요:
regex parse error:
(\d+
^
error: unclosed group
▶ 직접 실행해 보기

regex 크레이트의 에러 메시지는 영어로 나와요. “정규 표현식 파싱 에러: 닫히지 않은 그룹(unclosed group)“이라는 뜻이고, ^가 문제의 위치를 가리켜요.

패턴을 코드에 직접 썼고 올바르다는 걸 알고 있다면 .unwrap()을 써도 괜찮아요. 틀렸다면 프로그램을 처음 실행할 때 바로 알게 되니까요. 패턴이 사용자에게서 온다면(예를 들어 검색창), 프로그램이 죽지 않도록 Err를 처리하세요.

find는 첫 번째 매칭을 Option으로 반환해요. find_iter는 모든 매칭을 도는 반복자를 줘요.

use regex::Regex;
fn main() {
let re = Regex::new(r"\d+").unwrap();
let text = "3 apples, 12 pears and 7 plums";
if let Some(m) = re.find(text) {
println!("첫 번째 숫자: {} (바이트 위치 {}..{})", m.as_str(), m.start(), m.end());
}
let all: Vec<&str> = re.find_iter(text).map(|m| m.as_str()).collect();
println!("모든 숫자: {:?}", all);
let total: u32 = re.find_iter(text).map(|m| m.as_str().parse::<u32>().unwrap()).sum();
println!("과일 합계: {total}");
}

실행 결과:

첫 번째 숫자: 3 (바이트 위치 0..1)
모든 숫자: ["3", "12", "7"]
과일 합계: 22
▶ 직접 실행해 보기

\d+는 “숫자 하나 이상”이라는 뜻이라서, 12는 두 개가 아니라 하나의 매칭으로 찾아져요. 각 매칭은 자신의 텍스트(as_str())와 위치(start()와 end(), 바이트 단위)를 알고 있어요. 이 예제의 텍스트(“사과 3개, 배 12개, 자두 7개”)를 영어로 둔 것도 이 때문이에요. 한글은 한 글자가 3바이트라서 위치 숫자가 달라지거든요.

매칭 전체가 아니라 그 일부분이 필요할 때가 많아요. 패턴의 일부를 괄호로 감싸면 그 부분을 캡처(capture)할 수 있어요. (?<name>...)으로 이름을 붙이면 코드가 더 읽기 쉬워져요.

use regex::Regex;
fn main() {
let re = Regex::new(r"(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})").unwrap();
let text = "2024-05-17과 2024-06-02에 백업을 실행했어요.";
if let Some(caps) = re.captures(text) {
println!("전체 매칭: {}", &caps[0]);
println!("연도: {}", &caps["year"]);
println!("월: {}", &caps[2]);
println!("일: {}", caps.name("day").unwrap().as_str());
}
for caps in re.captures_iter(text) {
println!("{}/{}/{}", &caps["day"], &caps["month"], &caps["year"]);
}
}

실행 결과:

전체 매칭: 2024-05-17
연도: 2024
월: 05
일: 17
17/05/2024
02/06/2024
▶ 직접 실행해 보기

그룹을 읽는 방법은 세 가지예요.

  • caps[0]은 항상 매칭 전체예요. 그룹은 왼쪽부터 1, 2, 3… 순서로 번호가 붙어요. caps[2]는 월이에요.
  • caps["year"]는 이름으로 그룹을 읽어요. [...]로 인덱싱할 때 그 그룹이 매칭에 참여하지 않았다면 패닉이 나요.
  • caps.name("day")는 Option을 반환해요. 그룹이 선택 사항일 때는 이쪽이 안전해요.

captures는 첫 번째 매칭을 찾아요. captures_iter는 find_iter처럼 모든 매칭을 차례로 돌아요.

replace는 첫 번째 매칭을, replace_all은 모든 매칭을 바꿔요. 바꿀 텍스트 안에서 $name(또는 $1)을 쓰면 그룹이 캡처한 내용이 들어가요.

use regex::Regex;
fn main() {
let spaces = Regex::new(r"\s+").unwrap();
println!("{}", spaces.replace_all("공백이 너무 많아요", " "));
let date = Regex::new(r"(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})").unwrap();
let text = "2024-05-17부터 2024-06-02까지";
println!("{}", date.replace(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$d/$m/$y"));
println!("{}", date.replace_all(text, "$m_$y"));
println!("{}", date.replace_all(text, "${m}_${y}"));
}

실행 결과:

공백이 너무 많아요
17/05/2024부터 2024-06-02까지
17/05/2024부터 02/06/2024까지
2024부터 2024까지
05_2024부터 06_2024까지
▶ 직접 실행해 보기

네 번째 줄을 보세요. 05_2024가 아니라 2024부터 2024까지가 출력됐어요. 왜 그럴까요? 그룹 이름에는 _가 들어갈 수 있어서, $m_이 m_이라는 그룹으로 읽혔기 때문이에요. 그런 그룹은 없으니 빈 텍스트가 된 거예요. ${m}_처럼 이름을 중괄호로 감싸면 이름이 어디서 끝나는지 정확히 알려 줄 수 있어요. 헷갈리면 중괄호를 쓰세요.

\s+는 “공백 문자 하나 이상”이라는 뜻이라서, 이것을 공백 하나로 바꾸는 건 지저분한 입력을 정리하는 대표적인 방법이에요.

split은 패턴이 매칭되는 곳마다 문자열을 잘라요. str::split과 비슷하지만, 구분자로 패턴을 쓸 수 있어요.

use regex::Regex;
fn main() {
let sep = Regex::new(r"\s*[,;]\s*").unwrap();
let parts: Vec<&str> = sep.split("사과, 바나나;체리 , 대추").collect();
println!("{:?}", parts);
let two: Vec<&str> = sep.splitn("a, b, c, d", 2).collect();
println!("{:?}", two);
}

실행 결과:

["사과", "바나나", "체리", "대추"]
["a", "b, c, d"]
▶ 직접 실행해 보기

[,;]는 “쉼표 또는 세미콜론”이라는 뜻이고, 양쪽의 \s*가 그 주변의 공백을 모두 삼켜요. splitn(text, 2)는 두 조각이 되면 멈춰요.

한 번만 컴파일하고, 반복문 안에서는 하지 않기

섹션 제목: “한 번만 컴파일하고, 반복문 안에서는 하지 않기”

Regex를 만드는 게 느린 부분이에요. 크레이트가 패턴을 효율적인 매칭 기계로 바꾸거든요. 만들어진 것을 사용하는 건 빨라요. 그러니 반복문 안이나 자주 실행되는 함수 안에서 Regex::new를 호출하지 마세요. 한 번 만들어서 재사용하세요.

프로그램 곳곳에서 쓰는 패턴이라면 std::sync::LazyLock을 써서 static에 넣으세요. 처음 사용할 때 만들어지고, 그다음부터는 공유돼요.

use regex::Regex;
use std::sync::LazyLock;
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$").unwrap()
});
fn is_email(s: &str) -> bool {
EMAIL.is_match(s)
}
fn main() {
for input in ["[email protected]", "[email protected]", "not-an-email", "x@y"] {
println!("{input:22} -> {}", is_email(input));
}
}

실행 결과:

not-an-email -> false
x@y -> false
▶ 직접 실행해 보기

이 패턴에 새로 나온 것들이에요.

  • (?i)는 대소문자를 구분하지 않는 매칭을 켜요. 그래서 [a-z]가 B나 M에도 매칭돼요.
  • ^와 $는 “텍스트의 시작”과 “텍스트의 끝”을 뜻해요. 이게 없으면 is_match는 "hello [email protected]!!"에도 참이라고 답해요. 이메일이 그 안 어딘가에 있으니까요.
  • \.은 진짜 점이에요. 그냥 .은 “아무 문자”라는 뜻이에요.

이건 “이메일처럼 생겼나?“를 확인하는 검사라서 오타를 잡기에 좋아요. 정규 표현식으로 모든 올바른 이메일 주소를 완벽하게 검증하는 건 어렵기로 유명해요. 그래서 진짜로 확인하는 유일한 방법은 그 주소로 메일을 보내 보는 거예요.

예제: 로그에서 데이터 뽑아내기

섹션 제목: “예제: 로그에서 데이터 뽑아내기”

지금까지 배운 걸 모두 합쳐 볼게요. 로그를 읽고, 형식에 맞지 않는 줄은 건너뛰고, ERROR 줄마다 날짜, 시간, 메시지 안의 이메일 주소를 출력해요.

use regex::Regex;
use std::sync::LazyLock;
static LOG_LINE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(?<date>\d{4}-\d{2}-\d{2}) (?<time>\d{2}:\d{2}:\d{2}) (?<level>[A-Z]+) +(?<msg>.*)$").unwrap()
});
static EMAIL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap()
});
fn main() {
let log = "\
2024-05-17 09:12:03 INFO user [email protected] logged in
2024-05-17 09:15:44 ERROR payment failed for [email protected]
이 줄은 엉터리예요
2024-05-18 11:02:10 WARN disk 91% full
2024-05-18 11:05:00 ERROR could not email [email protected] or [email protected]";
let mut errors = 0;
for line in log.lines() {
let Some(caps) = LOG_LINE.captures(line) else {
println!("건너뜀: {line:?}");
continue;
};
if &caps["level"] == "ERROR" {
errors += 1;
let emails: Vec<&str> = EMAIL.find_iter(&caps["msg"]).map(|m| m.as_str()).collect();
println!("{} {} -> {:?}", &caps["date"], &caps["time"], emails);
}
}
println!("에러 {errors}개를 찾았어요");
}

실행 결과:

2024-05-17 09:15:44 -> ["[email protected]"]
건너뜀: "이 줄은 엉터리예요"
2024-05-18 11:05:00 -> ["[email protected]", "[email protected]"]
에러 2개를 찾았어요
▶ 직접 실행해 보기

몇 가지 짚고 넘어갈게요.

  • log 앞부분의 "\는 Rust의 작은 요령이에요. 문자열 안에서 줄 끝에 백슬래시를 두면 줄바꿈을 건너뛰어서, 첫 번째 로그 줄이 바로 시작돼요.
  • let Some(caps) = ... else { ... }는 let-else예요. 줄이 매칭되지 않으면 그 줄을 출력하고 continue로 다음 줄로 넘어가요.
  • (?<level>[A-Z]+) +는 로그 레벨과 그 뒤의 공백 하나 이상에 매칭돼요. INFO와 WARN은 ERROR와 줄을 맞추려고 공백이 하나 더 들어가 있거든요.
  • 실제 프로그램이라면 로그를 파일에서 한 줄씩 읽을 거예요. 파일 입출력을 참고하세요.

JavaScript, Python, Perl에서 정규 표현식을 써 봤다면 전후방 탐색(look-around, (?=...), (?<=...))이나 역참조(backreference, \1, “1번 그룹과 같은 텍스트”)를 쓰고 싶어질 수 있어요. regex 크레이트는 이런 기능을 지원하지 않고, 그렇다고 알려 줘요.

regex parse error:
foo(?=bar)
^^^
error: look-around, including look-ahead and look-behind, is not supported

“전방 탐색과 후방 탐색을 포함한 전후방 탐색은 지원하지 않는다”는 뜻이에요.

이건 의도적인 선택이에요. 이런 기능이 있으면 어떤 패턴은 특정 입력에서 지수 시간(exponential time)이 걸릴 수 있어요. 그래서 짧은 패턴과 충분히 긴 문자열만으로 프로그램이 멈춰 버릴 수 있죠(이것을 파국적 백트래킹(catastrophic backtracking)이라고 하는데, 실제 웹사이트를 다운시킨 적도 있어요). 이런 기능이 없기 때문에 regex 크레이트는 매칭 시간이 텍스트 길이에 비례해서만(패턴 크기를 곱한 만큼) 늘어난다고 보장할 수 있어요. 텍스트가 두 배면 일도 대략 두 배일 뿐, 폭발적으로 늘어나는 일은 없어요. 그래서 패턴이나 텍스트가 사용자에게서 오더라도 안전해요.

보통은 다른 방법으로 같은 결과를 얻을 수 있어요. 캡처 그룹을 써서 원하는 부분만 읽거나, 평범한 Rust 코드로 간단한 검사를 한 번 더 하면 돼요. 이런 기능이 정말 필요하다면 fancy-regex (영어) 크레이트가 그 기능을 제공해요. 대신 시간 보장은 없어요.

패턴 매칭되는 것
. 줄바꿈을 제외한 아무 문자
\d \w \s 숫자, “단어” 문자(글자, 숫자, _), 공백 문자
\D \W \S 반대: 숫자가 아닌 것, 단어 문자가 아닌 것, 공백이 아닌 것
[abc] [a-z] [^0-9] a, b, c 중 하나; 아무 소문자; 숫자가 아닌 아무것
* + ? 바로 앞의 것이 0개 이상, 1개 이상, 0개 또는 1개
{3} {2,5} {2,} 정확히 3개, 2개에서 5개, 2개 이상
^ $ 텍스트의 시작과 끝
\b 단어 경계(단어 문자와 단어가 아닌 문자 사이)
(...) (?<name>...) 번호가 붙는 그룹, 이름 있는 그룹
(?i) 여기서부터 대소문자 구분 안 함

하나 더 있어요. 세로 막대는 “또는”이라는 뜻이에요. 예를 들어 cat|dog은 두 단어 중 하나에 매칭되고, gr(a|e)y는 gray와 grey에 매칭돼요. 특수 문자를 글자 그대로 매칭하려면 앞에 백슬래시를 붙이세요. \., \(, \$처럼요.

원시 문자열을 깜빡하기. 일반 문자열에서 \d는 올바른 이스케이프가 아니라서, regex 크레이트가 패턴을 보기도 전에 Rust가 멈춰요.

use regex::Regex;
fn main() {
let re = Regex::new("\d+").unwrap();
println!("{}", re.is_match("id=22"));
}
error: unknown character escape: `d`
--> src/main.rs:4:27
|
4 | let re = Regex::new("\d+").unwrap();
| ^ unknown character escape
|
help: if you meant to write a literal backslash (perhaps escaping in a regular expression), consider a raw string literal
|
4 | let re = Regex::new(r"\d+").unwrap();
| +

d는 알 수 없는 이스케이프 문자라는 에러예요. 컴파일러는 해결책까지 제안해 줘요. “백슬래시를 글자 그대로 쓰려던 거라면(정규 표현식의 이스케이프처럼) 원시 문자열 리터럴을 고려하세요.” 앞에 r을 붙이면 돼요.

반복문 안에서 regex 만들기. 컴파일도 되고 동작도 하지만, 줄마다 패턴을 다시 컴파일하기 때문에 느려요.

for line in lines {
let re = Regex::new(r"\d+").unwrap(); // 매번 다시 컴파일돼요
if re.is_match(line) { /* ... */ }
}

Regex::new를 반복문 위로 옮기거나, 앞에서 본 것처럼 LazyLock static에 넣으세요.

검증할 때 ^와 $를 빠뜨리기. is_match는 패턴을 어디서든 찾아요. 입력 전체가 어떤 모양인지 확인하려면 패턴의 양 끝을 고정하세요.

  • cargo add regex로 크레이트를 추가하고, 패턴은 원시 문자열로 쓰세요. r"\d+"처럼요.
  • 패턴이 잘못됐을 수 있기 때문에 Regex::new는 Result를 반환해요.
  • is_match는 예/아니요로 답하고, find/find_iter는 매칭된 텍스트를, captures/captures_iter는 번호나 (?<name>...) 이름으로 그룹을 줘요.
  • replace와 replace_all에서는 $name으로 그룹을 넣을 수 있어요. 뒤에 글자나 _가 오면 ${name}을 쓰세요.
  • 패턴은 한 번만 컴파일해서 재사용하세요. 예를 들어 static LazyLock에 넣어서요.
  • 전후방 탐색과 역참조는 설계상 지원하지 않아요. 그 대가로 매칭은 항상 선형 시간이에요.

이해도 점검

1. Rust의 정규 표현식 패턴이 r"\d+"처럼 보통 r로 시작하는 이유는 무엇일까요?

2. 패턴이 (?<y>\d{4})-(?<m>\d{2})일 때, 텍스트 2024-05에서 caps["m"]은 무엇을 줄까요?

3. regex 크레이트가 전후방 탐색과 역참조를 지원하지 않는 이유는 무엇일까요?

4. 큰 파일의 모든 줄에 쓸 패턴이라면 Regex::new는 어디서 호출하는 게 가장 좋을까요?

점수: 0 / 4