· 7분 분량

DynamoDB용 PartiQL 파서를 직접 손으로 작성한 이유

DynamoDB는 의 좁은 일부만 받아들이고 나머지는 전부 요청 시점에 거부합니다. GROUP BY요? ValidationException. 문 수준의 LIMIT은요? ValidationException. * 연산자, CAST, 서브쿼리는요? 이 모두가 머릿속에서는 멀쩡히 파싱되고, 네트워크를 건너가서, 서버에서 죽습니다. 그 지식이 살고 있던 유일한 곳은 AWS 문서와 오류 메시지였고, 그래서 DynamoDB용 에디터라면 어느 것이든 — 한동안은 우리 것도 포함해서 — 엔진이 거부할 것이 확실한 문을 여러분이 태연히 작성하도록 내버려 두었습니다.

우리는 그 거부가 에디터 안에서, 키를 누르는 순간에, 문제가 되는 바로 그 절 위의 빨간 물결선과 함께, 그리고 다시 쓸 방법이 있는 곳에서는 원클릭 수정과 함께 일어나기를 원했습니다. 그 에디터의 필요가 DynamoDB의 PartiQL 방언을 위해 손으로 작성한 렉서와 CST 파서로 이어졌고, 이번 주 우리는 그것을 오픈소스로 공개했습니다: dynamodb-partiql-parser, 순수 TypeScript, 의존성 제로, MIT이며, CodeMirror 연결은 codemirror-lang-partiql로 따로 공개했습니다. 이 글은 왜 손으로 작성했는지, 첫 린터가 무엇을 놓쳤는지, 그리고 누군가 쓰레기를 붙여 넣었을 때에만 드러난 버그 두 개에 관한 것입니다.

정규식으로 충분했다, 충분하지 않게 되기 전까지는

DynoTable의 첫 PartiQL 린터는 대략 650줄의 정규식과 토큰 스캐닝이었고, 정말로 쓸모가 있었습니다. 서로 구분되는 검사 19개, 흔한 함정에 대한 빠른 수정(IN (...)[...]로, LIKEcontains()로, IS NULLattribute_not_exists()로). 그것은 출시되었고, 실제 실수를 잡아냈고, 그것이 다루는 경우에 대해서는 사용자들이 "왜 내 쿼리가 실패하나요" 티켓을 더 이상 올리지 않게 되었습니다.

하지만 정규식 린터는 패턴을 알 뿐 구조를 알지 못합니다. SELECT price * quantity*가 DynamoDB가 거부하는 산술이라는 것을 볼 수 없었는데, *는 "모든 열"이라는 뜻이기도 하고 그 둘을 구분하려면 실제로 파싱을 해야 하기 때문입니다. 그 진단 범위는 근사치였습니다 — 한 줄을 가리키기에는 충분했지만, 정확한 오프셋에서 텍스트를 이어 붙이는 빠른 수정을 굴리기에는 너무 거칠었습니다. 그리고 새 검사가 하나씩 늘 때마다 그 더미는 더 부서지기 쉬워졌는데, 각 정규식이 다른 모든 정규식의 가정을 상대로 자신을 방어해야 했기 때문입니다.

"린터에는 구조가 필요하다"에 대한 해법은 파서입니다. 문제는 어느 파서냐였습니다.

아무도 만들어 둔 적이 없었다

Workbench의 진짜 SQL 쪽에서 우리는 이미 이 과정을 겪었습니다. 우리에게 거짓말한 기성 SQL 파서를, 모든 노드에 소스 범위를 지니고 따옴표 있는 식별자와 없는 식별자를 보존하는 sql-parser-cst로 대체한 일 말입니다. 그 경험이 PartiQL 쪽에 필요한 기준을 정해 주었습니다 — 손실이 있는 AST가 아니라 무손실 구체 구문 트리라는 기준을요.

하지만 PartiQL은 파서에게 중요한 지점에서 SQL이 아닙니다. DynamoDB의 방언은 IN 목록을 대괄호로 씁니다(WHERE OrderID IN [100, 300, 234]). 백 리터럴(<<'a', 'b'>>), 따옴표 있는 키를 쓰는 맵 리터럴({'rating': 5}), MISSING 리터럴, 리스트 인덱스가 붙은 문서 경로(Devices.FireStick.DateWatched[0]), 그리고 RETURNING ALL OLD *이 있는데, 이 중 어느 것도 SQL 문법은 알지 못합니다. 반대 방향으로는 SQL 문법이 반드시 요구하는 것의 절반이 없습니다. 당시 npm에 있던 파서들은 범용 PartiQL을 위한 AWS의 Rust 구현을 WebAssembly로 빌드한 것이었고, DynamoDB가 구체적으로 무엇을 거부하는지에 대한 개념은 전혀 없었습니다.

그래서 우리가 하나 작성했습니다. 작은 렉서와 재귀 하강 파서를, sql-parser-cst가 우리에게 원하도록 가르쳐 준 모양대로요. 모든 노드가 자기 바이트 범위를 지닙니다. 전체가 런타임 의존성이 제로인데 — 이제 CI가 그 속성을 단언합니다 — 그것이야말로 이 파서를 브라우저를 포함해, 여러분의 프로젝트를 포함해 어디에나 끼워 넣을 수 있게 해 주는 것이기 때문입니다.

문법은 쉬운 쪽 절반이었습니다. 린터의 파서는 평생을 망가진 코드를 파싱하며 보냅니다. 키를 누르는 도중, 절반짜리 문, 세 번째 절의 오타. 첫 오류에서 멈춘다면 에디터는 쓸모없어질 테니, 이 파서는 오류에 관대합니다. 진단을 기록하고, 재동기화하고, 계속 나아가서, 두 번째 절이 미완성인 동안에도 네 번째 절이 여전히 린트됩니다.

비행기를 부수지 않고 엔진 갈아 끼우기

파서가 준비되었을 무렵, 정규식 린터의 네 함수는 에디터 전반에서 하중을 지탱하고 있었습니다 — 어떤 문이 자동 실행해도 안전한지를 결정하는 함수까지 포함해서요. 그 동작을 조용히 바꾸면 "에디터가 내 쿼리를 실행해 주지 않는다"로 나타나는데, 사용자가 신고하기보다는 그냥 떠나 버리는 부류의 버그입니다.

그래서 그 교체는 스트랭글러 방식이었습니다. 옛 린터는 이름이 바뀌어, 동결된 채, 트리 안에 남았습니다. 새 파서 기반 린터는 정확히 같은 네 함수를 다시 내보냈습니다. 그리고 패리티 코퍼스가 모든 픽스처를 양쪽 린터에 통과시키고 그 출력을 서로에 고정했습니다 — 정규식 버전이 낸 모든 진단을 파서 버전도 내야만, 그 이상을 내는 것이 허용되었습니다. 옛 린터는 오늘도 그 자리에, 동결된 채로 남아, 그 교체가 무엇을 약속했는지에 대한 실행 가능한 문서 역할을 합니다.

쓰레기만이 찾아내는 버그

두 가지 실패는 어떤 실제 쿼리에서도 나타나지 않았고, 둘 다 에디터를 쓰러뜨렸을 것입니다.

CodeMirror 린터는 문서에 대해, 모든 변경마다, 위에 오류 싱크 하나 없이 동기적으로 실행됩니다. 잡히지 않은 예외 하나는 린트를 실패시키는 것이 아니라 — 에디터 화면을 하얗게 날려 버립니다. 그리고 재귀 하강 파서에는 잡히지 않은 예외가 태생적으로 내장되어 있습니다. 바로 호출 스택입니다. [[[[[[…를 대괄호 몇천 개 깊이로, 또는 NOT NOT NOT … 체인을 붙여 넣으면, 각 중첩 단계가 하나의 스택 프레임이 되고, V8은 결국 RangeError: Maximum call stack size exceeded를 린터를 관통해 던집니다.

그 수정은 의도적으로 심심합니다. 표현식 재귀에는 단단한 깊이 상한이 있습니다 — 500단계, 사람이 쓰는 어떤 것보다도 훨씬 깊고 스택 예산보다는 한참 아래인데 — 그 너머에서는 파서가 예외를 던지는 대신 진단 하나를 내보냅니다. 그리고 붙여넣기가 현실적으로 사슬을 이루는 구문들, 예컨대 A UNION B UNION C …가 수천 갈래로 이어지는 경우는 재귀에서 평평한 목록으로 다시 작성되었습니다. 갈래마다 프레임 하나가 아니라, parseSelect 프레임 하나와 집합 연산의 배열로요. 이제 스트레스 스위트는 매 빌드마다 100 KB의 쓰레기와 30,000 깊이의 연산자 체인을 붙여 넣고, 공개 패키지는 전체 파이프라인을 결코 던지지 않는 lint() 진입점으로 감쌉니다. 이것을 다음에 끼워 넣을 에디터도 우리와 똑같이 오류 싱크가 없는 문제를 겪을 것이기 때문입니다.

AWS 문서에 대조해 감사할 수 있는 테스트 스위트

방언 규칙 — DynamoDB가 무엇을 받아들이고, 무엇을 거부하며, 어떤 다시 쓰기가 무엇을 고치는지 — 은 전부 AWS의 PartiQL 레퍼런스에서 나옵니다. 문서에서 파생된 동작에는 특유의 실패 방식이 있습니다. 문서는 움직이는데 코드는 그대로이고, 아무도 알아채지 못하는 것이죠.

그래서 코퍼스는 그것에 대조되도록 구조화되어 있습니다. 픽스처 208개, 그리고 모든 픽스처가 그 규칙이 나온 AWS 문서 페이지의 URL로 시작합니다. 커버리지 표가 문서화된 각 규칙을 그 픽스처에 대응시키고, 어떤 규칙이 픽스처를 잃으면 스위트가 실패합니다. AWS가 방언을 바꾸면, 그 차이는 출처가 붙은 픽스처 차이가 됩니다.

그 규율은 우리가 오픈소스로 공개하던 주에 스스로 값을 했습니다. 린터의 IN 목록 경고는 상한 두 개를 인용하고 있었습니다. 파티션 키 열에 대해 값 50개, 키가 아닌 열에 대해 100개. 공개 전에 모든 숫자를 다시 검증하면서, 우리는 AWS의 현재 문서에서 100은 확인할 수 있었지만 — 50은 유효한 어떤 문서에서도 찾을 수 없었습니다. 그것은 블로그 글과 오래된 포럼 답변 곳곳에서 살아남아 있지만, 1차 출처는 이미 넘어갔습니다. 린터는 우연히 맞게 하고 있었고(스키마가 없으면 어느 경우에 해당하는지 알 수 없기에 100을 넘을 때만 경고합니다), 이제 그 주석은 그 주장의 어느 절반이 문서화된 것이고 어느 절반이 민간전승인지를 정확히 밝힙니다.

여러분이 직접 만든다면 무엇이 통용되는가

  • 작은 방언을 위한 손으로 쓴 재귀 하강 파서는 몇 달이 아니라 며칠 작업이고, 모든 오류 메시지를 여러분이 소유하게 됩니다. "파서를 작성한다"의 무서운 버전은 큰 문법을 전제합니다.
  • AST가 아니라 CST를 만드세요. 모든 노드의 바이트 범위가 진단을 빠른 수정으로 바꿔 주는 것입니다. 손실이 있는 트리는 텍스트를 이어 붙일 수 없습니다.
  • 파서가 린터를 먹여 살린다면, 오류 관용성이 곧 기능입니다. 복구하고 계속하세요. 첫 오류에서 멈추는 파서는 그 뒤의 아무것도 린트하지 못합니다.
  • 동결된 인터페이스 뒤에서, 옛것을 새것에 고정하는 패리티 코퍼스와 함께 엔진을 교체하세요. 옛 구현이야말로 여러분이 이미 동의한 명세입니다.
  • 입력이 중첩될 수 있는 곳이라면 어디든, 누군가는 터무니없이 중첩된 무언가를 붙여 넣습니다. 재귀에 깊이 상한을 두고 사슬을 평평하게 만드세요. 쿼리만이 아니라 쓰레기로 테스트하세요.
  • 테스트에 출처를 인용하세요. 자기가 인코딩한 문서 페이지의 이름을 밝히는 픽스처는 그 문서가 바뀌었을 때 감사할 수 있는 테스트입니다 — 그리고 문서는 바뀝니다.

파서는 GitHub과 npm(npm install dynamodb-partiql-parser)에 있고, 에디터 통합은 codemirror-lang-partiql에 있습니다. 파서가 아니라 방언 자체를 원한다면, PartiQL과 SQL이 DynamoDB의 부분집합이 무엇을 할 수 있고 무엇을 할 수 없는지 다루고 PartiQL 예제가 실전 안내입니다. 이 모든 것이 만들어진 대상인 그 에디터는 DynoTable 안에 있으며, 무료로 사용해 보실 수 있습니다.

Console 없이 DynamoDB 작업하기

DynamoDB로는 실행할 수 없는 진짜 SQL(JOINs, GROUP BY, 집계)을 실행하는 빠른 DynamoDB 데스크톱 클라이언트. 시각적 편집과 여러분 자신의 Bedrock 키로 동작하는 AI 에이전트를 제공합니다.

30일 무료 체험, 신용카드 불필요 — 이후 기간 제한 없는 무료 요금제.