Skip to content

fix(file): 파일 저장 rollback 보상과 Worker Link 업로드 멱등성을 보강 - #185

Merged
krestar merged 13 commits into
mainfrom
fix/172-file-storage-rollback-idempotency
Aug 16, 2026
Merged

fix(file): 파일 저장 rollback 보상과 Worker Link 업로드 멱등성을 보강#185
krestar merged 13 commits into
mainfrom
fix/172-file-storage-rollback-idempotency

Conversation

@krestar

@krestar krestar commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Draft / 머지 전 확인 필요

구현과 최종 HEAD의 PostgreSQL 전체 테스트를 완료했습니다.
V48~V50 반영 순서와 머지 직전 migration 번호를 확인한 뒤 Ready로 전환합니다.

왜 필요한가

파일 저장소 쓰기는 DB 트랜잭션 밖에서 발생하므로, 파일 저장 이후 DB 작업이 실패하면 DB에는 참조가 없지만 물리 파일만 남을 수 있었습니다.
반대로 로컬 파일을 최종 경로에 직접 복사하면 쓰기 실패 시 불완전한 최종 파일이 노출될 가능성도 있었습니다.

Worker Link 문서 업로드는 재시도나 동시 요청에서 같은 논리 요청이 중복 실행될 수 있었습니다.
기존 clientRequestId는 클라이언트가 선택적으로 보내는 multipart 필드였고, 같은 키의 요청 내용이 달라졌는지 판별하거나 동시 요청을 하나의 결과로 수렴시키는 계약이 부족했습니다.

일반 파일 업로드와 Worker Link 업로드를 함께 수정한 이유는 두 경로 모두 비트랜잭션 파일 저장과 DB 트랜잭션 사이의 일관성 경계를 공유하기 때문입니다.
공통 저장 primitive와 rollback 보상 정리를 먼저 보강하고, Worker Link 경로에만 API 수준의 멱등성 계약과 동시성 제어를 추가했습니다.

Closes #172

무엇이 바뀌나

1. 로컬 파일 저장을 원자화하고 멱등 삭제를 지원

  • storage root와 같은 파일시스템의 임시 파일에 먼저 기록합니다.
  • 선언된 파일 크기와 실제 기록된 크기가 다르면 최종 반영 전에 실패시킵니다.
  • 기록이 끝난 파일은 ATOMIC_MOVE로만 최종 경로에 반영합니다.
  • atomic move를 지원하지 않는 환경에서는 일반 move로 fallback하지 않고 fail-closed 합니다.
  • 저장 실패 시 임시 파일을 정리하고, 정리 실패는 원래 예외의 suppressed exception으로 보존합니다.
  • FileStorage.deleteIfExists()를 추가해 rollback 보상 정리를 반복 호출해도 안전하게 했습니다.

2. DB rollback 시 생성된 파일을 보상 정리

  • 파일 저장 전에 트랜잭션 완료 상태를 관찰하도록 등록합니다.
  • store() 성공 직후에만 보상 정리를 활성화해, 생성 key가 기존 정상 파일과 충돌하여 저장에 실패한 경우 그 기존 파일을 삭제하지 않습니다.
  • 파일 저장 후 DB 작업이 rollback되거나 commit 과정에서 실패하면 생성된 파일을 삭제합니다.
  • 정상 commit에서는 파일을 유지합니다.
  • 트랜잭션 결과가 UNKNOWN이거나 보상 삭제가 실패하면 원래 실패 원인을 덮지 않고 운영 로그로 남깁니다.
  • 일반 파일 업로드와 Worker Link 문서 업로드가 같은 보상 정리 방식을 사용합니다.

3. Worker Link 문서 업로드에 멱등성 계약 적용

  • Idempotency-Key 헤더를 필수 canonical 식별자로 사용합니다.
  • 문서 종류, 파일명, MIME type, 크기, 파일 checksum을 기반으로 요청 해시를 계산합니다.
  • 같은 Worker Link에서 같은 키와 같은 요청을 재시도하면 기존 upload_id를 반환합니다.
  • 같은 키를 다른 내용에 재사용하면 409 IDEMPOTENCY_CONFLICT를 반환합니다.
  • Worker Link 행 잠금으로 같은 링크의 동시 요청을 직렬화하고, DB 행과 물리 파일이 하나의 결과로 수렴하도록 했습니다.
  • legacy multipart clientRequestId는 선택·deprecated 입력으로 유지하되 멱등성 판단에는 사용하지 않습니다.
  • 신규 레코드의 legacy 식별자는 canonical:<stored_file_id> 형식으로 저장해 해시 식별자와의 충돌을 방지합니다.
  • 문서 MIME 허용 범위는 기존과 동일하게 JPEG, PNG, WEBP, PDF를 유지합니다. PASSPORT_COPY는 문서 종류이며 PDF 전용 제한이 아닙니다.
  • documentType은 서버의 지원 enum을 OpenAPI에 명시하고, 알 수 없는 값은 파일 생성 전에 400 VALIDATION_FAILED로 거절합니다.

4. V51 멱등성 해시 스키마 추가

  • worker_document_upload_idempotency에 다음 nullable 컬럼을 추가합니다.
    • idempotency_key_hash
    • request_hash
  • canonical 행에 대한 unique/check constraint를 추가합니다.
  • 기존 legacy 행은 두 컬럼이 모두 NULL인 상태로 계속 읽을 수 있습니다.
  • 기존 데이터를 삭제하거나 강제 backfill하지 않습니다.

5. 회귀·PostgreSQL 동시성 테스트와 운영 문서 보강

  • source read 실패, size mismatch, finalize 실패, 기존 최종 파일 보호, 반복 삭제를 검증합니다.
  • rollback/commit 성공/commit 실패/결과 UNKNOWN에서 파일 보상 동작을 검증합니다.
  • 실제 PostgreSQL 16과 HTTP 요청으로 Worker Link 동시 업로드가 DB 행 1개와 물리 파일 1개로 수렴하는지 검증합니다.
  • legacy 식별자 전환 충돌과 PostgreSQL 외래 키를 고려한 테스트 정리 순서를 검증합니다.
  • 배포 전후 확인 및 고아 파일 복구 절차를 문서화합니다.

검증

자동 테스트

  • 최종 HEAD를 전용 PostgreSQL 16 테스트 DB에서 .\gradlew.bat clean test
    • 136개 test suite
    • 622개 test
    • skipped 0 / failures 0 / errors 0
    • BUILD SUCCESSFUL
  • Flyway V1~V51 적용 및 validate
  • WorkerLinkDocumentPostgreSqlIntegrationTest
  • DemoSeedPostgreSqlApplicationIntegrationTest
  • WorkerLinkSecurityIntegrationTest
  • 기존 파일 충돌 시 rollback 보상이 기존 정상 파일을 삭제하지 않는 회귀 테스트
  • documentType 거절 및 OpenAPI enum 계약 테스트

수동 테스트

  • 정상 WEBP 문서 업로드
    • 물리 파일 1개 생성
    • 원본과 저장 파일의 SHA-256 일치
    • 임시 파일 0개
  • 같은 Idempotency-Key와 같은 파일을 순차 재시도
    • 같은 upload_id 반환
  • 같은 Idempotency-Key와 같은 파일을 2개 요청으로 동시 업로드
    • 응답 2개가 같은 upload_id로 수렴
    • DB 행 1개, 물리 파일 1개, 임시 파일 0개
  • 같은 Idempotency-Key에 다른 파일을 업로드
    • 409 IDEMPOTENCY_CONFLICT
    • 추가 물리 파일 0개
  • DB canonical 행 확인
    • client_request_id = canonical:<stored_file_id>
    • stored_file_id = storage_key = upload_id
    • key/request hash 저장 확인

보안·개인정보

  • 원본 Idempotency-Key는 DB에 저장하지 않고 SHA-256 해시만 저장합니다.
  • 업로드 응답과 오류 응답에 storage 절대 경로를 노출하지 않습니다.
  • 기존 Worker Link 토큰 검증, 만료, 문서 형식·크기 검증은 유지합니다.
  • path traversal 방어와 storage root 경계 검증을 유지합니다.
  • 로그에는 원본 파일 내용이나 원본 멱등성 키를 남기지 않습니다.

API·DB·운영 영향

API

  • POST /api/v1/public/worker-links/{token}/documents
    • Idempotency-Key 헤더가 필수입니다.
    • 같은 키/같은 요청은 기존 성공 응답을 반환합니다.
    • 같은 키/다른 요청은 409 IDEMPOTENCY_CONFLICT를 반환합니다.
    • 성공 응답 스키마는 변경하지 않습니다.
  • 일반 파일 업로드 API에는 새 멱등성 계약을 추가하지 않았으며 기존 요청·응답 계약을 유지합니다.

DB

  • 신규 Flyway 마이그레이션: V51__add_worker_document_upload_idempotency_hashes.sql
  • nullable 컬럼과 constraint를 추가하는 additive migration입니다.
  • 이전 애플리케이션 버전은 새 컬럼을 사용하지 않아도 기동할 수 있으므로, 애플리케이션 rollback 시 V51을 되돌리지 않고 유지합니다.
  • 다만 신규 레코드의 client_request_idcanonical:<stored_file_id>이므로, 구버전 서버는 신버전에서 성공한 요청을 기존 clientRequestId로 찾지 못할 수 있습니다. 이는 schema 호환과 별개인 의미적 rollback 한계입니다.
  • rollback 가능 기간에는 Client가 Idempotency-Key와 multipart clientRequestId를 함께 전송하는 현재 동작을 유지하고, 구버전 rollback 후 신버전 성공 요청을 자동 재시도하지 않습니다.
  • 이미 적용된 Flyway 파일을 수정하거나 삭제하지 않습니다.

infra 및 배포 관련 전달 사항

  • #174의 V48~V50을 먼저 main에 반영한 뒤 이 PR을 최신 main에 맞춥니다.
  • #176에 중복 포함된 V47~V50의 정리 방향을 관련 담당자와 확인합니다. #176이 현재 상태 그대로 이 PR보다 먼저 머지되는 것을 전제로 하지 않습니다.
  • 이 PR 머지 직전에 main의 다음 migration 번호를 다시 조회합니다. 다른 PR이 V51을 먼저 사용했다면 이 PR migration을 다음 번호로 변경합니다.
  • 실제 파일 볼륨에서 atomic move가 지원되는지 배포 전 smoke test가 필요합니다. 미지원 환경에서는 불완전한 파일을 노출하지 않고 업로드가 실패합니다.
  • V51 unique constraint 적용 전 대상 테이블 규모와 기존 canonical 중복 데이터가 없는지 확인하고, 운영 DB recovery point를 확보합니다.
  • 배포 후 정상 업로드, 같은 키 재시도, 임시 파일 0개를 확인합니다.
  • 애플리케이션 rollback이 필요하면 이전 이미지로 되돌리되 V51 스키마는 유지하고, 위 의미적 멱등성 한계에 따라 자동 재시도를 막고 DB·파일 volume을 대조합니다.

화면 또는 응답 예시

같은 키와 같은 요청을 재시도하면 최초 응답과 같은 upload_id를 반환합니다.

{
  "upload_id": "<same-upload-id>",
  "file_name": "passport.webp",
  "size": 247114,
  "expires_at": "<expires-at>"
}

같은 키에 다른 요청 내용을 사용하면 다음 오류를 반환합니다.

{
  "status": 409,
  "code": "IDEMPOTENCY_CONFLICT",
  "message": "같은 Idempotency-Key가 다른 문서 업로드 요청에 이미 사용되었습니다."
}

머지 체크리스트

  • #174의 V48~V50 migration을 먼저 main에 반영
  • #176에 중복 포함된 V47~V50 정리 방향 확인
  • 최신 main 반영 및 충돌 해결
  • V51 번호 중복 여부 재확인
  • 최종 HEAD를 깨끗한 PostgreSQL 테스트 DB에서 전체 테스트
  • 필수 PR checks 통과
  • infra 담당자에게 파일 볼륨 atomic move 및 V51 사전 점검 항목 전달

- 임시 파일에 완전히 기록한 뒤 atomic move로 저장을 확정한다.
- 저장 실패 시 이번 요청이 만든 임시 산출물을 정리한다.
- storage key 기반 멱등 삭제 계약과 단위 테스트를 추가한다.
- 트랜잭션 완료 상태에 따라 요청 소유 파일을 보상 정리한다.
- 상위 트랜잭션 rollback과 commit 실패 경로까지 관찰한다.
- cleanup 실패와 불명확한 transaction 상태를 안전하게 기록한다.
- 일반 파일 이동 fallback을 제거해 불완전한 최종 파일 노출을 방지한다.
- 원자적 이동이 불가능하면 저장을 실패시키고 임시 파일을 정리한다.
- 기존 파일 보호 테스트 이름을 실제 검증 범위에 맞게 명확히 한다.
- canonical Idempotency-Key hash와 request hash 저장 컬럼을 추가한다.
- Worker Link 범위에서 canonical key의 유일성을 DB 제약으로 보장한다.
- 기존 clientRequestId 기반 레코드와 이전 애플리케이션의 호환성을 유지한다.
- 레거시 insert와 hash 무결성 제약을 PostgreSQL migration 테스트로 검증한다.
- Idempotency-Key를 필수 canonical key로 검증하고 해시만 저장한다.
- 정규화된 요청 정보와 파일 체크섬으로 재시도 및 충돌을 판별한다.
- Worker Link 행 잠금으로 동시 업로드를 직렬화한다.
- 업로드 파일에 트랜잭션 롤백 보상을 적용한다.
- clientRequestId를 선택·deprecated 필드로 전환하고 API 계약 테스트를 보강한다.
- 동일 Worker Link 문서 업로드의 실제 HTTP 경쟁을 PostgreSQL 16에서 재현한다
- 멱등 요청이 하나의 DB 행과 로컬 파일로 수렴하는지 반복 검증한다
- 실제 파일 저장 후 트랜잭션 롤백 시 DB와 파일이 함께 정리되는지 검증한다
- cleanup 실패와 UNKNOWN 결과의 운영 대응 및 배포 Smoke 절차를 문서화한다
- canonical 업로드의 legacy 호환 식별자를 서버 생성 파일 UUID로 분리한다.
- 기존 client_request_id가 Idempotency-Key 해시와 같아도 PK 충돌하지 않게 한다.
- legacy 행과 canonical 행이 함께 존재하는 전환 시나리오를 회귀 테스트로 검증한다.
- worker_document를 task와 stored_file보다 먼저 삭제한다.
- 테스트 실행 순서와 관계없이 FK 제약을 준수하도록 fixture 초기화를 수정한다.
@krestar
krestar marked this pull request as draft August 15, 2026 15:26
krestar and others added 3 commits August 16, 2026 00:26
- 파일 저장 성공 후에만 롤백 cleanup을 활성화한다.
- storage key 충돌로 저장에 실패한 경우 기존 파일을 삭제하지 않는다.
- 일반 파일 및 Worker Link 업로드의 파일 ownership 회귀 테스트를 추가한다.
- 지원하는 documentType을 OpenAPI에 명시하고 잘못된 유형을 파일 생성 전에 거절한다.
- V51 적용 후 구버전 서버로 롤백할 때 canonical 멱등성 결과를 재사용하지 못하는 한계를 문서화한다.
- 롤백 가능 기간에는 clientRequestId 전송을 유지하도록 운영 절차를 보강한다.
@krestar krestar added area:server Spring Boot API·도메인·DB·tenant·Task Workflow 영역; Prompt·모델·Provider 구현 제외 area:infra Server Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율 priority:P1 핵심 작업 다음으로 처리할 중요 작업 status:in-review 구현을 마치고 리뷰 또는 병합을 기다리는 작업 labels Aug 15, 2026
@krestar
krestar marked this pull request as ready for review August 16, 2026 05:43
@krestar
krestar merged commit 8954b85 into main Aug 16, 2026
4 checks passed
@krestar
krestar deleted the fix/172-file-storage-rollback-idempotency branch August 16, 2026 05:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:infra Server Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율 area:server Spring Boot API·도메인·DB·tenant·Task Workflow 영역; Prompt·모델·Provider 구현 제외 priority:P1 핵심 작업 다음으로 처리할 중요 작업 status:in-review 구현을 마치고 리뷰 또는 병합을 기다리는 작업

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[File][Reliability] FileStorage rollback 보상과 Worker Link 업로드 멱등성 보강

1 participant