Ian's Archive 🏃🏻

thumbnail
Spring Boot와 Uppy로 구현하는 대용량 파일 업로드
JAVA
2025.07.11.

“대용량 파일 업로드, 어떻게 처리하고 계신가요?”

데이터 플랫폼이나 콘텐츠 관리 시스템을 개발하다 보면 GB 단위의 대용량 파일을 안정적으로 업로드해야 하는 요구사항을 마주한다. 일반적인 HTTP POST 요청은 파일이 크거나 네트워크가 불안정할 경우 연결이 끊기면 처음부터 다시 시작해야 하는 치명적인 단점이 있다.

이 문제를 해결하기 위해 다음과 같은 요구사항을 정의했다.

  • Next.js 프론트엔드에서 파일 업로드 기능 제공
  • 수백 MB 이상의 대용량 파일도 청크(Chunk) 단위로 분할하여 안정적으로 업로드
  • 네트워크 문제로 업로드가 중단되더라도, 재시도 시 중단된 지점부터 이어서 업로드 가능

이 글에서는 위 요구사항을 만족시키기 위해 Uppy 라이브러리와 TUS(The Upload Server) 프로토콜을 선택한 이유와 이를 Spring Boot 백엔드와 연동하여 안정적인 대용량 파일 업로드 시스템을 구축한 과정을 공유한다.

1. 왜 Uppy를 선택했는가?

React/Next.js 환경에서 파일 업로드를 구현하기 위해 여러 라이브러리를 검토했다.

  • react-dropzone: 가볍고 UI 자유도가 매우 높지만, 파일 전송 로직은 직접 구현해야 한다.
  • FilePond: 세련된 UI와 이미지 편집 등 다양한 플러그인을 제공한다.
  • Uppy: 강력한 기능과 플러그인 생태계를 갖춘 올인원 파일 업로더이다.

고민 끝에 Uppy를 최종 선택했으며, 그 이유는 다음과 같다.

  • 완성도 높은 대시보드 UI: 파일 드래그 앤 드롭, 파일 목록, 이미지 미리보기, 업로드 진행률 표시, 오류 메시지까지 포함된 대시보드를 기본으로 제공하여 개발 시간을 단축할 수 있다.
  • TUS 프로토콜의 손쉬운 적용: 대용량 파일을 위한 청크(Chunking) 업로드와 중단 시 이어 올리기(Resumable Upload) 기능을 Tus 플러그인 하나로 간편하게 적용할 수 있다.
  • 유연한 모듈식 구조: @uppy/core를 중심으로 필요한 기능(ex: @uppy/drag-drop, @uppy/progress-bar)만 선택적으로 가져와 사용할 수 있어 번들 크기를 최적화할 수 있다.

물론 단점도 존재한다. 제공되는 UI가 강력한 만큼, 회사의 고유 디자인 시스템에 맞춰 세세한 커스터마이징이 필요할 경우 react-dropzone보다 작업이 복잡할 수 있다는 점이다.

하지만 react-dropzone은 대용량 파일 처리를 위한 청크 및 이어 올리기 로직을 직접 구현하거나 tus-js-client 같은 별도 라이브러리를 조합해야 하는 부담이 있었다. 개발 효율성과 기능적 안정성을 더 중요하게 고려하여 Uppy를 도입하기로 결정했다.

2. Uppy 연동

useEffect 훅을 사용하여 컴포넌트가 마운트될 때 Uppy 인스턴스를 설정하고, 상태가 변경될 때 옵션을 동적으로 조절하도록 구현했다.

copyButtonText
const uppyRef = useRef<Uppy | null>(null);

useEffect(() => {
    // 연계 유형이 'file'일 때만 Uppy 로직을 실행
    if (formData.clctLinkType === 'file') {
      const isShp = formData.fileExtension === 'shp';

      if (!uppyRef.current) {
        const uppyInstance = new Uppy({
          debug: process.env.NODE_ENV === 'development',
          autoProceed: true,
          restrictions: {
            maxNumberOfFiles: 5,
            allowedFileTypes: [`.${formData.fileExtension}`],
          },
        });

        // 파일 추가 시 메타데이터 설정
        uppyInstance.on('file-added', file => {
          uppyInstance.setFileMeta(file.id, {
            filename: file.name,
            filetype: file.type || 'application/octet-stream',
          });
        });

        // Tus 플러그인 설정
        uppyInstance.use(Tus, {
          endpoint: `${process.env.NEXT_PUBLIC_CLCT_URL}/api/tus/upload`,
          // 병렬 업로드 개수 설정
          parallelUploads: 3,
          retryDelays: [0, 1000, 3000, 5000],
          // 청크 크기 설정
          chunkSize: 100 * 1024 * 1024,
          // 동시에 처리할 개수
          limit: 5
        });

        // Dashboard UI 플러그인 설정
        uppyInstance.use(DashboardPlugin, {
          id: DASHBOARD_ID,
          inline: true,
          target: `#${DASHBOARD_TARGET_ID}`,
          proudlyDisplayPoweredByUppy: false,
          theme: 'light',
          height: 250,
        });

        uppyInstance.on('upload-success', (file, response) => {
          // 파일 객체나 응답, 특히 uploadURL이 없으면 중단
          if (!file || !response?.uploadURL) {
            toast.error('업로드 응답이 올바르지 않습니다.');
            return;
          }
          const uploadURL = response.uploadURL;

          console.log('TUS upload successful. Finalizing on server...');
          console.log('Upload URL:', uploadURL);

          // 서버의 후처리 API(/api/files/finalize)를 호출
          fetch(`${process.env.NEXT_PUBLIC_CLCT_URL}/api/files/finalize`, {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
            },
            body: JSON.stringify({
              uploadUrl: uploadURL,
            }),
          })
            .then(res => {
              if (!res.ok) {
                return res.text().then(text => {
                  throw new Error(text || '서버 파일 처리 중 오류 발생');
                });
              }
              // res.json()이 Promise<any>를 반환하므로 타입을 지정
              return res.json() as Promise<FinalizeResponse>;
            })
            .then(data => {
              console.log('Server finalization response:', data);

              // 스토어에 추가할 때 고유 파일명도 함께 저장
              addUploadedFile({
                id: file.id,
                name: data.originalFilename, // UI 표시용 원본 이름
                uniqueName: data.uniqueFilename, // 서버 파일 관리를 위한 고유 이름
                extension: formData.fileExtension,
                uploadURL: uploadURL,
              });

              toast.success(
                `'${data.originalFilename}' 파일이 성공적으로 처리되었습니다.`,
              );
            })
            .catch(error => {
              console.error('Failed to finalize upload on server:', error);
              toast.error(`파일 후처리 실패: ${error.message}`);
              // 실패했으므로 Uppy에서 해당 파일을 제거하여 다시 업로드할 수 있도록 유도
              uppyRef.current?.removeFile(file.id);
            });
        });

        uppyRef.current = uppyInstance;
      }

      uppyRef.current?.setOptions({
        restrictions: {
          maxNumberOfFiles: isShp ? 0 : 1, // shp는 여러 파일, 나머지는 1개
          allowedFileTypes: isShp
            ? SHAPEFILE_EXTENSIONS
            : [`.${formData.fileExtension}`],
        },
      });

      // Dashboard 노트 텍스트 업데이트 (타입 캐스팅 없이)
      const dashboard = uppyRef.current?.getPlugin(DASHBOARD_ID);
      // 'setOptions' 메서드가 있는지 확인하여 안전하게 호출
      if (dashboard && typeof (dashboard as any).setOptions === 'function') {
        (dashboard as any).setOptions({
          note: isShp
            ? 'Shapefile을 구성하는 모든 파일(.shp, .shx, .dbf 등)을 함께 선택해주세요.'
            : `선택한 확장자(.${formData.fileExtension}) 파일 1개만 업로드 가능합니다.`,
        });
      }
    }

    // Cleanup 함수
    return () => {
      if (uppyRef.current) {
        uppyRef.current.destroy();
        uppyRef.current = null;
      }
    };
  }, [
    formData.clctLinkType,
    formData.fileExtension,
    addUploadedFile,
    removeUploadedFile,
  ]);

  ...

  <div className={styles.formRow}>
    <div className={styles.formFullColumn}>
      <label className={styles.formLabel}>파일 업로드</label>
      <div id={DASHBOARD_TARGET_ID} />
    </div>
  </div>

위 코드에서 div 태그에 id를 부여하고, Uppy의 DashboardPlugin의 target 옵션에 해당 id를 연결하면 지정된 위치에 업로드 UI가 렌더링된다.

3. tus란?

이어 올리기를 위한 개방형 프로토콜이다. HTTP기반으로 동작하며, 클라이언트와 서버가 파일 업로드 상태를 계속 추적하여 네트워크가 끊기거나 브라우저가 닫혀도 이전에 업로드 된 지점부터 다시 시작할 수 있게 해준다.

주요 특징:

  • 이어 올리기: TUS의 핵심 기능으로, 업로드 중단 시 손실을 최소화한다.
  • 표준 프로토콜: HTTP 헤더 설정, 파일 병합, 임시 파일 관리 등 업로드 절차가 명확히 정의되어 있다.
  • 다양한 서버/클라이언트 구현체: Java, Go, Node.js, Python 등 다양한 언어로 구현된 서버가 있으며, tus-js-client(Uppy가 내부적으로 사용) 등 클라이언트 라이브러리도 풍부하다.
  • 확장 기능: 병렬 업로드, MD5 체크섬 비교, 업로드 만료 시간 설정 등 다양한 부가 기능을 지원한다.

4. TUS의 작동 방식

TUS는 주로 HEAD, POST, PATCH HTTP 메서드를 사용하여 업로드를 관리한다.

  1. (선택적) HEAD 요청: 클라이언트는 파일 업로드를 시작하기 전에 서버에 HEAD 요청을 보내 이미 해당 파일의 일부가 업로드되어 있는지 확인하고, 있다면 어느 지점(Upload-Offset)까지 업로드되었는지 파악한다. (처음 시작 시 404에러)

  2. POST 요청: 새로운 파일을 업로드하거나 HEAD 요청 결과 파일 정보가 없다면, 클라이언트는 POST 요청을 보낸다. 이때 Upload-Length 헤더에 전체 파일 크기를 담아 보낸다. 서버는 이 요청을 받고 파일을 저장할 고유한 URL을 생성하여 Location 헤더에 담아 응답한다.

  3. PATCH 요청: 클라이언트는 파일 데이터를 청크 단위로 나누어 PATCH 요청을 통해 서버에 전송한다. 각 요청에는 현재 데이터의 시작 위치를 나타내는 Upload-Offset 헤더와 데이터 타입을 명시하는 Content-Type: application/offset+octet-stream 헤더가 포함된다. 서버는 받은 청크를 임시 파일에 이어 붙이고, 현재까지 받은 총 바이트 수를 Upload-Offset 헤더에 담아 응답한다.

  4. 업로드 완료: 클라이언트가 보낸 Upload-Offset과 서버가 알고 있는 전체 파일 크기(Upload-Length)가 일치하면 업로드가 완료된다.

업로드 상태(URL, 오프셋 등)는 브라우저의 로컬 스토리지에 저장하여 페이지를 새로고침해도 상태를 유지한다.

1

5. Spring Boot 백엔드 구현

이제 Uppy 클라이언트와 통신할 Spring Boot 서버를 구성한다. tus-java-server 라이브러리를 활용하면 TUS 프로토콜을 손쉽게 구현할 수 있다.

  1. 의존성 추가
copyButtonText
implementation 'me.desair.tus:tus-java-server:1.0.0-3.0'
  1. Tus Service 설정(Configuration)

TUS 서버의 핵심 설정을 구성한다. 임시 파일 저장 경로, 업로드 URL, 임시 파일 만료 시간 등을 정의한다.

copyButtonText
@Configuration
public class TusConfig {

    private static final Logger log = LoggerFactory.getLogger(TusConfig.class);

    @Value("${tus.server.data-path}")
    private String tusDataPath;

    @Value("${server.servlet.context-path}")
    private String contextPath;

    @Bean
    public TusFileUploadService tusFileUploadService() throws IOException {
        Path storagePath = Paths.get(tusDataPath);
        if (Files.notExists(storagePath)) {
            Files.createDirectories(storagePath);
            log.info("Tus storage directory created at: {}", storagePath);
        }

        final String uploadUri = contextPath + "/api/tus/upload";
        log.info("Tus Upload URI configured to: {}", uploadUri);
        log.info("Tus temporary data path: {}", tusDataPath);

        return new TusFileUploadService()
                .withStoragePath(tusDataPath)
                .withUploadUri(uploadUri)
                .withUploadExpirationPeriod(24 * 60 * 60 * 1000L);
    }
}
  1. controller 구현

TUS 프로토콜 처리는 두 단계로 나뉜다.

  • TusUploadController: TUS 프로토콜의 모든 HTTP 요청(POST, PATCH, HEAD 등)을 받아 TusFileUploadService에 위임한다. 이 컨트롤러는 순수하게 프로토콜 처리만 담당한다.
  • FileProcessingController: TUS 업로드가 100% 완료된 후, 클라이언트가 호출하는 별도의 API이다. 임시 파일을 영구 저장소로 옮기고, 파일 정보를 DB에 저장하는 등 비즈니스 로직을 수행한다.
copyButtonText
@RestController
@RequestMapping("/api/files")
@CrossOrigin
public class FileProcessingController {

    private static final Logger log = LoggerFactory.getLogger(FileProcessingController.class);

    private final TusFileUploadService tusFileUploadService;
    private final Path permanentStoragePath;

    public FileProcessingController(TusFileUploadService tusFileUploadService,
                                    @Value("${file.permanent-storage-path}") String permanentStoragePathStr) throws IOException {
        this.tusFileUploadService = tusFileUploadService;
        this.permanentStoragePath = Paths.get(permanentStoragePathStr);
        // 영구 저장소 디렉토리가 없으면 생성
        if (Files.notExists(this.permanentStoragePath)) {
            Files.createDirectories(this.permanentStoragePath);
            log.info("Permanent storage directory created at: {}", this.permanentStoragePath);
        }
    }

    /**
     * TUS 업로드가 완료된 후 클라이언트가 호출하는 후처리 API
     * @param payload 클라이언트가 보낸 JSON. {"uploadUrl": "..."} 형태여야 함
     * @return 처리 결과
     */
    @PostMapping("/finalize")
    public ResponseEntity<Map<String, String>> finalizeUpload(@RequestBody Map<String, String> payload) {
        String uploadUrl = payload.get("uploadUrl");
        if (uploadUrl == null || uploadUrl.isBlank()) {
            return ResponseEntity.badRequest().body(null);
        }

        try {
            UploadInfo uploadInfo = tusFileUploadService.getUploadInfo(uploadUrl);
            if (uploadInfo == null || uploadInfo.isUploadInProgress()) {
                return ResponseEntity.badRequest().body(null);
            }

            // 원본 파일명과 확장자 추출
            String originalFilename = uploadInfo.getMetadata().getOrDefault("filename", "untitled");
            String extension = "";
            int lastDot = originalFilename.lastIndexOf('.');
            if (lastDot > 0) {
                extension = originalFilename.substring(lastDot); // .csv, .txt 등
            }

            // UUID를 기반으로 새로운 고유 파일명 생성
            String uniqueFilename = UUID.randomUUID().toString() + extension;
            Path destinationFile = permanentStoragePath.resolve(uniqueFilename);

            log.info("Finalizing upload. Original: {}, Unique: {}, Destination: {}", originalFilename, uniqueFilename, destinationFile);

            // 고유 파일명으로 파일 저장
            try (InputStream uploadedBytes = tusFileUploadService.getUploadedBytes(uploadUrl)) {
                Files.copy(uploadedBytes, destinationFile, StandardCopyOption.REPLACE_EXISTING);
            }

            // 임시 파일 삭제
            tusFileUploadService.deleteUpload(uploadUrl);

            // 클라이언트에 원본 파일명과 고유 파일명을 모두 담아 JSON으로 반환
            Map<String, String> responseBody = new HashMap<>();
            responseBody.put("originalFilename", originalFilename);
            responseBody.put("uniqueFilename", uniqueFilename);

            return ResponseEntity.ok(responseBody);

        } catch (IOException | TusException e) {
            log.error("Failed to finalize upload for URL: {}", uploadUrl, e);
            return ResponseEntity.internalServerError().body(null);
        }
    }


    /**
     * 영구 저장소에 있는 파일을 삭제하는 API
     * @param uniqueFilename URL 경로를 통해 전달된, 고유 파일명
     * @return 삭제 처리 결과
     */
    @DeleteMapping("/{uniqueFilename}")
    public ResponseEntity<String> deleteFile(@PathVariable String uniqueFilename) {
        String decodedFilename;
        try {
            decodedFilename = URLDecoder.decode(uniqueFilename, StandardCharsets.UTF_8);
        } catch (IllegalArgumentException e) {
            log.warn("Failed to decode filename: {}", uniqueFilename, e);
            decodedFilename = uniqueFilename;
        }

        log.info("Received request to delete file: {}", decodedFilename);

        if (decodedFilename == null || decodedFilename.isBlank()) {
            return ResponseEntity.badRequest().body("Filename is missing.");
        }

        try {
            Path fileToDelete = permanentStoragePath.resolve(decodedFilename);

            // Path Traversal 공격을 방지하기 위한 기본적인 검증
            if (!fileToDelete.normalize().startsWith(permanentStoragePath.normalize())) {
                log.error("Path traversal attempt detected! Filename: {}", decodedFilename);
                return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid filename.");
            }

            // Files.deleteIfExists()는 파일이 없어도 예외를 던지지 않고 false를 반환
            boolean deleted = Files.deleteIfExists(fileToDelete);

            if (deleted) {
                log.info("Successfully deleted file from permanent storage: {}", fileToDelete);
                return ResponseEntity.ok("File deleted successfully: " + decodedFilename);
            } else {
                log.warn("File to delete was not found in permanent storage: {}", fileToDelete);
                return ResponseEntity.ok("File was not found, but considered deleted: " + decodedFilename);
            }

        } catch (IOException e) {
            log.error("Error deleting file: {}", decodedFilename, e);
            return ResponseEntity.internalServerError().body("Error occurred while deleting the file.");
        }
    }
}

@Controller
@CrossOrigin
@RequestMapping("/api/tus")
public class TusUploadController {

    private static final Logger log = LoggerFactory.getLogger(TusUploadController.class);
    private final TusFileUploadService tusFileUploadService;

    @Autowired
    public TusUploadController(TusFileUploadService tusFileUploadService) {
        this.tusFileUploadService = tusFileUploadService;
    }

    @RequestMapping(value = {"/upload", "/upload/**"}, method = {
            RequestMethod.POST, RequestMethod.PATCH, RequestMethod.HEAD, RequestMethod.DELETE, RequestMethod.OPTIONS
    })
    public void processTusUpload(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
        try {
            this.tusFileUploadService.process(servletRequest, servletResponse);
        } catch (IOException e) {
            log.error("Tus exception occurred: {}", e.getMessage());
        }
    }
}

간략하게 내용 공유하라는 지시가 있어서 정리해보았다.

지금까지 Uppy와 TUS 프로토콜을 활용하여 Spring Boot 환경에서 안정적인 대용량 파일 업로드 기능을 구현하는 방법을 살펴봤다. 이 아키텍처는 업로드 중단에 대한 강력한 내성을 제공하여 사용자 경험을 크게 향상시킨다.

하지만 현재 구조는 모든 파일을 하나의 폴더에 저장하므로, 파일 개수가 수만 개 이상으로 늘어날 경우 파일 시스템의 성능 저하를 유발할 수 있다. 이를 해결하기 위해 다음과 같은 추가 개선을 계획하고 있다.

날짜 기반 서브디렉토리 생성: 파일을 저장할 때 YYYY/MM/DD와 같은 날짜 기반의 하위 폴더를 동적으로 생성하여 파일을 분산 저장하는 로직을 추가한다.

다시 일하러 가야겠다 하하

Reference

[tus protocol] 재개 가능한 파일 업로드를 위한 오픈 프로토콜

Thank You for Visiting My Blog, I hope you have an amazing day 😆
© 2023 Ian, Powered By Gatsby.