DEVELOPMENT NOTE2

Git 원본과 MySQL 조회 모델을 나눈 이유

Markdown 작성 방식을 유지하면서 공개 조회 책임을 MySQL로 분리한 과정

#architecture#markdown#mysql#spring-boot#read-model#content-sync
아카이브로 돌아가기

데이터베이스가 필요해도 원본은 Git에 남겼다

검색과 본문 로딩을 개선한 뒤에도 글이 바뀔 때마다 프런트엔드의 빌드와 배포가 필요했다. 공개 여부와 메타데이터 검증도 생성 과정에 묶여 있었고, 조회수나 반응 같은 동적 데이터를 둘 곳도 없었다.

MySQL을 콘텐츠 원본으로 바꾸려면 관리자 화면과 작성 API, 인증과 권한, 변경 이력, 백업과 복구까지 필요하다. 비용을 들여도 기존 작성 방식이 더 좋아지는 것은 아니었다.

Markdown과 MDX는 어느 편집기에서든 읽고 쓸 수 있다. 변경 이유는 Git 커밋으로 남고, 코드처럼 문장과 frontmatter를 검토할 수 있다. 특정 애플리케이션이나 데이터베이스가 없어도 원문을 복구할 수 있다.

그래서 houkago.posts를 원본으로 두고, MySQL의 post_read_models는 원본에서 언제든 다시 만들 수 있는 조회 모델로 정했다.

MySQL은 공개 조회를 위한 파생 모델이다

데이터베이스에는 정규화된 메타데이터와 원문인 rawBody, 변경 감지용 checksum, 동기화 상태와 revision을 저장한다. 데이터베이스가 사라져도 원본과 동기화 과정만으로 다시 만들 수 있어야 한다.

text
작성자
  -> houkago.posts의 Markdown 원본
  -> Spring Boot 콘텐츠 동기화
  -> MySQL post_read_models
  -> 공개 글 API
  -> 블로그

Markdown과 데이터베이스의 제목이 다르면 row를 직접 고치지 않는다. 다음 동기화가 메타데이터를 검증하고 checksum을 계산해 갱신한다. 데이터베이스만 수정해도 전체 동기화 뒤에는 Markdown의 값으로 돌아온다.

원본에서 파일이 사라진 경우에는 해당 row를 공개 조회에서 제외되는 논리 삭제 상태로 바꾼다. 같은 checkout을 다시 처리하면 데이터베이스의 기존 상태와 관계없이 원본이 나타내는 상태로 수렴한다.

동기화는 변경 여부까지 구분한다

한 번의 동기화는 다음 단계로 이어진다.

text
PostSourceScanner
-> PostSourceFileReader
-> PostMarkdownParser
-> PostMetadataMapper
-> PostChecksumCalculator
-> PostReadModelAssembler
-> PostReadModelUpsertService

Scanner는 저장소 아래의 index.md만 찾는다. Reader는 UTF-8 원문을 Parser에 전달하고, Parser는 YAML frontmatter와 본문을 나눈다. Metadata Mapper가 필수값과 category, status, date를 검증해 저장 상태로 변환한다.

Checksum은 공개 콘텐츠를 구성하는 메타데이터와 rawBody로 계산한다. 실행할 때마다 바뀌는 commitHashsyncedAt은 콘텐츠 변경으로 보지 않는다. 기존 row와 비교한 결과는 네 가지로 나뉜다.

  • CREATED: 기존 row가 없어 새로 만들었다.
  • UPDATED: checksum이 달라 콘텐츠와 메타데이터를 갱신했다.
  • TOUCHED: checksum은 같고 commitHash, syncedAt만 갱신했다.
  • DELETED: 이번 scan에서 사라진 ACTIVE row를 비공개 논리 삭제 상태로 바꿨다.

TOUCHED는 콘텐츠를 덮어쓰지 않으면서 이번 revision에서도 파일을 확인했다는 기록을 남긴다.

공개 조건은 조회할 때 다시 확인한다

Frontmatter의 상태는 동기화 과정에서 DRAFT, ACTIVE, PRIVATE로 변환된다. 공개 API는 이 변환만 믿지 않고 다음 세 조건을 모두 확인한다.

java
public boolean isPublic(
        PostSourceStatus sourceStatus,
        PostSyncStatus syncStatus,
        PostVisibility visibility
) {
    return sourceStatus == PostSourceStatus.PUBLISHED
            && syncStatus == PostSyncStatus.ACTIVE
            && visibility == PostVisibility.PUBLIC;
}

하나라도 맞지 않으면 목록에서 제외하고, slug를 직접 요청한 상세 API도 404를 반환한다. Draft 글의 존재와 slug까지 외부에 알릴 필요가 없기 때문이다.

목록은 raw_body를 선택하지 않는 projection을 사용하고, 상세에서만 한 건의 rawBody를 반환한다.

실제 글 265개로 확인했다

초기 OCI Docker smoke에서는 실제 posts checkout에서 265개 파일을 읽어 같은 수의 row를 적재했다. 당시 공개 가능한 ACTIVE/PUBLIC 글은 258개, PRIVATE 글은 7개였다. 현재 글 수를 뜻하는 고정 사양이 아니라, Parser부터 MySQL과 공개 API까지 연결됐음을 확인한 당시의 관찰값이다.

  • Flyway가 post_read_models schema를 만드는지 확인했다.
  • 목록 응답에는 rawBody가 없고 상세 응답에는 있는지 확인했다.
  • Draft, private, deleted row가 목록에서 빠지고 상세 요청에는 404를 반환하는지 확인했다.
  • 같은 checkout을 다시 동기화하면 대부분 TOUCHED로 처리되는지 확인했다.
  • 파일 하나를 없앤 fixture에서 DELETEDPRIVATE가 함께 적용되는지 확인했다.

프런트엔드는 단계적으로 옮겼다

모든 콘텐츠 경로를 한 번에 바꾸지는 않았다. /blog의 All Posts, Recent Posts, Featured Posts와 pagination metadata, /blog/page/[page] 목록부터 백엔드 API에서 읽도록 옮겼다.

글 상세와 MDX compile, 검색, category와 tag, Category Highlights, assets, sitemap, preview는 여전히 local/generated source를 사용했다. houkago.posts push가 백엔드 동기화와 기존 Vercel 배포를 함께 실행한 이유다.

상세 페이지는 API의 rawBody를 기존 MDX 안전 경계와 asset 경로 정책 안에서 렌더링해야 한다. 목록보다 실패 영향이 커서 기능별로 검증하며 전환했다.

작성자는 계속 Markdown과 Git을 사용하고, 서비스는 검증된 메타데이터와 공개 정책으로 목록을 조회하게 됐다. 대신 동기화 실패와 지연을 관리하고 두 리비전을 관찰·복구해야 한다. 전환 중에는 백엔드와 로컬 빌드가 잠시 다른 리비전을 보여줄 수 있고, 스키마 변경은 이전 애플리케이션 이미지와의 호환성도 고려해야 한다.

조회 모델을 둔다는 것은 테이블 하나를 추가하는 일이 아니었다. 원본에서 서비스 상태로 가는 변환을 반복 가능하고 복구할 수 있는 계약으로 만드는 일이었다.