C++ numeric_limits: 타입 최댓값·최솟값·NaN 조회와 오버플로우 검사

이 글의 핵심

INT_MAX 같은 매크로 대신 numeric_limits를 쓰면 템플릿 안에서도 타입에 맞는 한계값을 얻을 수 있지만, 이름만 보고 쓰다가 틀리는 지점이 있습니다. 실수에서 min()은 가장 작은 양수이고 가장 작은 값은 lowest()라는 점, 부호 없는 타입에 음수 검사를 넣으면 항상 참이 되는 문제, 오버플로우를 연산 전에 확인해야 하는 이유를 예제로 짚고 센티널 값과 범위 검증 패턴을 정리했습니다.

numeric_limits란?

std::numeric_limits 는 C++ 표준 라이브러리에서 제공하는 타입의 한계값과 속성을 조회하는 템플릿 클래스입니다. 각 타입의 최대/최소값, 정밀도, 특수 값 등을 컴파일 타임에 확인할 수 있습니다.

#include <limits>
#include <iostream>
// 필요한 모듈 import
using namespace std;

int main() {
    cout << "int 최소: " << numeric_limits<int>::min() << endl;
    cout << "int 최대: " << numeric_limits<int>::max() << endl;
    
    cout << "double 최소: " << numeric_limits<double>::min() << endl;
    cout << "double 최대: " << numeric_limits<double>::max() << endl;
}

왜 필요한가?:

  • 플랫폼 독립성: 플랫폼에 따라 달라지는 타입 한계를 일관되게 조회
  • 타입 안전: 매크로(INT_MAX) 대신 타입 안전한 방법
  • 컴파일 타임: 런타임 오버헤드 없음
  • 표준화: 모든 표준 타입 지원
// ❌ 매크로: 타입 불안전, 헤더 의존
#include <climits>
int max = INT_MAX;

// ✅ numeric_limits: 타입 안전, 표준화
int max = std::numeric_limits<int>::max();

주요 멤버 함수:

멤버 함수설명예시
min()최소값 (정수) 또는 최소 양수 (실수)numeric_limits<int>::min()
max()최대값numeric_limits<int>::max()
lowest()최소값 (실수 포함)numeric_limits<double>::lowest()
infinity()무한대 (실수만)numeric_limits<double>::infinity()
quiet_NaN()NaN (실수만)numeric_limits<double>::quiet_NaN()

주요 멤버 상수:

멤버 상수설명예시
is_signed부호 있는 타입인지numeric_limits<int>::is_signed
is_integer정수 타입인지numeric_limits<int>::is_integer
is_exact정확한 표현인지numeric_limits<int>::is_exact
has_infinity무한대 지원 여부numeric_limits<double>::has_infinity
digits1010진수 정밀도numeric_limits<double>::digits10

표에 없는 함정 하나가 특수화되지 않은 타입입니다. numeric_limits는 모든 타입에 대해 기본 템플릿이 존재해서, numeric_limits<std::string>::max()나 직접 만든 구조체에 대해 호출해도 컴파일 에러가 나지 않고 값 초기화된 T()(숫자라면 0)를 돌려줍니다. 템플릿 코드에서 이런 타입이 들어오면 “최댓값이 0”인 채로 조용히 동작하므로, 범용 코드에서는 static_assert(std::numeric_limits<T>::is_specialized)로 막아 두는 것이 안전합니다.

정수 타입

// char
cout << "char 최소: " << (int)numeric_limits<char>::min() << endl;
cout << "char 최대: " << (int)numeric_limits<char>::max() << endl;

// short
cout << "short 최소: " << numeric_limits<short>::min() << endl;
cout << "short 최대: " << numeric_limits<short>::max() << endl;

// int
cout << "int 최소: " << numeric_limits<int>::min() << endl;
cout << "int 최대: " << numeric_limits<int>::max() << endl;

// long long
cout << "long long 최소: " << numeric_limits<long long>::min() << endl;
cout << "long long 최대: " << numeric_limits<long long>::max() << endl;

실수 타입

// float
// 실행 예제
cout << "float 최소: " << numeric_limits<float>::min() << endl;
cout << "float 최대: " << numeric_limits<float>::max() << endl;
cout << "float 정밀도: " << numeric_limits<float>::digits10 << endl;

// double
cout << "double 최소: " << numeric_limits<double>::min() << endl;
cout << "double 최대: " << numeric_limits<double>::max() << endl;
cout << "double 정밀도: " << numeric_limits<double>::digits10 << endl;

실수 타입의 digits10은 “10진수로 이 자릿수까지는 정확히 왕복된다”는 뜻이라 double은 15입니다. 그런데 double 값을 문자열로 저장했다가 정확히 같은 값으로 다시 읽으려면 max_digits10(double은 17) 자리가 필요합니다. std::cout << std::setprecision(15)로 로그를 남긴 뒤 그 값을 다시 파싱해 비교하면 마지막 비트가 달라 “같은 값인데 ==가 거짓”이 되는 이유가 이것입니다. 직렬화에는 max_digits10을 쓰거나, C++17의 std::to_chars처럼 최단 왕복 표현을 만들어 주는 함수를 쓰는 편이 정확합니다.

epsilon()도 이름 때문에 오해가 많습니다. 이 값은 “1.0과 그다음으로 표현 가능한 double의 차이”라서 1 근처에서만 의미가 있습니다. std::abs(a - b) < epsilon()로 두 값을 비교하면 1000 근처의 값에서는 사실상 ==와 같은 검사가 되고, 0.001 근처에서는 너무 느슨한 검사가 됩니다. 비교할 값의 크기에 비례하는 상대 오차(std::abs(a - b) <= eps * std::max(std::abs(a), std::abs(b)))와 0 근처를 위한 절대 오차를 함께 쓰는 것이 일반적입니다.

특수 값

// 무한대
double inf = numeric_limits<double>::infinity();
cout << inf << endl;  // inf

// NaN
double nan = numeric_limits<double>::quiet_NaN();
cout << nan << endl;  // nan

// 체크
cout << isinf(inf) << endl;  // 1
cout << isnan(nan) << endl;  // 1

실전 예시

예시 1: 안전한 덧셈

bool safeAdd(int a, int b, int& result) {
    if (a > 0 && b > numeric_limits<int>::max() - a) {
        return false;  // 오버플로우
    }
    
    if (a < 0 && b < numeric_limits<int>::min() - a) {
        return false;  // 언더플로우
    }
    
    result = a + b;
    return true;
}

int main() {
    int result;
    
    if (safeAdd(INT_MAX, 1, result)) {
        cout << result << endl;
    } else {
        cout << "오버플로우" << endl;
    }
}

검사식이 a + b > max()가 아니라 b > max() - a인 이유는 앞의 식이 검사하려는 오버플로우를 스스로 일으키기 때문입니다. a가 양수일 때 max() - a는 절대 넘치지 않으므로 이 형태로 바꿔 쓰면 안전하게 비교할 수 있고, 음수 쪽도 같은 원리로 min() - a와 비교합니다. a가 0이면 두 조건 모두 건너뛰어 그대로 더하는데, 이 경우에는 넘칠 수 없으니 문제가 없습니다. 예제의 INT_MAX는 <climits>의 매크로이므로 이 코드를 그대로 컴파일하려면 해당 헤더가 필요하고, 같은 값을 numeric_limits<int>::max()로 쓰는 편이 이 글의 취지에 맞습니다.

예시 2: 범위 체크

template<typename T>
bool inRange(double value) {
    return value >= numeric_limits<T>::min() &&
           value <= numeric_limits<T>::max();
}

int main() {
    double x = 300.0;
    
    cout << "char 범위: " << inRange<char>(x) << endl;    // 0
    cout << "short 범위: " << inRange<short>(x) << endl;  // 1
    cout << "int 범위: " << inRange<int>(x) << endl;      // 1
}

이 함수는 정수 타입 T에서만 올바르게 동작합니다. inRange<double>(-1.0)처럼 실수 타입을 넣으면 min()이 가장 작은 양수라서 모든 음수가 범위 밖으로 판정되므로, 실수까지 다루려면 min() 대신 lowest()를 써야 합니다. 또 char는 부호 여부가 플랫폼마다 다르다는 점도 알아 두어야 합니다. x86의 GCC·MSVC에서는 char가 부호 있는 타입(-128127)이지만 ARM Linux에서는 부호 없는 타입(0255)이라, 같은 코드가 플랫폼에 따라 다른 결과를 냅니다. 바이트 값을 다룰 때는 signed char나 unsigned char, std::uint8_t처럼 부호를 명시하는 타입을 쓰는 편이 안전합니다.

예시 3: 초기값 설정

template<typename T>
T findMin(const vector<T>& v) {
    if (v.empty()) {
        return numeric_limits<T>::max();
    }
    
    T minVal = numeric_limits<T>::max();
    
    for (T x : v) {
        if (x < minVal) {
            minVal = x;
        }
    }
    
    return minVal;
}

int main() {
    vector<int> v = {5, 2, 8, 1, 9};
    cout << "최소: " << findMin(v) << endl;  // 1
}

예시 4: 타입 정보 출력

template<typename T>
void printTypeInfo() {
    cout << "타입: " << typeid(T).name() << endl;
    cout << "최소: " << numeric_limits<T>::min() << endl;
    cout << "최대: " << numeric_limits<T>::max() << endl;
    cout << "부호: " << (numeric_limits<T>::is_signed ? "있음" : "없음") << endl;
    cout << "정수: " << (numeric_limits<T>::is_integer ? "예" : "아니오") << endl;
    
    if (!numeric_limits<T>::is_integer) {
        cout << "정밀도: " << numeric_limits<T>::digits10 << endl;
        cout << "무한대: " << numeric_limits<T>::has_infinity << endl;
    }
    
    cout << endl;
}

int main() {
    printTypeInfo<int>();
    printTypeInfo<unsigned int>();
    printTypeInfo<float>();
    printTypeInfo<double>();
}

타입 속성

// 부호
numeric_limits<int>::is_signed      // true
numeric_limits<unsigned>::is_signed // false

// 정수
numeric_limits<int>::is_integer     // true
numeric_limits<double>::is_integer  // false

// 정확성
numeric_limits<int>::is_exact       // true
numeric_limits<float>::is_exact     // false

// 무한대
numeric_limits<double>::has_infinity  // true
numeric_limits<int>::has_infinity     // false

자주 발생하는 문제

문제 1: min() 오해

정수 타입에서 min()은 가장 작은 값(음수)이지만, 부동소수점 타입에서는 “0보다 큰 가장 작은 정규화된 값”입니다. 최댓값 탐색의 초깃값으로 numeric_limits<double>::min()을 쓰면 음수만 들어오는 입력에서 결과가 틀어집니다. 모든 산술 타입에서 가장 작은 유한값이 필요하면 C++11의 lowest()를 쓰세요.

// ❌ 실수 타입 min()은 최소 양수
cout << numeric_limits<double>::min() << endl;  // 2.22507e-308 (0에 가까운 양수)

// ✅ 최소값은 lowest()
cout << numeric_limits<double>::lowest() << endl;  // -1.79769e+308

문제 2: 오버플로우 체크 누락

부호 있는 정수의 오버플로우는 정의되지 않은 동작이라, 연산을 먼저 하고 결과가 음수가 됐는지 보는 식의 사후 검사는 컴파일러 최적화로 사라질 수 있습니다. 연산 전에 max()/min()과 비교해 넘칠지를 판단해야 합니다. GCC·Clang에서는 __builtin_add_overflow, C++26에서는 std::add_sat 같은 포화 연산도 선택지입니다.

// ❌ 오버플로우 무시
int x = INT_MAX;
x++;  // UB

// ✅ 체크
if (x < numeric_limits<int>::max()) {
    x++;
}

문제 2-1: char의 부호는 플랫폼마다 다름

numeric_limits<char>::is_signed는 x86의 GCC·Clang·MSVC에서는 true지만, ARM Linux(라즈베리 파이, AWS Graviton 등)의 GCC에서는 기본이 false입니다. 그래서 char c = 200; 뒤에 if (c < 0)으로 UTF-8 바이트를 구분하는 코드는 x86에서만 동작하고 ARM에서는 항상 거짓이 됩니다. 바이트 값을 다룰 때는 char 대신 unsigned char나 std::uint8_t, std::byte를 쓰고, 부호 여부가 중요한 템플릿 코드라면 numeric_limits<char>::is_signed를 가정하지 말고 검사해야 합니다.

문제 3: 부호 없는 타입

부호 없는 타입에는 음수가 없으므로 x >= 0 검사는 항상 참이고, 0u - 1은 numeric_limits<unsigned>::max()로 감깁니다. 템플릿 코드에서 “음수인지”를 검사해야 한다면 타입이 부호 있는지부터 is_signed로 갈라야 컴파일러 경고(-Wtype-limits) 없이 두 경우를 모두 처리할 수 있습니다.

// ❌ 항상 true인 음수 검사
unsigned int x = 10;
if (x >= 0) {  // 경고: comparison is always true
    // ...
}

// ✅ 템플릿에서 부호 여부로 분기
template <typename T>
bool isNegative(T v) {
    if constexpr (std::numeric_limits<T>::is_signed) {
        return v < 0;
    } else {
        return false;  // 부호 없는 타입은 음수가 될 수 없음
    }
}

실무 패턴

패턴 1: 안전한 타입 변환

template<typename Target, typename Source>
std::optional<Target> safeCast(Source value) {
    if (value < static_cast<Source>(std::numeric_limits<Target>::min()) ||
        value > static_cast<Source>(std::numeric_limits<Target>::max())) {
        return std::nullopt;
    }
    return static_cast<Target>(value);
}

// 사용
auto result = safeCast<int>(300L);
if (result) {
    std::cout << "변환 성공: " << *result << '\n';
} else {
    std::cout << "변환 실패: 범위 초과\n";
}

이 safeCast는 부호가 같은 타입끼리는 잘 동작하지만, 부호 있는 타입과 없는 타입이 섞이면 틀린 답을 냅니다. 예를 들어 safeCast<unsigned int>(-1)을 호출하면 numeric_limits<unsigned int>::max()를 int로 캐스팅하는 순간 -1이 되어 버려 비교가 엉뚱하게 이뤄집니다. 저도 이 패턴을 처음 썼을 때 부호 없는 대상 타입에서만 범위 검사가 통과해 버리는 버그를 한참 뒤에야 발견했습니다. C++20 이상이라면 이런 부호 혼합 비교를 올바르게 처리하는 std::in_range<Target>(value)와 std::cmp_less 계열 함수가 <utility>에 있으므로 직접 구현하지 말고 그것을 쓰는 것이 좋습니다. 실수에서 정수로 변환할 때는 범위 밖 값의 변환 자체가 정의되지 않은 동작이라, 반드시 변환 전에 검사해야 하고 NaN도 따로 걸러야 합니다.

C++20 이상이라면 이 문제를 표준 함수로 해결할 수 있습니다. std::in_range<Target>(value)는 부호가 섞인 경우까지 정확하게 “값이 Target의 범위 안에 있는가”를 판정하고, std::cmp_less(a, b) 같은 비교 함수는 -1 < 1u가 거짓이 되는 부호 섞인 비교 함정을 피해 줍니다. 위 safeCast의 범위 검사 부분을 if (!std::in_range<Target>(value)) return std::nullopt; 한 줄로 바꾸면 정수 사이 변환에서는 부호 문제까지 올바르게 처리됩니다. 실수에서 정수로 바꾸는 경우는 여전히 직접 검사해야 하는데, 범위를 벗어난 double을 정수로 변환하는 것 자체가 정의되지 않은 동작이라 반드시 변환 전에 비교해야 합니다.

패턴 2: 센티널 값

template<typename T>
class OptionalValue {
    T value_;
    static constexpr T SENTINEL = std::numeric_limits<T>::max();
    
public:
    OptionalValue() : value_(SENTINEL) {}
    OptionalValue(T value) : value_(value) {}
    
    bool hasValue() const {
        return value_ != SENTINEL;
    }
    
    T value() const {
        if (!hasValue()) {
            throw std::runtime_error("No value");
        }
        return value_;
    }
};

// 사용
OptionalValue<int> opt;
if (opt.hasValue()) {
    std::cout << opt.value() << '\n';
}

패턴 3: 범위 검증

template<typename T>
class BoundedValue {
    T value_;
    
public:
    BoundedValue(T value) {
        if (value < std::numeric_limits<T>::min() ||
            value > std::numeric_limits<T>::max()) {
            throw std::out_of_range("Value out of range");
        }
        value_ = value;
    }
    
    T get() const { return value_; }
};

// 사용
try {
    BoundedValue<int> val(42);
    std::cout << val.get() << '\n';
} catch (const std::out_of_range& e) {
    std::cerr << e.what() << '\n';
}

패턴 2와 3은 한계를 함께 알고 써야 합니다. 센티널 방식은 max()를 “값 없음”으로 예약하므로 실제 데이터에 max()가 들어오면 값이 없는 것으로 오인되고, double처럼 NaN이 있는 타입에서는 센티널 대신 NaN을 쓰는 경우도 많습니다. C++17 이상이라면 메모리를 조금 더 쓰더라도 std::optional<T>가 이런 모호함이 없어 기본 선택입니다. 패턴 3의 BoundedValue는 매개변수가 이미 T 타입이라, T의 범위를 벗어난 값은 생성자에 들어오기 전 변환 단계에서 잘려 있습니다. 그래서 이 검사는 항상 통과하며, 실제로 범위를 검증하려면 생성자가 더 넓은 타입(예: long long이나 double)을 받거나 BoundedValue<T, Min, Max>처럼 도메인 범위를 템플릿 인자로 받아야 의미가 생깁니다.

FAQ

Q1: INT_MAX 대신 numeric_limits를 써야 하는 이유가 있나요?

A: 구체 타입이 정해진 코드라면 두 방식의 값은 같습니다. 차이는 템플릿에서 드러납니다. INT_MAX는 int 전용이라 T가 무엇이든 맞는 값을 얻으려면 numeric_limits<T>::max()가 필요하고, std::size_t나 std::int64_t처럼 별칭 타입은 대응하는 매크로를 찾기도 번거롭습니다. 주로 쓰는 곳은 다음과 같습니다.

  • 오버플로우/언더플로우 체크
  • 초기값 설정 (최대/최소값으로 초기화)
  • 타입 정보 확인 (부호, 정수 여부 등)
  • 범위 검증 (타입 변환 전)
// 오버플로우 체크
if (x > std::numeric_limits<int>::max() - y) {
    // 오버플로우 발생
}

// 초기값 설정
int minVal = std::numeric_limits<int>::max();

Q2: min() vs lowest()?

A:

  • 정수: min()과 lowest()가 같음
  • 실수: min()은 최소 양수 (0에 가까운 값), lowest()는 최소값 (음수)
// 정수
std::cout << std::numeric_limits<int>::min() << '\n';     // -2147483648
std::cout << std::numeric_limits<int>::lowest() << '\n';  // -2147483648

// 실수
std::cout << std::numeric_limits<double>::min() << '\n';     // 2.22507e-308 (최소 양수)
std::cout << std::numeric_limits<double>::lowest() << '\n';  // -1.79769e+308 (최소값)

Q3: 성능 오버헤드는?

A: 없습니다. numeric_limits의 모든 멤버는 컴파일 타임 상수이므로 런타임 오버헤드가 전혀 없습니다.

// 컴파일 타임에 값이 결정됨
constexpr int maxInt = std::numeric_limits<int>::max();

Q4: 커스텀 타입은?

A: numeric_limits를 특수화하여 커스텀 타입을 지원할 수 있습니다.

struct MyInt {
    int value;
};

namespace std {
    template<>
    struct numeric_limits<MyInt> {
        static constexpr bool is_specialized = true;
        static constexpr MyInt min() { return MyInt{-100}; }
        static constexpr MyInt max() { return MyInt{100}; }
    };
}

// 사용
MyInt maxVal = std::numeric_limits<MyInt>::max();

Q5: 플랫폼 독립적인가요?

A: 네, numeric_limits는 컴파일하는 플랫폼의 실제 타입 크기에 맞는 값을 돌려줍니다. 그래서 코드는 이식 가능하지만, 값 자체는 플랫폼마다 다를 수 있습니다.

// 대부분의 플랫폼: 2147483647 (int는 32비트)
std::cout << std::numeric_limits<int>::max() << '\n';

// 64비트 Linux/macOS(LP64): 9223372036854775807
// Windows(64비트 포함, LLP64)와 32비트 플랫폼: 2147483647
std::cout << std::numeric_limits<long>::max() << '\n';

long이 Windows에서는 64비트 빌드에서도 32비트라는 점은 Linux에서 개발한 코드를 Windows로 옮길 때 자주 드러나는 버그 원인입니다. 파일 크기나 타임스탬프처럼 32비트를 넘을 수 있는 값은 long 대신 std::int64_t나 long long을 쓰는 편이 안전합니다.

Q6: infinity()와 NaN은 언제 사용하나요?

A: 실수 타입에서 특수 값을 표현할 때 사용합니다.

double inf = std::numeric_limits<double>::infinity();
double nan = std::numeric_limits<double>::quiet_NaN();

// 체크
if (std::isinf(inf)) {
    std::cout << "무한대\n";
}

if (std::isnan(nan)) {
    std::cout << "NaN\n";
}

// 연산
double result = 1.0 / 0.0;  // inf (IEEE 754 환경)
double invalid = 0.0 / 0.0;  // nan

1.0 / 0.0이 inf가 되는 것은 IEEE 754 부동소수점 규칙이고, C++ 표준 자체는 0으로 나누기를 정의되지 않은 동작으로 둡니다. 주요 컴파일러와 플랫폼은 IEEE 754를 따르므로(numeric_limits<double>::is_iec559가 true) 실제로는 inf가 나오지만, 명시적으로 무한대가 필요하면 infinity()를 쓰는 편이 의도가 분명합니다. NaN은 자기 자신과도 같지 않아서 x == quiet_NaN() 비교는 항상 거짓입니다. 그래서 NaN 검사는 반드시 std::isnan으로 해야 하고, -ffast-math 옵션으로 빌드하면 컴파일러가 NaN이 없다고 가정해 isnan 검사마저 최적화로 없애 버릴 수 있다는 점도 주의해야 합니다.

Q7: Windows에서 numeric_limits::max()가 컴파일되지 않는 이유는?

A: <windows.h>가 max와 min을 함수형 매크로로 정의하기 때문입니다. 전처리기가 max()를 매크로 호출로 치환해 버려 MSVC에서 error C2589: '(': illegal token on right side of '::' 같은 에러가 납니다. 프로젝트 전체에 NOMINMAX를 정의하는 것(CMake라면 target_compile_definitions(app PRIVATE NOMINMAX))이 가장 깔끔하고, 헤더를 고칠 수 없다면 (std::numeric_limits<int>::max)()처럼 이름을 괄호로 감싸 매크로 확장을 피합니다. 멤버 목록 전체는 cppreference의 std::numeric_limits에서 확인할 수 있습니다.

numeric_limits는 타입의 한계값과 속성을 컴파일 타임에 조회하는 표준 라이브러리 템플릿입니다.


같이 보면 좋은 글