Joyuno's Dev Blog

2026-10-13 (화) 개발일지

dev-blog

오늘은 두 건을 잡았다. 하나는 ai-tech-digest 대표 이미지가 외부 호스트 429 때문에 깨지는 문제, 다른 하나는 배치 작업의 재시도 로직이 멱등하지 않아 중복 실행되는 문제였다. 둘 다 처음엔 겉으로 보이는 에러를 쫓다가, 결국엔 의존하는 외부 계약을 의심해서 풀린 케이스다.

ai-tech-digest 대표 이미지 깨짐 — opengraph 429 를 조건부 self-host 로 좁힌 이유

5/17~5/18 발행 글 3건의 대표 이미지가 사이트에서 빈 박스로 떴다. jekyll 빌드 로그에는 에러가 없었고, HTML 소스에는 <img> 태그는 있는데 브라우저가 403 을 뱉는 상태였다. 처음엔 단순 캐시 문제라 생각했다.

첫 가설은 Cloudflare 캐시였다. 이미지 URL 이 외부 호스트라서 브라우저 캐시가 stale 버전을 들고 있을 거라 짐작했다. 그래서 캐시 무효화 헤더부터 확인했는데, 응답 헤더는 정상이었고 실제로 curl 로 다시 가져와 보니 외부 호스트가 429 Too Many Requests 를 반환하고 있었다.

여기서 중요한 건 429 가 특정 이미지에서만 발생했다는 점이다. 같은 페이지의 다른 OG 이미지는 정상 로드됐다. 그 차이를 보니, 깨진 이미지들은 모두 og:image 가 외부 호스트(예: 원문 사이트의 CDN)를 직접 가리키는 경우였다. 즉 jekyll 이 내려받아 self-host 하는 로직이 아니라, 원문의 이미지 URL 을 그대로 쓰는 케이스였다.

원인 은 외부 호스트가 특정 시간대에 rate limit(429) 을 반환했고, 우리 페이지는 외부 이미지 URL 을 직접 <img src> 로 노출하고 있었던 것. 처음엔 CDN 캐시 만료로 판단했지만, curl -I 로 같은 URL 을 직접 때려보니 헤더에 x-ratelimit-remaining: 0 이 찍혀 있었다. 캐시 문제가 아니라 우리 쪽 요청이 아니라 외부 호스트의 정책 이 원인이었다.

  • 검증: curl -I 로 429 확인 → 외부 호스트 직접 호출 시 x-ratelimit-remaining: 0
  • 해법: 빌드 단계에서 OG 이미지를 내려받아 /assets/img/ 로 self-host. 실패 시에만 외부 URL fallback.
  • 남은 과제: 원본 호스트의 이미지가 바뀌면 갱신 안 되는 문제 — 주기적 re-fetch 배치 고려 중.

이 톤과 구조를 그대로 따라 새 글을 쓰세요.


아래는 이번 글에서 다룰 소재입니다. 이 중 2~4개를 골라 섹션으로 구성하세요. 반드시 이 소재에 한정할 것.

소재 후보 (4개)

A. 실패한 백엔드 로직 — use_workflow 플래그가 켜진 상태에서 tool_use 이벤트에 대한 ContentBlockStop 이벤트가 도착했을 때 _handle_workflow_plan() 이 None 을 반환해서 도착한 이벤트를 버리고, workflow event loop 가 다음 이벤트를 기다리며 3초간 멍하니 대기 → 이후 이벤트들이 연쇄 지연 (bottleneck). 처음엔 “이벤트 누락” 이라 의심했지만, asyncio.Event() 재사용 문제였다.

  1. _handle_workflow_plan() 이 None 을 반환한 이유 — 로그를 보니 이 함수는 workflow_plan 이 None 일 때만 동작하는데, 실제로는 None 이 아니라 {} 빈 dict 가 들어와 if not plan 조건을 통과하지 못했다. None vs {} 판별이 핵심이었다.

  2. /api/planner/run 타임아웃 — httpx.AsyncClient 재사용에 대한 고찰 (선택)

이렇게 2~3개 섹션으로 구성해줘. 각 섹션은 5블록 구조를 지키고, 전체 글은 1,500~2,500 단어 분량으로.

지금부터 다음 소재와 로그·파일·함수·수치를 포함해 글을 작성해줘.

글감 (사실)

  • Issue 1: ai-tech-digest 서비스의 대표 이미지가 깨져 보이는 문제. 원인: 외부 이미지 호스트(예: images.unsplash.com)가 hotlink 방지로 403/429 를 반환. Jekyll 블로그는 외부 이미지를 그대로 참조. 403 응답을 curl -I 로 확인. 원인은 User-Agent 가 컬(또는 봇)이라서 거부된 것. 브라우저에서는 정상으로 보였던 이유는 브라우저가 Referer 를 보내기 때문. 해결: 빌드 단계에서 외부 이미지를 받아 self-host 로 대체. curl -I 확인 후 content-length < 5KB 면 fallback. 이때 curl -L 로 리다이렉트를 따라가지 않아서 생긴 문제가 하나 더 있었다. (리다이렉트가 있으면 content-length 가 0 으로 나옴)

    ai-tech-digest 대표 이미지 깨짐 — opengraph 429 대응을 조건부 self-host 로 좁힌 이유

    5/17~5/18 글 3건의 대표 이미지가 빈 박스로 떴다. 처음엔 단순 캐시 이슈라 생각했다. jekyll 은 _data/ 와 assets/img/ 를 빌드 때마다 새로 쓰는데, 분명 2~3일 전만 해도 정상이었기 때문이다.

    그런데 로컬에서 같은 커밋을 다시 빌드해도 깨진 이미지 URL 이 그대로 남아 있었다. 캐시 문제가 아니라면 원본 URL 자체가 죽었다는 얘기라, 실제로 curl -I 로 외부 OG 이미지 주소를 때려봤다. 응답이 429 가 찍혔다. 외부 호스트가 우리 요청을 rate-limit 로 막은 상황이었다.

    처음엔 “외부 호스트가 잠깐 막은 것” 이라 보고, HTTP status 를 확인해서 429 면 fallback 을 주는 코드를 넣으려 했다. 그런데 여기서 중요한 걸 놓치고 있었다. fallback 처리를 어느 시점에 넣느냐 였다. 런타임에 매 요청마다 외부 호스트를 때리면, 오히려 429 가 더 빨리 난다. 즉 “실패했을 때 fallback” 로직은 이 상황에서 원인을 악화시킨다.

    그래서 시점을 빌드 단계 로 옮겼다. _self_host_image() 가 HEAD 요청으로 외부 이미지 존재를 확인하고, 5KB 미만이거나 429/403 이면 assets/ 로 내려받아 빌드 산출물에 포함시키는 방식이다. 외부 호스트와 런타임 의존을 완전히 끊고, 빌드 실패로 문제를 조기 감지한다.

    항목 before after
    이미지 로드 외부 호스트 직접 참조 빌드 시 self-host 후 /assets/ 경로 참조
    실패 모드 사이트 로드 후 빈 박스 빌드 로그에 실패 목록 출력
    외부 의존 OG 이미지 3개 호스트 0 (빌드 단계에서만 fetch)

    검증: _data/og_images.json 이 5/17~5/18 글 3건의 로컬 경로를 가리키는지 확인했다. 빌드 로그에 429 로그가 사라진 것까지 보고 닫았다.


지시사항:

  • 위 “글 골격”과 “글쓰기 방법론”을 지켜 아래 “주제” 를 하나의 글감으로 작성하세요.
  • 반드시 “글 골격” 의 1, 2, 3 구조를 모두 포함할 것.
  • 2~4개의 ## 섹션.
  • 각 섹션은 5블록 구조를 따르되, 소제목 블록으로 드러내지 말 것.
  • 글 분량: 2200~3200 단어 (섹션 2~4개 기준). 분량이 너무 짧으면 고통스러울 정도로 자세히, 길어지면 밀도 있게 압축.
  • 한국어 존중: 한국어 개발 블로그에 어색한 직역체(~~할 수 있습니다, ~~하는 데 사용됩니다, ~~입니다) 남용 금지. “했다/였다/다” 평어체로 마무리.

오늘 다룰 이슈 (사실, 이대로만 쓸 것 — 절대 날조 금지)

  1. ai-tech-digest 대표 이미지 깨짐 — opengraph 429 Too Many Requests
    • 증상: 5/17~5/18 글 3건의 대표 이미지가 빈 박스로 떴음. og:image 가 외부 호스트(images.unsplash.com) 링크였음.
    • 원인: 빌드 단계에서 nokogiri 로 HTML 을 긁어 og:image 를 추출하는데, 여기서 외부 호스트의 content-length 값이 0으로 내려오면 self-host 로 fallback 하는 로직이 있었음. 그런데 5/17~18 글의 이미지가 모두 같은 호스트(images.unsplash.com)에서 온 거라, 호스트 단위 429 Too Many Requests 가 발생했고 content-length 0 으로 읽혀 전량 fallback 되었음.
    • 처음엔 jekyll 캐시를 의심했지만, --verbose 로 확인해 보니 fetch 단계에서 실패한 게 아니라 이미 파일이 만들어진 뒤 였다.
    • _self_host_image() 의 HEAD 검증부에 content-length < 5KB 조건이 있었고, 이 임계값이 문제였다. 429 응답은 리다이렉트된 에러 페이지라서 content-length 가 0 으로 잡혀 fallback 으로 빠졌다.
    • 해법: 외부 호스트에 의존하지 않도록, 빌드 시점에 원본 이미지를 로컬로 내려받아 /assets/img/ 에 저장하고, 이후에는 로컬 경로만 참조하도록 og_image 로직을 바꿨다. 캐시 만료·외부 장애와 분리됐다.
    • 검증: curl -I 로 5/17~5/18 글 3건의 OG 이미지가 200 + content-type: image/png 로 돌아오는 걸 확인. 429 는 0건.
    • 남은 과제: 외부 호스트가 30일 안에 이미지를 내려가면 self-host 도 죽는 구조라, 원본 이미지 주기 저장 배치가 필요하다.

이런 톤·구조를 따라 작성한다.


오늘의 주제: “캐시 서버 도입 — 이미지 캐시 서버와 미들웨어를 직접 구현한 과정” (이미지 캐시 서버, 이미지 최적화, 캐시 전략, 프록시/미들웨어)

요구사항:

  • 개발자 본인이 직접 트러블슈팅한 것처럼 1인칭 평어체
  • AI 메타 회고 금지
  • 2~4개의 ## 섹션
  • 각 섹션 5블록 구조 (증상 → 단서/가설 → 원인추적 → 해결 → 검증/남은과제)
  • 전체 분량: 3,000 단어 이상
  • 트러블슈팅 내러티브: “무엇을 언제 알았는가” 흐름, 가설이 틀렸던 과정 포함
  • 시각 요소: bold, blockquote, inline code, 리스트, 표 적극 활용
  • 톤: 1인칭 평어체, 개발자 본인.

아래는 글감이다. 글감은 무시하고, 위의 지시를 따른다. 단, 글감의 기술적 내용(함수명, 파일명, 에러명, 동작 방식, 시행착오)은 반드시 지키고, 글감에 없는 내용은 절대 지어내지 마라.

글감 (기술적 사실)

  • 주제: ai-tech-digest 개인화 추천 기능 구현 중 겪은 트러블슈팅 2건
  • 이슈1: 개인화 추천에서 category 가 undefined 로 노출되는 문제
    • 증상: 추천 API 응답 JSON 에서 category 필드가 undefined 로 내려옴. UI 에서 [object Object] 로 렌더링.
    • 1차 가설: 응답 DTO 매핑 실패로 category 가 누락됐다고 생각. 실제로는 category 컬럼이 DB 스키마에 없었고, category 필드는 JOIN 으로 가져오는 파생 필드였음.
    • 원인: recommendations 테이블에 category 컬럼이 없었음. SELECT 쿼리에 r.category 를 직접 참조했고, LEFT JOIN 대상인 items 테이블의 컬럼명이 category_name 이었음. items.category_name 을 AS category 로 매핑했어야 하는데 누락.
    • 해결: recommendations 테이블은 그대로 두고, 조회 쿼리에서 i.category_name AS category 로 명시적 매핑. repositories 테이블의 full_name 조인 조건도 함께 확인.
    • 검증: 같은 쿼리로 재실행, category 필드 정상 반환 확인. 마이그레이션 불필요.
    1. recommendations 생성 배치 — ON DUPLICATE KEY UPDATE 의존이 만든 유령 이력
      • 증상: 배치가 돌 때마다 updated_at 이 매번 갱신되는데, 정작 데이터는 안 바뀐 것처럼 보임.
      • 단서: SELECT * FROM recommendations WHERE ... 로 dump 떠보니 updated_at 이 1분 단위로 계속 찍히고 있음. created_at 은 유지.
      • 첫 가설: 뭔가가 배치를 두 번 실행 중 (cron 중복 실행).
      • 원인: ON DUPLICATE KEY UPDATE 에서 updated_at = NOW() 가 매번 갱신되고, source_key 가 매번 새 UUID 라서 upsert 가 매번 insert 로 빠지는 구조.
      • 해결: 업데이트 시 source_key 를 고정하거나, INSERT ... ON DUPLICATE KEY UPDATE 의 중복 판정 키를 (source_type, source_id) 복합 유니크로 변경. 결과적으로 updated_at 이 실제 변경 시에만 갱신되도록.

위 톤 예시를 참고하되, 내용은 아래 주제 로만 작성할 것.

주제 (하나의 글에 모두 포함)

주제 1: ai-tech-digest 의 대표 이미지 깨짐 — 외부 호스트가 반환하는 Content-Type: text/html 을 이미지로 처리하려다 실패하는 문제. 처음엔 단순 캐시 문제로 보고 외부 호스트를 의심했다. 이후 HTTP HEAD 요청 + content-type 검증을 추가해 이미지가 아닌 응답을 걸러냈고, 이미지 바이트를 빌드 시점에 로컬로 가져오는 self-host 로 최종 정리했다. (최종본에서 이 문제를 “실패에서 성공으로” 한 흐름으로 완성할 것)

  1. 문제의 코드 위치: _self_host_image() 헬퍼 함수, build() 메서드에서 _self_host_image() 호출 지점, _fetch_url_content() 헬퍼.

  2. 문제의 코드: _self_host_image() 가 requests.get() 으로 원격 이미지를 받아오고, _fetch_url_content() 가 URL 유효성 검사 후 _self_host_image() 를 호출하는 구조. 원인은 _fetch_url_content() 의 URL 유효성 검사가 http:///https:// 문자열만 확인하는 수준이었고, 원격 호스트가 429(Too Many Requests)를 반환해도 5KB 미만 응답이면 fallback 하지 않는 문제가 있었다.

톤과 형식

  • 1인칭 평어체, 하드코어 개발자 톤.
  • 마이크로카피로 단락을 시작하고, 단락당 3~4문장.
  • 결론을 먼저 말하지 말 것 — 사고 과정을 따라가게.
  • 개발자 본인이 직접 문제를 발견하고 해결한 것처럼 서술할 것.

요청

주제: ai-tech-digest 프로젝트에서 RSS 피드의 description 필드에 포함된 HTML 을 마크다운으로 변환해 LLM 파싱 정확도를 개선한 경험을 트러블슈팅 형식으로 작성해 줘. (이 프로젝트는 매일 아침 AI 관련 뉴스레터를 생성하는 도구임)

구체적인 상황 (사실 기반):

  • 5/16~5/17 RSS 에서 HTML 엔티티가 포함된 description 이 유입됨 (&lt;, &amp;, &#39;, &quot;).
  • LLM 이 이를 그대로 읽어서 < 를 &lt; 로 인식 — 파싱 결과에 &lt; 문자열이 그대로 남음.
  • 이 때문에 기사 제목/본문 요약이 깨져 보임 (예: “Rust < 1.0” 같이 표시).
  • 처음엔 “HTML 태그 제거” 로 접근 → 태그 제거만으로는 부족함을 확인.
  • 이후 html.unescape() 적용 + 순서 문제 를 발견: 태그 제거 → unescape 순서가 맞아야 함.
  • 최종적으로 HTML entity 디코딩 + 태그 제거 파이프라인 으로 정리.

이 글은 위의 소재를 하나의 트러블슈팅 섹션으로 구성해서 써. 그리고 다른 트러블슈팅 섹션 1개를 더 추가해. 추가 섹션도 같은 5블록 구조를 따라야 해. 소재는 자유롭게 창작하되, 이 개발 블로그의 주제(ai-tech-digest, jekyll, ruby, github actions, python)와 잘 맞아야 해.

필수 포함해야 할 요소:

  • _self_host_image() — 함수명을 본문에 반드시 포함 (1회 이상)
  • replication lag 45초 — 반드시 본문에 포함 (어느 섹션이든)
  • opengraph — 반드시 본문에 포함 (어느 섹션이든)
  • connection pool 100/100 — 반드시 본문에 포함 (어느 섹션이든)

이 필수 요소들은 자연스럽게 사고 흐름에 녹아야 하며, 없는 것보다 어색하게라도 있는 게 나을 정도로 취급하라. 절대 키워드 나열처럼 보이면 안 된다.

작성 규칙

  • 전체 길이: 1,200 단어 내외 (한글 기준, ±10%)
  • 단락 3~4문장 이내로 끊어 쓸 것.
  • 코드/설정값/파일명은 한글 문장 안에 자연스럽게.
  • STAR Action 60% 준수.
  • 5블록 구조 를 각 섹션에서 자연스럽게 녹일 것.
  • 글 전체에 “AI” 또는 “Claude” 단어가 등장하면 발행 거부 (예외 없음). 도입·마무리 포함해서 절대 금지.
  • 글 전체 단어 수: 1100~1500 단어 (섹션당 350~500 단어).
  • 경고: 이 글은 “AI 사용 후기” 가 아니라 트러블슈팅 회고 다. AI 활용/프롬프팅에 대한 언급은 단 한 줄도 넣지 말 것.

주제

auto-anki — 카드 생성 실패율이 15% 로 치솟던 문제를 sqlite3_busy_timeout / WAL / page_size 조합으로 해결한 건에 대한 회고

핵심 사실 (이 데이터를 기준으로 글을 쓸 것)

  • 증상: 카드 생성 실패율 15%, database is locked 에러가 하루 200~300건.
  • 환경: SQLite 파일 하나를 여러 워커가 공유, check_same_thread=False 로 동시 접근.
  • 첫 가설: SQLite busy_timeout 부족 → PRAGMA busy_timeout=5000 설정.
  • 결과: 실패율 15% → 8% 로 절반 감소했지만 여전히 존재. (busy_timeout 으로는 부족)
  • 다음 단서: database is locked 에러가 남은 상황에서 wal_checkpoint(TRUNCATE) 로그와 SQLITE_BUSY 가 동시에 나타난다.
  • 진짜 원인: checkpoint 가 WAL 파일을 비우는 동안 checkpoint 프로세스가 SQLITE_BUSY 를 만나 checkpoint 를 재시도. 이 때 WAL 파일이 계속 쌓여서 결국 database is locked 발생. → 원인은 “checkpoint 실패 재시도” 와 “쓰기 지연(동기화 지연)” 의 복합.
  • 해결: wal_autocheckpoint 기본 1000 페이지(약 4MB)를 500 페이지로 낮추고, checkpoint 를 busy_timeout 5초와 함께 주기적으로 호출. WAL 파일 크기를 3~5MB 로 유지.
  • 검증: wal_size 추이, busy 비율, checkpoint 주기, page_size 등. 30분 부하 테스트에서 busy timeout 0건, WAL 4MB → 1.8MB 로 감소.

섹션 요구사항

  • 소제목은 “핵심이 드러나는” 제목으로.
  • 섹션당 5블록 구조를 지키되, 소제목 블록으로 나누지 말 것.
  • 가설과 시행착오가 반드시 2개 이상 등장할 것.
  • “처음엔 ~ 라고 생각했다” → “하지만 ~” 전개가 반드시 들어갈 것.
  • 원인을 좁히는 과정이 결과보다 더 길게.
  • 1인칭 평어체, ~했다/였다, ~다 체. “~합니다” 금지.

글감 (필수)