코드에서 문자열을 다뤄본 사람이라면 누구나 겪는 딜레마가 있다. 한쪽 끝에는 Lemon이나 Bison 같은 파서 제너레이터, 혹은 Boost.Spirit 같은 무거운 라이브러리가 있고, 반대쪽 끝에는 포인터 산술과 strchr, strncmp를 엮어 그때그때 손으로 짜는 임시(ad-hoc) 파서가 있다. bgfx의 개발자 Branimir Karadžić는 이 두 극단 모두에 만족하지 못했던 경험을 공유하며, 그 사이의 실용적인 대안으로 자신이 만든 bx::Scanner를 소개한다. 셰이더 프런트엔드 파서를 위해 Lemon을 써봤지만 생성된 코드가 읽기 어렵고, 문법을 바꿀 때마다 결과 코드의 형태를 다시 익혀야 해서 결국 버렸다는 것이 출발점이다.
임시 파서 역시 나름의 함정이 있다. 의존성이 없고 빠르지만, 공백 건너뛰기, 식별자 수집, 에러 메시지를 위한 줄 번호 추적, 그리고 입력이 완벽하지 않을 때 어김없이 등장하는 off-by-one 처리 같은 똑같은 패턴이 매번 반복된다. 파싱이 필요한 곳마다 이 로직의 개별 복사본이 생기고, 각 복사본은 저마다의 일회성 버그를 품은 지뢰밭이 된다. Karadžić가 원했던 것은 제너레이터나 의존성 그래프를 끌어들이지 않으면서 이 반복적인 부분만 처리해주는 작고 재사용 가능한 기본 요소들이었다.
설계를 규정하는 제약들
bx::Scanner는 몇 가지 의도적인 제약 위에 세워져 있다. 첫째, 제로 카피에 비소유(non-owning)다. 스캐너는 절대 할당하거나 텍스트를 복사하지 않으며, 모든 결과는 원본 입력을 직접 가리키는 StringView로 돌아온다. 심지어 빈 결과조차 '아무것도 아닌 곳'이 아니라 커서 위치를 가리킨다. 둘째, 커서는 하나뿐이고 스스로 움직이지 않는다. accept, acceptWhile, acceptUntil이 커서를 전진시키고 peek는 같은 검사를 이동 없이 수행한다. seek와 reset은 있지만 암묵적으로 되돌아가는 백트래킹은 없다.
셋째, 줄과 열 추적이 내장돼 있다. 뒤로 향하는 seek를 포함해 개행을 가로지르는 모든 이동이 줄 번호를 갱신하며, getLine과 getColumn은 항상 1부터 시작하는 값으로 제공된다. 덕분에 쓸 만한 에러 메시지를 거의 공짜로 얻는다. 넷째, 별도의 미니 언어 대신 문자 클래스를 쓴다. Space, NonSpace, Identifier, EndOfLine, NewLine 정도의 소수 클래스가 일상적인 스캐닝의 대부분을 감당하고, 더 구체적인 것이 필요하면 그냥 bool(*)(char) 형태의 술어 함수를 accept에 넘기면 된다. 배워야 할 패턴 언어도, 디버깅할 대상도 자기 함수 하나뿐이다. 마지막으로 공개 인터페이스 전체가 작은 헤더 하나에 들어간다.
빈 값의 의미를 다루는 방식
이 설계에서 실무적으로 가장 미묘한 부분은 '매치되지 않음'과 '빈 것을 매치함'이 동일한 값으로 도착한다는 점이다. StringView에는 operator bool이 없어서 성공한 매치는 !scanner.accept('=').isEmpty() 형태로 읽힌다. bool을 반환하는 것보다 시끄럽지만, 매치된 텍스트가 답과 함께 돌아온다는 장점이 있다. 이 때문에 줄 단위 처리에서 흔한 함정이 생긴다. 얼핏 맞아 보이는 while (!lr.next().isEmpty())는 틀렸다. 빈 줄은 유효한 결과이므로 '문자가 없는 줄'과 '더 이상 줄이 없음'을 구분해야 하고, 종료 조건은 isDone이어야 한다.
대부분의 경우 이 '붕괴'는 문제가 아니라 원하던 답이다. 빈 토큰도 정당한 토큰이다. http://example.com에는 경로가 없고 대다수 URL에는 userinfo가 없으며, 빈 뷰가 여전히 커서를 가리키므로 구멍이 아니라 길이 0의 슬라이스로서 결과에 곧장 들어간다. 구분이 필요한 경우는 구조가 구분자의 존재 여부에 달려 있을 때뿐인데, 그때는 토큰이 아니라 입력에게 물어야 한다. 구분자를 소비하려면 accept, 소비하지 않으려면 peek다. 저자가 제시하는 경험칙은 명료하다. 반환값은 토큰이고, '구분자가 있었는가'라는 구조적 질문은 peek나 accept가 답한다. 여러 accept로 토큰을 조립해 단일 반환값이 부족할 때는 getCursor와 between으로 전체 구간을 잡는다.
실제로 무엇을 지웠는가
추상적인 우아함보다 설득력 있는 것은 삭제된 코드의 목록이다. 저자는 bx::Scanner를 도입하면서 INI 라이브러리 의존성 하나를 통째로 걷어냈고, URL 파싱과 경로 정규화, 스택 트레이스 심볼화 세 곳에 흩어져 있던 동일한 공백-식별자 루프의 손수 만든 복사본 세 개를 지웠다고 밝힌다. URL 파싱은 2만 3천 줄에 달하는 C++ 헤더 대신 75줄로 끝났다. INI 처리에서는 acceptUntil이 StringView를 반환한다는 성질을 이용해 한 줄을 그 자체의 하위 스캐너로 만들어, 닫는 대괄호나 개행이 없는 잘못된 줄이 뒷줄로 넘쳐 흐르는 고전적 버그를 '표현 불가능한 것'으로 만들었다. 경로 처리의 .. 처리는 입력이 아니라 출력을 되감으며, 워터마크로 상위 경로가 넘어서면 안 되는 접두사를 표시한다.
물론 이 도구가 만능은 아니다. bx::Scanner는 명시적으로 파서 제너레이터도 PEG 라이브러리도 아니며 그 어느 쪽이 되려 하지도 않는다. 복잡한 재귀 문법이나 정교한 우선순위 규칙, 대규모 언어 프런트엔드에는 여전히 제대로 된 파서 인프라가 어울린다. 이 스캐너의 가치는 그런 무게가 과한 수많은 소규모 스캐닝 작업, 즉 지금도 많은 코드베이스가 포인터를 쫓는 루프로 반복해 재구현하고 있는 그 영역에 있다. 공백을 건너뛰고 식별자를 모으는 루프를 또 손으로 짜고 있다는 자각이 든다면, 그 반복되는 부분을 작은 재사용 가능한 기본 요소로 대체할 수 있는지 살펴볼 만하다. 한국의 실무자에게 이 글의 함의는 특정 라이브러리의 채택 여부라기보다, 임시 파싱 코드에서 진짜 위험한 부분을 '경계 지음으로써 버그를 표현 불가능하게 만드는' 사고방식 그 자체에 가깝다.