DEVELOPMENT NOTE3

Checksum이 같아도 상태가 달랐던 이유

경로와 생명주기, 중단된 작업과 원본 검증까지 동기화의 성공 조건에 포함한 과정

#architecture#content-sync#read-model#correctness#source-validation#spring-boot
아카이브로 돌아가기

성공 종료가 올바른 상태를 보장하지 않았다

일회성 동기화는 commit hash와 CREATED, UPDATED, TOUCHED, DELETED를 남겼고 Worker는 작업을 succeeded로 옮겼다. 이 신호는 실행 위치를 알려 주지만 각 row가 원본이 요구하는 상태와 같은지는 보장하지 않았다.

text
exit 0 + Worker SUCCESS
  != 원본과 조회 모델의 모든 상태가 같음

기존 checksum에는 정규화된 메타데이터와 본문이 들어갔고 sourcePath, commit hash, syncedAt, DB lifecycle은 제외했다. 콘텐츠 동등성을 판단하는 정의는 타당했지만, upsert가 같은 checksum을 row 전체가 같다는 뜻으로 사용한 것이 문제였다.

같은 본문을 옮기면 경로가 달라진다

Slug와 본문, 메타데이터를 유지한 채 파일만 옮기면 checksum은 같다. 기존 row를 slug로 찾아도 TOUCHED가 되면 commit hash와 시각만 갱신하고 새 sourcePath는 저장하지 않는다.

Checksum에 경로를 섞는 대신 목표 상태 비교를 별도로 만들었다.

text
Checksum: 콘텐츠와 정규화된 메타데이터
Desired state: checksum + sourcePath + source·sync·visibility 상태

같은 본문을 blog/original-path/index.md에서 blog/relocated-path/index.md로 옮긴 통합 test는 TOUCHED=0, UPDATED=1이 됐고 새 경로를 저장했다. 누락 처리도 옮겨진 row를 삭제하지 않았다.

돌아온 글의 공개 상태도 복구했다

원본에서 사라진 글은 DELETED, PRIVATE가 되지만 row와 checksum은 남는다. 같은 파일이 같은 본문으로 돌아오면 checksum은 같아도 원하는 상태는 PUBLISHED, ACTIVE, PUBLIC이다.

생명주기를 목표 상태 비교에 포함해 이 경우도 UPDATED로 처리했다. 테스트에서는 글을 삭제해 API 404를 확인한 뒤 같은 본문을 복원했다. Row는 기존 checksum을 유지하면서 공개 상태로 돌아왔고 API도 다시 200을 반환했다.

두 문제는 운영 corruption을 복구한 사건이 아니라 audit fixture로 찾은 correctness gap이다.

processing 작업에는 복구 주체가 필요했다

File Spool의 processing은 terminal state가 아니다. Worker가 SIGKILL이나 crash, host reboot로 끝나면 trap이 실행되지 않아 작업이 남을 수 있다.

text
TERM·INT·HUP·일반 EXIT
  -> 현재 Worker가 processing을 incoming으로 되돌림
 
SIGKILL·crash·reboot
  -> 다음 Worker가 lock 획득 뒤 orphan을 회수

TERM, INT, HUP test는 각각 exit code 143, 130, 129를 유지하면서 작업을 requeue하고 lock을 놓는지 확인했다. 다음 Worker는 content domain과 maintenance flock을 실제로 얻은 뒤 lexical 순서로 orphan을 회수한다. 회수한 작업은 축약 경로 없이 정상 pipeline 전체를 다시 통과한다.

같은 filename이 incoming, succeeded 또는 failed에도 있으면 어느 쪽도 덮어쓰지 않는다. Processing copy를 고유한 recovery-collision 파일로 failed에 보존하고 Worker를 실패시킨다. Signal과 collision은 fixture로 검증했고 운영 queue를 일부러 중단하는 실험은 하지 않았다.

모든 원본을 먼저 검증했다

Backend는 authoring validator와 별개로 자신이 읽은 입력을 검증해야 했다. 기존 full preflight에 PostSourceLayoutValidator를 추가했다.

text
project|cs|blog/{slug}/index.md
algorithm/{platform}/{problemId}/index.md
algorithm slug = {platform}-{problemId}

Top-level directory와 category, depth와 slug directory가 일치해야 한다. Algorithm의 optional platform, problemId도 path와 같아야 한다. Traversal과 symlink, asset regular file 검사는 계속 Asset Publisher가 담당한다.

순서도 다음처럼 고정했다.

text
전체 scan과 parse
  -> metadata와 layout 검증
  -> checksum과 중복 검사
  -> 모든 후보가 유효함
  -> upsert
  -> 누락 처리

두 valid candidate 뒤에 invalid candidate를 둔 test에서 DB write는 0이었고 기존 keeper row는 ACTIVE/PUBLIC과 이전 commit을 유지했다. 자산 발행 상태 변경도 시작되지 않았다. 사전 검사 이후의 DB나 I/O 실패까지 하나의 트랜잭션으로 만든 것은 아니다. 원본 검증 실패가 상태 변경 중간에 발견되지 않도록 한 범위다.

운영 public origin은 필수 설정으로 바꿨다

Production에서 HOUKAGO_PUBLIC_ASSET_ORIGIN이 빠지면 localhost 기본값을 정상 URL처럼 반환할 수 있었다. Docker web app에서는 누락과 blank, path가 붙거나 HTTP(S) absolute origin이 아닌 값을 startup failure로 바꿨다. 유효한 origin은 trailing slash를 정규화하고 Compose도 required interpolation을 사용했다.

일회성 syncasset-sync는 공개 URL을 만들지 않으므로 이 설정이 필요하지 않아야 했다. 첫 hardening image는 web app에서는 정상인데 non-web context에서 PostPublicAssetUrl bean이 없다는 이유로 실패했다. Configuration만 web-only였고 이를 소비하는 PostReadController는 component scan에 남아 있었다.

Controller에도 같은 Servlet condition을 적용하고 non-web context에서 bean과 controller가 모두 없는지 test했다. 후속 one-shot은 CREATED=0, UPDATED=0, TOUCHED=277, DELETED=0으로 성공했고 공개 URL과 API, asset을 다시 확인했다. App과 MySQL은 재시작하지 않았다. 277은 당시 revision의 검증값이다.

엄격한 경계는 실패를 더 잘 보이게 한다

회귀 테스트는 재배치와 재등장, 종료 신호 처리와 중단 뒤 남은 작업 복구, 충돌 처리, 원본 구조와 상태 변경 순서, web·non-web 설정을 각각 고정했다. 2026년 8월 18일 운영 반영에서도 후보 277개가 원본 검증을 통과했고 자산 활성화와 점검, 캐시 무효화가 성공했다.

대신 후보 하나의 구조만 틀려도 전체 동기화가 멈추고 작성 단계와 수집 단계의 검증 일부가 중복된다. Worker 상태와 모드별 Spring 컨텍스트 테스트도 늘었다. 이 비용을 받아들인 이유는 부분 성공보다 상태 변경 전에 멈춘 상태가 복구하기 쉽기 때문이다.

일관성이 완성된 것은 아니다. 전체 사전 검사는 이후 DB와 I/O를 원자적으로 만들지 않고, 복구는 각 단계의 멱등성에 의존한다. 이번 변경은 어떤 상태를 같다고 볼지와 어떤 원본을 상태 변경 전에 믿을지 명확히 한 작업이었다.