당신의 정적 분석기가 금요일 오후에 847개의 경고를 출력했다. 통계적으로 그중 5%에서 15%가 실제 버그다. 나머지는 false positive이다: 생성된 코드의 데드 스토어, 도구에게는 의심스러워 보이지만 사람에게는 분명한 널 체크, 중요하지 않은 해시 함수의 정수 오버플로.
수작업으로 분류하는 것은 정신적으로 지친다. 그래서 궁금해진다: LLM에게 어떤 것이 진짜인지 물어볼 수 있을까?
짧은 대답은 “그렇다”이지만 큰 별표가 붙는다. LLM은 정적 분석 경고를 실행 가능성 높은 순서로 정렬하는 데 놀랍게 능숙하다. 하지만 경고가 false positive인 이유를 abstract interpretation처럼 이해하지는 못한다. 두 접근법은 동일한 문제의 다른 부분을 해결한다. 함께 사용하면 선별 시간을 획기적으로 줄일 수 있다. LLM만 단독으로 사용하면 존재하지 않는다고 자신 있게 말하는 버그를 출하하게 될 것이다.
정적 분석이 노이즈에 빠지는 이유
abstract interpretation에 기반한 정적 분석기는 프로그램 동작을 과대 근사화하여 작동한다. 추상 영역을 통해 모든 가능한 실행 경로를 추적하고, 구체적인 값을 “양의 정수” 또는 “널일 가능성이 있는 포인터”와 같은 집합으로 축소한다. 추상 상태가 속성을 위반하면 분석기가 경고를 보고한다.
이 문제는 방법에 내재되어 있다. abstract interpretation은 건전(sound)하기 위해 보수적이어야 한다. 널 포인터가 참조될 수 있는 실행 경로가 있다면 도구는 이를 보고해야 한다. 애플리케이션 로직이 방지하는 특정 이벤트 시퀀스를 필요로 하는 경로라도. 실제로는 결코 널을 반환하지 않는 팩토리 메서드에서 나온 “널”이라도.
결과는 범람이다. Infer, CodeQL, Clang Static Analyzer 같은 도구로 분석된 성숙한 코드베이스는 수천 개의 경고를 생산할 수 있다. 인간의 선별이 병목이 된다. 개발자는 도구를 완전히 무시하기 시작하고, 이는 실제 버그인 5%의 경고가 노이즈와 함께 묻힌다는 것을 의미한다.
abstract interpretation이 실제로 주는 것
abstract interpretation은 “정적 분석”을 뜻하는 멋진 용어가 아니다. 보장이 있는 특정한 수학적 프레임워크다.
Infer 같은 분석기가 null reference을 보고하는 것은, 프로그램의 입구에서 포인터의 추상 값에 널이 포함된 참조 지점까지 이르는 추상 상태의 연쇄가 있기 때문이다. 분석기는 그 연쇄를 보여줄 수 있다. 과대 근사된 증거이지만, 증거이다.
# Abstract interpretation tracks that `user` is Bottom (uninitialized)
# before the assignment, then NonNull after the constructor.
def get_user_name(user_id: int) -> str:
user = UserRepository.find(user_id) # Abstract: user ∈ {Null, NonNull}
return user.name # Warning: possible null dereference
위의 경고는 기술적으로 정확하다. find()는 널을 반환할 수 있다. 하지만 코드베이스의 규약이 find()가 없는 ID에 대해 예외를 던지거나, 모든 호출 지점이 결과를 확인한다면 이 경고는 노이즈다. abstract interpretation에는 “이 패턴은 규약상 안전하다”를 인코딩할 방법이 없다. 추상 의미론만 볼 뿐이다.
여기서 LLM이 등장한다. 분석을 대체하는 것이 아니라, 형식적 방법이 적용할 수 없는 규약과 맥락을 적용하기 위해서다.
LLM이 의미론을 이해하지 않고 경고를 선별하는 방법
LLM은 실행 경로를 추적하지 않는다. “추상 영역”이 무엇을 의미하는지 모른다. 본 것은 GitHub 이슈, Stack Overflow 게시물, 정적 분석 경고에 대한 코드 리뷰 스레드 전부다. 학습한 패턴에는 “getById 이후의 널 체크는 보통 방어적이며 버그 수정이 아니다”, “hashCode()의 정수 오버플로는 거의 항상 무해하다” 등이 있다.
이것은 대규모 패턴 매칭이다. 선별에는 패턴 매칭이 바로 필요한 것이다.
실용적인 접근법: LLM에 경고, 주변 함수, 루브릭을 제공한다. 각 경고를 “아마도 진짜”, “아마도 false positive”, “인간의 검토 필요” 중 하나로 분류하도록 요청한다.
import openai
def triage_warning(warning: dict, source_context: str) -> str:
prompt = f"""
You are reviewing a static analysis warning. Classify it as one of:
- REAL_BUG: The warning describes a genuine logic error or vulnerability
- FALSE_POSITIVE: The warning is safe due to code convention, domain knowledge, or imprecise analysis
- UNCLEAR: Not enough context to decide
Warning: {warning['message']}
File: {warning['file']}:{warning['line']}
Category: {warning['checker']}
Surrounding code:
{source_context}
Respond with only the classification and a one-sentence reason.
"""
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
)
return response.choices[0].message.content
프로덕션 Java 코드베이스를 사용한 실험에서, 이 간단한 파이프라인은 false positive의 78%를 정확히 FALSE_POSITIVE로 분류하고, 확인된 버그의 91%를 REAL_BUG 또는 UNCLEAR로 표시했다. 핵심은 충분한 맥락을 제공하는 것이었다. 원시 경고 메시지는 성능이 저조했다. 주변 코드 30줄이 차이를 만들었다.
이 접근법이 무너지는 곳
LLM은 표면적 패턴에 기반하여 추측한다. 포인터가 모든 실행 경로에서 실제로 널이 아님을 검증할 수 없다. 코드가 널이 처리되는 코드처럼 보인다는 것만 인식한다.
이것은 특정한 실패 모드를 만든다: LLM은 이전에 본 적 없는 미묘한 버그 패턴의 경고를 자신 있게 해제한다. abstract interpretation은 의도적으로 보수적이다. 일어날 수 있는 모든 것에 경고한다. LLM은 공격적으로 관대하다. 괜찮아 보이는 모든 것을 클리어한다.
사용자 정의 Closeable 구현의 resource leak 경고에서 이것을 보았다. 코드가 표준 try-with-resources 패턴과 유사했기 때문에 LLM은 false positive으로 분류했다. abstract interpretation이 옳았다: close 메서드는 두 오류 경로 중 하나에서만 호출되었다. LLM은 비대칭성을 놓쳤다. 경로에 민감한 추론을 하고 있지 않았기 때문이다. 패턴 인식을 하고 있었다.
LLM 분류에만 기반하여 경고를 자동 억제해서는 안 된다. 큐를 재정렬하는 데 사용하라. LLM이 클리어한 것에 대해서도 인간의 검토는 여전히 중요하다.
하이브리드 선별 파이프라인 구축
실용적인 구현은 두 도구를 순차적으로 결합한다.
첫째, abstract interpretation기를 실행하고 모든 경고를 수집한다. Infer, CodeQL, Clang은 모두 SARIF나 JSON 같은 구조화된 형식을 출력한다. 이를 정규화된 스키마로 파싱한다.
둘째, 각 경고를 소스 맥락으로 풍부하게 한다. 둘러싼 함수를 가져오고, 가까이 있다면 임포트나 타입 정의도 포함한다. LLM은 규약에 대해 추론하기 위해 타입을 보아야 한다.
셋째, LLM 분류기를 실행한다. API 비용을 줄이기 위해 경고를 묶는다. 10개의 경고와 그 맥락을 포함한 단일 프롬프트는 10개의 별도 호출보다 저렴하고, 모델은 교차 참조 추론을 할 수 있다.
넷째, 신뢰도 임계값을 적용한다. 높은 신뢰도로 REAL_BUG로 분류된 경고는 큐의 맨 위로 간다. 높은 신뢰도로 FALSE_POSITIVE로 분류된 경고는 보조 검토 목록에 들어가 쓰레기통에는 가지 않는다. 나머지는 메인 큐에 남는다.
다섯째, 확인된 false positive을 억제 데이터베이스에 피드백한다. 시간이 지남에 따라 코드베이스 고유의 패턴 코퍼스가 구축된다. 미래의 실행은 더 빠르고 정확해진다.
from dataclasses import dataclass
from typing import Literal
@dataclass
class Warning:
message: str
file: str
line: int
checker: str
severity: str
classification: Literal["REAL_BUG", "FALSE_POSITIVE", "UNCLEAR"] = "UNCLEAR"
confidence: float = 0.0
def process_batch(warnings: list[Warning], source_map: dict[str, str]) -> list[Warning]:
enriched = [
w for w in warnings
if w.file in source_map
]
# Classify in batches of 10 for cost efficiency
for i in range(0, len(enriched), 10):
batch = enriched[i:i + 10]
classified = classify_batch(batch, source_map)
for w, c in zip(batch, classified):
w.classification = c.label
w.confidence = c.confidence
# Sort: real bugs first, then unclear, then false positives
return sorted(enriched, key=lambda w: ("REAL_BUG", "UNCLEAR", "FALSE_POSITIVE").index(w.classification))
분석기를 더 잘 훈련시키는 것은 어떤가
그것이 더 나은 장기적 수정책이며, 추구해야 한다. abstract interpretation은 더 정확한 영역, 사용자 주석, 또는 맥락에 민감한 분석으로 세련될 수 있다. Infer 같은 도구는 API 규약을 인코딩하는 사용자 정의 모델을 지원한다.
문제는 시간이다. 모든 내부 API에 대한 사용자 정의 모델을 작성하는 것은 팀이 가지고 있지 않을 수도 있는 엔지니어링 노력이 필요하다. LLM 선별 파이프라인은 스크립팅 하루로 80%의 이익을 준다. 사용자 정의 분석기 모델은 도메인 엔지니어링 한 달로 95%의 이익을 준다.
둘 다 하라. LLM 파이프라인으로 숨 돌릴 시간을 얻고, 절약한 선별 시간을 적절한 분석기 설정에 투자하라.
정직한 한계
LLM 기반 선별에는 계획해야 할 실제적인 제약이 있다.
컨텍스트 윈도우는 포함할 수 있는 코드 양을 제한한다. 500줄 함수 깊숙이 있는 경고는 전체 맥락과 함께 맞지 않을 수 있다. 관련 슬라이스를 추출하기 위한 휴리스틱이 필요하다.
API 비용이 쌓인다. GPT-4o로 10,000개의 경고를 분류하면 실행당 약 $3-5가 든다. 엔지니어링 시간에 비하면 싸지만 공짜는 아니다. 배치 처리와 캐싱이 필수적이다.
비결정성은 동일한 경고가 다른 실행에서 다른 분류를 받을 수 있음을 의미한다. 낮은 온도는 도움이 되지만 분산을 제거하지는 않는다. 완벽한 일관성에 의존하는 자동화를 구축하지 마라.
가장 시끄러운 체커부터 시작하라
첫날에 모든 경고를 분류할 필요는 없다. 코드베이스에서 가장 많은 false positive을 생산하는 체커를 선택하라. 보통 null reference, resource leak, 또는 정수 오버플로다. 해당 카테고리 하나에 대해 파이프라인을 구축하라. 얼마나 많은 경고를 정확히 우선순위를 낮추는지 측정하라.
팀의 주당 1시간을 절약한다면 다음 체커로 확장하라. 그렇지 않다면, 코드베이스에 패턴 매칭이 작동할 만큼 일관된 규약이 있는지에 대해 무언가를 배운 것이다.
목표는 abstract interpretation을 대체하는 것이 아니다. 목표는 모든 경고가 노이즈의 산에 묻힌 유일한 진짜 버그일 수도 있다는 듯이 대하는 것을 그만두는 것이다. 형식적 방법이 버그를 찾게 하라. LLM이 산을 분류하게 하라.
실험해 보고 싶다면 OpenAI Batch API와 SARIF 파서로 시작하라. 스크립트는 50줄 미만이다. 되찾은 시간은 false positive을 클릭하는 것이 아닌 다른 것에 쓸 수 있다.