Antithesis C++ SDK에서 어서션 카탈로그를 표준 C++만으로 내보내기 위해 비-타입 템플릿 매개변수와 고정 문자열, 익명 네임스페이스를 결합한 설계가 clang++ 최적화 파이프라인의 심볼 중복 제거 버그를 드러낸 과정을 설명합니다. 또한 ALWAYS, REACHABLE, SOMETIMES 같은 어서션이 어떻게 동작하고 왜 카탈로그가 필요한지, 그리고 이처럼 드물게 조합되는 조건에서만 발생하는 버그를 자율 테스트로 어떻게 찾아내는지 다룹니다.

수석 엔지니어, Antithesis
전문 개발자로 25년 가까이 일하면서 C++ 컴파일러 버그를 세 번 찾았습니다. g++1, clang++, Microsoft의 MSVC에서 각각 하나씩이었죠. 대략 10년에 한 번꼴로 문제를 찾고 보니, 제 결론은 정말, 정말 난해한 일을 해야 버그가 드러난다는 겁니다. 아니, 정확히 말하면 난해한 기능을 쓰고, 거기에 또 다른 난해한 기능을 얹고, 또 다른 난해한 기능을 얹어야 합니다.
이 글은 우리 C++ SDK에서 우리가 하는 정말, 정말 난해한 작업이 어떻게 clang++ 버그를 찾게 되었는지에 대한 이야기입니다. C++에 아주 익숙하지 않더라도, 핵심 흐름은 따라갈 수 있고 세부 내용은 가볍게 넘겨도 괜찮습니다.
Antithesis는 여러분의 코드에 어서션을 추가해 소프트웨어의 정합성 속성을 지정할 수 있게 해 주는 언어별 SDK를 제공합니다.
그 다음 Antithesis는 여러분의 소프트웨어를 실행하면서, Test Composer로 만든 워크로드나 직접 작성한 워크로드로 이를 구동합니다. 실행 중에는 우리의 결함 주입기가 네트워크를 교란하고, 컨테이너를 크래시시키고, 코드에 임의의 지연을 유도하는 등 다양한 교란을 가합니다. 소프트웨어가 여러분이 추가한 어서션에 도달할 때마다 Antithesis가 해당 어서션이 유지되는지 확인합니다.
일반적인(비-Antithesis) 어서션은 아래처럼 보일 겁니다:
void increment_by_pointer(int* px) {
assert(px != nullptr);
*px++;
}
여기서 우리의 assert에 해당하는 것은 ALWAYS2이며, 메시지를 함께 포함하고, 이 메시지는 시스템에서 테스트 속성이 됩니다:
void increment_by_pointer(int* px) {
ALWAYS(px != nullptr, "increment_by_pointer에 전달된 매개변수는 null이 아니어야 합니다");
*px++;
}
우리 SDK는 이 밖에도 여러 흥미로운 어서션 타입을 제공합니다:
ALWAYS_LESS_THAN(x, value): 이는 ALWAYS(x < val)과 동등하고, GoogleTest의 EXPECT_LT와 유사합니다. 이 어서션은 이 지점에서 x의 값이 다양할수록 흥미롭다는 것을 시스템에 알려 줍니다 — 예를 들어, 우리는 x를 최대화하려고 시도할 수도 있죠. 차이점은 ALWAYS(x < val)에서 x < val 표현식이 불리언이라는 점입니다. 반면 ALWAYS_LESS_THAN(x, value)에서는 x와 value가 모두 정수이므로, 퍼저가 그 정수들에 대해 로직을 실행할 수 있습니다.
REACHABLE(): 이 코드는 도달 가능해야 한다는 것을 주장합니다. 예를 들어, 비디오 게임을 만든다고 가정해 봅시다. 레벨 3에 도달하려면 복잡한 일련의 작업을 해야 한다고 할 때, 다음처럼 넣을 수 있습니다:
void draw_level_3() {
REACHABLE();
/* some code */
}
이후 레벨 3로 갈 수 없게 만드는 버그가 누군가에 의해 도입되었다면, 우리의 자동화된 테스트가 그 문제를 찾아 내고 보고할 겁니다. 이와 반대되는 UNREACHABLE도 있는데, 이는 런타임 체크로, std::unreachable()과 조금 비슷합니다.
SOMETIMES(condition...): 이것은 시스템의 정합성에 대한 주장이 아니라, 테스트 커버리지에 대한 속성입니다. 예를 들어:int http_return_code = http_send(params);
SOMETIMES(http_return_code != 200 ...);
이는 자동으로 생성되는 일부 테스트들이 http_send가 실패하는 조건을 만들어 낸다는 것을 주장합니다. 이 어서션은 http_send가 항상 200만 반환한다면 다루지 못했을 오류 처리/오류 복구 경로를 우리가 실제로 타고 검사하는지를 확인합니다.
내부적으로, 각각의 어서션은 JSON을 생성해 Antithesis로 전송하고, Antithesis가 그 테스트 속성이 충족되는지 평가합니다. 일부 로직은 단순합니다. 예를 들어, 특정 실행에 대해서는 ALWAYS를 로컬에서 평가할 수도 있습니다. 하지만 일부 로직은 복잡합니다. 여러분은 모든 실행들의 집합에 대해 주장을 하고 있기 때문이죠. 예를 들어, “발생한 모든 실행에서, SOMETIMES 반환 코드가 500이었다” 또는 “발생한 모든 실행에서, 1234번째 줄에 한 번도 도달하지 않았다” 같은 식입니다. 모든 실행의 결과를 모으고 수집된 데이터에 대해 추론해야 하므로, 우리의 어서션은 JSON을 Antithesis로 보내고, Antithesis가 그곳에서 어서션들을 평가합니다.
우리가 보내는 JSON은 대략 다음과 같습니다:3
{
"assertion_type": "ALWAYS",
"condition_value": true,
"file": "foo.cpp",
"line": 1234
}
이제(평가 로직이 아니라) 이러한 어서션의 SDK 부분을 어떻게 구현할지 이야기해 봅시다.
ALWAYS를 어떻게 구현할지는 쉽게 상상할 수 있을 겁니다. 예를 들어:
void ALWAYS(bool condition) {
JSON json = {
// ...
};
send_to_antithesis(json);
}
그리고 파일명과 줄 번호가 필요하다면 매크로로 만들면 됩니다:
#define ALWAYS(condition) always_impl_function(condition, __FILE__, __LINE__)
좋습니다. 그렇다면 REACHABLE은 어떻게 구현할까요?
void draw_level_3() {
REACHABLE();
/* some code */
}
코드가 제대로 동작하고 draw_level_3의 REACHABLE 어서션에 도달한다면, 거기서 JSON을 내보내면 간단합니다. 하지만 그 코드에 절대 도달하지 못하는 버그가 있다면 어떨까요? 그 경우 Antithesis는 문제가 있다는 것을 어떻게 알까요? REACHABLE 어서션이 존재한다는 사실은 어떻게 알 수 있을까요? 이것이 바로 REACHABLE 어서션의 핵심 목적이지, 특수한 예외 케이스가 아닙니다.
우리의 해결책은 모든 어서션의 카탈로그를 내보내는 것입니다. 우리는 시작 시점에 이를 수행하며, 각각의 어서션이 실제로 실행되었는지 여부와 관계없이 모두 내보냅니다.
다시 말해, 일반적인 어서션은 Antithesis 플랫폼으로 여러 메시지를 보냅니다. (1) 시작 시점에, “이 어서션이 존재한다”는 사실만을 알리는 “카탈로그” 메시지를 하나 내보내고, 실제로 그 코드를 호출하든 말든 상관없습니다. 그리고 (2) 어서션에 도달할 때마다 하나 이상의 메시지를 보냅니다.
그렇다면 카탈로그는 어떻게 내보낼까요? 코드를 호출하지 않더라도 어떤 일이 발생하도록 어떻게 만들까요? 해법을 설명하기 전에 한 번 생각해 보세요. 코드가 전혀 실행되지 않더라도 실행되는 코드를 어떻게 작성하겠습니까?
간단히 말해: 언어마다 방법이 다릅니다. Go의 경우, 고객은 우리의 인스트루멘터를 실행합니다. 이 도구는 소스 코드의 AST를 훑어 모든 어서션을 식별하고, 어서션 카탈로그를 내보내는 함수를 생성합니다. Java의 경우도 유사하지만, 여러분의 JAR 바이트코드에서 작동합니다. 즉, 이러한 언어에서는 외부 도구가 추가 코드를 생성하고, 그 추가 코드는 항상 호출되어 카탈로그를 내보냅니다.
우리의 C++ SDK에서는 별도의 프로세스를 실행하지 않고 표준 C++ 구성만으로 카탈로그를 내보내는 방법을 찾아냈습니다.
C++에서 핵심 요소만 단순화한 일반적인 아이디어는 다음과 같습니다. Antithesis SDK는 다음과 같은 코드를 생성합니다:
struct Assertion {
Assertion() {
create_catalog_entry_and_send_to_antithesis();
}
void assert(bool condition) {
create_json_and_send_to_antithesis(condition);
}
};
struct CatalogEntry {
static Assertion assertion = Assertion();
};
그리고 여러분은 다음처럼 코드를 작성합니다:
void my_function(void* x) {
CatalogEntry::assertion.assert(x != nullptr);
}
여기서 무슨 일이 일어나는 걸까요? 핵심은 CatalogEntry 구조체입니다. 여기에 정적(static) 클래스 변수 assertion이 있습니다. 그 정적 클래스 변수는 어떤 코드를 실행하기 전에 초기화됩니다.4 정적 초기화 시점에 카탈로그 항목이 내보내지고, 이후(코드에 실제로 도달했을 때만) 그 정적 변수를 사용해 assert 메서드를 호출하여 실제 어서션을 내보냅니다.
“잠깐만요,”라고 하실 수 있겠습니다. 이 모든 게 좋아 보이지만, 정적 변수가 하나뿐이니 어서션이 100개든 1개든(혹은 0개든) 항상 하나의 CatalogEntry만 생기지 않겠냐는 거죠.
여기서 템플릿 마법을 씁니다. C++에서 템플릿을 써 본 분이라면, 아마 클래스 타입으로 매개변수화된 템플릿에 익숙할 겁니다. 예를 들어:
std::vector<int> x;
하지만 타입이 아닌 값으로 템플릿을 만들 수도 있습니다. (이를 비-타입 템플릿 매개변수라고 부릅니다.5) 예를 들어, “길이가 N인 float 배열”에 대한 템플릿을 다음처럼 만들 수 있습니다:
template <unsigned int N> struct array_of_float {
float data[N];
/* ... */
};
array_of_float<3> spatial_coordinate;
그리고 놀랍게도, 문자열을 기반으로 템플릿을 만들 수도 있습니다. 다만 기술적인 이유로 std::string이나 const char*는 사용할 수 없고, 고정 길이 문자 배열이어야 합니다. 예: { 'H', 'e', 'l', 'l', 'o', ' ', 'W', 'o', 'r', 'l', 'd'};.
이제 위 코드에 메시지, 파일명, 줄 번호를 추가하고, CatalogEntry를 이들로 템플릿화해 보겠습니다. 템플릿 규칙상 고정 길이 문자 배열이 필요하지만, 비-템플릿 클래스인 Assertion에는 그럴 필요가 없습니다. 따라서 다음처럼 보입니다:
struct Assertion {
const char* message;
const char* filename;
int line_number;
Assertion(const char* message, const char* filename, int line_number) :
message(message), filename(filename), line_number(line_number)
{
create_catalog_and_send_to_antithesis(message, filename, line_number);
}
void assert(condition) {
create_json_and_send_to_antithesis(condition, message, filename, line_number);
}
};
struct fixed_string { /* details omitted */ };
template <fixed_string message, fixed_string filename, int line>
struct CatalogEntry {
static Assertion assertion = Assertion(message.c_str(), filename.c_str(), line);
};
그리고 다음처럼 코드를 작성합니다:
void my_function(void* x) {
CatalogEntry<"x is not null", __FILE__, __LINE__>::assertion.assert(x != nullptr);
}
유일하게 까다로운 부분은 fixed_string 타입입니다. 이는 우리가 정의한, 고정 크기 배열을 가진 클래스입니다. 개념적으로 어려운 것은 없고, 단지 컴파일 타임 상수로만 동작해야 한다는 제약이 있을 뿐입니다. 자세한 구현은 우리 SDK에서 확인하실 수 있습니다.
물론 SDK를 사용할 때마다 이상한 템플릿 구문을 쓰게 하고 싶지는 않으므로, 우리는 다음과 같은 매크로를 정의했습니다:6
#define ALWAYS(condition, message) \
CatalogEntry< \
create_fixed_string(message), \
create_fixed_string(__FILE__), \
__LINE__ \
>::assertion.assert(condition)
그럼 여러분의 코드는 다음처럼 됩니다:
void my_function(void* x) {
ALWAYS(x != nullptr, "x is not null");
}
지금까지를 정리하면, 우리는 두 가지 난해한 기능을 사용했습니다. 첫째는 비-타입 템플릿 매개변수입니다. 음, 아주 난해하진 않을 수 있지만, C++로 프로그래밍한다면 아마 일상적으로 사용하는 템플릿의 95%는 타입 기반일 겁니다. 비-타입 템플릿 매개변수는 오래전부터 존재해 왔습니다(적어도 1998년부터; 당시 대부분의 컴파일러는 어떤 종류의 템플릿도 제대로 지원하지 못하던 시기였죠). 다만 1998년부터 2020년까지는 비-타입 템플릿 매개변수가 정수형에만 작동했습니다. 둘째는 비-타입 템플릿 매개변수로 문자 배열을 사용하는 것인데, 이 기능은 2020 표준에 추가되었습니다.
초기 버전의 SDK는 대략 위와 같았습니다. 그러고 나서 우리가 생성된 코드를 살펴보기 시작했죠. C++을 작성하는 사람들은 성능에 정말 민감하다는 게 우리의 경험이기 때문에, 생성 코드도 아주 꼼꼼히 검토했습니다. 그런데 이상한 점을 발견했습니다. 실행 파일이 엄청 컸습니다. 더 들여다보니, 실행 파일에 불필요한 심볼이 굉장히 많았습니다. 예를 들면:
fixed_string 타입에 대한 심볼fixed_string 타입에 대한 심볼CatalogEntry에 대한 심볼각각의 심볼은 긴 맹글링 이름을 가지고 있었는데, 이는 사실상 CatalogEntry<fixed_string{'x', ' ', 'i', 's', ' ', 'n', 'o', 't', ' ', 'n', 'u', 'l', 'l'}, fixed_string{'f', 'o', 'o', '.', 'c', 'p', 'p'}, 10>의 인코딩과 같았고, 이러한 인코딩이 수천 자에 달했습니다.
다음은 그 중 하나의 예시입니다:
_ZTAXtlN12_GLOBAL__N_112fixed_stringILm10EEEtlSt5arrayIcLm10EEtlA10_cLc83ELc97ELc109ELc101ELc32ELc110ELc97ELc109ELc101EEEEE[_ZTAXtlN12_GLOBAL__N_112fixed_stringILm10EEEtlSt5arrayIcLm10EEtlA10_cLc83ELc97ELc109ELc101ELc32ELc110ELc97ELc109ELc101EEEEE]
게다가 유사한 심볼의 복사본이 여러 개 있었습니다. (예를 들어, 클래스 자체와 assertion 변수는 서로 다른 두 심볼이었습니다.)
왜 이렇게 많은 심볼이 있었을까요? 컴파일러가 다른 파일에서 참조할 수도 있다고 보고 이를 내보내(export)했기 때문입니다. 하지만 우리는 그럴 리 없다는 걸 알고 있었습니다. 템플릿화된 클래스들(즉, Assertion이 아닌)은 한 번만 사용됩니다. 단일 파일에서만, 더 구체적으로는 한 파일의 하나의 위치에서만 사용됩니다.
그래서 fixed_string과 CatalogEntry를 익명 네임스페이스로 옮겼습니다:
namespace {
struct fixed_string { /* details omitted */};
template <fixed_string message, fixed_string filename, int line>
struct CatalogEntry {
static Assertion assertion = Assertion(message.c_str(), filename.c_str(), line);
};
}
혹시 언어 규정에 밝지 않더라도, 익명 네임스페이스는 특정 파일(번역 단위)에서만 사용되는 네임스페이스라는 점만 알면 됩니다.7 따라서 컴파일러는 그 번역 단위 밖에서 이 심볼들에 접근할 수 없다는 사실을 알고, 이 심볼들을 외부로 노출할 필요가 없다는 걸 이해합니다.
이 접근 방식에서, 컴파일러는 다음을 생성했습니다:
여전히 내보내지는 심볼 수가 0은 아니지만, 이전에 보던 다중 심볼 케이스보다는 훨씬 낫습니다. 파일 크기를 상당히 줄였고, 이 버전을 우리의 SDK에 포함해 배포했습니다.
그리고 몇 달 후, 고객이 문제를 보고했습니다. 그들이 한 일은 본질적으로 다음과 같았습니다:
file1.cpp:
void foo(void* x) {
ALWAYS(x != nullptr, "Same message");
}
file2.cpp:
void bar(void* y) {
ALWAYS(y != nullptr, "Same message");
}
핵심은 동일한 메시지를 서로 다른 두 파일에서 사용했다는 점입니다.
컴파일러는 난해한 메시지를 내보냈는데, 요지는 file2.o에서 “Same message”의 fixed_string을 찾을 수 없는데, 그 이유는 자신이 그것을 삭제했기 때문이라는 겁니다.
컴파일러8가 한 일은 다음과 같아 보였습니다:
file1.o에서 “Same message”에 대한 fixed_string 심볼 생성file1.o에서 “file1.cpp”에 대한 fixed_string 심볼 생성file1.o에서 CatalogEntry<(1)에서 만든 심볼, (2)에서 만든 심볼, 줄 번호>에 대한 심볼 생성file2.o에서 “Same message”에 대한 fixed_string 심볼 생성file2.o에서 “file2.cpp”에 대한 fixed_string 심볼 생성file2.o에서 CatalogEntry<(4)에서 만든 심볼, (6)에서 만든 심볼, 줄 번호>에 대한 심볼 생성우리는 여기저기 살펴본 끝에, Clang 16에서는 이 코드가 정상적으로 컴파일되고 Clang 17 이상에서는 그렇지 않다는 것을 알아냈습니다. 이는 LLVM이 17에서 새로운 최적화 파이프라인을 도입하면서 발생했는데, 그 파이프라인의 심볼 중복 제거에 버그가 있는 듯했습니다.
이제 “난해한 기능 위에 난해한 기능을 또 얹은” 버그가 무엇인지 요약해 봅시다. 컴파일러의 심볼 중복 제거 로직이 새로운 최적화 파이프라인 때문에 바뀌었고, 그 결과 한 비-타입 템플릿(CatalogEntry)이 또 다른 비-타입 템플릿(fixed_string)에 의해 템플릿화되고, 정수형이 아니라 문자 배열을 기반으로 하며, 둘 다 익명 네임스페이스 안에 있는 경우에 오류가 발생했습니다.
이 글을 쓰는 시점에 우리는 Clang에 버그 리포트를 올려 둔 상태입니다. 하지만 우리 SDK 사용자 입장에서 올바른 해결책 중 하나는 “서로 다른 어서션에 같은 메시지를 쓰지 말라”입니다 — 어차피 그렇게 하면 실패 시 진단 정보가 부실해지기 때문에 그렇게 하지 않는 편이 좋습니다.9
이런 형태의 버그는 온갖 시스템에서 발생합니다. 하나의 단일 원인 때문이 아니라, 드문 일이 또 다른 드문 일과, 또 다른 드문 일과 조합될 때 생기는 버그입니다. 여기서는 최적화 + 비-타입 템플릿 + 익명 네임스페이스의 조합이었고, 다른 시스템에서는 리더 선출 + 네트워크 결함 + 한 노드 장애일 수도 있습니다. 또 다른 시스템에서는 한 머신이 다른 머신보다 느림 + 클럭 스큐 + 패킷 드롭일 수도 있죠. 즉, 특정 기능과 환경 조건의 조합에서 버그가 발생하는데, 가능한 조합의 집합은 어마어마하게 큽니다.
그렇다면 이런 경우를 어떻게 테스트할까요? 그게 바로 자율 테스트가 빛을 발하는 지점입니다. Test Composer를 통해 Antithesis에 원자적 연산들을 제공하세요. 그리고 우리의 SDK(또는 다른 방법)를 사용해 항상 성립해야 하는 속성을 설정하세요. Antithesis는 여러분의 연산들을 서로 다른 조합으로 자동 구성하고, 무작위로 결함을 주입하면서 아무 문제도 일어나지 않는지(여러분의 모든 속성이 항상 유지되는지) 확인합니다. 그리고 이것은 최신 코드에 대해 매일 밤 자동으로 실행되어, 이런 형태의 버그를 찾아냅니다.
물론, 이렇게 버그를 찾고 나면 남은 일은 재현하고 디버깅하는 것뿐입니다.

아무 데나 붙이고 칭찬이 컴파일되는 걸 지켜보세요.