7B 모델을 활용한 구조화된 보안 취약점 탐지: 오류를 방지하는 스키마 설계
요약
7B 규모의 소형 모델을 사용하여 보안 취약점을 탐지할 때 발생하는 JSON 구조 오류 문제를 해결하는 방법을 다룹니다. 중첩된 스키마 대신 평탄한(flat) 스키마와 열거형(Enum)을 사용하여 모델의 추론 성능을 유지하면서도 구조적 안정성을 확보하는 설계 전략을 제시합니다.
핵심 포인트
- 소형 모델은 중첩된 구조에서 문맥을 놓치고 JSON 형식을 깨뜨리기 쉬움
- 스키마 복잡도를 낮추기 위해 단 한 단계의 중첩만 허용하는 평탄한 구조 권장
- 자유 텍스트 대신 폐쇄형 열거형(Enum)을 사용하여 출력 값의 일관성 확보
- 모델의 주의력(Attention)을 구조 유지보다 실제 추론 작업에 집중하도록 설계
지난해 약 2주 동안, 제 감사 파이프라인(audit pipeline) 실행 5회 중 1회꼴로 동일한 라인인 JSON.parse에서 오류가 발생하며 중단되었습니다. 모델의 보안 분석 자체는 종종 훌륭했습니다. 하지만 이를 감싸는 JSON은 동전 던지기처럼 운에 맡겨야 했습니다. 여기서는 끝에 쉼표(trailing comma)가 붙어 있거나, 심각도(severity)가 "pretty high"라고 적혀 있기도 했으며, 기억에 남는 한 사례에서는 모델이 유효한 JSON을 생성해놓고는 자신이 JSON을 생성했다는 정중한 설명 문단 안에 JSON을 넣어버리기도 했습니다.
저는 Ollama에서 실행되는 7b 모델인 qwen2.5-coder:7b에게 제가 API 응답을 설계하듯 설계한 중첩된 스키마(nested schema) 형태로 보안 취약점(security findings)을 출력하도록 요청하고 있었습니다. 그것이 실수였습니다. 결국 오류를 멈추게 한 스키마와, 나머지 오류를 잡아내는 루프를 소개합니다.
소형 모델에서 중첩된 스키마가 실패하는 이유
저의 첫 번째 스키마는 합리적으로 보였습니다:
{
"findings": [
{
...
프런티어 모델(frontier model)은 이를 문제없이 처리합니다. 하지만 7b 모델은 제가 분류하는 데 시간이 좀 걸릴 정도로 다양한 방식으로 실패합니다:
중첩(nesting)의 모든 단계는 추적을 놓치기 쉬운 지점입니다. 모델은 메모리에 의존하여 중괄호를 닫습니다. 생성 중간에 3단계 깊이까지 들어가면, 모델의 주의력(attention)이 주로 보안 추론(security reasoning)에 집중되어 있어 자신이 assessment 안에 있는지 vulnerability 안에 있는지 잊어버립니다. 프런티어 모델은 장부 정리(bookkeeping)를 위한 여유 용량을 가지고 있습니다. 소형 모델은 그 용량을 실제 작업에 쏟아붓기 때문에 구조가 무너집니다.
선택적 필드(Optional fields)는 즉흥적인 동작을 유도합니다. 소형 모델에게 내용이 느슨하게 정의된 details 객체를 주면, 모델은 실행할 때마다 매번 다른 키(key)를 만들어냅니다.
혼합된 타입(Mixed types)은 오류를 가중시킵니다. 문자열 옆에 있는 confidence 부동 소수점(float), 그리고 그 옆에 있는 숫자 배열은 세 가지 서식 규칙이 동시에 활성화됨을 의미합니다. 각각은 쉽지만, 온도(temperature) 설정이 적용된 상태에서 이들이 결합되면 끝에 쉼표가 붙는 등의 문제가 발생합니다.
해결책은 더 나은 파서(parser)를 사용하는 것이 아니었습니다. 스키마 복잡도는 하나의 예산(budget)이며, 7b 모델은 그 예산이 적다는 사실을 받아들이는 것이었습니다.
평탄한 스키마 (The flat schema)
이것이 제가 현재 몇 달째 거의 변경 없이 실행하고 있는 방식입니다:
import { z } from "zod";
const Severity = z.enum(["critical", "high", "medium", "low", "info"]);
...
실패를 통해 얻은 설계 규칙은 다음과 같습니다:
단 한 단계의 중첩 (One level of nesting, total). 평탄한 객체(flat objects)들의 배열로 구성하세요. 모델이 자신이 현재 어느 위치에 있는지 기억할 필요가 없어야 합니다.
분기 처리가 필요한 모든 항목에는 열거형 (Enums)을 사용하세요. 심각도(Severity)를 자유 텍스트로 두었을 때는 "High", "HIGH", "high-ish", "severe"와 같은 결과가 나왔습니다. 심각도를 프롬프트에 나열된 옵션을 가진 폐쇄형 열거형 (closed enum)으로 설정하면, 거의 매 실행마다 깔끔한 결과가 돌아옵니다. 만약 결과가 틀리더라도, 정렬 로직이 심각한(critical) 오류를 조용히 잘못 분류하는 대신 검증 (validation) 단계에서 잡아낼 수 있습니다.
설명이 필요한 모든 항목에는 문자열 (Strings)을 사용하세요. 원래는 lines를 숫자 배열로 만들고 싶었습니다. 하지만 모델은 범위(ranges), 문자열, 혹은 존재하지 않는 줄 번호를 출력하곤 했습니다. 이제 location은 산문(prose) 형태이며, 저는 이를 인간을 위한 힌트로 취급합니다. 사실 처음부터 그것이 본질이었으니까요. 모델의 출력에서 파싱 정밀도 (Parse precision)는 프로그래밍 방식으로 직접 처리할 부분에만 적용하세요.
선택적 필드 (Optional fields)를 사용하지 마세요. 모든 실행에서 모든 필드는 필수(required)여야 합니다. 작은 모델에게 일관성 (Uniformity)은 가독성 (legibility)과 같습니다.
심각도 열거형 (severity enum)은 매우 중요한 역할을 하므로 특별히 언급할 가치가 있습니다. spectr-ai의 모든 후속 단계가 이를 기준으로 정렬 및 필터링을 수행하므로, 창의성이 전혀 허용되지 않는 유일한 필드입니다.
경계에서만, 그리고 오직 그곳에서만 검증하세요
모델의 출력은 네트워크 요청과 마찬가지로 신뢰할 수 없는 입력 (untrusted input)입니다. 출력은 단 하나의 경계를 통과하며, 한 번 파싱 및 검증된 후에는 코드베이스의 나머지 부분이 타입이 지정된 값 (typed value)을 사용하여 작업하며, 다시는 의구심을 갖지 않습니다.
type Finding = z.infer<typeof Finding>;
function parseFindings(raw: string): z.SafeParseReturnType<unknown, { findings: Finding[] }> {
...
슬라이스-투-브레이스 (slice-to-braces) 트릭은 세련되지는 않았지만, "여기 당신의 JSON이 있습니다:"와 같은 부류의 실패를 한 번에 제거해 줍니다. 저는 이 방식과 타협했습니다.
오류를 다시 피드백하는 재시도 루프 (Retry loop)
신뢰성을 '좋음'에서 '지루할 정도로 안정적임'의 단계로 끌어올린 요소는 다음과 같습니다. 검증에 실패했을 때 단순히 재시도만 하지 말고, 모델에게 무엇이 틀렸는지 알려주세요.
async function getFindings(code: string, maxAttempts = 3): Promise<Finding[]> {
let feedback = "";
...
실무에서 중요한 세부 사항들:
Zod의 에러 메시지는 피드백 역할을 합니다. "severity: Invalid enum value. Expected 'critical' | 'high' | ..."와 같은 메시지는 모델에 정확히 필요한 수정 사항이며, 이미 실행 중인 검증기(validator)로부터 무료로 얻을 수 있습니다. 별도로 유지보수해야 할 에러 설명 코드가 필요 없습니다.
추출을 위한 낮은 온도 (Low temperature). 창의성은 분석 프롬프트(analysis prompt)에 있어야지, 포맷팅 프롬프트에 있어서는 안 됩니다. 온도를 0에 가깝게 설정하는 것만으로도 잘못된 형식의 출력(malformed-output) 비율이 눈에 띄게 줄어들었습니다.
Ollama의 format: "json"은 도움이 되지만 완벽한 해결책은 아닙니다. 이는 출력을 유효한 JSON 문법으로 제한하여 마지막 쉼표(trailing comma) 문제를 해결해주지만, 형식이 틀리거나 환각(hallucination)된 심각도(severity)를 포함한 '유효한 JSON'은 여전히 통과될 수 있습니다. 문법 강제(syntax enforcement)와 스키마 검증(schema validation)은 서로 다른 계층의 문제를 해결하므로, 두 가지 모두가 필요합니다.
세 번 시도 후, 실패 시에는 명확하게 알리세요. 초기에는 경고를 로그로 남기고 빈 결과 배열(findings array)을 반환하는 폴백(fallback) 로직을 사용했습니다. 이는 보안 도구로서 최악의 동작입니다. 파싱 실패가 조용히 "취약점 발견되지 않음"으로 변질되었기 때문입니다. 파이프라인이 검증된 출력을 생성할 수 없다면, 아주 크게 실패를 알려야 합니다.
이 모든 설정을 적용한 제 환경에서는 첫 번째 시도에서의 성공이 강력한 표준(norm)이 되었고, 재시도 루프(retry loop)가 나머지 거의 모든 상황을 처리합니다. 파싱 실패는 매일 겪는 짜증 나는 일에서, 이제는 궁금해서 로그를 확인하게 되는 수준으로 바뀌었습니다.
이 일반적인 교훈은 보안 취약점 탐지 그 이상에도 적용됩니다. 작은 모델을 사용할 때는 원하는 출력 형식을 얻으려고 싸우지 마세요. 대신 모델이 안정적으로 생성할 수 있는 형식으로 타협한 다음, 경계 지점(boundary)에서 기계적으로 그 형식을 강제하십시오.
모델이 여러분의 JSON 파서(parser)에 던져준 가장 저주스러운 결과물은 무엇이었나요?
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기