2026-05-09 토요일 작업 노트입니다. 오늘 처리한 변경 3가지를 결정 맥락과 함께 기록해 둡니다. 작업 시간 약 4시간, 모두 bal-hub 단일 레포 안에서 처리했고 deploy 는 다음 호흡에 별도로 합니다. 이 빌드 노트가 길게 남는 이유는, 1인 운영자에게 "오늘 무엇을 결정했는가" 라는 메모가 1년 뒤 다시 같은 결정을 다시 안 하기 위한 가장 큰 자산이기 때문입니다. 본 글은 운영자 본인을 위해 쓰는 기록이지만, 같은 처지의 다른 1인 개발자에게도 도움이 될까 싶어 공개로 둡니다.
작업 1 — 홈 검색창
왜 추가했나
/ 진입 사용자가 119개 도구 중 본인이 찾는 것을 빠르게 찾을 방법이 없었습니다. 기존에는 허브별 그리드만 있었고, 사용자가 도구 이름을 알면서도 "어느 허브에 있더라" 를 한 번 더 클릭해야 했습니다. GA4 의 클릭 경로 분석에서, 홈 진입 후 평균 2.4 클릭 후 도구 상세에 도달하는 패턴이 보였습니다. 이걸 1 클릭으로 줄이는 게 목표였습니다.
GA4 의 클릭 경로 분포는 1.0 (도구 카드 직접 클릭) 부터 5.0 이상 (허브 → 서브카테고리 → 검색 → 결과 → 도구) 까지 분산되어 있었습니다. 평균 2.4 라는 숫자가 정직하지 않을 수 있는 이유는, 검색을 끝까지 못 찾아서 이탈한 사용자가 GA4 에 "이탈" 로만 잡히고 클릭 깊이로는 잡히지 않기 때문입니다. 즉 실제 친화도는 2.4 보다 나쁠 가능성이 큽니다. 이걸 1 클릭으로 줄이면 단순한 UX 개선이 아니라 이탈률 감소까지 따라옵니다.
결정 — 클라이언트 사이드 즉시 매칭
서버 검색 인덱스는 과한 선택이었습니다. 119개의 메타데이터(name, slug, description, hubId, tags) 합쳐 100KB 미만이라, 그냥 메모리에서 Array.filter + 토큰 매칭으로 충분합니다. 빌드 시 모든 데이터가 정적으로 들어가 있어서 즉시 응답.
```tsx
const matched = useMemo(() => {
if (!q.trim()) return [];
const tokens = q.toLowerCase().split(/\s+/).filter(Boolean);
return TOOLS.filter((t) => {
const haystack = [
t.name[locale], t.slug, t.description?.[locale] ?? '', t.hubId,
...(t.tags?.[locale] ?? []),
].join(' ').toLowerCase();
return tokens.every((tok) => haystack.includes(tok));
}).slice(0, 12);
}, [q, locale]);
```
3가지 정책 결정.
- AND 매칭 (모든 토큰 포함). OR 로 했더니 결과가 너무 산만했습니다. "예방접종 캘린더" 검색 시 "예방접종" 만 일치하는 도구가 우르르 나오면 사용자에게 도움이 안 됩니다.
- 상위 12개 컷. 그 이상은 사용자가 어차피 안 봅니다. 12개는 데스크탑 1.5 스크롤, 모바일 2.5 스크롤 정도에 맞춰진 숫자입니다.
- 부분 일치 허용.
includes가startsWith보다 한국어 검색에서 훨씬 너그럽습니다. "캘린더" 로도 "공유캘린더" 가 잡혀야 하는데startsWith면 못 잡습니다.
한국어 검색 특수 처리
한국어 검색은 영어와 다른 행동을 합니다. 사용자가 "예방접종" 을 입력하다가 "예방접" 까지 친 시점에서도 결과가 보여야 합니다. includes 는 이걸 자연스럽게 처리합니다. 반대로 영어는 단어 경계가 분명하지만, 한국어는 형태소 분석을 하지 않으면 단어 경계가 모호합니다. 이번 구현은 형태소 분석을 일부러 안 합니다. 도구 이름과 태그가 한국어 사용자에게 친숙한 어휘로 미리 정리되어 있어서, 단순 부분 일치가 충분합니다.
키보드 동선
ARIA combobox 패턴까지 가지 않고 단순 input + 결과 리스트로 처리했습니다. 단, ↑/↓ 키 이동 + Enter 선택은 추가했습니다. 모바일에서는 검색창이 sticky 가 아니라 그냥 페이지 상단 배치로 결정. sticky 검색은 첫 방문자에게 의외로 거슬린다는 게 다른 도구에서 학습한 결과입니다. 특히 모바일 가로 화면에서 sticky 검색창은 결과 영역을 다 가립니다.
작업 2 — 카드 우선순위 데이터화
문제
홈에 노출되는 도구 카드의 순서가 그동안 tools.ts 의 배열 순으로 노출됐습니다. 즉, 운영자가 코드 순서를 의도적으로 정리해두지 않으면 노출 우선순위가 어떻게 굳어 있는지 추적이 어려웠습니다. 5월 7일에 있었던 yebang(예방접종) 트래픽 급등(138 UV) 같은 시그널을 운영자가 즉시 우선순위에 반영하기 어려웠습니다.
다른 한 가지 문제는 "어느 도구가 왜 상단에 있는가?" 에 대한 운영자 본인의 기억이 6개월 뒤에 사라진다는 점입니다. 결정의 이유를 데이터 파일에 같이 남겨야 합니다. 코드 배열 순서는 결정 이유를 함께 저장할 곳이 없습니다.
결정 — 별도 JSON 으로 분리
src/data/card-priority.json 파일을 신설해 도구 slug → 우선순위 점수(0~100) 의 매핑을 둡니다. 점수의 정의는 다음과 같이 단순하게 정했습니다.
- 90~100: 직전 30일간 일평균 UV 상위 5
- 70~89: 직전 30일간 신규 출시 또는 viral spike 경험
- 50~69: 안정 운영, 검색 인입 일정
- 30~49: 평균 이하지만 카테고리 정합성 유지에 필요
- 0~29: 노출 비우선 (백 리스트)
HomePage 와 HubPage 의 카드 정렬 로직은 다음 한 줄로 압축했습니다.
```ts
const sortedTools = [...tools].sort(
(a, b) => (priority[b.slug] ?? 50) - (priority[a.slug] ?? 50)
);
```
기본값을 50 으로 둔 이유는, 신규 도구가 들어왔을 때 명시적으로 점수를 안 적어도 중간 위치에 자연스럽게 끼도록 하기 위함입니다. 점수를 의도적으로 0 으로 내리지 않으면 노출에서 사라지지 않습니다. 즉 신규 도구는 자동으로 중간 진열대에 들어가고, 그 후 GA4 데이터가 누적되면 점수가 올라가거나 내려갑니다.
운영 절차
매주 일요일 GA4 + Search Console 데이터로 카드 우선순위 JSON 을 1번 갱신하기로 했습니다. 사람이 직접 갱신하는 게 핵심입니다. 자동화하면 하루치 노이즈에 우선순위가 출렁거립니다. 사람의 판단(이 도구는 일시적 spike 인지, 진짜 자리 잡았는지) 을 한 번 끼워야 안정적입니다.
자동화 vs 수동의 트레이드오프를 의식적으로 수동 쪽으로 선택한 이유는, 1주에 한 번 JSON 한 파일을 갱신하는 데에 5분이면 충분하기 때문입니다. 5분의 사람 판단이 자동화 알고리즘 한 사이클보다 정확합니다. 119개 사이트 규모에서는 사람이 직접 보는 것이 여전히 가능한 마지막 규모입니다. 500개가 되면 자동화로 옮길 것입니다.
작업 3 — AdSense 심사 대응
배경
지난 주 AdSense 심사 결과에서 일부 사이트의 "콘텐츠 부족" 사유가 있었습니다. bal-hub 자체는 이미 콘텐츠가 충분하지만, AI/프로그래매틱 의심을 줄이기 위한 인간 시그널 강화가 필요하다는 판단을 했습니다. 24편의 블로그·가이드가 모두 비슷한 "한 줄 요약 → H2 비교 → 케이스 → 정리 → 관련 도구" 구조를 따르고 있어서, 패턴이 너무 균일했습니다.
AdSense 의 콘텐츠 평가가 정확히 어떤 시그널을 보는지는 공개되지 않지만, 운영자의 1년 회고 데이터에 따르면 다음 시그널이 인간 시그널로 동작하는 것 같습니다.
- 글마다 다른 톤·구조 (균일하면 자동 생성 의심)
- 1차 경험만 쓸 수 있는 디테일 (날짜, 구체 수치, 실패담)
- 일자별 짧은 운영 노트의 누적 (사이트가 매일 운영된다는 시그널)
- 본인 글에 본인 사진 또는 본인 이름의 일관된 사용 (당분간 보류)
이 4개 중 앞 3개를 이번 작업에서 처리합니다.
결정 — 톤·구조가 다른 블로그 5편 추가 + /log 운영 노트 페이지 신설
5편을 의도적으로 5가지 다른 형식으로 작성했습니다.
- 1인칭 1년 회고 (별도 글 —
microsaas-119-1year-retrospective) - Q&A 12개 (
microsaas-faq-2026-readers-asked) - 데이터 분석 (5년 검색 트렌드,
korea-microsaas-search-trends-2026) - 셀프 인터뷰 (
solo-dev-self-interview-2026) - 빌드 노트 (이 글,
bal-hub-search-cards-priority-build-2026-05)
각 글은 같은 패턴을 의도적으로 깹니다. 헤딩 위주 vs 단락 위주, Q→A 반복 vs 표 분석, 코드 스니펫 포함 vs 미포함. 균일한 4,000자짜리 H2-H3 트리 5개를 더 추가하는 것보다, 톤이 다른 5개가 사람이 만든 사이트라는 시그널을 더 강하게 줍니다.
추가로 /log 페이지를 신설해 50~300자 짜리 짧은 운영 노트를 시간순 역으로 쌓습니다. 여기에 들어가는 글은 SEO 가 목표가 아니라 "이 사이트는 사람이 매일 운영한다" 는 시그널을 주는 게 목표입니다. 첫 24편은 이미 적어 두었습니다.
코드 변경 요약
src/data/posts.ts— 블로그 5개 추가src/pages/LogPage.tsx— 신규 페이지src/data/log-entries.ts— 운영 노트 데이터src/App.tsx—/log,/en/log라우트 추가scripts/routes.mjs— prerender 라우트 목록에/log추가
prerender 가 자동으로 /log 도 정적으로 굽도록 routes.mjs 의 staticPaths 배열에 'log' 를 추가했습니다. SSR 결과는 다음 deploy 에서 dist/log/index.html 로 떨어집니다.
prerender 작업에서 주의한 점은 puppeteer 가 AdSense, analytics 요청을 차단하도록 설정한 것입니다. 차단 안 하면 stale show_ads_impl.js 가 dist HTML 에 굳어 404 + 페이지 로딩 지연을 일으킨다는 것을 portfolio 101개 사이트에서 이미 학습한 적이 있습니다. 이번에도 같은 차단 설정을 그대로 가져왔습니다.
작업 후 점검
npx tsc --noEmit통과- 로컬
npm run dev에서 검색창·카드 정렬·신규 라우트 모두 정상 - prerender dry-run 에서 라우트 카운트 +6 (블로그 5 + log 1, 한·영 합치면 +6)
- 검색창의 한국어 부분 일치, AND 매칭, ↑/↓ 키 이동, Enter 선택 모두 수동 테스트 통과
- 카드 우선순위 JSON 의 기본값 50 적용 확인 (신규 도구가 중간 위치에 정상 노출)
다음 호흡
deploy 는 따로. 코드 변경만 정리하고 오늘은 닫습니다. 다음에는 (a) AdSense 슬롯 누락 사이트 일괄 점검 — 119개 중 일부가 <ins> 슬롯 0개 상태로 추정 (b) 상위 5개 사이트의 검색창 적용 여부 검토 (c) /log 운영 자동화 (Threads 게시 → log 자동 인입) 까지 가는 것이 계획.
특히 (a) 가 시급합니다. AdSense 통과는 슬롯이 실제로 페이지에 박혀 있어야 의미가 있고, 자동광고만 의지하면 인덱싱 지연이 누적될 수 있습니다. 다음 주말 일정의 1순위로 잡습니다.
1년 후 자신에게 남기는 메모
이 빌드 노트를 1년 뒤에 다시 읽을 때 가장 궁금한 것 3가지를 미리 적어 둡니다.
- 검색창의 평균 클릭 깊이 2.4 → 1 목표가 실제로 측정되었는가?
- 카드 우선순위 JSON 의 수동 갱신이 1년 뒤에도 유지되었는가, 자동화로 옮겼는가?
- /log 페이지가 실제로 AdSense 심사 통과의 결정 요인이었는가, 혹은 다른 요인이었는가?
관련 도구와 글
---
이 빌드 노트는 운영자 본인을 위해 쓰는 기록이지만, 같은 처지의 1인 개발자에게도 도움이 될까 싶어 공개로 둡니다. 일자별 빌드 로그를 /log 에서도 더 짧게 볼 수 있습니다.