C++ std::ratio: 컴파일 타임 분수 연산과 chrono 단위 표현 원리

이 글의 핵심

밀리초와 초, 킬로미터와 미터처럼 단위가 다른 값을 정수로만 다루면 변환 계수를 실수로 빠뜨리는 버그가 생깁니다. std::ratio는 비율을 값이 아니라 타입으로 표현해 단위가 다른 값끼리의 혼동을 컴파일 에러로 바꾸지만, 런타임 값으로는 만들 수 없고 큰 분자와 분모를 곱하면 컴파일 타임 오버플로우가 난다는 제약이 있습니다. 타입 안전 단위 시스템 패턴까지 정리했습니다.

ratio란?

std::ratio 는 C++11에서 도입된 컴파일 타임 분수 타입입니다. 분자와 분모를 템플릿 인자로 받아 컴파일 타임에 분수 연산을 수행하며, 자동으로 약분됩니다. 주로 std::chrono와 함께 사용됩니다.

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

int main() {
    using OneHalf = ratio<1, 2>;
    
    cout << OneHalf::num << "/" << OneHalf::den << endl;  // 1/2
    
    // 자동 약분
    using TwoFourths = ratio<2, 4>;
    cout << TwoFourths::num << "/" << TwoFourths::den << endl;  // 1/2
}

왜 필요한가?:

  • 타입 안전: 컴파일 타임에 단위 검증
  • 정확성: 정수 분수로 부동소수점 오차 없음
  • 성능: 런타임 비용 없음
  • 표준 라이브러리: chrono::duration의 기반
// ❌ 런타임 분수: 부동소수점 오차
double oneThird = 1.0 / 3.0;  // 0.333333...

// ✅ 컴파일 타임 분수: 정확
using OneThird = std::ratio<1, 3>;
// num=1, den=3 (정확한 1/3 표현)

자동 약분:

using R1 = std::ratio<2, 4>;   // 1/2로 약분
using R2 = std::ratio<6, 9>;   // 2/3으로 약분
using R3 = std::ratio<100, 25>; // 4/1로 약분

std::cout << R1::num << "/" << R1::den << '\n';  // 1/2
std::cout << R2::num << "/" << R2::den << '\n';  // 2/3
std::cout << R3::num << "/" << R3::den << '\n';  // 4/1

“자동 약분”에는 짚어 둘 미묘한 점이 있습니다. std::ratio<2, 4>의 num과 den은 약분된 1과 2이지만, 타입 자체는 여전히 std::ratio<2, 4>입니다. 그래서 std::is_same_v<std::ratio<2, 4>, std::ratio<1, 2>>는 false입니다. 약분된 타입이 필요하면 멤버 ::type(std::ratio<2, 4>::type은 std::ratio<1, 2>)을 쓰고, 값으로서 같은지 비교할 때는 std::is_same이 아니라 뒤에서 볼 std::ratio_equal을 써야 합니다. 이 차이는 ratio를 템플릿 인자로 받는 클래스를 만들 때 드러납니다. Distance<std::ratio<2, 2>>와 Distance<std::ratio<1, 1>>은 같은 “미터”를 뜻하지만 서로 다른 타입이라 대입이 되지 않습니다. ratio_add 같은 연산 결과는 표준이 약분된 std::ratio<N, D> 형태로 정의하므로 이 문제가 없습니다.

부호도 정규화됩니다. std::ratio<1, -2>의 num은 -1, den은 2로, 분모는 항상 양수가 되고 부호는 분자로 옮겨집니다. 분모가 0이면 static_assert로 컴파일이 실패합니다.

컴파일 타임 분수 연산의 의미

std::ratio 계열 타입은 값이 아니라 타입입니다. ratio_add, ratio_multiply 등은 별칭 템플릿으로, 결과 역시 std::ratio<num, den>의 한 형태입니다. 즉 “런타임에 분수를 계산”하는 것이 아니라, 단위가 맞는지·스케일이 무엇인지를 컴파일 타임에 확정합니다.

  • 정확성: 부동소수점으로 0.1초를 누적하는 대신, milli vs micro를 타입으로 구분.
  • 최적화: duration의 operator+ 등은 같은 Rep로 정규화할 때 비율만 템플릿 인스턴스화로 처리.

비율을 타입에 담는다는 것은 변환 계수가 컴파일러에게 보이는 상수가 된다는 뜻입니다. milliseconds를 seconds로 바꾸는 코드에서 “1000으로 나눈다”는 사실은 std::milli와 std::ratio<1>의 비율에서 컴파일 시점에 계산되고, 생성되는 기계어에는 나눗셈 명령 하나만 남습니다. 비율이 1이면 변환 코드 자체가 사라집니다. 사람이 ms / 1000을 직접 쓰는 방식과 런타임 비용은 같지만, 계수를 잘못 쓰거나(/ 100) 빠뜨리는 실수를 컴파일러가 대신 막아 준다는 점이 다릅니다. 단위가 다른 값을 실수로 더하려 하면 타입이 달라서 암시적 변환 규칙이 먼저 적용되고, 정밀도를 잃는 변환이면 컴파일 에러가 납니다.

분수 연산

using Half = ratio<1, 2>;
using Third = ratio<1, 3>;

// 덧셈
using Sum = ratio_add<Half, Third>;
cout << Sum::num << "/" << Sum::den << endl;  // 5/6

// 뺄셈
using Diff = ratio_subtract<Half, Third>;
cout << Diff::num << "/" << Diff::den << endl;  // 1/6

// 곱셈
using Prod = ratio_multiply<Half, Third>;
cout << Prod::num << "/" << Prod::den << endl;  // 1/6

// 나눗셈
using Quot = ratio_divide<Half, Third>;
cout << Quot::num << "/" << Quot::den << endl;  // 3/2

ratio_add 등은 C++11에서 별칭 템플릿으로 정의되어 있어서 ratio_add<Half, Third> 자체가 곧 결과 타입(ratio<5, 6>)입니다. 이전의 Boost 구현이나 일부 자료에서는 ratio_add<A, B>::type처럼 ::type을 붙이는데, 표준 버전에서도 결과가 ratio이므로 ::type이 있긴 하지만 붙일 필요는 없습니다. 이 연산들은 템플릿 인자로만 동작하므로 ratio_add<Half, x>처럼 변수를 넣을 수는 없고, 결과를 런타임 값으로 쓰려면 Sum::num과 Sum::den을 꺼내 계산해야 합니다. 계산은 모두 std::intmax_t로 이루어지며, 중간 결과가 넘치면 뒤의 “오버플로우” 절에서 보듯 컴파일 에러가 납니다. 표준은 약분을 적용해 중간값을 최대한 작게 유지하도록 요구하므로, 결과가 표현 가능한데도 중간 계산이 넘치는 경우는 드뭅니다.

비교

using Half = ratio<1, 2>;
using Third = ratio<1, 3>;

cout << ratio_equal<Half, ratio<2, 4>>::value << endl;     // 1
cout << ratio_less<Third, Half>::value << endl;            // 1
cout << ratio_greater<Half, Third>::value << endl;         // 1

비교 템플릿은 std::integral_constant<bool, ...>을 상속하므로 ::value로 bool을 얻고, C++17부터는 std::ratio_less_v<Third, Half>처럼 _v 변수 템플릿으로 더 짧게 쓸 수 있습니다. 컴파일 타임 값이므로 static_assert(std::ratio_less_v<std::milli, std::ratio<1>>);처럼 “이 단위는 초보다 작아야 한다” 같은 조건을 코드에 박아 두는 데 쓸 수 있습니다. ratio_equal<Half, ratio<2, 4>>가 참인 것은 앞에서 말한 대로 약분된 값을 비교하기 때문이며, 타입 비교인 std::is_same과는 결과가 다릅니다.

chrono와 함께

#include <chrono>

int main() {
    // duration<int, ratio<1, 1000>> = milliseconds
    using MyMilliseconds = chrono::duration<int, milli>;
    
    MyMilliseconds ms(1000);
    
    // 초로 변환
    auto sec = chrono::duration_cast<chrono::seconds>(ms);
    cout << sec.count() << "초" << endl;  // 1
}

duration_cast가 필요한 이유는 방향에 있습니다. 초를 밀리초로 바꾸는 것은 정보 손실이 없으므로 milliseconds ms = seconds(1);처럼 암시적으로 됩니다. 반대로 1500밀리초를 초로 바꾸면 정수 초로는 1.5를 표현할 수 없으므로 암시적 변환이 금지되고, duration_cast로 “버림을 감수한다”고 명시해야 합니다. 이 판단의 근거가 바로 두 ratio의 비율입니다. duration_cast는 버림(0 방향)을 하므로 반올림이 필요하면 C++17의 std::chrono::round, 내림·올림은 floor·ceil을 씁니다.

chrono::duration과 ratio:

std::chrono::duration은 내부적으로 std::ratio를 사용하여 시간 단위를 표현합니다.

// duration 정의
template<typename Rep, typename Period = std::ratio<1>>
class duration;

// 표준 duration 타입
using nanoseconds  = duration<int64_t, std::nano>;   // ratio<1, 1000000000>
using microseconds = duration<int64_t, std::micro>;  // ratio<1, 1000000>
using milliseconds = duration<int64_t, std::milli>;  // ratio<1, 1000>
using seconds      = duration<int64_t>;              // ratio<1>
using minutes      = duration<int, std::ratio<60>>;
using hours        = duration<int, std::ratio<3600>>;

위 정의는 이해를 돕기 위한 단순화입니다. 표준은 Rep 타입을 정확히 지정하지 않고 “나노초는 최소 64비트, 분은 최소 29비트, 시간은 최소 23비트의 부호 있는 정수”처럼 최소 범위만 요구합니다. 구현마다 실제 타입은 다를 수 있지만, 모든 표준 duration이 최소 ±292년을 표현할 수 있도록 설계되어 있습니다. 나노초를 64비트 정수로 세면 약 292년이 한계라는 점이 이 기준의 근원입니다.

duration과의 관계(정규화)

std::chrono::duration<Rep, Period>에서 Period는 std::ratio를 나타내는 타입입니다. 서로 다른 duration을 더하거나 빼면, 구현은 보통 공통 분모로 맞춘 하나의 duration 타입으로 결과를 만듭니다. 사용자 입장에서는 “나노초 + 마이크로초”를 직접 고민하지 않고 duration_cast로 원하는 단위만 고르면 됩니다.

using ns = std::chrono::duration<int64_t, std::nano>;
using us = std::chrono::duration<int64_t, std::micro>;
ns a{500};
us b{1};
auto sum = a + b;  // duration<int64_t, std::nano>, 값은 1500

서로 다른 duration의 합 타입은 std::common_type으로 정해지며, Period는 두 비율의 최대공약수, 즉 두 단위를 모두 정수로 표현할 수 있는 가장 큰 단위가 됩니다. 나노초와 마이크로초라면 나노초, 1/60초(프레임)와 1/1000초(밀리초)라면 1/3000초처럼 표준 이름이 없는 단위가 나올 수도 있습니다. 결과 타입을 auto로 받으면 이런 낯선 단위가 그대로 퍼져 나가므로, 저장하거나 출력하기 전에 원하는 단위로 duration_cast하는 것이 읽기 쉽습니다.

타입 안전성 측면: duration<double, std::milli>와 duration<int, std::milli>를 섞으면 Rep 타입 때문에 주의가 필요합니다. 시간 단위는 ratio로, 누적값의 성격(정수 틱 vs 실수 초)은 Rep로 나누어 생각하면 실수가 줄어듭니다.

커스텀 duration 만들기:

// 1/60초 (프레임 단위)
using Frames = std::chrono::duration<int, std::ratio<1, 60>>;

// 1일
using Days = std::chrono::duration<int, std::ratio<86400>>;

// 1주
using Weeks = std::chrono::duration<int, std::ratio<604800>>;

int main() {
    Frames frames(120);  // 120프레임
    auto seconds = std::chrono::duration_cast<std::chrono::seconds>(frames);
    std::cout << seconds.count() << "초\n";  // 2초
    
    Days days(7);
    Weeks weeks = std::chrono::duration_cast<Weeks>(days);
    std::cout << weeks.count() << "주\n";  // 1주
}

실무 활용:

// 게임 프레임 타이밍
using Frames60 = std::chrono::duration<double, std::ratio<1, 60>>;

Frames60 frameTime(1.0);  // 1프레임 (1/60초)
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(frameTime);
std::cout << ms.count() << "ms\n";  // 16ms

1/60초는 16.666…ms인데 16ms가 나오는 것은 duration_cast가 정수 밀리초로 버림하기 때문입니다. 프레임 예산을 16ms로 잡고 대기하면 실제로는 초당 62.5프레임으로 돌아 60Hz 화면과 어긋나게 됩니다. 이런 계산은 duration<double, std::milli>로 변환해 16.67을 그대로 다루거나, 처음부터 Frames60 단위로 누적하는 편이 정확합니다. Rep를 double로 둔 Frames60은 1.5프레임 같은 값도 표현할 수 있어서 보간(interpolation) 계산에 쓰기 좋지만, 반대로 부동소수점 누적 오차를 감수해야 합니다. 틱을 정확히 세야 하는 곳은 정수 Rep, 연속적인 시간 계산은 double Rep로 나누는 것이 일반적인 기준입니다.

실전 활용과 타입 안전성

  • 물리 단위 라이브러리: 길이·질량 등을 ratio로 스케일만 다르게 두면, ratio_divide로 단위 차원을 맞춘 변환 계수를 컴파일 타임에 얻을 수 있습니다.
  • 샘플 레이트: 오디오에서 44.1kHz vs 48kHz처럼 서로 다른 Period를 가진 duration으로 표현하면, 리샘플링 코드에서 “틱 단위” 혼동을 줄입니다.
  • 주의: std::ratio의 num/den은 std::intmax_t 범위입니다. 극단적으로 큰 분모/분자 조합은 오버플로를 일으킬 수 있으므로, SI 접두사 조합은 표준 typedef(std::milli, std::kilo 등)를 우선 사용하세요.

실전 예시

예시 1: 단위 변환

template<typename Ratio>
class Distance {
private:
    double value;
    
public:
    Distance(double v) : value(v) {}
    
    double getValue() const { return value; }
    
    template<typename OtherRatio>
    Distance<OtherRatio> to() const {
        using ConversionRatio = ratio_divide<Ratio, OtherRatio>;
        double converted = value * ConversionRatio::num / ConversionRatio::den;
        return Distance<OtherRatio>(converted);
    }
};

using Meters = Distance<ratio<1, 1>>;
using Kilometers = Distance<ratio<1000, 1>>;
using Centimeters = Distance<ratio<1, 100>>;

int main() {
    Meters m(1000);
    
    auto km = m.to<ratio<1000, 1>>();
    cout << km.getValue() << " km" << endl;  // 1
    
    auto cm = m.to<ratio<1, 100>>();
    cout << cm.getValue() << " cm" << endl;  // 100000
}

to()의 핵심은 ratio_divide<Ratio, OtherRatio>입니다. 미터(1/1)를 킬로미터(1000/1)로 바꾸는 계수는 (1/1) ÷ (1000/1) = 1/1000이고, 이 값이 컴파일 시점에 계산되어 value * 1 / 1000만 남습니다. 새 단위를 추가할 때는 using Millimeters = Distance<std::milli>; 한 줄이면 되고, 모든 단위 쌍의 변환 계수를 따로 정의할 필요가 없습니다.

이 예제는 단순화된 형태라서 “타입 안전”의 절반만 제공합니다. 변환은 안전해졌지만 Meters m(1000); Kilometers k(m.getValue());처럼 값을 꺼내 다른 단위에 넣는 실수는 막지 못합니다. 생성자를 explicit으로 만들고, 서로 다른 단위끼리 더하는 연산자를 정의해 자동으로 공통 단위로 맞추도록 하면 std::chrono::duration에 가까운 안전성을 얻을 수 있습니다. 아래 “패턴 1”이 explicit을 추가한 버전입니다. 또 num이 std::intmax_t라서 value * num에서 double로 변환되는데, 비율이 극단적으로 크면 double의 정밀도(약 15~16자리)를 넘을 수 있다는 점도 알아 두면 좋습니다.

예시 2: 속도 계산

template<typename DistanceRatio, typename TimeRatio>
class Speed {
public:
    double value;
    
    Speed(double v) : value(v) {}
};

using MetersPerSecond = Speed<ratio<1>, ratio<1>>;
using KilometersPerHour = Speed<ratio<1000>, ratio<3600>>;

int main() {
    MetersPerSecond mps(10);
    
    // m/s → km/h
    double kmh = mps.value * 3600 / 1000;
    cout << kmh << " km/h" << endl;  // 36
}

이 예제는 템플릿 인자로 거리와 시간 비율을 받지만 정작 변환은 * 3600 / 1000을 손으로 적어서 ratio의 이점을 쓰지 않고 있습니다. 비율을 활용하려면 속도 단위를 ratio_divide<DistanceRatio, TimeRatio>로 하나의 비율로 합치고, 두 속도 단위 사이의 변환 계수를 다시 ratio_divide로 구하면 됩니다. m/s는 1/1, km/h는 1000/3600 = 5/18이므로, m/s → km/h 계수는 (1/1) ÷ (5/18) = 18/5 = 3.6이 컴파일 시점에 나옵니다. 이처럼 여러 차원(길이, 시간, 질량)을 조합하는 본격적인 단위 시스템은 직접 만들기보다 mp-units나 Boost.Units 같은 라이브러리를 쓰는 편이 현실적입니다.

예시 3: 주파수

using Hertz = ratio<1, 1>;
using Kilohertz = ratio<1000, 1>;
using Megahertz = ratio<1000000, 1>;

template<typename Ratio>
class Frequency {
private:
    double value;
    
public:
    Frequency(double v) : value(v) {}
    
    double toHertz() const {
        return value * Ratio::num / Ratio::den;
    }
};

int main() {
    Frequency<Megahertz> cpu(3.5);  // 3.5 MHz
    
    cout << cpu.toHertz() << " Hz" << endl;  // 3.5e+06 (3,500,000 Hz)
}

원래 예제의 주석은 Megahertz로 3.5를 넣고 “3.5 GHz, 3500000000 Hz”라고 적혀 있었는데, 실제 결과는 3.5 MHz, 즉 3,500,000 Hz입니다. 3.5 GHz를 표현하려면 using Gigahertz = std::giga;로 단위를 하나 더 두어야 합니다. 이 실수가 바로 ratio가 막으려는 종류의 버그라는 점이 흥미롭습니다. 값 3.5가 어떤 단위인지는 타입(Frequency<Megahertz>)에 적혀 있으므로 코드를 읽으면 드러나지만, 주석이나 변수 이름에만 의존하면 이렇게 1000배가 어긋납니다. 또 std::cout은 큰 double을 기본적으로 지수 표기(3.5e+06)로 출력하므로, 자릿수를 모두 보려면 std::fixed를 설정해야 합니다.

표준 ratio

// SI 접두사
using atto  = ratio<1, 1000000000000000000>;
using femto = ratio<1, 1000000000000000>;
using pico  = ratio<1, 1000000000000>;
using nano  = ratio<1, 1000000000>;
using micro = ratio<1, 1000000>;
using milli = ratio<1, 1000>;
using centi = ratio<1, 100>;
using deci  = ratio<1, 10>;
using deca  = ratio<10, 1>;
using hecto = ratio<100, 1>;
using kilo  = ratio<1000, 1>;
using mega  = ratio<1000000, 1>;
using giga  = ratio<1000000000, 1>;
using tera  = ratio<1000000000000, 1>;

이 별칭들은 <ratio> 헤더의 std 네임스페이스에 이미 정의되어 있으므로 직접 선언할 필요 없이 std::milli, std::kilo처럼 씁니다. 위 목록에는 peta(10^15)와 exa(10^18)도 있으며, yocto(10^-24)·zepto(10^-21)·zetta·yotta는 std::intmax_t로 표현할 수 없는 값이라 해당 타입이 충분히 넓은 구현에서만 정의됩니다. 일반적인 64비트 환경에서는 쓸 수 없습니다.

자주 발생하는 문제

문제 1: 오버플로우

// ❌ 오버플로우 (<cstdint>의 INTMAX_MAX: intmax_t 최댓값)
using Big = ratio<INTMAX_MAX, 1>;
using Bigger = ratio_multiply<Big, ratio<2, 1>>;  // 컴파일 에러: 오버플로우

// ✅ 적절한 범위
using Safe = ratio<1000, 1>;

원래 예제는 INT_MAX를 썼는데, std::ratio는 std::intmax_t(보통 64비트)로 계산하므로 INT_MAX * 2는 전혀 넘치지 않고 정상적으로 ratio<4294967294, 1>이 됩니다. 오버플로우를 보려면 INTMAX_MAX처럼 64비트 한계에 가까운 값이 필요합니다. 이때 오버플로우는 런타임의 조용한 값 손상이 아니라 컴파일 에러로 나타납니다. 표준 라이브러리 구현이 연산 전에 static_assert로 범위를 검사하기 때문이며, GCC에서는 overflow in multiplication 같은 메시지가 나옵니다. 실무에서 이 에러를 만나는 경우는 대개 ratio를 직접 곱하는 코드가 아니라 duration끼리 연산할 때 공통 단위를 계산하는 과정입니다. 예를 들어 서로소에 가까운 큰 분모를 가진 두 사용자 정의 duration을 더하면 공통 Period의 분모가 두 분모의 곱에 가까워져 넘칠 수 있습니다.

문제 2: 런타임 계산

// ❌ 런타임 계산 불가
int n = 5;
// using MyRatio = ratio<n, 10>;  // 에러

// ✅ 컴파일 타임 상수
constexpr int N = 5;
using MyRatio = ratio<N, 10>;

템플릿 인자는 컴파일 시점에 값이 정해져야 하므로 일반 변수는 쓸 수 없고, the value of 'n' is not usable in a constant expression 에러가 납니다. constexpr 변수나 리터럴, 다른 템플릿 인자처럼 컴파일 타임 상수만 허용됩니다. 설정 파일에서 읽은 비율처럼 실행 중에 정해지는 값이라면 ratio로는 표현할 수 없으므로, 분자·분모를 멤버로 가진 런타임 분수 클래스를 쓰거나 double 계수를 쓰고 오차를 감수해야 합니다. 단위 종류는 컴파일 타임에 정해지고 값만 런타임에 바뀌는 구조, 즉 duration<Rep, Period>처럼 단위는 타입에 두고 크기는 멤버 값에 두는 설계가 ratio를 쓰는 올바른 방식입니다.

실무 패턴

패턴 1: 타입 안전 단위 시스템

template<typename Ratio>
class Length {
    double value_;
    
public:
    explicit Length(double v) : value_(v) {}
    
    double value() const { return value_; }
    
    template<typename OtherRatio>
    Length<OtherRatio> to() const {
        using Conv = std::ratio_divide<Ratio, OtherRatio>;
        double converted = value_ * static_cast<double>(Conv::num) / Conv::den;
        return Length<OtherRatio>(converted);
    }
};

using Meters = Length<std::ratio<1>>;
using Kilometers = Length<std::kilo>;
using Centimeters = Length<std::centi>;

// 사용
Meters m(1500);
auto km = m.to<std::kilo>();
std::cout << km.value() << " km\n";  // 1.5 km

패턴 2: 프레임 레이트 계산

template<int FPS>
class FrameTimer {
    using FrameDuration = std::chrono::duration<double, std::ratio<1, FPS>>;
    
    std::chrono::steady_clock::time_point lastFrame_;
    
public:
    FrameTimer() : lastFrame_(std::chrono::steady_clock::now()) {}
    
    bool shouldUpdate() {
        auto now = std::chrono::steady_clock::now();
        auto elapsed = now - lastFrame_;
        
        if (elapsed >= FrameDuration(1)) {
            lastFrame_ = now;
            return true;
        }
        return false;
    }
};

// 60 FPS 타이머
FrameTimer<60> timer;

while (running) {
    if (timer.shouldUpdate()) {
        update();
        render();
    }
}

FrameDuration(1)은 duration<double, ratio<1, 60>> 단위로 1, 즉 1/60초이고, steady_clock의 나노초 단위 elapsed와 비교할 때 두 단위는 공통 타입으로 맞춰진 뒤 비교됩니다. FPS를 템플릿 인자로 받으므로 FrameTimer<144>처럼 다른 주사율도 코드 수정 없이 만들 수 있습니다.

실제 게임 루프에 쓰려면 두 가지를 손봐야 합니다. 첫째, lastFrame_ = now는 프레임을 확인한 시각을 기준으로 다음 프레임을 잡기 때문에 확인이 조금씩 늦어질 때마다 오차가 누적되어 평균 프레임 속도가 60보다 낮아집니다. lastFrame_ += std::chrono::duration_cast<std::chrono::steady_clock::duration>(FrameDuration(1));처럼 이전 목표 시각에 간격을 더하면 누적 오차가 없어집니다. 둘째, 이 루프는 조건이 충족되지 않는 동안 쉬지 않고 돌아서 CPU 코어 하나를 100% 사용합니다. 다음 프레임 시각까지 std::this_thread::sleep_until로 기다리거나, 그래픽 API의 수직 동기화(vsync)에 맞추는 것이 일반적입니다.

패턴 3: 비율 계산

// 화면 비율
using AspectRatio16_9 = std::ratio<16, 9>;
using AspectRatio4_3 = std::ratio<4, 3>;

template<typename Ratio>
int calculateHeight(int width) {
    return width * Ratio::den / Ratio::num;
}

int main() {
    int width = 1920;
    
    int height16_9 = calculateHeight<AspectRatio16_9>(width);
    std::cout << "16:9 - " << width << "x" << height16_9 << '\n';  // 1920x1080
    
    int height4_3 = calculateHeight<AspectRatio4_3>(width);
    std::cout << "4:3 - " << width << "x" << height4_3 << '\n';  // 1920x1440
}

width * Ratio::den / Ratio::num처럼 곱셈을 먼저 하고 나눗셈을 나중에 하는 순서가 중요합니다. width / Ratio::num * Ratio::den으로 쓰면 1920 / 16 = 120, 120 × 9 = 1080으로 이 경우에는 같지만, 너비가 16으로 나누어떨어지지 않으면 정수 나눗셈에서 먼저 버려진 값이 곱해져 오차가 커집니다. 대신 곱셈을 먼저 하면 큰 해상도에서 int 오버플로우 가능성이 생기므로 값의 범위에 맞는 타입을 고르는 것도 함께 고려해야 합니다. 21:9처럼 정확히 나누어떨어지지 않는 비율(2560 × 9 / 21 = 1097.14…)은 실제 제품 해상도(2560×1080)와 맞지 않기도 하는데, 이는 마케팅 비율 “21:9”가 실제로는 64:27에 가까운 근삿값이기 때문입니다.

FAQ

Q1: ratio는 언제 사용하나요?

A:

  • std::chrono::duration의 시간 단위 정의
  • 타입 안전 단위 시스템 구현
  • 컴파일 타임 분수 연산

Q2: 런타임 분수 계산은 어떻게 하나요?

A: std::ratio는 컴파일 타임 전용입니다. 런타임 분수는 double 또는 별도의 분수 클래스를 사용하세요.

// 컴파일 타임
using Half = std::ratio<1, 2>;

// 런타임
double half = 1.0 / 2.0;

Q3: 성능 영향은?

A: 없습니다. 모든 계산이 컴파일 타임에 수행되므로 런타임 오버헤드가 없습니다.

Q4: 오버플로우는 어떻게 방지하나요?

A: std::ratio는 std::intmax_t를 사용하므로 큰 값도 지원하지만, 매우 큰 값은 오버플로우 가능합니다. 적절한 범위를 사용하세요.

// ✅ 안전
using Safe = std::ratio<1000000, 1>;

// ⚠️ 주의: 매우 큰 값
// using Unsafe = std::ratio<INTMAX_MAX, 1>;

Q5: ratio를 런타임 값으로 변환하려면?

A: num과 den을 사용하여 double로 변환합니다.

using OneThird = std::ratio<1, 3>;
double value = static_cast<double>(OneThird::num) / OneThird::den;
std::cout << value << '\n';  // 0.333333...

Q6: 음수 ratio를 만들 수 있나요?

A: 가능합니다. 분자가 음수이면 전체 ratio가 음수입니다. 분모에 음수를 넣어도(std::ratio<1, -2>) 부호가 분자로 옮겨져 num은 -1, den은 2가 됩니다.

using NegativeHalf = std::ratio<-1, 2>;
std::cout << NegativeHalf::num << "/" << NegativeHalf::den << '\n';  // -1/2

Q7: ratio 학습 리소스는?

A:

관련 글: std::chrono::duration, Time Conversion.

std::ratio는 컴파일 타임 분수 타입으로, 자동 약분되며 chrono::duration의 시간 단위를 정의합니다.


같이 보면 좋은 글