티스토리 뷰

회원들의 정보를 엑셀 다운로드를 했는데 메모리 부족 에러(OOM)가 뜬다. 
자세히 들여보니, 대량의 데이터를 조회했을 때 DB에서 애플리케이션한테 데이터를 받는 과정에서 OOM 발생하였다.

발생한 원인을 파악하기 위해 JVM heap 메모리 최대 크기에 대해 알아보았다.
메모리 최대 크기는 운영 체제 및 시스템의 물리적 메모리 등 따라서 정해진다.
내 PC 기준(mac, 64bit, 메모리 32GB) 사용 가능한 최대 메모리와 JVM heap memory 조회해봤더니

// 사용 가능한 최대 메모리
long maxMemory = Runtime.getRuntime().maxMemory();

// JVM heap memory
long heapSize = Runtime.getRuntime().totalMemory();

- 사용 가능한 최대 메모리 : 8589934592 byte >> 8GB
- JVM heap memory : 645922816 byte >> 640MB


그렇다면 애플리케이션이 DB로부터 대용량 데이터를 받을 때 heap 사용량은 어떻게 될까?
50만건 회원을 조회한다고 가정했을 때 heap 사용량을 보면

// 회원 조회(50만건)
List<Member> members = memberRepository.findAll();

scouter 모니터링 지표

특정 부분에서 지표가 급격히 솟아오는 것을 볼 수 있다.
이건 대용량 데이터를 함꺼번에 가져오면 heap memory 무리하게 사용한다는 것을 예측할 수 있다. 
이런 경우로 인해서 메모리가 부족하게 되면 OOM 발생하게 된다.

 

 

1. 어떻게 개선하지?


일단 대량의 데이터를 효율적으로 가져올 수 있는 방안은 Java 8에서 도입된 "Stream + fetch size" 조절하는 것이다.
Stream을 사용하면 Lazy Loading 방식으로 데이터를 처리하므로 대량의 데이터를 다룰 때 메모리 관리와 효율성 면에서 이점이 있다. 기존에는 List을 사용해서 모든 데이터를 함꺼번에 메모리에 적재했다면, Stream는 모든 배열을 즉시 메모리에 할당하는 것이 아니라 필요한 경우에만 데이터를 처리하게 됩니다.


Stream 최적화를 하기 위해서 fetch size 조절해서 몇 개씩 데이터를 가져올지 설정해야 한다.
JDBC 표준에서 제공하는 fetch size는 DB 서버로부터 애플리케이션으로 한 번에 가져올 레코드의 건수를 설정하는 역할을 한다. 만약 fetch size을 80 설정하면 80개씩 레코드를 가져오게 된다.


MySQL JDBC 드라이버는 기본적으로 모든 결과를 애플리케이션으로 바로 가져오는 것이 기본값입니다.
스트리밍 방법을 사용할려면 "Integer.MIN_VALUE" 값을 설정해야 한다. 만약 80 설정한다면 이론적으로는 한 번에 80개씩 가져와야 하지만, 실제로 이러한 설정은 스트리밍 효과를 내지 못하며 여전히 모든 데이터를 메모리에 적재할 수 있습니다. 그 이유는 MySQL 드라이버가 이 fetch size 값이 특별히 "Integer.MIN_VALUE" 일 때만 스트리밍 모드로 작동하도록 설계되어 있기 때문이다.

 

[ 개선하기 ]

이 원리를 바탕으로 JPA, Querydsl, Mapper 설정을 해보자.
- JPA/QueryDsl 사용시 : java.util.stream.Stream
- Mapper 사용시 : org.apache.ibatis.cursor.Cursor
- Stream/Cursor는 AutoCloseable를 상속

JPA
@QueryHints(value = {
  @QueryHint(name = HINT_FETCH_SIZE, value = Integer.MIN_VALUE + ""),
  @QueryHint(name = HINT_CACHEABLE, value = "false")
})
@Query("select m from Member")
Stream<Member> findStream();

 

Querydsl
public Stream<Member> findStream() {
    return jpaQuery()
            .setHint(QueryHints.HINT_FETCH_SIZE, Integer.MIN_VALUE)
            .stream();
}

 

Mapper
Cursor<Member> findCursor();

// xml
<select id="findCursor" resultType="Member" fetchSize="-2147483648">
    SELECT * FROM member
</select>

 

Service 예제 코드
@Transactional(readOnly = true)
public void excelDownload(MemberExalReqDto dto, HttpServletResponse response) {
    try (Stream<Member> members = memberRepository.findStream()) {
        // 엑셀 다운로드        
        members.forEach(x -> {...});
    }
}

50만건 회원들을 DB -> 애플리케이션 한 건씩 가져와서 처리를 하고 있다.
JVM heap 메모리 사용량을 보면 일정한 주기로 메모리를 사용하는 것을 확인할 수 있다.

scouter 모니터링 지표

 

 

[ 주의사항 ]

Connection Timeout
- Stream을 통해서 DB connection 하기 때문에 비즈니스 로직이 종료되기 전까지 계속 커넥션을 맺게 된다.
Connection을 반납하지 않는 구조이기 때문에 Connection Timeout이 발생할 수 있기 때문에 조심해야 한다.

@Transactional
- 1개의 DB connection을 사용하여 전체 데이터를 조회해야 하므로 Transactional annotation이 필수이다. 




참고
Spring Batch Reader 성능 분석 및 측정 part 2
Framework Cursor (cursor in MySql)
JDBC API Implementation Notes