콘텐츠 허브라는 사이드 프로젝트를 만들면서 겪은 일주일. 처음엔 "간단한 버그 하나"였는데, 파고들수록 시스템 전체의 취약점이 드러났다.


프롤로그: 이메일이 안 왔다

금요일 오후 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():
    """< > 문자가 &lt; &gt;로 변환되어야 함"""

에필로그: 패턴의 발견

일주일간 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를 추가할 예정이다. 조용한 실패를 하나씩 없애가는 중이다.