사내에서 파일 업로드 기능을 두 가지 방식으로 제공하고 있습니다.
- 서버에서 파일을 받고 S3에 업로드
- Presigned URL을 발급하고 클라이언트에서 Presigned URL로 직접 S3에 업로드
문제
첫 번째 방식은 구현이 간편하지만, 다음과 같은 CPU 사용 이슈가 있었습니다.
- 속도 저하: 25MB PDF 파일 업로드 시 평균 4~6초 소요
- CPU 사용량 급등: 최대 80%, 평균 50%
이를 해결하기 위해 Presigned URL 방식을 도입해 우회적으로 성능은 개선했지만, 또 다른 두 가지 문제가 발생했습니다.
- 클라이언트 복잡성 증가: Presigned URL 관련 Flow 이해필요
- 레거시 코드 호환성 문제: 기존 API를 사용하는 클라이언트 코드 수정의 불편함과 어려움
현재 쿠버네티스 자원을 알뜰하고 사용하고 있어, CPU 리소스를 늘리는 대신 최적화를 통해 문제를 해결하기로 결정했습니다.
분석 과정
1. Async Profiler 사용
JVM 애플리케이션의 CPU 병목을 파악하기 위해 Async Profiler 를 활용했습니다.
./asprof -d $SECONDS -f flamegraph.html $PID

로컬 환경에서 업로드 API를 호출하며 프로파일링한 결과, S3Storage.upload 함수 호출 이후 InMemoryBufferingS3OutputStream 클래스의 write 함수에서 병목이 발생하는 것을 확인했습니다.
아래에서 위로 콜스택 순서를 보여주며, 가로길이는 CPU 사용량에 비례합니다.
public void write(int b) {
synchronized(this.monitor) {
if (!this.isClosed()) {
if ((long)this.outputStream.size() == this.bufferSize.toBytes()) {
if (!this.isMultiPartUpload()) {
this.createMultiPartUpload();
}
this.completedParts.add(this.uploadPart(this.outputStream.toByteArray(), this.multipartUploadResponse));
this.outputStream.reset();
}
this.outputStream.write(b);
}
}
}
코드를 확인해 보니 버퍼만큼 채우고 멀티 파트 업로드하는 것을 확인했습니다.
멀티파트 업로드의 경우, 파일을 나누고, 각 파트를 업로드하고, 마지막에 결합하는 등의 추가적인 오버헤드가 발생하게 되는데
대용량 파일이 아닌 작은 파일에는 오히려 비효율적일 수 있다고 판단했습니다.
단순하게 생각해서 버퍼를 늘려보면 해결되지 않을까 해서 InMemoryBufferingProvider를 커스터마이징하여
버퍼 크기를 늘려봤지만 큰 효과를 보지못하였습니다.
2. S3Template 제거
과거 S3 업로드 기능 구현 시, 다운로드를 쉽게 처리하기 위해 awsspring 라이브러리의 S3Template을 사용했습니다.
앞서 문제가 있었던 InMemoryBufferingS3OutputStream을 기본으로 사용하는 S3Template을 제거하고 Amazon S3 SDK를 직접 사용하는 시도를 해봤습니다.
변경전(S3Template 사용)
override fun upload(fileInfo: FileInfo) {
try {
template.upload(bucketName, fileInfo.fullPath, fileInfo.file.inputStream)
} catch (e: IOException) {
throw S3Exception.create("파일이 업로드되지 않았습니다. - ${fileInfo.name}", e)
}
}
변경후(AWS SDK 사용)
override fun upload(fileInfo: FileInfo) {
val request = PutObjectRequest.builder()
.bucket(bucketName)
.key(fileInfo.fullPath)
.serverSideEncryption(ServerSideEncryption.AES256)
.build()
val body = RequestBody.fromInputStream(fileInfo.file.inputStream, fileInfo.file.size)
try {
s3Client.putObject(request, body)
} catch (e: IOException) {
throw S3Exception.create("파일이 업로드되지 않았습니다. - ${fileInfo.name}", e)
}
}
코드를 변경하고 테스트를 해본 결과
- 결과: 응답 시간이 2초로 단축 (25MB 파일 기준)
- 문제: CPU 사용량은 여전히 50% 수준으로 유지
응답은 시간은 확실히 줄었으나 CPU가 튀는 현상은 해결되지 않았습니다.
원인 파악을 위해 다시 프로파일러로 분석을 시작했습니다.

SDK를 사용하면서 upload 호출 시점에 InMemoryBuffering 병목이 있던 부분은 사라진 것을 확인할 수 있었지만,

Java Cipher 모듈을 사용하는 과정에서 CPU를 많이 사용하는 것을 확인할 수 있었습니다.
이 부분을 해결하기 위해 다음과 같은 시도를 했지만 문제는 해결되지 않았습니다.
- UseAES 하드웨어 가속화 확인 여부
- 파일 checksum 모드 변경
3. Java Cipher CPU 문제 발견
Amazon Corretto JDK를 사용하고 있어 성능 관련해서 찾아보던 중 ACCP를 우연히 알게되었고,
기존 Java 암호화 모듈(SunJCE)의 성능 한계를 해결하기 위해 AWS Corretto Crypto Provider(ACCP)를 도입했습니다.
AWS에서도 Java가 제공하는 암호화 프로바이더가 느리다라는 것을 인지하고 있었다.
그래서 더 나은 성능을 제공하는 암호화 알고리즘을 제공하였고,
적용 이후 성능을 올리고 운영비용을 절감할 수 있다고 발표했다.
https://aws.amazon.com/ko/blogs/opensource/introducing-amazon-corretto-crypto-provider-accp/

최적화 적용
문제를 확인했으니 ACCP를 적용하기로 해보았다.
ACCP 설치 및 적용
AWS Corretto에서 제공하는 암호화 모듈을 간단히 설치하여 기존 SunJCE 프로바이더를 대체할 수 있습니다.
// gradle
dependencies {
implementation("software.amazon.cryptools:AmazonCorrettoCryptoProvider:$accpVersion:$platform")
}
@ConfigurationPropertiesScan
@SpringBootApplication
fun main(args: Array<String>) {
AmazonCorrettoCryptoProvider.install() // 교체 코드 실행
runApplication<CmsApplication>(*args)
}
애플리케이션 시작 시 프로바이더를 쉽게 변경할 수 있습니다.
적용 이후 프로파일러를 켜서 확인해 본 결과, 업로드 과정에서 자바 암호화 모듈은 확인되지 않았다.

결과 확인
- 25MB 파일 업로드: CPU 사용량 평균 50% → 20% 감소 (평균 2초 응답)
- 10MB 이하 평균 파일 업로드: CPU 사용량 10% 미만으로 감소
암호화 모듈 교체 이후 내가 원했던 결과가 나오기 시작하였다.
배포 전


| 25MB 파일 업로드 | 4~6초 | 50~80% |
배포 후

| 25MB 파일 업로드 | 2초대 이내 | 10~30% |
마무리
S3Template을 걷어내고, 암호화 모듈을 교체함으로써 문제를 해결할 수 있었습니다.
CPU 사용량 급등 문제를 해결함으로써 서비스 안정성과 비용 효율성을 크게 개선할 수 있었고, 1년간 해결하지 못한 문제를 분석을 통해 최적화까지 이루니 뿌듯하기도 했다.
처음에는 Spring 진영과 Java에서 제공하는 기능들에서 문제가 발생할 것 이라고는 전혀 생각하고 있지 않아 많은 어려움이 있었다.
비슷한 문제를 겪는 개발자들에게 도움이 되길 바랍니다.