· 8분 분량

DynamoDB를 위한 로컬 SQL 엔진을 만든 방법 (JOIN이 없는 데이터베이스)

DynamoDB는 100 GB 테이블을 내보낼 수는 있어도 집계할 수는 없습니다. JOIN도, GROUP BY도, COUNT(*)도 없습니다 — 느린 버전조차 아니라 아예 없습니다 — 그리고 AWS 자체의 SQL스러운 표면인 조차 그것들을 더해 주지 않습니다. 그래서 모든 팀이 결국 같은 일회용 스크립트를 작성하게 됩니다. 을 페이지네이션하고, 행을 맵으로 접어 넣고, 답을 출력하고, 스크립트를 삭제하는 것이죠.

DynoTable의 Workbench는 그 스크립트에 대한 우리의 답입니다. 그중 어느 것도 지원하지 않는 테이블을 상대로 하나의 진짜 SELECT — 다중 테이블 JOIN … ON, WHERE, GROUP BY, HAVING, DISTINCT, CASE, 집계 — 를 작성하는 탭입니다. 이 글은 그것이 내부에서 어떻게 동작하는지, 우리에게 조용히 거짓말한 파서, 그리고 우리가 나중에 그 엔진의 메모리 모델을 뜯어낸 이유에 관한 것입니다.

쿼리 엔진을 만들지 마라

핵심 결정은 관계형 실행기를 작성하기를 거부한 것이었습니다. DynamoDB 페이지가 내장 SQLite 데이터베이스로 스트리밍되고, 지구상에서 가장 많이 테스트된 쿼리 엔진 중 하나인 SQLite가 관계형 작업을 수행합니다:

which tables,which join keysrewritten SQLYour SQLCompilersql-parser-cstDynamoDBpage streamLocal SQLiteon diskResult grid

각 DynamoDB 항목은 하나의 item 열에 원본 타입 봉투({platform: {S: "ios"}})로 SQLite에 들어갑니다. 작은 사용자 정의 함수가 쿼리 시점에 속성을 풀어내므로, 여러분의 SELECT platformattr(item, 'platform', …) 호출로 컴파일됩니다. 그 함수의 한 가지 세부 사항이 하중을 지탱합니다. JSON 파싱을 catch로 감싸는데, SQLite 바인딩이 잘못된 형식의 행 하나의 파싱 오류를 준비된 문 전체 의 중단으로 바꿔 버리기 때문입니다. 이 catch가 없으면 손상된 행 하나가 쿼리를 죽일 것입니다.

우리가 지킨 정직한 제약: DynamoDB의 액세스 패턴 물리 법칙은 여전히 적용됩니다. JOIN의 대상 쪽은 기본 키이거나 파티션 키여야 합니다 — 엔진은 조인을 교차곱 스캔이 아니라 스트림에 대한 키 조회로 해석합니다. 키가 아닌 속성에 대한 임의의 조인이 필요하다면, 그것은 쿼리 엔진의 기능이 아니라 모델링에 관한 대화입니다.

Workbench: 다중 테이블 SQL JOIN과, 그 아래의 조인된 결과 그리드.
Workbench: 다중 테이블 SQL JOIN과, 그 아래의 조인된 결과 그리드.

우리에게 거짓말한 파서

첫 버전은 널리 쓰이는 기성 SQL 파서를 사용했습니다. 몇 주에 걸쳐, 서로 독립적인 정확성 버그 네 개가 그 파서로 거슬러 올라갔습니다:

  • SELECT DISTINCT가 조용히 누락되었습니다. 파서는 그것을 받아들였고 이미터는 그것을 무시했습니다 — 모든 중복 행이 되돌아왔습니다. 한 사용자의 버그 리포트에는 이렇게 적혀 있었습니다: "카운터가 아니라 모든 값의 테이블을 보여줍니다."
  • 처리되지 않은 것은 무엇이든 문자 그대로의 SQL NULL이 되었습니다. CASE, CAST, 스칼라 서브쿼리 — 알 수 없는 구문 노드에 대한 이미터의 폴백은 return 'NULL'이었습니다. 쿼리는 실행되었고, 자신만만하게 틀린 답을 반환했으며, 아무 오류도 일으키지 않았습니다.
  • 식별자의 대소문자가 SQL처럼 동작하지 않았습니다. 속성이 platform일 때 SELECT PLATFORM FROM t는 null을 반환했습니다 — 모든 SQL 데이터베이스가 여러분에게 기대하도록 길들인 것과 정반대입니다.
  • 자동 완성이 리졸버와 어긋나서, 컴파일러가 이후 해석하지 못할 이름들을 제안했습니다.

대체재인 sql-parser-cst는 조사에서 다른 어디에서도 찾지 못한 하나의 프리미티브에서 이겼습니다. 그 트리는 모든 식별자의 원본 텍스트정규화된 이름 을 둘 다 보존합니다 — 그래서 컴파일러가 platform"PLATFORM"을 구분할 수 있습니다. 그 하나의 구분 덕분에 하나의 문법이 제대로 된 ANSI 의미론을 담을 수 있습니다. 따옴표 없는 식별자는 대소문자를 구분하지 않고 해석되고, 따옴표 있는 것은 대소문자를 구분하는 탈출구입니다 — 정확히 Postgres나 SQLite처럼요. 또한 모든 노드에 소스 범위를 지니므로, 진단이 문 전체가 아니라 문제가 되는 구문에 밑줄을 긋습니다.

그리고 우리는 return 'NULL' 폴백을 이제 정책으로 여기는 규칙으로 대체했습니다: 조용한 NULL은 결코 없다. 이미터의 기본 분기는 범위가 정확한 오류입니다. 윈도 함수는 그냥 안 된다가 아니라 왜 안 되는지를 말해 주는 메시지를 받습니다:

Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.

우리가 받아들인 거래: 정확한 버전에 고정되고, 테스트 스위트를 관문 삼아 오직 손으로만 업그레이드하는 1.0 이전 의존성. 우리는 옛 파서에 패치를 대는 것(병렬 프리 토크나이저가 필요했습니다 — 모든 입력을 두 번 파싱), 에디터의 구문 트리를 사용하는 것(토큰 수준이라 절 구조가 없음), 그리고 직접 만드는 것(SQLite 파서의 대부분, 끝없는 유지 보수)을 검토했습니다. 네 갈래의 정확성 실패가 그 모든 우려를 이깁니다.

무엇을 막지 말아야 하는지 아는 것

한 진단은 의도적으로 여러분을 막지 않습니다. 필터가 schema로는 대소문자를 확인할 수 없는 속성을 참조할 때, 우리는 표준 대소문자를 알 수 없습니다 — DynamoDB의 서버 측 필터는 대소문자를 구분하고, 그 schema는 키 속성만 선언하기 때문입니다. 그럴 때는 경고를 내보내며, 경고는 결코 Run을 비활성화하지 않습니다. 우리는 우리의 검증기 작업에서 이런 부류의 교훈을 어렵게 배웠습니다. 가장 흔한 정당한 쿼리 형태를 거부하는 관문은 관문이 없느니만 못합니다.

그 한도는 잘못된 형태였다

첫 집계 엔진은 스캔한 행을 크기 한도가 있는 메모리 내 SQLite에 버퍼링했습니다 — 여기는 64 MB, 저기는 250 MB, 그리고 한도가 없어 큰 테이블에서 앱을 OOM에 빠뜨릴 수 있는 경로도 하나 있었습니다. 한도에 도달하면 오버플로 플래그가 붙은 부분적인 답을 받았습니다.

재설계는 하나의 재구성에서 시작했습니다: 경쟁 상대는 개발자가 대신 작성할 일회용 스크립트다. 그리고 유능한 스크립트는 버퍼링 후 한도를 두지 않습니다 — 스트리밍 하면서, 각 페이지를 제한된 메모리로 누적 합계에 접어 넣습니다. 메모리 내 한도는 너무 작았던 것이 아니라, 잘못된 형태였습니다. 더 나쁘게도, 부분 집계는 "불완전한" 것이 아니라 — 틀린 것입니다. 이를 구체적으로 실감하게 한 순간: 우리 자신의 AI 데모가 부분적인 "최고 지출자" 순위를 자신만만하게 사실인 양 서술했습니다.

다시 만든 Compute 엔진은 그 스크립트가 하는 방식대로 동작하되, 스크립트가 결코 신경 쓰지 않는 모든 것을 더합니다:

  • 플래너가 모든 쿼리를 분류 하여 세 갈래 중 하나로 보냅니다: stream-fold(평범한 대수적 집계 — COUNT/SUM/AVG/MIN/MAX — 를 메인 프로세스 밖에서 페이지 단위로 접습니다. 크기는 접을 수 있는 쿼리를 결코 가로막지 않고, 그저 더 오래 걸리게 할 뿐입니다), materialize(정렬, 키로 한정된 조인 — 디스크 기반 SQLite로 흘려보내며, 메모리 한도가 없습니다), 아니면 우리가 처리할 수 없는 꼬리에 대해 알맞은 중량급 도구(DynamoDB의 S3 내보내기와 Athena)를 권하는 정직한 거부.
  • fold 분류는 의도적으로 좁습니다. HAVING, ORDER BY, 집계 옆의 맨 열 — 이 중 어느 것이든 materialize 갈래를 강제합니다. 어떤 절을 무시하고도 여전히 "정확"하다고 보고하는 fold라면, 바로 이 플래너가 막으려고 존재하는 그 틀렸지만 자신만만한 버그가 될 것입니다.
  • 정확성은 결과의 일부입니다. 집계 열은 페이지가 아직 스트리밍되는 동안 부분(partial) 배지를 보여주고, 스캔이 다 빠져나갔을 때에만 그것을 없앱니다. 산술적 정직함조차 추적됩니다. 정수 합은 2⁵³을 넘어서도 정확하게 유지되고(64비트 정수로 저장), 부동소수점 가수부를 초과하는 소수 합은 조용히 반올림하는 대신 스스로에게 partial: precision 플래그를 답니다.
  • 기계에게는 퓨즈가 팝업을 대신합니다. 상호작용형 앱은 500만 개 항목 스캔 전에 묻습니다. AI 어시스턴트와 MCP 호출자는 대신 스캔 예산과 페이지 경계 중단을 받습니다. 그리고 모델이 보는 결과 봉투는 의도적으로 다듬어집니다 — 비용 등급과 크기 추정치는 보류되는데, 오래된 추측을 드러내면 모델이 그것을 사실인 양 서술하도록 부추기기 때문입니다.

내부에서 사람들을 놀라게 한 비용 관련 사실 하나: 일회성 집계의 경우, 실시간 스캔은 DynamoDB→S3 내보내기를 생성하는 것보다 대략 6배 저렴 합니다 — 그래서 스크립트 형태의 갈래가 기본값이고, 데이터 웨어하우스는 반복되는 무거운 분석에 대한 권고이지 반사적 선택이 아닙니다. (요금 계산기가 여러분 자신의 테이블을 한 번 전부 훑는 비용을 계산해 줍니다.)

프로덕션에 SQL을 겨누어도 안전한가?

Workbench는 구조적으로 읽기 전용입니다. 로컬 엔진에 도달하는 최종 SQL은 심층 방어 관문을 통과하는데, 이 관문은 토큰화하여 하나의 SELECT가 아닌 것은 무엇이든 거부합니다 — 사용자 오류가 아니라 계약 위반 으로서요. 컴파일러가 애초에 그것을 만들어냈어서는 안 되기 때문입니다. SQLite 기반은 위험한 내장 기능을 비활성화한 채 실행되며, DynamoDB로의 쓰기는 앱 전체에서 정확히 하나의 경로만 갖습니다. 바로 SQL이 닿을 수 없는 검토 가능한 스테이징 영역입니다.

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

  • 쿼리 엔진을 작성하지 말고, 이미 존재하는 것 위에 얹는 충실한 컴파일러 를 작성하세요. 여러분의 버그는 조인 알고리즘이 아니라 이음새(식별자 대소문자, 타입 봉투, 손상된 행)에 살게 될 것입니다.
  • 파서에 따옴표 출처(quote-provenance)를 요구하세요. name"NAME"을 구분하지 못한다면, SQL의 대소문자 의미론을 구현할 수 없고, 여러분보다 사용자가 먼저 그 사실을 알게 될 것입니다.
  • "처리되지 않은 구문"을 기본값이 아니라 요란한 오류로 만드세요. 조용한 NULL은 자신만만하게 틀린 답이 출시되는 방식입니다.
  • 여러분의 설계를 사용자가 작성할 일회용 스크립트에 견주어 벤치마크하세요. 스크립트는 스트리밍하는데 여러분은 버퍼링한다면, 잘못된 형태를 만든 것입니다.
  • 부분 집계를 부분 점수가 아니라 라벨이 필요한 틀린 답으로 취급하세요.

Workbench 문서는 일상적인 기능 표면을 다루고, DynamoDB를 위한 SQL은 생태계 전반에서 무엇이 통하는지 짚어 줍니다. 아니면 그냥 DynoTable을 다운로드해서, ⌘⌥Q로 Workbench 탭을 열고, 그것을 갖지 못한 데이터베이스를 상대로 JOIN을 실행해 보세요.

Console 없이 DynamoDB 작업하기

DynoTable은 DynamoDB를 위한 빠른 데스크톱 클라이언트입니다 — 테이블을 탐색하고, SQL 스타일 쿼리를 실행하고, 항목을 로컬에서 편집하세요.