자동화 스크립트나 에이전트가 호출한 도구가 종료 코드 0을 반환했는데, 다음 단계가 이전 실행의 result.json을 읽을 수 있습니다. 실행마다 새 작업 폴더를 만들고, 결과에 이번 실행의 식별자를 넣어 검증하면 이 혼동을 줄일 수 있습니다. 여기에 JSON 형식과 필요한 필드 검사까지 통과한 결과만 다음 단계로 넘깁니다. 이 글은 신뢰하는 작은 도구의 결과 전달을 다루며, 결과 내용이 업무적으로 옳은지는 별도 검사 대상으로 남깁니다.
검증 범위: 2026년 10월 10일, Linux의 CPython 3.12.14에서 아래 파일 전체를 실행했습니다. Python 표준 라이브러리만 사용하며 합성 데이터로 구성했습니다. 실제 에이전트·운영 서비스·유료 모델을 호출하지 않았습니다. 공식 문서 조사와 AI 도움으로 구성한 로컬 재현입니다.
1. 성공 판정에서 빠진 질문
Python 공식 문서의 CompletedProcess.returncode는 자식 프로세스의 종료 상태입니다. check=True도 종료 코드가 0이 아닌 경우 예외를 내는 옵션입니다. 도구가 약속한 파일을 새로 썼는지, 호출자가 기대한 데이터를 넣었는지까지 확인해 주지는 않습니다. subprocess 공식 문서
예를 들어 같은 폴더에서 보고서 생성기를 반복 실행한다고 가정합니다. 어제 만든 result.json은 남아 있고, 오늘의 도구는 처리할 입력이 없다는 분기로 빠져 파일을 쓰지 않은 채 0으로 종료합니다. 호출자가 종료 코드와 파일 존재만 검사하면 어제 결과를 오늘 결과로 받아들입니다. 아래 재현에서는 이러한 도구 동작을 의도적으로 만듭니다.
2. 최소 재현: 새 결과가 없어도 옛 파일이 통과하는 경우
파일명은 artifact_check.py입니다. CPython 3.12와 표준 라이브러리만 필요하며, 실제 실행한 버전은 3.12.14입니다. 임시 폴더 안에만 파일을 쓰고 정상적으로 with 블록을 빠져나오면 정리합니다. 명령은 python artifact_check.py입니다. assert 검사가 실행되도록 -O 옵션 없이 실행합니다.
artifact_check.py
import json
from pathlib import Path
import subprocess
import sys
from tempfile import TemporaryDirectory
from uuid import uuid4
# Trusted synthetic worker. No network, user data, or external tools.
WORKER = r'''
import json
from pathlib import Path
import sys
mode, run_id = sys.argv[1:]
if mode == "nonzero":
sys.exit(2)
if mode == "missing":
sys.exit(0)
if mode == "bad_json":
Path("result.json").write_text("{", encoding="utf-8")
else:
result = {
"run_id": "old-run" if mode == "wrong_run" else run_id,
"items": "wrong-type" if mode == "bad_shape" else ["sample"],
}
Path("result.json").write_text(
json.dumps(result), encoding="utf-8"
)
'''
def launch(folder, mode, run_id):
return subprocess.run(
[sys.executable, "-c", WORKER, mode, run_id],
cwd=folder, check=False, timeout=5,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
def validate(folder, returncode, run_id):
if returncode != 0:
return "exit_nonzero"
try:
data = json.loads(
(folder / "result.json").read_text(encoding="utf-8")
)
except FileNotFoundError:
return "missing_file"
except (UnicodeDecodeError, json.JSONDecodeError):
return "invalid_json"
if not isinstance(data, dict):
return "invalid_shape"
if data.get("run_id") != run_id:
return "run_mismatch"
items = data.get("items")
if not isinstance(items, list) or not all(
isinstance(item, str) for item in items
):
return "invalid_shape"
return "accepted"
# Reused directory: the worker exits 0 without replacing old output.
with TemporaryDirectory() as name:
folder = Path(name)
(folder / "result.json").write_text(
'{"run_id":"old-run","items":["yesterday"]}',
encoding="utf-8",
)
run_id = uuid4().hex
process = launch(folder, "missing", run_id)
naive = process.returncode == 0 and (folder / "result.json").exists()
strict = validate(folder, process.returncode, run_id)
assert naive is True and strict == "run_mismatch"
print(f"reused: naive={naive}, validated={strict}")
# New working directory and identifier for every invocation.
cases = {
"ok": "accepted",
"missing": "missing_file",
"wrong_run": "run_mismatch",
"bad_json": "invalid_json",
"bad_shape": "invalid_shape",
"nonzero": "exit_nonzero",
}
for mode, expected in cases.items():
with TemporaryDirectory() as name:
folder = Path(name)
run_id = uuid4().hex
process = launch(folder, mode, run_id)
actual = validate(folder, process.returncode, run_id)
assert actual == expected, (mode, actual)
print(f"{mode}: {actual}")
실제 실행 결과
reused: naive=True, validated=run_mismatch
ok: accepted
missing: missing_file
wrong_run: run_mismatch
bad_json: invalid_json
bad_shape: invalid_shape
nonzero: exit_nonzero
첫 줄의 naive=True가 문제를 보여 줍니다. missing 모드의 자식 프로세스는 아무 파일도 쓰지 않았지만, 기존 파일이 있어서 단순 검사는 통과합니다. 같은 파일을 run_id로 확인한 결과는 run_mismatch입니다. 이어지는 여섯 실행에서는 매번 새로운 폴더와 식별자를 사용했습니다.
- ok: 이번 run_id와 문자열 목록이 있어 accepted가 됩니다
- missing: 새 폴더에 결과가 없으므로 missing_file입니다
- wrong_run: 파일은 새로 만들어도 다른 실행의 식별자라면 run_mismatch입니다
- bad_json·bad_shape: JSON 문법 오류와 필드 형식 오류를 구분합니다
- nonzero: 종료 코드 2이므로 결과를 읽기 전에 exit_nonzero로 거절합니다
3. 수정은 세 단계로 나눕니다
첫째, 호출마다 작업 폴더를 분리합니다. TemporaryDirectory와 cwd를 함께 사용해 상대 경로 result.json이 이번 폴더를 가리키게 합니다. 이렇게 하면 이전 실행에서 남긴 동일한 파일명을 우연히 재사용하는 경로가 사라집니다. TemporaryDirectory는 문맥 관리자 범위를 나갈 때 폴더와 내용을 정리합니다. tempfile 공식 문서
둘째, 호출자가 생성한 run_id를 도구에 전달하고 결과에서 같은 값인지 확인합니다. 새 폴더만으로 결과 내용의 출처까지 설명할 수는 없습니다. 도구가 잘못된 캐시를 복사하거나 다른 요청의 내용을 반환하는 실수도 있기 때문입니다. 식별자는 파일과 호출을 연결하는 검사이며, 이 예제의 uuid4 값은 인증 비밀값이 아닙니다.
셋째, 파싱 성공과 필요한 데이터 형식을 구분합니다. json.loads는 JSON을 Python 값으로 바꾸지만 이 도구의 결과 계약을 알아서 검증하지는 않습니다. 예제는 최상위 객체, 이번 run_id, 문자열로만 구성된 items 목록을 요구합니다. 빈 목록과 추가 필드는 허용합니다. 업무상 최소 한 건이 필요하거나 정확한 필드 집합이 필요하다면 검사 조건도 함께 바꿔야 합니다. json 공식 문서
4. 작은 팀의 자동화에 적용할 때
배치 스크립트, 문서 변환기, 에이전트용 로컬 도구처럼 파일을 결과로 돌려주는 작업부터 적용하기 좋습니다. 호출부에 새 폴더 생성과 결과 검사 함수를 두고, 도구에는 출력 위치와 실행 식별자를 명시적으로 전달합니다. 기존 도구를 수정할 수 없다면 실행별 폴더부터 도입할 수 있지만, 그것만으로 결과 내용의 호출 일치까지 확인했다고 보지는 않습니다.
업무 식별자와 실행 식별자도 구분합니다. 같은 업무를 재시도하더라도 각 시도에는 새 run_id를 부여해야 어느 시도가 만든 결과인지 판단할 수 있습니다. 결제·메시지 전송의 중복 방지를 위한 멱등성 키는 다른 역할입니다. 이 예제는 외부 부작용이나 재시도 정책을 검증하지 않습니다.
실제 적용 전에는 성공 경로만 돌리지 말고 파일 미생성, 다른 실행의 파일, 깨진 JSON, 잘못된 필드, 실패 종료를 회귀 테스트에 넣습니다. 새 검사가 기존 도구를 거절한다면 예전 파일로 대체해 성공 처리하기보다, 어떤 계약이 어긋났는지 기록하고 해당 작업의 다음 단계를 멈추는 편이 원인을 보존하기 쉽습니다. 이 판단은 위 합성 재현에 근거한 적용 권고입니다.
5. 이 예제가 보장하지 않는 것
accepted는 이 예제의 결과 전달 검사만 통과했다는 뜻입니다. items에 들어 있는 문자열의 정확성, 문서 내용의 품질, DB 변경의 성공까지 증명하지 않습니다. 악의적인 도구는 전달받은 run_id를 그대로 써서 거짓 결과를 만들 수도 있습니다. 임시 폴더와 cwd는 파일 접근이나 네트워크를 제한하는 보안 샌드박스가 아닙니다.
예제의 자식 프로세스는 작고 신뢰하는 코드입니다. 임의의 코드 실행, 심볼릭 링크 공격, 매우 큰 JSON, 동시 작성 중인 파일은 다루지 않습니다. 외부 결과를 받는 서비스라면 읽기 크기 제한과 실행 권한·격리도 별도로 설계해야 합니다. Python 공식 JSON 문서도 신뢰하지 않는 입력의 자원 사용을 주의하도록 안내합니다.
또한 이 코드에서는 간결한 출력 비교를 위해 자식의 stdout·stderr를 버립니다. 운영 적용 시에는 민감 정보를 제외한 진단 기록이 필요합니다. 5초 timeout이 발생하면 TimeoutExpired가 전파되고, 권한 오류 같은 미처리 파일 오류도 실행을 중단합니다. 이 경로와 프로세스 트리 정리는 이번 테스트 범위에 포함하지 않았습니다. 성공 결과를 장기 보관하려면 임시 폴더 정리 전에 검증된 데이터만 별도 저장하고, 실패 증거의 보존 기간도 정해야 합니다.
결론은 간단합니다. 종료 코드, 결과의 존재, 이번 호출과의 일치, 필요한 데이터 형식을 서로 다른 검사로 두면 어디에서 실패했는지 드러납니다. 먼저 옛 결과 파일을 남긴 상태에서 도구가 아무것도 쓰지 않는 테스트를 추가해 보세요. 그때도 성공으로 판정한다면, 결과를 받는 쪽의 계약부터 고칠 지점이 있습니다.
참고한 문서와 문제 발견 자료
토스닥터 글은 검증 단계가 거짓 성공을 만들지 않도록 구분한 사례로 참고했습니다. 이 글의 subprocess·파일 재현은 별도로 작성했으며, 토스의 시스템에서 같은 파일 문제가 발생했다는 뜻은 아닙니다. 새 라이브러리나 새 릴리스 소개가 아니라 결과 전달의 정확성을 확인하는 실습입니다.
댓글
댓글 쓰기