- 발행일
Editorial vs Crawled — 같은 Post 모델에서 출처 분리하기
Editorial vs Crawled — 같은 Post 모델에서 출처 분리하기
뉴스레터 플랫폼 PyNews에는 두 종류의 글이 있습니다.
- editorial: 편집자가 직접 작성한 큐레이션/에디토리얼 콘텐츠.
- crawled: 11개 RSS 소스에서 자동 수집한 외부 글.
처음 설계할 때 가장 고민했던 건 "테이블을 나눌 것인가, 한 테이블로 합칠 것인가" 였습니다. 결론은 한 테이블에 source_type 필드로 분기하는 방향. 이 글에서는 왜 그렇게 갔고, 그 결정이 모델/어드민/쿼리에 어떤 영향을 줬는지 정리합니다.
1. 왜 테이블을 나누지 않았나
"editorial과 crawled는 성격이 다르니까 EditorialPost, CrawledPost로 나누면 깔끔하지 않나?"라고 처음엔 생각했습니다. 하지만 실제로 같이 다루는 장면이 대부분이었습니다.
- 목록 페이지: 메인에 두 종류를 섞어서 최신순으로 보여줍니다.
- 뉴스레터 본문: 주간 메일에 editorial + crawled를 함께 묶어 보냅니다.
- 태그 필터링: "Django" 태그로 검색하면 출처 관계없이 나와야 합니다.
- 프론트 상세 페이지: 같은
/posts/:slug라우트로 두 종류 다 렌더링.
두 테이블로 나누면 매번 UNION 쿼리를 쓰거나, ORM 레벨에서 두 쿼리셋을 합쳐야 합니다. 공통 필드(title, slug, tags, published_at)가 대부분인데 테이블을 나누는 건 과잉이라고 판단했습니다.
2. SourceType으로 출처 구분
Django 3.0+의 TextChoices로 출처를 enum처럼 정의했습니다.
class Post(models.Model):
class SourceType(models.TextChoices):
EDITORIAL = "editorial", "직접 작성"
CRAWLED = "crawled", "크롤링"
title = models.CharField(max_length=200)
slug = models.SlugField(max_length=200, unique=True)
summary = models.TextField(help_text="글 요약 (목록에 표시)")
content = models.TextField(help_text="본문 (Markdown 지원)")
cover_image = models.ImageField(upload_to="covers/", blank=True, null=True)
tags = models.ManyToManyField(Tag, blank=True, related_name="posts")
author = models.CharField(max_length=100, default="PyNews 팀")
source_type = models.CharField(
max_length=10,
choices=SourceType.choices,
default=SourceType.EDITORIAL,
)
source_url = models.URLField(blank=True, help_text="원본 링크 (크롤링 글)")
published_at = models.DateField()
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_featured = models.BooleanField(default=False)
is_curated = models.BooleanField(
default=False,
help_text="큐레이션 승인 여부 (True인 글만 프론트에 노출)",
)
class Meta:
ordering = ["-published_at"]
포인트는 네 가지입니다.
(1) source_type의 기본값은 EDITORIAL
어드민에서 편집자가 새 글을 쓸 때 출처 선택을 깜빡해도 editorial로 저장됩니다. 크롤러는 항상 명시적으로 CRAWLED를 지정하므로 기본값이 editorial인 편이 실수 방지에 유리합니다.
(2) source_url은 blank=True
crawled 글에만 필요한 필드입니다. editorial 글에는 외부 링크가 없으므로 비워두어도 되도록 blank=True로 설정합니다. DB 레벨의 NOT NULL 제약을 풀어두는 것도 같은 의도입니다.
(3) is_curated — 프론트 노출 제어
두 출처 모두에 공통으로 적용되는 게이트입니다. 크롤러가 수집한 글을 일단 DB에는 넣되, 편집자의 승인(is_curated=True) 뒤에만 프론트에 노출합니다. 자동 수집의 품질 리스크를 이 플래그 하나로 막습니다.
(4) is_featured — 피처드 슬롯
"메인 상단 1~2개"처럼 특별히 강조할 글을 표시하는 독립 플래그. editorial/crawled 상관없이 선별 가능합니다.
3. 어드민 — 한 페이지에서 두 종류를 관리
한 테이블로 두면 Django 어드민을 그대로 쓸 수 있다는 장점이 큽니다.
@admin.register(Post)
class PostAdmin(admin.ModelAdmin):
list_display = (
"title", "source_type", "author",
"published_at", "is_featured", "is_curated",
)
list_filter = (
"source_type", "is_curated", "tags",
"is_featured", "published_at",
)
list_editable = ("is_curated",)
search_fields = ("title", "summary", "content")
prepopulated_fields = {"slug": ("title",)}
filter_horizontal = ("tags",)
def get_changeform_initial_data(self, request):
return {"source_type": "editorial", "author": "PyNews 팀"}
이 어드민 설정의 주요 포인트:
list_filter에source_type과is_curated: 편집자가 "아직 승인 안 된 크롤링 글"을 빠르게 필터링할 수 있습니다.list_editable = ("is_curated",): 목록 페이지에서 체크박스로 일괄 승인 가능. 매주 수십 개의 크롤링 글을 한 번에 보고 is_curated만 토글하는 게 일반적인 워크플로우입니다.get_changeform_initial_data: 새 글 작성 시 editorial + PyNews 팀을 기본으로. 편집자는 거의 이 조합으로 글을 쓰기 때문에 디폴트가 맞습니다.
4. 쿼리 패턴 — 섹션별로 분기
쿼리 레벨에서 두 종류를 분리하고 싶을 때는 source_type으로 필터링합니다.
# 메인 — 편집 픽만
editorial = Post.objects.filter(
source_type=Post.SourceType.EDITORIAL,
is_curated=True,
).order_by("-published_at")[:5]
# 메인 — 커뮤니티 소식만
community = Post.objects.filter(
source_type=Post.SourceType.CRAWLED,
is_curated=True,
).order_by("-published_at")[:10]
# 통합 피드 — 구분 없이
all_posts = Post.objects.filter(
is_curated=True,
).order_by("-published_at")
뉴스레터 본문 조립도 같은 패턴입니다.
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"]
# ...
DB 한 번 조회 후 파이썬 쪽에서 나누거나, 섹션별로 쿼리를 두 번 날리거나 — 상황에 따라 선택하면 됩니다. 데이터가 수백 개 수준이라면 전자가 단순합니다.
5. 크롤러 쪽에서의 모델 사용
crawl_news 명령이 Post를 생성할 때는 항상 CRAWLED를 명시합니다.
Post.objects.create(
title=entry.title,
slug=slug,
summary=summary,
content=cleaned_html,
source_type=Post.SourceType.CRAWLED,
source_url=entry.link,
published_at=published_date,
is_curated=False, # 수집 직후에는 승인 전
)
is_curated=False명시: 크롤링된 글은 편집자 승인 전까지 프론트에 노출되지 않습니다. 스팸/관련 없는 글이 흘러 들어와도 사용자 눈에는 닿지 않습니다.source_url: 크롤링 글에서만 채워지는 필드. editorial 글은 빈 문자열.
editorial 글은 반대로 어드민 UI를 통해 생성되므로 source_type은 기본값(editorial)이 그대로 쓰이고, source_url은 입력하지 않습니다.
6. is_curated 게이트가 만드는 편집 워크플로우
이 설계는 자연스럽게 "자동 수집 → 편집 검토 → 선별 노출" 워크플로우를 만듭니다.
월요일 00:00 UTC
↓ crawl_news 실행
50여 개 글 수집 (is_curated=False)
↓ 편집자 어드민 접속
list_filter로 "source_type=crawled, is_curated=False" 조회
↓ 제목/요약 훑어보고 괜찮은 것만 체크
list_editable로 is_curated=True 일괄 저장
↓ 프론트 반영
사용자에게 노출
자동화 + 수동 큐레이션 조합을 테이블 구조 위에 올리는 거라, 따로 "승인 큐" 테이블을 만들 필요가 없습니다.
7. 언제 테이블 분리를 고려할까
지금은 한 테이블 구조가 잘 맞지만, 다음 상황이 되면 분리를 재검토하겠다고 선을 그었습니다.
- 데이터가 수십만 건을 넘겨 단일 테이블 인덱스/VACUUM 부담이 커질 때.
- crawled 전용 필드(원문 언어, 크롤링 버전, 재크롤링 시각 등)가 5개 이상 늘어날 때. 그 시점엔 nullable 필드가 너무 많아져 스키마가 지저분해집니다.
- editorial에만 필요한 워크플로우 필드(초안/퍼블리시 상태, 리뷰어 등)가 생길 때.
이 시점이 오면 공통 필드는 AbstractBasePost로 추상 모델에 두고, editorial/crawled로 상속하는 패턴으로 가는 게 자연스럽습니다. 지금은 그럴 만큼 복잡하지 않아 단일 테이블을 유지합니다.
8. 이 설계가 만든 대가 — 백필 명령이 필요해진 이유
2절 (2)에서 이렇게 적었습니다.
source_url은 crawled 글에만 필요한 필드입니다. (…)blank=True로 설정합니다. DB 레벨의 NOT NULL 제약을 풀어두는 것도 같은 의도입니다.
편의를 위한 결정이었는데, 결과적으로 "crawled인데 URL이 없는 글"이 가능해졌습니다. 그리고 실제로 생겼어요. management command 4종을 만들면서 짠 backfill_urls가 정확히 그걸 복구하는 명령입니다.
broken = Post.objects.filter(source_type="crawled", source_url="")
이 쿼리가 존재한다는 것 자체가 모델이 못 막은 상태를 뜻합니다. 그리고 복구 방법이 "RSS를 다시 긁어 제목으로 역매칭" 이었으니, 잃어버린 정보를 추측으로 되찾는 작업이었어요. 매칭 안 된 글은 그냥 남겨뒀고요.
조건부 제약으로 막을 수 있었다
필드 자체는 blank=True로 두되, 조합에 제약을 걸면 됩니다.
class Meta:
constraints = [
models.CheckConstraint(
check=(
models.Q(source_type="editorial")
| (models.Q(source_type="crawled") & ~models.Q(source_url=""))
),
name="crawled_post_requires_source_url",
)
]
"editorial이면 자유, crawled면 URL 필수" 를 DB가 지키게 하는 거예요. 그러면 크롤러가 URL 없이 저장하려는 순간 그 자리에서 실패하고, 나중에 복구 명령을 만들 일이 없습니다.
이 패턴은 이후에도 반복해서 만났습니다. 속성의 range를 FK 둘로 쪼갤 때는 두 필드의 상호배타를, 필수 플래그를 관계 행으로 옮길 때는 클래스당 하나 제약을 걸어야 했어요. 결국 같은 교훈입니다 — "이 필드는 어떤 경우에만 의미 있다"가 있으면, 그 조건을 스키마에 적을 수 있는지 먼저 본다.
9. 그 밖에 걸리는 것
① 크롤러의 create()가 중복에 취약합니다.
Post.objects.create(title=entry.title, slug=slug, ...)
slug가 unique=True인데 create()를 씁니다. 같은 글이 다시 수집되거나 제목이 겹치면 IntegrityError로 크롤링 전체가 중단돼요. 주간 배치라 한 번 죽으면 그 주가 통째로 날아갑니다.
management command 글에서 "반복 실행에 안전하게 — 중복 체크 필수" 를 규칙으로 적어놓고, 정작 제일 자주 도는 명령이 get_or_create가 아닌 create입니다.
Post.objects.get_or_create(slug=slug, defaults={...})
② published_at이 DateField라 같은 날 글의 순서가 불안정합니다.
published_at = models.DateField()
class Meta:
ordering = ["-published_at"]
RSS 원본은 발행 시각까지 갖고 있는데 날짜만 저장합니다. 그러면 같은 날 수집된 글 수십 개의 정렬 순서가 DB가 돌려주는 순서에 맡겨져요. 목록을 새로고침할 때마다 순서가 바뀔 수 있고, 페이지네이션에서는 같은 글이 두 페이지에 나오거나 아예 빠지기도 합니다.
DateTimeField로 두거나, 최소한 ordering = ["-published_at", "-id"]처럼 동점 처리 기준을 넣어야 했습니다.
③ 편집자 글도 승인이 필요합니다.
is_curated의 기본값이 False라, 편집자가 어드민에서 직접 쓴 글도 승인 체크를 해야 노출됩니다. 크롤링 글의 품질 게이트로 만든 플래그가 editorial에도 똑같이 적용되는 셈이에요.
get_changeform_initial_data에서 source_type과 author는 기본값을 채워주면서 is_curated는 안 넣었습니다. 의도한 것이라면 괜찮지만, "자기가 쓴 글을 자기가 승인한다" 는 단계가 실제로 필요한지는 한 번 물어봤어야 했어요.
정리
"editorial과 crawled를 어떻게 공존시킬 것인가"는 모델 설계의 단골 질문인데, PyNews에서는 단일 Post 테이블 + source_type + is_curated 게이트 조합으로 풀었습니다. 단일 테이블이 공통 쿼리/어드민/프론트 라우팅을 단순하게 유지해주고, 플래그 두 개가 편집 워크플로우를 자연스럽게 만들어줍니다.
다만 단순함을 위해 푼 제약이 나중에 복구 작업으로 돌아왔습니다. blank=True 한 줄이 백필 명령 하나를 만들었어요. 프로젝트 초반에 중요한 건 유연성보다 단순함이 맞지만, "단순함"과 "제약 없음"은 다른 얘기라는 걸 이 설계에서 배웠습니다.
다음 글은 이 시리즈의 마지막 — Django(API) + React 19 + Vite를 Render와 Vercel로 분리 배포한 구조를 정리하겠습니다.