발행일

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_newsRSS 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_newssend_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_newslettersource_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가지 규칙

네 명령을 만들면서 암묵적으로 지킨 규칙이 있습니다.

  1. self.stdout.write만 사용print()는 쓰지 않음. Django의 stdout wrapping을 활용해야 call_command()로 호출했을 때도 출력이 캡처됩니다.
  2. 성공/실패 요약을 마지막 줄에self.style.SUCCESS()로 강조. 크론 로그를 빠르게 스캔할 수 있어야 합니다.
  3. 사이드 이펙트가 있는 명령은 --dry-run — 이메일 발송, 대량 업데이트, 삭제는 무조건 dry-run 지원.
  4. 반복 실행에 안전하게 — 중복 체크(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.pypython manage.py crawl_news).
  • 언더스코어는 명령어에서도 그대로 쓰입니다(backfill_urls).

10. 다시 보니 — 8절의 규칙을 스스로 두 번 어겼다

8절에 규칙 네 개를 적어놓고, 정작 같은 글의 코드가 그중 둘을 지키지 않습니다.

① "사이드 이펙트가 있으면 무조건 dry-run" — backfill_urls에는 없다

  1. 사이드 이펙트가 있는 명령은 --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=TrueSMTP 오류를 삼킵니다. 절반이 실패해도 예외가 안 나고, 크론 로그에는 성공 메시지만 남아요. 되돌릴 수 없는 작업이라 조심한다고 해놓고, 실패했다는 사실 자체를 안 보는 설정을 켠 셈입니다.

주간 발송이라 실패하면 다음 주까지 아무도 모릅니다. 예외를 받아서 로그에 남기고, 실패 시 종료 코드를 0이 아닌 값으로 돌려주는 게 맞았어요. 크론이 실패를 알아채려면 그게 유일한 신호니까요.


11. 그 밖에 걸리는 것

① 두 크론의 순서가 보장되지 않습니다.

crawl_newssend_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 토큰 기반 구독/해지 시스템을 어떻게 설계했는지 다뤄보겠습니다.