이 블로그는 AstroPaper 를 기반으로 시작했지만, 그대로 두지 않았다. 2026-05 부터 시작해서 매일 발행하는 1인 콘텐츠 시스템 관점에서 석 달 남짓 부딪히면서 얹은 기능들을 정리한다. 각 항목은 “AstroPaper 기본 상태에서 뭐가 부족했나 → 어떻게 해결했나 → 결과” 3단으로.
왜 이런 글을 쓰나: 이 블로그의 코드는 GitHub 에 공개되어 있는데, 정작 왜 이런 결정들을 내렸는지 는 커밋 메시지에 흩어져있어서 한 번에 볼 수가 없다. 이 문서를 그 컨텍스트 로그로 삼는다.
살아있는 문서 — 새 기능이 추가되면 아래에 계속 append. 마지막 갱신 정보는 하단 참고.
Table of contents
Open Table of contents
- 시작점 — AstroPaper 는 훌륭하지만 개인 프로덕트로는 부족했다
- 얹은 것들 (문제 → 해법 매핑)
- 1. 시리즈 페이지
/series— 접기/펼치기 - 2. 플레이그라운드
/playground - 3. i18n — KR + EN 이중 언어
- 4. Sonnet 5 번역 자동화 파이프라인
- 5. 링크 체커 — 내부 + 외부
- 6. Mermaid 로컬 사전 렌더
- 7. Scratch / Inbox 워크플로우
- 8. 종료 프로젝트/포스트의 소프트 숨김 컨벤션
- 9. 발행 시각 필터 — 미래 pubDatetime 자동 제외
- 10. 보안 스크러빙 지침
- 11. 사이드바 · Featured · 시리즈 태그 시스템
- 12. 리디자인 — 톤과 리듬
- 13. SEO 강화 — JSON-LD 구조화 데이터 페이지 유형별 분기
- 14. Perf — 이미지 lazy loading + PNG → WebP + 폰트 preload
- 15. 포스트 하단 피드백 CTA — 댓글 시스템 없이 채널만
- 16. Markdown 사후 검증기 — 우발적 strikethrough 감지
- 17. 에디토리얼 리디자인 — 사이드바를 걷고 단일 컬럼으로
- 18. 발행 잔디 — 정적 사이트에서 매일 움직이게 하기
- 19. 포트폴리오 재구조화 — 성과 우선 카드와 게재 기준
- 20. 다이어그램을 실제로 쓰기 시작했다
- 21. SEO — 도메인이 갈라져 있던 것을 합치고 sitemap 을 덜어냈다
- 22. Vercel 을 떠나 서버를 직접 굴리기 시작했다
- 23. 방문 통계를 서버 로그로 다시 만들었다
- 24. 홈에 입력창을 얹었다 — 메뉴를 걷지 않고
- 1. 시리즈 페이지
- 공통 원칙
- 앞으로 (여기부터 계속 append)
- 이 문서에 대해
시작점 — AstroPaper 는 훌륭하지만 개인 프로덕트로는 부족했다
AstroPaper 가 잘 하는 것:
- 마크다운 기반 정적 사이트, 빠르고 SEO 친화
- 태그 · 검색 (Pagefind) · RSS · 아카이브 기본 탑재
- 다크/라이트 테마, 깔끔한 타이포그래피
- Astro 5 최신, 프레임워크 학습 곡선 완만
하지만 내가 원한 건:
- 매일 학습 로그를 쌓는 곳 → 주제별 시리즈 탐색 이 필요
- 국내 지향이지만 x/구글로 외국 방문자 유입 → KR + EN 이중 언어
- 인터랙티브 콘텐츠 (용어 시뮬레이션) → 플레이그라운드 라우트
- 종료된 프로젝트/시리즈를 삭제 없이 감추기 → 소프트 숨김 컨벤션
- 발행 후 회수 못 하는 실수 방어 → 보안 스크러빙 · 링크 체커 · 발행 시각 필터
- 반복 작업 자동화 → 번역 · 링크 · Mermaid 파이프라인
총론: 블로그를 “포스트를 저장하는 사이트” 가 아니라 콘텐츠 발행 시스템 으로 보기 시작. 도구 · 컨벤션 · 스크립트가 매 발행 반복의 마찰을 줄이도록 계속 얹었다.
얹은 것들 (문제 → 해법 매핑)
1. 시리즈 페이지 /series — 접기/펼치기
- 문제: AstroPaper 는 태그 기반이지만 “이 주제를 처음부터 순서대로 읽고 싶다” 는 흐름이 없었다. 태그 페이지는 시간 순서지만 시리즈의 정체성 (제목/설명/편수) 이 없음.
- 해법:
src/pages/series/index.astro— 하드코딩된SERIES배열이id·title·description·tag를 정의- 각 시리즈는 태그로 필터링 → 자동으로 그 시리즈에 편입
- 접기/펼치기 (
<details>/<summary>) 로 시리즈 4개가 페이지를 잡아먹지 않게 - Chevron 은 open 시 90° 회전 + accent 색
- 결과: LLM 공부 (19편) · 백엔드 공부 · AGV 자율주행 · 바이브코딩 용어 4개 시리즈가 한 페이지에 압축. 클릭 한 번으로 편 목록 확장. JS 0 (
<details>만)
2. 플레이그라운드 /playground
- 문제: 용어 설명 글을 쓰면 “이 상태는 hover, 이 상태는 disabled” 같은 걸 텍스트로만 설명해야 함. 독자가 감을 잡기 어려움.
- 해법: 별도 라우트
/playground/로 인터랙티브 페이지를 분리- UI 용어 playground (버튼 상태 · 애니메이션 · duration · easing 을 hover · 클릭으로 체험)
- DB 용어 playground (정규화 Update Anomaly · B-tree 검색 · 트랜잭션 rollback 시뮬레이션)
- API 설계 playground
- 결과: 용어 정리 글이 있고 그 밑에 “[playground 로 체험하기]” 링크 → 텍스트 + 인터랙션 조합
3. i18n — KR + EN 이중 언어
- 문제: 국내 방문자가 주지만 x (트위터) 통한 외국 유입 + 구글 검색 유입이 있음. 영어 없이 이탈.
- 해법:
- 콘텐츠 컬렉션을
src/data/blog/ko/+src/data/blog/en/로 분리 - 라우트 미러:
/en/posts/,/en/tags/,/en/about hreflang태그로 검색엔진에 언어 대응 표기- 사이드바 KO/EN 스위처 (현재 언어 하이라이트)
getPath()유틸이 파일 경로에서 언어 접두어를 붙임/제거- PostDetails · Tag · Sidebar 컴포넌트가
Astro.url.pathname으로 언어 감지 → 라벨 자동 스위칭
- 콘텐츠 컬렉션을
- 결과: 한 번의 발행으로 두 언어 사이트가 동기화. 발행 부담은 KR 만 씀. EN 은 자동 (아래 4번).
4. Sonnet 5 번역 자동화 파이프라인
- 문제: 42편의 KR 포스트를 EN 으로 옮기려면 수동은 불가능. 신규 글도 발행 즉시 EN 필요.
- 해법:
scripts/translate/아래 파이프라인- 모델: Claude Sonnet 5 (인트로 프라이싱 $2/$10 per MTok)
- 프롬프트 캐싱: 시스템 프롬프트를 캐시로 마킹 → 반복 호출 시 90% 절감
- 6개 validator — 코드 블록 개수 · 링크 URL · 이미지 경로 · heading 구조 · HTML 태그 · 길이 비율
- Anchor 자동 재작성: KR heading slug (
#인터페이스--규격만-정하고-구현은-상속받는-쪽) → EN heading slug 자동 매핑 (같은 위치의 heading 을 찾아 slug 계산) - CLI:
pnpm translate one <slug>·pnpm translate batch
- 결과: 편당 $0.05 로 EN 사이트 자동 유지. 총 44편 번역에 $2 이하. 편차 없는 톤 · 링크/이미지 무결성 검증까지 자동.
5. 링크 체커 — 내부 + 외부
- 문제: 발행 후 slug 리팩터링하면 옛 anchor 링크가 죽음. 외부 링크는 시간 지나면 404 (예: alistair.cockburn.us 개편으로 삭제됨). 사람 눈으로는 못 잡음.
- 해법:
pnpm links(내부, ~1초): post/anchor/asset/tag/route 5축 검증/posts/x— 파일 존재?/posts/x#anchor— heading slug 매칭? (github-slugger로 정확히 계산)/assets/...— public/ 아래 파일?/tags/x— 실제 사용된 태그?/about,/portfolio,/playground— 라우트 존재?
pnpm links:external(외부, ~30초): HEAD → 405/403/501 이면 GET fallback → timeout/transient 시 backoff retry × 2 → bot-blocked 호스트 (st.com, ragas 등) 는 error 대신 warn
- 결과: 첫 실행에서 깨진 anchor 13건 (KR 실오류 1 + EN 파이프라인 갭 12) + 외부 404 1건 자동 발견. 이후 매 발행 전 clean 유지.
6. Mermaid 로컬 사전 렌더
- 문제:
remark-mermaidjs를 Astro 파이프라인에 넣었더니 Vercel 배포에서 Chromium 실행 실패로 본문이 통째로 유실 (로컬 100KB → 라이브 15KB, H1 만 남음). 로컬에서는 정상. - 해법:
scripts/render-mermaid.mjs- 로컬에서 Playwright +
mermaid-isomorphic로 SVG 생성 - 콘텐츠 SHA256 hash (앞 16자) 로 파일명 →
public/assets/mermaid/<hash>.svg - MD 의
```mermaid블록을<img src="/assets/mermaid/<hash>.svg" ...>로 자동 재작성 - 첫 줄
%% alt: ...로 접근성 (mermaid 는%%를 주석 처리해서 렌더에 영향 없음) - Orphan 감지 (참조 없는 hash 파일) +
--gc로 정리
- 로컬에서 Playwright +
- 결과: 방문자 렌더 지연 0, 클라이언트 JS 0. Vercel 은 Chromium 안 태우고 이미지만 서빙. 이 사고와 회복 과정 자체가 이 파이프라인이 왜 필요한지 설명하는 학습 케이스가 됨.
7. Scratch / Inbox 워크플로우
- 문제: 반쯤 쓴 메모를 어디에 두고 언제 발행할지 매번 헷갈렸다. inbox 를 텍스트로만 관리하니 어디까지 처리됐는지 안 보임.
- 해법:
src/000-inbox.md— 짧은 메모 저장소. 세션 시작 시 Claude 가 “처리 대기” 영역만 스캔 → 자동 발행 시도. 처리된 항목은 취소선 + 발행 링크 붙어 “처리 완료” 로 이동.src/scratch/— 긴 자유 형식 메모..gitignored(로컬 전용). 명시적 지시 때만 처리 ("scratch/X 정리해서 올려줘").src/scratch/published/— 발행 완료 아카이브. 최상단에<!-- 📤 발행됨: ... -->태그.
- 결과:
ls src/scratch/한 번으로 “뭐가 작성 중이고 뭐가 발행됐는지” 파악. 발행 마찰 대폭 감소.
8. 종료 프로젝트/포스트의 소프트 숨김 컨벤션
- 문제: 포트폴리오 프로젝트가 종료됐을 때 삭제하면 링크 · 기록 · 검색 인덱스가 다 죽음. 그렇다고 노출하면 “지금도 하고 있나?” 오해.
- 해법:
_접두어 파일명 컨벤션- Astro Content Collections glob 로더 패턴:
**/[^_]*.md _edgebook.md같이 접두어 붙이면 컬렉션에서 자동 제외- 파일은 유지, 페이지만 숨김 → 향후 부활도 파일명 되돌리기 한 번
- Frontmatter 에
status: paused+period: "2026-06-08 ~ 2026-06-19"로 종료 정보도 보존
- Astro Content Collections glob 로더 패턴:
- 결과: 종료 프로젝트 (EdgeBook) 를 삭제 없이 페이지에서만 숨김. 기록은 그대로.
9. 발행 시각 필터 — 미래 pubDatetime 자동 제외
- 문제:
pubDatetime을 미래로 실수 설정하면 dev 서버에선 보이는데 프로덕션에선 조용히 숨겨짐. “발행됐다고 착각” 문제. - 해법:
src/utils/postFilter.ts의isPublishTimePassed필터가 프로덕션 빌드에서 미래 시각 포스트 제외 - 결과: 실수해도 프로덕션 배포까지 도달하지 못함. dev 화면 확인 후 발행됐다 착각하는 함정 방어.
10. 보안 스크러빙 지침
- 문제: 공개 GitHub + Vercel 배포라 본문/에러 로그/스크린샷/frontmatter 어디든 시크릿 · PII · 사내 URL 노출 시 즉시 회수 불가.
- 해법:
CLAUDE.md § 🔴 보안 스크러빙- 절대 금지 목록 (API 키 · JWT · OAuth secret · supabase URL · 카드 · PII)
- 발행 전 grep 의심 패턴 (32자리 hex,
eyJprefix,sk-*,Bearer근처 등) - 조치 흐름 — 마스킹만으로 부족한 경우 키 재발급 우선
- 메모 인용 원칙: “한 줄씩 읽으면서 이게 외부 노출돼도 되는가 체크 후 옮긴다”
- 결과: 자동화되진 않았지만 매 발행마다 리마인더 강제. 이번 세션에서 이 지침 덕분에 실제로 몇 건 걸러냈다.
11. 사이드바 · Featured · 시리즈 태그 시스템
- 문제: AstroPaper 기본은 홈이 최신 글 리스트만. 대표작을 강조할 방법 없음. 시리즈 편입은 수동 태그.
- 해법:
featured: truefrontmatter → 홈페이지 상단 별도 섹션- 사이드바 프로필 — 아바타 · 이름 · 롤 · 소셜 (GitHub · 이메일 · RSS) · 언어 스위처를 왼쪽 고정
- 시리즈 태그 —
LLM공부·백엔드공부·AGV·용어정리같은 전용 태그 →/series페이지가 자동 편입
- 결과: 편집 없이 컨벤션만으로 콘텐츠 큐레이션. 대표작 5편 항상 홈 상단.
- 이후 변경 (2026-07-27): 사이드바와 Featured 섹션은 17번에서 걷어냈다. 시리즈 태그 시스템만 그대로 남아 있고, 프로필은 홈 히어로로, 언어 스위처는 헤더로 옮겼다.
12. 리디자인 — 톤과 리듬
- 문제: 기본 AstroPaper 는 다크/미니멀. 개인 톤이 없음.
- 해법:
- Pretendard 폰트 (CDN 동적 서브셋, 한국어 가독성)
- 접기/펼치기 (
<details>/<summary>) 를 시리즈뿐 아니라 긴 TOC · 확장 정보에 활용 - Hover effects (accent 색 전환) 로 인터랙션 리듬
- Design log (
docs/design-log.md) — Phase 별 결정 누적
- 결과: 개인 톤 확립 + 결정 히스토리 보존. Phase 1 (레이아웃) → Phase 7 (i18n UI) 로 이어짐.
13. SEO 강화 — JSON-LD 구조화 데이터 페이지 유형별 분기
- 문제: 기본 상태에서는 모든 페이지가
@type: BlogPostingJSON-LD 를 emit. 홈페이지 · 시리즈 · 태그 페이지도 “블로그 글” 로 잘못 마킹됨.description·publisher·mainEntityOfPage·inLanguage같은 표준 필드도 누락. - 해법:
src/layouts/Layout.astro의structuredData를 페이지 유형에 따라 분기- 포스트 (
pubDatetime있음) → BlogPosting +description·url·mainEntityOfPage·inLanguage·publisher필드 추가 - 그 외 (
pubDatetime없음) → WebSite 스키마
- 포스트 (
- 결과: 구글 리치 스니펫에서 저자 · 발행일 · 언어 정확 인식. 홈페이지가 잘못 article 로 마킹되던 문제 해결.
14. Perf — 이미지 lazy loading + PNG → WebP + 폰트 preload
세 축 동시 최적화:
- 문제: 스크린샷 위주 포스트의 초기 페이지 로드가 무거움 (
public/assets/posts/총 62 MB). 아카이브 · 태그 페이지에서 목록 스크롤 시 뷰 밖 이미지까지 즉시 로드. Pretendard CSS 는 렌더링 차단. - 해법:
- 커스텀 rehype 플러그인 (
src/plugins/rehype-image-perf.mjs) — 첫 이미지는loading="eager" fetchpriority="high"(LCP 후보), 나머지는loading="lazy" decoding="async" pnpm images:webp— sharp 로 PNG → WebP 일괄 변환 스크립트. WebP 가 더 작을 때만 교체 (일부 소형 스크린샷은 PNG 가 오히려 압축률 좋음), MD 의 이미지 URL 도 자동 갱신, 원본 삭제- Pretendard CSS
<link rel="preload">로 폰트 CSS 조기 취득 → 렌더링 차단 완화
- 커스텀 rehype 플러그인 (
- 결과:
public/assets/posts/62 MB → 15 MB (75% 감소, 61개 변환). 목록/태그 페이지 스크롤 시 뷰 밖 이미지 지연 로드 → 첫 뷰 페인트 개선. LCP 후보 이미지는 여전히 우선순위 유지.
15. 포스트 하단 피드백 CTA — 댓글 시스템 없이 채널만
- 문제: 이 블로그는 학습 일지 성격이라 댓글창을 붙일 정도의 상호작용 압력이 없다. 그런데 About 페이지는 nav · sidebar · footer 어디서도 링크되지 않아서 사실상 이력서 · 채용용 랜딩으로만 쓰이고, 방문자가 오류 지적 · 보충 의견을 남길 창구가 사이드바 이메일 아이콘 하나뿐이었다. 아이콘이 작아 존재를 인지하기 어렵다.
- 해법:
src/components/Feedback.astro— 각 포스트 하단에 dashed border 박스. 두 개의 액션만 병렬 배치.- ① 이메일 pill (클릭 = 복사) — 이메일 주소 자체가 버튼. Clipboard API + “복사됐어요!” 시각 피드백 (배경 accent 반전 + 체크 아이콘) · 실패 시 텍스트 selection 폴백. GitHub · Vercel · Notion 이 쓰는 표준 패턴.
- ② GitHub Issue 열기 (제목 prefill)
mailto:는 국내 사용자 상당수가 안 쓰므로 배제. Gmail 컴포즈 URL 도 초기엔 뒀다가 뺐음 — 이유는 Naver/Kakao 메일 사용자에겐 무의미, Gmail 사용자도 결국 복사→붙여넣기가 자연스러워서 UI 중복.- i18n 대응 (KO/EN 문구 분기). 인트로 카피는 “질문 · 코멘트 · 다른 시각 환영합니다” — 능동형으로 부정/긍정 피드백 둘 다 받는 시그널.
- 결과: 댓글 시스템의 JS 로드 · 스팸 · 모더레이션 · 유령방 문제 없이 실질 피드백 채널만 확보. 성능 손실 0. 방문자가 “여기 저자에게 말할 수 있는 곳” 을 명시적으로 인지.
- 참고: 댓글 (giscus 등) 은 트래픽이 붙고 실제 피드백 압력이 생길 때 재검토. 지금은 CTA 만으로 충분하다는 판단.
16. Markdown 사후 검증기 — 우발적 strikethrough 감지
- 문제: GFM 파서가
~1.5~2주같은 숫자 범위 tilde 를 strikethrough (~text~) 로 오파싱해서 글자에 취소선이 그어지는 사고. 실제 발행글 3편에서 발견 (claude-api-streaming-ttft-and-events·fems-project-log-01·rag-from-scratch-embedding-and-similarity-search) — 사용자 눈으로만 발견되던 부류라 시스템적 감지 필요. - 해법:
- 컨벤션: 숫자 범위는 en dash
–사용 (예:1~2일→1–2일). Leading approximate~는약으로 (예:~1.5초→약 1.5초). Tilde 는 본문에서 원천 배제. - 검증기:
scripts/translate/validate.mjs에detectAccidentalStrikethrough(text)추가. 코드블록 · inline code · 의도적~~strike~~제외 후 single-tilde 짝을 스캔. 라인 번호와 스니펫 반환. - 번역 파이프라인 통합:
validateAll(kr, en)이 KR/EN 각각 실행.[KO]·[EN]프리픽스로 보고. 번역 직후 자동 검출. - Standalone 감사:
scripts/check-markdown.mjs+pnpm check:md— 전체 발행글 일괄 스캔. CI 통합 가능한 exit code (0/1).
- 컨벤션: 숫자 범위는 en dash
- 결과: 전체 108개 파일 스캔 → 기존 발행글 3편의 tilde 오파싱 자동 발견 · 수정. 앞으로 번역 시 자동 감지. 라인 번호 정렬을 위해 코드블록 스트라이핑 시 개행 보존 (
m.replace(/[^\n]/g, " ")) 로 실제 파일 라인과 일치.
17. 에디토리얼 리디자인 — 사이드바를 걷고 단일 컬럼으로
- 문제: 좌측 220px 프로필 사이드바가 모든 페이지에서 같은 정보를 반복하면서 본문 폭을 좁혔다. 더 큰 문제는 홈이었다. 최신 글 목록으로 바로 시작해서 “이 사람이 무엇을 하는가” 를 말하는 문장이 첫 화면에 없었다. 설명이 200자 넘는 글이 많아 카드 3장이 화면을 다 먹었고, 정작 이 블로그의 자산인 “얼마나 꾸준히 쓰는가” 는 어디에도 안 보였다.
- 해법:
page-narrow단일 컬럼 (max-w-5xl) 으로 전환.Sidebar.astro와page-grid유틸 삭제. 대상은 홈 · 포트폴리오 ·/posts·/tags·/archives·/series·/playground·/404· 포스트 상세 · About 전부.- 좌측 기준선 통일 — 브레드크럼 · 백버튼이
app-layout(max-w-3xl 중앙) 을 쓰고 본문은 max-w-5xl 이라 좌측 edge 가 어긋났다. 둘을 같은 컨테이너로 옮기고, 긴 글 페이지는 본문 컬럼을 중앙 정렬이 아니라 좌측 정렬 +max-w-app(48rem) 으로 제한. 5xl 을 그대로 열면 한국어 한 줄이 1088px 이 되어 가독성이 무너진다. - 헤더 재구성 — 로고 36px → 26px +
PARKHYO.IN워드마크, nav 축소 + hover 언더라인, 아이콘 24px → 17px. 사이드바에 있던 KO/EN 스위처를 헤더로 이관 (포스트 상세의 짝 페이지 override 경로도 함께 재배선). - 홈 글 목록을 한 줄 인덱스로 —
날짜 | 제목 | 시리즈(PostRow.astro). 노출 3편 → 8편. Featured 섹션 폐지,featured: true는 목록에서 ★ 로만 표시. 목록 페이지(/posts)는 골라 읽는 화면이라 카드 유지. - 한국어 줄바꿈 — 저장소에
word-break설정이 없어서자세한이자/세한으로 쪼개졌다.body에word-break: keep-all+overflow-wrap: break-word(keep-all 단독은 긴 URL 이 넘친다).
- 결과: 8개 페이지의 h1 · 본문 좌측 edge 가 1280px 뷰포트에서 모두 139px 로 일치. 375px 가로 오버플로 0. 대신 프로필 사진 · 이름은 홈 히어로와 About 에만 남고, 다른 페이지에서는 헤더 워드마크가 그 역할을 한다.
18. 발행 잔디 — 정적 사이트에서 매일 움직이게 하기
- 문제: 홈에 GitHub 스타일 발행 히트맵을 붙였는데 8월 1일에서 멈춰 있었다. 정적 사이트라 “오늘” 이 빌드 시점에 HTML 로 박제되기 때문이다. 마지막 배포가 7/30 이면 그 주 토요일에 고정되고, 새 글을 안 올리면 영원히 안 움직인다. GitHub 은 매 요청마다 서버가 그리니 이 문제가 없다.
- 해법:
- 날짜 계산을 브라우저로 — 발행이 있었던 날짜만 담은 맵(
{ "2026-07-30": 2, ... }, 38일치 약 700바이트)을data-counts로 심고, 로드 시 방문 시점 기준으로 창을 다시 계산해 셀 레벨 · 툴팁 ·aria-label· 기간 요약을 갱신한다. 재배포 없이 매일 앞으로 나아간다. - 날짜 키는 KST 고정 — 서버 집계(
SITE.timezone)와 클라이언트가 같은 기준을 써야 해외 방문자에게 발행 캘린더가 하루씩 밀리지 않는다. - 마지막 칸은 오늘 — 처음엔 7행 격자를 꽉 채우려고 그 주 토요일까지 그렸는데, 아직 오지 않은 날짜가 “0편” 셀로 그려지고 툴팁까지
2026-08-08 · 0편으로 떴다. 미발생을 미발행으로 표시한 셈이라 틀렸다. 마지막 열이 짧아지더라도 오늘에서 끊는다. - 셀 개수 동기화 — 서버가 그린 칸 수와 방문 시점 칸 수가 다를 수 있어(주 정렬 때문에 91~97 사이에서 오르내린다) 스크립트가 셀을 복제 · 제거해 맞춘다. 없으면 배포가 오래됐을 때 마지막 며칠이 잘린다.
- 범례는 실제 단계와 같은 수로 — 5칸을 그렸는데 레벨 매핑이 1을 건너뛰어 실제 데이터는 0 · 2 · 3 · 4 네 단계만 썼다. 안 쓰는 색이 범례에 남아 있었다.
- 날짜 계산을 브라우저로 — 발행이 있었던 날짜만 담은 맵(
- 결과: 최근 90일 롤링 창.
Date를 +30일로 속여 확인했을 때 창이 따라 이동하고 합계가 재계산된다(오래된 글은 창 밖으로 빠지므로 숫자가 줄 수 있다). JS 를 끈 방문자는 빌드 시점 창을 본다 — 서버 렌더 값을 그대로 남겨둬서 빈 화면이 되지는 않는다.
19. 포트폴리오 재구조화 — 성과 우선 카드와 게재 기준
- 문제: 포트폴리오 카드가 기간 / 역할 / 기술 / 설명을 나열하는
<dl>정의 목록이라, 측정된 결과(mAP 85.50%, LoRa 2km)가 설명 문단에 묻혀 있었다. 채용 담당자가 스캔하는 건 숫자인데. 게재 기준도 없어서 저장소 링크만 있는 프로젝트와 배포된 제품이 같은 무게로 놓였다. - 해법:
- 케이스 블록으로 교체 — 좌측 서술 + 우측 지표(측정값 큰 활자) 2단. 경력은 좌측 sticky 타임라인 + 우측 성과 카드 3열 + 담당 업무 2열 표.
- 스키마 확장 —
highlight { value, label }추가,responsibilities를{ k, v },outcomes를{ value, label }객체 배열로. 레거시 문자열도z.union으로 계속 받고 렌더러에서 정규화한다. - 게재 기준 — 접속 가능한 사이트가 있는 것만
/portfolio에 노출. 저장소 링크만 있는 건_prefix 로 숨긴다(파일은 보존). 이 기준을CLAUDE.md에 규칙으로 박았다. - 인테이크 템플릿 (
docs/portfolio-intake.md) — 새 프로젝트 정보를 그 프로젝트의 Claude 세션에 붙여넣어 받아오는 프롬프트. 블로그 세션은 그 저장소를 못 보지만 프로젝트 세션은 커밋 · 로그 · 설정을 직접 확인할 수 있다. 홍보 문구 금지 · 수치는 측정 조건 동반 · 시크릿 금지를 프롬프트 단계에서 강제한다.
- 결과: 카드가 숫자부터 읽힌다. “수치는 측정 조건과 함께” 규칙은 실제로 값을 했는데, 어떤 성능 개선치는 랩 투영값이고 실측은 훨씬 낮았다 — 조건 없이 큰 활자로 세웠으면 과장이 될 뻔했다.
20. 다이어그램을 실제로 쓰기 시작했다
- 문제: mermaid 사전 렌더 파이프라인(6번)을 만들어놓고 146편 중 3편에서만 쓰고 있었다. 도구는 있는데 습관이 없었던 셈이다. 특히 항만 도메인 시리즈처럼 프로세스와 계층이 많은 글이 표와 글머리표로만 되어 있어서, 읽어도 구조가 안 그려졌다.
- 왜 영상이 아니라 다이어그램인가: “글만 있으니 허전하다” 는 진단에서 영상 도입을 검토했는데, 이 블로그에서는 비용이 맞지 않았다. 이미지 62MB → 15MB 최적화(14번)와 정면으로 부딪히고, mermaid 를 Vercel 파이프라인에 넣었다가 본문을 날린 전례(6번)가 있어 렌더 단계를 또 늘리는 것도 부담이다. 무엇보다 정지 그림이 나은 대상이었다. 영상이 맞는 자리는 “시간에 따라 변하고 그 변화 자체가 요점일 때” 로 좁혀진다 — 이 저장소의 mp4 두 개(LED 점멸, PWM)가 정확히 그 경우다.
- 해법: 항만 시리즈 5편에 다이어그램 6개를 넣었다. 항만 구성 계층 · 수출 8단계 · 수입 6단계 · 환적 흐름 · 식별자 계층(MRN → MSN → HSN) · 기관 분산 구조. KO 와 EN 에 같은 블록을 넣어 렌더러가 콘텐츠 해시로 같은 SVG 를 공유하게 했다 (11개 블록 → 6개 SVG). 라벨은 기존 정책대로 한국어 유지,
%% alt:로 접근성 텍스트를 각각 붙였다. - 왜 3편에서만 쓰고 있었나: 지침을 열어보니 mermaid 문서가 처음부터 끝까지 “어떻게 렌더하는가” 였다. 정작 글을 쓸 때 보는 발행 절차의 시각 자료 지시는 “코드 블록 / 스크린샷 적극 활용” 한 줄뿐이라 다이어그램이 후보에조차 오르지 않았다. 도구는 있는데 발행 경로에 연결이 안 돼 있었던 것이다. 발행 절차에 트리거(3단계 이상 순서 · 계층 · 주체 간 흐름 · 분기)를 넣어 막았다.
- 같이 막은 구멍 둘:
- 미렌더 블록이 조용히 발행되는 경로 — Astro 에 mermaid 플러그인이 없어서
pnpm mermaid:render를 잊으면 다이어그램 대신 코드 블록이 그대로 나간다. 빌드도 링크 검사도 통과해서 사람 눈으로만 잡혔다.pnpm check:md에 미렌더 블록 탐지를 추가하고 발견 시 exit 1 로 실패시킨다. mermaid:gc가 사용 중인 SVG 를 전부 지우던 버그 — 고아 판정을 살아 있는mermaid블록 기준으로만 해서, 렌더 후 블록이<img>로 치환되고 나면 모든 SVG 가 고아가 됐다. 실제로 방금 커밋한 다이어그램 6개가 삭제돼 git 에서 복구했다.<img>참조까지 세도록 고쳤다.
- 미렌더 블록이 조용히 발행되는 경로 — Astro 에 mermaid 플러그인이 없어서
- 결과: 클라이언트 JS 0, 방문자 렌더 지연 0 을 유지하면서 구조가 보이게 됐다. 도구를 만드는 것과 쓰는 것은 별개라는 게 이번 교훈이다. 그리고 도구를 만들 때 “언제 쓰는가” 를 같이 안 적으면 안 쓰게 된다.
21. SEO — 도메인이 갈라져 있던 것을 합치고 sitemap 을 덜어냈다
“검색이 잘 되고 있나” 를 확인해보려다 몇 달째 신호가 두 호스트로 갈려 있던 것을 발견했다. Vercel 은 www.parkhyo.in 을 Production 으로 두고 parkhyo.in 을 307 로 www 에 넘기고 있었는데, 코드의 SITE.website 는 non-www 였다. 여기서 canonical · sitemap 581개 · hreflang · robots.txt 가 전부 파생되니 이런 모양이 된다.
- 문제: 실제로 본문을 서빙하는 www 페이지가 “원본은 내가 아니라 non-www 다” 라고 선언하고, 거기 가면 다시 www 로 튕겨 돌아온다. 구글은 리디렉트되는 canonical 을 무시하고 자기가 알아서 정한다. 즉 내가 지정한 대표 주소가 하나도 안 먹히고 있었다. 게다가 307 은 “임시” 라 두 호스트의 신뢰도가 합쳐지지도 않는다. 이걸 몰랐던 건 양쪽이 각각은 정상으로 보였기 때문이다 — Vercel 대시보드는 “Valid Configuration” 초록불, 사이트는 잘 열리고, 빌드도 통과한다.
curl -I로 응답 헤더를 직접 봐야 드러났다. - 곁다리로 나온 것 셋:
- 홈
<title>이Park Hyoin하나 — 한글 이름 “박효인” 조차 없어서 한국어 검색어가 하나도 안 잡혔다.description도 18자. - sitemap 581개 중 390개가 태그 페이지, 그중 187개(KO 93 · EN 94)가 글 하나짜리 목록이었다. 크롤러가 실제 글보다 얇은 페이지를 두 배 넘게 보고 있었다.
/en/과/en/about/이lang="ko"로 나가고 있었다 —<Layout>에htmlLang을 안 넘겨 기본값이 나간 것. 영문 페이지가 자기를 한국어라고 선언하는 상태라, hreflang 이 없는 것보다 이게 더 나빴다.
- 홈
- 해법:
- 도메인: Vercel Domains 에서
parkhyo.in을 Production 으로,www.parkhyo.in을 308 영구 리디렉트로 뒤집었다. 방향을 뒤집을 때 순서가 있다 — non-www 를 먼저 Production 으로 올리고 www 에 리디렉트를 걸어야 한다. 반대로 하면 그 사이에 순환이 생겨 사이트가 잠깐 죽는다. - sitemap 다이어트:
src/utils/thinTags.ts가 마크다운을 직접 읽어 언어별로 “글이 1편뿐인 태그” 를 집계하고,astro.config.ts의 sitemapfilter가 그 목록을 뺀다. 페이지 자체는 그대로 생성한다 — 사이트 안에서는 탐색에 쓰이니까.astro.config.ts는astro:content를 못 쓰므로gray-matter로 frontmatter 를 파싱하되, 판정 기준(draft 제외 · 발행 시각 경과)은postFilter.ts와 맞췄다. - 메타데이터: 홈 KO/EN 에 이름(한글·로마자)과 다루는 주제를 담은
title·description을 넣었다. 문구는 전부 사이트에 이미 있던 표현(히어로 문장 · About · 시리즈 이름 · 지표 라벨)에서 가져왔다. - hreflang:
staticHreflang()을 만들어 홈 · About 의 KO/EN 짝을 연결했다. 포스트 상세는 KO/EN 짝을 찾아 계산하지만 고정 페이지는 경로로 정해져 있어 만들어주면 된다.htmlLang도 같이 채웠다.
- 도메인: Vercel Domains 에서
- 여기서 한 번 틀렸던 것: 얇은 태그 필터를 처음 붙였을 때 187개 중 92개만 걸러졌다. 한글 태그가 sitemap URL 에서 퍼센트 인코딩되기 때문이었다 —
/tags/하드웨어/가/tags/%ED%95%98%EB%93%9C...로 나와서 라틴 문자 slug 만 대조에 성공했다. 숫자가 예상과 다른 걸 보고 확인해서 잡았다. 필터에decodeURIComponent를 넣어 해결. - 결과: sitemap 581 → 394, 태그 페이지 390 → 203. 포스트 74편은 하나씩 대조해 전부 남아있는 것을 확인했다. canonical · sitemap · hreflang · robots.txt 가 전부 리디렉트 없이
parkhyo.in한 곳을 가리킨다. 네이버는http://parkhyo.in으로 등록돼 있던 것을https://로 새로 등록했다 — 구글은 도메인 속성이라 프로토콜을 다 덮지만 네이버는 http 와 https 를 아예 다른 사이트로 본다. - 관측 기준선: 정리 직전 Search Console 기준 2026-06-13 ~ 08-31 총 클릭 65회 (하루 0.8회). 이 숫자가 어떻게 움직이는지가 이번 작업의 답이 될 것이다. 색인이 옮겨가는 동안 한동안 떨어질 수도 있다.
22. Vercel 을 떠나 서버를 직접 굴리기 시작했다
- 문제: Vercel 은 잘 돌아갔다. 문제는 의존이었다. 댓글을 붙이거나 DB 를 두거나 메일을 받으려 할 때마다 “여기서는 안 되니까” 로 막혔고, 그때마다 남의 플랫폼 사정에 맞춰 방법을 찾아야 했다. 인프라를 직접 만져보고 싶기도 했다.
- 먼저 바로잡은 오해: 이전을 결심하기 전에 “Lightsail 로 가면 댓글도 되고 DB 도 되나” 를 물었는데, 막고 있던 건 Vercel 이 아니라 백엔드가 없다는 사실이었다. Vercel 에서도 어댑터를 붙이면 다 된다. 실제로 갈리는 건 기능이 아니라 운영 책임이다. 그걸 알고 나서도 가기로 한 이유는 통제권과 학습이었다.
- 해법: 빌드는 CI, 서버는 서빙만.
서버에서 pnpm build 를 돌리지 않는 이유가 있다. 빌드 중에 OG 이미지 152장을 resvg 로 렌더하고 Pagefind 색인을 만든다. 512MB 인스턴스에서는 메모리로 넘어지고, 넘어지면 사이트가 아니라 배포가 멈춘다. GitHub Actions 가 빌드하고 dist/ 만 rsync 로 보내면 인스턴스를 작게 유지할 수 있다.
- 덤으로 얻은 것: 릴리스를 옆에 받아두고 심볼릭 링크만 바꾸는 방식이라, 배포가 실패하면 직전 릴리스가 그대로 서빙된다. Vercel 때는 빌드가 깨지면 그 상태가 반영됐는데 지금은 실패한 배포가 사이트를 건드리지 못한다. 롤백도 링크를 되돌리면 끝이다.
- 재현해야 했던 것: 닷새 전에 정리한 도메인 규칙(21번)을 nginx 설정으로 다시 써야 했다. www → apex 308 경로 보존, HTML 은
max-age=0 must-revalidate,/_astro/는 1년immutable, HSTS 유지. 전환 전에--resolve로 Host 헤더를 붙여 IP 로 직접 검증하고 넘겼다. - 결과: DNS 전환 후 응답이 이전과 일치했다. 색인 이동을 기다렸다가 하려 했는데, 호스팅 이전은 크롤러에게 보이지 않는 변경이라 (호스트명 · 경로 · 상태 코드 · canonical 이 전부 그대로고 IP 만 바뀐다) 미룰 이유가 없었다. 미뤄야 하는 건 URL 이 바뀌는 변경일 때다.
도중에 걸린 것 다섯
하나같이 “왜 안 되지” 로 몇 분씩 잡아먹은 것들이다.
| 증상 | 원인 |
|---|---|
unknown directive "http2" | 독립 지시어 http2 on; 은 nginx 1.25 부터다. Ubuntu 24.04 는 1.24 라 listen 443 ssl http2; 형식을 써야 한다 |
| 설정을 고쳤는데 서버가 옛 파일을 받음 | raw.githubusercontent.com 이 5분 캐시를 물고 있다. 쿼리 파라미터로는 안 뚫리고, URL 에 커밋 해시를 박아야 한다 |
| 인증서 발급이 두 번 실패 | TXT 를 넣었는데도 dns1 에만 있고 dns2 에는 없었다. Namecheap 은 자기 네임서버끼리도 시차가 있다. Let’s Encrypt 가 어느 쪽에 물어볼지 모르니 둘 다 확인하고 진행해야 한다 |
| 로그를 지웠는데 새로 안 생김 | 유닉스는 열려 있는 파일을 지워도 프로세스가 계속 쓴다. 이름만 사라지고 실체는 nginx 가 붙들고 있었다. nginx -s reopen 이 필요하다. 애초에 rm 대신 truncate -s 0 이 맞다 |
curl $R 이 옵션을 못 알아봄 | zsh 는 변수를 공백으로 쪼개지 않는다. R="--resolve a:b:c" 를 bash 처럼 쓰면 옵션 하나로 넘어간다 |
그리고 전환 전에 알아챘어야 했던 함정이 하나 더 있었다. Vercel 이 이미 HSTS 를 2년으로 보내고 있었다. 방문자 브라우저에 캐시되어 있으니, 전환 시점에 유효한 인증서가 없으면 브라우저가 경고 화면조차 없이 접속을 거부한다. “일단 넘기고 인증서 받자” 가 통하지 않는 상황이었고, 그래서 DNS-01 로 미리 받아뒀다.
23. 방문 통계를 서버 로그로 다시 만들었다
- 문제: Vercel 을 떠나면서
@vercel/analytics를 걷어냈다. 안 걷으면 방문자마다/_vercel/insights/script.js를 요청하고 nginx 가 404 를 준다. 그런데 대체가 없었다. Umami 는 Postgres 가 필요해 512MB 인스턴스를 넘긴다. - 해법: nginx 접근 로그를 GoAccess 로 분석한다. 상주 프로세스가 없고 시간마다 잠깐 돌아 정적 파일을 만들고 끝난다. 클라이언트 JS 를 안 쓰니 광고 차단기에 막히지 않는다.
- 화면을 다시 만든 이유: GoAccess 기본 리포트는 패널이 15개쯤 되는 영문 고밀도 화면이라 뭘 봐야 할지 알 수가 없었다. 보고 나서 할 일이 없는 것(브라우저 · OS · 방문 시간대 · 접속 IP · 지역 · 검색어)을 숨기고, 다섯 개만 남겨 블로그 톤의 페이지로 다시 그렸다.
| 남긴 것 | 이걸로 뭘 하나 |
|---|---|
| 일별 방문자 · 페이지뷰 | 지난 기간 대비 추세. 절대값은 안 본다 |
| 많이 읽힌 글 | 152편 중 실제로 읽히는 것. 뭘 더 쓸지 정하는 근거 |
| 유입 경로 | 구글 · 네이버 · 직접 비율. SEO 작업의 성적표 |
| 404 | 깨진 링크. 이전 후유증이 여기 먼저 찍힌다 |
| 상태 코드 | 평소엔 안 봐도 되지만 5xx 가 뜨면 즉시 |
크롤러는 별도 페이지로 뺐다. 사람 통계에서는 빼는 게 맞지만 “이전 후에도 구글봇 · Yeti 가 오는가” 는 따로 확인해야 한다. 크롤러가 끊기면 색인이 서서히 빠지는데 검색 순위로 드러날 땐 늦다.
-
여기서 두 번 틀렸다.
하나, 봇 필터가 샜다. 첫날 “크롤러 제외 실방문 60명” 이 나왔다. Vercel 시절이 하루 5.6명이었으니 10배다. 방문당 페이지도 5.3 이었는데, 사람이 블로그에서 평균 5.3페이지를 보지는 않는다. 로그를 열어보니 실체는 이랬다.
<내 집 IP> 123회 나 자신 curl/8.7.1 60회 검증하며 돌린 것 TikTokSpider 27회 Let's Encrypt 10회 Applebot 7 · Amazonbot 6 · Bytespider 6 404: /.env · /.git/HEAD · /hudson ← 취약점 스캐너GoAccess 의
--ignore-crawlers는 알려진 이름 목록 방식이라 TikTokSpider · Bytespider · curl 을 놓친다. 그래서 사후 필터를 포기하고 nginx 가 기록 단계에서 로그를 나누게 했다.map으로 UA 와 경로를 판정해access.log(전체)와human.log(사람만)로 따로 쓴다. 사후 grep 과 달리 GoAccess 가 파일을 직접 읽으므로 증분 처리도 그대로 작동한다.둘, 폴백이 데이터를 영구히 오염시켰다.
human.log가 아직 없을 때access.log로 대신 읽고 경고만 띄우게 만들어뒀는데,--persist로 쌓는 누적 DB 에 봇 552건이 그대로 들어갔다. 필터를 고친 뒤에도 대시보드는 90명 을 보여줬다. 경고는 화면에만 뜨고 오염은 디스크에 남는다. 지금은human.log가 없으면 방문 통계를 아예 건너뛴다. 빈 화면이 잘못된 숫자보다 낫다. -
셋, 그래도 3주 동안 틀린 숫자를 보고 있었다 (2026-09-23). 대시보드가 방문자 4,290 · 페이지뷰 18,660 을 가리켰다. 개인 블로그 숫자로 이상해서 로그를 열었다.
하루 776 요청 중 404 가 604 (78%) 그중 234 개가 wp-login · .env · phpmyadmin 같은 취약점 탐색 경로 GCP 대역 IP 두 개가 각각 281 개 경로를 같은 1 초 안에 훑고 감두 IP 의 UA 가 평범한 크롬이었다.
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36— 위에서 만든 문자열 필터에 걸릴 단어가 하나도 없다. UA 는 요청자가 적어 보내는 값이라 애초에 못 믿는 것이었는데, 목록을 늘리는 방향으로만 고치고 있었다.그래서 상태 코드로 거르기로 했다. 진짜 독자는 404 를 거의 안 낸다.
map $status $status_human { default 0; "~^(200|304)$" 1; }두 IP 만 빼도 방문당 페이지가 6.2 → 1.7 로 떨어졌다. 학습 블로그 통상 범위다. 그런데 그 벤치마크를 내 대시보드가 이미 화면에 적고 있었다. “학습 블로그 평균 1.5–2.5” 라고 써놓고 4.3 을 3주 동안 봤다. 이상 신호를 만들어두고 안 읽은 것이다.
-
겸사겸사 찾은 것 —
access_log는 상위를 덮어쓴다. nginx 는 server 블록에access_log를 쓰는 순간 http 레벨에서 물려받은 기본 로그를 쓰지 않는다. 443 블록에human.log만 적어둔 탓에 필터에 걸러진 HTTPS 요청이 어디에도 안 남고 있었다.access.log467 줄 중 436 줄이 308(포트 80 리다이렉트)이고 200 은 20 줄뿐이었다. 크롤러 리포트가 제구실을 못 한 이유다. 설정 주석에는 처음부터 “access.log 전체” 라고 적혀 있었는데 사실이 아니었다. -
나라별 방문자도 붙였다. GoAccess 가 이미
--enable-geoip=mmdb로 빌드돼 있었고--ignore-panel=GEO_LOCATION으로 꺼둔 상태였다. DB 파일만 얹으면 되는 것이었다. MaxMind GeoLite2 대신 DB-IP Lite 를 골랐다 — GeoLite2 는 계정과 라이선스 키를 요구해서 키가 만료되면 조용히 갱신이 멈추는데, 그건 방금 겪은 고장과 같은 종류다. DB-IP 는 계정이 없어도 돼서 서버에 비밀을 하나도 안 둬도 된다.나라별이 처음부터 있었다면 훨씬 빨리 알아챘을 것이다. 지난 데이터에서 1위가 벨기에(562히트) 였는데, 구글 클라우드 유럽 리전이 벨기에에 있다. 개인 블로그에 벨기에가 1위면 바로 이상하다.
-
결과: 방문자 3명, 방문당 1.0 페이지. 허전하지만 이게 진짜 숫자다. 서버 로그 분석의 구조적 한계이기도 하다 — Vercel 은 클라이언트 JS 로 셌기 때문에 JS 를 실행하지 않는 봇이 자연히 빠졌다. 광고 차단기에 안 막힌다는 장점의 이면이다.
24. 홈에 입력창을 얹었다 — 메뉴를 걷지 않고
-
문제: AI 시대의 UI 는 결국 “묻는 자리” 로 수렴할 것 같았다. 공공데이터포털처럼 홈에서 찾고 싶은 것을 적으면 답이 나오는 형태. 그런데 홈을 입력창 하나로 바꾸면 두 가지를 잃는다 — 검색엔진이 홈에서 가져갈 내용과 기존 메뉴에 익숙한 사람이다.
-
해법: 대신하지 않고 얹었다. 히어로 바로 아래에 입력창이 오고, 지표 · 발행 잔디 · 최근 글 · 시리즈는 그 밑에 그대로 있다.
두 군데를 뒤진다.
어디 무엇을 비용 주제 표 ( src/data/topics.ts)포트폴리오 · 시리즈 · 주요 페이지 0 Pagefind 블로그 글 본문 0 (정적 인덱스) 주제 표를 따로 둔 이유가 있다. 포트폴리오 페이지는
noIndex와data-pagefind-ignore로 일부러 빼뒀다. 구글 결과에 안 띄우려는 것이라 그 결정은 그대로 두고 싶었는데, 그러면 “julgot” 을 쳐도 아무것도 안 나온다. 별칭 표가 그 틈을 메운다 — 인덱스는 손대지 않고 입력창에서만 답이 나온다. -
여기서 한 번 틀렸다.
asdfqwer를 쳤는데 글이 3편 나왔다. 점수로 거르려고 보니 더 이상했다."julgot" 3.921 ← 정답 "zzzzz" 4.849 ← 헛소리인데 더 높다Pagefind 가 질의를 쪼개서 조각까지 맞추기 때문이었다.
asdfqwer는LLM-as-Judge의 as 에,zzzzz는 코드 예제의 ‘z’ 에 걸렸다. 점수는 정규화돼 있지 않아서 문턱값으로 못 가른다.그래서 발췌의
<mark>를 봤다. Pagefind 가 “무엇을 맞췄다고 보는지” 가 거기 표시된다. 표시된 말이 질의를 품거나 질의의 상당 부분이어야 통과시키게 했더니,asdfqwer와ㅁㄴㅇㄹ은 “찾은 게 없습니다” 로 떨어지고pandas·nginx·ROS2는 그대로 나왔다. -
결과: “포트폴리오” 는 페이지 카드와 관련 글을, “julgot” 은 프로젝트 카드와 배포 회고를 띄운다. 서버를 한 번도 안 거친다. 아직 LLM 은 안 붙였다 — 별칭 표로 어디까지 되는지부터 보고 정하기로 했다.
공통 원칙
기능들을 관통하는 4가지:
- 발행 마찰 최소화 — 메모에서 발행까지 클릭 · 결정 수를 계속 줄인다. Inbox/Scratch 워크플로우 · 번역 자동화 · Mermaid 사전 렌더 다 이 축.
- 회수 못 하는 실수 방어 — public git 에 한 번 나가면 되돌리기 어렵다. 보안 스크러빙 · 링크 체커 · pubDatetime 필터 · 종료 프로젝트 소프트 숨김 다 이 축.
- 1인 리뷰어 부재 대체 — 팀엔 리뷰어가 있지만 1인엔 없다. 검증기 · 링크 체커 · Claude Code 2-에이전트 워크플로우 로 기계에 위임.
- 재활용 킷 관점 — 매 기능이 스크립트 + 컨벤션 조합.
.claude/agents/·CLAUDE.md골격 · scripts/ 스크립트들 그대로 다른 프로젝트에 옮길 수 있게 설계.
앞으로 (여기부터 계속 append)
- 자동 orphan 이미지 감지 —
public/assets/posts/아래에 참조 없는 이미지 정리 - 번역 파이프라인의 mermaid 라벨 번역 — 현재는 EN 포스트도 mermaid 다이어그램 라벨이 KR (verbatim 정책). alt 텍스트 · description 만 번역
- playground 확장 — 자바 컬렉션 · Spring Boot 요청 흐름 시각화 등
- RSS 카테고리 분리 — 언어별 · 시리즈별 RSS
- 댓글 — 직접 만들기로 정했다 (2026-09). Cusdis 는 저장소가 아카이브됐고, Giscus 는 GitHub 계정을 요구해서 에세이 독자에게 장벽이 된다. 설계는 세 줄로 고정 — 승인 후 노출 · 평문만 · 계정 없음. 이러면 스팸과 XSS 가 구조적으로 빠진다
- Umami 등 제대로 된 애널리틱스 — Postgres 를 올리게 되면 그때. 지금 로그 분석으로는 체류 시간 · 이탈률을 못 낸다\n- 입력창에 의미 검색 — 빌드 때 글을 임베딩해 정적 JSON 으로 싣는 방식. 별칭 표로 안 되는 게 쌓이면 그때 붙인다
- 1편짜리 태그 페이지
noindex— sitemap 에서 빼도 각 글 하단 링크로 발견되므로 색인은 남는다. 확실히 빼려면noindex가 필요한데, 되돌리기는 쉬워도 성격이 달라 보류 중
이 문서에 대해
- 최초 발행: 2026-07-10
- 살아있는 문서 — 새 기능이 붙을 때마다 위 목록에 append + 하단 갱신 기록 한 줄
- 소스:
src/data/blog/ko/blog-beyond-astropaper-what-i-added.md
갱신 기록
- 2026-07-10 — 초판. 12개 기능 정리 (시리즈 · 플레이그라운드 · i18n · 번역 자동화 · 링크 체커 · Mermaid · Scratch/Inbox · 소프트 숨김 · pubDatetime 필터 · 보안 스크러빙 · Featured/시리즈 태그 · 리디자인)
- 2026-07-10 (2차) — SEO 강화 (JSON-LD 페이지 유형별 분기 · 표준 필드 보강) + Perf 3종 (rehype 이미지 lazy loading · PNG → WebP 스크립트 · Pretendard CSS preload) 추가. README 도 AstroPaper 원본에서 커스텀으로 교체.
- 2026-07-10 (3차) — 포스트 하단 피드백 CTA (
Feedback.astro) 추가. 댓글 시스템 없이 이메일 · GitHub Issue 로 실질 채널만 확보. - 2026-07-10 (4차) — 피드백 CTA UX 를 국내 사용자 기준으로 개편.
mailto:대신 “주소 복사 (Clipboard API)” + “Gmail 로 쓰기” + “GitHub Issue 열기” 3-트랙. 이메일 주소는 텍스트로 노출 +user-select: all로 클릭 한 번 전체 선택.docs/analytics-log.md관측 로그 신설 (첫 30일 스냅샷: Visitors 168 · Pages/Visitor 7.8 · Bounce 45%). - 2026-07-10 (5차) — 피드백 CTA 슬림화. Hick’s law 관점에서 옵션 줄임. 이메일 pill 자체가 클릭 = 복사 (GitHub · Vercel · Notion 표준 패턴), copy 아이콘 → check 아이콘 스왑. Gmail 버튼 · 별도 “주소 복사” 버튼 · 이메일 라벨 전부 제거. 인트로 카피도 “오류/보충” defensive → “질문 · 코멘트 · 다른 시각 환영” 능동형으로.
- 2026-07-10 (6차) — 사이드바 이메일 아이콘도 클릭 = 복사로 통일.
mailto:href 는 폴백용으로 유지 (Clipboard API 실패 시 원 mailto 동작). fixed toast (bottom-center) 로 “이메일 주소가 복사됐어요” 알림. Ctrl/Cmd/Shift/Alt+click 은 native 동작 유지 (새 탭 등). 사이트 전체에서 이메일 UX 일관성 확보. - 2026-07-10 (7차) — Markdown 사후 검증기 (
detectAccidentalStrikethrough) 도입. GFM 이~1.5~2주같은 숫자 범위 tilde 를 strikethrough 로 오파싱하는 문제. 번역 파이프라인validateAll에 통합 + standalonepnpm check:md스크립트. 전체 108개 파일 스캔 → 기존 발행글 3편의 tilde 오파싱 자동 발견 후 수정. 컨벤션: 숫자 범위는 en dash–, leading approximate 는약으로. - 2026-07-28 (8차) — 홈/포트폴리오 에디토리얼 리디자인 (17~19번). 사이드바 ·
page-grid제거 후 전 페이지 단일 컬럼, 헤더 워드마크 + KO/EN 스위처 이관, 홈 글 목록을 한 줄 인덱스로 (Featured 폐지), 발행 잔디 + 지표 격자 신설, 포트폴리오 카드를 성과 우선 케이스 블록으로 교체하고highlight/outcomes/responsibilities스키마 확장. 포트폴리오 게재 기준(라이브 사이트가 있는 것만)과 인테이크 템플릿 신설.word-break: keep-all로 한국어 단어 중간 줄바꿈 차단. 정적 사이트라 빌드 시점에 박제되던 잔디를 클라이언트 재계산으로 전환해 최근 90일 롤링 창으로 만듦. - 2026-08-04 (9차) — 발행 잔디의 마지막 칸을 오늘로 정정 (18번). 7행 격자를 채우려고 그 주 토요일까지 그렸더니 아직 오지 않은 날짜가 “0편” 셀로 표시됐다. 마지막 열이 짧아지더라도 오늘에서 끊는 쪽이 맞다. 범례도 실제로 쓰는 4단계(0 · 2 · 3 · 4)에 맞춤.
- 2026-08-30 (10차) — 다이어그램 사용 시작 (20번). mermaid 파이프라인이 있는데도 146편 중 3편에서만 쓰고 있었다. 항만 시리즈 5편에 다이어그램 6개 추가 (KO/EN 11블록 → 6 SVG 공유). 영상(Remotion) 도입은 비용·유지비 대비 이득이 낮아 보류하고, 정지 그림으로 해결되지 않는 대상에만 쓰기로 기준을 정했다. README 도 현행화 — 사이드바 스위처 → 헤더, 번역 자동 실행 중단 표기, 잔디·단일 컬럼·성과 카드 항목 추가, 누락돼 있던
check:md·images:webp스크립트 보강. - 2026-08-30 (11차) — mermaid 를 안 쓰고 있던 원인을 지침에서 찾아 막음. 발행 절차에 다이어그램 트리거와 렌더 단계 추가 (inbox · scratch 양쪽),
pnpm check:md에 미렌더 블록 탐지 추가 (exit 1),mermaid:gc가 사용 중인 SVG 를 전부 삭제하던 버그 수정 및 지침에 주의사항 추가. - 2026-08-30 (12차) — 기존 글 소급 적용 1차. 트리거 기준으로 146편을 훑어 후보를 뽑고 1순위 5편에 다이어그램 추가 (세션 인증 시퀀스 · 세션 vs JWT · RAG 데이터 준비 파이프라인 · presearch 수집 게이트 · 레이어드 아키텍처). 번역 중단을 코드로도 강제 —
assertTranslateEnabled()가 Anthropic 클라이언트 생성 전에 종료시켜 API 토큰이 소모되지 않는다. - 2026-08-31 (13차) — SEO 정비 (21번).
parkhyo.in과www로 갈려 있던 신호를 non-www 로 통합 (Vercel 307 → 308 방향 반전). 글 1편짜리 태그 187개를 sitemap 에서 제외해 581 → 394. 홈 KO/EN 의title·description신설 (기존엔Park Hyoin한 줄이라 한국어 검색어가 안 잡혔다). 홈·About 에 hreflang 추가하면서,/en/과/en/about/이lang="ko"로 나가던 것도 함께 수정. 네이버는http://로 등록돼 있던 사이트를https://로 재등록. - 2026-09-06 (14차) — Vercel 에서 AWS Lightsail 자체 운영으로 이전 (22번). 빌드는 GitHub Actions, 서버는 정적 서빙만. 심볼릭 링크 원자 교체라 배포가 실패해도 직전 릴리스가 유지된다. Let’s Encrypt 자동 갱신(webroot), 도메인 규칙 재현(www → apex 308 · 캐시 헤더 · HSTS). 방문 통계를 nginx 로그 + GoAccess 로 다시 만들고 블로그 톤의 대시보드(
/stats)를 붙였다 (23번). 봇 필터가 새서 첫날 수치가 20배 부풀었던 것과, 폴백이 누적 DB 를 오염시킨 것을 함께 기록했다. 라이선스도 분리 — 코드는 MIT, 글은 CC BY-NC 4.0. - 2026-09-24 (11차) — 방문 통계가 3주 동안 틀린 숫자를 보여주고 있었다. 하루 776 요청 중 604(78%)가 404 였고, GCP 대역 IP 두 개가 각각 281 경로를 같은 1 초에 훑고 갔다. UA 가 평범한 크롬이라 문자열 필터에 하나도 안 걸렸다. 상태 코드 필터(200·304 만 집계)로 바꾸니 방문당 페이지가 6.2 → 1.7 로 떨어졌다. 겸사겸사
access_log가 server 블록에서 상위를 덮어써 HTTPS 요청이 어디에도 안 남던 것도 고쳤다. 나라별 방문자(DB-IP Lite) 추가. 그리고 홈에 입력창(24번) — 주제 표 + Pagefind, 서버를 안 거친다.asdfqwer에 글이 나오던 것은 발췌의<mark>를 검사해서 막았다.