원클릭으로
multipart-upload
Axum Multipart 파일 업로드 처리 — 필드 구분, 바이트 읽기, 파일 타입별 처리, 크기 제한, 에러 처리
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
Axum Multipart 파일 업로드 처리 — 필드 구분, 바이트 읽기, 파일 타입별 처리, 크기 제한, 에러 처리
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Spring Security 5.5.x + jjwt 0.10.7 레거시 JWT 인증 - WebSecurityConfigurerAdapter, OncePerRequestFilter, javax.servlet 환경
Spring Boot 3.x + Spring Security 6.x + jjwt 0.12.x 기반 모던 JWT 인증 패턴. SecurityFilterChain Bean, 람다 DSL, jakarta.servlet, Virtual Threads 적용
Unity 6 LTS 2D 모바일 게임용 uGUI 시스템 전문 스킬. Canvas/RectTransform/TextMeshPro, 모바일 UI 패턴(팝업·무한 스크롤·광고·IAP), 성능 최적화, UI Toolkit과의 선택 기준 포함.
아크라시아(akrasia, 자제력 없음) 학술 논쟁의 핵심 구도와 주요 연구자·문헌을 빠르게 파악할 수 있는 도메인 지식 스킬. 도덕윤리교육 전공 대학원생(석/박사)이 학위논문·KCI 투고·세미나 준비 시 고대–현대–한국 학계–도덕심리학 흐름을 한 번에 짚도록 구성. <example>사용자: "아리스토텔레스의 propeteia와 astheneia 구분을 인용하려는데 출처를 알려줘"</example> <example>사용자: "데이비슨이 의지박약을 어떻게 가능하다고 봤는지 핵심 논증을 정리해줘"</example> <example>사용자: "한국 도덕교육 학계에서 아크라시아 다룬 논문 있어?"</example>
아리스토텔레스 『니코마코스 윤리학』에서 akrasia(자제력없음)와 akolasia(무절제)의 5축 차이를 정밀하게 정리한 학위논문 자료 스킬. NE VII.4 1147b20-1148b14, VII.8 1150b29-1151a28, III.10-12 1117b23-1119b18 절별 분해와 표준 학자 해석(Bostock, Broadie-Rowe, Pakaluk, Hursthouse, Charles 등)을 포함. 도덕교육 적용을 위한 두 상태 차이의 함의 및 한국어 번역어 처리 권장안 제공. <example>사용자: "akrates와 akolastos를 prohairesis 측면에서 어떻게 구분해야 하나요?"</example> <example>사용자: "NE VII.4의 ἁπλῶς akrasia가 akolasia와 어떻게 갈라지는지 절별 분해해주세요"</example> <example>사용자: "Hursthouse의 연속체 모델을 도덕교육 적용 절에서 어떻게 활용할 수 있나요?"</example>
한국 위기 대응 자원(자살·자해·정신건강·여성·청소년·노인·다문화) 핫라인과 앱·챗봇 안전 가드 응답 패턴 종합. 꿈 해몽·정신건강 앱 등 자가 진단/감정 콘텐츠 도메인에서 위험 신호 포착 시 안전한 자원 안내 문구를 작성할 때 참조. <example>사용자: "꿈 해몽 앱에 위기 안내 문구를 어떻게 넣을까?"</example> <example>사용자: "한국에서 자살예방 핫라인 번호가 어떻게 바뀌었지?"</example> <example>사용자: "정신건강 챗봇 안전 가드 응답 템플릿을 짜줘"</example>
| name | multipart-upload |
| description | Axum Multipart 파일 업로드 처리 — 필드 구분, 바이트 읽기, 파일 타입별 처리, 크기 제한, 에러 처리 |
소스: https://docs.rs/axum/latest/axum/extract/struct.Multipart.html 소스: https://docs.rs/axum/latest/axum/extract/struct.DefaultBodyLimit.html 검증일: 2026-06-20
주의: axum 0.8.x 기준입니다.
Multipart는 기본 feature가 아니므로Cargo.toml에 반드시 명시해야 합니다.
axum = { version = "0.8", features = ["multipart"] }
axum::extract::Multipart는 multipart/form-data 요청 바디를 파싱하는 extractor이다.
use axum::{
extract::Multipart,
routing::post,
Router,
};
async fn upload(mut multipart: Multipart) {
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap_or("unknown").to_string();
let data = field.bytes().await.unwrap();
println!("Field `{}`: {} bytes", name, data.len());
}
}
let app = Router::new().route("/upload", post(upload));
핵심 규칙:
Multipart는 mut으로 받아야 한다 (내부 상태가 변경됨)next_field()는 Option<Field>를 반환 — None이면 모든 필드 소진Field는 한 번만 소비 가능 (bytes/text/chunk 중 하나만 호출)Field 구조체의 메서드로 필드 종류를 판별한다.
| 메서드 | 반환 타입 | 설명 |
|---|---|---|
name() | Option<&str> | 폼 필드 이름 (<input name="...">) |
file_name() | Option<&str> | 파일명 (파일 필드에만 존재) |
content_type() | Option<&str> | MIME 타입 (파일 필드에만 존재) |
text() | Result<String, Error> | 필드를 문자열로 소비 |
bytes() | Result<Bytes, Error> | 필드를 바이트로 소비 |
chunk() | Result<Option<Bytes>, Error> | 스트리밍 방식으로 청크 단위 읽기 |
async fn upload(mut multipart: Multipart) {
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap_or("unknown").to_string();
if let Some(file_name) = field.file_name() {
// 파일 필드
let file_name = file_name.to_string();
let content_type = field.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let data = field.bytes().await.unwrap();
println!("File: {} ({}, {} bytes)", file_name, content_type, data.len());
} else {
// 텍스트 필드
let value = field.text().await.unwrap();
println!("Text field `{}`: {}", name, value);
}
}
}
판별 기준: file_name()이 Some이면 파일, None이면 텍스트 필드이다.
let data: Bytes = field.bytes().await?;
메모리를 절약하려면 chunk()로 청크 단위 처리한다.
use tokio::io::AsyncWriteExt;
use tokio::fs::File;
async fn stream_to_file(field: &mut axum::extract::multipart::Field<'_>, path: &str) -> Result<u64, std::io::Error> {
let mut file = File::create(path).await?;
let mut total: u64 = 0;
while let Some(chunk) = field.chunk().await.map_err(|e| {
std::io::Error::new(std::io::ErrorKind::Other, e)
})? {
file.write_all(&chunk).await?;
total += chunk.len() as u64;
}
file.flush().await?;
Ok(total)
}
주의:
chunk()기반 스트리밍은Field의&mut참조가 필요합니다. 소유권 이동 후에는 사용할 수 없습니다.
MIME 타입 또는 확장자로 파일 종류를 구분한다.
| 파일 타입 | Content-Type | 확장자 |
|---|---|---|
application/pdf | .pdf | |
| Plain Text | text/plain | .txt |
| Word (docx) | application/vnd.openxmlformats-officedocument.wordprocessingml.document | .docx |
| Word (doc) | application/msword | .doc |
use std::path::Path;
async fn handle_upload(mut multipart: Multipart) -> Result<String, (StatusCode, String)> {
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let Some(file_name) = field.file_name().map(|s| s.to_string()) else {
continue; // 텍스트 필드는 건너뜀
};
let content_type = field.content_type()
.unwrap_or("application/octet-stream")
.to_string();
// MIME 타입 기반 허용 목록
let allowed = [
"application/pdf",
"text/plain",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/msword",
];
if !allowed.contains(&content_type.as_str()) {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("Unsupported file type: {}", content_type),
));
}
// 확장자 이중 검증 (클라이언트가 Content-Type을 위조할 수 있음)
let ext = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let allowed_ext = ["pdf", "txt", "docx", "doc"];
if !allowed_ext.contains(&ext) {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("Unsupported extension: .{}", ext),
));
}
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
// 파일 저장 또는 처리
let save_path = format!("./uploads/{}", file_name);
tokio::fs::write(&save_path, &data).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
}
Ok("Upload complete".to_string())
}
주의: Content-Type은 클라이언트가 임의로 설정할 수 있으므로, 확장자 검증과 매직 바이트 검증을 병행하는 것을 권장합니다.
Axum은 기본적으로 요청 바디 크기를 2MB로 제한한다.
use axum::extract::DefaultBodyLimit;
// 라우터 전체에 적용
let app = Router::new()
.route("/upload", post(upload))
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)); // 10MB
// 특정 라우트에만 적용
let app = Router::new()
.route("/upload", post(upload))
.route_layer(DefaultBodyLimit::max(50 * 1024 * 1024)); // 50MB
// 제한 해제 (권장하지 않음)
let app = Router::new()
.route("/upload", post(upload))
.layer(DefaultBodyLimit::disable());
use tower_http::limit::RequestBodyLimitLayer;
let app = Router::new()
.route("/upload", post(upload))
.layer(RequestBodyLimitLayer::new(10 * 1024 * 1024));
DefaultBodyLimit vs RequestBodyLimitLayer:
DefaultBodyLimit는 Axum 내장으로 Multipart, Json, Bytes 등 모든 extractor에 적용RequestBodyLimitLayer는 Tower 미들웨어로 http_body_util::Limited를 사용DefaultBodyLimit가 우선async fn upload(mut multipart: Multipart) -> Result<String, (StatusCode, String)> {
const MAX_FILE_SIZE: usize = 10 * 1024 * 1024; // 10MB
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
{
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
if data.len() > MAX_FILE_SIZE {
return Err((
StatusCode::PAYLOAD_TOO_LARGE,
"File exceeds 10MB limit".to_string(),
));
}
}
Ok("OK".to_string())
}
주의:
MultipartError는multer::Error를 래핑한 단일 구조체로 공개 variant가 없다. 에러는.to_string()으로 메시지 처리하거나, extractor 레벨은MultipartRejection으로 구분한다.
| 상황 | 처리 방법 |
|---|---|
| Content-Type이 multipart가 아님 | MultipartRejection::InvalidBoundary (extractor 레벨) |
| 바디 크기 초과 | 연결 종료 또는 .to_string()으로 확인 |
| 필드 읽기 실패 | .map_err(|e| e.to_string()) |
| 불완전한 multipart 데이터 | .to_string()으로 에러 메시지 처리 |
use axum::{
extract::Multipart,
http::StatusCode,
response::IntoResponse,
Json,
};
use serde_json::json;
enum UploadError {
MultipartError(String),
FileTooLarge { max: usize, actual: usize },
UnsupportedType(String),
IoError(std::io::Error),
}
impl IntoResponse for UploadError {
fn into_response(self) -> axum::response::Response {
let (status, message) = match self {
Self::MultipartError(msg) => (StatusCode::BAD_REQUEST, msg),
Self::FileTooLarge { max, actual } => (
StatusCode::PAYLOAD_TOO_LARGE,
format!("File size {}B exceeds limit {}B", actual, max),
),
Self::UnsupportedType(t) => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
format!("Unsupported: {}", t),
),
Self::IoError(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("IO error: {}", e),
),
};
(status, Json(json!({ "error": message }))).into_response()
}
}
async fn upload(mut multipart: Multipart) -> Result<Json<serde_json::Value>, UploadError> {
let mut files: Vec<String> = vec![];
while let Some(field) = multipart.next_field().await
.map_err(|e| UploadError::MultipartError(e.to_string()))?
{
let file_name = match field.file_name() {
Some(name) => name.to_string(),
None => continue,
};
let data = field.bytes().await
.map_err(|e| UploadError::MultipartError(e.to_string()))?;
if data.len() > 10 * 1024 * 1024 {
return Err(UploadError::FileTooLarge {
max: 10 * 1024 * 1024,
actual: data.len(),
});
}
tokio::fs::write(format!("./uploads/{}", file_name), &data)
.await
.map_err(UploadError::IoError)?;
files.push(file_name);
}
Ok(Json(json!({ "uploaded": files })))
}
use axum::{
extract::{DefaultBodyLimit, Multipart},
http::StatusCode,
response::Json,
routing::post,
Router,
};
use serde_json::{json, Value};
use std::path::Path;
use tokio::net::TcpListener;
const MAX_BODY_SIZE: usize = 50 * 1024 * 1024; // 50MB
const UPLOAD_DIR: &str = "./uploads";
#[tokio::main]
async fn main() {
tokio::fs::create_dir_all(UPLOAD_DIR).await.unwrap();
let app = Router::new()
.route("/upload", post(handle_upload))
.layer(DefaultBodyLimit::max(MAX_BODY_SIZE));
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn handle_upload(
mut multipart: Multipart,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let allowed_ext = ["pdf", "txt", "docx", "doc"];
let mut results = vec![];
while let Some(field) = multipart.next_field().await
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?
{
let file_name = match field.file_name() {
Some(name) => name.to_string(),
None => continue, // 텍스트 필드 스킵
};
let ext = Path::new(&file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
if !allowed_ext.contains(&ext) {
return Err((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
Json(json!({"error": format!("Unsupported: .{}", ext)})),
));
}
let data = field.bytes().await
.map_err(|e| (StatusCode::BAD_REQUEST, Json(json!({"error": e.to_string()}))))?;
let path = format!("{}/{}", UPLOAD_DIR, file_name);
tokio::fs::write(&path, &data).await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))))?;
results.push(json!({
"name": file_name,
"size": data.len(),
}));
}
Ok(Json(json!({ "uploaded": results })))
}
| 항목 | 값 |
|---|---|
| Extractor | axum::extract::Multipart |
| 기본 바디 크기 제한 | 2MB |
| 크기 제한 변경 | DefaultBodyLimit::max(bytes) |
| 파일 판별 | field.file_name().is_some() |
| 한 번에 읽기 | field.bytes().await |
| 스트리밍 읽기 | field.chunk().await (반복) |
| 필수 의존성 | axum, tokio (full feature) |