100 more of those BITFIELDs

Salvatore Sanfilippo

BITFIELD 100개 더

오늘 Redis가 7주년을 맞았습니다. 이를 조금이나마 기념하고자 지난 며칠간 재미있는 코딩 마라톤을 진행하며 BITFIELD라는 다소 엉뚱한 신규 명령어를 구현했습니다.

이 명령어의 핵심 아이디어 자체는 새로운 것이 아닙니다. 과거에 저를 비롯한 여러 사람이 제안한 적은 있지만 진지하게 추진된 적은 없고, 아이디어 자체도 늘 조금은 낯설게 느껴졌습니다. Redis에는 이미 비트 연산 기능이 있습니다. 일부 사용자들은 이를 무척 좋아합니다. 많은 데이터를 간결하게 표현하는 좋은 방법이기 때문입니다. 하지만 지금까지는 각 비트를 개별적으로 다루는 방식이었습니다. 비트를 설정하고, 테스트하고, 가져오고, 특정 범위에서 켜진 비트 수를 세는 식이었죠.

비트필드를 구현하면 어떨까요? 길든 짧든 임의의 크기를 가진 정수를 임의의 오프셋에 저장하는 겁니다. 그렇게 하면 Redis 문자열을 5비트 부호 있는 정수 배열처럼 활용하면서도 단 한 비트도 낭비하지 않을 수 있습니다.

며칠 전 Redis Labs의 Yoav Steinberg가 비트 오프셋에 저장된 임의 크기 정수에 대한 일련의 명령어를 보다 진지한 방식으로 제안했습니다. 메일을 읽자마자 미소가 지어졌습니다. 일종의 숨겨둔 꿈이었거든요. Yoav의 제안과 Redis Labs 엔지니어들의 다른 피드백을 바탕으로, 저는 타입 정의를 위한 짧은 이름을 사용하고 오버플로 동작을 아주 세밀하게 제어할 수 있는, 서브 커맨드를 갖춘 단일 명령어의 초기 스펙을 작성했습니다.

오늘 공개하려는 계획이었기에 몇 분 전에 첫 구현을 막 마쳤습니다. Redis가 생일에 우리가 실제로 일을 한다는 걸 알아주길 바라는 마음에서였죠.

그렇게 완성된 BITFIELD 명령어는 여러 서브 커맨드를 지원합니다:

SET <type> <offset> <value> — 지정된 값을 설정하고 이전 값을 반환합니다.

GET <type> <offset> — 지정된 값을 가져옵니다.

INCRBY <type> <offset> <increment> — 지정된 카운터를 증가시킵니다.

OVERFLOW라는 추가 메타 커맨드도 있습니다. 뒤이어 실행될 명령어들의 오버플로 동작 방식을 설정하는 용도입니다(짐작하셨겠지만). 따라서 OVERFLOW는 여러 번 지정할 수 있습니다:

OVERFLOW SAT — 포화(Saturation) 모드로, 어느 방향으로든 오버플로가 발생하면 해당 방향의 최댓값으로 고정됩니다.

OVERFLOW WRAP — 일반적인 래핑(wrap around) 방식입니다. 흥미로운 점은 부호 있는 정수에서도 동작하며, 가장 작은 음수나 가장 큰 양수 쪽으로 래핑된다는 것입니다.

OVERFLOW FAIL — 이 모드에서는 오버플로가 발생할 경우 연산 자체가 수행되지 않습니다.

정수 타입은 비트 수 앞에 “u” 또는 “i” 접두사를 붙여 지정할 수 있습니다. 예를 들어 u8, i5, u20, i53은 모두 유효한 타입입니다. 한 가지 제한이 있는데, 현재 Redis 프로토콜이 64비트 부호 없는 정수를 반환할 수 없기 때문에 u64는 지정할 수 없습니다.

몇 가지 예제를 살펴보겠습니다. 8비트 부호 없는 정수를 증가시키려면 다음과 같이 사용할 수 있습니다:

127.0.0.1:6379> BITFIELD mykey incrby u8 100 1
1) (integer) 3

이는 오프셋 100(비트맵에서 101번째 비트)에 있는 8비트 부호 없는 정수를 증가시키는 예제입니다.

하지만 오프셋을 지정하는 다른 방법도 있습니다. 오프셋 앞에 “#”를 붙이는 방식인데, “문자열을 지정된 크기의 카운터 배열로 간주하고 N번째 카운터를 다루라”는 의미입니다. 기본적으로 8비트 타입에 #10을 사용하면 8*10을 곱해 오프셋을 계산한다는 뜻입니다. 이렇게 하면 오프셋 계산 없이도 여러 카운터를 독립적으로 다룰 수 있습니다:

127.0.0.1:6379> BITFIELD mykey incrby u8 #0 1
1) (integer) 1
127.0.0.1:6379> BITFIELD mykey incrby u8 #0 1
1) (integer) 2
127.0.0.1:6379> BITFIELD mykey incrby u8 #1 1
1) (integer) 1
127.0.0.1:6379> BITFIELD mykey incrby u8 #1 1
1) (integer) 2

오버플로를 제어할 수 있다는 점도 흥미롭습니다. 예를 들어 1비트 부호 없는 카운터는 기본 오버플로 정책인 “wrap”에서 0과 1을 오가며 토글됩니다:

127.0.0.1:6379> BITFIELD mykey incrby u1 100 1
1) (integer) 1
127.0.0.1:6379> BITFIELD mykey incrby u1 100 1
1) (integer) 0
127.0.0.1:6379> BITFIELD mykey incrby u1 100 1
1) (integer) 1
127.0.0.1:6379> BITFIELD mykey incrby u1 100 1
1) (integer) 0

보시다시피 0과 1이 번갈아 나타납니다.

포화 모드도 유용하게 쓰일 수 있습니다:

127.0.0.1:6379> bitfield mykey overflow sat incrby i4 100 -3
1) (integer) -3
127.0.0.1:6379> bitfield mykey overflow sat incrby i4 100 -3
1) (integer) -6
127.0.0.1:6379> bitfield mykey overflow sat incrby i4 100 -3
1) (integer) -8
127.0.0.1:6379> bitfield mykey overflow sat incrby i4 100 -3
1) (integer) -8

보시다시피 3씩 감소시켜도 -8 아래로 내려가지 않습니다.

한 명령어 안에 여러 연산을 담을 수 있다는 점도 참고해 주세요. 항상 결과 배열을 반환합니다:

127.0.0.1:6379> BITFIELD mykey get i4 100 set u8 200 123 incrby u8 300 1
1) (integer) -8
2) (integer) 123
3) (integer) 7

이 명령어의 의도된 용도는 실시간 분석, A/B 테스트, 정수의 오버플로를 활용해 사용자에게 매번 조금씩 다른 것을 보여주는 등의 작업입니다. 이렇게 많은 작은 카운터들을 공유된 공간에 메모리 효율적으로 패킹하는 방식은 여러 가지로 활용될 수 있지만, 그건 Redis 커뮤니티의 재능 있는 프로그래머 여러분의 과제로 남겨두겠습니다.

이 명령어는 향후 몇 주 안에 Redis 안정 버전에 백포트될 예정이므로, 머지않아 바로 사용해 보실 수 있을 겁니다.

구현이 궁금하신가요? 아마 생각보다 좀 더 복잡할 겁니다: https://github.com/antirez/redis/commit/70af626d613ebd88123f87a941b0dd3570f9e7d2

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

이 글은 muse-spark-1.2-contributor 모델을 사용해 번역했습니다.