Skip to content

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

Description

@krestar

한 줄 목표

파일 저장 후 DB transaction이 rollback되거나 Worker Link 문서 업로드가 중복 요청 경합으로 실패해도
이번 요청이 생성한 물리 파일을 안전하게 보상 정리하고, Worker Link의 Idempotency-Key 계약을 Accepted ADR과 일치시킵니다.

왜 필요한가요?

현재 파일 업로드 경로는 파일 원본을 FileStorage에 먼저 저장한 뒤
stored_file 메타데이터, 멱등성 레코드와 감사 이력을 DB에 기록합니다.

이 순서에서는 파일 저장이 성공한 뒤 다음 작업이 실패하면 DB는 rollback되지만
물리 파일은 그대로 남을 수 있습니다.

  • stored_file insert 실패
  • audit 기록 실패
  • Worker Link 멱등성 레코드 unique 충돌
  • 바깥쪽 transaction의 후속 DB 작업 실패
  • transaction commit 실패

영향을 받는 대표 경로는 다음과 같습니다.

  • POST /api/v1/files
  • POST /api/v1/public/worker-links/{token}/documents
  • Renewal 결과 적용 transaction 안에서 FileService.upload()를 호출하는 생성 문서 저장 경로

일반 파일 업로드는 요청마다 새로운 fileId와 storage key를 생성합니다.
이 API 자체를 멱등 API로 바꾸는 것은 이번 이슈의 목표가 아니지만,
실패한 요청이 만든 물리 파일은 재시도 전에 보상 정리되어야 합니다.

Worker Link 문서 업로드는 현재 clientRequestId로 기존 결과를 조회한 뒤 파일을 저장하고
마지막에 worker_document_upload_idempotency를 기록합니다.
동일한 키의 요청이 동시에 들어오면 두 요청이 최초 조회를 모두 통과하여 서로 다른 파일을 저장할 수 있고, (worker_link_id, client_request_id) unique constraint에서 패배한 transaction의 파일은 DB rollback만으로 제거되지 않습니다.

또한 LocalFileStorage는 최종 경로에 바로 파일을 복사합니다.
입력 스트림 오류나 디스크 오류로 store() 자체가 실패하면 부분 파일이 남을 수 있습니다.

Accepted ADR-0002와 Issue #7은 Worker Link 문서 제출에 Idempotency-Key를 필수로 사용하고 같은 키·같은 요청은 같은 semantic result, 같은 키·다른 요청은 409 IDEMPOTENCY_CONFLICT로 처리하도록 정했습니다.
그러나 현재 구현은 optional Idempotency-Key를 실제 처리에 사용하지 않고 multipart의 clientRequestId만 사용하므로 구현·OpenAPI·ADR이 일치하지 않습니다.

DB와 FileStorage는 하나의 ACID transaction이 아니므로 이번 이슈는 완전한 분산 원자성을 주장하지 않습니다.
대신 확인된 rollback과 저장 실패 경로를 동기적으로 보상하고, cleanup 실패나 commit 결과 불명확 상태를 운영자가 추적할 수 있게 만드는 것을 목표로 합니다.

현재 구현

현재 main에서 확인된 저장·transaction 경계는 다음과 같습니다.

  • FileService.upload()
    • fileStorage.store()storedFileRepository.insert() → audit 저장 순서로 실행
    • DB rollback 시 FileStorage 보상 연산 없음
  • WorkerLinkDocumentService.upload()
    • 기존 clientRequestId 조회 → fileStorage.store()stored_file insert → idempotency row 저장 순서로 실행
    • 동시 요청의 unique 충돌 전에 서로 다른 물리 파일이 생성될 수 있음
  • WorkerLinkDocumentController
    • Idempotency-Key가 optional이며 실제 command/service에 전달되지 않음
  • LocalFileStorage.store()
    • 최종 storage key에 Files.copy()를 직접 수행
    • 부분 write와 rollback을 정리하는 delete contract 없음

구현 원칙

1. FileStorage 보상 연산

  • FileStorage Port에 storage key 기반의 idempotent deleteIfExists 또는 동등한 cleanup 연산을 추가합니다.
  • 삭제 대상은 이번 요청에서 서버가 새로 생성한 storage key로 한정합니다.
  • 사용자가 제공한 원본 파일명, Worker Link token 또는 다른 요청의 key를 삭제 경로로 사용하지 않습니다.
  • 같은 key를 여러 번 cleanup해도 성공한 것으로 처리할 수 있어야 합니다.
  • 기존에 commit된 정상 파일이나 기존 멱등성 결과가 가리키는 파일은 cleanup하지 않습니다.

2. transaction rollback 보상 경계

  • 서비스 메서드 내부 예외뿐 아니라 메서드 반환 후 commit 실패와 바깥쪽 transaction의 후속 작업 실패도 요청 소유 파일의 보상 범위에 포함합니다.
  • 구현은 transaction synchronization 또는 동등하게 실제 transaction completion을 관찰할 수 있는 방식을 사용하며, 파일 생성과 보상 관찰 사이에 정리되지 않는 실패 구간이 생기지 않도록 합니다.
  • transaction이 COMMITTED이면 파일을 유지하고, rollback이 확인된 경우에만 요청 소유 파일을 cleanup합니다.
  • commit 결과가 UNKNOWN인 경우 정상 DB row가 존재할 가능성이 있으므로 파일을 무조건 삭제하지 않고 안전한 구조화 로그를 남겨 후속 reconciliation 대상으로 분류합니다.
  • cleanup 실패가 원래 DB·transaction 예외를 덮어쓰거나 요청을 성공으로 바꾸지 않도록 합니다.
  • cleanup 실패 로그에는 request_id, 안전한 서버 생성 storage key, 실패 단계처럼 진단에 필요한 최소 정보만 포함합니다.
  • metric label에는 storage key, 파일명, token처럼 고유값이나 민감값을 넣지 않습니다.

3. LocalFileStorage의 부분 파일 방지

  • 저장 도중 실패해도 최종 storage key가 부분 파일 상태로 노출되거나 남지 않도록 합니다.
  • 서버 생성 임시 파일에 기록한 뒤 안전하게 finalize하는 방식 또는 동등한 수준으로 완전한 write만 최종 파일로 확정하는 방식을 사용합니다. 같은 파일시스템의 atomic move를 사용할 수 있다면 우선 고려합니다.
  • 쓰기 또는 finalize가 실패하면 구현이 만든 임시 산출물과 생성됐을 수 있는 부분 파일을 idempotent하게 정리합니다.
  • 정상 저장 후에는 구현이 만든 임시 산출물이 남지 않습니다.
  • storage key 형식과 기존 정상 파일의 최종 위치는 변경하지 않습니다.

별도의 DB 상태 모델과 복구 작업 없이 DB commit 후 staging 파일 finalize 방식으로 바꾸지는 않습니다. 이 방식은 finalize 실패 시 DB row는 있지만 최종 파일이 없는 반대 방향의 불일치를 만들 수 있기 때문입니다.

일반 파일 업로드

POST /api/v1/files

  • FileStorage.store() 도중 실패하면 최종 파일과 임시 파일이 남지 않습니다.
  • 파일 저장 후 stored_file insert 또는 audit 기록이 실패하면 요청 소유 파일을 정리합니다.
  • FileService.upload()가 더 큰 transaction에 참여한 뒤 후속 DB 작업이 실패해도 해당 transaction에서 생성한 파일을 정리합니다.
  • 정상 commit 시 기존 fileId, MIME, size, scan status 응답 계약과 다운로드 경로를 유지합니다.
  • DB 또는 storage 오류를 성공 응답으로 변환하지 않습니다.
  • POST /api/v1/files에 범용 Idempotency-Key를 도입하지 않습니다.

Worker Link 문서 업로드 멱등성

POST /api/v1/public/worker-links/{token}/documents

Canonical key와 요청 동일성

  • Accepted ADR에 따라 Idempotency-Key header를 필수 canonical key로 사용합니다.
  • key는 허용 길이와 형식을 파일 저장 전에 검증하며, 원본 key를 로그나 DB에 저장하지 않고 안전한 hash만 저장합니다.
  • 기존 Client 호환을 위해 multipart clientRequestId는 optional field로 유지하되 deprecated·non-authoritative로 표시하고 멱등성 판단에는 사용하지 않습니다.
  • Idempotency-Key가 없거나 형식이 잘못된 요청은 물리 파일을 생성하기 전에 400 VALIDATION_FAILED로 거절합니다. 두 값의 문자열 불일치만으로 요청을 거절하는 새 계약은 추가하지 않습니다.
  • 후속 이슈에서 clientRequestId를 제거할 수 있도록 canonical header와 legacy field의 전환 규칙을 OpenAPI와 contract test에 명시합니다.
  • request hash에는 검증·정규화된 문서 유형, 파일명, MIME, size와 파일 내용 checksum 등 결과에 영향을 주는 값을 포함합니다.
  • Worker Link 원본 token은 request hash, 멱등성 레코드, 저장 key, 로그와 오류에 포함하지 않습니다. scope에는 안전한 worker_link_id와 action을 사용합니다.

순차·동시 재요청

  • 같은 scope에서 동일한 key와 동일한 request hash를 순차 재요청하면 새 파일을 만들지 않고 기존 upload_id와 같은 semantic result를 반환합니다.
  • 동일한 key와 동일한 요청이 동시에 들어와도 두 호출은 같은 성공 결과로 수렴하고 DB 멱등성 row와 최종 물리 파일은 하나만 남습니다.
  • 구현상 패배 요청이 임시 또는 최종 파일을 생성했다면 해당 요청 소유 파일만 정리합니다.
  • 같은 key를 다른 request hash에 사용하면 기존 성공 결과와 파일을 변경·삭제하지 않고 409 IDEMPOTENCY_CONFLICT를 반환합니다.
  • unique 충돌 같은 내부 경합을 그대로 5xx로 노출하지 않고 rollback 이후에도 위 semantic result를 안전하게 반환합니다. 구현은 key 단위 선점, lock, 분리된 조회 transaction 또는 동등한 방식을 선택할 수 있습니다.
  • 다른 Worker Link, 다른 사업장, 다른 요청이 소유한 파일은 cleanup 대상이 되지 않습니다.

DB·API 영향

  • 일반 파일 업로드 요청·응답 schema: 변경 없음
  • Worker Link 업로드 응답 schema: 변경 없음
  • Worker Link 업로드 요청: Idempotency-Key를 optional에서 required로 바로잡고 clientRequestId를 optional·deprecated 호환 field로 명시
  • Client: 현재 두 값을 함께 보내는 동작은 유지할 수 있으며, Idempotency-Key가 실제 canonical key로 처리되는지 OpenAPI·Client contract test로 확인
  • DB: key hash와 request hash를 안전하게 저장하기 위해 필요한 경우 additive Flyway migration 추가
  • 기존 멱등성 레코드: migration이 필요하면 기존 데이터의 보존·호환 전략을 migration 설명과 테스트에 포함
  • 파일 저장 최종 경로와 storage key 형식: 변경 없음
  • 기존 정상 저장 파일 migration: 없음
  • AI Runtime 계약: 변경 없음

완료 조건

자동 테스트

  • ./gradlew clean test
  • 일반 파일 업로드 정상 commit 후 파일 유지
  • FileStorage.store() 중간 실패 후 임시·최종 파일 0개
  • 파일 저장 후 stored_file insert 실패 시 요청 소유 파일 cleanup
  • stored_file 저장 후 audit 실패·transaction rollback 시 cleanup
  • 바깥쪽 transaction의 후속 작업 실패 시 FileService.upload()가 생성한 파일 cleanup
  • transaction commit 후에는 cleanup하지 않음
  • rollback cleanup을 반복 호출해도 다른 파일에 영향 없음
  • cleanup 자체가 실패해도 원래 요청이 성공으로 처리되거나 원래 예외가 유실되지 않음
  • transaction completion status가 UNKNOWN이면 파일을 맹목적으로 삭제하지 않고 관측 이벤트 기록
  • Worker Link 동일 key·동일 요청의 순차 재요청이 같은 upload_id 반환
  • Worker Link 동일 key·동일 요청의 동시 호출이 같은 성공 결과 반환
  • 동시 요청 후 idempotency row 1개, stored_file row 1개, 최종 파일 1개, 임시 파일 0개
  • Worker Link 동일 key·다른 파일 또는 metadata 요청은 409 IDEMPOTENCY_CONFLICT
  • header 누락 또는 잘못된 형식은 파일 생성 전 400
  • clientRequestId가 없거나 header와 다른 경우에도 정의한 legacy 호환 규칙대로 동작하며 멱등성 판단은 header를 기준으로 수행
  • 정상 파일 다운로드·WorkerDocument 연결·OCR 원본 접근·Renewal 생성 문서 저장 회귀 없음

PostgreSQL 16·실제 LocalFileStorage 통합 검증

  • PostgreSQL 16에서 unique 충돌과 rollback 경로를 자동화된 통합 테스트로 재현합니다.
  • 격리된 LocalFileStorage 임시 디렉터리에서 실패 전후 최종 파일과 임시 파일 수를 검증합니다.
  • barrier를 사용한 동일 요청 concurrent test를 반복 실행합니다.
  • 각 실행 후 DB row 수, 멱등성 결과와 물리 파일 수가 일치하는지 확인합니다.
  • H2 테스트만으로 PostgreSQL의 unique 대기·충돌 동작을 대신하지 않습니다.

보안·개인정보

  • 파일 본문, 원본 파일명, Worker Link token, 원본 Idempotency-Key, JWT, API Key와 비밀번호를 일반 로그·오류·metric에 추가하지 않습니다.
  • cleanup 대상은 서버가 생성하고 이번 요청이 소유한 storage key로 한정합니다.
  • 모든 DB 조회·연결은 기존 company_id와 Worker Link scope를 유지합니다.
  • cleanup 실패 로그에서도 문서 내용과 민감한 원본 파일명을 기록하지 않습니다.
  • request hash는 일방향 digest로 저장하며 원본 파일 내용이나 token을 복원 가능한 형태로 보관하지 않습니다.
  • Accepted ADR의 FileStorage·tenant·idempotency 경계를 유지합니다.

관측성

  • 최소한 cleanup 시도·실패와 transaction unknown 상태를 구분할 수 있는 안전한 구조화 로그를 추가합니다.
  • cleanup 실패 metric이 운영상 필요하면 bounded tag만 사용하는 counter를 추가할 수 있으며, transaction unknown 전용 metric은 필수 범위로 강제하지 않습니다.
  • 관측 정보에 request_id, endpoint/action, storage 구현 종류와 실패 단계는 포함할 수 있습니다.
  • storage key는 필요한 경우 로그 field에만 제한적으로 기록하고 metric tag로 사용하지 않습니다.
  • cleanup 실패와 transaction unknown 상태에 대한 운영 대응 방법을 문서화합니다.

배포 후 Smoke Test

  1. 일반 파일을 업로드하고 기존 API로 다운로드합니다.
  2. WorkerDocument에 파일을 연결하고 문서함·OCR 원본 접근을 확인합니다.
  3. Worker Link에서 같은 key·같은 파일을 순차 재요청하여 같은 upload_id가 반환되는지 확인합니다.
  4. 통합 테스트 실패 지점에서 DB rollback 후 요청 소유 파일이 정리되는지 확인합니다.
  5. Renewal 생성 문서 저장과 후속 WorkerDocument 연결이 정상인지 확인합니다.
  6. cleanup failure와 transaction unknown 관측 이벤트에 민감정보가 없는지 확인합니다.

롤백

  • storage key와 기존 정상 파일의 최종 경로 형식은 변경하지 않습니다.
  • additive DB migration을 추가한다면 이전 애플리케이션이 새 schema에서도 동작할 수 있도록 배포 순서와 하위 호환성을 검증합니다.
  • DB migration이 없다면 애플리케이션 버전만 이전 버전으로 되돌릴 수 있습니다.
  • 이미 존재하는 orphan 파일은 이번 이슈에서 일괄 삭제하지 않습니다.
  • 기존 orphan 정리가 필요하면 DB 참조 여부, transaction 불명확 상태와 파일 ownership을 검증하는 별도 운영 cleanup/reconciliation 작업으로 분리합니다.

이번 이슈에서 하지 않는 것

  • 범용 분산 transaction 도입
  • S3 등 외부 Object Storage migration
  • 기존 저장 파일 전체 재배치
  • 기존 orphan 파일 일괄 삭제
  • 주기적인 전체 storage reconciliation scheduler 구현
  • 바이러스 검사 인프라
  • 파일 보유기간·자동 삭제 정책
  • POST /api/v1/files의 멱등 API 전환
  • 모든 POST API의 공통 Idempotency Framework
  • Renewal 실행 자체의 멱등성 변경
  • deprecated clientRequestId의 즉시 제거

Metadata

Metadata

Assignees

Labels

area:infraServer Dockerfile·DB 설정·CI hook·배포 가능성 영역; 통합 인프라 운영은 infra 저장소와 조율area:serverSpring Boot API·도메인·DB·tenant·Task Workflow 영역; Prompt·모델·Provider 구현 제외priority:P1핵심 작업 다음으로 처리할 중요 작업

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions