Proven C Book←↑→

102 빌드와 시험 — 여럿이 짓는 프로젝트의 구조

먼저 알아야 할 것

18장 개발환경 구축 · Makefile 과 새니타이저
101장 실전의 C · make 와 git
53장 오류와 계약 · 계약이라는 생각

돌아보기

18장에서 Makefile 을 하나 썼고 그것으로 이 책의 예제가 전부 돌았다. 그런데 왜 실제 프로젝트들은 빌드에 그렇게 공을 들이는가 — 컴파일이 되면 된 것 아닌가?

답. 「컴파일된다」와 「빌드가 있다」는 다른 말이기 때문이다. 컴파일은 이번 한 번 소스가 기계어가 되는 일이고, 빌드는 언제 무엇을 다시 만들 것인가에 대한 약속이다. 파일이 셋일 때는 그 약속이 없어도 된다 — 통째로 다시 만들면 그만이다. 그러나 파일이 수백 개가 되면 통째로 다시 만드는 데 몇 분이 들고, 그러면 사람은 다시 만들지 않기 시작한다. 그 순간부터 손에 든 실행 파일이 지금 소스에서 나온 것인지 아무도 모른다.

★ 이 장의 첫 시연이 그 자리에서 벌어지는 사고다. 그리고 그 사고는 「느려서 불편하다」가 아니라 버퍼 넘침으로 나타난다.

이 장의 필요성과 맥락

101장에서 make 와 git 의 이름을 보았다. 이 장은 그 위에 규모를 얹는다. 이 책은 지금까지 한 사람의 책상을 가정했지만, C 로 짓는 것들은 대개 여럿이 오래 짓는다. 파일을 어떻게 나누고, 무엇을 시험하고, 무엇을 기계가 막게 할 것인가 — 이것들은 취향이 아니라 규모가 강제하는 구조다.

이 장이 끝나면

네 가지를 다룬다. 빌드가 있으면 무엇이 달라지는가(그리고 없으면 무엇을 잃는가), 파일과 폴더를 어떻게 나누는가, 시험을 어떻게 구성하는가, 그리고 여럿이 일할 때 무엇을 기계에 맡기고 무엇을 사람이 보는가. 도구의 문법은 다루지 않는다 — make 나 CMake 의 문법은 판마다 달라지고, 이 장이 남기려는 것은 문법이 아니라 왜 그렇게 배치하는가다. 근거는 실물 프로젝트 넷이다.

이 장에서 답할 질문

  1. 시험은 많을수록 좋은가?

102.1 빌드가 있으면 무엇이 달라지는가#

빌드 도구가 대신해 주는 일은 셋이다.

무엇없으면 무슨 일이 생기나
증분바뀐 것만 다시 만든다. 없으면 전체 재빌드가 몇 분이 되고, 사람이 다시 만들기를 건너뛴다
의존무엇이 바뀌면 무엇을 다시 만들어야 하는지 안다. 없으면 낡은 산출물이 조용히 살아남는다
재현같은 소스에서 같은 것이 나온다. 없으면 「내 기계에서는 됩니다」가 논쟁이 된다

표 102.1 — 빌드가 대신해 주는 일

가운데 것이 이 절의 주제다. 없을 때 무슨 일이 생기는지 실제로 일으켜 본다.

examples/ch102/stale/run.sh

#!/bin/sh
# 낡은 산출물 사고를 재현한다.
#
#   헤더의 GREET_MAX 를 16 → 32 로 넓히고 greet.c 만 손댄다.
#   의존을 적지 않은 규칙은 greet.o 만 다시 만든다 --- greet 는 32 를 믿고 쓰는데
#   main.o 는 옛 16 으로 그릇을 잡은 채 남는다. 곧 *버퍼가 넘친다.*
set -eu
cd "$(dirname "$0")"

cp greet.h greet.h.orig
trap 'mv -f greet.h.orig greet.h 2>/dev/null || true' EXIT INT TERM

for kind in broken fixed; do
    printf '== Makefile.%s\n' "$kind"
    make -s -f "Makefile.$kind" clean >/dev/null
    make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
    printf '   처음        : %s\n' "$(./demo 2>&1 | head -1)"

    sed -i 's/#define GREET_MAX 16/#define GREET_MAX 32/' greet.h
    touch greet.c
    make -s -f "Makefile.$kind" CFLAGS="-std=c23 -Wall -Wextra -g -fsanitize=address$([ $kind = fixed ] && printf ' -MMD -MP')" >/dev/null 2>&1
    out=$(./demo 2>&1 | head -3 || true)
    printf '   헤더를 넓힌 뒤: %s\n' "$(printf '%s' "$out" | head -1)"
    printf '%s' "$out" | grep -q 'stack-buffer-overflow' && printf '   → ASan: stack-buffer-overflow\n' || true

    sed -i 's/#define GREET_MAX 32/#define GREET_MAX 16/' greet.h
    make -s -f "Makefile.$kind" clean >/dev/null
done

실행 결과

== Makefile.broken
   처음        :   GREET_MAX=16  len=15
   헤더를 넓힌 뒤: =================================================================
   → ASan: stack-buffer-overflow
== Makefile.fixed
   처음        :   GREET_MAX=16  len=15
   헤더를 넓힌 뒤:   GREET_MAX=32  len=31

무엇이 벌어졌는지 뜯어본다. 헤더에 그릇의 크기가 있다.

#define GREET_MAX 16
void greet(char *out);

greet.c 는 그 수만큼 채우고, main.c 는 그 수만큼 그릇을 잡는다. 두 파일이 같은 수를 믿는다. 그런데 Makefile.broken 의 규칙은 이렇게만 적혀 있다.

%.o: %.c
  $(CC) $(CFLAGS) -c -o $@ $<

여기 어디에도 「이 목적 파일은 greet.h 에도 딸려 있다」는 말이 없다. 그래서 헤더를 16 에서 32 로 넓히고 greet.c 만 손대면, make 는 greet.o 만 다시 만든다. 새 greet 는 31 글자와 끝의 NUL 까지 32 바이트를 쓰는데, 옛 main.o 는 여전히 16 바이트짜리 그릇을 잡는다.

★ 결과는 느림이 아니라 스택 버퍼 넘침이다. 시연이 새니타이저(18장) 아래에서 그것을 그 자리에서 잡아 준다. 잡히지 않았다면 이 프로그램은 「가끔 이상한 값이 나오는」 물건이 되었을 것이다.

흔한 오해. “그러면 늘 make clean 을 하면 되지 않나”

되기는 한다. 그리고 그것이 바로 증분을 버리는 일이다. 파일이 셋일 때는 값이 0 이지만, 수백 개가 되면 매번 몇 분이고, 그 몇 분이 쌓이면 사람은 결국 clean 도 건너뛴다. 규율을 사람의 의지에 맡기면 규모가 그것을 이긴다.

답은 의존을 사람이 적지 않는 것이다. 컴파일러가 이미 알고 있다 — 어떤 헤더를 열었는지는 컴파일할 때 알게 되므로, 그것을 받아 적게 하면 된다.

CFLAGS = ... -MMD -MP
-include main.d greet.d

-MMD 는 목적 파일마다 「이 파일은 이 헤더들에 딸려 있다」는 규칙을 .d 파일로 남기고, -include 가 그것을 다음 빌드에 물린다. 시연의 Makefile.fixed 는 CFLAGS 에 그 두 낱말을 붙이고 -include 한 줄을 더했을 뿐이며, 같은 조작에서 사고가 나지 않는다.

102.2 파일을 어떻게 나누는가 — 헤더는 계약, 소스는 사정#

파일을 나누는 기준은 「길어서」가 아니다. 함께 바뀌는 것끼리 모으고, 따로 바뀌는 것을 가른다가 기준이다. 함께 바뀌는 것을 갈라 두면 고칠 때마다 여러 파일을 오가야 하고, 따로 바뀌는 것을 붙여 두면 한쪽 때문에 다른 쪽이 자꾸 다시 만들어진다.

그 위에 C 만의 사정이 하나 얹힌다 — 헤더는 계약이고 소스는 사정이다 (53장). 헤더에 적힌 것은 부르는 쪽이 기대는 약속이고, 소스에 있는 것은 그 약속을 지키는 방법이다. 방법은 바꿔도 되지만 약속을 바꾸면 남이 깨진다.

무엇어디에왜
함수 원형·공개 타입·공개 매크로헤더부르는 쪽이 알아야 하는 약속
내부 도우미 함수소스에 static약속이 아니다. 이름도 밖으로 새지 않는다(56장)
구조체의 속되도록 소스멤버를 드러내면 배치가 계약이 된다 — 그때부터 못 바꾼다
전역 변수되도록 두지 않는다바뀌는 자리가 어디인지 아무도 모르게 된다(58장)
static 함수의 정의헤더에 두지 않는다포함하는 파일마다 사본이 생긴다 — 예외는 아래

표 102.2 — 헤더에 둘 것과 두지 않을 것

★ 마지막 줄에는 널리 쓰이는 예외가 하나 있다. 아주 짧은 함수를 헤더에 두고 싶을 때 static inline 으로 적는 관행이다(C99 부터). 사본이 생기는 것은 같지만 컴파일러가 대개 펼쳐 넣고, 쓰이지 않아도 경고가 나지 않는다. 그래도 그 함수의 몸이 헤더에 드러난다는 사실은 남으므로, 고칠 여지가 있는 것은 여전히 소스에 둔다.

★ 세 번째 줄이 규모에서 가장 비싸다. 헤더에 struct 의 멤버를 적어 두면 그 배치가 공개 약속이 되어, 멤버 하나를 늘리는 일이 부르는 쪽 전부를 다시 만드는 일이 된다. 그래서 큰 프로젝트는 자주 이름만 내놓고 속은 감춘다.

/* 헤더: 이름만 있고 속이 없다 */
typedef struct config config;
config *config_open(const char *path);
int     config_get_int(const config *c, const char *key, int fallback);
void    config_close(config *);

부르는 쪽은 config 가 몇 바이트인지 모른다. 그래서 알 필요도 없고, 바뀌어도 안 깨진다. 대가는 값으로 못 쓰고 포인터로만 다뤄야 한다는 것이다.

102.3 공개와 내부의 경계#

프로젝트가 커지면 헤더도 두 갈래가 된다 — 밖에 내놓는 것과 안에서만 쓰는 것이다. 이 경계를 파일 배치로 못박은 대표 사례가 리눅스 커널이다. 커널은 사용자 공간에 나가는 선언을 include/uapi/ 로 갈라내고, 안쪽 헤더가 그것을 포함하는 구조로 바꾸었다. 갈라낸 까닭은 두 가지로 알려져 있다 — 헤더끼리의 얽힘을 줄이는 것, 그리고 무엇이 공개 약속인지 눈으로 분명해지는 것이다.

실제 사례. 경계가 없으면 무슨 일이 생기나

경계를 적어 두지 않아도 프로그램은 돈다. 문제는 시간이 지난 뒤다.

누군가 내부용 헤더를 포함해 내부 함수를 부르기 시작한다. 그 함수는 계약이 아니라 사정이었으므로 이름과 인자가 바뀔 수 있는 물건인데, 이제 쓰는 사람이 생겼다. 다음번에 그것을 고치려는 사람은 「이건 내부니까」라고 말할 근거가 없다 — 어디에도 적혀 있지 않기 때문이다.

그래서 경계는 디렉터리로 긋는 편이 낫다. 주석으로 「내부용」이라 적는 것보다 include/ 와 src/ 로 갈라 두는 편이 훨씬 강하다. 사람은 주석을 안 읽지만 경로는 눈에 들어오고, 빌드 규칙으로 강제할 수도 있다.

102.4 폴더 구조 — 관행은 무엇을 위한 것인가#

프로젝트마다 이름은 조금씩 다르지만 갈래는 대개 같다. 외울 목록이 아니라 각각이 무엇을 위한 것인지를 보는 것이 요점이다.

자리무엇이 있나왜 갈라 두나
include/공개 헤더여기 있는 것이 곧 약속이다 — 경계가 경로로 보인다
src/소스와 내부 헤더사정은 사정끼리
tests/시험과 그 자료본체와 섞이지 않아야 배포할 때 골라내기 쉽다
build/산출물★ 소스와 섞지 않는다. 통째로 지울 수 있어야 하고, 버전 관리에서 빼기 쉬워야 한다
docs/문서코드와 함께 버전이 매겨져야 뜻이 있다
scripts/기계가 하는 일사람의 기억에 기대던 절차를 파일로 굳힌 자리

표 102.3 — 흔한 폴더 갈래와 그 이유

★ build/ 를 따로 두는 것이 생각보다 크게 남는 장사다. 산출물이 소스 옆에 흩어져 있으면 「지금 이 .o 가 어느 설정으로 만들어진 것인가」를 아무도 모르고, 설정을 바꿔 가며 빌드할 수도 없다. 자리를 나누면 build/debug 와 build/release 가 나란히 살 수 있다.

102.5 시험의 갈래 — 각각 무엇을 잡는가#

「시험을 짠다」는 한 가지 일이 아니다. 갈래마다 잡는 사고가 다르다.

갈래무엇을 잡나값과 대가
단위 시험함수 하나의 계약 위반 — 경계값, 실패 경로빠르고 자주 돈다. 그러나 부품이 맞물리는 자리는 못 본다
통합 시험부품을 이었을 때 생기는 어긋남진짜에 가깝다. 느리고, 깨졌을 때 원인 찾기가 어렵다
「회귀(regression)」·골든「예전에는 이렇게 나왔다」가 달라진 것 — 고쳤던 결함이 되돌아온 것을 회귀라 한다고치려던 것 말고 딴 것이 바뀐 것을 잡는다
퍼즈사람이 생각 못 한 입력 — 넘침, 널, 깨진 형식사람의 상상력 밖을 훑는다. 새니타이저와 함께 써야 뜻이 산다

표 102.4 — 시험의 갈래와 각각이 잡는 것

이 책이 이미 그중 둘을 보였다. 18장의 새니타이저는 퍼즈와 짝이고, 92장에서 본 「실패하는 껍데기」는 단위 시험이 잘 안 도는 경로를 일부러 밟게 하는 기법이다.

102.6 골든 시험 — 정답을 파일로 굳힌다#

갈래 가운데 규모가 커질수록 가장 크게 보답하는 것이 골든이다. 출력이 있는 프로그램이라면 거의 공짜로 세울 수 있기 때문이다. 입력을 파일로 두고, 지금 옳다고 확인한 출력을 정답 파일로 굳혀 두고, 다음부터는 기계가 diff 로 견준다.

PostgreSQL 이 이 방식을 크게 쓴다. src/test/regress/ 아래에 입력을 담은 sql/, 정답을 담은 expected/, 이번 결과가 쌓이는 results/ 를 두고, 어긋난 것을 regression.diffs 에 남긴다. 견주는 도구는 특별한 것이 아니라 그냥 diff 다.

같은 것을 열 몇 줄로 세울 수 있다.

examples/ch102/golden/run.sh

#!/bin/sh
# 골든 시험 --- 정답을 파일로 굳혀 두고 기계가 견준다.
#   in/X.txt 를 먹여 나온 것을 expected/X.txt 와 diff 로 맞대어 본다.
#   --accept 를 주면 지금 결과를 정답으로 굳힌다(처음 한 번, 그리고 *의도한* 변경 뒤).
set -eu
cd "$(dirname "$0")"
cc -std=c23 -Wall -Wextra -o wordcount wordcount.c
mkdir -p results
fail=0 n=0
for input in in/*.txt; do
    name=$(basename "$input" .txt)
    ./wordcount < "$input" > "results/$name.txt"
    n=$((n + 1))
    if [ "${1:-}" = "--accept" ]; then
        cp "results/$name.txt" "expected/$name.txt"
        continue
    fi
    if ! diff -u "expected/$name.txt" "results/$name.txt" > "results/$name.diff"; then
        printf '  ✗ %s\n' "$name"
        sed -n '3,6p' "results/$name.diff" | sed 's/^/      /'
        fail=$((fail + 1))
    else
        printf '  ✓ %s\n' "$name"
        rm -f "results/$name.diff"
    fi
done
[ "${1:-}" = "--accept" ] && { printf '정답 %d 개를 굳혔다\n' "$n"; exit 0; }
[ "$fail" -eq 0 ] && printf '골든 %d 개 통과\n' "$n" || { printf '어긋남 %d 개\n' "$fail"; exit 1; }

실행 결과

  ✓ empty
  ✓ spaces
  ✓ two-lines
골든 3 개 통과

이렇게 굳혀 둔 입력과 정답을 픽스처라 부른다 — 시험이 딛고 서는 준비물이다. 그 성질과 함정은 103장에서 따로 본다.

세 가지를 눈여겨본다.

  1. 정답은 손으로 적지 않는다. --accept 로 지금 결과를 굳힌다. 손으로 적으면 틀리고, 틀린 정답은 시험을 거꾸로 만든다.
  2. 실패는 어긋난 줄만 보여 준다. diff 가 그 일을 이미 한다.
  3. 그래서 이 시험은 「무엇이 옳은가」를 모르는 채로도 쓸모가 있다 — 어제와 달라졌는가를 묻기 때문이다.

반례. 정답을 눈으로 보지 않고 굳히기

--accept 는 편해서 위험하다. 시험이 깨졌을 때 원인을 보지 않고 다시 굳혀 버리면, 버그를 정답으로 만든 것이다. 그때부터 그 시험은 아무것도 지키지 않는다.

규율은 하나다 — --accept 는 어긋난 곳을 읽고 「이 변경은 의도한 것」이라고 말할 수 있을 때만 누른다. 이 책의 예제 검증도 같은 규율로 돈다.

102.7 얼마나, 그리고 무엇을 시험하지 않는가#

「얼마나 시험해야 하는가」에 답이 있는가. 상한을 보여 주는 사례가 하나 있다.

SQLite 는 자기 시험 문서에서 수를 밝힌다1 — 3.42.0(2023년 5월) 기준으로 라이브러리 본체가 약 155.8 KSLOC 인데 시험 코드와 스크립트는 92,053.1 KSLOC, 곧 본체의 590배 라고 적는다. 시험을 돌리는 틀 — 하네스 — 도 하나가 아니라 넷이고(TCL 시험, TH3, SQL Logic Test, 그리고 dbsqlfuzz), 기본 구성의 핵심 부분에 대해 TH3 아래에서 분기 커버리지 100% 를 유지한다고 밝힌다 — 「라이브러리 전체가 언제나」가 아니라는 단서가 원문에 붙어 있다.

★ 여기서 배울 것은 「590배를 따라 하라」가 아니다. 두 가지다.

  1. 틀을 여럿 두는 까닭 — 한 방식이 눈감는 자리를 다른 방식이 본다. 같은 생각으로 짠 시험 백 개보다 다르게 짠 시험 열 개가 낫다.
  2. 시험의 양은 목적이 아니라 결과다 — SQLite 가 그만큼 시험하는 것은 그것이 「어디서나 도는 데이터베이스」를 표방하기 때문이다. 목표가 다르면 답도 다르다.

그리고 반대편 물음이 더 중요하다.

문. 시험은 많을수록 좋은가?

답. 아니다. 시험도 코드이고, 코드는 부채다. 시험 하나를 늘리면 앞으로 그것을 고치는 일도 함께 늘어난다. 그래서 제 몫을 못 하는 시험은 짐이다.

그런 시험들의 얼굴은 대개 비슷하다. 구현을 그대로 베낀 시험(함수가 하는 일을 그대로 다시 적어 놓아, 구현을 고치면 반드시 함께 깨진다), 언어를 시험하는 시험(1 + 1 이 2 인지 확인한다), 깨졌을 때 무엇이 잘못됐는지 알려 주지 않는 시험(오백 줄짜리 통합 시험 하나).

쓸 만한 것을 고르는 기준은 하나로 줄일 수 있다 — 깨졌을 때 내가 무엇을 고쳐야 하는지 알려 주는가.

102.8 협업 — 기계가 막는 것과 사람이 보는 것#

여럿이 일하기 시작하면 물음이 하나 늘어난다. 어긋난 것을 누가 막는가.

답은 둘로 나뉜다. 기계가 막는 것(게이트) 과 사람이 보는 것(리뷰) 이다. 이 둘을 섞으면 양쪽 다 나빠진다 — 기계가 셀 수 있는 것을 사람에게 시키면 사람이 지치고, 사람이 판단할 것을 기계에 맡기면 규칙이 엉뚱한 것을 막는다.

기계가 막는다사람이 본다
빌드가 되는가 · 시험이 도는가이 설계가 옳은 방향인가
서식·이름 규칙이름이 뜻에 맞는가
새니타이저·정적 분석이 조용한가이 계약이 부르는 쪽에 자연스러운가
문서에 적힌 수가 실제와 같은가이 설명이 읽는 사람에게 통하는가

표 102.5 — 기계에 맡길 것과 사람이 볼 것

실제 사례. 이 책이 그렇게 돌고 있다

이 책의 저장소에는 그 게이트가 스물세 개 있다(scripts/check-*.py). 몇 가지만 들면 이렇다 — 두 판(한국어·영어)이 같은 장을 가리키는지, 표준 조항 인용이 실재하는 조항인지(문단 번호까지), 「이 부의 마지막 장이다」가 정말 마지막인지, 약어가 장마다 처음 나올 때 풀리는지.

★ 이것들은 전부 사람이 눈으로는 못 지키는 것들이다. 실제로 한 장을 끼워 넣을 때마다 번호를 가리키던 서술 여럿이 조용히 낡았고, 그때마다 사람이 아니라 검사기가 찾아냈다. 게이트의 값어치는 「엄격함」이 아니라 사람의 주의력을 아껴 주는 것에 있다.

그리고 게이트에는 대가가 있다. 거짓 양성이 잦은 검사는 사람을 무시하게 만들고, 그러면 진짜를 놓친다. 이 저장소도 그래서 검사 하나를 만들었다가 싣지 않았다 — 원고에 대고 돌려 보니 잡은 스물넷이 전부 거짓 양성이었다. 헛짚는 게이트는 없느니만 못하다.

102.9 적어 두어야 하는 것#

마지막으로, 남이 들어올 자리를 만드는 일이다. 문서는 많을수록 좋은 것이 아니라 각각이 다른 물음에 답할 때 쓸모가 있다.

문서답하는 물음
README이것은 무엇이고, 어떻게 만들고, 어떻게 쓰는가
기여 안내무엇을 지켜야 내 변경이 받아들여지는가 — 게이트 목록이 여기 온다
변경 기록지난 판에서 무엇이 달라졌는가 — 특히 깨지는 변경
설계 기록왜 그렇게 정했는가. 코드는 「무엇」을 말하지만 「왜」는 못 말한다

표 102.6 — 무엇이 어떤 물음에 답하는가

★ 마지막 줄이 가장 자주 빠지고 가장 비싸다. 몇 달 뒤 그 결정을 되돌리려는 사람은 무엇을 견주어 그렇게 정했는지 모르므로, 이미 검토하고 버린 길을 다시 걷는다.

복습 정리

기억할 것요지
빌드가 하는 일증분·의존·재현. 없으면 사람이 「다시 만들기」를 건너뛴다
낡은 산출물의존을 안 적으면 한쪽만 다시 만들어져 버퍼가 넘친다
고치는 법사람이 적지 않는다 — -MMD -MP 로 컴파일러가 받아 적게
파일 나누기함께 바뀌는 것끼리. 헤더는 계약, 소스는 사정
구조체의 속헤더에 드러내면 배치가 계약이 된다 — 되도록 감춘다
폴더경계를 경로로 긋는다. 산출물은 build/ 로 갈라 둔다
시험의 갈래단위·통합·골든·퍼즈가 각각 다른 사고를 잡는다
골든정답을 굳히고 diff 로 견준다. 어긋난 곳을 읽고 다시 굳힌다
얼마나양이 아니라 독립성. 그리고 제 몫을 못 하는 시험은 부채다
협업기계가 셀 수 있는 것은 게이트로, 판단할 것은 리뷰로

표 102.7 — 빌드와 시험 — 기억할 것

여기까지가 무엇을 어디에 둘 것인가다. 다음 장은 그 위에서 어떻게 일할 것인가를 본다 — 여럿이 굴릴 때 쓰는 어휘와 절차, 그리고 검사가 정말 무는지 확인하는 법이다.

주

  1. SQLite. How SQLite Is Tested. sqlite.org/testing.html — 수치는 그 문서의 3.42.0 기준 절이다. ↩