Parsing JSON Objects without intermediate ASTs
중간 AST 없이 JSON 객체 파싱하기
JSON 라이브러리는 보통 먼저 JSON AST를 만든 뒤 도메인 데이터로 변환합니다. 이 글은 Haskell의 지연 평가와 비트셋을 이용해 중간 표현을 생략하는 파서를 구현하고, 마이크로벤치마크에서 약 3배 빠른 결과를 얻었습니다.
- 주제
AI 요약
JSON을 도메인 데이터로 읽는 일반적인 방식은 입력 바이트를 JSON AST(Abstract Syntax Tree)로 파싱한 뒤, AST를 다시 원하는 자료형으로 변환하는 두 단계로 이뤄집니다. AST는 파싱과 데이터 검증을 분리해 코드를 관리하기 쉽게 만들지만, 최종 자료형과 별도로 메모리를 할당하고 변환 연산도 수행합니다. 글쓴이는 이 중간 표현을 없애면서 필수 필드 누락이나 타입 불일치를 확인하는 방법을 Haskell로 구현합니다.
부분 초기화한 도메인 자료형
예시 자료형 Album에는 제목, 선택적 아티스트 이름, 곡 수가 들어갑니다. 글쓴이는 파서가 Album 값을 직접 채우도록 설계합니다. 우선 필드에 undefined를 넣어 부분 초기화한 값을 만들고, JSON 객체를 읽으며 해당 필드를 갱신합니다. Haskell의 지연 평가를 이용하는 방식이지만, 자료형의 필드는 엄격 평가(strict)로 선언하면 안 됩니다.
초기화되지 않은 필드에 undefined가 남았는지 직접 평가해 확인하면 오류가 발생합니다. 대신 Word64 같은 비트셋을 함께 전달합니다. 각 비트는 자료형의 필드 하나를 나타내며, 파서가 필드를 채우면 그 위치의 비트를 지웁니다. 객체 파싱이 끝난 뒤 비트셋이 0이면 모든 필드가 설정된 상태입니다. 남은 비트를 확인하면 누락 필드 이름을 오류 메시지에 포함할 수 있습니다. 누락된 필드가 Maybe 타입뿐이라면 해당 필드에 Nothing을 넣어 파싱을 성공 처리할 수도 있습니다.
파서 코드 생성과 안전성
필드 이름을 분기하는 파서 코드는 자료형 정의를 바탕으로 만들 수 있습니다. 글쓴이는 Template Haskell을 사용해 전문화된 파서를 컴파일 시점에 생성하고, flatparse 라이브러리로 구현했습니다. 파서는 도메인 자료형을 직접 채우면서 알 수 없는 필드를 건너뛰는 동작도 고려해야 합니다. 다만 본문 예시 코드는 공백 처리와 Maybe 필드의 오류 복구 같은 세부 사항을 생략합니다.
이 방식에는 제약도 있습니다. 도메인 자료형의 필드가 지연 평가여야 하고, 파서 생성 코드와 검증 과정을 함께 관리해야 하므로 구조가 복잡해집니다. 구현은 개념 검증(proof of concept) 수준이며, 문자열 이스케이프 처리가 완전하지 않고 숫자 형식도 일부만 지원합니다. 실제 JSON 라이브러리에서 제공하는 필드 이름 변환, 누락 필드 동작 설정, ADT 처리 같은 옵션도 지원하지 않습니다.
벤치마크 결과
Criterion으로 Album과 비슷한 크기의 객체를 파싱하는 마이크로벤치마크를 진행했습니다. 비교 대상은 AST를 사용하는 Haskell 라이브러리 aeson, 글쓴이의 라이브러리에서 AST를 사용하는 방식, 그리고 AST 없이 도메인 자료형을 직접 파싱하는 방식입니다. 책 객체에서는 각각 1.051μs, 922.7ns, 314.2ns가 나왔습니다. 책 목록을 포함한 저자 객체에서는 2.901μs, 2.813μs, 1.027μs였습니다. 이 측정에서는 AST를 생략한 구현이 AST를 사용하는 자체 구현보다 약 3배 빨랐습니다.
글쓴이는 이 결과를 실제 서비스의 성능 향상으로 일반화하지 않습니다. 마이크로벤치마크이며, 파서 기능과 설정도 aeson에 미치지 못하기 때문입니다. 목적은 중간 표현의 런타임 비용을 드러내고, 이를 없애는 대안을 보여주는 데 있습니다. AST는 파싱과 검증을 분리하는 장점이 있지만, 이 접근법은 두 단계를 합치는 대신 코드 복잡성과 유지보수 부담을 감수합니다. 글은 Rust의 serde가 단계적 프로그래밍(staged programming)을 활용해 중간 표현을 컴파일 시점으로 옮기는 사례도 언급합니다.
Lobsters 반응
- @tobz619 — 흥미롭습니다. 초기화하지 않은 필드가 든 원시 스택 할당과 비슷하게 느껴집니다. 모든 필드가 설정됐는지 비트셋으로 확인하는 방식은 단순하면서 효과적입니다. 이 패턴을 Haskell에서 래퍼 타입으로 일반화하고 비트셋을 감출 수 있는지도 궁금합니다. 제가 만드는 웹 앱에서도 한 클라이언트가 정책을 올린 뒤 다른 클라이언트가 필요로 할 때까지 업로드가 끝났는지 확인하려고 비슷한 접근을 시도하고 있습니다. 제가 완전히 엉뚱한 생각을 한 건 아니었다니 반갑습니다.
- @morj — 괜찮은 접근입니다. 대상 레코드의 필드를 지연 평가로 정의하는 방식은 사용자들이 받아들이기 어려울 것 같습니다. 먼저 튜플로 역직렬화한 다음, Template Haskell이나 Generics로 생성한 코드가 튜플 값을 레코드에 옮기면 해결될 수도 있습니다. 최적화기가 튜플 변환을 알아서 없애길 바랍니다. 중간 표현을 건너뛰면 3배 빨라진다는 점을 보여줍니다. 예전에 aeson과 serde_json을 간단히 비교했을 때는 차이가 9배였습니다. 이제 남은 3배 개선을 회복하면 Haskell이 더 우월한 언어라는 사실을 공식적으로 입증하게 됩니다.
- @vamolessa — 관련해서, C에서도 사용성이 괜찮은 ‘즉시 모드(immediate mode)’ JSON 읽기와 쓰기를 구현할 수 있습니다: https://git.sr.ht/~lessa/foundation/tree/master/item/test/json_test.c#L83
원문: Lobsters / 번역·요약: Trawling