← iOS 개발자 CS 로드맵 29 / 35

29 · 시간과 달력

Date가 담고 있는 것과 distantFuture의 정밀도 함정, 날짜 산술이 Calendar를 거쳐야 하는 이유, DST 경계의 23·25시간, 존재하지 않는 시각과 모호한 시각, DateFormatter 캐싱의 18배 차이, 시간 데이터의 저장·전송 형식 선택.
진행률
0 / 0 완료

시간은 프로그래머의 직관이 가장 자주 배신당하는 영역이다. 하루는 24시간이 아니고, 1년은 365일이 아니며, 같은 시각이 두 번 오거나 아예 존재하지 않는 시각도 있다. 그런데 이 사실들은 대부분의 날에 아무 문제를 일으키지 않아서 코드가 몇 년씩 멀쩡히 돌다가 특정 날짜에 터진다.

P0 16장 Q4에서 벽시계와 단조 시계의 구분, 그리고 "믿을 시각은 서버, 재야 할 간격은 단조 시계"라는 원칙을 다뤘다. 이 챕터는 그 위에서 달력 계산 자체의 문제를 파고든다 — 절대 시각을 사람이 읽는 날짜로 바꾸는 순간 생기는 일들이다.

Q1. Date는 무엇을 담고 있는가?

🔑 30초 답변

Double 하나뿐이다. Date는 2001-01-01 00:00:00 UTC를 기준점으로 한 초 단위 실수(timeIntervalSinceReferenceDate)만 들고 있는 값 타입이고, 타임존도 달력 체계도 갖고 있지 않다. 그래서 같은 Date 값을 서울 렌즈로 읽으면 5월 9일, LA 렌즈로 읽으면 5월 8일이 된다 — 값 자체는 1비트도 바뀌지 않는다. print(date)가 UTC로 보이는 것도 Date가 타임존을 들고 있어서가 아니라 description이 그렇게 표시할 뿐이다. 그리고 Double이라 기준점에서 멀어질수록 정밀도가 나빠진다.

CS 원리

시간을 다루는 시스템은 "시각(instant)"과 "그 시각의 표현(representation)"을 분리한다. 이 분리가 이 챕터 전체를 관통하는 원리다.

이 구분이 왜 중요하냐면, 많은 버그가 둘을 혼동해서 생기기 때문이다. "오늘 자정"은 시각이 아니라 표현이다 — 어느 타임존의 자정인지 정하지 않으면 의미가 없다. 반대로 "결제가 일어난 순간"은 시각이고, 이걸 지역 시간 문자열로 저장하면 정보가 손실된다.

두 번째 원리는 부동소수점 표현의 대가다. DateDouble이라는 것은 기준점에서 멀어질수록 표현 가능한 눈금이 성겨진다는 뜻이다(30장에서 자세히 다룬다). 2026년 근처에서는 ULP(연속한 두 표현 가능 값의 간격)가 약 1.19×10⁻⁷초라 마이크로초 차이가 구분되지만, Date.distantFuture(약 4001년) 근처에서는 ULP가 약 7.6마이크로초까지 벌어진다. 그보다 촘촘한 차이는 ==같다고 계산된다.

Date 안에는 숫자 하나뿐 — 타임존도 달력도 없다 Date = 787,234,200.0 (초) 2001-01-01 00:00:00 UTC 로부터 서울 렌즈 LA 렌즈 2026-05-09 오전 3:30 Calendar + TimeZone(Asia/Seoul) 2026-05-08 오전 11:30 Calendar + TimeZone(America/LA) 날짜가 하루 다른데 Date 값은 1비트도 안 바뀌었다 Double 이라 기준점에서 멀수록 눈금이 성겨진다 2001 2026 · ULP 1.19e-7초 4001 · ULP 7.6μs distantFuture
"만료 없음"을 distantFuture로 흉내 내고 미세 비교를 하면 여기서 조용히 깨진다.

iOS에서는

Date는 렌즈 없이는 아무 날짜도 아니다. 사람이 읽는 값을 얻으려면 반드시 CalendarTimeZone을 거쳐야 한다.

let d = Date(timeIntervalSinceReferenceDate: 787_234_200)

// 저장된 것은 이 숫자 하나뿐이다
d.timeIntervalSinceReferenceDate        // 787234200.0
d.timeIntervalSince1970                 // 1_765_454_...  (기준점만 다르다)

// 사람이 읽는 값은 렌즈를 통해서만 나온다
var seoul = Calendar(identifier: .gregorian)
seoul.timeZone = TimeZone(identifier: "Asia/Seoul")!
var la = Calendar(identifier: .gregorian)
la.timeZone = TimeZone(identifier: "America/Los_Angeles")!

seoul.dateComponents([.year, .month, .day, .hour], from: d)   // 5/9 03시
la.dateComponents([.year, .month, .day, .hour], from: d)      // 5/8 11시
//   같은 Date, 다른 날짜 — 값은 그대로다

print가 만드는 착각을 조심해야 한다.

print(d)          // 2026-05-08 18:30:00 +0000
//   Date 가 UTC 를 "갖고 있어서"가 아니라 description 이 UTC 로 찍을 뿐이다.
//   디버깅 중 이 출력을 보고 "타임존이 UTC 로 잘못 설정됐다"고 오해하기 쉽다.

distantFuture 함정도 실제로 재현된다.

let far = Date.distantFuture
let farPlus = far.addingTimeInterval(0.000_001)      // 1마이크로초 더하기

far == farPlus        // true   ← 더했는데 같다
//   4001년 근처는 ULP 가 약 7.6μs 라 1μs 차이가 표현되지 않는다

// "만료 없음"을 이렇게 흉내 내면 미세 비교가 조용히 깨진다
struct Token { let expiresAt: Date }            // ❌ distantFuture 로 무한 표현
struct Token2 { let expiresAt: Date? }          // ✅ nil 로 명시

실험 · 도구

렌즈에 따라 날짜가 갈리는 것을 직접 확인해 보자.

import Foundation

let d = Date(timeIntervalSinceReferenceDate: 787_234_200)

for tz in ["Asia/Seoul", "America/Los_Angeles", "UTC", "Pacific/Kiritimati"] {
    var cal = Calendar(identifier: .gregorian)
    cal.timeZone = TimeZone(identifier: tz)!
    let c = cal.dateComponents([.year, .month, .day, .hour, .minute], from: d)
    print(String(format: "%-22@ %d-%02d-%02d %02d:%02d",
                 tz as NSString, c.year!, c.month!, c.day!, c.hour!, c.minute!))
}
// Asia/Seoul             2026-05-09 03:30
// America/Los_Angeles    2026-05-08 11:30
// UTC                    2026-05-08 18:30
// Pacific/Kiritimati     2026-05-09 08:30    ← UTC+14, 가장 이른 타임존

정밀도 저하도 측정할 수 있다.

func ulp(of date: Date) -> Double {
    date.timeIntervalSinceReferenceDate.ulp
}

print(ulp(of: Date()))                  // 약 1.19e-07  (2026년 근처)
print(ulp(of: .distantFuture))          // 약 7.6e-06   (4001년 근처)
print(ulp(of: .distantPast))            // 과거 방향도 마찬가지로 성겨진다

// 실제로 더해도 같아지는 경계를 찾는다
var step = 1e-9
while Date.distantFuture == Date.distantFuture.addingTimeInterval(step) {
    step *= 2
}
print("distantFuture 에서 구분 가능한 최소 간격: \(step)초")

프로젝트 적용

  1. 모델에는 Date만 담는다. 이미 서식된 문자열이나 "년/월/일" 정수 조합을 담으면 렌즈 정보가 사라진다.
  2. "오늘"·"자정"·"이번 달"을 말할 때는 타임존을 명시한다. 이 말들은 시각이 아니라 표현이라 렌즈 없이는 정의되지 않는다.
  3. "무한"을 distantFuture로 흉내 내지 않는다. Date?nil이나 전용 열거형으로 명시한다.
  4. print(date) 출력을 타임존 설정의 근거로 삼지 않는다. 항상 UTC로 찍힌다.
  5. 경과 시간 측정에는 Date를 쓰지 않는다. ContinuousClock 계열을 쓴다 — P0 16장 Q4에서 다룬 단조 시계 원칙이 그대로 적용된다.
⚠️ 흔한 오해

"Date는 UTC 시각이다"는 부정확하다Date타임존이 없는 절대 시각이고, UTC는 그것을 읽는 렌즈 중 하나일 뿐이다. "print(date)가 UTC로 나오니 타임존이 UTC로 설정된 것"도 틀렸다 — description의 표시 방식이다. "Date는 정밀도가 무한하다"도 틀렸다 — Double이라 기준점에서 멀수록 성겨진다. "distantFuture는 '아주 먼 미래'니까 무한 대용으로 안전하다"도 틀렸다 — 미세 비교가 깨진다. "같은 Date면 같은 날짜"도 틀렸다 — 어느 타임존에서 보느냐에 따라 하루가 다르다.

🧒 쉽게 이해하기

Date스톱워치에 찍힌 숫자 하나다. "2001년 1월 1일부터 몇 초 흘렀는가", 그게 전부다.

이 숫자에는 "몇 월 며칠"이라는 정보가 없다. 그건 계산해서 나오는 것이지 들어 있는 게 아니다.

그리고 계산하려면 어느 나라 시계로 볼지를 정해야 한다. 같은 순간이라도 서울에서는 새벽 3시 30분이고 로스앤젤레스에서는 전날 오전 11시 30분이다. 날짜가 하루 다른데, 스톱워치 숫자는 똑같다.

여기서 헷갈리는 게 하나 있다. 화면에 Date를 찍어 보면 항상 UTC 시간으로 나온다. 그래서 "얘가 UTC를 갖고 있구나" 싶은데, 그게 아니다. 찍어서 보여줄 때만 그렇게 정한 것이다. 마치 온도계 숫자를 항상 섭씨로 읽어 주기로 한 것과 같다 — 온도계 안에 "섭씨"가 들어 있는 게 아니다.

마지막으로 하나. 이 숫자는 소수점이 있는 숫자라 아주 먼 미래로 갈수록 눈금이 성겨진다. 4001년쯤 가면 7마이크로초보다 작은 차이는 아예 표현이 안 된다. 그래서 "만료 안 됨"을 아주 먼 미래로 흉내 내면, 거기서 미세한 시간 비교를 할 때 결과가 이상해진다.

꼬리 질문

"오늘 자정 이후의 데이터"를 조회하려면 무엇을 계산해야 하는가?

어느 타임존의 자정인지를 먼저 정하고, 그 자정에 해당하는 절대 시각을 계산해 조회 조건으로 쓴다.

extension Calendar {
    /// 이 달력·타임존 기준으로 주어진 시각이 속한 날의 시작(자정)
    func startOfDayInstant(for date: Date) -> Date { startOfDay(for: date) }
}

var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Seoul")!
let midnight = cal.startOfDay(for: Date())     // 절대 시각으로 반환된다

let todays = items.filter { $0.createdAt >= midnight }

여기서 어느 타임존을 쓸지가 제품 결정이라는 점이 중요하다.

  • 사용자 기기 타임존 — "내 하루" 기준. 여행 중이면 기준이 바뀐다.
  • 계정에 저장된 타임존 — 사용자가 명시적으로 고른 홈 타임존. 여행 중에도 안정적이다.
  • 서비스 고정 타임존 — 정산·마감처럼 사업 규칙이 정하는 기준. 대개 이쪽이다.

startOfDay가 단순히 "시·분·초를 0으로" 만드는 게 아니라는 점도 중요하다. DST 전환일에는 자정이 존재하지 않는 타임존도 있어서(브라질 등 일부 지역), 시스템 계산에 맡겨야 한다(Q4).

쉽게 말하면 "오늘"이라는 말은 어디에 서 있느냐에 따라 다르다. 서울의 오늘과 뉴욕의 오늘은 시작 시각이 다르다. 그래서 "오늘 것만 보여줘"라고 하려면 어느 도시의 하루인지 먼저 정해야 한다.
서버와 클라이언트가 계산한 "날짜"가 다른 경우 어디를 보는가?

세 축을 순서대로 대조한다 — 타임존, 달력, 그리고 주 시작 규칙.

(1) 타임존. 가장 흔하다. 서버가 UTC로 날짜를 자르고 클라이언트가 기기 타임존으로 자르면, UTC 기준 자정 근처의 데이터가 다른 날로 분류된다. 한국(UTC+9)에서는 오전 9시 이전 데이터가 전부 어긋난다.

(2) 달력. 사용자 기기가 불교력·이슬람력이면 Calendar.current가 그것을 쓴다. 비즈니스 로직에는 고정 identifier를 써야 한다.

(3) 주 시작·주차 규칙. "이번 주"를 계산할 때 .gregorianfirstWeekday=1(일요일 시작)·minimumDaysInFirstWeek=1이고, .iso8601firstWeekday=2(월요일 시작)·minimumDaysInFirstWeek=4다. 같은 날짜가 다른 주차로 계산된다.

// 비즈니스 로직용 — 사용자 설정과 완전히 분리한다
var biz = Calendar(identifier: .gregorian)
biz.timeZone = TimeZone(identifier: "Asia/Seoul")!    // 서비스 기준 타임존
biz.firstWeekday = 2                                   // 주 시작을 명시
biz.minimumDaysInFirstWeek = 4                         // 주차 규칙도 명시

// 표시용 — 사용자 설정을 따른다
let display = Calendar.autoupdatingCurrent

진단은 양쪽에서 같은 입력으로 경계값을 찍어 보는 것이 빠르다. 자정 직전·직후, 연말, 주 경계를 넣고 결과를 대조한다.

쉽게 말하면 두 사람이 "며칠이야?"에 다르게 답한다면, 서로 다른 시계나 다른 달력을 보고 있는 것이다. 게다가 "이번 주"의 시작을 한 사람은 일요일로, 다른 사람은 월요일로 치면 주차 번호까지 달라진다.
Date를 정수 타임스탬프로 저장하면 어떤 문제가 생기는가?

정밀도와 범위를 잃는데, 대부분의 경우 그게 오히려 나은 선택이다. 다만 어느 단위인지 합의해야 한다.

고려할 점이 셋이다.

  • 단위. 초·밀리초·마이크로초 중 무엇인가. 자바스크립트는 밀리초, Unix 관례는 초다. 단위를 필드 이름에 넣는 것이 안전하다 — createdAtMs.
  • 기준점. Unix epoch(1970)인가 Apple 기준(2001)인가. timeIntervalSince1970을 쓰면 상호운용이 쉽다.
  • 2038년 문제. 32비트 부호 있는 정수로 초를 담으면 2038년에 넘친다. 64비트를 쓰면 문제없다.

정수 저장의 장점이 실제로 크다 — 비교·정렬이 정확하고, 부동소수점 오차가 없으며, 언어·플랫폼 간 해석이 흔들리지 않는다. DateDouble은 앞서 본 정밀도 저하가 있다.

// 저장·전송 — 정수 밀리초로 고정
struct Event: Codable {
    let createdAtMs: Int64
    var createdAt: Date { Date(timeIntervalSince1970: Double(createdAtMs) / 1000) }
}

다만 "날짜"(시각이 아닌)를 저장할 때는 다르다. 생일이나 기념일처럼 타임존과 무관한 달력 날짜는 타임스탬프로 저장하면 안 된다 — 타임존이 바뀌면 하루 밀린다. 이 경우는 "2026-08-15" 같은 날짜 문자열이나 년·월·일 정수 세 개로 저장한다(Q6).

쉽게 말하면 시각은 스톱워치 숫자로 적어 두는 게 정확하다. 다만 단위를 꼭 적어 둬야 한다 — 초인지 밀리초인지 모르면 1000배 틀린다. 그런데 생일 같은 건 스톱워치로 적으면 안 된다. 생일은 "8월 15일"이지 "몇 초"가 아니기 때문이다.

Q2. 날짜 산술은 왜 Calendar를 거쳐야 하는가?

🔑 30초 답변

"하루"는 고정된 초 수가 아니기 때문이다. date.addingTimeInterval(86400)정확히 86,400초 뒤를 주고, calendar.date(byAdding: .day, value: 1, to:)달력상 하루 뒤를 준다. 평소에는 같지만 DST 전환일에는 다른 결과가 나온다 — 둘 중 하나가 틀린 게 아니라 서로 다른 불변식(경과 시간 보존 vs 벽시계 시각 보존)을 지킨 것이다. 월 더하기는 더 심하다. 1월 31일에 한 달을 더하면 2월 31일이 없으므로 달력이 조정해야 하고, 이 조정 규칙은 직접 구현할 것이 아니다.

CS 원리

달력 산술이 어려운 이유는 단위가 균일하지 않기 때문이다.

단위길이가 고정인가
고정물리적 단위
분 · 시사실상 고정윤초를 무시하는 시스템에서는
가변DST로 23 또는 25시간이 되는 날이 있다
가변28~31일. 그리고 "31일 + 1개월"이 정의되지 않는다
가변윤년

가변 단위의 산술은 대수적 성질이 깨진다. 우리가 당연히 여기는 규칙들이 성립하지 않는다.

그래서 직접 구현하면 안 된다. 이 조정 규칙은 문화권마다 다르고 예외가 많아서, 달력 구현체에 위임하는 것이 유일하게 안전한 방법이다.

두 번째 원리는 어느 달력을 쓸 것인가다. Calendar.current접근한 순간의 설정을 찍은 스냅샷이라 이후 사용자가 설정을 바꿔도 반영되지 않는다. autoupdatingCurrent는 실시간 반영되고, Calendar(identifier:)사용자 설정과 완전히 무관하다. 정산·마감 같은 비즈니스 로직은 반드시 고정 identifier + 고정 TimeZone을 써야 한다.

"하루 뒤"는 두 가지 뜻이다 — 둘 다 맞다 출발 3/7 12:00 PST addingTimeInterval(86400) → 13:00 경과 시간 보존 date(byAdding: .day, 1) → 12:00 벽시계 시각 보존 스프링포워드로 그날이 23시간이라 한 시간 어긋난다 하루가 1,440분이 아닌 날이 있는 이상 둘 중 하나만 고를 수 있다 월 더하기는 대수적 성질까지 깨진다 1/31 +1개월 2/28 (조정됨) +1개월 3/28 1/31 +2개월 (한 번에) 3/31 3/28 ≠ 3/31 — 결합법칙이 성립하지 않는다 이런 조정 규칙을 직접 구현하지 말고 달력에 위임한다
두 결과 모두 옳다. 어느 불변식을 지킬 것인지를 코드가 선택해야 한다.

iOS에서는

먼저 어느 달력을 쓸지 정하는 것이 시작이다. 셋의 성격이 다르다.

Calendar.current              // 접근 시점의 사용자 설정 스냅샷 — 이후 변경 반영 안 됨
Calendar.autoupdatingCurrent  // 사용자 설정을 실시간 반영 — 표시용에 적합
Calendar(identifier: .gregorian)   // 사용자 설정과 완전히 무관 — 비즈니스 로직용

// 비즈니스 로직용 달력은 세 축을 모두 고정한다
var biz = Calendar(identifier: .gregorian)
biz.timeZone = TimeZone(identifier: "Asia/Seoul")!
biz.locale = Locale(identifier: "en_US_POSIX")

산술은 의도를 드러내는 API를 고른다.

let base = someDate

// "정확히 24시간 뒤" — 타임아웃·만료처럼 경과 시간이 중요한 경우
let exact = base.addingTimeInterval(24 * 60 * 60)

// "달력상 내일 같은 시각" — 알림·일정처럼 벽시계 시각이 중요한 경우
let tomorrow = biz.date(byAdding: .day, value: 1, to: base)!

// 여러 단위를 한 번에 — 순서 의존을 피한다
var comps = DateComponents(); comps.month = 1; comps.day = 15
let later = biz.date(byAdding: comps, to: base)!

월말 조정은 달력이 알아서 한다.

let jan31 = biz.date(from: DateComponents(year: 2026, month: 1, day: 31))!

biz.date(byAdding: .month, value: 1, to: jan31)!   // 2026-02-28  (조정)
biz.date(byAdding: .month, value: 2, to: jan31)!   // 2026-03-31  (조정 없음)
//   한 번에 2개월을 더한 것과, 1개월씩 두 번 더한 것이 다르다

한국 맥락에서 자주 묻는 것 하나 — Calendar.Identifier.republicOfKorea는 존재하지 않는다. 컴파일 에러가 난다. 양력은 .gregorian을 쓰고, 설날·추석 같은 음력이 필요하면 .chinese로 환산한다.

let lunar = Calendar(identifier: .chinese)
let c = lunar.dateComponents([.year, .month, .day], from: someDate)
//   실측: 2027-02-06 그레고리력이 음력 1월 1일(설날)에 해당한다

실험 · 도구

두 산술이 갈리는 날을 직접 찾아보면 감이 온다.

import Foundation

var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/Los_Angeles")!

let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = cal.timeZone
fmt.dateFormat = "MM-dd HH:mm zzz"

// 2026년 미국 스프링포워드는 3월 8일이다
let base = cal.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 12))!

print("출발      ", fmt.string(from: base))
print("+86400초  ", fmt.string(from: base.addingTimeInterval(86400)))
print("+1일(달력)", fmt.string(from: cal.date(byAdding: .day, value: 1, to: base)!))
// 출발       03-07 12:00 PST
// +86400초   03-08 13:00 PDT     ← 벽시계로 한 시간 밀렸다
// +1일(달력) 03-08 12:00 PDT     ← 정오가 보존됐다

1년 전체를 훑어 하루가 24시간이 아닌 날을 찾을 수도 있다.

# 특정 타임존에서 DST 전환일을 모두 나열한다
python3 - <<'PY'
from zoneinfo import ZoneInfo
from datetime import datetime, timedelta
tz = ZoneInfo("America/Los_Angeles")
d = datetime(2026, 1, 1, tzinfo=tz)
while d.year == 2026:
    nxt = d + timedelta(days=1)
    hours = (nxt.astimezone(ZoneInfo("UTC")) - d.astimezone(ZoneInfo("UTC"))).total_seconds() / 3600
    if hours != 24:
        print(f"{d.date()}  →  {hours:.0f}시간")
    d = nxt
PY
# 2026-03-08  →  23시간
# 2026-11-01  →  25시간

프로젝트 적용

  1. 비즈니스 로직에는 고정 달력을 쓴다. Calendar(identifier:) + 고정 TimeZone. Calendar.current는 사용자 설정에 좌우된다.
  2. "며칠 뒤"를 86400 * n으로 계산하지 않는다. 만료 시각처럼 경과 시간이 의미 있는 경우에만 초 산술을 쓴다.
  3. 월 산술에서 결합법칙을 가정하지 않는다. "매월 31일 결제" 같은 규칙은 말단 처리를 명시적으로 정한다 — 말일로 당길지, 다음 달 1일로 밀지.
  4. 여러 단위를 더할 때는 DateComponents로 한 번에 넘긴다. 순서 의존을 피할 수 있다.
  5. DST가 있는 타임존을 테스트에 넣는다. 한국은 DST가 없어 개발 중에는 이 문제가 절대 재현되지 않는다.
⚠️ 흔한 오해

"하루는 86,400초다"가 이 챕터의 가장 비싼 오해다 — DST가 있는 타임존에서는 23시간이나 25시간인 날이 있다. "addingTimeInterval(86400)date(byAdding:.day)보다 정확하다"도 틀렸다 — 둘은 다른 것을 보존하며 어느 쪽이 맞는지는 용도가 정한다. "월 더하기는 결합법칙이 성립한다"도 틀렸다 — 1/31 기준으로 확인된다. "Calendar.current를 쓰면 사용자 설정이 자동 반영된다"도 틀렸다 — 스냅샷이라 이후 변경이 반영되지 않는다. 실시간 반영은 autoupdatingCurrent다. "한국은 .republicOfKorea 달력을 쓴다"는 존재하지 않는다 — 컴파일 에러다.

🧒 쉽게 이해하기

"내일 이 시간에 만나자"와 "24시간 뒤에 만나자"는 같은 말이 아니다.

평소에는 같다. 그런데 서머타임이 시작되는 날에는 다르다. 그날은 새벽에 시계를 한 시간 앞으로 돌리기 때문에 하루가 23시간밖에 안 된다.

그래서 "내일 정오"라고 하면 정오에 만나고, "24시간 뒤"라고 하면 오후 1시에 만나게 된다. 둘 중 하나가 틀린 게 아니라, 지키려는 약속이 다른 것이다. 하나는 "시계 바늘 위치"를 지키고, 하나는 "실제로 흐른 시간"을 지킨다.

달을 더하는 건 더 이상하다. 1월 31일에 한 달을 더하면 며칠일까? 2월 31일은 없다. 그래서 달력이 알아서 2월 28일로 당긴다.

그런데 여기서 재미있는 일이 생긴다. 한 달씩 두 번 더하면 2월 28일을 거쳐 3월 28일이 되는데, 두 달을 한 번에 더하면 3월 31일이 된다. 같은 두 달인데 답이 다르다.

그러니까 이런 계산은 직접 하려고 하면 안 된다. 예외가 너무 많다. 달력에게 물어보는 게 맞다.

꼬리 질문

"매월 31일에 결제"를 어떻게 구현해야 하는가?

말단 처리 규칙을 제품이 먼저 정해야 한다. 기술적 정답이 없다.

선택지가 셋이다.

  • 말일로 당긴다 — 2월은 28(29)일, 4월은 30일에 결제. 대부분의 구독 서비스가 이 방식이다. date(byAdding: .month)의 기본 동작과 일치한다.
  • 다음 달 1일로 민다 — 결제일이 뒤로 밀리므로 회계상 다른 달에 잡힌다. 정산이 복잡해진다.
  • 기준일을 낮춘다 — 애초에 28일 이하만 결제일로 허용한다. 가장 단순하고 사고가 없다.

구현에서 중요한 것은 "원래 결제일"을 별도로 보관하는 것이다. 조정된 날짜를 다음 계산의 기준으로 삼으면 날짜가 점점 앞당겨진다.

// ❌ 조정된 날짜를 기준으로 다시 더하면 계속 당겨진다
//    1/31 → 2/28 → 3/28 → 4/28 …  (31일로 돌아오지 못한다)

// ✅ 원래 기준일을 유지하고 매번 원점에서 계산한다
struct Subscription {
    let anchorDay: Int          // 31 — 사용자가 고른 결제일
    let startedAt: Date

    func billingDate(monthsLater n: Int, in cal: Calendar) -> Date {
        let base = cal.date(byAdding: .month, value: n, to: startedAt)!
        var c = cal.dateComponents([.year, .month], from: base)
        let range = cal.range(of: .day, in: .month, for: base)!
        c.day = min(anchorDay, range.count)      // 그 달의 말일로 제한
        return cal.date(from: c)!
    }
}
쉽게 말하면 "매달 31일에 내기"로 정했는데 2월에는 31일이 없다. 그래서 2월 28일에 낸다. 문제는 그다음이다. 28일을 새 기준으로 삼으면 3월도 28일, 4월도 28일이 되어 영영 31일로 못 돌아온다. 그래서 "원래는 31일"이라는 사실을 따로 적어 두고 매번 거기서 계산해야 한다.
Calendar.current와 autoupdatingCurrent 중 무엇을 기본으로 써야 하는가?

용도에 따라 셋 다 쓸 일이 있다. 기본값을 하나로 정하기보다 계층별로 정하는 것이 맞다.

계층선택이유
비즈니스 로직·정산Calendar(identifier:) 고정사용자 설정과 무관해야 재현 가능
화면 표시autoupdatingCurrent설정 변경이 즉시 반영돼야 한다
단발 계산current스냅샷으로 충분하고 값 복사가 싸다

autoupdatingCurrent를 저장 프로퍼티로 두면 값이 살아 있는 동안 계속 바뀔 수 있다는 점을 인지해야 한다. 이는 장점이자 위험이다 — 계산 중간에 설정이 바뀌면 같은 함수 안에서 다른 달력을 쓰게 될 수 있다.

// 한 계산 단위 안에서는 달력을 한 번만 읽어 고정한다
func summarize(_ items: [Item]) -> Summary {
    let cal = Calendar.current        // 스냅샷 — 이 함수 안에서 일관됨
    …
}

테스트에서는 반드시 고정 달력을 주입한다. current를 쓰는 코드는 CI 머신의 설정에 좌우된다(28장 Q1 꼬리질문의 의존성 주입과 같은 이유).

쉽게 말하면 달력이 세 종류다. 사진 찍어 둔 달력(그 순간 설정 그대로), 계속 갱신되는 달력(사용자가 바꾸면 따라 바뀜), 못 박아 둔 달력(아무도 못 바꿈). 돈 계산에는 못 박아 둔 것을, 화면에는 갱신되는 것을 쓴다.
두 날짜 사이의 "일수 차이"는 어떻게 계산해야 하는가?

무엇을 세고 싶은지에 따라 방법이 다르다. 이게 흔한 버그의 원인이다.

(1) 경과한 시간을 24로 나눈 값 — "36시간 = 1.5일". 초 차이를 86400으로 나눈다. DST 날에는 24시간이 아닌 날이 섞여 어긋난다.

(2) 달력상 며칠이 바뀌었는가 — "어제와 오늘은 1일 차이". 시각과 무관하게 날짜 경계를 몇 번 넘었는지를 센다. 대부분의 UI가 원하는 것은 이쪽이다.

// ❌ 23:59 와 00:01 이 "0일 차이"로 나온다
let naive = Int(b.timeIntervalSince(a) / 86400)

// ✅ 날짜 경계 기준 — 두 시각을 각각 그날의 시작으로 내린 뒤 센다
func dayGap(_ a: Date, _ b: Date, _ cal: Calendar) -> Int {
    let da = cal.startOfDay(for: a)
    let db = cal.startOfDay(for: b)
    return cal.dateComponents([.day], from: da, to: db).day ?? 0
}
//   어제 23:59 → 오늘 00:01 은 1 이 나온다

"3일 전" 같은 상대 표시에도 같은 구분이 필요하다. 사용자는 날짜 경계로 세지 시간으로 세지 않는다. 시스템이 제공하는 상대 서식을 쓰면 이 처리가 포함된다.

date.formatted(.relative(presentation: .named))    // "어제", "3일 전"
쉽게 말하면 "며칠 지났어?"라는 질문에 답이 둘이다. 시간을 재서 24로 나누는 방법과, 달력에서 날짜가 몇 번 넘어갔는지 세는 방법. 밤 11시 59분과 그다음 날 0시 1분은 2분 차이지만 하루가 지난 것이다. 사람은 두 번째로 센다.

Q3. DST 경계에서 하루는 몇 시간인가?

🔑 30초 답변

23시간이거나 25시간이다. 서머타임이 시작되는 날(스프링포워드)에는 시계를 한 시간 앞으로 돌리므로 그날이 23시간이 되고, 끝나는 날(폴백)에는 한 시간 뒤로 돌리므로 25시간이 된다. 그래서 date.addingTimeInterval(86400)calendar.date(byAdding: .day, value: 1, to:)서로 다른 결과에 도착한다. 둘 다 틀린 게 아니라 서로 다른 불변식을 지킨 것 — Apple DTS 엔지니어 Quinn의 표현대로 "하루가 1,440분이 아닌 날이 존재하는 이상 둘 중 하나만 고를 수 있다".

CS 원리

DST는 물리적 시간과 사회적 시간의 분리가 만드는 문제다. 절대 시각은 균일하게 흐르지만, 지역 시간이라는 사상(mapping)이 1년에 두 번 불연속점을 갖는다.

이 불연속이 두 종류의 이상 상태를 만든다.

지역 시간 → 절대 시각의 사상이 전단사(bijection)가 아니다. 어떤 값은 대응이 없고(갭), 어떤 값은 둘이다(중복). 이것이 시간 계산이 어려운 근본 이유다.

여기서 중요한 실무 원칙이 나온다 — 지역 시간 문자열은 절대 시각을 유일하게 결정하지 못한다. 그래서 시각을 저장할 때는 반드시 오프셋이나 UTC를 함께 담아야 하고, 지역 시간만 저장하면 연간 한 시간 동안의 데이터는 복원 불가능해진다.

지역 시간 ↔ 절대 시각의 대응이 일대일이 아니다 스프링포워드 01:00 01:59 02:00~02:59 03:00 그날 23시간 이 시각은 존재하지 않는다 (갭) 폴백 00:59 01:30 (1회차) 01:30 (2회차) 02:00 그날 25시간 같은 지역 시각에 절대 시각이 둘 (중복) 그래서 "01:30"만 저장하면 복원할 수 없다 "2026-11-01 01:30" 만 저장 두 시각 중 어느 것인지 알 수 없다 UTC 또는 오프셋을 함께 저장 "…01:30-07:00" — 유일하게 복원된다
연간 한 시간 동안 생성된 데이터가 지역 시간만으로는 복원되지 않는다.

iOS에서는

두 산술이 서로 다른 불변식을 지킨다는 것이 코드로 그대로 드러난다.

var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/Los_Angeles")!

// 2026년 스프링포워드는 3월 8일 — 3월 7일 정오에서 출발
let base = cal.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 12))!

base.addingTimeInterval(86400)                      // 3/8 13:00 PDT — 경과 시간 보존
cal.date(byAdding: .day, value: 1, to: base)!       // 3/8 12:00 PDT — 정오 보존
//   둘 다 옳다. 무엇을 보존할지는 용도가 정한다.

존재하지 않는 시각을 요청하면 시스템이 조정한다. nil이 아니라는 점이 중요하다.

// 3/8 02:30 은 존재하지 않는다
let ghost = cal.date(from: DateComponents(year: 2026, month: 3, day: 8, hour: 2, minute: 30))
//   nil 이 아니라 기본 matchingPolicy 에 따라 다음 유효 시각으로 밀린다 (03:30)
//   "nil 이 오겠지"라고 가정한 코드는 여기서 조용히 어긋난다

타임존의 오프셋 변화를 직접 조회할 수도 있다.

let tz = TimeZone(identifier: "America/Los_Angeles")!

tz.isDaylightSavingTime(for: base)          // false (PST)
tz.secondsFromGMT(for: base)                // -28800
tz.nextDaylightSavingTimeTransition(after: base)   // 다음 전환 시각
tz.daylightSavingTimeOffset(for: base)      // 0 또는 3600

실험 · 도구

DST가 없는 한국에서 개발하면 이 문제가 절대 재현되지 않는다. 그래서 강제로 흉내 내야 한다.

// 코드에서 타임존을 명시하면 기기 설정과 무관하게 재현할 수 있다 — 테스트에 적합
func hoursInDay(_ y: Int, _ m: Int, _ d: Int, tz: String) -> Double {
    var cal = Calendar(identifier: .gregorian)
    cal.timeZone = TimeZone(identifier: tz)!
    let start = cal.date(from: DateComponents(year: y, month: m, day: d))!
    let next  = cal.date(byAdding: .day, value: 1, to: start)!
    return next.timeIntervalSince(start) / 3600
}

hoursInDay(2026, 3, 8,  tz: "America/Los_Angeles")   // 23.0
hoursInDay(2026, 11, 1, tz: "America/Los_Angeles")   // 25.0
hoursInDay(2026, 3, 8,  tz: "Asia/Seoul")            // 24.0  ← 한국은 DST 없음

시뮬레이터의 타임존을 통째로 바꿔 UI까지 확인할 수도 있다.

# 시뮬레이터 타임존 변경
xcrun simctl spawn booted defaults write .GlobalPreferences AppleTimeZone \
    -string "America/Los_Angeles"

# 기기 시각을 전환 직전으로 옮겨 실제 전환을 겪게 한다
#   설정 › 일반 › 날짜 및 시간에서 자동 설정을 끄고 수동으로 맞춘다
#   (시뮬레이터는 호스트 시각을 따르므로 macOS 쪽에서 바꿔야 하는 경우가 많다)

# 타임존 데이터베이스가 아는 전환 시각을 직접 확인
python3 -c "
from zoneinfo import ZoneInfo
from datetime import datetime
tz = ZoneInfo('America/Los_Angeles')
for m, d in [(3,8),(11,1)]:
    for h in [1,2,3]:
        t = datetime(2026,m,d,h,30,tzinfo=tz)
        print(f'{m}/{d} {h}:30 → utcoffset={t.utcoffset()}  dst={t.dst()}')
"

프로젝트 적용

  1. 시각 저장에는 반드시 UTC나 오프셋을 포함한다. 지역 시간 문자열만 저장하면 연간 한 시간 분량이 복원 불가능해진다.
  2. 반복 일정은 지역 시간 규칙으로 저장한다. "매일 오전 9시"를 절대 시각의 반복으로 저장하면 DST 후 8시나 10시에 울린다.
  3. "하루 전체"를 다룰 때 startOfDaydate(byAdding:)을 쓴다. 자정에 86400을 더하는 방식은 전환일에 틀린다.
  4. DST가 있는 타임존을 테스트 매트릭스에 고정한다. America/Los_Angeles가 무난하다. 남반구(Australia/Sydney)는 전환 방향이 반대라 함께 넣으면 좋다.
  5. 전환 시각 근처의 로그를 조사할 때 오프셋을 함께 본다. 폴백 날에는 타임스탬프가 되돌아가는 로그가 정상이다(P1 18장).
⚠️ 흔한 오해

"모든 지역 시각은 유일한 절대 시각에 대응한다"는 틀렸다 — 폴백 날에는 이고 스프링포워드 날에는 없다. "존재하지 않는 시각을 요청하면 nil이 온다"도 틀렸다 — 기본 정책이 다음 유효 시각으로 밀어준다. "한국은 DST가 없으니 신경 안 써도 된다"도 위험하다 — 해외 사용자서버가 다른 타임존에 있을 수 있다. "UTC로만 다루면 DST 문제가 사라진다"도 절반만 맞다 — 저장은 안전해지지만 "매일 오전 9시" 같은 지역 시간 규칙은 여전히 지역 시간으로 표현해야 한다.

🧒 쉽게 이해하기

어떤 나라들은 여름에 시계를 한 시간 앞으로 돌린다. 해가 길어지니까 시간을 당겨 쓰자는 것이다. 가을에는 다시 뒤로 돌린다.

그래서 이상한 날이 1년에 두 번 생긴다.

봄에 시계를 앞으로 돌리는 날은, 새벽 2시가 되는 순간 바로 3시로 건너뛴다. 그날 2시 30분이라는 시각은 아예 없다. 그리고 그날은 23시간밖에 안 된다.

가을에 뒤로 돌리는 날은 반대다. 새벽 2시가 되면 다시 1시로 돌아간다. 그래서 1시 30분이 두 번 온다. 그날은 25시간이다.

여기서 문제가 생긴다. 누가 "11월 1일 새벽 1시 30분에 왔어요"라고 하면, 첫 번째 1시 30분인지 두 번째인지 알 수가 없다. 그래서 시각을 적어 둘 때는 "어느 쪽 1시 30분인지"까지 적어야 한다.

한국은 서머타임을 안 해서 이런 일이 없다. 그래서 한국에서 아무리 테스트해도 이 버그는 안 나온다. 일부러 미국 시계로 바꿔 놓고 확인해 봐야 한다.

꼬리 질문

"매일 오전 9시 알림"을 DST가 있는 지역에서 어떻게 구현하는가?

절대 시각의 반복이 아니라 달력 규칙으로 등록해야 한다. 시스템이 매번 지역 시간으로 계산하도록 맡기는 것이다.

import UserNotifications

// ❌ 절대 시각 + 86400 반복 — DST 후 8시나 10시에 울린다
let bad = UNTimeIntervalNotificationTrigger(timeInterval: 86400, repeats: true)

// ✅ 달력 성분으로 등록 — 시스템이 매일 지역 시간 09:00 을 계산한다
var comps = DateComponents()
comps.hour = 9
comps.minute = 0
let good = UNCalendarNotificationTrigger(dateMatching: comps, repeats: true)

서버가 푸시를 보내는 구조라면 서버 쪽에서 같은 계산이 필요하다. 이때 사용자의 타임존을 서버가 알아야 하고, 사용자가 여행하면 갱신돼야 한다.

그리고 존재하지 않는 시각을 지정한 경우를 생각해야 한다. "매일 새벽 2시 30분"으로 등록하면 스프링포워드 날에는 그 시각이 없다. 시스템은 대개 조정해 주지만, 어떻게 조정되는지 확인하고 제품 요구와 맞는지 봐야 한다.

쉽게 말하면 알람을 "24시간마다"로 맞추면, 시계를 한 시간 돌린 날부터 계속 한 시간씩 어긋난다. "매일 아침 9시"라고 맞춰야 시계가 바뀌어도 9시에 울린다.
로그 타임스탬프가 되돌아가는 것이 관측됐다. 버그인가?

지역 시간으로 기록했다면 정상이고, UTC로 기록했다면 버그다.

폴백 날에는 지역 시각이 실제로 되돌아간다. 01:59 다음에 01:00이 오는 것이 그 타임존에서는 맞는 동작이다. 로그를 지역 시간으로 남겼다면 정렬이 뒤섞이고 중복 구간이 생긴다 — 이게 28장 Q7 꼬리질문에서 "로그는 UTC로 남기라"고 한 이유다.

반면 UTC 타임스탬프가 되돌아갔다면 다른 원인을 봐야 한다.

  • NTP 보정 — 기기 시계가 앞서 있다가 동기화로 뒤로 조정됐다. P0 16장 Q4에서 본 벽시계의 성질이다.
  • 사용자의 수동 시각 변경.
  • 여러 기기·서버의 로그를 병합했는데 clock skew가 있는 경우.

어느 쪽이든 경과 시간 계산에 벽시계를 쓰면 안 된다는 결론은 같다. 구간 측정은 단조 시계로 하고, 로그에는 UTC 시각 + 단조 시계 기준 상대값을 함께 남기면 병합과 순서 판정이 모두 가능해진다.

쉽게 말하면 일지에 동네 시계로 시각을 적으면, 시계를 뒤로 돌린 날에는 같은 시각이 두 번 적히고 순서가 엉킨다. 세계 공통 시계로 적으면 그런 일이 없다. 그런데 세계 공통 시계로 적었는데도 되돌아갔다면, 그건 시계 자체가 조정된 것이라 다른 문제다.
타임존 데이터베이스가 바뀌면 과거 데이터의 해석이 달라지는가?

달라질 수 있다. 각국 정부가 DST 정책이나 표준시를 바꾸면 tz 데이터베이스가 갱신되고, OS 업데이트로 기기에 반영된다.

실제로 일어나는 일이다 — 여러 나라가 최근 몇 년 사이 DST를 폐지하거나 표준시 오프셋을 변경했다. 이때 미래 시각의 해석이 바뀐다.

  • 과거 시각 — 대개 안전하다. tz 데이터베이스는 과거 규칙을 이력으로 보존하므로 옛 날짜는 옛 규칙으로 해석된다.
  • 미래 시각위험하다. "2027년 7월 1일 09:00 지역 시간"으로 저장한 일정은, 그 사이 정책이 바뀌면 다른 절대 시각이 된다.

그래서 미래 일정의 저장 방식이 갈린다.

대상저장 방식
과거에 일어난 일절대 시각(UTC) — 이미 확정됐다
미래 일정지역 시간 + 타임존 식별자 — 규칙이 바뀌면 따라가야 한다

"2027년 7월 1일 서울 09:00 회의"는 그 시점의 서울 규칙으로 해석돼야 하지, 지금 계산한 UTC 값으로 고정하면 안 된다. 이건 제품 요구에 달렸다 — "그 지역의 오전 9시"인지 "지금 계산한 그 절대 시각"인지.

쉽게 말하면 "내년 7월 1일 서울 시간 9시에 만나자"고 약속했는데, 그 사이 나라가 시간 규칙을 바꾸면 그 9시가 가리키는 실제 순간이 달라진다. 이미 지나간 일은 바뀌지 않지만, 앞으로의 약속은 규칙을 따라 움직인다. 그래서 지난 일앞으로의 약속은 적어 두는 방식이 달라야 한다.

Q4. 존재하지 않는 시각과 모호한 시각을 API는 어떻게 다루는가?

🔑 30초 답변

실패시키지 않고 정책에 따라 조정한다. Q3에서 본 갭과 중복을 Calendarnil로 거부하는 대신 정책 파라미터로 해결한다 — matchingPolicy(존재하지 않는 시각을 어느 쪽으로 밀지), repeatedTimePolicy(중복될 때 첫 번째를 쓸지 마지막을 쓸지), direction(앞으로 찾을지 뒤로 찾을지). 기본값은 다음 유효 시각으로 밀어주는 쪽이라, "없는 시각이면 nil이 오겠지"라고 가정한 코드가 조용히 어긋난다. 반복 일정을 다룰 때는 enumerateDates가 이 정책을 일관되게 적용해 주므로 직접 루프를 돌리는 것보다 안전하다.

CS 원리

부분 함수(partial function)를 다루는 방식에는 세 가지 선택지가 있다.

세 번째가 우월한 이유는 정답이 맥락마다 다르기 때문이다. "새벽 2시 30분 알림"이 존재하지 않는 날, 어떤 앱은 건너뛰어야 하고 어떤 앱은 3시 30분에라도 울려야 한다. 라이브러리가 하나를 고를 수 없다.

다만 기본값이 있다는 것이 함정이다. 정책을 명시하지 않으면 라이브러리의 기본 선택이 적용되고, 그 선택이 우리 요구와 다를 수 있다. 그런데 대부분의 날에는 갭·중복이 없으므로 기본값이 무엇인지 모른 채 몇 년이 지나간다.

이것은 P1 21장 Q6에서 본 페일 사일런트 문제의 한 형태다 — 실패가 조용히 성공처럼 처리된다.

02:30 을 요청했는데 그날 그 시각이 없다면? 02:30 — 존재하지 않음 .nextTime 다음 유효 시각 03:30 기본값 .previousTimePreservingSmaller 이전 쪽으로 01:30 .strict 건너뛴다 — 결과 없음 01:30 이 두 번 있는 날은 또 다른 파라미터가 정한다 repeatedTimePolicy: .first DST 적용 중인 첫 번째 repeatedTimePolicy: .last 표준시로 돌아온 두 번째 기본값이 조용히 적용되므로 "nil 이 오겠지"는 틀린 가정이다
정답이 맥락마다 다르므로 라이브러리가 하나를 고를 수 없다. 그래서 정책을 파라미터로 받는다.

iOS에서는

세 파라미터를 명시하는 API가 nextDate(after:matching:matchingPolicy:repeatedTimePolicy:direction:)다.

var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/Los_Angeles")!

var target = DateComponents(); target.hour = 2; target.minute = 30
let dayBefore = cal.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 12))!

// 기본 정책 — 다음 유효 시각으로 민다
cal.nextDate(after: dayBefore, matching: target,
             matchingPolicy: .nextTime)
//   3/8 03:30 — 없는 02:30 대신 조정됐다

// 엄격 정책 — 존재하지 않으면 그날을 건너뛴다
cal.nextDate(after: dayBefore, matching: target,
             matchingPolicy: .strict)
//   3/9 02:30 — 전환일을 아예 건너뛴다

중복 시각repeatedTimePolicy가 정한다.

let fallBefore = cal.date(from: DateComponents(year: 2026, month: 10, day: 31, hour: 12))!
var t = DateComponents(); t.hour = 1; t.minute = 30

cal.nextDate(after: fallBefore, matching: t,
             matchingPolicy: .nextTime, repeatedTimePolicy: .first)
//   11/1 01:30 PDT — 첫 번째 (DST 적용 중)

cal.nextDate(after: fallBefore, matching: t,
             matchingPolicy: .nextTime, repeatedTimePolicy: .last)
//   11/1 01:30 PST — 두 번째 (표준시)
//   두 결과의 절대 시각은 정확히 1시간 차이다

반복 일정은 직접 루프를 돌리지 말고 enumerateDates에 맡긴다. 정책이 매 반복에 일관되게 적용된다.

var nine = DateComponents(); nine.hour = 9; nine.minute = 0

cal.enumerateDates(startingAfter: start, matching: nine,
                   matchingPolicy: .nextTime,
                   repeatedTimePolicy: .first,
                   direction: .forward) { date, exactMatch, stop in
    guard let date else { return }
    // exactMatch 가 false 면 조정이 일어났다는 뜻이다 — 로깅해 두면 진단에 도움된다
    if !exactMatch { logger.notice("시각 조정됨: \(date)") }
    schedule(at: date)
    if date > end { stop = true }
}

exactMatch 플래그가 중요하다. 조정이 일어났는지를 알려주므로, 페일 사일런트를 페일 라우드로 바꿀 수 있다.

실험 · 도구

정책별 결과를 나란히 찍어 보면 기본값의 영향이 분명해진다.

import Foundation

var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "America/Los_Angeles")!
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX"); fmt.timeZone = cal.timeZone
fmt.dateFormat = "MM-dd HH:mm zzz"

let before = cal.date(from: DateComponents(year: 2026, month: 3, day: 7, hour: 12))!
var t = DateComponents(); t.hour = 2; t.minute = 30

for (name, policy) in [("nextTime", Calendar.MatchingPolicy.nextTime),
                       ("nextTimePreservingSmallerComponents", .nextTimePreservingSmallerComponents),
                       ("previousTimePreservingSmallerComponents", .previousTimePreservingSmallerComponents),
                       ("strict", .strict)] {
    let d = cal.nextDate(after: before, matching: t, matchingPolicy: policy)
    print(String(format: "%-42@ %@", name as NSString,
                 (d.map(fmt.string(from:)) ?? "없음") as NSString))
}

기존 코드가 정책을 명시하는지 훑는다.

# 정책을 지정하지 않은 nextDate 호출 — 기본값이 조용히 적용된다
grep -rn "nextDate(after:" --include=*.swift . | grep -v matchingPolicy

# 반복 일정을 직접 루프로 돌리는 곳 — enumerateDates 로 옮길 후보
grep -rn "while.*addingTimeInterval\|for.*byAdding: .day" --include=*.swift .

# nil 을 기대하는 코드 — date(from:) 은 조정해서 값을 돌려준다
grep -rn -A2 "cal.date(from:" --include=*.swift . | grep -i "nil\|guard let\|else"

프로젝트 적용

  1. 정책을 항상 명시한다. 기본값에 기대면 그 기본값이 무엇인지 모른 채 몇 년이 지나간다.
  2. exactMatch를 확인하고 로깅한다. 조정이 일어난 사실을 남기면 전환일 장애를 사후 판독할 수 있다.
  3. 반복 일정은 enumerateDates에 맡긴다. 직접 루프는 정책을 매번 다시 구현하게 만든다.
  4. 제품 요구를 먼저 정한다. "없는 시각이면 건너뛴다"와 "밀어서라도 실행한다"는 기능 명세이지 구현 세부가 아니다.
  5. 전환일 경계를 단위 테스트에 고정한다. 2026년 기준 America/Los_Angeles의 3/8과 11/1이 대표 케이스다.
⚠️ 흔한 오해

"존재하지 않는 시각을 요청하면 nil이 온다"가 가장 위험한 가정이다 — 기본 정책이 조정해서 값을 돌려준다. "matchingPolicy를 안 쓰면 정책이 적용되지 않는다"도 틀렸다 — 기본값이 적용된다. "중복 시각은 어느 쪽이든 상관없다"도 틀렸다 — 두 결과의 절대 시각이 한 시간 다르다. "직접 루프로 하루씩 더하는 게 더 명확하다"도 위험하다 — 갭·중복 처리를 전부 다시 구현하게 된다.

🧒 쉽게 이해하기

"매일 새벽 2시 30분에 알람"을 맞춰 놨는데, 그날은 2시 30분이 없다. 시계가 2시에서 바로 3시로 건너뛰기 때문이다.

그럼 알람은 어떻게 될까? 답이 하나가 아니다.

"3시 30분에라도 울려라" — 약을 먹는 알람이라면 이게 맞다.
"1시 30분에 미리 울려라" — 미리 준비해야 하는 일이라면 이쪽.
"그날은 그냥 넘어가라" — 정시에만 의미가 있는 일이라면 이쪽.

셋 다 말이 된다. 그래서 프로그램이 마음대로 정할 수 없고, 우리가 골라줘야 한다.

문제는 안 골라도 프로그램이 알아서 하나를 고른다는 점이다. 기본은 "다음 시각으로 밀기"다. 대부분의 날에는 이런 일이 안 생기니까, 우리가 뭘 고른 줄도 모르고 몇 년이 지나간다. 그러다 어느 봄날 새벽에 알람이 한 시간 늦게 울린다.

꼬리 질문

반복 일정을 미리 계산해 저장해 두면 안 되는가?

미래 일정은 저장하지 말고 규칙을 저장하는 것이 원칙이다. Q3 꼬리질문에서 본 타임존 규칙 변경 때문이다.

미리 계산한 절대 시각을 저장하면 두 가지가 깨진다.

  • 정책 변경. 국가가 DST를 폐지하면 저장된 절대 시각이 지역 시간 기준으로 한 시간 어긋난다.
  • 사용자 이동. 타임존을 바꾸면 "매일 9시"의 의미가 달라져야 하는데 저장된 값은 그대로다.

대신 규칙 + 다음 몇 개만 캐시하는 절충이 실용적이다.

struct Recurrence: Codable {
    let components: DateComponents      // hour: 9, minute: 0
    let timeZoneID: String              // "Asia/Seoul" — 규칙 해석의 기준
    let policy: String                  // "nextTime" 등 — 정책도 함께 저장한다
}
//   실행 시점에 매번 계산한다. 캐시는 성능용일 뿐 진실의 원천이 아니다.

다만 이미 지나간 실행 기록은 절대 시각으로 저장해야 한다 — 그건 확정된 사실이라 규칙이 바뀌어도 변하면 안 된다.

쉽게 말하면 "매주 월요일 9시에 회의"라는 약속과 "지난주 월요일 9시에 회의했다"는 기록은 다르게 적어야 한다. 약속은 규칙으로 적어 두고 그때그때 계산하고, 기록은 일어난 그 순간을 못 박아 둔다.
exactMatch가 false인 경우를 사용자에게 알려야 하는가?

일정의 성격에 따라 다르다. 판단 기준은 "조정 때문에 사용자가 손해를 보는가"다.

  • 알려야 하는 경우 — 투약 알림, 마감 시각, 결제일처럼 시각 자체가 중요한 일정. "이번 주는 서머타임 전환으로 알림이 3:30에 울립니다" 같은 안내가 필요하다.
  • 안 알려도 되는 경우 — 백그라운드 동기화, 캐시 갱신처럼 대략의 주기만 맞으면 되는 작업.

어느 쪽이든 로그에는 남긴다. 사용자에게 안 보여주더라도 사후 조사에는 필요하다 — P1 18장에서 본 대로 "왜 그때 그렇게 동작했나"를 나중에 물을 수 있어야 한다.

if !exactMatch {
    logger.notice("일정 조정: 요청 \(requested, privacy: .public) → 실제 \(actual, privacy: .public)")
    metrics.increment("schedule.adjusted")     // 빈도를 지표로 추적한다
}

지표로 추적하면 연 2회 급증하는 패턴이 보이고, 그게 정상임을 팀이 알 수 있다.

쉽게 말하면 알람이 예정보다 늦게 울렸으면 왜 늦었는지 알려주는 게 친절하다. 특히 약 먹는 알람이라면 그렇다. 반대로 컴퓨터가 알아서 하는 정리 작업이라면 굳이 말할 필요는 없다. 다만 일지에는 적어 둬야 나중에 "그날 왜 이랬지?"에 답할 수 있다.
서버가 계산한 일정과 클라이언트 계산이 어긋난다면?

양쪽이 같은 세 가지를 공유하는지 확인한다 — 타임존 데이터베이스 버전, 정책, 기준 달력.

(1) tz 데이터베이스 버전. 서버가 오래된 이미지를 쓰고 있으면 최근 정책 변경이 반영되지 않는다. 컨테이너 베이스 이미지의 tzdata 버전을 확인한다.

(2) 정책. 서버 언어의 라이브러리는 기본 정책이 다를 수 있다. Python의 zoneinfo는 갭에 대해 조정하지 않고 그대로 반환하는 등 동작이 다르다. 정책을 명시적으로 합의해야 한다.

(3) 계산 주체를 하나로. 가장 확실한 해법은 한쪽만 계산하는 것이다. 서버가 다음 실행 시각을 계산해 절대 시각으로 내려주고 클라이언트는 그대로 쓰거나, 반대로 클라이언트가 계산해 올린다.

이건 P0 16장 Q4의 "기준이 하나뿐이면 모두가 같은 답에 동의한다"는 원칙과 같다. 두 곳에서 독립적으로 계산하면 어긋날 수밖에 없다.

쉽게 말하면 두 사람이 각자 달력을 보고 "다음 회의가 언제"를 계산하면, 달력 판이 다르거나 규칙을 다르게 알고 있으면 답이 갈린다. 그래서 한 사람이 정해서 알려주는 게 제일 확실하다.

Q5. DateFormatter는 왜 느리고 무엇을 캐시해야 하는가?

🔑 30초 답변

초기화가 무겁기 때문이다. 생성할 때마다 로케일·달력·타임존 데이터를 로드하고 심볼 테이블을 구성한다. 실측(3000회 반복)으로 매번 새로 만들면 캐시 재사용 대비 약 18배 느렸다(0.0483초 vs 0.0027초). Apple 문서도 인스턴스를 여러 번 만들고 버리는 것보다 캐싱이 효율적이라고 명시한다. 다만 스레드 안전성 보장이 제한적이다 — "설정을 끝내고 더 안 바꾼다"는 조건에서 동시 읽기만 보장되고, 사용 중 dateFormat을 바꾸는 것은 별개의 경합 문제다. 이 딜레마를 근본적으로 없애는 것이 값 타입인 FormatStyle이다(28장 Q6).

CS 원리

이 문제는 비싼 초기화를 가진 객체를 다루는 고전적 딜레마다.

초기화가 비싸면 재사용하고 싶어진다. 그런데 재사용하려면 공유해야 하고, 공유하면 가변 상태가 여러 곳에서 보인다. 여기서 캐싱과 스레드 안전성이 충돌한다.

해결 방식이 셋 있다.

Foundation은 세 번째로 갔다. FormatStyle열거형 복사 수준의 생성 비용이라 캐싱이 불필요하고, 값 타입이라 경합이 없다. 비싼 초기화를 없애는 것이 캐싱보다 나은 해법이라는 결론이다.

덧붙여 플랫폼별 보장 차이를 알아야 한다. Linux의 swift-corelibs-foundation은 이 스레드 안전성이 별도 이슈로 보고된 적이 있어, "Foundation이니 당연히 안전하다"고 넘기면 안 된다. 서버 사이드 Swift에서 특히 주의할 지점이다.

비싼 초기화 → 공유하고 싶다 → 공유하면 경합 위험 매번 생성 로케일·달력·타임존 로드 + 심볼 테이블 구성 × 3000회 0.0483초 캐시 재사용 초기화 1회 + 이후 문자열 생성만 0.0027초 약 18배 차이 — 그래서 캐싱 유인이 생긴다 그런데 공유하면 보장이 제한적이다 설정 후 안 바꾸면 동시 읽기만 보장된다 Linux 구현은 별도 이슈 보고 이력 있음 사용 중 dateFormat 변경 별개의 경합 문제 — 보장 밖 "캐시했으니 안전"이 아니다 값 타입(FormatStyle)은 딜레마 자체를 없앤다 생성이 싸므로 공유할 이유가 없고, 공유하지 않으므로 경합이 없다
캐싱은 증상 대응이고, 값 타입 재설계는 원인 제거다.

iOS에서는

레거시 코드에서 DateFormatter를 유지해야 한다면 불변 공유 규약을 코드로 강제하는 것이 최선이다.

// ✅ 설정을 초기화 시점에 끝내고 이후 노출하지 않는다
enum WireFormat {
    static let iso: DateFormatter = {
        let f = DateFormatter()
        f.locale = Locale(identifier: "en_US_POSIX")
        f.timeZone = TimeZone(secondsFromGMT: 0)
        f.calendar = Calendar(identifier: .gregorian)
        f.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
        return f                     // 반환 후 아무도 설정을 못 바꾸게 한다
    }()
}

// ❌ 흔한 사고 — 공유 인스턴스의 설정을 그때그때 바꾼다
formatter.dateFormat = "yyyy-MM-dd"     // 다른 스레드가 읽는 중이면 경합
let s = formatter.string(from: date)

더 나은 방향은 값 타입으로 옮기는 것이다. 캐싱 고민 자체가 사라진다.

// 기계용 — 전용 타입
let s = Date().formatted(.iso8601)

// 표시용 — 필드만 요청
let d = Date().formatted(.dateTime.year().month().day())

// 파싱도 같은 스타일로
let back = try Date("2026-08-15T14:30:00Z", strategy: .iso8601)

주의할 점은 "캐시했으니 스레드 안전하다"가 아니라는 것이다. 캐싱은 성능 문제를 풀 뿐이고, 안전성은 설정을 안 바꾼다는 별도 규약에서 온다.

실험 · 도구

18배 차이를 직접 재현해 보자.

import Foundation

let date = Date()
let clock = ContinuousClock()

// (A) 매번 새로 생성
let a = clock.measure {
    for _ in 0..<3_000 {
        let f = DateFormatter()
        f.locale = Locale(identifier: "en_US_POSIX")
        f.dateFormat = "yyyy-MM-dd"
        _ = f.string(from: date)
    }
}

// (B) 한 번 만들어 재사용
let shared = DateFormatter()
shared.locale = Locale(identifier: "en_US_POSIX")
shared.dateFormat = "yyyy-MM-dd"
let b = clock.measure { for _ in 0..<3_000 { _ = shared.string(from: date) } }

// (C) 값 타입
let c = clock.measure { for _ in 0..<3_000 { _ = date.formatted(.iso8601) } }

print("매번 생성: \(a)")   // 약 0.048초
print("캐시:      \(b)")   // 약 0.0027초 — 약 18배
print("FormatStyle: \(c)")

경합 위험이 있는 코드를 찾아낸다.

# 공유 포매터의 설정을 나중에 바꾸는 곳 — 경합 후보
grep -rn -A3 "static let.*Formatter" --include=*.swift . \
  | grep -E "dateFormat *=|timeZone *=|locale *="

# Thread Sanitizer 로 실제 경합을 잡는다 (P0 15장 Q6)
xcodebuild test -scheme App -enableThreadSanitizer YES

프로젝트 적용

  1. 새 코드는 FormatStyle을 쓴다. 캐싱·경합 문제가 함께 사라진다.
  2. 남겨 둘 DateFormatter는 불변으로 만든다. 생성 클로저 안에서 설정을 끝내고 let으로 노출한다.
  3. 공유 포매터의 설정을 런타임에 바꾸지 않는다. 형식이 여러 개면 포매터를 여러 개 만든다.
  4. Thread Sanitizer를 CI에 넣는다. 이 계열 경합은 재현이 드물어 수동 테스트로는 안 잡힌다.
  5. 서버 사이드 Swift라면 플랫폼 보장을 확인한다. Linux 구현의 안전성이 Apple 플랫폼과 같다고 가정하지 않는다.
⚠️ 흔한 오해

"DateFormatter는 스레드 안전하다"는 조건부로만 맞다 — 설정을 끝내고 더 안 바꾼다는 전제에서 동시 읽기만 보장된다. "캐시하면 스레드 안전해진다"도 틀렸다 — 캐싱은 성능 문제만 푼다. "FormatStyle도 캐싱해야 빠르다"도 틀렸다 — 생성 비용 자체가 작아 캐싱이 불필요하다. "성능 차이가 미미하다"도 실측과 다르다 — 약 18배다. "Foundation은 어디서나 같은 보장을 준다"도 위험하다 — Linux 구현은 별도다.

🧒 쉽게 이해하기

날짜를 예쁘게 적어 주는 기계가 있다. 그런데 이 기계는 켜는 데 오래 걸린다 — 나라별 규칙표를 전부 읽어 들여야 하기 때문이다.

그래서 쓸 때마다 새로 켜면 느리다. 실제로 재 보면 18배 차이가 난다. 당연히 한 번 켜 놓고 계속 쓰고 싶어진다.

그런데 여기서 문제가 생긴다. 여러 사람이 같은 기계 하나를 같이 쓰면, 한 사람이 설정을 바꾸는 순간 다른 사람 결과가 이상해진다. "읽기만 하면 괜찮다"는 약속이 있지만, 그 약속을 지키는지 아무도 감시하지 않는다.

새로 나온 방식은 이 고민 자체를 없앴다. 기계를 아주 가볍게 만들어서, 켜는 데 시간이 안 걸리게 한 것이다. 그러면 각자 자기 것을 켜서 쓰면 되고, 같이 쓸 이유가 없어진다.

이게 좋은 해법의 모양이다 — 문제를 잘 관리하는 게 아니라 문제가 생길 이유를 없애는 것.

꼬리 질문

포매터를 스레드별로 두는 방식은 어떤가?

동작은 하지만 대부분의 경우 과한 해법이다.

스레드 로컬 저장소에 포매터를 두면 공유가 없어 경합이 사라지고 재사용도 된다. 다만 대가가 있다.

  • 스레드 수만큼 초기화한다. 스레드 풀이 크면 메모리와 초기화 비용이 그만큼 늘어난다.
  • Swift Concurrency와 궁합이 나쁘다. async 함수는 await 전후로 다른 스레드에서 재개될 수 있어(P0 02장 Q8) 스레드 로컬 가정이 깨진다.
  • 수명 관리가 애매하다. 스레드가 죽을 때까지 남는다.

Swift Concurrency 환경에서는 @TaskLocal이나 액터에 가두는 방식이 더 맞고, 애초에 값 타입으로 옮기는 것이 가장 단순하다.

// 굳이 공유가 필요하면 액터에 가둔다
actor FormatterBox {
    private let f: DateFormatter = { … }()
    func string(from d: Date) -> String { f.string(from: d) }
}
//   다만 await hop 비용이 붙으므로, 값 타입으로 가는 편이 대개 낫다
쉽게 말하면 기계를 같이 쓰기 싫으면 사람마다 한 대씩 주면 된다. 그런데 사람이 많으면 기계도 많아지고, 게다가 일하다가 사람이 바뀌는(async에서 스레드가 바뀌는) 구조에서는 "내 기계"라는 개념 자체가 흔들린다.
ISO8601DateFormatter도 같은 비용이 드는가?

초기화 비용은 있지만 DateFormatter보다 가볍다. 로케일별 심볼 테이블을 구성할 필요가 없기 때문이다 — 형식이 고정돼 있으므로 로케일 데이터를 로드하지 않는다.

그래도 반복 호출 경로에서는 재사용하는 것이 낫고, 같은 스레드 안전성 주의가 적용된다(formatOptions를 나중에 바꾸면 안 된다).

가장 나은 선택은 역시 .iso8601 FormatStyle이다.

// 셋 다 같은 결과 — 비용과 안전성이 다르다
ISO8601DateFormatter().string(from: d)          // 매번 생성 — 피한다
sharedISO.string(from: d)                       // 재사용 — 설정 불변 규약 필요
d.formatted(.iso8601)                           // 값 타입 — 고민 없음

// 소수 초가 필요하면 옵션을 붙인다
d.formatted(.iso8601.dateTimeSeparator(.standard))

다만 파싱 관용도는 확인이 필요하다. 서버가 소수 초를 섞어 보내면 옵션을 맞춰야 하고, 이건 28장 Q7 꼬리질문에서 다룬 대로 계약을 하나로 정하는 것이 근본 해법이다.

쉽게 말하면 정해진 한 가지 형식만 쓰는 기계는 규칙표를 안 읽어도 되니 켜는 게 빠르다. 그래도 계속 껐다 켜는 것보다는 켜 두는 게 낫고, 가벼운 새 방식이 있으면 그게 제일 편하다.
포매팅이 실제로 병목인지 어떻게 확인하는가?

측정 없이 최적화하지 않는다는 원칙이 여기서도 적용된다. 확인 순서는 이렇다.

(1) Time Profiler로 스택을 본다. DateFormatter 관련 프레임이 상위에 보이는지 확인한다(P0 15장 Q6). 보이지 않으면 병목이 아니다.

(2) 호출 빈도를 센다. 목록 셀마다 포매팅하면 스크롤 중 초당 수백 번이 된다 — 이게 전형적인 병목 패턴이다.

// ❌ 셀 렌더마다 포매팅 — 스크롤 중 반복된다
cell.dateLabel.text = item.createdAt.formatted(.dateTime.month().day())

// ✅ 모델을 만들 때 한 번만 계산해 담아 둔다
struct Row { let title: String; let dateText: String }
//   다만 로케일 변경 시 재계산이 필요하다 (28장 Q7 꼬리질문)

(3) 화면 밖 작업인지 본다. 포매팅이 메인 스레드에서 일어나면 프레임 예산을 먹는다(20장 Q5). 목록 데이터를 백그라운드에서 준비하면서 함께 포매팅하는 것이 낫다.

대개는 포매터 생성이 아니라 호출 횟수가 문제다. 캐싱으로 18배를 아껴도 1000번 부를 것을 100번으로 줄이는 것이 더 크게 효과가 난다.

쉽게 말하면 기계 켜는 시간을 줄이는 것보다 기계를 덜 쓰는 게 대개 더 효과적이다. 같은 날짜를 화면 넘길 때마다 다시 계산하고 있다면, 그건 한 번 계산해서 적어 두면 될 일이다.

Q6. 시간 데이터를 어떻게 저장하고 전송해야 하는가?

🔑 30초 답변

무엇을 표현하는지에 따라 세 가지로 갈린다.일어난 순간(결제·로그·생성 시각)은 절대 시각으로 — UTC 타임스탬프나 오프셋 포함 ISO 8601. ② 달력상 날짜(생일·기념일·마감일)는 타임존 없는 날짜로 — "2026-08-15". 타임스탬프로 저장하면 타임존이 바뀔 때 하루 밀린다. ③ 미래의 지역 시각 약속(회의·알림)은 지역 시간 + 타임존 식별자로 — 정책이 바뀌면 따라가야 하기 때문이다(Q3 꼬리질문). 이 셋을 한 형식으로 뭉치면 반드시 어딘가 깨진다.

CS 원리

같은 "시간"이라는 말이 서로 다른 세 가지 개념을 가리킨다. 각각 다른 불변식을 가지므로 표현도 달라야 한다.

개념지켜야 할 불변식표현
순간(instant)지구 어디서 봐도 같은 순간UTC 타임스탬프 · 오프셋 포함 ISO 8601
달력 날짜타임존과 무관하게 같은 날2026-08-15 (시각 없음)
지역 시각 약속그 지역의 그 시각지역 시간 + 타임존 식별자

세 번째가 특히 간과된다. "2027년 7월 1일 서울 09:00 회의"를 지금 계산한 UTC 값으로 저장하면, 그 사이 한국이 서머타임을 도입할 경우 실제로는 08:00이나 10:00에 열린다. 저장해야 할 것은 계산 결과가 아니라 계산의 입력이다.

여기서 일반 원리가 나온다 — 파생값이 아니라 원본을 저장한다. 절대 시각은 (지역 시각 + 타임존 규칙)에서 계산되는 값이고, 규칙이 바뀔 수 있다면 계산 결과를 굳히면 안 된다.

마지막으로 윤초를 짚어야 한다. Unix time과 Date는 윤초를 의도적으로 무시한다 — 하루는 항상 86,400초다. 2016-12-31에 실제로 윤초가 삽입됐지만, 그 날짜 경계를 Date 차이로 재면 정확히 86400.0초가 나온다. 물리적으로는 86401초가 흘렀는데도 그렇다. 이는 버그가 아니라 설계상의 선택이고, 대부분의 앱에는 문제가 되지 않는다. 다만 정밀한 물리 시간이 필요하면 Date가 답이 아니라는 뜻이다.

"시간"이라는 한 단어가 세 가지를 가리킨다 ① 일어난 순간 결제 · 로그 · 생성 시각 UTC 타임스탬프 1765454200 또는 2026-08-15T14:30:00Z ② 달력 날짜 생일 · 기념일 · 마감일 타임존 없는 날짜 2026-08-15 타임스탬프로 저장하면 하루 밀린다 ③ 지역 시각 약속 회의 · 반복 알림 지역 시간 + 타임존 ID 2027-07-01 09:00 + "Asia/Seoul" ③을 UTC 로 굳히면 규칙이 바뀔 때 어긋난다 지금 계산해 UTC 로 저장 정책 변경 시 08:00 이나 10:00 에 열린다 입력을 저장하고 매번 계산 언제나 그 지역의 09:00 일반 원리 — 파생값이 아니라 계산의 입력을 저장한다 이미 확정된 과거는 파생값(절대 시각)이 곧 사실이므로 그대로 저장한다
세 개념을 한 형식으로 뭉치면 반드시 어딘가 깨진다.

iOS에서는

세 개념을 타입으로 갈라두면 호출부에서 헷갈릴 여지가 사라진다.

import Foundation

/// ① 일어난 순간 — 절대 시각
struct Instant: Codable, Comparable {
    let date: Date
    static func < (l: Self, r: Self) -> Bool { l.date < r.date }
}

/// ② 달력 날짜 — 타임존과 무관
struct CalendarDate: Codable, Hashable {
    let year: Int, month: Int, day: Int

    var iso: String { String(format: "%04d-%02d-%02d", year, month, day) }

    /// 특정 타임존에서 이 날짜가 시작하는 절대 시각
    func startOfDay(in tz: TimeZone) -> Date {
        var cal = Calendar(identifier: .gregorian); cal.timeZone = tz
        return cal.date(from: DateComponents(year: year, month: month, day: day))!
    }
}

/// ③ 지역 시각 약속 — 규칙을 저장한다
struct LocalAppointment: Codable {
    let components: DateComponents      // year·month·day·hour·minute
    let timeZoneID: String

    /// 지금의 타임존 규칙으로 절대 시각을 계산한다 — 저장하지 않고 매번 계산
    func resolve() -> Date? {
        guard let tz = TimeZone(identifier: timeZoneID) else { return nil }
        var cal = Calendar(identifier: .gregorian); cal.timeZone = tz
        return cal.date(from: components)
    }
}

JSON 계약도 이 구분을 반영해야 한다. 필드 이름에 의도를 담으면 오해가 줄어든다.

struct Order: Codable {
    let placedAtUTC: Date          // ① 순간 — ISO 8601 로 인코딩
    let deliveryDate: String       // ② 달력 날짜 — "2026-08-20"
    let pickupLocalTime: String    // ③ 지역 시각 — "2026-08-20T14:00"
    let pickupTimeZone: String     // ③ 의 짝 — "Asia/Seoul"
}

let dec = JSONDecoder()
dec.dateDecodingStrategy = .iso8601      // ① 만 Date 로 디코딩된다

흔한 사고 하나 — 생일을 Date로 저장하면 사용자가 해외로 이동했을 때 하루 밀려 보인다. 생일은 순간이 아니라 달력 날짜다.

실험 · 도구

생일이 밀리는 현상을 직접 재현해 보자.

import Foundation

// 서울에서 1990-08-15 자정으로 저장했다고 하자
var seoul = Calendar(identifier: .gregorian)
seoul.timeZone = TimeZone(identifier: "Asia/Seoul")!
let stored = seoul.date(from: DateComponents(year: 1990, month: 8, day: 15))!

// 사용자가 LA 로 이동하면
var la = Calendar(identifier: .gregorian)
la.timeZone = TimeZone(identifier: "America/Los_Angeles")!
let c = la.dateComponents([.year, .month, .day], from: stored)
print("\(c.year!)-\(c.month!)-\(c.day!)")     // 1990-8-14   ← 하루 밀렸다

// 달력 날짜로 저장했다면 어디서 보든 8월 15일이다
let birthday = (year: 1990, month: 8, day: 15)

윤초가 무시되는 것도 확인할 수 있다.

// 2016-12-31 에 윤초가 실제로 삽입됐다
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(secondsFromGMT: 0)!
let a = utc.date(from: DateComponents(year: 2016, month: 12, day: 31))!
let b = utc.date(from: DateComponents(year: 2017, month: 1, day: 1))!

print(b.timeIntervalSince(a))     // 86400.0  ← 물리적으로는 86401초가 흘렀다
//   Unix time 은 윤초를 의도적으로 무시한다. 버그가 아니라 설계다.

기존 코드에서 개념이 섞인 곳을 찾는다.

# 날짜 성격 필드가 Date 타입인 곳 — ② 를 ① 로 저장한 후보
grep -rn -E "(birth|anniversar|dueDate|expiryDate).*: *Date" --include=*.swift .

# 미래 일정을 절대 시각으로 저장하는 곳 — ③ 을 ① 로 저장한 후보
grep -rn -E "(scheduledAt|meetingAt|remindAt).*: *Date" --include=*.swift .

프로젝트 적용

  1. 필드마다 세 개념 중 어느 것인지 정한다. 스키마 문서에 명시하고 필드 이름에 반영한다 — placedAtUTC, deliveryDate, pickupLocalTime.
  2. 달력 날짜를 Date로 저장하지 않는다. 생일·기념일·마감일이 대표적이다. 문자열이나 년·월·일 정수로 저장한다.
  3. 미래 일정은 규칙을 저장한다. 절대 시각으로 굳히면 타임존 정책 변경에 취약해진다.
  4. 타임스탬프에는 단위를 이름에 넣는다. createdAtMs처럼. 초/밀리초 혼동은 1000배 오차를 낸다.
  5. 윤초가 필요한 정밀도라면 Date가 아닌 다른 수단을 쓴다. 대부분의 앱에는 해당하지 않지만, 알고 쓰는 것과 모르고 쓰는 것은 다르다.
⚠️ 흔한 오해

"모든 시간은 UTC로 저장하면 안전하다"는 절반만 맞다 — 순간에는 옳지만 달력 날짜와 미래 지역 약속에는 틀리다. "생일을 Date로 저장해도 된다"는 틀렸다 — 타임존이 바뀌면 하루 밀린다. "미래 일정을 미리 계산해 두면 효율적이다"도 위험하다 — 타임존 규칙이 바뀔 수 있다. "Unix time은 물리적 경과 시간이다"도 틀렸다 — 윤초를 무시하므로 하루는 항상 86,400초로 계산된다. "타임스탬프는 국제 표준이라 단위 혼동이 없다"도 틀렸다 — 초와 밀리초가 모두 쓰인다.

🧒 쉽게 이해하기

"시간"이라고 부르지만 사실은 세 가지 다른 것이다.

첫째, 일어난 순간. "결제가 된 그 순간"은 지구 어디서 보든 같은 한 순간이다. 이건 스톱워치 숫자로 적어 두면 된다.

둘째, 달력의 날짜. 내 생일이 8월 15일이라면, 내가 미국에 있든 한국에 있든 8월 15일이다. 그런데 이걸 스톱워치 숫자로 적어 두면, 나라를 옮겼을 때 8월 14일로 보인다. 생일이 하루 당겨지는 것이다.

셋째, 앞으로의 약속. "내년 7월 1일 서울 시간 9시에 회의"는, 그 사이 나라가 시간 규칙을 바꾸면 실제 순간이 달라져야 한다. 그러니까 지금 미리 계산해서 못 박아 두면 안 되고, "서울 9시"라는 약속 자체를 적어 둬야 한다.

이 셋을 한 가지 방식으로 적으려고 하면 반드시 어딘가 어긋난다.

덧붙여 재미있는 사실 하나. 컴퓨터의 시간은 윤초를 없는 셈 친다. 하루를 무조건 86400초로 계산한다. 실제로 1초가 더 있었던 날도 컴퓨터에게 물어보면 "86400초였다"고 답한다. 이건 고장이 아니라 일부러 그렇게 정한 것이다.

꼬리 질문

사용자가 여행 중일 때 "오늘의 기록"은 어느 타임존 기준이어야 하는가?

제품이 정해야 하는 문제이고, 어느 쪽이든 일관성이 핵심이다.

선택지와 성격이 다르다.

  • 현재 위치 타임존 — "지금 여기의 하루". 여행 중 자연스럽지만, 이동하는 날의 기록이 겹치거나 비어 보인다. 시차가 큰 곳으로 가면 하루가 두 번 오거나 사라진다.
  • 홈 타임존 고정 — 사용자가 설정한 기준. 통계가 안정적이지만 여행 중 "오늘"이 어색하다.
  • 기록 시점의 타임존을 함께 저장 — 가장 정보가 많다. 나중에 어느 기준으로든 다시 집계할 수 있다.

세 번째를 권한다 — 절대 시각 + 그때의 타임존을 함께 저장하면 나중에 정책을 바꿔도 재계산이 가능하다. 파생값이 아니라 원본을 저장하는 원칙과 같다.

struct Entry: Codable {
    let at: Date              // 절대 시각
    let timeZoneID: String    // 기록 당시의 타임존
}
//   "홈 기준 통계"와 "현지 기준 통계"를 둘 다 만들 수 있다
쉽게 말하면 여행하면서 일기를 쓰는데, "현지 시간"으로 날짜를 매길지 "집 시간"으로 매길지 정해야 한다. 어느 쪽이든 되는데, 둘을 섞으면 같은 날이 두 번 나오거나 하루가 빈다. 제일 좋은 건 시각과 함께 "그때 어디였는지"도 적어 두는 것이다.
서버가 타임스탬프를 초로 주는지 밀리초로 주는지 어떻게 확인하는가?

값의 자릿수로 즉시 판별할 수 있다. 현재 시각 기준으로 초는 10자리, 밀리초는 13자리다.

func normalizeEpoch(_ raw: Double) -> Date {
    // 2001년~2286년 범위를 초로 해석했을 때 벗어나면 밀리초로 본다
    let asSeconds = Date(timeIntervalSince1970: raw)
    let plausible = (2000...2200)
    var cal = Calendar(identifier: .gregorian); cal.timeZone = TimeZone(secondsFromGMT: 0)!
    let year = cal.component(.year, from: asSeconds)
    return plausible.contains(year) ? asSeconds
                                    : Date(timeIntervalSince1970: raw / 1000)
}

다만 이런 추측 로직은 임시 방편이다. 근본 해법은 계약을 명시하는 것이다.

  • API 문서에 단위를 적는다.
  • 필드 이름에 단위를 넣는다createdAtMs, expiresAtSec.
  • 가능하면 ISO 8601 문자열을 쓴다. 사람이 읽을 수 있어 이런 혼동이 애초에 없다.

추측 로직의 위험은 경계에서 틀린다는 점이다. 1970년대 초 날짜나 아주 먼 미래 값은 오판할 수 있다. 계약이 없으면 데이터가 이상할 때 어느 쪽이 잘못인지도 판정할 수 없다.

쉽게 말하면 숫자만 받고 "이게 초일까 밀리초일까" 맞히는 건 자릿수를 세어 짐작하는 것이다. 대개 맞지만 가끔 틀린다. 애초에 "이건 밀리초"라고 이름에 적어 두면 짐작할 필요가 없다.
과거 데이터를 잘못된 형식으로 저장해 왔다면 어떻게 고치는가?

마이그레이션 가능 여부를 먼저 판정한다. 정보가 손실됐으면 복구할 수 없는 경우가 있다.

(1) 복구 가능한 경우. 달력 날짜를 Date로 저장했지만 저장 당시 타임존을 알 수 있으면 되돌릴 수 있다. 대개 저장 시점의 서버 타임존이나 사용자 기본값이 일정했다면 그 렌즈로 읽어 날짜를 복원한다.

// 저장 당시 KST 자정으로 넣었다면 KST 렌즈로 읽어 날짜를 복원한다
var kst = Calendar(identifier: .gregorian)
kst.timeZone = TimeZone(identifier: "Asia/Seoul")!
let c = kst.dateComponents([.year, .month, .day], from: legacyDate)
let restored = CalendarDate(year: c.year!, month: c.month!, day: c.day!)

(2) 복구 불가능한 경우. 지역 시간만 저장했는데 폴백 중복 구간에 걸린 값은 어느 쪽인지 알 수 없다(Q3). 이 경우 규칙을 정해 일괄 처리하고 불확실성을 기록해 둔다.

(3) 진행 방식. P0 09장 Q4의 마이그레이션 원칙이 그대로 적용된다 — 원자성, 단조 전진, 실제 데이터에 가까운 케이스로 사전 검증. 그리고 양방향 호환 기간을 두어 구버전 앱이 읽을 수 있게 새 필드를 추가하고 옛 필드를 당분간 유지한다(31장).

쉽게 말하면 이미 잘못 적어 둔 장부를 고칠 수 있는지는 정보가 남아 있느냐에 달렸다. "8월 15일"을 "몇 초"로 적어 뒀어도 어느 나라 시계로 적었는지 알면 되돌릴 수 있다. 그런데 시계를 뒤로 돌린 날의 애매한 시각은 어느 쪽이었는지 영영 모른다.

출처 · 참고자료