- 발행일
Django management command 4종 설계기 — 크롤러, 뉴스레터, 시드, 백필
Django management command 4종 설계기 — 크롤러, 뉴스레터, 시드, 백필
PyNews 프로젝트에는 Django management command가 4개 있습니다.
news/management/commands/
├── crawl_news.py # 주간 RSS 크롤러
├── send_newsletter.py # 구독자에게 이메일 발송
├── seed_editorial.py # 샘플 편집 콘텐츠 시드
└── backfill_urls.py # 과거 데이터의 source_url 복구
"그냥 scripts/ 디렉터리에 Python 파일 두면 되지 않나?"라고 생각하기 쉽지만, Django 프로젝트에서 ORM과 설정을 쓰는 일회성/반복 작업은 management command로 만드는 것이 훨씬 낫습니다. 이 글에서는 4개를 만들며 정리한 기준과 패턴을 공유합니다.
1. 왜 management command인가
일반 Python 스크립트로 만들면 매번 이 두 줄이 필요합니다.
import django
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
django.setup()
그리고 venv 활성화, 환경변수 로드, 작업 디렉터리 이동 같은 boilerplate가 스크립트마다 반복됩니다. management command는 이 모든 것을 python manage.py 한 명령 안에 감춥니다.
덤으로 얻는 것:
argparse가 기본 내장 —add_arguments()로 옵션 정의.self.stdout.write()/self.style.SUCCESS()로 통일된 로그 스타일.- 크론, Render Cron Job, CI 등 실행 주체가 달라져도 명령어 형식이 동일.
- Django 테스트 러너에서
call_command()로 호출 가능.
2. 네 명령의 역할과 실행 주기
| 명령 | 역할 | 실행 주기 |
|---|---|---|
crawl_news | RSS 11개 소스에서 글 수집 | 매주 월요일 (cron) |
send_newsletter | 최근 N일 글을 구독자에게 메일 발송 | 매주 월요일 (cron) |
seed_editorial | 초기 편집 콘텐츠 시드 | 배포 시 1회 (수동) |
backfill_urls | 과거 Post의 source_url 복구 | 1회성 데이터 보정 (수동) |
실행 주기가 다르다는 점이 중요합니다. **반복 실행되는 명령(crawl_news, send_newsletter)**과 **일회성 명령(seed_editorial, backfill_urls)**을 같은 위치에 두지만, 안에 들어가는 방어 로직은 다릅니다.
3. 반복 실행용 — --days, --dry-run 옵션 패턴
crawl_news와 send_newsletter는 둘 다 같은 옵션 조합을 씁니다.
def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=7,
help="최근 N일간의 글 대상 (기본: 7)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="실제 수행 없이 결과만 확인",
)
--days
크론이 매주 돌기 때문에 기본값 7로 두면 한 주치 데이터가 처리됩니다. 장애로 한 주를 놓쳤을 때는 --days 14로 호출하면 되고, 초기 데이터 백필 시에는 --days 90 같은 식으로 대응합니다.
--dry-run
특히 send_newsletter에서 중요합니다. 이메일 발송은 되돌릴 수 없기 때문에 먼저 dry-run으로 제목/본문/대상자 수를 찍어보는 습관이 생겼습니다.
def handle(self, *args, **options):
dry_run = options["dry_run"]
subject = f"[PyNews] {date.today().strftime('%Y-%m-%d')} 주간 파이썬 백엔드 소식"
body = self._build_body(posts)
subscribers = Subscriber.objects.filter(is_active=True)
if dry_run:
self.stdout.write(f"제목: {subject}")
self.stdout.write(f"대상: {subscribers.count()}명")
self.stdout.write(body[:500])
return
messages = []
for sub in subscribers:
unsub_note = f"\n\n---\n구독 해지: 토큰 {sub.token}"
messages.append((subject, body + unsub_note, None, [sub.email]))
sent = send_mass_mail(messages, fail_silently=True)
self.stdout.write(self.style.SUCCESS(f"{sent}명에게 발송 완료"))
4. send_newsletter — 왜 send_mass_mail인가
뉴스레터는 구독자 수가 늘면 SMTP 연결 비용이 무시 못 할 수준이 됩니다. send_mail을 루프 안에서 호출하면 구독자 수만큼 연결을 열고 닫게 됩니다.
Django는 이 문제를 위해 send_mass_mail을 제공합니다. 한 번의 SMTP 연결로 여러 메일을 보냅니다.
# ❌ 구독자 수만큼 SMTP 연결
for sub in subscribers:
send_mail(subject, body, None, [sub.email])
# ✅ 한 번의 연결로 전부 발송
messages = [(subject, body, None, [sub.email]) for sub in subscribers]
send_mass_mail(messages, fail_silently=True)
단, send_mass_mail은 메시지마다 본문을 다르게 구성하는 것이 필수입니다. 여기서는 구독 해지: 토큰 {sub.token} 한 줄을 각자 다르게 붙여서 해지 링크를 개인화합니다.
5. send_newsletter — source_type으로 섹션 분리
뉴스레터 본문을 편집 콘텐츠(editorial) 와 크롤링 콘텐츠(crawled) 섹션으로 나눠 구성했습니다.
def _build_body(self, posts):
editorial = [p for p in posts if p.source_type == "editorial"]
crawled = [p for p in posts if p.source_type == "crawled"]
sections = []
if editorial:
sections.append("## 📝 이번 주 편집 픽\n")
sections.extend(f"- [{p.title}](...) — {p.summary}" for p in editorial)
if crawled:
sections.append("\n## 🌐 커뮤니티 소식\n")
sections.extend(f"- [{p.title}](...) — {p.source_url}" for p in crawled)
return "\n".join(sections)
같은 Post 테이블 안에서 source_type 필드 하나로 분기하는 구조인데, 뉴스레터 본문 구성에 정확히 맞는 설계였습니다. 이 모델 분리 전략은 6편에서 자세히 다룹니다.
6. 일회성 명령 — get_or_create로 멱등성 확보
seed_editorial은 배포 직후 샘플 글 5개를 DB에 넣는 용도입니다. 문제는 "실수로 두 번 실행하면 샘플 글이 2배로 생긴다"는 점입니다.
해결책은 단순합니다 — get_or_create로 멱등(idempotent)하게 만듭니다.
SAMPLES = [
{
"slug": "django-6-release-notes",
"title": "Django 6.0의 새로운 기능 총정리",
"summary": "비동기 ORM, 폼 렌더링 개편, 성능 개선 ...",
"is_featured": True,
"published_at": date(2026, 3, 1),
},
# ...
]
def handle(self, *args, **options):
created_count = 0
for data in SAMPLES:
post, created = Post.objects.get_or_create(
slug=data["slug"],
defaults=data,
)
if created:
created_count += 1
self.stdout.write(f"✓ {post.title}")
self.stdout.write(self.style.SUCCESS(f"{created_count}개 생성됨"))
slug를 키로 잡고 나머지 필드는defaults에 둡니다.- 이미 존재하면 아무 것도 하지 않고 넘어갑니다. 재실행이 안전합니다.
- 수정이 필요하면
update_or_create를 쓸지 정책적으로 결정합니다.seed_editorial은 초기 시드용이라 그냥get_or_create로 충분합니다.
7. 데이터 보정용 — backfill_urls
과거에 source_url 없이 저장된 크롤링 글들을 RSS에서 제목으로 역매칭해서 복구하는 명령입니다.
def handle(self, *args, **options):
# 1. 문제 있는 레코드 찾기
broken = Post.objects.filter(source_type="crawled", source_url="")
self.stdout.write(f"복구 대상: {broken.count()}개")
# 2. 모든 RSS 피드에서 title → url 맵 구성
title_to_url = {}
for feed_url in RSS_FEEDS:
feed = feedparser.parse(feed_url)
for entry in feed.entries:
title = entry.get("title", "").strip()
link = entry.get("link", "")
if title and link:
title_to_url[title] = link
# 3. 제목 매칭되는 것만 업데이트
fixed = 0
for post in broken:
if post.title in title_to_url:
post.source_url = title_to_url[post.title]
post.save(update_fields=["source_url"])
fixed += 1
self.stdout.write(f"✓ {post.title}")
self.stdout.write(self.style.SUCCESS(f"{fixed}/{broken.count()}개 복구"))
설계 포인트:
save(update_fields=[...]): 복구할 필드만 업데이트해서 다른 컬럼의auto_now같은 사이드 이펙트를 피합니다.- 제목 완전일치로만 매칭: 부분 일치는 오매칭 위험이 커서, 못 찾은 글은 그냥 남겨두고 수동 처리에 맡깁니다.
- 한 번 돌고 나면 쓸 일이 없는 명령이지만, 지우지 않고 코드에 남겨둡니다. 유사한 케이스가 다시 생기면 복사해서 변형하기 좋습니다.
8. 공통적으로 지킨 4가지 규칙
네 명령을 만들면서 암묵적으로 지킨 규칙이 있습니다.
self.stdout.write만 사용 —print()는 쓰지 않음. Django의 stdout wrapping을 활용해야call_command()로 호출했을 때도 출력이 캡처됩니다.- 성공/실패 요약을 마지막 줄에 —
self.style.SUCCESS()로 강조. 크론 로그를 빠르게 스캔할 수 있어야 합니다. - 사이드 이펙트가 있는 명령은
--dry-run— 이메일 발송, 대량 업데이트, 삭제는 무조건 dry-run 지원. - 반복 실행에 안전하게 — 중복 체크(
get_or_create,filter().exists()) 필수.
9. 디렉터리 구조 복기
Django가 management command를 인식하려면 특정 디렉터리 구조가 필요합니다.
news/
├── management/
│ ├── __init__.py
│ └── commands/
│ ├── __init__.py
│ ├── crawl_news.py
│ ├── send_newsletter.py
│ ├── seed_editorial.py
│ └── backfill_urls.py
management/와commands/두 디렉터리 모두__init__.py필수.- 파일명이 그대로 명령어가 됩니다(
crawl_news.py→python manage.py crawl_news). - 언더스코어는 명령어에서도 그대로 쓰입니다(
backfill_urls).
10. 다시 보니 — 8절의 규칙을 스스로 두 번 어겼다
8절에 규칙 네 개를 적어놓고, 정작 같은 글의 코드가 그중 둘을 지키지 않습니다.
① "사이드 이펙트가 있으면 무조건 dry-run" — backfill_urls에는 없다
- 사이드 이펙트가 있는 명령은
--dry-run— 이메일 발송, 대량 업데이트, 삭제는 무조건 dry-run 지원.
backfill_urls는 정확히 대량 업데이트입니다. 그런데 7절 코드에는 add_arguments도 --dry-run 분기도 없어요. 바로 돌면 바로 씁니다.
게다가 이 명령이 제일 위험합니다. 제목 완전일치로 URL을 매칭하는데, 매칭이 틀리면 "엉뚱한 링크가 붙은 글" 이 생기고 그건 화면에서 티가 안 나요. 데이터를 고치는 명령일수록 먼저 보여주고 나중에 쓰는 게 필요했습니다.
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true")
def handle(self, *args, **options):
...
for post in broken:
if post.title in title_to_url:
if options["dry_run"]:
self.stdout.write(f"[dry-run] {post.title} → {title_to_url[post.title]}")
continue
...
② fail_silently=True가 "되돌릴 수 없다"는 전제와 충돌한다
4절에서 이메일이 되돌릴 수 없기 때문에 dry-run을 만들었다고 했는데, 실제 발송은 이렇습니다.
sent = send_mass_mail(messages, fail_silently=True)
self.stdout.write(self.style.SUCCESS(f"{sent}명에게 발송 완료"))
fail_silently=True는 SMTP 오류를 삼킵니다. 절반이 실패해도 예외가 안 나고, 크론 로그에는 성공 메시지만 남아요. 되돌릴 수 없는 작업이라 조심한다고 해놓고, 실패했다는 사실 자체를 안 보는 설정을 켠 셈입니다.
주간 발송이라 실패하면 다음 주까지 아무도 모릅니다. 예외를 받아서 로그에 남기고, 실패 시 종료 코드를 0이 아닌 값으로 돌려주는 게 맞았어요. 크론이 실패를 알아채려면 그게 유일한 신호니까요.
11. 그 밖에 걸리는 것
① 두 크론의 순서가 보장되지 않습니다.
crawl_news와 send_newsletter가 둘 다 매주 월요일입니다. 뉴스레터가 크롤러보다 먼저 돌면 이번 주에 수집된 글이 빠진 채로 발송돼요. 시간 차를 두거나, 한 명령이 다른 명령을 호출하거나, 최소한 크롤링 완료를 확인하는 가드가 필요합니다.
② 제목이 겹치면 마지막 것만 남습니다.
title_to_url[title] = link # 같은 제목이 여러 피드에 있으면 덮어씀
RSS 11개를 순회하며 딕셔너리에 넣는데, 같은 제목의 글이 두 피드에 있으면 나중 것이 이깁니다. "완전일치라 안전하다"고 적었지만 완전일치가 유일성을 보장하진 않아요. 후보가 둘 이상이면 건너뛰고 로그에 남기는 편이 안전합니다.
③ 백필이 행마다 UPDATE를 날립니다.
post.save(update_fields=[...])를 루프 안에서 부르니 복구 대상 수만큼 쿼리가 나갑니다. update_fields로 컬럼을 좁힌 건 좋은데, 건수가 많으면 bulk_update가 맞아요. 지금 규모에서는 문제없지만 "한 번 돌고 나면 쓸 일 없는 명령" 이라도 대상이 수천 건이면 체감됩니다.
12. 이 패턴을 다른 프로젝트에 옮기며 알게 된 것
이후 다른 프로젝트에서도 같은 방식으로 명령을 만들었는데, 두 가지가 더 필요했습니다.
- 되돌리는 명령을 짝으로 만들기. 워크플로우에 상태를 주입하는 backfill 명령을 만들었다가 기능을 철회한 적이 있는데, 코드는 지웠지만 DB에 쓴 데이터는 남았습니다. 그 경험은 RVS 도입·철회 회고에 적었어요.
- 적재 순서를 명령 안에 담기. 서로 의존하는 시드가 여러 개면 각각 돌리는 대신 순서를 아는 상위 스크립트를 두는 게 안전했습니다.
그리고 시드 스크립트가 일회성이 아니게 되는 경우도 있었습니다. 목업 데이터를 만들려고 짠 변환기가 나중에 실제 적재 파이프라인의 원형이 됐거든요. scripts/ 아래 단독 파일로 뒀던 게 결국 management command로 다시 태어났습니다. 그 과정은 엑셀 시드 자동화 기록에 있습니다.
정리
Django 프로젝트에서 ORM과 설정을 쓰는 반복/일회성 작업은 management command로 만들자가 이 프로젝트의 주된 교훈이었습니다. 반복 실행용에는 --days + --dry-run 패턴을, 일회성에는 get_or_create 기반 멱등성을 원칙으로 잡으면 유지보수가 훨씬 편해집니다.
다만 규칙을 적어두는 것과 지키는 건 다른 일이었어요. --dry-run을 원칙으로 정해놓고 정작 제일 위험한 명령에 빠뜨렸고, 되돌릴 수 없다며 조심하던 발송에서는 fail_silently로 실패를 감췄습니다. 규칙은 체크리스트로 만들어 커밋 전에 명령별로 대조하는 편이 낫겠더군요.
다음 글에서는 이 명령들이 활용하는 UUID 토큰 기반 구독/해지 시스템을 어떻게 설계했는지 다뤄보겠습니다.