[SpringBoot]파일 업로드 속도를 높여보자!!

coh·2024년 8월 19일
1

Spring

목록 보기
1/2
post-thumbnail

다수의 파일을 서버에 올릴 때 속도를 개선하는 작업을 진행중이다.

시작하기 전에 결론부터 말하자면 성능 향상은 이뤄냈으나 실질 서비스에서는 이렇게 적용하면 안되기 때문에 다른 방안을 생각하는 중이다.

약 1350개의 작은 파일(각 3Mb)을 스프링부트 서버로 올릴 때 속도를 측정할 것이다. 속도를 측정하기 위해 AOP를 사용해보자

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이다. 자동으로 스레드 풀을 제공한다.

Executors

  • 동시 요청 시, 스레드를 만드는 것은 귀찮고 오버헤드가 발생할 수 있다. 따라서 스레드를 만들고 재사용하는 스레드풀 개념이 등장한다. 팩토리 클래스인 Executors는 스레드 풀 구현을 위한 인터페이스로 사용된다.
  • ExecutorService는 인터페이스로 구현체로 Executors클래스에서 제공하는 Static Factory Method인 newFixedThreadPool을 통해 초기화하였다.
  • 나는 파일 갯수만큼 스레드를 초기화하였는데 실행 머신의 CPU코어 수를 기준으로 생성하면 더 좋은 퍼포먼스를 얻을 수 있다고 한다.
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

Callable

  • Callable인터페이스는 java.util.concurrent패키지에서 제공한다. 실행할 작업을 정의하고 결과를 반환하거나 예외를 던질 수 있다.
  • Runnable의 void run()과는 달리 결과를 반환하거나 예외를 던질 수 있어서 사용했다.
  • 파일 저장 작업을 Callable인터페이스로 정의후 Executor의 submit메서드를 통해 수행한다.

Future

해당 객체는 비동기적으로 실행된 작업의 결과를 나타내는 객체이다. 하지만 내가 작성한 코드의 Filse.copy는 엄밀하게 말하면 동기적 작업이다.
완전 비동기적으로 AsynchronousFileChannel을 이용하여 해결할 수 있지만 메신저 서버의 특성상 실시간 통신이기 때문에 상대방이 전달된 파일의 이름을 클릭하면 바로 다운 받을 수 있게 하는 것이 중요하다고 생각했다.

  • Future.get()을 통해 Callable이 반환한 결과를 가져올 수 있다.
  • Future.isDone()을 통해 작업 완료 여부를 확인할 수 있다.
  • Future.cancel()을 통해 아직 실행중이거나 대기 중인 작업을 취소할 수 있다.

큰 파일들을 올려보자

큰 파일을 여러개 업로드할 때도 멀티스레드가 잘 동작할까?
-> 결론부터 말하면 아니다. 오히려 싱글스레드보다 성능이 떨어지는 경우가 많다.
다음은 180Mb 파일 4개를 싱글스레드와 멀티스레드로 올린 속도이다. 멀티스레드의 퍼포먼스가 더 떨어지는 것을 볼 수 있다.

그렇다면 왜 작은 파일에서는 효과적인데 큰 파일에서는 오히려 효과가 없을까? 내 생각에는 결국은 Context Switching으로 인한 Overhead가 가장 큰 이유일 것 같다.

  • 작은 파일같은 경우는 Context Switching이 발생하기 전에 다운로드가 끝난다. 따라서 하드웨어 자원을 최대치로 쓰면서 다운로드를 병렬로 진행할 수 있다.

  • 큰 파일의 경우는 HW자원을 최대치로 쓰지만 디스크 I/O속도의 한계 때문에 결국 속도의 이점은 잃고 컨텍스트 스위칭 비용만 더 커지는 것 같다. 또한 스레드 생성 비용, 할당된 리소스들의 낭비도 발생한다.

한계점

I/O바운드

시스템에서 (CPU작업시간 < 입출력 작업시간)인 작업.

  • 파일 읽기/쓰기, 네트워크통신, DB접근시간 등과 같이 CPU가 할 일이 적고 주로 데이터가 디스크에 읽혀지거나 네트워크 전송될 때까지 기다리는 시간이 큰 작업들.

파일을 서버에 저장하는 작업은 I/O바운드 작업으로 여러 스레드가 동시 접속하는 경우 디스크 자원에 대한 경합이 발생하여 성능이 저하될 수 있다! 특히, 디스크 캐시와 I/O 스케줄링으로 인해 더 많은 오버헤드가 발생할 수 있다.

네트워크 대역폭

대역폭 : 특정 시간 동안 전송할 수 있는 데이터 양. bps

사실 여기에는 한 가지 가정이 있다. 여러 사람이 서버에 접속하지 않는다는 점. 만약 여러 사람이 동시에 파일을 업로드하면은 어떻게 될까? 아니면 한 명이 엄청 많은 양의 파일을 올리게 되면?

네트워크 대역폭은 한정된 자원이고 사용자들이 업로드 기능을 요청할 때마다 스레드가 할당된다. 이 경우 스레드가 많아질수록 각 스레드가 사용 가능한 대역폭은 줄어들고 서버에 데이터가 도착하는 시간은 오래 걸리게 된다.

이 경우 병목현상이 생길 수 있다. 따라서 대역폭을 초과하지 못하게 많은 양의 파일을 한번에 올리지 못하게 막는 것이 불편하더라도 성능면에서 나은 선택지가 될 수 있다.

끗.

profile
Written by coh

0개의 댓글