Hazelfy 웹서비스 구축기 2편 1,8000페이지 그 후

지난 글 “공공데이터 API로 주차장 정보 사이트 18,000페이지를 자동 생성한 이야기”의 후속편이다. 그 사이 사이트는 배포됐고, 색인이 시작됐고, 아키텍처가 한 번 갈아엎어졌고, 정적 사이트에 실시간 데이터가 올라갔다. 2주간의 기록이다.

먼저 근황: 색인은 생각보다 빨랐다

헤이즐파이(Hazelfy, hazelfy.com)는 전국 공영·민영 주차장의 요금, 운영시간, 위치를 지역별로 비교할 수 있게 정리한 생활 정보 서비스다. 전국주차장정보표준데이터 약 18,000곳을 기반으로 페이지를 자동 생성하며, 현재 서울·경기 지역을 우선 배포해 운영 중이다.

배포 후 며칠 만에 네이버와 구글 양쪽에서 색인이 시작됐다. 신생 도메인은 색인까지 몇 주씩 걸린다는 게 통설이라 마음을 비우고 있었는데, 서치콘솔 색인 요청 + 사이트맵 + 이 블로그의 소개 글(백링크) 조합이 생각보다 빨리 일했다. 재밌는 건 검색 결과에 개별 주차장 페이지가 뜰 때 “가장 가까운 주차장은 약 552m, 가장 저렴한 곳은 시간당 2,400원” 같은 문장이 스니펫으로 뽑힌다는 점이다. 페이지마다 데이터에서 계산된 고유 문장을 넣는다는 원칙이 검색엔진에 정확히 읽히고 있다는 증거였다.

그리고 예상 못 한 일 — 네이버 AI 브리핑이 “hazelfy”를 검색하면 서비스를 요약해서 설명해준다. 출처를 보니 이 블로그의 지난 글이 주 재료였다. AI 검색 시대의 백링크는 링크 주스를 넘기는 파이프가 아니라, AI가 브랜드를 설명할 때 읽는 대본이 된다. 이 글을 쓰는 이유 중 하나도 그 대본을 최신으로 갱신하기 위해서다.

벽 1: Cloudflare Pages, 파일 2만 개의 벽

첫 번째 벽은 배포에서 왔다. Next.js 정적 내보내기(output: ‘export’)는 페이지당 HTML 하나에 RSC 페이로드(.txt) 하나를 만든다. 주차장 18,000곳 + 허브 + 집계 페이지를 전국으로 계산하면 약 44,000파일 — Cloudflare Pages 무료 티어의 빌드당 20,000파일 제한을 정면으로 초과한다.

선택지는 셋이었다. 유료 플랜(10만 파일), Vercel 이전, 아니면 구조를 바꾸는 것. 결론은 세 번째였다:

hazelfy.com/* → 라우터 Worker → 경로 프리픽스별로 R2 버킷 디스패치 + 엣지 캐시

R2는 오브젝트 스토리지라 파일 수 제한이라는 개념 자체가 없다. 빌드는 그대로 정적으로 돌리고, 산출물을 rclone으로 R2에 델타 싱크하고, 앞단의 얇은 Worker가 요청 경로를 보고 버킷에서 꺼내 캐시와 함께 서빙한다. SSR로 돌아선 게 아니라 “정적 파일의 저장소와 서빙 계층”만 바꾼 것이다. 니치가 늘어나면 버킷을 하나 더 만들면 되니, 파일 수 상한은 사실상 소멸했다.

전환 과정에서 얻은 교훈 하나 — 엣지 캐시를 쓰면 배포가 끝이 아니다. 캐시 버전을 올려도 이미 엣지에 앉아 있는 HTML은 TTL만큼 낡은 채로 서빙된다. 배포 직후 zone 캐시 퍼지까지가 배포다.

벽 2: 정적 사이트에 “지금 주차 가능 대수”를 띄울 수 있을까

주차장 정보 사이트의 숙명적인 질문은 “그래서 지금 자리 있어?”다. 표준데이터에는 총 주차면수만 있지 실시간 현황이 없다. 그리고 우리는 서버가 없는 정적 사이트다.

해법은 역할 분리였다. 페이지는 계속 빌드 타임에 정적으로 굽고, 실시간 숫자 하나만 클라이언트에서 가져온다. 다만 브라우저가 지자체 API를 직접 부르면 CORS, http 혼합 콘텐츠, 키 노출 삼중고가 터지므로, 아까 그 라우터 Worker에 프록시 엔드포인트를 하나 얹었다. 브라우저 → Worker → 상류 API 순으로 흐르고, Worker가 소스별 응답을 {총면수, 현재, 여유}로 정규화한 뒤 60초 캐시로 상류를 보호한다.

현재 붙은 소스는 둘이다. 서울시 열린데이터광장의 시영주차장 실시간 정보(123곳 중 122곳 매칭, 실시간 수신 109곳), 그리고 한국공항공사의 전국 13개 공항 25개 주차장 실시간 만차율. 공항은 아예 전용 섹션(/parking/airport/)으로 만들었고, 외국인 수요를 고려해 영어·중국어·일본어 페이지와 hreflang까지 얹었다. 소스 추가가 설정 한 줄 수준이 되도록 범용 구조로 짰기 때문에, 실시간을 제공하는 지자체가 늘어나는 만큼 위젯도 늘어난다.

이 과정의 잔교훈들: 공공 API 키는 이미 URL 인코딩된 상태로 발급되는 경우가 있어 encodeURIComponent(decodeURIComponent(key))로 정규화해야 이중 인코딩 사고가 없고, PowerShell에서 파이프로 시크릿을 넣으면 ‘r이 붙어 30자 키가 31자가 되는 지옥을 경험할 수 있으며, 에러 응답은 절대 캐시하면 안 된다(장애가 60초씩 박제된다).

벽 3: 원본 데이터를 의심하라 — 좌표가 1km 틀린 주차장

“역촌역 근처 주차장” 페이지에서 실제로 역 옆에 있는 공원 주차장이 빠져 있었다. 알고리즘 버그인 줄 알고 하버사인 거리 계산을 세 번 검산했는데, 범인은 원본 데이터였다. 행안부 표준데이터의 좌표가 실제 위치에서 1km 어긋나 있었던 것. 파주의 어떤 주차장은 42km를 순간이동해 있었다.

전수 검증 파이프라인을 만들었다. 각 주차장의 주소를 지오코딩해서 저장된 좌표와 비교하고, 0.5km 이상 어긋나면 지오코딩 좌표로 교체, 결측이면 보충한다. 결과: 교정 153곳, 결측 보충 834곳, 정상 유지 17,314곳. 표본 중앙값 오차가 3m였으니 데이터의 99%는 멀쩡했다 — 그래서 전량 교체가 아니라 임계 초과분만 손대는 게 맞다. 번지 없는 주소는 지오코딩이 동 중심점으로 뭉개지기 때문에, 원본이 대체로 옳다는 전제를 버리면 오히려 데이터를 망친다.

이 장르(공공데이터 재가공)의 진짜 차별화는 화려한 기능이 아니라 이런 검증 노동이라고 생각한다. 페이지마다 데이터 기준일을 표기하는 것도 같은 맥락이다. 정보 서비스의 신뢰는 “우리 데이터가 어디서 왔고 언제 것인지”를 숨기지 않는 데서 시작된다.

전략 전환: 지도 앱과 정면승부하지 않는다

출시 전 세운 가설 하나는 깨졌다. “○○주차장”이라는 지명형 검색은 네이버 지도와 플레이스가 최상단을 독식한다. 신생 사이트가 개별 주차장 페이지로 정면승부하면 진다.

대신 지도가 구조적으로 답하지 못하는 질문에 자리를 잡았다. “이 구에서 제일 싼 주차장은?”, “월 정기권 저렴한 곳은?”, “설 연휴에 무료 개방하는 주차장은?” — 데이터 전체를 쥔 쪽만 답할 수 있는 비교·순위·집계형 질문이다. 그래서 시군구별 최저가/무료/월정기권 집계 페이지를 만들고, 지하철역·랜드마크 반경 800m의 “근처 주차장” 페이지를 246개 역에 깔고, 행정안전부의 명절 무료개방 주차장 10,074곳을 지역별로 정리했다. 개별 주차장 18,000페이지는 삭제하지 않고 이 집계 페이지들을 받치는 하부 구조로 강등시켰다.

같은 데이터로 “데이터 리포트”도 시작했다. 창간호는 서울 자치구별 주차장 확보율 — 등록 차량 대비 주차면 비율이 최저 자치구 112.7%에서 최고 214.8%까지 1.9배 벌어진다는 걸 공공데이터로 집계했다. 다음 호는 전국 주차요금 격차를 다룬다. 블로그가 베낄 수 없는 콘텐츠는 전체 데이터셋에서만 나온다.

다음 계획

단기로는 서치콘솔 색인 신호를 보며 배포 지역을 순차 확장하고, 9월 추석 무료개방 데이터가 공개되면 명절 섹션을 갱신한다. 중기로는 두 번째 니치(휴일·심야 약국)를 같은 파이프라인 위에 올린다. 포털 구조와 서브디렉토리 확장 설계는 처음부터 그걸 위해 깔아뒀다.

헤이즐파이는 서버 비용 0원으로 돌아가는 사이트다. 정적 빌드 + 오브젝트 스토리지 + 엣지 Worker 조합이면 개인 개발자도 수만 페이지 규모의 데이터 서비스를 운영할 수 있다. 공공데이터는 이미 공개돼 있다. 흩어져 있을 뿐이다.

hazelfy.com / 정보 수정 제보와 문의는 사이트의 문의 페이지로.