문제 배경
서비스에는 사용자가 이미지를 업로드하면, 해당 파일을 다른 인스턴스(배경 제거 워커) API 서버로 전달해 배경을 제거하는 기능이 있었습니다. 파일 전송은 Spring WebFlux의 WebClient를 사용하여 POST /ml/remove-background API를 호출하는 방식으로 구현되었습니다.
스프링 서버는 WebClient를 사용했지만, 전체적인 비즈니스 로직 흐름에 맞춰 block()을 호출해 동기적으로 결과를 기다리는 구조였습니다.
장애 발생과 증상
최근 에러 모니터링 강화를 위해 요청 본문(Request Body)을 로깅하는 캐싱 필터(ContentCachingRequestWrapper 등)를 도입했습니다. 유닛 테스트는 모두 통과했지만, 실제 운영 환경에 배포한 직후부터 파일 전송이 실패하는 현상이 발생하기 시작했습니다.
로그를 확인한 결과, 다음과 같은 IllegalStateException 예외가 발생하고 있었습니다.

"InputStream has already been read … do not use InputStreamResource if a stream needs to be read multiple times"
이 예외는 InputStream을 두 번 이상 읽으려고 할 때 발생하는 문제로, 스트림이 이미 소비된 상태에서 다시 읽기를 시도했음을 의미합니다.
근본적인 원인 분석
문제의 핵심 원인은 InputStream의 일회성(single-read) 특징과 새로 도입한 캐싱 필터의 동작 방식이 충돌했기 때문입니다.
InputStreamResource의 특징:InputStream기반의 리소스는 데이터를 한 방향으로 순차적으로 읽는 스트림입니다. 한 번 읽고 나면 스트림의 끝에 도달하여 다시 읽을 수 없습니다.- 캐싱 필터의 동작: 에러 로깅 및 디버깅을 위해 도입한 캐싱 필터는 요청이 들어올 때마다 요청 본문을 읽어서 내용을 캐싱하거나 로그로 기록합니다. 이 과정에서
InputStream을 최초로 읽어 소모시킵니다. - 문제 발생 지점: 필터를 통과한 요청이 실제 비즈니스 로직으로 전달되면,
WebClient는 외부 API로 파일을 전송하기 위해 다시 한번InputStream을 읽으려고 시도합니다. 하지만 스트림은 이미 캐싱 필터에 의해 소모된 상태이므로,IllegalStateException이 발생하게 된 것입니다.
결론적으로, 캐싱 필터가 로깅을 위해 스트림을 먼저 읽고, WebClient가 전송을 위해 다시 읽으려 하면서 오류가 발생한 것이었습니다.
해결 방안
이 문제를 해결하려면 여러 번 읽기가 가능한 리소스를 사용해야 합니다.
가장 안정적인 해결책은 업로드된 파일을 메모리에 byte[](바이트 배열) 형태로 먼저 읽어들인 후, 이를 ByteArrayResource로 감싸서 전송하는 것입니다. byte[]는 데이터 전체를 메모리에 보관하므로, 캐싱 필터가 먼저 읽든 WebClient가 나중에 읽든 몇 번이고 재사용이 가능합니다.
수정 전 코드
InputStreamResource는 한 번만 읽을 수 있어 캐싱 필터와 함께 사용할 때 문제가 발생합니다.
// 문제: InputStreamResource는 1회성 리소스입니다.
Resource fileResource = new InputStreamResource(file.getInputStream()) {
@Override
public String getFilename() {
return filename;
}
};
수정 후 코드
ByteArrayResource는 메모리의 바이트 배열을 사용하므로 반복해서 읽을 수 있습니다.
byte[] fileBytes = file.getBytes();
Resource fileResource = new ByteArrayResource(fileBytes) {
@Override
public String getFilename() {
return filename;
}
};
결론
이번 장애의 원인은 InputStreamResource의 일회성 특징을 간과한 채, 요청 본문을 미리 읽는 캐싱 필터를 도입하면서 발생한 충돌이었습니다.
이를 반복 읽기가 가능한 ByteArrayResource로 교체함으로써 캐싱 필터와 WebClient가 모두 문제없이 동작하도록 수정했습니다. 이 경험을 통해 스트림을 다룰 때는 소비 주체를 명확히 파악하고, 여러 컴포넌트가 데이터를 공유해야 할 경우 재사용 가능한 리소스를 사용해야 한다는 점을 다시 한번 확인하게 되었습니다.
'프로덕트 문제 해결 기록' 카테고리의 다른 글
| [ThatzFit] AOP로 로깅 일원화 (0) | 2025.09.02 |
|---|---|
| [ThatzFit] Spring Data JPA 비관적 락 리팩토링: 문제 해결 기록 (1) | 2025.09.01 |
| [ThatzFit] 트랜잭션 최적화: 쓰기 경계 분리 (1) | 2025.09.01 |
| [ThatzFit] API 에러 모니터링: 체계적인 슬랙(Slack) 알림 시스템 구축기 (0) | 2025.08.14 |
| [WIMI] Spring에서 "No acceptable representation" 오류 (0) | 2025.03.28 |