fix(file): 파일 저장 rollback 보상과 Worker Link 업로드 멱등성을 보강 - #185
Draft
krestar wants to merge 11 commits into
Draft
Conversation
- 임시 파일에 완전히 기록한 뒤 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
marked this pull request as draft
August 15, 2026 15:26
- 파일 저장 성공 후에만 롤백 cleanup을 활성화한다. - storage key 충돌로 저장에 실패한 경우 기존 파일을 삭제하지 않는다. - 일반 파일 및 Worker Link 업로드의 파일 ownership 회귀 테스트를 추가한다.
- 지원하는 documentType을 OpenAPI에 명시하고 잘못된 유형을 파일 생성 전에 거절한다. - V51 적용 후 구버전 서버로 롤백할 때 canonical 멱등성 결과를 재사용하지 못하는 한계를 문서화한다. - 롤백 가능 기간에는 clientRequestId 전송을 유지하도록 운영 절차를 보강한다.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
왜 필요한가
파일 저장소 쓰기는 DB 트랜잭션 밖에서 발생하므로, 파일 저장 이후 DB 작업이 실패하면 DB에는 참조가 없지만 물리 파일만 남을 수 있었습니다.
반대로 로컬 파일을 최종 경로에 직접 복사하면 쓰기 실패 시 불완전한 최종 파일이 노출될 가능성도 있었습니다.
Worker Link 문서 업로드는 재시도나 동시 요청에서 같은 논리 요청이 중복 실행될 수 있었습니다.
기존
clientRequestId는 클라이언트가 선택적으로 보내는 multipart 필드였고, 같은 키의 요청 내용이 달라졌는지 판별하거나 동시 요청을 하나의 결과로 수렴시키는 계약이 부족했습니다.일반 파일 업로드와 Worker Link 업로드를 함께 수정한 이유는 두 경로 모두 비트랜잭션 파일 저장과 DB 트랜잭션 사이의 일관성 경계를 공유하기 때문입니다.
공통 저장 primitive와 rollback 보상 정리를 먼저 보강하고, Worker Link 경로에만 API 수준의 멱등성 계약과 동시성 제어를 추가했습니다.
Closes #172
무엇이 바뀌나
1. 로컬 파일 저장을 원자화하고 멱등 삭제를 지원
ATOMIC_MOVE로만 최종 경로에 반영합니다.FileStorage.deleteIfExists()를 추가해 rollback 보상 정리를 반복 호출해도 안전하게 했습니다.2. DB rollback 시 생성된 파일을 보상 정리
store()성공 직후에만 보상 정리를 활성화해, 생성 key가 기존 정상 파일과 충돌하여 저장에 실패한 경우 그 기존 파일을 삭제하지 않습니다.UNKNOWN이거나 보상 삭제가 실패하면 원래 실패 원인을 덮지 않고 운영 로그로 남깁니다.3. Worker Link 문서 업로드에 멱등성 계약 적용
Idempotency-Key헤더를 필수 canonical 식별자로 사용합니다.upload_id를 반환합니다.409 IDEMPOTENCY_CONFLICT를 반환합니다.clientRequestId는 선택·deprecated 입력으로 유지하되 멱등성 판단에는 사용하지 않습니다.canonical:<stored_file_id>형식으로 저장해 해시 식별자와의 충돌을 방지합니다.PASSPORT_COPY는 문서 종류이며 PDF 전용 제한이 아닙니다.documentType은 서버의 지원 enum을 OpenAPI에 명시하고, 알 수 없는 값은 파일 생성 전에400 VALIDATION_FAILED로 거절합니다.4. V51 멱등성 해시 스키마 추가
worker_document_upload_idempotency에 다음 nullable 컬럼을 추가합니다.idempotency_key_hashrequest_hashNULL인 상태로 계속 읽을 수 있습니다.5. 회귀·PostgreSQL 동시성 테스트와 운영 문서 보강
검증
자동 테스트
.\gradlew.bat clean testBUILD SUCCESSFULWorkerLinkDocumentPostgreSqlIntegrationTestDemoSeedPostgreSqlApplicationIntegrationTestWorkerLinkSecurityIntegrationTestdocumentType거절 및 OpenAPI enum 계약 테스트수동 테스트
Idempotency-Key와 같은 파일을 순차 재시도upload_id반환Idempotency-Key와 같은 파일을 2개 요청으로 동시 업로드upload_id로 수렴Idempotency-Key에 다른 파일을 업로드409 IDEMPOTENCY_CONFLICTclient_request_id = canonical:<stored_file_id>stored_file_id = storage_key = upload_id보안·개인정보
Idempotency-Key는 DB에 저장하지 않고 SHA-256 해시만 저장합니다.API·DB·운영 영향
API
POST /api/v1/public/worker-links/{token}/documentsIdempotency-Key헤더가 필수입니다.409 IDEMPOTENCY_CONFLICT를 반환합니다.DB
V51__add_worker_document_upload_idempotency_hashes.sqlclient_request_id는canonical:<stored_file_id>이므로, 구버전 서버는 신버전에서 성공한 요청을 기존clientRequestId로 찾지 못할 수 있습니다. 이는 schema 호환과 별개인 의미적 rollback 한계입니다.Idempotency-Key와 multipartclientRequestId를 함께 전송하는 현재 동작을 유지하고, 구버전 rollback 후 신버전 성공 요청을 자동 재시도하지 않습니다.infra 및 배포 관련 전달 사항
main에 반영한 뒤 이 PR을 최신main에 맞춥니다.main의 다음 migration 번호를 다시 조회합니다. 다른 PR이 V51을 먼저 사용했다면 이 PR migration을 다음 번호로 변경합니다.화면 또는 응답 예시
같은 키와 같은 요청을 재시도하면 최초 응답과 같은
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가 다른 문서 업로드 요청에 이미 사용되었습니다." }머지 체크리스트
main에 반영main반영 및 충돌 해결