시계열 압축
메트릭 하나를 저장한다고 해보자. 한 데이터포인트는 (series_id, ts, value) 세 값으로 표현된다. 어떤 시리즈인지, 언제 측정했는지, 얼마였는지다.
컬럼 기반으로 쌓으면 이 세 값이 각각 다른 컬럼에 모인다. 그런데 세 컬럼의 성질이 완전히 다르다.
series_id : 1001 1001 1001 1001 1001 1002 1002 1002 ... 같은 값이 길게 반복ts : 1700000000 1700000030 1700000060 ... 일정 간격으로 증가value : 21.5 21.75 21.5 22.0 ... float64같은 압축 기법을 세 컬럼에 똑같이 적용하면 손해다. 컬럼마다 다른 기법이 필요하다.
반복되는 값: 런 길이 인코딩
Section titled “반복되는 값: 런 길이 인코딩”series_id로 정렬해두면 같은 값이 수천 개씩 연속된다. 런 길이 인코딩(RLE)은 이걸 (값, 반복 횟수) 쌍으로 바꾼다.
1001 ×4096, 1002 ×4096, 1003 ×4096, ...저장 비용이 시리즈 개수에 비례하고 데이터포인트 개수와 무관해진다. 데이터포인트당 비용으로 환산하면 0에 수렴한다.
값 종류가 적을 때는 한 단계 더 갈 수 있다. 사전(dictionary)을 따로 두고 본문에는 사전 인덱스만 저장하는 방식이다. 시리즈가 1000종이면 인덱스가 10비트로 들어가고 그 인덱스 열에 다시 RLE를 걸 수 있다. 런이 길면 RLE로, 값이 자주 바뀌면 비트팩으로 구간별로 갈아타는 하이브리드 방식을 쓰는 구현이 많다.
일정하게 증가하는 값: 차분
Section titled “일정하게 증가하는 값: 차분”타임스탬프는 값 자체는 크다. 2026년 기준 unix epoch 초가 17억이 넘으니 32비트를 거의 다 쓴다. 하지만 인접한 두 값의 차이는 작다. 30초마다 수집하면 차이가 전부 30이다.
1차 차분과 frame of reference
Section titled “1차 차분과 frame of reference”차분을 구한 뒤, 블록 단위로 최솟값을 빼고 남은 잔차를 비트팩하는 방식이다. 최솟값을 기준점으로 삼는다는 뜻에서 frame of reference라고 부른다.
원본 : 1700000000 1700000030 1700000060 17000000901차 차분 : 30 30 30min = 30 : 0 0 0잔차가 전부 0이므로 비트 폭이 0이 된다. 값이 하나도 저장되지 않고 블록 헤더의 기준값 하나만 남는다.
차분의 차분을 저장하는 방식이다. 간격이 일정하면 역시 0이 된다.
두 방식은 여기까지는 결과가 같다. 갈리는 건 간격이 조금씩 밀릴 때다. 수집 주기가 30, 31, 32, 33초로 서서히 드리프트하는 경우를 보자.
1차 차분 : 30 31 32 33min = 30 : 0 1 2 3 잔차가 계속 커진다2차 차분 : 1 1 1 상수로 유지된다frame of reference의 잔차는 블록이 길어지는 만큼 계속 커진다. n개 값을 한 블록에 담으면 비트 폭이 log₂n까지 올라간다. 반면 2차 차분은 1로 고정이라 1~2비트에서 끝난다.
실제 수집 환경의 지터는 방향이 일정하지 않아서(30, 29, 31, 30처럼 오차가 양쪽으로 튄다) 두 방식의 차이가 거의 안 난다. 차이가 드러나는 건 클럭이 한쪽으로 밀리는 경우다.
float64
Section titled “float64”정수 컬럼 두 개가 거의 공짜가 되므로, 결국 value 컬럼 크기가 전체 결과를 결정한다. 그런데 float은 정수에 쓰던 기법을 그대로 사용하지 못하므로, 비트 연산으로 접근한다.
[부호 1비트][지수 11비트][만티사 52비트]
21.5 = 0x403580000000000021.75 = 0x4035C00000000000두 값은 크기가 비슷하니 부호와 지수가 같고 만티사 상위 비트까지 같다. 다른 건 중간의 몇 비트뿐이다.
XOR과 0비트 절단
Section titled “XOR과 0비트 절단”연속한 두 값을 XOR하면 같은 비트가 0이 된다.
0x40358000000000000x4035C00000000000 XOR------------------0x0000400000000000앞쪽 17비트가 0, 뒤쪽 46비트가 0이다. 의미 있는 비트는 가운데 1비트뿐이다. 여기서 앞뒤 0을 잘라내고 중간만 남기면 값 하나가 1비트로 줄어든다. 앞 0의 개수와 남긴 길이를 메타데이터로 붙여야 하니 실제로는 그만큼 더 붙는데, 앞 블록과 절단 위치가 같으면 그 메타데이터도 생략할 수 있다. 이게 Gorilla 계열의 방식이다.
문제는 이 방식이 값의 십진 표기가 아니라 이진 표기에 의존한다는 점이다. 21.5와 21.75는 둘 다 이진에서 유한소수라 만티사 뒷부분이 0으로 깔끔하게 채워진다. 21.6은 아니다.
21.5 = 0x403580000000000021.6 = 0x403599999999999A 0.6이 이진 순환소수라 만티사가 9로 채워진다
XOR = 0x000019999999999A앞 0이 19비트, 뒤 0이 1비트다. 의미 있는 비트가 44비트로, 메타데이터까지 붙이면 55비트를 넘는다. 원래 64비트에서 거의 줄지 않았다.
사람이 쓰는 메트릭 값은 대부분 십진수로 예쁜 값이다. CPU 사용률 21.6퍼센트, 응답 시간 0.15초. 그런데 이진에서 예쁜 값은 21.75, 0.125처럼 2의 거듭제곱 분수뿐이다. 이 불일치가 XOR 방식의 한계다.
바이트 스트림 분할
Section titled “바이트 스트림 분할”비트 단위로 자르지 않고 값을 바이트 위치별로 재배열하는 방식도 있다. 첫 바이트만 모아 한 스트림, 두 번째 바이트만 모아 다음 스트림으로 쪼갠다.
원본 : [40 35 80 00 00 00 00 00][40 35 99 99 99 99 99 9A]
분할 후 : 40 40 | 35 35 | 80 99 | 00 99 | 00 99 | 00 99 | 00 99 | 00 9A ↑ 동일 ↑ 동일 ↑ 여기부터 갈린다이렇게 재배열하면 부호와 지수가 들어있는 앞쪽 스트림에 같은 바이트가 줄줄이 늘어선다. 뒤에 붙는 범용 압축기(LZ 계열, 엔트로피 코더)가 그 반복을 잡는다.
해상도가 바이트라는 게 한계다. 21.5에서 21.75로 갈 때 실제로 바뀐 건 1비트인데, 이 방식은 세 번째 바이트가 바뀌었다는 것까지만 표현한다. 1비트 변화를 8비트 단위로 반올림하는 셈이다. 앞쪽 스트림에서 지수부를 지우는 이득까지는 가져가지만 만티사 하위 바이트는 그대로 남는다.
십진 스케일 정수 변환
Section titled “십진 스케일 정수 변환”접근을 뒤집는 방법도 있다. float의 비트를 좁히는 대신, 애초에 float을 쓰지 않는 것이다.
21.5 21.6 21.75 값들을 보고 필요한 자릿수를 찾는다 (여기서는 소수 둘째 자리)×1002150 2160 2175 정수가 되었다1차 차분 후 비트팩 작은 정수 몇 비트스케일 지수(여기서는 2)는 블록당 하나만 저장하면 된다. 복원은 정수 ÷ 10²이다. 21.5 × 100 = 2150이 정확히 나오는지는 확인해야 하는데, 변환 후 다시 나눠서 원본과 비트 단위로 같은지 검사하면 된다. 같으면 무손실이고 다르면 이 방식을 포기하고 다른 인코딩으로 넘어간다.
이 방식은 XOR과 강점이 정반대다. XOR이 못 잡던 21.6이 여기서는 2160이라는 깔끔한 정수가 된다. 십진으로 예쁜 값이 많다는 성질을 이용하니 당연하다. 반대로 나눗셈이나 로그로 계산돼서 유효숫자가 열몇 자리인 값은 정수 변환이 무손실로 안 된다. 이 경우는 상위 비트를 사전으로 묶고 하위 비트를 비트팩으로 저장하는 쪽으로 우회한다.
카운터 메트릭에는 특히 잘 맞는다. 요청 수나 바이트 수 같은 값은 원래 정수인데 float64에 담겨 있을 뿐이라 스케일 1로 변환이 끝난다. 게다가 단조 증가하니 차분이 작은 양수로 몰린다. 타임스탬프 컬럼과 똑같은 구조가 되는 셈이다.
참고