다수의 파일을 서버에 올릴 때 속도를 개선하는 작업을 진행중이다.
시작하기 전에 결론부터 말하자면 성능 향상은 이뤄냈으나 실질 서비스에서는 이렇게 적용하면 안되기 때문에 다른 방안을 생각하는 중이다.
약 1350개의 작은 파일(각 3Mb)을 스프링부트 서버로 올릴 때 속도를 측정할 것이다. 속도를 측정하기 위해 AOP를 사용해보자
build.gradle
관련 라이브러리를 추가하자.
// AOP 관련 라이브러리
implementation 'org.springframework.boot:spring-boot-starter-aop'
LoggingAspect.java
package com.ls.in.messenger.util;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
@Aspect
@Component
@Slf4j
public class LoggingAspect {
@Around("execution(* com.ls.in.messenger.controller.impl.StompChatControllerImpl.*(..))")
public Object execute(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
System.out.println("Start:" + joinPoint.toString());
try{
return joinPoint.proceed();
}
finally {
long finish = System.currentTimeMillis();
long timeMs = finish - start;
System.out.println("End : " + joinPoint.toString() + " " + timeMs);
}
}
@Around("@annotation(measureExecutionTime)")
public Object logExecutionTime(ProceedingJoinPoint joinPoint, MeasureExecutionTime measureExecutionTime) throws Throwable {
long startTime = System.currentTimeMillis();
Object proceed = joinPoint.proceed();
long endTime = System.currentTimeMillis();
System.out.println("{" + joinPoint.getSignature() + "}" + "executed in {" + (endTime - startTime) + "} ms");
return proceed;
}
}
AOP를 사용하여 시간을 측정하는 방법은 두가지이다.
LoggingAspect.java는 두 가지 방법에 대한 코드이다
애너테이션은 다음과 같이 만들며 사용시 측정할 함수 위에 애너테이션을 걸면 된다.
package com.ls.in.messenger.util;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
// 메서드에만 적용되며, 런타임까지 유지되는 애너테이션
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MeasureExecutionTime {
}
@PostMapping("/file/single")
@ResponseBody
public List<String> singleThreadFile(@RequestParam("files") MultipartFile[] files) { // hadnleFileUpload메소드는 클라이언트가 업로드한 파일을 서버에 저장하는 역할
if (files.length == 0) {
throw new RuntimeException("Failed to store empty file."); // 비어 있으면 예외처리
}
log.info("# 싱글스레드 업로드 POST");
List<String> response = new ArrayList<>();
Path fileStorageLocation = Paths.get("src/main/resources/message/").toAbsolutePath();
try {
Files.createDirectories(fileStorageLocation);
} catch (IOException e) {
throw new RuntimeException("Could not create storage directory", e);
}
for (MultipartFile file : files){
String originalFileName = file.getOriginalFilename();
String storedFileName = UUID.randomUUID().toString() + "_" + originalFileName;
try {
Path targetName = fileStorageLocation.resolve(storedFileName);
Files.copy(file.getInputStream(), targetName, StandardCopyOption.REPLACE_EXISTING);
response.add(originalFileName + "::" + storedFileName);
} catch (IOException e) {
throw new RuntimeException("Failed to store File:" + originalFileName, e);
}
}
return response;
}
파일을 다운로드 받기 위해 가장 효과적인 InputStream으로 코드를 작성했다.
3Mb의 1개의 파일을 올리는 코드의 속도를 측정해보자
사진크기와 비슷한 파일을 가정하기 위해 3Mb로 선정했다.
1개의 파일은 8ms가 나왔다!!
좀 유의미한 결과를 보기위해 1344개의 파일을 서버로 올려보자. 용량은 약 4Gb다.
17.8초가 측정되었다.
성능을 좀 더 늘릴 수 없을까. 대용량의 사진 데이터를 클라우드 서버에 업로드하는 경우를 가정하여 성능을 좀 더 늘리고 싶다! 각 파일을 저장하는 로직을 스레드로 병렬 처리하면 어떨까?
멀티스레드를 이용하여 해결하는 방법을 생각해보았다.
@PostMapping("/file/upload")
@ResponseBody
public List<String> multiThreadFileUpload(@RequestParam("files") MultipartFile[] files) {
if (files.length == 0) {
throw new RuntimeException("Failed to store empty file.");
}
log.info("# 멀티스레드 업로드 POST");
List<String> uploadedFilesInfo = new ArrayList<>();
Path fileStorageLocation = Paths.get("src/main/resources/message/").toAbsolutePath();
int numberOfThreads = files.length;
if (files.length > Runtime.getRuntime().availableProcessors())
numberOfThreads = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
List<Future<String>> futures = new ArrayList<>();
try {
Files.createDirectories(fileStorageLocation);
} catch (IOException e) {
throw new RuntimeException("Could not create storage directory", e);
}
for (MultipartFile file : files) {
Callable<String> fileSaveJob = () -> {
String originalFileName = file.getOriginalFilename();
String storedFileName = UUID.randomUUID().toString() + "_" + originalFileName;
try {
Path targetName = fileStorageLocation.resolve(storedFileName);
Files.copy(file.getInputStream(), targetName, StandardCopyOption.REPLACE_EXISTING);
return originalFileName + "::" + storedFileName;
} catch (IOException e) {
throw new RuntimeException("Failed to store File:" + originalFileName, e);
}
};
futures.add(executor.submit(fileSaveJob));
}
for (Future<String> future : futures) {
try {
uploadedFilesInfo.add(future.get());
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve file upload result", e);
}
}
executor.shutdown();
return uploadedFilesInfo;
}
9.75초... 약 2배의 성능 개선을 이루었다.
여기서 사용한 ExecutorService는 병렬처리를 위해 제공되는 JDK API이다. 자동으로 스레드 풀을 제공한다.
int numberOfThreads = files.length;
if (files.length > Runtime.getRuntime().availableProcessors())
numberOfThreads = Runtime.getRuntime().availableProcessors();
ExecutorService executor = Executors.newFixedThreadPool(numberOfThreads);
CPU가 사용할 수 있는 코어 수보다 더 많은 스레드를 생성하면 Context Switching이 더 자주 발생하며 이에 따른 오버헤드가 발생해서 더 안좋은 퍼포먼스를 낸다.
따라서 만들 스레드 갯수를 정하는 것이 중요하다.
나는 컴퓨터의 사용가능 코어 수가 8개로 나온다. 따라서 1 ~ 8사이의 스레드를 생성하는 코드를 작성했다.
여담이지만 멀티스레드 프로세서 같은 경우 8코어지만 16으로 값이 찍힐 수 있다. 하나의 코어에서 2개의 명령어를 처리할 수 있기 때문이다. 이런 부분에서 CS지식의 중요성을 느낀다.
System.out.println(Runtime.getRuntime().availableProcessors());
//출력값 : 8
해당 객체는 비동기적으로 실행된 작업의 결과를 나타내는 객체이다. 하지만 내가 작성한 코드의 Filse.copy는 엄밀하게 말하면 동기적 작업이다.
완전 비동기적으로 AsynchronousFileChannel을 이용하여 해결할 수 있지만 메신저 서버의 특성상 실시간 통신이기 때문에 상대방이 전달된 파일의 이름을 클릭하면 바로 다운 받을 수 있게 하는 것이 중요하다고 생각했다.
큰 파일을 여러개 업로드할 때도 멀티스레드가 잘 동작할까?
-> 결론부터 말하면 아니다. 오히려 싱글스레드보다 성능이 떨어지는 경우가 많다.
다음은 180Mb 파일 4개를 싱글스레드와 멀티스레드로 올린 속도이다. 멀티스레드의 퍼포먼스가 더 떨어지는 것을 볼 수 있다.
그렇다면 왜 작은 파일에서는 효과적인데 큰 파일에서는 오히려 효과가 없을까? 내 생각에는 결국은 Context Switching으로 인한 Overhead가 가장 큰 이유일 것 같다.
작은 파일같은 경우는 Context Switching이 발생하기 전에 다운로드가 끝난다. 따라서 하드웨어 자원을 최대치로 쓰면서 다운로드를 병렬로 진행할 수 있다.
큰 파일의 경우는 HW자원을 최대치로 쓰지만 디스크 I/O속도의 한계 때문에 결국 속도의 이점은 잃고 컨텍스트 스위칭 비용만 더 커지는 것 같다. 또한 스레드 생성 비용, 할당된 리소스들의 낭비도 발생한다.
시스템에서 (CPU작업시간 < 입출력 작업시간)인 작업.
파일을 서버에 저장하는 작업은 I/O바운드 작업으로 여러 스레드가 동시 접속하는 경우 디스크 자원에 대한 경합이 발생하여 성능이 저하될 수 있다! 특히, 디스크 캐시와 I/O 스케줄링으로 인해 더 많은 오버헤드가 발생할 수 있다.
대역폭 : 특정 시간 동안 전송할 수 있는 데이터 양. bps
사실 여기에는 한 가지 가정이 있다. 여러 사람이 서버에 접속하지 않는다는 점. 만약 여러 사람이 동시에 파일을 업로드하면은 어떻게 될까? 아니면 한 명이 엄청 많은 양의 파일을 올리게 되면?
네트워크 대역폭은 한정된 자원이고 사용자들이 업로드 기능을 요청할 때마다 스레드가 할당된다. 이 경우 스레드가 많아질수록 각 스레드가 사용 가능한 대역폭은 줄어들고 서버에 데이터가 도착하는 시간은 오래 걸리게 된다.
이 경우 병목현상이 생길 수 있다. 따라서 대역폭을 초과하지 못하게 많은 양의 파일을 한번에 올리지 못하게 막는 것이 불편하더라도 성능면에서 나은 선택지가 될 수 있다.
끗.