"다이제스트가 왜 8시에 왔지? 7시 반에 오기로 했잖아."

일요일 아침, 커피를 마시며 이메일함을 열었다. 평소처럼 7시 30분에 와야 할 AI 다이제스트가 8시 15분에 도착해 있었다. 45분 지연.

"서버 문제인가?"

터미널을 열고 로그를 뒤지기 시작했다. 그리고 4시간 후, 나는 예상치 못한 곳에서 범인을 찾았다.


1막: 범인은 YouTube

로그를 쭉 따라가다 보니 이상한 패턴이 보였다.

[06:00:18] Starting lightweight crawl (RSS + YouTube)
...
[08:06:57] Lightweight crawl completed

2시간 6분. 경량 크롤이라며?

더 파고들자 에러가 보였다.

ERROR: Could not extract channel ID from: https://www.youtube.com/@B_ZCF

비즈카페 채널. @handle 형식의 URL이었다.

왜 실패했나?

YouTube 크롤러는 이렇게 동작한다:

  1. @B_ZCF 같은 URL을 받으면 → YouTube 페이지를 로드
  2. HTML에서 channelId를 추출
  3. RSS 피드 URL로 변환 (/feeds/videos.xml?channel_id=...)

문제는 2번이었다. YouTube가 Playwright 브라우저를 봇으로 인식해서 빈 페이지를 반환한 거다.

# 이 코드가 실패함
response = await client.get(url, timeout=30)
# YouTube: "봇이시네요? 안 보여드릴게요 ㅎㅎ"

한 구독이 실패 → 재시도 → 또 실패 → 또 재시도... 이게 2시간 동안 반복됐다.

해결책: 안정적인 식별자 사용

@handle은 사람에게 친숙하지만, 크롤러에게는 불안정하다. YouTube가 정책을 바꾸면 바로 망한다.

반면 channel ID는 안정적이다. 바로 RSS 피드로 변환 가능.

# 변경 전
https://www.youtube.com/@B_ZCF  # 봇 차단 위험

# 변경 후
https://www.youtube.com/channel/UCWgXoKQ4rl7SY9UHuAwxvzQ  # 안전

DB의 모든 YouTube 구독을 channel ID 형식으로 마이그레이션했다.

교훈: 크롤러는 "사용자 친화적 URL"이 아니라 "안정적 식별자"를 써야 한다.


2막: 타임아웃의 부재

YouTube 문제를 해결하고 나니 더 근본적인 문제가 보였다.

경량 크롤에 타임아웃이 없었다.

# 기존 코드
async def crawl_lightweight_job():
    result = await crawl_lightweight_subscriptions()  # 언제 끝날지 모름

"경량"이라는 이름에 속았다. RSS와 YouTube만 크롤하니까 빠르겠지?

아니다. 외부 서비스에 의존하는 순간, 그건 더 이상 "경량"이 아니다.

  • 네트워크 타임아웃
  • 봇 차단
  • DNS 장애
  • 서버 점검

이 중 하나만 걸려도 무한 대기다. 그리고 APScheduler는 이전 작업이 끝나야 다음 작업을 시작한다. 경량 크롤이 2시간 걸리면, 다이제스트도 2시간 밀린다.

해결책: 타임아웃 + 알림

LIGHTWEIGHT_CRAWL_TIMEOUT_MINUTES = 30

async def crawl_lightweight_job():
    try:
        result = await asyncio.wait_for(
            crawl_lightweight_subscriptions(),
            timeout=30 * 60  # 30분 제한
        )
    except asyncio.TimeoutError:
        await send_system_alert("Lightweight Crawl Timeout", ...)

이제 30분을 넘기면 강제 종료하고 Slack으로 알린다.

교훈: "경량 작업"도 외부 의존성이 있으면 무한 대기할 수 있다. 타임아웃은 선택이 아니라 필수다.


3막: 좀비의 습격

서버 로그를 보다가 또 이상한 걸 발견했다.

$ ps aux | grep uvicorn
60965 uvicorn app.main:app  # 오늘 아침
90098 uvicorn app.main:app  # 토요일 밤 (???)

서버가 2개였다. 토요일에 서버를 재시작했는데, 기존 프로세스를 안 죽이고 새로 띄운 거다.

"근데 왜 문제가 안 생겼지?"

포트 8000은 먼저 뜬 프로세스가 점유한다. 두 번째 프로세스는 포트를 못 잡고... 뭘 하고 있었을까?

아무것도 안 하고 메모리만 먹고 있었다. 좀비.

더 무서운 건, APScheduler가 두 프로세스에서 모두 초기화됐다는 거다. 둘 다 07:30에 다이제스트를 시도하면? DB lock 충돌. 둘 다 실패.

해결책: 강제 정리

# start_server.sh
pkill -9 -f "uvicorn app.main:app" 2>/dev/null  # 먼저 다 죽이고
.venv/bin/python -m uvicorn ...  # 새로 시작

그리고 CLAUDE.md에 경고를 추가했다:

⚠️ 절대 직접 uvicorn 실행 금지!
→ 중복 프로세스로 스케줄러 충돌 발생

교훈: 프로세스 관리 스크립트는 "시작 전 정리"를 강제해야 한다.


4막: 문서의 힘

마지막으로 깨달은 게 있다.

이 모든 문제는 문서에 경고가 없어서 생겼다.

기존 문서:

# Run server
cd backend && ./scripts/start_server.sh start

"이렇게 하세요"만 있다. 사람들은 "더 빠른 방법"을 찾는다. uvicorn 직접 실행이 더 빠르잖아?

수정된 문서:

# Run server (MUST use this script)
cd backend && ./scripts/start_server.sh start

# ⚠️ 절대 직접 uvicorn 실행 금지!
# → 중복 프로세스로 스케줄러 충돌 발생

"하지 말아야 할 것 + 이유"가 있다. 왜 안 되는지 알면, 우회할 동기가 줄어든다.

교훈: 코드로 강제할 수 없는 것은 문서가 유일한 방어선이다. "DO NOT + 이유" 패턴.


에필로그: 오늘의 수확

시간 작업
8:00 "다이제스트 왜 늦었지?"
9:00 YouTube 봇 차단 발견
10:00 channel ID로 마이그레이션
11:00 경량 크롤 타임아웃 추가
11:30 좀비 프로세스 발견 & 정리
12:00 CLAUDE.md 경고 강화

4시간의 삽질로 4개의 교훈을 얻었다:

  1. 안정적 식별자 사용 - @handle보다 channel ID
  2. 타임아웃 필수 - "경량"이라도 외부 의존성이 있으면 무한 대기 가능
  3. 프로세스 정리 강제 - 시작 전에 기존 프로세스 kill
  4. 문서에 "금지 + 이유" - 코드가 못 막으면 문서가 막아야 한다

다음 주에는 YouTube 크롤러 성능 개선이다. 한 채널이 느려도 다른 채널에 영향 없도록, 채널별 병렬 처리 + 개별 타임아웃을 추가할 예정.

오늘의 45분 지연이 다음 주에는 0분이 되길.


"버그는 발견한 순간 교훈이 된다. 기록하지 않으면 같은 버그를 두 번 만난다."