[입 개발] base64 가 있는데 base62 같은걸 왜 써야 하나요?

몇일 전에 [입 개발] base62와 진법 연산 라는 글을 적었습니다. 이런 내용을 얘기하면 꼭 빠지지 않고 좋은 질문이 하나 꼭 나옵니다.(나오기를 바랍니다.)

“왜 base64가 있는데 base62 같은걸 써야하죠?” 넵 그렇습니다. 다행히도, 이 내용을 제 주변에 설명했을 때도, 들었던 질문이고, 해당 글을 적었을 때도 받은 질문입니다.(좋은 질문해주신 질문자들에게 감사드립니다.)

먼저 간단하게 설명을 시작하기 전에, 10진수는 영어로 decimal 또는 base 10, 8진수는 octet digits 또는 base 8, 그럼 16진수는 넵 hexdecmial 또는 base 16 이 됩니다. 그럼 당연히 base62는 62진법, base64는 64진법이겠죠.

이 얘기를 하면, 위의 질문이 더 좋아집니다. 64진법으로 표현하는게, 62진법으로 표현하는 것 보다, 진법이 크니, 변화된 정보량이 더 작아지지 않는가? 라는 생각을 하게 하니깐요.

그런데 정답부터 말하자면, base64의 정보량이 더 줄어드는것이 맞습니다.(엥 작성자 양반 도대체 무슨 소리를 하는것이오…) 그런데, 이것을 항상 쓸 수 있는가? 라고 물어보면… 그렇지는 않기 때문입니다.(작성자 양반 이것은 또 무슨소리오!!!)

일단 binary로 표현하는 것은 일종의 256진법 표기입니다. base62, 64에 비해서 한 바이트에 많은 정보가 함축되지요. 그런데, 이제 다음과 같은 질문이 추가로 나와야 할듯 합니다. base64는 왜 나왔을까요? 아무데나 다 쓸 수 있나요? 아래는 base64에서 사용하기 위한 인코딩 표입니다. base62와 비교하면, 사실 +,/,= 해서 3개를 더 쓰고 있습니다.(마지막에 =는 사실 padding 입니다. 값이 없다라는 것을 알려주기 위해서이죠.)

base64

자, 이제 다시 한번 질문드립니다. 위의 base64 문자표에 있는 값으로 구성을 하면, 웹 url 형태의 query string으로 넘겼을 때 제대로 처리가 될까요? 자자 열심히 머리를 굴려봅시다.(이 질문을 하는 이유는… 심지어 이 글을 쓰는 이유는 여기서 뭔가 제대로 처리가 되지 않기 때문이겠죠?

https://charsyam.wordpress.com/abc?q===query=abcd+/=

위의 query string ㅔ서 q== 이 key 이고 query=abcd+/= 가 value라고 하면 뭔가 이상합니다. 하지만, key와 value 가 모두 base64로 인코딩이 된다면, 가능한 일입니다. 그럼 이제 패드는 안쓴다고 해봅시다. 그래도 다음과 같은 형태는 가능합니다.

https://charsyam.wordpress.com/abc?q+/+=query+/

그래서 url safe base64 라는 형태를 찾아보면 위의 표에서 ‘+’, ‘/’ 를 각각 ‘-‘,’_’ 등으로 바꾸고, ‘=’도 ‘.’ 이나 다른 문자로 바꾸는 경우가 있습니다. 즉 base64의 테이블표를 변경해야 하는 이슈가 생기는 것입니다.

실제로 base64는 이메일에서 안전하게 메일을  보내기 위한 인코딩 방법으로 출발했습니다. (rfc1341 를 참고하세요.)예전에는 ascii 만이 세상의 표준이므로 대부분의 서비스들이 7bit 까지만 인식하고 8bit로 된 데이터는 뭔가 처리하는데 에러가 있는 시대였습니다. 그래서 우리의 EUC-KR로 표현되던 한글이나 2byte 언어 국가 CJK 같은 경우는 메일로 첨부파일도 못보내고, 더 심한건, 그냥 한글로는 메일을 못보낸다는 것입니다.(EUC-KR 등에서는 한글 표현을 위해서 2byte를 사용하는데, 확장 표시를 위해서 두 byte의 첫 bit를 1로 셋팅했습니다.) 그래서 여기서 좀 안전한 방법을 찾자가 quoted-printed 와 base64 가 나오게 되었습니다.(quoted-printed는 url-encode 와 유사하게 128보다 큰 문자는 %AB 이런식으로 3글자로 표시하는 방식입니다.)

위와 같은 이유로 base64가 나오게 된 것입니다. 경우에 따라서 테이블을 변경해서 쓰거나 해야 안전해 지는 것이죠. 그런데… base64에서 사용하는 문자들을 또 특정한 시스템에서 쓸 수 없다면 어떻게 될까요? 8bit 표현을 6bit 로 줄인것 처럼, 64로 표시할 수 있는 데이터를 다시 더 줄여야 할 필요가 생길 수 있습니다. base62 또한 그런 상황에서 필요가 되어서 만들어 진 것이죠. 예를 들어 web에서 사용하는데 벌써 ‘-‘, ‘_’ 는 예약 문자등으로 쓰여서 쓸 수 없거나 하는…

그래서 base62, base36, base26, base10 등 얼마든지 만들어 나갈 수 있습니다. encoding, decoding 이라는 것은, 이러한 상황에서의 문제를 풀기 위해서고, 이런 문제는 얼마든지 다시 발생할 수 있으니까요. 설명이 되었으면 좋겠습니다.

[입 개발] base62와 진법 연산

혹시 shorten url 서비스 같은 것을 어떻게 구현할 것인가에 대해서 고민해 본적이 있으신가요?
이런 서비스를 제공할려고 보면, 겹치지 않는 유니크한 값을 만들어야 합니다. 이건[입 개발] Global Unique Object ID 생성 방법에 대한 정리 를 참고하시면 됩니다.

그런데 이런 값을 그냥 스트링 형태로 표현하면, 123456789 은 binary 로는 4byte 이지만, 문자로 표현하면 9byte가 사용됩니다. 그렇다고 바이너로 표현하면 눈에 보이지 않는 형태이므로, 뭔가 전달하기가 어렵습니다.

123456789 이 각각 1,2,3,4,5,6,7,8 이 한바이트이므로, 이걸 뭔가 줄이는 방법이 없을까요?
이진수로 11111111은 16진수로 표현하면 FF 입니다. 그냥 스트링으로만 보면 8byte 가 2바이트로 줄었습니다. 그러나 123456789를 16진수 스트링으로 표현하면 0x075BCD15 가 됩니다.(Big Endian 입니다.)

그래도 9bytes 가 8bytes로 한바이트가 줄었습니다. 뭔가 더 줄이는 방법이 없을까요? 16진법으로 좀 줄었으니… 진법을 좀 올리면 어떨까요? 대략 62진법 정도로? 그럼 이걸 어떻게 표현해야 할까요?(base62를 설명하기 위한 이 대놓고 설정이라니..)

자 먼저 간단한 예를 들어봅시다. 12233 이라는 값을 62진법으로 표현하기 위해서는 어떻게 해야할까요?

1) 몫은 197, 나머지는 19
      197 
   |-----
 62|12233
    12214
    -----
       19 

2) 몫은 3, 나머지는 11
        3 
   |-----
 62|  197
      186
    -----
       11 

3) 몫은 0, 나머지는 3
        0 
   |-----
 62|    3
        0
    -----
        3

4) 계산하면 3 * 62^2 + 11 * 62^1 + 19 * 62^0 = 12233 이 됩니다.

즉 12233 은 62진법으로 [3, 11, 19] 로 표현이 됩니다.
그럼 이 값을 이제 각 자리르 62진법으로 표시하기 위한 symbol로 변환해주면 62진법처럼 보일껍니다.

CODEC = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

운좋게도 a-z,A-Z,0-9까지를 합치면 62글자가 됩니다. 각각 0부터 61까지 표현한다고 하면…
[3, 11, 19]는 CODEC[3], CODEC[11], CODEC[19]가 됩니다. 그러면 결과는 간단히 dlt 가 됩니다.

그러면 위의 공식대로 간단하게 코드를 작성해 볼까요?
3, 11, 19 는 실제로 나머지(mod) 라고 보시면 됩니다.

def to_base62(v):
    ret = []
    while v > 0:
        v, idx = divmod(v ,62)
        ret.insert(0,CODEC[idx])

    return ''.join(ret)

그럼 다시 디코딩은 어떻게 할 수 있을까요? 반대로 하면 됩니다. 문자를 위의 CODEC에서의 위치에서 찾아서, 그 값 곱하기 62^자리수 승을 해주면 됩니다.

CODEC = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
CODECMAP = {}

c = 0
for i in CODEC:
    CODECMAP[i] = c
    c += 1

def from_base62(v):
    ret = 0
    i = 0
    for s in reversed(v):
        ret += (pow(62, i) * CODECMAP[s])
        i += 1

    return ret

이제 전체코드를 볼가요?

import sys

CODEC = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
CODECMAP = {}

c = 0
for i in CODEC:
    CODECMAP[i] = c
    c += 1

def to_base62(v):
    ret = []
    while v > 0:
        v, idx = divmod(v ,62)
        ret.insert(0,CODEC[idx])

    return ''.join(ret)

def from_base62(v):
    ret = 0
    i = 0
    for s in reversed(v):
        ret += (pow(62, i) * CODECMAP[s])
        i += 1

    return ret

r = to_base62(int(sys.argv[1]))
v = from_base62(r)
print(r)
print(v)

우리가 이렇게 수를 특정 진법으로 표현할 수 있다는 것이 핵심입니다. 62진법이 중요한건 아니라는 거죠. 예를 들어, 대소문자를 구별할 수 없는 시스템이라면 이걸 줄여서 알파벳 26글자 + NUMERIC 10글자를 하면 36진법으로도 표현이 가능합니다. 숫자를 못쓰면 26진법도 가능한거죠. 자 이제 자기만의 진법 표현으로 숫자등을 한번 줄여보시기 바랍니다.

[입 개발] 아는 사람은 알지만 모르는 사람은 모르는 memcached expire 이슈…

Memcached는 아주 유명한 오픈소스 인메모리 캐시 솔루션입니다. 많은 사람들이 사용하기에 이미 그 사용법이 굉장히 많이 알려져있습니다. 그런데, 자주 사용하던 사람들이 아니면 잘 모르는 이상한 동작이 memcached 에는 하나 있습니다. 그것이 바로 expire 입니다.

보통 expire를 Memcached 에 셋팅할때는 second 단위로, expire 되어야 할 상대 시간을 넣습니다. 즉 대부분 예상은 아래와 같이 생각하게 됩니다.

expected expire time = current time + set expire time

그리고 이것은 아주 잘 동작합니다. 우리가 30일 이상 expire time 을 설정하기 전까지는…

넵… 주변에서 expire time 을 30일 이상, 즉 60 * 60 * 24 * 30 이상으로 설정하기 전까지는 잘 쓰다가, 왜 30일 이상으로 설정하면 데이터가 바로 사라져서 정상 동작하지 않는다라고 말씀하시는 분들이 속출합니다. 물론 이게 30일인지 아는것도 시간이 걸린다는… 미안해… 에단…

사실 이 문제는 memcached를 오래 다뤄부신 분들은 대부분 한번씩 겪어보는 장애(?) 또는 현상입니다. 왜냐하면 신기하게도 memcached 는 30일이 넘어가는 값에 대해서는 해당 값이 절대 시간이라고 처리를 해버립니다. 왜냐고 묻지 말아주세요. 제가 만든건 아니라서…

먼저 expire 를 처리하게 되는 items.c 안의 do_item_get 을 살펴보도록 하겠습니다. item이 expire 가 되는 경우는 주로 item 에 접근하게 될 때, 시간을 체크합니다.

item *do_item_get(const char *key, const size_t nkey, const uint32_t hv, conn *c) {
    ......

    if (it != NULL) {
        was_found = 1;
        if (item_is_flushed(it)) {
            do_item_unlink(it, hv);
            do_item_remove(it);
            it = NULL;
            pthread_mutex_lock(&c->thread->stats.mutex);
            c->thread->stats.get_flushed++;
            pthread_mutex_unlock(&c->thread->stats.mutex);
            if (settings.verbose > 2) {
                fprintf(stderr, " -nuked by flush");
            }
            was_found = 2;
        } else if (it->exptime != 0 && it->exptime <= current_time) { do_item_unlink(it, hv); do_item_remove(it); it = NULL; pthread_mutex_lock(&c->thread->stats.mutex);
            c->thread->stats.get_expired++;
            pthread_mutex_unlock(&c->thread->stats.mutex);
            if (settings.verbose > 2) {
                fprintf(stderr, " -nuked by expire");
            }
            was_found = 3;
        } else {
            it->it_flags |= ITEM_FETCHED|ITEM_ACTIVE;
            DEBUG_REFCNT(it, '+');
        }
    }
    ......
}

코드를 보면 it->exptime 이 0이 아니고 it->exptime REALTIME_MAXDELTA 라는 조건이 있습니다. 그리고 그 값은 위에 606024*30 으로 정의되어 있습니다. 즉 30일이죠.

#define REALTIME_MAXDELTA 60*60*24*30

static rel_time_t realtime(const time_t exptime) {
    if (exptime == 0) return 0;

    if (exptime > REALTIME_MAXDELTA) {
        if (exptime <= process_started)
            return (rel_time_t)1;
        return (rel_time_t)(exptime - process_started);
    } else {
        return (rel_time_t)(exptime + current_time);
    }
}

위의 코드를 보면 exptime 이 0일때는 값을 0으로 리턴합니다. exptime이 0일 때는 expire time이 설정되지 않은 아이템입니다. 이제 그 다음 코드를 보시면 exptime > REALTIME_MAXDELTA 라는 조건이 있습니다. 그리고 그 값은 위에 606024*30 으로 정의되어 있습니다. 즉 30일이죠.

그래서 설정한 exptime이 30일이 넘어가면, extpime <= process_started 조건에 만족하면 그냥 1로 셋팅합니다. 즉 1초 뒤에 다 지워지는 겁니다. process_started 는 프로세스가 처음에 시작한 시간입니다. 그리고 REALTIME_MAXDELTA 보다 적으면, current_time 에 exptime을 저장합니다. 즉 상대시간으로 인식이 되는 거죠.

그리고 마지막으로 current_time은 clock_handler 함수에서 설정되어집니다. 아래와 같이 current_time 에서는 이미 process_started 가 빠져 있습니다.

static void clock_handler(const int fd, const short which, void *arg) {
……
struct timeval tv;
gettimeofday(&tv, NULL);
current_time = (rel_time_t) (tv.tv_sec – process_started);
……
}

그럼 30일 이상의 값을 셋팅하고 싶을 때는 어떻게 해야 할까요? 넵 바로 그날짜에 맞는 시간값을 넣어주시면 됩니다. 예를 들어 지금부터 한달 뒤인 2016/11/22 에 맞는 unixtimestamp 로 설정하시면 됩니다. 즉 오늘 날짜를 구해서 거기에 원하는 날짜 만큼 더한 unixtimestamp로 expire time을 설정하시면 제대로 된 expire를 설정할 수 있습니다.

자 이제, memcached의 expire time을 설정할때는 항상 주의하셔야 합니다. 반대로 Redis 는 그냥 -_- 정한 값이 expire time 입니다. Redis 에서는 이런 이슈는 없습니다.

[입 개발] Consistent Hashing 에 대한 기초

최근에, consitent hashing 에 대해서 다시 한번 공부를 해야할 기회가 생겨서 다시 한번 훝어보게 되었습니다. 그러면서 느낀게… 내가 잘못 이해하고 그렇게 구라를 치고 있었구나라는 점을 느끼게 되었죠.(이것도 개구라일지도…)

Consistent Hashing 에 의 가장 큰 특징 중에 하나는 HashRing에 k 개의 노드가 있는 상황에서, 노드가 사라지거나 추가될 때 1/k 정도의 key에 대한 것만 유실이 되고 나머지 key는 변동 없이 그 위치에 존재한다는 것입니다. 그리고 같은 값으로 노드들이 만들어지면 그 순서도 항상 동일합니다.(전 바로 이 부분이 신기했었습니다. 그런데… 그런데…)

일단 Consistent Hashing 이라는 것은 David Kager 라는 사람에 의해서 처음 소개가 되었습니다. https://www.akamai.com/es/es/multimedia/documents/technical-publication/consistent-hashing-and-random-trees-distributed-caching-protocols-for-relieving-hot-spots-on-the-world-wide-web-technical-publication.pdf

일단 논문은 어려우니 패스하기로 하고…

1] Consistent Hashing의 핵심은 hash 함수

일단 Consistent Hashsing 의 가장 큰 핵심은 hash 함수입니다. 뭐, 여러가지 어려운 얘기들로 시작하면 더욱 더 어려워지므로, 가장 간단하게 말하자면, hash 함수의 특징은 f = hash(key) 의 결과가 항상 같은 key에 대해서는 같은 hash 결과 값이 나온다는 것입니다. 이 얘기는 우리가 host1, host2, host3 와 같은 주소를 해시하면 hash 함수를 바꾸지 않는 이상은 항상 같은 hash 값이 나오게 됩니다. 그러면 그 hash 값으로 정렬을 하게 되면? 항상 같은 순서가 나오겠죠.

그리고 이 hash 값으로 Hash Ring을 만들면…?

이해를 쉽게 하기 위해서, 어떤 hash의 결과가 0 부터 1 까지 float 형태로 나온다고 가정하겠습니다. hash(“host1”) = 0.25, hash(“host2”) = 0.5, hash(“host3”) = 0.75 가 나온다고 가정하고, 특정 key에 대한 hash 결과는 그것보다 hash값이 크면서 가장 가까운 host에 저장이 된다고 하겠습니다. 즉 hash(“key1”) = 0.3 이면 key1이라는 key가 위치할 서버는 0.5 값을 가지는 host2가 되게 됩니다. 0.75 보다 크면 Ring 이므로 다시 첫번째 host1에 저장이 되게 됩니다.

이제 우리는 hash 함수와 서버의 목록만 알면, 바로 특정 key를 어디에 저장할 것인지 결정할 수 있게 되었습니다. 그리고 Consistent Hashing은 위에서 말했듯이… 서버가 추가되거나 없어져도, 1/k 개의 key만 사라지는 특성이 있습니다. 이것은 또 어떻게 보장이 되는 것일까요?

hash(“host4”) = 0.6 인 서버가 하나 추가되었다고 가정하겠습니다. 이 서버가 들어오면 순서는
host1, host2, host4, host3 이 됩니다. 즉 host4 와 host3 사이의 값, 즉 hash 함수의 결과가 0.6 ~ 0.75 인 녀석들만 저장해야 할 서버가 바뀌지, 다른 녀석들은 원래의 위치에 그대로 저장되므로 찾을 수 있게 됩니다.

다시 정리하자면, A, B, C 세 대의 서버가 hash Ring을 구성합니다.

4

여기에 1이라는 key가 들어오면 hash(“1”) 해서 그 결과값을 보니 B가 규칙에 맞아서 B에 저장되게 됩니다.

5

이제 두번 째 2가 들어올 경우 hash(“2”) 한 값이 C에 속해야 하므로 C에 저장되게 됩니다.

6

마지막으로 key 3,4는 hash(“3”), hash(“4”) 의 값이 A 서버에 속하므로 A 에 들어가고 key 5는 C에 가까워서 C에 들어가게 됩니다.

7

그런데 위의 예제나 그림을 보면, A, B, C의 공간이 서로 균일하지가 않습니다. 또, B가 죽는다고 가정하면 B의 부하는 전부 C로 넘어가게 됩니다. 뭔가 불공평한 일이 벌어지는 것이죠. 그래서 이것을 해결하기 위해서 가상의 친구들을 더 만들어냅니다.

가상의 친구들을 한 서버당 2개씩 더 만든다고 하면 hash(‘A’), hash(‘A+1’), hash(‘A+2’) 로 Hash Ring 에 추가합니다. B는 hash(‘B’), hash(‘B+1’), hash(‘B+2’) 등으로 추가하면 됩니다. 즉 총 3개의 서버를 9개로 보이게 하는거죠. 아래와 같이 A+1은 실제로는 A지만, hash ring에서 가상적으로 다른 녀석으로 보이게 됩니다. hash ring 자체도 더 촘촘해지고, 어떤 서버가 한대 장애가 나더라도, 그 부하가, 적절하게 나머지 두 서버로 나눠지게 됩니다. 실제 서비스에서는 서버당 수십개의 가상 노드를 만들어서 처리하게 됩니다.(2~3개도 너무 적습니다.) 이것을 보통 vnode 라고 부르게 됩니다.

ch2

핵심 결론은, 서버 이름으로 hash 값을 만들어서 정렬한 것을 하나의 Ring 처럼 생각해서 key를 hash 값에 따라 저장한다입니다. 다음은 아주 간단하게 만든 Consistent Hashing 코드입니다. rebuild가 핵심입니다.

그런데!!!, 하나 더… 위에서 우리가 놓친 중요한 개념이 있습니다. hash ring이 우리가 의도한 것과 다르게 구성되는 경우가 언제가 될까요? 바로… Hash Ring을 구성하는 서버의 이름이 바뀌게 되는 경우입니다.

Consistent hashing을 많이 쓰는 libmemcached 의 경우 보통 서버 주소가 들어가게 됩니다. “1.1.1.1:11211”, “1.1.1.2:11211”, “1.1.1.3:11211” 그런데 이런 이름의 경우에 만약 1.1.1.2 서버가 문제가 있어서 새 장비를 받아야 하는데 그 장비가 1.1.1.4 의 ip를 가진다면? Hash Ring이 꼬여 버릴 수 있습니다. 이런 문제를 해결하려면 위의 직접적인 이름 대신에 alias 한 다른 이름으로 Consistent Hashing 을 구성해야 합니다. 즉 redis001, redis002, redis003, redis004 이런 이름으로 Hash Ring을 구성하고, 서버가 바뀌더라도 이 이름을 사용하고 Consistent hashing의 결과로 가져올 값만 다르게 가져오면 되는 것이죠.

바로 이해가 안되시더라도 곰곰히 생각해보시면 무릎을 딱 치시게 될껍니다.

[입 개발] Redis 의 slowlog는 어떻게 측정되는가?

Redis 를 쓰면서 많이 참고하게 되는 명령들 중에 slowlog 가 있습니다. 그런데 이 slowlog가 정확하게 뭘 보여주는지에 대해서는 잘 정리가 되어있지 않아서…(너만 몰라… 컥… 퍽퍽퍽) 공부를 해봤습니다.

slowlog 는 뭘까요? 사실 DBMS등에도 보면 해당 쿼리가 얼마나 오래 실행되었는지 보여주는 기능들이 있습니다. 수행 시간(duration)이 얼마이상이면 로그를 남겨주기도 합니다. 어플리케이션에서도 보통 마지막에 수행시간이 얼마 이상이면 따로 로그를 남기지요. nginx 도 호출이 들어가고 나서 응답이 나올 때 까지의 시간을 재서 보여주는 기능이 있습니다.

showlog는 수행 시간이 느린 요청(쿼리)를 보여주는 기능입니다. 그럼 그 수행 시간을 어떻게 정의하는 가에 따라서 천차만별이라고 할 수 있습니다.

일반적으로 생각하는 것은 쿼리를 Redis 가 받아들인 시간 부터 결과가 나오는 시간이라고 생각할 것입니다. 이게 사실 거의 맞긴 합니다. 먼저 Redis에서 시간을 측정하는 코드를 보시죠.

void call(client *c, int flags) {
    long long dirty, start, duration;
    
    ......
    start = ustime();
    c->cmd->proc(c);
    duration = ustime()-start;
    ......
    if (flags & CMD_CALL_SLOWLOG && c->cmd->proc != execCommand) {
        char *latency_event = (c->cmd->flags & CMD_FAST) ?
                              "fast-command" : "command";
        latencyAddSampleIfNeeded(latency_event,duration/1000);
        slowlogPushEntryIfNeeded(c->argv,c->argc,duration);
    }    ......
}

위의 코드를 보면 Redis 에서의 수행 시간을 재는 범위는 명확합니다. 패킷이 완성되어서, 실제로 딱 수행된 시간입니다.

수행된 시간만 들어간다는 의미는 무엇일까요? 즉 패킷이 완성되기 까지 대기하는 시간은 포함되지 않는다는 것입니다. 여기서 Redis slowlog의 맹점이 하나 존재합니다. 그것은 Redis는 Single Threaded 라는 겁니다.

스크린샷 2016-08-09 오전 1.41.44

Redis Event Loop는 처음부터 연결된 이벤트가 발생된 클라이언트의 루프를 돌면서 데이터를 읽고 패킷이 완성되면 그 때 실행하게 됩니다. 그런데 클라이언트들이 많고, 처리해야 하는 명령들이 많다면, 뒤에 있는 녀석은 실행이 늦게될 수 있습니다. 그런데 그 명령이 수행되는 시간 자체는 짧은 경우에 slowlog에는 남지 않습니다. 즉 실제 응답은 늦고 처리도 늦게 되었지만, 명령이 수행되는 시간 자체는 짧으므로, slowlog에 남지 않는 경우가 됩니다.

그러므로 slowlog에 잡히지는 않지만, 서버의 응답이 느려지는 경우는 Redis 서버가 너무 많은 쿼리를 처리하고 있는 건 아닌지 확인하셔야 합니다. 그리고 쿼리 수 가 너무 많다면, 서버를 분리하여, 쿼리 처리량을 줄이는 것이 해결책입니다.

[입 개발] Jedis 2.1.0 을 가지고 삽질한 이야기…

최근에… “왜 안되지!!!” -> “헉… 왜 됬지!!!” -> “왜 안되지!!!” -&gt “마음의 평안”을 가진 일이 있습니다. 그게 바로 Jedis 2.1.0을 쓰면서 발생한 일입니다. -_-;;; 저는 레디스 꼬꼬마기 때문에… 여러 개의 Request 가 갈 수도 있으니…pipeline을 이용해야지라고 결정을 했습니다. 참고로 현재 Jedis 버전은 2.8.2 와 2.9.0 입니다.(즉 2.1.0 은 아주아주 예전 버전입니다.)

그런데… 이상한 일은 Redis에 데이터를 집어넣는 아주 간단한 작업인데… 데이터가 늦게 들어가는 것이었습니다. 즉 해당 함수를 호출하면, 이상하게 함수를 호출 한 뒤, 몇 초 후에 해당 명령어가 Redis Monitor를 통해서 실행되는 것을 볼 수 있었습니다.

이 때 부터는 “왜 안되지!!!” 모드였습니다.

현재 버전까지는 Jedis 가 Future를 이용해서 비동기 실행을 지원하는 구조가 아니기 때문에, 아주 옛날 버전인 2.1.0 에서는 당연히 비동기가 안 될것은 자명한 일이었습니다.

그런데… 결국 제가 pipeline 에서 sync 함수를 사용해야 하는데 exec 를 사용했기 때문에 발생한 이슈라는 것을 깨닫게 되었습니다.(역시 사람은 낮잠을 자야…) 그런데… 이게 문제다라는 것을 깨달은 순간부터…

그럼 Jedis의 2.1.0 에서의 sync 와 exec 함수를 간단하게 살펴보도록 하겠습니다. 먼저 기본적으로 Jedis는 응답을 바로 주지만, pipeline 모드에서는 Response 구조체에 값을 넣어주게 됩니다.

    public void sync() {
        List<Object> unformatted = client.getAll();
        for (Object o : unformatted) {
            generateResponse(o);
        }
    }

    public Response<List<Object>> exec() {
        client.exec();
        Response<List<Object>> response = super.getResponse(currentMulti);
        currentMulti = null;
        return response;
    }

위의 코드 처럼 sync는 그냥 던진 리퀘스트에 대한 응답을 모두 생성하게 됩니다. getAll 함수는 inputStream 으로 들어온 응답들을 파싱해서 돌려주게 됩니다.

    public List<Object> getAll(int except) {
        List<Object> all = new ArrayList<Object>();
        flush();
        while (pipelinedCommands > except) {
        	try{
                all.add(Protocol.read(inputStream));
        	}catch(JedisDataException e){
        		all.add(e);
        	}
            pipelinedCommands--;
        }
        return all;
    }

그리고 pipeline은 아래의 Queable 을 상속 받기 때문에, 위에서 getAll로 받은 결과들을 각 Response에 채워줍니다.

package redis.clients.jedis;

import java.util.LinkedList;
import java.util.Queue;

public class Queable {
    private Queue<Response<?>> pipelinedResponses = new LinkedList<Response<?>>();

    protected void clean() {
        pipelinedResponses.clear();
    }

    protected Response<?> generateResponse(Object data) {
        Response<?> response = pipelinedResponses.poll();
        if (response != null) {
            response.set(data);
        }
        return response;
    }

    protected <T> Response<T> getResponse(Builder<T> builder) {
        Response<T> lr = new Response<T>(builder);
        pipelinedResponses.add(lr);
        return lr;
    }
}

그렇기 때문에 sync를 했다면, 제대로 실행되었겠지만, 제가 호출한 exec()는 multi()의 쌍을 위한 것이므로 완전히 잘못되었다고 볼 수 있습니다.

이 때 부터, 이제 “왜 됬지!!!!!!” 모드로 변환하게 됩니다. 이 때도 물론 전 좀 잘못된 지식을 가지고 있었습니다. sync를 할 때 명령이 실행될 것이다라고 생각한거죠. 그러니 sync 도 안했는데… 왜!!! exec는 exec 커맨드만 보냅니다. -_-(아마 여기서는 에러 응답이…)(자꾸 pipeline 곽 multi/exec 를 헷갈리고 있습니다.)

그런다가 다시 jedis를 소스를 따라가보니… 또 다시 충격이… set을 실행하면 다음과 같이 client.set을 실행합니다.

    public Response<String> set(String key, String value) {
        client.set(key, value);
        return getResponse(BuilderFactory.STRING);
    }

다시 client의 set을 따라가 봅시다. 헉 sendCommand 가 있습니다. 실제로 보낸다는 얘기죠.

    public void set(final byte[] key, final byte[] value) {
	sendCommand(Command.SET, key, value);
    }

sendCommand 는 실제로 Protocol.java 의 sendCommand를 호출합니다.

    private static void sendCommand(final RedisOutputStream os,
	    final byte[] command, final byte[]... args) {
	try {
	    os.write(ASTERISK_BYTE);
	    os.writeIntCrLf(args.length + 1);
	    os.write(DOLLAR_BYTE);
	    os.writeIntCrLf(command.length);
	    os.write(command);
	    os.writeCrLf();

	    for (final byte[] arg : args) {
		os.write(DOLLAR_BYTE);
		os.writeIntCrLf(arg.length);
		os.write(arg);
		os.writeCrLf();
	    }
	} catch (IOException e) {
	    throw new JedisConnectionException(e);
	}
    }

이것만 보고는 다시 -_- “왜 안되지!!!” 로 되돌아가게 됩니다. 코드만 보면 pipeline도 바로 실행이 되기 때문에, 제가 호출한 시점에 set 명령이 동작했어야 하기 때문입니다. 그리고 유심히 코드를 보다가 RedisOutputStream 을 보고서야 그 의문이 풀렸습니다.

RedisOutputStream 은 java/redis/clients/util/RedisOutputStream.java 에 있습니다. 아래는 RedisOutputStream의 생성자입니다. 일단 8192 bytes를 buf 라는 이름으로 할당합니다.
그리고 write 함수를 보면 buf에 commands를 저장합니다. 그리고 이 데이터를 실제 쓰는 시점은 flushBuffer를 호출할 때입니다.

    public RedisOutputStream(final OutputStream out, final int size) {
        super(out);
        if (size <= 0) {
            throw new IllegalArgumentException("Buffer size <= 0");
        }
        buf = new byte[size];
    }

    public void write(final byte b[], final int off, final int len) throws IOException {
        if (len >= buf.length) {
            flushBuffer();
            out.write(b, off, len);
        } else {
            if (len >= buf.length - count) {
                flushBuffer();
            }

            System.arraycopy(b, off, buf, count, len);
            count += len;
        }
    }

    private void flushBuffer() throws IOException {
        if (count > 0) {
            out.write(buf, 0, count);
            count = 0;
        }
    }

이 얘기는 반대로 flushBuffer 가 호출되지 않을때는 명령이 전달되지 않는다는 것입니다. write 함수를 보면 데이터가 buf size(8192) 보다 커져야 할 때 실제로 명령을 전달하지 않습니다. IO를 줄이기 위해서죠.

즉, 제가 보낸 set 명령은 8192 바이트보다 적어서 flushBuffer 가 호출되지 않아서 전달되지 않다가, 나중에 이 버퍼가 차거나 다른 이유로 그 때 전달된 것입니다. 이제 정신 상태가 “마음의 평화” 상태로 전이되었습니다.

그런데 한가지 의문이 들 수 있습니다. client 의 sendCommmand를 바로 호출한 흐름과 동일하므로 평소에는 어떻게 바로 결과를 얻을 수 있을까요?

그것은 Jedis 가 get관련 함수가 있거나 응답을 바로 읽어야 할 때, flush 함수를 임의로 호출 해 주기 때문입니다. 즉, 바로 결과를 읽어야 할 시점에는 buf 에 있는 데이터를 전부 전달하고 그 결과를 받아오게 됩니다.

정리하자면, 제 set 명령은 buf 사이즈가 차지 않아서 buf에만 들어가고 실행되지 않고 리턴 된 것입니다. 그냥 set 명령을 호출했으면 바로 결과를 보기 위해서 getStatusCodeReply() 함수등이 실행되면서 명령이 전달되었을텐데, pipeline이라 바로 결과를 읽지 않기 때문에 발생한 것입니다.
그래서 다른 명령들이 해당 클라이언트를 사용해서 8192 바이트가 넘거나 해당 명령이 결과를 얻을려고 할 때 실제로 수행이 됩니다. 만약 다른 명령이 응답을 읽으려고 했다면, 버퍼에 들어가 있던 set 명령의 결과를 받아가므로 실제로는 익셉션이나 다른 값을 얻게 되었을겁니다.

결과적으로 Jedis는 multi 가 호출되지 않으면 exec 에서 Exception을 내도록 수정이 되었으므로, 그 뒤에는 이런 문제를 겪을 일이 없습니다. -_-;;; 저 혼자 삽질을 쭈욱쭈욱 했네요. 흑흑흑, 처음에는 딴 이슈인줄 알고 보다가… 엄청나게 삽질한 케이스입니다. T.T

[입 개발] 개발자 문화

최근에 개발자 문화에 대해서 이야기를 했어야 하는데… 잘못 알고 엄한 소리만 하다가 끝난 적이 있습니다. 그래서 이 글은 그 발표에 대한 반성을 하고자 합니다.

image007_charsyam

그런데, 개발자 문화라는 건 뭘까요? (우걱우걱 먹는건가요?)  그래서 먼저 당연히 구글신을 통해서 몇가지 검색을 해봤습니다.

위의 링크들은 제가 구글에서 “개발자 문화”라는 키워드로 나온 검색결과 첫페이지에서 긁어온 것입니다. 읽어보면, 개발자 문화라는 건, 일종의 개발 프로세스하고도 다아있습니다.

그런데, 개발자 문화라는 건 진짜로 개발프로세스만 말하는 것일까요? 물론, 아니라는 건 아닙니다. 우리가 일반적으로 듣고 싶어하는 것도, git/github 을 이용한다든지, 코드를 짜고 코드리뷰를 하고, 자동화된 테스트와 쉬운 deploy 입니다.

어떻게 보면, 우리가 말하는 개발자 문화는 조엘스포스키의 12단계와도 닫아있습니다.

The Joel Test

  1. Source Control(소스 컨트롤)을 사용하십니까?
  2. 한번에 빌드를 만들어낼 수 있습니까?
  3. daily build(일별 빌드)를 만드십니까?
  4. 버그 데이타베이스를 가지고 있습니까?
  5. 새로운 코드를 작성하기 전에 버그들을 잡습니까?
  6. up-to-date(최신) 스케줄을 가지고 있습니까?
  7. spec(설계서)를 가지고 있습니까?
  8. 프로그래머들이 조용한 작업환경을 가지고 있습니까?
  9. 돈이 허락하는 한도내의 최고의 툴들을 사용하고 있습니까?
  10. 테스터들을 고용하고 있습니까?
  11. 신입사원들은 면접때 코드를 직접 짜는 실기시험을 봅니까?
  12. hallway usability testing(무작위 사용성 테스팅)을 하십니까?

 

사실 위의 단계에서 몇가지를 더 추가할 수 있습니다. 개인적으로는

  1. 자동화된 테스트를 보유하고 있습니까?
    1. 버그가 안날 수는 없지만, 버그가 났던 것은 자동화된 테스트로 커버가 되어야 합니다.
  2. 코드 리뷰를 하고 있습니까?
    1. 사실 코드리뷰는 저희도 잘 안하고 있긴한데, 코드리뷰를 전문적으로 하는 것은 상당한 도움이 됩니다.
  3. 코드가 커밋되면 자동으로 빌드와 테스트가 실행됩니까?
  4. 배포/롤백이 쉽습니까?
    1. 배포/롤백이 쉬워야 하루에 몇번씩 또는 몇십번 씩 배포가 가능합니다.
  5. 장애를 낸 것에 대해서 비판하지 않고, 장애를 빨리 고칠려고 노력합니까?
  6. 서비스의 히스토리등이 잘 정리되어 있습니까?

사실 저도 개발자 문화라고 말할 정도로 뭔가 잘 알지를 못하기 때문에… 이런 것들 정도가 생각이 납니다.  최근에 저희 팀의 동료가 올린 멋진 슬라이드가 있습니다.

사실 가장 중요한 것은 git/github을 쓰거나 git flow/gitlab flow 이런 브랜치 방법론이라든지, 뭔가 좋은 툴을 사용하는게 아니라고 생각합니다. 위의 코드 리뷰 처럼, 뭔가를 시도하고 실패하면, 거기서 다시 발전한 부분을 찾는 것, 그리고 서로의 잘못을 찾는 것이 아니라, 장점을 찾고, 존중, 신뢰하며 발전할 부분을 찾는것 그것이어야 말로 가장 중요한 개발자 문화이지 않을까 싶습니다.

이미 모든게 잘 갖춰져 있는 것도 재미있지만, 즐겁게 실패하면서 함께 더 좋은 문화를 만들어갈려고 노력하는 것도 참 재미있는 일이 아닐까 싶습니다.