"다이제스트가 왜 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 크롤러는 이렇게 동작한다:
@B_ZCF같은 URL을 받으면 → YouTube 페이지를 로드- HTML에서
channelId를 추출 - 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개의 교훈을 얻었다:
- 안정적 식별자 사용 -
@handle보다channel ID - 타임아웃 필수 - "경량"이라도 외부 의존성이 있으면 무한 대기 가능
- 프로세스 정리 강제 - 시작 전에 기존 프로세스 kill
- 문서에 "금지 + 이유" - 코드가 못 막으면 문서가 막아야 한다
다음 주에는 YouTube 크롤러 성능 개선이다. 한 채널이 느려도 다른 채널에 영향 없도록, 채널별 병렬 처리 + 개별 타임아웃을 추가할 예정.
오늘의 45분 지연이 다음 주에는 0분이 되길.
"버그는 발견한 순간 교훈이 된다. 기록하지 않으면 같은 버그를 두 번 만난다."