한 줄 목표
파일 저장 후 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 보상 연산
2. transaction rollback 보상 경계
3. LocalFileStorage의 부분 파일 방지
별도의 DB 상태 모델과 복구 작업 없이 DB commit 후 staging 파일 finalize 방식으로 바꾸지는 않습니다. 이 방식은 finalize 실패 시 DB row는 있지만 최종 파일이 없는 반대 방향의 불일치를 만들 수 있기 때문입니다.
일반 파일 업로드
POST /api/v1/files
Worker Link 문서 업로드 멱등성
POST /api/v1/public/worker-links/{token}/documents
Canonical key와 요청 동일성
순차·동시 재요청
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 계약: 변경 없음
완료 조건
자동 테스트
PostgreSQL 16·실제 LocalFileStorage 통합 검증
보안·개인정보
관측성
배포 후 Smoke Test
- 일반 파일을 업로드하고 기존 API로 다운로드합니다.
- WorkerDocument에 파일을 연결하고 문서함·OCR 원본 접근을 확인합니다.
- Worker Link에서 같은 key·같은 파일을 순차 재요청하여 같은
upload_id가 반환되는지 확인합니다.
- 통합 테스트 실패 지점에서 DB rollback 후 요청 소유 파일이 정리되는지 확인합니다.
- Renewal 생성 문서 저장과 후속 WorkerDocument 연결이 정상인지 확인합니다.
- 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의 즉시 제거
한 줄 목표
파일 저장 후 DB transaction이 rollback되거나 Worker Link 문서 업로드가 중복 요청 경합으로 실패해도
이번 요청이 생성한 물리 파일을 안전하게 보상 정리하고, Worker Link의
Idempotency-Key계약을 Accepted ADR과 일치시킵니다.왜 필요한가요?
현재 파일 업로드 경로는 파일 원본을
FileStorage에 먼저 저장한 뒤stored_file메타데이터, 멱등성 레코드와 감사 이력을 DB에 기록합니다.이 순서에서는 파일 저장이 성공한 뒤 다음 작업이 실패하면 DB는 rollback되지만
물리 파일은 그대로 남을 수 있습니다.
stored_fileinsert 실패영향을 받는 대표 경로는 다음과 같습니다.
POST /api/v1/filesPOST /api/v1/public/worker-links/{token}/documentsFileService.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 결과 불명확 상태를 운영자가 추적할 수 있게 만드는 것을 목표로 합니다.
docs/adr/0002-api-security-and-error-contract.md현재 구현
현재
main에서 확인된 저장·transaction 경계는 다음과 같습니다.FileService.upload()fileStorage.store()→storedFileRepository.insert()→ audit 저장 순서로 실행WorkerLinkDocumentService.upload()clientRequestId조회 →fileStorage.store()→stored_fileinsert → idempotency row 저장 순서로 실행WorkerLinkDocumentControllerIdempotency-Key가 optional이며 실제 command/service에 전달되지 않음LocalFileStorage.store()Files.copy()를 직접 수행구현 원칙
1. FileStorage 보상 연산
FileStoragePort에 storage key 기반의 idempotentdeleteIfExists또는 동등한 cleanup 연산을 추가합니다.2. transaction rollback 보상 경계
COMMITTED이면 파일을 유지하고, rollback이 확인된 경우에만 요청 소유 파일을 cleanup합니다.UNKNOWN인 경우 정상 DB row가 존재할 가능성이 있으므로 파일을 무조건 삭제하지 않고 안전한 구조화 로그를 남겨 후속 reconciliation 대상으로 분류합니다.request_id, 안전한 서버 생성 storage key, 실패 단계처럼 진단에 필요한 최소 정보만 포함합니다.3. LocalFileStorage의 부분 파일 방지
별도의 DB 상태 모델과 복구 작업 없이
DB commit 후 staging 파일 finalize방식으로 바꾸지는 않습니다. 이 방식은 finalize 실패 시 DB row는 있지만 최종 파일이 없는 반대 방향의 불일치를 만들 수 있기 때문입니다.일반 파일 업로드
POST /api/v1/filesFileStorage.store()도중 실패하면 최종 파일과 임시 파일이 남지 않습니다.stored_fileinsert 또는 audit 기록이 실패하면 요청 소유 파일을 정리합니다.FileService.upload()가 더 큰 transaction에 참여한 뒤 후속 DB 작업이 실패해도 해당 transaction에서 생성한 파일을 정리합니다.fileId, MIME, size, scan status 응답 계약과 다운로드 경로를 유지합니다.POST /api/v1/files에 범용Idempotency-Key를 도입하지 않습니다.Worker Link 문서 업로드 멱등성
POST /api/v1/public/worker-links/{token}/documentsCanonical key와 요청 동일성
Idempotency-Keyheader를 필수 canonical key로 사용합니다.clientRequestId는 optional field로 유지하되 deprecated·non-authoritative로 표시하고 멱등성 판단에는 사용하지 않습니다.Idempotency-Key가 없거나 형식이 잘못된 요청은 물리 파일을 생성하기 전에400 VALIDATION_FAILED로 거절합니다. 두 값의 문자열 불일치만으로 요청을 거절하는 새 계약은 추가하지 않습니다.clientRequestId를 제거할 수 있도록 canonical header와 legacy field의 전환 규칙을 OpenAPI와 contract test에 명시합니다.worker_link_id와 action을 사용합니다.순차·동시 재요청
upload_id와 같은 semantic result를 반환합니다.409 IDEMPOTENCY_CONFLICT를 반환합니다.DB·API 영향
Idempotency-Key를 optional에서 required로 바로잡고clientRequestId를 optional·deprecated 호환 field로 명시Idempotency-Key가 실제 canonical key로 처리되는지 OpenAPI·Client contract test로 확인완료 조건
자동 테스트
./gradlew clean testFileStorage.store()중간 실패 후 임시·최종 파일 0개stored_fileinsert 실패 시 요청 소유 파일 cleanupstored_file저장 후 audit 실패·transaction rollback 시 cleanupFileService.upload()가 생성한 파일 cleanupUNKNOWN이면 파일을 맹목적으로 삭제하지 않고 관측 이벤트 기록upload_id반환stored_filerow 1개, 최종 파일 1개, 임시 파일 0개409 IDEMPOTENCY_CONFLICT400clientRequestId가 없거나 header와 다른 경우에도 정의한 legacy 호환 규칙대로 동작하며 멱등성 판단은 header를 기준으로 수행PostgreSQL 16·실제 LocalFileStorage 통합 검증
보안·개인정보
Idempotency-Key, JWT, API Key와 비밀번호를 일반 로그·오류·metric에 추가하지 않습니다.company_id와 Worker Link scope를 유지합니다.관측성
request_id, endpoint/action, storage 구현 종류와 실패 단계는 포함할 수 있습니다.배포 후 Smoke Test
upload_id가 반환되는지 확인합니다.롤백
이번 이슈에서 하지 않는 것
POST /api/v1/files의 멱등 API 전환clientRequestId의 즉시 제거