[현장실습] 파일 업로드 성능 튜닝하기&안전성 추가

오영선·2024년 2월 2일

실습

목록 보기
5/12

현장실습에서 apk upload 기능을 구현하였습니다. 해당 기능을 디벨롭 하는 과정에서 작성된 글입니다.

❓현재 문제

업로드 apk 파일시, 100MB를 최대로 지정해주었기 때문에 더 큰 파일은 업로드 되지 않음.
또한, 업로드 되는 시간이 생각보다 길어서 이를 단축해보려고 합니다.

개발 환경

java : 17
사용 언어 - kotlin
DBMS : MariaDB
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.4")

현재 상황 및 성능 - POST요청에 대한 동기 작업

    @PostMapping("/upload")
    fun uploadApk(
        @RequestBody member: ApkUploadDto
    ): Response<KioskInfoDto> {
        try {
            //파일 비어있는지 검사
            
            //버전 중복 검사
            
            //APK 파일 업로드할 폴더 생성
            val url = FileConstant.getApkPath(member.kioskType, member.version)
           
            //app.apk 업로드
            val destFile = File(url + "app.apk")
            member.file.transferTo(destFile)
            
            //릴리즈 노트로 releaseNoto.txt 작성
            
            //kiosk info를 가져와 apk 관련 정보를 DB에 반영
            println("응답 return 시작")
            return Response.success(
                code = 201,
                result = updated,
            )
        } catch (e: IllegalStateException) {
            return Response.error(
                code = 400,
                message = e.message
            )
        } catch (e: Exception) {
            return Response.error(
                code = 500,
                message = e.message
            )
        }
    }

응답시간

1KB미만 : 140ms
50MB : 1.84s
2.9 GB 업로드 : 서버 터짐(3.72s)
업로드 실패했을때 3~4s가 걸림

org.apache.tomcat.util.http.fileupload.impl.SizeLimitExceededException: 
the request was rejected because its size (3024133937) exceeds the configured maximum (10485760)

✅ 비동기 적용 후

결과화면

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

@Configuration
class AsyncConfig {
    // 비동기 작업을 실행합니다
    @Bean
    fun excutorService() : ExecutorService {
        //쓰레드 풀에서 재활용할 스레드를 가져옵니다.
        //TODO newCachedThreadPool 외에는 어떤것이 있을까?
//        return Executors.newCachedThreadPool();
        return Executors.newFixedThreadPool(15);

    }
}

비동기 처리를 위해 자바 비동기 처리 라이브러리의 ExecutorService를 가져온다.

    @PostMapping("/upload")
    fun uploadApk(
            @ModelAttribute member: ApkUploadDto,
    ): Response<KioskInfoDto> {
        try {
//            ...
            }
            //version 파일 생성

            //kiosk info를 가져와 최신 정보를 DB에 반영

            //app.apk 업로드 (비동기)
            executorService.submit { ->
                try {
                    println("비동기시작")
                    uploadApk(url, member.file)
                    // 업로드 후 다른 로직 처리
                } catch (e: Exception) {
                    e.printStackTrace()
                    println("비동기에러")
                    // 필요한 경우 예외 처리
                }
            }
           
            println("응답 return 시작")
            return Response.success(
                    code = 201,
                    result = null,//updated,
            )
        } ...
    }

executorService 처리를 사용해 apk 업로드 과정을 처리해주었습니다.
이제 응답 200ok를 보내는 요청과, apk 업로드는 별개의 스레드로 처리될 것입니다.

비동기 적용 후(1)

비동기 적용 후(2)

응답시간

1KB미만 : 65ms
50MB : 1.17s, 707ms
2.9 GB 업로드 : 서버 터짐(3.72s)

  • 전체적으로 업로드 시간이 단축되었습니다.
  • 파일 업로드를 별도의 스레드로 실행했기 때문에 업로드 시간 자체가 똑같을 것이라 예상했는데, 그렇지는 않았습니다.

네트워크 속도
DISK I/O속도
파일이 서버로 날아가는 속도

위 사항이 변수가 되어 파일 크기에 따른 응답 시간이 달라지는 것으로 예상됩니다.

실제로 요청이 도착한 후, api 처리 시간만 비교해보았을때 다음과 같이 ms 시간차이가 거의 없는 것을 알 수 있습니다.

사용자가 업로드 후, 전송이 완료되었는지 알 수 없다.

파일 업로드를 비동기 처리했더니, 다음과 같은 고민이 생겼습니다.

  • 💡POST요청 후, apk 업로드 여부에 관계없이 200OK를 받아버리니 파일 업로드가 완료되기 전에 뒤로가기 등 다른 창을 실행해버리면?

  • 파일 업로드는 별도의 스레드를 통해 작동함으로, 사용자는 이 업로드가 완료되었는지 확인할 수 없습니다. 또한 app.apk에 대한 메타데이터를 db에 저장하고 있지 않음으로, 키오스크 apk 목록을 조회하여 다운로드를 요청하더라도 apk가 없는 에러를 만날 수 있습니다.

    이러한 정보를 보여주기 위해, APK 메타데이터 저장이 필요합니다.

APK 메타데이터 목록

컬럼명설명 타입
ID(PK)레코드를 식별하기 위한 키BIGINT
ORIGINAL_NAME저장된 파일의 원본 이름VARCHAR(255)
SAVED_PATH파일의 저장 경로 (마지막 '/' 포함)VARCHAR(255)
SAVED_NAME저장된 파일의 이름VARCHAR(255)
EXTENSION_NAME파일의 확장자명 ('.' 포함)CHAR(8)
SIZE파일의 크기INT

그러나 현 서비스는 apk 메타데이터가 따로 조회되거나 사용될 일이 없다는 점에서, 이러한 정보를 따로 관리하는 것은 낭비가 될 수 있습니다.
(apk 파일 크기가 다 비슷하고, 확장자 및 파일이름이 반드시 'app.apk'로 저장됨)

  • 💡DB 저장, 릴리즈노트.txt 생성, 파일 업로드를 각각 스레드 처리하고 모두 완료 후 응답을 보내는 방법은 어떨까?


    세가지 작업을 모두 비동기로 작업했더니, 50MB전송이 390ms로 줄어들었습니다. 즉, 요청을 보내는 시간외에 크게 단축되었음을 확인할 수 있습니다. 그러나 이 작업들이 완료되었는지 클라이언트가 확인하기 위해서는 적절한 Response 200을 반환해주어야 합니다.

이제 CompletableFuture를 사용하여 세개의 TASK를 모은 후 응답해보겠습니다.

CompletableFuture
Future 인터페이스는 java5부터 java.util.concurrency 패키지에서 비동기의 결과값을 받는 용도로 사용했다. 하지만 비동기의 결과값을 조합하거나, error를 핸들링할 수가 없었다.
자바8부터 CompletableFuture 인터페이스가 소개되었고, Future 인터페이스를 구현함과 동시에 CompletionStage 인터페이스를 구현한다. CompletionStage는 비동기 연산 Step을 제공해서 계속 체이닝 형태로 조합이 가능하다

코드

		...
        	//version 파일 생성
            val futures = listOf( //비동기 작업을 list로 묶음
                    CompletableFuture.runAsync({
                        try {
                            uploadApk(uploadDir, member.file)
                        } catch (e: Exception) {
                            e.printStackTrace()
                        }
                    }, executorService),

                    CompletableFuture.runAsync({
                        try {
                           //릴리즈노트.txt 작성
                        } catch (e: Exception) {
                            e.printStackTrace()
                        }
                    }, executorService),
                    CompletableFuture.runAsync({
                        try {
                            //kiosk info를 가져와 최신 정보를 DB에 반영
                        } catch (e: Exception) {
                            e.printStackTrace()
                        }
                    }, executorService)
            )

            // 모든 CompletableFuture가 완료될 때까지 대기
            CompletableFuture.allOf(*futures.toTypedArray()).join()
	...
  • join 작업 후 성능 1



    API 요청 시간 : 170ms (큰 변화 없음)
    서버 내에서 작업 시간 : 118ms

  • join 작업 후 성능 2



    API 요청 시간 : 815ms (약 1/2)
    서버 내에서 작업 시간 : 190ms

특히 50MB 전송 요청은, 여러번 시도한 결과 815ms 539ms, 614ms 등이 나왔으며, 첫 작업 시간(1.84s)의 1/2, 1/3로 줄어들었음을 확인할 수 있었습니다.

⚠️비동기로 처리하면 안되는 작업

  • 폴더 생성하기
    이 폴더는 릴리즈노트, app.apk 두가지 작업 모두 사용하기 때문에 동기작업이 필요함
  • 유효성 검사하기
    : 전송한 파일이 비어있거나, 타입명, 버전명이 비어있지 않은지
    마찬가지로, 비동기 작업을 수행하다 에러가 날 수 있는 부분을 미리 검사후, code=400 으로 응답해야 합니다.

결과

  • 파일 업로드 시간이 1.84s -> 612ms 로 최대 62% 감소하였습니다.
  • 파일 성공 여부를 POST의 응답으로 확인하였는데, 이 부분에 대해 두가지 선택지
    (get으로 APK 메타데이터를 한번 더 조회, 비동기 작업을 다시 회수해 응답 반환)가 있었고 후자를 택하기로 했습니다.
  • APK 저장 서비스인 만큼, apk 파일업로드 여부가 가장 중요하기 때문에 apk 메타데이터를 조회하기 보다 해당 업로드에서 예외가 발생하지 않았는지 응답의 메시지를 통해 즉각적으로 확인이 필요하다 판단되었습니다.

GB 업로드에 대해

  • 현재 카페24호스팅을 사용하고 있는데, 큰 파일을 업로하는 트래픽 발생시 준비된 트래픽을 많이 사용해 서버가 내려갈 수도 있다고 합니다.
    따라서 1GB를 넘는 파일은 서버에 올라가지 않도록 최대 100MB 설정을 적용하였습니다.
    또한, 프론트엔드에서 파일을 form-data로 전송하기 전 미리 파일 검사하는 것으로 결정되었습니다.

참고자료

https://velog.io/@kyu0/Spring-Boot-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%B2%98%EB%A6%AC-feat.-Multi-Threading
https://shanepark.tistory.com/441
https://velog.io/@kyu0/Spring-Boot-%ED%8C%8C%EC%9D%BC-%EC%97%85%EB%A1%9C%EB%93%9C-%EC%B2%98%EB%A6%AC-feat.-Multi-Threading

0개의 댓글