ETS 모델 기반 시계열 예측

미래를 예측하고 대비하는 일은 비즈니스의 가장 핵심적인 부분입니다. 전력 수요를 예측해야 발전소를 증설할 수 있고, 콜센터의 통화수를 예측해야 적정한 수의 상담원을 배치할 수 있습니다. 예측 모델은 여러가지 변수를 포함할 수 있지만, 과거의 이력을 기반으로 미래의 추세를 예측한다는 점에서는 공통적입니다.

가장 간단한 단순회귀분석부터, 다중회귀분석, 딥러닝에 이르기까지 여러가지 모델링 방법이 있지만, 여기에서는 시계열 데이터에 쉽게 적용할 수 있으면서도 좋은 결과를 보여주는 ETS 모델을 알아보도록 하겠습니다.

ETS 모델은 업계에서 널리 사용되는 시계열 예측 모델로서, 지수평활법(Exponential Smoothing)을 기반으로 합니다. 지수평활법은 과거의 관측치에 시간의 흐름에 따른 가중치를 주고 합산하여 미래를 예측하는 방식입니다. 단순 지수평활법 (Single Exponential Smoothing) 에서 출발하여 하나씩 살펴보면 ETS 모델을 이해할 수 있습니다.

지수평활법: Exponential Smoothing

단순 지수평활법 (Single Exponential Smoothing)은 다음 예측치 (St)를 현재 값 (yt−1)과 이전 예측치(St−1)의 합산으로 계산합니다. 알파(α)는 0보다 크고 1보다 작은 스무딩 매개변수입니다:

St = α yt−1 + (1−α) St−1
실제 이 수식이 어떻게 동작하는지 예제 값을 넣어서 직관적으로 이해해볼 수 있습니다. 아래의 예제는 엑셀로 수식을 만들어서 스무딩 매개변수의 조정에 따른 변화를 표현한 것입니다.

α = 0.1

α = 0.5

α = 0.9

매우 단순한 수식이지만 스무딩 매개변수에 따라 원본 그래프에 근접하게 변화하는 모습을 볼 수 있습니다. 최적의 스무딩 매개변수를 찾으면 해당 수식을 이용하여 미래의 값도 재귀적으로 예측할 수 있습니다.

13번 행부터는 관측치가 없기 때문에 y를 마지막 값으로 고정하고 계산하면 위와 같이 예측치가 계산됩니다. 즉, 시계열 예측이 스무딩 매개변수에 따른 모형의 에러를 최소화하는 최적화 문제로 변환된 것입니다.

그러나, 단순 지수평활법의 단점은 추세가 있는 경우 잘 모델링하지 못한다는 점입니다. 이중 지수평활법 (Double Exponential Smoothing) 은 이러한 단점을 보완합니다. 아래의 예제는 이미 각 모델에 대해 최적으로 선정된 스무딩 매개변수 값을 사용하여 계산된 결과를 보여줍니다.

이중 지수평활법은 두 개의 방정식을 사용합니다.

St = α yt + (1 − α) (St−1 + bt−1)

bt = γ (St - St-1) + (1 − γ) bt−1

Ft+m = St + mbt

첫번째 수식은 이전 St-1 값에 추세변화량을 더하여 기저를 생성합니다. 두번째 수식은 추세변화량을 보정하는 역할을 수행합니다. 예측치는 기저와 추세변화량을 합산한 값입니다. 위의 그래프를 통해 단순 지수평활법과 이중 지수평활법의 예측 특성 차이를 확인할 수 있습니다.

이렇게 이중 지수평활법은 추세를 반영하지만 여기에 더해서 계절성 (Seasonality)이 있는 경우를 잘 반영하지 못합니다. 이 때문에 삼중 지수평활법 (Triple Exponential Smoothing) 혹은 홀트-윈터스 모델 (Holt-Winters) 이라 불리는 방법이 제안되게 됩니다.

ETS 모델

ETS 모델은 Error, Trend, Seasonality 3가지 요소로 구성된 모델을 의미합니다. 시계열 데이터는 추세의 특성과, 계절성을 각각 조합하여 다음과 같이 12가지의 유형을 상정할 수 있습니다.

각 모델을 수식으로 표현하면 아래와 같습니다. 예를 들어, 에러, 추세, 계절성이 모두 가산적인 모델이라면 ETS(A,A,A) 혹은 ETS AAA 모델로 표기합니다. 계절성이 없는 가산적 모델이라면 ETS(A,A,N)에 해당됩니다.

  • N: 상수 모델 (Constant)
  • A: 가산적 모델 (Additive)
  • Ad: 감쇄하는 가산적 모델 (Damped Additive)
  • M: 승법적 모델 (Multiplicative)
  • Md: 감쇄하는 승법적 모델 (Damped Multiplicative)

forecast 커맨드

로그프레소는 주어진 시계열 데이터에 대해 위와 같이 다양한 ETS 모델을 대상으로 에러를 최소화하는 시계열 모형을 자동으로 탐색합니다. 예측된 값은 _future 필드로 출력되며, 추세 (_trend), 상위 95% 신뢰구간 (_upper), 하위 95% 신뢰구간 (_lower) 필드를 동시에 출력합니다.

table order=asc passengers 
| forecast passengers

둘러보기

더보기

JMH로 알아보는 오토 박싱의 부하

쉽게 쓰여진 자바 코드에서는 일반적으로 정수 값의 목록을 List 클래스로 관리합니다. 자바 컬렉션 프레임워크는 구조적으로 잘 설계된 편이지만, 자바 언어의 한계로 프리미티브 타입을 자바 컬렉션으로 관리하는 경우 성능 상 많은 불이익을 받게 됩니다. 이는 자바 컬렉션이 설계적으로 Object 타입만 받아들일 수 있고, 프리미티브는 Object를 상속하지 않기 때문에 자동으로 객체 변환(Boxing)을 수행하기 때문입니다. 64비트 시스템에서 int 프리미티브는 8바이트를 차지하는 반면, Integer 객체는 포인터 8바이트, 객체 헤더 16바이트, 데이터 8바이트로 총 32바이트를 소모하므로 메모리량을 많이 차지할 뿐더러, GC 작업에 있어서도 하나의 프리미티브 배열에 비해 개별 Integer 객체를 모두 추적해야 하므로 많은 비용이 들어갑니다. 자바 컬렉션으로 인한 성능 페널티는 계산량이 많은 응용프로그램일수록 더욱 두드러지는데, 수십-수백 테라바이트의 역인덱스 포스팅 리스트를 스캔해야 하는 검색엔진이나, 대량의 연산을 수행해야 하는 데이터베이스에서도 마찬가지로 큰 성능 저하를 유발합니다. 오래된 예이지만, 루씬 3.5 시절의 아래 이슈는 객체 참조로 인한 메모리 사용량을 줄이고 프리미티브 배열로 처리하는게 어느 정도의 성능 향상 효과를 가져올 수 있는지 잘 보여줍니다: * [\[LUCENE-2205\] Rework of the TermInfosReader class to remove the Terms\[\], TermInfos\[\], and the index pointer long\[\] and create a more memory efficient data structure](https://issues.apache.org/jira/browse/LUCENE-2205) ### JMH: Java Microbenchmark Harness 이제 오토박싱으로 인한 부하를 실제 측정해보기에 앞서, JMH를 소개하도록 하겠습니다. JMH는 JDK에서 공식적으로 제공하는 마이크로 벤치마크 프레임워크입니다. JMH를 기반으로 벤치마크 테스트를 작성하는 것을 권장하는 이유는, 조심스럽게 테스트 코드를 작성하지 않으면 JVM에서 제공하는 다양한 최적화 기법 때문에 벤치마크 테스트가 의도한대로 동작하지 않고 왜곡된 결과를 내놓을 수 있기 때문입니다. 아래와 같이 몇 가지 잘못된 예를 생각해볼 수 있습니다: <ul><li>JIT 컴파일 여부: 핫스팟 컴파일러는 일정 횟수 이상 실행되는 메소드를 컴파일하는데, 만약 웜업 단계를 생략하게 되면 결과에 왜곡이 발생하게 됩니다.</li><li>데드코드 제거: 벤치마크 작성 시 성능 측정 대상 코드만 간단히 루프에 넣어 돌리는 경우가 흔한데, 핫스팟 컴파일러는 참조되지 않는 무의미한 코드를 자동으로 삭제하기 때문에 왜곡된 결과를 얻을 수 있습니다.</li><li>버추얼테이블 최적화: 인터페이스 구현체가 1개인 경우에는 실행 시 분기할 필요가 없기 때문에 네이티브 코드가 최적화됩니다. 그러나 코드 실행에 따라 동일 인터페이스를 구현하는 클래스가 추가로 로드되는 경우, 이전 실행과 달리 왜곡된 결과를 얻을 수 있습니다.</li></ul> ### JMH 벤치마크 프로젝트 만들기 Maven을 이용해서 새 JMH 프로젝트를 생성합니다. ``` $ mvn archetype:generate -DinteractiveMode=false -DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype -DgroupId=com.logpresso -DartifactId=benchmark -Dversion=1.0 ``` 그러면 아래와 같이 샘플 코드가 생성됩니다. ```java package com.logpresso; import org.openjdk.jmh.annotations.Benchmark; public class MyBenchmark { @Benchmark public void testMethod() { // This is a demo/sample template for building your JMH benchmarks. Edit as needed. // Put your benchmark code here. } } ``` 아래와 같이 두 개의 벤치마크 테스트 코드를 작성합니다. ```java package com.logpresso; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.runner.Runner; import org.openjdk.jmh.runner.RunnerException; import org.openjdk.jmh.runner.options.Options; import org.openjdk.jmh.runner.options.OptionsBuilder; public class MyBenchmark { private static final int MAX_SIZE = 10000000; @Benchmark public int[] testPrimitive() { int[] array = new int[MAX_SIZE]; for (int i = 0; i < MAX_SIZE; i++) array[i] = i; return array; } @Benchmark public Integer[] textBoxing() { Integer[] array = new Integer[MAX_SIZE]; for (int i = 0; i < MAX_SIZE; i++) array[i] = i; return array; } } ``` 컴파일 후 아래와 같이 실행합니다: ``` $ mvn clean package $ java -jar target/benchmark-1.0.jar ``` 이제 실행하면 아래와 같은 결과를 볼 수 있습니다: ``` # Run complete. Total time: 00:14:20 Benchmark Mode Cnt Score Error Units MyBenchmark.testPrimitive thrpt 200 166.054 ± 1.265 ops/s MyBenchmark.textBoxing thrpt 200 32.191 ± 0.484 ops/s ``` 프리미티브 배열의 처리량이 객체 배열에 비해 약 5배 정도 높은 것을 확인할 수 있습니다. 전체 실행 결과는 [이 링크](https://gist.github.com/xeraph/06cedd1054ad0ff3e9536e2b7115a537)에서 볼 수 있습니다. JMH 프레임워크에서 적용할 수 있는 옵션은 여러가지인데, 이번 글에서는 가장 간단한 테스트 구성만 알아보았습니다. 자세한 내용은 [OpenJDK: JMH](http://openjdk.java.net/projects/code-tools/jmh/) 페이지를 참고하시기 바랍니다.

2017-01-12

쿼리는 어떤 과정을 거쳐서 실행되는가

쿼리는 사용자와 데이터베이스를 매개하는 역할을 수행합니다. 응용프로그램을 개발할 때 파일 입출력을 직접 다루는 대신 데이터베이스를 사용하는 이유는 의도하는 데이터 처리 결과를 간단하게 얻을 수 있기 때문인데요. 쿼리를 사용하면 프로그램 코드를 작성하는 것에 비해 같은 작업을 훨씬 짧게 표현할 수 있습니다. 예를 들어 웹 로그에서 클라이언트 IP별 다운로드 트래픽 총량을 계산하려면 아래와 같은 과정을 거쳐야 합니다. 웹 로그는 아래와 같은 형식으로 기록됩니다. ``` 110.70.47.162 - - [23/May/2020:13:24:22 +0900] "GET /static/images/favicon.png HTTP/1.1" 200 57692 "-" "Mozilla/5.0 (iPhone; CPU iPhone OS 13_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/81.0.4044.124 Mobile/15E148 Safari/604.1" 849 ``` 1. 웹 로그 파일을 연다. 2. IP와 정수로 구성된 해시테이블을 초기화한다. 3. 웹 로그 파일을 개행 문자로 분리해서 행 단위로 읽어들인다. 1. 웹 로그에서 클라이언트 IP와 전송량에 해당되는 문자열을 추출한다. 2. 전송량 문자열을 정수로 변환한다. 3. 해시테이블에서 클라이언트 IP를 조회한다. 4. 기존 누적값과 현재 전송량을 합산하여 클라이언트 IP와 전송량의 쌍을 다시 해시테이블에 넣는다. 4. 파일 끝을 만나면 해시테이블의 모든 클라이언트 IP 키를 순회하면서 결과를 출력한다. 5. 웹 로그 파일을 닫는다. 프로그래밍 언어에 따라 다르지만 파이썬으로는 몇 줄, 자바로는 수십 줄 정도 구현이 필요할 것이고, 클라이언트 IP가 엄청나게 많아서 메모리에 다 올릴 수 없는 상황은 고려하지 않고 있습니다. 만약 일반적인 데이터베이스 테이블에 웹 로그가 파싱되어 저장되어 있는 상태라면 일반적으로 SQL을 이용해서 간단하게 1줄로 쿼리할 수 있습니다. ``` SELECT client_ip, SUM(bytes) FROM weblog GROUP BY client_ip ``` weblog 테이블에 데이터가 정규화된 형태로 존재한다면 로그프레소 쿼리는 아래와 같이 표현합니다. ``` table weblog | stats sum(bytes) by client_ip ``` 일반 데이터베이스는 테이블에 적재되지 않은 데이터를 처리하지 못하지만, 로그프레소는 아래와 같이 추출과 형 변환을 포함하여 모든 데이터 처리를 한 줄로 표현할 수 있습니다. ``` textfile access_log | rex field=line "^(?<client_ip>\S+).* (?<bytes>\S+)$" | eval bytes=long(bytes) | stats sum(bytes) by client_ip ``` ## 쿼리 실행 단계 데이터베이스는 위와 같이 기술한 쿼리 문장을 실제 실행 가능한 코드로 변환해야 합니다. 이 과정은 크게 쿼리 파싱, 최적화, 실행으로 구분합니다. ### 쿼리 파싱 ![Operator Tree](/media/ko/2020-05-25-query-execution/operator_tree.png) 쿼리는 한 줄로 간단하게 표현하였지만 프로그램 코드를 직접 작성한 것과 동일하게 동작하려면 각 기능 단위가 사용자의 의도에 맞게 배치되어야 합니다. 데이터베이스는 이러한 기능 단위를 쿼리 연산자 (Query Operator) 라고 부릅니다. 각 쿼리 연산자는 레코드를 입력 받아서 고유의 데이터 처리를 수행한 후 출력하며, 다음 연산자는 이전 연산자의 출력을 입력으로 받아들입니다. 위의 그림에서 실행 흐름은 아래에서 위로 올라가는 방향으로 표현되어 있는데 이것이 약간 부자연스럽게 느껴질 수 있습니다. 하지만 조인 연산이 포함되는 경우에 하단이 늘어나면서 넓게 배치되고 결과는 하나로 모이게 되므로 트리 형태로 표현하는 것이 유리하고, 그에 맞춰서 실행 흐름은 아래에서 위로 표시하는 것입니다. ### 최적화 데이터베이스는 쿼리를 파싱하여 트리 형태로 만들고 난 후에 이를 논리적, 물리적으로 최적화하는 과정을 수행합니다. 예를 들어, 위의 경우에 집계를 수행하므로 테이블 혹은 파일의 레코드 순서는 중요하지 않습니다. 따라서 병렬화된 테이블 스캔 혹은 파일 스캔으로 변환할 수 있습니다. 이 외에도 타입 추론, 비용 계산, 조인 순서, 필터링 위치 결정은 쿼리 최적화에서 중요한 주제인데 이는 나중에 다시 다루도록 하겠습니다. ![Query Optimizer](/media/ko/2020-05-25-query-execution/query_optimizer.png) ### 실행 최적화를 거치고 나면 최종적으로 쿼리 실행 계획 (Query Plan) 이 완성됩니다. 데이터베이스는 이 쿼리를 실행하는데 어느 정도의 CPU, 메모리, 디스크 자원을 사용할지 결정해야 합니다. 가장 단순하게는 프로그램을 직접 구현해서 실행하듯이 단일 프로세스가 쿼리를 실행할 수 있습니다. **프로세스 모델** PostgreSQL처럼 역사가 오래된 데이터베이스는 프로세스 모델 기반으로 동작합니다. ![Process Model](/media/ko/2020-05-25-query-execution/process_model.png) 프로세스 모델은 클라이언트가 접속하면 새로운 프로세스를 생성해서 할당합니다. 유닉스 프로그래밍 스타일의 오랜 전통이기도 하지만, 공유 메모리를 기반으로 프로세스를 분리하는 이런 실행 모델은 프로그램 오류로 크래시가 발생하더라도 해당 프로세스만 영향을 받기 때문에 상대적으로 장애에 견고합니다. 프로세스가 재시작하더라도 디스크에서 다시 읽어들일 필요 없이 공유 메모리 영역이 그대로 유지되는 장점도 있습니다. IBM DB2, Oracle (11g 이하 버전) 데이터베이스는 이러한 아키텍처로 설계되어 있습니다. 그러나 프로세스를 fork 하는 방식은 상당히 무거운 편입니다. 프로세스 풀링을 통해 어느 정도 단점을 희석시키기는 하지만, 운영체제에 실행을 맡기므로 스케줄링을 세밀하게 관리하기 어렵고 CPU 캐시 활용도 비효율적입니다. **스레드 모델** 로그프레소 쿼리 엔진은 스레드 모델 기반으로 동작합니다. ![Thread Model](/media/ko/2020-05-25-query-execution/thread_model.png) 로그프레소는 쿼리마다 하나의 쿼리 스케줄러 스레드를 실행합니다. 쿼리 스케줄러는 쿼리 실행 계획에서 의존성이 해소된 실행 가능한 태스크를 선별하여 쿼리 태스크를 병렬적으로 실행합니다. 스캔이나 정렬과 같이 실행 성능에 큰 영향을 미치는 요소는 별도의 스레드 풀이 할당되어 있어서, CPU를 최대한 활용하여 빅데이터 쿼리를 실행합니다. 로그프레소 쿼리 엔진은 푸시 모델로 구성되어 있기 때문에, 스캔 스레드의 병렬화가 전체 쿼리의 수행 성능을 좌우합니다. 메모리에 캐시된 페이지를 처리하는 경우 이러한 병렬화는 수 배 이상의 성능 차이를 낼 수 있습니다. 다음 편에서는 Pull 모델과 Push 모델의 장단점을 알아보도록 하겠습니다. ## 레퍼런스 - [PostgreSQL 12 Documentation: 1.2. Architectural Fundamentals](https://www.postgresql.org/docs/12/tutorial-arch.html) - [Oracle 18c Database Concepts: 15 Process Architecture](https://docs.oracle.com/en/database/oracle/oracle-database/18/cncpt/process-architecture.html#GUID-4B460E97-18A0-4F5A-A62F-9608FFD43664)

2020-05-25

GC를 회피하는 메모리 관리 기술

자바는 프로그래머가 메모리 관리를 직접 하지 않도록 설계된 언어입니다. 가비지 컬렉션 기술(이하 GC)은 이전 세대의 프로그램에서 있었던 많은 메모리 참조 오류들을 원천적으로 해결하여 크래시나 메모리 오염으로부터 프로그래머들을 해방시켰습니다. 그러나 이러한 특성은 웹 애플리케이션 서버처럼 상태가 별로 없는 프로그램의 동작에는 적합하나, 데이터베이스처럼 대량의 상태를 유지관리해야 하는 프로그램에는 근본적인 한계를 가지고 있습니다. 메모리에 많은 개체들이 유지될수록, GC를 수행하는데 더 많은 시간이 소요됩니다. 단순히 사용되지 않는 쓰레기 개체를 찾는 것 뿐만 아니라, 단편화를 해결하기 위해 메모리 재배치를 수행하면서 많은 메모리 복사를 유발하기 때문입니다. 많은 웹 서비스들이 수천만 건 이상의 세션 정보나 데이터들을 자체 캐싱하는 대신 C로 구현된 memcached나 redis 같은 외부 데몬에 캐싱하는 것은 이런 이유도 깔려있습니다. 그렇다면 아예 방법이 없는 것인가? 그렇지 않습니다. 바로 오프힙 메모리입니다. ### 다이렉트 버퍼의 유래 DirectByteBuffer는 자바 1.4 시절 NIO 기술과 함께 등장했습니다. JNI로 직접 C 라이브러리를 자바에 링크시켜 본 경험이 있다면 쉽게 이해하겠지만, 자바 월드에서 네이티브 월드로 넘어갈 때는 메모리 복사가 일어납니다. 자바 코드에서 참조하는 배열의 메모리는 [불연속적이거나 네이티브 콜을 한 직후에 GC로 삭제될 수도 있기 때문에](http://docs.oracle.com/javase/7/docs/technotes/guides/jni/spec/design.html#wp1265), 자바가상머신은 시스템 콜 호출 시 별도의 메모리 공간에 입력 데이터를 복사해서 사용합니다. 초기 버전의 자바는 디스크나 네트워크 등 I/O를 수행하면서 빈번한 메모리 복사가 발생하므로 많은 성능 저하가 있을 수 밖에 없었고, 자바 1.4부터 I/O 시 반복적으로 복사를 하지 않도록 다이렉트 버퍼를 제공하기 시작했습니다. 다이렉트 버퍼는 불필요한 복사를 회피할 수 있는 대신, 연속적인 메모리 공간 할당, 참조 정보 관리로 인해 자바 힙 메모리에 비해 할당과 접근 모두 상대적으로 느립니다. 그러나 여기서 중요한 특징은 [GC 범위의 바깥](http://docs.oracle.com/javase/8/docs/api/java/nio/ByteBuffer.html)에 있다는 사실입니다. 다이렉트 버퍼는 GC의 영향에서 벗어날 수 있으므로, 메모리 재배치로 인한 수십 초 단위의 GC 수행을 회피할 수 있습니다. > The buffers returned by this method typically have somewhat higher allocation and deallocation costs than non-direct buffers. **The contents of direct buffers may reside outside of the normal garbage-collected heap**, and so their impact upon the memory footprint of an application might not be obvious. ### 다이렉트 버퍼의 생명주기 제어 위의 인용문구에서 보이듯이, 다이렉트 메모리의 사용량은 단순 힙 사용량으로는 관측되지 않습니다. 이러한 특성은 GC 자체에도 영향을 주는데, 예를 들어 다이렉트 메모리를 더 이상 어디에서도 참조하지 않는데도 불구하고 즉시 GC로 제거되지 않는 현상이 발생합니다. 실제로는 가용 메모리가 있는데도 불구하고 아래와 같이 다이렉트 메모리 할당에 실패할 수 있습니다. ``` java.lang.OutOfMemoryError: Direct buffer memory at java.nio.Bits.reserveMemory(Bits.java:658) at java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:123) at java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:306) ``` 강제로 System.gc()를 호출하지 않는 이상, 다이렉트 바이트버퍼를 해제하려면 다른 방법을 고안해야 합니다. 이 이슈 때문에 다이렉트 메모리를 응용하는 애플리케이션들은 흔히 문서화되지 않은 아래 방법을 사용합니다. ```java ByteBuffer bb = ByteBuffer.allocateDirect(10000); Method cleanerMethod = bb.getClass().getMethod("cleaner"); cleanerMethod.setAccessible(true); Object cleaner = cleanerMethod.invoke(bb); Method cleanMethod = cleaner.getClass().getMethod("clean"); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); ``` 본래는 Finalizer에 의해 호출될 다이렉트 바이트버퍼의 내부 Cleaner 메소드를 리플렉션을 이용해서 직접 호출하는 것입니다. 그러나 이제 문제는 새로운 차원으로 옮겨가게 됩니다. 다이렉트 바이트버퍼의 메모리를 직접 할당하고 해제한다는 것은, 기존의 C/C++와 같은 언어에서 직접 메모리를 할당하고 해제했던 것처럼 메모리에 대한 모든 생명주기를 직접 책임져야 한다는 의미입니다. 특히, 멀티스레드 환경에서는 개체의 참조를 정확하게 계산하도록 레퍼런스 카운터 등을 이용해야 합니다. 만약, 이미 해제해버린 메모리를 대상으로 데이터를 쓰거나 읽으려고 시도한다면, 크래시를 피할 수 없습니다. 예를 들어 아래의 코드는 아주 쉽게 크래시를 유도합니다. ```java for (int i = 0; i < 1000; i++) { ByteBuffer bb = ByteBuffer.allocateDirect(10000); Method cleanerMethod = bb.getClass().getMethod("cleaner"); cleanerMethod.setAccessible(true); Object cleaner = cleanerMethod.invoke(bb); Method cleanMethod = cleaner.getClass().getMethod("clean"); cleanMethod.setAccessible(true); cleanMethod.invoke(cleaner); byte[] buf = new byte[10000]; bb.put(buf); } ``` 윈도우 환경의 크래시 발생 예 ``` # # A fatal error has been detected by the Java Runtime Environment: # # EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fff1c899f58, pid=10756, tid=16228 # # JRE version: Java(TM) SE Runtime Environment (8.0_60-b27) (build 1.8.0_60-b27) # Java VM: Java HotSpot(TM) 64-Bit Server VM (25.60-b23 mixed mode windows-amd64 compressed oops) # Problematic frame: # C [ntdll.dll+0x39f58] # # Failed to write core dump. Minidumps are not enabled by default on client versions of Windows # # An error report file with more information is saved as: # hs_err_pid10756.log ``` 이 뿐만 아니라, 오프힙 메모리는 바이트 배열에 불과하기 때문에 반드시 바이트 직렬화 과정을 거쳐야 합니다. 예를 들어, 16909060라는 정수 값이 있다면, 01 02 03 04로 인코딩해야 합니다. 여기에 더해서 다이렉트 바이트버퍼의 할당과 해제는 상대적으로 훨씬 느리기 때문에, 오프힙 메모리의 응용에는 버퍼 풀링을 포함한 정교한 아키텍처 설계가 필요합니다. 로그프레소는 오프힙 메모리 기술을 통해 GC 문제를 회피하고 수백 기가에 이르는 데이터를 압축된 상태로 캐싱하며 쿼리 실행을 가속합니다.

2017-01-03