
애플리케이션 코드의 보안 리뷰를 위한 AI
요약
LLM이 기존 정적 분석 도구(SAST)보다 높은 재현율을 보이지만, 낮은 정밀도로 인해 오탐(False Positive)이 발생하는 트레이드오프를 분석합니다. AI 보안 리뷰의 세 가지 형태인 채팅, 에이전트, 하이브리드 파이프라인의 특성을 설명합니다.
핵심 포인트
- LLM은 SAST 도구보다 취약점 탐지 재현율이 높음
- 높은 재현율의 대가로 정밀도가 낮아 오탐 발생 가능성 높음
- 보안 리뷰 방식은 채팅, 에이전트, 하이브리드 세 가지로 구분됨
- AI를 보안 파이프라인에 통합할 때 노이즈 관리가 핵심
2025년 벤치마크에서는 10개의 실제 C# 프로젝트에 심어진 63개의 실제 취약점을 대상으로 세 가지 산업용 정적 분석 도구(SonarQube, CodeQL, Snyk Code)를 실행했습니다. 그중 가장 성능이 좋았던 Snyk Code는 약 0.55의 F1 점수를 기록했습니다. 가장 성적이 낮았던 SonarQube는 0.26을 기록했습니다. 이어 동일한 연구진이 같은 데이터 세트를 세 가지 최첨단 LLM(Large Language Models)을 통해 실행했습니다. GPT-4.1, Mistral Large, DeepSeek V3는 모두 0.75에서 0.80 사이의 점수를 기록했으며, 이는 주로 정적 분석 도구들이 그냥 지나쳤던 것들을 잡아냈기 때문입니다.
만약 이를 _"AI가 승리했으니, SAST(Static Application Security Testing)를 대체하라"_라고 읽는다면, 당신은 틀린 것입니다. 동일한 연구와 그와 유사한 수많은 연구들은 LLM이 재현율 (Recall)(더 많은 것을 잡아냄) 측면에서는 승리하지만, 정밀도 (Precision) 측면에서는 크게 뒤처진다는 것을 보여줍니다. IDOR(Insecure Direct Object Reference) 탐지에 대한 별도의 분석에 따르면, 인기 있는 AI 코딩 에이전트가 IDOR로 표시한 문제의 88%가 실제로는 IDOR가 아니었습니다. 즉, 당신이 AI에게 50개의 파일로 구성된 풀 리퀘스트(Pull Request)를 건네주면, AI는 당신이 놓친 SQL 인젝션(SQL injection)을 찾아낼 것입니다. 하지만 동시에 인젝션 버그가 아닌 인젝션 버그 6개, 레이스 컨디션(Race condition)이 아닌 레이스 컨디션 2개, 그리고 인증 로직 자체가 없는 코드에서 "잠재적인 권한 우회(potential authorization bypass)"를 찾아내기도 할 것입니다.
이러한 긴장 상태가 바로 AI 보안 리뷰의 실체입니다. 당신은 확신을 가지고 놓치는 리뷰어 대신, 존재하지 않는 것까지 포함하여 확신을 가지고 찾아내는 리뷰어를 선택하는 트레이드오프(Trade-off)를 하고 있는 것입니다. 이 글의 목적은 네 가지 고전적인 취약점 클래스(SQL injection, XSS, 인증 버그, 안전하지 않은 역직렬화(Unsafe deserialization)) 전반에 걸쳐 이러한 트레이드오프가 어디에서 이득을 주는지 살펴보고, 노이즈(Noise)가 신호(Signal)를 압도하지 않도록 AI를 보안 리뷰 파이프라인에 어떻게 연결할지 설명하는 것입니다.
"AI 보안 리뷰"가 실제로 의미하는 것
먼저 마케팅 문구부터 걷어내 봅시다. 사람들이 _"보안 리뷰를 위한 AI"_라고 말할 때, 그들은 대개 세 가지 중 하나를 설명하고 있으며, 이들은 서로 대체 가능한 것이 아닙니다.
첫 번째는 **채팅 스타일의 리뷰 (chat-style review)**입니다. 함수나 디프 (diff)를 모델에 붙여넣고 보안 문제를 찾아달라고 요청하는 방식입니다. 이것이 대부분의 엔지니어가 실제로 매일 수행하는 방식입니다. 비용이 저렴하고, 인프라가 전혀 필요 없으며, 코드베이스에 대한 기억도 전혀 없습니다. 모델은 사용자가 붙여넣은 내용만을 볼 뿐 그 외의 것은 보지 못합니다.
두 번째는 도구(파일 읽기, grep, 때로는 쉘)를 갖추고 특정 취약점 클래스 (vulnerability class)를 스캔하도록 지시하는 시스템 프롬프트 (system prompt)가 포함된 **에이전트 스타일의 리뷰 (agent-style review)**입니다. Claude Code의 보안 리뷰, Gemini CLI Action, GitHub Copilot Agent의 보안 모드가 모두 여기에 해당합니다. 에이전트가 무엇을 살펴볼지 결정하며, 프롬프트가 무엇을 발견 사항 (finding)으로 간주할지 결정합니다.
세 번째는 **하이브리드 파이프라인 (hybrid pipeline)**입니다. 결정론적인 정적 분석 도구 (deterministic static analysis tool)가 후보 위치를 찾으면, 각 후보에 대해 LLM을 호출하여 분류 (triage)를 수행합니다. Semgrep의 AI 어시스턴트가 이런 방식으로 작동합니다. SAST-Genius와 같은 최신 학술 프레임워크도 마찬가지입니다. LLM은 원본 코드베이스를 절대 보지 않으며, 후보 발견 사항과 그 주변 컨텍스트 (context)만을 봅.
이 세 가지는 겉보기에는 비슷해 보이지만 실제로는 매우 다르게 동작합니다. 순수 채팅 방식은 노이즈가 높고 유연성이 높지만 기억력이 없습니다. 에이전트 방식은 노이즈가 중간 수준이며 에이전트가 살펴보기로 선택한 범위로 제한됩니다. 하이브리드 방식은 SAST가 이미 힘든 작업을 수행했기 때문에 노이즈가 낮으며, LLM은 단지 _"이것이 실제로 악용 가능한가?"_라는 질문을 받게 됩니다. 누군가 _"우리는 보안 리뷰를 위해 AI를 사용한다"_라고 말한다면, 결과에 대해 결론을 내리기 전에 그들이 이 세 가지 중 무엇을 의미하는지 확인하십시오.
AI가 취약점을 "보는" 방식: 간단히, 내부 구조 살펴보기
CodeQL과 같은 정적 분석기 (Static analyzer)는 오염 분석 (Taint analysis)을 수행합니다. 이 도구는 프로그램의 데이터 흐름 그래프 (Data-flow graph)를 구축하고, 소스 (Source) (HTTP 쿼리 파라미터, 요청 본문, 환경 변수)로부터 들어오는 모든 입력을 오염된 (Tainted) 것으로 표시한 다음, 할당, 함수 호출, 필드 액세스를 통해 해당 오염이 싱크 (Sink) (SQL 쿼리 문자열, HTML 템플릿, 역직렬화 호출)에 도달하는지 추적합니다. 만약 오염된 값이 도구가 알고 있는 정화기 (Sanitizer)를 거치지 않고 싱크에 도달하면, 그것은 취약점 발견 (Finding)이 됩니다. 이는 구문론적 (Syntactic)입니다. 이 방식은 무언가를 증명할 수 있지만, 도구가 따라갈 수 없는 간접 경로(Indirection)를 통해 흐르는 것들, 즉 콜백 (Callback), 동적 디스패치 (Dynamic dispatch), 여러 파일에 걸쳐 구축된 문자열 등은 놓칠 수 있습니다.
LLM은 그렇게 하지 않습니다. LLM은 패턴 매칭 (Pattern-matches)을 합니다. req.query.id를 받아 SQL 문자열에 연결하는 함수를 붙여넣으면, 모델은 학습 데이터 세트에서 레이블이 지정된 사례를 포함하여 해당 패턴의 수만 가지 변형을 이미 보았습니다. 모델은 CodeQL이 알려줄 것과 동일한 내용을 알려주며, 종종 왜 그런지, 그리고 _어떻게 수정해야 하는지_까지 알려줍니다. 하지만 LLM은 공식적인 데이터 흐름 그래프 (Data-flow graph)를 가지고 있지 않습니다. 단지 그것이 있는 것처럼 추론할 뿐입니다. 이것이 LLM이 쉬운 문제(패턴이 학습 데이터에 포화되어 있음)에서는 더 많이 잡아내고, 어려운 문제(흐름을 증명할 수 없이 "위험해 보인다"는 식으로 패턴 매칭을 함)에서는 내용을 지어내는 이유입니다.
네 가지 취약점 클래스를 살펴볼 때 이 차이점을 명심하십시오. 특정 클래스가 "오염된 입력 근처의 인식 가능한 구문론적 형태"에서 멀어질수록, LLM의 성능은 저하됩니다.
[

AI 성능에 따른 네 가지 클래스 순위
순서가 중요합니다: 위쪽으로 갈수록 _가장 구문론적이고 패턴화된 형태_에 가깝고, 아래쪽으로 갈수록 _가장 의미론적이고 문맥 의존적_입니다. AI 보안 리뷰는 이 순서를 밀접하게 따릅니다.
안전하지 않은 역직렬화 (Unsafe Deserialization): 패턴 매칭의 천국
이것은 AI가 가장 잘 수행하는 클래스입니다. 왜냐하면 위험한 함수들은 짧고, 이름이 명확하며, 잘 알려져 있고, 이를 안전하게 만들 수 있는 영리한 방법이 없기 때문입니다. 실제로는 두 가지 사례가 지배적입니다.
첫 번째는 Python의 pickle 모듈입니다. 완전히 제어할 수 없는 데이터에 대해 pickle.loads()를 호출하는 것은 원격 코드 실행 (Remote Code Execution, RCE) 프리미티브 (primitive)입니다. pickle 형식에는 역직렬화 (deserialization) 과정에서 임의의 객체를 생성하고 임의의 호출 가능 객체 (callables)를 호출할 수 있는 옵코드 (opcodes)가 포함되어 있습니다. 이것은 pickle의 버그가 아닙니다. 문서 페이지 상단의 모듈 자체 경고문에도 명시되어 있습니다. 해결책은 _하지 않는 것_입니다. 데이터가 JSON 형태라면 JSON을 사용하세요. 더 풍부한 구조가 필요하다면 Protocol Buffers나 MessagePack과 같은 타입화된 형식을 사용하세요. "신뢰할 수 없는 데이터에 대해서도 안전한 pickle" 버전이란 존재하지 않습니다.
두 번째는 Java의 ObjectInputStream입니다. 개념은 동일합니다. 역직렬화 과정에서 readObject 메서드에 부수 효과 (side effects)를 가진 임의의 클래스를 인스턴스화할 수 있습니다. 2015년 Apache Commons Collections "가젯 체인 (gadget chain)" 공격은 이를 이론적인 위험에서 지금 당장 운영 환경에 패치를 적용해야 하는 위험으로 바꾸어 놓았습니다. Java 9 (2017년 출시)에서는 JEP 290이 추가되어, 스트림별 또는 JVM별로 역직렬화가 허용되는 클래스의 허용 목록 (allowlist)을 제공하는 ObjectInputFilter를 사용할 수 있게 되었습니다. 만약 Java 직렬화를 반드시 사용해야 한다면, 필터를 가능한 가장 작은 클래스 목록으로 설정하고 그 외의 모든 것은 거부해야 합니다.
두 경우 모두 버그가 다음과 같이 나타납니다:
:::tabs
vulnerable_pickle.py
import pickle
from flask import Flask, request
...
VulnerableDeserialization.java
import java.io.ObjectInputStream;
import java.io.InputStream;
...
:::
LLM에게 _"보안 문제를 검토해줘"_라고 요청하면, 이 두 가지 모두를 안정적으로 잡아낼 것입니다. HTTP 입력과 유사한 것 옆에 있는 pickle.loads( 문자열은 매우 포화된 학습 신호 (training signal)입니다. 필터가 없는 new ObjectInputStream(...).readObject()도 마찬가지입니다. 이를 현재의 어떤 프런티어 모델 (frontier model)에 넣어도, 모델은 수정 방법이 포함된 확신에 찬 정확한 결과값을 반환할 것입니다.
더 어려워지는 지점은 간접적인 (indirect) 버전입니다. 예를 들어, pickle.loads를 세 단계 떨어진 곳에서 감싸고 있는 loadState()라는 헬퍼 함수(helper function)가 있고, 이 함수가 pickle을 전혀 언급하지 않는 라우트 핸들러(route handler)에서 호출되는 경우입니다. 정적 분석 보안 도구(SAST tools)는 그 체인을 따라갑니다. LLM(Large Language Models)도 모든 내용이 컨텍스트 윈도우(context window) 안에 있고 주의를 기울인다면 그 체인을 따라갑니다. 하지만 라우트 핸들러만 붙여넣은 채팅 방식의 리뷰는 이를 놓칠 것입니다. 코드베이스를 grep 할 수 있는 에이전트(agent)라면 아마도 이를 잡아낼 것입니다. 바로 이 지점이 "AI인지 아닌지"보다 "어떤 종류의 AI 리뷰인지"가 더 중요해지는 지점입니다.
Tip
Python이나 Java가 포함된 코드베이스를 가지고 있다면,pickle.loads,pickle.load,marshal.loads,ObjectInputStream,XMLDecoder, 그리고 (Loader=SafeLoader가 없는)yaml.load에 대해 일회성 grep을 실행해 보세요. 놀라울 정도로 많은 실수를 잡아낼 수 있는 5분짜리 감사(audit) 방법입니다.
SQL Injection: 대부분 해결됨, 하지만 대부분은 아님
SQL 인젝션(SQL injection)은 AI 리뷰의 교과서적인 사례입니다. 모든 모델은 오염된 입력(tainted input) + 문자열 연결(string concatenation) + SQL 실행(SQL execution)이라는 패턴을 포화 상태로 학습했습니다. 이 Node 코드를 넣으면 어떤 모델이든 무엇이 잘못되었는지 알려줄 것입니다.
vulnerable.js
app.get("/user", async (req, res) => {
const { id } = req.query;
const rows = await db.query(`SELECT * FROM users WHERE id = ${id}`);
...
이제 이를 약간 더 어렵게 만들어 보겠습니다. 쿼리를 헬퍼 함수로 옮기고, 매개변수화된 것처럼 보이지만 실제로는 그렇지 않은 템플릿 태그(template tag)를 사용하여 SQL을 빌드해 보세요.
looks-fine-but-isnt.js
const sql = (strings, ...values) =>
strings.reduce((acc, s, i) => acc + s + (values[i] ?? ""), "");
...
여기서 sql 태그는 장식용입니다. 이는 보간된(interpolated) 값을 쿼리에 그대로 붙여넣습니다. slonik이나 sql-template-strings와 같은 라이브러리들의 관례 때문에, 마치 파라미터 바인딩 (parameter binding)을 수행하는 태그된 템플릿 리터럴 (tagged template literal)처럼 보입니다. 주니어 리뷰어라면 이를 대충 훑고 지나칠 것입니다. LLM 또한 채팅 스타일의 리뷰에서는 이를 놓칠 수 있는데, 그 형태(shape)가 안전한 라이브러리처럼 보이기 때문입니다. sql의 정의를 따르는 에이전트 스타일 (agent-style) 리뷰는 이를 잡아내며, SAST가 헬퍼 함수(helper)의 이름과 상관없이 데이터 흐름 (data flow)을 추적하기 때문에 하이브리드 파이프라인 (hybrid pipeline)도 이를 잡아냅니다.
LLM이 평균보다 성능이 떨어지는 몇 가지 사례는 다음과 같습니다:
- 동적 ORM 쿼리 (Dynamic ORM queries): ORM이 로우 프래그먼트 (raw fragments)를 허용하도록 설정된 경우입니다.
knex.raw(${col} = ?)는 형태상으로는 괜찮지만, 만약col이 사용자 제어 대상이라면 위험합니다. - 연결된 인자(concatenated arguments)로 호출되는 저장 프로시저 (Stored procedures): SQL 인젝션 (SQL injection)이 여러분의 코드에 있는 것이 아니라, 프로시저의 본문(body)에 있는 경우입니다. 모델이 프로시저 소스 코드를 가지고 있지 않다면 이를 알 수 없습니다.
- Mongo 쿼리의 NoSQL 인젝션 (NoSQL injection): 연산자 인젝션 (
{ $ne: null })이 발생하는 경우입니다. 구문적 형태 (syntactic shape)가 다르고, 학습 신호 (training signal)가 훨씬 약합니다. 이 지점에서 LLM의 정확도는 눈에 띄게 떨어집니다.
결론은 역직렬화 (deserialization) 사례와 같은 형태입니다. 단순한 사례는 매우 뛰어나고, 간접적인 사례는 에이전트나 하이브리드 방식이 필요하며, 동적인 사례 (로우 프래그먼트, 저장 프로시저, NoSQL 연산자)는 LLM 단독으로는 신뢰할 수 없는 영역입니다.
XSS: 컨텍스트가 전부다
XSS는 AI 리뷰의 성능이 눈에 띄게 떨어지기 시작하는 지점입니다. 이 클래스는 단순히 "사용자 입력이 페이지에 나타난다"는 것보다 더 방대합니다. 최소 네 가지의 뚜렷한 하위 형태 (반사형(reflected), 저장형(stored), DOM 기반(DOM-based), 템플릿 기반(template-based))가 존재하며, 특정 출력의 안전성은 해당 값이 "어떤 HTML 컨텍스트 (HTML context)"에 놓이느냐에 따라 달라집니다. 동일한 문자열이라도 요소 텍스트 (element text)에서는 안전할 수 있지만, 속성 (attribute) 내에서는 위험할 수 있으며, <script> 태그 안에서는 코드 실행 프리미티브 (code execution primitive)가 될 수 있습니다.
단순한 사례들은 잘 작동합니다. LLM은 다음과 같은 종류의 문제는 즉시 잡아낼 것입니다:
reflected-xss.js
app.get("/search", (req, res) => {
const { q } = req.query;
res.send(`<h1>Results for ${q}</h1>`);
...
또한 개발자가 사용자 입력에서 유도된 값을 사용하여 dangerouslySetInnerHTML을 사용하는 React 변형 사례도 잡아낼 것입니다.
실패하는 지점은 다음과 같습니다:
- 혼합된 이스케이프 규칙을 가진 템플릿 엔진 (Template engines). Twig, Jinja, Mustache, Handlebars는 모두 기본적으로 자동 이스케이프 (autoescape)를 수행하지만, 예외 사항이 존재합니다. Twig의
{{ x | raw }}는 이스케이프를 비활성화합니다. Mustache와 Handlebars의{{{ x }}}도 동일한 역할을 합니다. Twig 템플릿을 스캔하는 LLM은 종종{{ x }}를 보고
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기