무엇인가
collection-resilience-lab은 차단하는 서버와 수집 워커를 한 리포에서 함께 만든 실험실이다.
차단이 걸리고 그것을 알아채고 대응하고 다시 받아 오기까지를 같은 조건에서 반복해 잰다.
이름은 처음부터 "수집 안정화 실험실"로 불렀다.
만든 것은 셋이다.
속도 제한·출발지 차단·세션 만료를 스위치로 켜고 끄는 수집 대상 서버,
응답을 실패 일곱 종으로 나누는 수집 클라이언트,
그 분류마다 다르게 대응하는 큐 워커다.
대상 서버는 로그인, 2차 인증, 거래내역 조회를 흉내 낸다.
실패 분류와 첫 대응은 이렇게 짝지었다.
DLQ는 끝내 처리하지 못한 작업을 따로 모아 두는 큐다.
| 실패 종류 | 첫 대응 |
|---|---|
| 속도 제한(429) | 큐 전체를 멈춘다 |
| 출발지 차단 | 다른 출발지로 바꾸고 로그인부터 다시 한다 |
| 세션 만료 | 다시 인증한다 |
| 자격증명 실패 | 바로 DLQ로 보낸다 |
| 일시 장애 | 간격을 두고 다시 시도한다 |
| 파싱 실패 | 원본 응답을 남기고 DLQ로 보낸다 |
| 모르는 응답 | 원본 응답을 남기고 DLQ로 보낸다 |
왜 만들었나
외부 사이트를 대상으로 수집 실험을 하면 약관과 법을 건드리게 된다.
게다가 상대가 어떤 조건에서 막는지 모르니 같은 측정을 다시 할 수도 없다.
그래서 수집 대상 서버를 직접 만들었다.
차단 조건을 스위치로 켜고 끌 수 있으면 대응 로직을 같은 조건에서 반복해 잴 수 있다.
증명하려던 것은 하나였다.
수집이 막혔을 때 무엇 때문에 막혔는지 구분하고 구분한 대로 다르게 대응한다는 것을
실제로 돌아가는 코드와 측정한 수치로 보여 주는 것이다.
막힌 원인을 뭉뚱그리면 대응할 수단은 재시도 하나뿐이다.
잘못 대응하면 오히려 상황이 나빠진다.
속도 제한에 워커마다 따로 물러나기를 걸면 워커 수만큼 한도를 넘는다.
차단하는 쪽 설계는 보안관제에서 하던 일이라, 임계치를 정하고 대응을 걸고 오탐을 줄이던 일을 이번에는 반대편에서 다시 해 봤다.
어떻게 동작하나
수집 요청 API가 요청을 받아 큐에 넣고 작업 ID를 바로 돌려준다.
워커는 작업을 꺼내 로그인과 2차 인증을 거친 뒤 거래내역을 페이지마다 받는다.
응답이 실패면 분류기가 종류를 붙이고 워커는 그 종류에 정해진 처분대로 움직인다.
큐는 Redis 위의 BullMQ이고 출발지는 프록시 컨테이너 두 개다.
운용 원칙은 셋이다.
첫째, 속도 제한은 큐 단위로 지킨다.
첫 커밋에서 워커마다 따로 물러나는 방식을 버렸다.
기록된 이유는 "워커 3대가 각자 한도까지 요청하면 상대 서버 기준을 못 지킨다"였다.
둘째, 출발지는 출발지 차단일 때만 바꾸고 429에서는 바꾸지 않는다.
출발지 전환 커밋에 남긴 이유는
"속도 제한을 출발지 전환으로 피하는 것은 우회다"였다.
셋째, 제한 창보다 큰 작업은 받은 페이지부터 이어받고
큐 전체가 정해진 주기 동안 한 페이지도 받지 못하면 그때 DLQ로 보낸다.
구현·테스트·증거 기록·리뷰는 대부분 Claude Code가 했다.
수행 에이전트가 이슈 하나를 구현하면 그 맥락이 없는 별도 에이전트가 리뷰했고
리뷰에서 나온 지적은 재현한 뒤에만 반영했다.
커밋마다 결정 이유를 Constraint·Rejected·Not-tested 트레일러로 남겨서
버린 안과 확인하지 않은 것도 이력에 함께 남는다.
내가 결정한 것은 7건이다.
외부 사이트 대신 대상 서버를 직접 만든다.
막힘을 재시도 하나로 뭉뚱그리지 않고 실패 종류마다 다르게 대응한다.
출발지 차단을 즉시 DLQ로 보내던 것을 큐 전체 정지로 바꾼다.
제한 창보다 큰 작업은 이어받기와 진행 기반 상한으로 푼다.
진행이 없는 상태는 작업 단위가 아니라 큐 전체 단위로 센다.
측정 규모는 워커 2개 × 작업 200건이다.
출발지는 출발지 차단일 때만 바꾼다.
첫날 대상 서버 이슈 셋과 실패 분류 이슈 하나를 닫은 뒤부터는, 내가 그 이슈를 내 말로 설명할 수 있어야 이슈를 닫았다.
그 과정은 따로 적었다.
써 보니
표의 수치는 2026-09-27에 네 시나리오를 차례로 돈 실행 한 번(16분 39초)에서 나왔다.
시나리오당 1회라 n=1이고 편차는 모른다.
노트북 한 대의 Docker(colima)에 대상 서버·프록시·Redis를 띄우고
워커 프로세스 2개(concurrency 1)에 작업 200건을 넣었다.
작업 하나는 요청 10개로 이루어지고 요청마다 150ms 고정 지연을 넣었다.
세션 만료 시나리오에서는 세션 수명을 1초로 줘서 작업 도중에 만료가 일어나게 했다.
| 시나리오 | 완료 | 처리량(건/초) |
|---|---|---|
| 차단 없음 | 200/200 | 1.24 |
| 속도 제한 | 200/200 | 0.71 |
| 출발지 차단 | 200/200 | 0.59 |
| 세션 만료 | 200/200 | 0.96 |
네 시나리오 모두 실패 0, DLQ 0으로 끝났다.
기대와 달랐던 것은 출발지 차단이다.
출발지를 69번 바꿔서 작업은 모두 끝났지만 두 워커가 같은 출발지로 몰리는 바람에 두 출발지가 번갈아 막혔고
출발지가 모두 막힌 상황도 81번 있었다.
그래서 처리량이 속도 제한만 켰을 때(0.71)보다 낮은 0.59로 나왔다.
이 결과를 놓고 나는 고칠 곳이 출발지 수가 아니라 요청 속도라고 봤다.
회복 시간 지표에는 README에 적어 둔 한계가 있다.
이 지표는 실패가 멈춘 시점을 재지 않는다.
이 설계에서는 작업이 실패로 끝나지 않으므로, 회복 시간은 사실상 첫 실패 응답 뒤 작업 10건이 끝나기까지 걸린 시간이다.
계획은 짧게 잡혀 있었다.
둘째 단계를 3시간으로 잡았을 때 Fable 5.1 검토는 4.5~6시간이 걸릴 것이라고 경고했다.
프로젝트 전체는 6일, 커밋 63개로 끝났다.
실패와 고친 것
쓸 만한 교훈은 시나리오 표보다 앞 단계에서, 테스트가 통과했거나 통과할 줄 알았던 곳에서 실제 실행이 어긋난 기록에서 나왔다.
일어난 순서대로 적는다.
막혔던 일곱 건은 리포의 문제 해결 기록 문서에 증상·가설·증거·원인·수정 순서로 남아 있고
빈 컨텍스트 리뷰가 잡은 설계 결함 셋은 그 사이사이에 끼어 있다.
pnpm 11이 설정 스키마를 바꾸면서 옛 빌드 승인 키가 조용히 무시됐다.
install이 exit 1로 끝나서 모든 스크립트가 시작조차 하지 못했다.
기능 자체는 멀쩡했고 설정을 새 위치와 새 키 이름으로 옮겨서 풀었다.
세션 만료 헤더를 검증하는 테스트는 통과했는데, 실제 서버에 curl로 보내자 헤더 없는 401이 돌아왔다.
결함 두 개가 겹쳐 있었다.
쿠키 수명을 서버의 만료 시점과 같은 값으로 내려보내서 클라이언트가 먼저 쿠키를 버렸고
서버는 만료를 감지한 순간 세션을 지워서 두 번째 요청을 모르는 세션으로 분류했다.
테스트가 쓴 Fastify app.inject()는 쿠키를 헤더에 그대로 실어 보내므로, 쿠키 수명을 해석하는 쪽이 아예 없었다.
쿠키 수명을 고정값으로 떼고 만료 사실을 남기게 고친 뒤 회귀 테스트 3개를 더했다.
그 뒤로는 경계를 확인하려고 회차마다 실제 포트를 열고 curl 기록을 남겼다.
리뷰가 잡은 첫 결함은 빈 환경변수였다.
빈 환경변수가 비밀번호를 빈 문자열로 덮어썼다.
작업 다섯 건 만에 데모 계정이 잠겼고 그 뒤로는 올바른 비밀번호도 거절됐다.
빈 값과 공백만 있는 값은 설정하지 않은 것으로 보도록 고쳤다.
실패한 작업을 조회하면 가끔 실패 사유가 비어 있었다.
반복 15회 중 4회였다.
API가 작업을 먼저 읽고 상태를 나중에 읽었기 때문에, 그 사이에 실패로 넘어간 작업이 사유 없는 실패로 보였다.
상태를 먼저 읽도록 바꾸자 15회 중 0회가 됐다.
리뷰는 작업 ID에서 로그인 ID를 뺀 설계도 잡아냈다.
대상 서버는 로그인 하나에 계좌 하나를 묶는데 ID에서 로그인을 빼면 권한 없는 로그인이 먼저 낸 실패가
계좌 주인에게 영구히 전파되고 순서가 반대면 결과가 권한 없는 로그인에게 새어 나갔다.
로그인 ID를 다시 넣었다.
속도 제한을 켜면 큐가 멈췄다 풀리기를 되풀이했다.
워커 2개에 작업 4건을 넣었는데 90초 동안 1건만 끝났다.
제한이 풀리는 순간 두 워커가 동시에 출발해 한 창을 나눠 썼고 작업이 매번 처음부터 다시 받았기 때문에 주기마다 같은 결과가 반복됐다.
BullMQ의 속도 제한이 큐 전체를 멈춘다는 계획 문서의 가정도 절반만 맞았다.
워커 옵션에 limiter가 있을 때만 큐 전체가 멈췄다.
이 문제는 받은 페이지부터 이어받기와 진행 기반 상한으로 풀었다.
큐 전체 정지 테스트는 limiter를 빼도 통과했다.
이 사실은 고치려는 것을 일부러 빼고 돌려 본 대조 실행에서 드러났다.
테스트가 같은 순간에 출발한 것으로 간주해 빼 둔 50ms 구간 안에, 바로 잡아내야 할 요청이 들어 있었다.
제한 사건이 한 번만 일어나야 한다고 단언하도록 고치자, limiter를 뺀 코드에서 "expected length 1 but got 2"로 실패했다.
검사를 일부러 망가뜨려 보는 이유는 다른 글에 적었다.
리뷰는 출발지 차단의 처분도 잡아냈다.
출발지 차단을 바로 DLQ로 보내자, 2초 차단 동안 대기 중이던 5건이 전부 영구 실패가 됐다.
대상 서버의 차단은 스스로 풀리므로,
풀리는 시각까지 큐 전체를 멈추도록 바꿨다.
진행 기반 상한은 처음에 작업마다 셌다.
워커 2개가 한 창을 나눠 쓰자, 큐는 나아가는데도 경쟁에서 계속 밀린 작업이 DLQ로 갔다.
큐 전체로 세도록 바꿨고
그 구현에서도 리뷰가 결함 셋을 찾아냈으며 셋 모두 테스트로 재현한 뒤 고쳤다.
최종 리뷰에서는 확정된 1건과, 그럴듯하다는 수준에 머문 후보 중 재현으로 확인한 1건을 더 고쳤다.
마지막 문제는 실행 절차를 확인하다가 나왔다.
홈 밖의 임시 디렉터리에 새로 clone하고 docker compose up -d를 실행하자 프록시 컨테이너가 뜨지 않았다.
colima의 기본 설정은 홈 디렉터리만 VM에 공유하기 때문에, 호스트 파일을 마운트하던 프록시 설정이 VM 안에 없었다.
설정을 이미지에 넣었다.
남은 교훈은 셋이다.
app.inject()는 소켓과 쿠키 경계를 지나지 않으니 실제 포트에 보낸 curl 증거를 따로 남긴다.
테스트는 고치려는 것을 빼고도 실패하는지 대조 실행을 한 번 해 본다.
리뷰가 내놓은 사실 주장은 재현한 뒤에 반영한다.
지금 상태
첫 커밋은 2026-09-22이고 코드는 27일에 멈췄다.
28일에 README의 AI 사용 절을 지금 상태에 맞게 고친 커밋이 마지막이다.
활동한 날은 6일, 커밋은 63개다.
리포는 공개이고 MIT 라이선스다.
이슈 12개 중 11개를 닫았고 README 정리 이슈 #16만 열려 있다.
의존성 갱신을 제안하는 Dependabot PR 4건도 열린 채다.
알고 있는 한계는 README에 적어 뒀다.
출발지 차단 상태를 워커끼리 공유하지 않고 큐가 나아가는 동안 한 작업만 계속 밀리는 굶주림은 상한이 잡아내지 못한다.
#16이 열려 있는 것은 README 작업이 덜 돼서가 아니다.
README 정리는 9월 27일 PR #22로 머지됐고, 문제 해결 기록 두 건을 내 말로 설명하는 확인이 남아 있다.
Dependabot PR 4건은 코드를 멈춘 뒤인 10월 1일에 열렸다.
이 리포는 더 손대지 않고 지금 상태로 둔다.
#16과 Dependabot PR도 정리하지 않은 채 남겨 둔다.