비동기 파일 업로드

coh·2024년 9월 18일
2

Spring

목록 보기
2/2

문서함 파일 업로드 개요

상황 : 문서함은 대용량 파일을 업로드 할 수 있어야 합니다.
문제점 : 대용량 파일(2.3Gb)를 업로드 시 응답 속도가 느렸습니다.
해결법 : 비동기 처리를 진행했습니다.
결과 : 3.6초 → 3.1초로 점진적으로 응답속도의 개선을 이루어냈습니다.

version 0.1

기존 코드 Controller + storeFile()

# DocumentController.java
@PostMapping("/api/creation")
	public ResponseEntity<String> createDocument(
			@RequestParam("title") String title,
			@RequestParam("content") String content,
			@RequestParam(value = "file", required = false) MultipartFile file, // 각 유저별로 폴더를 관리해야함
			@RequestParam("category") String category,
			@RequestParam("writerEmpId") Integer writerEmpId) {
		String fileName = null;
		if (file != null)
			fileName = file.getOriginalFilename();
		DocumentInitDTO document = new DocumentInitDTO(title, content, fileName, category, writerEmpId);
		DocumentBox documentBox = documentService.saveDocument(document);
		fileStorageService.storeFile(file, documentBox);
		return new ResponseEntity<>("Document created successfully", HttpStatus.OK);
	}

# FileStorageService.java
public String storeFile(MultipartFile file, DocumentBox documentBox) {
		if (file == null)
			return "";

		Path fileStorageLocation = Paths.get("src/main/resources/docs/" + documentBox.getDocumentId()).toAbsolutePath().normalize();
		try {
			Files.createDirectories(fileStorageLocation);
		} catch (Exception ex) {
			throw new RuntimeException("Could not create the directory where the uploaded files will be stored.", ex);
		}

		String fileName = file.getOriginalFilename();

		try {
			Path targetLocation = fileStorageLocation.resolve(fileName);
			Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);

			return fileName;
		} catch (IOException ex) {
			throw new RuntimeException("Could not store file " + fileName + ". Please try again!", ex);
		}
	}

2.3기가 업로드 시 발생시간 약 3.6초.
Files.copy()는 동기적인 메서드로 해당 메서드 호출 시 완료될 때까지 스레드는 기다린다...!

전공 지식을 떠올려보면 파일을 저장하는 작업은 CPU가 하드디스크에 요청하고 하드디스크가 파일을 저장하게 된다. 작업처리 속도는 CPU >> 하드디스크인데 이걸 기다리는 것은 너무 미련한 일!

따라서 비동기로 업로드 해야겠다고 생각했다.

version 0.2

비동기 처리 파일 업로드
@Async + CompletableFuture이용.

# AsyncConfig.java
@Configuration
@EnableAsync
public class AsyncConfig {
	@Bean(name = "taskExecutor")
	public Executor asyncExecutor(){
		ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
		executor.setCorePoolSize(5);
		executor.setMaxPoolSize(10);
		executor.setQueueCapacity(25); // 큐 크기
		executor.setThreadNamePrefix("Async-");
		executor.initialize();
		return executor;
	}
}

# Controller
	@PostMapping("/api/creation")
	public ResponseEntity<String> createDocument(
			@RequestParam("title") String title,
			@RequestParam("content") String content,
			@RequestParam(value = "file", required = false) MultipartFile file, // 각 유저별로 폴더를 관리해야함
			@RequestParam("category") String category,
			@RequestParam("writerEmpId") Integer writerEmpId) {
		String fileName = null;
		if (file != null)
			fileName = file.getOriginalFilename();
		DocumentInitDTO document = new DocumentInitDTO(title, content, fileName, category, writerEmpId);
		DocumentBox documentBox = documentService.saveDocument(document);

		CompletableFuture<String> future = fileStorageService.storeFile(file, documentBox);
//		future.thenAccept(result -> {
//			System.out.println("작업 결과: " + result);
//		});
//		System.out.println("Test: 메인스레드는 ㄱㅖ속작업");

		return new ResponseEntity<>("Document created successfully", HttpStatus.OK);
	}

# storeFile()
	@Async("taskExecutor")
	public CompletableFuture<String> storeFile(MultipartFile file, DocumentBox documentBox) {
		if (file == null)
			return CompletableFuture.completedFuture("");

		Path fileStorageLocation = Paths.get("src/main/resources/docs/" + documentBox.getDocumentId()).toAbsolutePath().normalize();
		try {
			Files.createDirectories(fileStorageLocation);
		} catch (Exception ex) {
			throw new RuntimeException("Could not create the directory where the uploaded files will be stored.", ex);
		}

		String fileName = file.getOriginalFilename();

		try {
			Path targetLocation = fileStorageLocation.resolve(fileName);
			Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);

			return CompletableFuture.completedFuture(fileName);
		} catch (IOException ex) {
			throw new RuntimeException("Could not store file " + fileName + ". Please try again!", ex);
		}
	}

v2 문제점 발생

파일 저장을 비동기처리하고 바로 응답반환. 빠른 응답이 가능해졌으나 이로인해 문제가 발생했습니다.

상황 : 게시글 생성 후 바로 파일 다운로드 시 불완전한 파일 다운로드
문제점 : Files.copy()가 끝나지 않았는데 해당 파일 다운로드 접근
해결책 : Future.join()으로 대기 혹은 다운로드할 때 예외처리하기

version 0.3

Files.copy()를 비동기처리 하고 다른 작업들 수행하도록 코드의 배치를 변경시켰습니다. 이후 future.join()으로 동기화를 제어했습니다.

문제점 문서함에서 Files.copy()가 진행이 되기 전에 사용자가 다운로드 버튼을 누르면 불완전한 파일이 다운로드 되는 현상.

해결책 : 비동기로 임시폴더에 다운로드 → DB작업 → join()대기 후 완료되면 경로 이동.

@PostMapping("/api/creation")
	public ResponseEntity<String> createDocument(
			@RequestParam("title") String title,
			@RequestParam("content") String content,
			@RequestParam(value = "file", required = false) MultipartFile file,
			@RequestParam("category") String category,
			@RequestParam("writerEmpId") Integer writerEmpId) {

		// 1. 파일을 임시 경로에 먼저 저장
		CompletableFuture<String> futureFile = fileStorageService.storeFileVersion2(file);

		// 2. DB에 document 저장 (documentBox 생성)
		String fileName = null;
		if (file != null)
			fileName = file.getOriginalFilename();
		DocumentInitDTO document = new DocumentInitDTO(title, content, fileName, category, writerEmpId);
		DocumentBox documentBox = documentService.saveDocument(document); // AutoIncrement된 PK 생성

		// 3. 임시 저장된 파일을 DocumentBox의 PK를 이용해 새로운 경로로 이동
		futureFile.join();
		futureFile.thenAccept(filePath -> {
			try {
				// 생성된 DocumentBox의 ID로 새로운 폴더 생성
				Path newDirectory = Paths.get("src/main/resources/docs/" + documentBox.getDocumentId()).toAbsolutePath().normalize();
				Files.createDirectories(newDirectory); // 새로운 폴더 생성

				// 임시 폴더에 저장된 파일을 새로운 경로로 이동
				Path sourceFilePath = Paths.get("src/main/resources/docs/tmp").resolve(filePath);
				Path targetFilePath = newDirectory.resolve(filePath);
				Files.move(sourceFilePath, targetFilePath, StandardCopyOption.REPLACE_EXISTING);

				System.out.println("파일이 새로운 경로로 성공적으로 이동되었습니다: " + targetFilePath);
			} catch (IOException e) {
				throw new RuntimeException("파일을 이동하는 동안 오류가 발생했습니다.", e);
			}
		}).exceptionally(ex -> {
			System.err.println("파일 처리 중 오류 발생: " + ex.getMessage());
			return null;
		});

		return new ResponseEntity<>("Document created successfully", HttpStatus.OK);
	}

DB작업과 파일 업로드 작업을 병렬적으로 실행하여 시간적 이점을 벌었다. 하지만 Files.copy()시간을 기다려야 하므로 다시 응답 속도가 느려지는 문제가 있다. 3.1초니까 약 0.5초의 성능 개선을 이루었다.
가장 좋은 성능은 다운로드시 검증하는 것이다. future.join()을 삭제하고 다운로드에서 검증해보자.

version 0.4

future.join() 코드 삭제하고 빠른 응답 반환하기.
다운로드 버튼을 사용자가 누를 시 파일 있는지 검증하는 코드 추가.

다운로드에서 파일이 준비되지 않았다면 400에러로 메세지를 보내는 방식이다. 만약 알람을 주고 싶으면 웹소켓 방식으로 실시간 알람을 줄 수 있으나 코드가 복잡해지기에 Polling방식으로 구현하였다.

상황 : 0.5초 성능 이득을 보았으나 다시 응답속도가 느려지는 문제
문제점 : I/O바운드 작업이 끝날 때까지 기다리고 있는 상황.
해결법 : 비동기로 tmp폴더에 다운로드 후, 완료되면 다운로드 경로로 이동. 사용자가 다운로드할 때 다운로드 경로에 파일이 없으면 업로드 중이라는 메세지로 안내.
결과 : 3.1초 → 0.1초. 불완전한 파일이 다운되는 오류 방지.
결과로 발생한 문제점 : 비동기 업로드를 진행 중에 파일이 제대로 업로드가 안 된 경우 클라이언트는 알 수가 없는 문제. 이 때문에 v0.3으로 revert할 수밖에 없었다.

@GetMapping("/{id}/download")
	public ResponseEntity<?> downloadFile(@PathVariable Integer id) {
		DocumentBox document = documentService.getDocumentById(id);
		String storedPath = "src/main/resources/docs/" + id;
		String fileName = document.getDocumentPath();
		Path path = Paths.get(storedPath).resolve(fileName);
		if (!Files.exists(path))
			return new ResponseEntity<>("파일이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요.", HttpStatus.BAD_REQUEST);
		if (document.getCategory().name().equalsIgnoreCase("approval"))
			storedPath = "src/main/resources/approvalComplete";
		return fileStorageService.getResourceResponse(storedPath, fileName);
	}

그 결과 응답 시간을 122ms를 얻을 수 있었다. 근데.. 사실은 이렇게 비동기 업로드를 진행했을 때 파일이 제대로 업로드가 안 된 경우는 또 문제가 발생할 수 있다. 그렇기에 join()으로 기다렸다가 진행하는 것이 좋아보여서 revert로 다시 되돌리려고 한다.

이후 포스팅은 동기 vs 비동기에 관한 포스팅을 진행하며 개념을 정리하려 한다.

profile
Written by coh

0개의 댓글