File Upload

파일 업로드는 보안·안정성이 핵심입니다. Kosmos DSL로 단일/다중 업로드, 미리보기, 진행률 표시, 서버 검증/저장 전략까지 일관되게 구현합니다.

설계 원칙

  • 명확한 제한: 허용 확장자/콘텐츠 타입/최대 크기를 선명하게 안내하고 서버에서 강제합니다.
  • 안전한 저장: 업로드 파일은 웹 루트 밖 또는 안전 버킷에 저장하고, 원본 파일명은 신뢰하지 않습니다.
  • 점진적 향상: 기본 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 폼 통합