1. 왜 템플릿 오류는 “시끄러운가”
템플릿을 작성한다는 것은 사실상 “미래를 위한 코드”를 작성하는 것입니다. 누군가가 구체적인 타입을 대입했을 때에야 컴파일러가 본격적으로 이를 검사하게 됩니다. 그래서 오류는 종종 “이런 대입”, “이런 인스턴스화”, “그리고 여기서 이 타입에는 size() 메서드가 없습니다” 같은 긴 장문으로 보입니다. 우리는 이것을 짧고 사람이 이해할 수 있는 한 문장, 즉 “이 함수는 오직 …에 대해서만 동작합니다”로 바꾸는 법을 배우고자 합니다.
전형적인 상황을 떠올려 봅시다. 여러분은 “숫자”를 위한 범용 함수를 작성했는데, 누군가가(혹은 일주일 뒤의 여러분 자신이) 실수로 std::string을 넘깁니다. 컴파일러는 정직하게 함수 본문을 컴파일하려고 시도하고, x * x에서 막힌 뒤, 관련된 모든 템플릿의 비극적인 생애를 장황하게 설명하기 시작합니다. 컴파일러의 관점에서는 잘한 일입니다. 학생의 관점에서는 “아아아아아아”가 됩니다.
static_assert는 컴파일러에게 이렇게 말하는 우리의 방법입니다. “조건이 충족되지 않으면, 바로 여기서 멈추고 내가 쓴 메시지를 보여줘.”
static_assert: 기본 아이디어와 문법
static_assert는 컴파일 시점의 검사입니다. 조용히 통과하거나, 아니면 오류와 함께 컴파일을 중단합니다. 실행 중에 동작하는 if와 달리, static_assert는 말 그대로 “입구의 경비원”입니다. 계약이 깨졌다면 코드를 더 진행시키지 않습니다.
문법은 메시지가 있는 형태와 없는 형태, 두 가지가 있습니다. 학습용 코드에서는 메시지 없는 형태를 거의 사용하고 싶지 않을 것입니다. “텍스트 없는 오류”는 설명 없이 “네가 틀렸어”라고 적힌 쪽지와 같기 때문입니다.
#include <concepts>
template <class T>
T square(T x) {
static_assert(std::integral<T> || std::floating_point<T>,
"square<T>: T must be an integer or floating-point type");
return x * x;
}
여기서 아이디어는 단순합니다. 누군가가 square(std::string{"hi"})를 호출하면, 컴파일러는 멈추고 정확히 여러분이 작성한 문구를 보여줍니다.
2. 중요한 규칙: static_assert는 인스턴스화 시점에 동작한다
일반 함수(템플릿이 아닌 함수)를 작성하면 컴파일러는 이를 즉시 검사합니다. 템플릿을 작성하면, 컴파일러는 종종 사용되는 순간까지 함수 본문의 검사를 미룹니다. 그래서 템플릿 안에서의 static_assert 동작은 처음에는 마법처럼 보이지만, 좋은 종류의 마법입니다.
중요한 그림을 간단히 이해해 봅시다.
flowchart TD
A[템플릿 선언됨] --> B{구체적인 타입으로 호출이 있는가?}
B -- 아니오 --> C[본문은 거의 검사되지 않음]
B -- 예 --> D[인스턴스화]
D --> E[본문 컴파일 + static_assert]
E -- 조건 true --> F[정상]
E -- 조건 false --> G[사용자 메시지와 함께 오류]
즉, 템플릿 안의 static_assert는 템플릿을 실제로 부적절한 타입에 적용하려 할 때 발동하는 “함정”입니다. 그리고 이것이 우리가 원하는 바입니다. 템플릿이 존재한다는 사실만으로 프로젝트가 “망가져서는” 안 되고, 잘못 사용될 때에만 깨져야 합니다.
4. 함정: static_assert(false, "...")를 “혹시 몰라서” 쓰기
초보자가 자주 하는 실수는 이런 모양입니다. “어떤 실행 경로를 금지하고 싶으니 static_assert(false, "…")를 쓰자.” 일반 코드에서는 이것이 즉시 컴파일을 중단시킵니다. 템플릿 코드에서도 역시 컴파일을 중단시키지만, 문제는 템플릿을 호출하지 않아도 항상 중단된다는 점입니다. 즉, 실수로 “헤더 안의 폭탄”을 만들어 버린 것입니다.
짧은 예제로 문제를 보겠습니다.
template <class T>
void broken() {
static_assert(false, "This template is forbidden"); // 좋지 않음
}
이것이 “좋지 않은” 이유는 false가 T에 의존하지 않기 때문입니다. 컴파일러는 즉시 false를 보고, 사용 시점까지 기다릴 의무가 없습니다. 그냥 “안 된다”고 말하고 끝내 버립니다.
템플릿 안의 static_assert가 유용하려면, 그 조건은 보통 템플릿 매개변수(예를 들어 T)에 의존해야 합니다. 그러면 검사는 실제 사용 시점까지 미뤄집니다.
5. “금지된 분기”를 위한 always_false_v<T> 기법
때로는 정말로 이렇게 말해야 할 때가 있습니다. “여기까지 왔다면, 지원되지 않는 타입을 사용하고 있는 것입니다.” 이를 위해 매우 널리 쓰이고 단순한 트릭이 있습니다. 바로 “타입에 의존하는 거짓”을 만드는 것입니다.
최소한의 준비 코드는 다음과 같습니다.
template <class>
inline constexpr bool always_false_v = false;
이제 이렇게 작성할 수 있습니다.
#include <concepts>
template <class T>
void print_value(const T&) {
static_assert(always_false_v<T>,
"print_value<T>: unsupported type (add an overload or constrain T)");
}
여기서는 always_false_v<T>가 T에 의존하므로 컴파일러가 “즉시” 불평하지 않습니다. 누군가가 실제로 부적절한 타입으로 print_value를 호출할 때에만 오류가 나타납니다.
이 기법은 여러 타입에 대해 오버로드를 준비해 두고, 마지막의 “대체용” (fallback) 분기에서 “아니요, 이 타입은 지원하지 않습니다”라고 말해야 할 때 특히 유용합니다.
6. static_assert는 어디에 둘까: 입구에 가깝게
문제가 생길 만한 줄 옆, 예를 들어 (x.size(), a + b, x * x) 같은 곳에 검사를 두고 싶어질 수 있습니다. 하지만 UX를 위해서도, 미래의 여러분의 정신 건강을 위해서도, static_assert는 함수나 클래스의 시작 부분에 두는 편이 더 낫습니다. 그러면 오류가 났을 때 사용자는 “이 함수는 이런 요구 사항이 있다”를 보게 되고, “아래 어딘가에서 뭔가 맞지 않았다”를 보지 않게 됩니다.
두 가지 스타일을 비교해 봅시다. 첫 번째 스타일은 검사가 멀리 있어서 오류를 읽기 더 어렵습니다.
#include <concepts>
template <class T>
T add_late(T a, T b) {
T c = a + b; // 여기서 오류가 장황하게 쏟아질 수 있음
static_assert(std::integral<T> || std::floating_point<T>,
"add_late<T>: T must be numeric");
return c;
}
두 번째 스타일이 보통은 더 낫습니다.
#include <concepts>
template <class T>
T add_early(T a, T b) {
static_assert(std::integral<T> || std::floating_point<T>,
"add_early<T>: T must be numeric");
return a + b;
}
물론 컴파일러는 a + b에 문제가 있다는 점을 스스로 알아챌 수도 있습니다. 하지만 우리는 컴파일러가 사람을 더 잘 도울 수 있도록 돕는 것입니다.
7. static_assert와 concept를 함께 쓰기: 요구 사항 설명하기
concept와 requires를 통한 제약은 이미 꽤 괜찮은 진단을 제공하는 경우가 많습니다. 하지만 특히 학습용 라이브러리나, 이해하기 쉬운 코드가 중요한 팀 내부 코드를 작성할 때는, 여러분 자신의 말로 오류를 설명하고 싶을 때가 있습니다.
실용적인 접근 방식 중 하나는 인터페이스에는 concept로 제약을 걸고, 내부에는 여전히 static_assert를 남겨 두는 것입니다. 더 구체적인 설명을 위해서이거나, 더 친절한 텍스트를 위해서입니다. 중요한 것은 이것을 “중복을 위한 중복”으로 만들지 않는 것입니다. 컴파일러 메시지가 이미 완벽하다면, 불필요한 static_assert는 오히려 소음만 늘릴 수 있습니다.
학습용 애플리케이션(CLI 작업 추적기) 분위기의 예를 봅시다. 우리는 문자열에서 숫자형 id를 파싱하고 싶습니다.
#include <charconv>
#include <concepts>
#include <string_view>
template <std::integral T>
T parse_int(std::string_view s) {
T value{};
auto [ptr, ec] = std::from_chars(s.data(), s.data() + s.size(), value);
static_assert(std::integral<T>, "parse_int<T>: T must be integral"); // 설명
(void)ptr; (void)ec;
return value;
}
순수한 논리만 놓고 보면 여기의 static_assert는 중복입니다. (T는 이미 std::integral이기 때문입니다.) 하지만 학습 목적에서는 이것을 일종의 “표지”로 남겨 두는 것이 도움이 될 때도 있습니다. 학생은 이 함수가 “대충 숫자 같은 것”이 아니라 실제로 정수 타입을 기대한다는 점을 보게 됩니다.
더 자주 유용한 것은 다른 방식입니다. concept가 거친 1차 필터를 만들고, static_assert가 계약을 더 구체화하는 것입니다. 예를 들어 “타입은 숫자여야 하고, 우리는 나눗셈이 의미를 가지기를 기대한다” 같은 경우입니다.
#include <concepts>
template <class T>
T average2(T a, T b) {
static_assert(std::floating_point<T>,
"average2<T>: use floating point type (e.g. double) to avoid integer division");
return (a + b) / static_cast<T>(2);
}
8. 실전 예시: TaskBoard에서 진단 개선하기
static_assert가 “추상적인 철학”으로만 남지 않도록, 이 강의의 분위기에 맞춰 하나의 애플리케이션, 즉 콘솔 작업 추적기 TaskBoard를 계속 발전시키고 있다고 상상해 봅시다. 이전 단계에서 우리는 이미 std::vector에 작업을 저장하고, 문자열로 명령을 읽고, 이를 std::stringstream으로 파싱하고, 입력을 검사했습니다. 이제는 재사용 가능한 작은 “파싱 유틸리티”를 만들고 싶습니다.
예를 들어 다음과 같은 명령이 있다고 합시다.
add 101 "Buy milk"
101은 id입니다. 그리고 여기서 우리는 두 가지를 원합니다. 첫째, 편리한 범용 파싱 함수. 둘째, 프로그래머가 실수로 이를 “잘못된 방식으로” 사용했을 때 이해하기 쉬운 컴파일 오류입니다.
정수 전용으로 설계된 작은 함수 parse_id<T>를 만들어 봅시다.
#include <charconv>
#include <concepts>
#include <string_view>
template <class T>
T parse_id(std::string_view s) {
static_assert(std::integral<T>,
"parse_id<T>: T must be an integral type (int, long long, ...)");
T id{};
std::from_chars(s.data(), s.data() + s.size(), id);
return id;
}
이제 명령 처리 코드에서는 원하는 타입을 명시적으로 말할 수 있습니다.
#include <iostream>
#include <string>
int main() {
std::string token = "101";
int id = parse_id<int>(token);
std::cout << "id=" << id << '\n'; // id=101
}
누군가가 auto id = parse_id<std::string>("101");를 시도하면, 컴파일러는 정확히 여러분의 메시지에서 멈춥니다. “왜 from_chars를 string에 쓸 수 없지?”도 아니고, “적절한 오버로드가 없다”도 아니라, 사람이 읽을 수 있는 “T must be integral”에서 멈추는 것입니다.
메시지 설계에서 중요한 뉘앙스 하나를 주목해 봅시다. 메시지는 두 가지 질문에 답해야 합니다. 첫째, 정확히 무엇이 잘못되었는가(예: “T must be integral”). 둘째, 대신 무엇을 해야 하는가(예: “use int/long long” 또는 “write another overload”). 두 번째 부분이 없으면 static_assert는 “네가 잘못했네”로 끝나 버리고, 우리는 수정까지 도와주고 싶습니다.
Concept vs static_assert: 무엇이 무엇을 담당하나
학생들은 가끔 이렇게 헷갈리기 시작합니다. “음… concepts가 있으면 왜 static_assert도 필요하죠?” 아주 자연스러운 질문입니다. 두 도구 모두 제약과 관련되어 있기 때문입니다. 하지만 맡는 역할은 분명히 다릅니다.
| 도구 | 위치 | 가장 잘하는 일 | 오류가 보통 어떻게 보이는가 |
|---|---|---|---|
|
시그니처 안 | 본문 분석 전에 부적절한 타입을 걸러냄; API를 읽을 때 계약을 눈에 보이게 만듦 | “constraint …를 만족하지 않음” (이미 꽤 괜찮은 경우가 많음) |
|
템플릿 내부(대개 시작 부분) | 여러분의 메시지를 제공함; 요구 사항을 구체화함; 연쇄 오류 대신 “하나의 이해하기 쉬운 이유”를 만듦 | 여러분이 작성한 텍스트 |
학습용 코드에서는 두 가지를 함께 갖추는 경우가 많습니다. constraints는 API를 정직하게 만들고, static_assert는 진단을 친절하게 만듭니다. 하지만 실제 프로덕션에서는 이미 훌륭한 constraints를 중복하지 않도록 static_assert를 보통 절제해서 사용합니다.
9. 템플릿에서 static_assert를 사용할 때의 전형적인 실수
실수 №1: 템플릿 내부에 static_assert(false, "...")를 넣고 “왜 아무것도 컴파일되지 않지?”라고 놀라기.
이렇게 되는 이유는 false가 템플릿 매개변수에 의존하지 않아서, 컴파일러가 템플릿을 한 번도 호출하지 않았더라도 즉시 “빌드를 넘어뜨릴” 권리가 있기 때문입니다. 올바른 방법은 조건을 T에 의존하게 만드는 것입니다. 예를 들어 always_false_v<T>를 사용하거나, std::integral<T> 같은 실제 검사식을 쓰는 식입니다.
실수 №2: "Type error"나 "Bad type" 같은 메시지.
형식적으로는 “사람이 읽을 수 있는” 메시지이지만(단어가 있으니까요), 실제로는 거의 쓸모가 없습니다. 무엇이 기대되었고 어떻게 고쳐야 하는지를 설명하지 않기 때문입니다. 훨씬 더 좋은 형식은 “함수 이름: 요구 사항 + 힌트”입니다. 예를 들어 "parse_id<T>: T must be integral (use int/long long)" 같은 식입니다.
실수 №3: static_assert가 코드 너무 깊은 곳에 있어서, 사용자가 먼저 “장문의 오류”를 보고 나서야 여러분의 메시지를 보게 되는 경우.
복잡한 템플릿 로직 몇 줄 뒤에 검사를 두면, 컴파일러가 그 전에 이미 2차 오류를 한가득 “파낼” 수 있습니다. 그러면 static_assert는 더 이상 UX를 구해 주지 못합니다. 상황을 개선하는 가장 쉬운 방법은 함수나 클래스의 시작 부분에, 입력 계약을 검사하듯 static_assert를 두는 것입니다.
실수 №4: “혹시 몰라서” 지나치게 엄격한 요구 사항을 거는 것.
가끔은 T에게 모든 것을 한꺼번에 요구하고 싶어집니다. 더하기, 곱하기, size(), 복사 가능성, cout으로의 출력까지 말입니다. 이렇게 하면 템플릿의 적용 가능성이 급격히 줄어들고, 나중에는 “분명 맞는 타입 같은데” 필터를 통과하지 못해 스스로 곤란해집니다. 좋은 static_assert 문구(그리고 constraints도 마찬가지)에서는 함수 본문이나 API 계약에 정말 필요한 것만 요구해야 합니다.
실수 №5: 기대의 혼동 — static_assert를 런타임 검증처럼 사용하려는 시도.
static_assert는 컴파일 단계에서만 동작합니다. 사용자 입력을 검사해 주지 않고, “문자열이 비어 있다”를 잡아내지 않으며, from_chars가 오류를 반환했다고 해서 구해 주지도 않습니다. 이것은 잘못된 타입과 잘못된 사용 계약으로부터 여러분을 보호합니다. 입력과 데이터 검증에는 스트림 상태 검사, optional/expected, 오류 코드, 그리고 обычные if가 있습니다 — 하지만 그것은 이미 다른 도구들의 영역입니다.
GO TO FULL VERSION