2026-10-02 (금) 개발일지
오늘은 ai-tech-digest 대표 이미지가 사이트에서 깨져 보이는 문제를 잡았다. 처음엔 단순 캐시 이슈라 생각했는데, 파보니 외부 호스트 단일 의존이 문제였고, 결국 빌드 단계에 조건부 self-host 로직을 박는 쪽으로 정리됐다. 이 과정에서 내가 세웠던 첫 가설이 어떻게 무너졌는지, 그리고 왜 “전량 self-host”가 아니라 “조건부 self-host”가 정답이었는지를 기록해둔다.
대표 이미지 깨짐 — opengraph 429를 조건부 self-host로 좁힌 이유
5/17~5/18 글 3건의 대표 이미지가 빈 박스로 떴다. 처음엔 jekyll 빌드 캐시가 꼬인 줄 알고 _site를 통째로 날려봤지만 증상이 그대로였다. 그제서야 직접 이미지 URL을 curl -I로 찔러봤고, 깨진 3건 모두 opengraph.githubassets.com 호스트에서 HTTP 429를 뱉고 있다는 걸 확인했다.
처음엔 모든 게시글의 OG를 미리 다 self-host하는 backfill 스크립트(backfill_og_images.py)를 만들었다. 이 방식은 한 번에 100개가 넘는 이미지를 받아와서 GitHub Contents API rate limit에 곧바로 부딪혔다. 게다가 외부 호스트가 정상 응답을 주는 글까지 전부 로컬 파일로 교체해버리니, 커밋이 쓔레기처럼 늘어나고 이미지 바이너리 diff가 노이즈가 됐다.
진짜 원인은 외부 호스트가 가끔 죽는 단일 장애점인데, 그건 막을 수 없으니 빌드 시점에 죽어있는 경우만 골라 self-host하는 게 맞았다. _self_host_image() 안에 HTTP HEAD 검증을 넣어, status 200 + content-type image/* + content-length ≥ 5KB이면 외부 URL을 그대로 쓰고, 조건이 깨질 때만 assets/og/로 다운받아 GitHub Contents API로 commit하는 fallback으로 정리했다.
- 외부가 죽은 글만 골라 self-host
- 정상이면 원래 URL 유지 — 커밋 노이즈 제거
- HEAD 검증 실패 시에만 로컬 다운로드
검증: 오늘 빌드에서 깨진 글이 fallback으로 정확히 1건만 self-host됐고(assets/og/2026-05-17-...png), 나머지는 외부 URL 그대로 살아남는 걸 확인했다. 남은 과제는 HEAD 검증이 301/302 리다이렉트를 따라가지 못하는 케이스인데, GitHub 외 CDN이 리다이렉트를 거는 경우라 한 번 더 로깅을 추가해뒀다.
리포트 자동 검증 스크립트 — 4,200자 상한에 걸린 문서를 check.py로 조기 차단한 건
보안업체 제안서를 쓰다 보니 항상 같은 실수가 반복됐다. 분량 상한(4,200자)을 넘겨서 제출 직전에야 깨지는 것. 이번엔 아예 파이프라인에 검증 스크립트를 넣기로 했다. check.py가 각 섹션별 글자 수를 세고, 에러 0 / 경고 0이 아니면 빌드를 실패시키는 방식이다.
처음엔 단순히 글자 수만 세면 된다고 생각했다. 그런데 막상 돌려보니 UTF-8 인코딩 문제가 터졌다. 파일을 open()할 때 인코딩을 지정하지 않으면 Windows 기본 cp949로 읽혀서, 한글 문서가 전부 깨진 문자열로 카운트되는 것. encoding='utf-8' 명시 후에야 정확한 값이 나왔다.
[SAMYANG] OK (4028자) 에러 0 / 경고 0— 상한 4,200자에 여유 172자[MEDYTOX] OK (4199자) 에러 0 / 경고 0— 상한 4,200자에 1자 여유[SFIRE] OK (4082자) 에러 0 / 경고 0— 첫 실행에서 통과[NICE] OK (4010자) 에러 0 / 경고 0
시행착오: [HANA]가 4,124자로 처음에 통과했는데, 알고 보니 내가 상한을 4,200이 아니라 4,000으로 잘못 세팅해놨던 거였다. 상한을 4,200으로 고치니까 에러 0 / 경고 0이 나왔다. 기준값이 틀리면 검증 스크립트가 오히려 거짓 실패를 만든다는 걸 그때 알았다.
남은 과제: 이 스크립트는 글자 수만 검증한다. 실제로는 문서 내 개인정보 포함 여부나 맞춤법 검사까지는 못 잡는다. 다음 단계로는 제안서마다 달라지는 “필수 포함 섹션” 존재 여부를 체크하는 기능을 추가할 계획이다.
OmniTrace LLM 레이어 — 스트리밍 게이트웨이가 한 번에 완성 응답을 돌려준 문제
OmniTrace에서 내가 직접 맡은 건 LLM 레이어였다. 팀이 수집·저장 인프라를 만들었고, 나는 분산 트레이스·서비스 토폴로지맵·지연 분포(P50/P95/P99)·에러 분석 등 React 대시보드 6개 뷰와 3계층 이상탐지를 맡았다. 그런데 게이트웨이 테스트 중에 이상한 증상이 하나 나왔다.
스트리밍 요청을 보냈는데 응답이 한 번에 도착하는 것. curl -N으로 SSE를 찔러봐도, Transfer-Encoding: chunked가 아니라 Content-Length가 먼저 찍혀서 내려왔다. 처음엔 클라이언트 쪽 버퍼링 문제인 줄 알았다. 그런데 게이트웨이 로그를 뒤져보니, 스트리밍 요청이 들어오면 게이트웨이가 업스트림에 일반 요청으로 바꿔서 보내고 있었다.
원인은 게이트웨이 라우팅 규칙에 있었다. /v1/chat/completions로 오는 요청을 판별할 때 stream=true 파라미터를 보지 않고, 단순히 URL 경로만 보고 업스트림으로 프록시하도록 돼 있었던 것. 그래서 stream 옵션이 무시되고, 업스트림이 완성된 응답을 한 번에 돌려준 거다.
해법: 게이트웨이에서 stream 필드를 명시적으로 검사하고, true면 Transfer-Encoding: chunked를 유지한 채 SSE로 내려보내도록 라우팅 로직을 수정했다. 이 과정에서 content-type을 text/event-stream으로 강제하지 않으면 일부 HTTP 클라이언트가 버퍼링한다는 사실도 확인했다.
검증: 수정 후 curl -N으로 다시 찔러보니 200ms 간격으로 토큰이 나뉘어 도착했다. P95 지연 시간이 기존 2.4초에서 1.1초로 줄었고, 대시보드에서도 스트리밍 응답이 제대로 분포에 잡히는 걸 확인했다. 남은 리스크는 SSE 연결이 중간에 끊길 때 재연결 처리인데, 이건 다음 스프린트 과제로 넘겼다.