소스 정보
- 저장소
- puk0806/gugbab-claude
- 최근 소스 활동
- 2026년 6월 23일 04:41
- 감지된 SKILL.md 언어
- 한국어
- 스타
- 2
- 포크
- 0
설치 방법
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
소스 파일 검토
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
메뉴
기본적으로 소스를 먼저 확인하는 Prompt가 선택됩니다. 직접 명령으로 전환하거나 로컬 사본을 다운로드할 수도 있습니다.
설치 여부를 결정하기 전에 SKILL.md와 SkillsMP에 표시된 보조 파일을 읽어 보세요.
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
직접 명령은 검토 Prompt를 거치지 않습니다. 실행하기 전에 소스를 확인하세요.
npx skills add https://github.com/puk0806/gugbab-claude --skill multipart-upload명령은 한 줄로 유지됩니다. 복사하기 전에 가로로 스크롤해 전체 내용을 확인하세요.
로컬 사본을 원하시나요? SkillsMP에서 현재 제공할 수 있는 파일을 다운로드하세요.
SOC 직업 분류 기준
SKILL.md 표시 중
| 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!(, 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!(, content_type),
));
}
= Path::(&file_name)
.()
.(|e| e.())
.();
= [, , , ];
!allowed_ext.(&ext) {
((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
(, ext),
));
}
= field.().
.(|e| (StatusCode::BAD_REQUEST, e.()))?;
= (, file_name);
tokio::fs::(&save_path, &data).
.(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.()))?;
}
(.())
}
주의: 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( multipart: Multipart) <Json<serde_json::Value>, UploadError> {
: <> = [];
(field) = multipart.().
.(|e| UploadError::(e.()))?
{
= field.() {
(name) => name.(),
=> ,
};
= field.().
.(|e| UploadError::(e.()))?;
data.() > * * {
(UploadError::FileTooLarge {
max: * * ,
actual: data.(),
});
}
tokio::fs::((, file_name), &data)
.
.(UploadError::IoError)?;
files.(file_name);
}
((json!({ : 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", , , ];
= [];
(field) = multipart.().
.(|e| (StatusCode::BAD_REQUEST, (json!({: e.()}))))?
{
= field.() {
(name) => name.(),
=> ,
};
= Path::(&file_name)
.()
.(|e| e.())
.();
!allowed_ext.(&ext) {
((
StatusCode::UNSUPPORTED_MEDIA_TYPE,
(json!({: (, ext)})),
));
}
= field.().
.(|e| (StatusCode::BAD_REQUEST, (json!({: e.()}))))?;
= (, UPLOAD_DIR, file_name);
tokio::fs::(&path, &data).
.(|e| (StatusCode::INTERNAL_SERVER_ERROR, (json!({: e.()}))))?;
results.(json!({
: file_name,
: data.(),
}));
}
((json!({ : results })))
}
| 항목 | 값 |
|---|---|
| Extractor | axum::extract::Multipart |
| 기본 바디 크기 제한 | 2MB |
| 크기 제한 변경 | DefaultBodyLimit::max(bytes) |
| 파일 판별 | field.file_name().is_some() |
| 한 번에 읽기 | field.bytes().await |
| 스트리밍 읽기 | field.chunk().await (반복) |
| 필수 의존성 | axum, tokio (full feature) |