첨부 파일 때문에 checkout이 남아 있었다
글 목록은 백엔드 API를 읽었지만 상세 본문과 첨부 파일은 여전히 프런트엔드 build가 houkago.posts에서
가져왔다. Generator가 상대 경로를 public path로 바꾸고 public/generated/posts에 복사했기 때문이다.
이 구조에서는 콘텐츠 변경마다 프런트엔드 배포가 필요하고, 공개 파일의 revision도 프런트엔드 artifact에 묻힌다. 복사 중 실패하거나 글이 비공개로 바뀌었을 때 이전 파일을 어떻게 처리할지도 명확하지 않았다.
원본 repository를 Nginx document root로 사용할 수도 없었다. Draft와 private 글의 파일까지 URL로 열릴 수 있기 때문이다. Spring Boot가 파일을 제공하면 상시 app이 다시 checkout과 연결된다.
그래서 원본과 공개용 snapshot을 분리했다.
houkago.posts: 작성 원본과 이력
public snapshot: 검증된 공개 파일공개 글의 assets만 포함했다
공개 대상은 API와 같은 세 조건을 모두 만족해야 한다.
sourceStatus = PUBLISHED
syncStatus = ACTIVE
visibility = PUBLIC대상 글의 assets/ 전체를 snapshot에 넣고 index.md와 다른 sibling은 제외했다. Markdown에 직접 등장한
파일만 고르면 custom component reference를 놓칠 수 있어 authoring contract인 assets/ 전체를 선택했다.
경로가 원본 root 밖으로 나가거나 absolute path인 경우, symbolic link가 외부를 가리키는 경우와 참조한 regular file이 없을 때는 publication을 중단한다. Draft와 private, archived, deleted 글의 파일도 포함하지 않는다.
변경분 대신 전체 snapshot을 만들었다
Mutable directory에 변경 파일만 덮어쓰면 rename과 delete, unpublish 때 오래된 파일이 남을 수 있다. 매번 공개 가능한 전체 상태를 다시 만들었다.
asset-root/
├── releases/<previous-sha>/posts/<slug>/...
├── releases/<target-sha>/posts/<slug>/...
└── current -> releases/<target-sha>Generation ID는 checkout의 full commit SHA다. 같은 ID로 다시 실행하면 파일 목록과 bytes까지 비교한다. 완전히 같으면 재시도로 인정하고, 같은 ID인데 내용이 다르면 immutable revision 위반으로 실패한다.
Stage와 공개 전환을 나눴다
새 snapshot은 hidden staging directory에 먼저 만든다. 경로와 symlink, Markdown·thumbnail reference,
regular file count와 total bytes, slug와 generation path를 모두 검사한다. 검증이 끝나면 staging directory를
releases/<generation>으로 atomic move한다. Atomic move가 불가능하면 일반 move로 낮추지 않고 실패한다.
완성된 release와 사용자가 보는 release도 구분했다. Temporary symbolic link를 만든 뒤 current 이름으로
atomic replace한다. 사용자는 복사 중인 directory가 아니라 완성된 이전 또는 새 generation만 본다.
데이터베이스 동기화 뒤에 활성화했다
실행 순서는 다음과 같다.
전체 후보 사전 검사
-> 공개 자산 stage와 검증
-> MySQL 동기화
-> current 활성화
-> API와 자산 smoke
-> Next.js cache 무효화Stage가 실패하면 데이터베이스를 바꾸지 않는다. Activation은 DB sync 뒤에 둬 이전 본문이 새 snapshot에서 이미 사라진 파일을 가리키는 상태를 피했다. 두 작업이 하나의 transaction은 아니므로 DB 성공과 link 전환 사이에는 짧은 revision window가 남는다.
활성화 뒤 점검이 실패하면 새 current가 이미 보일 수 있어 자동 롤백하지 않고 Worker 실패와
운영자 복구 경계로 전환한다. 캐시 무효화 실패도 성공한 DB와 자산을 되돌리지 않으며 재시도와 300초
TTL을 사용한다.
Nginx는 snapshot만 제공했다
Nginx는 checkout이 아니라 current/posts를 읽는다.
./assets/diagrams/flow.png
-> current/posts/<slug>/diagrams/flow.png
-> /assets/posts/<slug>/diagrams/flow.pngCanonical URL은 https://api.houkago.moe/assets/posts/<slug>/<path>다. Directory listing과 dot-file 접근은
막고 ETag와 300초 cache를 사용했다. Asset bytes가 당시 글 checksum에 없고 같은 filename의 내용이 바뀔 수
있어 long-lived immutable cache는 선언하지 않았다.
실제 snapshot으로 확인했다
2026년 8월 16일 운영 E2E에서 다음 generation을 관찰했다.
| 항목 | 관찰값 |
|---|---|
| Public posts | 258 |
| Assets | 333 |
| Total bytes | 8,087,458 |
Generation ID와 commit SHA, 새 current, 이전 release 보존을 확인했다. 대표 공개 파일은 HTTPS 200, draft
파일은 404였고 ETag와 Cache-Control: max-age=300도 확인했다. API smoke와 cache 무효화는 성공했으며
app과 MySQL은 재시작되지 않았다. 이 수치는 당시 한 generation의 결과이지 용량 기준이나 SLA가 아니다.
빌드 결합을 줄인 대신 복사본을 운영한다
전체 snapshot은 매번 I/O와 저장 공간을 사용하고 retention 정책도 필요하다. 원본과 serving copy의 revision을
함께 관찰해야 하며 DB sync와 activation도 원자적이지 않다. 공개 글의 assets/ 전체를 복사하므로 사용하지
않는 파일이 포함될 수도 있다.
당시 약 8MB에는 Object Storage와 CDN의 credential과 lifecycle보다 단일 host filesystem이 단순했다. 가장 확장성 높은 선택이 아니라 현재 크기에서 원본과 공개 책임을 분리한 선택이었다.
이 작업으로 프런트엔드 build의 asset 복사를 제거할 기반은 생겼다. 다만 상세 본문과 검색, category와 tag, sitemap 등 다른 로컬 reader는 아직 남아 있었다.