Redis 모듈 네이티브 타입: 나만의 데이터 구조 만들기
Redis 모듈 네이티브 타입: 나만의 데이터 구조 만들기
Redis 모듈은 Redis의 핵심에 없는 새 데이터 구조를 추가해 줄 수 있어요. 그런데 모듈이 만든 데이터 구조가 RDB 저장·AOF 재작성·TYPE 보고까지 Redis 기본 타입처럼 자연스럽게 동작하게 만들 수 있는데, 이걸 네이티브 타입(native types) 지원이라고 불러요. 이 글에서는 그 네이티브 타입을 등록하고 직렬화를 다루는 모듈 API를 살펴볼게요.
네이티브 타입이 왜 필요할까
Redis 모듈은 Redis에 내장된 자료구조를 두 가지 방식으로 접근할 수 있어요. 하나는 Redis 명령을 호출하는 높은 수준의 방식이고, 다른 하나는 자료구조를 직접 조작하는 낮은 수준의 방식이에요.
네이티브 타입 지원은 바로 여기서 빛을 발해요. 모듈이 새 자료구조를 구현했는데, 그게 마치 Redis가 원래 가진 타입처럼 보이게 만들 수 있어요. 이 문서는 그런 새 데이터 구조를 만들고 RDB 직렬화, AOF 재작성, TYPE 명령으로 타입 보고 등을 처리하기 위해 모듈 시스템이 내보내는 API를 설명해요.
네이티브 타입 하나는 보통 네 가지로 구성돼요.
- 새 데이터 구조와 그 위에서 동작하는 명령들의 구현
- RDB 저장·로딩, AOF 재작성, 키에 연결된 값 해제,
DEBUG DIGEST에 쓰는 값 요약(해시) 계산을 담당하는 콜백 집합 - 각 모듈 네이티브 타입마다 고유한 9자리 이름
- RDB 파일에 모듈 전용 데이터 버전으로 기록되는 인코딩 버전 (이걸 통해 모듈이 RDB에서 과거 표현을 다시 로드할 수 있어요)
RDB 로딩·저장과 AOF 재작성을 처음 보면 복잡해 보일 수 있는데, 모듈 API가 읽기·쓰기 오류 처리를 대신해 주는 고수준 함수를 제공해요. 그래서 실질적으로는 새 데이터 구조를 하나 추가하는 일이 그렇게 어렵지 않답니다.
아주 쉬우면서도 완성된 네이티브 타입 예제가 Redis 배포판 안의 /modules/hellotype.c에 들어 있어요. 실전에서 어떻게 적용되는지 보고 싶다면 이 예제를 함께 보는 걸 추천할게요.
새 데이터 타입 등록하기
새 네이티브 타입을 Redis 코어에 등록하려면, 모듈은 데이터 타입에 대한 참조를 담을 전역 변수를 선언해야 해요. 등록 API는 그 전역 변수에 저장할 데이터 타입 참조를 돌려줍니다.
static RedisModuleType *MyType;
#define MYTYPE_ENCODING_VERSION 0
int RedisModule_OnLoad(RedisModuleCtx *ctx) {
RedisModuleTypeMethods tm = {
.version = REDISMODULE_TYPE_METHOD_VERSION,
.rdb_load = MyTypeRDBLoad,
.rdb_save = MyTypeRDBSave,
.aof_rewrite = MyTypeAOFRewrite,
.free = MyTypeFree
};
MyType = RedisModule_CreateDataType(ctx, "MyType-AZ",
MYTYPE_ENCODING_VERSION, &tm);
if (MyType == NULL) return REDISMODULE_ERR;
}
예제에서 보듯 새 타입을 등록하는 데는 API 호출 하나면 충분해요. 다만 인자로 여러 함수 포인터를 넘기는데, 어떤 건 선택이고 어떤 건 필수예요. 위에 나온 rdb_load·rdb_save·aof_rewrite·free 집합은 반드시 전달해야 하고, .digest와 .mem_usage는 선택 사항이에요.
ctx 인자는 OnLoad 함수에서 받는 컨텍스트예요. 타입 name은 A-Z, a-z, 0-9에 밑줄 _과 하이픈 -이 포함된 문자 집합으로 만든 9자 이름이에요.
예를 들어 b-tree 자료구조를 만들고 내 이름이 antirez라면, 타입 이름을 btree1-az라고 지을 수 있어요. 이 이름은 저장할 때 64비트 정수로 변환해 RDB 파일에 기록되고, RDB 데이터를 로드할 때 어떤 모듈이 데이터를 로드할 수 있는지를 알아내는 데 쓰여요.
마지막 인자는 타입 메서드를 등록 함수에 넘기는 구조체예요. rdb_load, rdb_save, aof_rewrite, digest, free, mem_usage는 모두 콜백인데 원형은 다음과 같아요.
typedef void *(*RedisModuleTypeLoadFunc)(RedisModuleIO *rdb, int encver);
typedef void (*RedisModuleTypeSaveFunc)(RedisModuleIO *rdb, void *value);
typedef void (*RedisModuleTypeRewriteFunc)(RedisModuleIO *aof, RedisModuleString *key, void *value);
typedef size_t (*RedisModuleTypeMemUsageFunc)(void *value);
typedef void (*RedisModuleTypeDigestFunc)(RedisModuleDigest *digest, void *value);
typedef void (*RedisModuleTypeFreeFunc)(void *value);
rdb_load는 RDB 파일에서 데이터를 로드할 때 호출돼요.rdb_save가 만든 것과 같은 형식의 데이터를 읽어요.rdb_save는 RDB 파일로 데이터를 저장할 때 호출돼요.aof_rewrite는 AOF가 재작성될 때 호출돼요. 모듈은 이 콜백을 통해 주어진 키의 내용을 다시 만들기 위한 명령 시퀀스를 Redis에 알려줘요.digest는DEBUG DIGEST가 실행될 때, 그 모듈 타입을 가진 키가 발견되면 호출돼요.
왜 타입 이름은 반드시 9자인가
Redis가 RDB 파일에 데이터를 저장할 때 모듈 전용 데이터 타입도 함께 저장해야 해요. RDB 파일은 다음처럼 키-값 쌍의 연속으로 이뤄져 있어요.
[1 byte type] [key] [a type specific value]
이 구조에서 각 모듈 타입을 구분할 수 있도록 이름을 고정 크기로 다루는 것이죠.
키 설정과 조회
RedisModuleKey *key = RedisModule_OpenKey(ctx,keyname,REDISMODULE_WRITE);
struct some_private_struct *data = createMyDataStructure();
RedisModule_ModuleTypeSetValue(key,MyType,data);
RedisModule_ModuleTypeSetValue()는 쓰기용으로 연 키 핸들에 값을 저장하는데, 인자로 키 핸들·타입 등록 때 얻은 네이티브 타입 참조·모듈 네이티브 타입을 구현하는 void* 포인터 세 가지를 받아요. Redis는 여러분의 데이터가 무엇을 담고 있는지 전혀 알지 못해요. 타입 등록 때 넘긴 콜백을 호출해 연산할 뿐이죠.
반대로 키에서 개인 데이터를 꺼낼 때는 다음처럼 할 수 있어요.
RedisModuleKey *key = RedisModule_OpenKey(ctx,argv[1],
REDISMODULE_READ|REDISMODULE_WRITE);
int type = RedisModule_KeyType(key);
if (type != REDISMODULE_KEYTYPE_EMPTY &&
RedisModule_ModuleTypeGetType(key) != MyType)
{
return RedisModule_ReplyWithError(ctx,REDISMODULE_ERRORMSG_WRONGTYPE);
}
해제 메서드
앞서 말했듯 Redis가 네이티브 타입 값을 가진 키를 해제할 때는 모듈의 도움을 받아 메모리를 놓아줘요. 그래서 타입 등록 때 free 콜백을 넘기는 거예요.
typedef void (*RedisModuleTypeFreeFunc)(void *value);
데이터 구조가 단일 할당으로 이뤄졌다 가정하면, 해제 메서드는 아주 단순하게 구현할 수 있어요.
void MyTypeFreeCallback(void *value) {
RedisModule_Free(value);
}
RDB 저장·로드 메서드
RDB 저장과 로드 콜백은 데이터 타입의 디스크 표현을 만들고(다시 로드하고) 복원해야 해요. Redis는 다음 타입들을 자동으로 RDB 파일에 저장해 주는 고수준 API를 제공해요.
- 부호 없는 64비트 정수
- 부호 있는 64비트 정수
- double
- 문자열
void RedisModule_SaveUnsigned(RedisModuleIO *io, uint64_t value);
uint64_t RedisModule_LoadUnsigned(RedisModuleIO *io);
void RedisModule_SaveSigned(RedisModuleIO *io, int64_t value);
int64_t RedisModule_LoadSigned(RedisModuleIO *io);
void RedisModule_SaveString(RedisModuleIO *io, RedisModuleString *s);
void RedisModule_SaveStringBuffer(RedisModuleIO *io, const char *str, size_t len);
RedisModuleString *RedisModule_LoadString(RedisModuleIO *io);
char *RedisModule_LoadStringBuffer(RedisModuleIO *io, size_t *lenptr);
void RedisModule_SaveDouble(RedisModuleIO *io, double value);
double RedisModule_LoadDouble(RedisModuleIO *io);
이 함수들은 모듈 쪽에서 오류 검사를 요구하지 않아요. 호출이 성공한다고 가정해도 된답니다.
double 값들의 배열을 구현하는 double_array라는 네이티브 타입을 예로 들어볼게요.
struct double_array {
size_t count;
double *values;
};
rdb_save 메서드는 이렇게 생길 수 있어요.
void DoubleArrayRDBSave(RedisModuleIO *io, void *ptr) {
struct dobule_array *da = ptr;
RedisModule_SaveUnsigned(io,da->count);
for (size_t j = 0; j < da->count; j++)
RedisModule_SaveDouble(io,da->values[j]);
}
우리가 한 일은 요소 개수를 저장한 다음 각 double 값을 순서대로 저장한 것이에요. 나중에 이 구조를 rdb_load 메서드에서 불러올 때는 이렇게 하면 됩니다.
void *DoubleArrayRDBLoad(RedisModuleIO *io, int encver) {
if (encver != DOUBLE_ARRAY_ENC_VER) {
/* We should actually log an error here, or try to implement
the ability to load older versions of our data structure. */
return NULL;
}
struct double_array *da;
da = RedisModule_Alloc(sizeof(*da));
da->count = RedisModule_LoadUnsigned(io);
da->values = RedisModule_Alloc(da->count * sizeof(double));
for (size_t j = 0; j < da->count; j++)
da->values[j] = RedisModule_LoadDouble(io);
return da;
}
메모리 할당
모듈 데이터 타입은 네이티브 자료구조를 구현하는 데 쓰는 힙 메모리를 할당·재할당·해제할 때 RedisModule_Alloc() 함수 계열을 쓰는 것이 좋아요.
#define malloc RedisModule_Alloc
#define realloc RedisModule_Realloc
#define free RedisModule_Free
#define strdup RedisModule_Strdup