Date는 왜 자기가 "몇 년 몇 월 며칠"인지 스스로 답하지 못하는지, 서머타임 경계에서 "내일 같은 시각"과 "정확히 24시간 뒤"가 왜 갈라지는지, 윤초는 Unix 시간 산술에서 왜 조용히 사라지는지 — 이 챕터는 Date·Calendar·TimeZone 세 타입이 나눠 맡은 책임의 경계선을 따라간다. 여기서 생기는 착각은 로그 타임스탬프 정도로 끝나지 않고, 결제 마감 시각·예약 알림·정산 배치처럼 실제 돈과 사용자 신뢰가 걸린 곳에서 버그로 튀어나온다.
Q1. Date는 내부적으로 무엇을 저장하는가? "타임존이 없다"는 게 무슨 뜻인가?
Date는 Double 하나(timeIntervalSinceReferenceDate)만 들고 있는 값 타입이고, 기준점은 2001-01-01 00:00:00 UTC다. 이 숫자는 절대 시간선 위의 한 점일 뿐, "몇 년 몇 월 며칠 몇 시"라는 개념 자체를 갖고 있지 않다. 사람이 읽는 날짜·시각으로 바꾸려면 반드시 Calendar와 TimeZone이라는 별도의 해석 렌즈를 통과해야 한다. 그래서 같은 Date 값을 서울 렌즈로 읽으면 5월 9일, LA 렌즈로 읽으면 5월 8일처럼 완전히 다른 "날짜"가 나온다 — 값 자체는 단 1비트도 바뀌지 않는다.
원리
Apple 문서는 Date를 이렇게 정의한다: "A Date value encapsulates a single point in time, independent of any particular calendrical system or time zone." 여기서 "특정 달력 체계나 타임존과 무관하다"는 말이 핵심이다. Date는 그레고리력이든 불교력이든, 서울이든 LA든 상관없이 정의되는 절대 좌표 하나 — 기준 시점(2001-01-01 00:00:00 UTC)으로부터 몇 초가 지났는지를 나타내는 부동소수점 오프셋일 뿐이다. ObjC 쪽의 CFAbsoluteTime도 같은 기준점을 공유한다. Unix epoch(1970-01-01)와는 정확히 978,307,200초 차이가 난다.
내부 동작
Date 값 하나는 사실상 8바이트 Double이다. "몇 시인지", "무슨 요일인지" 같은 질문에는 스스로 답할 수 없고, 그 질문에 답하려면 항상 별도의 Calendar + TimeZone이 개입해야 한다. print(date)를 찍으면 UTC로 나오는 것도 Date가 타임존을 들고 있어서가 아니라, description(디버그 출력)이 임의로 UTC를 골라 보여주는 것뿐이다 — 실제로 같은 값을 서울 렌즈로 보면 완전히 다른 벽시계 시각이 나온다.
실무에서 자주 놓치는 지점은 Double의 정밀도가 기준점(2001년)에서 멀어질수록 나빠진다는 것이다. 2026년 근처(timeIntervalSinceReferenceDate ≈ 8억)에서 1마이크로초 차이는 Double의 최소 단위(ULP, unit in the last place)인 약 1.19×10⁻⁷초보다 커서 문제없이 구분된다. 그런데 Date.distantFuture(대략 4001년, timeIntervalSinceReferenceDate ≈ 631억)로 가면 ULP가 약 7.6×10⁻⁶초(7.6마이크로초)까지 벌어져서, 같은 1마이크로초 차이가 두 값이 정확히 같다고(==) 계산돼버린다. "만료 없음"을 distantFuture로 흉내내는 코드에서 그 값끼리 미세하게 비교하는 로직이 있다면 이 지점에서 조용히 깨진다.
실험 · 도구
같은 Date를 두 타임존으로 읽어 날짜가 갈라지는 걸 직접 확인할 수 있다(TimeZone(identifier:)을 고정했으므로 실행 기기의 지역 설정과 무관하게 아래 실측값 그대로 재현된다).
import Foundation
let now = Date(timeIntervalSinceReferenceDate: 800_000_000)
var calSeoul = Calendar(identifier: .gregorian)
calSeoul.timeZone = TimeZone(identifier: "Asia/Seoul")!
var calLA = Calendar(identifier: .gregorian)
calLA.timeZone = TimeZone(identifier: "America/Los_Angeles")!
let seoul = calSeoul.dateComponents([.year, .month, .day, .hour, .minute], from: now)
let la = calLA.dateComponents([.year, .month, .day, .hour, .minute], from: now)
print(seoul.year!, seoul.month!, seoul.day!, seoul.hour!, seoul.minute!)
// 2026 5 9 15 13 — 서울은 5/9 오후 3시 13분
print(la.year!, la.month!, la.day!, la.hour!, la.minute!)
// 2026 5 8 23 13 — LA는 아직 5/8 밤 11시 13분(날짜까지 다르다)Double 정밀도가 기준점에서 멀어질수록 나빠지는 것도 ulp로 직접 잴 수 있다.
import Foundation
let delta = 1e-6 // 1마이크로초 차이
func distinguishable(at t: TimeInterval) -> Bool {
Date(timeIntervalSinceReferenceDate: t) != Date(timeIntervalSinceReferenceDate: t + delta)
}
print(distinguishable(at: 800_000_000))
// true — 2026년 근처에서는 1마이크로초 차이가 구분된다
let farFuture = Date.distantFuture.timeIntervalSinceReferenceDate // 63_113_904_000.0 (약 4001년)
print(distinguishable(at: farFuture))
// false — 4001년 근처에서는 같은 1마이크로초 차이가 "같은 값"으로 뭉개진다
print(farFuture.ulp)
// 7.62939453125e-06 — 이 지점에서 Double이 표현 가능한 최소 간격(약 7.6마이크로초)프로젝트 적용
① 서버와 주고받고 저장하는 시각은 항상 절대값(Date 혹은 ISO 8601 문자열)으로만 들고 다니고, "몇 시로 보일지" 계산은 실제로 화면에 그릴 때만 Calendar+TimeZone을 통과시켜라. 모델에 TimeZone을 미리 구워 넣지 마라 — 사용자가 여행하면 그 순간 바로 틀린 시각이 된다.
import Foundation
// ✅ 모델은 절대 시각(Date)만 들고 있는다 — 타임존은 표시 시점의 몫
struct Meeting {
let startsAt: Date
}
func displayText(for meeting: Meeting, in identifier: String) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: identifier) ?? .current
let comps = calendar.dateComponents([.month, .day, .hour, .minute], from: meeting.startsAt)
return "\(comps.month!)/\(comps.day!) \(comps.hour!):\(comps.minute!)"
}
let meeting = Meeting(startsAt: Date(timeIntervalSinceReferenceDate: 800_000_000))
print(displayText(for: meeting, in: "Asia/Seoul")) // 5/9 15:13
print(displayText(for: meeting, in: "America/Los_Angeles")) // 5/8 23:13
// ❌ 만약 Meeting이 startsAt과 함께 "표시용 타임존 문자열"까지 저장해뒀다면,
// 사용자가 여행 중 다른 지역으로 이동하는 순간 화면에 옛 타임존 시각이 남는다.② distantFuture/distantPast 같은 "센티널" 값끼리 초 단위 미만으로 미세 비교하지 마라. "만료 없음"은 Date?의 nil처럼 타입으로 명시적으로 분리하는 편이 안전하다.
// ❌ "만료 없음"을 distantFuture로 흉내
struct TokenBad {
let expiresAt: Date // 무제한이면 .distantFuture를 넣는 관행
}
// ✅ 옵셔널로 "없음"을 명시 — distantFuture 근처의 정밀도 문제와 무관해진다
struct TokenGood {
let expiresAt: Date? // nil == 만료 없음
}
func isExpired(_ token: TokenGood, now: Date = Date()) -> Bool {
guard let expiresAt = token.expiresAt else { return false }
return now > expiresAt
}"Date에 타임존이 들어있다"는 틀렸다. Date는 순수 초 오프셋 하나뿐이고, print(date)가 UTC로 보이는 것도 description이 임의로 그렇게 표시할 뿐이다. "같은 Date를 여러 타임존에서 출력하면 값이 달라진다"도 부정확한 말이다 — 값(순간)은 절대 바뀌지 않고, 오직 문자열로 표현한 결과만 달라진다.
시계 없이 그냥 "우주가 시작한 뒤로 몇 초가 지났다"는 숫자 하나만 들고 있는 사람이 있다고 해보자. 그 사람한테 "지금 몇 시야?"라고 물으면 스스로는 답을 못 한다. "서울 기준으로 계산해줄까, LA 기준으로 계산해줄까?"라고 되물어야 한다. Date가 딱 그런 사람이다 — 숫자(초)만 들고 있고, 그걸 "몇 년 몇 월 며칠 몇 시"로 바꾸는 건 타임존이라는 번역기의 몫이다. 비유가 깨지는 곳: 실제로는 그 사람(Date)이 들고 있는 숫자 자체는 서울이든 LA든 절대 안 바뀐다. 바뀌는 건 번역된 결과뿐이다.
꼬리 질문
Date 두 개를 ==로 비교했는데 실제로는 미세하게 다른 시각인데 같다고 나올 수 있을까?
Date는 Double 하나이고, 기준점(2001년)에서 멀어질수록 표현 가능한 최소 간격(ULP)이 커진다. distantFuture 근처(약 4001년)에서는 ULP가 약 7.6마이크로초까지 벌어져서, 그보다 촘촘한 차이는 아예 저장할 수 없고 두 값이 ==로 참(true)이 나온다. 실무에서 이 문제가 튀는 지점은 먼 미래의 "센티널" 값끼리 비교하거나, 초 단위 미만의 순서를 Date 하나로 보장하려 할 때다.timeIntervalSinceReferenceDate 대신 timeIntervalSince1970을 쓰면 정밀도가 달라지는가?
timeIntervalSince1970은 Unix epoch 생태계(JSON 타임스탬프, 서버 API)와 맞닿아 있어 상호운용 목적으로 더 자주 쓰인다.distantPast/distantFuture는 실제로 어떤 값이고 어디에 쓰이는가?
Date.distantFuture는 timeIntervalSinceReferenceDate가 약 631억(대략 4001년)인 값이다. "만료 없음", "무기한 타이머", 정렬 시 "가장 나중" 센티널 등으로 관행적으로 쓰이지만, Q1 본문에서 봤듯 그 근처에서는 마이크로초 단위 비교가 뭉개질 수 있다는 트레이드오프가 있다.Q2. Calendar.current / autoupdatingCurrent / Calendar(identifier:)는 각각 언제 쓰는가?
Calendar.current는 접근한 순간의 사용자 설정을 찍어둔 스냅샷이라, 이후 사용자가 지역/캘린더 설정을 바꿔도 이미 들고 있는 값은 그대로다. Calendar.autoupdatingCurrent는 특수한 동적 참조라 앱이 실행되는 도중 사용자가 설정을 바꾸면 그 즉시 반영된다. Calendar(identifier: .gregorian)처럼 특정 식별자로 직접 만들면 사용자 설정과 완전히 무관하게 고정된다 — 비즈니스 로직과 서버 wire format에는 이 고정 버전이 정답이다.
원리
Foundation은 "달력"이 문화권마다 다른 규칙 체계라는 걸 타입으로 인정한다. Calendar는 그레고리력·불교력·이슬람력(3종)·유대력·페르시아력·일본력·중국 음력 등 여러 Identifier를 지원하고, 각각 주의 시작 요일(firstWeekday)이나 새해 첫 주를 정하는 최소 일수(minimumDaysInFirstWeek) 같은 규칙을 스스로 갖는다. 이 규칙들은 지역 설정(Locale)에 따라서도, 명시적 식별자에 따라서도 달라질 수 있다.
내부 동작
실제로 확인해보면 Calendar(identifier: .gregorian)은 firstWeekday = 1(일요일 시작), minimumDaysInFirstWeek = 1이고, Calendar(identifier: .iso8601)은 firstWeekday = 2(월요일 시작), minimumDaysInFirstWeek = 4다(Calendar 문서 참고). ISO 8601 표준이 "그 해의 첫 목요일이 포함된 주"를 1주차로 잡기 때문에 최소 4일 규칙이 필요하다 — 이 차이 하나만으로도 "이번 주가 몇 주차인지" 계산 결과가 캘린더 식별자에 따라 달라질 수 있다.
참고로 Calendar.Identifier에는 한국(Republic of Korea) 전용 식별자는 없다 — 실제로 .republicOfKorea 같은 케이스는 존재하지 않고 컴파일 에러가 난다. 한국은 양력 날짜 계산에는 .gregorian을 쓰고, 음력이 필요한 기능(설날·추석 알림 등)에는 .chinese 식별자로 그레고리력 날짜를 음력으로 환산한다.
| 스냅샷 시점 | 이후 설정 변경 반영 | 주 용도 | |
|---|---|---|---|
Calendar.current | 접근한 순간 | 반영 안 됨 | 짧은 1회성 계산 |
Calendar.autoupdatingCurrent | 항상 최신 | 실시간 반영 | 오래 들고 있는 프로퍼티 |
Calendar(identifier: .gregorian) | 해당 없음 — 고정 | 반영 안 됨 | 비즈니스 로직 · wire format |
실험 · 도구
식별자별 firstWeekday/minimumDaysInFirstWeek 차이와, .chinese 캘린더로 그레고리력 날짜를 음력으로 바꾸는 것 둘 다 TimeZone(identifier: "Asia/Seoul")을 고정한 채 직접 확인할 수 있다.
import Foundation
let gregorian = Calendar(identifier: .gregorian)
print(gregorian.firstWeekday, gregorian.minimumDaysInFirstWeek) // 1 1
let iso = Calendar(identifier: .iso8601)
print(iso.firstWeekday, iso.minimumDaysInFirstWeek) // 2 4
var chineseCal = Calendar(identifier: .chinese)
chineseCal.timeZone = TimeZone(identifier: "Asia/Seoul")!
var seoulGregorian = Calendar(identifier: .gregorian)
seoulGregorian.timeZone = TimeZone(identifier: "Asia/Seoul")!
let feb6_2027 = seoulGregorian.date(from: DateComponents(year: 2027, month: 2, day: 6))!
let lunar = chineseCal.dateComponents([.year, .month, .day], from: feb6_2027)
print(lunar.year!, lunar.month!, lunar.day!)
// 44 1 1 — 그레고리력 2027-02-06이 음력으로는 설날(1월 1일)이다프로젝트 적용
① 뷰모델처럼 오래 들고 있는 프로퍼티는 autoupdatingCurrent로, 함수 안 지역 변수처럼 짧게 쓰고 버릴 계산은 current로 충분하다.
final class ScheduleViewModel {
// ✅ 앱이 오래 떠 있는 동안 사용자가 지역 설정을 바꿔도 즉시 반영된다
private let calendar = Calendar.autoupdatingCurrent
func isToday(_ date: Date) -> Bool {
calendar.isDateInToday(date)
}
}
func quickWeekday(of date: Date) -> Int {
// ✅ 함수 안에서 한 번 쓰고 버릴 계산은 current로 충분
Calendar.current.component(.weekday, from: date)
}② 서버 정산 배치나 결제 마감처럼 사용자 지역 설정과 완전히 무관해야 하는 로직은 Calendar(identifier: .gregorian)과 고정 TimeZone(대개 UTC)으로 못 박아라.
func billingCutoff(for month: DateComponents) -> Date {
var calendar = Calendar(identifier: .gregorian) // 사용자 설정과 무관하게 고정
calendar.timeZone = TimeZone(identifier: "UTC")! // wire format은 항상 UTC
return calendar.date(from: month)!
}
// ❌ 여기서 Calendar.current를 쓰면, 사용자가 캘린더/지역 설정을 바꾸는 순간
// 같은 로직이 다른 결과를 내는 "재현 불가능한" 정산 버그가 된다.③ 음력 기반 기능은 .chinese 캘린더로 변환하되, 표시용 문자열은 사용자 로케일에 맞춰 별도로 포매팅한다.
func isLunarNewYear(_ date: Date, timeZone: TimeZone) -> Bool {
var chineseCal = Calendar(identifier: .chinese)
chineseCal.timeZone = timeZone
let comps = chineseCal.dateComponents([.month, .day], from: date)
return comps.month == 1 && comps.day == 1
}"Calendar.current를 쓰면 항상 최신 사용자 설정이 반영된다"는 틀렸다 — 캡처한 시점의 스냅샷일 뿐이고, 실시간 반영이 필요하면 반드시 autoupdatingCurrent를 써야 한다. 또한 "한국 전용 Calendar 식별자가 있다"도 틀렸다 — Calendar.Identifier에 그런 케이스는 존재하지 않는다.
Calendar.current는 "지금 이 순간 사진 찍어둔 설정표"다. 한 번 찍으면 그 사진은 안 바뀐다. autoupdatingCurrent는 "설정이 바뀌면 실시간으로 다시 그려지는 살아있는 화면"이고, Calendar(identifier: .gregorian)은 아예 "이 나라 이 달력 방식으로만 본다"고 못 박아버린 것이다. 비유가 깨지는 곳: current는 완전히 얼어붙은 사진이 아니라, "다시 접근할 때마다 그 순간 설정으로 새로 사진을 찍되, 한 번 들고 있는 동안은 안 바뀌는" 사진에 더 가깝다.
꼬리 질문
Locale.current와 Calendar.current는 같은 패턴을 공유하는가?
iso8601과 gregorian 캘린더의 차이는 firstWeekday/minimumDaysInFirstWeek뿐인가?
YYYY/yyyy 새해 버그의 근본 원인이기도 하다.Calendar.current가 캡처하는 "설정"에는 timeZone도 포함되는가?
Q3. DST 경계에서 86400초 더하기와 Calendar.date(byAdding:)은 왜 다른 답을 내는가?
"하루는 항상 86400초"라는 가정은 서머타임(DST)이 있는 타임존에서 깨진다. date.addingTimeInterval(86400)은 순수 초 산술이라 "정확히 24시간 뒤"를 구하지만, DST 스프링포워드가 낀 날은 벽시계가 1시간 튀어 있어서 그 결과가 "내일 같은 시각"과 어긋난다. calendar.date(byAdding: .day, value: 1, to:)는 반대로 "달력상 다음 날, 같은 시:분:초"를 목표로 계산해 벽시계 시각을 보존한다. 이 둘은 서로 다른 불변식(시각 보존 vs 경과시간 보존)을 지키는 것이라, DST 경계에서는 반드시 갈라진다 — 어느 한쪽이 버그인 게 아니다.
원리
DST가 있는 타임존에서는 1년에 이틀, 하루가 23시간이거나 25시간이다. Apple DTS 엔지니어 Quinn "The Eskimo!"가 개발자 포럼 스레드에서 남긴 설명이 이 트레이드오프를 정확히 짚는다: "The existence of days that aren't exactly 1,440 minutes means that you have to choose one or the other invariant; you can't have both." — 1,440분(24시간)이 아닌 날이 존재하는 이상, "시각을 보존한다"와 "정확한 경과 시간을 보존한다" 중 하나만 고를 수 있다.
내부 동작
2026년 미국의 DST 시작일(3월 둘째 일요일, 3/8)로 직접 확인한 결과다. America/Los_Angeles 기준 3/7 정오(PST, UTC-8)를 기준점으로 잡고 하루를 더하면:
base.addingTimeInterval(86400)→ 2026-03-08 13:00 (PDT, UTC-7) — 벽시계가 1시간 밀렸다.calendar.date(byAdding: .day, value: 1, to: base)→ 2026-03-08 12:00 (PDT) — 벽시계 정오가 그대로 보존됐다.
이유는 그 사이(3/8 02:00~03:00)에 벽시계가 존재하지 않기 때문이다 — DST 스프링포워드 순간 시계가 02:00에서 바로 03:00으로 건너뛴다. 그래서 정확히 86,400초를 더하면 "사라진 그 1시간"만큼 벽시계가 더 흘러가버려 13:00에 도착하고, Calendar는 그 사라진 시간을 감안해 "같은 시:분:초"를 맞춰준다.
존재하지 않는 시각을 직접 만들어보면 더 명확하다. DateComponents(2026-03-08 02:30)을 calendar.date(from:)으로 만들면 nil이 아니라 03:30으로 밀린 시각이 나온다 — 기본 matchingPolicy가 존재하지 않는 시각을 "다음으로 유효한 시각"으로 옮겨주기 때문이다.
실험 · 도구
아래 코드는 TimeZone(identifier:)을 명시했으므로 실행 기기의 지역 설정과 무관하게 그대로 재현된다.
import Foundation
let tz = TimeZone(identifier: "America/Los_Angeles")!
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = tz
let base = calendar.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 12))!
let plus86400 = base.addingTimeInterval(86400)
let byAddingDay = calendar.date(byAdding: .day, value: 1, to: base)!
print(plus86400 == byAddingDay) // false — 두 결과가 다르다
let a = calendar.dateComponents([.month, .day, .hour], from: plus86400)
let b = calendar.dateComponents([.month, .day, .hour], from: byAddingDay)
print(a.hour!) // 13 — 벽시계 1시간 밀림
print(b.hour!) // 12 — 정오 보존var comps = DateComponents()
comps.year = 2026; comps.month = 3; comps.day = 8
comps.hour = 2; comps.minute = 30
comps.timeZone = tz
let d = calendar.date(from: comps)!
let back = calendar.dateComponents([.hour, .minute], from: d)
print(back.hour!, back.minute!) // 3 30 — 요청한 02:30이 존재하지 않아 03:30으로 밀렸다
// TimeZone API로 전환 시각을 직접 조회할 수도 있다
let justBefore = calendar.date(from: DateComponents(year: 2026, month: 3, day: 8, hour: 1, minute: 59))!
print(tz.isDaylightSavingTime(for: justBefore)) // false
if let transition = tz.nextDaylightSavingTimeTransition(after: justBefore) {
print(calendar.dateComponents([.hour, .minute], from: transition).hour!) // 3 — 02:00이 아니라 03:00에 착지
}프로젝트 적용
① "내일", "다음 주" 같은 달력 개념의 연산은 절대 TimeInterval 초 산술로 하지 말고 Calendar.date(byAdding:)을 써라.
// ❌ "내일 같은 시각"을 초 산술로
let wrongTomorrow = Date().addingTimeInterval(86400)
// ✅ 달력 개념의 연산은 Calendar가 대신 DST를 처리해준다
let rightTomorrow = Calendar.current.date(byAdding: .day, value: 1, to: Date())!② 반복 알림처럼 "존재하지 않는 시각"이나 "두 번 나타나는 시각"을 만날 수 있는 로직은 matchingPolicy를 명시적으로 골라야 한다.
var comps = DateComponents()
comps.hour = 2; comps.minute = 30 // 스프링포워드 날엔 존재하지 않을 수 있는 시각
// .nextTime: 존재하지 않으면 다음으로 유효한 시각으로 밀어준다(기본값과 동일 계열)
let next = Calendar.current.nextDate(
after: Date(),
matching: comps,
matchingPolicy: .nextTime
)③ 반대로 "정확히 24시간마다"가 진짜 요구사항인 경우(예: 서버 배치 주기, 캐시 TTL)는 TimeInterval 산술이 오히려 맞는 선택이다 — "달력상 하루"와 "물리적 24시간"은 서로 다른 요구라서 목적에 맞춰 골라야 한다.
"Calendar.date(byAdding:)이 더 정확하다"는 표현은 부정확하다. 둘 중 하나가 틀린 게 아니라, "달력상 같은 시각을 유지"와 "정확한 경과 시간을 유지"라는 서로 다른 목적을 만족할 뿐이다. 또한 "한국 서비스는 DST를 신경 쓸 필요가 없다"도 위험한 가정이다 — 서버가 다른 지역에 있거나 해외 사용자가 있다면 여전히 영향을 받고, 심지어 한국도 1987~88년에는 서머타임을 실시했다(실측: 1987-05-10 서울 UTC 오프셋 +10, 2026년 같은 날짜는 +9).
"내일 같은 시간에 만나자"는 약속은 시계가 어떻게 바뀌든 "내일 이 시각"을 뜻한다(Calendar.byAdding). 반면 "지금부터 정확히 24시간 뒤에 타이머를 울려줘"는 시계판이 어떻게 되든 정확히 24시간의 길이를 뜻한다(+86400초). 서머타임이 있는 동네에서는 봄날 새벽 시계가 2시에서 갑자기 3시로 튀어버리는데, 그날은 이 두 약속이 서로 다른 결과를 낸다. 비유가 깨지는 곳: 실제로 시간이 "사라지는" 건 아니다. 지구 자전은 그대로이고, 단지 인간이 정한 "시계 표시 규칙(타임존 오프셋)"만 그 순간에 바뀔 뿐이다.
꼬리 질문
가을 폴백(시계를 되돌릴 때 같은 시각이 두 번 나타나는 경우)은 어떤 문제를 만드는가?
DateComponents로 "그날 01:30"을 만들면 어느 쪽 01:30인지 모호해지는데, 실측해보면 기본 정책은 첫 번째로 지나가는(DST가 아직 적용 중인 PDT) 쪽을 고른다. Calendar.RepeatedTimePolicy로 .first/.last 중 어느 쪽을 원하는지 명시할 수 있다.matchingPolicy에는 어떤 옵션들이 있고 무엇이 다른가?
Calendar.MatchingPolicy에는 .nextTime(존재하지 않는 시각이면 다음으로 유효한 시각), .nextTimePreservingSmallerComponents, .previousTimePreservingSmallerComponents, .strict(존재하지 않으면 nil) 네 가지가 있다. 기본값은 .nextTime 계열이라, Q3 본문에서 본 것처럼 02:30이 03:30으로 자동으로 밀렸다. 정확히 nil을 받아 "이 시각은 없다"를 명시적으로 처리하고 싶다면 .strict를 골라야 한다.한국에서만 서비스해도 DST를 신경 써야 하는 이유는?
Q4. 윤초는 Unix time과 Date 산술에서 어떻게 처리되는가?
Unix time과 Date는 윤초(leap second)를 의도적으로 무시한다 — 하루는 항상 정확히 86,400초로 계산된다. 실제 UTC는 지구 자전 지연을 보정하려고 부정기적으로 윤초를 삽입하지만, 그 삽입은 시스템 시계(NTP)가 뒤에서 매끄럽게 흡수해버려 애플리케이션 코드에는 보이지 않는다. 그래서 윤초가 낀 구간을 Date 차이로 재면 실제 물리적 경과 시간보다 정확히 1초 부족하게 계산된다. 실무 결론은 하나다 — 경과 시간(duration)을 재는 데는 절대 벽시계(Date)를 쓰지 말고, ContinuousClock/SuspendingClock 같은 모노토닉 시계를 써라.
원리
UTC는 지구 자전이 불규칙하게 느려지는 걸 보정하려고 IERS(국제 지구자전·기준계 서비스)가 필요할 때 1초를 끼워 넣는 체계다. 반면 Unix time(POSIX time)은 "하루는 항상 86,400초"를 강제하는 단순한 카운터로 설계됐다 — 윤초가 삽입되는 순간엔 그 초를 반복하거나 매끄럽게 늘려버려서, Unix 타임스탬프 자체에는 윤초가 존재하지 않는 것처럼 취급된다.
내부 동작
Date/timeIntervalSince1970도 이 POSIX 관례를 그대로 따른다. 예를 들어 2016-12-31 23:59:60 UTC라는 윤초가 실제로 삽입됐던 날, Date 산술로 "2016-12-31 00:00 UTC ~ 2017-01-01 00:00 UTC" 사이의 경과 시간을 재면 정확히 86,400.0초가 나온다. 하지만 그날은 실제로 윤초 1개가 더 끼어 있었으므로 물리적으로는 86,401초가 흘렀다 — Date 산술은 이 1초를 아예 셈에 넣지 않는다.
이게 왜 위험하냐면, 서버-클라이언트 간 시각 동기화(NTP)는 시스템 시계 자체를 앞뒤로 조금씩 밀어 조정하기도 한다는 사실과 겹치기 때문이다. Date()로 시작 시각을 찍고 한참 뒤 Date()로 끝 시각을 찍어 차이를 구하는 "벽시계 스톱워치"는, 그 사이에 시스템 시계가(윤초든 사용자가 직접 시간을 바꾸든 NTP 보정이든) 조정되면 실제 걸린 시간과 다른 값을 내놓는다. Swift의 ContinuousClock(iOS 16+)은 이런 조정과 무관하게 항상 "실제로 흐른 물리적 시간"만 증가하는 모노토닉 시계이고, SuspendingClock은 여기서 기기가 잠들어 있는 동안(sleep)은 멈춘다는 점만 다르다.
실험 · 도구
윤초가 실제 삽입됐던 날짜 경계를 Date 산술로 재보면 86,400초로만 계산되는 걸 확인할 수 있고, ContinuousClock으로 실제 경과 시간을 재는 표준 패턴도 그대로 재현 가능하다.
import Foundation
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(identifier: "UTC")!
// 2016-12-31 23:59:60 UTC에 실제로 윤초가 삽입됐던 구간을 걸친다
let before = utc.date(from: DateComponents(year: 2016, month: 12, day: 31))!
let after = utc.date(from: DateComponents(year: 2017, month: 1, day: 1))!
print(after.timeIntervalSince(before))
// 86400.0 — 실제로는 윤초 때문에 86401초가 흘렀지만 Date 산술은 이를 반영하지 않는다import Foundation
// ❌ 벽시계(Date)로 경과 시간 측정 — 시스템 시각 조정에 취약하다
let wallStart = Date()
// ... 작업 수행 ...
let wallElapsed = Date().timeIntervalSince(wallStart)
// ✅ ContinuousClock — 시스템 시각이 어떻게 조정되든 실제 물리적 경과만 잰다
let clock = ContinuousClock()
let start = clock.now
// ... 작업 수행 ...
let elapsed = clock.now - start
print(elapsed) // 예: 0.203808958 seconds
// SuspendingClock: 기기가 완전히 잠들어(sleep) 있는 동안은 멈춘다는 점만 다르다
let sclock = SuspendingClock()
let sstart = sclock.now
// ... 작업 수행 ...
print(sclock.now - sstart)프로젝트 적용
① API 응답 시간, 애니메이션 프레임 간격, 타임아웃 계산처럼 "얼마나 걸렸는지"를 재는 모든 코드는 Date 대신 ContinuousClock을 기본값으로 삼아라.
func measure<T>(_ label: String, _ work: () async throws -> T) async rethrows -> T {
let clock = ContinuousClock()
let start = clock.now
defer {
let elapsed = clock.now - start
print("[\(label)] 소요:", elapsed)
}
return try await work()
}② 반대로 "몇 시 몇 분에 일어난 일인지" 같은 시각(instant) 자체가 필요하면 여전히 Date가 맞다 — 모노토닉 시계 값은 임의의 기준점에서 시작하는 상대값이라 "몇 년 몇 월 며칠"로 해석할 수 없다.
struct RequestLog {
let firedAt: Date // "언제" 일어났는지 — 로그·타임스탬프용
let duration: Duration // "얼마나 걸렸는지" — ContinuousClock 결과
}"Date 산술은 윤초까지 정확히 반영한다"는 틀렸다 — 대부분의 시스템 시간 체계처럼 윤초는 무시된다. 반대로 "그러니 Date는 쓰면 안 된다"도 과한 결론이다 — Date는 "언제 일어났는지"를 기록하는 절대 시각 용도로는 여전히 옳은 선택이고, 문제가 되는 건 오직 경과 시간(duration) 측정에 벽시계를 쓰는 경우다.
벽에 걸린 시계(Date)는 가끔 관리인이 와서 맞춰준다 — 표준 시각에 맞춰 살짝 되돌리거나 밀 때가 있다(윤초, 시각 보정). 그래서 "몇 시에 시작했지?"를 기록하는 용도로는 훌륭하지만, "얼마나 걸렸지?"를 재려고 시작할 때 시계를 보고 끝날 때 시계를 다시 보면, 그 사이 관리인이 손을 댔을 경우 실제 걸린 시간과 다르게 나올 수 있다. 스톱워치(모노토닉 시계)는 관리인이 벽시계를 어떻게 만지든 상관없이 버튼을 누른 순간부터 그냥 흐른 시간만 센다. 비유가 깨지는 곳: 실제로 벽시계가 "손대는" 일은 아주 드물게(윤초는 몇 년에 한 번꼴) 일어나서, 대부분의 짧은 측정에서는 둘의 차이가 안 보인다 — 그렇다고 원칙 자체가 사라지는 건 아니다.
꼬리 질문
ContinuousClock과 SuspendingClock 중 언제 어느 쪽을 써야 하는가?
DispatchTime도 모노토닉 시계인가?
DispatchTime.now()는 마하 절대 시간(mach absolute time) 기반의 모노토닉 값으로, GCD 코드에서 오랫동안 벽시계 대신 경과 시간을 재는 데 쓰여왔다. Swift Concurrency 시대엔 같은 목적을 ContinuousClock이 더 타입 안전하고 Duration과 자연스럽게 통합된 형태로 대체한다.윤초는 왜 갈수록 논란이 되는가?
Q5. DateComponents와 DateInterval로 무엇을 하는가?
DateComponents는 두 가지 역할을 한다 — Calendar.dateComponents(_:from:)로 Date를 "년/월/일/요일" 같은 조각으로 분해(질의)하거나, 반대로 조각을 채워 Calendar.date(from:)/nextDate(after:matching:)로 Date를 구성(생성)하는 스펙으로도 쓴다. nextDate(after:matching:)는 "다음 월요일 오전 9시" 같은 조건에 맞는 가장 가까운 미래 시각을 캘린더가 대신 찾아준다. DateInterval은 시작 시각과 지속 시간으로 정의되는 구간을 값으로 다루는 타입으로, intersects/contains로 구간끼리의 겹침을 계산해준다.
원리
DateComponents 자체는 검증되지 않은 순수 데이터 구조다 — DateComponents(month: 13)처럼 캘린더상 불가능한 값도 얼마든지 담을 수 있고, 그 자체로는 어떤 캘린더 체계에 속하는지도 모른다. 실제로 유효한 Date로 바뀌는 건 항상 특정 Calendar가 그 조각들을 해석할 때뿐이다. DateInterval은 반대로 "시작 + 지속 시간(duration)"이라는 단순한 값 타입으로 기간 자체를 1급 값처럼 다루게 해주고, Calendar.dateInterval(of:for:)로 "이 날짜가 속한 달의 시작~끝" 같은 구간도 바로 얻을 수 있다.
내부 동작
Calendar.nextDate(after:matching:matchingPolicy:)는 내부적으로 주어진 after 시각 이후를 캘린더 규칙에 따라 전진하며 DateComponents가 지정한 조건(예: weekday, hour, minute)을 만족하는 첫 시각을 찾는다. 실측해보면 "2026-07-27(월) 15:00" 기준으로 "다음 월요일 09:00"을 찾으면 오늘이 이미 그 시각을 지난 월요일이므로 결과는 일주일 뒤인 "2026-08-03 09:00"이 나온다 — 조건에 맞는 게 오늘이어도 after 시각을 이미 지났으면 건너뛴다.
DateInterval은 start와 duration(초) 두 값만으로 구간을 표현하고, intersects(_:)는 두 구간이 조금이라도 겹치는지, intersection(with:)는 겹치는 부분 구간 자체를, contains(_:)는 특정 시각이 구간 안에 있는지를 계산한다. 이 계산은 전부 단순 구간 비교라 Calendar가 필요 없다.
실험 · 도구
아래 코드는 TimeZone(identifier: "Asia/Seoul")을 고정해 실행 기기의 지역 설정과 무관하게 그대로 재현된다.
import Foundation
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Asia/Seoul")!
// 기준: 2026-07-27(월) 오후 3시
let now = calendar.date(from: DateComponents(year: 2026, month: 7, day: 27, hour: 15))!
var matching = DateComponents()
matching.weekday = 2 // 1=일요일 기준이므로 월요일은 2
matching.hour = 9
matching.minute = 0
let next = calendar.nextDate(after: now, matching: matching, matchingPolicy: .nextTime)!
print(next)
// 2026-08-03 09:00 KST — 오늘 월요일이지만 9시가 이미 지났으므로 다음 주로 건너뛴다let interval1 = DateInterval(
start: calendar.date(from: DateComponents(year: 2026, month: 7, day: 1))!,
end: calendar.date(from: DateComponents(year: 2026, month: 7, day: 15))!
)
let interval2 = DateInterval(
start: calendar.date(from: DateComponents(year: 2026, month: 7, day: 10))!,
end: calendar.date(from: DateComponents(year: 2026, month: 7, day: 20))!
)
print(interval1.intersects(interval2)) // true
print(interval1.intersection(with: interval2)) // 7/10 ~ 7/15 구간
let probe = calendar.date(from: DateComponents(year: 2026, month: 7, day: 12))!
print(interval1.contains(probe), interval2.contains(probe)) // true true프로젝트 적용
① "다음 알림 시각"처럼 조건부 미래 시각을 직접 손으로 날짜를 더해가며 계산하지 말고 nextDate(after:matching:)에 위임하라 — DST·월말·윤년 같은 캘린더 예외를 매번 손으로 재현할 필요가 없어진다.
func nextTuesday9AM(after date: Date, calendar: Calendar) -> Date? {
var matching = DateComponents()
matching.weekday = 3 // 화요일
matching.hour = 9
matching.minute = 0
return calendar.nextDate(after: date, matching: matching, matchingPolicy: .nextTime)
}② "이 예약이 저 예약과 겹치는지" 같은 기간 충돌 검사는 직접 start/end 비교 코드를 짜지 말고 DateInterval로 표현하고 intersects에 위임하라 — 경계값(정확히 맞닿은 경우) 처리를 직접 재발명하지 않아도 된다.
struct Reservation {
let interval: DateInterval
}
func hasConflict(_ new: Reservation, with existing: [Reservation]) -> Bool {
existing.contains { $0.interval.intersects(new.interval) }
}"DateComponents는 Date처럼 이미 검증된 값이다"는 틀렸다. DateComponents(month: 13)처럼 캘린더상 불가능한 값도 얼마든지 담을 수 있고, calendar.date(from:)이 그걸 nil로 거부하거나 다른 값으로 정규화해버릴 수 있다 — DateComponents는 검증된 결과가 아니라 "질의 또는 생성을 위한 스펙"일 뿐이다.
DateComponents는 "년 이만큼, 월 이만큼, 시 이만큼" 하고 각 서랍에 숫자를 적어둔 메모지 같은 것이다. 메모지 자체는 "13월"처럼 말이 안 되는 숫자를 적어도 아무도 안 막는다. 그 메모지를 들고 달력(Calendar)한테 가져가야 비로소 "그런 날짜는 없어" 하고 걸러지거나 실제 날짜로 바뀐다. DateInterval은 "언제부터 언제까지"라고 적힌 시간표 종이 한 장이고, 두 장을 나란히 놓아보면 겹치는 구간이 한눈에 보인다. 비유가 깨지는 곳: 실제로 DateInterval은 종이 두 장을 눈으로 비교하는 게 아니라, 정확한 초 단위 숫자 비교로 겹침을 계산한다.
꼬리 질문
nextDate(after:matching:)에서 오늘이 조건을 만족해도 건너뛰는 이유는?
after 파라미터는 "이 시각 이후"를 의미하는 경계값이다. 기준 시각이 이미 오늘 오전 9시를 지난 오후 3시였기 때문에, 오늘 월요일 9시는 이미 지나간 시각이라 후보에서 제외되고 다음 주 월요일로 넘어간다. 만약 기준 시각을 오늘 오전 6시로 바꾸면 "오늘 9시"가 그대로 답이 된다.DateInterval끼리 정확히 맞닿기만 하는 경우(한쪽 end == 다른 쪽 start)는 intersects가 true인가?
Calendar.dateInterval(of:for:)는 DateInterval을 직접 만드는 것과 무엇이 다른가?
Calendar.dateInterval(of:for:)는 "이 날짜가 속한 달(혹은 주·년)의 시작과 끝"을 캘린더 규칙에 맞춰 자동으로 계산해준다 — 몇 월이 며칠까지인지, 그 캘린더의 주 시작 요일이 무엇인지 같은 규칙을 직접 계산할 필요가 없다. 반면 DateInterval(start:end:)를 직접 만드는 건 이미 알고 있는 두 시각으로 임의의 구간을 표현할 뿐, 캘린더 규칙과는 무관하다.