DEVELOPMENT NOTE2

개인 블로그에 운영 경계를 늘린 이유

Markdown 블로그에 조회 모델과 두 운영 파이프라인을 추가하며 얻은 것과 남은 한계

#architecture#content-pipeline#continuous-delivery#systemd#nextjs#retrospective
아카이브로 돌아가기

처음에는 Markdown만으로 충분했다

블로그는 houkago.posts의 Markdown과 MDX를 Next.js가 읽어 Vercel에 배포하는 구조로 시작했다. 작성과 배포 경로가 짧았고 별도 백엔드나 데이터베이스도 필요하지 않았다.

글과 기능이 늘자 프런트엔드가 원본 검증과 목록, 검색 데이터, 공개 정책과 렌더링 입력까지 모두 맡게 됐다. 콘텐츠 revision과 프런트엔드 build도 강하게 묶였다. 그렇다고 Git 기반 작성 경험을 버리고 원본을 데이터베이스로 옮기고 싶지는 않았다.

처음부터 큰 시스템을 설계한 것은 아니었다. 문제가 생길 때마다 실패와 변경을 어디까지 격리할지 정하고 그 자리에 경계를 하나씩 추가했다.

원본과 조회 책임부터 나눴다

houkago.posts는 계속 원본과 review history를 소유한다. MySQL은 검증된 메타데이터와 본문을 공개 조회에 맞게 투영한 파생 모델이다. 데이터베이스가 사라져도 checkout에서 다시 만들 수 있어야 한다.

text
houkago.posts: 원본
MySQL: 조회 모델

Scanner와 Parser, 메타데이터 검증, checksum, upsert와 누락 처리를 연결해 원본 상태로 수렴하게 했다. 프런트엔드는 모든 파일을 직접 해석하지 않고 정렬과 pagination, 공개 정책이 적용된 응답을 받을 수 있게 됐다.

콘텐츠와 애플리케이션의 생명주기를 분리했다

초기 동기화는 Spring Boot 시작 과정에 붙어 있었다. 이후 일회성 실행과 Webhook, File Spool, systemd Content Worker로 나눴다.

text
Git push
  -> Spring: HMAC과 allowlist 검증
  -> Atomic File Spool
  -> Content Worker: FF-only checkout 갱신
  -> 일회성 동기화
  -> MySQL과 공개 API 확인
  -> Next.js cache 무효화

Spring은 외부 요청 검증과 작업 발행까지만 담당한다. Git credential과 Docker, host shell은 Worker에만 둔다. MySQL 반영이 성공한 뒤 공개 API를 확인하고 houkago-posts tag를 비운다. 무효화 실패에 대비해 300초 TTL도 유지한다.

백엔드 code 변경은 별도 pipeline으로 처리한다.

text
Main push
  -> CI
  -> Native ARM64 image
  -> GHCR exact digest
  -> Deploy Worker
  -> app-only 교체와 smoke
  -> current·previous release state

운영 서버에서 원본을 다시 빌드하지 않는다. Deploy Worker는 이미지와 마이그레이션을 확인한 뒤 app만 교체하고 MySQL 생명주기는 유지한다. 롤백도 mutable tag나 원본 재빌드가 아니라 previous digest를 같은 검증 경로에 넣는다. 이미지 롤백과 스키마 롤백은 다르므로 마이그레이션이 있으면 자동 경로를 중단한다.

두 파이프라인은 같은 원칙을 사용했다

  • 외부 요청과 강한 호스트 권한을 분리한다.
  • HTTP와 장시간 작업 사이에는 atomic File Spool을 둔다.
  • 검증에 실패하면 위험한 다음 단계로 넘어가지 않는다.
  • Delivery ID와 checksum, current digest처럼 중복 판단 기준을 명시한다.
  • Lock 파일 존재가 아니라 실제 flock 획득으로 동시 실행을 막는다.
  • 배포 단위는 바뀔 수 있는 tag가 아니라 exact digest로 정한다.

Fail-closed의 목적은 실패를 없애는 것이 아니었다. 불완전한 결과를 성공으로 기록하지 않고 복구할 상태를 남기는 것이었다.

운영에서는 코드 밖의 조건이 문제였다

첫 실제 콘텐츠 갱신에서는 UMask=0007이 새 Markdown을 0660으로 만들어 동기화 컨테이너가 읽지 못했다. 호스트 사용자가 읽을 수 있다는 사실은 실제 workload identity의 권한을 보장하지 않았다.

Deploy Worker에서는 git fetch.git/FETCH_HEAD를 써야 한다는 점과 release env의 atomic rename에 상위 디렉터리 쓰기 권한이 필요하다는 점을 놓쳤다. 로컬 fixture는 실제 systemd mount namespace를 완전히 재현하지 못했다.

해결은 sandbox 전체를 여는 것이 아니었다. Git 구간의 UMask와 readability guard를 분리하고, .git metadata와 release env 디렉터리에 필요한 최소 권한만 추가했다. Permission은 파일 mode뿐 아니라 process identity와 UMask, directory operation, mount namespace를 함께 봐야 했다.

검증한 범위와 남은 한계

실제 콘텐츠 push부터 MySQL과 Next.js cache까지 연결했다. 백엔드 main push도 CI와 ARM64 publish, 자동 배포로 이어졌고 Release A에서 B로 전환한 뒤 A로 rollback하고 같은 B를 복원했다. MySQL container는 유지됐다.

당시 Backend CI는 2분 57초, image publish는 28초였다. Single-app 교체에서는 약 11~13초의 외부 HTTP 502가 관찰됐다. 당시 조건의 관찰값이며 SLA가 아니다. 이 구조에는 distributed lock과 atomic full sync, blue/green, 완전한 spool reconciliation도 없다.

프런트엔드도 아직 하이브리드다.

백엔드 API 기반로컬 생성 기반
/blog 목록상세 메타데이터와 MDX 본문
Recent·Featured Posts검색 index
PaginationCategory·Tag 일부
/blog/page/[page]Assets, sitemap, preview

같은 화면에서 백엔드 목록과 로컬 category count가 잠시 다른 revision을 볼 수 있다. Published 상세와 asset, 생성 index가 build 입력인 동안 posts 변경에 따른 Vercel 배포도 제거할 수 없다. 최종 목표는 프런트엔드가 houkago.posts checkout 없이 build되는 상태다. 원본은 계속 Git에 남고 전달 경로만 API로 단일화된다.

복잡성은 책임을 설명할 수 있어야 한다

개인 블로그 트래픽만 보면 더 단순한 선택도 가능했다. 하지만 이 프로젝트에서는 권한 분리와 immutable 배포, rollback을 실제로 설계하고 검증하는 일도 목표였다.

도구가 많다는 사실만으로 복잡성을 정당화할 수는 없다. File Spool은 HTTP와 호스트 작업을 나누고, digest는 release identity를 고정하며, current와 previous는 복구 대상을 명시한다. 그 책임을 설명할 수 없다면 유지할 이유도 없다.

가장 크게 달라진 질문은 “작동하는가”에서 “실패하면 어디에서 멈추는가”였다. 현재 구조는 완성형이 아니라 두 운영 pipeline과 하이브리드 프런트엔드가 공존하는 과도기다. 그래도 원본과 조회, API와 동기화, 외부 요청과 호스트 권한, 이미지와 스키마 롤백의 실패 단위는 이전보다 분명해졌다.