-
Spring 대용량 파일 업로드Back-end/Spring 2025. 10. 30. 10:58
요구사항
- 1GB 이상의 파일 업로드
테스트 결과 문제사항 1
- OOM: Java Heap Space

JVM heap 최대 사이즈를 1GB로 설정하고 1.8 GB 파일을 업로드 한 경우 OOM 발생 상세설명
Request 데이터를 재사용 가능하게 하기위해 HttpServletRequest 를 HttpServletRequestWrapper 로 변환 하는 filter 가 있는데, 해당 필터에서 Request Data 를 InputStream 으로 읽어 들여 ByteArrayOutputStream 으로 캐싱 하는 과정에서 OOM ( OutOfMemory ) 발생.
- HTTP Request Message 내용의 본문을 Inputstream 을 사용해서 한번이라도 읽어 들인다면 본문의 내용은 비워진다
예제 소스)
public class ReadableServletRequestWrapper extends HttpServletRequestWrapper { private ByteArrayOutputStream cachedBytes; private void cacheInputStream() throws IOException { cachedBytes = new ByteArrayOutputStream(); IOUtils.copy(super.getInputStream(), cachedBytes); //OOM 발생 } }ByteArrayOutputStream 를 new 연산자를 사용해 heap 에 생성하고 데이터를 캐싱하는 과정에서 OOM이 발생한다. Content-Type이 application/json, application/x-www-form-urlencoded 요청과 같은 경우는 데이터의 크기가 제한 적이지만 multipart/form-data 는 HTTP Message 본문에 파일 binary 데이터가 포함 되어있기 때문에 크기가 굉장히 커져 버릴 수 있다.
조치 방안 1
- HTTP Request Message 를 래핑 하는 기능으로 인해서 데이터가 위/변조 되었는지 확인하는 HMAC 필터에서 요청 데이터를 검증하고, 특정 API 들이 호출 되기 전에 스프링 AOP 를 활용 해서 요청, 응답 데이터를 데이터 베이스에 저장하기 때문에 필터 제거는 불가능 하다.
- 채택한 방법은 래핑 하는 필터에서 Content-Type이 multipart/form-data 이거나 Content-length 가 300MB 를 초과 한다면 해당 필터를 실행하지 않고 다음 필터를 실행 하는 것이다. 300 MB라는 값은 DB에 저장해서 관리 하도록 했다. 단 여전히 300MB 파일을 수십 개 동시 등록 한다면 같은 오류가 발생 할 수 있기 때문에 multipart/form-data일 경우 해당 필터를 skip 하도록 구현 해도 될 듯 하다.
예제소스)
public class ReadableServletRequestFilter implements Filter { private static final long MAX_BYTE = 300L * 1024L * 1024L; // 300MB private long wrapThresholdBytes = MAX_BYTE; public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; // 멀티파트는 래핑하지 않음 (대용량 업로드 시 메모리 캐싱 위험) final String ct = request.getContentType(); final boolean isMultipart = ct != null && ct.toLowerCase(Locale.ROOT).startsWith("multipart/"); long contentLength = getContentLengthLongCompat(request); if (isMultipart) { chain.doFilter(request, response); return; } // 2) Content-Length 가 알려져 있고, 300MB 초과면 래핑 skip if (contentLength >= 0 && contentLength > wrapThresholdBytes) { chain.doFilter(request, response); return; } // 3) Content-Length 를 모르는 경우(= -1, chunked 등) 안전상 래핑 skip if (contentLength < 0) { chain.doFilter(request, response); return; } ReadableServletRequestWrapper multiReadRequest = new ReadableServletRequestWrapper((HttpServletRequest) req); chain.doFilter(multiReadRequest, res); } private long getContentLengthLongCompat(HttpServletRequest req) { long len = req.getContentLengthLong(); return len >= 0 ? len : -1; // 혹시 모를 -1 처리 } }테스트 결과 문제 사항 2
- 조치 방안 1 로 인해서 대용량 파일 업로드 시 OOM 오류는 더 이상 발생 하지 않으나 로그 이력 저장 기능 에서 오류가 발생 했는데 예제 소스코드는 다음과 같다.
- MultipartResolver는 CommonsMultipartResolver 사용
CommonsMultipartResolver resolver = (CommonsMultipartResolver) MyApplicationContext.getCtx().getBean("multipartResolver"); if(resolver.isMultipart(req)){ MultipartHttpServletRequest multipartRequest = multipartResolver.resolveMultipart(request); }multipart/form-data 메시지의 part 들의 key : value 데이터를 추출 하기 위해 HttpServletRequest 를 MultipartHttpServletRequest 로 변환 하는 부분이 있는데, 이 필터 / AOP 를 거치고 나니 실제 목적지 API 에서는 form-data 가 전부 비워져 있었다.
서브 기능 (로그 이력 저장) 은 정상적으로 동작 했지만 본래의 기능 (파일 업로드)이 동작하지 않았다.
조치 방안 2
- form-data가 비워져 있다는 것은 컨트롤러가 호출 되기 전 필터 나 컨트롤러 프록시 에서 이미 InputStream 을 소비 했을 가능성이 높다고 생각 했다.
- 조사를 해보니 CommonsMultipartReolsver 는 resolveMultipart() 메서드 호출 시 Apache Common Fileupload 라이브러리를 사용 해서 원본 HttpServletRequest 의 InputStream을 소비 하고 다시 주입 하지 않는다. 그래서 HMAC 필터 이후 다음 필터로 request를 넘겨 줄 때 원본 데이터가 사라진 것이다.
Apache Common Fileupload 1.5 미만 버전은 Dos 공격에 취약 하기 때문에 1.5 이상 버전 사용 할 것을 권장
- CommonsMultipartReolsver는 더 이상 권장 되지 않는 멀티파트 리졸버 이며, Apache Common Fileupload, Apache Common IO 라이브러리와도 의존성이 있다. 현재 ITSM 시스템은 전자정부표준프레임워크 기반 이기 때문에 Default 로 사용 하고 있었지만, 지금 스프링에서는StandardServletMultipartResolver 를 기본으로 사용 하고 있고 StandardServletMultipartResolver 의 resolveMultipart() 소스는 다음과 같다.
public MultipartHttpServletRequest resolveMultipart(HttpServletRequest request) throws MultipartException { return new StandardMultipartHttpServletRequest(request, this.resolveLazily); } public StandardMultipartHttpServletRequest(HttpServletRequest request, boolean lazyParsing) throws MultipartException { super(request); if (!lazyParsing) { parseRequest(request); } } private void parseRequest(HttpServletRequest request) { try { Collection<Part> parts = request.getParts(); //Inputstream 으로 데이터 읽고 다시 원본 request 에 데이터 주입 this.multipartParameterNames = new LinkedHashSet<String>(parts.size()); MultiValueMap<String, MultipartFile> files = new LinkedMultiValueMap<String, MultipartFile>(parts.size()); for (Part part : parts) { String filename = extractFilename(part.getHeader(CONTENT_DISPOSITION)); if (filename != null) { files.add(part.getName(), new StandardMultipartFile(part, filename)); } else { this.multipartParameterNames.add(part.getName()); } } setMultipartFiles(files); } catch (Exception ex) { throw new MultipartException("Could not parse multipart servlet request", ex); } }- 주목할 부분은 parseRequest 의 request.getParts() 이다. getParts() 를 호출 하고 상위 클래스를 따라 가다 보면 org.apache.catalina.connector.Request.parseParts ($TOMCAT_HOME/lib/catalina.jar) 를 호출 하게 되는데 parseParts() 내부에서 원본 request 를 inputstream 으로 읽는 부분, 원본 request에 파라미터 다시 주입 하는 부분, Collection<Part>에 파트를 주입 하는 부분이 있다.
- 요약 : CommonsMultipartResolver를 StandardServletMultipartResolver 로 변경하고, HttpServletRequest 의 getParts() 를 사용 하기 위해 기존 서블릿 버전 2.5 에서 4.0 으로 변경.
- HttpServletRequest의 getParts() 는 multipart/form-data 요청일 경우 각 part 별로 데이터를 반환 해주는 API 이다. 사용법이 간편해서 소스 코드가 깔끔해서 채택 했다.
- 또한 MultipartResolver 의 config 설정은 기존 CommonsMutlipartResolver bean 을 생성 할 때 주입 하는 부분을 제거하고 servlet 설정에서 (web.xml) 다음과 같이 설정
⚙️ 설정 변경 (web.xml)
<multipart-config> <max-file-size>2500000000</max-file-size> <!-- 개별 파일 2.5GB --> <max-request-size>2500000000</max-request-size> <!-- 전체 요청 2.5GB --> <file-size-threshold>307200</file-size-threshold> <!-- 300KB 이상 디스크 저장, 이하는 메모리 사용 --> </multipart-config>📈 설정 설명 — file-size-threshold
- 업로드 파일을 메모리에 둘지 / 임시파일로 쓸지 결정하는 임계값(byte 단위).
- 예를 들어 file-size-threshold=300KB 라면:
- 300KB까지는 RAM에 저장
- 초과분은 /tmp (OS 기본 temp 디렉터리)로 임시 파일 생성
- <location></locaiton> 속성으로 임시파일 디렉터리 지정 가능
- 요청이 종료되면 Tomcat이 자동으로 임시 파일 삭제.
⚠️ 주의
- 값이 너무 크면(예: 1GB) 여러 파일 동시 업로드 시 Heap 메모리 고갈 위험.
- 대용량 업로드가 아주 많은 시스템이라면 0 (항상 디스크 사용) 으로 설정하는 것이 바람직하다.
- 단, 0으로 설정하면 mutlipart 의 일반 폼 필드 파트 같은 작은 데이터도 별도의 임시파일에 쓰기 때문에 약간의 값을 설정 하는 것이 좋다.
- 반면, 소규모 업로드(수십 KB)만 존재하는 경우에는메모리 저장이 디스크 I/O보다 훨씬 빠르므로 성능상 유리하다.
✅ 최종 정리
- OOM 원인: Request InputStream을 전부 Heap으로 캐싱하는 필터 구조.
- 조치: multipart/form-data 및 대용량 요청은 필터 skip.
- 추가 개선:
- Commons → Standard 리졸버 전환
- Servlet 버전 2.5 → 4.0 업그레이드
- file-size-threshold=0 설정으로 메모리 사용 최소화
💬 “작은 파일은 메모리에, 큰 파일은 디스크에” —
file-size-threshold는 성능과 안정성 사이의 균형을 조절하는 핵심 설정이다.
'Back-end > Spring' 카테고리의 다른 글
[Spring] 어노테이션 @ExceptionHandler (0) 2022.04.12 [Spring] 스프링이 제공하는 ExceptionResolver (0) 2022.04.12 [Spring] API 예외 처리 (0) 2022.04.12 [Spring] 예외 처리(Exception)와 오류 페이지 (0) 2022.04.11 [Spring] 스프링 인터셉터(Spring Interceptor) (0) 2022.04.11