상황
: 문서함은 대용량 파일을 업로드 할 수 있어야 합니다.
문제점
: 대용량 파일(2.3Gb)를 업로드 시 응답 속도가 느렸습니다.
해결법
: 비동기 처리를 진행했습니다.
결과
: 3.6초 → 3.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 >> 하드디스크인데 이걸 기다리는 것은 너무 미련한 일!
따라서 비동기로 업로드 해야겠다고 생각했다.
비동기 처리 파일 업로드
@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);
}
}
파일 저장을 비동기처리하고 바로 응답반환. 빠른 응답이 가능해졌으나 이로인해 문제가 발생했습니다.
상황
: 게시글 생성 후 바로 파일 다운로드 시 불완전한 파일 다운로드
문제점
: Files.copy()가 끝나지 않았는데 해당 파일 다운로드 접근
해결책
: Future.join()으로 대기 혹은 다운로드할 때 예외처리하기
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()을 삭제하고 다운로드에서 검증해보자.
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 비동기에 관한 포스팅을 진행하며 개념을 정리하려 한다.