현장실습에서 apk upload 기능을 구현하였습니다. 해당 기능을 디벨롭 하는 과정에서 작성된 글입니다.
업로드 apk 파일시, 100MB를 최대로 지정해주었기 때문에 더 큰 파일은 업로드 되지 않음.
또한, 업로드 되는 시간이 생각보다 길어서 이를 단축해보려고 합니다.
java : 17
사용 언어 - kotlin
DBMS : MariaDB
implementation("org.mariadb.jdbc:mariadb-java-client:2.7.4")

@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'로 저장됨)
세가지 작업을 모두 비동기로 작업했더니, 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로 줄어들었음을 확인할 수 있었습니다.
code=400 으로 응답해야 합니다.1.84s -> 612ms 로 최대 62% 감소하였습니다. 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