본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 24. 05:24

AI 생성 코드의 의존성 격차: 선언된 것 1개, 임포트된 것 4개

요약

AI 에이전트가 생성한 코드에서 발생하는 '의존성 격차(dependency gap)' 문제를 다룹니다. CI 통과가 실제 환경의 재현성을 보장하지 않음을 지적하며, 정적 분석 도구인 `repro_probe.py`를 통해 매니페스트와 실제 임포트 간의 불일치를 측정하는 방법을 제안합니다.

핵심 포인트

  • CI 통과가 패키지 설치 환경의 완벽한 재현성을 의미하지는 않음
  • AI 에이전트가 환경에 이미 설치된 패키지를 임포트할 때 의존성 누락 발생
  • ast 모듈을 활용한 정적 분석으로 실행 없이 의존성 격차 측정 가능
  • 매니페스트와 실제 임포트 간의 일치 여부를 검증하는 것이 중요

AI 생성 코드의 의존성 격차(dependency gap)란 소스 코드에서 임포트(import)된 패키지 수에서 매니페스트(manifest)에 선언된 패키지 수를 뺀 값입니다. CI(지속적 통합)가 통과(green)되었다는 것은 단지 해당 패키지들이 작성자의 머신에는 이미 설치되어 있었다는 것을 증명할 뿐, 새로 체크아웃(fresh checkout)한 환경에서도 설치되어 있다는 것을 의미하지 않습니다. 따라서 머지(merge) 전에 정적(statically)으로 이 격차를 측정해야 합니다. repro_probe.pyast를 사용하여 소스 코드를 읽으며, 코드를 절대 실행하지 않습니다. 하나의 패키지를 선언했지만 네 개를 임포트한 프로젝트의 경우: gap=3, coverage 25.0%, exit 1이 됩니다.

AI 공개: 저는 이 글을 AI 글쓰기 어시스턴트와 함께 초안을 작성했습니다. 사용된 도구, 세 개의 피스처(fixtures), 그리고 아래의 모든 숫자는 Python 3.13.5, 표준 라이브러리(stdlib)만 사용하고 네트워크 연결이 없는 실제 로컬 실행 결과에서 가져왔습니다. 저는 이를 직접 실행하고, 종료 코드(exit codes)를 확인했으며, 바이트 단위로 결정론적(deterministic)임을 확인하기 위해 STDOUT을 두 번 해싱하였고, 게시하기 전에 모든 문장을 수정했습니다.

통과된 CI(Green CI)는 기계가 자기 자신과 일치한다는 것을 알려주는 것에 불과합니다. 흥미로운 질문은 당신의 것이 아닌 머신에서는 어떤 일이 벌어지는가 하는 점입니다.

제가 계속해서 목격하는 실패 사례는 다음과 같습니다. 에이전트(agent)가 기능을 작성합니다. 에이전트는 pandas, numpy, 그리고 YAML 파서를 임포트합니다. 테스트는 통과합니다. 에이전트의 환경에 이전 작업으로부터 이미 해당 패키지들이 설치되어 있었기 때문입니다. 디프(diff)가 반영됩니다. 팀원이 이를 풀(pull)하고, pip install -r requirements.txt를 실행한 뒤 코드를 돌리면, ModuleNotFoundError: No module named 'numpy' 에러를 마주하게 됩니다. 매니페스트는 해당 임포트에 대해 전혀 알지 못했습니다. 아무도 거짓말을 하지 않았습니다. 정보가 소스 파일에서 의존성 목록으로 전달되지 않았을 뿐입니다.

그 격차는 작고, 지루하며, 정적으로 측정 가능합니다. 그래서 저는 그것을 측정했습니다.

제가 실제로 실행한 것

요약(TL;DR).

  • 초록색 체크표시는 패키지가 이미 존재하는 환경에서 스위트(suite)가 실행되었음을 증명합니다. 이것이 프로젝트가 자체 매니페스트(manifest)로부터 설치된다는 것을 증명하지는 않습니다.
  • repro_probe.py는 표준 라이브러리(stdlib)인 ast를 사용하여 모든 *.py 파일을 순회하며, 매니페스트를 텍스트로 읽습니다. 대상 프로젝트 내의 그 어떤 것도 임포트(import), 설치 또는 실행하지 않습니다.
  • 임포트당 네 가지 결정론적 규칙: R1 표준 라이브러리(stdlib), R2 로컬 모듈, R3 선언 일치, R4 미선언 제3자 패키지(격차).
  • requests를 선언하고 4개의 제3자 패키지를 임포트한 프로젝트의 경우: gap=3 (numpy, pandas, yaml), coverage 25.0%, exit 1.
  • 모든 임포트가 선언되었거나, 로컬이거나, 표준 라이브러리인 정직한 프로젝트의 경우: gap=0, coverage 100.0%, exit 0. 이 주장은 반증 가능하며, 정직한 사례를 통과했습니다.
  • STDOUT은 두 번의 실행 간에 바이트 단위로 동일합니다 (sha256 일치). 키(key)도, 네트워크도 사용하지 않았습니다. 매니페스트가 없으면 → exit 2.

실행 결과가 논거보다 앞서는 이유는, 실행 결과 자체가 논거이기 때문입니다.

반전: 통과가 재현 가능함을 의미하지는 않는다

AI 생성 코드에 대한 대부분의 논의는 "실행되는가"에서 멈춥니다. 에이전트가 무언가를 만들어냈는가? 테스트가 초록색(pass)으로 나왔는가? 그럼 배포하십시오. 진짜 비용이 발생하는 부분은 병합(merge) 시점에 아무도 확인하지 않는 부분입니다. 즉, 이 코드가 깨끗한 머신에서, 오직 자체 매니페스트만으로 설치되고 임포트될 수 있는가 하는 점입니다.

격차가 실재하며 드문 일이 아니라는 최근의 증거가 있습니다. AI-Generated Code Is Not Reproducible (Yet) (arXiv 2512.22387, v3, 2026년 3월)에서 Vangala, Adibifar, Gehani 및 Malik는 Claude Code, OpenAI Codex 및 Gemini를 통해 100개의 프롬프트로부터 300개의 프로젝트를 생성한 후, 각각을 실행하려고 시도했습니다. 그들의 초록(abstract)에 따르면 프로젝트의 단 68.3%만이 즉시 실행(out-of-the-box) 가능하며, 따라서 약 3분의 1은 첫 실행에서 실패합니다. 언어별 편차도 큽니다: Python 89.2%, Java 44.0%. 그리고 제가 이 도구를 만들게 만든 문장은 바로 이것입니다: 그들은 선언된 의존성에서 실제 런타임 의존성으로의 평균 확장이 13.5배임을 측정했습니다. 3개를 선언했지만 수십 개가 필요했습니다. 그것이 바로 제가 만든 결함 있는 피스처(fixture)가 모방하고 있는 형태이며, 단지 규모만 더 작을 뿐입니다.

저는 이 수치들을 조심스럽게 다루고 싶습니다. 이것은 저의 측정값이 아니라 _그들_의 측정값입니다. 생성된 프로젝트를 대상으로 한 통제된 연구이며, 여러분이 직접 방법론을 읽어보실 수 있도록 출처와 함께 인용했습니다. 아래에 제시된 저의 수치는 오직 제가 만든 피스처(fixture)에서만 도출된 것입니다.

네 가지 규칙

전체 과정은 하나의 파일로 이루어집니다. 소스 코드의 모든 임포트(import)는 결정론적으로 정확히 하나의 버킷(bucket)으로 분류됩니다.

  • R1 stdlib (표준 라이브러리). 임포트가 sys.stdlib_module_names에 포함되어 있습니다 (Python 3.10+ 버전부터 이 세트가 제공됩니다). os, json, pathlib 등은 선언이 필요하지 않습니다.
  • R2 local (로컬). 프로젝트 내에 name.py 또는 name/__init__.py가 존재합니다. 이는 의존성이 아닌 사용자의 자체 모듈입니다.
  • R3 declared-match (선언 일치). 정규화된 임포트 이름이 매니페스트(manifest)에 있습니다. 까다로운 사례들이 여기에 속합니다. 예를 들어 yaml을 임포트하지만 패키지는 PyYAML인 경우, 또는 cv2를 임포트하지만 opencv-python을 설치하는 경우입니다. 작은 매핑(map)을 통해 일반적인 사례들을 처리합니다.
  • R4 undeclared (미선언). 표준 라이브러리도 아니고, 로컬 모듈도 아니며, 선언되지도 않았습니다. 이것이 바로 격차(gap)입니다. 매니페스트에서 전혀 언급되지 않은 제3자 패키지를 임포트하므로, 새로 pip install을 실행해도 가져올 수 없습니다.

지표는 단순히 R4 집합의 크기입니다. 분류 루프의 원문은 다음과 같습니다:

for name in sorted(imports):
    if name in std:
        rule = "R1 stdlib"
...

모델 호출도, pip도, subprocess도 사용하지 않습니다. 텍스트를 읽고 AST(추상 구문 트리)를 탐색할 뿐입니다.

결함 있는 프로젝트: 선언된 것 1개, 임포트된 것 4개

피스처는 아주 작은 "에이전트 스타일" 파일입니다. 이 파일은 requests, numpy, pandas, yaml을 임포트하며, 여기에 로컬 utils 모듈과 두 개의 표준 라이브러리 모듈이 추가됩니다. requirements.txt에는 정확히 한 줄, requests만 적혀 있습니다. 편집되지 않은 실제 출력 결과는 다음과 같습니다:

repro_probe v1 | project=broken_project
  logging          R1 stdlib
  numpy            R4 UNDECLARED
...

4개의 임포트 중 3개가 미선언된 제3자 패키지입니다. 커버리지(Coverage)는 25.0%입니다. 종료 코드(Exit)는 1이며, 이는 CI(지속적 통합) 환경에서 빌드 실패를 의미합니다.

yaml을 보세요. 여기서는 R4로 표시되어 있는데, 이는 매니페스트(manifest)에 배포 이름(distribution name)인 PyYAML이 나열되어 있지 않기 때문입니다. 이것이 바로 grep import를 매우 신뢰할 수 없게 만드는 '임포트 이름(import-name) 대 배포 이름(distribution-name)'의 함정입니다. 의존성이 "명백"할 때조차 문자열이 일치하지 않습니다. 이 도구는 자체 맵(map)을 통해 정규화(normalize)를 수행하므로, 단순한 텍스트 검색으로는 놓칠 수 있는 사례를 잡아내며, (다음 섹션에서 보여주듯) PyYAML이 실제로 선언되어 있을 때는 이를 해결합니다.

정직한 프로젝트: 주장은 반증 가능해야 한다

모든 것에 플래그를 지정하는 체크는 가치가 없습니다. 저의 반대 의견("통과(passing)는 재현 가능하지 않다")이 사실이라면, 진정으로 깨끗한 프로젝트는 깨끗한 결과로 돌아와야 합니다. 따라서 두 번째 피스처(fixture)는 사용하는 모든 제3자 임포트(third-party import)를 선언하고, 로컬 helpers 모듈을 임포트하며, 표준 라이브러리(stdlib)에 의존합니다. 동일한 도구, 동일한 규칙을 적용하면 다음과 같습니다:

repro_probe v1 | project=clean_project
  helpers          R2 local
  io               R1 stdlib
...

격차(Gap) 0, 커버리지(Coverage) 100.0%, 종료 코드(Exit) 0입니다. 여기서 yamlR3 선언됨(declared) 상태임을 주목하세요. 동일한 임포트이지만 결과는 반대인데, 이는 이 매니페스트에 PyYAML이 나열되어 있기 때문입니다. 이 맵은 양방향으로 작용합니다. 즉, 실제 배포 이름으로 선언된 의존성에 대해서는 잘못된 플래그(false-flag)를 지정하지 않습니다. 정직한 프로젝트는 통과합니다. 이 부분이 이 도구를 단순한 승인 도구(rubber stamp)가 아닌 검사 도구로 만드는 지점입니다.

세 번째 피스처는 소스 코드는 있지만 requirements.txtpyproject.toml이 없습니다. 종료 코드는 2입니다. 잘못된 입력이며, 추측하기를 거부합니다. 매니페스트가 누락되었을 때 임의로 결과를 만들어내는 도구는 도구가 없는 것보다 더 나쁩니다.

결정론적인가(Is it deterministic)?

병합 전 게이트(pre-merge gate)가 깜빡거린다면 그것은 노이즈입니다. 저는 각 피스처를 두 번씩 실행하고 STDOUT(표준 출력)만 해싱했습니다 (서비스 메시지는 stderr로 전송되어 해싱 스트림에서 제외되었습니다):

clean_project   run1=5177ac0a...  run2=5177ac0a...  -> IDENTICAL
broken_project  run1=52a677f9...  run2=52a677f9...  -> IDENTICAL
bad_project     run1=e3b0c442...  run2=e3b0c442...  -> IDENTICAL

동일한 입력에 대해 매번 동일한 바이트가 나옵니다. 출력은 정렬되어 있으므로 집합 순서에 따른 흔들림(set-ordering wobble)이 없습니다. 종료 코드를 CI에 연결하여 깨끗한 트리가 계속 녹색(pass) 상태를 유지하도록 신뢰할 수 있습니다.

이것이 아닌 것

이 부분은 도구의 정직함을 유지하는 부분입니다. 따라서 수치를 인용하기 전에 반드시 읽어보시기 바랍니다.

의존성 격차(dependency gap)는 재현 가능성에 대한 _신호(signal)_이지, 프로젝트가 실행되지 않을 것이라는 증거가 아닙니다. 이는 "제3자 패키지가 임포트(import)되었으나 선언(declare)되지 않았다"는 것을 의미하며, 이는 새로 설치할 때 실패할 강력한 이유가 되지만 확정적인 보증은 아닙니다. 해당 패키지가 베이스 이미지(base image)에서 제공될 수도 있고, 선택적인 코드 경로(optional code path)일 수도 있습니다. 격차는 위험을 표시할 뿐, 프로젝트의 운명에 대해 판결을 내리는 것이 아닙니다. 제가 이 지표를 brokenness(고장 상태)가 아닌 gap(격차)이라고 의도적으로 명명한 이유입니다.

또한, 제가 숨기지 않을 몇 가지 실제적인 사각지대가 존재합니다:

  • 버전 고정(version pins)을 확인하지 않습니다. numpy가 선언되었으나 존재하지 않는 버전으로 고정되어 있거나 충돌하는 범위로 지정되어 있어도 R3로 통과됩니다. 격차는 _존재 여부(presence)_에 관한 것이지, _해결 가능성(resolvability)_에 관한 것이 아닙니다.
  • 전이 의존성(transitive dependencies)을 추적하지 않습니다. 이 도구는 직접적인 임포트(direct imports)만 확인하며, 해당 패키지들이 끌어오는 의존성까지는 보지 못합니다. 논문에서 측정한 13.5배의 확장은 주로 전이 계층(transitive layer)에서 발생하며, 정적 임포트 대 매니페스트(static import-vs-manifest) 체크로는 도달할 수 없습니다.
  • extras나 환경 마커(environment markers)를 이해하지 못합니다. package[extra]; sys_platform == ...는 기본 이름으로 정규화(normalized)되며, extra 부분은 검증되지 않습니다.
  • 표준 라이브러리(stdlib) 허용 목록은 버전에 종속적입니다. 이는 도구를 실행하는 Python의 sys.stdlib_module_names가 보고하는 내용에 따라 달라집니다. 다른 마이너 버전(minor version)에서 실행하면 모듈의 분류가 바뀔 수 있습니다.
  • requirements.txt와 PEP 621의 [project] dependencies만 읽습니다. Poetry의 [tool.poetry.dependencies], setup.py/setup.cfg, 그리고 optional-dependencies는 의존성 세트로 파싱되지 않습니다. Poetry 프로젝트를 대상으로 실행하면 모든 임포트가 선언되지 않은 것으로 나타나 실제 격차가 아닌 잘못된 BROKEN 판정을 받게 됩니다. requirements 기반 프로젝트에서 실행하거나, 이 점을 인지한 상태에서 판결 결과를 확인하십시오.
  • 로컬 탐지는 최상위 수준(top-level)에서만 이루어집니다. 프로젝트 루트에 있는 name.py 또는 name/__init__.py를 로컬(R2)로 취급합니다.

src/-레이아웃 패키지(layout package)나 PEP 420 네임스페이스 패키지(__init__.py가 없는 로컬 디렉토리)는 대신 R4로 플래그가 지정됩니다. 이는 누락된 의존성이 아니라, 완벽하게 재현 가능한 프로젝트에서 발생하는 오탐(false positive)입니다.

이러한 잘못된 BROKEN 사례들은 게이트(gate)로서 안전한 방향으로 오류를 발생시킵니다(너무 적게 불평하기보다 너무 많이 불평하는 쪽을 택함). 하지만 바로 이 점 때문에 판결을 신뢰하기 전에 단순히 종료 코드(exit code)만 보는 것이 아니라 명시된 목록을 읽어야 하는 것입니다.

즉, 깨끗한 종료 코드 0(exit 0)은 필요조건이지만 충분조건은 아닙니다. 이는 가장 어리석고 흔한 실패 사례(임포트되었으나 선언되지 않음)를 배제할 뿐 그 이상의 것을 보장하지는 않습니다. 하지만 그 한 가지 클래스는 배제할 가치가 있습니다. 왜냐하면 그것이 바로 통과된 PR(Pull Request)을 팀원의 ModuleNotFoundError로 바꿔버리는 주범이기 때문입니다.

다른 검사들과의 관계

이것은 런타임 가드(runtime guard)가 아닙니다. 이는 머지(merge) 버튼을 누르기 전, 디프(diff) 상에서 아무것도 실행하지 않고 수행되는 아티팩트 검사(artifact check)입니다. 이는 green-checkmark auditor의 사촌 격으로, 해당 도구는 테스트 통과가 독립적인 신호를 전달하는지를 묻습니다. 반면 여기에서의 질문은 매니페스트(manifest)가 임포트(imports)와 일치하는가입니다. 두 가지 모두 '초록색 상태(green status)는 주장일 뿐 증거가 아니다'라는 동일한 의구심에서 출발합니다. 이는 your agent returns 200 and lies의 이면에 있는 것과 같은 의구심입니다. 만약 이미 드리프트(drift)를 확인하기 위해 매니페스트를 파싱하고 있다면, 이는 pinning and verifying MCP tool manifests와 맥락을 같이 합니다. 그리고 되돌릴 수 없는 단계 이후가 아니라 그 '전'에 장벽을 설치하려는 전체적인 본능은, 런타임 대신 머지에 적용된 pre-execution gate와 같습니다.

자신의 리포지토리에서 실행해보기

이 도구는 표준 라이브러리(stdlib)만 사용하는 단일 Python 파일입니다. 실제 프로젝트를 대상으로 지정해 보세요:

python3 repro_probe.py path/to/your/project
echo "exit: $?"

Exit 0은 임포트된 모든 서드파티 패키지가 선언되었음(또는 표준 라이브러리/로컬 패키지임)을 의미합니다. Exit 1은 임포트되었으나 선언되지 않은 이름의 목록을 제공합니다. Exit 2는 대조할 매니페스트 (manifest)가 없음을 의미합니다. 문제를 수정하는 동안 알려진 격차(gap)를 N만큼 허용하고 싶다면 --gate N을 추가하세요.

이 도구는 잘못된 버전 고정 (version pin)이나 누락된 전이적 의존성 (transitive dep)은 잡아내지 못합니다. 하지만 AI가 작성한 diff가 CI를 통과한 뒤 설치되지 않는 가장 흔한 이유, 즉 소스 코드에서 임포트(import)는 했지만 매니페스트에서 누락한 패키지는 잡아낼 수 있습니다.

최근 에이전트 (agent)가 작성한 코드에 이 도구를 실행해 보신다면, 어떤 격차가 발생했는지, 그리고 그중 '임포트 이름 (import-name) 대 배포 이름 (distribution-name)' 함정에 빠진 경우가 있었는지 진심으로 알고 싶습니다. 그 부분은 제 작은 도구가 얼마나 잘 커버하는지 가장 확신이 서지 않는 사례입니다. 다음 수치 데이터들을 계속 지켜봐 주시고, 가장 심각했던 격차를 댓글로 남겨주세요.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0