Rust의 nom과 pest를 이용한 고성능 파서 구축
Lukas Schneider
DevOps Engineer · Leapcell

소개
소프트웨어 개발 분야에서 구조화된 데이터를 해석하고 처리해야 할 필요성은 어디에나 존재합니다. 설정 파일, 도메인 특정 언어, 네트워크 프로토콜 또는 복잡한 사용자 입력에 이르기까지, 파싱은 많은 애플리케이션의 핵심입니다. 특히 비학술적인 문법의 경우 수동으로 파서를 작성하는 것은 지루하고 오류가 발생하기 쉬운 작업일 수 있지만, 성능과 안전성에 중점을 둔 Rust는 이 작업을 단순화하는 강력한 도구를 제공합니다. 이 블로그 게시물은 Rust 생태계에서 두 가지 주요 파서 조합 라이브러리인 nom
과 pest
를 탐구하며, 개발자가 우아하고 쉽게 효율적이고 강력한 파서를 구축할 수 있도록 지원하는 방법을 보여줍니다. 방법론을 살펴보고, 접근 방식을 비교하며, 다음 파싱 문제에 대한 올바른 도구를 선택하는 데 필요한 지식을 갖추겠습니다.
파싱 전에 알아야 할 핵심 개념
nom
과 pest
의 복잡한 내용을 살펴보기 전에, 작동 방식을 이해하는 데 중요한 몇 가지 기본 개념을 정의해 보겠습니다.
- 파서(Parser): 입력 문자열 또는 바이트 스트림을 받아 추상 구문 트리(AST) 또는 더 간단한 데이터 구조와 같은 구조화된 표현으로 변환하는 함수 또는 구성 요소입니다.
- 조합기(Combinator): 파싱 맥락에서 조합기는 하나 이상의 파서를 입력으로 받아 새 파서를 반환하는 고차 함수입니다. 이를 통해 함수형 프로그래밍 패러다임을 닮은 간단하고 재사용 가능한 구성 요소로 복잡한 파서를 구축할 수 있습니다.
- 문법(Grammar): 언어 또는 데이터 형식의 유효한 구조를 정의하는 규칙 집합입니다. 문법은 종종 Backus-Naur Form (BNF) 또는 Extended Backus-Naur Form (EBNF)과 같은 형식 문법을 사용하여 표현됩니다.
- 추상 구문 트리(AST): 프로그래밍 언어로 작성된 소스 코드의 추상 구문 구조를 트리로 표현한 것입니다. 트리에서 각 노드는 소스 코드에 나타나는 구성을 나타냅니다.
- 렉서(Lexer) 또는 토크나이저(Tokenizer): 파싱의 첫 번째 단계로, 입력 텍스트를 토큰(키워드, 식별자, 연산자 등과 같은 의미 있는 단위)의 시퀀스로 분해합니다.
- 파서 생성기(Parser Generator): 문법 정의를 입력으로 받아 파서 소스 코드를 자동으로 생성하는 도구입니다.
pest
는 파서 생성기의 예입니다. - 파서 조합기 라이브러리(Parser Combinator Library): 작은 파싱 함수에서 파서를 수동으로 구성하는 데 사용할 수 있는 함수(조합기) 집합을 제공하는 라이브러리입니다.
nom
은 파서 조합기 라이브러리의 예입니다.
nom을 이용한 파서 구축
nom
은 Rust를 위한 강력하고 제로 카피(zero-copy) 파서 조합기 라이브러리입니다. 디자인 철학은 파싱 규칙이 작고 테스트하기 쉬운 함수로 구성되는 함수적 접근 방식을 강조합니다. nom
은 바이트 슬라이스 또는 문자열 슬라이스를 직접 처리하여 불필요한 메모리 할당 및 복사를 피하므로 효율성에 크게 기여합니다.
key:value
와 같은 기본 키-값 쌍 형식을 파싱하는 간단한 예제를 사용하여 nom
을 설명해 보겠습니다.
use nom:: bytes::complete::{tag, take_while1}, character::complete::{alpha1, multispace0}, sequence::separated_pair, IResult, ; // 키(영숫자 문자)에 대한 파서 정의 fn parse_key(input: &str) -> IResult<&str, &str> { alpha1(input) } // 값(줄 바꿈 또는 입력 끝까지 모든 문자)에 대한 파서 정의 fn parse_value(input: &str) -> IResult<&str, &str> { take_while1(|c: char| c.is_ascii_graphic())(input) } // 키와 값 파서를 구분 기호로 결합 fn parse_key_value(input: &str) -> IResult<&str, (&str, &str)> { // `separated_pair`는 세 개의 파서를 사용합니다: 첫 번째 요소, 구분 기호, 두 번째 요소. separated_pair(parse_key, tag(":"), parse_value)(input) } fn main() { let input = "name:Alice\nage:30"; match parse_key_value(input) { Ok((remaining, (key, value))) => { println!("Parsed key: {}, value: {}", key, value); println!("Remaining input: '{}'", remaining); } Err(e) => println!("Error parsing: {:?}", e), } let input_with_whitespace = " city:NewYork "; let (remaining, (key, value)) = separated_pair( multispace0.and_then(parse_key), // 키 앞에 선택적 공백 허용 tag(":"), parse_value, )(input_with_whitespace) .expect("Failed to parse with whitespace"); println!("Parsed key: {}, value: {}", key, value); println!("Remaining input: '{}'", remaining); }
이 예제에서:
alpha1
(하나 이상의 알파벳 문자 일치) 및take_while1
(조건이 참인 동안 문자 일치)과 같은nom
의 내장 조합기를 사용하여parse_key
및parse_value
를 정의했습니다.tag(":")
는 리터럴 문자열:
을 일치시키는 간단한 파서입니다.separated_pair
는 세 개의 파서(첫 번째 요소 파서, 구분 기호 파서, 두 번째 요소 파서)를 순차적으로 적용하는 강력한 조합기입니다. 요소 파서의 결과를 튜플로 반환합니다.nom
파서에서 반환되는IResult
유형에는 성공 시 나머지 입력 및 구문 분석된 값, 또는 오류가 포함됩니다.
nom
은 파싱에 대한 세밀한 제어가 필요하거나, 이진 형식(binary format)을 처리하거나, 제로 카피 특성으로 인해 성능이 절대적으로 중요한 경우에 빛을 발합니다. 완전 초보자에게는 학습 곡선이 더 가파를 수 있으며, 이는 여러 개의 작은 파싱 함수를 구성하는 방법을 이해해야 하기 때문입니다.
pest를 이용한 파서 작성
pest
는 파서 생성기를 활용하여 다른 접근 방식을 취합니다. Rust 코드에 파싱 로직을 작성하는 대신, pest
의 사용자 정의 EBNF와 유사한 구문을 사용하여 별도의 파일에 문법을 정의합니다. 그런 다음 pest
는 자동으로 파서 코드를 생성하므로 복잡한 문법 및 도메인 특정 언어(DSL)에 매우 적합하며, 여기서 문법 정의의 가독성과 유지 관리성이 가장 중요합니다.
pest
를 사용하여 동일한 키-값 쌍 형식을 파싱해 보겠습니다. 먼저 key_value.pest
라는 파일에 문법을 정의합니다.
// key_value.pest WHITESPACE = _{ " " | "\t" } key = @{ ASCII_ALPHA+ } value = @{ (ANY - NEWLINE)+ } pair = { key ~ ":" ~ value }
다음으로 main.rs
파일에서 pest
를 통합합니다.
use pest::Parser; use pest_derive::Parser; // 문법 파일에서 생성된 파서 포함 #[derive(Parser)] #[grammar = "key_value.pest"] // 문법 파일 경로 pub struct KeyValueParser; fn main() { let input = "name:Alice\n misure:100cm"; // "pair" 규칙을 사용하여 입력 문자열 구문 분석 let pairs = KeyValueParser::parse(Rule::pair, input) .expect("Failed to parse input"); for pair in pairs { if pair.as_rule() == Rule::pair { let mut inner_rules = pair.into_inner(); let key = inner_rules.next().unwrap().as_str(); let value = inner_rules.next().unwrap().as_str(); println!("Parsed key: {}, value: {}", key, value); } } // 공백이 포함된 예제 (WHITESPACE 규칙으로 자동 처리) let input_with_whitespace = " city:NewYork "; let parsed_with_whitespace = KeyValueParser::parse(Rule::pair, input_with_whitespace) .expect("Failed to parse with whitespace"); for pair in parsed_with_whitespace { if pair.as_rule() == Rule::pair { let mut inner_rules = pair.into_inner(); let key = inner_rules.next().unwrap().as_str(); let value = inner_rules.next().unwrap().as_str(); println!("Parsed key: {}, value: {}", key, value); } } }
key_value.pest
문법에서:
WHITESPACE = _{ " " | "\t" }
는 공백에 대한 규칙을 정의합니다._
는 이를 "보이지 않게" 만듭니다.pest
는 명시적으로 지시하지 않는 한 규칙 사이의 공백을 자동으로 무시합니다.key = @{ ASCII_ALPHA+ }
는 하나 이상의 알파벳 문자를 키로 정의합니다.@
는 일치하는 텍스트를 캡처하려는 것임을 나타냅니다.value = @{ (ANY - NEWLINE)+ }
는 줄 끝 바꿈 문자를 제외한 모든 문자를 하나 이상 값으로 정의합니다. 이것은 "나머지 줄" 값에 대한 일반적인 패턴입니다.pair = { key ~ ":" ~ value }
는key
, 리터럴":"
,value
규칙을 결합하여pair
를 형성합니다.~
연산자는 순차적 일치를 나타냅니다.
pest
는 다음과 같은 경우에 탁월합니다:
- 복잡하고 형식적으로 정의된 문법을 다룰 때.
- 문법 가독성과 유지 관리성이 중요할 때.
- 문법 규칙을 선언적으로 정의하는 것을 선호할 때.
- 생성된 파서 오버헤드가 허용될 때.
nom과 pest 중 선택하기
nom
과 pest
모두 훌륭한 도구이지만, 약간 다른 사용 사례와 선호도를 충족시킵니다.
특징 | nom | pest |
---|---|---|
접근 방식 | 파서 조합기 라이브러리 (명령형) | 파서 생성기 (선언형, 문법 기반) |
문법 정의 | Rust 코드 (함수, 매크로) | 별도 .pest 파일 (EBNF 유사 구문) |
성능 | 일반적으로 매우 높음 (제로 카피 파싱) | 높지만, 생성된 코드에 약간의 오버헤드가 있음 |
유연성 | 높음, 바이너리 형식, 사용자 정의 로직에 이상적 | 보통, 텍스트 기반 문법에 탁월함 |
학습 곡선 | 복잡한 시나리오에서 더 가파름 | 문법 정의에 더 접근하기 쉬움 |
오류 처리 | 명시적 IResult 처리 | 스팬 정보가 포함된 기본 오류 보고 |
사용 사례 | 네트워크 프로토콜, 바이너리 데이터, 단순 라인 프로토콜 | DSL, 구성 파일, 프로그래밍 언어, 마크업 |
순수한 속도와 저수준 제어가 필요한 경우, 특히 바이너리 입력을 다룰 때 nom
이 종종 선택되는 도구입니다. 조합기 방식은 일단 익숙해지면 매우 강력할 수 있습니다. 반면에 pest
는 명확하고 유지 관리 가능한 DSL을 허용하는 강력한 문법 정의 언어와 코드 생성을 통해 언어 파싱, DSL 또는 구분된 문법 정의와 파싱 논리 사이의 명확한 구분이 유리한 모든 시나리오를 단순화합니다.
궁극적으로 선택은 문법의 복잡성, 성능 요구 사항 및 각 패러다임에 대한 편안함 수준에 달려 있습니다. 경우에 따라 개발자는 렉서(토크나이저)에는 nom
을 사용하고, 그런 다음 해당 토큰을 pest
생성 파서에 공급하여 구문 분석을 수행하는 등 요소를 결합하기도 합니다.
결론
Rust는 효율적이고 강력한 파서를 구축할 수 있는 탁월한 기능을 제공하며, nom
과 pest
는 이 영역에서 선도적인 라이브러리로 돋보입니다. 함수형 파서 조합기 접근 방식을 사용하는 nom
은 탁월한 성능과 세밀한 제어를 제공하여 저수준 및 바이너리 파싱 작업에 이상적입니다. 반면 pest
는 강력한 문법 정의 언어와 코드 생성을 통해 복잡한 텍스트 기반 파서를 생성하는 과정을 단순화하여 명확하고 유지 관리 가능한 DSL을 허용합니다. 핵심 원칙과 적용 시나리오를 이해함으로써 Rust 개발자는 어떤 파싱 문제라도 자신 있게 해결할 올바른 도구를 선택할 수 있으며, 구조화되지 않은 데이터를 정밀하고 빠르게 의미 있는 인사이트로 변환할 수 있습니다.