2026-07-13 (월) 개발일지
오늘의 회고: ai-tech-digest CI 실패, 그리고 그보다 더 깊은 문제
오늘은 ai-tech-digest의 주간 CI가 글이 없을 때마다 실패 메일을 뿌리는 문제를 잡았다. 처음엔 RSS 피드 자체의 문제라고 생각했지만, 파고들수록 스크립트의 빈 결과 처리 방식이 근본 원인이었다. 거기에 더해, 예전에 만든 백엔드 코드에서 발견한 작은 결함들 – google-auth 미들웨어 누락, 경로 상수 오류, alembic 마이그레이션 포워드 빈틈 – 도 함께 정리했다.
ai-tech-digest 주간 CI 실패 — 빈 결과를 하드 실패에서 정상 skip으로
매주 수요일 아침, GitHub Actions에서 실패 메일이 도착했다. 제목은 ai-tech-digest weekly run failed. 당장 서비스가 멈추는 건 아니었지만, 매번 메일 확인 → 수동 재실행 → 성공하는 패턴이 반복됐다. 그런데 왜 실패하는지 규칙이 보이지 않았다. 성공할 때도 있었고, 실패할 때도 있었다.
처음엔 GeekNews RSS 피드 자체에 문제가 있다고 생각했다. curl로 직접 RSS를 가져와 봤지만 HTTP 200에 정상 XML이 떨어졌다. 다음으로 GitHub Actions의 환경 변수 문제를 의심했다. TZ=Asia/Seoul을 명시적으로 추가해봤지만 효과가 없었다. 로그를 자세히 보니, 스크립트가 정상 실행되고 exit 1로 종료되고 있었다.
그제서야 geeknews_papers_collector.py의 로직을 들여다봤다. 주간 글을 수집하는 collect_weekly_papers() 함수가 RSS에서 최근 50개 아이템만 가져오고, 그 안에 해당 주의 글이 없으면 빈 리스트를 반환한 뒤, 그 상태에서 main()이 exit(1)을 호출하고 있었다. main_weekly.py도 같은 구조였다.
가설을 세웠다: “RSS 피드가 일시적으로 50개 밖에 안 보여주는데, 시리즈가 중단되거나 발행이 늦어져서 범위 밖으로 밀려난 것 아닌가?” 실제로 확인해보니 ‘AI/ML 논문 모음’ 시리즈가 6월 초(30295) 이후로 보이지 않았다. 시리즈가 중단된 상태에서, 매주 스크립트는 빈 결과를 받고 exit 1로 CI를 실패시키고 있었다.
핵심 전환점: “빈 결과가 실패가 아니라 정상 상태의 하나라는 걸 인정해야 했다. 단순히
exit 1을exit 0으로 바꾸는 것만으로는 부족했다. RSS 50개 밖에 없는 상황에서 중단된 시리즈를 계속 찾으려면 더 많은 아이템을 가져올 수 있어야 했다.”
수정은 두 가지였다. 첫째, geeknews_papers_collector.py와 main_weekly.py에서 빈 결과일 때 exit(1)을 exit(0)으로 변경했다. 글이 없어도 CI는 성공으로 마무리된다. 둘째, RSS에서 최근 50개를 넘어 더 많은 아이템을 가져올 수 있도록 페이지네이션 폴백을 추가했다. RSS 피드가 페이지네이션을 지원하는지 확인한 뒤, ?page=2 식으로 추가 요청을 보내 최대 100개까지 확장했다.
| 항목 | Before | After |
|---|---|---|
| 글 없음 처리 | exit(1) → 실패 메일 |
exit(0) → 정상 skip |
| RSS 수집 범위 | 최근 50개 고정 | 페이지네이션으로 최대 100개 |
| 수정 파일 | geeknews_papers_collector.py |
+ main_weekly.py |
다른 옵션으로 시리즈 자체를 제거하는 것도 고려했지만, 언제 재개될지 모르므로 유지하고 skip 로직만 추가하는 쪽이 낫다고 판단했다.
검증은 로컬에서 빈 상태로 스크립트를 실행해 exit code가 0인지 확인하고, GitHub Actions에서 수동으로 워크플로우를 트리거해 success로 끝나는 걸 확인했다. 페이지네이션 폴백도 정상 동작했다. 기존 91개 테스트에도 영향이 없었다. 커밋 d539b96에 푸시됐다.
남은 과제: 시리즈가 다시 시작되면 정상 수집되는지 모니터링 필요. 페이지네이션 폴백이 rate limit에 걸리지 않는지도 추후 확인할 예정이다.
사주 백엔드 P0 3건 — google-auth 미들웨어, 경로 오타, alembic 포워드 갭
ai-tech-digest CI를 수정하면서, 예전에 만들어둔 사주 백엔드 저장소에서 발견했던 세 가지 P0 결함을 함께 수정했다. 이 저장소는 FastAPI 기반으로, 인증·파일 경로·DB 마이그레이션에 각각 문제가 있었다.
첫 번째는 google-auth 미들웨어가 일부 엔드포인트에 적용되지 않은 문제였다. 코드 리뷰 중 app.py에서 미들웨어 등록 순서를 확인하다가, 특정 라우터 그룹이 GoogleAuthMiddleware보다 먼저 등록되어 미들웨어를 타지 않는 걸 발견했다. 원인은 include_router() 호출 순서였다. 미들웨어가 전역에서 동작하려면 모든 라우터보다 먼저 등록되어야 하는데, 일부 라우터가 먼저 추가된 상태였다. 해결은 add_middleware 호출을 include_router보다 위로 올리는 것. before/after는 단순했지만, 이 한 줄 순서 차이로 인증이 빠진 엔드포인트가 생길 수 있었다.
두 번째는 경로 상수 오류였다. config.py에 정의된 UPLOAD_DIR 경로가 실제 파일 시스템과 달라서 파일 저장 시 FileNotFoundError가 발생하는 조건이 있었다. 로그를 보니 Path("/data/uploads")로 되어 있었는데, 실제 컨테이너 마운트는 /app/data/uploads였다. 상수를 Path("/app/data/uploads")로 수정했다. 다른 옵션으로 환경 변수로 분리하는 것도 고려했지만, 현재는 고정 경로 하나만 사용하므로 하드코딩된 값을 바로잡는 게 더 단순했다.
세 번째는 alembic 마이그레이션에서 포워드 스크립트에 테이블 생성 구문이 누락된 문제였다. alembic revision --autogenerate로 생성한 스크립트를 검토하던 중, 새로 추가한 user_sessions 테이블이 upgrade() 함수에 포함되지 않은 걸 확인했다. 원인은 env.py의 target_metadata에 새 모델이 등록되지 않아 autogenerate가 인식하지 못한 것. from models import UserSession을 추가하고 target_metadata에 반영한 뒤, 수동으로 op.create_table() 구문을 추가했다. 검증은 로컬에서 alembic upgrade head를 실행해 실제 테이블이 생성되는지, 기존 데이터가 깨지지 않는지 확인했다.
오늘의 한줄
CI 실패는 항상 로직 버그일 확률이 높다 — “빈 결과”도 정상 상태의 하나로 인정해야 하고, 미들웨어 순서 하나가 보안 구멍을 만든다. 작은 디테일이 시스템 전체를 좌우한다.