Writing system software: code comments.

Salvatore Sanfilippo

시스템 소프트웨어 작성하기: 코드 주석

원문은 Salvatore Sanfilippo님이 에 게재했습니다. 이 블로그 구독하기

꽤 오랫동안 YouTube의 "writing system software" 시리즈에서 코드 주석에 대해 이야기하는 새로운 영상을 찍고 싶었다. 하지만 곰곰이 생각해 보니 이 주제는 블로그 글로 다루는 편이 더 낫겠다는 생각이 들어 이렇게 글을 쓰게 됐다. 이 글에서는 Redis의 주석을 분석하며 유형별로 분류해 보려 한다. 그 과정에서 왜 주석 작성이 장기적으로 유지보수 가능하고, 다른 사람뿐만 아니라 작성자 본인이 수정하고 디버깅할 때도 이해할 수 있는 좋은 코드를 만드는 데 무엇보다 중요한지, 내 생각을 풀어내고자 한다.

모두가 그렇게 생각하는 것은 아니다. 많은 사람들은 코드가 충분히 탄탄하면 주석은 쓸모없다고 믿는다. 모든 것이 잘 설계되어 있으면 코드 자체가 코드가 무엇을 하는지 설명해 주므로 코드 주석은 불필요하다는 발상이다. 나는 두 가지 큰 이유로 그 관점에 동의하지 않는다:

  1. 많은 주석은 코드가 무엇을 하는지 설명하지 않는다. 코드가 하는 일만 봐서는 알 수 없는 것을 설명한다. 이때 빠진 정보는 대개 코드가 특정 동작을 하는지, 혹은 더 자연스러워 보이는 다른 방법이 아닌 지금의 명확한 방법을 왜 택했는지다.
  2. 코드가 하는 일을 한 줄 한 줄 문서화하는 것은 대개 유용하지 않다. 읽어보면 이해할 수 있기 때문이다. 하지만 읽기 좋은 코드를 작성하는 핵심 목표 중 하나는 독자가 코드를 읽으면서 머릿속에 담아야 하는 노력과 세부 정보의 양을 줄이는 것이다. 그래서 나에게 주석은 독자의 인지 부하를 낮추는 도구다.

다음 코드 스니펫은 위 두 번째 지점을 잘 보여주는 예시다. 이 글에 등장하는 모든 코드 스니펫은 Redis 소스 코드에서 가져온 것임을 밝혀둔다. 각 스니펫은 추출된 파일 이름을 앞에 붙여 제시한다. 사용한 브랜치는 해시가 32e0d237인 현재 "unstable" 브랜치다.

scripting.c:
    /* Initial Stack: array */
    lua_getglobal(lua,"table");
    lua_pushstring(lua,"sort");
    lua_gettable(lua,-2);       /* Stack: array, table, table.sort */
    lua_pushvalue(lua,-3);      /* Stack: array, table, table.sort, array */
    if (lua_pcall(lua,1,0,0)) {
        /* Stack: array, table, error */

        /* We are not interested in the error, we assume that the problem is
         * that there are 'false' elements inside the array, so we try
         * again with a slower function but able to handle this case, that
         * is: table.sort(table, __redis__compare_helper) */
        lua_pop(lua,1);             /* Stack: array, table */
        lua_pushstring(lua,"sort"); /* Stack: array, table, sort */
        lua_gettable(lua,-2);       /* Stack: array, table, table.sort */
        lua_pushvalue(lua,-3);      /* Stack: array, table, table.sort, array */
        lua_getglobal(lua,"__redis__compare_helper");
        /* Stack: array, table, table.sort, array, __redis__compare_helper */
        lua_call(lua,2,0);
    }

Lua는 스택 기반 API를 사용한다. 위 함수에서 각 호출을 따라가면서 Lua API 레퍼런스를 참고한다면 독자도 매 순간 스택 상태를 머릿속으로 재구성할 수 있을 것이다. 하지만 왜 독자에게 그런 수고를 강요해야 하는가? 코드를 작성하는 동안 원저자는 어쨌든 그 정신적 노력을 해야 했다. 내가 한 일은 그저 각 호출 뒤의 현재 스택 구조를 매 줄마다 주석으로 달아놓은 것뿐이다. 이제 이 코드를 읽는 일은 Lua API 자체가 따라가기 결코 쉽지 않다는 사실을 떠나서 아주 간단해졌다.

여기서 내 목표는 소스 코드의 특정 구간만 읽어서는 분명히 알 수 없는 배경을 제공하는 도구로서 주석이 얼마나 유용한지에 대한 내 관점을 제시하는 것에 그치지 않는다. 더 나아가 역사적으로 쓸모없거나 심지어 위험하다고 여겨져 온 종류의 주석, 즉 왜가 아니라 코드가 무엇을 하는지를 서술하는 주석이 얼마나 유용한지에 대한 근거도 제시하려는 것이다.

주석의 분류

이 작업은 Redis 소스 코드의 무작위 부분을 읽으면서 다양한 맥락에서 주석이 유용한지, 왜 유용한지를 확인하는 것부터 시작했다. 곧 드러난 것은 주석이 기능, 작성 스타일, 길이, 갱신 빈도 면에서 매우 다르며, 그만큼 매우 다른 이유로 유용하다는 사실이었다. 결국 이 작업을 분류 작업으로 발전시키게 됐다.

연구 과정에서 나는 아홉 가지 유형의 주석을 구분해 냈다:

  • 함수 주석
  • 설계 주석
  • 이유(Why) 주석
  • 교육용 주석
  • 체크리스트 주석
  • 가이드 주석
  • 자명한 주석
  • 부채 주석
  • 백업 주석

내 생각에 앞선 여섯 가지는 대체로 매우 긍정적인 주석 형태이며, 마지막 세 가지는 다소 논란의 여지가 있다. 다음 절들에서는 각 유형을 Redis 소스 코드의 예시와 함께 분석하겠다.

함수 주석

함수 주석의 목표는 애초에 독자가 코드를 읽지 않아도 되게 하는 것이다. 대신 주석을 읽은 뒤에는 해당 코드를 특정 규칙을 따르는 블랙박스로 간주할 수 있어야 한다. 보통 함수 주석은 함수 정의의 맨 위에 위치하지만, 클래스나 매크로, 인터페이스를 정의하는 다른 기능적으로 독립된 코드 블록을 문서화하기 위해 다른 곳에 위치할 수도 있다.

rax.c:

    /* Seek the grestest key in the subtree at the current node. Return 0 on
     * out of memory, otherwise 1. This is an helper function for different
     * iteration functions below. */
    int raxSeekGreatest(raxIterator *it) {
    ...

함수 주석은 사실 인라인 API 문서의 한 형태다. 함수 주석이 충분히 잘 작성되어 있다면, 사용자는 대부분의 경우 함수나 클래스, 매크로 등의 구현을 읽을 필요 없이 자신이 읽고 있던 곳(해당 API를 호출하는 코드)으로 다시 돌아갈 수 있어야 한다.

모든 종류의 주석 중에서도 이는 프로그래밍 커뮤니티 전반에서 필요하다고 가장 널리 인정받는 유형이다. 유일하게 따져볼 점은 API 레퍼런스 문서에 가까운 주석을 코드 내부에 두는 것이 좋은 생각인가 하는 것이다. 나에게 답은 간단하다. 나는 API 문서가 코드와 정확히 일치하기를 원한다. 코드가 바뀌면 문서도 바뀌어야 한다. 이런 이유로 함수나 다른 요소의 프롤로그로서 함수 주석을 사용하면 API 문서를 코드 가까이에 두게 되며, 세 가지 성과를 얻을 수 있다:

  • 코드가 변경될 때 문서를 동시에 쉽게 변경할 수 있어 API 레퍼런스가 오래되거나 낡을 위험이 없다.
  • 이 방식은 변경 내용을 가장 잘 이해해야 할 변경 작성자가 API 문서 변경의 작성자도 될 확률을 최대화한다.
  • 코드를 읽으면서 함수나 메서드의 문서를 정의된 위치에서 바로 찾을 수 있어 독자가 코드와 문서를 오가며 맥락을 전환하지 않고 코드에만 집중할 수 있다.

설계 주석

"함수 주석"이 보통 함수의 시작 부분에 위치하는 반면, 설계 주석은 더 자주 파일의 시작 부분에 위치한다. 설계 주석은 기본적으로 특정 코드 조각이 어떤 알고리즘, 기법, 요령, 구현을 어떻게 그리고 왜 사용하는지를 서술한다. 코드에서 구현된 내용에 대한 더 높은 수준의 개요인 셈이다. 이런 배경 지식이 있으면 코드를 읽기가 더 쉬워진다. 게다가 나는 설계 노트를 찾을 수 있는 코드를 더 신뢰하는 편이다. 적어도 개발 과정 중 어느 시점에 명시적인 설계 단계가 있었다는 것을 알 수 있기 때문이다.

내 경험상 설계 주석은 구현이 제안하는 해결책이 다소 지나치게 단순해 보일 경우, 어떤 대안들이 있었고 왜 아주 단순한 해결책이 해당 사례에 충분하다고 여겨졌는지를 밝히는 데도 매우 유용하다. 설계가 올바르다면 독자는 그 해결책이 적절하며, 그러한 단순함이 게으름이나 기초적인 것만 코딩할 줄 알기 때문이 아니라 어떤 과정을 거쳐 나온 결과임을 스스로 납득하게 될 것이다.

bio.c:
     * DESIGN
     * ------
     *
     * The design is trivial, we have a structure representing a job to perform
     * and a different thread and job queue for every job type.
     * Every thread waits for new jobs in its queue, and process every job
     * sequentially.
     ...

이유(WHY) 주석

이유 주석은 코드가 무엇을 하는지가 아주 명확한 경우에도 코드가 왜 그런 일을 하는지를 설명한다. Redis 복제 코드에서 가져온 다음 예를 보자.

replication.c:

    if (idle > server.repl_backlog_time_limit) {
	/* When we free the backlog, we always use a new
	 * replication ID and clear the ID2. This is needed
	 * because when there is no backlog, the master_repl_offset
	 * is not updated, but we would still retain our replication
	 * ID, leading to the following problem:
	 *
	 * 1. We are a master instance.
	 * 2. Our replica is promoted to master. It's repl-id-2 will
	 *    be the same as our repl-id.
	 * 3. We, yet as master, receive some updates, that will not
	 *    increment the master_repl_offset.
	 * 4. Later we are turned into a replica, connect to the new
	 *    master that will accept our PSYNC request by second
	 *    replication ID, but there will be data inconsistency
	 *    because we received writes. */
	changeReplicationId();
	clearReplicationId2();
	freeReplicationBacklog();
	serverLog(LL_NOTICE,
	    "Replication backlog freed after %d seconds "
	    "without connected replicas.",
	    (int) server.repl_backlog_time_limit);
    }

함수 호출만 보면 궁금할 것이 거의 없다. 타임아웃에 도달하면 메인 복제 ID를 바꾸고, 보조 ID를 지운 뒤, 마지막으로 복제 백로그를 해제한다. 하지만 백로그를 해제할 때 왜 복제 ID를 바꿔야 하는지는 정확히 명확하지 않다.

이것은 소프트웨어가 일정 수준의 복잡도에 도달하면 끊임없이 일어나는 일이다. 관련된 코드와는 별개로 복제 프로토콜 자체가 어느 정도 복잡성을 가지고 있어, 다른 나쁜 일이 일어나지 않도록 하기 위해 특정한 일을 해야 하는 것이다. 아마도 이런 종류의 주석은 어떤 면에서 시스템에 대해 숙고하고 개선해야 할지 점검할 기회이며, 그렇게 해서 더 이상 그런 복잡성이 필요 없게 되면 주석도 제거할 수 있을 것이다. 하지만 어떤 것을 더 단순하게 만드는 것이 다른 것을 더 어렵게 하거나 단순히 실현 불가능한 경우가 많고, 하위 호환성을 깨는 향후 작업을 요구하기도 한다.

다른 예를 하나 더 보자.

replication.c:

    /* SYNC can't be issued when the server has pending data to send to
     * the client about already issued commands. We need a fresh reply
     * buffer registering the differences between the BGSAVE and the current
     * dataset, so that we can copy to other replicas if needed. */
    if (clientHasPendingReplies(c)) {
        addReplyError(c,"SYNC and PSYNC are invalid with pending output");
        return;
    }

클라이언트에게 보낼 대기 중인 출력(이전 명령어로 인한)이 남아 있는 상태에서 SYNC를 실행하면 이 명령은 실패해야 한다. 복제 핸드셰이크 동안 클라이언트의 출력 버퍼가 변경 사항을 누적하는 데 사용되고, 이후 첫 번째 레플리카와의 전체 동기화를 위해 RDB 파일을 생성하는 동안 접속하는 다른 레플리카들을 위해 복제될 수 있기 때문이다. 이것이 우리가 그렇게 하는 이유다. 우리가 하는 일은 자명하다. 대기 중인 응답이 있는가? 에러를 내보낸다. 주석이 없다면 왜 그렇게 하는지는 상당히 불분명하다.

이런 주석은 복제처럼 복잡한 프로토콜과 상호작용을 설명할 때만 필요하다고 생각할 수도 있다. 과연 그럴까? 파일을 완전히 바꾸고 목표도 바꿔 보자. 그래도 여전히 이런 주석이 곳곳에 보인다.

expire.c:

    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {
        int expired;
        redisDb *db = server.db+(current_db % server.dbnum);

        /* Increment the DB now so we are sure if we run out of time
         * in the current DB we'll restart from the next. This allows to
         * distribute the time evenly across DBs. */
        current_db++;
        ...

흥미로운 사례다. 우리는 시간이 허락하는 한 여러 DB에서 키를 만료시키고 싶다. 하지만 현재 데이터베이스를 처리하는 루프의 끝에 다음에 처리할 "데이터베이스 ID"를 증가시키는 대신, 다른 방식으로 한다. db 변수에 현재 DB를 선택해 넣은 뒤, 바로 다음에 처리할 데이터베이스(다음번 이 함수 호출 시)의 ID를 증가시킨다. 이렇게 하면 한 번 호출에서 너무 많은 작업을 수행해 함수가 종료되더라도 같은 데이터베이스부터 다시 시작하는 문제가 생기지 않는다. 같은 데이터베이스 처리에만 매몰되어 다른 데이터베이스에 논리적으로 만료된 키들이 쌓이는 일을 막을 수 있다.

이런 주석을 통해 우리는 왜 그 시점에서 증가시키는지를 설명함과 동시에, 앞으로 코드를 수정할 사람이 이러한 특성을 유지해야 함을 알린다. 주석이 없다면 이 코드는 전혀 해가 없어 보인다. 선택하고, 증가시키고, 작업을 하러 간다. 증가 연산을 루프 끝에 두는 것이 더 자연스러워 보일 텐데도 굳이 옮기지 않을 뚜렷한 이유가 없어 보인다.

여담이지만, 원래 코드에서 루프 증가 연산은 실제로 끝에 있었다. 수정 과정에서 그 위치로 옮겨졌고, 동시에 이 주석이 추가됐다. 그러니 일종의 "회귀 방지 주석"이라고 할 수 있다.

교육용 주석

교육용 주석은 코드 자체나 우리가 알아야 할 특정 부작용을 설명하려 하지 않는다. 대신 코드가 동작하는 도메인(예: 수학, 컴퓨터 그래픽스, 네트워킹, 통계, 복잡한 자료구조)을 가르친다. 이는 독자의 기술 범위를 벗어난 분야일 수도 있고, 단순히 세부 사항이 너무 많아 전부 기억하기 어려운 분야일 수도 있다.

버전 5의 LOLWUT 명령은 화면에 회전된 사각형을 표시해야 한다(http://antirez.com/news/123). 이를 위해 기본적인 삼각법을 사용한다. 사용되는 수학이 간단함에도 불구하고 Redis 소스 코드를 읽는 많은 프로그래머는 수학적 배경이 없을 수도 있으므로, 함수 상단의 주석은 함수 내부에서 무슨 일이 일어날지를 설명한다.

lolwut5.c:

    /* Draw a square centered at the specified x,y coordinates, with the specified
     * rotation angle and size. In order to write a rotated square, we use the
     * trivial fact that the parametric equation:
     *
     *  x = sin(k)
     *  y = cos(k)
     *
     * Describes a circle for values going from 0 to 2*PI. So basically if we start
     * at 45 degrees, that is k = PI/4, with the first point, and then we find
     * the other three points incrementing K by PI/2 (90 degrees), we'll have the
     * points of the square. In order to rotate the square, we just start with
     * k = PI/4 + rotation_angle, and we are done.
     *
     * Of course the vanilla equations above will describe the square inside a
     * circle of radius 1, so in order to draw larger squares we'll have to
     * multiply the obtained coordinates, and then translate them. However this
     * is much simpler than implementing the abstract concept of 2D shape and then
     * performing the rotation/translation transformation, so for LOLWUT it's
     * a good approach. */

이 주석은 함수 자체의 코드나 부작용, 혹은 함수와 관련된 기술적 세부 사항에 관한 내용을 전혀 담고 있지 않다. 설명은 주어진 목표를 달성하기 위해 함수 내부에서 사용되는 수학적 개념에만 한정된다.

나는 교육용 주석이 엄청난 가치가 있다고 생각한다. 독자가 그런 개념을 모를 경우 무언가를 가르쳐 주거나, 적어도 더 깊이 탐구할 수 있는 출발점을 제공한다. 이는 결국 교육용 주석이 특정 코드 경로를 읽을 수 있는 프로그래머의 수를 늘린다는 뜻이기도 하다. 많은 프로그래머가 읽을 수 있는 코드를 작성하는 것은 나의 주요 목표 중 하나다. 수학 실력은 없을지 몰라도 훌륭한 수정이나 최적화를 기여할 수 있는 탄탄한 프로그래머들이 있다. 그리고 일반적으로 코드는 실행되는 것 이상으로 읽혀야 한다. 사람이 다른 사람을 위해 쓰는 것이기 때문이다.

제대로 된 코드를 작성하려면 교육용 주석을 거의 피할 수 없는 경우도 있다. 좋은 예가 Redis의 래딕스 트리 구현이다. 래딕스 트리는 구조가 복잡한 자료구조다. Redis 구현은 전체 자료구조 이론을 구현하면서 다시 서술하는데, 다양한 경우와 알고리즘이 노드를 병합하거나 분할하기 위해 무엇을 하는지 등을 보여준다. 각 주석 섹션 바로 뒤에는 앞에서 서술한 내용을 구현한 코드가 이어진다. 래딕스 트리를 구현한 파일을 몇 달 동안 건드리지 않다가도 파일을 열어 몇 분 만에 버그를 고치고 다른 일을 계속할 수 있었다. 래딕스 트리가 어떻게 동작하는지 다시 공부할 필요가 없었다. 설명 자체가 코드와 뒤섞여 동일한 것이기 때문이다.

주석이 너무 길어서 일부 발췌만 보여주겠다.

rax.c:

    /* If the node we stopped at is a compressed node, we need to
     * split it before to continue.
     *
     * Splitting a compressed node have a few possible cases.
     * Imagine that the node 'h' we are currently at is a compressed
     * node contaning the string "ANNIBALE" (it means that it represents
     * nodes A -> N -> N -> I -> B -> A -> L -> E with the only child
     * pointer of this node pointing at the 'E' node, because remember that
     * we have characters at the edges of the graph, not inside the nodes
     * themselves.
     *
     * In order to show a real case imagine our node to also point to
     * another compressed node, that finally points at the node without
     * children, representing 'O':
     *
     *     "ANNIBALE" -> "SCO" -> []

     ... snip ...

     * 3a. IF $SPLITPOS == 0:
     *     Replace the old node with the split node, by copying the auxiliary
     *     data if any. Fix parent's reference. Free old node eventually
     *     (we still need its data for the next steps of the algorithm).
     *
     * 3b. IF $SPLITPOS != 0:
     *     Trim the compressed node (reallocating it as well) in order to
     *     contain $splitpos characters. Change chilid pointer in order to link
     *     to the split node. If new compressed node len is just 1, set
     *     iscompr to 0 (layout is the same). Fix parent's reference.

     ... snip ...

        if (j == 0) {
            /* 3a: Replace the old node with the split node. */
            if (h->iskey) {
                void *ndata = raxGetData(h);
                raxSetData(splitnode,ndata);
            }
            memcpy(parentlink,&splitnode,sizeof(splitnode));
        } else {
            /* 3b: Trim the compressed node. */
            trimmed->size = j;
            memcpy(trimmed->data,h->data,j);
            trimmed->iscompr = j > 1 ? 1 : 0;
            trimmed->iskey = h->iskey;
            trimmed->isnull = h->isnull;
            if (h->iskey && !h->isnull) {
                void *ndata = raxGetData(h);
                raxSetData(trimmed,ndata);
            }
            raxNode **cp = raxNodeLastChildPtr(trimmed);
        ...

보다시피 주석의 설명은 코드에서 동일한 레이블과 짝을 이룬다. 이 형식으로 전부를 보여주기는 어려우니 전체 내용을 보고 싶다면 다음 파일 전체를 확인해 보길 바란다:

https://github.com/antirez/redis/blob/unstable/src/rax.c

이런 수준의 주석이 모든 것에 필요한 것은 아니지만, 래딕스 트리 같은 것은 정말 사소한 세부 사항과 코너 케이스로 가득하다. 기억하기 어렵고, 특정 세부 사항은 해당 구현에 고유한 것이기도 하다. 연결 리스트에 대해 이렇게 하는 것은 물론 별 의미가 없다. 언제 그럴 가치가 있는지를 판단하는 것은 개인의 감각에 달렸다.

체크리스트 주석

이것은 매우 흔하면서도 독특한 유형이다. 때로는 언어의 한계나 설계상의 문제, 혹은 시스템에서 자연스럽게 발생하는 복잡성 때문에 특정 개념이나 인터페이스를 한곳에 집중시킬 수 없어, 코드 어딘가에서 다른 곳의 코드도 손봐야 함을 기억하라고 알려주는 경우가 있다. 일반적인 개념은 다음과 같다:

    /* Warning: if you add a type ID here, make sure to modify the
     * function getTypeNameByID() as well. */

완벽한 세상이라면 이런 것이 전혀 필요하지 않아야겠지만, 실제로는 때때로 피할 도리가 없다. 예를 들어 Redis 타입은 "객체 타입" 구조체를 이용해 표현할 수 있고, 각 객체가 자신이 속한 타입에 링크되도록 하면 다음과 같이 할 수 있을 것이다:

    printf("Type is %s\n", myobject->type->name);

하지만 어떨까? 우리에게는 비용이 너무 크다. Redis 객체는 다음과 같이 표현되기 때문이다:

    typedef struct redisObject {
        unsigned type:4;
        unsigned encoding:4;
        unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                                * LFU data (least significant 8 bits frequency
                                * and most significant 16 bits access time). */
        int refcount;
        void *ptr;
    } robj;

우리는 타입을 표현하는 데 64비트 대신 4비트를 사용한다. 이는 왜 때로는 일이 마땅히 그래야 할 만큼 중앙 집중화되거나 자연스럽지 않은지를 보여주기 위한 예시일 뿐이다. 상황이 그렇다면 때로는 방어적인 주석을 사용해 특정 코드 섹션을 건드릴 때 코드의 다른 부분도 함께 수정해야 함을 상기시키는 것이 도움이 된다. 구체적으로 체크리스트 주석은 다음 중 하나 또는 둘 다를 수행한다:

  • 무언가가 수정될 때 해야 할 일련의 조치를 알려준다.
  • 특정 변경이 어떤 방식으로 수행되어야 하는지에 대해 경고한다.

blocked.c에 있는 또 다른 예시로, 새로운 블로킹 타입이 도입될 때를 들 수 있다.

blocked.c:

     * When implementing a new type of blocking opeation, the implementation
     * should modify unblockClient() and replyToBlockedClientTimedOut() in order
     * to handle the btype-specific behavior of this two functions.
     * If the blocking operation waits for certain keys to change state, the
     * clusterRedirectBlockedClientIfNeeded() function should also be updated.

체크리스트 주석은 특정 "이유 주석"이 쓰이는 맥락과 유사한 상황에서도 유용하다. 즉, 어떤 코드가 왜 특정 위치에서, 혹은 어떤 것의 앞이나 뒤에서 실행되어야 하는지가 명확하지 않을 때다. 하지만 이유 주석이 어떤 문장이 왜 거기에 있는지를 알려준다면, 같은 경우에 쓰인 체크리스트 주석은 코드를 망가뜨리지 않고 수정하려면 어떤 규칙을 따라야 하는지를 알려주는 데 더 치우쳐 있다(이 경우 규칙은 주어진 순서를 따르라는 것이다).

cluster.c:

    /* Update our info about served slots.
     *
     * Note: this MUST happen after we update the master/replica state
     * so that CLUSTER_NODE_MASTER flag will be set. */

체크리스트 주석은 특정 연산의 순서가 극도로 중요한 리눅스 커널 내부에서 매우 흔하다.

가이드 주석

나는 가이드 주석을 남용하는 수준이라 아마도 Redis의 대다수 주석이 가이드 주석일 것이다. 게다가 가이드 주석은 대다수 사람들이 완전히 쓸모없다고 믿는 바로 그 주석이다.

  • 코드만 봐서는 불분명한 것을 서술하지 않는다.
  • 가이드 주석에는 설계에 대한 힌트도 없다.

가이드 주석은 단 한 가지 일을 한다. 명확한 구분과 리듬을 제공하고 앞으로 읽게 될 내용을 소개함으로써, 소스 코드에 쓰인 내용을 처리하는 동안 독자를 곁에서 보살피고 돕는 것이다.

가이드 주석이 존재하는 유일한 이유는 코드를 읽는 프로그래머의 인지 부하를 낮추기 위해서다.

rax.c:

    /* Call the node callback if any, and replace the node pointer
     * if the callback returns true. */
    if (it->node_cb && it->node_cb(&it->node))
	memcpy(cp,&it->node,sizeof(it->node));

    /* For "next" step, stop every time we find a key along the
     * way, since the key is lexicographically smaller compared to
     * what follows in the sub-children. */
    if (it->node->iskey) {
	it->data = raxGetData(it->node);

	return 1;
    }

위에 있는 주석들이 코드에 무언가를 더하고 있는 것은 아니다. 위 가이드 주석들은 코드를 읽는 것을 도울 뿐 아니라, 당신이 코드를 올바르게 이해하고 있다는 사실을 확인시켜 줄 것이다. 예를 더 들어보자.

networking.c:

    /* Log link disconnection with replica */
    if ((c->flags & CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR)) {
        serverLog(LL_WARNING,"Connection with replica %s lost.",
            replicationGetSlaveName(c));
    }

    /* Free the query buffer */
    sdsfree(c->querybuf);
    sdsfree(c->pending_querybuf);
    c->querybuf = NULL;

    /* Deallocate structures used to block on blocking ops. */
    if (c->flags & CLIENT_BLOCKED) unblockClient(c);
    dictRelease(c->bpop.keys);

    /* UNWATCH all the keys */
    unwatchAllKeys(c);
    listRelease(c->watched_keys);

    /* Unsubscribe from all the pubsub channels */
    pubsubUnsubscribeAllChannels(c,0);
    pubsubUnsubscribeAllPatterns(c,0);
    dictRelease(c->pubsub_channels);
    listRelease(c->pubsub_patterns);

    /* Free data structures. */
    listRelease(c->reply);
    freeClientArgv(c);

    /* Unlink the client: this will close the socket, remove the I/O
     * handlers, and remove references of the client from different
     * places where active clients may be referenced. */
    unlinkClient(c);

Redis는 말 그대로 가이드 주석으로 가득하다. 그래서 여는 파일마다 이런 주석이 수두룩하게 들어 있다. 왜 굳이 그럴까? 이 글에서 지금까지 분석한 모든 주석 유형 중 이것이 가장 주관적인 유형임을 인정한다. 나는 이런 주석이 없는 코드를 덜 좋다고 평가하지는 않지만, 사람들이 Redis 코드를 읽기 쉽다고 여긴다면 그 이유 중 일부는 분명 가이드 주석 덕분이라고 굳게 믿는다.

가이드 주석은 앞서 말한 것 외에도 쓸모가 있다. 코드를 명확히 구분된 섹션으로 나누기 때문에, 코드에 무언가를 추가할 때 엉뚱한 곳이 아니라 적절한 섹션에 삽입될 가능성이 매우 높다. 관련된 구문들이 가까이 모여 있는 것은 가독성 측면에서 큰 이득이다.

또한 unlinkClient() 함수가 호출되기 직전의 가이드 주석도 확인해 보라. 이 가이드 주석은 함수가 무엇을 할지를 독자에게 간략히 알려주어, 큰 그림에만 관심이 있다면 굳이 함수 내부로 들어가 볼 필요를 없애준다.

자명한 주석

가이드 주석은 매우 주관적인 도구다. 좋아할 수도 있고 그렇지 않을 수도 있다. 나는 좋아한다. 하지만 가이드 주석은 매우 나쁜 주석으로 타락할 수 있다. 쉽게 "자명한 주석"이 될 수 있는 것이다. 자명한 주석이란 주석을 읽는 인지 부하가 연관된 코드를 읽는 것과 같거나 더 높은 가이드 주석을 말한다. 다음 형태의 자명한 주석은 많은 책에서 피하라고 말하는 바로 그 유형이다.

    array_len++;	/* Increment the length of our array. */

그러니 가이드 주석을 작성한다면 자명한 주석을 쓰지 않도록 주의하라.

부채 주석

부채 주석은 소스 코드 자체에 하드코딩된 기술 부채에 대한 진술이다:

t_stream.c:

    /* Here we should perform garbage collection in case at this point
     * there are too many entries deleted inside the listpack. */
    entries -= to_delete;
    marked_deleted += to_delete;
    if (entries + marked_deleted > 10 && marked_deleted > entries/2) {
	/* TODO: perform a garbage collection. */
    }

위 스니펫은 Redis 스트림 구현에서 발췌한 것이다. Redis 스트림은 XDEL 명령을 이용해 중간에 있는 요소를 삭제할 수 있게 한다. 이는 여러모로 유용할 수 있는데, 특히 어떤 자료구조나 시스템을 사용해 저장하든 특정 데이터를 보유해서는 안 되는 개인정보 보호 규정 등의 맥락에서 그렇다. 대부분 append-only인 자료구조에는 매우 이례적인 사용 사례지만, 사용자가 중간 항목의 50% 이상을 삭제하기 시작하면 스트림은 "매크로 노드"로 구성되며 단편화되기 시작한다. 항목은 단순히 삭제된 것으로 표시될 뿐, 주어진 매크로 노드 내의 모든 항목이 해제되어야만 실제로 회수된다. 따라서 대량 삭제는 스트림의 메모리 동작을 바꾸게 된다.

지금 당장은 사용자가 스트림에서 대부분의 히스토리를 삭제할 것이라 예상하지 않으므로 이는 문제가 아닌 것처럼 보인다. 하지만 향후 가비지 컬렉션을 도입하고 싶어질 수도 있다. 삭제된 항목과 남아 있는 항목 간의 비율이 일정 수준에 도달하면 매크로 노드를 압축할 수 있을 것이다. 게다가 가비지 컬렉션 후에는 인근 노드들을 서로 붙일 수도 있다. 나는 나중에 가비지 컬렉션을 수행할 진입점이 어디였는지 기억하지 못할까 봐 조금 걱정되어 TODO 주석을 남겼고, 심지어 트리거 조건까지 적어 두었다.

이는 아마 그다지 좋은 방법은 아니다. 더 나은 생각은 파일 상단의 설계 주석에 왜 현재 GC를 수행하지 않는지와, 나중에 GC를 추가하려면 진입점이 어디인지를 적어 두는 것이었다.

FIXME, TODO, XXX, "This is a hack"은 모두 부채 주석의 형태다. 일반적으로 그다지 좋지 않아서 나는 피하려고 노력하지만, 항상 가능한 것은 아니며 때로는 문제를 영원히 잊어버리는 대신 소스 코드 안에 메모를 남기는 편을 선호한다. 적어도 주기적으로 이런 주석들을 grep으로 찾아보고, 메모를 더 나은 곳으로 옮길 수 있는지, 혹은 문제가 더 이상 유효하지 않거나 바로 고칠 수 있는지를 살펴봐야 한다.

백업 주석

마지막으로 백업 주석은 개발자가 새로운 코드에 가한 변경에 대해 확신이 서지 않아 코드 블록이나 심지어 함수 전체의 이전 버전을 주석 처리해 두는 것이다. 의아한 점은 이제 Git이 있는데도 이런 일이 여전히 일어난다는 것이다. 아마 사람들은 몇 년 된 커밋 속에 있는, 더 정상적이거나 안정적이라고 여겨지는 그 코드 조각을 잃어버릴까 봐 불안함을 느끼는 것 같다.

하지만 소스 코드는 백업을 위한 곳이 아니다. 함수나 코드 일부의 이전 버전을 저장하고 싶다면 작업이 아직 끝나지 않은 것이며 커밋해서는 안 된다. 새 함수가 이전 것보다 낫다는 확신을 갖거나, 확신이 설 때까지는 개발 트리에서만 가지고 있으라.

백업 주석으로 나의 분류를 마친다. 결론을 맺어 보자.

분석 도구로서의 주석

주석은 스테로이드를 맞은 러버덕 디버깅이다. 다만 상대가 러버덕이 아니라 코드의 미래 독자라는 점이 다르다. 미래 독자는 러버덕보다 훨씬 위압적이며 트위터를 쓸 수도 있다. 그래서 그 과정에서 당신은 자신이 진술하는 것이 받아들일 만한지, 떳떳한지, 충분히 괜찮은지를 정말로 이해하려 노력하게 된다. 그리고 그렇지 않다면 숙제를 해서 좀 더 괜찮은 무언가를 내놓게 된다.

이는 문서를 작성할 때 일어나는 과정과 동일하다. 작성자는 특정 코드 조각이 무엇을 하는지, 어떤 보장을 하며 어떤 부작용이 있는지에 대한 요점을 제공하려 한다. 이는 종종 버그를 잡을 기회가 된다. 무언가를 설명하다 보면 허점이 있다는 것을 발견하기가 매우 쉽다... 특정 동작에 대해 확신이 서지 않아 전부 설명할 수 없는 경우가 있는데, 그런 동작은 복잡성 속에서 그저 무작위로 떠오른 것일 뿐이다. 당신은 정말로 그런 것을 원하지 않으므로, 돌아가서 전부 고치게 된다. 나는 이것이 주석을 작성해야 할 훌륭한 이유라고 생각한다.

좋은 주석을 쓰는 일은 좋은 코드를 쓰는 것보다 어렵다

주석을 쓰는 것이 좀 더 하찮은 일이라고 생각할 수도 있다. 어쨌든 당신은 코딩을 할 수 있으니까! 하지만 이렇게 생각해 보라. 코드는 구문과 함수 호출의 집합이며, 프로그래밍 패러다임이 무엇이든 마찬가지다. 솔직히 코드가 좋지 않다면 그런 구문들이 별로 말이 되지 않을 때도 있다. 주석은 항상 어떤 설계 과정이 진행 중일 것을 요구하며, 당신이 작성하는 코드를 더 깊은 의미에서 이해할 것을 요구한다. 게다가 좋은 주석을 쓰려면 글쓰기 실력을 갈고닦아야 한다. 그 글쓰기 실력은 이메일, 문서, 설계 문서, 블로그 글, 커밋 메시지를 작성하는 데도 도움이 될 것이다.

나는 무엇보다 공유하고 소통하고 싶은 절박한 마음 때문에 코드를 쓴다. 주석은 코드를 보조하고, 돕고, 우리의 노력을 설명한다. 그리고 결국 나는 코드를 쓰는 것만큼이나 주석을 쓰는 것을 사랑한다.

(이 블로그 글을 쓰는 동안 피드백을 준 Michel Martens에게 감사드립니다)

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

댓글