☁️ 이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략
1. 문제 파악 – 응답시간이 8초?
최근 진행 중인 프로젝트에서 이미지 업로드 기능을 독립적인 API로 설계하고 있었습니다.
사용자는 이미지와 함께 간단한 정보를 전송하며, 서버는 이를 데이터베이스에 기록한 뒤 이미지 파일을 저장합니다.
단순한 API라 여겼지만, 부하 테스트 결과는 충격적이었습니다.
200 Threads x 10 Loop = 총 2000회 요청
평균 응답 시간: 약 8초
Throughput: 33 requests/sec
단순 이미지 업로드임에도, 트래픽이 몰리자 응답 시간이 기하급수적으로 증가했습니다.
원인을 찾기 위해 서버의 흐름을 다시 점검했습니다.
2. 근본 원인 – 트랜잭션과 I/O의 결합
애플리케이션 구조는 아래와 같았습니다:
문제는 이미지 저장이 트랜잭션 범위 안에 포함되어 있다는 점입니다.
즉, DB 커밋이 완료되기 전까지 이미지 저장이 끝나야 하며,
이로 인해 커넥션이 오래 점유되고, 처리량(Throughput)은 급감하게 됩니다.
3. 해결 전략 – 트랜잭션과 이미지 저장의 분리
1) 트랜잭션 외부로 로직 분리
가장 근본적인 해결은 이미지 저장 로직을 트랜잭션 외부로 빼는 것이었습니다.
200 Threads x 10 Loop = 총 2000회 요청
평균 응답 시간: 4초
Throughput: 49.4 requests/sec
2) @Async 기반의 비동기 처리
Spring의 @Async를 활용해 이미지 저장을 별도 쓰레드에서 수행하도록 구성했습니다.
200 Threads x 10 Loop = 총 2000회 요청
평균 응답 시간: 약 2.8초
Throughput: 60.1 requests/sec
Throughput: 약 800 req/min (기존 400 대비 2배 향상)
응답 시간: 눈에 띄게 개선
그러나 또 다른 문제가 발생했습니다.
2-1) 비동기 처리의 함정 – MultipartFile의 생명주기
일부 요청에서 다음과 같은 예외가 발생했습니다:
java.lang.IllegalStateException: Stream closed
이는 MultipartFile
의 InputStream
이 요청 스레드 종료 이후에 닫혀 버리기 때문입니다.
비동기 쓰레드는 해당 리소스를 더 이상 사용할 수 없어 예외가 발생한 것입니다.
2-2) 해결책 – InputStream 대신 메모리에 로딩
이를 해결하기 위해 MultipartFile
에서 직접 InputStream
을 사용하지 않고,
byte 배열로 메모리에 미리 읽어들인 후 작업을 넘겨주는 방식으로 변경했습니다.
200 Threads x 10 Loop = 총 2000회 요청
평균 응답 시간: 약 1.5초
Throughput: 88.9 requests/sec
byte[] imageBytes = multipartFile.getInputStream().readAllBytes();
이렇게 하면 MultipartFile
의 생명주기와 무관하게 안정적으로 비동기 작업이 가능해졌습니다.
4. 스레드풀 설정과 CallerRunsPolicy – 마지막 안전장치
비동기 처리가 늘어나면서, 스레드풀 설정도 함께 조정했습니다
@Bean(name = "asyncTaskExecutor")
public Executor asyncTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("AsyncExecutor-");
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(100);
executor.setWaitForTasksToCompleteOnShutdown(true);
executor.setAllowCoreThreadTimeOut(true);
executor.setAwaitTerminationSeconds(3);
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
executor.initialize();
return executor;
}
여기서 중요한 포인트는 CallerRunsPolicy입니다.
스레드풀의 큐가 가득 차고 스레드가 모두 바쁜 경우,
해당 작업을 현재 요청을 보낸 스레드에서 직접 실행하도록 지시하는 정책입니다.
이 정책은 다음과 같은 장점이 있습니다:
- 작업 유실 없음: 이미지 저장과 같이 반드시 수행되어야 하는 작업 보장
- 자연스러운 트래픽 조절 (Backpressure): 과부하 상황에서 응답이 느려지며 클라이언트의 요청 속도도 늦춰짐
물론 이 정책의 단점은, 요청 스레드에서 직접 처리하므로 해당 요청의 응답 속도가 느려질 수 있다는 점입니다.
그러나 이번 프로젝트처럼 이미지 데이터를 미리 byte[]로 로딩해두면, 처리 시간 자체가 짧아 이 단점은 크게 부각되지 않았습니다.
즉, CallerRunsPolicy는 비동기 안정성을 높이기 위한 최후의 보루였습니다.
7. 최종 성능 비교
📊 이미지 업로드 방식별 성능 비교 ( 200 Threads, 10 requests per thread )
구분 | 설명 | 응답 시간 (Response Time) | 처리량 (Throughput) | 메모리 사용량 (Memory Usage) |
---|---|---|---|---|
1 | 기존 방식 트랜잭션 내에 파일 I/O 포함 | 7.2s / 1 Request | 33.5 req/sec | 기본값 (InputStream buffer) |
2 | 트랜잭션 외부로 I/O 분리 | 3.8s / 1 Request | 49.4 req/sec | 차이 없음 |
3 | @Async + inputStream 단, 파일 다운로드 실패로 실사용 불가 | 2.8s / 1 Request | 60.1 req/sec | 차이 없음 |
4 | @Async + byte[] 활용 Heap 메모리 활용 | 1.5s / 1 Request | 88.9 req/sec | 힙 메모리 사용량 현저히 증가 |
8. 깨달은 점
- 단순 API도 트랜잭션과 I/O의 분리는 필수적이다.
- 비동기 처리의 안정성은
리소스 생명주기 관리
에서 시작된다. - @Async는 강력하지만, 스레드풀 설정과 예외 정책 없이 쓰면 오히려 불안정해질 수 있다.
- CallerRunsPolicy는 단순한 fallback이 아니라, 서비스 신뢰성을 유지하기 위한 전략적 선택이다.
이번 경험을 통해, 단순한 이미지 업로드 API에서도 성능 병목, 비동기 안정성, 리소스 생명주기, 스레드풀 설정이 서로 긴밀히 연결되어 있음을 알 수 있었습니다.
여러분의 비동기 처리는 정말로 안전한가요?
지금 한 번 더 점검해보세요. 🧩
'프로젝트 일기 > 한편의 수학 학원' 카테고리의 다른 글
데코레이터 패턴을 통한 비동기 처리의 안정적 도입 (0) | 2025.04.14 |
---|---|
[Refactor] Bean Validation Duplicated (0) | 2025.04.09 |
벌크연산을 통한 쿼리 최적화 (0) | 2025.04.04 |
로그인 시도 횟수 제한 기능 구현하기 (0) | 2025.04.03 |