읽기 전용과 쿼리 취소는 DB 서버에 닿아야 한다

OwlAI의 SQL 실행 도구는 모델이 쓴 SQL을 고객의 운영 DB에서 실행합니다. 이때 클라이언트 쪽에서 건 제한은 걸린 것처럼 보여도 DB 서버에 닿지 않는 경우가 있습니다. 접속 옵션으로 건 읽기 전용은 커넥션 풀러 뒤에서 사라져, 실측에서 CREATE TABLE이 그대로 실행됐습니다. 드라이버의 소켓 타임아웃은 연결만 끊고 서버의 쿼리는 계속 돌게 둡니다. 사용자가 답변을 멈춰도 그 사실을 에이전트 루프만 알면, 이미 DB로 나간 쿼리는 시간 예산 600초가 다할 때까지 돕니다.
그래서 쓰기 차단, 시간 상한, 취소 세 가지를 모두 DB 서버가 직접 지키는 방법으로 겁니다. 읽기 전용은 트랜잭션마다 첫 문장으로 걸고, 시간 상한은 서버에도 걸고, 취소는 엔진마다 서버가 알아듣는 취소 호출로 보냅니다. 이 글은 엔진별로 무엇을 어디에 걸었는지와, 답변 중지 신호가 실행 중인 쿼리까지 가는 경로를 다룹니다.
접속 옵션과 세션 설정은 풀러 뒤의 DB 연결까지 가지 않는다
SQL 문자열에서 INSERT, DELETE 같은 낱말을 찾아 막는 방법은 오래 버티지 못합니다. 쓰기를 일으키는 함수는 엔진마다 달라 목록이 끝나지 않습니다. 그래서 쓰기를 거절하는 일은 DB 서버에 맡겼습니다.
DB 서버에 읽기 전용을 거는 쉬운 방법은 접속할 때 옵션으로 주거나 세션에 SET을 한 번 보내는 것입니다. 클라이언트가 DB에 직접 붙는다면 이것으로 충분합니다. 그런데 사이에 PgBouncer, Supavisor, ProxySQL 같은 커넥션 풀러가 있으면 달라집니다. 트랜잭션 모드 풀러는 트랜잭션 동안에만 서버 연결 하나를 클라이언트에 내주고, 트랜잭션이 끝나면 그 연결을 풀로 돌려보냅니다. 설정을 건 자리에 따라 결과는 이렇게 갈립니다.
| 설정을 건 자리 | 풀러 뒤에서 일어나는 일 |
|---|---|
| 접속 옵션 | 풀러가 받고 실제 DB 연결에는 넘기지 않는다. PostgreSQL 실측에서 읽기 전용이 꺼진 채 CREATE TABLE이 실행됐다 |
세션 SET |
그 순간 묶인 서버 연결에만 남는다. 다음 트랜잭션은 다른 서버 연결에서 돌 수 있고, 남은 설정은 그 연결을 이어받는 다른 클라이언트에게 간다 |
| 트랜잭션 범위 설정 | 트랜잭션 동안 풀러가 서버 연결 하나에 묶어 두므로, SQL이 도는 바로 그 연결에 걸린다. 트랜잭션이 끝나면 사라진다 |
드라이버의 읽기 전용 표시도 쓰기를 막는다는 보장이 없습니다. Tibero JDBC 드라이버에 읽기 전용을 표시하고 실행해 보니 쓰기가 그대로 성공했습니다. 드라이버의 표시를 서버가 강제할지는 엔진이 정합니다.
읽기 전용은 트랜잭션마다 첫 문장으로 건다
SQL 실행 도구는 SQL 한 번마다 트랜잭션을 새로 열고, 그 트랜잭션의 첫 문장으로 읽기 전용을 겁니다. 모델이 쓴 SQL은 그 트랜잭션 안에서 돌고, 쓰기를 시도하면 DB 서버가 오류로 거절합니다.
| 엔진 | 거는 방법 | 쓰기를 시도하면 |
|---|---|---|
| PostgreSQL | 트랜잭션 맨 앞에 SET TRANSACTION READ ONLY, 결과는 서버 커서로 읽는다 |
서버가 25006으로 거절한다 |
| MariaDB·MySQL | 연결의 첫 문장으로 START TRANSACTION READ ONLY |
서버가 1792로 거절한다 |
| Oracle | 트랜잭션 맨 앞에 SET TRANSACTION READ ONLY |
서버가 ORA-01456으로 거절한다 |
| Tibero | 엔진이 읽기 전용 트랜잭션을 받지 않는다 | 조회 권한만 있는 계정이 막는다. 실행부는 어떤 경우에도 커밋하지 않고, 연결을 닫을 때 롤백한다 |
MariaDB·MySQL과 Oracle에서는 읽기 전용을 걸지 못하면 그 연결로 아무 SQL도 실행하지 않습니다. 걸리지 않은 채 실행하면 남는 방어가 계정 권한 하나뿐이기 때문입니다.
트랜잭션 범위 설정은 그 트랜잭션 안에서 SQL이 한 문장만 실행된다는 전제에서 성립합니다. PostgreSQL에는 ;로 이은 여러 문장을 한 번에 실행하는 단순 질의 프로토콜이 있어서, 실행 도구는 한 번에 한 문장만 받는 확장 프로토콜로만 SQL을 보냅니다.
Oracle에는 읽기 전용 트랜잭션 밖에서 쓰는 길이 하나 더 있습니다. PRAGMA AUTONOMOUS_TRANSACTION으로 선언한 함수는 호출한 쪽과 별개인 자율 트랜잭션에서 돌기 때문에, SELECT가 부른 함수 안의 쓰기가 커밋됩니다. 그래서 Oracle 실행부는 접속마다 ALTER SESSION DISABLE COMMIT IN PROCEDURE를 먼저 겁니다. 함수와 프로시저 안의 커밋이 ORA-00034로 거절되므로 자율 트랜잭션은 커밋하지 못합니다. 이 설정을 걸지 못해도 그 연결로는 실행하지 않습니다.
읽기 전용 트랜잭션이 막는 범위는 엔진마다 다르고, Tibero처럼 아예 받지 않는 엔진도 있습니다. 그래서 마지막 경계는 접속 계정입니다. 데이터베이스 연결 등록 창의 접속 준비 영역은 엔진별로 읽기 전용 계정을 만드는 SQL을 보여 주고, 조회(SELECT) 권한만 있는 계정으로 연결하도록 안내합니다.
시간 상한은 서버에도 걸고, 소켓 타임아웃에는 맡기지 않는다
SQL 한 번에는 시간 예산이 걸립니다. 값은 설치 설정 timeouts.dbQuerySeconds(기본 600초)이고, 모델이 도구 인자로 바꿀 수 없습니다. 예산은 SQL이 도는 동안 따로 뜨는 감시 스레드가 지킵니다. 감시 스레드는 0.1초마다 경과 시간과 취소 신호를 보고, 예산이 다하거나 취소 신호가 서면 엔진의 취소 호출을 부릅니다.
감시 스레드가 취소에 실패할 때를 대비해 DB 서버 쪽 시간 상한을 하나 더 겁니다. 서버 쪽 상한은 예산보다 5초 늦게 걸어서, 평소에는 감시 스레드가 먼저 문장을 멈춥니다.
| 엔진 | 서버 쪽 시간 상한 |
|---|---|
| PostgreSQL | 트랜잭션 범위 SET LOCAL statement_timeout |
| MariaDB | max_statement_time(트랜잭션을 연 뒤 세션 값으로) |
| MySQL | max_execution_time(트랜잭션을 연 뒤 세션 값으로) |
| Oracle | 드라이버의 호출 시간 상한(call_timeout) |
| Tibero | 드라이버의 쿼리 타임아웃 |
두 상한은 서로 빈 곳을 덮습니다. MySQL의 max_execution_time은 읽기 전용 SELECT에만 걸리고 저장 함수 안의 문장에는 걸리지 않는데, 그 문장은 감시 스레드가 보내는 KILL QUERY가 멈춥니다. 반대로 감시 스레드의 취소가 서버에 닿지 않으면 서버 쪽 상한이 문장을 끊습니다.
드라이버의 소켓 읽기 타임아웃은 서버 쪽 상한으로 쓰지 않습니다. MariaDB 드라이버로 실측해 보니 소켓 타임아웃은 클라이언트의 연결만 끊었고, 서버의 쿼리는 계속 돌았습니다.
멈춘 이유는 드라이버마다 다른 오류 문구가 아니라 감시 스레드가 남긴 표시로 판정합니다. 문장이 오류 없이 끝났어도 그 사이 감시 스레드가 취소를 보냈다면 결과를 버립니다. 취소가 오류 없이 끝나는 경우가 있어서, 그대로 두면 중간까지 읽은 행이 완전한 결과로 모델에게 갑니다. 모델은 취소됐다거나 시간을 넘겼다는 결과를 받고, 시간 초과라면 SQL을 고쳐 쓸지 정합니다.
취소는 엔진마다 서버가 알아듣는 방법으로 보낸다
감시 스레드가 부르는 취소 호출은 엔진마다 다릅니다.
| 엔진 | 취소 방법 |
|---|---|
| PostgreSQL | 드라이버의 취소 요청. 서버에서 pg_cancel_backend와 같은 효과를 낸다 |
| MariaDB·MySQL | 드라이버에 취소 API가 없어, 별도 연결로 KILL QUERY <스레드 ID>를 보낸다. 실측 1.0~1.15초에 멈췄다 |
| Oracle | 연결에 break 신호를 대역 안(in-band)으로 보낸다 |
| Tibero | 드라이버를 돌리는 사이드카 세션에 취소를 끼워 보낸다 |
Oracle 드라이버는 기본으로 break 신호를 TCP 긴급 데이터, 곧 대역 밖(out-of-band)으로 보냅니다. NAT나 프록시는 이 데이터를 흔히 떨어뜨립니다. 포트 매핑 뒤에 둔 Oracle 18c XE에서 기본 설정으로 취소하면 60초가 지나도 쿼리가 멈추지 않았고, 20초로 건 호출 시간 상한도 30~40초가 지나서야 끊었습니다. 그래서 break를 대역 안으로 보냅니다. 같은 환경에서 대역 안 break는 1~3초에 쿼리를 멈췄습니다.
답변 중지 신호를 실행 중인 쿼리까지 보낸다
에이전트 루프는 라운드(모델을 한 번 부르고 그 응답에 든 도구 호출을 실행하기까지)를 시작할 때와, 아직 시작하지 않은 도구 호출을 건너뛸 때 취소 신호를 확인합니다. 루프만 신호를 보면 이미 DB로 나간 SQL은 취소를 모릅니다. 격리 환경에서 서브에이전트를 멈춰 보니, 이미 나간 조회 둘(102초, 615초)이 끝날 때까지 한 라운드가 14분 동안 이어졌습니다.
그래서 취소 신호를 도구 핸들러까지 보냅니다. 루프는 도는 동안 자기 취소 신호를 Python 컨텍스트 변수(contextvars)에 올려 둡니다. 컨텍스트 변수는 asyncio.to_thread로 넘어간 스레드까지 따라가므로, SQL 실행 도구는 어느 스레드에서 돌든 지금 루프의 취소 신호를 꺼낼 수 있습니다. 신호를 함수 인자로 넘기면 루프와 도구 사이의 모든 함수가 그 인자를 받아 넘겨야 합니다.
SQL 실행 도구는 신호 둘을 함께 봅니다. 하나는 호출한 쪽이 준 신호(채팅 턴의 취소, 스키마 분석 작업의 취소)이고, 다른 하나는 지금 루프의 신호입니다. 실행 도구는 둘 가운데 하나라도 서면 서는 스레드 이벤트 하나를 만들어 감시 스레드에 넘기고, 두 신호를 0.25초마다 확인합니다. SQL 결과를 기다리던 코루틴이 취소돼도 같은 이벤트가 섭니다.
루프도 취소에 맞춰 끝납니다. 도구가 도는 사이 취소되면 남은 도구 호출은 취소됐다는 결과로 닫혀, 호출마다 결과가 하나씩 붙은 채로 이력에 남습니다. 루프는 그 자리에서 끝납니다. 취소된 호출을 연속 실패로 세면, 루프가 모은 내용으로 답을 쓰게 하는 모델 호출을 한 번 더 보냅니다. 취소된 턴에는 쓸 답이 없습니다.
이 경로가 루프 안에 있어서 채팅, 플레이북, 스키마 분석, 딥 리서치의 조사원 어디서 멈춰도 이미 보낸 쿼리까지 멈춥니다(「에이전트 루프는 하나만 두고, 기능별 동작은 훅으로 끼운다」).
설계에서 고른 것
쓰기 차단을 엔진의 읽기 전용 트랜잭션과 계정 권한에 맡겼으므로, 방어가 얼마나 단단한지는 엔진마다 다릅니다. Tibero는 계정 권한이 주된 경계이고, 고객이 계정에 쓰기 권한을 주면 그만큼 열립니다. 금지어 목록으로 거르는 방법은 DB 종류를 몰라도 돌아간다는 장점이 있지만, 목록이 엔진마다 다르고 끝나지 않습니다. 그래서 연결 등록 창이 엔진별 읽기 전용 계정 SQL을 함께 보여 주게 했습니다.
연결은 모든 엔진에서 SQL 한 번마다 새로 열고 버립니다. 그래서 MariaDB·MySQL처럼 시간 상한을 세션 값으로 거는 엔진에서도 그 값이 다음 호출이나 다른 클라이언트에게 남지 않고, 읽기 전용과 시간 상한은 늘 그 호출의 트랜잭션에만 걸립니다. 대가는 SQL마다 접속하는 시간입니다.
취소는 신호를 짧은 간격으로 확인해서 전합니다. 누른 뒤 감시 스레드가 알아차리기까지 최대 0.35초가 걸리고, 실측한 엔진에서는 쿼리가 멈추기까지 1~3초가 더 들었습니다. 사람이 누르는 취소에는 이 지연이 보이지 않아서, 두 신호를 한 이벤트로 모으는 단순한 구조를 골랐습니다.
커넥션 풀러를 앞에 둔 DB나 한 조회가 수 분씩 걸리는 운영 DB에 AI가 쓴 SQL을 보낸다면, 설정이 실제로 어느 서버 연결에 걸리는지부터 확인합니다. 읽기 전용과 시간 상한은 트랜잭션 범위로 걸고, 취소는 엔진의 취소 호출로 보내고, 소켓 타임아웃은 끊긴 연결을 정리하는 데만 씁니다.
어떤 문제를 해결하나요?

인사이트 · 회사 DB에 묻기
운영 DB에 AI를 붙이자는 말에 보안팀이 묻는 세 가지
업무 DB에 AI를 연결하자는 이야기가 나오면 DB 관리자와 보안팀은 같은 질문 세 개를 꺼냅니다. AI가 실수로 데이터를 지우면 어떻게 하나.

인사이트 · 보안과 감사
볼 권한이 없는 데이터를 AI에게 물으면 어떻게 될까
인사 데이터를 볼 수 없는 영업 사원이 AI에게 "올해 연봉 인상률 평균을 알려 줘"라고 묻습니다. AI가 회사 DB 전체에 접속할 수 있다면 사람에게 닫힌 문이 AI를 거쳐 열립니다.

인사이트 · OwlDB와 함께 쓰면
AI 조회 하나에 주문 화면까지 느려지지는 않을까
AI가 운영 DB에서 수억 건짜리 테이블을 조건 없이 훑기 시작하면, 같은 DB를 쓰는 주문 화면과 정산 배치가 그 순간부터 함께 느려집니다.
정형 데이터와 NL2SQL의 다른 글

엔지니어링 노트 · 정형 데이터와 NL2SQL
테이블을 잘못 고른 SQL도 실행된다, 검색이 후보부터 좁힌다
테이블이 수천 개인 업무 DB에는 주문 테이블 옆에 ORDMSTBAK, ORDMST202312 같은 백업·작업용 사본이 흔히 함께 남아 있습니다.

엔지니어링 노트 · 정형 데이터와 NL2SQL
컬럼의 뜻은 DDL에 없다, 질문 전에 값을 읽어 둔다
주문 테이블의 STATUSCODE CHAR(1)에는 S, D, C가 들어 있습니다. 모델이 C를 완료(complete)로 짐작하면 「지난달 완료 주문 매출」에 취소 주문이 섞여 나옵니다.

엔지니어링 노트 · 정형 데이터와 NL2SQL
부서마다 다른 매출 계산식, 승인된 용어집 정의만 질문 앞에 싣는다
「지난달 매출」을 물으면 영업팀은 수주 금액을, 재무팀은 반품을 뺀 세금계산서 발행 금액을 떠올립니다.