콘텐츠 허브라는 사이드 프로젝트를 만들면서 겪은 일주일. 처음엔 "간단한 버그 하나"였는데, 파고들수록 시스템 전체의 취약점이 드러났다.
프롤로그: 이메일이 안 왔다
금요일 오후 5시 30분. 평소처럼 다이제스트 이메일이 올 시간이었다.
안 왔다.
"서버가 죽었나?" 싶어서 확인해보니 서버는 멀쩡히 돌아가고 있었다. 그런데 이상하게 uvicorn 프로세스가 2개였다. 목요일에 서버를 재시작했을 때 기존 프로세스를 안 죽이고 새로 띄웠던 거다.
두 개의 프로세스가 각각 스케줄러를 돌리면서 서로 충돌. 포트는 첫 번째가 점유하고, 두 번째는 조용히 죽어있었다. 조용히.
이게 이번 주 디버깅 여정의 시작이었다.
1막: 조용한 실패의 공포
가장 무서운 버그는 에러를 뱉지 않는 버그다.
발견 1: 서버가 2개 돌아도 아무도 모른다
$ ps aux | grep uvicorn
57959 uvicorn app.main:app # 금요일 시작
60079 uvicorn app.main:app # 목요일 시작 (좀비)
포트 충돌? 에러 로그? 없다. 두 번째 프로세스는 그냥 조용히 바인딩 실패하고 죽었다. APScheduler는 두 프로세스 모두에서 초기화됐고, DB lock이 충돌하면서 둘 다 다이제스트 생성에 실패했다.
교훈: 백그라운드 서비스는 "하나만 돌아야 한다"를 코드로 강제해야 한다.
# 해결책: PID 파일 패턴
if [ -f server.pid ] && kill -0 $(cat server.pid) 2>/dev/null; then
echo "Already running!"
exit 1
fi
발견 2: 에러가 나도 아무도 모른다
다이제스트 전송이 실패했는데, 로그 파일에만 기록됐다. 서버에 SSH로 접속해서 tail -f 해야 알 수 있는 구조. 사용자가 "이메일 안 왔어요"라고 말하기 전까진 아무도 몰랐다.
교훈: 로그는 디버깅용, 알림은 장애 감지용. 역할이 다르다.
# 해결책: 실패 즉시 Slack 알림
async def send_system_alert(title: str, message: str):
await slack_webhook.send(f"🚨 {title}\n{message}")
발견 3: 스케줄러가 도는지 안 도는지 모른다
"다음 다이제스트가 언제 예정되어 있지?"
코드 주석에 # 07:30 KST라고 적혀있지만, 실제로 언제 실행되는지 확인할 방법이 없었다. APScheduler 내부 상태를 보려면 Python REPL에 접속해야 했다.
교훈: 스케줄러 상태는 API로 노출해야 한다.
@router.get("/scheduler-status")
async def get_scheduler_status():
jobs = scheduler.get_jobs()
return {
"running": scheduler.running,
"jobs": [{"id": j.id, "next_run": j.next_run_time} for j in jobs]
}
2막: 데이터가 거짓말을 한다
서버 문제를 해결하고 나니 다이제스트 내용 자체에 문제가 있었다.
발견 4: 4주 전 글이 "최신"으로 나온다
LinkedIn 크롤러가 published_at을 추출하지 못하면 None으로 저장했다. 다이제스트 쿼리는 published_at이 없으면 crawled_at으로 대체했는데, crawled_at은 항상 "지금"이다.
결과: 4주 전 글도 크롤한 시점이 "지금"이니까 "최신 콘텐츠"로 분류됐다.
# 문제의 코드
time_filter = coalesce(Content.published_at, Content.crawled_at) > since
교훈: 시간 추출은 "있으면 좋은 것"이 아니라 다이제스트 품질의 핵심이다.
발견 5: "3w · edited"를 파싱할 수 없다
LinkedIn 시간 텍스트를 파싱하려고 했는데 regex가 안 먹혔다.
re.match(r"^(\d+)\s*([wdhms])\b", "3w·edited") # None!
왜? ·가 공백이 아니니까. LinkedIn은 •, ·, -, –, —, | 등 온갖 구분자를 쓴다. 전부 다른 문자다.
교훈: 소셜 미디어 데이터는 "사람이 읽기 좋은 형식 ≠ 파싱하기 좋은 형식"
# 해결책: 장식 문자 제거 후 파싱
text = re.sub(r"[•·\-–—|]", " ", text).strip().lower()
발견 6: "Jan 15"가 올해인지 작년인지
2월에 "Jan 15"를 파싱하면? 1월은 이미 지났으니까 작년이어야 한다. 하지만 단순히 datetime(2026, 1, 15)하면 올해 1월로 해석된다.
교훈: 날짜 파싱은 현재 시점과의 비교가 필수다.
if result > datetime.now():
result = datetime(result.year - 1, result.month, result.day)
3막: 한 곳을 고치면 다른 곳이 터진다
버그 하나를 고치면 다른 버그가 드러났다.
발견 7: X 타임라인에 남의 트윗이 섞인다
구독한 계정 @andrejkarpathy의 트윗만 가져오고 싶었는데, "You might like", "Promoted" 트윗이 섞여 들어왔다. 크롤러가 모든 <article> 요소를 수집했기 때문이다.
교훈: 소셜 미디어 크롤 시 URL 내 작성자와 메타데이터 작성자를 비교해야 한다.
if tweet_author.lower() != username.lower():
continue # 타임라인 오염 방지
발견 8: HN만 5건 제한 → 나중에 LinkedIn에서 터짐
"기타 콘텐츠" 섹션에 HackerNews 글이 50개나 들어가서 5건 제한을 걸었다. 나중에 LinkedIn에서 같은 문제가 생겼다.
교훈: 플랫폼별 정책은 균일하게 적용해야 한다.
# 나쁜 예
if platform == "HackerNews":
items = items[:5]
# 좋은 예
for platform, items in by_platform.items():
by_platform[platform] = items[:5]
발견 9: AI가 "언급한" 콘텐츠를 잘못 판단
AI 요약에서 실제로 링크한 콘텐츠만 "기타 콘텐츠"에서 빼고 싶었다. 처음엔 제목 포함 여부로 판단했다.
# 문제의 코드
if any(title in ai_output for title in content.title):
mentioned_ids.add(content.id)
AI가 "OpenAI released GPT-5"라고 쓰면, 제목에 "OpenAI"가 들어간 글 10개가 다 "언급됨"으로 처리됐다.
교훈: 제목은 겹치고, URL은 고유하다.
# 해결책: URL 매칭
urls = re.findall(r"\(https?://[^\)]+\)", ai_output)
if content.url in urls:
mentioned_ids.add(content.id)
4막: 눈에 안 보이는 보안 구멍
기능이 돌아가자 보안 문제가 보이기 시작했다.
발견 10: 이메일에 <script> 태그가 들어간다
기술 블로그 제목에 <script>alert('XSS')</script> 같은 게 들어있으면? 이메일 본문에 그대로 삽입됐다.
교훈: 사용자 생성 콘텐츠는 항상 이스케이프해야 한다. 그리고 순서가 중요하다.
# 순서 중요!
text = html.escape(text) # 먼저 이스케이프
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text) # 그다음 마크다운
발견 11: 보안 코드는 테스트 없이 사라진다
보안 수정은 "동작에 영향 없음"으로 보이기 쉽다. 리팩토링할 때 "이거 왜 있지?" 하고 지워버릴 수 있다.
교훈: 보안 수정에는 구체적 공격 벡터를 테스트명에 명시한 테스트가 필수다.
def test_html_escape_xss_script():
"""<script> 태그가 이스케이프되어야 함"""
def test_html_escape_angle_brackets():
"""< > 문자가 < >로 변환되어야 함"""
에필로그: 패턴의 발견
일주일간 19개의 버그를 잡으면서 세 가지 패턴이 보였다.
패턴 1: Fallback 체인
시도 A 실패 → 시도 B 실패 → 시도 C → 마지막 방어선
LinkedIn 시간 파싱이 대표적이다. <time datetime> 태그 → 알려진 셀렉터 → 전체 텍스트 스캔 → 7일 전으로 가정. 한 가지 방법에 의존하면 HTML 변경 한 번에 전면 실패한다.
패턴 2: 조용한 실패 방지
에러 발생 → 로그 기록 + 알림 전송 + 재시도 스케줄
"에러가 나면 누군가는 알아야 한다." 재시도는 1회만, 무한 루프 방지. 두 번 실패하면 수동 점검 유도.
패턴 3: 균일 정책
"HN만 5건 제한" → "모든 플랫폼 5건 제한"
특정 케이스만 고치면 다른 케이스에서 시차를 두고 같은 문제가 터진다. 정책은 일반화해서 적용하고, 예외가 필요하면 명시적으로 문서화한다.
이번 주 숫자
| 항목 | Before | After |
|---|---|---|
| 테스트 케이스 | 103개 | 118개 |
| 기록된 교훈 | 13개 | 32개 |
| 장애 알림 | 0개 | Slack 연동 |
| 스케줄러 모니터링 | 불가 | API 제공 |
마치며
"간단한 버그 하나"가 시스템 전체의 설계 결함을 드러냈다.
조용한 실패는 누적된다. 오늘의 "나중에 고치지"가 내일의 "왜 안 되지?"가 된다.
에러가 나면 시끄럽게 알려야 한다. 그래야 고칠 수 있다.
다음 주에는 이메일 실패 시 Slack fallback과 health check API를 추가할 예정이다. 조용한 실패를 하나씩 없애가는 중이다.