Issue 01중국 AI
AC POST
중국 AI 목록
掘金2026년 9월 16일 14:35중국어 → 한국어

LangGraph 프로젝트 순환 임포트 원인과 세 가지 해법

LangGraph 다중 파이프라인 프로젝트에서 겪은 순환 임포트 문제의 발생 원인과 원리, 사례, 해결 방법을 정리했다.

중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.

핵심 결론(TL;DR)

순환 임포트(circular import) 오류의 본질은 "무한 루프"가 아니라 부분 초기화(partially initialized)다. Python은 한 줄씩 실행하는데, sys.modules는 먼저 빈 모듈 자리표시자를 생성한 뒤 점진적으로 채우기 때문에, 의존성이 고리(ring)를 이룰 때 특정 모듈이 참조되는 그 순간 아직 초기화가 끝나지 않아, 아직 정의되지 않은 속성을 참조하게 되어 오류가 발생한다.

이 글은 내가 멀티 에이전트 프로젝트(LangGraph 멀티 파이프라인)에서 겪은 실제 순환 임포트 문제를 바탕으로, 네 가지를 명확히 설명한다:

- 근본 원인: Python 실행 모델과 sys.modules 캐시 메커니즘으로부터 "부분 초기화"가 어떻게 발생하는지 도출

- 하나의 은밀한 함정: 패키지 내부의 하위 모듈 하나를 임포트하면 패키지의 __init__.py가 먼저 실행되어 연쇄 임포트를 일으킨다

- 세 가지 해법: 함수 내 지연 임포트(임시방편), 계층 분리로 공통 의존성을 하위로 내리기(근본 해결), 의존성 역전으로 추상에 의존(우아한 근본 해결)

- 이식 가능한 아키텍처 원칙: 모듈 의존성을 어떻게 방향성 비순환 그래프(DAG)로 만들 것인가

실행 환경: Python 3.11 / LangGraph 0.x, 최종 업데이트 2026-09.

一. 순환 임포트란 무엇인가

Python 프로젝트를 하다 보면(예: LangGraph, LangChain 같은 에이전트 프로젝트), 계층형 아키텍처를 구성할 때 때때로 순환 임포트 문제가 발생한다. 먼저 가장 단순한 예시로 보여주겠다:

a.py print ( "a.py 실행 시작" ) from b import b_func # 2번째 줄: b를 임포트 print ( "a.py 계속 실행" ) def a_func (): return "I am a" print ( "a.py 실행 완료" )

b.py print ( "b.py 실행 시작" ) from a import a_func # 2번째 줄: a를 임포트 print ( "b.py 계속 실행" ) def b_func (): return "I am b" print ( "b.py 실행 완료" )

현재 a와 b 두 모듈이 있는데, Python이 실행 단계에 있을 때 Python은 한 줄씩 실행하며, 각 줄이 어떤 문장인지에 따라 해당하는 일을 한다: 모듈 a에서 from b import b_func에 도달했을 때 b가 모듈 캐시에 없으면 빈 b 모듈 객체를 생성한다;

생성한 뒤, b의 모듈로 가서 b의 코드를 실행하고, from a import a_func에 도달하면 Python 인터프리터는 a 캐시가 존재하는지 확인한다——앞서 a 캐시를 생성했으므로 a 캐시에서 a_func를 가져오려 하지만, a에서는 아직 해당 함수 정의까지 실행되지 않았으므로 예외를 던진다(오류 문구는 Python 버전과 임포트 방식에 따라 약간 다르다):

Python 3.8 ~ 3.13, from ... import 형식(가장 흔함): ImportError : cannot import name 'a_func' from partially initialized module 'a' (most likely due to a circular import ) ( /path/ a. py ) # Python 3.14, from ... import 형식(필자가 3.14 .7 에서 실측): ImportError : cannot import name 'a_func' from 'a'

문제가 발생하는 근본 원인: 부분 초기화

이것은 "무한 루프"가 아니다. Python은 무한 재귀 임포트를 하지 않기 때문이다. 진짜 문제는 어떤 모듈이 아직 실행을 마치지 않았는데 다른 곳에서 참조되어, 참조한 쪽이 "반제품"을 받게 된다는 점이다.

二. 근본 원인 분석

2.1 Python의 실행 모델

순환 임포트를 이해하려면 먼저 Python의 실행 모델(execution model)을 이해해야 한다.

간단히 말하면, Python은 "모든 함수와 클래스를 먼저 정의해 놓고 실행을 시작하는" 방식이 아니라, 바이트코드로 컴파일된 뒤 가상 머신이 한 줄씩 실행하는 방식이다——def, import, from ... import ... 같은 것들이 모두 실행 문장이며, 실행에 도달해야 생성된다:

x = 1 # 이 줄은 할당, 할당을 실행 def 함수명 (): # 이 줄은 def, 실행하면 = 함수 객체 생성 pass print (x) # 이 줄은 print, 실행하면 출력 import os # 이 줄은 import, 실행하면 = 모듈 로딩을 트리거하여 해당 모듈로 가서 코드 실행

핵심: 함수는 def에 도달하기 전까지는 존재하지 않는다.

만약 def 함수명 이전에 함수명()을 호출하면 NameError가 바로 발생한다. 이는 C/Java 같은 컴파일 언어와 다른데——그들은 컴파일 단계에서 모든 함수가 이미 존재하지만, Python은 실행하는 곳까지 정의되는 방식이다.

2.2 Python의 모듈 캐시 sys.modules

sys.modules는 Python의 모듈 캐시로, 전역 딕셔너리이며 key는 모듈명, value는 모듈 객체다. 역할은 모듈 객체를 캐시하여, 이후 같은 모듈의 내용을 사용할 때 캐시에서 바로 가져오는 것이다.

한 번의 완전한 임포트 흐름은 이렇다:

import a 실행 ↓ 1 . sys .modules에 ' a ' 가 있는지 확인 ├── 있음 → 바로 반환, 코드를 다시 실행하지 않음 └── 없음 → 다음 단계로 진행 ↓ 2 . 빈 모듈 객체를 생성하여 sys .modules [ 'a' ] 에 넣음 (먼저 자리표시, 이 시점에는 아무 속성도 없음) ↓ 3 . a .py 코드를 위에서 아래로 실행, 정의된 변수/함수/클래스가 점차 모듈 객체의 속성이 됨 ↓ 4 . a .py 실행 완료, sys .modules [ 'a' ] 가 그제야 완전한 모듈 객체가 됨

이 캐시에는 세 가지 역할이 있다:

- 중복 실행 방지: 같은 모듈이 여러 번 import될 때 한 번만 실행됨;

- 단일 인스턴스 보장: 프로그램 전체에서 같은 모듈은 인스턴스가 하나뿐이며, 모든 곳에서 공유됨;

- 무한 루프 방지: A가 B를 임포트하고 B가 다시 A를 임포트할 때 무한 재귀로 이어지지 않음.

즉, sys.modules는 당신의 코드가 무한 루프에 빠지지 않도록 보장하지만, 그 대가는 부분 초기화 문제가 발생한다는 것이다.

2.3 근본 원인 분석: 부분 초기화는 어떻게 발생하는가

Python 가상 머신이 코드를 한 줄씩 실행하며, 처음에는 a 모듈에서 실행된다. from b import b_func라는 임포트 문장에 도달하면:

- sys.modules에 b가 없음 → 빈 b 모듈 객체를 생성하여 자리표시 → b의 코드를 실행하러 감;

- b가 한 줄씩 실행되며 from a import a_func에 도달하면, a가 이미 sys.modules에 있음을 발견;

- 하지만 이때의 a는 부분 초기화 상태일 뿐이다——b를 임포트하는 그 줄까지 실행하고 "일시정지"된 상태이며, a_func는 임포트 문장 이후에야 def로 생성된다;

- Python은 이 반제품 a에서 a_func를 바로 찾으려 하지만 찾지 못함 → ImportError를 던짐.

타임라인으로 나타내면 더 명확하다:

T0 a .py 실행 시작, 빈 모듈 a를 생성하여 sys .modules에 넣음 T1 a .py가 from b import b_func에 도달 T2 b .py를 실행하러 감, 빈 모듈 b를 생성하여 sys .modules에 넣음 T3 b .py가 from a import a_func에 도달 T4 a가 이미 캐시에 있음을 발견 → 바로 가져옴 (하지만 a는 T0~T1만 실행했고, a_func는 아직 정의되지 않음) T5 a_func를 찾지 못함 → 예외를 던짐

따라서 순환 임포트의 본질은: Python의 한 줄씩 실행 + sys.modules의 선(先) 자리표시 후(後) 채우기가 결합되어, 고리 위의 특정 모듈이 참조될 때 아직 초기화가 끝나지 않았다는 것이다.

한 가지 보충: 순환 임포트가 반드시 오류를 내는 것은 아니다. 만약 b가 참조하는 것이 a에서 임포트 문장 이전에 이미 정의된 속성이라면 정상적으로 가져올 수 있다. 오류 발생 여부는 "참조되는 속성이, 그것이 속한 모듈이 일시정지된 그 순간에 이미 정의되어 있는지"에 달려 있다.

三. 사례 분석: 패키지 초기화의 연쇄 임포트

3.1 배경

나는 멀티 에이전트 프로젝트(ai-agent-lab)를 만들고 있었고, 계층형 아키텍처를 채택했다: common 공통 계층, services 서비스 계층, services/agent Agent 계층. 이전에 프롬프트를 통합 관리하려고 시스템 프롬프트를 services/agent/prompts 디렉터리에 넣었는데, 테스트를 돌리다 은밀한 순환 임포트를 만났다.

문제 설명:

`conversation_service`(services 계층)가 최상단에서 `agent.prompts`(agent 계층)를 임포트함; agent 패키지 초기화를 트리거하고, `agent.tools.history_tool`이 다시 `conversation_service`를 임포트하여 고리를 형성함.

3.2 핵심 함정: 하위 모듈 하나를 임포트하면 전체 패키지의 init .py가 먼저 실행된다

여기서 가장 간과하기 쉬운 점은: 패키지 내부의 어느 하위 모듈이든 임포트하면 Python은 이 패키지의 __init__.py를 먼저 실행하고, __init__.py 안에서 임포트하는 것들이 연쇄적인 임포트를 일으킨다는 것이다.

당시 나의 의존성 경로는 이러했다:

conversation_service 실행: from services. agent . prompts import CHAT_SYSTEM_PROMPT ↓ Python이 먼저 services/agent/__init__. py 실행 (패키지 초기화) ↓ __init__. py 안: from . agent_service import chat_with_agent ↓ agent_service. py 실행: from services. agent . graph import agent_graph ↓ graph. py 실행: from services. agent . nodes import memory_node, router_node, tool_node ↓ tool_node. py 실행: from services. agent . tools import TOOL_REGISTRY ↓ tools/__init__. py 실행: from . history_tool import get_recent_chats, get_chat_messages ↓ history_tool. py 실행: from services. conversation_service import load_chat_list, get_sorted_chat_list ↓ conversation_service 로 되돌아감 —— 하지만 그것은 agent. prompts를 임포트하는 그 줄까지만 실행된 반제품 → 오류

나는 아주 가벼운 prompts(프롬프트 상수)만 임포트했다고 생각했지만, 실제로는 도미노처럼 __init__.py에 이끌려 agent_service → graph → nodes → tools → history_tool이라는 의존성 체인 전체가 한 번씩 트리거되었고, 마지막에는 자기 자신으로 되돌아와 고리를 형성했다.

3.3 문제 코드

services/conversation_service.py(당시 작성 방식) from services.agent.prompts import CHAT_SYSTEM_PROMPT # 최상단 임포트, 전체 agent 패키지 초기화를 트리거 def add_new_chat ( username ): ... init_messages = [deepcopy(CHAT_SYSTEM_PROMPT)] # 시스템 프롬프트를 딥카피 한 부 ...

services/agent/tools/history_tool.py from services.conversation_service import load_chat_list, get_sorted_chat_list

3.4 근본 원인

의존성 방향에서 보면, 이것은 전형적인 의존성 방향 반전 문제다:

- conversation_service(services 계층)가 agent 계층에 의존했고;

- agent 계층의 도구 history_tool이 다시 services 계층의 conversation_service에 의존했다;

- 두 계층이 서로 의존하고, 의존 관계가 하나의 사이클(그래프 이론의 사이클)을 이루어, 어떤 모듈도 "먼저 완전히 초기화"될 수 없다.

아키텍처 차원의 근본 원인: 의존 방향이 반대다. 상위 계층은 하위 계층을 임포트할 수 있지만, 하위 계층은 절대 반대로 상위 계층을 임포트해서는 안 된다.

4. 해결 방안

순환 임포트에 대해서는 세 가지 층위의 해법이 있다: 지연 임포트(임시방편), 계층 분리·결합 해제(근본 해결), 의존성 역전(우아한 근본 해결).

4.1 방안 1: 함수 내 지연 임포트(임시방편)

방법: 최상단의 import를 함수 내부로 옮겨, 모듈 로딩 시점이 아니라 함수가 호출될 때 실행되게 한다. 이때는 모든 모듈이 이미 초기화를 끝낸 뒤이므로 자연히 반제품 문제가 생기지 않는다.

```python # services/conversation_service.py # 최상단에서는 더 이상 임포트하지 않고, 함수 내부에서 임포트하도록 변경 def add_new_chat(username): ... with get_user_lock(username): history = load_chat_list(username) history.insert(0, new_chat_meta) save_chat_list(username, history) # 함수 호출 시점까지 지연해서 임포트, 이때 short_term_memory_service는 이미 초기화 완료 from services.short_term_memory_service import save_chat_messages save_chat_messages(chat_id, init_messages) ```

최초에는 나도 프로젝트의 2, 3군데에서 이런 방식을 써서 services 계층, common 계층 내부의 상호 임포트를 회피했다.

- 장점: 변경이 극히 작고 빠르게 출혈을 멈출 수 있다;

- 단점: 임시방편일 뿐 근본 해결이 아니다 —— 의존 사이클은 여전히 존재하고, 단지 그것을 런타임으로 미뤄 우회했을 뿐이다; 매번 호출할 때마다 sys.modules를 한 번 조회한다(오버헤드는 매우 작지만 존재한다); 게다가 최상단에서는 전체 의존성을 볼 수 없어 가독성이 나빠진다.

- 적용 시나리오: 임시 응급 조치, 서드파티 라이브러리 내부의 순환 임포트, 두 모듈이 실제로 약하게 결합되어 있을 때.

4.2 방안 2: 계층 분리·결합 해제, 공통 의존성 하향 배치(근본 해결)

나중에 저층 지식을 상대적으로 철저히 파악한 뒤 되돌아보니, conversation_service는 왜 agent 계층을 임포트하는가? 단지 시스템 프롬프트 상수 CHAT_SYSTEM_PROMPT 하나를 얻기 위해서였다. 프롬프트 상수는 본질적으로 누구나 쓸 수 있는 공용 자원이라 근본적으로 agent 계층에 두어서는 안 되는 것이었다.

방법: 여러 곳에서 의존하는 공통 부분을 더 하위 계층 모듈로 내려, 의존 방향이 다시 단방향이 되게 한다.

리팩터링 전(사이클 있음): conversation_service ──→ agent.prompts ↑ │ └───────────────────────┘ (history_tool이 agent 패키지를 거쳐 conversation_service를 역방향 의존) 리팩터링 후(단방향, 사이클 없음): conversation_service ──→ common.prompt(일반 계층으로 하향) agent 계층 ──→ common.prompt 모두가 최하위 계층인 common에만 의존하게 되어 사이클이 깨진다

구체적 작업: 프롬프트를 services/agent/prompts에서 common/prompt/chat_prompt.py로 옮기고, common/prompt/__init__.py에서 통합 export한다:

```python # common/prompt/__init__.py from .chat_prompt import CHAT_SYSTEM_PROMPT __all__ = ["CHAT_SYSTEM_PROMPT"]

services/conversation_service.py(리팩터링 후) from common.prompt import CHAT_SYSTEM_PROMPT # 더 하위 계층인 common에 의존하도록 변경, 더 이상 agent 패키지를 건드리지 않음 ```

이렇게 하면 conversation_service가 더 이상 agent 패키지 초기화를 트리거하지 않고, 그 연쇄 임포트 체인이 근원에서 끊겨 사이클이 사라진다.

- 장점: 사이클을 근본적으로 제거하고, 의존 관계가 명확한 방향성 비순환 그래프(DAG)가 되어 아키텍처가 더 깔끔해진다;

- 단점: "무엇을 하향 배치해야 하는가"를 판단해야 하고, 어느 정도 리팩터링 비용이 든다;

- 핵심 원칙: 상위 계층은 하위 계층을 임포트할 수 있고, 하위 계층은 절대 상위 계층을 임포트해서는 안 된다; 여러 곳에서 의존하는 것은 더 하위 계층에 둔다.

4.3 방안 3: 의존성 역전, 추상 지향(우아한 근본 해결)

어떤 상황에서는 두 모듈이 실제로 "개념상 서로를 필요로 하는" 경우가 있어, 억지로 하향 배치하면 매우 어색하다. 이때는 의존성 역전 원칙(DIP)을 쓸 수 있다: 상위 계층이 하위 계층의 구체 구현에 의존하지 않고, 추상 인터페이스(Protocol / ABC)를 정의하며, 하위 계층이 그 인터페이스를 구현한다.

나는 harness의 설계 논리를 배웠는데, 현재 프로젝트의 HarnessBuilder(파이프라인 빌더)가 바로 이런 사고방식이다. 그것은 각각의 구체적인 Agent 그래프에서 사용되어야 하지만, 그 자신은 절대 어떤 비즈니스 모듈에도 역방향 의존하지 않는다:

```python # common/agent/harness.py —— 순수 범용 계층, 비즈니스 의존성 제로 class HarnessBuilder: """ 설계 원칙: 비즈니스 의존성 제로——어떤 state, 노드, prompt도 import하지 않는 순수 범용 계층 """ def __init__(self, state_class, name: str): self._workflow = StateGraph(state_class) # 상태 클래스는 외부에서 전달받음 ... def node(self, name: str, func: Callable): # 노드 함수는 외부에서 전달받음 ... self._workflow.add_node(name, func) return self

services/agent/graph.py —— 비즈니스 계층이 범용 계층에 의존, 구체적인 state와 노드를 "주입" agent_graph = ( HarnessBuilder(AgentState, name="agent_react") # 구체 상태 클래스 전달 .node("memory", memory_node) # 구체 노드 함수 전달 .node("router", router_node) .node("tool", tool_node) .entry("memory") .edge("memory", "router") .build() ) ```

범용 계층 harness는 구체적인 state와 노드가 어떻게 생겼는지 알지도, 신경 쓰지도 않으며, Callable 같은 추상에만 의존한다; 구체적인 비즈니스 모듈이 반대로 범용 계층에 의존한다. 의존의 화살표가 "역전"되어 사이클이 자연히 형성될 수 없고, 동시에 확장성도 얻었다 —— 새 파이프라인을 추가할 때 범용 계층은 한 줄의 코드도 고칠 필요가 없다.

- 장점: 가장 우아하고, 의존성 역전 원칙에 부합하며, 확장성이 가장 좋고, 범용 능력을 재사용할 수 있다;

- 단점: 추상적 사고가 필요하고, 코드량이 약간 많다;

- 적용 시나리오: 복잡한 시스템, 재사용이 필요한 범용 프레임워크, 확장이 예상되는 곳.

4.4 세 가지 방안 비교

| 방안 | 해결 정도 | 변경량 | 우아함 | 적용 시나리오 | | --- | --- | --- | --- | --- | | 함수 내 지연 임포트 | 임시방편(사이클 여전) | 작음 | 낮음 | 임시 응급 조치, 약한 결합 | | 계층 분리·결합 해제, 공통 의존성 하향 | 근본 해결(사이클 제거) | 중간 | 중간 | 대다수 비즈니스 시나리오 | | 의존성 역전, 추상 지향 | 근본 해결(사이클 형성 불가) | 중간 | 높음 | 범용 프레임워크, 확장이 필요한 복잡한 시스템 |

5. 정리

1. 순환 임포트의 본질은 "부분 초기화"이며, 무한 루프가 아니다. Python은 한 줄씩 실행되고, def / import 모두 실행문이다; sys.modules는 먼저 빈 모듈 자리표시자를 만들고 점진적으로 채우는데, 이는 무한 재귀를 막아주는 동시에 사이클 위의 모듈이 참조될 때 아직 반제품일 수 있게 한다 —— 아직 정의되지 않은 속성을 참조하면 `cannot import name ...` 오류가 난다.

2. "패키지 초기화의 연쇄 임포트"를 경계하라. 패키지 내의 하위 모듈 하나를 임포트하면 먼저 패키지의 __init__.py가 실행되고, 그것이 임포트하는 내용이 전체 의존성 체인을 트리거한다. 그래서 겉보기에 무관해 보이는 가벼운 임포트가 패키지 전체를 끌어올 수 있다. 실무에서는 __init__.py를 가능한 한 가볍게 유지해야 한다.

3. 순환 임포트 해결의 핵심은 의존 사이클을 제거해 의존 관계를 방향성 비순환 그래프(DAG)로 만드는 것이다. 세 가지 층위의 해법:

- 지연 임포트: import를 함수 안에 넣어 런타임에 로드 —— 빠른 응급 조치지만 사이클은 남는다;

- 계층 분리·결합 해제: 공통 의존성을 더 하위로 내려 의존 방향을 단방향으로 보장 —— 가장 흔히 쓰이는 근본 해결 수단;

- 의존성 역전: 상위가 추상에 의존하고 하위가 추상을 구현해 주입 —— 가장 우아하며 확장성도 겸비한다.

4. 계속 활용할 수 있는 두 가지 아키텍처 원칙:

- 의존 방향은 반드시 단방향이어야 한다: 상위가 하위에 의존하고, 하위는 절대 역으로 상위에 의존하지 않는다;

- 추상은 세부에 의존해서는 안 되고, 세부가 추상에 의존해야 한다.

순환 임포트를 만나면 먼저 지연 임포트로 "우회"하려 하지 말고, 모듈 의존 그래프를 그려 그 사이클을 찾아내고, 여러 곳에서 의존하는 것이 계층을 잘못 놓은 것은 아닌지 판단하라 —— 대부분의 경우 공통 부분을 하향 배치하거나 추상 한 층을 뽑아내면 사이클이 근본적으로 사라진다.

제가 이해한 부분에 틀린 점이 있다면 적극적으로 지적해 주시면 좋겠고, 문제가 있으면 댓글로 서로 토론하며 함께 agent 역량을 높여가면 좋겠습니다.

도움이 되셨다면, 괜찮다면 좋아요 한 번 눌러주시면 감사하겠습니다, 감사합니다 QwQ

parser

1

2

읽음

0