법원경매 정보를 모아보는 개인 프로젝트(nakchalai)에서 이미지 파이프라인 작업을 하루 종일 했다. 사진 12.6만 장을 저장한 GCS 버킷에 캐시 헤더를 붙이는 백필 작업을 끝냈고 Next.js의 런타임 이미지 최적화가 사실상 중복이라는 걸 실측으로 확인하고 CDN 직접 서빙으로 전환했다. 같은 날 크롤러 장애 대응 구조를 3계층으로 정리했고 사진을 지우는 연산 직전에 정말 지워도 되는지 되돌아보는 계기도 있었다. 하루 동안 배운 판단들을 정리해보려 한다.
12.6만 객체에 cache-control 백필하기
사진 파일명은 nanoid로 생성해서 URL이 영구적으로 불변이다.
그래서 public, max-age=31536000, immutable 헤더를 붙이기로 했다.
1년짜리 캐시에 변경 없음을 알리는 값인데, 불변 URL에 정확히 맞는 설정이다.
백필 스크립트를 처음에 new Storage()로 작성해서 로컬에서 돌렸더니 13,000건 전부 인증 실패가 나왔다.
성공 0건이다.
로컬에 application-default credentials이 없으면 ADC 경로는 전부 거절당한다.
경고 로그가 정상 로그처럼 보여서 한동안 인식을 못 했다는 게 더 문제였다.
실패 카운터가 진짜 신호였다.
프로젝트에서 이미 쓰던 getStorage() 함수로 자격증명 처리를 포함해 클라이언트를 만들고 단일 객체 하나로 PATCH 테스트를 통과한 뒤에 전체를 돌렸다.
스크립트는 멱등하게 설계했다.
cacheControl이 없는 객체만 PATCH하고 이미 맞는 헤더를 가진 객체는 스킵 카운터로 넘긴다.
그 덕분에 중간에 pkill -9로 끊어도 재실행이 안전했다.
실제로 재실행했을 때 스킵 처리가 초당 약 3,000건까지 가속돼서 전체 126,346객체를 5.3분 만에 확인했다.
PATCH보다 열 배 빠른 속도다.
멱등 스킵을 설계에 넣어두면 “잠깐 취소하고 다시”가 사실상 공짜가 된다.
한 가지 더 기억에 남는 건 버킷이 살아 있다는 관찰이다. 백필하는 동안 프로덕션 크롤러가 계속 사진을 올리고 있어서 객체 수가 조금씩 늘어났다. 생산자가 살아 있는 버킷의 백필은 한 번에 끝날 수 없는 chasing 상태다. 그래서 운영 순서를 정리했다. 새 코드를 먼저 배포해서 업로드 시점에 헤더가 자동으로 붙게 만들고 그다음에 크롤링이 끝난 뒤 마지막 스윕을 한 번 돌린다. 멱등 설계라 마지막 스윕은 1~2분이면 끝난다.
런타임 이미지 최적화는 중복이었다
이 작업의 출발은 비용 확인이었다. CDN 송신 요금이 걱정돼서 확인해봤는데, 실측해보니 CDN이 아니라 크롤러의 쓰기·삭제 연산이 비용 대부분이었다. Cloudflare 캐시가 정상 동작 중이었고 오리진은 하루 25회 이하로 불리고 있었다. 걱정이 기우였다.
대신 구조적인 문제를 하나 발견했다.
홈 화면의 GCS 썸네일 217개가 전부 /_next/image 프록시로 들어가고 있었다.
브라우저에서 Cloud Run을 거쳐 Cloudflare로 다시 GCS로 가는 이중 경로다.
/_next/image는 Vercel 전용 기능이 아니라 Next 서버 내장 기능이라서 자체 호스팅이면 요청마다 CPU 비용을 낸다.
그런데 배치 파이프라인이 업로드 시점에 이미 높이 500px webp로 최적화를 끝낸 상태였다.
실측해보니 원본이 요청 폭보다 이미 작아서 리사이즈 자체가 불가능했다. 얻는 건 재인코딩 차이 1.2KB, 절감률 3.5%다. 오차 범위 수준이다. 이미 webp라 포맷 변환도 없다. 512MB 인스턴스에서 sharp의 CPU만 소모하는 구조였다.
Next 16.3의 loaderFile로 전환했다.
커스텀 로더를 쓰면 GCS 호스트 이미지가 Cloudflare 엣지에서 바로 응답된다.
여기서 문서와 실제 동작이 다른 부분이 있었는데, images.loader는 문자열 enum만 허용해서 인라인 함수를 넣으면 스키마 검증이 거절한다.
loader: "custom"과 images.loaderFile 조합이 공식 경로다.
또 하나, remotePatterns 검증이 내장 로더 안에 구현돼 있어서 커스텀 로더로 넘어가면 참조되지 않는다.
죽은 패턴을 제거해도 안전하다는 뜻이다.
원칙을 하나 정리했다. 최적화는 업로드 시점의 1회 비용에 붙이고 런타임의 요청마다 비용에서 떼낸다.
크롤러 장애 대응을 3계층으로 정리
법원 API를 길게 긁는 크롤러는 상류 장애를 자주 만난다. 지난 며칠 동안 죽은 원인을 계층별로 분리해서 대응 구조를 정리했다.
첫째, 서버가 20초 침묵하는 무응답(status=0)을 종전에는 차단으로 오판해서 프로세스가 죽었다. 이제는 물건 단위 실패로 강등하고 진짜 차단 신호만 세션을 종료한다. 연속 6회 실패 시 강등, 성공 시 리셋이다.
둘째, 서류 서버가 장애 창에 들면 법원당 수백 건 대상에 3회 재시도를 모두 소진했다. 실측으로 실패 2,442회가 누적된 뒤 60분간 신규 0건이 정체된 사례가 있었다. 현황과 명세 레인의 카운터를 분리하고 한쪽이 연속 10회 실패하면 해당 레인만 이번 실행을 건너뛴다. 다음 실행에 재수집하면 되니 수집 의미는 같고 낭비만 없어진다.
셋째, csNo라는 사건번호 필드의 오염이었다.
법원 API가 병합 사건번호를 br 태그가 섞인 통짜 문자열로 내려보낸다.
같은 원인이 태그 모양이 달라져서 서로 다른 증상으로 두 번 터졌다.
<br /> 변형은 법원 서버를 20초 무응답으로 만들었고 </br> 변형은 기존 정규식에 잡히지 않아 68ms 만에 500을 냈다.
500이 정상 응답보다 빠르게 돌아온다는 건 백엔드 처리 실패가 아니라 능동 거부라는 신호다.
오염된 행 35건의 숫자와 500 발생 횟수가 정확히 일치하는 것까지 확인해서 단일 헬퍼로 3곳을 통합하고 송신 전 가드를 붙였다.
법원 API 500이 0이 됐다.
여기서 타임아웃 설계의 실패 기록도 남긴다. 고정 10초 타임아웃은 큰 응답(최대 47MB)에서 탈락했다. 크기 비례 타임아웃은 청크 전송에는 content-length가 없어서 무의미했다. 다음으로 시도한 stall 감지는 동작했지만 그 뒤에 붙인 watchdog이 하루 4번 프로세스를 죽였다. watchdog은 HTTP 활동만 봐서 GCS 업로드나 sharp CPU 작업 중에는 핑이 안 들어간다고 오판한 것이다. 진짜 hang은 0건이었다. 전부 오판이었던 셈이라 watchdog을 전면 제거했다. 제거 뒤 계층별 안전망(stall 감지, 연결 타임아웃, 상위 실패 시 종료, 러너 재시작)만 남겼더니 치명 오류 0으로 1시간 14분을 연속 생존했다.
삭제 직전에 되돌아본 8,306행
DB에는 사진 행이 109,288건인데 로컬 파일은 77,779건이라 차이가 8,306행이었다. 얼핏 파일 없는 고아 행처럼 보여서 정리 대상이 됐다. 삭제 직전에 값 분포를 정밀하게 봤더니 반전이 있었다.
중복으로 보이는 행들의 URL당 행 수가 정확히 12, 24, 36, 42였다. 전부 12의 배수다. 규칙적인 배수 분포는 버그가 아니라 설계상 반복이라는 시그니처다. 한 사건의 관련 사진 12장을 같은 사건의 12개 물건이 공유하는 구조라서 중복이 정상이었다. 그대로 삭제했다면 유효 데이터 8,306행을 날릴 뻔했다.
행 수 차이만 보고 고아라고 판단하지 말고 삭제 전에 값 분포를 먼저 봐야 한다. 특히 정확한 배수 패턴이 보이면 설계상 중복일 가능성이 높다.
같은 맥락에서 사진 보존 가드도 붙였다. 상세 조회가 실패하면 사진이 빈 배열로 내려오는데, 이때 기존 사진을 지워버리는 위험이 있었다. 사진은 재수집이 불가능한 자산이라 빈 배열일 때 삭제 쿼리, 덮어쓰기, 고아 정리 3곳을 전부 건너뛰게 만들었다. 실패한 물건은 상태를 discovered로 강등해서 다음 스윕에 재수집한다.
하루가 여기서 끝나지 않았다
같은 날 Playwright MCP 작업도 있었다.
agent가 쓰는 playwright 설정이 일회용 브라우저만 띄우는 --isolated 모드라서 로그인 세션이 있는 실제 크롬에 붙는 --extension 모드를 추가로 등록했다.
번들을 역분석해보니 이 모드가 뜻밖의 위험을 갖고 있었다.
--extension 서버는 사용자의 실제 크롬을 자기 프로세스의 직계 자식으로 spawn한다.
그런데 idle-killer 확장이 자손 프로세스를 정리하는 구조라서 세션이 idle 상태가 되면 사용자의 실제 브라우저 전체가 종료되는 시나리오가 있었다.
chrome-extension:// URL 마커 가드와 —extension 토큰 매칭 가드, 두 겹으로 막았다.
가드별 독립 시나리오와 뮤테이션 테스트로 검증까지 끝냈다.
가드가 없을 때 죽고 있을 때 살아나는지를 전부 확인했다.
남는 것
오늘 작업을 관통하는 원칙은 측정이었다. CDN 비용 걱정은 실측으로 기우임이 밝혀졌고 런타임 최적화의 중복도 표로 확인하고 버렸다. 500의 원인도 응답 속도와 요청 밀도, 숫자 일치로 추적했다. 삭제 대상도 분포를 보고 되돌렸다. 추측 대신 측정. 당연한 말이지만 측정이 없으면 원칙은 그냥 말일 뿐이다.
다음에는 프로덕션 배포 후 마지막 스윕과 CDN 전환 효과 확인이 남았다.