Files
clean-architecture-backend-…/docs/superpowers/specs/2026-08-07-fileserver-platform-design.md
T
DongHyeonkaandClaude Opus 5 5f10b791d3 chore: record pre-existing uncommitted repository state
Snapshot of the in-flight state that already existed, identically, in both
this worktree and the main checkout before this session began: the initial
HTTP Client platform implementation (previously untracked), the redis-lab
removal, and the JPA / object-storage / notification integration work.

Kept separate from this session's HTTP Client review response, which lands
in the following commit, so the two bodies of work stay reviewable apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 16:48:43 +09:00

1894 lines
58 KiB
Markdown

# Fileserver Platform 설계서
**문서 상태:** 설계 확정안
**작성 기준일:** 2026-08-07
**입력 근거:** `Spring 기반 Fileserver 설계 심층 리서치`
**대상 저장소:** Spring 기반 Backend Skeleton
---
## 1. 요약
이 설계는 Fileserver를 단순한 업로드·다운로드 컨트롤러가 아니라 다음 네 계층을 분리한 공통 파일 서비스 플랫폼으로 정의한다.
1. **Content Store** — byte stream, staging, range read, publish, delete를 담당한다.
2. **Metadata Store** — 파일 상태, 소유·권한 연결 정보, 크기, digest, MIME 판정, version, lease, 만료를 관리한다.
3. **Transfer Adapter** — Spring MVC, Spring WebFlux, Nginx 위임으로 HTTP 전송을 제공한다.
4. **Verification Layer** — checksum, 형식 판정, 악성 파일 검사, quarantine을 담당한다.
공개 API는 `fileId``uploadId`만 사용한다. `Path`, 실제 파일명, 디렉터리, mount 경로, symlink와 같은 파일시스템 개념은 로컬 저장소 어댑터 밖으로 노출하지 않는다. 파일 내용과 메타데이터는 하나의 ACID transaction으로 묶을 수 없으므로 상태 머신, version, writer lease, reconciliation을 통해 일관성을 유지한다.
최초 Stable 릴리스는 Linux 로컬 파일시스템과 인증된 Kubernetes PVC RWO를 대상으로 다음을 제공한다.
- raw 및 multipart 단일 업로드
- 제한형 다중 파일 업로드
- streaming append와 SHA-256 검증
- GET, HEAD, 단일 Range, 조건부 요청
- 직접 전송과 Nginx 위임
- logical delete와 비동기 physical cleanup
- MVC와 WebFlux 어댑터
- 다중 인스턴스용 DB version·lease
- tus 1.0 별도 Stable 모듈
- NFS·PVC RWX 제한 지원 프로파일
- IETF resumable upload draft-12 Experimental 모듈
---
## 2. 목표와 성공 기준
### 2.1 목표
- 다양한 웹 서비스가 파일 업로드·다운로드를 즉시 사용할 수 있는 공통 기술 모듈을 제공한다.
- 로컬 디스크, PVC, NFS, 향후 Object Storage가 동일한 저장소 의미론을 공유하도록 한다.
- 최대 파일 크기에서도 JVM heap 사용량이 파일 크기에 비례하지 않도록 한다.
- 부분 파일, 경로 탈출, 권한 우회, 검사 전 공개를 구조적으로 차단한다.
- 장애 후 성공 여부가 모호한 작업을 단순 실패와 구분하고 복구할 수 있게 한다.
- 구현자가 설계 중 다시 판단하지 않도록 HTTP 계약, 상태 전이, 오류, 설정, 테스트 완료 조건을 고정한다.
### 2.2 성공 기준
| 영역 | 완료 기준 |
|---|---|
| 공개 식별자 | 외부 API가 `fileId`, `uploadId`만 사용하고 실제 경로를 노출하지 않는다. |
| 업로드 | raw·multipart 스트리밍이 bounded memory로 동작하며 부분 파일은 READY 이전에 읽을 수 없다. |
| 무결성 | 서버가 actual size와 SHA-256을 계산하고 client digest가 있으면 검증한다. |
| publish | atomic move probe가 통과하거나 metadata pointer publish를 사용한다. |
| 다운로드 | `200`, `206`, `304`, `412`, `416`과 관련 header 계약을 일관되게 제공한다. |
| 보안 | traversal, symlink escape, 원본명 저장, 무조건 overwrite, 검사 전 공개를 차단한다. |
| 다중 인스턴스 | upload별 단일 writer lease와 metadata version 충돌 검사가 동작한다. |
| 장애 복구 | process kill, disk full, network interruption 후 READY invariant가 깨지지 않는다. |
| 운영 | temp, orphan, quota, disk usage, transfer, verification metric과 cleanup job을 제공한다. |
| 플랫폼 | Linux local과 지정 PVC 프로파일의 인증 테스트를 통과한다. |
---
## 3. 범위
### 3.1 포함 범위
- Spring MVC와 Spring WebFlux
- blocking channel SPI와 async publisher SPI
- Linux local disk
- Kubernetes PVC RWO 인증 프로파일
- 인증된 PVC RWX·NFSv4.1 제한 프로파일
- Windows NTFS 호환성 CI 프로파일
- 단일·다중 인스턴스
- `multipart/form-data`, `application/octet-stream`
- 단일·제한형 다중 파일 업로드
- streaming upload, cancellation, status, cleanup
- GET, HEAD, byte range, conditional request, cache header
- 애플리케이션 직접 전송, zero-copy capability, Nginx 위임
- SHA-256, MIME·signature 검사 SPI, AV·CDR SPI
- quota reservation, concurrency limit, storage high-water 보호
- tus 1.0
- IETF resumable upload draft-12 Experimental
- 관리자 health, orphan scan, reconcile, cleanup, reverify
- metric, trace, audit, problem detail
### 3.2 제외 범위
- 공개 API의 임의 절대·상대 경로 입력
- 공개 디렉터리 list·scan
- symlink follow·생성
- hard link 생성
- 공개 재귀 삭제
- webroot 내부 저장
- 원본 파일명 그대로의 physical filename
- 조건 없는 overwrite
- READY 이전 다운로드
- 하나의 offset에 대한 동시 append
- proxy가 이미 전달한 비멱등 upload의 자동 재시도
- NFS lock만을 이용한 다중 인스턴스 정합성
- 다른 `FileStore` 사이의 atomic move 보장
- copy 실패 시 자동 rollback 보장
- 모든 파일 형식의 안전성 판정
- 임의 ZIP extraction
- Object Storage provider 구현과 signed URL
- FTP, SFTP, SMB client 기능
---
## 4. 고정 설계 결정
| 항목 | 결정 |
|---|---|
| 운영 우선 플랫폼 | Linux |
| Java | Java 21 |
| Spring | 6.2 최신 patch와 7.0 최신 patch를 release matrix에서 검증 |
| MVC | 정식 지원, streaming 전용 `AsyncTaskExecutor` 사용 |
| WebFlux | 정식 지원, event loop에서 blocking filesystem I/O 금지 |
| 공통 저장소 계약 | `Path`가 아니라 create·append·finalize·stat·openRead·delete 의미론 |
| metadata 기준 | 관계형 DB의 metadata가 authoritative |
| publish 기준 | same-FileStore atomic move 또는 metadata pointer publish |
| 공개 식별자 | opaque `FileId`, `UploadId` |
| physical key | 서버가 생성한 `ContentKey` |
| 원본명 | 비신뢰 표시 metadata |
| 기본 업로드 | create-only |
| overwrite | `If-Match` 또는 metadata version 필수 |
| checksum | 서버 계산 SHA-256 필수, client digest 선택 검증 |
| ETag | immutable READY bytes의 SHA-256 strong ETag |
| private cache | `private, no-store` 기본 |
| 재개 업로드 | tus 1.0 Stable, HTTPbis draft-12 Experimental |
| 다중 append | 단일 writer lease, 병렬 업로드는 독립 part 후 concatenate 방식만 |
| 삭제 | logical delete 후 physical cleanup |
| NFS | 외부 DB version·lease와 reconciliation을 전제로 제한 지원 |
| Windows | 초기 non-blocking compatibility profile |
---
## 5. 지원 매트릭스
### 5.1 런타임·저장소
| 대상 | 지원 수준 | 조건 |
|---|---|---|
| Linux ext4/XFS local | 완전 지원 | startup capability probe 통과 |
| Kubernetes PVC RWO | 조건부 완전 | 지정 CSI·StorageClass·mount option 인증 |
| Kubernetes PVC RWX | 제한 지원 | 실제 backend별 release certification |
| NFSv4.1 | 제한 지원 | DB lease·version, ambiguous completion reconciliation |
| Windows NTFS | 호환성 | nightly test, 운영 지원은 후속 확정 |
| Nginx stable | 완전 지원 | internal location과 Range 계약 인증 |
| 단일 인스턴스 | 완전 지원 | process-local serialization 가능 |
| 다중 인스턴스 | 완전 지원 조건부 | 공유 metadata DB와 writer lease 필수 |
### 5.2 프로토콜·기능
| 기능 | 수준 | 모듈 |
|---|---|---|
| raw upload | Stable | `fileserver-mvc`, `fileserver-webflux` |
| multipart 단일 | Stable | MVC·WebFlux |
| multipart batch | Stable 제한형 | 별도 batch endpoint, 비원자적 결과 배열 |
| direct download | Stable | MVC·WebFlux |
| single Range | Stable | core HTTP contract |
| multi Range | Beta | 개수·overlap·총량 budget 필수 |
| Nginx delegation | Stable | `fileserver-nginx` |
| tus 1.0 | Stable 별도 모듈 | `fileserver-tus` |
| HTTPbis draft-12 | Experimental | `fileserver-resumable-httpbis-draft12` |
| NFS RWX | Limited | 인증 프로파일 |
| Windows | Compatibility | CI profile |
---
## 6. 전체 아키텍처
```text
HTTP Client
├─ Spring MVC Adapter
├─ Spring WebFlux Adapter
└─ tus / HTTPbis Adapter
Application Services
├─ UploadApplicationService
├─ FinalizeUploadService
├─ DownloadApplicationService
├─ FileLifecycleService
├─ CleanupApplicationService
└─ ReconciliationService
├───────────────┐
▼ ▼
Metadata Store Port Content Store Port
│ │
▼ ├─ Local Filesystem Adapter
JPA Metadata Adapter └─ Future Object Storage Adapter
├─ Verification Port
├─ Authorization Port
├─ Quota Port
└─ Observability
Download path
Application authorization
├─ Direct transfer
└─ Nginx X-Accel-Redirect
```
### 6.1 의존 방향
- `fileserver-core-api`는 Spring MVC, WebFlux, JPA, NIO 구현 타입에 의존하지 않는다.
- `fileserver-application`은 core port만 사용한다.
- `fileserver-storage-local`은 NIO와 local path를 캡슐화한다.
- `fileserver-metadata-jpa`는 metadata port를 구현한다.
- HTTP adapter는 application service만 호출한다.
- Nginx 모듈은 물리 경로 대신 안전한 internal URI descriptor만 생성한다.
- 검사·권한·quota 정책은 SPI로 주입하며 Fileserver가 비즈니스 규칙을 내장하지 않는다.
### 6.2 업로드 실행 흐름
```text
1. 인증·기술 정책 확인
2. quota 예약
3. FileRecord(CREATED)와 UploadSession 생성
4. ContentStore.createUpload(CREATE_NEW)
5. FileRecord → UPLOADING
6. stream append + actual size + SHA-256 계산
7. channel close
8. FileRecord → UPLOADED
9. verification 실행
10. VERIFYING / QUARANTINED / REJECTED
11. publish strategy 실행
12. physical stat 재검증
13. metadata pointer, size, digest, MIME, version 기록
14. FileRecord → READY
15. quota 예약을 committed usage로 전환
```
### 6.3 다운로드 실행 흐름
```text
1. FileId 조회
2. 존재 은닉 정책을 포함한 authorization
3. READY 상태 확인
4. conditional header 평가
5. Range parsing·budget 검증
6. transfer mode 선택
- DIRECT
- ZERO_COPY capability
- NGINX_DELEGATED
7. 응답 header 확정
8. bytes 전송 또는 internal redirect
9. 성공·중단·전송량 관측
```
---
## 7. 모듈 구조
```text
backend-skeleton/
├── modules/fileserver/
│ ├── fileserver-core-api/
│ ├── fileserver-application/
│ ├── fileserver-metadata-jpa/
│ ├── fileserver-storage-local/
│ ├── fileserver-verification/
│ ├── fileserver-mvc/
│ ├── fileserver-webflux/
│ ├── fileserver-nginx/
│ ├── fileserver-admin/
│ ├── fileserver-tus/
│ ├── fileserver-resumable-httpbis-draft12/
│ ├── fileserver-spring-boot-starter/
│ └── fileserver-testkit/
├── infra/fileserver/
│ ├── local/
│ ├── nginx/
│ ├── nfs/
│ └── kubernetes/
└── docs/fileserver/
├── support-matrix.md
├── http-contract.md
├── storage-certification.md
├── security.md
├── operations.md
└── upgrade-guide.md
```
| 모듈 | 책임 |
|---|---|
| `fileserver-core-api` | ID, 상태, value object, port, 오류, capability |
| `fileserver-application` | upload·download·lifecycle orchestration |
| `fileserver-metadata-jpa` | metadata, lease, quota reservation persistence |
| `fileserver-storage-local` | staging, append, range read, publish, delete, probe |
| `fileserver-verification` | digest, MIME verdict, scanner pipeline |
| `fileserver-mvc` | Servlet multipart/raw/download adapter |
| `fileserver-webflux` | `PartEvent`, `DataBuffer`, reactive transfer adapter |
| `fileserver-nginx` | internal URI와 `X-Accel-Redirect` response strategy |
| `fileserver-admin` | health, orphan, reconcile, cleanup, reverify |
| `fileserver-tus` | tus 1.0 protocol adapter |
| `fileserver-resumable-httpbis-draft12` | versioned Experimental protocol adapter |
| `fileserver-spring-boot-starter` | properties, auto-configuration, startup gate |
| `fileserver-testkit` | contract, filesystem, HTTP, fault, performance harness |
---
## 8. 핵심 공개 모델
### 8.1 식별자
```java
public record FileId(UUID value) {
public FileId {
Objects.requireNonNull(value, "value");
}
}
public record UploadId(UUID value) {
public UploadId {
Objects.requireNonNull(value, "value");
}
}
public record ContentKey(String value) {
public ContentKey {
if (value == null || !value.matches("[a-z0-9/_-]{16,200}")) {
throw new IllegalArgumentException("invalid content key");
}
}
}
public record StorageNamespace(String value) {
public StorageNamespace {
if (value == null || !value.matches("[a-z][a-z0-9-]{1,62}")) {
throw new IllegalArgumentException("invalid storage namespace");
}
}
}
```
`ContentKey`는 public HTTP contract에 포함하지 않는다. `FileId`는 추측하기 어려운 ID를 사용하지만 비밀 token으로 취급하지 않으며 모든 요청에서 authorization을 수행한다.
### 8.2 파일 상태
```java
public enum FileState {
CREATED,
UPLOADING,
UPLOADED,
VERIFYING,
QUARANTINED,
READY,
REJECTED,
FAILED,
DELETING,
DELETED,
EXPIRED
}
```
허용 전이는 `FileStateMachine` 하나에서 관리한다. persistence adapter나 controller가 상태를 직접 대입하지 않는다.
```java
public interface FileStateMachine {
void requireTransition(FileState current, FileState target);
boolean canTransition(FileState current, FileState target);
}
```
### 8.3 ByteRange
```java
public record ByteRange(long startInclusive, long endInclusive) {
public ByteRange {
if (startInclusive < 0 || endInclusive < startInclusive) {
throw new IllegalArgumentException("invalid byte range");
}
}
public long length() {
return Math.addExact(Math.subtractExact(endInclusive, startInclusive), 1);
}
}
```
HTTP suffix/open-ended Range는 HTTP adapter의 parser가 현재 representation 길이를 기준으로 위 value object로 정규화한다.
### 8.4 파일 metadata
```java
public record FileDescriptor(
FileId fileId,
StorageNamespace namespace,
FileState state,
String originalFilename,
String mediaType,
long size,
String sha256,
String strongEtag,
Instant publishedAt,
long version
) {}
```
실제 path, scanner 원문 응답, user metadata 원문은 public descriptor에 포함하지 않는다.
---
## 9. 상태 머신과 invariant
### 9.1 상태 전이
```text
CREATED → UPLOADING
UPLOADING → UPLOADED | FAILED | EXPIRED | DELETING
UPLOADED → VERIFYING | FAILED | DELETING
VERIFYING → READY | QUARANTINED | REJECTED | FAILED
QUARANTINED → VERIFYING | READY | REJECTED | DELETING
READY → DELETING
REJECTED → DELETING
FAILED → UPLOADING | VERIFYING | DELETING | EXPIRED
DELETING → DELETED | FAILED
EXPIRED → DELETING
```
`FAILED`에서의 복구 전이는 저장된 `lastErrorCode`와 recovery policy가 허용할 때만 수행한다.
### 9.2 필수 invariant
- READY에는 읽을 수 있는 immutable content가 존재한다.
- READY의 size와 SHA-256은 실제 bytes와 일치한다.
- READY가 아닌 레코드는 direct download와 Nginx internal mapping에서 제외된다.
- 하나의 upload에는 하나의 유효 writer lease만 존재한다.
- offset은 durable append가 확인된 byte 수만큼만 증가한다.
- client가 주장한 크기·MIME·파일명은 authoritative 값이 아니다.
- REJECTED, DELETED, EXPIRED는 public API에서 재활성화되지 않는다.
- DB와 storage가 불일치하면 READY를 추정하지 않고 recovery queue로 보낸다.
- logical delete가 성공하면 신규 download authorization은 즉시 차단된다.
- physical cleanup 실패는 DELETING 또는 FAILED 상태와 운영 경보로 남는다.
---
## 10. Metadata Store 설계
### 10.1 Port
```java
public interface FileMetadataStore {
FileRecord insert(FileRecordDraft draft);
Optional<FileRecord> find(FileId fileId);
FileRecord transition(
FileId fileId,
long expectedVersion,
FileState expectedState,
FileState targetState,
FileRecordMutation mutation
);
FileRecord markDeleting(FileId fileId, long expectedVersion);
List<FileRecord> findRecoverable(FileRecoveryQuery query);
}
public interface UploadSessionStore {
UploadSession create(UploadSessionDraft draft);
Optional<UploadSession> find(UploadId uploadId);
WriterLease acquireLease(
UploadId uploadId,
String owner,
Instant now,
Duration leaseDuration,
long expectedVersion
);
UploadSession commitOffset(
UploadId uploadId,
WriterLease lease,
long expectedOffset,
long committedOffset
);
void releaseLease(UploadId uploadId, WriterLease lease);
List<UploadSession> findExpired(Instant cutoff, int limit);
}
```
### 10.2 관계형 schema
| Table | 핵심 컬럼 |
|---|---|
| `fs_file` | `file_id`, `namespace`, `state`, `content_key`, `original_name`, `claimed_media_type`, `verified_media_type`, `expected_size`, `actual_size`, `sha256`, `strong_etag`, `published_at`, `version`, `last_error_code`, timestamps |
| `fs_upload_session` | `upload_id`, `file_id`, `expected_length`, `committed_offset`, `protocol`, `expires_at`, `lease_owner`, `lease_until`, `version` |
| `fs_verification_result` | `file_id`, `verifier`, `verdict`, `details_code`, `started_at`, `completed_at` |
| `fs_quota_reservation` | `reservation_id`, `scope`, `reserved_bytes`, `committed_bytes`, `expires_at`, `status`, `version` |
| `fs_cleanup_item` | `cleanup_id`, `file_id`, `content_key`, `type`, `attempt`, `next_attempt_at`, `status`, `last_error_code` |
`fs_file.version``fs_upload_session.version`은 optimistic locking에 사용한다. 모든 상태 전이는 `WHERE version = ? AND state = ?` 조건을 포함한다.
### 10.3 authoritative source
- 공개 metadata는 `fs_file`을 기준으로 한다.
- physical `stat`은 publish 검증과 reconciliation에 사용한다.
- NFS·PVC의 timestamp는 Last-Modified의 authoritative source로 사용하지 않는다.
- `published_at`을 HTTP Last-Modified로 사용한다.
## 11. Content Store Port
### 11.1 Capability
```java
public record ContentStoreCapabilities(
boolean rangedRead,
boolean atomicCreate,
boolean atomicPublish,
boolean conditionalWrite,
boolean serverSideCopy,
boolean delegatedDownload,
boolean resumableAppend
) {}
```
Capability는 설정값만 읽지 않고 실제 저장소 root에서 startup probe한 결과로 생성한다.
### 11.2 Blocking SPI
```java
public interface BlockingContentStore {
UploadHandle createUpload(CreateContentCommand command);
AppendResult append(
UploadHandle handle,
long expectedOffset,
ReadableByteChannel source,
long contentLength
);
StoredContent finalizeUpload(
UploadHandle handle,
FinalizeContentCommand command
);
ContentMetadata stat(ContentKey key);
ReadableByteChannel openRead(ContentKey key, ByteRange range);
DeleteResult delete(ContentKey key, DeletePrecondition precondition);
ContentStoreCapabilities capabilities();
}
```
### 11.3 Async SPI
```java
public interface AsyncContentStore {
CompletionStage<UploadHandle> createUpload(CreateContentCommand command);
CompletionStage<AppendResult> append(
UploadHandle handle,
long expectedOffset,
Flow.Publisher<ByteBuffer> content
);
CompletionStage<StoredContent> finalizeUpload(
UploadHandle handle,
FinalizeContentCommand command
);
CompletionStage<ContentMetadata> stat(ContentKey key);
Flow.Publisher<ByteBuffer> openRead(ContentKey key, ByteRange range);
CompletionStage<DeleteResult> delete(
ContentKey key,
DeletePrecondition precondition
);
ContentStoreCapabilities capabilities();
}
```
공통 SPI에 Spring `Resource`, `DataBuffer`, Reactor 타입을 포함하지 않는다. WebFlux adapter는 `Flow.Publisher<ByteBuffer>``Flux<DataBuffer>` 사이를 변환하고 pooled buffer의 수명주기를 책임진다.
### 11.4 Capability 확장
```java
public interface CopyCapableContentStore {
CompletionStage<StoredContent> copy(
ContentKey source,
ContentKey target,
CopyPrecondition precondition
);
}
public interface CapacityAwareContentStore {
StorageCapacity capacity();
}
public interface DelegatedDownloadStore {
DelegatedDownloadDescriptor createDelegation(
ContentKey key,
ByteRange range,
Duration ttl
);
}
```
`copy`, capacity, delegation은 최소 Port에 강제하지 않는다.
---
## 12. Local Filesystem Adapter
### 12.1 저장 레이아웃
```text
${root}/
├── staging/
│ └── ab/cd/<upload-id>.part
├── content/
│ └── ab/cd/<content-key>.bin
├── quarantine/
│ └── ab/cd/<content-key>.bin
└── probe/
```
- shard는 server-generated ID의 앞 2 byte씩 사용한다.
- 원본 파일명과 확장자를 physical filename에 사용하지 않는다.
- `staging`, `content`, `quarantine`은 동일 `FileStore`에 위치해야 한다.
- root는 application source, config, webroot와 분리한다.
- startup에서 디렉터리 owner·permission을 검증한다.
### 12.2 경로 안전 규칙
```java
public interface PhysicalPathResolver {
Path stagingPath(UploadId uploadId);
Path contentPath(ContentKey contentKey);
Path quarantinePath(ContentKey contentKey);
}
```
`PhysicalPathResolver``fileserver-storage-local` 내부 package-private 구현으로 둔다. 공개 module export 대상이 아니다.
필수 검사:
1. absolute 또는 drive-qualified 입력을 받지 않는다.
2. ID에서 만든 고정 component만 resolve한다.
3. normalize 결과가 root 아래인지 확인한다.
4. 모든 open·stat·delete에 `NOFOLLOW_LINKS`를 사용한다.
5. parent component가 symlink인지 확인한다.
6. provider가 지원하면 `SecureDirectoryStream`을 사용한다.
7. open 후 file identity와 expected parent identity를 재검증한다.
### 12.3 staging 생성
- `CREATE_NEW`, `WRITE`, `NOFOLLOW_LINKS`로 연다.
- 충돌 시 새로운 storage key를 재발급하지 않고 invariant violation으로 기록한다.
- file permission은 owner read·write만 허용하는 프로파일을 기본으로 한다.
- append 전에 실제 file length와 metadata offset을 대조한다.
### 12.4 append
- 고정 크기 direct buffer pool 또는 heap buffer를 사용하며 파일 전체를 적재하지 않는다.
- 기본 buffer는 128 KiB다.
- `expectedOffset`이 실제 길이 또는 metadata offset과 다르면 append를 수행하지 않는다.
- 실제 수신 byte 수가 정책 최대값을 넘으면 즉시 중단한다.
- append 도중 실제 size와 SHA-256을 streaming 계산한다.
- `contentLength >= 0`이면 실제 append byte와 일치해야 한다.
- cancellation과 exception 시 channel을 닫고 session은 복구 가능한 상태로 남긴다.
### 12.5 delete
- symbolic link를 따라가지 않는다.
- logical delete를 먼저 수행한 뒤 cleanup worker가 physical object를 삭제한다.
- large file은 삭제 latency와 filesystem 특성을 metric으로 기록한다.
- 실제 파일이 이미 없으면 idempotent success로 처리하되 reconciliation event를 남긴다.
---
## 13. Storage Capability Probe와 Startup Gate
### 13.1 Probe 항목
| Probe | 통과 기준 | 실패 정책 |
|---|---|---|
| writable root | create·write·close·delete 성공 | startup 실패 |
| `CREATE_NEW` 경쟁 | 두 동시 create 중 정확히 하나 성공 | startup 실패 |
| same `FileStore` | staging·content·quarantine 동일 | startup 실패 |
| atomic move | observer가 partial target을 보지 않고 move 성공 | mode에 따라 실패 또는 pointer publish |
| replace | old 또는 new만 관측 | overwrite capability 비활성 |
| fsync profile | force 후 restart test 결과 저장 | durability 등급 표시 |
| symlink no-follow | target 접근이 차단됨 | startup 실패 |
| open-delete | OS 동작 기록 | lifecycle policy 조정 |
| capacity | usable·total 조회 가능 | admin capability 제한 |
### 13.2 Publish mode
```java
public enum PublishMode {
ATOMIC_MOVE_REQUIRED,
ATOMIC_MOVE_PREFERRED,
METADATA_POINTER
}
```
- `ATOMIC_MOVE_REQUIRED`: probe 실패 시 startup 실패
- `ATOMIC_MOVE_PREFERRED`: 가능하면 atomic move, 불가능하면 pointer publish
- `METADATA_POINTER`: immutable physical key를 완성한 뒤 DB pointer를 READY boundary로 사용
기본값은 `ATOMIC_MOVE_PREFERRED`다.
### 13.3 Runtime capability endpoint
`GET /internal/fileserver/capabilities`는 다음을 제공한다.
```json
{
"storageType": "LOCAL",
"publishMode": "ATOMIC_MOVE_PREFERRED",
"rangedRead": true,
"atomicCreate": true,
"atomicPublish": true,
"conditionalWrite": true,
"delegatedDownload": true,
"resumableAppend": true,
"filesystemProfile": "linux-ext4"
}
```
physical root와 mount detail은 반환하지 않는다.
---
## 14. Publish와 완료 처리
### 14.1 Atomic move strategy
```text
staging channel close
→ optional `FileChannel.force(true)`
→ verify expected length·digest
→ target parent 준비
→ `Files.move(staging, target, ATOMIC_MOVE)`
→ target stat
→ DB READY transition
```
`REPLACE_EXISTING`은 overwrite precondition이 있는 경로에서만 사용한다. create-only 경로는 target이 이미 있으면 실패한다.
### 14.2 Metadata pointer strategy
```text
staging write 완료
→ immutable content key로 새 physical object 완성
→ physical stat 검증
→ DB transaction에서 contentKey pointer와 READY 상태 publish
→ 이전 physical object를 cleanup queue에 등록
```
이 전략은 rename의 원자성 대신 metadata store transaction을 public publish boundary로 사용한다.
### 14.3 Ambiguous completion
다음 상황은 `AmbiguousCompletionException`으로 분류한다.
- NFS rename request가 서버에서 처리되었을 수 있으나 응답이 유실됨
- write·force 후 연결 또는 mount 응답이 사라짐
- DB commit 응답을 받지 못해 상태 전이 성공 여부를 알 수 없음
처리 순서:
1. operation ID와 expected physical key를 조회한다.
2. metadata version과 state를 재조회한다.
3. physical stat·size·digest를 확인한다.
4. 명백한 성공이면 성공 결과를 복원한다.
5. 명백한 미실행이면 제한적으로 재실행한다.
6. 판정 불가면 recovery queue와 `retryable=false, reconciliationRequired=true` 오류를 반환한다.
---
## 15. Upload Application 설계
### 15.1 공개 command
```java
public record CreateUploadRequest(
StorageNamespace namespace,
String originalFilename,
String claimedMediaType,
OptionalLong expectedLength,
Optional<String> expectedSha256,
UploadProtocol protocol,
Instant expiresAt
) {}
public interface UploadApplicationService {
UploadSessionView create(CreateUploadRequest request, RequestContext context);
AppendUploadResult append(
UploadId uploadId,
long expectedOffset,
ReadableByteChannel content,
long contentLength,
RequestContext context
);
FileView finalizeUpload(
UploadId uploadId,
FinalizeUploadRequest request,
RequestContext context
);
UploadSessionView status(UploadId uploadId, RequestContext context);
void cancel(UploadId uploadId, RequestContext context);
}
```
Async API는 별도 interface로 동일 의미를 제공한다.
### 15.2 Create
- authorization hook 실행
- expected length가 있으면 정책 최대값 검증
- quota reservation 생성
- FileRecord CREATED 생성
- UploadSession 생성
- storage staging 생성
- state를 UPLOADING으로 전이
- `Location`과 current offset 0 반환
DB 생성 후 storage 생성이 실패하면 FileRecord를 FAILED로 전이하고 quota reservation을 해제한다. storage 생성 후 DB 응답이 모호하면 operation ID로 reconciliation한다.
### 15.3 Append
- upload 상태·만료 확인
- writer lease 획득
- metadata offset, physical length, request offset 일치 검증
- concurrency, rate, storage high-water gate 확인
- streaming append
- committed offset 저장
- lease release
append 실패 후 offset은 실제 저장이 확인된 길이까지만 증가한다. metadata offset과 physical length가 다르면 자동 append하지 않고 reconciliation으로 보낸다.
### 15.4 Finalize
- expected length가 있으면 committed offset과 비교
- server SHA-256과 client digest 비교
- state를 UPLOADED로 전이
- verification pipeline 실행
- verdict가 ACCEPT이면 publish
- metadata READY 전이
- quota commit
- REJECT 또는 QUARANTINE이면 public download 금지
### 15.5 Multipart batch
`POST /v1/files:batch`는 다음 계약을 사용한다.
- 최대 part 수 기본 16
- 각 파일은 독립 FileRecord·UploadSession
- 요청 전체 ACID 원자성은 보장하지 않는다.
- 일부 실패 시 성공 파일을 rollback하지 않는다.
- `200 OK`와 파일별 결과 배열을 반환한다.
- 총 request byte와 tenant quota를 요청 전·중 모두 검사한다.
```json
{
"results": [
{"clientPartId":"a", "status":"CREATED", "fileId":"..."},
{"clientPartId":"b", "status":"REJECTED", "problem":{"code":"FILE_TOO_LARGE"}}
]
}
```
---
## 16. Verification Layer
### 16.1 Port
```java
public interface FileVerifier {
String verifierId();
CompletionStage<VerificationResult> verify(VerificationRequest request);
}
public record VerificationResult(
VerificationVerdict verdict,
String code,
Optional<String> verifiedMediaType,
Map<String, String> safeMetadata
) {}
public enum VerificationVerdict {
ACCEPT,
QUARANTINE,
REJECT,
RETRY
}
```
### 16.2 기본 pipeline
```text
Length verifier
→ SHA-256 verifier
→ filename policy
→ media type detector
→ signature/parser verifier
→ optional AV scanner
→ optional CDR
→ final policy combiner
```
- client `Content-Type`은 claimed metadata로만 저장한다.
- 단순 magic byte 일치만으로 안전 판정을 내리지 않는다.
- scanner timeout은 READY로 우회하지 않는다.
- 위험 형식은 quarantine 또는 reject한다.
- HTML, SVG 등 scriptable 문서는 기본 attachment이며 inline은 명시적 안전 프로파일에서만 허용한다.
### 16.3 검사 비동기화
- 검사 시간이 짧은 프로파일은 upload request 안에서 완료하여 `201`을 반환할 수 있다.
- AV·CDR처럼 긴 검사는 `202 Accepted`와 VERIFYING 상태를 반환한다.
- READY 전환은 verification worker가 수행한다.
- retryable scanner 장애는 exponential backoff와 최대 시도 횟수를 사용한다.
- 최대 시도 초과는 FAILED 또는 QUARANTINED로 전이한다.
---
## 17. Authorization과 기술 정책 Hook
```java
public interface FileAccessPolicy {
void authorize(FileOperation operation, FileAccessSubject subject, FileDescriptor descriptor);
}
public enum FileOperation {
CREATE,
APPEND,
FINALIZE,
READ_METADATA,
DOWNLOAD,
DELETE,
COPY,
MOVE,
ADMIN_REVERIFY,
ADMIN_FORCE_DELETE
}
```
Fileserver는 사용자 등급·업무 역할 같은 비즈니스 정책을 내장하지 않는다. 대신 모든 공개 operation에서 위 hook을 반드시 호출하고, starter가 no-op allow-all 구현을 운영 프로파일에서 자동 생성하지 않도록 한다.
존재 은닉 프로파일에서는 권한 없는 file에 `404`를 반환한다. 내부 audit에는 `ACCESS_DENIED`를 기록하되 fileId·userId 원문을 metric label에 사용하지 않는다.
---
## 18. Quota, Capacity와 Transfer Budget
### 18.1 Quota Port
```java
public interface FileQuotaService {
QuotaReservation reserve(QuotaScope scope, long expectedBytes, Duration ttl);
void extend(QuotaReservation reservation, long additionalBytes);
void commit(QuotaReservation reservation, long actualBytes);
void release(QuotaReservation reservation);
}
```
expected length가 없으면 프로파일별 initial reservation을 잡고 append 중 증분 예약한다.
### 18.2 기본 운영 프로파일
| 설정 | Standard | Large-file |
|---|---:|---:|
| 최대 파일 | 100 MiB | 5 GiB |
| 최대 request | 116 MiB | 5 GiB + 16 MiB |
| 최대 multipart part | 16 | 16 |
| in-memory part | 512 KiB | 256 KiB |
| stream buffer | 128 KiB | 256 KiB |
| 인스턴스 동시 upload | 16 | 32 |
| 인스턴스 direct download | 64 | 128 |
| scope 동시 upload | 4 | 8 |
| temp soft limit | usable 70% | usable 70% |
| temp hard limit | usable 85% | usable 85% |
| idle read timeout | 45 s | 60 s |
| 미완료 upload TTL | 24 h | 72 h |
| multi Range 최대 개수 | 8 | 8 |
이 값은 starter 기본값이며 운영 환경은 부하 인증 결과로 재정의한다.
### 18.3 Admission control
새 upload는 다음 중 하나가 발생하면 거절한다.
- quota reservation 실패
- storage hard high-water 초과
- instance upload permit 고갈
- scope 동시성 초과
- verification queue hard limit 초과
soft high-water에서는 대용량 upload를 throttle하거나 `429/503``Retry-After`를 반환한다.
---
## 19. HTTP API
### 19.1 공개 endpoint
| Method·Path | 목적 | 성공 |
|---|---|---|
| `POST /v1/files` | multipart 단일 업로드 | `201` READY 또는 `202` VERIFYING |
| `POST /v1/files:raw` | raw streaming 업로드 | `201` 또는 `202` |
| `POST /v1/files:batch` | 제한형 다중 업로드 | `200` 결과 배열 |
| `PUT /v1/files/{fileId}/content` | create-only·조건부 교체 | `201` 또는 `204` |
| `GET /v1/files/{fileId}` | metadata | `200` |
| `GET /v1/files/{fileId}/content` | download | `200`, `206`, `304` |
| `HEAD /v1/files/{fileId}/content` | download metadata | `200`, `304` |
| `DELETE /v1/files/{fileId}` | logical delete | `202` 또는 `204` |
| `POST /v1/files/{fileId}:copy` | 조건부 copy | `202` |
| `POST /v1/files/{fileId}:move` | logical namespace move | `200` 또는 `204` |
| `POST /v1/uploads` | resumable resource 생성 | `201` |
| `HEAD /v1/uploads/{uploadId}` | offset 조회 | protocol별 `200/204` |
| `PATCH /v1/uploads/{uploadId}` | append | `204` |
| `DELETE /v1/uploads/{uploadId}` | cancel | `204` |
### 19.2 Header 계약
| Header | 계약 |
|---|---|
| `Content-Type` | client 값은 claimed type, verified type을 별도 저장 |
| `Content-Length` | 있으면 사전 검증, 없어도 streamed hard limit 적용 |
| `Content-Disposition` | `inline` 또는 `attachment`, `filename` + `filename*` |
| `Accept-Ranges` | byte range 지원 시 `bytes` |
| `Range` | 기본 single, budget이 있는 경우 제한형 multi |
| `Content-Range` | `206` 실제 범위, `416``bytes */size` |
| `ETag` | SHA-256 strong validator |
| `Last-Modified` | `publishedAt` |
| `If-None-Match` | GET·HEAD revalidation, create-only `*` |
| `If-Modified-Since` | ETag 보조 |
| `If-Match` | overwrite·delete lost-update 방지 |
| `If-Range` | validator 일치 시에만 partial |
| `Cache-Control` | private 기본 `private, no-store` |
| `Content-Digest` | 실제 HTTP message content digest |
| `Repr-Digest` | 전체 representation digest 선택 제공 |
| `Location` | 생성된 file·upload resource |
| `Retry-After` | `429`, `503`, 장기 검사의 polling 힌트 |
| `X-Accel-Redirect` | Nginx 내부 응답 전용 |
### 19.3 상태 코드
| Status | 조건 |
|---:|---|
| `200` | metadata, 전체 GET, batch result |
| `201` | file 또는 upload 생성 |
| `202` | 검사 또는 physical cleanup 비동기 |
| `204` | append, cancel, body 없는 update |
| `206` | satisfiable Range |
| `304` | GET·HEAD validator 일치 |
| `400` | 잘못된 header·요청 조합 |
| `401` | 인증 없음 |
| `403/404` | 접근 거부 또는 존재 은닉 |
| `409` | 상태·offset·lease 충돌 |
| `410` | 만료 upload |
| `411` | `require-content-length=true` 프로파일 |
| `412` | precondition 실패 |
| `413` | 크기·quota 정책 위반 |
| `415` | 허용하지 않는 upload media type |
| `416` | 만족 불가능 Range |
| `422` | digest·signature·scanner reject |
| `429` | 동시성·rate limit |
| `503` | storage·scanner unavailable |
| `504` | downstream timeout |
| `507` | 저장공간 부족 |
---
## 20. Range와 Conditional Request
### 20.1 Range parser
```java
public interface HttpRangeResolver {
ResolvedRanges resolve(String rangeHeader, long representationLength, RangeBudget budget);
}
public record RangeBudget(
int maxRanges,
long maxTotalBytes,
boolean mergeOverlaps
) {}
```
기본 public 다운로드는 single Range만 허용한다. multi Range를 활성화한 profile에서는 최대 8개, overlap merge 후 총 byte가 representation 길이 이하인 경우만 허용한다.
### 20.2 응답 결정 순서
```text
authorization
→ READY 확인
→ current ETag·Last-Modified 계산
→ If-Match / If-Unmodified-Since
→ If-None-Match / If-Modified-Since
→ Range parse
→ If-Range 평가
→ 200 / 206 / 304 / 412 / 416 결정
```
`If-Range`가 불일치하면 Range를 무시하고 전체 `200`을 반환한다.
### 20.3 ETag와 digest
- stored SHA-256을 quoted strong ETag로 사용한다.
- metadata-only 변경은 representation ETag를 바꾸지 않는다.
- `Content-Digest`는 전송 bytes 기준이다.
- full response에서는 stored SHA-256을 재사용할 수 있다.
- partial response에서는 해당 range digest를 streaming 계산하거나 기능을 비활성화한다.
- 전체 representation digest가 필요하면 `Repr-Digest`를 제공한다.
## 21. Spring MVC Adapter
### 21.1 Upload
- `MultipartFile#getBytes()`를 사용하지 않는다.
- raw upload는 request input stream을 `ReadableByteChannel`로 변환한다.
- multipart는 container threshold와 temp directory를 starter가 명시적으로 설정한다.
- upload request thread가 storage write를 장시간 점유하지 않도록 전용 executor를 사용한다.
- 기본 executor는 bounded queue와 rejection policy를 가진다.
- request cancellation과 client disconnect를 application service에 전달한다.
### 21.2 Download
전송 전략은 다음 순서로 선택한다.
1. Nginx 위임이 활성화되고 threshold 이상이면 delegation
2. local `Path`를 안전하게 반환할 수 있고 zero-copy 조건이 맞으면 zero-copy capability
3. 그 외 `StreamingResponseBody`
Range 처리는 core HTTP contract가 결정한다. Spring의 자동 Range 지원에만 의존하지 않고 MVC와 WebFlux가 같은 결과를 반환하도록 공통 resolver를 사용한다. `InputStreamResource`는 반복 가능한 Range resource로 사용하지 않는다.
### 21.3 Executor
```java
public record MvcTransferExecutorProperties(
int coreThreads,
int maxThreads,
int queueCapacity,
Duration shutdownTimeout
) {}
```
기본값:
```text
coreThreads=8
maxThreads=32
queueCapacity=64
shutdownTimeout=30s
```
queue가 가득 차면 무제한 대기하지 않고 `429` 또는 `503`으로 변환한다.
---
## 22. Spring WebFlux Adapter
### 22.1 Upload
- raw body는 `Flux<DataBuffer>`를 순차 소비한다.
- multipart streaming은 `Flux<PartEvent>`를 사용한다.
- pooled `DataBuffer`는 전달하거나 명시적으로 release한다.
- blocking local filesystem adapter 호출은 bounded elastic이 아니라 전용 bounded scheduler에서 실행한다.
- async store가 제공되면 event loop를 유지한 채 `Flow.Publisher<ByteBuffer>`로 전달한다.
- cancellation 시 channel, lease, temp resource를 정리한다.
### 22.2 Download
- async store는 `Flux<DataBuffer>`로 변환한다.
- local file zero-copy가 runtime에서 가능하면 capability optimization으로 사용한다.
- Range와 conditional 결정은 MVC와 동일한 core resolver를 사용한다.
- slow client에서 in-flight buffer 수가 설정 상한을 넘지 않도록 한다.
### 22.3 Blocking 검출
CI에서 BlockHound 또는 동등한 검증으로 다음을 차단한다.
- event loop에서 `Files.*`, `FileChannel`, JDBC 호출
- synchronous scanner 호출
- blocking metadata repository 호출
---
## 23. Nginx 전송 위임
### 23.1 구조
```text
Client
→ GET /v1/files/{fileId}/content
→ Application authorization + READY gate
→ validated ContentKey를 internal relative URI로 변환
→ X-Accel-Redirect: /__files/ab/cd/<content-key>.bin
→ Nginx internal location
→ physical content transfer
```
internal URI는 절대 physical path를 포함하지 않는다. `NginxInternalUriMapper`는 검증된 `ContentKey`만 받아 `/__files/` 아래의 상대 URI를 생성한다. 이 header는 Nginx가 내부 redirect로 소비하므로 client 응답에는 노출하지 않는다. 별도 공개 signed URL을 발급하는 기능은 Object Storage 모듈의 책임으로 남긴다.
### 23.2 정책
- 기본 delegation threshold는 16 MiB다.
- private file은 Nginx shared cache를 기본 비활성화한다.
- `internal` location은 외부 직접 요청을 거부한다.
- `X-Accel-Redirect`는 downstream client에 그대로 전달되지 않도록 한다.
- Range, ETag, Content-Disposition, Cache-Control 결과가 direct mode와 동일해야 한다.
- Nginx access log에 physical root와 원본 파일명을 남기지 않는다.
- mapper가 생성한 URI는 `ContentKey`의 허용 문자와 shard 규칙을 다시 검증한다.
### 23.3 Nginx upload
| 경로 | 기본 buffering |
|---|---|
| 작은 multipart | on 허용 |
| 대용량 raw | off |
| tus PATCH | off |
| HTTPbis PATCH | off |
upstream 전송이 시작된 non-idempotent upload에는 `proxy_next_upstream` 재시도를 적용하지 않는다.
---
## 24. 재개 가능한 업로드
### 24.1 공통 원칙
- upload resource별 single writer lease
- offset은 metadata와 physical length를 함께 검증
- mismatch 시 body를 쓰지 않고 `409`
- 서버 재시작 후 offset reconciliation
- create 시 quota 예약
- expiration과 cleanup
- client checksum 검증
- upload resource는 READY file과 별도 수명주기를 가진다.
### 24.2 tus 1.0 Stable
지원 기능:
- creation
- `HEAD``Upload-Offset`
- `PATCH application/offset+octet-stream`
- checksum extension
- expiration extension
- termination extension
- concatenation extension은 Beta
성공 append는 `204`와 새 `Upload-Offset`을 반환한다. offset mismatch는 resource를 변경하지 않고 `409`를 반환한다.
### 24.3 HTTPbis draft-12 Experimental
- module 이름과 package에 `draft12`를 포함한다.
- feature flag 없이는 bean을 생성하지 않는다.
- media type과 header를 draft version에 고정한다.
- 104 interim response 지원 여부를 runtime capability로 표시한다.
- 최종 RFC 변화에 따른 breaking change를 허용한다.
- Stable core와 endpoint namespace를 분리한다.
### 24.4 병렬 upload
하나의 upload offset에 여러 writer를 허용하지 않는다. 병렬 전송은 다음 구조만 제공한다.
```text
parent upload
├─ part 1 resource
├─ part 2 resource
└─ part N resource
→ 각 part checksum 검증
→ 순서와 총 길이 검증
→ concatenate
→ final verification
```
---
## 25. 파일 관리 기능
### 25.1 stat
공개 `stat`은 DB metadata를 반환한다. physical stat은 내부 일관성 검증에만 사용한다.
### 25.2 delete
```text
If-Match 검증
→ READY/REJECTED/FAILED → DELETING
→ 공개 read 즉시 차단
→ cleanup item 등록
→ physical delete
→ quota 반영
→ DELETED
```
### 25.3 copy
- capability가 없으면 application-level stream copy를 사용한다.
- target은 create-only가 기본이다.
- source와 target metadata는 별도 레코드다.
- copy 실패 시 incomplete target은 cleanup queue로 보낸다.
- 자동 rollback 보장을 선언하지 않는다.
### 25.4 move
공개 move는 physical path move가 아니라 logical namespace·ownership metadata 변경이다. physical content는 immutable key를 유지한다. physical move는 admin maintenance에만 사용한다.
### 25.5 list·scan
public API에는 제공하지 않는다. admin API는 bounded pagination, prefix allowlist, rate limit, dry-run을 요구한다.
---
## 26. 오류 모델과 Problem Detail
### 26.1 예외 hierarchy
```text
FileserverException
├─ FileNotFoundException
├─ FileAlreadyExistsException
├─ InvalidPathException
├─ PathOutsideNamespaceException
├─ FileAccessDeniedException
├─ StorageFullException
├─ QuotaExceededException
├─ FileTooLargeException
├─ UnsupportedMediaTypeException
├─ IntegrityMismatchException
├─ UploadOffsetMismatchException
├─ UploadExpiredException
├─ FileNotReadyException
├─ AtomicPublishUnsupportedException
├─ TransferTimeoutException
├─ PartialWriteException
├─ AmbiguousCompletionException
├─ StorageUnavailableException
├─ ConcurrentFileModificationException
└─ MalwareDetectedException
```
모든 예외는 다음 metadata를 가진다.
```java
public record FileserverFailureContext(
String code,
boolean retryable,
boolean ambiguous,
boolean reconciliationRequired,
Optional<FileId> fileId,
Optional<UploadId> uploadId,
OptionalLong expectedOffset,
OptionalLong currentOffset,
Optional<FileState> currentState
) {}
```
### 26.2 Problem Detail
```json
{
"type": "urn:fileserver:problem:upload-offset-mismatch",
"title": "Upload offset mismatch",
"status": 409,
"code": "UPLOAD_OFFSET_MISMATCH",
"retryable": true,
"uploadId": "...",
"expectedOffset": 1048576,
"currentOffset": 524288,
"traceId": "..."
}
```
내부 path, mount, scanner credential, storage token을 포함하지 않는다.
---
## 27. 보안 정책
### 27.1 위험 등급
| 등급 | 대상 | 정책 |
|---|---|---|
| F1 | ID 기반 create·read·delete, single Range | 기본 허용, auth·size·state gate |
| F2 | 대용량 stream, multi Range, resumable, overwrite, copy | quota·budget·precondition 필수 |
| F3 | list, capacity, orphan, force delete, reverify | internal admin plane |
| F4 | arbitrary path, symlink, recursive delete, webroot storage | 전체 차단 |
### 27.2 필수 방어
- opaque ID와 server-generated physical key
- original filename sanitization
- extension allowlist가 있더라도 Content-Type을 신뢰하지 않음
- signature/parser·scanner verdict
- executable permission 제거
- separate mount와 webroot 밖 저장
- size, part count, concurrency, minimum-rate 제한
- private download cache 제한
- READY gate
- CSRF 방어가 필요한 cookie 기반 upload endpoint
- authorization on every access
- range bomb 제한
- ZIP/XML expanded-size 제한을 verifier에 적용
### 27.3 파일명 sanitization
제거·치환 대상:
- `/`, `\`, NUL
- control characters
- bidi override characters
- CR/LF와 quote injection
- trailing dot·space
- Windows reserved names
- UTF-8 255 byte 초과
sanitized name은 Content-Disposition에만 사용하며 physical path 생성에는 사용하지 않는다.
---
## 28. 다중 인스턴스와 NFS
### 28.1 Writer lease
```java
public record WriterLease(
UploadId uploadId,
String owner,
UUID token,
Instant expiresAt,
long version
) {}
```
- DB conditional update로 획득한다.
- append 중 주기적으로 갱신한다.
- lease token이 다르면 offset commit을 거부한다.
- process pause로 lease가 만료된 writer는 이후 commit하지 못한다.
- local file lock이나 NFS lock을 correctness 근거로 사용하지 않는다.
### 28.2 NFS reconciliation
다음 이벤트에서 metadata와 physical state를 재확인한다.
- rename timeout
- stale file handle
- mount reconnect
- attribute mismatch
- server restart
- lease takeover
reconciliation 결과:
```text
CONFIRMED_SUCCESS
CONFIRMED_NOT_APPLIED
RECOVERABLE_PARTIAL
QUARANTINE_REQUIRED
UNRESOLVED
```
`UNRESOLVED`는 자동 retry하지 않고 운영 queue로 보낸다.
### 28.3 PVC certification unit
지원 단위는 `PVC`라는 이름이 아니라 다음 tuple이다.
```text
Kubernetes version
+ CSI driver/version
+ StorageClass
+ access mode
+ filesystem/backend
+ mount options
```
---
## 29. Cleanup와 Reconciliation
### 29.1 Cleanup 종류
- expired upload
- cancelled staging
- failed verification content
- deleted READY content
- orphan physical object
- stale quota reservation
- abandoned lease
- previous version after pointer publish
### 29.2 안전 규칙
- cleanup은 version과 lease를 확인한다.
- 기본 admin 실행은 dry-run이다.
- active upload와 동일 physical key는 삭제하지 않는다.
- batch size와 bytes budget을 둔다.
- 실패는 exponential backoff와 최대 retry를 사용한다.
- 장기 실패는 orphan metric과 alert로 승격한다.
### 29.3 Reconciliation
```java
public interface FileReconciliationService {
ReconciliationResult reconcile(FileId fileId);
ReconciliationBatchResult reconcileOrphans(ReconciliationQuery query);
}
```
자동 reconciliation이 READY를 임의 추정해서는 안 된다. size, digest, expected content key, metadata version이 모두 맞을 때만 상태를 복원한다.
---
## 30. 관측성
### 30.1 Metric
| Metric | 주요 tag |
|---|---|
| upload count·duration | protocol, storageType, resultCode, sizeBucket |
| download count·duration | transferMode, rangeType, resultCode, sizeBucket |
| transfer bytes | direction, storageType |
| active transfers | direction, instance |
| interruption | direction, reason |
| resumable append | protocol, result |
| offset mismatch | protocol, clientType |
| checksum failure | algorithm, stage |
| verification queue | verifier, verdict, ageBucket |
| temp·orphan bytes | storagePool, ageBucket |
| storage usage | pool, mountProfile |
| quota | scopeType, result |
| cleanup | type, result |
| delegation ratio | sizeBucket |
| access denial | operation, policyCode |
실제 file ID, upload ID, filename, path, user ID를 metric label로 사용하지 않는다.
### 30.2 Trace
```text
upload.create
upload.append
upload.finalize
verify.digest
verify.media-type
verify.malware
storage.publish
storage.stat
metadata.transition
download.authorize
download.resolve-range
download.open
download.delegate
cleanup.item
reconcile.file
```
### 30.3 Audit
다음 작업은 audit 대상이다.
- overwrite
- delete·force delete
- admin reverify
- orphan reconcile
- quarantine 승인·거절
- delegated download 발급
- access denial
filename, path, signed token, content sample은 audit에 기록하지 않는다.
---
## 31. Spring Boot 설정
```yaml
backend:
fileserver:
enabled: true
storage:
type: local
root: /var/lib/backend/files
publish-mode: atomic-move-preferred
require-same-file-store: true
fail-on-symlink: true
buffer-size: 128KiB
upload:
profile: standard
max-file-size: 100MiB
max-request-size: 116MiB
max-parts: 16
require-content-length: false
incomplete-ttl: 24h
idle-timeout: 45s
instance-concurrency: 16
scope-concurrency: 4
download:
single-range-only: true
max-ranges: 8
direct-concurrency: 64
private-cache-control: "private, no-store"
content-digest: false
nginx:
enabled: false
delegate-threshold: 16MiB
internal-prefix: /__files/
verification:
async: true
checksum: sha-256
require-media-type-verdict: true
scanner-required: false
max-attempts: 5
quota:
enabled: true
reservation-ttl: 24h
cleanup:
batch-size: 100
max-bytes-per-run: 10GiB
fixed-delay: 5m
tus:
enabled: false
checksum: true
expiration: true
termination: true
httpbis-draft12:
enabled: false
mvc:
executor:
core-threads: 8
max-threads: 32
queue-capacity: 64
webflux:
io-workers: 16
max-in-flight-buffers: 8
```
### 31.1 Startup validation
다음 조건은 startup 실패다.
- storage root가 webroot 또는 application config 아래임
- staging과 content가 다른 `FileStore`
- symlink no-follow probe 실패
- `ATOMIC_MOVE_REQUIRED`인데 probe 실패
- metadata store 없이 multi-instance mode 활성화
- no-op authorization policy가 production profile에서 활성화
- scanner-required인데 verifier bean 없음
- Nginx delegation을 켰는데 token service 또는 mapping 검증 없음
---
## 32. 관리자 API
| Method·Path | 기능 | 통제 |
|---|---|---|
| `GET /internal/fileserver/storage-health` | capacity와 probe 결과 | admin network·role |
| `GET /internal/fileserver/capabilities` | runtime capability | path 비노출 |
| `GET /internal/fileserver/orphans` | bounded orphan 조회 | pagination·rate limit |
| `POST /internal/fileserver/orphans:reconcile` | dry-run·apply | audit |
| `POST /internal/fileserver/files/{id}:reverify` | 재검사 | audit |
| `POST /internal/fileserver/files/{id}:force-delete` | 강제 삭제 | 사유·이중 권한 |
| `GET /internal/fileserver/uploads/incomplete` | 미완료 조회 | filename 마스킹 |
| `POST /internal/fileserver/uploads:cleanup` | cleanup | lease·version 확인 |
| `GET /internal/fileserver/verification-queue` | 검사 지연 | bounded result |
관리자 API는 public starter에서 자동 노출하지 않고 별도 `fileserver-admin` 모듈과 management port에서만 활성화한다.
---
## 33. 테스트 전략
### 33.1 계약 테스트
- Content Store blocking·async contract
- Metadata optimistic transition contract
- state machine illegal transition
- upload offset and lease
- checksum and size
- GET·HEAD header parity
- `200/206/304/412/416`
- Range first, middle, suffix, end, empty
- `If-Range`, `If-Match`, `If-None-Match`
- multipart single·batch
- tus create·HEAD·PATCH·checksum·expiry·termination
### 33.2 보안 테스트
- `../`, percent-encoded separator, absolute path, Windows drive path
- parent symlink replacement race
- hard link discovery
- filename CRLF·bidi·reserved name
- extension·Content-Type·signature mismatch
- scriptable content inline 차단
- scanner timeout·malware verdict
- internal Nginx path direct access
- unauthorized download and existence hiding
- multi Range bomb
### 33.3 장애 테스트
- write 전·중·후 process kill
- close 후 publish 전 kill
- physical publish 후 DB commit 전 kill
- disk full and quota exhaustion
- permission denied
- slow upload·download
- network disconnect
- WebFlux cancellation
- MVC executor saturation
- NFS disconnect·server restart·rename ambiguity
- PVC remount·Pod reschedule
- scanner unavailable
### 33.4 성능 테스트
- 100 MiB와 5 GiB streaming
- concurrent upload/download
- direct vs Nginx throughput
- p50, p95, p99, max latency
- heap, direct memory, allocation, GC
- temp disk and scanner throughput
- Range overhead
- cleanup throughput
### 33.5 인증 매트릭스
| 프로파일 | 빈도 | Gate |
|---|---|---|
| Linux ext4 local | PR | 필수 |
| Linux XFS local | nightly | release 필수 |
| PVC RWO 주 CSI | release | 필수 |
| PVC RWX | release | 지원 선언 시 필수 |
| NFSv4.1 | nightly | 제한 지원 필수 |
| NFS fault injection | RC | 제한 지원 필수 |
| Windows NTFS | nightly | 초기 non-blocking |
| Nginx stable | release | nginx 모듈 필수 |
| MVC Tomcat | PR | 필수 |
| MVC Jetty | release | 지원 선언 시 필수 |
| WebFlux Reactor Netty | PR | 필수 |
| Spring 6.2 latest | release | 필수 |
| Spring 7.0 latest | release | 필수 |
---
## 34. CI 품질 Gate
모든 pull request:
```text
unit test
core contract test
local ext4 integration
MVC Tomcat HTTP contract
WebFlux Reactor Netty contract
architecture test
path traversal·symlink security suite
bounded-memory regression
```
Nightly:
```text
XFS
NFSv4.1
Windows NTFS
large-file performance
slow client
process-kill matrix
scanner failure
```
Release:
```text
Spring 6.2 / 7.0 matrix
PVC certification
Nginx contract
multi-instance lease
fault injection
support-matrix diff
sensitive-log scan
```
---
## 35. 릴리스 단계
### Milestone A — Core Alpha
- core model·state machine
- JPA metadata
- local staging·append·publish
- raw upload
- full download
- checksum
### Milestone B — HTTP Beta
- multipart
- GET·HEAD·single Range
- conditional request
- MVC·WebFlux
- security verifier
- cleanup
### Milestone C — Distributed RC
- multi-instance lease
- Nginx delegation
- PVC RWO certification
- admin plane
- chaos·performance gate
### Milestone D — Extended Release
- tus 1.0
- NFS limited profile
- PVC RWX certification
- multi Range Beta
- HTTPbis draft-12 Experimental
---
## 36. 구현자가 임의로 변경하면 안 되는 결정
- 공개 API에 `Path`와 physical filename을 노출하지 않는다.
- Content Store의 최소 Port를 filesystem 명령 mirror로 바꾸지 않는다.
- READY 이전 다운로드를 허용하지 않는다.
- metadata DB를 우회해 physical file 존재만으로 READY를 추정하지 않는다.
- create-only 기본값을 unconditional overwrite로 바꾸지 않는다.
- client Content-Type과 filename을 신뢰하지 않는다.
- WebFlux event loop에서 blocking I/O를 실행하지 않는다.
- MVC streaming에 unbounded executor를 사용하지 않는다.
- NFS lock을 단독 correctness mechanism으로 사용하지 않는다.
- upload timeout 후 blind retry를 수행하지 않는다.
- arbitrary path, symlink, recursive delete를 escape hatch로 열지 않는다.
- IETF draft 모듈을 Stable API와 섞지 않는다.
- metric label에 fileId·filename·path를 넣지 않는다.
---
## 37. 완료 정의
프로젝트 완료는 다음 산출물이 코드와 CI에 연결됐을 때 선언한다.
| 산출물 | 완료 기준 |
|---|---|
| 지원 매트릭스 | runtime·filesystem·protocol별 자동 test job 연결 |
| 상태 머신 | 모든 허용·금지 전이 contract test |
| Content Store | blocking·async contract와 local adapter 인증 |
| Metadata Store | optimistic version·lease·recovery test |
| HTTP 계약 | MVC·WebFlux·Nginx mode parity |
| 보안 | traversal·symlink·MIME·권한 공격 suite |
| 장애 | crash point·disk full·network fault 후 invariant 유지 |
| 성능 | 최대 파일에서도 bounded heap·direct memory |
| 운영 | metric, trace, audit, cleanup, reconciliation, runbook |
| 재개 업로드 | tus 1.0 contract suite |
| 제한 지원 | NFS·PVC RWX·Windows 수준이 runtime capability와 문서에 표시 |
---
## 38. 구현 순서
```text
1. 모듈·품질 기반
2. core ID·상태·오류
3. Content Store와 Metadata Store 계약
4. JPA metadata
5. local path·staging·capability probe
6. append·checksum·quota
7. publish·state transition·reconciliation
8. upload application
9. HTTP Range·conditional core
10. MVC
11. WebFlux
12. verification·authorization
13. delete·cleanup·admin
14. Nginx delegation
15. multi-instance·PVC
16. tus 1.0
17. NFS limited certification
18. HTTPbis draft Experimental
19. chaos·performance·release matrix
```