파일을 읽어 저장하는 것만으로는 부족했다
houkago.posts의 Markdown을 원본으로 유지하려면 MySQL 조회 모델이 원본과 같은 상태로 돌아갈 수 있어야
한다. 새 글과 수정된 글, 내용이 같은 글을 구분하고 원본에서 사라진 글은 공개 조회에서 내려야 했다.
모든 책임을 하나의 importer에 넣으면 어느 단계가 실패했는지 알기 어렵다. 반대로 변경 이벤트부터 처리하면 핵심 규칙과 실행 계기가 섞인다. 먼저 로컬 checkout 전체를 순서대로 처리하는 동기화 과정을 만들고, 실행 시점은 분리했다.
index.md 탐색
-> 원문 읽기와 YAML 분리
-> 메타데이터 검증
-> checksum 계산
-> 전체 후보 사전 검사
-> CREATED / UPDATED / TOUCHED 반영
-> 사라진 글을 DELETED로 전환각 단계는 다음 단계가 사용할 입력까지만 만든다. 파일이나 YAML, 메타데이터, 중복, 데이터베이스 오류를 서로 다른 경계에서 드러내고, 하나라도 실패하면 즉시 호출자에게 예외를 전달한다.
데이터베이스를 쓰기 전에 모두 검사했다
PostSourceScanner는 checkout 아래의 index.md만 찾는다. .git, node_modules, build output, 숨김
디렉터리와 assets는 제외한다. 경로는 저장소 기준의 sourcePath로 바꾸고 / 구분자로 정렬해 운영체제와
파일 탐색 순서의 영향을 없앴다.
PostSourceFileReader는 UTF-8 원문을 자르거나 줄바꿈을 바꾸지 않고 읽는다. PostMarkdownParser도
---를 기준으로 YAML frontmatter와 rawBody만 나누며, YAML은 Jackson YAMLMapper로 해석한다.
필수값과 콘텐츠 규칙은 PostMetadataMapper가 한곳에서 검사한다.
title,slug,date,description,category,status필수값- category와 status의 허용값
date,updated의LocalDate변환- 원본·동기화·공개 상태 변환
- 선택 메타데이터와 tag 목록 정규화
검사를 통과한 후보에는 메타데이터와 rawBody, sourcePath, checksum이 들어간다. 모든 후보를 먼저
준비하고 같은 scan 안의 중복 slug와 sourcePath도 거부한다. 뒤쪽 파일에서 오류가 발견돼도 앞쪽 파일만
데이터베이스에 반영되는 상황을 줄이기 위한 사전 검사다.
이 순서는 JPA dirty checking도 막는다. 기존 managed entity를 먼저 수정하면 save()를 생략해도
트랜잭션이 끝날 때 UPDATE가 발생할 수 있다. 따라서 변경 여부를 판단한 뒤 필요한 entity만 수정한다.
Checksum으로 콘텐츠 변경을 구분했다
Checksum은 Markdown 파일 전체에 단순히 SHA-256을 적용한 값이 아니다. 검증된 메타데이터와 rawBody를
정해진 필드 순서로 직렬화한 뒤 계산한다.
포함하는 값은 다음과 같다.
rawBody,title,slug,date,description,categorysourceStatus,tags,updated,thumbnail,seriesfeatured,platform,problemId
본문의 CRLF와 CR은 LF로 통일하고 tag는 정렬한다. 각 필드에는 이름과 null 표시, 문자열 길이와 값을 함께 기록해 문자열을 단순히 이어 붙일 때 생기는 경계 모호성도 피했다.
commitHash와 syncedAt은 제외한다. 같은 콘텐츠를 다른 Git revision에서 확인했다는 이유만으로
UPDATED가 되어서는 안 되기 때문이다. sourcePath도 콘텐츠 checksum에는 포함하지 않고 별도 식별·조회
정보로 사용한다.
네 가지 상태로 반영했다
기존 row는 sourcePath와 slug로 각각 찾는다. 둘이 같은 ID를 가리키면 같은 글이고, 서로 다른 ID를
가리키면 자동으로 합치지 않고 충돌로 실패한다. 한쪽만 존재하면 그 row를 사용하고 둘 다 없을 때만 새로
만든다.
| 상태 | 조건 | 변경 범위 |
|---|---|---|
CREATED | 기존 row가 없음 | 콘텐츠, 원본 정보와 동기화 정보를 새로 저장 |
UPDATED | 기존 row가 있고 checksum이 다름 | 콘텐츠와 메타데이터를 새 후보로 갱신 |
TOUCHED | 기존 row가 있고 checksum이 같음 | commitHash, syncedAt만 갱신 |
DELETED | 기존 ACTIVE row의 sourcePath가 사라짐 | 논리 삭제하고 비공개로 전환 |
TOUCHED도 쓰기를 건너뛰는 상태는 아니다. 콘텐츠 필드는 유지하지만 이번 revision에서도 파일이 유효하게
확인됐다는 기록은 남긴다. 같은 checkout으로 다시 동기화하면 첫 실행의 CREATED나 변경 직후의
UPDATED는 대부분 TOUCHED로 수렴한다.
사라진 글은 삭제하지 않고 숨겼다
모든 upsert가 성공하면 이번 scan의 sourcePath 목록과 기존 ACTIVE row를 비교한다. 데이터베이스에는
있지만 원본에는 없는 row의 값은 다음처럼 바꾼다.
syncStatus = DELETED
visibility = PRIVATE
commitHash = 현재 commit
syncedAt = 현재 동기화 시각Slug와 제목, 메타데이터, rawBody, checksum, 마지막 sourcePath는 남긴다. 공개 API는 ACTIVE/PUBLIC
row만 조회하므로 목록에서 사라지고 상세 요청에는 404를 반환한다. 복구할 정보는 보존하되 사라진 원본을
계속 공개하지 않는 방식이다.
Scan 결과가 비어 있으면 누락 비교와 논리 삭제를 실행하지 않는다. 잘못된 checkout 경로나 mount 문제로
모든 글이 한꺼번에 DELETED가 되는 일을 막기 위한 규칙이다.
사전 검사가 전체 원자성을 보장하지는 않는다
원본 오류는 쓰기 전에 발견하지만 전체 동기화를 하나의 트랜잭션으로 묶지는 않았다. 각 후보의 upsert와 누락 row 처리는 각각 트랜잭션을 가진다. 세 번째 row에서 데이터베이스 연결이 끊기면 앞의 두 row는 이미 커밋됐을 수 있다. 누락 처리는 모든 upsert가 성공한 뒤에만 실행되지만, 이것도 전체 실행을 원자적으로 만들지는 않는다.
현재는 작은 트랜잭션과 순차 처리, 즉시 실패, 재실행하면 수렴하는 규칙을 선택했다. 완전한 일괄 반영이 필요하다면 staging table이나 import run, atomic swap 같은 별도 설계가 필요하다.
순수한 파일 이동도 남은 문제다. sourcePath는 checksum에 없고 TOUCHED는 경로를 갱신하지 않는다.
따라서 slug와 내용은 그대로 두고 파일만 옮기면 이전 경로가 남아 누락 처리와 충돌할 수 있다. 현재 통합
테스트는 경로와 본문이 함께 바뀌어 UPDATED가 되는 경우만 보장한다. 경로를 checksum에 넣을지, 별도
상태로 감지할지, TOUCHED가 경로를 갱신할지는 추가로 정해야 한다.
현재 규모에서는 NOT IN 조회로 누락 경로를 찾지만 글이 크게 늘면 메모리 집합 비교나 staging table이
더 적합할 수 있다.
실제 데이터베이스까지 검증했다
단위 테스트에서는 다음 계약을 확인했다.
- Scanner의 제외 경로, 정렬과 운영체제에 독립적인
sourcePath - Frontmatter 구분자와 YAML 오류,
rawBody보존 - 필수값과 category, status, date 변환
- Checksum의 줄바꿈과 tag 순서 정규화
- 생성·수정·확인 분기와 JPA entity 변경 범위
sourcePath와 slug 충돌- 누락 원본 처리, 빈 scan 방어와 공개 API 비노출
- 모든 후보의 사전 준비, 중복 거부와 즉시 실패
MySQL Testcontainers 통합 테스트와 로컬 Docker smoke에서는 실제 schema와 dirty checking, 논리 삭제, 공개 조회 정책도 확인했다. 이 검증으로 실행 방식과 관계없이 동기화 과정이 지켜야 할 범위를 고정했다.