C++ static_assert: 타입 크기·템플릿 제약·구조체 레이아웃을 컴파일 시점에 검증하기

이 글의 핵심

런타임 assert는 릴리스 빌드에서 꺼지지만 static_assert는 조건이 틀리면 빌드 자체를 멈춥니다. 이 글은 어떤 불변식을 컴파일 시점으로 옮길 수 있는지, 템플릿 안의 static_assert가 인스턴스화될 때만 평가되는 점, C++17의 메시지 생략 문법과 type_traits 조합을 라이브러리 경계 예제로 보여줍니다.

static_assert란?

static_assert는 C++11에서 도입된 선언으로, 컴파일 시점에 조건을 검사합니다. 조건이 거짓이면 컴파일 에러가 나서 프로그램이 만들어지지 않고, 참이면 아무 코드도 생성하지 않습니다.

// C++11: 메시지 필수
static_assert(sizeof(void*) == 8, "64비트 플랫폼 필요");

// C++17: 메시지 생략 가능
static_assert(sizeof(int) >= 4);

// constexpr 함수 결과도 조건으로 쓸 수 있음
constexpr int square(int x) { return x * x; }
static_assert(square(3) == 9, "square 함수 오류");

template<typename T>
void func(T value) {
    static_assert(std::is_integral<T>::value, "정수 타입만 가능");  // <type_traits>
}

조건은 상수 표현식이어야 합니다. 함수 매개변수 같은 런타임 값은 쓸 수 없습니다.

void func(int x) {
    // static_assert(x > 0, "양수");  // 에러: x는 상수 표현식이 아님
}

static_assert는 네임스페이스 범위, 클래스 본문, 함수 본문 어디에나 둘 수 있습니다. 조건이 참이면 실행 파일에 아무것도 남지 않으므로 런타임 비용이 없고, 런타임 assert와 달리 NDEBUG와 무관하게 항상 검사됩니다. 그래서 “디버그 빌드에서만 확인하는 불변식”이라기보다 “빌드 자체의 불변식”에 가깝습니다. 잘못된 템플릿 인자, 플랫폼 가정, 구조체 레이아웃 가정이 있으면 배포 전에, 링크하기도 전에 빌드가 멈춥니다. 멤버 순서나 #pragma pack, 정렬을 바꾸는 리팩토링에서 sizeof/offsetof 검증이 깨지면 바로 알 수 있다는 점에서 코드로 강제하는 문서 역할도 합니다.

assert와 static_assert: 언제 무엇을 쓰나

특징assertstatic_assert
검사 시점런타임컴파일 타임
조건런타임 값 가능상수 표현식만 가능
릴리스 빌드NDEBUG 정의 시 제거항상 검사
실행 비용검사 비용 있음없음
상황권장
사용자 입력·파일 내용·네트워크 패킷 값assert 또는 명시적 에러 처리
템플릿 인자·constexpr 상수·구조체 크기·정렬static_assert
”이 코드 경로는 절대 오면 안 된다” (내부 불변식)assert (또는 std::unreachable 계열)
“이 타입 조합은 지원하지 않는다”static_assert + type_traits

기준은 단순합니다. 조건이 컴파일 시점에 완전히 알려지면 static_assert, 실행 중에만 알려지면 assert나 일반 에러 처리 경로입니다.

실전 예시

예시 1: 타입 크기 검증

#include <cstdint>

struct Packet {
    uint32_t header;
    uint32_t data[10];
    uint32_t checksum;
};

static_assert(sizeof(Packet) == 48,
              "Packet 크기는 48바이트여야 함");

이 검사가 실제로 잡아 주는 것은 “누군가 필드를 추가하거나 타입을 바꿨다”는 사실입니다. 예를 들어 header를 uint16_t로 줄이면 사람은 크기가 2바이트 줄었다고 생각하기 쉽지만, 컴파일러는 뒤따르는 uint32_t 배열의 정렬을 맞추려고 2바이트 패딩을 넣어 sizeof가 그대로 48이 됩니다. 반대로 uint64_t 필드를 끼워 넣으면 8바이트 정렬 때문에 예상보다 더 커집니다. 파일이나 네트워크로 구조체를 그대로 쓰는 코드라면 sizeof와 함께 아래 패턴 3처럼 offsetof로 각 필드 위치까지 고정해 두어야 “크기는 맞는데 필드 위치가 바뀐” 경우를 잡을 수 있습니다.

예시 2: 템플릿 제약

#include <type_traits>

template<typename T>
class NumericArray {
    static_assert(std::is_arithmetic<T>::value,
                  "숫자 타입만 가능");
    T data[100];
};

NumericArray<int> arr1;     // OK
NumericArray<double> arr2;  // OK
// NumericArray<std::string> arr3;  // 컴파일 에러

클래스 템플릿 본문의 static_assert는 클래스가 암시적으로 인스턴스화될 때(객체를 만들거나 크기가 필요할 때) 평가됩니다. NumericArray<std::string>* p;처럼 포인터만 선언하면 완전한 타입이 필요 없어 인스턴스화되지 않으므로 에러가 나지 않습니다. 함수 템플릿이라면 그 함수를 호출해서 본문이 인스턴스화될 때 검사됩니다. 그래서 static_assert는 “잘못 쓰면 반드시 빌드가 깨진다”는 보장이지, “선언만 해도 막는다”는 보장은 아닙니다.

예시 3: 플랫폼 검증

// 64비트 플랫폼 확인
static_assert(sizeof(void*) == 8, "64비트 플랫폼 필요");

// 엔디안 확인 (GCC/Clang 전용 매크로)
static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__,
              "리틀 엔디안 플랫폼 필요");

// C++ 버전 확인
static_assert(__cplusplus >= 201703L, "C++17 이상 필요");

이 예제의 두 줄은 이식성 함정이 있습니다. __BYTE_ORDER__는 GCC/Clang이 정의하는 매크로라 MSVC에서는 정의되지 않으므로, 위 static_assert는 MSVC에서 “선언되지 않은 식별자” 컴파일 에러가 납니다. 이를 피하려고 #if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__처럼 전처리기로 옮기면 더 조용한 함정이 생깁니다. 전처리기는 정의되지 않은 식별자를 0으로 바꾸므로 MSVC에서는 0 == 0이 되어 검사가 아무 의미 없이 통과합니다. 아래 패턴 2처럼 #ifdef __BYTE_ORDER__로 감싸거나, C++20이라면 컴파일러와 무관한 표준 방법인 static_assert(std::endian::native == std::endian::little);(<bit>)를 씁니다.

__cplusplus도 함정이 있습니다. MSVC는 기본적으로 199711L을 보고하므로 /Zc:__cplusplus 옵션을 켜지 않으면 C++20으로 빌드해도 이 검사가 실패합니다. 다른 OS에서 잘 빌드되던 헤더가 Windows에서만 “C++17 이상 필요”로 깨진다면 대개 이 원인이며, MSVC에서는 _MSVC_LANG을 함께 확인하는 것이 일반적입니다.

예시 4: 상수 검증

constexpr int MAX_SIZE = 100;
constexpr int BUFFER_SIZE = 50;

static_assert(BUFFER_SIZE < MAX_SIZE,
              "버퍼 크기는 최대 크기보다 작아야 함");

template<int N>
class FixedArray {
    static_assert(N > 0, "크기는 양수여야 함");
    static_assert(N <= 1000, "크기는 1000 이하여야 함");
    int data[N];
};

자주 발생하는 문제

문제 1: 런타임 조건

// 런타임 값 사용 불가
void func(int x) {
    static_assert(x > 0, "양수");  // 에러
}

// 템플릿 비타입 인자로 받기
template<int N>
void func() {
    static_assert(N > 0, "양수");  // OK
}

x가 constexpr 함수의 매개변수여도 마찬가지입니다. constexpr int f(int x) { static_assert(x > 0); ... }는 에러인데, constexpr 함수는 런타임에도 호출될 수 있어 매개변수가 상수 표현식으로 취급되지 않기 때문입니다. 컴파일 시점 값이 필요하면 위처럼 템플릿 비타입 인자로 받거나, C++20의 consteval 함수 안에서 조건이 틀리면 throw하는 식으로 “컴파일 시점 평가에서 상수 표현식이 아니게 만드는” 방법을 씁니다.

문제 2: 에러 메시지

// 불명확한 메시지
static_assert(sizeof(T) == 4, "에러");

// 명확한 메시지
static_assert(sizeof(T) == 4,
              "T는 4바이트 타입이어야 합니다");

문제 3: 템플릿 인스턴스화와 trait의 한계

template<typename T>
class MyClass {
    // 인스턴스화 시점에 검사
    static_assert(std::is_copy_constructible<T>::value,
                  "T는 복사 가능해야 함");
};

MyClass<int> obj1;                          // OK
// MyClass<std::unique_ptr<int>> obj2;     // 에러

std::unique_ptr는 복사 생성자가 = delete이므로 is_copy_constructible이 false가 되어 위 검사에 걸립니다. 다만 std::vector<std::unique_ptr<int>>처럼 복사 생성자가 선언은 되어 있지만 인스턴스화하면 실패하는 타입은 is_copy_constructible_v가 true를 돌려준다는 유명한 함정이 있습니다. trait는 선언만 보고 판단하기 때문에, 이런 경우에는 검사를 통과한 뒤 실제 복사 코드에서 긴 에러가 납니다.

문제 4: 조건 표현식

// 복잡한 조건
static_assert(A && B || C && D, "조건 실패");

// 이름 있는 조건
constexpr bool isValid = (A && B) || (C && D);
static_assert(isValid, "조건 실패");

조건을 이름 있는 constexpr bool로 빼면 코드를 읽는 사람이 조건의 의미를 이름으로 알 수 있습니다. &&와 ||를 괄호 없이 섞으면 GCC/Clang의 -Wparentheses가 경고하므로 괄호를 명시하는 편이 좋습니다. 여러 조건을 하나로 묶기보다 static_assert를 여러 개로 쪼개면 어느 조건이 실패했는지 에러 위치와 메시지로 바로 드러납니다.

문제 5: 템플릿 안의 static_assert(false)

“이 분기로 오면 안 된다”를 표현하려고 템플릿 안에 static_assert(false, ...)를 쓰면, 그 템플릿을 한 번도 쓰지 않아도 컴파일이 실패합니다(C++20까지). 조건이 템플릿 인자에 의존하지 않으면 컴파일러가 템플릿 정의 시점에 바로 평가할 수 있기 때문입니다.

template<typename T>
void serialize(const T& v) {
    if constexpr (std::is_integral_v<T>) {
        /* 정수 직렬화 */
    } else if constexpr (std::is_floating_point_v<T>) {
        /* 실수 직렬화 */
    } else {
        // C++20까지: 호출하지 않아도 에러
        // static_assert(false, "지원하지 않는 타입");

        // 조건을 T에 의존하게 만드는 관용구
        static_assert(sizeof(T) == 0, "지원하지 않는 타입");
    }
}

sizeof(T) == 0이나 template<class> inline constexpr bool always_false = false; 같은 관용구는 조건을 T에 의존하게 만들어 인스턴스화될 때까지 평가를 미룹니다. C++23(P2593, 여러 컴파일러가 이전 표준 모드에도 결함 보고로 소급 적용)부터는 인스턴스화되지 않은 템플릿 안의 static_assert(false)를 허용하므로, 최신 GCC/Clang/MSVC에서는 그냥 써도 됩니다. 구버전 컴파일러를 지원해야 하는 라이브러리에서는 여전히 관용구를 쓰는 편이 안전합니다.

라이브러리 경계에서의 static_assert

라이브러리 코드에서 static_assert는 템플릿 비타입 인자의 허용 범위를 봉인하거나(N > 0 && N <= Max), 직렬화 구조체에 필드가 추가되어 sizeof가 바뀌면 프로토콜이 깨졌음을 알리거나, SIMD 버퍼의 크기·정렬 요구를 강제하는 데 주로 쓰입니다.

template<std::size_t N>
class FixedBuffer {
    static_assert(N > 0, "크기는 양수");
    static_assert(N % 16 == 0, "SIMD 처리를 위해 16바이트 배수여야 함");
    alignas(16) unsigned char storage_[N];
};

C++17: 메시지 생략 문법

C++17부터 두 번째 인자(문자열 메시지)를 생략할 수 있습니다. C++11·14에서는 메시지가 필수입니다.

static_assert(sizeof(int) == 4);              // OK (C++17)
static_assert(std::is_integral_v<int>);       // trait의 _v 변수 템플릿도 C++17

메시지가 없으면 컴파일러가 출력하는 진단(대개 실패한 조건식)만으로 원인을 추적해야 합니다. 짧은 산술·크기 검증에는 생략해도 충분하지만, 팀이 읽어야 할 계약에는 의도를 설명하는 메시지를 붙이는 편이 리뷰와 유지보수에 유리합니다.

// 간단한 크기 검증: 메시지 생략
static_assert(alignof(double) == 8);

// 팀이 읽어야 할 계약: 메시지 유지
static_assert(std::is_trivially_copyable<Packet>::value,
              "Packet은 memcpy로 바이트 스트림에 그대로 쓸 수 있어야 합니다");

“바이트로 그대로 쓸 수 있다”를 보장하는 trait는 is_trivially_copyable입니다. memcpy나 fwrite로 객체 표현을 복사해도 되는지가 정확히 이 속성이고, is_trivially_destructible은 소멸자가 아무 일도 하지 않는다는 뜻일 뿐이라 복사 안전성을 보장하지 않습니다. 참고로 C++26에서는 메시지 자리에 문자열 리터럴뿐 아니라 컴파일 시점에 만든 문자열 객체(data()와 size()를 constexpr로 제공하는 타입)를 넣을 수 있게 되어(P2741), 타입 이름이나 크기를 메시지에 포함할 수 있습니다.

Type Traits 활용

<type_traits>의 trait와 조합하면 “이 템플릿은 이런 타입만 받는다”를 선언적으로 적을 수 있습니다. C++17 이후에는 _v 접미사로 값을 바로 쓸 수 있습니다.

template<typename T>
void process(T value) {
    static_assert(std::is_integral<T>::value,
                  "정수 타입 필요");
    static_assert(std::is_trivially_copyable<T>::value,
                  "trivially copyable 필요");
}

자주 함께 쓰는 trait로는 정확히 같은 타입인지 보는 std::is_same_v<T, U>, 상속 관계를 확인하는 std::is_base_of_v<Base, Derived>, 암시적 변환 가능 여부를 보는 std::is_convertible_v<From, To>, 정렬 요구를 확인하는 alignof(T)(또는 std::alignment_of_v<T>)가 있습니다.

#include <type_traits>

template<typename T, typename U>
void assertSame() {
    static_assert(std::is_same<T, U>::value, "T와 U가 일치해야 합니다");
}

template<typename Derived, typename Base>
void assertInheritance() {
    static_assert(std::is_base_of<Base, Derived>::value,
                  "Derived는 Base를 상속해야 합니다");
}

C++20 concept가 있으면 가독성은 더 좋아지지만, 구형 컴파일러를 지원하는 코드나 라이브러리 내부 가드에는 여전히 static_assert와 type_traits 조합이 널리 쓰입니다.

두 방식은 가독성뿐 아니라 실패 방식이 다릅니다. static_assert는 함수 본문 안에서 터지는 하드 에러라서, 오버로드 후보가 여러 개일 때 “이 후보는 빼고 다른 후보를 고르라”는 신호가 되지 못합니다. 즉 SFINAE 친화적이지 않습니다. template<std::integral T> void process(T)처럼 concept(또는 std::enable_if)으로 제약하면 조건을 만족하지 않는 오버로드는 후보에서 조용히 빠지고, 에러가 나더라도 “이 제약을 만족하지 않는다”는 짧은 진단이 호출 지점에 나옵니다. 그래서 다른 오버로드가 처리할 수도 있는 타입이면 concept, 여기 들어오면 무조건 사용자 실수라서 분명한 메시지를 주고 싶으면 static_assert를 쓰는 식으로 나누는 것이 실용적입니다.

참고로 값으로 받는 매개변수에 static_assert(!std::is_const<T>::value)를 거는 것은 큰 의미가 없습니다. 템플릿 인자 추론이 최상위 const를 제거하므로, process<const int>(1)처럼 명시적으로 지정하지 않는 한 항상 참입니다.

실무 패턴

패턴 1: 클래스 템플릿의 타입 제약 (Concepts 대안)

template<typename T>
class SafeNumericArray {
    static_assert(std::is_arithmetic<T>::value,
                  "T는 숫자 타입이어야 합니다");
    static_assert(!std::is_const<T>::value,
                  "T는 const가 아니어야 합니다");   // 명시적 인자라 의미 있음
    static_assert(sizeof(T) <= 8,
                  "T는 8바이트 이하여야 합니다");

    T data_[100];

public:
    void set(std::size_t index, T value) {
        data_[index] = value;
    }
};

SafeNumericArray<int> arr1;     // OK
SafeNumericArray<double> arr2;  // OK
// SafeNumericArray<std::string> arr3;  // 에러: 숫자 타입 아님

클래스 템플릿은 인자를 직접 적어 주므로 const 검사도 실제로 의미가 있습니다. const 원소 배열은 set()을 컴파일할 수 없으므로 이를 선언 시점에 분명한 메시지로 막는 셈입니다.

패턴 2: 플랫폼 요구사항

// platform_check.h
#pragma once

static_assert(sizeof(void*) == 8,
              "이 프로그램은 64비트 플랫폼에서만 동작합니다");

// MSVC는 /Zc:__cplusplus 없이는 199711L을 보고하므로 _MSVC_LANG도 확인
#if defined(_MSVC_LANG)
static_assert(_MSVC_LANG >= 201703L, "C++17 이상이 필요합니다");
#else
static_assert(__cplusplus >= 201703L, "C++17 이상이 필요합니다");
#endif

// 엔디안 확인 (GCC/Clang에서만 매크로가 있음)
#ifdef __BYTE_ORDER__
static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__,
              "리틀 엔디안 플랫폼이 필요합니다");
#endif

static_assert(sizeof(int) == 4, "int는 4바이트여야 합니다");
static_assert(sizeof(long long) == 8, "long long은 8바이트여야 합니다");

패턴 3: 구조체 레이아웃 검증

#include <cstddef>   // offsetof
#include <cstdint>

#pragma pack(push, 1)
struct NetworkPacket {
    uint8_t version;
    uint8_t type;
    uint16_t length;
    uint32_t timestamp;
    uint8_t data[256];
};
#pragma pack(pop)

// 1 + 1 + 2 + 4 + 256 = 264
static_assert(sizeof(NetworkPacket) == 264,
              "NetworkPacket 크기가 예상과 다릅니다");

static_assert(offsetof(NetworkPacket, version) == 0,
              "version 오프셋이 잘못되었습니다");
static_assert(offsetof(NetworkPacket, timestamp) == 4,
              "timestamp 오프셋이 잘못되었습니다");

#pragma pack(1)은 크기를 맞춰 주지만 대가가 있습니다. 패딩이 없어지면 timestamp 같은 필드가 자연 정렬되지 않은 주소에 놓일 수 있고(이 예제는 우연히 4바이트 경계에 맞음), ARM 일부 환경에서는 정렬되지 않은 접근이 느리거나 예외를 일으킵니다. 또 packed 구조체의 멤버에 대한 포인터나 참조를 만들면 GCC가 -Waddress-of-packed-member 경고를 냅니다. 그래서 프로토콜 파싱은 packed 구조체에 직접 캐스팅하기보다 바이트 버퍼에서 필드별로 memcpy해 꺼내는 방식이 더 이식성이 좋고, static_assert는 어느 쪽을 택하든 레이아웃 가정을 문서화하는 안전장치로 남겨 둡니다. 여러 바이트 필드는 네트워크 바이트 순서(빅 엔디안) 변환도 별도로 필요합니다.

참고 자료

  • cppreference: static_assert
  • Scott Meyers, “Effective Modern C++”
  • David Vandevoorde 외, “C++ Templates: The Complete Guide” (2nd Edition)

같이 보면 좋은 글