Testing Everything in Constexpr — 컴파일 타임 테스트를 끝까지 쓰게 만드는 설계

📺 원본 영상: Testing Everything in Constexpr - Braden Ganetsky - C++Now 2026
🎙️ 채널: CppNow
📅 정리 일자: 2026-09-09
⚠️ Note: 본 리포트는 영상 음성 변환문을 바탕으로 작성된 보고서를 검수한 것이다. 이번 검수에는 STT 원문이 제공되지 않았고 원본 영상도 직접 확인하지 못했으므로, 발언·코드·수치의 대조 검증에는 한계가 있다. 외부 문서로 보충한 기술적 설명은 별도로 구분했다. 제공된 스크린샷 목록에는 로컬 경로만 있고 webUrl이 없어 이미지는 삽입하지 않았다.


개요

Braden Ganetsky의 출발점은 컴파일 타임에 사용할 수 있다고 주장하는 코드는 실제로 컴파일 타임에서 테스트해야 한다는 원칙이다. 그러나 이 원칙을 실천하려면 동일한 검사를 런타임에도 적용하면서, 실패 원인을 읽기 쉽게 보여주고, 선행 조건이 무너진 뒤에는 위험한 후속 검사를 중단할 수 있어야 한다. 강연은 개인 파서 라이브러리 tok3n의 테스트가 단순한 static_assert에서 독립적인 테스트 프레임워크로 발전하는 과정을 통해, 이러한 요구를 C++20에서 어떻게 다룰 수 있는지 설명한다.


같은 소스 코드가 두 실행 환경에서 같은 결과를 낸다는 보장은 어디에 있는가

강연 초반의 사례는 문자 배열을 보관하는 고정 문자열 타입에서 시작한다. Ganetsky는 이 타입을 C++20의 비타입 템플릿 인자(Non-type Template Argument)로 사용해, 입력의 첫 문자가 지정된 집합에 속하는지 검사하는 파서를 만든다. 문자열은 정렬되어 있고 중복이 없어야 한다는 제약을 두어, 같은 문자 집합을 여러 순열로 표현하는 일을 막는다. 예를 들어 a, b, c를 허용하는 파서라면 "b"라는 입력을 받아들이는 것이 기대 동작이다.

처음에는 컴파일 타임의 static_assert와 런타임의 assert가 모두 통과한다. 그런데 동일한 검사를 람다 안으로 옮기자 컴파일 타임 검사는 여전히 통과하는 반면 런타임 검사는 실패한다. 강연자는 이를 자신이 사용하던 컴파일러의 버그라고 설명하며, 의도적으로 두 경로의 구현을 다르게 작성한 사례와 구분한다. 제공된 리포트에는 정확한 컴파일러 버전과 재현 코드 전체가 없으므로, 이 현상을 현재 모든 컴파일러에서 재현되는 문제로 확대할 수는 없다.

이 경험이 바꾼 것은 테스트의 범위다. Ganetsky에게 컴파일 타임 테스트와 런타임 테스트는 서로를 대신하는 검사가 아니라, 라이브러리가 지원한다고 주장하는 두 환경에 대한 별도의 증거가 된다. 구현이나 컴파일러에 따라 동작 차이가 생긴다면 이를 발견하고, 문서화하거나 우회할 기회가 필요하기 때문이다. 그가 원하는 것은 두 벌의 테스트를 관리하는 일이 아니라, 한 번 작성한 검사로 두 환경을 검증하는 사용 방식이다.

💡 [영상 참조] 04:08 부근에서는 람다 안의 컴파일 타임 검사와 런타임 검사가 서로 다른 결과를 보이는 사례를 설명한다. 정확한 코드와 재현 링크는 원본 화면을 확인해야 한다.

우연히 발견한 계산 능력에서 의도적으로 설계한 언어 기능으로

Ganetsky는 C++ 컴파일 타임 계산의 역사를 템플릿의 표현력을 발견해 온 과정으로 소개한다. 그 상징적인 사례가 Erwin Unruh의 소수 계산 프로그램으로, 템플릿 인스턴스화와 컴파일 오류를 이용해 계산 결과를 드러낸다. 강연자가 여기서 강조하는 것은 특정 소수 목록보다, 원래의 설계 의도를 넘어 템플릿이 계산 수단으로 사용되기 시작했다는 사실이다. 부분 특수화와 명시적 특수화는 패턴 매칭처럼 작동하고, SFINAE(Substitution Failure Is Not An Error)는 후보 선택과 대체 경로를 구성하는 도구가 된다.

이 시대의 복잡성을 보여주는 예제가 특정 타입에 멤버 X가 있는지 알아내는 멤버 탐지 관용구(Member Detector Idiom)다. 강연에 나온 방식은 검사 대상 타입과 X를 가진 보조 타입을 동시에 상속하고, 이름 탐색에 모호성이 생기는지를 이용한다. 함수 오버로드가 서로 다른 크기의 타입을 반환하도록 구성한 다음, sizeof로 어느 후보가 선택되었는지 판별한다. 단순한 속성 하나를 알아내기 위해 상속, 오버로드 해석, 템플릿 치환, 반환 타입 크기를 함께 동원해야 했던 셈이다.

C++11에 이르면 decltype, std::declval, 표준 타입 특성(Type Traits), static_assert가 이런 작업을 훨씬 직접적으로 표현하게 해 준다. 검사하려는 표현식이 유효한지를 치환 과정에서 확인하고, 결과를 std::true_type이나 std::false_type으로 나타낼 수 있다. C++20에서는 콘셉트(Concept)와 requires 표현식을 통해 요구사항 자체를 코드에 가까운 형태로 적을 수 있다. 다만 이러한 예제들은 같은 문제를 향해 발전한 표현 방식으로 읽어야 하며, 접근 제어나 탐지 대상까지 모든 버전이 완전히 동일한 의미를 가진다고 단정할 수는 없다.

값을 계산하는 constexpr 역시 비슷한 방향으로 발전한다. 강연은 C++11의 제한적인 함수 본문에서 출발해, C++14의 지역 변수와 반복문, C++17의 if constexpr, C++20의 consteval과 상수 평가 중 동적 할당으로 이어지는 변화를 짚는다. 그 결과 컴파일 타임 테스트는 작은 산술 함수나 타입 속성을 넘어, 문자열과 컨테이너를 사용하는 사용자 코드로 범위를 넓힐 수 있게 된다. Ganetsky의 관점에서는 계산 능력의 확장이 곧 검증해야 할 동작의 확장이다.

컴파일 타임 계산이 제공하는 두 가지 가치

강연자가 제시하는 첫 번째 가치는 실행 전에 문제를 드러낼 수 있다는 점이다. 상수 평가(Constant Evaluation)는 허용되지 않는 연산을 만났을 때 해당 계산을 상수 표현식으로 받아들이지 않으므로, 일부 잘못된 실행 경로가 배포 전에 발견된다. 특히 뒤에서 등장하는 비어 있는 결과 객체의 역참조 사례는 이 특성이 테스트 설계에 어떤 영향을 미치는지를 보여준다. 다만 이는 검사한 입력과 경로에 대한 검증이지, 프로그램 전체의 모든 정의되지 않은 동작(Undefined Behavior, UB)을 제거했다는 뜻은 아니다.

두 번째 가치는 사전 계산을 통해 런타임 작업을 줄이는 것이다. Ganetsky는 미리 계산한 테이블이나 데이터를 바이너리에 포함해 고객에게 전달하는 경우를 예로 든다. 이때 중요한 것은 실행 중 같은 계산을 반복할 필요가 줄어든다는 점이며, 제공된 리포트에는 이를 정량화한 벤치마크나 메모리 사용량 비교가 없다. 따라서 이 발표의 성능적 함의는 특정 속도 향상 수치보다, 계산을 수행할 시점을 선택할 수 있다는 데 있다.

C++20의 동적 할당 지원도 이러한 맥락에서 이해해야 한다. 강연자는 상수 평가 중 std::string이나 std::vector를 사용할 수 있다는 점을 강조하면서, 해당 할당이 영속적으로 유지되는 형태는 아니라고 설명한다. 즉, 컴파일 타임에 일반적인 동적 자료구조를 활용할 수 있다는 말과 그 할당 결과를 그대로 런타임 힙 객체로 넘길 수 있다는 말은 다르다. 핵심은 더 복잡한 알고리즘을 상수 평가 안에서 실행하고 검사할 수 있게 되었다는 변화다.

실패해야 통과하는 테스트가 엉뚱한 이유로 성공할 때

Ganetsky는 컴파일 타임 테스트를 소스 외부에서 판정하는 방식과 소스 내부에서 판정하는 방식으로 나눈다. 여기서 소스 외부 테스트란 테스트 파일의 위치를 뜻하는 것이 아니라, 빌드 시스템 같은 외부 도구가 컴파일 성공 여부를 해석하는 방식이다. 예를 들어 래퍼 객체의 호출 연산자가 const 참조를 반환해야 한다면, 그 반환값에 대입하는 코드를 작성하고 컴파일이 실패하는지 확인할 수 있다. 강연에서는 CMake로 별도 대상을 만들고 CTest의 WILL_FAIL 속성을 사용해 이러한 실패를 테스트 성공으로 처리한다.

문제는 컴파일 실패의 원인이 하나가 아니라는 데 있다. 반환값에 대한 잘못된 대입 때문에 실패할 수도 있지만, 필요한 헤더의 검색 경로를 누락했기 때문에 실패할 수도 있다. 강연자는 실제로 target_include_directories 설정이 빠진 상황을 보여주며, 이때도 단순히 종료 결과만 뒤집으면 테스트가 통과한다고 지적한다. 실패했다는 사실만으로는 의도한 규칙이 작동했다는 증거가 되지 않는다.

청중은 이 문제에 대해 진단 메시지의 정규식 검사, Clang의 진단 검증 기능, GCC의 JSON 진단 출력 같은 대안을 제시한다. Ganetsky도 추가 도구를 사용하면 실패 원인을 더 정밀하게 검증할 수 있음을 인정한다. 따라서 그의 비판은 모든 컴파일 실패 테스트가 본질적으로 부정확하다는 주장이 아니라, 단순한 성공·실패 반전만으로 구성한 테스트의 한계를 겨냥한다. 그는 여러 플랫폼에서 사용하기 쉬운 테스트를 선호하기 때문에, 가능하면 정상적으로 컴파일되는 소스 안에서 기대 조건을 검증하는 쪽을 선택한다.

이를 논리적으로 보면 기대한 조건을 모두 확인하는 테스트는 조건들의 논리곱에 가깝다. 반면 어떤 원인으로든 컴파일이 실패하면 성공으로 간주하는 테스트는, 여러 실패 원인 중 하나만 발생해도 통과할 수 있다. 그래서 전자의 경우 필수 조건 하나가 틀려도 테스트가 실패하지만, 후자의 경우 의도하지 않은 오류가 의도한 오류를 가릴 수 있다. 강연의 논리 게이트 설명은 이 차이를 드러내기 위한 비유로 이해하는 것이 정확하다.

타입 속성 검증과 사용자 코드 실행은 다른 문제다

소스 내부 검사로 바꾸면 앞의 래퍼 사례는 반환값에 실제로 대입하는 대신, 그 반환 타입이 대입 가능한지를 타입 특성으로 확인할 수 있다. 대입 불가능해야 한다는 조건을 참으로 표현해 static_assert로 검증하면, 헤더 누락은 정상적인 빌드 실패가 된다. 콘셉트를 사용하면 호출 가능성, 참조 반환 여부, 대입 가능성을 나누어 요구사항으로 표현할 수도 있다. Ganetsky는 이러한 분해가 자신이 사용한 컴파일러에서 실패 지점을 더 잘 설명해 주었다고 평가한다.

그러나 청중은 탐지 관용구가 모든 잘못된 프로그램을 포착하지는 못한다고 지적한다. 어떤 생성자가 오버로드 해석에 참여한다는 사실과, 그 생성자의 본문이 실제로 인스턴스화되었을 때 유효하다는 사실은 다르기 때문이다. 인스턴스화된 본문 내부에서 실패하는 static_assert 같은 오류는 단순한 제약 불충족으로 바뀌지 않는다. Ganetsky도 이 범위는 발표에서 해결하지 않는다고 인정하며, 소스 내부 탐지가 컴파일 실패 테스트 전체를 대체한다는 주장을 피한다.

이 구분은 이후 논의의 기반이 된다. 타입이 특정 조건을 만족하는지는 본래 컴파일 타임 속성이므로, 같은 참·거짓 값을 런타임에 다시 확인해도 얻는 정보가 제한적이다. 반면 constexpr 함수의 본문을 실행하는 것은 입력, 객체 상태, 알고리즘의 제어 흐름을 포함하는 동작 검증이다. 강연자는 후자의 경우 컴파일 타임과 런타임의 구현이 의도적으로 다를 수도 있기 때문에, 두 실행 환경의 테스트가 모두 필요하다고 설명한다.

파서 라이브러리가 요구한 테스트의 규모

이 설계 여정의 실험장은 tok3n이라는 C++20 파서 조합기(Parser Combinator) 라이브러리다. Ganetsky는 이를 상업적 필요보다 자신의 학습과 흥미를 위해 발전시킨 개인 프로젝트라고 소개한다. 표현식 템플릿(Expression Templates)을 활용하며, 기본적인 조합 문법은 Boost.Spirit 같은 라이브러리에서 영향을 받았다. 프로젝트 이름의 숫자 3 표기는 작성자의 공식 저장소에서도 확인된다.

음성에서 설명한 기본 예제는 "abc"_all로 "abcd"를 파싱하는 것이다. 성공 여부뿐 아니라 결과가 "abc"인지, 소비되지 않은 나머지가 "d"인지도 확인해야 한다. "xyz"_any는 세 문자 중 하나를 파싱하며, 반복이나 선택적 파싱을 나타내는 연산자를 조합하면 검사할 동작이 더 늘어난다. 입력 전체를 소비하도록 요구하는 complete 수정자(Modifier)를 적용하면, 앞부분만 일치하는 입력은 실패해야 하므로 같은 기본 파서도 다른 결과를 갖는다.

파서가 제공하는 인터페이스 역시 테스트 범위를 넓힌다. 입력을 받아 결과를 반환하는 parse, 출력 매개변수를 함께 받는 parse, 실제 결과값을 만들지 않고 파싱 가능성과 나머지를 확인하는 lookahead가 등장한다. 여기에 문자 타입과 파서 계열, 출력 타입까지 달라지면 하나의 예제에도 여러 속성을 확인해야 한다. Ganetsky가 검사를 재사용 가능한 단위로 묶으려는 이유는 코드의 외형을 정돈하는 데 그치지 않고, 이 조합들을 실제로 빠짐없이 작성할 수 있게 하기 위해서다.

단순한 static_assert에서 선언적인 테스트 문법까지

초기 테스트는 하나의 파일에 모인 void 함수들과 다수의 static_assert로 이루어져 있었다. 파서 조합 결과의 타입이 기대한 타입과 같은지, 파싱 결과가 특정 문자열인지, 나머지가 비어 있는지를 개별적으로 검사했다. 이후 파일을 나누고 결과 타입을 일반화하면서, 성공과 실패를 검증하는 consteval 보조 함수를 도입한다. 성공 검증은 결과 존재 여부와 값, 나머지를 확인하고, 실패 검증은 값이 없으며 나머지가 예상대로인지 확인하는 식이다.

다음 단계에서는 테스트가 영어 문장처럼 읽히기를 원했다. 이를 위해 Ganetsky는 자체 객체와 오버로드된 쉼표 연산자를 사용해 여러 조건을 이어 붙이는 문법을 만든다. 해당 연산자는 consteval로 실행되며, 조건이 거짓이면 throw를 통해 상수 평가를 실패시킨다. 그의 당시 MSVC 환경에서는 문제가 생긴 줄을 비교적 잘 알려주었지만, 다른 컴파일러에서도 같은 위치 정보를 제공하지는 않았고 사용자 정의 메시지도 충분하지 않았다.

그 뒤에는 컴파일 타임과 런타임을 함께 검사하는 매크로 기반 프레임워크가 등장한다. 공통 assertion 매크로 위에 파서의 콘셉트, 계열, 결과 타입, 파싱 성공, 출력값, 나머지를 검증하는 전용 매크로를 쌓는다. 이를 통해 반복되는 오류 메시지를 중앙에서 관리하고, 테스트 작성자는 의미 있는 입력과 기대값에 집중할 수 있게 된다. 런타임 실행기와 CTest 테스트 탐색 기능까지 갖추면서, 단순한 컴파일 타임 테스트 모음이 하나의 테스트 시스템으로 발전한다.

발전 단계해결한 문제새롭게 드러난 한계
개별 static_assert컴파일 타임 동작을 직접 검증반복되는 검사와 설정
consteval 검증 함수성공·실패 조건을 공통화세부 실패 원인의 표현
쉼표 연산자 기반 문법문장처럼 읽히는 검사컴파일러별 진단 차이
전용 assertion 매크로두 실행 환경 검사와 사용자 메시지의존 검사 오류와 강한 결합
독립 테스트 프레임워크평가와 보고 분리, 조합 가능한 검사컴파일 타임 제어 흐름과 상태 관리

이 변화는 추상화를 추가하기만 한 과정이 아니다. 각 단계에서 이전 설계가 해결하지 못한 구체적인 문제가 나타났고, 그 문제가 다음 설계의 요구사항이 되었다. 특히 Ganetsky는 전용 매크로가 파서 라이브러리에 강하게 결합되고, 타입 목록의 반복과 조합을 위해 Boost.Preprocessor까지 필요해진 상황을 부담으로 본다. 편리한 테스트 문법을 얻는 대가로 구현 복잡성과 의존성이 계속 커지고 있었기 때문이다.

친절한 오류 메시지를 무너뜨린 것은 후속 검사였다

가장 중요한 실패 사례는 "a"로 시작하는 입력만 처리하는 파서에 "b"를 전달하면서도 성공을 기대하는 테스트다. 첫 번째 검사는 파싱 결과에 값이 있는지 확인하고, 당연히 실패한다. 하지만 이어지는 검사는 결과값을 꺼내 "b"와 비교하려고 한다. 이미 첫 번째 검사에서 드러났듯이 결과는 비어 있으므로, 이 접근 자체가 유효하지 않다.

런타임에서는 선행 assertion이 실패한 뒤 함수에서 반환하면 후속 접근을 막을 수 있다. 그러나 독립적으로 작성된 여러 static_assert에는 그런 순차 실행 의미가 없다. 앞의 static_assert가 실패한다고 해서 뒤의 static_assert 조건 평가를 차단하는 제어 흐름이 생기지는 않는다. 그 결과 원래 기대했던 파싱 실패 메시지에 더해, 유효하지 않은 값 접근에서 시작한 방대한 템플릿 진단이 따라붙을 수 있다.

이 사례에서 첫 번째 오류와 두 번째 오류의 성격은 다르다. 첫 번째는 평가 가능한 조건이 false인 정상적인 테스트 실패이고, 두 번째는 조건 자체를 유효한 상수 표현식으로 평가할 수 없는 문제다. 메시지 문자열을 아무리 다듬어도 이 구분을 해결하지 못하면 진단은 계속 오염된다. Ganetsky의 다음 설계는 바로 선행 조건에 의존하는 검사를 언제 평가해야 하는가라는 질문으로 이동한다.

💡 [영상 참조] 56:21 부근에서는 두 번째 검사가 단순한 assertion 실패에 도달하지 못하고 상수 평가 오류를 일으키는 이유를 설명한다. 강연자가 사용하는 결과 타입은 optional과 유사한 자체 타입이므로, 구체적인 접근 함수의 동작은 원본 구현을 함께 확인해야 한다.

계산은 컴파일 타임에, 설명은 런타임에

독립 프레임워크에서 Ganetsky가 선택한 전환은 검사 시점과 오류 보고 시점을 분리하는 것이다. 제공된 리포트에 따르면 강연에서 이름은 ‘케첩’으로 발음되며, STT에는 catchup, Ketchup 등으로 혼재한다. 공식 영문 표기를 확인하지 못했으므로 여기서는 케첩으로 표기한다. 이 라이브러리는 C++20을 대상으로 하며, 파서 라이브러리와 분리해서 사용할 수 있도록 설계되었다. Ganetsky는 이름에서 연상되는 Catch2보다 GoogleTest의 스트리밍 메시지 API에 가까운 사용감을 추구했다고 설명한다.

아이디어 자체는 조건의 참·거짓을 컴파일 타임에 계산해 두고, 그 결과를 런타임 테스트 실행기가 해석하는 것이다. 예를 들어 값이 4[?]인 optional에 대해 값이 존재하는지 먼저 검사하고, 이어 값이 5[?]인지 검사할 수 있다. 두 번째 조건은 컴파일 타임에 거짓으로 판정되지만, 이를 static_assert 실패로 만들지 않고 실행 시점에 테스트 오류로 출력한다. 제공된 리포트는 앞뒤 문맥을 근거로 실제값을 4, 기대값을 5로 해석했으나, 숫자가 뒤바뀐 듯한 발언을 언급하고 있어 원본 코드나 STT와 대조하기 전까지 두 값은 미확정으로 둔다.

이렇게 하면 오류 메시지 작성에 런타임의 표현력을 사용할 수 있다. 값과 문맥을 출력 스트림에 이어 붙이거나, 런타임 포매팅을 이용해 기대값과 실제값을 설명할 수 있다. Ganetsky는 snitch가 컴파일 타임 판정 결과의 실패 보고를 런타임으로 미루는 선택지를 제공한다는 점에서 영감을 얻었다고 말한다. 컴파일러마다 다른 진단 형식 대신, 테스트 프레임워크가 일관된 보고 형식을 제공할 수 있다는 점이 그에게 중요했다.

다만 강연의 컴파일이 항상 성공한다는 표현에는 범위가 있다. 유효한 상수 평가의 결과가 false인 경우를 테스트 데이터로 다룬다는 뜻이지, 문법 오류나 상수 평가 불가능한 연산까지 자동으로 런타임 오류로 바꾼다는 뜻은 아니다. 앞서 문제가 된 잘못된 후속 접근은 여전히 별도의 제어가 필요하다. 또한 이 방식에서는 빌드 성공만으로 테스트 통과를 판단할 수 없으며, 테스트 실행기를 실제로 실행해야 결과를 알 수 있다.

assert와 expect가 정의하는 실패 이후의 동작

케첩은 실패가 이후 검사에 미치는 영향에 따라 assert와 expect를 구분한다. 강연자의 설명에서 assert는 실패하면 현재 함수에서 조기 반환하고, expect는 실패를 기록한 뒤 계속 진행한다. 여기서 assert는 프레임워크의 검사 구분을 가리키며, 실패 시 프로그램을 중단하는 표준 <cassert>의 assert 매크로와는 다르다. 이때 현재 함수라는 표현이 중요하다. assertion이 보조 함수 안에 있다면 그 함수에서 반환하는 것이지, 호출자 전체를 자동으로 종료하는 것은 아니다.

평가 환경은 이 구분과 별도의 축이다. 컴파일 타임만 검사할 수도 있고 런타임만 검사할 수도 있으며, 같은 조건을 두 환경에서 모두 검사할 수도 있다. Ganetsky는 두 환경을 명시하는 매크로 이름이 길다는 점을 인정하면서도, 현재로서는 무엇을 검사하는지 분명하게 드러내는 이름을 선호한다. 테스트의 종료 정책과 평가 환경을 나누면 어떤 검사가 필수 전제이고 어떤 검사가 추가적인 관찰인지 표현하기 쉬워진다.

구분 축선택강연에서 설명한 의미
실패 이후 제어assert실패 시 현재 함수에서 조기 반환
실패 이후 제어expect실패를 기록하고 계속 진행
평가 환경컴파일 타임상수 평가에서 조건 판정
평가 환경런타임실행 중 조건 판정
평가 환경두 환경같은 검사 의도를 양쪽에 적용
오류 보고런타임평가 환경과 위치, 메시지를 함께 출력

이 구조의 장점은 실패를 하나의 문자열로만 취급하지 않는다는 데 있다. 어떤 환경에서 발생했는지, 진행을 중단해야 하는지, 어느 소스 위치에 해당하는지를 함께 전달할 수 있다. 특히 파싱 결과의 존재 여부는 후속 값 접근의 전제이므로 치명적 검사로 다루는 것이 자연스럽다. 반면 유효한 결과에서 서로 독립적인 여러 속성을 비교한다면 비치명적 검사로 더 많은 정보를 수집할 수 있다.

테스트를 함수로 추출하자 사라진 상수 표현식

다음 문제는 재사용이다. 파싱 성공 여부, 결과값, 나머지를 확인하는 세 검사를 매번 반복하고 싶지 않으므로, Ganetsky는 파서와 입력, 기대 결과, 기대 나머지를 받는 보조 함수로 추출한다. 일반적인 런타임 테스트라면 자연스러운 정리다. 그러나 보조 함수 내부에서 컴파일 타임 평가를 강제하려고 하면, 전달받은 일반 함수 매개변수의 값을 그대로 상수 표현식으로 사용할 수 없다는 문제가 나타난다.

이 설명을 constexpr 함수에는 인자를 전달할 수 없다는 의미로 읽으면 안 된다. 강연에서 막힌 것은 런타임에 호출되는 보조 함수의 매개변수를, 그 함수 내부의 별도 상수 평가 문맥에서 이용하려는 구조다. 호출자가 리터럴을 넘겼다는 사실만으로 함수 본문 안의 해당 매개변수가 컴파일 타임 상수가 되는 것은 아니다. 그래서 Ganetsky는 값 자체 대신 그 값을 만들어 내는 호출 가능 객체를 전달하는 방식으로 전환한다.

먼저 캡처 없는 람다의 타입을 템플릿 인자로 받고, 보조 함수 안에서 해당 람다를 기본 생성한 뒤 호출한다. 이후에는 람다 객체를 함수 인자로 전달하는 형태도 제시한다. 강연 예제의 핵심은 호출 연산자가 상수 평가 가능하고, 값을 생성하는 과정에서 런타임 객체 상태를 읽지 않는다는 점이다. 따라서 임의의 캡처나 런타임 데이터를 가진 람다까지 같은 방식으로 사용할 수 있다는 일반화는 피해야 한다.

그런데 입력과 기대값마다 람다를 직접 쓰면 테스트가 다시 번거로워진다. Ganetsky는 이 반복을 작은 매크로에 맡겨, 테스트 작성자가 일반적인 값 목록을 쓰면 내부적으로 람다로 감싸 전달하게 한다. 여기서 매크로는 검증 로직 전체를 소유하는 대신, 호출 지점의 표현식을 필요한 형태로 바꾸는 역할을 한다. 반복적인 문법은 매크로가 처리하고, 실제 검증은 함수에서 재사용하는 분담이다.

재사용 가능한 검사에는 호출 문맥도 따라가야 한다

보조 함수로 검사를 추출하면 실패 위치가 모두 같은 줄로 모일 수 있다. 서로 다른 두 테스트가 잘못된 나머지를 기대하더라도, 실제 비교는 보조 함수 안의 동일한 문장에서 수행되기 때문이다. 실패 보고가 그 내부 위치만 알려주면 어느 입력에서 발생한 오류인지 구분하기 어려워진다. Ganetsky는 검사 내부 위치와 호출 지점의 위치를 함께 보존하는 추적 정보가 필요하다고 설명한다.

이를 위해 등장하는 개념이 패킷(Packet)이다. 강연에서 패킷은 인자 없이 호출할 수 있는 객체이며, assert_that이나 expect_that에 전달할 수 있다. 보조 함수가 검사를 즉시 수행하는 대신 검사할 람다를 반환하면, 바깥 계층이 호출 문맥을 덧붙이고 안쪽 계층이 실제 실패 위치를 기록하는 구조를 만들 수 있다. 여러 계층으로 조합해도 어디서 요청한 검사가 어디에서 실패했는지 연결할 수 있다는 것이 목적이다.

청중은 모든 것을 매크로로 작성하면 호출 지점의 줄 번호를 얻을 수 있지 않느냐고 묻는다. Ganetsky는 그 가능성을 인정하면서도, 그렇게 하면 매크로 내부의 여러 세부 검사가 같은 호출 위치로 보일 수 있다고 답한다. 그가 실제로 지향하는 것은 단일 매크로보다 매처(Matcher)를 재귀적으로 조합하는 시스템이다. 문장처럼 읽히는 검사를 만들면서도, 바깥 테스트와 안쪽 세부 조건의 위치를 모두 남기는 편이 목적에 맞는다.

여기에는 공개 기능의 경계도 있다. 강연 당시 매처 시스템은 케첩 자체가 아니라 tok3n의 테스트 코드에 구현되어 있었다. 케첩 사용자는 조합의 기본 수단을 이용해 자신의 매처를 만들어야 하며, 발표에서 보인 파서 전용 문법을 그대로 내장 기능으로 기대해서는 안 된다. 또한 패킷 자체는 런타임에 호출되므로, 그 내부의 개별 검사들이 컴파일 타임과 런타임 평가를 처리한다는 점도 구분해야 한다.

의존 검사를 중첩하면 해결되는 것과 깨지는 것

의존 검사 문제에 대한 첫 해결책은 후속 검사를 선행 조건이 참일 때만 남기는 것이다. Ganetsky는 if constexpr로 파싱 성공 여부를 확인하고, 성공한 경우에만 결과값을 검사하는 구성을 설명한다. 그러나 템플릿 문맥 밖에서 if constexpr를 사용한다고 해서 잘못된 코드의 모든 검사가 사라지는 것은 아니다. 그래서 템플릿 람다와 의존적인 조건을 도입해 후속 부분의 인스턴스화를 제어한다.

이 방식은 유효하지 않은 상수 평가를 막는 데 도움이 되지만, 두 평가 환경을 결합한다. 컴파일 타임의 선행 조건을 기준으로 코드 자체를 제거하면, 런타임에서는 선행 조건이 성공하더라도 후속 검사가 존재하지 않을 수 있다. 두 환경의 동작 차이를 확인하고 싶어서 테스트를 함께 작성했는데, 한쪽의 실패가 다른 쪽의 검사 범위까지 결정하는 것이다. Ganetsky는 바로 이 결합을 원하지 않는다.

문법적으로도 중첩은 부담을 만든다. 의존하는 검사를 선행 assertion의 내부 인자로 넣도록 자동화할 수는 있지만, 템플릿 문맥을 위해 추가한 람다가 별도의 함수 범위를 형성한다. 그러면 내부의 치명적 assertion이 실행하는 return은 바깥 테스트가 아니라 그 람다만 종료할 수 있다. 보호하고 싶었던 바깥 후속 코드가 계속 실행되는 문제가 생기므로, 단순히 모든 검사를 더 깊게 중첩하는 방식으로는 원하는 사용성을 얻기 어렵다.

강연 후반의 청중은 C++26 기능을 활용하는 대안을 제안한다. 그러나 Ganetsky는 테스트 대상인 파서 라이브러리가 C++20이므로 테스트 프레임워크도 그 기준을 유지해야 한다고 답한다. 따라서 이후의 해법은 최신 언어 기능을 사용할 수 있는 이상적인 상황보다, C++20에서 직선형 테스트 코드를 유지하는 문제에 초점을 맞춘다. 이 제약이 상태를 가진 메타프로그래밍으로 향하는 이유다.

함수별 실패 상태를 컴파일 타임에 기억한다

Ganetsky가 최종적으로 원하는 동작은 명확하다. 같은 함수에서 앞선 컴파일 타임 assert가 실패했다면, 뒤의 컴파일 타임 검사는 조건 자체를 평가하지 않아야 한다. 그러나 런타임 검사에는 별도의 진행 규칙이 적용되어야 한다. 이를 위해 그는 함수별로 컴파일 타임 치명적 실패가 발생했는지를 기억하는 구조를 도입한다.

첫 단계는 현재 함수를 식별할 키를 만드는 것이다. 강연에서는 std::source_location::current().function_name()으로 얻은 함수 이름을 해시하고, 그 값을 std::integral_constant에 넣어 타입으로 변환한다. Ganetsky는 자신이 사용하는 환경에서 함수 템플릿의 인스턴스화별로도 구분되는 식별자를 얻는다고 설명한다. 다만 이것이 모든 구현에서 충돌 없는 고유성을 보장하는 표준 기능이라는 뜻은 아니며, 그 한계는 뒤의 기술 보충에서 따로 짚는다.

두 번째 단계는 타입을 키로 삼아 실패 상태를 남기는 것이다. 강연의 error_key<T>는 관련 함수를 선언하고, set_error_for<T>는 인스턴스화될 때 그 함수의 정의를 제공하는 역할을 한다. 이후 검사에서는 해당 정의를 사용할 수 있는지를 통해 오류 상태가 이미 설정되었는지 알아낸다. 런타임 변수를 수정하는 대신 템플릿 인스턴스화와 함수 정의의 가용성을 상태처럼 사용하는, 상태를 가진 메타프로그래밍(Stateful Metaprogramming)이다.

치명적 검사에서는 이 상태를 확인하는 consteval 람다가 조건 평가를 감싼다. 이미 실패 상태가 있으면 실제 조건을 평가하지 않고 중립적인 성공값을 반환하며, 실패 상태가 없으면 조건을 평가한다. 새로 조건이 실패했다면 sizeof를 이용해 상태 설정용 타입의 인스턴스화를 유도하고, 뒤의 검사에서 이를 알아볼 수 있게 한다. 비치명적 검사는 기존 실패 상태를 확인해 평가를 생략할 수 있지만, 자신의 실패로 이후 검사 전체를 중단시키는 상태를 설정하지는 않는다.

여기서 성공값을 반환한다는 것은 생략한 조건이 실제로 참임을 증명했다는 뜻이 아니다. 앞선 실패가 이미 보고될 예정이므로, 뒤의 의존 검사에서 추가 오류를 만들지 않도록 내부 판정값을 사용하는 것이다. 강연의 핵심 성과는 assertion을 위에서 아래로 나열하는 익숙한 문법을 유지하면서, 상수 평가 단계에서도 의존 관계를 처리하려 했다는 점이다. 다만 상태 조회를 갱신하는 세부 방식과 정확한 템플릿 의존성은 음성만으로 완전한 구현을 재구성할 수 없다.

💡 [영상 참조] 85:25 부근에서는 치명적 검사 실패 시 set_error_for의 인스턴스화를 유도하는 과정을 설명한다. 친구 함수 선언·정의와 상태 조회의 정확한 코드는 원본 화면에서 확인해야 한다.

두 실행 환경의 독립성에는 아직 비대칭이 남는다

이 설계에서 Ganetsky가 강조하는 독립성은 컴파일 타임 실패 이후에도 런타임 조건을 별도로 검사할 수 있다는 것이다. 상수 평가에서는 함수의 실패 상태를 확인해 후속 조건 평가를 생략하고, 런타임에서는 실제 실행 결과에 따라 검사를 진행한다. 따라서 컴파일 타임에서 선행 조건이 실패했다는 이유만으로 런타임의 후속 검사를 코드에서 제거하지 않아도 된다. 이는 if constexpr로 검사 전체를 감쌌던 이전 방식과 달라진 지점이다.

그러나 반대 방향에는 여전히 제한이 있다. 강연자는 런타임의 치명적 검사가 실패해 함수에서 조기 반환하면, 뒤에 있는 컴파일 타임 검사 결과를 보고하는 코드에도 도달하지 못할 수 있다고 설명한다. 컴파일 타임에서 무엇을 평가했는지와 런타임에서 무엇을 출력했는지는 서로 다른 문제인 셈이다. 따라서 발표 당시 구현을 완전히 대칭적인 두 테스트 실행 흐름으로 이해해서는 안 된다.

Ganetsky는 이를 완성된 최종 해법으로 포장하지 않는다. 패킷 API와 상태 관리 방식은 계속 개발 중이며, 발표를 위해 도달한 지점도 비교적 최근의 결과라고 말한다. 강연에서 주장하는 성과는 원하는 직선형 문법과 컴파일 타임 의존 검사 처리를 C++20에서 상당 부분 결합했다는 것이다. 남은 비대칭과 이식성 문제까지 해결되었다는 검증 결과는 제공된 리포트에 없다.

기술 보충 — 강연과 별도로 확인할 구현 경계

다음 내용은 강연자의 발언을 추가로 재구성한 것이 아니라, 설계를 해석할 때 필요한 외부 문서 기반 보충이다. 특히 상수 평가의 안전성, if constexpr의 검사 범위, 함수 식별자의 고유성, 상태를 가진 메타프로그래밍의 표준화 상태를 구분할 필요가 있다. 이 경계를 알아야 발표의 아이디어를 이해하는 것과 그대로 이식 가능한 구현을 확보하는 것을 혼동하지 않는다.

상수 평가가 UB 탐지에 유용하다는 설명은 언어 규칙에 근거한다. C++ 작업 초안은 여러 금지 연산을 핵심 상수 표현식(Core Constant Expression)의 조건에서 배제한다. 하지만 실제로 평가하지 않은 입력과 경로까지 검증하는 것은 아니므로, 이를 프로그램 전체의 안전성 보증으로 확대할 수는 없다. 또한 현재 작업 초안에는 C++20 이후의 변경도 포함되어 있으므로 특정 표준 버전의 허용 연산은 해당 버전 기준으로 확인해야 한다. C++ 작업 초안의 핵심 상수 표현식 규칙

if constexpr의 중요한 역할은 템플릿 안에서 조건에 따라 버려진 부분의 인스턴스화를 피할 수 있게 한다는 점이다. 이것이 임의의 잘못된 코드를 템플릿 밖에서도 숨겨주는 전처리기 기능이라는 뜻은 아니다. 따라서 강연에서 템플릿 문맥과 의존적인 조건을 추가한 이유는, 단순한 실행 분기보다 코드의 인스턴스화 여부를 제어하기 위해서다. 그렇다고 템플릿 람다를 추가하기만 하면 모든 종류의 부적격 코드가 허용되는 것은 아니다. C++20 초안의 if constexpr 규칙

함수 이름을 해시해 고유 타입으로 사용하는 방식에는 식별자 설계의 가정이 들어간다. source_location이 제공하는 함수 이름은 현재 함수의 이름을 나타내지만, 표준이 프로그램 전체에 걸친 충돌 없는 고유 식별자로 규정한 것은 아니다. 여기에 유한한 해시를 적용하면 해시 충돌 가능성도 별도로 남는다. 따라서 실제 프레임워크에서는 동일 이름의 함수, 템플릿 인스턴스화, 번역 단위가 달라지는 상황에서 필요한 구분을 얻는지 확인해야 한다는 것이 이 규정에서 도출되는 구현상 함의다. C++ 작업 초안의 소스 위치 정보 규정

친구 함수 주입(Friend Injection)을 이용한 상태 저장은 표준화 측면에서도 주의 깊게 읽어야 한다. 관련 코어 이슈인 CWG 2118은 이러한 상태 저장 기법을 다루며, 확인한 이슈 문서는 여전히 열린 상태로 표시되어 있다. 문서에는 2015년 회의에서 이 기법을 부적격으로 만들자는 합의가 있었으나 금지 메커니즘은 정해지지 않았다는 기록도 있다. 따라서 강연의 방법은 흥미로운 구현 실험으로 평가할 수 있지만, 장기적인 표준 보장이 확립된 상태 저장 API처럼 취급할 수는 없다. CWG 2118 — Stateful metaprogramming via friend injection

마무리 / 핵심 정리

Ganetsky의 설계 여정을 관통하는 목표는 검사를 더 많이 추가할 수 있게 만드는 것이다. 컴파일 타임을 지원한다는 약속을 검증하려면 해당 환경에서 실제 코드를 실행해야 하고, 런타임에서도 같은 동작을 기대한다면 두 경로를 함께 확인해야 한다. 그러나 테스트 작성이 반복적인 매크로와 템플릿 문법으로 가득 차거나 실패 로그가 읽기 어려우면, 개발자는 필요한 검사를 충분히 작성하지 않게 된다. 그래서 이 강연에서 사용성은 테스트의 장식이 아니라 검증 범위를 넓히는 조건이다.

가장 큰 설계적 전환은 조건을 평가하는 시점, 실패 이후 진행을 결정하는 방식, 오류를 사용자에게 보고하는 시점을 분리한 것이다. 이 분리 덕분에 컴파일 타임의 거짓 판정을 런타임의 풍부한 메시지로 설명할 수 있고, 호출 문맥을 보존하는 재사용 가능한 검사를 만들 수 있다. 동시에 선행 검사가 실패한 뒤 유효하지 않은 후속 검사를 수행하지 않도록, 컴파일 타임에도 별도의 의존 관계 처리가 필요하다는 사실이 드러난다. 좋은 테스트 프레임워크는 참과 거짓을 확인하는 도구를 넘어, 실패를 정확한 순서와 문맥으로 전달하는 시스템이라는 해석이 가능하다.

케첩의 상태 기반 해법은 그 목표에 도달하려는 실험적인 구현이다. 발표 당시에는 런타임 조기 반환과 컴파일 타임 결과 보고 사이의 비대칭이 남아 있고, 핵심 메타프로그래밍 기법의 이식성과 표준화 상태도 별도로 검토해야 한다. 그럼에도 강연이 제시하는 요구사항은 분명하다. 지원하는 실행 환경에서 코드를 검증하고, 전제가 무너진 검사는 중단하며, 개발자가 실패 원인을 읽고 다음 행동을 결정할 수 있게 만들어야 한다.


📚 출처 및 참고

구분내용
원본 영상Testing Everything in Constexpr - Braden Ganetsky - C++Now 2026
채널CppNow
강연자Braden Ganetsky
원본 리포트에 기재된 STT 범위00:00–89:05[?] — STT 원문 미제공으로 범위 재확인 불가
리포트 작성2026-09-09
작성 방식제공된 STT 기반 리포트의 문맥 교정 및 외부 기술 문서 대조
프로젝트 표기 확인tok3n 공식 저장소
강연과 별도의 기술 참고본문의 기술 보충에 연결한 C++ 작업 초안, C++20 초안 및 CWG 이슈 문서

본 리포트의 강연 서술은 제공된 STT 기반 리포트를 근거로 하며, 이번 검수에서 원본 음성이나 화면의 코드·수치를 직접 대조하지는 못했다. SFINAE, decltype, std::declval, consteval 등 기술 용어 표기는 유지하고, 상수 평가와 assertion의 동작을 오해할 수 있는 설명을 다듬었다. 원본 리포트에서 발언 혼선을 언급한 실제값·기대값과 재확인할 수 없는 STT 범위에는 [?]를 표시했다. 외부 자료는 프로젝트 이름 확인과 기술적 경계 설명에 사용했으며, 발표 당시 구현을 직접 빌드하거나 검증한 결과는 아니다.

💬 본 리포트는 영상 내용을 바탕으로 재구성되었다. 정확한 코드, 진단 출력, 라이브러리 표기는 원본 영상을 참조해야 한다.