LeeHeoYoon: 파일 업로드-다운로드 기능 구현#6
Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Spring Boot 기반 파일 스토리지 서비스 구현 - 파일 업로드/다운로드/삭제/복사/이동 API - 폴더 생성/조회/이름변경/삭제/이동 API - PostgreSQL + ltree 기반 폴더 계층 구조 - Nginx X-Accel-Redirect 기반 파일 다운로드 - Docker Compose 배포 환경 구성 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
브라우저에서 파일/폴더 API를 테스트할 수 있는 데모 UI 추가
- Thymeleaf + Bootstrap 5 기반 단일 페이지 파일 탐색기
- StorageUiController (GET /ui, GET /ui/download/{fileId})
- 폴더 CRUD (생성/이름변경/삭제/이동) + 파일 CRUD (업로드/다운로드/삭제/이동/복사/메타데이터)
- Nginx /ui 프록시 설정 추가
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix PropertyReferenceException by separating Sort for Folder (name) and FileMetadata (originalName) in FolderService.listContents - Fix ltree type casting (varchar->ltree) by adding stringtype=unspecified to JDBC URL and implicit cast in init.sql - Fix TransactionRequiredException in async upload status update by wrapping @Modifying query call with TransactionTemplate - Fix ltree subpath() "invalid positions" error in folder move queries by adding CASE expression for exact match vs descendant paths - Add MissingServletRequestPartException and MultipartException handlers to return 400 instead of 500 - Add duplicate filename check in copyFile() at service level Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Hibernate binds String parameters as varchar, not text. The existing text→ltree cast didn't cover this, causing INSERT failures on the folder_path column. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change storage.base-path default from /data/storage to ./storage-data so the app works without root permissions on local machines. Docker still overrides via STORAGE_BASE_PATH env var. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
StorageUiController now delegates file serving to Nginx via X-Accel-Redirect header, matching the API download pattern. This eliminates App memory/CPU overhead for file transfers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Raise client_max_body_size and Spring multipart limits to 300MB - Add per-client bandwidth limit of 10MB/s for API uploads - Add per-client download bandwidth limit of 10MB/s (after first 1MB) - Add descriptive comments for limit settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Enhance gzip settings (level 6, min 256B, more types, vary/proxied) - Remove proxy_buffering off to enable gzip on proxied responses - Disable gzip for binary file downloads (X-Accel-Redirect) - Change app port binding to expose-only (internal access via nginx) - Make nginx port configurable via NGINX_PORT env var - Add k6 compression test script comparing gzip vs plain performance Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
파일 업로드 최적화 분석상황 (As-Is)현재 파일 업로드 시 데이터가 3단계로 버퍼링됩니다: Spring Boot 내부의 문제 (Problem)
해결 방법별 비교Option A:
|
| 항목 | Before | After |
|---|---|---|
| 디스크 쓰기 | 3회 (tmpfs + multipart + temp) | 1회 (Nginx → 디스크) |
| JVM 파일 I/O | 전체 바이트 통과 | 0 바이트 (경로만 수신) |
| RAM 사용 | 512MB tmpfs | 0 (직접 디스크) |
| Tomcat 점유 | 업로드 전체 시간 | 콜백 처리만 (~ms) |
| MD5 검증 | 없음 | 자동 제공 |
| 동시 300MB 업로드 | ~2개 (tmpfs 한계) | 디스크 용량만큼 |
| 장점 | 단점 |
|---|---|
| 완전한 JVM 분리, 최고 성능 | nginx:alpine 사용 불가, 커스텀 이미지 빌드 필요 |
| 자동 MD5 해시, 자동 에러 시 정리 | 서드파티 모듈 (마지막 업데이트 ~2020), Nginx 업그레이드 시 호환성 위험 |
| 파일 크기 자동 리포팅 | API 변경 필요 (2단계 요청), 클라이언트 수정 필요 |
Option B: client_body_in_file_only + 2단계 API (표준 Nginx)
방식:
1. Client → Spring Boot: 메타데이터만 전송 → PENDING 저장 + uploadToken 발급
2. Client → Nginx(/upload-direct/): 파일 전송 → Nginx가 디스크에 저장 (표준 기능)
3. Nginx → Spring Boot: proxy_pass (X-File-Path 헤더로 temp 경로 전달, body 없음) → ATOMIC_MOVE + COMPLETED
Nginx 설정:
location /upload-direct/ {
client_body_in_file_only on;
client_body_temp_path /data/storage/.tmp;
client_body_buffer_size 0;
proxy_pass http://spring_app/api/files/callback;
proxy_set_header X-File-Path $request_body_file;
proxy_pass_request_body off;
proxy_set_header Content-Length 0;
}결과:
| 항목 | Before | After |
|---|---|---|
| 디스크 쓰기 | 3회 | 1회 (Nginx → 디스크) |
| JVM 파일 I/O | 전체 바이트 통과 | 0 바이트 (경로만 수신) |
| RAM 사용 | 512MB tmpfs | 0 |
| Tomcat 점유 | 업로드 전체 시간 | 콜백 처리만 (~ms) |
| Nginx 이미지 | nginx:alpine | nginx:alpine (그대로) |
| 장점 | 단점 |
|---|---|
| 표준 Nginx 기능, 커스텀 빌드 불필요 | MD5 자동 계산 없음 |
| JVM 완전 분리, Option A와 동일한 성능 | Nginx가 temp 파일 자동 정리 안 함 → cleanup 확장 필요 |
| 프로덕션 검증된 안정적 방식 | API 변경 필요 (2단계 요청), 클라이언트 수정 필요 |
storage-data 볼륨의 :ro → :rw 변경 필요 |
uploadToken 보안 + 만료 처리 필요 |
Option C: 현재 구조 설정 최적화 (코드 변경 없음)
방식: API/코드 변경 없이 설정만 조정
변경 1 — tmpfs 제거, 디스크 기반 temp:
# nginx.conf
client_body_temp_path /data/nginx-body 1 2; # tmpfs 대신 디스크# docker-compose.yml: tmpfs 삭제, nginx-body 볼륨 추가
nginx:
volumes:
- nginx-body:/data/nginx-body
# tmpfs 삭제변경 2 — 소파일 메모리 처리:
# application.yml
file-size-threshold: 1MB # 10KB → 1MB결과:
| 항목 | Before | After |
|---|---|---|
| RAM 사용 | 512MB tmpfs | 0 (디스크 기반) |
| 소파일 디스크 I/O | 10KB 이상 모두 디스크 | 1MB 이상만 디스크 |
| 디스크 쓰기 | 3회 | 2회 (Nginx 디스크 + multipart) |
| JVM 파일 I/O | 전체 바이트 통과 | 전체 바이트 통과 (변화 없음) |
| Tomcat 점유 | 업로드 전체 시간 | 업로드 전체 시간 (변화 없음) |
| API 변경 | — | 없음 |
| 코드 변경 | — | 없음 (설정 3줄) |
| 장점 | 단점 |
|---|---|
| 설정 3줄 수정, 코드 변경 0 | JVM이 여전히 업로드 경로에 존재 |
| API 계약 유지, 클라이언트 수정 없음 | Nginx → Spring Boot 네트워크 재전송 제거 불가 |
| 즉시 적용 가능 | 근본적 확장성 문제 미해결 |
전체 비교표
| A: upload-module | B: client_body_in_file_only | C: 설정 최적화 | |
|---|---|---|---|
| 디스크 쓰기 | 1회 | 1회 | 2회 |
| JVM 바이트 통과 | 0 | 0 | 전체 |
| RAM 절약 | 512MB | 512MB | 512MB |
| Tomcat 점유 | ~ms | ~ms | ~30s/300MB |
| Nginx 이미지 | 커스텀 빌드 | 표준 alpine | 표준 alpine |
| API 변경 | 있음 (2단계) | 있음 (2단계) | 없음 |
| 코드 변경 | 높음 | 중간 | 없음 |
| 구현 난이도 | 높음 | 중간 | 낮음 (설정 3줄) |
| 확장성 개선 | 매우 큼 | 매우 큼 | 제한적 |
권장 전략
- 즉시: Option C 적용 (설정 3줄, 512MB RAM 절약)
- 다음 단계: Option B 적용 (JVM 파일 I/O 완전 분리, 표준 Nginx 유지)
- 필요 시: Option A 검토 (MD5 자동 검증이 필수인 경우)
Option B Step 3: Nginx → Spring Boot 콜백 동작 원리왜 temp 경로를 전달하는가?Nginx와 Spring Boot가 같은 디스크 볼륨을 공유하지만, Nginx가 파일을 어디에 저장했는지 Spring Boot는 모릅니다. 그래서 Nginx가 저장한 파일의 경로를 헤더로 알려줘야 합니다. 단계별 동작Step 2: Client → Nginx (파일 저장) Step 3: Nginx → Spring Boot (경로만 전달) # Nginx 설정
proxy_set_header X-File-Path $request_body_file; # 파일 경로를 헤더에 넣고
proxy_pass_request_body off; # 파일 바이트는 안 보냄
proxy_set_header Content-Length 0; # 빈 body
proxy_pass http://spring_app/internal/callback; # Spring Boot에 전달핵심 포인트
다운로드 vs 업로드: 동일한 철학다운로드와 업로드 모두 "경로만 전달하고, 실제 파일 I/O는 Nginx가 디스크에서 직접 처리"하는 동일한 철학입니다. |
Option B 구현 계획 (Planner-Architect-Critic 3 Round 합의)목표Nginx 새로운 업로드 흐름3 Round 피드백 반영 이력 (13건)
구현 단계변경 파일 (16개)
|
- Add nginx client_body_in_file_only for zero-copy file reception - Implement 2-stage upload: init (metadata) → nginx raw body upload - Add internal callback controller with shared secret validation - Add upload token with 30-min TTL and conditional UPDATE for race safety - Add orphan nginx temp file cleanup scheduler - Add k6 load test scripts for before/after comparison - Update UI with Option B upload + legacy multipart fallback Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Fix nginx proxy_pass URI concatenation bug with rewrite directive - Fix client_body_buffer_size 0 causing upload hang (set to 16k) - Remove non-root user constraints for Docker volume compatibility - Switch nginx to listen on port 80 (root mode) - Add BASE_URL env support to k6 scripts for Docker network testing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
⚡ Option B (Nginx Direct-to-Disk 2-Stage Upload) 부하테스트 결과테스트 환경
결과 비교
Option B 세부 지표
분석
수정된 이슈
|
📋 Gzip 압축 파일 유지 저장 전략 분석파일 업로드/다운로드 시 gzip 압축 상태를 유지한 채 디스크에 저장하고, 다운로드 시에도 그대로 전달하는 방식의 효과를 분석했습니다. 현재 상태현재 gzip은 API JSON 응답과 HTML 페이지에만 적용되고 있습니다. # nginx.conf:33-43 — 텍스트 기반 응답만 압축
gzip_types application/json text/plain text/css application/javascript application/xml;
# nginx.conf:121 — 파일 다운로드는 gzip off
location /storage-internal/ {
gzip off; # ← 바이너리 파일 압축 안 함
sendfile on; # ← zero-copy 전송
}파일 업로드/다운로드 경로에서 gzip 압축은 일어나지 않고 있습니다. 브라우저/HTTP 클라이언트는 request body를 gzip 압축하지 않으며, 다운로드도 명시적으로 Zero-Copy(sendfile)란?운영체제는 메모리를 유저 스페이스(앱이 사는 곳)와 커널 스페이스(OS가 관리하는 곳)로 나눕니다. 앱이 디스크나 네트워크를 쓰려면 반드시 커널에게 요청(시스템 콜)해야 합니다. 일반 전송: read() + write() — 시스템 콜 2번문제: nginx는 파일 내용을 수정하지 않는데, 앱 메모리를 거쳐야 합니다. Zero-Copy 전송: sendfile() — 시스템 콜 1번// 리눅스 시스템 콜
sendfile(socket_fd, file_fd, offset, count);
// "이 파일을 저 소켓으로 직접 보내줘" — 끝비교
gzip을 켜면 왜 zero-copy가 깨지는가gzip 압축은 데이터를 읽고 변환하는 작업이라 커널만으로는 처리할 수 없습니다. 만약 gzip 압축 저장을 구현한다면?효과: 미미
부작용: 심각
결론비용 대비 효과가 없으므로 구현을 권장하지 않습니다.
더 효과적인 대안
|
📊 Zero-Copy vs 일반 전송 vs Gzip 전송 — 다이어그램1. 일반 전송 (read + write) — 복사 4회sequenceDiagram
participant App as nginx (유저 스페이스)
participant Kernel as 커널 스페이스
participant Disk as 디스크
participant NIC as 네트워크 카드
Note over App,NIC: read() 시스템 콜
App->>Kernel: read(file_fd, buf, size)
Kernel->>Disk: ① DMA 복사 요청
Disk-->>Kernel: 데이터 → 커널 버퍼
Kernel-->>App: ② CPU 복사 (커널→앱 메모리)
Note over App: 앱 메모리에 파일 데이터 적재
Note over App,NIC: write() 시스템 콜
App->>Kernel: write(socket_fd, buf, size)
Note over Kernel: ③ CPU 복사 (앱→소켓 버퍼)
Kernel->>NIC: ④ DMA 복사 (소켓→네트워크)
NIC-->>NIC: 클라이언트로 전송
Note over App,NIC: 총: 복사 4회 (CPU 2회), 컨텍스트 스위치 2회
2. Zero-Copy (sendfile) — 복사 2회, CPU 복사 0회sequenceDiagram
participant App as nginx (유저 스페이스)
participant Kernel as 커널 스페이스
participant Disk as 디스크
participant NIC as 네트워크 카드
Note over App,NIC: sendfile() 시스템 콜 1번
App->>Kernel: sendfile(socket_fd, file_fd, offset, count)
Kernel->>Disk: ① DMA 복사 요청
Disk-->>Kernel: 데이터 → 커널 버퍼
Note over Kernel: ② 포인터만 소켓에 전달 (복사 없음!)
Kernel->>NIC: ③ DMA 복사 (커널 버퍼→네트워크)
NIC-->>NIC: 클라이언트로 전송
Note over App: nginx 메모리 사용: 거의 0
Note over App,NIC: 총: 복사 2회 (DMA만), CPU 복사 0회
3. Gzip 전송 — Zero-Copy 깨짐sequenceDiagram
participant App as nginx (유저 스페이스)
participant Kernel as 커널 스페이스
participant Disk as 디스크
participant NIC as 네트워크 카드
Note over App,NIC: gzip on이면 sendfile 무효화
App->>Kernel: read 요청
Kernel->>Disk: ① DMA 복사
Disk-->>Kernel: 데이터 → 커널 버퍼
Kernel-->>App: ② CPU 복사 (커널→앱)
Note over App: ③ nginx가 메모리에서 gzip 압축 수행<br/>CPU 소모 + 메모리 점유
App->>Kernel: ④ CPU 복사 (압축 데이터→소켓 버퍼)
Kernel->>NIC: ⑤ DMA 복사 (소켓→네트워크)
NIC-->>NIC: 클라이언트로 전송
Note over App,NIC: 총: 복사 4회 + 압축 연산, zero-copy 완전 손실
4. 현재 프로젝트의 파일 다운로드 흐름flowchart LR
Client[클라이언트] -->|GET /api/files/123| Spring[Spring Boot]
Spring -->|X-Accel-Redirect<br/>/storage-internal/UUID| Nginx[Nginx]
Nginx -->|sendfile zero-copy<br/>gzip off| Disk[(디스크<br/>/data/storage/UUID)]
Disk -->|DMA 직접 전송<br/>앱 메모리 0| Client
style Nginx fill:#2d9,stroke:#333,color:#000
style Disk fill:#69c,stroke:#333,color:#000
5. 방식별 비교 요약graph TB
subgraph "❌ Gzip 압축 저장 방식 (제안)"
A1[업로드 시 서버에서 압축] --> A2[.gz로 디스크 저장]
A2 --> A3{클라이언트가<br/>gzip 지원?}
A3 -->|Yes| A4[gzip_static 서빙<br/>sendfile 가능]
A3 -->|No| A5[실시간 해제 필요<br/>sendfile 불가 ❌]
A2 --> A6[Range Request 불가 ❌]
A2 --> A7[파일 크기 이중 관리 ❌]
end
subgraph "✅ 현재 방식 (권장)"
B1[원본 그대로 디스크 저장] --> B2[sendfile zero-copy 전송]
B2 --> B3[모든 클라이언트 호환 ✅]
B2 --> B4[Range Request 지원 ✅]
B2 --> B5[CPU 복사 0회 ✅]
end
style A5 fill:#f66,stroke:#333,color:#000
style A6 fill:#f66,stroke:#333,color:#000
style A7 fill:#f66,stroke:#333,color:#000
style B3 fill:#2d9,stroke:#333,color:#000
style B4 fill:#2d9,stroke:#333,color:#000
style B5 fill:#2d9,stroke:#333,color:#000
6. 파일 유형별 gzip 압축 효과xychart-beta
title "파일 유형별 gzip 압축률 (%)"
x-axis ["텍스트/CSV", "JSON/XML", "PDF", "Office", "이미지", "동영상", "ZIP/RAR"]
y-axis "압축률 (높을수록 효과적)" 0 --> 100
bar [75, 80, 10, 3, 1, 1, 0]
|
📋 Nginx io_uring 적용 효과 분석현재 io_uring이란?기존 I/O는 매 작업마다 시스템 콜(유저↔커널 전환)이 필요합니다. io_uring은 유저와 커널이 공유하는 링 버퍼를 통해 I/O 요청을 배치로 주고받아, 시스템 콜 오버헤드를 제거하는 리눅스 커널 기술(5.1+)입니다. graph LR
subgraph "기존 방식: epoll + sendfile"
A1[요청 1] -->|시스템 콜| K1[커널 처리]
A2[요청 2] -->|시스템 콜| K2[커널 처리]
A3[요청 3] -->|시스템 콜| K3[커널 처리]
A4[요청 N] -->|시스템 콜| K4[커널 처리]
end
graph LR
subgraph "io_uring 방식: 공유 링 버퍼"
A[요청 1,2,3...N] -->|링 버퍼에 배치| SQ[Submission Queue]
SQ -->|시스템 콜 1번| K[커널 일괄 처리]
K -->|결과 배치| CQ[Completion Queue]
CQ --> R[결과 1,2,3...N]
end
io_uring 내부 구조graph TB
subgraph "유저 스페이스 (nginx)"
SQ_U["Submission Queue<br/>(요청 넣기)"]
CQ_U["Completion Queue<br/>(결과 읽기)"]
end
subgraph "공유 메모리 (mmap)"
RING["링 버퍼<br/>시스템 콜 없이 접근 가능"]
end
subgraph "커널 스페이스"
IO["I/O 엔진<br/>(디스크/네트워크 처리)"]
end
SQ_U -->|"요청 추가 (no syscall)"| RING
RING -->|"요청 수집"| IO
IO -->|"완료 통보"| RING
RING -->|"결과 읽기 (no syscall)"| CQ_U
style RING fill:#ff9,stroke:#333,color:#000
핵심: SQ/CQ가 현재 프로젝트에서 효과가 없는 이유 — Amdahl's Lawgraph LR
Client[클라이언트] -->|요청| LR["limit_rate 10m<br/>⏱️ ~100,000μs/MB<br/>🔴 실제 병목"]
LR --> SF["sendfile zero-copy<br/>⏱️ ~2-5μs<br/>🟢 이미 최적"]
SF --> Disk["디스크"]
style LR fill:#f66,stroke:#333,color:#000
style SF fill:#2d9,stroke:#333,color:#000
경로별 분석graph TB
subgraph "다운로드 경로"
D1["limit_rate 10m"] -->|"병목 ⏱️ 100,000μs/MB"| D2["sendfile zero-copy"]
D2 -->|"⏱️ 2-5μs"| D3["디스크 → 네트워크"]
end
subgraph "업로드 경로"
U1["limit_rate 10m"] -->|"병목 ⏱️ 100,000μs/MB"| U2["client_body_in_file_only"]
U2 -->|"디스크 쓰기 500MB/s+"| U3["디스크 저장"]
end
subgraph "API 프록시"
P1["proxy_pass"] -->|"⏱️ μs 수준"| P2["Spring Boot"]
P2 -->|"병목: DB 쿼리 ms 수준"| P3["JSON 응답"]
end
style D1 fill:#f66,stroke:#333,color:#000
style U1 fill:#f66,stroke:#333,color:#000
style P2 fill:#f66,stroke:#333,color:#000
style D2 fill:#2d9,stroke:#333,color:#000
io_uring이 의미 있는 규모 vs 현재 프로젝트quadrantChart
title "io_uring 도입 효과 판단 기준"
x-axis "동시 접속: 낮음" --> "동시 접속: 높음"
y-axis "I/O 빈도: 낮음" --> "I/O 빈도: 높음"
quadrant-1 "io_uring 매우 효과적"
quadrant-2 "제한적 효과"
quadrant-3 "효과 없음"
quadrant-4 "제한적 효과"
"CDN/대형파일서버": [0.9, 0.85]
"고빈도 DB (NVMe)": [0.5, 0.95]
"초저지연 트레이딩": [0.3, 0.9]
"현재 프로젝트": [0.2, 0.15]
도입 비용현재 flowchart TD
A["nginx:alpine<br/>(현재)"] -->|"커스텀 빌드 필요"| B["nginx 1.27+ 소스 빌드<br/>--with-io_uring"]
B --> C["Docker seccomp 프로필 수정<br/>io_uring syscall 허용"]
C --> D["호스트 커널 5.1+ 확인"]
D --> E["보안 패치 직접 관리<br/>nginx 업데이트 수동"]
style A fill:#2d9,stroke:#333,color:#000
style B fill:#f96,stroke:#333,color:#000
style C fill:#f96,stroke:#333,color:#000
style E fill:#f66,stroke:#333,color:#000
결론현재 구성( 병목이 I/O 시스템 콜이 아니라 대역폭 제한( 더 효과적인 개선 방향:
|
📋 동적 다운로드 속도 제어 (X-Accel-Limit-Rate) 적용 및 부하 테스트 결과변경 내용1. nginx.conf — limit_rate 10m → 128m
2. FileController.java — X-Accel-Limit-Rate 동적 제어 추가 private String calculateRate(Long fileSize) {
if (fileSize < 100MB) return "0"; // 무제한 (sendfile zero-copy 최대 활용)
return String.valueOf(128 * 1024 * 1024); // 100MB 이상: 128MB/s
}Spring Boot가 sequenceDiagram
participant Client as 클라이언트
participant Nginx as Nginx
participant Spring as Spring Boot
participant Disk as 디스크
Client->>Nginx: GET /api/files/123
Nginx->>Spring: proxy_pass
Spring->>Spring: calculateRate(fileSize)
Spring-->>Nginx: X-Accel-Redirect: /storage-internal/UUID<br/>X-Accel-Limit-Rate: 0 (무제한)
Nginx->>Disk: sendfile() zero-copy
Disk-->>Client: 파일 전송 (limit_rate 동적 적용)
부하 테스트 결과10MB 파일, 시나리오: 단일 사용자(15s) → 5명 동시(15s) → 20명 동시(15s) xychart-beta
title "다운로드 Throughput 비교 (MB/s)"
x-axis ["Before (avg)", "Before (med)", "After (avg)", "After (med)", "After (peak)"]
y-axis "MB/s" 0 --> 1200
bar [13.5, 13.7, 226, 182, 1110]
xychart-beta
title "다운로드 소요 시간 비교 (ms, 낮을수록 좋음)"
x-axis ["Before avg", "Before med", "After avg", "After med"]
y-axis "ms" 0 --> 800
bar [741, 727, 84, 55]
왜 이렇게 차이가 나는가graph LR
subgraph "Before: limit_rate 10m"
B1["sendfile zero-copy<br/>⏱️ 2-5μs"] --> B2["limit_rate 10m<br/>⏱️ 727ms/10MB<br/>🔴 병목"]
end
subgraph "After: X-Accel-Limit-Rate 0 (무제한)"
A1["sendfile zero-copy<br/>⏱️ 2-5μs"] --> A2["limit_rate 없음<br/>⏱️ 55ms/10MB<br/>🟢 zero-copy 최대 활용"]
end
style B2 fill:#f66,stroke:#333,color:#000
style A2 fill:#2d9,stroke:#333,color:#000
기존에는 확장성
// 예시: 사용자 등급별 속도 제어
if (user.isPremium()) return "0"; // 프리미엄: 무제한
if (activeDownloads > 3) return "5242880"; // 동시 3개 초과: 5MB/s
if (fileSize < 100MB) return "0"; // 소형 파일: 무제한
return "134217728"; // 기본: 128MB/s |
- Increase nginx limit_rate from 10MB/s to 128MB/s for upload/download - Add X-Accel-Limit-Rate header in FileController for per-request rate control - Files < 100MB: unlimited (maximize sendfile zero-copy) - Files >= 100MB: 128MB/s cap - Add k6 download throughput load test (1/5/20 concurrent users) - Load test results: 16.7x throughput improvement (13.5 → 226 MB/s avg) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
성능 개선 적용 및 부하테스트 결과개선 제안 중 성능 관련 3개 항목을 적용하고 부하테스트를 수행했습니다. 적용된 변경사항1.
|
| 지표 | 값 |
|---|---|
| throughput avg | 78 MB/s |
| throughput peak | 232 MB/s |
| download duration avg | 189ms |
| download duration p95 | 376ms |
| TTFB avg | 21ms |
| 에러율 | 0% |
| 총 전송량 | 22 GB (55초) |
| 총 요청 수 | 2,060건 |
업로드 (10MB 파일, 1→10 VU ramp-up)
| 지표 | 값 |
|---|---|
| init duration avg | 24ms |
| upload duration avg | 61ms |
| total duration avg | 91ms |
| upload p95 | 135ms |
| 처리량 | 6.99 req/s |
| 에러율 | 0% |
| 총 전송량 | 8.9 GB sent (2분) |
| 총 업로드 수 | 844건 |
모든 threshold 통과, 에러율 0%입니다.
- Add aio threads + directio 1m to /storage-internal/ for non-blocking concurrent large file downloads - Reduce keepalive_timeout from 65s to 30s to free FDs faster under high concurrency - Add error_page 413 with ErrorController for JSON response when nginx rejects oversized uploads before reaching Spring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
PR Learning-Is-Vital-In-Development#6 서버의 실제 API에 맞게 벤치마크를 재작성: - 2단계 업로드 (init + Nginx direct-to-disk) - ID 기반 폴더/파일 관리 (X-User-Id 헤더) - 6개 시나리오: Upload, Download, FolderList, MoveFolder, DeleteFiles, DeleteFolder Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Docker 네트워크 내 Nginx 컨테이너가 80 포트에서 listen하므로 기본 host를 nginx:8080 → my-storage-nginx:80 으로 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Docker 네트워크 내 Nginx 컨테이너가 80번 포트에서 listen하고 컨테이너 이름이 my-storage-nginx이므로 기본값 변경 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이전 커밋에서 파일이 잘못 덮어써진 것을 복구하고 기본 호스트를 nginx:8080 → my-storage-nginx:80 으로 수정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
Spring Boot 기반 파일/폴더 관리 REST API 서비스. Nginx와 Spring Boot의 역할 분리를 통해 파일 I/O 성능을 단계적으로 최적화했습니다.
최적화 결과 종합
다운로드 성능
업로드 성능 (10MB 파일)
네트워크 전송량 (HTML 응답)
/ui(HTML)단계별 최적화 과정
1단계: X-Accel-Redirect 도입
변경: Spring Boot가 파일 바이트를 직접 스트리밍 → Nginx
sendfile()zero-copy 전송으로 전환원리:
sendfile()은 디스크 → 커널 → 네트워크를 앱 메모리를 거치지 않고 직접 전달합니다 (CPU 복사 0회). Spring Boot는 인증과 헤더만 처리하고, 실제 파일 전송은 Nginx에 위임합니다.2단계: 대역폭 제한 및 gzip 압축
변경:
limit_rate 10m대역폭 제한 + gzip 압축 최적화원리: 파일 다운로드는
gzip off+sendfile on으로 zero-copy를 유지하면서, API/HTML 응답은 gzip level 6으로 압축하여 전송량 74% 절감.3단계: Nginx Direct-to-Disk 2-Stage Upload
변경: 클라이언트 → Spring Multipart → Nginx Direct-to-Disk 전환
원리: Nginx가
client_body_in_file_only on으로 파일 바이트를 JVM을 거치지 않고 디스크에 직접 기록합니다. Spring Boot는 메타데이터 검증과ATOMIC_MOVE만 수행합니다.결과: 응답시간 72% 감소, 처리량 113% 증가
4단계: X-Accel-Limit-Rate 동적 속도 제어
변경: 고정
limit_rate 10m→ 파일 크기 기반 동적 제어 (128MB/s 상한)원리: Spring Boot가
X-Accel-Limit-Rate헤더로 요청별 다운로드 속도를 nginx에 전달합니다. 100MB 미만 파일은 무제한(sendfile zero-copy 최대 활용), 100MB 이상은 128MB/s로 제한합니다.결과: throughput 16.7x 개선 (13.5 → 226 MB/s), 다운로드 시간 13.2x 단축
기술 스택
주요 설계 결정
gzip off+sendfile on→ zero-copy 유지 (바이너리 파일에 gzip은 효과 0~2%이면서 zero-copy를 깨뜨림)실행 방법
cd docker cp .env.example .env docker-compose up -dTest plan
🤖 Generated with Claude Code