지난 글에서 제약이 오토마톤 상태로 토큰을 거른다는 것을 봤다. 그 오토마톤을 JSON Schema에서 만들었을 뿐, 원리 자체는 JSON과 아무 상관이 없다. 상태 기계를 만들 수 있는 규칙이면 무엇이든 제약이 될 수 있다. 그래서 출력이 JSON이 아니어야 하는 순간 — SQL 한 줄, 사내 명령어, 수식, 특정 마크업 — 문법을 직접 적는 쪽으로 간다.
언제 스키마로 부족한가
세 가지 신호가 있다. 첫째, 출력이 JSON이 아니다. SQL이나 정규식이나 설정 파일 문법을 뽑아야 하는 경우다. 둘째, JSON이긴 한데 값 안의 문자열에 다시 문법이 있다. {"query": "SELECT ..."}처럼 필드 값이 또 다른 언어인 경우, 스키마는 그 값이 문자열이라는 것까지만 보장한다. 셋째, 깊이가 정해지지 않은 재귀 구조가 필요하다.
거꾸로, 표현력이 큰 쪽을 습관적으로 고르는 것도 손해다. 안에서 되는 일을 바깥 도구로 하면 컴파일 비용과 상태 수만 늘어난다. 값이 정해진 라벨 몇 개면 enum으로 끝내고, 객체 트리면 JSON Schema로 끝내고, 그것으로 안 되는 것만 문법으로 간다.
| 표현할 수 있는 것 | 컴파일 비용 | 쓰는 곳 | |
|---|---|---|---|
enum · const |
유한한 문자열 집합 | 거의 없음 | 분류 라벨, 상태값 |
| 정규식 | 고정 형식 문자열 | 낮음 | 날짜, 코드, 식별자 |
| JSON Schema | 모양이 정해진 트리 | 중간 | API 응답, 추출 결과 |
| 문맥자유문법 | 재귀 구조, 임의 DSL | 높음 | SQL, 수식, 사내 명령어 |
문법은 이렇게 생겼다
로컬 추론 쪽에서 널리 쓰이는 표기가 GBNF다. 이름은 BNF에서 왔고, 실제로는 EBNF에 가까운 반복·선택 기호를 함께 쓴다.
root ::= "SELECT " cols " FROM " name cond?
cols ::= "*" | name (", " name)*
cond ::= " WHERE " name op num
op ::= "=" | ">" | "<"
name ::= "orders" | "users" | "amount" | "id"
num ::= [0-9]+
읽는 방법은 단순하다. ::= 왼쪽은 규칙 이름, 오른쪽은 그 규칙이 펼쳐지는 방법이다. |는 선택, ?는 있어도 되고 없어도 됨, *는 0회 이상 반복, [0-9]는 문자 범위다. 생성 중에 규칙이 펼쳐지면 남은 항목들이 스택에 쌓이고, 스택 맨 위가 다음에 무엇이 와야 하는지를 정한다.
여기서 눈여겨볼 것은 name 규칙이다. 테이블과 컬럼 이름을 문법 안에 나열해 두면, 존재하지 않는 이름은 생성될 수 없다. SQL 생성에서 가장 흔한 실패가 없는 컬럼을 지어내는 것인데, 그 부류가 통째로 사라진다.
문법을 손으로 안 쓰는 것이 요령이다
위 문법에서 이름 목록을 사람이 유지하면 스키마가 바뀔 때마다 어긋난다. 실무에서는 카탈로그에서 문법을 만들어 낸다.
def build_sql_grammar(tables: dict[str, list[str]]) -> str:
"""{"orders": ["id", "amount"], "users": ["id", "email"]} 형태를 받는다."""
table_alt = " | ".join(f'"{t}"' for t in tables)
column_alt = " | ".join(
f'"{c}"' for c in sorted({c for cols in tables.values() for c in cols})
)
return f"""
root ::= "SELECT " cols " FROM " table cond?
cols ::= "*" | column (", " column)*
cond ::= " WHERE " column op num
op ::= "=" | ">" | "<"
table ::= {table_alt}
column ::= {column_alt}
num ::= [0-9]+
"""
이렇게 만들면 스키마 마이그레이션이 문법에 자동으로 반영된다. 그리고 지난 글에서 본 캐시 문제를 여기서도 조심한다 — 이 함수를 요청마다 부르면 매번 새로 컴파일된다. 카탈로그 버전을 키로 캐시해 두고 스키마가 바뀔 때만 다시 만든다.
한계도 이 예시에 그대로 드러난다. column을 테이블과 무관하게 나열했으므로 SELECT email FROM orders가 문법상 유효하다. 어느 컬럼이 어느 테이블에 속하는지는 문맥 의존 규칙이라 문맥자유문법으로 못 적는다. 테이블별로 문법을 나눠 만들면 한 단계 좁힐 수 있지만, 조인이 들어오면 다시 안 된다. 이 층은 결국 받은 뒤에 카탈로그로 검증한다.
자주 걸리는 세 가지
좌재귀. 규칙이 자기 이름으로 시작하면 대부분의 구현이 무한 루프에 빠지거나 컴파일을 거부한다.
# 안 된다 — expr이 expr로 시작한다
expr ::= expr " + " term | term
# 반복으로 바꾼다
expr ::= term (" + " term)*
연산자 우선순위가 필요하면 층을 나눈다. expr ::= term (("+"|"-") term)*, term ::= factor (("*"|"/") factor)*, factor ::= num | "(" expr ")" 식이다. 이 형태는 좌재귀가 아니면서 우선순위를 표현한다.
공백. 문법은 공백을 자동으로 넘겨 주지 않는다. 규칙에 " "를 안 적으면 모델은 공백을 넣을 수 없고, SELECTamount가 나온다. 반대로 ws ::= " "*처럼 아무 데나 임의 공백을 허용하면 모델이 공백만 계속 뽑는 경로가 생겨서 출력이 늘어질 수 있다. 필요한 자리에 정확히 하나를 박아 두는 편이 안전하다.
모호성. 같은 문자열이 두 가지로 파싱되는 문법은 컴파일러가 상태를 훨씬 많이 만들거나 아예 실패한다. 선택지의 접두사가 겹치지 않게 쓰는 것이 요령이다. "SELECT" | "SELECTED"처럼 겹치는 대안은 파서를 어렵게 만든다.
문법과 JSON을 같이 쓰기
실무에서 가장 자주 필요한 형태는 "JSON 안에 다른 문법의 문자열을 넣는 것"이다. 두 가지 방법이 있다.
첫째는 호출을 나누는 것이다. 먼저 스키마로 메타데이터를 받고, 그다음 문법으로 SQL만 받는다. 각 호출의 제약이 단순해지고 실패했을 때 어디가 틀렸는지 바로 보인다. 대신 왕복이 두 번이다.
둘째는 문법 안에 JSON 구조를 직접 적는 것이다. 문맥자유문법은 JSON을 포함하므로 이게 된다.
root ::= "{\"reason\": \"" text "\", \"sql\": \"" query "\"}"
text ::= [^"\\]{1,200}
query ::= "SELECT " cols " FROM " table
cols ::= "*" | column (", " column)*
한 번의 호출로 끝나지만 문법이 금세 지저분해지고, JSON 이스케이프를 문법 안에서 다뤄야 해서 실수하기 쉽다. 필드가 두세 개를 넘어가면 호출을 나누는 쪽이 낫다.
문법이 품질에 미치는 영향
제약이 셀수록 모델이 갈 수 있는 길이 좁아지고, 좁아진 만큼 원래 가려던 경로가 막힐 확률이 올라간다. SQL 문법을 예로 들면, 모델이 LEFT JOIN을 쓰려 했는데 문법에 조인이 없으면 어떻게든 단일 테이블 쿼리를 만든다. 그 쿼리는 문법적으로 완벽하고 실행도 되지만 질문에 대한 답은 아니다.
그래서 문법을 좁힐 때는 못 하는 요청을 거절할 길을 같이 열어 둔다.
root ::= query | refusal
refusal ::= "UNSUPPORTED: " [^\n]{1,120}
query ::= "SELECT " cols " FROM " table cond?
refusal 분기가 있으면 모델이 억지 쿼리를 만드는 대신 그쪽으로 갈 수 있다. 그리고 그 문자열이 로그에 쌓이면 문법에 무엇을 더해야 하는지가 데이터로 남는다. 문법을 넓히는 작업을 추측이 아니라 기록으로 하게 되는 셈이다.
정리
문법 기반 제약은 JSON Schema의 확장이 아니라 그 아래 놓인 더 일반적인 도구다. 판단은 두 단계로 한다. 먼저 지금 필요한 형태가 enum이나 정규식으로 표현되는지 보고, 안 되면 JSON Schema를 보고, 그것도 안 되면 문법을 쓴다. 그리고 문법으로도 못 적는 것 — 앞에서 선언한 이름만 쓴다든가, 컬럼이 그 테이블에 실제로 있다든가 하는 문맥 의존 규칙 — 은 애초에 생성 제약의 몫이 아니다.
그 몫을 다루는 것이 다음 글이다. 형태가 맞는 출력을 받은 다음에 무엇을 더 확인해야 하는지를 본다.
지난 글: 제약 디코딩: 스키마가 토큰을 막는 방식
다음 글: 형태는 맞는데 값이 틀릴 때
읽어주셔서 감사합니다. 😊

