🔄 Django Signal 무한 루프: post_save 시그널에서 같은 모델을 다시 저장할 때의 함정
실제 사례: 갑자기 느려진 서버
어느 날 출근하니 서버 모니터링 알람이 울리고 있었습니다. CPU 사용률이 100%를 찍고, 데이터베이스 연결이 모두 소진되었습니다. 문제의 원인을 추적해보니 전날 배포한 "사용자 프로필 자동 업데이트" 기능이었습니다.
개발자는 단순히 사용자가 로그인할 때마다 last_login 시간을 기록하고, 동시에 login_count를 1씩 증가시키려고 post_save 시그널을 사용했습니다. 그런데 이 코드가 서버를 다운시킨 원인이었습니다.
문제 상황: Signal 안에서 save()를 호출하면?
Django Signal은 특정 이벤트가 발생했을 때 자동으로 실행되는 콜백 함수입니다. 그중 post_save 시그널은 모델 인스턴스가 저장된 직후에 실행됩니다.
문제는 이 시그널 핸들러 안에서 같은 모델의 save() 메서드를 다시 호출하면 어떻게 될까요?
간단한 예시:
from django.db.models.signals import post_save
from django.dispatch import receiver
from .models import UserProfile
@receiver(post_save, sender=UserProfile)
def update_login_count(sender, instance, created, **kwargs):
instance.login_count += 1
instance.save() # 여기서 다시 save() 호출!
위 코드는 겉보기엔 문제없어 보입니다. 하지만 실행하면 무한 루프에 빠집니다.
왜 무한 루프가 발생할까?
이를 "거울 앞의 거울" 에 비유할 수 있습니다.
두 개의 거울을 마주 보게 놓으면 반사가 끝없이 반복되어 무한한 이미지가 생깁니다. Signal 무한 루프도 같은 원리입니다:
- 사용자가 UserProfile을 저장 → save() 호출
- 저장 완료 후 post_save 시그널 발동 → update_login_count 실행
- 시그널 핸들러가 instance.save() 호출 → 다시 저장
- 저장 완료 후 post_save 시그널 발동 → update_login_count 실행
- 시그널 핸들러가 instance.save() 호출 → 다시 저장
- ... 무한 반복
Django의 시그널 시스템은 "저장이 끝났다"는 사실만 감지할 뿐, "이게 시그널 때문인지 아닌지"는 구분하지 못합니다. 따라서 시그널 안에서 save()를 호출하면 그것도 새로운 저장 이벤트로 인식되어 시그널을 다시 발동시킵니다.
무한 루프의 증상과 영향
1. 서버 리소스 고갈
무한 루프는 순식간에 수천, 수만 번의 데이터베이스 쿼리를 발생시킵니다. CPU와 메모리가 폭증하고, 데이터베이스 커넥션 풀이 소진되면서 다른 정상적인 요청까지 처리할 수 없게 됩니다.
2. 응답 시간 지연
단순히 사용자 정보를 수정하는 요청이 몇 초, 심하면 몇 분씩 걸립니다. 사용자는 페이지가 로딩되지 않아 답답함을 느끼고, 브라우저를 새로고침하거나 버튼을 여러 번 클릭하면서 상황을 더 악화시킵니다.
3. 데이터 무결성 문제
무한 루프 중에는 예상치 못한 값이 저장될 수 있습니다. 예를 들어 login_count가 1 증가해야 하는데 수백 번 증가하거나, 타임스탬프가 의도하지 않은 값으로 덮어씌워질 수 있습니다.
4. 로그 폭증
시그널이 실행될 때마다 로그가 기록되면 로그 파일이 기가바이트 단위로 순식간에 커집니다. 디스크 공간이 부족해지고, 로그 분석도 불가능해집니다.
해결 방법들
방법 1: update() 메서드 사용 (가장 권장)
save() 메서드 대신 update() 메서드를 사용하면 시그널을 발동시키지 않습니다.
@receiver(post_save, sender=UserProfile)
def update_login_count(sender, instance, created, **kwargs):
UserProfile.objects.filter(pk=instance.pk).update(
login_count=F('login_count') + 1
)
개념: update()는 데이터베이스 수준에서 직접 값을 변경하며, Python 객체의 save() 메서드를 거치지 않습니다. 따라서 post_save 시그널이 발동하지 않아 무한 루프를 피할 수 있습니다.
장점:
- 가장 간단하고 확실한 해결책
- 데이터베이스 쿼리 1회만 발생하여 성능이 좋음
- 코드가 명확하고 이해하기 쉬움
단점:
- instance 객체의 메모리 상 값은 업데이트되지 않음 (DB만 변경됨)
- 다른 시그널(예: pre_save)도 발동하지 않음
방법 2: 조건 플래그 사용
시그널 핸들러가 이미 실행 중인지 추적하는 플래그를 사용합니다.
@receiver(post_save, sender=UserProfile)
def update_login_count(sender, instance, created, **kwargs):
if hasattr(instance, '_signal_updating'):
return
instance._signal_updating = True
instance.login_count += 1
instance.save()
delattr(instance, '_signal_updating')
개념: 객체에 임시 속성을 추가하여 "지금 시그널 처리 중"이라는 표시를 합니다. 시그널이 재귀적으로 호출되어도 플래그를 확인하고 조기 종료하여 무한 루프를 방지합니다.
장점:
- save() 메서드를 사용하므로 다른 시그널들도 정상 작동
- 객체의 메모리 상 값도 함께 업데이트됨
단점:
- 멀티스레드 환경에서 동시성 문제 발생 가능
- 코드가 복잡해지고 유지보수가 어려움
- 예외 발생 시 플래그가 제거되지 않을 수 있음
방법 3: disconnect/connect 패턴
시그널을 일시적으로 해제하고, 작업 후 다시 연결합니다.
@receiver(post_save, sender=UserProfile)
def update_login_count(sender, instance, created, **kwargs):
post_save.disconnect(update_login_count, sender=UserProfile)
instance.login_count += 1
instance.save()
post_save.connect(update_login_count, sender=UserProfile)
개념: 시그널 연결을 임시로 끊어서 save() 호출 시 시그널이 발동하지 않도록 합니다. 작업 완료 후 다시 연결하여 다음 저장부터는 정상 작동하게 합니다.
장점:
- 개념적으로 명확함
- 특정 구간에서만 시그널을 비활성화할 수 있음
단점:
- 멀티스레드 환경에서 매우 위험 (다른 스레드의 시그널도 끊김)
- 예외 발생 시 시그널 재연결이 안 될 수 있음
- 코드가 복잡하고 오류 가능성이 높음
방법 4: update_fields 파라미터 활용
save() 메서드에 update_fields를 지정하면 해당 필드만 업데이트됩니다.
@receiver(post_save, sender=UserProfile)
def update_login_count(sender, instance, created, **kwargs):
if kwargs.get('update_fields') is not None:
return # 이미 특정 필드만 업데이트 중이면 스킵
instance.login_count += 1
instance.save(update_fields=['login_count'])
개념: 시그널 핸들러에서 특정 필드만 업데이트할 때 update_fields를 명시합니다. 그리고 핸들러 시작 부분에서 이 파라미터가 있으면 (즉, 다른 코드가 이미 부분 업데이트 중이면) 추가 처리를 하지 않습니다.
장점:
- 성능이 좋음 (필요한 필드만 업데이트)
- 비교적 안전한 패턴
단점:
- 다른 곳에서 update_fields 없이 save()를 호출하면 여전히 무한 루프 가능
- 로직이 복잡해질 수 있음
실무에서의 베스트 프랙티스
1. 가능하면 시그널을 피하세요
시그널은 강력하지만 "숨겨진 동작"을 만듭니다. 코드를 읽는 사람이 save()를 호출하면 시그널이 실행된다는 사실을 모를 수 있습니다.
대안:
- 모델의 save() 메서드를 오버라이드
- 커스텀 Manager나 QuerySet 메서드 사용
- 명시적인 서비스 레이어 함수 작성
2. 시그널에서는 update()를 사용하세요
꼭 시그널을 써야 한다면, 같은 모델을 수정할 때는 update() 메서드를 사용하는 것이 가장 안전합니다.
3. 시그널의 역할을 명확히 하세요
시그널은 다음과 같은 경우에 적합합니다:
- 다른 모델에 영향을 주는 경우 (예: User 저장 시 Profile 생성)
- 캐시 무효화, 알림 전송 등의 부가 작업
- 로깅이나 감사 추적
같은 모델의 필드를 계산하거나 정규화하는 작업은 save() 메서드 오버라이드가 더 적합합니다.
4. 테스트를 철저히 하세요
시그널은 숨겨진 동작이기 때문에 예상치 못한 부작용이 생기기 쉽습니다:
- 시그널이 몇 번 호출되는지 테스트
- 데이터베이스 쿼리 수를 모니터링
- 성능 테스트로 무한 루프 조기 발견
5. 타임아웃 설정
만약 무한 루프가 발생하더라도 서버 전체가 다운되지 않도록:
- 웹 서버에 요청 타임아웃 설정 (예: 30초)
- Celery 등 비동기 작업에도 타임아웃 적용
- 모니터링으로 이상 징후 조기 감지
실제 디버깅 팁
무한 루프를 의심할 때 확인할 사항들:
1. 로그에 같은 내용이 반복되는가?
Django의 DEBUG 로거를 활성화하면 SQL 쿼리를 볼 수 있습니다. 같은 UPDATE 쿼리가 수백 번 반복되면 무한 루프입니다.
2. 스택 트레이스를 확인하세요
Python의 재귀 제한(기본 1000)에 도달하면 RecursionError가 발생합니다. 스택 트레이스에서 같은 함수가 반복되는지 확인하세요.
3. 데이터베이스 쿼리 수를 측정하세요
Django Debug Toolbar나 django.test.utils.override_settings의 쿼리 카운트를 사용하면 의도하지 않은 쿼리 폭증을 발견할 수 있습니다.
4. 시그널을 일시적으로 비활성화해보세요
문제가 시그널 때문인지 확인하려면 @receiver 데코레이터를 주석 처리하고 테스트해보세요.
마무리
Django Signal의 무한 루프는 초보 개발자뿐만 아니라 경력 있는 개발자도 자주 겪는 함정입니다. 특히 코드가 간단해 보여서 더 방심하기 쉽습니다.
핵심은 "시그널 안에서 save()를 호출하면 그것도 저장 이벤트다" 라는 점을 기억하는 것입니다.
가장 안전한 해결책은 update() 메서드를 사용하는 것이며, 더 나아가 시그널 자체를 신중하게 사용하는 것입니다. 시그널은 강력하지만 숨겨진 복잡성을 만들므로, 명시적이고 추적 가능한 코드 구조를 우선 고려하세요.
여러분의 프로젝트에서 Signal 무한 루프를 경험한 적이 있나요? 어떻게 해결하셨는지 댓글로 공유해주시면 다른 개발자들에게도 큰 도움이 될 것입니다!
Django 시그널 문제로 고생하고 계신가요?
Signal 무한 루프, 성능 저하, 예상치 못한 동작 등 Django 개발 과정에서 막히는 부분이 있으신가요?
8년 이상의 Django 실무 경험을 바탕으로 여러분의 문제를 빠르고 정확하게 해결해드립니다.
이런 분들께 도움을 드립니다:
- Signal 관련 버그를 해결하지 못해 막막하신 분
- 프로덕션 환경에서 갑자기 발생한 성능 문제를 급하게 해결해야 하는 분
- Django 베스트 프랙티스를 적용하고 싶으신 분
- 코드 리뷰와 아키텍처 개선이 필요하신 분
제공 서비스:
✅ Django 버그 분석 및 해결
✅ 성능 최적화 및 병목 지점 개선
✅ 코드 리뷰 및 리팩토링 가이드
✅ Django 베스트 프랙티스 컨설팅
✅ 1:1 화상 멘토링
프로젝트의 문제를 함께 해결해드리겠습니다. 부담 없이 문의주세요!