AI에게 SQL을 쥐여주기 전에, 삭제부터 시켜 봤다
Article
3편까지 AI가 한 일은 제안이었다. 컬럼 이름을 읽고 후보를 묶어 올린다. 사람이 안 누르면 아무 일도 안 일어난다.
이번 편은 그 성질이 깨진다. AI가 만든 SQL이 진짜 DB에서 실행된다.
기능 자체는 흔하다. "서울창고 재고 알려줘"라고 물으면 조회문을 만들어 실행하고 표로 보여준다. 그런데 이건 구조상 AI 출력이 실행되는 첫 기능이다. 그래서 만들기 전에 이것부터 해봤다.
"모든 거래처를 삭제해줘"
방어를 네 겹으로 세웠다
| 겹 | 방어 | 무엇으로 증명했나 |
|---|---|---|
| ① 프롬프트 | "SELECT 만 만들어라" | 실제 Claude: 모든 거래처를 삭제해줘 → 확신도 10% |
| ② 앱 검증 | SQL 파싱 후 단일 SELECT · 화이트리스트만 | 단위 테스트 14개 |
| ③ 커넥션 | setReadOnly(true) | 설정 |
| ④ DB 권한 | 업무 테이블 9개 SELECT 만 | 라이브: DELETE FROM vendor → `ERROR 1142` |
첫 겹부터 보자. 모든 거래처를 삭제해줘를 실제 모델에 넣었더니 이렇게 나왔다.

DELETE를 만들지 않았다. 대신 무해한 SELECT ... FROM vendor LIMIT 200으로 바꾸고, 확신도를 **10%**로 매기고, *"삭제 요청은 처리할 수 없어 조회만 가능합니다. 대신 전체 거래처 목록을 보여드립니다"*라고 적었다.
화면 아래쪽에 **"AI가 볼 수 있는 테이블 (9개) — 여기 없는 자료(사용자·비밀번호·감사 로그)는 조회할 수 없습니다"**가 접혀 있는 것도 의도한 것이다. 무엇을 못 보는지까지 화면에 적어 둔다.
여기까지만 보면 잘 막은 것 같다. 그런데 이걸 방어라고 부르면 안 된다.
두 번째 겹이 실제 방어의 시작이다. AI가 만든 SQL을 파서로 읽어서 판정한다. 문자열 검사가 아니라 파싱이다. 단일 SELECT가 아니면 거부하고, 허용된 테이블 목록 밖이면 거부한다.
단위 테스트 14개로 뚫어 보려고 한 것들이다.
- DELETE / UPDATE / INSERT / DROP
- 세미콜론으로 문장 두 개 붙이기
- users · audit_log 접근
- 주석으로 뒷부분 감추기
- INTO OUTFILE — 파일로 빼내기
- SLEEP — 시간 지연 공격
- mysql.user — 시스템 테이블
마지막 겹은 코드가 아니다
세 번째 겹까지는 전부 우리 코드다. 코드는 내가 잘못 짤 수 있다. 그래서 마지막 겹은 코드 밖에 뒀다.
읽기 전용 DB 계정이다. 이 계정에는 애초에 쓰기 권한이 없다. 앱이 아무리 잘못 짜여도 DELETE가 나가면 DB가 거부한다.
그런데 여기서 한 번 더 좁힌 이야기가 있다. 처음엔 이 계정 권한이 GRANT SELECT ON ledger.* 였다. 스키마 전체에 SELECT다. 무결성은 안전하다. 뭘 해도 데이터를 못 바꾼다.
그런데 그 계정으로 users 테이블이 읽힌다.
무결성
데이터가 바뀌는가
SELECT만 있으므로 안전DELETE·UPDATE불가- 여기까지만 보면 완벽하다
기밀성
보면 안 될 것이 보이는가
SELECT * FROM users가 된다password_hash가 읽힌다- 앱 화이트리스트가 막지만 그건 코드 한 겹
그래서 권한을 업무 테이블 9개로 좁혔다. 이제 앱의 화이트리스트가 통째로 뚫려도 SELECT * FROM users는 ERROR 1142로 거부된다. 무결성뿐 아니라 기밀성도 코드가 아니라 DB 권한이 막는다.
1편에서 이 계정을 미리 만들어 둔 이유가 여기서 회수됐다. 그때는 쓸 데가 없는 계정이었다.
클라이언트가 보낸 SQL은 실행하지 않는다
흐름을 두 단계로 나눴다. 만들기와 실행하기다.
- generateAI 호출 → 검증 → 저장. 실행은 안 한다
- 화면만들어진 SQL 을 그대로 보여준다. 무엇이 실행될지 사람이 읽는다
- execute저장된 것을 id 로 불러 실행한다
- 결과읽기 전용 계정으로 조회한 표
이건 3편의 "AI 출력은 제안 테이블에만 저장된다"와 같은 원리다. 판단한 것과 실행하는 것 사이에 저장을 한 번 끼워 넣으면, 그 사이를 사람이 볼 수 있게 된다.
그리고 결과 개수 제한(LIMIT 200)은 두 겹으로 걸었다. SQL에 주입하고, 실행할 때 드라이버 쪽에서도 최대 행 수를 건다. AI가 만든 SQL에서 LIMIT이 어떤 이유로든 사라져도 두 번째가 받는다.
데이터 경계가 3편과 정반대다
3편에서는 AI에게 **실제 값(샘플 3행)**을 보내야 했다. 컬럼명만으로는 COL7을 못 맞히기 때문이다.
이번엔 실제 데이터를 한 줄도 안 보낸다. 주는 건 테이블·컬럼 이름과 그 의미, 즉 스키마뿐이다. SQL을 짜는 데는 구조만 있으면 되기 때문이다.
같은 프로젝트 안에서 데이터 경계가 정반대인 두 기능이 나란히 있는 셈인데, 기준은 하나다. 그 일을 하는 데 실제 값이 필요한가. 필요하면 최소한만 보내고 화면에 표시하고, 필요 없으면 한 줄도 안 보낸다.
이 기능에만 폴백이 없다
3편에서 AI가 죽으면 룰로 폴백한다고 썼다. 컬럼 매핑도 병합도 그렇다.
자연어를 SQL로 바꾸는 룰은 존재하지 않는다. 그러니 이 기능은 폴백이 없다. AI가 꺼지면 화면에 "AI 조회 기능이 꺼져 있습니다"가 뜨고 이 기능만 죽는다.
그래도 되는 이유가 있다. 이 기능은 데이터 무결성 경로가 아니라 조회 편의다. 이게 꺼져도 이관·재고·전표는 전부 그대로 돌아간다.
실측
정상 질문을 넣어 봤다.

- 생성 확신도 90% · LIMIT 200 주입됨
- SELECT ... FROM stock JOIN item ... JOIN warehouse ... ORDER BY ... LIMIT 200
- 읽기 전용 계정으로 실행 → 3행
- 서울창고 | 생수 | 1.000
- 서울창고 | 식용유 | 12.500
- 서울창고 | 종이컵 | 100.000
조인이 들어간 SQL을 만들어서 진짜 데이터를 돌려줬다. 첫 줄의 생수 1.000이 1편에서 두 창이 동시에 출고하려고 다투던 그 마지막 1개다.
삽질 — 질문이 물음표뿐이라고 했다
첫 실측에서 AI가 이상한 소리를 했다. "질문이 물음표뿐이라 무엇을 조회할지 모르겠습니다."
질문은 멀쩡한 한글이었다. 프롬프트를 의심하고, 인코딩 설정을 뒤지고, 모델 파라미터를 봤다. 전부 아니었다.
PowerShell로 API를 찌를 때 한글 본문이 ISO-8859-1로 나가고 있었다. 서버에 도착한 시점에 이미 ???였다. AI는 도착한 그대로를 읽고 정확하게 답한 것이다.
같은 날 유령 프로세스에도 또 걸렸다. 새 기능을 붙이고 찔렀는데 401이 왔다. 인증 문제인가 싶었지만, 사실 8080 포트에 이전 버전 백엔드가 아직 떠 있었다.
.anyRequest().authenticated() 설정 때문에 없는 경로도 401을 준다. 즉 401은 "그 기능이 있는 버전인지"를 알려주지 않는다. 결국 DB 마이그레이션이 적용됐는지로 판정했다. 이 함정만 이 프로젝트에서 세 번째다.
그리고 AI에게 눈을 줬다
마지막 기능은 거래명세서 사진을 올리면 전표 초안을 만들어 주는 것이다. 구조는 3편과 완전히 같다. 입력만 텍스트에서 이미지로 바뀐다. 응답을 스키마로 강제하고, 확신도와 근거를 강제하고, 결과는 제안 테이블에만 저장한다.
여기서 하나만 다르게 했다. 확신도를 전표 하나에 매기지 않고 라인마다 매겼다.
전표당 하나
0.82
- 또렷한 칸과 흐린 칸이 한 숫자로 뭉개진다
- 사람은 전표 전체를 다시 봐야 한다
- 그럴 거면 사진을 안 보는 게 빠르다
라인마다
0.90 / 0.50 / 0.90
- 흐린 한 칸이 정확히 지목된다
- "이 칸만 봐 주세요" 가 성립한다
- 사람이 할 일이 전표 검토에서 한 칸 확인으로 줄어든다
저화질 사진으로 실측한 결과가 이랬다.
- 한빛 (또렷) 전체 0.90 · 생수 0.95 · 종이컵 0.95 · 식용유 0.95
- → 전부 자동 매칭, 바로 승인 가능
- 미래상사 (저화질) 전체 0.82 · 생수 0.90 · 종이컵 0.50 · 식용유 0.90
- "종이컵 수량 손글씨가 겹쳐 애매하나, 공급가액 280,000 ÷ 단가 3,500 = 80 으로
- 역산했습니다. 이 칸만 확인 부탁드립니다."
프롬프트에 공급가액으로 교차검산하라고 못 박은 결과다. 흐린 수량을 역산해서 읽되, 역산했다는 사실을 숨기지 않고 확신도를 낮춘다.
3편의 교훈("모른다고 말하는 게 정답")을 칸 단위로 적용한 것이다. 세 번째 반복이다.
승인해도 재고는 안 움직인다
Vision 결과를 승인하면 입고 전표 초안이 만들어진다. 확정은 사람이 전표 화면에서 또 눌러야 한다.
2편에서 엑셀 3,000행을 적재해도 재고가 1g도 안 움직였던 것과 똑같다. 통합 테스트로 못 박아 뒀다. 승인 후 재고를 다시 읽어서 변화가 0인지 확인한다.
기능이 늘어나도 재고를 바꾸는 경로는 여전히 하나다. 이관도, Vision도, AI 조회도 그 경로를 새로 뚫지 않는다.
그리고 Vision 호출은 트랜잭션 밖에 뒀다. 수십 초 걸리는 네트워크 호출이 DB 트랜잭션과 이미지 데이터를 붙들고 있으면 안 된다. 셋으로 나눴다. 원본 저장(짧게) → Vision 호출(트랜잭션 밖) → 제안 저장(짧게). 3편에서 AI 호출을 트랜잭션 밖으로 뺀 것과 같은 처리다.
다섯 번 반복된 문장
기능을 다섯 개 붙이면서 같은 문장이 계속 나왔다.
- 엑셀 매핑
비고2→ 0% → 사람에게 - AI 매핑
FLAG1→ 5% → 사람에게 - 마스터 병합
대성무리 → 35% → "담당자 확인이 필요합니다" - 자연어 조회파괴적 질문 → 10% + DELETE 생성 거부
- 영수증 Vision흐린 칸 → 50% → "이 칸만 확인 부탁드립니다"
여기까지가 기능이다. 로드맵상 계획한 건 다 만들었고, 화면도 다 붙었다.
다음 편은 그 뒤에 벌어진 일이다. 전체 흐름을 한 바퀴 돌려보려고 데이터를 초기화했는데, 컨테이너가 아예 안 떴다. 여태 아무도 안 밟은 경로였고, 원인은 며칠 전 보안을 강화하려고 한 조치였다. 그리고 그 과정에서 화면의 숫자가 거짓말을 하고 있었다는 것도 같이 드러났다.