프로그래밍/Python

Django 개발자라면 한 번은 겪어봤을 그 악몽: 순환 참조(Circular Import) 문제

Tiboong 2025. 9. 10. 10:15

"ImportError: cannot import name 'User' from partially initialized module"

이런 에러 메시지를 본 순간, Django 개발자라면 누구나 한숨이 나올 것입니다. 특히 프로젝트가 커질수록 자주 마주치게 되는 순환 참조(Circular Import) 문제입니다.

27년간 다양한 프로젝트를 경험하면서 이 문제로 고생하는 개발자들을 정말 많이 봤습니다. 오늘은 이 문제가 왜 발생하는지, 어떻게 해결하는지 실제 코드 예시와 함께 정리해보겠습니다.

 

🔥 문제 상황: 이런 코드 본 적 있나요?

케이스 1: 모델 간 순환 참조

models/user.py

from django.db import models
from .post import Post  # 이 부분이 문제!

class User(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    
    def get_recent_posts(self):
        return Post.objects.filter(author=self)[:5]

 

models/post.py

from django.db import models
from .user import User  # 여기서도 User를 import!

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)
    
    def get_author_info(self):
        return f"{self.author.name} ({self.author.email})"

 

실행하면?

ImportError: cannot import name 'Post' from partially initialized module 'myapp.models.post'

 

케이스 2: 뷰와 유틸리티 함수 간 순환 참조

 

views.py

from django.shortcuts import render
from .utils import send_notification

def create_post(request):
    # 포스트 생성 로직
    post = Post.objects.create(...)
    send_notification(post)  # utils의 함수 호출
    return render(request, 'success.html')

 

utils.py

from django.core.mail import send_mail
from .views import get_user_context  # 뷰에서 함수를 가져옴

def send_notification(post):
    context = get_user_context(post.author)  # 순환 참조 발생!
    send_mail(...)

 

💡 해결책 1: 지연 임포트(Lazy Import) 사용

가장 간단한 해결책은 함수 내부에서 import하는 것입니다.

 

수정된 models/user.py

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()
    
    def get_recent_posts(self):
        from .post import Post  # 함수 내부에서 import
        return Post.objects.filter(author=self)[:5]

 

장점: 빠르고 간단한 해결

단점: 함수가 호출될 때마다 import 비용 발생

 

💡 해결책 2: 문자열 참조 사용

Django ORM에서는 모델 관계를 문자열로 참조할 수 있습니다.

 

models/post.py

from django.db import models

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    # 문자열로 참조 (앱이름.모델명 형식)
    author = models.ForeignKey('myapp.User', on_delete=models.CASCADE)
    
    def get_author_info(self):
        return f"{self.author.name} ({self.author.email})"

 

같은 앱 내에서는 모델명만 사용 가능:

author = models.ForeignKey('User', on_delete=models.CASCADE)

 

💡 해결책 3: 모델 구조 재설계

가장 근본적인 해결책은 모델 관계를 명확히 정의하는 것입니다.

 

models.py (단일 파일)

from django.db import models

class User(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(User, on_delete=models.CASCADE)

# 비즈니스 로직은 별도 서비스 파일로 분리

services/user_service.py

from ..models import User, Post

class UserService:
    @staticmethod
    def get_recent_posts(user_id, limit=5):
        return Post.objects.filter(author_id=user_id)[:limit]
    
    @staticmethod
    def get_user_stats(user_id):
        user = User.objects.get(id=user_id)
        post_count = Post.objects.filter(author=user).count()
        return {
            'user': user,
            'post_count': post_count
        }

💡 해결책 4: Django의 get_model() 활용

Django가 제공하는 get_model() 함수를 사용하는 방법입니다.

from django.apps import apps

class User(models.Model):
    name = models.CharField(max_length=100)
    
    def get_recent_posts(self):
        Post = apps.get_model('myapp', 'Post')
        return Post.objects.filter(author=self)[:5]

 

🚨 실제 프로젝트에서 자주 보는 복잡한 케이스

케이스: Signals에서 발생하는 순환 참조

models/user.py

from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from .notification import create_welcome_notification

class User(models.Model):
    name = models.CharField(max_length=100)

@receiver(post_save, sender=User)
def user_created(sender, instance, created, **kwargs):
    if created:
        create_welcome_notification(instance)  # 순환 참조 위험!

 

해결책: signals.py 분리

# signals.py
from django.db.models.signals import post_save
from django.dispatch import receiver

@receiver(post_save, sender='myapp.User')  # 문자열 참조 사용
def user_created(sender, instance, created, **kwargs):
    if created:
        from .services import NotificationService
        NotificationService.create_welcome_notification(instance)

 

⚡ 프로 팁: 순환 참조를 예방하는 프로젝트 구조

myproject/
├── models/
│   ├── __init__.py
│   ├── base.py        # 공통 Abstract 모델
│   ├── user.py        # User 관련 모델만
│   └── content.py     # Post, Comment 등
├── services/          # 비즈니스 로직 분리
│   ├── user_service.py
│   └── content_service.py
├── utils/            # 순수 유틸리티 함수
└── views/           # 뷰 로직만

 

핵심 원칙:

  1. 단일 책임: 각 파일은 하나의 관심사만 담당
  2. 의존성 방향: 항상 한 방향으로만 의존
  3. 레이어 분리: 모델 → 서비스 → 뷰 순서로 의존

 

🛠️ 디버깅 팁: 순환 참조 빠르게 찾기

 

Python의 importlib 모듈로 순환 참조를 확인할 수 있습니다:

import sys
import importlib

def check_circular_import(module_name):
    try:
        importlib.import_module(module_name)
        print(f"✅ {module_name} 정상 import")
    except ImportError as e:
        print(f"❌ {module_name} import 실패: {e}")
        # sys.modules를 확인해서 부분적으로 로드된 모듈 찾기
        for name, module in sys.modules.items():
            if hasattr(module, '__file__') and module.__file__ is None:
                print(f"🔍 부분 로드된 모듈: {name}")

 

🎯 결론: 순환 참조는 설계의 문제

순환 참조는 대부분 잘못된 아키텍처 설계에서 발생합니다.

  • 모델은 데이터 구조만 정의
  • 비즈니스 로직은 서비스 레이어로 분리
  • 문자열 참조나 지연 import 적극 활용
  • 의존성 방향을 명확히 설계

💬 여전히 해결이 안 되시나요?

복잡한 프로젝트일수록 순환 참조 문제는 더욱 골치 아픕니다. 특히 레거시 코드에서는 더욱 그렇죠.

27년 경력의 시니어 개발자가 여러분의 Django 프로젝트를 직접 진단하고 해결해드립니다.

  • 🔍 코드 구조 분석 및 문제점 파악
  • ⚡ 긴급 버그 수정 (24시간 내)
  • 🏗️ 아키텍처 리팩토링 제안
  • 📚 팀원 교육 및 가이드 제공

➡️ 크몽에서 전문가 도움받기

"혼자 고민하지 마세요. 문제를 빠르게 해결하고 다음 스텝으로 나아가세요!"