Claude Code를 사용하여 Python 코드베이스의 37개 순환 임포트 문제 해결하기
요약
180k 라인의 대규모 Python 코드베이스에서 발생한 37개의 순환 임포트 문제를 Claude Code를 활용하여 해결하는 과정을 다룹니다. 단순히 에러 메시지를 전달하기보다, 전체 임포트 그래프와 사이클 정보를 제공하는 것이 문제 해결의 핵심임을 강조합니다.
핵심 포인트
- 단일 에러 메시지 대신 전체 임포트 그래프를 제공해야 한다.
- 순환 임포트는 강력하게 연결된 컴포넌트(SCC) 분석으로 파악한다.
- 사이클을 크기보다 영향 범위(blast radius) 기준으로 우선순위를 정해 해결하는 것이 중요하다.
요약 (TL;DR)
저는 약 180k 라인의 Python 서비스에서 발생한 37개의 순환 임포트 체인을 함수 레벨 임포트와 기도에 의존하여 유지하고 있었습니다. 저는 Claude Code를 사용하여 임포트 그래프를 매핑하고, 사이클을 순위별로 지정한 다음, PR(Pull Request) 단위로 하나씩 해결했습니다. 그리고 재발 방지를 위해 CI(Continuous Integration) 계약을 추가했습니다. 이 작업은 8일의 근무 시간이 걸렸습니다. 가장 큰 교훈은 다음과 같습니다: 에러 메시지가 아니라 에이전트에게 그래프를 제공하라.
문제점 (The Problem)
만약 여러분이 3년 이상 된 Python 코드베이스에서 작업해 본 경험이 있다면, 아마 다음을 본 적이 있을 것입니다:
ImportError: cannot import name 'InvoiceService' from partially initialized module 'billing.services' (most likely due to a circular import)
저희 서비스는 청구 및 구독 백엔드였으며, 약 180,000 라인의 Python 3.12로 구성되어 있었고, Django 스타일의 레이어링을 수동으로 구현한 형태였습니다. 이 서비스는 몇 년 동안 같은 방식으로 '수정'되어 왔습니다: 임포트를 함수 내부로 이동시키는 방식.
def create_invoice(customer_id: int):
# avoid circular import
from billing.services import InvoiceService
...
저는 세어보았습니다. 레포지토리에는 214개의 # avoid circular import 주석이 있었습니다. 이 모든 것은
1단계: 그래프를 먼저 구축하세요 (에이전트가 추측하게 두지 마세요)
제 첫 시도는 순진했습니다. ImportError를 Claude Code에 붙여넣고 "이 순환 임포트를 고쳐줘"라고 말하는 것이었습니다. 그것은 수정했지만, 또 다른 함수 레벨의 임포트를 추가함으로써였습니다. 기술적으로는 맞았지만, 완전히 쓸모가 없었습니다.
에이전트가 틀린 것은 아니었습니다. 단지 전체 그림을 가지고 있지 않았을 뿐입니다. 단일 트레이스백(traceback)은 사이클의 한 에지(edge)만을 보여줍니다. 그래서 저는 전체 임포트 그래프를 생성하여 그것을 제공했습니다.
저는 pydeps를 사용하여 모듈 종속성을 JSON으로 덤프한 다음, 강력하게 연결된 컴포넌트(strongly connected components)를 찾기 위한 작은 스크립트를 사용했습니다:
import json
import networkx as nx
...
출력 (요약):
1. size=14 ['billing.models', 'billing.services', 'billing.tasks', ...]
2. size= 6 ['accounts.permissions', 'accounts.models', 'audit.log', ...]
3. size= 3 ['notifications.email', 'notifications.templates', 'billing.models']
...
총 37개의 강력하게 연결된 컴포넌트였습니다. 모듈 14개로 이루어진 거대한 것 하나와, 2~3개 모듈 루프가 길게 늘어선 나머지들입니다.
2단계: 크기가 아닌 영향 범위(blast radius)별로 순위를 매기세요
제 본능은 "거대한 것부터 시작해야 한다"였습니다. 틀렸습니다. 저는 Claude Code에게 각 사이클을 다음 기준으로 점수화하도록 요청했습니다:
- 사이클의 어떤 멤버를 다른 _모듈_이 임포트하는지 여부 (fan-in)
- 그 안에 얼마나 많은 지연 임포트(lazy imports)가 존재하는지
- 어떤 멤버가 요청의 핫 패스(hot path)에 있는지 여부
그런 다음 오름차순으로 정렬했습니다. 작고 고립된 사이클부터 시작한 것입니다. 제가 하나씩 수정할 때마다, 여러 작은 사이클들이 그 거대한 컴포넌트와 에지를 공유하고 있었기 때문에 큰 컴포넌트는 조금씩 줄어들었습니다. 사이클 #1에 도달했을 때, 그것은 14개 모듈에서 5개로 줄어든 상태였습니다.
💡 이것이 프로젝트에서 내린 최고의 결정이었습니다. 14개 모듈짜리 괴물부터 시작했다면 아무도 검토할 수 없는 3,000줄 분량의 PR(Pull Request)을 만들었을 것입니다.
3단계: 고정된 프롬프트로 사이클별 PR 생성하기
각 사이클에 대해 저는 Claude Code (당시 v2.x)에게 동일한 구조화된 프롬프트를 제공했습니다:
## Cycle #23
Members: notifications.email, notifications.templates, billing.models
...
마지막 규칙인 “어떤 엣지(edge)가 잘못되었는지 설명하라”는 가장 가치 있는 부분이었습니다. 이 규칙은 에이전트에게 단순히 오류를 사라지게 만드는 대신, 제가 동의하거나 반대할 수 있는 아키텍처적 주장을 하도록 강제했습니다.
billing/events.py (새로 추가된 최하위 레이어 — billing에서 아무것도 임포트하지 않음)
from dataclasses import dataclass
from typing import Callable
...
notifications/email.py
def send_receipt(event: InvoicePaid) -> None:
...
이제 모델은 `publish(InvoicePaid(...))`를 호출하며, email이 존재한다는 사실을 전혀 모릅니다. 엣지를 제거하고 사이클을 없앴습니다.
### 단계 4: 모든 것을 커버한 세 가지 수정 패턴
37개의 사이클 전체에 걸쳐, 모든 수정은 다음 세 가지 범주 중 하나에 속했습니다:
| 패턴 | 사이클 수 | 예시 |
| :--- | :--- | :--- |
| 공유 코드를 한 레이어 아래로 이동 | 19 | `services`에 있던 상수와 열거형(enums)을 `types` 모듈로 이동 |
| ... |
graph TD
A[api] --> S[services]
S --> M[models]
...
레이어가 이렇게 보이자, 남아있던 “큰” 사이클은 대부분 저절로 해소되었습니다.
### 단계 5: CI 계약으로 고정하기
사이클을 수정하는 것은 3주 후에 다시 발생한다면 무의미합니다. 저는 레이어 계약과 함께 `import-linter`를 추가했습니다:
[importlinter]
root_package = app
...
그리고 `models`에서 `notifications`를 임포트하는 것을 금지하는 규칙을 추가했습니다. 이것은 CI에서 약 4초 만에 실행되며, 누군가(사람이든 에이전트든) 상향 임포트를 재도입하면 빌드를 실패시킵니다.
또한 새로운 `# avoid circular import` 주석을 플래그 하는 작은 린팅 규칙도 추가했습니다. 만약 필요하다면, 계약서가 무언가를 알려주고 있다는 뜻입니다.
### 결과들
✅ **37 → 0**개의 퍼스트파티 모듈 간 강하게 연결된 컴포넌트(strongly connected components) 문제 해결
✅ **214 → 9**개 함수 레벨 임포트(function-level imports) (남은 9개는 합법적인 선택적 종속성 임포트)
✅ **6 → 0**개의 임포트 순서 불안정성으로 인해 건너뛴 테스트
✅ 최악의 엔드포인트 콜드 스타트 시간이 **~1.1초에서 ~0.7초로** 감소
✅ 31개 PR, 평균 **~140줄 변경** (각 PR은 15분 이내 검토 완료)
❌ 하나의 회귀(regression): 사이드 이펙트에 의존하는 Celery 태스크 등록 문제. 스테이징 환경에서 발견되어 한 시간 만에 수정.
총계: 8일 작업, 실제 집중 시간 약 25시간.
## 배운 점 (Lessons Learned)
**1. 에이전트에게 트레이스백(traceback) 대신 그래프를 제공하라.**
트레이스백은 하나의 엣지(edge)만을 보여준다. 하나의 엣지를 고치는 에이전트는 가장 쉬운 해결책, 즉 지연 임포트(lazy import)를 선택할 것이다. 모든 엣지가 나열된 전체 강하게 연결된 컴포넌트를 넘겨주었을 때, 수정 사항은 미적인 수준이 아닌 아키텍처적 수준으로 올라갔다. 프롬프트의 기발함보다 컨텍스트의 형태가 더 중요하다.
**2.
저는 동일한 그래프 우선 접근 방식을 저희 **프론트엔드**에 적용할 계획입니다. 이 프론트엔드는 `madge`가 22개의 순환 임포트를 보고하는 TypeScript 애플리케이션입니다. 제 직감으로는 패턴 분리가 다르게 나타날 것 같습니다 (공유 타입 이동이 훨씬 많고 이벤트는 적을 것입니다). 그리고 '잘못된 엣지 설명' 규칙이 그곳에서도 통할지 궁금합니다.
또한, 제가 직접 수동으로 작성하는 대신, 에이전트가 어떤 수정 작업도 하기 전에 그래프를 읽어서 레이어 계약(layer contracts)을 _제안_하도록 실험하고 있습니다. 초기 결과: 약 80%의 확률로 레이어를 올바르게 파악하며, `utils`가 어디에 속해야 하는지에 대해 이상할 정도로 고집이 세다는 것을 알았습니다. (맞습니다. `utils`는 존재해서는 안 됩니다.)
## 마무리
만약 `# 순환 임포트 피하기` 주석들이 쌓여 있는 더미가 있다면, 당신은 몇 가지 짜증나는 버그를 가진 것이 아니라 문서화되지 않은 아키텍처를 가지고 있다는 뜻입니다. 그래프를 매핑하고, 엄격한 규칙을 가지고 에이전트에게 전달하며, 작은 것부터 시작하여 계약으로 확정하십시오.
👉 **이 내용이 유용했다면 Dev.to에서 저를 팔로우해 주세요** — Claude Code와 자율 코딩 에이전트를 사용하여 실제 리팩토링을 배포하는 빌드 로그(성공과 실패 모두 포함)를 작성합니다.
💬 그리고 다음 내용을 듣고 싶습니다: 당신이 풀어본 순환 임포트 중 _가장 최악이었던_ 사례는 무엇인가요? 댓글에 남겨주세요. 🚀
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기