컴퓨터는 정해진 규칙에 의해 돌아가고 있기 때문에, 어떤 처리를 하려면 입력으로부터 필요한 수를 계산하거나 미리 알고 있는 수를 바탕으로 판단해야한다. 네트워크 통신에서 기본 몇 byte를 읽을 것이냐, 연결이 되지 않을 경우 몇초를 기다릴 것인가 등등.
이러한 동작에 모두 기본값(Default value)라는 것이 개입하고 있고, 이번 글에선 현재 레거시 서비스에서 사용되는 MyBatis에서 잘 손대지 않는 파라미터 값인 fetchSize
와 이것이 Cursor
에 어떤 영향을 주는지 정리해둔다.
MyBatis 사용시 통상작업과 Cursor의 차이
통상적으로 Spring에서 MyBatis를 사용하는 코드는 다음과 같이 동작한다.
- 프로젝트 가동시 DAO Mapper 인터페이스 선언을 바탕으로, MyBatis가 동적으로 생성한 바이트코드로 프록시를 구성해 XML 정의로부터 데이터베이스 작업을 할 수 있게 준비한다.
- DAO Mapper를 통해 데이터베이스 작업이 진행되면, 알맞은 드라이버나 풀을 통하여 작업을 수행한다.
- 2의 작업이 완료될 때까지 코드는 블로킹된다.
- 모든 작업이 완료되면 다시 Spring의 구성요소로 코드 흐름이 돌아온다[1]
하지만 Cursor
는 다르다. 직설적으로 이야기하면, 이 시절 동작을 MyBatis에서 다시 경험하는 것이다.
1 | try (PreparedStatement pstmt = con.prepareStatement(updateString)) { |
Java의 Iterable<T>
인터페이스에 맞게 구현되어 있어 foreach
구조로도 돌릴 수 있어 유용한 Cursor
는, 한줄씩(row by row)로 차근차근 읽어오는 것이 기본 동작이다. 이게 가능하기 때문에, 데이터베이스로부터 모든 데이터를 받아올 때까지 DAO에서 멈춰있는게 아니라 최초 Cursor
가동에 필요한 데이터만 받아지면 DAO를 실행한 다음줄로 코드 흐름이 넘어간다.
즉, 아래와 같이 이전에 언급한 통상적 흐름이 바뀐다.
- 프로젝트 가동시 DAO Mapper 인터페이스 선언을 바탕으로, MyBatis가 동적으로 생성한 바이트코드로 프록시를 구성해 XML 정의로부터 데이터베이스 작업을 할 수 있게 준비한다.
- DAO Mapper를 통해 데이터베이스 작업이 진행되면, 알맞은 드라이버나 풀을 통하여 작업을 수행한다.
- 트랜잭션을 시작한다. Cursor의 경우, 2의 작업이 Cursor로 iteration을 반복할 수 있는 상태가 되면 모든 데이터가 받아지지 않았더라도 DAO 인터페이스를 통해 Cursor를 반환한다.
- 데이터베이스 커넥션(
PreparedStatement
나Statement
등)이 유지되는 동안에 필요한 작업을 수행한다.Cursor
가 결과 데이터셋의 끝에 도달할때까지 작업을 반복할 수 있다. - 트랜잭션이 종료되었다고 판단되면 커넥션은 자동으로 프레임워크에 의해 닫힌다.
이에 따라 JVM 메모리에 한번에 모든 결과를 올려둘 필요가 없으므로, 충분한 시간만 주어진다면[2] 조회 데이터 수가 많더라도 OutOfMemoryError
없이 데이터를 모두 읽어서 처리할 수 있다.
다만 Cursor
는 지금은 위의 코드처럼 사람이 일일이 커넥션 관리를 할 필요는 없고, 프레임워크와 잘 검증된 라이브러리들의 도움을 받을 수 있다는 것이 다르다. 나아졌다면 더 나아졌다고 볼 수 있을 것이다.
Cursor 적용과 주의점
Cursor 의 적용 자체는 매우 간단하다. DAO Mapper 인터페이스 선언의 리턴 타입을 타입 제네릭을 활용해 Cursor<T>
로만 감싸주면 된다.
1 | List<String> selectWholeIds(); |
위의 선언을,
1 | Cursor<String> selectWholeIds(); |
로 바꾸고 import 를 정리해주면 된다. 그리고 연관된 코드를 수정하고 나면 프로젝트 가동 자체에는 문제가 없지만, 이 Cursor
객체는 Spring의 서비스(@Service
) 레벨에서 이렇게 짜면 컨트롤러에선 데이터를 받아올 수 없다.
1 |
|
왜 이렇게 짜면 안되는 걸까? Cursor
는 한줄씩 데이터를 처리할 수 있게 해준다고 앞에서 언급했는데, 이는 즉 데이터 처리가 끝나면 다음 줄을 읽어와야하기 때문이다. 그러려면 전체 데이터를 모두 순회할 때까지 데이터베이스 연결이 유지되어야 한다는 걸 의미한다.
해야될 것만 정리하면 해당 서비스 메소드에 @Transactional
을 달아 트랜잭션 상태를 유지시키고, @Service
메소드를 벗어나기 전에 Cursor
를 써야하는 작업을 모두 마쳐야한다는 것. Cursor
를 서비스 외부로 유출해선 안된다. 위 코드에서는 selectWholeIds
메소드 안에서 다 해결해야한다.
1 |
|
Cursor와 fetchSize 속성의 관계
Cursor
에 대한 개념과 적용 예시를 간단히 살펴보았는데, 성능과 연산 복잡도에 대해 걱정하는 분들은 오히려 Cursor
를 적용하고나서 성능이 감소하지 않을까 걱정할 수도 있는데 실제로 이 기우는 쓸데없는 걱정이 아니다. 그러나 원인을 알면 개선할 수 있다.
실제로 고정된 데이터세트에서 이전에 8000~9000ms 걸리던 파일 변환 작업이 Cursor
적용 후에 적게는 500ms, 많게는 2000ms 이상 더 소요되기도 하였다.
왜 그런지 생각해보면 꽤 간단한데, 통상적 구현에서는 JVM이 그 쓰레드 내에서는 DB에서 데이터를 받아오는 과정만 집중하면 되는데 중간중간 다른 작업을 돌림으로써 효율이 떨어지는 것이다. Context switching으로 인한 손해가 제법 있다.
데이터베이스와 통신하는 작업에서 많은 오버헤드(Overhead)가 발생되는데, 측정 자료는 회사 내부에서 공개에 대한 허락를 받지 못하여 이 점은 양해 바란다. 네트워크 성능과 서버 사양이 허용되는 선에서 이 작업은 적정 빈도로 줄이면 성능 개선을 얻을 수 있다.
네트워크 통신보다 메모리에 올라가 있는 내용을 컴퓨터가 처리하는게 몇배는 더 빠르다. 따라서 얼만큼 처리할 데이터를 잘 쟁여놓느냐(pre-fetch)는 처리와 통신 시간 사이의 적절한 저울질의 승부다.
- 통신 빈도를 줄인다 - 통신 한번에 받아올 데이터의 양이 늘어난다(캐시를 많이 해야하므로 JVM 메모리를 많이 먹는다)
- 통신 빈도를 늘린다 - 통신 한번에 받아올 데이터의 양이 줄어든다(캐시를 적게 해도 되므로 JVM 메모리를 적게 먹는다)
이 trade-off에서 현재 서비스 환경과 실정에 맞는 성능을 뽑아야하는데 정확한 가이드는 없다. 몇가지 시도를 하고, 벤치마크 툴 등을 통해 최적수치를 예측해보아야한다.
한번에 받아오는 데이터량을 조절하는 이 속성이 fetchSize
인데, 단위는 행(row) 수이다. 세가지 설정 방법이 있다.
- MyBatis 전역 설정
- Mapper XML 에 개별 설정
- DAO 인터페이스에
@Options
어노테이션으로 개별 설정
적절한 성능은 어디에서 나올지 모른다. DBMS 구현에 따라서도 다르고, Java 버전에 따라서도 소소한 차이가 예상된다. 이 부분에 좀 더 관심있다면 stackoverflow 등지에서 적정수치가 얼마인지 물어보는 글들을 찾아보는게 도움이 된다.
prefetch 크기의 결정 요소와 Oracle Database Driver
MyBatis SQL Mapper XML 문서에 보면 fetchSize
로 검색해볼 때 이에 대해 설명하는 부분이 있다.
fetchSize: This is a driver hint that will attempt to cause the driver to return results in batches of rows numbering in size equal to this setting. Default is unset (driver dependent).
놀랍게도 이 크기는 별도 설정이 없다면 드라이버 기본 설정을 따라가게 되어있다. 그리고 회사에서 쓰던 Oracle 12c의 드라이버의 기본값은 10
이다.[3][4]
벌크 데이터 처리를 하는데 이 값을 그대로 두고 계속 사용했다면, 데이터 통신 오가는 과정(trip)에서 많은 손해를 봤었을 수도 있겠다고 생각한다. 실제로 내부 업무에 적용해본 결과, fetchSize
를 조정하고 Cursor
를 적용하는 것으로 워밍업 여부에 따라서 데이터 추출 및 파일 생성에 드는 시간을 기존 대비 60% 이하로 들도록 단축을 할 수 있었다. Out of Memory 걱정도 덜고, 쿼리 최적화 작업 없이.
결론
우리가 무심결에 ‘돌아가니까 된거 아닌가’라고 생각했던 부분을 다시 살펴보는 것은 중요하다. 단순히 이 수치를 조정하는 것만으로 성능을 얻은 이번 경우는 운이 좋았던 것 같다.
만약에 데이터베이스 통신 작업과 메모리에 올라온 데이터를 CPU가 처리하는 작업 간의 속도 차이를 사전에 인지하고 있지 않았다면 이런 시도를 해보기 어려웠을 것 같다. 블랙박스로 놓고 쓰고 있던 부분을 한번쯤 살펴보는 것도 재밌다.
사내에 쓰고 있는 프로젝트는 테스트 작성 후 전환을 통해 단계적으로 파일 생성처리에 쓰이는 작업들을 바꿔나가고 있다. 다만, 데이터가 적어서 굳이 이렇게 공을 들여야하는 싶은 업무들에 대해선 고민 중인 부분들도 많다.
- 1.보통은 Service 요소로 돌아온다 ↩
- 2.서비스 품질을 위한 조회 제한 시간, MyBatis에서 자체적으로 설정 가능한 타임아웃, 네트워크 보안 장비 들에 의해 유지할 수 있는 최장 커넥션 시간 등을 고려 ↩
- 3.https://docs.oracle.com/en/database/oracle/oracle-database/12.2/jjdbc/resultset.html#GUID-BD27B43E-525F-44C4-973F-18D2BB8BA007 ↩
- 4.https://docs.oracle.com/en/database/oracle/oracle-database/12.2/jjdbc/JDBC-coding-tips.html#GUID-B83114F0-8B95-473C-8018-5083C777B5A3 ↩