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

이 글의 핵심

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

static_assert란?

static_assert 는 C++11에서 도입된 키워드로, 컴파일 타임에 조건을 검사합니다. 조건이 거짓이면 컴파일 에러를 발생시켜, 런타임 전에 문제를 발견할 수 있습니다.

static_assert(sizeof(int) == 4, "int는 4바이트여야 함");

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

왜 필요한가?:

  • 조기 오류 발견: 컴파일 타임에 문제 발견
  • 타입 안전: 템플릿 제약 조건 검증
  • 플랫폼 검증: 특정 플랫폼 요구사항 확인
  • 성능: 런타임 오버헤드 없음
// ❌ 런타임 assert: 실행 시 오류 발견
void func(int x) {
    assert(x > 0);  // 런타임에 검사
}

// ✅ static_assert: 컴파일 타임에 오류 발견
template<int N>
void func() {
    static_assert(N > 0, "N은 양수여야 함");  // 컴파일 타임에 검사
}

static_assert의 문법:

// C++11: 메시지 필수
static_assert(condition, "error message");

// C++17: 메시지 선택적
static_assert(condition);

// 예시
static_assert(sizeof(void*) == 8, "64비트 플랫폼 필요");
static_assert(sizeof(int) >= 4);  // C++17

static_assert의 동작 원리:

static_assert는 컴파일러가 코드를 컴파일할 때 조건을 평가합니다. 조건이 false이면 컴파일 에러를 발생시키고, true이면 아무 코드도 생성하지 않습니다.

// 컴파일 타임에 평가
static_assert(2 + 2 == 4, "수학 오류");  // OK

// constexpr 함수도 사용 가능
constexpr int square(int x) { return x * x; }
static_assert(square(3) == 9, "square 함수 오류");  // OK

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

assert vs static_assert 비교:

특징assertstatic_assert
검사 시점런타임컴파일 타임
성능 영향있음 (검사 비용)없음
조건런타임 값 가능constexpr만 가능
릴리스 빌드제거됨 (NDEBUG)항상 검사
사용 목적런타임 검증타입/플랫폼 검증
// assert: 런타임 검사
#include <cassert>
void func(int x) {
    assert(x > 0);  // 런타임에 검사, 릴리스에서 제거됨
}

// static_assert: 컴파일 타임 검사
template<int N>
void func() {
    static_assert(N > 0, "N은 양수");  // 컴파일 타임에 검사, 항상 유효
}

컴파일 타임 검증이 주는 이점

  • 실패를 “배포 전”에 고정: 잘못된 템플릿 인자·플랫폼·레이아웃 가정이 있으면 링크하기 전에 터집니다.
  • 비용 제로: 조건이 참이면 실행 코드가 생기지 않습니다. 바이너리 크기·분기 예측 부담이 없습니다.
  • 문서화: “이 타입은 반드시 복사 가능해야 한다” 같은 규칙을 코드로 강제합니다.
  • 리팩토링 안전망: 멤버 순서·#pragma pack·정렬을 바꿨을 때 sizeof/offsetof 검증이 깨지면 즉시 알 수 있습니다.

런타임 assert와 달리 NDEBUG와 무관하게 항상 활성입니다. “디버그에서만 터지는 불변식”이 아니라 빌드 불변식에 가깝습니다.

assert vs static_assert — 언제 무엇을 쓰나

상황권장
사용자 입력·파일 내용·네트워크 패킷 값assert 또는 명시적 에러 처리
템플릿 인자·constexpr 상수·구조체 크기·정렬static_assert
“이 코드 경로는 절대 오면 안 된다” (내부 불변식)assert (또는 unreachable 계열)
“이 타입 조합은 지원하지 않는다”static_assert + type_traits

한 줄 기준: 조건이 컴파일 시점에 완전히 알려지면 static_assert, 실행 중에만 알려지면 assert나 일반 에러 경로입니다.

기본 사용

// C++11
static_assert(condition, "에러 메시지");

// C++17 (메시지 선택적)
static_assert(condition);

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

실전 예시

예시 1: 타입 크기 검증

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비트 플랫폼 필요");

// 엔디안 확인
static_assert(__BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__,
              "리틀 엔디안 플랫폼 필요");

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

이 예제의 두 줄은 이식성 함정이 있습니다. __BYTE_ORDER__는 GCC/Clang이 정의하는 매크로라 MSVC에서는 정의되지 않고, 전처리기는 정의되지 않은 식별자를 0으로 바꾸므로 0 == 0이 되어 빅 엔디안 검사가 아무 의미 없이 통과합니다(아래 패턴 2처럼 #ifdef로 감싸야 합니다). C++20이라면 static_assert(std::endian::native == std::endian::little);(<bit>)가 컴파일러와 무관한 표준 방법입니다. __cplusplus도 MSVC는 기본적으로 199711L을 보고하므로, /Zc:__cplusplus 옵션을 켜지 않으면 C++20으로 빌드해도 이 검사가 실패합니다. 저도 Linux에서 잘 돌던 헤더가 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];
};

assert vs static_assert

// assert: 런타임 검사
#include <cassert>
void func(int x) {
    assert(x > 0);  // 런타임
}

// static_assert: 컴파일 타임 검사
template<int N>
void func() {
    static_assert(N > 0, "N은 양수");  // 컴파일 타임
}

자주 발생하는 문제

문제 1: 런타임 조건

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

// ✅ constexpr 사용
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: 템플릿 인스턴스화

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

// MyClass<std::unique_ptr<int>> obj;  // 에러

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로 빼면 에러 메시지에 “isValid가 false”라고만 나오는 대신, 코드를 읽는 사람이 조건의 의미를 이름으로 알 수 있습니다. &&와 ||를 괄호 없이 섞으면 GCC/Clang의 -Wparentheses가 경고하므로 (A && B) || (C && D)처럼 괄호를 명시하는 편이 좋습니다. 조건을 여러 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 % 4 == 0, "4바이트 정렬 워크로드");
    alignas(16) unsigned char storage_[N];
};

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

C++17부터 두 번째 인자(문자열 메시지)를 생략할 수 있습니다.

static_assert(sizeof(int) == 4);                    // OK (C++17)
static_assert(std::is_integral<int>::value);        // trait + 메시지 생략 (C++17)
// C++17: std::is_integral_v<int> 도 동일

장점: 짧은 가드에 적합합니다.

주의: 메시지가 없으면 컴파일러가 출력하는 진단만으로 원인을 추적해야 합니다. 팀 단위로는 실패 시 의도를 한글/영문으로 남기는 첫 번째 인자 형태(C++11 스타일)를 유지하는 경우도 많습니다. 중요한 불변식에는 메시지를 붙이는 편이 리뷰와 유지보수에 유리합니다.

// 간단한 산술·크기 검증 — 메시지 생략
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에서는 메시지 자리에 std::format 결과처럼 컴파일 시점에 만든 문자열을 넣을 수 있게 되어(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_const<T>::value,
                  "const 타입 불가");
    static_assert(std::is_trivially_copyable<T>::value,
                  "trivially copyable 필요");
}

자주 쓰는 조합 예:

  • std::is_same<T, U>::value / std::is_same_v<T,U>: 정확히 같은 타입인지.
  • std::is_base_of<Base, Derived>::value: 상속 관계 검증.
  • std::is_convertible<From, To>::value: 암시적 변환 가능 여부.
  • std::alignment_of::value: 정렬 요구가 하드웨어/ABI와 맞는지와 함께 static_assert.
#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를 쓰는 식으로 나누는 것이 실용적입니다. 참고로 위 process(T value)의 !std::is_const<T> 검사는 값으로 받는 매개변수에서는 템플릿 인자 추론이 최상위 const를 제거하므로 사실상 항상 참입니다.

실무 패턴

패턴 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(size_t index, T value) {
        data_[index] = value;
    }
};

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

패턴 2: 플랫폼 요구사항

// 플랫폼 검증 헤더
// platform_check.h
#pragma once

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

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

// 엔디안 확인
#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: 구조체 레이아웃 검증

#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)

// 패킷 크기 검증 (네트워크 프로토콜)
static_assert(sizeof(NetworkPacket) == 264,
              "NetworkPacket 크기가 예상과 다릅니다");

// 멤버 오프셋 검증 (offsetof는 <cstddef>)
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는 어느 쪽을 택하든 레이아웃 가정을 문서화하는 안전장치로 남겨 둡니다. 여러 바이트 필드는 네트워크 바이트 순서(빅 엔디안) 변환도 별도로 필요합니다.

FAQ

Q1: static_assert는 언제 사용해야 하나요?

A:

  • 타입 검증: 템플릿 매개변수 제약
  • 크기 검증: 구조체 레이아웃, 패킷 크기
  • 플랫폼 검증: 64비트, 엔디안, C++ 버전
  • 템플릿 제약: 타입 특성 검사
// 타입 검증
template<typename T>
void process(T value) {
    static_assert(std::is_integral<T>::value, "정수 타입 필요");
}

// 크기 검증
struct Data {
    int x, y, z;
};
static_assert(sizeof(Data) == 12, "Data 크기는 12바이트");

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

Q2: assert와 어떤 차이가 있나요?

A:

  • static_assert: 컴파일 타임 검사, 성능 영향 없음, constexpr만 가능
  • assert: 런타임 검사, 성능 영향 있음, 모든 값 가능
// assert: 런타임 검사
void func(int x) {
    assert(x > 0);  // 실행 시 검사
}

// static_assert: 컴파일 타임 검사
template<int N>
void func() {
    static_assert(N > 0, "N은 양수");  // 컴파일 시 검사
}

선택 기준:

  • 컴파일 타임에 알 수 있는 조건: static_assert
  • 런타임에만 알 수 있는 조건: assert

Q3: 성능 영향은?

A: 없습니다. static_assert는 컴파일 타임에만 검사하며, 실행 파일에는 아무 코드도 생성하지 않습니다.

static_assert(sizeof(int) == 4, "int는 4바이트");
// 위 코드는 실행 파일에 아무 영향도 없음

Q4: C++17에서 어떻게 변경되었나요?

A: C++17부터 에러 메시지가 선택적입니다.

// C++11: 메시지 필수
static_assert(sizeof(int) == 4, "int는 4바이트");

// C++17: 메시지 선택적
static_assert(sizeof(int) == 4);

Q5: 템플릿에서는 언제 검사되나요?

A: 템플릿 인스턴스화 시점에 검사됩니다.

template<typename T>
class MyClass {
    static_assert(std::is_copy_constructible<T>::value,
                  "T는 복사 가능해야 함");
};

// 인스턴스화 시점에 검사
MyClass<int> obj1;  // OK
// MyClass<std::unique_ptr<int>> obj2;  // 에러: 복사 불가

Q6: 런타임 값을 검사할 수 있나요?

A: 불가능합니다. static_assert는 컴파일 타임 상수만 검사할 수 있습니다.

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

// ✅ 컴파일 타임 상수: 가능
template<int N>
void func() {
    static_assert(N > 0, "양수");  // OK
}

constexpr int value = 10;
static_assert(value > 0, "양수");  // OK

Q7: 여러 조건을 검사하려면?

A: 여러 static_assert를 사용하거나, 논리 연산자로 결합합니다.

template<typename T>
class MyClass {
    // 방법 1: 여러 static_assert
    static_assert(std::is_integral<T>::value, "정수 타입 필요");
    static_assert(sizeof(T) <= 8, "8바이트 이하 필요");
    
    // 방법 2: 논리 연산자
    static_assert(std::is_integral<T>::value && sizeof(T) <= 8,
                  "정수 타입이고 8바이트 이하여야 함");
};

Q8: static_assert 학습 리소스는?

A:

관련 글: Type Traits, Concepts, constexpr.

static_assert는 컴파일 타임에 조건을 검사하여 타입 안전성과 플랫폼 요구사항을 보장합니다.


같이 보면 좋은 글