이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략

2025. 4. 13. 19:20·프로젝트 일기/한편의 수학 학원

☁️ 이미지 업로드 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입니다.

스레드풀의 큐가 가득 차고 스레드가 모두 바쁜 경우,
해당 작업을 현재 요청을 보낸 스레드에서 직접 실행하도록 지시하는 정책입니다.
이 정책은 다음과 같은 장점이 있습니다:

  1. 작업 유실 없음: 이미지 저장과 같이 반드시 수행되어야 하는 작업 보장
  2. 자연스러운 트래픽 조절 (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. 깨달은 점

  1. 단순 API도 트랜잭션과 I/O의 분리는 필수적이다.
  2. 비동기 처리의 안정성은 리소스 생명주기 관리에서 시작된다.
  3. @Async는 강력하지만, 스레드풀 설정과 예외 정책 없이 쓰면 오히려 불안정해질 수 있다.
  4. CallerRunsPolicy는 단순한 fallback이 아니라, 서비스 신뢰성을 유지하기 위한 전략적 선택이다.

이번 경험을 통해, 단순한 이미지 업로드 API에서도 성능 병목, 비동기 안정성, 리소스 생명주기, 스레드풀 설정이 서로 긴밀히 연결되어 있음을 알 수 있었습니다.

여러분의 비동기 처리는 정말로 안전한가요?
지금 한 번 더 점검해보세요. 🧩

'프로젝트 일기 > 한편의 수학 학원' 카테고리의 다른 글

API 테스트 하는데... 인증까지 매번..?  (0) 2025.04.22
데코레이터 패턴을 통한 비동기 처리의 안정적 도입  (0) 2025.04.14
[Refactor] Bean Validation Duplicated  (0) 2025.04.09
벌크연산을 통한 쿼리 최적화  (0) 2025.04.04
로그인 시도 횟수 제한 기능 구현하기  (0) 2025.04.03
'프로젝트 일기/한편의 수학 학원' 카테고리의 다른 글
  • API 테스트 하는데... 인증까지 매번..?
  • 데코레이터 패턴을 통한 비동기 처리의 안정적 도입
  • [Refactor] Bean Validation Duplicated
  • 벌크연산을 통한 쿼리 최적화
윤희종
윤희종
호기심을 잃지 말자 지적, 질문은 언제나 환영합니다 ;)
  • 윤희종
    서버견문록
    윤희종
  • 전체
    오늘
    어제
    • 분류 전체보기 (36)
      • 데일리 플랜 (1)
      • 이것저것 (4)
      • Java (6)
      • Spring (12)
        • SpringBoot (10)
        • Spring MVC (0)
      • Computer Science (4)
        • Network (1)
        • Operating System (0)
        • Data Structure (0)
        • Algorithm (2)
        • Database (0)
      • IOS (0)
      • 프로그래머스 문제풀이 (2)
      • 프로젝트 일기 (7)
        • 한편의 수학 학원 (7)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    스프링 부트 인증 우회
    SecurityFilterChain
    비동기 처리 유의점
    SecurityFilterChain 구성
    스프링 부트
    인증 테스트
    read through
    springboot
    mysql 쿼리 최적화
    캐시
    스프링 시큐리티 구성
    인증 우회 테스트
    Spring
    제네릭
    성능 개선
    servlet
    스프링 시큐리티 사용법
    알고리즘
    스프링
    cache write back
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
윤희종
이미지 업로드 API에서의 트랜잭션 병목과 비동기 처리 전략
상단으로

티스토리툴바