동적 실행 엔진적재Python import설계

사용자 코드를 런타임에 import 하는 엔진

사용자 파이썬을 재시작 없이 API 로 만들려면 그 코드가 디스크의 진짜 파일이어야 한다. 그 제약에서 나온 버그 세 종류와, 땜빵 세 개 뒤에 바꾼 방향을 적었다.

2025년 10월 31일5분

이 엔진은 사용자가 작성한 파이썬 파일을 받아서, 서버를 재시작하지 않고 바로 뜨는 API로 만든다.

그래서 상태를 어디에 둘지는 고를 수가 없었다. importlib 은 데이터베이스 컬럼을 읽지 못하니, 모듈이 되려면 디스크 위에 진짜 파일이 있어야 한다.

문서에는 이렇게 한 줄로 남아 있다.

핵심 로직에 관계형 데이터베이스를 쓰지 않고 파일시스템에 영속화한다 — 동적 모듈 로딩을 가능하게 하기 위해.

이후 다섯 달 동안 올라온 버그의 대부분이 이 결정에서 나왔다.

파일이 API가 되기까지

파일 하나가 API가 되기까지 네 군데를 거친다.

덮어씀컨테이너 이미지/app공유 볼륨PVCsys.path검색 경로sys.modules적재된 객체서브앱 라우트FastAPI

노드에 커서를 올리면 연결된 것만 남는다

일반적인 웹 서버라면 이 중 어느 것도 런타임에 변하지 않는데, 여기서는 넷 다 변한다.

실행 중엔 되고, 재기동하면 깨진다

사용자가 같은 디렉터리에 파일 두 개를 두고 한쪽에서 다른 쪽을 불러왔다.

# routes/{에이전트}/sandboxes/bbb.py
from aaa import example_var

서버가 떠 있는 동안은 잘 됐다. 재기동하면 이렇게 됐다.

ModuleNotFoundError: No module named 'aaa'

원인은 sys.path였다. 감시 대상인 상위 디렉터리만 검색 경로에 넣고, 모듈이 실제로 놓인 디렉터리는 넣지 않았다. 실행 중에는 앞선 등록 과정의 부수효과로 우연히 경로가 맞아떨어졌고, 재기동하면 그 우연이 사라졌다.

모듈이 놓인 디렉터리도 검색 경로에 넣는 것으로 고쳤다. 확인할 때는 늘 서버가 떠 있으니, 이 버그는 확인하는 시점에는 보이지 않고 파드가 재시작될 때에야 드러난다.

등록 순서가 싱글톤을 둘로 만든다

다음은 싱글톤 문제였다. 라우터 모듈 A와 B가 공통으로 서비스 모듈 C를 불러오고, C는 싱글톤이었다.

등록 코드는 모듈을 무조건 다시 읽고 있었다.

if normalized_module_name in sys.modules:
    del sys.modules[normalized_module_name]   # 있으면 지우고 새로 만든다

파일을 수정했을 때 반영되게 하려는 의도였다. 그런데 sys.modules에서 이름을 지우면 그 다음 import 는 새 객체를 만든다. 이미 그 객체를 참조하고 있던 쪽은 옛 객체를 계속 쥔다.

등록기sys.modulesA 모듈B 모듈A 등록C 등록 — del sys.modules["C"]B 등록같은 싱글톤이 아니다
1 / 4

01A 가 C 를 불러온다. C 가 처음으로 적재되고 싱글톤 인스턴스 하나가 만들어진다. A 는 그것을 참조한다.

A → B → C 순서로 등록됐다면 아무 일도 없었을 것이다. 그래서 규칙을 뒤집어, 이미 적재된 모듈은 재사용하고 파일이 실제로 수정됐을 때만 다시 읽게 했다.

컨테이너가 자기 모듈을 잃어버린다

이번 문제는 파이썬 바깥에서 왔다.

ModuleNotFoundError: No module named 'utils.commons.debugger'

이 패키지는 이미지에 들어 있는 공용 유틸리티라 로컬에서는 당연히 있었는데, 쿠버네티스에 올리자 사라졌다.

공유 볼륨을 이미지의 같은 경로에 마운트하고 있었고, 마운트는 그 경로의 원래 내용을 가린다. 이미지에 분명히 들어 있는 파일이 볼륨이 붙는 순간 없는 것이 됐다.

해결은 배포 쪽에서 했다. 초기화 컨테이너가 이미지의 공용 패키지를 볼륨으로 복사하고, 편집 서비스 쪽에는 읽기 전용으로 붙였다. 애플리케이션 코드는 건드리지 않았다.

세 버그의 오류 메시지는 거의 같은 ModuleNotFoundError 였는데, 원인은 각각 검색 경로, 모듈 캐시, 볼륨 마운트였다.

땜빵 세 개, 그리고 방향 전환

중복 등록 문제는 한동안 국소 수정으로 버텼다. 중복 검사 로직을 간소화하고, 중복이어도 라우터가 있으면 통과시키고, 그 수정이 낳은 회귀를 같은 날 핫픽스로 막았다. 며칠 사이의 일이다.

넉 달 뒤에 방향을 바꿨다. 파일을 하나씩 "읽어서 실행하고 라우터를 꺼내는" 단일 루프를 버리고, 두 단계로 쪼갰다.

1단계. 모든 파일을 컴파일하고, 빈 모듈 객체를 만들어 sys.modules에 등록한다. 실행하지 않는다.

2단계. 등록된 모듈들을 실행하고 라우터를 꺼낸다.

2단계가 시작될 때는 모든 모듈 이름이 이미 sys.modules에 있으니 import 는 처리 순서와 상관없이 이름을 찾는다. 그리고 컴파일이 전부 끝난 뒤에 실행이 시작되니, 세 번째 파일에 문법 오류가 있어도 앞선 두 파일이 절반쯤 배포된 상태로 남지 않는다.

2단계가 실제로 보장하는 것

순서 문제가 정말 사라졌는지는 같은 구조를 작게 재현해서 확인했다. 컴파일해서 빈 모듈로 등록만 하고, 실행 순서만 바꿔 가며 돌렸다.

1단계로 둘 다 등록한 뒤, 2단계 실행 순서만 바꿨다
bbb 가 aaa 에 의존한다
aaa.py — example_var = 1
bbb.py — from aaa import example_var
↓
실행 결과
exec bbb → exec aaaImportError: cannot import name 'example_var' from 'aaa'
실패aaa 는 sys.modules 에 있지만 본문이 아직 안 돌아서 비어 있다. 이름은 찾았는데 속성이 없다.

그러니까 2단계 로딩이 해결한 것은 이름 해석이지 실행 순서가 아니다. 모듈 최상단에서 from X import name 을 쓰면 여전히 순서를 타고, import X 를 쓰고 속성 접근을 함수 안으로 미루면 순서와 상관없어진다. 어느 쪽이 될지는 사용자가 코드를 어떻게 쓰느냐에 달려 있어서 우리가 강제할 수는 없다.

프로세스 안에서 import 하는 게 맞았나

sys.modules 는 프로세스 전역 가변 상태인데, 우리는 그걸 배포 상태 저장소처럼 쓰고 있다. 키를 지우면 남이 쥔 참조가 낡고, 안 지우면 수정이 반영되지 않는다. 그 사이에서 "파일이 실제로 바뀌었을 때만 지운다"로 타협했고, 그 판단이 어긋나면 두 증상 중 하나를 또 보게 된다.

ModuleNotFoundError 하나만 보고는 그게 검색 경로 때문인지, 모듈 캐시인지, 볼륨 마운트인지, 등록 순서인지 로그로 알 수가 없다. 층마다 다른 진단 메시지를 붙이면 구분이 될 것 같다.

되돌아보면 데이터베이스를 안 쓴 것보다 사용자 코드를 우리 프로세스 안에서 import 하기로 한 쪽이 더 큰 결정이었던 것 같다. 별도 프로세스나 컨테이너에서 실행했다면 이 글의 버그는 전부 없었을 것이고, 대신 호출 지연과 자원 격리 비용을 냈을 것이다. 그 교환이 맞았는지는 다시 따져 봐야 알겠지만, 지금은 이 방식이 감당할 만한 범위 안에 있다고 보고 있다.

끝