매일 아침 AI가 만들어주는 뉴스레터를 운영하고 있다. RSS, LinkedIn, YouTube, Hacker News 등에서 콘텐츠를 크롤하고, Gemini API로 큐레이션해서 이메일/Slack으로 보내는 시스템이다. 처음엔 "프롬프트만 잘 쓰면 되겠지"라고 생각했는데, 실제로 운영해보니 프롬프트만으로는 해결 안 되는 문제가 꽤 있었다. 그 과정에서 만들어진 "품질 루프" 전략을 정리해본다.
두 개의 루프
AI 출력 품질을 잡는 방법을 두 가지 트랙으로 나눴다.
| 빠른 루프 | Deep 루프 | |
|---|---|---|
| 뭘 바꾸나 | 프롬프트 텍스트 | Python 코드 |
| 배포 필요? | No | Yes |
| 반복 속도 | ~90초 | 수 분 (배포 포함) |
| 도구 | MCP 도구 6개 | 코드 에디터 + pytest + deploy.sh |
핵심 아이디어는 먼저 빠른 루프로 시도하고, 2-3번 해봐도 재발하면 Deep 루프로 전환하는 것이다.
빠른 루프: 프롬프트 최적화
DB에 저장된 프롬프트를 MCP 도구로 수정하고, 즉시 재생성해서 확인하는 사이클이다. 코드 변경이나 배포가 전혀 필요 없다.
get_digest(id) → 현재 결과 확인
get_digest_prompt → 프롬프트 확인
update_digest_prompt → 수정
regenerate_digest(id) → 재생성 (~90초)
get_digest(new_id) → 결과 점검
redeliver_digest(id) → OK면 발송
이 루프로 해결한 것들:
- So What 형식:
→ **So What**같은 화살표 표기를> **So What?**블록인용으로 통일 - 구분자 혼재: 마침표(
.)와 중간점(·)이 섞이던 것을 중간점으로 정리 - 이모지 제거: "뉴스레터에 이모지 쓰지 마" → 프롬프트 한 줄로 해결
- 오늘의 픽 개수: 5-6개씩 나오던 걸 3-4개로 제한
프롬프트 수정 → 재생성까지 90초면 결과를 볼 수 있어서, 한 세션에 3-4번 반복하면 대부분 원하는 수준에 도달한다.
이 방식이 가능한 이유
프롬프트가 코드에 하드코딩되어 있으면 수정할 때마다 배포해야 한다. 이 시스템에서는 프롬프트를 DB의 사용자 설정(user.digest_prompt)으로 분리해뒀다. 기본값은 코드에 있지만, MCP 도구로 DB 값을 덮어쓰면 즉시 반영된다.
이 설계 덕분에 프롬프트 실험의 마찰이 거의 0이다. "프롬프트 엔지니어링"이라고 거창하게 부르지만, 실제로는 그냥 텍스트 수정 → 90초 대기 → 결과 확인의 반복이다.
Deep 루프: 코드로 잡는 구조적 문제
프롬프트를 아무리 바꿔도 해결 안 되는 문제가 있다. AI가 지시를 무시하는 게 아니라, 시스템 구조상 다른 곳에서 문제가 발생하는 경우다. 오늘 수정한 두 가지가 정확히 이 케이스였다.
사례 1: 저품질 YouTube가 계속 나타나는 미스터리
YouTube 크롤링에서 description이 "저작권 고지" 같은 쓸모없는 텍스트뿐인 영상이 있다. 이런 건 AI 입력에서 제외하도록 필터를 만들어뒀다.
# digest.py — AI 입력 단계에서 제외
if item["platform"] == "youtube" and _is_low_quality_description(item["content"], item["title"]):
continue # AI가 이 콘텐츠를 아예 못 봄
그런데 다이제스트에 계속 나타났다. 왜?
범인은 postprocessor였다. AI가 다이제스트를 생성한 뒤, postprocessor가 "플랫폼 다양성 보장"을 위해 누락된 플랫폼의 콘텐츠를 자동 추가한다. 이때 필터링 전 전체 목록을 받아서 쓰고 있었다.
AI 입력 필터: "이 YouTube 저품질이네, 빼자" ✅
AI: YouTube 없이 다이제스트 생성 ✅
Postprocessor: "YouTube가 하나도 없네? 전체 목록에서 추가하자" ← 저품질 포함!
수정은 간단했다. postprocessor에 넘기는 목록에서도 저품질을 빼면 된다.
# 기존: postprocessor에 전체 목록 전달
body = postprocess_digest(body, all_contents=all_rows)
# 수정: 필터링된 목록 전달
filtered_rows = [
(c, s) for c, s in all_rows
if not (s.platform.value == "youtube" and c.content and c.title
and _is_low_quality_description(c.content, c.title))
]
body = postprocess_digest(body, all_contents=filtered_rows)
교훈: 필터는 한 군데만 걸면 안 된다. 데이터가 흐르는 모든 경로에서 일관되게 적용해야 한다. AI 입력에서 빼면서 후처리 보정 로직에는 전체 데이터를 넘기면, 필터를 우회하는 백도어가 생긴다.
사례 2: 카테고리 헤더가 두 가지 형식으로 나오는 문제
"더 보기" 섹션에 카테고리별로 콘텐츠가 그룹핑된다. AI는 **AI/ML** (bold)로 헤더를 쓴다. 그런데 postprocessor가 누락 콘텐츠를 추가할 때는 ### AI/ML (h3)로 넣고 있었다.
## 더 보기
**AI/ML** ← AI가 생성 (bold)
― **[Article 1]** ...
### 개발 도구 ← postprocessor가 추가 (h3)
― **[Article 2]** ...
같은 섹션에 두 형식이 혼재되니 보기 안 좋았다. 수정 포인트는 세 군데:
- postprocessor 삽입 로직:
### Topic→**Topic**로 변경 (2곳) - 정규화 규칙 추가: AI가 간혹 h3로 쓰는 경우 대비, "더 보기" 섹션 내
### Topic을**Topic**으로 자동 변환- 단, 오늘의 픽의
### [제목](url)은[로 시작하므로 보존
- 단, 오늘의 픽의
# "더 보기" 섹션 내 h3 → bold (오늘의 픽 ### [제목] 보존)
after = re.sub(r'^### ([^\[].+)$', r'**\1**', after, flags=re.MULTILINE)
교훈: AI 출력에 규칙을 강제하려면, AI에게만 의존하지 말고 후처리로 보장해야 한다. AI는 대부분 지시를 따르지만 100%는 아니다. postprocessor는 "AI가 실수해도 최종 결과는 일관되게" 만드는 안전망이다.
Postprocessor라는 안전망
이 시스템에서 가장 중요한 설계 결정 중 하나가 AI 출력과 최종 결과 사이에 postprocessor를 두는 것이었다.
AI에게 "이모지 쓰지 마", "구분자는 ·를 써"라고 아무리 강조해도, 가끔은 무시한다. 특히 프롬프트가 길어지면 뒤쪽 지시를 놓치는 경향이 있다.
postprocessor는 이런 불확실성을 코드로 잡는다:
def postprocess_digest(body, all_contents=None):
body = fix_source_format(body) # "출처 ." → "출처:"
body = normalize_more_format(body) # 불릿/구분자/h3 정규화
body = remove_full_list(body) # 불필요한 섹션 제거
if all_contents:
body = add_missing_platform_contents(body, all_contents) # 누락 보정
body = group_by_platform(body) # 토픽 그룹핑 정리
body = remove_empty_more_section(body) # 빈 섹션 제거
return body
각 함수가 하나의 규칙만 담당하고, 파이프라인으로 순차 적용된다. 새 규칙이 필요하면 함수 하나만 추가하면 된다.
이 패턴의 장점:
- 프롬프트는 "의도"를 전달하고, postprocessor는 "형식"을 보장한다
- 프롬프트를 단순하게 유지할 수 있다 (형식 지시를 줄여도 됨)
- 테스트 가능하다 (pytest로 각 규칙을 독립 검증)
빠른 루프 vs Deep 루프, 어떻게 판단하나
실전에서의 판단 기준:
| 상황 | 트랙 |
|---|---|
| 처음 발견한 이슈 | 빠른 루프부터 |
| "오늘의 픽 4개로 줄여줘" | 빠른 루프 (프롬프트) |
| 프롬프트 2-3번 수정해도 재발 | Deep 루프로 전환 |
| "이 콘텐츠가 왜 포함되지?" | Deep 루프 (필터 로직) |
| "형식이 매번 깨져" | Deep 루프 (postprocessor) |
경험상, 톤/분량/개수 같은 "소프트"한 지시는 프롬프트로 충분하고, 구조/형식/필터링 같은 "하드"한 규칙은 코드가 필요하다.
배포 없이 프롬프트를 바꿀 수 있는 아키텍처
이 두 트랙 전략이 작동하는 배경에는 아키텍처 선택이 있다:
- 프롬프트를 DB에 저장: 코드의
DEFAULT_PROMPT를 사용자별로 오버라이드 가능 - MCP 도구: Claude Code에서 직접 API를 호출해 프롬프트 수정/재생성/재발송
- Postprocessor 파이프라인: AI 출력을 규칙 기반으로 후처리
- Docker 배포: 코드 변경은 이미지 빌드 → 배포 필요
이 구조 덕분에 "프롬프트 실험은 빠르게, 코드 변경은 안전하게"가 가능하다.
실전 팁
- 프롬프트에 형식 규칙을 너무 많이 넣지 마라: AI가 내용 생성에 집중하게 하고, 형식은 postprocessor에 맡겨라
- 필터는 데이터 흐름의 모든 지점에 적용하라: 입력 필터만 걸고 보정 로직에 원본을 넘기면 필터가 무력화된다
- 빠른 루프 도구를 먼저 만들어라: 프롬프트를 바꾸려고 매번 코드를 수정하고 배포하는 건 너무 느리다
- AI 출력을 맹신하지 마라: 대부분 잘 따르지만, "항상"은 아니다. 코드로 보장할 수 있는 건 코드로 하자
- 각 루프의 판단 기준을 명확히 하라: "이거 프롬프트로 될까?"를 2-3번만 시도하고, 안 되면 과감히 코드로 전환