WebSquare 크롤링 해부 + 조용한 버그 두 개

26년 08월 04일

WebSquare 사이트는 딥링크가 안 된다

나는 법원경매 데이터 파이프라인을 운영하는 사이드 프로젝트에서 하루 종일 크롤링 아키텍처를 해부하고, 흩어진 API 클라이언트를 정리하고, 조용히 데이터를 갉아먹던 버그 두 개를 잡았다. 세 작업이 다르게 보이지만 결국 같은 문제로 귀결됐다 — 어디서 데이터가 들어오고, 어디서 변형되고, 어디서 저장되는지 경로를 끝까지 추적하지 않으면 어떤 버그도 제대로 못 고친다는 것.

법원경매정보 사이트를 크롤링하려면 먼저 이 사이트가 보통의 웹 페이지가 아니라는 걸 받아들여야 한다. WebSquare라는 프레임워크로 만들어진 이 사이트는 물건 상세 페이지로 들어가도 URL이 바뀌지 않는다. 흔히 쓰는 “GET URL에 식별자 붙여서 바로 접근” 패턴이 통째로 안 된다는 뜻이다.

상세 데이터는 POST body로만 로드된다. csNo, cortOfcCd, dspslGdsSeq 같은 식별자를 URL에 붙여서 직접 들어가면 “부모 객체를 찾을 수 없습니다” 에러가 뜨고, 새로고침하면 검색 폼으로 튕겨 나간다. Playwright로 실제 브라우저를 띄워서 클릭하는 수밖에 없었던 이유다.

화면 전환은 setSrc(url, dataObject) 패턴으로 동작한다. 물건 식별자가 URL이 아니라 dataObject.data에 담겨 인메모리로 전달되기 때문에 서버 사이드에서 fetch 한 번으로 DOM을 가져오는 일은 불가능하다. 거기에 X-Frame-Options: SAMEORIGIN 헤더까지 붙어 있어서 iframe 임베드도 차단된다. 결국 Playwright 같은 실제 브라우저 컨텍스트만이 유일한 접근 경로다.

WAF를 피하는 코드는 함부로 손대지 않는다

이 제약을 먼저 받아들이고 나니 그 동안 코드에 남아있던 redirect: 'manual', throwHttpErrors: false, retry: 0 같은 설정들이 왜 그렇게 생겼는지 이해가 됐다. WebSquare 세션과 WAF를 피하기 위한 의도적 선택이었다. 단순히 “코드가 안 예쁘다”고 ky.create() 인스턴스로 바꾸면 rate gating이나 WAF 감지 로직이 날아간다. 외부 사이트를 크롤링할 땐 이상적인 코드보다 “왜 이렇게 됐는지” 이해하는 게 먼저다.

API 클라이언트는 서비스 경계를 따라 모은다

같은 날 외부 API 클라이언트 4개를 src/api/{service}/ 디렉토리로 통합했다. 그 전엔 파일이 여기저기 흩어져 있어서 어느 클라이언트가 어느 엔드포인트를 호출하는지 한눈에 안 보였다.

정리 결과는 단순하다.

  • court-auction/ — 법원경매. WebSquare WAF 때문에 raw ky() 호출 유지.
  • market-price/ — 시장가. 표준 REST라 ky.create() 인스턴스화.
  • geocode/ — 지오코딩. 동일.
  • bldhub/ — 공공데이터. serviceKey 파라미터가 이중 인코딩되는 이슈가 있어 fetch 직접 호출 유지.

여기서 중요한 건 “전부 같은 패턴으로 통일”이 아니라 서비스마다 제약이 다르면 패턴도 달라야 한다는 점이다. ky.create()가 보기엔 깔끔하지만, WebSquare의 redirect: 'manual'이나 공공데이터의 인코딩 이슈 앞에서는 힘을 못 쓴다. 17개 파일을 옮기고 19개 consumer import를 업데이트한 뒤 152개 테스트를 전부 통과시켰을 때 비로소 “아, 이제 구조가 말이 되는구나” 싶었다.

조용히 데이터를 갉아먹던 버그 두 개

구조 정리가 끝난 뒤엔 항상 숨어있던 버그가 드러난다. 이날 잡은 건 두 개였고, 둘 다 “경계를 잘못 정의했다”는 공통점이 있었다.

JSONB 컬럼에 text를 넣는 실수

첫 번째는 JSONB 컬럼에 text를 넣으려던 실수였다. replace_property_photos 함수가 property_photos.ai_analysis 컬럼에 값을 넣을 때 r->>'ai_analysis'를 썼다. ->> 연산자는 항상 text를 반환한다. 근데 컬럼은 jsonb다. 그래서 전체 사진 저장이 조용히 실패하고 있었다.

재미있는 건 바로 옆에 있던 replace_property_analysis 함수는 ->를 정확히 쓰고 있었다는 거다. 형제 함수가 정답 패턴을 이미 보여주고 있었는데 한쪽만 오타가 났던 셈이다. 코드 리뷰에서 형제 함수랑 대조해보니 바로 보였다. PostgreSQL JSONB를 다룰 땐 ->(jsonb 반환)과 ->>(text 반환)의 차이를 절대 가볍게 보면 안 된다.

--limit 단위 착각

두 번째는 CLI --limit 플래그의 단위 착각이었다. --limit 5를 주면 물건 5개만 처리할 줄 알았는데 실제로는 공고 5개를 처리하고 있었다. 법원경매 공고 하나에 물건이 최대 14개까지 딸려 있어서 --limit 5를 줬는데 69건이 적재됐다.

원인은 단순했다. 외부 루프는 공고를 순회하면서 limit을 체크했지만 내부 루프는 공고 안의 모든 물건을 순회하면서는 break를 안 걸고 있었다. 내부 루프에 if (processedProperties >= limit) break; 한 줄을 추가하니까 바로 잡혔다. 사용자가 --limit의 의미를 어떻게 이해하느냐와 코드가 실제로 어느 단위를 세고 있느냐가 어긋나면 아무 에러도 없이 데이터가 부풀려진다. 이게 제일 위험하다.

남은 문제와 코드 주석 한국어화

크롤링 아키텍처를 전면 조사하면서 아직 못 고친 데이터 갭이 하나 있다. 매각기일과 매각결정기일을 구분하지 못하고 있는 건데, 입찰일과 결과확인일이 섞여들면 권리분석 정확도에 직결된다. 다음엔 이 타입 분리부터 들어가려 한다.

그리고 코드 주석을 영어에서 한국어로 일괄 번역했다. 병렬 워커 3~5개를 띄워서 파일 단위로 돌리고 번역 후엔 typecheck를 돌려서 로직 변경이 없는지 확인했다. 주석만 바꿨는데 타입 에러가 뜨면 안 되니까. 함수 설명, 파라미터 주석, TODO까지 전부 한국어로 바꿨고 외부 API 문서 인용이나 라이브러리 공식 명칭은 그대로 뒀다. 모노레포 전체가 한국어 주석으로 통일되니까 다음에 들어올 사람(아마 미래의 나)이 읽기 훨씬 편해질 것 같다.