Python 순환 import 해결법 (실전 예제 포함)
게시일
업데이트
순환 import(circular import) 는 모듈 A가 모듈 B를 import하는 동안, B도 (직접이든 다른 모듈을 거치든) A를 import할 때 발생합니다. Python은 보통 무한 루프에 빠지지 않습니다. 대신 부분 초기화(partial import) 상태가 됩니다. 한쪽 모듈이 아직 로딩 중인데 다른 쪽이 그 안의 이름을 읽으려 하면 ImportError / AttributeError가 나거나, 반쯤만 초기화된 객체가 생깁니다.
빠른 해결 (여기서 시작)
| 상황 | 먼저 할 일 |
|---|---|
| 두 모듈이 최상위에서 서로를 import | 공유 코드를 둘 다 import할 수 있는 세 번째 모듈로 분리 |
| 특정 함수 안에서만 이름이 필요 | 그 함수 안에서 import (lazy import) |
| 타입 어노테이션용으로만 필요 | from typing import TYPE_CHECKING 사용, 필요하면 어노테이션을 따옴표로 |
| 깊은 사이클이 있는 큰 패키지 | 하위 레이어가 상위 레이어를 import하지 않도록 의존 방향 재설계 |
당장 막히는 경우가 많은 최소 패턴:
# Instead of a top-level cycle:
# models.py imports services.py
# services.py imports models.py
# services.py — import only where used
def process_user(user_id: int):
from models import User # lazy import breaks the load-time cycle
return User.get(user_id)이게 임시 패치처럼 느껴진다면 계속 읽으세요. 근본 해결은 거의 항상 의존 방향(dependency direction) 이고, 영리한 import 트릭이 아닙니다.
- Runcell Science: Claude Science를 대체할 오픈소스 AI 연구 워크스페이스
- 맥 잠자기 방지: 맥북 닫아도 Codex와 Claude Code 계속 실행하기
- OpenClaw vs ZeroClaw vs Pi Agent vs Nanobot: 2026년에 어떤 AI 에이전트 스택을 선택해야 할까?
- Claude Code로 Jupyter 노트북을 분석하는 방법 | Data Science 실무 가이드와 한계
- Claude Code 루틴 사용법: AI 에이전트 cron 작업과 자동 트리거
- Claude Code Desktop에서 Bypass permissions 켜는 법
- Google의 A2A 프로토콜을 사용한 두 개의 Python 에이전트 빌드하기 - 단계별 튜토리얼
- 2025년 파이썬에서 가장 성장하는 상위 10개 데이터 시각화 라이브러리
순환 import는 어떻게 보이나
최소 실패 예제
형제 모듈 두 개를 만듭니다.
# a.py
import b
def hello_from_a():
return "A"
print("a loaded", b.hello_from_b())# b.py
import a
def hello_from_b():
return "B"
print("b loaded", a.hello_from_a())실행:
python a.py전형적인 실패 메시지(버전마다 표현이 조금 다름):
ImportError: cannot import name 'hello_from_a' from partially initialized module 'a'
(most likely due to a circular import)이 문구가 바로 이 문제의 검색어 형태입니다. Python이 a를 로딩하기 시작했고, 그 과정에서 b를 로딩했고, b가 a의 로딩이 끝나기 전에 a에서 이름을 가져오려 한 상태입니다.
실제로 깨지는 지점
Python이 모듈을 import할 때:
- 빈 모듈 객체를 만들고
sys.modules에 넣습니다. - 모듈 본문을 위에서 아래로 실행합니다.
- 그 본문이 끝난 뒤에야 최상위 이름이 모두 존재한다고 보장됩니다.
사이클 중에는 A의 2단계가 아직 도는 동안 B가 A에서 나중에 정의되는 이름을 요구합니다. 이름이 없음 → import 오류. 이건 부분 초기화 실패이지, 끝없이 도는 루프가 아닙니다.
문제 해결 흐름
이 순서로 진행하세요. 사이클이 사라지면 멈춥니다.
-
깨끗한 entrypoint로 재현
검색/에러 메시지에 나온 파일과 같은 방식으로 실행합니다(python -m package.module또는 스크립트 경로). 순환 import는 entrypoint에 민감합니다. -
traceback을 아래에서 위로 읽기
마지막 몇 프레임에module_x가module_y를 import하는 동안module_y가 이미 스택에 있는 모습이 보통 보입니다. -
한 방향 의존성 스케치
각 모듈과 import 대상을 나열합니다. 코어 모델에서 앱/UI/API 쪽으로 “위로” 향하는 간선이 있으면 의심 대상입니다. -
필요 유형 분류
- 런타임 값/함수 → 구조 변경 또는 lazy import
- 타입만 →
TYPE_CHECKING - 공유 상수/모델 → leaf 모듈로 추출
-
가장 작은 올바른 수정 적용
공유 모듈 추출 > lazy import > 패키지 레이아웃 재설계 순으로 선호하세요. “절대 import만 쓰면 된다” 같은 맹신은 피합니다. -
같은 entrypoint로 다시 실행
python a.py스타일과 패키지 스타일-m둘 다 쓰는 환경이면 둘 다 확인합니다.
해결 1: 세 번째 모듈로 추출 (기본 추천)
models와 services가 둘 다 User가 필요할 때, 서로를 import하게 두지 마세요. 상위 레이어에 의존하지 않는 leaf 모듈에 User를 둡니다.
app/
models/user.py # leaf: no import from services
services/billing.py # imports models.user
api/routes.py # imports services# models/user.py
class User:
def __init__(self, user_id: int, email: str):
self.user_id = user_id
self.email = email
@classmethod
def get(cls, user_id: int) -> "User":
return cls(user_id, f"user{user_id}@example.com")# services/billing.py
from models.user import User
def invoice(user_id: int) -> str:
user = User.get(user_id)
return f"Invoice for {user.email}"# api/routes.py
from services.billing import invoice
def handle(user_id: int) -> str:
return invoice(user_id)의존 방향은 일방: api → services → models. 하위에서 상위를 import하지 않으니 사이클이 사라집니다.
해결 2: Lazy (지역) import
임시 사이클을 풀기 어려울 때, 심볼이 필요한 함수/메서드 안에서 import합니다.
# reporters.py
def build_report(order_id: str) -> dict:
from orders import Order # imported at call time, not module load time
order = Order.load(order_id)
return {"order_id": order.id, "total": order.total}# orders.py
class Order:
def __init__(self, id: str, total: float):
self.id = id
self.total = total
@classmethod
def load(cls, order_id: str) -> "Order":
return cls(order_id, 19.99)
def pretty(self) -> str:
from reporters import build_report # only if you truly need this edge
return str(build_report(self.id))lazy import가 적합한 경우
- 레거시 코드에서 끈질긴 사이클을 빠르게 끊을 때
- 선택적 무거운 의존성(필요한 코드 경로에서만 import)
장기 설계로 피해야 하는 경우
- import 비용이 민감한 hot path(보통 작지만 측정 가능)
- 사이클이 계속 늘어나는 구조 — 모듈을 추출하세요
해결 3: 어노테이션 전용 import에는 TYPE_CHECKING
import 이유가 타입 힌트뿐이라면 런타임에서 빼 두세요.
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from models.user import User # not imported at runtime
def notify(user: User, message: str) -> None:
print(user.email, message)from __future__ import annotations(또는 "User"처럼 따옴표 어노테이션)이면 어노테이션이 동작하기 위해 런타임에 User가 필요 없습니다. 타입 힌트 사이클에 맞는 도구이고, User 메서드를 호출하는 용도가 아닙니다.
해결 4: import 대신 의존성 주입
서비스가 “서로 필요할 때” 전역 import 대신 collaborator를 인자로 넘깁니다.
# notifications.py
class Notifier:
def send(self, email: str, body: str) -> None:
print(f"to={email} body={body}")
# checkout.py
class Checkout:
def __init__(self, notifier: "Notifier"):
self.notifier = notifier
def complete(self, email: str) -> None:
# business logic...
self.notifier.send(email, "Order complete")# main.py
from notifications import Notifier
from checkout import Checkout
checkout = Checkout(Notifier())
checkout.complete("a@example.com")notifications와 checkout는 서로를 import하지 않습니다. 가장자리(main.py)에서 조합이 wiring을 담당합니다. 큰 앱/프레임워크의 constructor injection과 같은 아이디어입니다.
흔한 함정 (사이클을 고치지 않는 조언)
오래된 가이드에 자주 나옵니다. 다른 문제는 풀 수 있어도 순환 import는 안 풀립니다.
| 자주 보는 조언 | 현실 |
|---|---|
| “절대 import를 쓰세요” | 절대 import는 가독성을 높입니다. 상호 의존 자체를 없애지는 않습니다. |
“__all__을 설정하세요” | from module import * 내보내기 면만 제어합니다. 사이클에는 영향 없음. |
“항상 importlib.import_module” | 동적 import로 로딩을 늦출 수는 있지만, 잘못된 시점에 import하면 사이클은 남습니다. 명시적 지역 import나 구조 변경을 선호하세요. |
| “Python이 무한 루프에 빠진다” | 흔한 결과는 ImportError / 부분 초기화이지, 도는 루프가 아닙니다. |
| “파일 이름만 바꾸면 된다” | 이름 충돌은 헷갈리는 import 오류를 만들 수 있지만, 진짜 A↔B 의존은 이름만으로 안 풀립니다. |
사이클을 막는 패키지 레이아웃 패턴
건강한 패키지는 DAG(방향성 비순환 그래프)처럼 보입니다.
package/
__init__.py # keep thin; avoid importing everything eagerly
domain/ # pure models, no IO
services/ # use domain
adapters/ # DB, HTTP, filesystem
api/ # entrypoints; imports services only실전 규칙:
- Leaf 모듈은 앱 쪽을 import하지 않는다.
__init__.py는 얇게. 성급한from .a import */from .b import *체인은 흔한 사이클 공장입니다.- 패키지 절반을 끌어오는 “편의” re-export보다 안정적인 leaf 모듈에서 명시 import.
- 라이브러리 개발 시 패키지로 실행:
python -m package.api— 실제 의존 문제를 가리는 경로 해킹을 줄입니다. 관련: Python 스크립트 실행.
리팩터링 중 파일을 많이 다루면 pathlib이 문자열 경로 조합보다 이동/이름 변경 스크립트를 깔끔하게 유지합니다.
디버깅 도구 모음
사이클이 들어오는 지점 추적
# debug_import.py
import sys
import trace
tracer = trace.Trace(count=False, trace=True)
tracer.runfunc(lambda: __import__("your_package.entry"))일상 작업에는 traceback이면 충분합니다. 큰 코드베이스에서는 도구가 도움이 됩니다.
python -X importtime -c "import your_package"— import 순서와 비용- import-linter (서드파티) — “services는 api를 import하면 안 됨”을 CI 규칙으로
- pyright / mypy — 어노테이션과 함께
TYPE_CHECKING실수를 조기 발견
테스트에서 부분 초기화 증상 잡기
def test_package_imports_cleanly():
import importlib
import your_package.api as api
importlib.reload(api) # optional stress
assert hasattr(api, "handle")단위 테스트가 작은 모듈만 격리 import하면, 앱 entrypoint에서만 보이는 사이클을 놓칠 수 있습니다. 실제 entry 모듈을 import하는 smoke test 하나를 추가하세요.
결정 표: 어떤 해결을 쓸까?
| 신호 | 선호 |
|---|---|
| 양쪽이 쓰는 공유 모델/상수 | 세 번째 모듈 추출 |
| 한 호출 경로만 상대 모듈 필요 | 그 함수에서 lazy import |
| 타입 체커용 import만 존재 | TYPE_CHECKING |
| 두 서비스가 서로를 조율 | 의존성 주입 / 콜백 / 이벤트 |
| 패치 후에도 사이클이 재발 | 패키지 레이어링 규칙 + CI 린트 |
FAQ
마무리
순환 import는 의존 그래프 문제입니다. Python은 이를 부분 초기화 오류로 드러내며, 특히 익숙한 “most likely due to a circular import” 메시지로 보입니다. 의존을 한 방향으로 만들어 해결하세요. 공유 leaf를 추출하고, 필요할 때만 lazy import하고, 어노테이션 import는 TYPE_CHECKING 뒤에 두고, 두 서비스가 서로 필요하면 바깥에서 wiring하세요.
절대 import, __all__, 파일 이름 변경 같은 속설 수정은 진짜 사이클을 녹이지 않습니다. 그래프가 깨끗해지면 import는 다시 지루해집니다. 그게 목표입니다.
관련 가이드
- Python type hints —
TYPE_CHECKING과 맞물리는 어노테이션 패턴 - Python pathlib — 패키지 재구성 시 더 깔끔한 경로 처리
- How to run Python scripts — import 동작에 영향을 주는 entrypoint (
python,-m) - Python try/except — 런타임 import 실패 처리와 진단
- Python dataclasses — leaf 모듈에 두기 좋은 가벼운 도메인 모델
- Python decorators — import 시점 부작용을 만들 수 있는 패턴