이상욱
어려운 것이 아니라, 아직 안 해본 것뿐입니다.
에어소프트건 직구 가격 비교 서비스
만든 이유
샵이 여러 곳이라 가격을 비교하려면 창을 여러 개 띄워야 했고, 샵마다 상품명도 달라서 같은 물건인지 확인하기 어려웠습니다. 영어 검색이 어려워 직구를 포기하는 지인들도 있었습니다. 그래서 수집을 허용한 해외 샵들의 상품을 한곳에 모으고, 배송비·관세·검사비까지 더한 예상 도착가를 보여주는 사이트를 만들었습니다.
구조
[Windows 작업 스케줄러] 04:00 / 18:30
│
▼
샵별 수집기 (Shopify 공용 / 샵 전용)
│ 점증 백오프 재시도, 요청 간격·상한
▼
병합 전 검증 ── 직전 대비 80% 미만이면 덮어쓰기 취소
│
▼
매칭·가격 이력 생성 → 정적 JSON
│
▼
Cloudflare Pages 배포 → 배포 후 라이브 확인 → 실패 시 Discord 알림
[감시 창] 마지막 성공 후 17시간 넘으면 자동 재크롤 (self-heal)
[Google Apps Script] PC 외부에서 1시간마다 점검, 20시간 미갱신 시 Discord 알림
문제와 해결
1. 샵마다 구조가 전부 다름
샵별로 수집기를 하나씩 만들려고 했습니다.
각 샵의 구조를 파악해서, 같은 방식(Shopify 등)은 공용 수집기로 묶고 구조가 다른 샵은 전용 수집기를 만들었습니다.
→ 7종 플랫폼과 자체 구축 2곳, 19개 샵 약 14만 상품을 하나의 데이터로 통합
2. 봇 차단과 요청 제한
간격을 두고 요청을 계속 보내면서, 얼마나 많이 요청하면 제한이 걸리고 제한이 얼마나 오래가는지 알아보려 했습니다.
요청 사이에 딜레이를 두고 순차로 보내며 상한을 걸었습니다. 그래도 429(요청 제한)나 5xx(서버 오류)가 오면 15초, 30초, 45초로 대기 시간을 늘려가며 최대 3번 다시 요청했습니다. 403이나 404처럼 다시 보내도 결과가 같은 응답은 재시도하지 않고 바로 중단했습니다.
async function fetchPage(url) {
let res = await fetch(url, { headers: { 'User-Agent': UA } });
// 429·5xx만 재시도. 403·404는 다시 보내도 같으므로 바로 반환
for (let attempt = 1; attempt <= 3 && (res.status === 429 || res.status >= 500); attempt++) {
const wait = 15000 * attempt; // 15s → 30s → 45s
await sleep(wait);
res = await fetch(url, { headers: { 'User-Agent': UA } });
}
return res; // 실패면 호출부가 중단을 판단하고, 3번의 안전장치가 기존 데이터를 지킴
}
3. 부분 수집으로 인한 데이터 훼손 위험
봇 차단으로 수집이 중간에 끊기는 걸 겪으면서, 그 상태로 저장되면 멀쩡한 데이터가 덮어써질 수 있다고 판단했습니다. 실제로 훼손된 적은 없고, 설계 단계에서 미리 막아두기로 했습니다.
데이터를 합치기 전에 직전 수집량과 비교해서, 80% 미만이면 덮어쓰기를 취소하고 기존 데이터를 유지했습니다. 1,000개 중 100개 정도는 단종이나 매진으로 자연스럽게 빠질 수 있어 기준을 80%로 잡았습니다.
→ 차단된 회차에도 데이터 무손실, 다음 정상 회차에 자동 복구
4. 일부 상품이 수집되지 않음 (페이지네이션 함정)
크롤링을 해도 수집되지 않는 상품이 있어서 추적해보니, 한 샵에서 약 1만 개 중 211개만 수집되고 멈추고 있었습니다. 처음에는 요청한 개수(250개)보다 적게 오면 마지막 페이지라고 판단했습니다.
// 처음 종료 조건
if (batch.length < LIMIT) break; // 250개보다 적게 오면 마지막 페이지
그 샵은 250개를 요청해도 페이지마다 211개, 235개처럼 들쭉날쭉하게 응답했고, 첫 페이지부터 마지막으로 오판해 멈춘 것이었습니다. 그래서 "적게 오면 끝"이라는 판단을 버리고, 빈 페이지가 오거나 받아온 상품이 전부 이미 본 것일 때만 멈추도록 바꿨습니다. 상품은 id로 중복을 제거했습니다.
// 바꾼 종료 조건
if (batch.length === 0) break; // 빈 페이지면 끝
if (batch.every(p => seen.has(p.id))) break; // 전부 이미 본 상품이면 끝
// seen은 Set — 상품이 1만 개를 넘어도 확인 비용이 늘지 않음
→ 211개 → 약 1만 개 수집
5. 조용한 배포 실패
데이터 수신이 원활하지 않은 곳에서 휴대폰으로 사이트를 보다가, 날짜가 최신으로 바뀌지 않은 것을 보고 배포가 실패하고 있었다는 걸 알았습니다. 실패해도 알림이 없어서 우연히 보기 전까지 몰랐습니다.
배포 직전에 토큰을 자동 갱신하고, 배포 후 실제 사이트에 반영됐는지 확인하며, 실패하면 Discord로 알림이 오도록 했습니다.
→ 배포 실패를 우연히 발견하는 대신 알림으로 바로 인지
6. 감시 프로세스의 단일 장애점
감시 프로세스를 작업 창의 백그라운드로 띄워두면 충분하다고 생각했습니다. 그런데 7월 25일 감시가 스스로 종료된 뒤 약 17시간 동안 데이터가 갱신되지 않는 것을 알아채지 못했습니다. 감시가 로그온 세션에 붙어 있어서, 세션이 내려가면 감시도 함께 내려가는 구조였습니다.
감시를 세션과 무관한 독립 실행으로 분리하고, 마지막 크롤 완료 후 17시간 안에 성공 기록이 없으면 스스로 다시 크롤하는 self-heal을 넣었습니다. 처음에는 14시간이었는데, 정상 운영 중 최대 간격이 약 16시간이라 매일 저녁 오작동 직전까지 가 있던 것을 발견하고 17시간으로 고쳤습니다.
PC가 통째로 꺼지면 PC 안의 감시도 함께 멈추기 때문에, PC 바깥의 Google Apps Script가 1시간마다 점검해 데이터가 20시간 넘게 바뀌지 않으면 Discord로 알리도록 했습니다. 20시간은 가장 긴 크롤 간격(14시간 30분)에 크롤 소요 시간을 더해도 오탐이 나지 않도록 잡은 값입니다. 운영 중인 PC를 끌 수 없어서, 기준을 1시간으로 낮춰 알림이 오는 것을 확인한 뒤 되돌렸습니다.
→ 17시간 자동 복구 → 20시간 사람 호출의 단계형 대응
사용자 반응과 반영
에어소프트 커뮤니티(구독자 약 2만 4천 명)에 사이트를 공개하고, 업데이트마다 글을 올려 의견을 받았습니다.
- 404 제보 → 배포 전에 크롤러를 다시 돌리지 않아, 그사이 판매가 내려간 상품이 남아 있던 것이 원인이었습니다. 최신 데이터로 다시 반영했고, 처음부터 계획해둔 자동 갱신이 붙으면서 같은 문제는 반복되지 않았습니다.
- 두 단어 모두 포함 검색 요청 → 당일 반영했습니다. 부분 문자열로 비교해서 다른 단어 안에 포함된 경우도 걸리는 한계는 사용자에게 그대로 안내했습니다.
- 수량 버튼을 누르면 NaN이 뜨는 버그 → 수정했습니다.
- 폐업한 샵이 목록에 있다는 지적 → 확인 후 제외했습니다.
- 수집을 거부한 샵 추가 요청 → robots.txt와 이용약관을 확인하고, 거부한 곳은 운영자에게 직접 허가를 요청해 이메일·라인으로 명시적 허가를 받은 3곳만 추가했습니다. 한 곳은 "가격·재고가 바뀔 수 있음을 명시해달라"는 조건을 걸어 사이트 안내문에 반영했습니다. 모든 수집기는 봇 이름과 연락처를 밝히고 요청합니다.
다시 만든다면
- 처음부터 공통 요청 함수로 만들겠습니다. 요청·재시도 코드가 수집기마다 복사돼 12곳에 따로 있고 방식도 제각각입니다. 정책 하나를 바꾸려면 12곳을 고쳐야 했고, 실제로 한 곳은 옛날 방식으로 남아 있었습니다.
- 샵별로 갱신을 감시하겠습니다. 지금 알림은 전체 데이터가 멈춘 것만 잡습니다. 샵 하나가 정기 크롤에서 빠져 며칠간 갱신되지 않은 적이 있었는데 알아채지 못했습니다.
- 검색을 단어 단위로 비교하겠습니다. 부분 문자열 비교라 짧은 단어가 다른 단어 안에 포함되면 엉뚱한 결과가 섞입니다.
- 중간에 빈 페이지를 주는 샵에 대비하겠습니다. 지금은 빈 페이지면 끝으로 처리합니다. 그런 샵이 생기면 몇 페이지를 더 확인하도록 보강할 계획입니다.
TarGila — 게임 퀘스트·아이템 체크리스트 크롬 확장
화면은 AI 코딩 도구로 빠르게 만들고, 데이터 계층을 직접 설계했습니다.
- 의존하던 외부 GraphQL API가 3주간 다운되어 신규 사용자는 앱이 아예 뜨지 않았습니다. 계층별로 진단해 엣지가 아닌 백엔드 장애임을 특정하고 대체 REST API로 옮겼습니다.
- 대체 API는 텍스트를 플레이스홀더로 반환해 한글이 나오지 않았습니다. 두 API가 공유하는 표준 ID로 장애 직전 캐시에서 사전을 만들어 매핑했습니다. → 한글 커버리지 98.6%
- 대체 API의 선행 퀘스트 관계가 불완전해(221/517) 잠금 로직이 오작동했습니다. 라이브 값을 우선 쓰고 비어 있으면 캐시로 채우는 병합으로 바꿨습니다. → 선행 관계 94% 복구, 원 소스가 정상화되면 자동 전환