본문 바로가기

공부

🍎 레디스 톺아보기 - 데이터 타입 이해하기

728x90

왜 레디스를 공부하게 되었는가?

내가 개발 공부를 하면서 가장 처음 Redis(이하 레디스)를 접한 계기는 로그인 기능 개발을 하면서다. 대부분 인증 세션을 레디스에 저장해서 사용하거나, pub/sub을 이용해 실시간 채팅을 구현 등을 경험했다.

 

이전 회사에서도 인증을 담당하면서 비교적 레디스와 관련된 일을 경험했지만, 당시 레디스를 적극적으로 활용하기 어려웠다. 이유는 일단 서비스 내에서 레디스를 이용할 요구사항이 없었고, 레디스 서버를 인증 세션을 저장하는 용도로 사용하고 있어서 만약 해당 레디스가 특정 기능으로 인해 죽는다면, 서비스 전체에 장애가 발생할 수 있어서 비교적 쉽게 접근하기 어려웠다. 그런데 최근 레디스를 활용할 일이 생겼는데 보면 볼수록 활용성이 높고, 매력적이라 공부하게 되었다.

레디스 데이터 타입 이해하기

String

/* SET key value [NX] [XX] [KEEPTTL] [GET] [EX <seconds>] [PX <milliseconds>]
 *     [EXAT <seconds-timestamp>][PXAT <milliseconds-timestamp>] */
void setCommand(client *c) {
    robj *expire = NULL;
    int unit = UNIT_SECONDS;
    int flags = OBJ_NO_FLAGS;

    if (parseExtendedStringArgumentsOrReply(c,&flags,&unit,&expire,COMMAND_SET) != C_OK) {
        return;
    }

    c->argv[2] = tryObjectEncoding(c->argv[2]);
    setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL);
}

설명

SET bike:1 superrediszzangzzang
GET bike:1

레디스에서 가장 대표적인 데이터 타입으로, 텍스트, 직렬화된 객체, 바이너리 배열과 같은 연속된 바이트를 의미한다. 기본적으로 SET, SETNX, GET, MSET, MGET은 값을 설정하고, GET은 값을 조회한다. 문자열은 바이너리 데이터를 저장할 수 있는데, 예를 들어 이미지도 저장할 수 있다. 단, 512MB보다 클 수 없으므로 주의할 필요가 있다. 따라서 바이너리를 처리할 수 있는 특성에 맞게 Base64로 인코딩 필요 없이 바이너리 형태 그대로 저장하는 것이 좋을 것 같다.

SET bike:1 123123 NX
SET bike:1 123123 XX

특히, '이미 키가 있을 경우', 반대로 '키가 없는 경우만 이란 경우' 같은 경우는 개발 과정에 빈번하게 필요한데 이를 위한 옵션이 존재한다.

 

그리고 문자열을 INCR 명령어로 카운터로 이용할 수 있는데, 중요한 포인트는 이 명령어는 원자성이 보장되어, 동시성에 여러 클라이언트에 의해 요청되어도 경합 상태가 발생하지 않는다.

 

대부분의 문자열 연산은 O(1)로 효율적이지만, SUBSTR, GETRANGE, SETRANGE와 같은 명령어는 O(N)으로 동작하게 된다. 따라서 큰 문자열을 처리하는 경우 성능 문제가 발생하는데, 이는 레디스의 싱글-스레드 구조에 치명적이기 때문에 주의할 필요가 있다.

Lists

/* Implements LPUSH/RPUSH/LPUSHX/RPUSHX. 
 * 'xx': push if key exists. */
void pushGenericCommand(client *c, int where, int xx) {
    int j;

    robj *lobj = lookupKeyWrite(c->db, c->argv[1]);
    if (checkType(c,lobj,OBJ_LIST)) return;
    if (!lobj) {
        if (xx) {
            addReply(c, shared.czero);
            return;
        }

        lobj = createListListpackObject();
        dbAdd(c->db,c->argv[1],lobj);
    }

    listTypeTryConversionAppend(lobj,c->argv,2,c->argc-1,NULL,NULL);
    for (j = 2; j < c->argc; j++) {
        listTypePush(lobj,c->argv[j],where);
        server.dirty++;
    }

    addReplyLongLong(c, listTypeLength(lobj));

    char *event = (where == LIST_HEAD) ? "lpush" : "rpush";
    signalModifiedKey(c,c->db,c->argv[1]);
    notifyKeyspaceEvent(NOTIFY_LIST,event,c->argv[1],c->db->id);
}

다음으로 알아볼 타입은 list다. 레디스의 list는 linked list이며 최대 2^32-1개(4,294,967,295)의 요소를 가질 수 있으며, 주로 스택, 큐 구현 또는 간단한 메시지 큐로 이용된다. list를 다루는 명령어로는 LPUSH, RPUSH, LPOP, RPOP, LLEN, LMOVE, LTRIM 등이 있으며 L은 리스트의 머리 쪽, R은 리스트의 꼬리 쪽을 의미한다. 언급한 명령어는 레디스의 성격에 맞게 모두 원자적으로 수행된다.

 

여기서 중요한 점은 앞서 언급했듯 레디스의 list는 양방향 연결 리스트(linked list)다. 따라서 리스트의 가장 앞(head)과 가장 끝(tail)에 데이터를 추가하는 것은 일정한 시간 복잡도를 가지지만, 큰 데이터에 대해서 인덱스 접근에는 주의해야 한다.

 

따라서 대규모 데이터를 다루면서 중앙에 접근할 경우 Sorted set이란 다른 데이터 타입을 이용하는 것을 고려해야 한다.

 

공식 문서에 일반적인 용도를 설명하는데, 만약 사용자가 접속했을 때 최신 게시글 10개를 노출하는 경우 사용자가 글을 작성할 때 LPUSH 하고, LRANGE 0 9로 조회하면 최근 게시글의 작성한 사용자의 ID를 빠르게 탐색할 수 있다고 한다. 이 경우 LPUSH를 계속하게 되면 리스트가 무한히 길어지는 것을 염두해 LTRIM으로 구간을 자를 수 있다. 이렇게 하면 과거 데이터는 버려지고 최신 데이터만 가지고 있어 효율적으로 메모리 처리가 가능하게 된다.

 

또 다른 용도는 메시지 큐와 같이 생성자가 task를 PUSH 하고, 소비자가 POP 하며 마치 메시지 큐처럼 활용할 수 있다. 하지만 일반적인 LPUSH, LPOP을 구현하면 불필요한 요청 (Polling 방식으로 소비자는 작업이 있는지 알 수 없어 일정 시간마다 LPOP을 해야 함)해 자원을 소모하게 된다. 이때 레디스에서 BLPOP, BLPUSH와 같은 명령어를 지원해, 클라이언트가 대기하고 있다 새로운 작업이 들어온 경우 요청 순서대로 응답을 보내고, 일정 시간이 지나면 자동으로 NULL을 반환한다.

Set

void saddCommand(client *c) {
    robj *set;
    int j, added = 0;

    set = lookupKeyWrite(c->db,c->argv[1]);
    if (checkType(c,set,OBJ_SET)) return;
    
    if (set == NULL) {
        set = setTypeCreate(c->argv[2]->ptr, c->argc - 2);
        dbAdd(c->db,c->argv[1],set);
    } else {
        setTypeMaybeConvert(set, c->argc - 2);
    }

    for (j = 2; j < c->argc; j++) {
        if (setTypeAdd(set,c->argv[j]->ptr)) added++;
    }
    if (added) {
        signalModifiedKey(c,c->db,c->argv[1]);
        notifyKeyspaceEvent(NOTIFY_SET,"sadd",c->argv[1],c->db->id);
    }
    server.dirty += added;
    addReplyLongLong(c,added);
}

레디스의 Set(이하, 셋)은 고유한 문자열에 대한 비정렬 집합이다. 명령어는 SADD, SREM, SISMEMBER, SINTER, SCARD 등이 있으며 순서대로, 추가, 삭제, 포함 여부, 교집합, 크기에 해당한다. 그 외에도 집합 연산을 추가로 지원하기 때문에 'A 이벤트와 B 이벤트 모두 참여한 사용자'와 같은 연산도 충분히 가능하며, 일반적으로 접근이 O(1)로 효율적이지만, SMEMBERs와 같은 명령어는 O(N)의 시간 복잡도를 가지게 된다. 따라서 큰 규모의 데이터의 경우 SSCAN을 이용해 순차적으로 조회할 수 있도록 접근하자.

Hashes

void hsetCommand(client *c) {
    int i, created = 0;
    robj *o;

    if ((c->argc % 2) == 1) {
        addReplyErrorArity(c);
        return;
    }

    if ((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;
    hashTypeTryConversion(o,c->argv,2,c->argc-1);

    for (i = 2; i < c->argc; i += 2)
        created += !hashTypeSet(o,c->argv[i]->ptr,c->argv[i+1]->ptr,HASH_SET_COPY);

    /* HMSET (deprecated) and HSET return value is different. */
    char *cmdname = c->argv[0]->ptr;
    if (cmdname[1] == 's' || cmdname[1] == 'S') {
        /* HSET */
        addReplyLongLong(c, created);
    } else {
        /* HMSET */
        addReply(c, shared.ok);
    }
    signalModifiedKey(c,c->db,c->argv[1]);
    notifyKeyspaceEvent(NOTIFY_HASH,"hset",c->argv[1],c->db->id);
    server.dirty += (c->argc - 2)/2;
}

레디스의 hash는 익히 알고 있는 언어에서 hash map으로 이해할 수 있다. HSET, HGET, HMGET, HINCRBY 등의 명령어로 관리할 수 있으며, HINCRBY 같은 경우 조회-설정을 원자성으로 지원한다.

 

hash 명령어는 기본적으로 O(1)으로 동작하지만, HKEYS, HVALS, HGETALL 등의 명령어는 O(n)으로 동작한다. 여기서 n은 key-value 개수를 의미한다.

Sorted set

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned long rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    /* we assume the element is not already inside, since we allow duplicated
     * scores, reinserting the same element should never happen since the
     * caller of zslInsert() should test in the hash table if the element is
     * already inside or not. */
    level = zslRandomLevel();
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    x = zslCreateNode(level,score,ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;
}

정렬된 집합의 경우 고유한 문자열에 대해 연관된 점수로 정렬된 자료구조로, 둘 이상의 문자열에 대해 동일한 점수를 가진 경우 문자열 사전순으로 정렬되는 특징을 가진다. 이 부분에서 인터넷에서 가장 흔하게 소개되는 사례는 '리더보드'이다. 정렬된 집합은 skip list, hash table을 포함한 dual-ported 데이터 구조를 사용해 삽입 시 O(log(N))으로 일정한 성능을 보장한다.

앞서 언급했듯 데이터 삽입은 O(log(N))으로 일정한 성능을 가지지만, ZRANGE 조회 시 큰 값을 반환하는 경우 O(log(N) + M) 성능을 가질 수 있다. 여기서 M은 반환할 데이터 수이다.

Stream

레디스의 stream의 내용이 방대해 별도의 게시글로 작성 예정

Geospatial

레디스의 Geospatial(이하, 지리 공간 인덱스)는 좌표를 저장하고, 지정된 반경 또는 경계 상자 내에 가까운 지점을 조회할 수 있다.

GEOADD bike:avariable -122.27652 37.805186 station:1

GEOSEARCH bikes:rentable FROMLONLAT -122.2612767 37.7936847 BYRADIUS 5 km WITHDIST

 

Bitmap

레디스의 Bitmap(이하, 비트맵)은 실제 데이터 형식이 아닌, 비트 벡터처럼 취급되는 문자열이다. 비트맵은 최대 512MB의 크기를 가질 수 있으며 2^32개의 서로 다른 비트맵을 설정하는데 적합하다. 가장 흔한 용도로는 일 별 사이트 방문자 수를 기록할 경우 사용자 ID를 bitset 하는 것으로 구현할 수 있다. 단, 이 방식은 사용자 아이디가 중복 없는 정수 범위라는 조건이 있다.

 

비트맵의 기본적인 명령어 SETBIT/SETBIT 명령어는 O(1) 시간 복잡도를 가지지만, 일부 명령어는 O(N) 시간 복잡도를 가질 수 있다.

Bitfield

레디스의 Bitfield(이하, 비트필드)는 가변 길이의 정수 값을 원자 단위로 관리할 수 있다.