콘텐츠로 이동

Rust 날짜와 시간 다루기 (chrono)

프로그램은 늘 시간을 다뤄요. “이 작업이 얼마나 걸렸지?” “청구서 마감일이 언제지?” “여행까지 며칠 남았지?” Rust는 이런 질문을 두 종류로 나눠서 생각해요.

표준 라이브러리의 std::time 모듈은 스톱워치예요. 시간이 얼마나 흘렀는지를 재요. 달력이나 월, 요일은 전혀 몰라요. 달력이 필요한 일에는 거의 모든 Rust 프로젝트가 chrono 크레이트를 써요. 이 페이지는 스톱워치부터 시작해서 달력으로 넘어가요.

Instant::now()는 컴퓨터 시계의 값을 하나 읽어 둬요. 나중에 .elapsed()를 부르면, 그 시점부터 시간이 얼마나 흘렀는지 Duration으로 알려 줘요.

use std::time::{Duration, Instant};
fn main() {
let start = Instant::now();
let mut total: u64 = 0;
for i in 0..1_000_000 {
total += i;
}
let elapsed = start.elapsed();
println!("합계 = {total}");
println!("걸린 시간: {:?}", elapsed);
println!("걸린 시간: {} ms", elapsed.as_millis());
println!("5초 미만인가요? {}", elapsed < Duration::from_secs(5));
}
▶ 직접 실행해 보기

여기서는 일부러 실행 결과를 싣지 않았어요. 걸리는 시간은 컴퓨터마다, 실행할 때마다 달라지거든요. 걸린 시간: 6.862793ms 같은 결과가 나올 거예요. 버튼을 두 번 눌러서 결과를 비교해 보세요.

Instant는 시간을 재는 데만 쓸 수 있어요. 날짜로 출력할 수도 없고, 값은 앞으로만 흘러가요. 코드 실행 시간을 잴 때는 바로 그게 좋아요. 누군가 시스템 시계를 바꿔도 전혀 영향을 받지 않으니까요.

Duration은 “90초”처럼 시간의 길이를 나타내요. 초나 밀리초로 만들고, 서로 더하고, 비교할 수 있어요.

use std::time::Duration;
fn main() {
let short = Duration::from_millis(1500);
let long = Duration::from_secs(90);
println!("{:?}", short);
println!("{:?}", long);
println!("{} ms", short.as_millis());
println!("{:.1} s", short.as_secs_f64());
let total = short + long;
println!("합계: {:?}", total);
println!("{}분 {}초", long.as_secs() / 60, long.as_secs() % 60);
println!("{}", short < long);
}

실행 결과:

1.5s
90s
1500 ms
1.5 s
합계: 91.5s
1분 30초
true
▶ 직접 실행해 보기

Duration은 여기저기서 만나게 돼요. std::thread::sleep(Duration::from_millis(100))은 스레드를 잠시 멈추고, 네트워크 라이브러리의 타임아웃도 Duration으로 지정해요.

SystemTime은 컴퓨터 시계의 시각, 그러니까 화면 구석에 보이는 그 시각이에요. 보통은 유닉스 에포크(Unix epoch), 즉 1970년 1월 1일(UTC)부터 시간이 얼마나 흘렀는지 물어볼 때 써요. 이 숫자를 유닉스 타임스탬프라고 해요.

use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
let now = SystemTime::now();
let since_1970 = now
.duration_since(UNIX_EPOCH)
.expect("시계가 1970년 이전으로 맞춰져 있어요");
println!("1970년 1월 1일부터 지난 초: {}", since_1970.as_secs());
}
▶ 직접 실행해 보기

이 숫자도 언제 실행하느냐에 따라 달라지니까 실행 결과는 싣지 않았어요. 그런데 duration_since는 왜 Result를 돌려줄까요? 시스템 시계는 틀릴 수도 있고, 뒤로 돌아갈 수도 있기 때문이에요. 코드 실행 시간을 잴 때 SystemTime 말고 Instant를 써야 하는 이유도 바로 이거예요.

std::time이 하는 일은 대략 여기까지예요. “3월”이나 “금요일”이 뭔지는 전혀 몰라요. 그런 일에는 chrono가 필요해요.

프로젝트에 크레이트를 추가하세요(처음이라면 Cargo와 크레이트를 먼저 읽어 보세요).

Terminal window
cargo add chrono
[dependencies]
chrono = "0.4"

Rust Playground에도 chrono가 들어 있어서, 아래의 버튼은 모두 동작해요. chrono의 타입은 크게 두 가족으로 나뉘어요.

  • Naive(나이브) 타입(NaiveDate, NaiveTime, NaiveDateTime)은 시간대가 없는 날짜와 시간이에요. 달력에 적어 둔 “2025년 3월 14일”이나 “9시 30분” 같은 거예요. 평소 코드에서는 대부분 이쪽을 써요.
  • DateTime<Tz>는 DateTime<Utc>나 DateTime<Local>처럼 시간대가 있는 한 순간을 나타내요.

NaiveDate::from_ymd_opt(년, 월, 일)로 날짜를 만들어요. 반환값은 Option이에요. 모든 조합이 실제로 있는 날짜는 아니니까요.

use chrono::{Datelike, NaiveDate, NaiveTime};
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap();
println!("{date}");
println!("{}년 {}월 {}일", date.year(), date.month(), date.day());
let time = NaiveTime::from_hms_opt(9, 30, 0).unwrap();
let meeting = date.and_time(time);
println!("{meeting}");
let other = date.and_hms_opt(17, 45, 10).unwrap();
println!("{other}");
let impossible = NaiveDate::from_ymd_opt(2025, 2, 30);
println!("{:?}", impossible);
}

실행 결과:

2025-03-14
2025년 3월 14일
2025-03-14 09:30:00
2025-03-14 17:45:10
None
▶ 직접 실행해 보기

2월 30일은 없는 날짜라서, 망가진 날짜 대신 None이 나와요. 직접 적어 넣은 정해진 값이라면 .unwrap()을 써도 괜찮아요. 사용자에게서 받은 값이라면 None도 처리해 주세요.

use chrono::Datelike 줄이 .year(), .month(), .day() 메서드를 쓸 수 있게 해 줘요. (.hour()와 .minute()에는 Timelike가 같은 역할을 해요.)

Utc::now()는 지금 이 순간을 UTC로 알려 줘요. Local::now()는 그 컴퓨터의 시간대로 알려 줘요.

use chrono::{Local, Utc};
fn main() {
let utc_now = Utc::now();
let local_now = Local::now();
println!("UTC: {}", utc_now);
println!("로컬: {}", local_now);
println!("오늘: {}", local_now.format("%Y-%m-%d"));
let today = local_now.date_naive();
println!("NaiveDate로 바꾸면: {today}");
}
▶ 직접 실행해 보기

출력은 오늘 날짜라서 매일 달라져요. Playground 서버는 UTC를 쓰기 때문에, 거기서는 두 줄에 같은 시각이 나와요. .date_naive()는 시간대와 시각을 떼어 내고 평범한 NaiveDate만 남겨요. 좋은 습관 하나: “지금”은 프로그램의 바깥쪽에서 한 번만 구하고, 그 날짜를 함수에 넘겨 주세요. 그러면 함수를 테스트하기가 쉬워져요. 마감일 예제에서 직접 보게 될 거예요.

.format()에는 패턴을 넘겨요. %로 시작하는 코드가 각각 날짜의 한 부분으로 바뀌어요. %Y는 연도, %m은 월 숫자, %d는 일이에요.

use chrono::NaiveDate;
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap();
let dt = date.and_hms_opt(9, 5, 0).unwrap();
println!("{}", date.format("%Y-%m-%d"));
println!("{}", date.format("%d/%m/%Y"));
println!("{}", date.format("%A, %B %-d, %Y"));
println!("{}", date.format("%a %d %b"));
println!("{}", dt.format("%H:%M"));
println!("{}", dt.format("%-I:%M %p"));
let file_name = date.format("report-%Y%m%d.txt").to_string();
println!("{file_name}");
}

실행 결과:

2025-03-14
14/03/2025
Friday, March 14, 2025
Fri 14 Mar
09:05
9:05 AM
report-20250314.txt
▶ 직접 실행해 보기

가장 많이 쓰는 코드는 이거예요. %Y 연도, %m 월(01–12), %d 일(01–31), %H 시(00–23), %M 분, %S 초, %A 요일 이름, %B 월 이름. % 뒤에 -를 붙이면 앞의 0이 빠져서, %-d는 05 대신 5를 출력해요. .format()이 돌려주는 값은 바로 출력할 수 있어요. String이 필요하면 .to_string()을 부르세요.

요일 이름과 월 이름(%A, %B, %a, %b)은 영어로 나와요. 한국식으로 쓰고 싶다면 패턴 안에 한글을 그대로 적으면 돼요. date.format("%Y년 %-m월 %-d일")은 2025년 3월 14일이 돼요.

NaiveDate::parse_from_str은 그 반대 일을 해요. 같은 % 패턴으로 텍스트를 읽어요. 텍스트가 패턴과 맞지 않을 수도 있으니 Result를 돌려줘요.

use chrono::{NaiveDate, NaiveDateTime};
fn main() {
let a = NaiveDate::parse_from_str("2025-03-14", "%Y-%m-%d");
println!("{:?}", a);
let b = NaiveDate::parse_from_str("14/03/2025", "%d/%m/%Y");
println!("{:?}", b);
let c: Result<NaiveDate, _> = "2025-03-14".parse();
println!("{:?}", c);
let d = NaiveDateTime::parse_from_str("2025-03-14 09:30", "%Y-%m-%d %H:%M");
println!("{:?}", d);
for text in ["2025-13-01", "March 14", "2025-03"] {
match NaiveDate::parse_from_str(text, "%Y-%m-%d") {
Ok(date) => println!("파싱 성공: {date}"),
Err(e) => println!("{text:?} 파싱 실패: {e}"),
}
}
}

실행 결과:

Ok(2025-03-14)
Ok(2025-03-14)
Ok(2025-03-14)
Ok(2025-03-14T09:30:00)
"2025-13-01" 파싱 실패: input is out of range
"March 14" 파싱 실패: input contains invalid characters
"2025-03" 파싱 실패: premature end of input
▶ 직접 실행해 보기

표준 형식인 YYYY-MM-DD라면 그냥 .parse()를 불러도 돼요. 13월은 없으니까 첫 번째 잘못된 예는 “out of range”가 나와요. 에러 메시지는 chrono가 내는 영어 그대로인데, 각각 “범위를 벗어남”, “쓸 수 없는 문자가 들어 있음”, “입력이 중간에 끝남”이라는 뜻이에요. 짧지만 사용자에게 보여 줘도 될 만큼 분명해요.

날짜에 며칠을 더하는 계산을 직접 짜 보면 생각보다 어려워요. 달마다 날 수가 다르고, 윤년도 있으니까요. chrono가 대신 해 줘요. Days와 함께 checked_add_days를, Months와 함께 checked_add_months를 쓰거나, TimeDelta를 더하면 돼요.

use chrono::{Days, Months, NaiveDate, TimeDelta};
fn main() {
let start = NaiveDate::from_ymd_opt(2025, 1, 31).unwrap();
let plus_30 = start.checked_add_days(Days::new(30)).unwrap();
println!("30일 후: {plus_30}");
let minus_7 = start.checked_sub_days(Days::new(7)).unwrap();
println!("7일 전: {minus_7}");
let next_month = start.checked_add_months(Months::new(1)).unwrap();
println!("1개월 후: {next_month}");
let week_later = start + TimeDelta::weeks(1);
println!("1주 후: {week_later}");
let dt = start.and_hms_opt(23, 30, 0).unwrap();
let later = dt + TimeDelta::hours(2);
println!("2시간 후: {later}");
}

실행 결과:

30일 후: 2025-03-02
7일 전: 2025-01-24
1개월 후: 2025-02-28
1주 후: 2025-02-07
2시간 후: 2025-02-01 01:30:00
▶ 직접 실행해 보기

“1개월 후”를 보세요. 1월 31일에 한 달을 더하면 2월 28일이에요. 2월 31일은 없으니까 chrono가 유효한 마지막 날을 골라 줘요. checked_로 시작하는 메서드는 Option을 돌려주는데, 결과가 chrono가 다룰 수 있는 (아주 넓은) 연도 범위를 벗어날 때만 None이 돼요. 23시 30분에 2시간을 더하면 제대로 다음 날로 넘어가요.

Datelike의 .weekday()로 요일을 알 수 있어요. 날짜에서 다른 날짜를 빼면 TimeDelta가 나오고, .num_days()로 그걸 숫자로 바꿀 수 있어요.

use chrono::{Datelike, NaiveDate, Weekday};
fn main() {
let date = NaiveDate::from_ymd_opt(2025, 3, 15).unwrap();
let day = date.weekday();
println!("{date}의 요일: {day} ({})", date.format("%A"));
let weekend = matches!(day, Weekday::Sat | Weekday::Sun);
println!("주말인가요? {weekend}");
let start = NaiveDate::from_ymd_opt(2025, 1, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2025, 12, 25).unwrap();
let gap = end - start;
println!("{}일, 꽉 채운 주로는 {}주", gap.num_days(), gap.num_weeks());
println!("거꾸로 빼면: {}일", (start - end).num_days());
let check_in = start.and_hms_opt(14, 0, 0).unwrap();
let check_out = NaiveDate::from_ymd_opt(2025, 1, 4).unwrap().and_hms_opt(11, 0, 0).unwrap();
println!("호텔 숙박: {}시간", (check_out - check_in).num_hours());
}

실행 결과:

2025-03-15의 요일: Sat (Saturday)
주말인가요? true
358일, 꽉 채운 주로는 51주
거꾸로 빼면: -358일
호텔 숙박: 69시간
▶ 직접 실행해 보기

Weekday는 Mon부터 Sun까지의 배리언트를 가진 열거형이라서 match로 나눠 처리할 수 있어요. 빼는 쪽 날짜가 더 나중이면 차이가 음수가 된다는 점을 눈여겨보세요. 이게 쓸모가 있어요. 남은 날이 음수라면 “기한이 지났다”는 뜻이니까요.

예제: 마감일까지 며칠 남았을까?

섹션 제목: “예제: 마감일까지 며칠 남았을까?”

지금까지 배운 걸 합쳐 볼게요. 이 함수는 안에서 Local::now()를 부르지 않고, “오늘”을 매개변수로 받아요. 그래서 아래 실행 결과는 언제나 같고, 함수를 테스트하기도 쉬워요. 실제 프로그램에서는 Local::now().date_naive()를 넘기면 돼요.

use chrono::NaiveDate;
fn days_left(today: NaiveDate, deadline: NaiveDate) -> String {
let days = (deadline - today).num_days();
match days {
0 => String::from("due today"),
1 => String::from("due tomorrow"),
d if d > 1 => format!("{d} days left"),
-1 => String::from("1 day overdue"),
d => format!("{} days overdue", -d),
}
}
fn main() {
let today = NaiveDate::from_ymd_opt(2025, 6, 10).unwrap();
let tasks = [
("Pay rent", "2025-06-10"),
("Send report", "2025-06-11"),
("Renew passport", "2025-07-01"),
("Return library book", "2025-06-09"),
("Dentist bill", "2025-06-03"),
];
println!("Today is {}", today.format("%A, %-d %B %Y"));
for (name, date_text) in tasks {
let deadline = NaiveDate::parse_from_str(date_text, "%Y-%m-%d").unwrap();
println!("{:<20} {} {}", name, deadline.format("%d %b"), days_left(today, deadline));
}
}

실행 결과:

Today is Tuesday, 10 June 2025
Pay rent 10 Jun due today
Send report 11 Jun due tomorrow
Renew passport 01 Jul 21 days left
Return library book 09 Jun 1 day overdue
Dentist bill 03 Jun 7 days overdue
▶ 직접 실행해 보기

today를 다른 날짜로 바꿔서 다시 실행해 보세요. {:<20}은 이름을 20칸에 맞춰 채워서 열이 가지런해지게 해요(출력 참고). 이 예제의 문자열을 영어로 둔 것도 그래서예요. 한글은 화면에서 두 칸을 차지하고 UTF-8로는 한 글자에 3바이트라서, 한글을 넣으면 {:<20}으로 열이 맞지 않아요.

chrono만 있는 건 아니에요. time 크레이트는 더 작고 인기 있는 대안이고, 새로 나온 jiff 크레이트는 시간대와 서머타임을 특히 꼼꼼하게 다뤄요. 여기서 chrono를 쓰는 건 가장 널리 쓰이고 Playground에도 들어 있기 때문이에요.

use chrono::Datelike를 빠뜨리기. 이게 없으면 .year(), .month(), .weekday()가 동작하지 않아요. 에러는 메서드가 “private”(비공개)라고 해서 헷갈리지만, help 줄에 진짜 해결책이 나와 있어요.

error[E0624]: method `year` is private
--> src/main.rs:5:25
|
5 | println!("{}", date.year());
| ^^^^ private method
|
= help: items from traits can only be used if the trait is in scope
help: trait `Datelike` which provides `year` is implemented but not in scope; perhaps you want to import it
|
1 + use chrono::Datelike;
|

“트레이트의 메서드는 그 트레이트가 스코프 안에 있을 때만 쓸 수 있어요. use chrono::Datelike;를 추가하세요”라는 뜻이에요.

%m과 %M 헷갈리기. %m은 월, %M은 분이에요. 날짜와 시간이 함께 있는 값이라면 잘못 써도 에러가 나지 않아요. 아무 말 없이 분을 출력해 버려요.

use chrono::NaiveDate;
fn main() {
let meeting = NaiveDate::from_ymd_opt(2025, 3, 14).unwrap().and_hms_opt(9, 5, 0).unwrap();
println!("{}", meeting.format("%Y-%M-%d")); // 틀림: 분이 나와요
println!("{}", meeting.format("%Y-%m-%d")); // 맞음: 월이 나와요
}

실행 결과:

2025-05-14
2025-03-14
▶ 직접 실행해 보기

SystemTime으로 코드 실행 시간 재기. 시스템 시계는 (예를 들어 인터넷과 시각을 맞출 때) 갑자기 건너뛸 수 있어요. 시간을 잴 때는 Instant를 쓰고, 실제 날짜가 필요할 때만 SystemTime이나 chrono를 쓰세요.

로직 깊숙한 곳에서 Local::now() 부르기. 결과가 날마다 바뀌고 테스트도 쓰기 어려워져요. days_left처럼 날짜를 매개변수로 넘겨 주세요.

  • std::time::Instant는 걸린 시간을 재고, Duration은 시간의 길이이며, SystemTime은 실제 시계예요(유닉스 타임스탬프에 좋아요).
  • 달력이 필요하면 chrono를 추가하세요. 시간대 없는 날짜에는 NaiveDate / NaiveDateTime을, 지금 이 순간에는 Utc::now() / Local::now()를 써요.
  • 모든 입력이 실제 날짜는 아니니까 from_ymd_opt는 Option을, parse_from_str은 Result를 돌려줘요.
  • .format("%Y-%m-%d")는 % 코드로 날짜를 텍스트로 바꿔요. 기억하세요. %m은 월, %M은 분이에요.
  • 시간을 더할 때는 checked_add_days, checked_add_months, + TimeDelta를 써요. 두 날짜를 빼면 TimeDelta가 나오고, .num_days()로 날 수를 얻어요.
  • 함수 안에서 시계를 읽지 말고, “오늘”을 함수에 넘겨 주세요.

이해도 점검

1. 코드 한 부분이 얼마나 걸리는지 재려면 어떤 타입을 써야 할까요?

2. NaiveDate::from_ymd_opt(2025, 2, 30)은 무엇을 돌려줄까요?

3. chrono 포맷 문자열에서 %M은 무슨 뜻일까요?

4. 두 NaiveDate를 deadline - today처럼 뺐어요. 날 수는 어떻게 얻을까요?

5. chrono에서 2025년 1월 31일에 Months::new(1)을 더하면 무엇이 될까요?

점수: 0 / 5