DB가 되돌아가도 덮어쓴 이미지 바이트는 돌아오지 않는다
슬라이드 이미지를 재생성할 때 페이지의 다음 버전 번호로 파일명을 만들면 경로가 안정적으로 보인다. 그러나 두 실행이 같은 현재 버전을 읽으면 다음 버전도 같다. AI Commons에서는 페이지와 이미지 이력을 트랜잭션으로 저장하더라도, 그 전에 쓴 파일이 다른 실행의 파일을 덮을 수 있었다.
다음은 같은 페이지를 처리하는 두 실행의 충돌을 설명하는 순서다. 실제 운영 로그가 아니라 공유 경로에서 가능한 실패 조건이다.
- A와 B가 같은 버전을 읽는다둘 다 다음 버전의 같은 파일 경로를 계산한다.
- B가 먼저 파일과 DB를 저장한다페이지는 B의 경로를 가리키고, 이미지 이력에도 다음 버전이 생긴다.
- A가 같은 파일을 덮어쓴다뒤이은 이력 삽입은 페이지·버전의 유일 제약에 걸려 트랜잭션이 롤백된다.
- DB와 파일의 결과가 갈라진다DB 기록은 B의 결과인데 바이트는 A의 이미지다. A가 실패 정리로 파일을 지우면 B가 공개한 경로까지 사라진다.
유일 제약은 중복 이력을 막는다. 이미 저장소에 수행한 덮어쓰기와 삭제까지 취소하지는 않는다. 파일을 트랜잭션 전에 만든다는 조건을 유지하려면 각 실행이 남의 파일에 접근하지 않는 경로부터 필요했다.
재시도는 같은 작업 ID로 다른 실행을 만든다
변경은 파일명에 식별자를 넣되, 논리적인 jobId 대신 실행마다 새로 만든 artifactId를 사용했다. 큐의 재시도와 stalled 실행 재개는 같은 작업 ID를 사용할 수 있다. 멈춘 실행이 늦게 돌아오는 동안 새 시도가 시작되면 jobId만 붙인 경로도 공유된다.
재생성의 버전 번호는 재생성 여부와 현재 버전으로 정한다. 이전 이미지를 참조했는지와는 별개다. 참조 파일을 읽지 못했거나 디자인 변경으로 참조를 끊었다고 버전을 1로 되돌리면 기존 이력과 다시 충돌한다. 아래는 재생성 경로의 핵심을 줄인 예시다.
const artifactId = ulid(); // 이 실행에서 새로 만든다
const nextVersion = currentImageVersion + 1;
const fileName = `${pageId}_v${nextVersion}_${artifactId}.webp`; 원본 이미지와 서비스용 이미지 모두 같은 실행 식별자를 가진다. 서비스는 파일명 규칙을 다시 추정하지 않고 페이지의 imageKey를 읽는다. 따라서 기존 이미지 경로를 일괄 변경할 필요도 없다.
파일 생성부터 DB 공개까지 한 실행이 책임진다
경로가 분리돼도 실패한 파일은 남을 수 있다. 첫 파일을 쓴 뒤에는 설정 조회, 워터마크 처리, 서비스용 파일 쓰기와 DB 저장이 이어진다. 이력 삽입 실패만 잡으면 그 앞 단계의 실패를 놓친다. withPageArtifacts는 첫 쓰기부터 공개까지의 콜백을 감싸고, 콜백이 성공하지 않으면 등록한 자기 경로만 지운다.
// 핵심 제어 흐름만 발췌
let published = false;
try {
const result = await fn();
published = true;
return result;
} finally {
if (!published) {
await Promise.all(ownFiles.map(
f => fs.promises.unlink(f).catch(() => undefined)
));
}
} 페이지와 이미지 이력은 같은 DB 트랜잭션에 저장하고, 배치 경로의 자산 기록도 그 트랜잭션에 넣는다. 페이지가 삭제돼 조건부 갱신이 0행이면 공개를 실패시킨다. B가 성공하고 A가 이력 충돌로 실패해도 A는 A의 파일만 삭제하므로, B의 이미지 바이트와 경로는 유지된다.
파일을 공유한 채 롤백 때 이전 바이트를 복원하는 대안은 복원 순서까지 조정해야 한다. 실행별 경로는 그 조정 비용을 줄이는 대신 파일 수와 실패 정리 책임을 늘린다.
입력의 리비전을 기록하는 일과 최신 결과를 고르는 일은 다르다
생성 중 디자인이 바뀌면 결과에는 시작 때 캡처한 디자인 리비전만 기록할 수 있다. 저장 직전 재조회한 최신 리비전을 그대로 붙이면, 옛 입력으로 만든 이미지를 새 디자인의 결과로 표시한다. 저장 시점에는 캡처한 값이 아직 유효한지만 확인하고, 달라졌다면 null을 기록해 다음 생성에서 다시 판단하도록 한다.
| 장치 | 지키는 조건 | 남는 문제 |
|---|---|---|
| 실행별 artifactId | 다른 실행의 파일을 덮거나 정리하지 않음 | 어느 결과가 최신 페이지가 될지는 결정하지 않음 |
| 페이지·이력 트랜잭션 | DB 기록을 함께 저장하거나 함께 취소 | 파일 쓰기는 DB 롤백 밖에 있음 |
| 캡처 리비전 유효성 검사 | 옛 입력 결과에 새 리비전을 붙이지 않음 | 요청 순서에 따른 결과 채택은 별도 조건 |
초기 변경에 있던 작업 소유권과 순서 제어는 후속 변경에서 되돌려졌다. 현재 페이지 저장 조건은 페이지 ID와 슬라이드 ID이며, 동시 실행에서는 마지막에 저장한 결과가 남는다. 실행별 파일명은 결과 선택을 위한 소유권 CAS가 아니다. 사용자 편집을 보호하는 조건은 동시 편집과 늦은 AI 결과에서 별도로 다룬다.
실패한 실행이 지우는 경로를 검증한다
회귀 테스트는 이미지 이력 삽입에 유일 제약 오류를 주입하고, 쓰기에 사용한 경로가 새 artifactId를 포함하는지 확인한다. 삭제 요청의 경로도 그 실행이 쓴 경로와 비교한다. 페이지·이력·자산이 트랜잭션 콜백을 사용하는지, 생성 도중 리비전이 바뀌면 최신 값 대신 null을 기록하는지도 확인한다.
이 테스트의 DB와 파일 쓰기·삭제는 모킹돼 있다. PostgreSQL의 실제 롤백이나 여러 워커의 파일 경쟁을 재현하는 통합 테스트는 아니다. 프로세스가 정리 전에 종료되면 파일이 남고, 삭제 오류도 삼킨다. 경로 격리가 지키는 것은 다른 실행의 공개 파일이며, 모든 고아 파일 제거와 완료 상태의 순서까지 보장하지는 않는다.