ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 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>에 파트를 주입 하는 부분이 있다.
    • 요약 : CommonsMultipartResolverStandardServletMultipartResolver 로 변경하고, 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는 성능과 안정성 사이의 균형을 조절하는 핵심 설정이다.

Designed by Tistory.