Writing system software: code comments.

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 주석
  • Teacher 주석
  • 체크리스트 주석
  • 가이드 주석
  • 자명한 주석
  • 부채 주석
  • 백업 주석

제 생각에 앞선 여섯 가지는 대체로 매우 긍정적인 형태의 주석이며, 마지막 세 가지는 다소 문제가 있습니다. 다음 절에서는 각 유형을 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 주석

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를 증가시킵니다. 이렇게 하면 한 번의 호출에서 너무 많은 노력을 쏟아 함수가 종료되더라도 같은 데이터베이스에서 다시 시작하는 문제가 생기지 않습니다. 같은 데이터베이스 처리에만 매달린 나머지 논리적으로 만료된 키들이 다른 데이터베이스에 쌓이는 일을 막을 수 있습니다.

이러한 주석을 통해 우리는 왜 그 시점에서 증가시키는지를 설명함과 동시에, 다음에 코드를 수정할 사람이 그 특성을 유지해야 함을 알립니다. 주석이 없다면 이 코드는 전혀 무해해 보입니다. 선택하고, 증가시키고, 작업을 하러 갑니다. 증가를 루프 끝, 더 자연스러워 보이는 곳으로 옮기지 않을 명백한 이유가 없어 보입니다.

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

Teacher 주석

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

버전 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. */

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

저는 Teacher 주석이 엄청난 가치가 있다고 생각합니다. 독자가 그러한 개념을 모를 경우 무언가를 가르쳐 주거나, 적어도 더 깊이 탐구할 출발점을 제공합니다. 이는 다시 말해 Teacher 주석이 특정 코드 경로를 읽을 수 있는 프로그래머의 수를 늘린다는 뜻입니다. 많은 프로그래머가 읽을 수 있는 코드를 작성하는 것은 제 중요한 목표 중 하나입니다. 수학적 기술은 없지만 훌륭한 수정이나 최적화를 기여할 수 있는 탄탄한 프로그래머도 있습니다. 그리고 일반적으로 코드는 실행되는 것 이상으로 읽혀야 합니다. 인간이 다른 인간을 위해 쓰는 것이기 때문입니다.

제대로 된 코드를 작성하기 위해 Teacher 주석을 거의 피할 수 없는 경우도 있습니다. 좋은 예가 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.

체크리스트 주석은 특정 “Why 주석”이 사용되는 맥락과 유사한 상황, 즉 어떤 코드가 왜 특정 위치에서, 무언가의 앞이나 뒤에서 실행되어야 하는지가 명확하지 않을 때에도 유용합니다. 하지만 Why 주석이 왜 그 문장이 그곳에 있는지를 알려준다면, 같은 경우에 쓰인 체크리스트 주석은 코드 동작을 깨지 않고 수정하려면 어떤 규칙을 따라야 하는지를 알려주는 쪽에 더 치우쳐 있습니다(이 경우 규칙은 주어진 순서를 따르라는 것입니다).

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 전용인 자료구조에는 매우 이례적인 사용 사례지만, 사용자가 중간에서 50% 이상의 항목을 삭제하기 시작하면 스트림은 “매크로 노드”로 이루어지며 단편화되기 시작합니다. 항목들은 단지 삭제된 것으로 표시될 뿐이며, 주어진 매크로 노드 안의 모든 항목이 해제되어야만 회수됩니다. 따라서 대량 삭제는 스트림의 메모리 동작을 바꾸게 됩니다.

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

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

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

백업 주석

마지막으로 백업 주석은 개발자가 새로운 코드에서 수행된 변경에 대해 확신이 없기 때문에 어떤 코드 블록이나 전체 함수의 이전 버전을 주석 처리해 두는 경우를 말합니다. Git이 있는 지금도 이런 일이 일어난다는 것이 의아합니다. 사람들은 몇 년 된 커밋 속에 더 온전하거나 안정적이라고 여겨지는 코드 조각을 잃어버릴까 봐 불안한 느낌을 갖는 것 같습니다.

하지만 소스 코드는 백업을 위한 것이 아닙니다. 함수나 코드 일부의 이전 버전을 저장하고 싶다면, 작업이 끝나지 않은 것이며 커밋해서는 안 됩니다. 새 함수가 이전 것보다 낫다는 것을 확실히 하거나, 확신이 설 때까지 개발 트리에만 두어야 합니다.

백업 주석으로 제 분류를 마칩니다. 결론을 내려 보겠습니다.

분석 도구로서의 주석

주석은 스테로이드를 맞은 러버덕 디버깅과 같습니다. 다만 당신이 대화하는 상대는 러버덕이 아니라 코드의 미래 독자이며, 그는 러버덕보다 훨씬 더 부담스럽고 트위터를 쓸 수도 있습니다. 그래서 그 과정에서 당신은 자신이 서술하는 내용이 수용 가능하고, 떳떳하며, 충분히 좋은지를 정말로 이해하려 노력하게 됩니다. 그리고 그렇지 않다면 숙제를 다시 하고 더 괜찮은 무언가를 내놓게 됩니다.

이는 문서를 작성할 때 일어나는 과정과 같습니다. 작성자는 주어진 코드 조각이 무엇을 하는지, 어떤 보장을 제공하는지, 어떤 부작용이 있는지에 대한 요점을 제공하려 합니다. 이는 종종 버그를 찾는 기회가 됩니다. 무언가를 설명하다 보면 구멍이 있다는 것을 발견하기가 매우 쉽습니다... 특정 동작에 대해 확신이 없기 때문에 모든 것을 제대로 설명할 수 없는 경우, 그 동작은 복잡성 속에서 무작위로 생겨난 것일 뿐입니다. 당신은 정말로 그런 것을 원치 않으므로 돌아가서 모든 것을 고치게 됩니다. 저는 이것이 주석을 쓸 훌륭한 이유라고 생각합니다.

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

주석을 쓰는 것이 덜 고귀한 형태의 작업이라고 생각할 수도 있습니다. 결국 당신은 코딩을 할 수 있으니까요! 하지만 이렇게 생각해 보시기 바랍니다. 코드는 일련의 문장과 함수 호출, 혹은 당신의 프로그래밍 패러다임이 무엇이든 그에 해당하는 것들의 집합입니다. 솔직히 코드가 좋지 않다면 그러한 문장들은 때로 별로 말이 되지 않습니다. 주석은 항상 어떤 설계 과정이 진행 중일 것을 요구하며, 당신이 작성하는 코드를 더 깊은 의미에서 이해할 것을 요구합니다. 게다가 좋은 주석을 쓰기 위해서는 글쓰기 능력을 길러야 합니다. 그 글쓰기 능력은 이메일과 문서, 설계 문서, 블로그 글, 커밋 메시지를 작성하는 데도 도움이 될 것입니다.

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

(이 블로그 글을 쓰는 동안 피드백을 주신 Michel Martens님께 감사드립니다)

원문은 Salvatore Sanfilippo님이 에 게재했습니다.

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