Computer Science / / 2024. 3. 12. 09:30

파일 해싱 처리와 JVM 메모리

개요

회사에서 프로젝트를 진행하면서 파일 기반 로직을 도입하게 되었습니다. 이 때, 파일 내용을 해시 알고리즘을 통해 해싱하고 해당 값으로 여러 파일의 중복 여부를 빠르게 파악하고, 이에 따라 다양한 로직을 실행해야 했습니다.

동시에 많은 수의 파일을 입력 받아서 작업을 하기 위해 병렬처리가 필수적인 요구사항이 되었습니다. 병렬처리 상황에서 Out Of Memory(OOM) 오류 없이, 어떻게 최적의 성능을 발휘할 수 있을지에 대한 고민에서 실험을 진행하고, 이 글을 통해 그 경험을 공유하는 것이 목적입니다.

 

JVM의 메모리 구조

JVM 메모리 구조는 여러 영역으로 나뉘는데, Heap, Stack, Method Area, 그리고 Native Method Stack이 주요 구성 요소입니다. 이 중에서 파일 해싱 같은 작업을 할 때 가장 중점을 두어야 할 부분은 바로 Heap 영역입니다.

힙 영역은 동적으로 할당된 객체들이 저장되는 곳으로, 자바 가상 머신이 관리하는 메모리의 핵심 영역입니다. GC의 주된 작업 대상이기도 하고, 이 영역은 모든 스레드가 공유하는 공간으로 애플리케이션의 전반적인 메모리 사용 효율성에 큰 영향을 미칩니다.

파일의 내용을 해싱하는 과정을 예로 들면, 파일의 데이터를 byte 배열의 형태로 읽어들이게 되는데, 이 과정에서 파일 데이터는 영역에 저장됩니다. 이는 파일 해싱 작업뿐만 아니라, 파일을 다루는 다양한 작업에서 기본적으로 수행되는 처리 과정입니다.

 

테스트 조건

  • 한번에 N개의 Request가 들어 올 수 있고, Request당 M개의 File 입력에 따른 가용 가능한 메모리 공간이 많지 않을 수 있다는 상황을 설정하였습니다.
  • 힙의 최소 공간 크기를 128MB로 설정하고, 힙의 최대 공간 크기를 256MB로 설정하였습니다.
  • 입력되는 두꺼운 전자책 한권의 용량이라고 가정하여 e-book 150MB라고 가정하였습니다.
  • 한번의 요청에 100번을 parallel로 처리하도록 유도하였습니다.

 

readAllBytes 또는 getBytes를 이용한 접근 방법

//실행코드
@PostMapping(value = "/hash/read-all", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public void readAll(@RequestPart MultipartFile file) {
    AtomicInteger count = new AtomicInteger(0);
    
    IntStream.range(0, 100)
        .parallel()
        .forEach(i -> {
            long start = System.currentTimeMillis();
            
            try {
                MessageDigest md = MessageDigest.getInstance(ALGORITHM);
                byte[] hashBytes = md.digest(file.getBytes());
                String hash = DatatypeConverter.printHexBinary(hashBytes);
                log.info("count : {}, result : {}", count.addAndGet(1), hash);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                log.info("result : {}ms", System.currentTimeMillis() - start);
            }
        });
    log.info("successful finish");
}

readAllBytes() 또는 getBytes()와 같은 메소드를 사용하여 파일의 모든 내용을 한 번에 메모리에 로드하는 경우, 파일의 크기에 상관없이 전체 파일 내용이 힙 영역에 바이트 배열 형태로 저장됩니다.

이 방식은 파일 크기가 작고, 메모리 사용량에 대한 걱정이 없는 경우에는 더 간단하고 직관적이며 빠르게 동작 할 수 있습니다.

하지만 파일의 전체 내용을 한 번에 힙 영역에 로딩하므로, 처리해야 할 파일의 크기가 클 경우 상당한 양의 메모리를 소비합니다. 이는 특히 대용량 파일을 처리할 때 시스템의 메모리 리소스에 부담을 줄 수 있습니다.

또한 큰 바이트 배열이 힙에 할당되면, 이 데이터가 더 이상 필요하지 않게 되었을 때 이를 수거하는 GC의 작업량도 증가합니다. GC의 실행 시간이 길어질 수 있으며 이는 성능 저하로 이어질 수 있습니다.

//RESULT
[com.donghwan.HashController.lambda$readAll$1:76] - result : 498ms
[com.donghwan.HashController.lambda$readAll$1:76] - result : 700ms
[com.donghwan.HashController.lambda$readAll$1:76] - result : 342ms
org.springframework.web.util.NestedServletException: Handler dispatch failed; nested exception is java.lang.OutOfMemoryError
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1087)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006)
at org.springframework.web.servlet.FrameworkServlet.doPost(FrameworkServlet.java:909)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:528)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:596)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:492)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343)
at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:389)
at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)
at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:926)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1791)
at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.base/java.lang.Thread.run(Thread.java:840) ...

 

InputStream를 이용한 접근 방법

//실행코드
@PostMapping(value = "/hash/input-stream", consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE})
public void inputStream(@Schema(description = "데이터 소스의 문서 File") @RequestPart MultipartFile file) {
    AtomicInteger count = new AtomicInteger(0);
    
    IntStream.range(0, 100)
        .parallel()
        .forEach(i -> {
            long start = System.currentTimeMillis();
            
            try (InputStream in = file.getInputStream()) {
                MessageDigest digest = MessageDigest.getInstance(ALGORITHM);
                byte[] buffer = new byte[8192]; // 8 KB 버퍼
                
                int read;
                while ((read = in.read(buffer)) != -1) {
                    digest.update(buffer, 0, read);
                }
                
                byte[] hashBytes = digest.digest();
                
                StringBuilder sb = new StringBuilder();
                for (byte b : hashBytes) {
                    sb.append(String.format("%02x", b));
                }
                
                String hash = sb.toString();
                log.info("count : {}, result : {}", count.addAndGet(1), hash);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                log.info("result : {}ms", System.currentTimeMillis() - start);
            }
        });
        
    log.info("successful finish");
}

 

InputStream으로 파일을 작은 단위로 읽는 경우, 파일의 전체 내용을 한 번에 힙 영역에 로드하는 대신 필요한 만큼만 메모리에 로드하여 처리합니다.

이 때, MessageDigest와 update 함수는 내부 상태를 유지하기 위해 고정된 양의 메모리만을 사용합니다. 알고리즘의 해시 계산과 중간 상태 저장을 하기 위한 약간의 메모리만을 필요로 합니다. 이 메모리 사용량은 처리하는 데이터의 크기와 무관하며, 따라서 매우 큰 데이터를 처리하더라도 메모리 사용량이 증가하지 않습니다.

대용량 파일을 처리할 때 한 번에 전체 파일을 메모리에 로딩하는 것보다 훨씬 적은 양의 메모리를 사용합니다. 이는 힙 영역의 메모리 사용량을 최소화하여, 메모리 오버헤드를 줄이고 OOM 발생 가능성을 낮춥니다.

점진적으로 데이터를 처리하고 버퍼를 재사용함으로써 생성되는 객체의 수를 줄일 수 있습니다. 이는 가비지 컬렉터의 작업 부담을 경감시켜 성능을 향상시킬 수 있습니다.

//RESULT
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 1, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 2184ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 2, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 2174ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 3, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 2242ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 4, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 2174ms
...
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 97, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 2114ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 98, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 1918ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 99, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 1891ms
[com.donghwan.HashController.lambda$inputStream$0:49] - count : 100, result : 70c4933da917c982e4b4f28d74711ce684c79586d6e9de22ef73f1cc9ef4013629cdf24acfdccc4e60347ad487b5ea343133c2c0f53c941c4058e2a69ae5202a
[com.donghwan.HashController.lambda$inputStream$0:53] - result : 1896ms
[com.donghwan.HashController.inputStream:57] - successful finish

 

결론

readAllBytes나 getBytes와 같은 메소드를 사용하여 파일의 모든 내용을 한 번에 메모리에 로딩하는 접근 방식은, 단순하고 직관적이라는 장점이 있습니다. 소량의 데이터를 다룰 때는 이 방식이 매우 효과적일 수 있습니다.

하지만 대용량 파일을 처리할 때는 상당한 양의 메모리를 소비하고, 결과적으로 성능 저하 또는 OOM 오류를 유발할 위험이 있습니다. 실제로, 우리의 실험에서도 이러한 메소드를 사용했을 때, 처리 속도가 저하되고 메모리 사용량이 급증하여 OOM이 발생하였습니다.

InputStream을 이용한 접근 방식은 파일을 작은 단위로 나누어 읽어들이므로, 한 번에 처리해야 하는 데이터의 양을 최소화합니다. 이 방식은 메모리 사용량을 상당히 줄여줌으로써, 대용량 파일을 효율적으로 처리할 수 있는 방법을 제시합니다. 병렬 처리 과정에서도 각 스레드가 필요로 하는 메모리 양이 줄어들어, 전체적인 시스템의 안정성을 유지할 수 있었습니다. 이 접근 방식은 메모리 사용량을 효과적으로 관리하는 데 성공했습니다.

대용량 파일 처리와 같은 작업에서는 InputStream과 같은 스트림 기반 접근 방식이 메모리 사용과 성능 측면에서 더 우수한 결과를 보여줍니다.

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유