1. 버그 공장
만약 여러분이 아래처럼 코드를 본 적이 있다면, timeout = 5000;, 이미 흥미로운 문제가 있다는 것을 아실 겁니다: 5000이 무슨 단위인가? 밀리초인가요? 초인가요? 마이크로초인가요? 아니면 사실은 “5초”인데 누군가 눈대중으로 * 1000을 곱하고 잊어버린 건 아닐까요. 시간은 특히 까다로운 영역입니다: 단위 관련 오류는 그럴듯하게 보이고 때로는 “거의 동작하기” 때문에 야간 디버깅의 좋은 후보가 됩니다.
예를 들어 여러분이 학습용 콘솔 앱 StudyPlanner을 개발한다고 합시다(가상의 학습 계획기). 설정 파일에 '자동저장 주기'가 있습니다. 프로그램의 한 부분에서는 값이 초 단위라고 생각하고, 다른 부분에서는 밀리초 단위라고 생각하면, 애플리케이션은 너무 자주 저장하거나(성가심), 너무 드물게 저장해서 데이터가 손실되거나 합니다. 두 경우 모두 사용자는 그 동작을 정상적이라고 받아들일 수 있고 버그 리포트를 받을 때까지 문제를 알아차리지 못할 수 있습니다.
그래서 표준 라이브러리에는 std::chrono::duration이 있습니다: 단위가 타입에 내장된 타입으로, 컴파일러가 여러분을 도와주기 시작합니다.
2. std::chrono::duration: «숫자 + 단위»
처음에 std::chrono::milliseconds{250}를 보면 C++가 갑자기 친절해진 것처럼 느껴질 수 있습니다. 하지만 이것은 마법이나 시간 프레임워크가 아니라 단순한 아이디어입니다: duration은 '얼마나'와 '무슨 단위로'의 쌍입니다. 그리고 '무슨 단위로'는 주석이 아니라 타입 자체에 들어 있어 컴파일러가 검사합니다.
가장 간단한 예부터 시작합시다: 헤더를 포함하고 몇 개의 duration을 만듭니다.
#include <chrono>
int main() {
std::chrono::milliseconds t1{250};
std::chrono::seconds t2{2};
(void)t1;
(void)t2;
}
여기서 가장 큰 장점은 가독성입니다: 주석이 없어도 어디가 밀리초고 어디가 초인지 알 수 있습니다. 이런 기반 위에서 신뢰할 수 있는 코드를 만들기 더 쉬워집니다.
내부(템플릿 수준)에서는 duration이 대략 duration<Rep, Period>처럼 보입니다. 여기서 Rep은 숫자 타입(예: long long)이고, Period는 분수 형태의 '스텝'(std::ratio)입니다. 하지만 지금은 이 내부를 몰라도 괜찮고 '타입이 단위를 담는다'는 사실만 이해하면 됩니다.
미리 정의된 시간 단위
실무에서는 보통 임의의 ratio를 다루고 싶지 않습니다. 우리가 필요한 것은 초, 밀리초, 분, 시간 같은 익숙한 단위입니다. std::chrono에는 이런 단위의 별칭이 준비되어 있어 편리합니다. 직접 'ms'를 정의하거나 상수를 직접 쓰지 않아도 됩니다.
아래는 머릿속에 '시간 눈금'을 고정시키기 위한 작은 표입니다:
| Duration type | 의미 | 적합한 경우 |
|---|---|---|
|
나노초 | 매우 정밀한 측정(그러나 잡음이 많을 수 있음) |
|
마이크로초 | 빠른 연산의 측정 |
|
밀리초 | UI/네트워크/대기 타임아웃 |
|
초 | 사람이 이해하기 쉬운 간격 |
|
분 | 리마인더/플랜/주기 |
|
시간 | 스케줄/긴 간격 |
우리 StudyPlanner에서는 예를 들어 "25분마다 알림"은 std::chrono::minutes로, "자동저장 최소 대기"는 std::chrono::milliseconds로 저장하는 것이 자연스럽습니다.
duration 생성: 왜 {}가 좋은가
milliseconds{250}를 쓸 때, 값을 만들면서 동시에 단위를 문서화하는 효과가 있습니다. 이것은 변수 이름을 autosave_interval_ms처럼 짓는 것과 비슷하지만 더 낫습니다: 컴파일러가 타입을 실제로 검사해 잘못된 타입 사용을 막아줍니다.
예를 들어 플래너에는 '자동저장 간격'과 '알림 간격'이 있습니다.
#include <chrono>
struct Config {
std::chrono::milliseconds autosaveInterval{1500};
std::chrono::minutes reminderEvery{25};
};
int main() {
Config cfg{};
(void)cfg;
}
여기서 좋은 점은 필드가 즉시 '올바른' 타입으로 초기화되어 이후 코드에서 단위를 거의 신경 쓰지 않아도 된다는 것입니다. 우리는 이전 강의에서 강한 타입과 계약의 가치를 이야기했는데, duration은 시간에 관한 그런 계약입니다.
3. .count() 없이도 가능한 duration 산술
duration를 사용할 때, 종종 '.count()'로 '숫자'를 꺼내어 이전처럼 산술을 하고 싶어집니다. 하지만 chrono의 핵심은 가능한 한 오래 타입 안전한 duration 세계 안에 머무르는 것입니다.
덧셈과 비교는 바로 작동합니다:
#include <chrono>
#include <iostream>
int main() {
auto total = std::chrono::seconds{2} + std::chrono::milliseconds{500};
if (total > std::chrono::seconds{2}) {
std::cout << "Longer than 2 seconds\n"; // Longer than 2 seconds
}
}
여기서 중요한 점은 total이 여전히 duration이라는 것입니다. 컴파일러는 정보 손실을 피하기 위해 적절한 '공통 duration 타입'(보통 더 정밀한 단위)을 선택합니다.
예를 들어 '1초보다 큰지'를 확인하려면 이렇게 하세요:
#include <chrono>
#include <iostream>
int main() {
std::chrono::milliseconds t{1500};
if (t > std::chrono::seconds{1}) {
std::cout << "More than 1 second\n"; // More than 1 second
}
}
이 코드는 사람의 문장처럼 읽힙니다. 더 중요한 것은 '1500 밀리초'를 단순한 숫자 '1'과 비교할 수 없다는 점입니다. 왜냐하면 비교 대상이 모두 duration이기 때문입니다.
4. .count() — 경계에서만
.count()는 '원시 숫자'를 반환합니다. 함정은 이 숫자가 현재 타입의 단위로 반환된다는 점입니다. 예를 들어 milliseconds{1500}.count()는 1500을 반환하고, seconds{1500}.count()도 1500을 반환하지만 의미는 완전히 다릅니다.
따라서 좋은 스타일 규칙은 다음과 같습니다: .count()는 출력/저장/외부 API 경계에서만 사용하세요, 즉 숫자가 필요한 경우에만 사용합니다.
예: 자동저장 간격을 밀리초로 출력할 때:
#include <chrono>
#include <iostream>
int main() {
std::chrono::milliseconds autosave{1500};
std::cout << autosave.count() << " ms\n"; // 1500 ms
}
여기서 접미사 " ms"는 장식이 아니라 실용적인 의미입니다. 단지 1500만 출력하면 나중에 혼란을 초래할 수 있습니다.
5. 단위 변환과 duration_cast
시간 단위 변환은 오류가 가장 자주 발생하는 지점입니다. 표준 라이브러리는 이를 고려해서 일부 변환은 암묵적으로 허용하고, 다른 변환은 프로그래머의 명시적 행동을 요구합니다.
직관적 규칙은 이렇습니다: 더 작은 단위로의 변환은 보통 안전합니다(정밀도 손실 없음). 반대로 더 큰 단위로의 변환은 보통 소수점 이하를 버리게 되어 명시성이 필요합니다.
무손실 변환: 초 → 밀리초
이 경우 대부분 자연스럽게 이루어집니다: 2초는 정확히 2000밀리초입니다.
#include <chrono>
#include <iostream>
int main() {
std::chrono::seconds s{2};
std::chrono::milliseconds ms = s;
std::cout << ms.count() << " ms\n"; // 2000 ms
}
이 방식의 편의성은 설정에서 seconds를 지정하고, 시스템 내부는 밀리초로 동작하게 할 수 있다는 점입니다. 수동으로 1000을 곱할 필요가 없습니다.
정밀도 손실: milliseconds → seconds via duration_cast
예를 들어 1500ms는 1.5s입니다. 하지만 std::chrono::seconds는 정수 초를 저장하므로 소수부를 어떻게 처리할지 결정해야 합니다. std::chrono의 기본 정책은 절삭(truncation)입니다.
#include <chrono>
#include <iostream>
int main() {
std::chrono::milliseconds ms{1500};
auto s = std::chrono::duration_cast<std::chrono::seconds>(ms);
std::cout << s.count() << " s\n"; // 1 s
}
이것은 '이상한 동작'이 아니라 정직한 계약입니다: 반올림이 필요하면 명시적으로 구현해야 합니다. 기본 duration_cast는 '타입 변환(가능한 절삭)'입니다.
6. 정밀도: 시간 의미를 잃기 쉬운 곳
시간과 관련된 가장 안타까운 오류는 '초와 밀리초를 섞은 것'보다 '필요 이상으로 일찍 정밀도를 잃은 것'입니다.
예를 들어 2500ms 간격을 초로 저장하면 2초가 됩니다. 만약 이 값이 알림 간격이라면 사용자는 기대보다 약간 일찍 알림을 받게 됩니다. 개별적으로는 사소하지만 누적되면 플래너가 의도와 다르게 동작합니다.
실용적 조언은 다음과 같습니다: 내부 로직에서는 충분히 정밀한 단위를 사용하고, 출력/저장 시에만 필요한 단위로 변환하세요.
예를 들어 StudyPlanner에서는 내부적으로 '짧은' 간격은 밀리초로, 사람이 이해하는 간격(예: 공부 블록 길이)은 분 단위로 유지하는 정책을 채택할 수 있습니다. 중요한 것은 이 규칙을 의식적으로 정하는 것입니다.
소수 duration: std::chrono::duration<double>
때로는 소수가 의미가 있을 수 있습니다. 예: '1.5초'나 '0.1초' 같은 경우입니다. 이때 duration<double>을 사용할 수 있습니다.
하지만 소수 duration이 항상 더 좋은 것은 아닙니다. 소수는 정밀도를 흐리게 하고 예상치 못한 곳에서 반올림 이슈를 불러올 수 있습니다. 기본적으로는 정수 밀리초/마이크로초를 사용하고, 정말 필요할 때만 소수를 도입하세요.
예: 1.9s가 있고 이를 밀리초로 변환할 때:
#include <chrono>
#include <iostream>
int main() {
std::chrono::duration<double> sec{1.9};
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(sec);
std::cout << ms.count() << " ms\n"; // 1900 ms
}
반대로(위험한 시나리오): 소수 초를 정수 초로 변환하면 소수 부분이 사라집니다.
#include <chrono>
#include <iostream>
int main() {
std::chrono::duration<double> sec{1.9};
auto s = std::chrono::duration_cast<std::chrono::seconds>(sec);
std::cout << s.count() << " s\n"; // 1 s
}
다시 말하지만 이것은 절삭입니다. 0.1초 단위의 정밀도가 필요하다면 seconds로 변환하지 말거나 밀리초로 저장하세요.
7. 애플리케이션에 작은 적용: 타임아웃을 타입으로 표현하기
함수가 int timeout을 받으면, 호출자는 단위를 기억해야 합니다. 반면 함수가 std::chrono::milliseconds timeout을 받으면 단위가 계약의 일부가 됩니다.
작은 StudyPlanner 예제를 만들어 보겠습니다: '자동저장 간격 설정' 함수(여기서는 단순 출력만 합니다).
#include <chrono>
#include <iostream>
void set_autosave_interval(std::chrono::milliseconds interval) {
std::cout << "Autosave interval: " << interval.count() << " ms\n";
}
int main() {
set_autosave_interval(std::chrono::milliseconds{250}); // Autosave interval: 250 ms
set_autosave_interval(std::chrono::seconds{2}); // Autosave interval: 2000 ms
}
두 번째 호출에 주목하세요: 호출자는 seconds로 전달했지만 내부에서는 밀리초로 출력됩니다. 즉 호출자는 초 단위로 생각할 수 있고 내부는 밀리초로 동작하며, 어디에도 수동 곱셈(*1000)이 필요하지 않습니다.
짧은 요약: 코드에서 시간을 다루는 방법
전체 그림을 이해하려면 간단한 '파이프라인'을 기억하세요:
flowchart LR
A["내부 로직
duration 타입 (ms/min)"] --> B["산술 및 비교
duration 대 duration"]
B --> C["경계: 출력/저장"]
C --> D["명시적 단위 변환
duration_cast"]
D --> E["count() + 접미사
' ms' / \" s\""]
요지는 가능한 한 오래 duration으로 살고, 출력/저장 시에만 명시적 duration_cast와 .count()를 사용하는 것입니다.
8. std::chrono::duration 사용 시 흔한 실수
실수 1: 간격을 int로 저장하고 '팀의 규약'에 의존하는 것.
전형적인 실수입니다: int timeout = 5000;이고 주석에 'ms'라고 적혀 있습니다. 주석은 오래되어 틀리게 될 수 있고 코드가 복사되면 새 개발자는 초 단위라고 착각할 수 있습니다. 올바른 방법은 std::chrono::milliseconds{5000} 또는 std::chrono::seconds{5}처럼 타입으로 단위를 표현하는 것입니다.
실수 2: 너무 일찍 .count()를 호출하고 '원시 숫자'로 산술을 하는 것.
한번 .count()를 꺼내면 단위 정보를 잃습니다. 컴파일러는 더 이상 도와주지 못하고 밀리초와 초를 더해서 엉뚱한 결과를 낼 수 있습니다. duration끼리 연산하고 .count()는 출력/직렬화에서만 사용하세요.
실수 3: count()를 단위 표기 없이 출력하는 것.
화면에 1500만 뜨면 수수께끼입니다. 1주일 후엔 밀리초인지 초인지 알기 어렵습니다. 항상 " ms", " s", " min" 같은 접미사를 붙여 로그를 명확하게 만드세요.
실수 4: duration_cast에서 반올림을 기대하는 것.
기본적인 duration_cast는 절삭을 합니다. 반올림이 필요하면 더 작은 단위로 작업하여 수식을 통해 명시적으로 반올림을 구현해야 합니다. 만약 1500ms가 2s가 되길 바란다면 직접 반올림 로직을 써야 합니다.
실수 5: '보기 좋게' 더 큰 단위로 변환해서 의미를 잃는 것.
가끔 모든 것을 seconds로 저장하면 '보기에는' 깔끔할 수 있습니다. 하지만 250ms나 1500ms 같은 타임아웃이 있는 경우 초로 바꾸면 의미를 해칩니다. 내부에서는 충분히 정밀한 단위를 사용하고 출력 시에만 보기 좋은 단위로 변환하세요.
GO TO FULL VERSION