[BE-81] 서류 업로드 실패 이력 저장 · 운영 조회 API (PRD Operations / A-3)
작업 내용 (설계 의도)
근거 TDD: 20260808-지원관리-확장-tdd.md — “2단계 — 서류 업로드 실패 이력 조회”, “실패 경로” 표
변경 사항
게이트 ② 결정으로 기존 판단을 철회한 티켓입니다. 최초 설계는 업로드 실패를 로그 WARN만 남기고 별도 테이블·API를 만들지 않기로 했으나(Open Questions 4), PRD Operations가 요구하는 “이력으로 저장하고 조회”를 그대로 구현하기로 확정됐습니다. 경로 검증 실패·저장 루트 접근 거부는 보안 사건이라 사후 추적이 필요하다는 것이 채택 사유입니다.
-
document컨텍스트에 편입합니다 — 실패 대상이 문서 업로드이고DocumentType·계열 id를 참조하므로 신규 컨텍스트를 만들지 않습니다. -
사유 코드 7종 (C — 메타데이터 저장 실패를 별도 값으로 분리 확정). 두 저장 실패는 파일 잔존 여부가 반대라 하나로 합칠 수 없습니다.
코드 시점 파일 상태 보상 삭제 EXTENSION_NOT_ALLOWED검증 미생성 불필요 SIZE_EXCEEDED검증 미생성 불필요 PATH_VALIDATION_FAILED새니타이즈 (보안) 미생성 불필요 STORAGE_ROOT_ESCAPE루트 검증 (보안) 미생성 불필요 STORAGE_IO_FAILED파일 쓰기 미생성 (쓰기 자체 실패) 불필요 METADATA_PERSISTENCE_FAILED메타데이터 행 저장 생성됐다가 삭제됨 실행됨 OTHER— — — OTHER에 묻으면 보상 삭제가 실제로 돌았는지 사후 구분이 불가능해집니다. 값 목록은 dba 스키마 COMMENT와 정확히 일치해야 합니다.판별 기준 (혼동 금지):
STORAGE_IO_FAILED= 파일 미생성(쓰기 자체가 실패) /METADATA_PERSISTENCE_FAILED= 파일 생성 후 보상 삭제(쓰기는 성공, 메타데이터 행 저장이 실패). 차이는 파일이 남았다 지워졌는가입니다. 값 이름은 PRD FR-4의 도메인 어휘(“DB에는 파일 바이너리가 아닌 경로와 메타데이터를 저장한다”)를 따랐습니다 — 실패한 대상이 정확히 그 메타데이터 행이고, 파일(STORAGE_*)과 메타데이터(METADATA_*)의 대비가 이름만으로 드러납니다. -
documentType·seriesId는 nullable입니다 — 확장자 검증은 유형 파싱 전에도 실패하므로 그 시점에는 유형을 모릅니다. 반면originalFileName은 항상 확보됩니다. -
별도 트랜잭션(
REQUIRES_NEW)으로 기록한 뒤 원 예외를 재전파합니다. 업로드 실패는 원 트랜잭션을 롤백시키므로, 같은 트랜잭션에 기록하면 이력도 함께 사라집니다.UploadDocumentUseCase가 도메인 예외를 잡아 기록 UseCase를 호출하고 rethrow하는 구조이며, 기존CollectJobPostingsUseCase.collectSafely·DispatchPendingNotificationsUseCase.dispatchSafely와 같은 패턴입니다. 원 예외 타입·메시지를 보존해야 기존 400 응답 계약이 깨지지 않습니다. -
추가 전용 엔티티입니다 — 수정·삭제 메서드를 만들지 않습니다.
-
보존 정책: 사유별 2단 분리 (E — dba 확정)
대상 정책 보안 사유 2종 ( PATH_VALIDATION_FAILED·STORAGE_ROOT_ESCAPE)무기한 — 삭제 금지 (침해 시도 추적 근거) 나머지 5종 테이블 5만 행 임계 도달 시 400일 초과분 정리 발생 규모(월 수 건)상 임계 도달이 현실적으로 오지 않으므로 정리 배치를 선제 구현하지 않습니다 — 임계 도달이 관측되면 그때 만듭니다. 조회는
days필터로 좁힙니다. -
운영 조회 API
GET /api/operations/document-upload-failures— 최신순(occurred_at DESC, id DESC) 페이지네이션 + 사유 코드 필터.seriesTitle은seriesId집합 배치 조회 1회로 채웁니다(N+1 금지).
티켓 분리 근거: BE-61(업로드 API)에 흡수하지 않습니다 — ① 도메인 모델·Repository·운영 조회 API가 한 책임으로 묶이고 ② 흡수하면 BE-61이 L을 넘으면서 운영 조회까지 떠안으며 ③ 단계 2 wave 4 너비가 2 → 3으로 넓어집니다. UploadDocumentUseCase.kt 수정이 BE-61(wave 3)과 다른 wave라 Single Writer도 안전합니다.
롤백: 신규 테이블 + 신규 파일이고 기록 실패가 업로드 자체를 막지 않으므로(기록은 best-effort, 실패 시 WARN 로그) 역방향 DDL로 안전합니다.
의존
- BE-57 (document 도메인·
DocumentType) - BE-61 (
UploadDocumentUseCase— 기록 훅을 붙일 지점) - DB-03 (
application_document_upload_failures— dba 명명, 2단계 서류 계열 접두사 통일)
다이어그램
처리 흐름
sequenceDiagram participant FE as web(SPA) participant C as DocumentApiController participant U as UploadDocumentUseCase participant D as DocumentDomainService participant R as RecordDocumentUploadFailureUseCase participant Op as OperationApiController FE->>C: POST /api/documents (multipart) C->>U: execute(command) U->>D: upload(input) alt 검증·저장 실패 D-->>U: DocumentSizeExceededException 등 U->>R: execute(사유·파일명) [REQUIRES_NEW] R-->>U: 기록 완료 (실패해도 WARN만) U-->>C: 원 예외 재전파 (400 계약 보존) else 성공 D-->>U: ApplicationDocumentVersion end Op->>Op: GET /api/operations/document-upload-failures
클래스 의존
flowchart LR subgraph Presentation["presentation"] DocApi[DocumentApiController] OpApi[OperationApiController] end subgraph Application["application/document"] Upload[UploadDocumentUseCase] Record[RecordDocumentUploadFailureUseCase] ListF[ListDocumentUploadFailuresUseCase] end subgraph Domain["domain/document"] DS[DocumentDomainService] Query[DocumentUploadFailureQueryService] Entity[DocumentUploadFailure] Reason[DocumentUploadFailureReason] Repo[DocumentUploadFailureRepository] end DocApi --> Upload Upload --> DS Upload --> Record Record --> Entity Record --> Repo OpApi --> ListF ListF --> Query Query --> Repo Entity --> Reason
테스트 케이스
- 21MB 업로드 실패 시
SIZE_EXCEEDED이력 1건이 남고 응답은 그대로 400DOCUMENT_SIZE_EXCEEDED다 exe확장자 실패 시EXTENSION_NOT_ALLOWED이력이 남고documentType이 null이어도 저장된다- 경로 검증 실패 시
PATH_VALIDATION_FAILED이력이 남는다 (보안 사유) - 저장 루트 이탈 시
STORAGE_ROOT_ESCAPE이력이 남는다 (보안 사유) - 파일 쓰기 자체가 실패하면
STORAGE_IO_FAILED이력이 남고 파일이 생성되지 않는다 - 파일 쓰기 성공 후 메타데이터 행 저장이 실패하면
METADATA_PERSISTENCE_FAILED이력이 남고 보상 삭제로 파일이 사라진다 (C) - 두 저장 실패가 서로 다른 사유 코드로 구분되어 보상 삭제 실행 여부를 사후에 판별할 수 있다
- 보안 사유 2종은 정리 대상에서 제외된다 (무기한 보존, E)
- 원 트랜잭션이 롤백돼도 실패 이력은 커밋되어 남는다 (REQUIRES_NEW 검증)
- 실패 이력 기록 중 예외가 나도 원 예외가 그대로 사용자에게 전달된다 (기록은 best-effort)
- 원 예외의 타입·에러 코드가 기록 훅 추가 전과 동일하다 (기존 400 계약 회귀)
- 업로드가 성공하면 실패 이력이 남지 않는다
- 이력 조회가
occurred_at내림차순으로 반환된다 reasonCode필터로 특정 사유만 조회된다days=30범위 밖 이력은 조회되지 않는다days=366이면 400BAD_REQUEST다 (경계값)- 이력 20건 조회 시 계열 조회가 1회만 발생한다 (N+1 방지)
seriesId가 null인 이력은seriesTitle도 null이다- 이력이 0건이면 빈 페이지를 반환한다 (0건 경계)
- 실패 이력 엔티티에 수정·삭제 메서드가 없다 (추가 전용)