NL2SQL 보안: LLM이 만든 SQL은 AST 검증 게이트 없이 실행하지 않기
LLM이 생성한 SQL은 값이 아니라 문장 전체가 입력이라 파라미터 바인딩으로 못 막아요. AST로 뜯어 SELECT만·허용 테이블·테넌트 필터 3규칙을 검사하는 게이트 만들기.
"이번 달 회비 안 낸 멤버만 보여줘." 동호회 운영 화면에 이런 자연어 질문을 붙이고 싶다는 생각, 한 번쯤 해 보셨을 거예요. LLM에게 문장을 주면 SQL이 나오고, 그걸 DB에 던지면 표가 뜨니까 데모까지는 금방이에요. 문제는 그다음이에요.
파라미터 바인딩이 지키는 건 값 자리뿐이에요
SQL 인젝션 대책으로 배우는 건 파라미터 바인딩이죠. WHERE club_id = $1처럼 값 자리를 비워 두고 나중에 채우는 방식이에요. 그런데 LLM이 만든 SQL은 값이 아니라 문장 전체가 입력이에요. 바인딩할 자리 자체를 모델이 정하니까, 모델이 WHERE 절을 통째로 빼먹거나 엉뚱한 테이블을 조인해도 드라이버는 막을 이유가 없어요.
라이프케어로그에서 만드는 플랜비 같은 동호회 운영 데이터를 흉내 낸 샘플 테이블(멤버·회비 2개)로 직접 돌려 보니, 같은 질문인데도 WHERE 절이 매번 조금씩 달랐어요. 어떤 답엔 club_id 조건이 아예 없었고요. 그대로 실행했다면 다른 동호회 멤버까지 섞여 나왔을 거예요. 값은 멀쩡한데 구조가 틀린 셈이에요.
실행 전에 AST로 문장을 뜯어 보는 게이트
그래서 LLM이 준 문자열을 바로 실행하지 않고, 파서로 AST(구문 트리)를 만든 뒤 규칙을 검사해요. 흐름은 4단계예요.
검사 규칙은 3개면 시작할 수 있어요.
- SELECT 문 1개만
- 허용 테이블
- 컬럼만
- 테넌트 필터(club_id) 필수
- SELECT 하나만: 문장이 2개 이상이거나
UPDATE·DELETE가 섞이면 트리 루트에서 바로 걸려요. 세미콜론으로 이어 붙인 문장은 파서가 2개로 쪼개 주니까 문자열 검색보다 정직해요. - 허용 테이블·컬럼만: 서브쿼리 안쪽까지 트리를 순회하며 테이블 이름을 화이트리스트와 대조해요.
pg_계열 시스템 함수도 여기서 거부하고요. - 테넌트 필터 필수:
WHERE에 현재 사용자의club_id비교가 없으면 실패예요. 이 값은 모델 출력이 아니라 세션에서 꺼내 바인딩해요. 딱 이 지점에서만 파라미터 바인딩이 제 역할을 해요.
파이썬이면 sqlglot 같은 파서로 트리를 얻을 수 있어요. 게이트를 직접 만들어 보니 검사 코드는 트리 순회 함수 하나로 끝났고, 적용해 봤더니 club_id가 빠진 초안은 실행 전에 전부 반려됐어요. 반려된 문장은 버리지 말고 따로 모아 두세요. 프롬프트를 고칠 때 가장 좋은 재료가 돼요.
게이트 뒤에 한 겹 더
AST 검사도 앱 코드라 실수할 수 있어요. 그래서 실행 계정은 읽기 전용 롤로 두고, statement_timeout을 3초처럼 짧게 걸고, 결과엔 LIMIT 100 상한을 강제해요. RLS는 마지막 경계로 켜 두되, 규칙 판단은 서버 코드에서 하는 편이 테스트하기 쉬워요.
플랜엘처럼 법령·판례를 검색하는 서비스에 자연어 질의를 붙인다고 상상해 보면, 데이터는 공개 자료라 테넌트 걱정은 덜해요. 대신 월 3건 무료 같은 사용량 한도가 있으니, 그 집계 테이블만큼은 모델이 건드리면 안 되죠. 검사 대상이 값이 아니라 문장의 모양이라는 점은 똑같아요.
테스트해 보면 파서 설치부터 첫 반려까지 30분 정도예요. SQL을 대신 써 주는 편의는 챙기고, 실행 버튼은 여전히 게이트가 쥐고 있게 두는 거예요.
관련 글
댓글
아직 댓글이 없어요. 첫 댓글을 남겨주세요.