File Upload
파일 업로드는 보안·안정성이 핵심입니다. Kosmos DSL로 단일/다중 업로드, 미리보기, 진행률 표시, 서버 검증/저장 전략까지 일관되게 구현합니다.
중요: 폼에
enctype="multipart/form-data"를 반드시 지정해야 합니다.설계 원칙
- 명확한 제한: 허용 확장자/콘텐츠 타입/최대 크기를 선명하게 안내하고 서버에서 강제합니다.
- 안전한 저장: 업로드 파일은 웹 루트 밖 또는 안전 버킷에 저장하고, 원본 파일명은 신뢰하지 않습니다.
- 점진적 향상: 기본 input[type=file]로 동작 → 선택적으로 드래그앤드롭/미리보기/진행률을 추가합니다.
코드 심화: 기본 파일 입력 컴포넌트
public class CompFileInput implements HtmlComponent {
private final String id; // 필드명과 동일하게 사용 권장
private final boolean multiple;
private final String accept; // "image/*,.pdf"
private final String help; // 안내 문구
private final String error; // 서버 검증 에러
public CompFileInput(String id, boolean multiple, String accept, String help, String error) {
this.id = id; this.multiple = multiple; this.accept = accept; this.help = help; this.error = error;
}
@Override public String render(RenderContext ctx) {
var group = El.div().css("mb-3");
group.child(El.label().css("form-label").attr("for", id).text("파일 업로드"));
var input = El.input().css("form-control");
input.attr("type","file").attr("id", id).attr("name", id + (multiple ? "[]" : ""));
if (multiple) input.attr("multiple","multiple");
if (accept != null && !accept.isBlank()) input.attr("accept", accept);
if (error != null && !error.isBlank()) input.css(" is-invalid");
group.child(input);
if (help != null && !help.isBlank()) {
group.child(El.div().css("form-text").text(help));
}
if (error != null && !error.isBlank()) {
group.child(El.div().css("invalid-feedback d-block").text(error));
}
return group.render(ctx);
}
}
폼 예시: 단일/다중 업로드
// 단일 업로드
El.form().attr("method","POST").attr("action","/files/upload")
.attr("enctype","multipart/form-data")
.children(
new CompFileInput("file", false, ".pdf,.docx,image/*", "최대 10MB, PDF/이미지 허용", null),
El.button().css("btn btn-primary mt-2").attr("type","submit").text("업로드")
);
// 다중 업로드
El.form().attr("method","POST").attr("action","/files/upload-multi")
.attr("enctype","multipart/form-data")
.children(
new CompFileInput("files", true, "image/*", "최대 5개, 이미지 전용, 파일당 5MB", null),
El.button().css("btn btn-primary mt-2").attr("type","submit").text("업로드")
);
서버: DTO & Controller
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.*;
public class FileUploadForm {
@NotNull(message = "파일을 선택하세요.")
private MultipartFile file; // 단일
// 게터/세터...
}
public class MultiUploadForm {
@Size(min = 1, max = 5, message = "최소 1개, 최대 5개 업로드")
private List<MultipartFile> files; // 다중
// 게터/세터...
}
@Controller
public class FileController {
@PostMapping("/files/upload")
public String upload(@Valid @ModelAttribute("form") FileUploadForm form,
BindingResult br, Model model) throws IOException {
MultipartFile f = form.getFile();
if (f != null) {
// 1) 크기 제한
if (f.getSize() > 10 * 1024 * 1024) { // 10MB
br.rejectValue("file", "size.exceeded", "최대 10MB까지 업로드 가능합니다.");
}
// 2) 콘텐츠 타입 화이트리스트
String ct = Optional.ofNullable(f.getContentType()).orElse("");
if (!List.of("application/pdf","image/png","image/jpeg","image/gif").contains(ct)) {
br.rejectValue("file", "type.invalid", "PDF/PNG/JPEG/GIF만 허용됩니다.");
}
}
if (br.hasErrors()) {
model.addAttribute("error", br.getFieldError("file") != null
? br.getFieldError("file").getDefaultMessage() : null);
return "files/form";
}
// 저장 (파일명 sanitize + 랜덤 이름)
String saved = storageService.save(f); // 구현은 아래 참조
model.addAttribute("path", saved);
return "redirect:/files/success";
}
}
스토리지 전략(로컬/원격)
- 로컬: 웹 루트 밖(예:
/var/app/uploads)에 저장하고, 서빙은 컨트롤러 통해서 처리 - 원격 버킷: S3/GCS 등 “버킷 경로 + 임의 파일명(UUID)”로 저장, 직접 공개 URL 노출 전 점검
- 메타데이터: DB에 id, originalName, savedName, contentType, size, owner, createdAt 저장
public interface StorageService {
String save(MultipartFile file) throws IOException;
Resource loadAsResource(String path);
}
@Service
public class LocalStorageService implements StorageService {
private final Path root = Paths.get("/var/app/uploads");
@Override public String save(MultipartFile file) throws IOException {
Files.createDirectories(root);
String ext = Optional.ofNullable(file.getOriginalFilename())
.map(n -> n.contains(".") ? n.substring(n.lastIndexOf(".")) : "")
.orElse("");
String saved = UUID.randomUUID() + ext.toLowerCase(Locale.ROOT);
try (InputStream in = file.getInputStream()) {
Files.copy(in, root.resolve(saved), StandardCopyOption.REPLACE_EXISTING);
}
return saved; // DB에는 saved와 originalName 등 함께 기록
}
@Override public Resource loadAsResource(String path) {
try {
Path p = root.resolve(path).normalize();
return new UrlResource(p.toUri());
} catch (Exception e) {
throw new RuntimeException("파일을 찾을 수 없습니다.");
}
}
}
드래그앤드롭/미리보기/진행률
아래 스크립트는 선택적 예시입니다. 기본 제출만으로도 동작합니다.
드래그앤드롭 영역(이미지 미리보기)
이곳에 파일을 끌어놓거나 클릭하세요
XHR 진행률 표시 (단일 파일)
제한/정책 표 (권장 기본값)
| 항목 | 권장 값 | 설명 |
|---|---|---|
| 최대 파일 크기 | 10MB(단일) / 5MB×5(다중) | 서버/리버스 프록시 설정과 일치 |
| 허용 타입 | PDF, PNG, JPEG, GIF | 화이트리스트 기반 |
| 저장 위치 | /var/app/uploads | 웹 루트 외부, 컨트롤러 통해 서빙 |
| 파일명 전략 | UUID + 원본 확장자 | 원본명은 참고용으로만 |
| 바이러스 검사 | 선택(업무 중요도에 따라) | 클램AV 등 외부 스캐너 연동 |
접근성 & 보안 체크리스트
접근성
- 레이블: 파일 입력에 명확한 레이블을 제공합니다.
- 키보드: 드래그앤드롭만 의존하지 말고 버튼으로도 선택 가능하게 합니다.
- 상태 안내: 진행률/오류 메시지는 텍스트로도 제공합니다.
보안
- 경로 탐 traversal 방지: 저장 시 경로 조작을 무시하고 내부 이름을 사용합니다.
- 타입 이중 확인: Content-Type뿐 아니라 시그니처(Magic Number)를 점검합니다(가능 시).
- 직접 서빙 금지: 업로드 디렉터리를 정적루트로 노출하지 않습니다.
운영 팁
- 만료 정책: 임시 파일/미사용 파일을 정기적으로 청소하는 배치를 둡니다.
- 감사 로그: 업로드/다운로드/삭제 이벤트를 기록합니다.
- 썸네일: 이미지의 경우 서버에서 안전한 썸네일을 별도 생성해 사용합니다.
다음으로
- Validation — 크기/타입/개수 검증과 오류 피드백
- Input Types — 폼 컨트롤 전반과 일관된 패턴
- Alerts — 업로드 성공/실패 메시지 UX
- GenericCrudController — 업로드 필드가 포함된 CRUD 폼 통합