Engineering · Security · Governance

정상 요청을 막은 가드 가려내기: 읽기 전용 SQL 검증 단일화와 가드레일 PII 판정 분석

DB 연동에서 괄호 집합연산이 거부되던 원인을 9곳에 흩어진 읽기 전용 검증으로 특정해 단일 검증기로 통일하고, 채팅 붙여넣기 차단은 마스킹이 아니라 가드레일 모델의 PII 판정임을 직접 호출과 차단 로그 200건 분류로 확인했습니다. 두 건 모두 어느 계층이 막았는지부터 가렸습니다.

기간
2026.08 – 2026.09
소속
고객사 구축 · 은행
역할
원인 분석·쿼리 가드 수정

Key Results

9곳 → 1곳
읽기 전용 SQL 검증 구현DB 노드·도구 저장·문서화 프리뷰 경로를 단일 검증기로 위임
34 성공 / 16 거부
DB 조회 노드 실행 검증개발 환경과 고객 데모 환경 각 50케이스 실제 실행
PII 1 / 200건
가드레일 차단 이벤트 중 PII 카테고리고객 배포 라인 외부망 데모 환경의 통제 정책 로그 누적치

은행에 구축한 XGEN 에이전트 플랫폼에서 "정상적인 요청이 막힌다"는 제보가 한 달 간격으로 두 번 들어왔습니다. 8월 초에는 DB 연동에서 집합연산 같은 조회 문법이 거부된다는 제보였고, 9월 초에는 채팅창에 클립보드 내용을 붙여넣으면 오류가 난다는 제보였습니다. 하나는 SQL 문장을 검사하는 읽기 전용 검증기, 다른 하나는 사용자 입력을 분류하는 가드레일 모델의 일이었지만, 사용자에게는 둘 다 "시스템이 이유 없이 요청을 거절한다"로 보였습니다.

가드가 정상 요청을 막으면 가장 쉬운 대응은 가드를 끄거나 차단 목록에서 단어를 빼는 것입니다. 하지만 은행 환경에서 가드는 통제 장치이고, 한쪽을 느슨하게 하면 반대 방향의 구멍이 생기기 쉽습니다. 실제로 첫 번째 건을 추적하는 동안, 읽기 전용이라고 적힌 경로에서 데이터 변경 문장이 통과하는 것도 함께 드러났습니다.

이 글은 두 건을 차례로 다룹니다. 어느 계층이 요청을 막았는지 먼저 확정한 과정, SQL 차단 규칙을 단어가 아니라 문장 구조 기준으로 다시 정의한 이유, 그 과정에서 제가 넣은 회귀로 현장 노드가 멈춘 사고, 그리고 가드레일 차단은 코드를 고치지 않고 판정 근거를 정리하는 쪽을 택한 이유를 적었습니다.

요청이 지나는 두 갈래의 가드

XGEN에서 사용자 요청은 두 갈래로 검사를 받습니다. 채팅 입력은 에이전트가 LLM을 부르기 전에 PII 마스킹, 금칙어 마스킹, 가드레일 순서로 지나갑니다. 가드레일은 Qwen3Guard-Gen-8B 모델에 입력을 보내 Safe, Controversial, Unsafe 중 하나와 위반 카테고리를 받는 프롬프트 모더레이션입니다. 차단으로 판정되면 LLM을 호출하지 않고 "이러한 요청은 허용되지 않습니다. 원인: {카테고리}" 문구를 답변으로 돌려줍니다. 가드 모델은 분류 전용이라 응답 본문은 버리고 판정 라인만 파싱하며, 검사 방향도 입력 한쪽뿐입니다.

DB 조회는 캔버스의 DB 노드들(조회, 결과 가공, 도구 불러오기, 연결하기)과 도구 저장, 문서화 프리뷰가 각자 SQL 문장을 받아 읽기 전용인지 검사한 뒤 대상 DB로 보냅니다. 연결에 쿼리 템플릿이 등록돼 있으면 템플릿 재검증 함수가 한 번 더 실행됩니다. 관리자 설정의 SQL 콘솔은 별도 경로로 플랫폼 자체 DB에 문장을 실행합니다. 대상 DB는 PostgreSQL, Oracle, Informix 세 종류입니다.

[채팅 입력]
사용자 입력 → PII 마스킹 → 금칙어 마스킹 → 가드레일(Qwen3Guard-Gen-8B)
                                              ├ 차단: "허용되지 않습니다. 원인: {카테고리}" (LLM 미호출)
                                              └ 통과: LLM 호출 → 답변
 
[DB 조회]
DB 노드 · 도구 저장 · 문서화 프리뷰 → 읽기 전용 검증 → 대상 DB (PostgreSQL · Oracle · Informix)
  └ 템플릿 등록 연결이면 → 템플릿 재검증
관리자 SQL 콘솔 → 콘솔 전용 검증 → 플랫폼 DB

두 갈래 모두 차단 결과가 HTTP 오류로 드러나지 않는다는 점이 같습니다. 가드레일 차단은 정상 답변 말풍선에 문구로 나오고, SQL 검증 거부는 노드 실행 결과에 안내 문구로 나옵니다. 그래서 제보는 "오류가 난다", "조회가 안 된다"로 들어오고, 어느 가드가 무슨 근거로 막았는지는 사용자가 알 수 없습니다. 두 건 모두 이 질문에 먼저 답하는 데서 시작했습니다.

집합연산이 거부되던 DB 조회: 9곳에 흩어진 검증

첫 제보는 현업의 "DB 연동에서 집합연산 등이 조회가 안 된다"였습니다. 재현해 보니 괄호로 감싼 집합연산, 공통테이블식(WITH), 앞에 주석이 붙은 쿼리가 거부됐습니다. 집합연산의 각 가지에 정렬이나 건수 제한을 걸려면 괄호가 필수라, 실무 쿼리가 그대로 막히는 상황이었습니다.

검증 코드를 찾아보니 읽기 전용 검증이 9곳에 각자 구현돼 있었고, 공통 결함이 셋이었습니다. 첫째, 문장의 앞부분만 보고 판정하면서 선행 괄호를 벗기지 않아 괄호 집합연산과 선행 주석 쿼리가 거부됐습니다. 둘째, PostgreSQL·Oracle·Informix 경로 세 곳은 공통테이블식을 아예 허용하지 않았습니다. 셋째, 위험 키워드를 원문에서 매칭해 문자열과 주석을 가리지 않았습니다. 그래서 값 안에 삭제 관련 단어가 들어 있기만 해도 차단되고, 반대로 주석 뒤에 숨긴 키워드는 통과했습니다. 오탐과 미탐이 같은 원인에서 나오고 있었던 셈입니다.

관리자 SQL 콘솔은 더 근본적인 문제였습니다. 코드 주석에는 "보안상 조회만 허용"이라고 적혀 있었지만 검증 코드는 최초 커밋부터 한 줄도 없었고 권한 체크만 있었습니다. 해당 권한을 가진 계정이면 플랫폼 DB에 삭제나 권한 부여 구문을 그대로 실행할 수 있는 상태였습니다.

콘솔에 금지 목록 검증을 넣는 과정에서 토큰 차단의 한계가 실측으로 드러났습니다. 삭제·권한 계열 7종만 토큰으로 막았더니, 익명 코드 블록 안에서 문자열로 삭제 구문을 동적으로 생성해 실행하면 테이블이 실제로 사라졌습니다. 함수를 만들어 숨긴 뒤 조회로 호출하는 방식, 외부 프로그램 실행, 권한 상승도 모두 통과했습니다. 문자열과 달러 인용은 오탐을 막으려고 마스킹한 뒤 토큰 검사를 하므로, 그 안에서 생성된 문장은 내용 검사로 잡을 수 없습니다. 마스킹을 풀면 이번에는 정상 조회가 오탐됩니다. 내용을 들여다보는 방식으로는 이 딜레마를 벗어날 수 없었습니다.

토큰 차단은 반대 방향의 오탐도 만들었습니다. 계정·역할 생성과 변경을 막으려고 넣은 ROLE, USER 토큰이 문맥과 무관하게 걸려, role이나 user를 컬럼명이나 별칭으로 쓰는 정상 조회가 콘솔에서 거부됐습니다. 고객 배포 라인의 실배포본에서는 거꾸로 읽기 전용 연결인데도 데이터 변경 계열 문장이 가드를 통과해 DB까지 전달되는 것이 확인됐습니다. 그쪽 금지 목록에는 삭제·DDL·권한만 있고 데이터 변경 계열이 통째로 빠져 있었습니다. 같은 날 현업 요구사항은 "조회 문법은 템플릿이든 자유 쿼리든 어디서든 되어야 하고, CREATE·DELETE·DROP만 막히면 된다. PostgreSQL·Oracle·Informix 동일"로 정리됐습니다. 문법이 방언마다 제각각 차단되던 상태를 끝내라는 요구였습니다.

제가 넣은 회귀: 템플릿 등록 연결에서만 죽던 노드

검증 통일을 배포한 뒤, 템플릿이 등록된 연결로 DB 조회 노드를 실행하면 정상 조회여도 0.00초 만에 노드가 죽는 장애가 현장에서 났습니다.

File ".../<템플릿 매처>.py", line 588, in _revalidate_query
NameError: name 'up' is not defined

원인은 통일 작업 중 템플릿 재검증 함수에서 변수 up의 정의만 지우고, 그 아래에서 이 변수를 참조하던 키워드 스캔 블록을 지우지 않은 것이었습니다. 잔재가 남았는지 grep으로 점검했지만 소문자로 검색했고 실제 상수명은 대문자라 걸리지 않았습니다.

검증에서 놓친 이유가 더 중요했습니다. 이 경로는 연결에 템플릿이 등록돼 있을 때만 실행됩니다. 검증에 쓴 외부망 연결 3개가 모두 템플릿 0개라 해당 줄이 한 번도 실행되지 않았고, "템플릿 0개니 괜찮다"고 넘긴 판단이 그대로 구멍이 됐습니다. 템플릿을 쓰는 현장에서만 터진 이유입니다. 개발 환경 연결에 검증용 템플릿을 등록해 현장 구성을 재현하자 같은 오류가 났고, 잔재 블록 8줄을 지운 수정본은 통과했습니다. 그 블록이 하던 키워드 검사는 바로 위의 단일 검증기가 이미 더 촘촘하게 하고 있었습니다.

이 결함은 문법만 보는 컴파일 체크로는 잡히지 않지만, pyflakes는 정확히 한 줄로 잡았습니다. 관련 폴더 전체를 정적 분석하니 수정 전 1건, 수정 후 0건이었습니다. 재발 방지책으로 세 가지를 정했습니다. 푸시 전 정적 분석을 필수로 하고, 조건부 경로를 검증에 포함하고, 잔재 점검 grep은 대소문자를 무시합니다. DB 연결은 읽기 전용 여부와 템플릿 등록 여부의 조합이 4가지라, 최소한 템플릿 등록 연결로 한 번은 실행해야 합니다.

같은 시기에 결과 가공 노드만 집합연산이 실패하는 문제도 정리했습니다. 이 노드는 행 데이터를 인메모리 경량 DB에 적재한 뒤 문장을 실행하는데, 그 엔진은 괄호로 감싼 집합연산 가지와 가지별 정렬·건수 제한을 문법으로 받지 않습니다. 가드는 통과하는데 엔진이 거부하니, 사용자에게는 "왜 이 노드만 안 되지"로 보였습니다. 이미 의존성에 있던 SQL 파서로 실행 직전에 등가 변환하게 했습니다. 괄호 가지는 벗기고, 정렬·건수 제한을 가진 가지는 하위 질의로 감싸고, 중첩 집합연산은 재귀 처리 후 감싸 결합 순서를 보존합니다. 변환에 실패하면 원본을 그대로 실행합니다.

붙여넣으면 "허용되지 않습니다": 가드레일의 PII 판정

두 번째 제보는 9월 초 "클립보드로 복사한 내용을 채팅창에 붙여넣으면 에러가 난다, 의도된 구현인지 확인해 달라"였고, PII 통제 기능과 관련이 있는지도 함께 물었습니다. 실제로 UI에 표시된 것은 HTTP 오류가 아니라 답변 말풍선의 "이러한 요청은 허용되지 않습니다"였습니다.

첫 가설은 PII 마스킹이었습니다. 붙여넣는 내용에는 이름, 연락처 같은 개인정보가 섞이기 쉽고, 제보도 PII 통제를 의심하고 있었습니다. 하지만 이 문자열은 코드 전체에서 한 곳, 가드레일 판정 결과를 문구로 만드는 함수에만 있었습니다. 마스킹은 값을 가릴 뿐 요청을 거절하지 않습니다. 차단 주체는 가드레일로 좁혀졌습니다.

다음 질문은 "PII 마스킹을 켰는데 왜 걸리나"였습니다. 처리 순서가 마스킹 다음 가드레일이므로 가드 모델은 이미 마스킹된 텍스트를 받습니다. 그래서 가드 모델을 직접 호출해 입력 유형별 판정을 확인했습니다.

입력 판정
은행 여신 상품 종류를 묻는 질문 Safe
이름 + 주민번호 + 연락처 + 계좌 Controversial / PII
위 문장을 마스킹한 버전 Controversial / PII
이름 + 연락처만 Controversial / PII
담당자 연락처가 든 내부 문서 요약 요청 Controversial / PII

마스킹된 문장도 같은 판정을 받았습니다. 가드 모델은 값이 가려져도 이름·연락처가 놓인 문장 패턴을 다시 PII로 분류합니다. 마스킹과 가드레일은 별개의 통제이고, 마스킹으로 가드레일을 회피할 수는 없습니다.

남은 것은 판정이 Unsafe가 아니라 Controversial이라는 점이었습니다. 판정 파서는 정밀 필터링 모드가 켜져 있을 때만 Controversial을 차단합니다. 상태 API와 설정값을 보니 조사한 고객 배포 라인의 외부망 데모 환경은 제공자 vLLM, 정밀 필터링 꺼짐, 가드 장애 시 통과(fail-open)였고, 이 환경에서는 위 입력들이 모두 통과합니다. 따라서 실제로 차단이 났다면 제보 환경의 정밀 필터링 모드가 켜져 있거나, 붙여넣은 내용이 PII가 아닌 다른 카테고리로 Unsafe를 받은 것입니다. 폐쇄망 설정값은 원격으로 조회할 수 없어 현장 확인 항목으로 넘겼습니다.

"예전에는 안 걸렸다"는 말도 설명이 필요했습니다. 7월 말 변경으로 노드별 가드레일 사용 토글이 제거되고 거버넌스 기반 기본 적용으로 바뀌었습니다. 에이전트-정책 매핑에 가드레일 예외 행이 없는 에이전트는 모두 적용 대상이 됐습니다. 결함이 아니라 정책 변경에 따른 정상 동작이었습니다.

마지막으로 실제 차단 사유의 분포를 확인했습니다. 통제 정책 로그의 가드레일 차단 이벤트 누적 200건은 비폭력 불법행위 94, 폭력 41, 비윤리 36, 정치적 민감 19, 성적 6, 자해 3, PII 1건이었습니다. PII는 실제 차단 사유로는 드물었습니다. 이 분포를 보지 않고 제보를 PII 통제 결함으로 단정했다면 마스킹 정책을 고치는 헛수고를 했을 것입니다.

판단: 규칙은 문장 구조로, 통제는 근거로

SQL 검증에서는 세 가지 방향을 비교했습니다.

선택지 장점 단점 판단
9곳을 각각 패치 변경 범위가 작음 같은 결함이 경로마다 다르게 남고, 다음 노드에서 다시 갈라짐 버림
허용 문장만 통과하는 화이트리스트 미탐이 적음 관리자 콘솔 초기 안으로 시도했으나 정상 조회까지 과도하게 막음 폐기
단일 검증기 + 마스킹 후 판정 오탐·미탐의 공통 원인 제거, 방언 차이 흡수 검증기 하나의 결함이 전 경로로 퍼짐 채택

단일 검증기는 주석(중첩 포함)·문자열·달러 인용을 길이를 보존한 채 마스킹한 뒤 판정합니다. 길이를 보존하므로 위치 정보가 그대로 남고, 값 안의 단어를 구문으로 오인하지 않습니다. 선행 괄호와 공백을 벗겨 괄호 집합연산을 허용하고, 읽기를 시작하는 토큰 7종(조회, 공통테이블식, 값 목록, 테이블, 실행 계획, 설정 보기, 구조 보기)을 허용합니다. 차단은 오히려 강화했습니다. 문장 자리의 변경 구문, 세미콜론 다중 문장, 쓰기가 들어 있는 공통테이블식, 행 잠금, 서버 파일 읽기와 외부 세션 함수 계열을 거부합니다. 9곳은 모두 이 검증기에 위임했습니다. "단일 검증기의 결함이 전 경로로 퍼진다"는 단점은 반대로 말하면 한 곳만 고치면 된다는 뜻이고, 템플릿 경로 장애도 그 검증기 바깥에 남은 잔재에서 났습니다.

관리자 콘솔은 플랫폼 DB를 다루는 관리 도구라 금지 목록 방식을 유지하되, 차단 대상을 세 군집 13종으로 넓혔습니다. 삭제·파괴(객체 삭제, 행 삭제, 전체 비우기), 권한(부여·회수, 계정·역할 생성·변경), 우회 경로(익명 코드 블록, 동적 문장 실행, 준비된 문장, 함수·프로시저 생성, 외부 프로그램 실행)입니다. 우회 경로를 함께 막은 근거는 앞의 실측입니다. 문자열 내용을 검사하는 대신, 문자열을 문장으로 바꿔 실행하는 진입점 자체를 막았습니다. 콘솔에서는 그 뒤 ROLE, USER 토큰 오탐을 제거했습니다. DB 노드 경로에서는 고객 배포 라인에 데이터 변경 계열 차단을 추가하면서 정상 조회 오탐을 같은 변경에서 함께 고쳤고, 방언별로 제각각이던 조회 문법 차단은 요구사항대로 하나의 변경으로 통합했습니다.

가드레일은 코드를 바꾸지 않았습니다. 선택지는 가드를 끄거나 강도를 낮추는 것, 판정 로직을 손보는 것, 판정 근거를 정리하고 예외를 협의하는 것이었습니다. 오탐처럼 보여도 PII 판정은 의도된 통제이고, Controversial을 차단할지는 이미 환경별 설정으로 분리돼 있었습니다. 가드를 끄는 것은 통제 하나를 통째로 없애는 일이라, 대신 해제 경로를 단위별로 정리해 전달했습니다. 에이전트 단위로는 거버넌스 에이전트 관리의 가드레일 예외, 강도 조절은 정밀 필터링 모드, 전면 해제는 가드레일 제공자 "사용 안 함"이고, 사후 확인은 통제 정책 로그의 카테고리 기록입니다. 현장에는 차단 문구 끝의 카테고리로 실제 원인을 확정하고, 해당 시각의 로그를 대조하고, 붙여넣기가 잦은 업무 에이전트는 에이전트별 예외로 협의하는 순서를 제안했습니다.

검증 결과

지표 이전 이후 측정 조건
DB 조회 노드 실행 괄호 집합연산·공통테이블식·선행 주석 거부 조회 34종 성공, 차단 16종 거부 개발 환경과 고객 데모 환경 각 50케이스 실제 실행
실무형 복잡 쿼리 - 다중 조인·윈도우·JSON·배열·한글 리터럴 전부 성공 개발 환경 30케이스
검증기 문법 점검 설정 보기 1건 오차단 수정 후 통과 검증기 단위 48종
도구 저장 4종 거부 4종 저장, 삭제 구문 거부 개발 환경과 고객 데모 환경
결과 가공 노드 실패 7건 실패 0건 실행 파드 13케이스
관리자 콘솔 검증 없음 차단 12종, 허용 13종, 오탐 0 실제 컨트롤러 코드로 25케이스
미정의 참조 1건 0건 pyflakes, 관련 폴더 전체
PII 포함 입력의 가드 판정 - 4/4 Controversial 가드 모델 직접 호출

DB 도구 불러오기 경로는 도구 호출 이벤트를 확인해 에이전트가 실제로 도구를 호출하고 결과를 받는 것까지 봤습니다. 템플릿 경로 장애는 템플릿을 등록한 개발 환경 연결로 재현에 성공한 뒤 수정본 통과를 확인했습니다. 가드레일 쪽은 결과라기보다 진단의 근거입니다. 상태 API와 설정값, 가드 모델 직접 호출 5건, 차단 로그 200건의 카테고리 분포가 "PII 마스킹 결함이 아니라 가드레일의 의도된 판정이며, 실제 차단 여부는 환경 설정에 달려 있다"는 결론을 받칩니다.

회고와 남은 과제

토큰 단위 차단은 차단어를 하나 더할 때마다 그 단어를 컬럼명으로 쓰는 조회가 새로 막히는 구조입니다. 실제로 데이터 변경 계열을 막으면서 COPY, LOCK을 토큰 차단 목록에 넣자, 둘 다 SQL 비예약어라 컬럼명 오탐이 다시 생겼습니다. 문장 첫 토큰일 때만 막도록 좁히는 수정안을 따로 만들었지만, 그 변경은 기록상 닫힌 상태로 남아 있어 반영 여부를 다시 확인해야 합니다. 단일 검증기가 "문장 자리"를 기준으로 판정하도록 바꾼 것도 같은 이유입니다. 단어 목록은 문맥을 모르고, 문맥을 아는 판정만 오탐과 미탐을 함께 줄입니다.

Oracle과 Informix 경로는 코드를 같은 검증기로 통일했지만 실제로 실행해 본 적은 없습니다. 접속 가능한 DB를 확보하면 같은 케이스로 검증해야 합니다. 템플릿 경로 장애에서 배운 것은, 조건부로만 타는 경로는 검증 데이터가 그 조건을 만족하지 않으면 영원히 실행되지 않는다는 점입니다. 이후 DB 관련 변경에서는 연결 조합 4가지를 체크리스트로 둡니다.

가드레일 쪽에는 표시 문제가 하나 남아 있습니다. 차단 문구의 카테고리 라벨이 가드 모델의 영어 원문 그대로 노출되고, 통제 정책 로그에도 영어로 적재됩니다. 판정 로직과 무관한 표시 문자열이라 한글 매핑을 검토 항목으로 두었습니다. 두 건을 돌아보면, 차단 문구 한 줄과 로그 한 줄로 어느 계층이 막았는지를 확정한 것이 이후 판단을 모두 결정했습니다. 막은 주체를 모른 채 가드를 고쳤다면, SQL 쪽은 차단어를 빼는 땜질로, 가드레일 쪽은 엉뚱한 마스킹 정책 수정으로 끝났을 것입니다.