요약
파서 콤비네이터는 정규식보다 읽기 쉽고 tree-sitter보다 가벼운 파싱 방법입니다.
빈에서 일하는 개발자 플로가 애니메이션을 곁들여 장별로 풀어 쓴 해설입니다. 정규식은 복잡한 작업에서 금세 지저분해지고 tree-sitter(코드 편집기의 문법 강조에 쓰이는 무거운 파싱 라이브러리)는 과할 때가 많다며, 그 사이를 채울 도구로 파서 콤비네이터를 소개했습니다.
왜 중요한가
- 정규식 하나로는 버겁고 정식 파서를 들이기는 부담스러운 중간 크기 작업에 쓸 방법을 보여 줍니다.
- 파서가 평범한 함수라서 작은 조각을 따로 만들고 시험한 뒤 다시 조합해 쓸 수 있습니다.
- 라이브러리 없이 TypeScript로 처음부터 만들어 보여 줘서 원리를 그대로 따라갈 수 있습니다.
핵심 내용
- 파서를 문자열을 받아 읽어 낸 값과 남은 문자열을 돌려주는 함수로 정의합니다.
- 차례로 실행하는
pSeq, 결과 모양을 바꾸는pMap, 실패할 때까지 되풀이하는pRepeat이 기본 도구입니다. - 클라우드플레어 1.1.1.1에 보낸 ping 출력을 헤더, 응답 패킷, 통계로 나눠 타입이 있는 객체로 바꾸는 예제를 끝까지 만듭니다.
pSeq를 겹쳐 쓰면 같은 일을 하는 정규식은 읽을 수 없게 되지만, 콤비네이터 코드는 읽기 쉽게 남는다고 했습니다.- 파싱은 여럿을 하나로 접는 reduce(폴드)와 반대로, 하나를 여럿으로 쪼개는 언폴드라고 설명했습니다.
HN 반응
- 애니메이션이 이해를 돕는다는 칭찬과 함께, 장을 넘기는 화면이라 되돌아보거나 검색하기 어렵다는 불만이 이어졌습니다.
- 결과를 튜플로 묶었다 푸는 코드가 늘어난다는 지적에는, 하스켈이나 OCaml에서는 전용 문법 덕분에 그런 번거로움이 적다는 반론이 나왔습니다.
파이썬을 쓰는데 문제가 정규식 하나로 처리하기엔 크고, 기사에 나온 것 같은 본격적인 파서를 쓰기엔 작다면 내장 re.Scanner0를 추천합니다.
re모듈에 들어 있는 정규식 기반 토크나이저1인데(그래서 재귀 파싱은 안 됩니다), 무슨 이유에선지 공식 문서에는 전혀 나오지 않습니다.토큰 종류마다 패턴을 주고 매치를 처리할 함수를 주면, 처리된 토큰의 리스트를 돌려줍니다.
중요한 점은 리스트를 한 번에 처리하면서 매치가 끊기지 않고 이어지는지 확인해 준다는 겁니다. 캡처 그룹을 쓴 단순한
re.findall은 매치되지 않은 문자를 그냥 무시합니다. 실행 중인 스캐너에 대한 참조도 받을 수 있어서, 오류를 보고할 때 쓸 매치 위치를 기록할 수도 있습니다.와! 좋은 꿀팁이네요.
“re”보다는 크고 “parsimonious”보다는 작은, 애매한 영역에 딱 맞는 도구네요.
보통 이런 주제들은 서로 겹치지 않게
따로 언급되더라고요. (적어도 제가 지난번에 사람이 읽기 쉬운 정규식을 만들고 싶어서 파고들었던 좁은 토끼굴에서는 그랬어요.)
https://github.com/mikaelho/python-human-regex
https://github.com/vinta/awesome-python#text-processing
가독성과 의사소통에 문제가 있다니, 참 파이썬답네요.
파서 컴비네이터라는 개념을 꽤 잘 설명한 글입니다.
저는 개인적으로 모나드와 데이터 처리라는 개념이 무척 흥미롭습니다. 몇 년 전 어디선가 “모나딕 파서 컴비네이터”에 관한 논문을 읽었는데, 호기심이 생겨서 이 아이디어로 많이 실험해 봤습니다. 반환값에 대한 제 첫 생각도 이 글과 똑같았습니다.
추출한 값과 나머지를 함께 반환하는 것이 “논리”상으로는 말이 되지만, 성능 면에서는 그렇지 않습니다. 문자열 슬라이싱이 인덱스만 가지고 작업하는 것보다 훨씬 느린 것 같습니다. 적어도 nodejs에서는 그렇습니다.
다만 인덱스 방식으로 하면 순수성을 유지하기 위해 함수 시그니처가 조금 달라져야 해서, 아쉽게도 그만큼 쓰기에 덜 우아해집니다.
이 타입스크립트 라이브러리는 이것저것 짜깁기해서 만들었고, 나중에는 Claude의 도움도 받았습니다(뻔뻔한 자기 홍보입니다: https://github.com/sovrin/unitas ). 타입이 어느 정도는 붙어 있고(재귀는 되는 경우도 있고 안 되는 경우도 있습니다) 필요할 만한 함수는 다 들어 있을 겁니다.
애니메이션이 정말 마음에 듭니다. 무슨 일이 일어나는지 직관적으로 이해하게 해 주네요!
제 생각에 파서 컴비네이터의 단점 하나는, 예를 들어 (별도의 문법 파일로 파서를 생성하는) 파서 제너레이터 라이브러리와 비교하면 매핑/언래핑 코드를 많이 추가해야 한다는 점입니다. 제 생각에는 이 때문에 파서를 읽기가 더 어려워집니다.
이를 개선해 보려고 러스트로 파서 컴비네이터 라이브러리를 만들고 있습니다. 컴비네이터를 두 개(capture와 bind) 더 추가해서, 파서의 결과를 버킷에 저장하고(bind) 나중에 그 버킷에 저장된 값으로 결과를 만들 수(capture) 있게 했습니다. 그러면 언래핑/매핑 코드를 없애고 문법과 결과 생성을 분리할 수 있습니다.
시간 되시면 피드백을 주시면 정말 감사하겠습니다!
여기서 보실 수 있습니다: https://github.com/ArneCode/marser
제가 처음 접한 파서 컴비네이터는 파서 제너레이터 라이브러리 안에 있었습니다: https://github.com/engelberg/instaparse
(OP가 쓴 방식이 파서 컴비네이터를 처음 도입한 원조 방식이라는 걸 방금 알았으니, 아마 이상하게 들리겠습니다만) 파서가 이렇게 그냥 함수인 파서 컴비네이터는 이번에 처음 봤습니다. 파서를 조합하고 싶은데 매핑/언래핑 코드를 잔뜩 쓰기는 싫다면, 파서 제너레이터가 만들어 준 파서 위에 컴비네이터를 쓰면 되지 않나요?
이게 무슨 의미가 있는지 잘 모르겠어요. roll 예제를 Winnow로 쓰면 (언)래핑 로직이 그렇게 많지도 않거든요:
이 경우에는 그렇습니다. 하지만 그건 roll 문법이 상대적으로 단순해서, 서로 다른 컴비네이터가 중첩되는 일 등이 없기 때문이라고 생각합니다. (이해하기 쉬워서 README에 쓴 예제입니다.) winnow 코드도 “d” 값을 무시하려고 separated_pair를 쓰는데, 이 역시 count와 sides 결과만 남기는 로직을 얹는 셈입니다.
비교용으로 써 온 문법 하나는 함수 시그니처입니다. nom, chumsky와 비교해 봤습니다: https://blog.arnedebo.com/posts/a-grammar-first-approach-to-parser-combinators/#parsing-function-signatures
이 라이브러리들은 “언래핑” 코드를 separated_by나 .ignore_then 같은 컴비네이터로 대체하는 방법을 찾아냈지만, 그래도 결과 처리 코드가 많이 남아서 제 생각에는 코드를 읽고 이해하기가 더 어려워집니다. 제가 개선하려는 것이 바로 이 부분입니다.
물론 아주 주관적인 문제라서, 다른 라이브러리들의 방식을 더 선호하는 분도 있을 겁니다.
파서 컴비네이터를 쓰면 대체 어떤 매핑/언래핑을 감수해야 하나요?
이 사이트의 pSeq처럼 파서 두 개를 조합할 때는 각 파서의 반환값을 어떻게 할지 생각해야 합니다. 한 가지 방법은 모든 반환값을 튜플에 넣는 겁니다. 파서 p1,p2,...,p_n을 순서대로 조합한다고 하고 R(p)를 파서 p의 반환값이라고 하면, pSeq(p1,p2,...,p_n)이 만든 파서의 반환값은 (R(p1), R(p2), ..., R(p_n))입니다.
파서를 작성할 때는 컴비네이터를 여러 겹 중첩해서 쓰는 일이 흔합니다. 그러면 튜플이 중첩되고, 여기서 값을 꺼내는 일이 곧 이 튜플을 “언래핑”하는 일입니다. 게다가 순서에 들어 있는 값을 전부 쓰지는 않을 수 있습니다. 공백을 파싱하기 위한 것일 뿐인 값도 있으니까요. 그래서 파서 결과로 값을 만들 때는 이런 값을 무시해야 합니다. 예를 들어 결과 튜플을 “매핑”해서 관련 있는 필드만 남기는 식으로 할 수 있습니다. 다른 방법도 있는데, 예컨대 R(p1)만 반환하는 p1.then_ignore(p2)처럼 파서를 이어 붙이는 방법입니다.
표현을 좀 더 잘 골랐으면 좋았겠네요. 제가 뜻한 바가 이제 더 분명해졌기를 바랍니다.
제 라이브러리로 작성한 문법의 파서와 다른 라이브러리로 작성한 파서를 나란히 비교한 글도 써 봤습니다. 예시로 도움이 될 수 있겠습니다: https://blog.arnedebo.com/posts/a-grammar-first-approach-to-parser-combinators/
저는 하스켈에서 늘 파서 컴비네이터를 써 왔는데, 말씀하신 그런 건 본 적이 없습니다. 하스켈에서는 보통 각 부분을 원하는 함수(예: AST 노드 생성자)에 바로 적용하니까 그런 것 같습니다.
우리는 하스켈 덕분에 여러 모로 호강하고 있어요!
파서 컴비네이터 하나만으로도 모나드(정확히는 애플리커티브 펑터)를 쓸 만한 가치가 있죠. 게다가 모나드로 할 수 있는 멋진 일이 그 밖에도 얼마나 많은데요!
맞아요. <$와 $> 연산자는 파서에 쓰기에 정말 우아해요.
맞아요. <$와 $> 연산자는 파서에 쓰기에 정말 우아해요.
맞아요. <$와 $> 연산자는 파서에 쓰기에 정말 우아해요.
하스켈은 do 표기법으로 튜플을 만들고 푸는 과정을 숨길 수 있기 때문이라고 생각합니다. 저는 주로 OCaml에서 파서 컴비네이터를 쓰는데, 바인딩 연산자로 비슷한 요령을 씁니다. 이런 문법적 설탕이 없으면 훨씬 다루기 번거로워지는 게 사실입니다.
쓰시는 언어에 튜플 구조 분해 문법이 기본으로 있나요?
무시하는 예시는 하스켈에서는 그냥
p1 <* p2예요(그 연산자는 표준 라이브러리에 있어요).이게 왜 한 페이지로 안 되어 있는지 이해가 안 돼요. 슬라이드쇼라서 새로운 정보가 나올 때 앞의 “챕터”를 다시 찾아보기가 정말 어렵고, 검색도 안 되고요.
디지털 매체에는 없는 종이 매체의 제약을, 괜한 미적 취향 때문에 굳이 다시 들여오는 것 같지 않나요?
맞아요, 저도 처음에는 계속 읽을 엄두가 안 났어요 .. 끝까지 읽길 잘했지만요 .. 다음으로 넘기는 방법을 몰라서, 그냥 스크롤하면 될 걸 스크롤과 넘기기를 번갈아 해야 하는 게 짜증스러웠어요 ..
그래도 사용자 경험이 훌륭한 내용을 가려 버리네요.
말씀하시는 바는 알겠는데, 저는 좋았어요! 각 부분을 따로 떼어 놓으니 오히려 더 읽기 편하게 느껴지거든요. 이상적인 세상이라면 이런 글마다 독자가 “슬라이드쇼”와 “한 페이지” 보기를 전환할 수 있게 해 줄 텐데요. 같은 내용이 둘 다에 어울리니까요.
맞아요, 그러면 좋겠어요. 다만 챕터/섹션을 간단히 보이거나 숨기는 기능만 있어도 꽤 도움이 될 것 같아요. 코드 에디터의 폴더나 코드 조각 접기와 비슷한 식으로요(예를 들어 VS Code에 이 기능이 있죠).
정말 멋진 웹사이트이고 이 주제도 아주 좋아합니다.
그런데 작은 화면에서도 한번 테스트해 보시면 좋겠습니다. 관심 있는 콘텐츠를 위한 화면 공간이 너무 제한적이에요. 오른쪽 위 절반은 여백이 많고, 스크롤되는 콘텐츠는 사실상 사용 가능한 화면 공간의 3분의 1 정도만 쓰고, 왼쪽에도 또 다른 3분의 1 정도가 차지되어 있습니다.
님의 웹사이트이고 님의 비전이니 이해하고, UI에서 다른 시도를 하시는 것도 존중합니다. 하지만 목표가 생각을 전달하는 것이라면, 독자로서는 콘텐츠에 더 많은 화면 공간이 주어지면 좋겠습니다. 이 글을 보려고 링크를 눌렀을 때는 다른 글에는 관심이 없거든요(적어도 일부러 찾아온 이 글을 다 읽기 전까지는요).
참고로 지금 맥북 에어를 쓰고 있습니다.
피드백 감사합니다. 유튜브 영상과 블로그 글의 중간쯤, 한입 크기로 나눠 소비할 수 있는 무언가를 만들자는 아이디어였습니다. 비주얼이 말을 대부분 하게 하고 싶었는데, 파서 컴비네이터는 결국 글이 많이 나온 것 같네요. UI를 좀 더 다듬고, 테스트용으로 더 작은 폰도 구해 보겠습니다.
계속 실험해 보세요.
그리고 오른쪽 위에서 일어나는 일도 멋지지만, 사용자는 다른 콘텐츠를 어떻게 봐야 하는지 알아내는 데 정신이 팔려 있어서 그걸 제대로 감상하기가 어렵습니다. 아마 거기에 가장 많은 공을 들이셨을 텐데 말이죠.
좀 더 다듬어진 경험을 목표로 하신다면, 의도한 경험이 무엇인지 사용자가 헷갈릴 만한 요소를 줄이는 것도 고려해 볼 만합니다. 많은 사용자는 UI 각 부분이 어떻게 동작하는지에 대해 이미 선입견이 있어서, 의도하신 경험을 전달하는 데 방해가 될 수 있습니다.
또 하나 나쁜 점은 페이지를 넘겨도 브라우저 기록에 남지 않아서, 브라우저의 뒤로/앞으로 가기로 이동할 수 없다는 겁니다.
문서여야 할 것에 새 내비게이션 UI를 브라우저 위에 발명해 얹고 브라우저의 탐색 관례를 우회하는 건, 정말이지 좋은 생각이 아닙니다.
정말 재미있게 봤고, 여기 나온 아이디어를 직접 써 보는 것이 기대됩니다.
효과적인 발표 형식에 아주 가까이 가셨다고 생각하는데, 글을 위한 공간이 좁은 게 가장 큰 불편이었습니다. 움직이는 비주얼도 도움은 되지만, 글의 의미를 받아들이려 할 때는 조금 산만하게 느껴지기도 했습니다. 비주얼을 숨기거나 펼치는 토글 하나로 두 문제를 모두 해결할 수 있을 것 같습니다.
아주 멋진 시각 가이드네요! 여기서 파서에 관한 짧은 시 하나를 소개하는 게 어울릴 것 같습니다. 프리츠 뤼어(Fritz Ruehr)가 쓴 시인데, 컴퓨터공학 첫 학기 때부터 머릿속에 남아 있습니다. 이 시만 있으면 파서의 타입 시그니처는 절대 잊지 않을 겁니다.
„사물을 위한 파서는 문자열에서 출발하여 사물과 문자열로 이루어진 쌍의 목록으로 가는 함수!“
https://people.willamette.edu/~fruehr/haskell/seuss.html
저도 그걸 찾아서 올리려던 참이었어요! 그런데 구글이 알아서 닥터 수스 풍 파서 컴비네이터 예제를 지어내려고 하더라고요.
그리고 저도 컴퓨터공학 첫 학기에 이런 걸 접했으면 좋았을 텐데요.
첫 번째 주소는 호스트 이름일 수도 있으니 IP 주소로 파싱해서는 안 될 것 같습니다. 사소한 트집처럼 보일 수 있지만, 여러 종류의 구문 중 어느 하나가 올 수 있을 때 어떻게 하는지를 보여 줄 기회가 되기도 합니다.
본문에 할당된 영역이 좁아서 글을 읽기가 다소 힘들었습니다. 챕터가 짧은데도 여러 페이지에서 스크롤해야 했습니다.
시작 타입으로는 좋지 않습니다:
이 타입은 문자열을 받으면 T를 반드시 반환한다는 뜻입니다. 하지만 파서는 T를 반환할 수도 있는 것입니다.
기사는 또 ‘부분 파서(partial parser)’라는 개념으로 시작하고, 여기서 다른 (부분이 아닌?) 파서를 만들어 낸다고 하는데요?
아니요, 구분은 없습니다. 부분 파서를 조합하면 부분 파서가 나옵니다. 완전한 파서(그게 무슨 뜻이든)를 조합하면 완전한 파서가 나오고요.
(아이러니하게도 여기에는 ‘partial’이라는 말이 딱 맞는 정의가 있습니다. 모든 입력에 대해 잘 정의되지 않은 함수라는 뜻이죠. 어떤 입력에는 T가 반환되고, 다른 입력에는 어쩌면 오류가 던져질 수도 있고, 누가 알겠습니까..)