개인 프로젝트 파이프라인 안정성 개선기: 재시도 로직부터 GraphQL N+1까지
매일 밤 자동으로 돌아가는 학습 기록 자동화 파이프라인이 자꾸 초안을 못 만들길래 원인을 뜯어보다가, 재시도 로직 설계와 API 호출 최적화에 대해 꽤 많은 걸 새로 알게 됐다.
학습 주제
개인적으로 운영 중인 GitHub 커밋/학습 기록 자동 수집 → AI 요약 초안 생성 → 블로그 자동 포스팅 파이프라인이 있는데, 어느 순간부터 초안이 거의 안 만들어지는 문제가 생겼다. 이걸 고치면서 자연스럽게 "일시적 오류와 영구적 오류를 어떻게 구분해서 처리할지", "API 호출을 어떻게 줄일지", "병렬 처리는 어디에 적용해야 안전한지" 같은 걸 하루 종일 파고들게 됐다. 날짜는 2026년 7월 8일.
탐구 과정
처음엔 단순히 "AI API를 안 부르는 것 같다"는 느낌으로 시작했는데, 로그를 까보니 원인이 생각보다 구조적이었다. Gemini가 503(일시적 과부하)을 반환하면 재시도 없이 바로 포기하고, 폴백으로 쓰던 Perplexity는 결제 문제로 매번 실패하고 있었다. 문제는 여기서 끝이 아니라 — 코드가 "한 항목이라도 두 API 모두 실패하면" 그날 전체 실행을 통째로 멈추도록 짜여 있었다는 거다. 일시적인 서버 과부하 한 번 때문에 그날 백준이든 개발이든 학습이든 전부 초안이 안 나오는 구조였다.
이걸 고치려고 재시도/폴백 로직을 손보다 보니 자연스럽게 "진짜 영구 실패(일일 한도 초과)"와 "일시적 실패(서버 과부하)"를 구분하는 게 핵심이라는 걸 깨달았다. 그러고 나서 폴백으로 쓸 무료 API를 찾다가 Groq를 알게 됐고, 실제로 붙여보면서 Llama 계열 모델이 한국어 생성 시 간헐적으로 다른 언어 토큰을 섞어내는 문제도 직접 겪어봤다. 그 다음엔 파이프라인 실행 로그를 뜯어보다가 GitHub GraphQL 호출이 전체 실행 시간의 절반 이상을 차지한다는 걸 발견했고, 여기서 N+1 문제와 병렬 처리 개념을 실전에 적용해볼 기회가 생겼다.
핵심 학습 내용
일시적 실패 vs 영구적 실패
가장 크게 배운 건 "실패를 하나로 뭉뚱그리면 안 된다"는 것이었다. 503(서버 과부하)은 잠깐 기다리면 해결되는 문제라 지수 백오프로 재시도해야 하고, 401(결제/쿼터 초과)은 아무리 재시도해도 안 되는 문제라 즉시 포기하고 다음 단계로 넘어가야 한다. 이 둘을 같은 실패로 처리하면 두 가지 방향으로 다 문제가 생긴다 — 진짜 복구 가능한 오류를 성급하게 포기하거나, 절대 안 될 오류에 시간을 낭비하거나.
Gemini 503 (일시적) → 2초, 4초 백오프로 재시도
Perplexity/Groq 401·413 (영구적) → 즉시 실패 처리, 재시도 안 함
두 API 모두 실패해도 "그 항목만" 스킵, 전체 실행은 계속
GraphQL N+1 문제
레포별로 브랜치 목록을 조회하고, 브랜치마다 또 커밋을 따로 조회하는 구조였는데, 실행 1회당 순차적으로 68번 정도 HTTP 왕복이 일어나고 있었다. 실제 로그를 확인해보니 92번 실행 누적으로 GraphQL 호출에만 57분 넘게 쓰고 있었다. 대부분 레포가 매번 "커밋 0개"를 반환하는데도 매일 똑같이 조회하고 있었던 거다.
해결책은 두 가지였다. 첫째, 레포 조회 시 pushedAt(마지막 푸시 시각)을 같이 가져와서 이번 수집 기간에 아예 손 안 댄 레포는 브랜치/커밋 조회 자체를 건너뛴다. 둘째, 브랜치 목록 조회와 브랜치별 커밋 조회를 refs → target → history 형태의 중첩 쿼리로 합쳐서 레포당 요청을 1번으로 줄인다.
중복 체크 캐싱
커밋 하나를 검사할 때마다 저장된 파일 전체를 매번 새로 열어서 비교하는 방식이었는데, 파일이 300개 넘게 쌓인 상태에서 커밋 300개를 수집하면 최악의 경우 9만 번 가까운 파일 읽기가 발생하는 구조였다. 폴더를 한 번만 스캔해서 메모리에 집합(set)으로 캐싱해두고, 이후엔 그 집합으로만 조회하도록 바꾸니 훨씬 가벼워졌다.
병렬 처리는 아무 데나 넣으면 안 된다
레포별 GraphQL 조회나 커밋별 REST 상세 조회처럼 서로 완전히 독립적인 작업은 병렬화 효과가 컸다(순차 1.2초 → 병렬 0.41초로 확인). 반면 AI 호출(Gemini/Groq)은 무료 티어에 분당 요청 한도가 있어서 병렬로 쏘면 오히려 rate limit 에러만 늘어난다는 걸 깨닫고 여긴 의도적으로 순차 처리를 유지했다. "병렬화 가능하다 = 병렬화해야 한다"가 아니라는 걸 실감했다.
모델별 특성 차이
Groq에서 돌리는 Llama-3.3-70b는 속도는 빠르지만 한국어 생성 중 간헐적으로 한자나 다른 언어 토큰이 섞이는 현상이 있었다("설명하여" → "설명_driving하여" 같은 식). 반면 Gemini는 같은 조건에서 이런 문제가 없었다. 결국 폴백은 최후의 안전망 정도로만 남겨두고, 압축 재요청 같은 후속 처리는 품질이 안정적인 쪽으로 몰아주는 게 낫다는 결론을 내렸다.
이해한 내용
- 실패 처리는 "성공/실패" 이분법이 아니라 원인별로 분기해야 한다는 걸 코드로 직접 겪으며 이해했다.
- N+1 문제는 이론으로는 알고 있었지만, 실제 로그 수치(호출 횟수, 누적 시간)로 병목을 확인하고 나서야 왜 중요한지 체감이 됐다.
- 캐싱은 "매번 새로 계산하지 않는다"는 단순한 원칙인데, 데이터가 쌓일수록 그 차이가 기하급수적으로 벌어진다는 걸 확인했다.
- GitHub GraphQL에서 개인 계정과 조직(organization) 소유 레포는 조회 방식이 다르다는 것도 새로 알았다 —
user(login:)쿼리로는 조직 레포가 아예 안 잡히고viewer쿼리로 바꿔야 한다는 점.
실전 적용
이번에 정리한 원칙들은 앞으로 개인 프로젝트에서 외부 API를 여러 개 연결할 때 그대로 쓸 수 있을 것 같다. 특히 "일시적/영구적 실패 구분 + 전체 중단 대신 개별 항목만 스킵" 패턴은 지금 만들고 있는 다른 자동화 스크립트에도 적용해볼 계획이다. 그리고 GitHub PR 데이터를 커밋처럼 수집해서 별도 초안으로 만드는 기능도 이번에 같이 추가했는데, 앞으로 진행 중인 프로젝트의 PR 히스토리를 정리하는 데 활용해볼 생각이다.
추가 학습 계획
- GraphQL 배치 쿼리 패턴을 더 일반화해서 다른 API 연동에도 재사용할 수 있는 형태로 정리해보고 싶다.
- 무료 티어 LLM들의 언어별 안정성 차이를 조금 더 체계적으로 비교해보고 싶다(지금은 한 번의 실측일 뿐이라 표본이 적다).
- Claude Code의 SessionEnd 훅처럼 세션/작업 단위로 자동 기록을 남기는 방식을 다른 개발 도구에도 적용할 수 있을지 알아볼 예정이다.