bomlog
지금 보고 계신 이 블로그 — git push가 곧 발행인 4개 국어 블로그 플랫폼.
지금 보고 계신 이 블로그입니다. 홈랩 LXC 위에서 돌아가고, CMS 없이 git 저장소가 곧 콘텐츠 정본입니다. 플랜 확정부터 루트 도메인 라이브까지 이틀, PR 47개로 만들었습니다.
핵심 기능
- push = 발행: 글을 커밋하고 push하면 웹훅이 돌아 8초 안에 반영됩니다. 빌드 파이프라인이 없습니다.
- git의 UI인 에디터: 웹 에디터의 저장 버튼은 commit, 발행 버튼은 파일 이동 commit입니다. 이력과 롤백이 공짜입니다.
- 4개 국어: 한국어 원문에 영어·일본어·중국어 형제 파일을 두면 hreflang과 폴백까지 자동입니다.
- 직접 만든 기본기: 회원/댓글/방명록/좋아요, 간격 없는 검색(⌘K), 2단 카테고리, OG 카드 생성, 홈랩 라이브 상태 위젯, 셀프호스트 방문 분석까지 전부 자체 구현입니다.
구조
두 개의 프로세스와 두 개의 저장소로 이루어져 있습니다.
- web (Next.js 15, App Router): 페이지 렌더, MDX 컴파일, 검색 인덱스, OG 카드 생성. 콘텐츠는 서버 컴포넌트에서 읽으므로 MDX가 클라이언트 번들에 들어가지 않습니다.
- api (NestJS 11 + Prisma): DB와 외부 입력을 만지는 유일한 프로세스입니다. 웹훅 수신, git pull/push, 인증, 댓글, 좋아요, 정리 크론이 여기 있습니다. web은 DB에 직접 접근하지 않습니다.
- 콘텐츠 저장소: MDX 파일이 정본이고 서버에는 clone만 있습니다. push가 오면 api가 HMAC을 검증하고 pull한 뒤 web의 내부 엔드포인트를 호출해 메모리 캐시를 무효화합니다. 통지가 유실될 때를 대비해 10분 TTL 폴백도 둡니다.
- 앱 저장소: 이 사이트의 코드입니다. 기본 브랜치 직접 push는 로컬 가드가 막기 때문에 모든 변경이 dev 브랜치와 PR을 거칩니다.
캐싱은 층을 나눴습니다. MDX 컴파일 결과는 slug+locale+mtime 키의 메모리 LRU,
HTML은 세션과 조회수가 섞여 있어 private, no-cache, 정적 자산과 이미지는
장기 캐시에 CF 엣지까지 태웁니다. 글 목록의 썸네일은 전부 자동 생성 OG 카드인데,
원본 PNG를 그대로 쓰면 850KB라 next/image로 17KB webp까지 줄였습니다.
만든 과정
플랜 문서가 곧 작업 지시서였습니다. Phase 0부터 9까지 각 단계에 목표, 작업, 완료 조건(DoD), QA 체크리스트를 먼저 적고, 그 문서대로 구현한 뒤 체크리스트를 채워야 다음 단계로 넘어갔습니다. 작업 단위가 작아도 QA는 생략하지 않는 게 규칙이었습니다.
- Phase 0–1 (9/1 오전): 저장소 2개 생성, 콘텐츠 규약 확정, 모노레포 스캐폴드와 디자인 토큰. 여기서 Tailwind를 버리고 styled-components로 전환했고, 제목용 세리프 폰트를 실물로 확인한 뒤 Pretendard 700 단일 체계로 통일했습니다. 트렌드보다 실제로 화면에서 깔끔한 쪽을 택했습니다.
- Phase 2 (9/1): MDX 런타임 렌더(shiki 듀얼 테마), 웹훅 파이프라인. push→반영 8초를 E2E로 확인하고 나서야 완료 처리했습니다.
- Phase 3 (9/1): 홈·log·works·태그·검색·RSS·sitemap·llms.txt, 읽기 진행바, TOC 스크롤스파이, callout, 관련 글, 404 이스터에그.
- Phase 4·9 (9/1): 인증 전체와 /lab 에디터. 가입→인증 메일→로그인→5회 실패 잠금까지 E2E, 에디터는 draft 저장→서명 토큰 미리보기→발행→라이브까지 E2E.
- Phase 5·7 (9/1): 댓글/방명록/좋아요/조회수/모더레이션, 그리고 티스토리 60편 이관 (이미지 393장). 이관 글은 raw HTML을 허용하는 legacy 렌더러로 분기했습니다.
- Phase 6 (9/2):
web.서브도메인 스테이징에서 전수 QA를 통과한 뒤 루트 도메인 nginx 설정을 교체했습니다. 구 랜딩은 백업해 두고 롤백 절차를 먼저 적었습니다. - Phase 8 (9/2): 홈랩 상태 위젯, umami 분석, 번역 파이프라인. 이후로는 OG 카드, 언어 뱃지, 아이콘 같은 마감 작업이 이어졌습니다.
중요했던 설계 결정
- 빌드 없는 런타임 렌더. 정적 생성은 글 하나 고칠 때마다 빌드가 돌아야 합니다. MDX를 요청 시 컴파일하고 메모리에 캐시하면 push 8초 뒤 반영되고, 서버는 컨테이너 하나로 충분합니다.
- DB는 api만 만집니다. web이 Prisma를 직접 들면 편하지만, 그러면 검증과 rate limit이 두 군데로 흩어집니다. 쓰기 경로를 하나로 모으고 ValidationPipe로 이중 검증합니다.
- JWT 세션이지만 차단은 됩니다. 읽기는 JWT만 보고, 쓰기 API는 매번 DB에서 사용자 상태를 재확인합니다. 차단된 사용자는 토큰이 살아 있어도 댓글을 못 씁니다.
- 댓글은 로그인, 좋아요는 익명. 좋아요에 로그인 벽을 세우면 참여가 급감하고, 위조돼도 피해가 없습니다. 반대로 댓글은 책임이 필요합니다. 익명 식별은 IP 해시로 하고 솔트가 없으면 서버가 뜨지 않게 fail-fast를 걸었습니다.
- 토큰은 해시로 저장. 인증·재설정 토큰은 DB에 해시만 둡니다. DB가 유출돼도 인증 링크를 재구성할 수 없습니다.
- 플랫 slug URL. 파일은 연도 폴더에 있지만 URL에는 연도가 없습니다. 파일을 옮겨도 링크가 안 깨집니다. 언어 자동 리다이렉트는 SEO 때문에 하지 않고, Accept-Language는 첫 방문 힌트로만 씁니다.
- 에디터는 git의 UI. 에디터 전용 저장 경로를 만들지 않았습니다. 저장은 commit+push고, 반영은 기존 웹훅 경로를 그대로 탑니다. git 작업은 웹훅 pull과 락을 공유하고, push가 실패하면 hard reset으로 되돌립니다.
리뷰에서 잡힌 것들
구현 뒤 리뷰와 실측에서 고친 것들입니다. 대부분 "문서대로 했는데 실제는 달랐다"는 종류입니다.
- rewrite가 동적 라우트를 이깁니다.
/api/*를 통째로 api로 넘기는 rewrite가 Auth.js의[...nextauth]핸들러보다 먼저 평가되어 세션 요청이 404가 났습니다. auth 경로를 정규식으로 rewrite에서 제외하고, api가 소유하는 auth 액션만 명시적으로 넘기도록 고쳤습니다. - CSP 해시 방식은 App Router에서 빈 화면을 만듭니다. 테마 스크립트를 sha256 해시로
허용하는 엄격한 CSP를 넣었더니 모든 브라우저가 빈 화면이었습니다. RSC 스트리밍 자체가
인라인
<script>로 오기 때문입니다.unsafe-inline으로 후퇴하고, nonce 전환은 전 페이지 dynamic화가 선행돼야 해서 TODO로 남겼습니다. 설계 문서에 실측 주석을 같이 적었습니다. - 세션 JSON은 ADMIN인데 쿠키는 USER. JWT 콜백에서 role을 갱신해도 기본
updateAge24시간 동안은 쿠키가 안 바뀌어서, UI는 관리자인데 미들웨어가 /lab에서 튕겼습니다.updateAge를 5분으로 줄였습니다. 한 줄 수정인데 찾는 데 가장 오래 걸린 축에 듭니다. - 모바일에서 /lab 게이트가 튕기는 변종. 조각난 쿠키, 낡은 role 같은 변종을 미들웨어의 직접 검증이 못 잡았습니다. 직접 검증이 실패하면 세션 엔드포인트에 한 번 더 물어보는 폴백을 3초 타임아웃으로 추가했습니다.
- 가로 오버플로우 전면 방어. 긴 제목, 긴 코드, 긴 URL이 모바일에서 화면을 밀어냈습니다.
Prose·카드·그리드에
overflow-wrap과min-width: 0을 일괄로 깔았습니다. 가변 길이 텍스트는 ellipsis와 overflow 방어를 기본으로 탑재한다는 원칙이 여기서 나왔습니다. - systemd
Environment=는 공백에서 잘립니다. 발신자 이름에 공백이 있어 메일 헤더가 깨졌습니다. 따옴표로 감싸야 합니다. - 좀비 프로세스. 재시작했다고 믿은 서버가 사실 옛 프로세스였고, 그 위에서 한 디버깅이
전부 헛수고였습니다. 지금 배포 스크립트는 stop→교체→start→
is-active확인까지 한 흐름으로 묶여 있습니다. - umami는 MySQL 8 전용 함수를 씁니다. 마이그레이션이
BIN_TO_UUID를 호출해 중앙 MariaDB에서 실패했습니다. 로컬 DB를 세우는 대신 호환 함수를 주입해 "DB는 중앙 MariaDB" 원칙을 지켰습니다. - OG 카드의 순환 참조. 카드 커버를 OG URL로 통일한 뒤, OG 라우트가 배경으로 커버를 다시 쓰면 무한히 종이 카드만 나옵니다. 원본 사진 경로는 OG 라우트 내부에서만 읽도록 분리했습니다.
- satori와 폰트. OG 렌더러는 SVG data URI가 불안정해 아이콘을 PNG로 넣고, Pretendard에 한자가 없어 일본어·중국어 카드는 Noto 서브셋을 글리프 폴백으로 붙였습니다.
- 아이콘. 첫 파비콘은 범용 문서 글리프였습니다. 형제 서비스 아이콘과 나란히 놓고 보니 상징이 없고 32px에서 뭉개져서, 사이트 토큰(종이·잉크·오렌지 점)으로 그린 모노그램 b로 바꿨습니다. 후보 3라운드를 256px과 32px 두 배율로 렌더해 비교했습니다.
아직 남은 것
- CSP nonce 전환 (전 페이지 dynamic화 선행)
- 방문자 인증 메일의 외부 릴레이 (현재 홈 IP 직접 배달이라 일부 메일 서비스가 거부)
- GitHub/Google OAuth 앱 등록 (코드는 env만 넣으면 활성화)
배운 것
구축 과정에서 밟은 지뢰들을 별도 글로 정리했습니다. 블로그를 만드는 가장 좋은 방법은 블로그에 쓸 글감이 생기는 방식으로 만드는 것입니다.
한 가지 더. 완료 조건과 QA 체크리스트를 먼저 적어 두면 "다 됐다"는 말이 검증 가능해집니다. 위의 수정 목록 대부분은 체크리스트가 없었다면 라이브에서 발견했을 것들입니다.