[입 개발] IPv4 TCP Socket, Listen 에서 Accept 까지…

갑자기 초괴수 지인분이 TCP Socket 에서 Listen 하고 Accept 할 때 어떤 일이 벌어지는지에 대해서 궁금해 하시는 질문을 올리셨습니다. 사실 Accept 자체는 별로 하는게 없다라는 건 알고 있었는데, 실제로 그 사이에 어떤 일이 벌어지는지에 대해서는 저도 잘 모르고 있어서, 그냥 한번 살펴봤습니다. 먼저, 이걸 보기 전에 TCP의 Connection이 맺어지는 3-way handshake는 굉장히 유명하고 중요하니, 이미지를 도용해옵시다.

3whs

일단 위의 그림을 보면 client 가 connect 를 하기 전에 server 는 listen을 해둬야 합니다. 그럼 이 listen을 하는 동안 어떤 작업이 일어나게 될까요?(linux 4.12.2 기준입니다.)

먼저 봐야할 소스는 net/ipv4/af_inet.c 의 inet_stream_ops 설정입니다. 실제 c코드 등의 함수가 커널레벨에서는 이 함수들과 매핑이 된다고 보시면 됩니다. 여기서 listen 은 inet_listen, accept 은 inet_accept 이 설정되어 있는 것을 볼 수 있습니다.

const struct proto_ops inet_stream_ops = {
    .family        = PF_INET,
    .owner         = THIS_MODULE,
    .release       = inet_release,
    .bind          = inet_bind,
    .connect       = inet_stream_connect,
    .socketpair    = sock_no_socketpair,
    .accept        = inet_accept,
    .getname       = inet_getname,
    .poll          = tcp_poll,
    .ioctl         = inet_ioctl,
    .listen        = inet_listen,
    .shutdown      = inet_shutdown,
    .setsockopt    = sock_common_setsockopt,
    .getsockopt    = sock_common_getsockopt,
    .sendmsg       = inet_sendmsg,
    .recvmsg       = inet_recvmsg,
    .mmap          = sock_no_mmap,
    .sendpage      = inet_sendpage,
    .splice_read       = tcp_splice_read,
    .read_sock     = tcp_read_sock,
    .peek_len      = tcp_peek_len,
#ifdef CONFIG_COMPAT
    .compat_setsockopt = compat_sock_common_setsockopt,
    .compat_getsockopt = compat_sock_common_getsockopt,
    .compat_ioctl      = inet_compat_ioctl,
#endif
};

그럼 이제 inet_listen 함수를 찾아봅니다. 코드를 보면 TCP_FASTOPEN 에 대한 처리도 있는데, 이 부분은 일단은 생략합니다. inet_listen 함수에서는 해당 socket 이 TCP_LISTEN 상태가 아니면 inet_csk_listen_start 함수를 호출하고 listen의 파라매터로 넘어오는 backlog 를 설정합니다.

/*
 *  Move a socket into listening state.
 */
int inet_listen(struct socket *sock, int backlog)
{
    struct sock *sk = sock->sk;
    unsigned char old_state;
    int err;

    lock_sock(sk);

    err = -EINVAL;
    if (sock->state != SS_UNCONNECTED || sock->type != SOCK_STREAM)
        goto out;

    old_state = sk->sk_state;
    if (!((1 << old_state) & (TCPF_CLOSE | TCPF_LISTEN)))
        goto out;

    /* Really, if the socket is already in listen state
     * we can only allow the backlog to be adjusted.
     */
    if (old_state != TCP_LISTEN) {
        /* Enable TFO w/o requiring TCP_FASTOPEN socket option.
         * Note that only TCP sockets (SOCK_STREAM) will reach here.
         * Also fastopen backlog may already been set via the option
         * because the socket was in TCP_LISTEN state previously but
         * was shutdown() rather than close().
         */
        if ((sysctl_tcp_fastopen & TFO_SERVER_WO_SOCKOPT1) &&
            (sysctl_tcp_fastopen & TFO_SERVER_ENABLE) &&
            !inet_csk(sk)->icsk_accept_queue.fastopenq.max_qlen) {
            fastopen_queue_tune(sk, backlog);
            tcp_fastopen_init_key_once(true);
        }

        err = inet_csk_listen_start(sk, backlog);
        if (err)
            goto out;
    }
    sk->sk_max_ack_backlog = backlog;
    err = 0;

out:
    release_sock(sk);
    return err;
}

그럼 다시 inet_csk_listen_start 함수를 살펴봅니다 net/ipv4/inet_connection_sock.c 안에 있습니다. inet_csk_listen_start 함수에서 처음에 신경써서 볼 부분은 reqsk_queue_alloc 함수를 호출하는 부분입니다. 변수명이 뭔가 와 닫는가요? icsk_accept_queue 라는 이름으로 할당하고 있습니다. 네, 이것이 바로 TCP에서 실제 connect 하는 client 에 대한 연결 요청이 저장되는 queue 입니다. accept 에서는 여기에 있으면 바로 가져가고, 없으면 대기하게 되는거죠. 여기서 해당 포트를 확보하는데 문제가 발생하면 TCP_CLOSE 상태로 가게됩니다.

int inet_csk_listen_start(struct sock *sk, int backlog)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct inet_sock *inet = inet_sk(sk);
    int err = -EADDRINUSE;

    reqsk_queue_alloc(&icsk->icsk_accept_queue);

    sk->sk_max_ack_backlog = backlog;
    sk->sk_ack_backlog = 0;
    inet_csk_delack_init(sk);

    /* There is race window here: we announce ourselves listening,
     * but this transition is still not validated by get_port().
     * It is OK, because this socket enters to hash table only
     * after validation is complete.
     */
    sk_state_store(sk, TCP_LISTEN);
    if (!sk->sk_prot->get_port(sk, inet->inet_num)) {
        inet->inet_sport = htons(inet->inet_num);

        sk_dst_reset(sk);
        err = sk->sk_prot->hash(sk);

        if (likely(!err))
            return 0;
    }

    sk->sk_state = TCP_CLOSE;
    return err;
}

이제 해당 socket 이 TCP_LISTEN 상태가 되었습니다. 그런데 젤 앞에 TCP 3-way handshake는 client 가 connect 함수를 호출하면서 SYN 패킷을 보내면서 부터 시작되게 됩니다. 이 부분은 어디서 처리하게 될까요? tcp_rcv_state_process 라는 함수가 net/ipv4/tcp_input.c 에 있습니다. 그런데 이 함수는 어디서 호출되는 것일까요? 다음과 같이 tcp_protocol 정의를 보면 실제 데이터를 처리하는 tcp_v4_rcv 라는 함수가 있습니다.

static struct net_protocol tcp_protocol = {
    .early_demux    =   tcp_v4_early_demux,
    .early_demux_handler =  tcp_v4_early_demux,
    .handler    =   tcp_v4_rcv,
    .err_handler    =   tcp_v4_err,
    .no_policy  =   1,
    .netns_ok   =   1,
    .icmp_strict_tag_validation = 1,
};

해당 socket 이 TCP_LISTEN 상태이면 다시 tcp_v4_do_rcv 라는 함수를 호출하게 되고 다시 tcp_child_process 함수를 호출하거나 하지 않더라도 최종적으로 tcp_rcv_state_process 함수를 호출하게 됩니다.(tcp_child_process가 호출되지 않아도 그 밑에 tcp_rcv_state_process가 호출됩니다.) TCP_LISTEN 인 경우를 보면, 앞에 TCP 3-way handshake를 한번 더 기억해야 합니다.

SYN -> SYN+ACK -> ACK 형태의 순서로 넘어가게 되는데, SYN과 ACK가 server 쪽에서 받게 되는 패킷입니다. ACK가 오면, 이제 ESTABLISH 가 되는 것이므로 여기서는 바로 return 1을 하게 됩니다. 그러면 실제로 SYN+ACK를 보내는 상황은 SYN을 받았을 때 입니다. FIN이 설정되어 있으면, TCP 접속을 종료하는 거니 discard 하게 되고, 정상적이면 conn_request 함수를 호출하게 됩니다.

    case TCP_LISTEN:
        if (th->ack)
            return 1;

        if (th->rst)
            goto discard;

        if (th->syn) {
            if (th->fin)
                goto discard;
            /* It is possible that we process SYN packets from backlog,
             * so we need to make sure to disable BH right there.
             */
            local_bh_disable();
            acceptable = icsk->icsk_af_ops->conn_request(sk, skb) >= 0;
            local_bh_enable();

            if (!acceptable)
                return 1;
            consume_skb(skb);
            return 0;
        }
        goto discard;

conn_request 함수는 다음 ipv4_specific를 살펴봐야 합니다. tcp_v4_conn_request 함수랑 매핑이 되어 있네요.

const struct inet_connection_sock_af_ops ipv4_specific = {
    .queue_xmit    = ip_queue_xmit,
    .send_check    = tcp_v4_send_check,
    .rebuild_header    = inet_sk_rebuild_header,
    .sk_rx_dst_set     = inet_sk_rx_dst_set,
    .conn_request      = tcp_v4_conn_request,
    .syn_recv_sock     = tcp_v4_syn_recv_sock,
    .net_header_len    = sizeof(struct iphdr),
    .setsockopt    = ip_setsockopt,
    .getsockopt    = ip_getsockopt,
    .addr2sockaddr     = inet_csk_addr2sockaddr,
    .sockaddr_len      = sizeof(struct sockaddr_in),
#ifdef CONFIG_COMPAT
    .compat_setsockopt = compat_ip_setsockopt,
    .compat_getsockopt = compat_ip_getsockopt,
#endif
    .mtu_reduced       = tcp_v4_mtu_reduced,
};

tcp_v4_conn_request 는 tcp_conn_request 라는 함수를 다시 호출합니다. TCP_FASTOPEN 이 아닐 때 보면 inet_csk_reqsk_queue_hash_add 를 호출하는데 이 함수가 실제로 accept_queue에 값을 집어넣는 함수입니다. 라고 생각했는데, 소스를 잘못 본것입니다. 여기서는 TIMEOUT만 설정하게 됩니다. 그리고 SYN+ACK를 보내고 되죠.

    if (fastopen_sk) {
        af_ops->send_synack(fastopen_sk, dst, &fl, req,
                    &foc, TCP_SYNACK_FASTOPEN);
        /* Add the child socket directly into the accept queue */
        inet_csk_reqsk_queue_add(sk, req, fastopen_sk);
        sk->sk_data_ready(sk);
        bh_unlock_sock(fastopen_sk);
        sock_put(fastopen_sk);
    } else {
        tcp_rsk(req)->tfo_listener = false;
        if (!want_cookie)
            inet_csk_reqsk_queue_hash_add(sk, req, TCP_TIMEOUT_INIT);
        af_ops->send_synack(sk, dst, &fl, req, &foc,
                    !want_cookie ? TCP_SYNACK_NORMAL :
                           TCP_SYNACK_COOKIE);
        if (want_cookie) {
            reqsk_free(req);
            return 0;
        }
    }

같은 tcp_conn_request 함수안의 앞부분을 보면 inet_reqsk_alloc 을 호출하는 부분이 있습니다.

int tcp_conn_request(struct request_sock_ops *rsk_ops,
             const struct tcp_request_sock_ops *af_ops,
             struct sock *sk, struct sk_buff *skb)
{
    struct tcp_fastopen_cookie foc = { .len = -1 };
    __u32 isn = TCP_SKB_CB(skb)->tcp_tw_isn;
    struct tcp_options_received tmp_opt;
    struct tcp_sock *tp = tcp_sk(sk);
    struct net *net = sock_net(sk);
    struct sock *fastopen_sk = NULL;
    struct dst_entry *dst = NULL;
    struct request_sock *req;
    bool want_cookie = false;
    struct flowi fl;

    /* TW buckets are converted to open requests without
     * limitations, they conserve resources and peer is
     * evidently real one.
     */
    if ((net->ipv4.sysctl_tcp_syncookies == 2 ||
         inet_csk_reqsk_queue_is_full(sk)) && !isn) {
        want_cookie = tcp_syn_flood_action(sk, skb, rsk_ops->slab_name);
        if (!want_cookie)
            goto drop;
    }

    if (sk_acceptq_is_full(sk)) {
        NET_INC_STATS(sock_net(sk), LINUX_MIB_LISTENOVERFLOWS);
        goto drop;
    }

    req = inet_reqsk_alloc(rsk_ops, sk, !want_cookie);
    if (!req)
        goto drop;

    ......

inet_reqsk_alloc 함수를 보면 TCP_NEW_SYN_RECV 로 셋팅하는 부분이 있습니다. 그러면서 새로운 child 소켓을 생성하기 위한 준비를 하는 것으로 보입니다. TCP_NEW_SYN_RECV는 https://patchwork.ozlabs.org/patch/449704/ 를 보시면 왜 추가되었는지 설명이 나옵니다.(저도 잘 몰라요 ㅋㅋㅋ)

struct request_sock *inet_reqsk_alloc(const struct request_sock_ops *ops,
                      struct sock *sk_listener,
                      bool attach_listener)
{
    struct request_sock *req = reqsk_alloc(ops, sk_listener,
                           attach_listener);

    if (req) {
        struct inet_request_sock *ireq = inet_rsk(req);

        kmemcheck_annotate_bitfield(ireq, flags);
        ireq->opt = NULL;
#if IS_ENABLED(CONFIG_IPV6)
        ireq->pktopts = NULL;
#endif
        atomic64_set(&ireq->ir_cookie, 0);
        ireq->ireq_state = TCP_NEW_SYN_RECV;
        write_pnet(&ireq->ireq_net, sock_net(sk_listener));
        ireq->ireq_family = sk_listener->sk_family;
    }

    return req;
}

다시 처음의 tcp_v4_rcv 함수로 돌아갑니다.(net/ipv4/tcp_ipv4.c), 여기서 tcp_check_req 함수가 호출이 됩니다.(net/ipv4/tcp_minisocks.c)

    if (sk->sk_state == TCP_NEW_SYN_RECV) {
        struct request_sock *req = inet_reqsk(sk);
        struct sock *nsk;

        sk = req->rsk_listener;
        if (unlikely(tcp_v4_inbound_md5_hash(sk, skb))) {
            sk_drops_add(sk, skb);
            reqsk_put(req);
            goto discard_it;
        }
        if (unlikely(sk->sk_state != TCP_LISTEN)) {
            inet_csk_reqsk_queue_drop_and_put(sk, req);
            goto lookup;
        }
        /* We own a reference on the listener, increase it again
         * as we might lose it too soon.
         */
        sock_hold(sk);
        refcounted = true;
        nsk = tcp_check_req(sk, skb, req, false);
        if (!nsk) {
            reqsk_put(req);
            goto discard_and_relse;
        }
        if (nsk == sk) {
            reqsk_put(req);
        } else if (tcp_child_process(sk, nsk, skb)) {
            tcp_v4_send_reset(nsk, skb);
            goto discard_and_relse;
        } else {
            sock_put(sk);
            return 0;
        }
    }

tcp_check_req 에서는 뭔가 복잡한 작업을 하고 있습니다.(제가 이걸 보고 바로 이해할 능력은 안됩니다. 하하하하 T.T) 일단 SYN+ACK를 보내고 여기서 ACK를 받아야 정상적으로 연결이 완료되기 때문에, child 소켓이 만들어지고(accept 하면 server 소켓이 아니라 다른 소켓을 받게 되는거 기억나시죠?, tcp_v4_syn_recv_sock 함수에서 만들어집니다.) 마지막에 inet_csk_complete_hashdance 함수를 호출하면서

struct sock *tcp_check_req(struct sock *sk, struct sk_buff *skb,
               struct request_sock *req,
               bool fastopen)
{
     ......
     /* OK, ACK is valid, create big socket and
     * feed this segment to it. It will repeat all
     * the tests. THIS SEGMENT MUST MOVE SOCKET TO
     * ESTABLISHED STATE. If it will be dropped after
     * socket is created, wait for troubles.
     */
    child = inet_csk(sk)->icsk_af_ops->syn_recv_sock(sk, skb, req, NULL,
                             req, &own_req);
    if (!child)
        goto listen_overflow;

    sock_rps_save_rxhash(child, skb);
    tcp_synack_rtt_meas(child, req);
    return inet_csk_complete_hashdance(sk, child, req, own_req);

inet_csk_complete_hashdance 함수에서는 실제로 inet_csk_reqsk_queue_add 함수를 호출해서 실제로 accept_queue에 새로 생성된 child socket을 집어넣어줍니다.

struct sock *inet_csk_complete_hashdance(struct sock *sk, struct sock *child,
                     struct request_sock *req, bool own_req)
{
    if (own_req) {
        inet_csk_reqsk_queue_drop(sk, req);
        reqsk_queue_removed(&inet_csk(sk)->icsk_accept_queue, req);
        if (inet_csk_reqsk_queue_add(sk, req, child))
            return child;
    }
    /* Too bad, another child took ownership of the request, undo. */
    bh_unlock_sock(child);
    sock_put(child);
    return NULL;
}

지금까지 진행한 부분에서 새로 생성된 소켓을 TCP_ESTABLISHED 로 설정되는 부분이 안보입니다. 이건 어디서 할까요? 실제로 위에 살짝 빠지고 넘어간 tcp_v4_syn_recv_sock 함수를 살펴보면, 새로운 소켓을 만들기 위한 tcp_create_openreq_child 함수를 호출합니다. 여기서 다시 inet_csk_clone_lock 함수를 통해서 parent를 clone 하게 되는데 여기서 TCP_SYN_RECV 로 state가 설정되게 되고, 다시 tcp_rcv_state_process 에서 TCP_ESTABLISHED로 설정이 됩니다.(확실하지는 않습니다.)

이제 accept 이 호출되었을 때를 살펴보도록 하겠습니다. accept 은 처음에 inet_accept 함수를 호출하게 되고, 여기서 내부적으로 inet_csk_accept 을 호출하게 됩니다. 먼저 accept_queue를 확인해서 empty 이면, 하나도 존재하지 않으니, inet_csk_wait_for_connect 를 호출해서 내부적으로 대기하게 됩니다. O_NONBLOCK 이 설정되어 있으면 하나도 없을 때, 익숙한 -EAGAIN이 리턴됩니다.

/*
 * This will accept the next outstanding connection.
 */
struct sock *inet_csk_accept(struct sock *sk, int flags, int *err, bool kern)
{
    struct inet_connection_sock *icsk = inet_csk(sk);
    struct request_sock_queue *queue = &icsk->icsk_accept_queue;
    struct request_sock *req;
    struct sock *newsk;
    int error;

    lock_sock(sk);

    /* We need to make sure that this socket is listening,
     * and that it has something pending.
     */
    error = -EINVAL;
    if (sk->sk_state != TCP_LISTEN)
        goto out_err;

    /* Find already established connection */
    if (reqsk_queue_empty(queue)) {
        long timeo = sock_rcvtimeo(sk, flags & O_NONBLOCK);

        /* If this is a non blocking socket don't sleep */
        error = -EAGAIN;
        if (!timeo)
            goto out_err;

        error = inet_csk_wait_for_connect(sk, timeo);
        if (error)
            goto out_err;
    }
    req = reqsk_queue_remove(queue, sk);
    newsk = req->sk;

    if (sk->sk_protocol == IPPROTO_TCP &&
        tcp_rsk(req)->tfo_listener) {
        spin_lock_bh(&queue->fastopenq.lock);
        if (tcp_rsk(req)->tfo_listener) {
            /* We are still waiting for the final ACK from 3WHS
             * so can't free req now. Instead, we set req->sk to
             * NULL to signify that the child socket is taken
             * so reqsk_fastopen_remove() will free the req
             * when 3WHS finishes (or is aborted).
             */
            req->sk = NULL;
            req = NULL;
        }
        spin_unlock_bh(&queue->fastopenq.lock);
    }
out:
    release_sock(sk);
    if (req)
        reqsk_put(req);
    return newsk;
out_err:
    newsk = NULL;
    req = NULL;
    *err = error;
    goto out;
}

reqsk_queue_remove 를 호출하면 실제로 accept_queue 에서 연결을 하나 가져오게 됩니다.(링크드 리스트에서 head를 가져옵니다.)

Advertisements

[입 개발] DNS Caching in JVM

JVM 에서 (혹은 Java) 에서는 DNS Caching 이 디폴트로 FOREVER 입니다. 이 말은 한번 쿼리한 DNS 주소는 다시는 쿼리하지 않는다는 것입니다. 이러면 당연히 DNS 쿼리 시간이 들지 않으니, 속도면에서는 유리하지만, DNS 변화를 주는 것으로 뭔가 처리할려고 하면, 결국 계속 실패하게 됩니다.  이전 ip를 계속 써버리니… 그럼 이 동작을 어떻게 확인하는가? JDK 소스를 까보시면 간단하게 아실 수 있습니다.

./src/share/classes/java/net/InetAddress.java
./src/share/classes/sun/net/InetAddressCachePolicy.java

InetAddress class 는 getAllByName0 함수가 불려질 때 먼저 cache 되어있는지를 getCachedAddresses 함수를 통해서 확인합니다. 아래 코드르 보면 cache 에 없을때만 실제 DNS 질의를 하게 됩니다.

    private static InetAddress[] getAllByName0 (String host, InetAddress reqAddr, boolean check)
        throws UnknownHostException  {

        /* If it gets here it is presumed to be a hostname */
        /* Cache.get can return: null, unknownAddress, or InetAddress[] */

        /* make sure the connection to the host is allowed, before we
         * give out a hostname
         */
        if (check) {
            SecurityManager security = System.getSecurityManager();
            if (security != null) {
                security.checkConnect(host, -1);
            }
        }

        InetAddress[] addresses = getCachedAddresses(host);

        /* If no entry in cache, then do the host lookup */
        if (addresses == null) {
            addresses = getAddressesFromNameService(host, reqAddr);
        }

        if (addresses == unknown_array)
            throw new UnknownHostException(host);

        return addresses.clone();
}

그리고 getCachedAddresses 함수는 InetAddressCachePolicy 가 FOREVER일 때는 해당 값을 expire 시키지 않습니다.

private int getPolicy() {
    if (type == Type.Positive) {
        return InetAddressCachePolicy.get();
    } else {
        return InetAddressCachePolicy.getNegative();
   }
}

public CacheEntry get(String host) {
    int policy = getPolicy();
    if (policy == InetAddressCachePolicy.NEVER) {
        return null;
    }
    CacheEntry entry = cache.get(host);
 
    // check if entry has expired
    if (entry != null && policy != InetAddressCachePolicy.FOREVER) {
        if (entry.expiration >= 0 &&
            entry.expiration < System.currentTimeMillis()) {
            cache.remove(host);
            entry = null;
        }
    }
 
    return entry;
}

따로 설정을 하지 않으면 InetAddressCachePolicy 는 디폴트로 FOREVER 입니다.

// Controls the cache policy for successful lookups only
private static final String cachePolicyProp = "networkaddress.cache.ttl";
private static final String cachePolicyPropFallback =
    "sun.net.inetaddr.ttl";
 
// Controls the cache policy for negative lookups only
private static final String negativeCachePolicyProp =
    "networkaddress.cache.negative.ttl";
private static final String negativeCachePolicyPropFallback =
    "sun.net.inetaddr.negative.ttl";
 
public static final int FOREVER = -1;
public static final int NEVER = 0;
 
private static int cachePolicy = FOREVER;
 
public static synchronized int get() {
    return cachePolicy;
}

그리고 해당 값은 Security Property 와 System Property에 의해서 제어가 가능합니다.

스크린샷 2017-12-27 오후 5.28.32

단위는 초고, 초가 자동으로 밀리세컨으로 변경됩니다.

long expiration;
if (policy == InetAddressCachePolicy.FOREVER) {
    expiration = -1;
} else {
    expiration = System.currentTimeMillis() + (policy * 1000);
}

우선순위는 networkaddress.cache.ttl 가 있으면 이걸 먼저 사용하고, 없으면 그 다음에 sun.net.inetaddr.negative.ttl 을 사용합니다. 즉 Security Property 설정이 우선입니다.

Integer tmp = java.security.AccessController.doPrivileged(
  new PrivilegedAction<Integer>() {
    public Integer run() {
        try {
            String tmpString = Security.getProperty(cachePolicyProp);
            if (tmpString != null) {
                return Integer.valueOf(tmpString);
            }
        } catch (NumberFormatException ignored) {
            // Ignore
        }
 
        try {
            String tmpString = System.getProperty(cachePolicyPropFallback);
            if (tmpString != null) {
                return Integer.decode(tmpString);
            }
        } catch (NumberFormatException ignored) {
            // Ignore
        }
        return null;
    }
  });
if (tmp != null) {
    cachePolicy = tmp.intValue();
    if (cachePolicy < 0) {
        cachePolicy = FOREVER;
    }
    propertySet = true;
} else {
    /* No properties defined for positive caching. If there is no
     * security manager then use the default positive cache value.
     */
    if (System.getSecurityManager() == null) {
        cachePolicy = DEFAULT_POSITIVE;
    }
}

즉 위와 같이 해당 값을 설정하면 그 이후에는 캐시가 날라가서 다시 실제 쿼리를 날리게 됩니다. 보통 로컬에 dnsmasq 나 unbound 같은 로컬 DNS 캐시 서버를 둬서, 거기서 캐싱을 하면 실제적으로 외부로 날라가는 것보다는 훨씬 DNS 쿼리 비용을 줄일 수 있습니다.

참고문헌:
https://www.lesstif.com/pages/viewpage.action?pageId=17105897

[입 개발] I don’t know DNS Caching

흐음, 입 개발 전문가 CharSyam  입니다. 나름 입 개발을 오래해보긴 하고, DNS 프로토콜도 직접 구현해보고, Dynamic DNS를 Zookeeper 기반으로 만들어보기도 해서 잘 안다고(이렇게 적고 실제로는 일도 모른다고 읽으시면 됩니다.) 생각했는데… 제 상식을 깨는 일이 발생했습니다.(다른 분들의 상식이 아니라 제 상식이니 무시하시면 됩니다.)

아래와 같은 코드가 존재합니다. 여기서 http://www.naver.com 은 몇번 호출이 될까요?

import requests
requests.get('http://www.naver.com')
requests.get('http://www.naver.com')

 한번도 안한다. 한번만 한다. 두번 한다. 세번 한다. 네번 한다. 정답은 설정과 OS에 따라 다르다입니다.(퍽퍽퍽, 죽어!!!) 디폴트 설정이라는 가정하에서는 Windows와 Linux 에서의 설정이 또 다릅니다. 이게 무슨 소리냐 하면 Windows 는 OS 레벨에서의 DNS가 캐싱이 되므로 아마, 위의 코드는 한번도 안할 수도 있습니다.(이전에 했다면…), 처음 실행된다는 가정하에서는 그럼 한번만 되겠죠. 그런데 여기서는 이제 Linux 쪽, 특히 Debian 계열로 한정을 짓는다면, 위의 코드는 4번의 DNS 호출을 하게 됩니다.(왜 두번이 아니라… 그것은 http://www.naver.comhttps://www.naver.com 으로 redirection 되기 때문에~~~), 그런데 엉? 4번이라고? 처음이라도 한번만 되어야 되는게 아니야?

먼저 tcpdump 를 설치합니다.

sudo apt-get install tcpdump

그리고 udp 53번을 모니터링 합니다.

sudo tcpdump -i eth0 udp port 53

그리고 위의 코드를 실행시키면… 다음과 같은 결과가 나옵니다.

01:32:31.303838 IP 192.168.0.2.60630 > acns.uplus.co.kr.domain: 30077+ A? www.naver.com. (31)
01:32:31.303846 IP 192.168.0.2.60630 > pcns.bora.net.domain: 30077+ A? www.naver.com. (31)
01:32:31.303856 IP 192.168.0.2.60630 > pcns.bora.net.domain: 45919+ AAAA? www.naver.com. (31)
01:32:31.306473 IP pcns.bora.net.domain > 192.168.0.2.60630: 30077 3/3/3 CNAME www.naver.com.nheos.com., A 210.89.160.88, A 210.89.164.90 (199)
01:32:31.306983 IP acns.uplus.co.kr.domain > 192.168.0.2.60630: 30077 3/3/3 CNAME www.naver.com.nheos.com., A 125.209.222.142, A 210.89.164.90 (199)
01:32:31.311150 IP pcns.bora.net.domain > 192.168.0.2.60630: 45919 1/1/0 CNAME www.naver.com.nheos.com. (116)
01:33:08.638991 IP 192.168.0.2.60630 > acns.uplus.co.kr.domain: 31222+ A? www.naver.com. (31)
01:33:08.638999 IP 192.168.0.2.60630 > pcns.bora.net.domain: 31222+ A? www.naver.com. (31)
01:33:08.639010 IP 192.168.0.2.60630 > pcns.bora.net.domain: 64566+ AAAA? www.naver.com. (31)
01:33:08.642771 IP pcns.bora.net.domain > 192.168.0.2.60630: 64566 1/1/0 CNAME www.naver.com.nheos.com. (116)
01:33:08.642781 IP pcns.bora.net.domain > 192.168.0.2.60630: 31222 3/3/3 CNAME www.naver.com.nheos.com., A 125.209.222.141, A 210.89.160.88 (199)
01:33:08.643297 IP acns.uplus.co.kr.domain > 192.168.0.2.60630: 31222 3/3/3 CNAME www.naver.com.nheos.com., A 125.209.222.141, A 210.89.160.88 (199)
......

우리는 OS 레벨의 DNS Caching을 기대하지만, 이 얘기는 기본적으로 OS 레벨에서의 DNS 캐시가 꺼져있다라는 얘기가 됩니다.(대부분의 linux에서 꺼져있다라는…) DNS Level Failover를 적용할려고 하다가, Python 에서는 DNS Caching 이 어떻게 이루어지는지를 보려고 하다보니…, 우연히 https://stackoverflow.com/questions/11020027/dns-caching-in-linux 를 찾게 되었는데…(역시 갓 SO, 참고로 JVM 에서는 DNS Caching 이 영구적이라, 특정 옵션을 주지 않으면, 처음 받게 된 ip를 계속 사용하게 되므로, 기본 옵션으로는 DNS Level Failover를 쓸 수 없습니다.)

여기서 “꺼져있다”, “꺼져있다”, “꺼져있다”를 보고 충격을 받았습니다. 그래서 위와 같이 실험을 했더니… 사실이었습니다. 자세한 내용은 위의 SO 글을 읽으시면… 잘 알게 되는데, glibc의 getaddrinfo 자체에서 발생하는 이슈라고 해서, 다음과 같이 코드를 실행해 봤습니다.

#include <stdio.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    struct addrinfo hints;
    struct addrinfo *result;

    memset(&hints, 0, sizeof(struct addrinfo));
    hints.ai_family = AF_UNSPEC;
    hints.ai_socktype = SOCK_STREAM;
    hints.ai_flags = 0;
    hints.ai_protocol = 0;

    for (int i = 0; i < atoi(argv[1]); i++) {
        int s = getaddrinfo("www.naver.com", NULL, &hints, &result);
        if (s != 0) {
            fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(s));
            exit(-1);
        }
        printf("%d\n", i);
        sleep(10);
    }
    return 0;
}

실행해보면, 매번 쿼리가 날라가는 것을 볼 수 있습니다. 즉, 이 이야기는, DNS Caching을 app 수준에서 따로 제어하지 않는다면, DNS 쿼리를 매번 호출하게 된다라는 얘기가 됩니다. 보통 DNS 쿼리를 굉장히 자주 날리는 것은 성능상 문제를 일으킬 수 있습니다. 실제로 이걸 잘 하는게 쉽지는 않을듯 하네요.

glibc 코드를 살짝 까보면, getaddrinfo 는 gaih_inet 이라는 함수를 호출해서 결과를 가져옵니다. gaih_inet 는 USE_NSCD가 켜져있으면 NSCD에서 캐시된 결과를 찾는 것으로 보이고, 그게 아니라면, 일단 hosts 파일에 있는지 체크합니다. 이래서 hosts에 등록하면 DNS 쿼리 없이 항상 제일 먼저 가져오게 됩니다. 그 뒤에 옵션이나 상황에 따라, gethostbyname2_r, gethostbyname3_r, gethostbyname4_r 을 콜하게 됩니다. 이 함수들은 실제로 resolv/nss_dns/dns-host.c 에 있는 _nss_dns_gethostbynameX_r 함수들과 매핑이 되고 여기서 DNS Query를 날리고 가져오게 됩니다.

즉, 이 얘기는 OS단에서 해주는게 없을 가능성이 높고, 지금 DNS 쿼리는 여전히 발생하고 있을지 모른다는 얘기다 됩니다.(일단 저는 이렇게 이해했는데… 잘 아시는 분 답변좀 T.T), 이걸 해결하기 위해서는 nscd 를 설치해야만, OS 레벨의 캐싱이 적용이 되게 됩니다. 아니면, app 레벨에서 직접 해줘야…

일단 자바의 경우는 jvm 레벨에서 DNS 캐싱이 적용되어 있습니다. 그래서 DNS Based Failover를 하려면 좀 더 자세히 알아야 합니다. jdk 소스를 보면 InetAddress.java, InetAddressCachePolicy.java 이 있습니다. InetAddress.java 에서 InetAddressCachePolicy 클래스를 사용하는데, 여기의 기본 옵션이 FOREVER 입니다. 그리고 InetAddress 에서 getCachedAddresses 함수를 호출하면서, 매번 위의 Policy를 확인하고, 위의 값들을 조절하면 networkaddress.cache.ttl 는 내부적으로 사용할 TTL 의 값(가져와서 ttl 확인하고 expire 시키네요.) 두 definition networkaddress.cache.ttl 이나 sun.net.inetaddr.ttl 을 먼저 체크해서 하나라도 값이 있으면, 해당 값으로 설정이 되고 없으면 SecurityManager를 체크하는데 이때 SecurityManager도 없으면 기본 TTL이 30초로 설정되게 됩니다.

[혀로그래머 charsyam은 구라쟁이 Q&A] 레디스 관련 Q&A

안녕하세요. 혀로그래머 구라쟁이 charsyam 입니다. 오늘은 제가 자주 서식하는 페북 커뮤니티에 질문을 누군가 올려주셔서 거기에 대한 답변을 간단하게 달아놓은 것을… 질문이 워낙 좋으셔서… 정리해 봤습니다.

먼저 질문은 다음과 같습니다.

  1. 인프라 구조에서 Scale-Out 구조를 가진 경우 각 데이터를 어떤 Node에 저장되고 있는지를 판별하고 있어야 하며 데이터 유실을 대비하여 데이터 블럭을 보통 분리하여 저장합니다. 레디스의 경우 한 노드가 죽었을때 휘발성인 캐쉬임을 대비하여 어떤 방식을 구현하는지요?
  2. 본문에 사용량이 많아지면 메모리 파편화가 일어난다고 하였는데(보통 디스크나 메모리의 경우 영속성이 있어야 성능이 잘나오는걸로 알고 있습니다.) 해당 파편화를 줄이는 알고리즘이나 파편화가 일어난 경우 해당 데이터를 재배치를 하는건가요?
  3. 서버 아키텍처는 캐쉬의 경우 여러 종류의 캐쉬를 두어 각 캐쉬별 역할을 구분하게 되어지는데 레디스도 그런 방식을 차용하고 있는건가요?
  4. 이슈를 대비하여 서버 대수만 늘려야한다면 아키 설계가 어려울듯 한데 아키 설계는 보통 어떻게?
  5. 레디스 서버 한대 다운시 처리는 어케 하는지요

여기에 대한 답변을 다음과 같이 정리했습니다.

  1. 인프라 구조에서 Scale-Out 구조를 가진 경우 각 데이터를 어떤 Node에 저장되고 있는지를 판별하고 있어야 하며 데이터 유실을 대비하여 데이터 블럭을 보통 분리하여 저장합니다. 레디스의 경우 한 노드가 죽었을때 휘발성인 캐쉬임을 대비하여 어떤 방식을 구현하는지요?
    1. 글에서 언급한 것 처럼 그냥 버리는 케이스가 있습니다. 각 노드들에 데이터들이 날아가도 실제 DB에서 처리할 수 있는 정도라면… 예를 들어 한대 죽었을 때 10% 정도 부하가 올라가는데, 이 정도는 원래 처리할 수 있다면, 무시해도 되겠죠.
    2. 캐시도 중요할 경우 Master/Slave 로 레디스 같은 경우 설정해 둘 수 있습니다. 멤캐시는 이게 안되서, 따로 리플리케이션을 구현하셔야 합니다.(Mysql Binlog를 이용하든지, 서버 로직에서 두 군데를 쓰든지…)
  2. 본문에 사용량이 많아지면 메모리 파편화가 일어난다고 하였는데(보통 디스크나 메모리의 경우 영속성이 있어야 성능이 잘나오는걸로 알고 있습니다.) 해당 파편화를 줄이는 알고리즘이나 파편화가 일어난 경우 해당 데이터를 재배치를 하는건가요?
    1. 레디스의 메모리 파편화는 다른 장비로 이전하는 수 밖에 없습니다. 보통 메모리 파편화가, 잦은 메모리 할당과 해제로 인해서 발생하므로, 장비 이전을 하면, 삽입만 대량으로 발생하니, 단편화 이슈가 조금 덜합니다. 보통 이런 경우 메모리를 2배로 늘린 장비로 이전합니다. 이전 과정은 간단하지만, 모니터링이 필요합니다.
    2. 말씀하신것 처럼 해당 데이터를 재배치 하는 것은 현재 레디스 상황에서는 쉽지는 않습니다. 재배치를 해봐도, 메모리 상황이 바로 좋아지는 것이 아니라, jemalloc에서 내부적으로 관리하는 매커니즘과 섞여서, 좀 외부에서 알기 어렵습니다.
  3. 서버 아키텍처는 캐쉬의 경우 여러 종류의 캐쉬를 두어 각 캐쉬별 역할을 구분하게 되어지는데 레디스도 그런 방식을 차용하고 있는건가요?
    1. 레디스가 내부적으로 그렇게 나누는 것은 아니고, 캐시를 사용하는 비지니스 로직에서 보통은 종류를 나눠서 사용하는 것이 제 경험상 메모리 사용량이나 파편화면에서 유리했습니다.(보통 그렇게 많이 쓰구요.)
    2. 통합 캐시(그냥 다 때려박는 형태)의 경우는 아이템별 메모리 사이즈의 차이가 커서 파편화를 더 가속화 시키는 측면이 있습니다.
  4. 이슈를 대비하여 서버 대수만 늘려야한다면 아키 설계가 어려울듯 한데 아키 설계는 보통 어떻게?
    1. 클라우드냐, 자체 IDC냐에 따라서 고려해야 할 것들이 좀 바뀝니다. 일단, 공통적으로 서비스의 Configuration이 Dynamic 할 수 있어야 합니다. 뭘 쓰는지는 크게 중요하지 않지만, 서비스를 내리고 올리는 형태가 아니라, 특정 보드에 설정을 바꾸면, 그게 전체 서버에 자동으로 반영되서, 서비스를 중단하지 않을 수 있어야 합니다. 서버의 추가나 제거도 마찬가지 입니다.
    2. 그런 아키텍처가 구성이 되면, 이제 IDC냐 클라우드냐에 따라서 고민할 것이 네트웍 밴드위스와 상면의 이슈가 있습니다. 개발자 입장에서는 네트웍 스위치의 밴드위스를 고려하지 않는 경우가 있는데, 이럴 경우, 큰 문제를 일으킬 수 있습니다. 상면 위치도 마찬가지입니다. 미리 잘 고민 안하면, 장비가 추가가 안되서, 해당 캐시만 다른 IDC에 넣어야 하는 경우도…(레이턴시가…)
  5. 레디스 서버 한대 다운시 처리는 어케 하는지요
    1. 레디스 서버 한대 다운시는… 여러 가지 방법이 있습니다. 자동 failover를 원하시면 sentinel 을 쓰든지 자체로 간단한 agent 를 만들어서 하는 방법이 있습니다. 이걸 vip, dynamic dns랑 잘 활요하면 클라이언트 입장에서는 크게 신경을 쓰지 않게 auto failover 를 제공할 수도 있습니다.

[혀로그래머 charsyam은 구라쟁이 #2] 캐시 멤캐시나 레디스 쓰세요. 쉬워요

안녕하세요. 혀로그래머이자 구라쟁이 charsyam 입니다. 오늘은 지난 1편 [혀로그래머 charsyam은 구라쟁이 #1] 샤딩은 쉬워요 샤딩하세요. 의 뒤를 이은 2편 “캐시 멤캐시나 레디스 쓰세요. 쉬워요” 편입니다.

일단 캐시를 왜 쓰는걸까요? 캐시라는 의미는 원래는 연산이 오래 걸리는 작업이나, 레이턴시가 긴곳에서 뭔가 가져와야 할때, 그 시간을 위해서 보다 빠른 저장 장치나, 미리 결과를 저장해 놓고 전달하는 것을 말합니다.

즉, DB나 다른 API 서버에서 결과를 받아와야 하는 것이 느리면, 메모리나, 로컬에 미리 저장을 해놓고 그 결과를 던져주는 것으로 시간을 줄이는게 캐시의 역할입니다.

그런데 로컬에만 들고 있으면, 어떤 서버는 해당 값을 들고 있을 수도 있고, 또 어떤 서버는 없을 수도 있고, write가 발생하면 해당 캐시를 지워줘야 하는데…, 그럼 전체 서버에 해당 값을 지우라고 보내야 하니깐… 귀찮아서, 어느 서버에 데이터를 담아두고, 모두가 거기를 참조하게 됩니다.

이때, 보통 멤캐시나 레디스를 쓰게 됩니다. 남들이 많이 쓰니깐, 추천도 많이 받으니깐요. 현재는 보통 멤캐시보다 레디스를 더 많이 사용하게 됩니다. 이 이유를 설명하자면 복잡한데, 간단하게 설명하면, 레디스가 더 많은 기능을 제공해 줍니다. 그래서 사용하기에 더 편합니다.(레디스 말고 캐시로만 쓰실꺼면 Memcache 변형인 Arcus 같은 것도 좋습니다.)

사실, 이런 캐시가 도입되면 사실 여러가지로 편해집니다. 속도도 빨라지고, 알고리즘이 기본적으로 제공되는 것들을 이용하면, 구현도 좀 더 쉬워지고요. 실제로 거의 대부분(99.99999%) 의 웹서비스 업체에서는 멤캐시나, 레디스를 이용하고 있습니다. 아래를 보면 DB 앞에 멤캐시를 추가하고 나서 DB의 query 가 변경되는 실제 예입니다. select query 수가 1000 까지 오르던 것이 거의 100 근처까지 떨어진 것을 알 수 있습니다.

스크린샷 2017-05-28 오전 12.49.08.png

 

레디스나 멤캐시를 쓰다보면, 처음에는 모든 것이 좋습니다. 그래서 이런 글도 있습니다.

레디스와 함께한 시간 모두 눈부셨다.

날이 좋아서

날이 좋지 않아서

날이 적당해서

모든 날이 좋았다.

(드라마 레디스)

그런데 트래픽이 몰아치고,  메모리가 한계에 이르기 시작하면 새로운 세상이 펼쳐집니다. 보통 말하지 않는 것들이 모두 이 메모리의 이슈와 연관되어 있습니다.

먼저 첫번째 문제로, 캐시가 죽으면 어떻게 해야 할까요? 캐시니깐 죽어도 됩니다라고 말하면, 구라입니다. 트래픽이 적을 때는 큰 문제는 아닙니다. 자, 캐시가 무엇때문에 쓴다고 했었나요? 연산이 오래걸리거나, 시간이 긴 작업의 속도를 빠르게 제공하기 위해서 입니다. 그럼 다음과 같은 상황을 가정해봅시다.

  1. 현재 트래픽이 굉장히 높습니다.
  2. 캐시가 죽습니다.

그러면, 이제 기존의 트래픽이 어디로 가게될까요? 빙고, DB로 가게 됩니다.(사실입니까?) 그럼, 캐시가 전혀 없을 때, 이 DB가 버틸 수 있다면, 사실 큰 문제가 없습니다.(아깐 앞에선 구라라며, 여기서도 구라를…) 그런데 보통 캐시가 대부분의 처리를 담당하고 있었다면, 캐시 서버가 죽는 순간 모든 트래픽은 DB로 전달되고, DB가 못버티면, 서비스 장애가 발생하게 됩니다. 그러면 캐시 서버는 몇대를 두는 것이 정답일까요? 정답은…

 

정답은… 죽어도 디비에 큰 영향이 없을 수 만큼 두어야 합니다.(즉 케바케!!!, 죽어 퍽퍽퍽, 이런걸 답이라고…) 사실, 결국 이런건 적절히 서비스를 운영해보면서 바꿀 수 밖에 없습니다. 약간의 힌트가 있다면, 캐시 없이 버틸 수 있는 QPS가 어느정도인지 확인해보고, 캐시가 커버해주는 처리량등을 잘 확인해야 합니다.

그렇다면, 캐시 서버들이 죽지 않도록 만들면 되지 않을까요? 라는 질문을 할 수 있습니다. 그런데, 그러기 위해서 필요한 것들이 캐시 서버의 메모리 관리입니다. 그런데 이 메모리 관리가 쉽지가 않습니다. 특히 멤캐쉬의 경우는 좀 문제가 적은데, 레디스의 메모리 관리는 많은 노력이 필요합니다.

일단 레디스는 메모리 파편화가 발생하기 쉽습니다. 그로 인해서 항상 레디스의 RSS 실제 물리 메모리 사용량을 모니터링 하다가, 어느 수준의 증가가 보이면, 다른 장비로 이전을 시켜줘야 합니다. 그럼 이 과정은 쉽냐?, 크게 어렵지는 않지만, 또한 실패할 가능성도 있습니다. 그리고, 이를 위해서는 결국 서버 아키텍처가 이런 변화를 쉽게 대응할 수 있도록 해줘야 합니다. 다른 장비로 이전을 한다면, 기존 서버의 도메인이나 ip를 새로운 장비가 가지도록 하거나, 쉽게 시그널을 보내면, 기존 장비 대신에 새로운 서버로 연결되도록 미리 만들어 두어야 합니다.(하지만 실제로 이러기도 쉽지는 않습니다.)

또한 죽지 않더라도, 인메모리 캐시들은 스왑메모리 영역을 사용하게 되면, 그때부터 성능이 많이 떨어지게 되는데, 멤캐시나 레디스를 쓰다보면, 특정 상황에 스왑메모리를 사용하게 되는 경우가 발생합니다. 이렇게 되면 해당 캐시서비를 리스타트 하지 않는 이상 해당 메모리는 계속 스왑을 쓸 수도 있기 때문에, 특정 상황에서 성능이 저하될 수 있습니다.

 

정리하자면, 레디스나 멤캐시를 사용하는 것은, 쉽고 좋습니다. 하지만, 이를 많이 사용하게 되었을 때 부터의 관리 이슈는, 상당히 심각합니다. 이를 해결하지 못하면, 도리어 레디스나 멤캐시를 사용하는 것이 심각한 문제의 원인이 됩니다.

 

재밌는 것은 많이 쓰는 곳은 외국의 큰 회사들도 같은 문제를 가지고 있습니다. 1대, 2대가 아니라, 몇십대, 아니면 몇백대, 아니면 몇천대를 쓰는 곳에서는 이런 이슈들을 어떻게 다루어야 할까요? 거기에 대한 많은 고민이 필요합니다.

[혀로그래머 charsyam은 구라쟁이 #1] 샤딩은 쉬워요 샤딩하세요.

안녕하세요. 혀로그래머!!! charsyam 입니다.  민방위 훈련을 받다가, 나처럼 실력 없는 개발자는 스스로 갈궈야 한다라는 생각이 들어서, 스스로를 비판하고 까기 위한 글을 쓰기로 했습니다.

원래 제가 발표하는 주 대상이 주로 개발 경험이 많이 없는 학생, 주니어들을 대상으로 하다보니, 많은 구라를 포함하고 있습니다.(제가 발표때 마다 호구(?) 조사를 빙자해서 학력/경력을 물어보는 이유입니다.) 그 중에서 가장 대표적인 구라가 바로… 샤딩 쉬워요. 샤딩하세요 입니다.

사실 서비스가 성장해 나가다가 가장 문제가 되는 부분이 데이터의 폭발입니다. 서비스가 성장하는 데 API 서버가 문제라면 소위 Stateless 형태라면, 쉽게 확장이 가능합니다.(이런 얘기 할 때, 절대로, Stateless 하지 못한 경우는 언급하면 안됩니다. 구라가 깨집니다.) 그런데 데이터의 폭발로 인해서 DB서버가 늘어나야 한다고 하면, 뭔가 버거워 보입니다. 그래서 항상 서비스 시작 전에 이런 부분에 대해서 미리 고민하고 시작해라라고 말을 합니다.(딱, 여기까지만 얘기합니다.)

그러면서 화려하게 DB 샤딩 방법에는 Range, Module, Indexed 같은 방법이 있고, 각각의 특성이 있다고 하면서 섞어써도 된다는 말들을 화려하게 해줍니다. 이러면서 구라가 완성이 되는 거죠.

그런데…, 처음 서비스를 구축할 때 부터, 이런 부분을 고려하면 정말 좋은걸까요? 당연히 시간이 조금 더 걸립니다. 하지만, 뒤에는 좋긴 합니다. 처음부터 고려를 하고 시작한 서비스니깐요. 그리고 여기까지만 얘기하고 이제 조용히 종료를 해야죠.(구라는 언제나 진실과 함께 해야만  잘 먹혀듭니다.)

그런데, 이런 고민을 서비스를 시작할때만 할까요? 기존에 서비스를 쓰고 있던 업체들에게도 샤딩하세요 라고 얘기를 합니다.(실제로는 서비스하시는 분들은 제가 피해다닙니다. 구라가 걸릴까봐… 손목가지 날라갑니다.) 그런데 그게 그렇게 쉽게 가능할까요?

자, DB 한대, API 서버 한대로 운영중인 회사가 있다고 하겠습니다. 서비스가 성장하는게 보입니다. 이제 슬슬 DB 서버에 데이터가 꽉 찬거 처럼 보입니다. 그런데 지나가던 구라쟁이(?)가 샤딩하세요, 어렵지 않습니다. 이런 얘기를 합니다. 그걸 믿고, 이제 사장님은 개발자에게 샤딩을 하라고 시키시죠. 그거 쉽데, 이런 것들이 있고, 블라 블라 블라 하십니다.

아, 쉽겠구나 하면서, DB 로직을 살펴봅니다. 그런데… 어디서 join 문들이 보이기 시작하네요. 또 프로시저 같은것도 쓰고 있습니다. 슬슬 열불이 나기 시작합니다. 어떤 XX가 쉽다고 얘기했을까요?

잘 생각해보면 DB 서버 안에 테이블 여러개라면 join이 쉽게 가능합니다. 느리게 돌 수도 있지만, DB가 처리하는 것 만큼 효과적으로 짜기도 어렵습니다. 그런데… 샤딩을 하고 나니… 테이블이… 서로 다른 서버에 데이터를 나눠 가지고 있습니다. join 해야할 테이블도 분리되어 있습니다.

거기다가, 트랜잭션도 쓰고 있군요. 이제 부터 지옥이 펼쳐집니다. 사실 기술 세미나에서 이런 얘기를 쉽게 하지 않습니다. 사실 쉬운 방법도 없구요. 처음부터 시작하면서 이런걸 고민해도, 사실 쉽지 않을 수 있습니다. 다른 테이블 간의 트랜잭션도 없애야 하고, join도 다 로직단에서 처리를 해줘야 합니다. 처음 부터 그렇게 만들지 않았다면… 절대로 쉬운 일이 아닙니다. 이렇게 되면, 실제로 코드에 해당 실패에 대한 대응들이 다 들어가야 합니다. 일종의 보상 처리라고도 합니다만… 잘 짜도 실수의 여지는 항상 있습니다.(돈 많이 주고 오라클 쓰시는 방법도 있다고 합니다. 전 안써봐서 모릅니다.)

그럼 문제가 여기에만 있을까요? 아닙니다. 샤딩을 하면, 이제 장비가 추가되어야 할 때마다, 노력이 추가로 들어갑니다. 물론 어느정도 자동화가 가능합니다만, 꼭 모니터링이 필요한 일입니다. (이런 글을 참고하세요. http://gywn.net/2012/05/how_to_shard_big_data_in_tumblr/)

밑단의 데이터에서 뭔가 변경이 일어나는 것은 절대로 쉽지 않습니다. 다만 성장하다보면, 절대로 피해갈 수 없는 길이기도 합니다. 그리고 누가 샤딩이 쉽다라고 하면, 아 저 아재 구라쟁이구나 이렇게 보셔도 됩니다.(초천재일수도 있긴 합니다만…)

다음 편은, 캐시 멤캐시나 레디스 쓰세요. 쉬워요에 대한 구라를 파헤치도록 하겠습니다. 모두들 고운하루되세요.

[용어 정리] 입 개발자를 위한 TF-IDF

뭔가 아는척을 위해서 알아두면 좋은 단어중에 지난번에 언급했던 Accuracy, Recall, Precision 같은 것들이 있는데, 이것 말고도 알아두면 입 개발자로 아는 척 하기 좋은 단어가 있습니다. 바로 TF-IDF 인데요. 보통, 검색이나 다른쪽을 하시는 분들은 다들 잘 알고 있는 단어이기도 합니다.(개인적으로는 해당 강의 https://www.coursera.org/learn/ml-foundations 를 들으시길 추천합니다.)

그럼 일단 단어를 정리하면 TF-IDF 는 TF와 IDF의 합성어입니다.

TF Term Frequency, 문서에서 해당 단어가 얼마나 나왔는지를 나타내는 단어, 예를 들어,  이 문서에 “입개발”이 10번 나오면 입개발의 TF는 10이라고 할 수 있습니다. 다만 이런 값의 정의는 바꿀 수도 있습니다. 여러번 나와도 1이라고 정의할 수도 있고, 엄청 많은 값을 좀 줄이기 위해서 log 값을 씌우기도 합니다.
DF Document Frequency 입니다. TF는 한 문서에서 나타난 빈도라면, DF는 전체 문서들에서, 몇 개의 문서에 나타나는지에 대한 값입니다.  즉 이에 대한 수식은 대략 (해당 단어가 나타난 문서 수/ 전체 문서 수) 라고 보시면 됩니다.
IDF Inverse Docuemnt Frequency 입니다. DF의 역수를 취했다고 보시면 됩니다. 즉 (전체 문서 수/해당 단어가 나타난 문서 수) 입니다. 그런데 해당 단어가 있는 문서가 없을 수도 있으니 보통 분모에 1을 더해줘서(0 되지 말라고), 해서 (전체 문서 수/1 + 해당 단어가 나타난 문서 수)로 많이 표시합니다.

이제 여기서 중요한 것은 왜 IDF를 사용하는가 입니다. 검색이든, 문서의 유사도 검색을 할 때도 많이 사용하는데, 이런 것들을 할때 중요한 것은 해당 문서의 특징을 뽑아내는 거라고 할 수 있습니다.(지금부터 구라가 작열합니다!!)

먼저 문서의 유사도를 비교한다면 어떻게 할 수 있을까요? “머신러닝” 이렇게 외치시면, 일단 “러닝머신”을 한두시간 타 보시고요. 어려운 방법을 빼고 생각해보면… 단어가 얼마나 일치하는가 보면 될것 같습니다.

  1. 단어들을 모두 분리해서, 각 단어의 개수를 센다.
  2. 해당 단어들의 개수랑 얼마나 일치하는 지 살펴본다.
    1. 그런데 요 부분도 이해하기 어려울 수…

tfidf1

위의 그림을 보면 각 단어의 출현 빈도를 저장하고, 이 값들을 비교해서 다른 문서와 얼마나 유사한지 비교하게 됩니다.

tfidf2

위의 그림도 단순한 유사도를 구하는 예입니다. 여러 가지 방법이 있을 수 있습니다.(여기서는 문서에 많은 단어가 있으면, 그 유사도 값이 너무나 커버리는 이슈도 있어서, 이 값을 normalize 를 시켜야 하는데 이런건 일단 넘어가도록 하겠습니다. )

그런데 문서를 하나 본다면 일단 설명을 쉽게 하기 위해서 영어를 예로 들면, the, a, an, and, or, but 등등의 관사나 조사 같은 것들이 많이 들어있게 됩니다. 그런데 단순히 문서를 단어로만 나눠서 갯수로 비교를 한다면? 위의 기법을 써버리면 엄청 the 가 많아도 다들 비슷한 문서로 생각해 버리게 될겁니다. 그럼 어떤 방법이 있을까요? 간단하게 생각하기에…

  1. 저렇게 쓸모 없는 단어를 다 빼고 비교한다.
    1. 그럼에도 중요한 단어와 중요하지 않은 단어를 구분하지 못하는 문제가…
  2. 그냥 저렇게 중요하지 않을 단어들은 가중치를 낮게 주고 중요한 단어들은 가중치를 올려주자.

위의 두 방법중에, 1번은 꽤 명확한데, 2번은 그럼 중요한 단어를 어떻게 정할 것인가 하는 이슈가 생깁니다. 그런데 지금 이게 무슨 용어를 설명하는 걸까요? 네, 그렇습니다. TF-IDF!!!

즉, TF-IDF가 중요한 단어와 중요하지 않은 단어를 구분할 수 있는 방법인 것입니다. 여기서 일단 TF-IDF의 가정은, 특정 단어가, 해당 문서에서는 자주 출현하지만, 다른 문서에서는 많이 안나오면 중요한 단어일 것이다 라는 것입니다. 왜냐하면 다른 문서들에도 자주 나오는 거면, 아까 말한 the, a, an, of, and, or, but 같은 관사나 접속사등이 많을 것이기 때문입니다.

이제 뭔가 연관이 보이시나요? IDF의 (전체 문서 수/해당 단어가 나타난 문서 수)가 어떤 의미일까요? 즉 해당 단어가 적은 문서에 나타날 수록 IDF 가 커지게 됩니다. DF는 반대로, 해당 단어가 여러 문서에 나타날 수록 값이 커지는거구요.

이제 IDF를 구함으로써, 우리는 문서들 중에서는 적게 나타나는 단어를 찾을 수 있게 되었습니다. 그리고 해당 문서에서 중요한 단어는 TF로 구할 수 있기 때문에, 우리의 핵심 가정 – “해당 문서에서는 자주 나타나고, 전체 문서에는 적게 나타나는 단어”를 구하는 방법이 TF와 IDF를 곱하는 TF-IDF 가 되는 것입니다.

그런데 실제로 아까 제가 말한 공식으로 바로 쓰지는 않습니다. 왜냐하면 해당 값들이 천차 만별로 커지기 때문에, 로그를 씌운다든지, 제곱근을 구한다든지 그렇게 됩니다. 위키디피아에 꽤 설명이 잘 되어 있습니다. https://ko.wikipedia.org/wiki/TF-IDF