22장이 Date라는 절대 시점과 Calendar의 달력 연산을 다뤘다면, 이 장은 그 결과를 사람이 읽는 문자열로 — 또는 그 반대로 — 바꾸는 지점을 다룬다. DateFormatter를 매번 새로 만들면 정확히 얼마나 손해인지, YYYY 한 글자가 새해 근처에서 왜 연도를 통째로 틀리게 만드는지, HH를 분명히 지정했는데도 오후 2시가 왜 섞여 나오는지, FormatStyle이 DateFormatter와 설계상 근본적으로 다른 이유, 그리고 돈 계산에 Double을 쓰면 안 되는 진짜 이유까지 — 전부 이 문서를 쓰면서 Swift 6.2.1 / macOS 26 SDK에서 직접 돌려 확인한 결과다.
Q1. DateFormatter는 왜 생성 비용이 비싸고, 스레드 안전성은 어디까지 보장되는가?
DateFormatter를 만드는 건 로케일·캘린더·타임존 데이터를 로드하고 월/요일 이름 같은 심볼 테이블을 구성하는 무거운 초기화라, 매번 새로 만들면 실측 기준 캐시 재사용 대비 18배 느렸다. Apple 공식 문서도 "인스턴스를 여러 번 만들고 버리는 것보다 하나를 캐싱해 재사용하는 편이 대체로 더 효율적"이라고 명시한다. 스레드 안전성은 "설정을 끝내고 더 이상 바꾸지 않는다"는 조건에서만 여러 스레드가 동시에 읽기(포맷팅)를 해도 안전하다는 뜻이지, 한 스레드가 포맷팅하는 동안 다른 스레드가 dateFormat 같은 프로퍼티를 바꿔도 안전하다는 뜻이 아니다.
원리
Apple 문서는 "Creating a date formatter is not a cheap operation... it is typically more efficient to cache a single instance than to create and dispose of multiple instances"라고 명시한다. 그리고 iOS 7 / macOS 10.9 이후 "modern behavior + 64비트" 조건에서 DateFormatter는 여러 스레드에서 동시에 사용해도 스레드 세이프하다고 안내한다 — 이건 "안전하게 만들어 두면 여러 스레드가 동시에 읽어도 된다"는 뜻이지, "설정 변경도 아무 때나 해도 된다"는 뜻이 아니다.
내부 동작
생성 비용이 큰 이유는 초기화 시점에 로케일 데이터(월/요일 이름, AM/PM 심볼, 숫자 구분자 등), 캘린더 규칙, 타임존 오프셋 테이블을 로드하고 dateFormat 패턴 문자열을 파싱해 내부 심볼 테이블을 구성해야 하기 때문이다. 이건 인스턴스마다 반복할 필요가 없는 준비 작업인데, 새 인스턴스를 만들 때마다 처음부터 다시 한다. 스레드 안전성 보장은 플랫폼에 따라 갈린다는 점도 실무에서 중요하다 — Darwin(iOS/macOS)의 Foundation은 위 조건에서 안전하다고 명시하지만, swift-corelibs-foundation(Linux)에서는 DateFormatter가 스레드 세이프하지 않다는 이슈가 별도로 보고된 적이 있다. 서버사이드 Swift처럼 크로스플랫폼 코드를 짤 때는 이 차이를 그냥 "Foundation이니까 당연히 안전하겠지"로 넘기면 안 된다.
실험 · 도구
아래 코드를 그대로 파일로 저장해 swift 파일명.swift로 돌리면 직접 확인할 수 있다.
import Foundation
let iterations = 3000
let t0 = Date()
for _ in 0..<iterations {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
_ = f.string(from: Date())
}
let freshElapsed = Date().timeIntervalSince(t0)
let cached = DateFormatter()
cached.locale = Locale(identifier: "en_US_POSIX")
cached.dateFormat = "yyyy-MM-dd HH:mm:ss"
let t1 = Date()
for _ in 0..<iterations {
_ = cached.string(from: Date())
}
let cachedElapsed = Date().timeIntervalSince(t1)
print("매번 새로 생성:", freshElapsed, "초") // 실측: 0.0483초
print("캐시 재사용:", cachedElapsed, "초") // 실측: 0.0027초
print("비율:", freshElapsed / cachedElapsed, "배") // 실측: 약 18배프로젝트 적용
① 앱 전체에서 반복해서 쓰는 고정 포맷터는 static let로 한 번만 만들어 절대 재설정하지 않는다. Swift의 static let은 내부적으로 swift_once류의 1회 초기화 보장을 쓰므로, 이 캐시 자체를 만드는 과정도 스레드 세이프하다.
extension DateFormatter {
// 설정을 끝낸 뒤로는 절대 바꾸지 않는다 — 그래야 여러 스레드가 동시에 읽어도 안전하다
static let wireDateTime: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return f
}()
}
// 어디서든 이 인스턴스 하나를 공유해서 쓴다 — 매번 새로 만들지 않는다
let payloadDate = DateFormatter.wireDateTime.string(from: Date())② "스레드 세이프하다"는 말을 "아무 스레드에서나 아무 때나 설정을 바꿔도 된다"로 착각하면 안 된다. 용도가 다르면 아예 별도의 불변 포맷터를 만든다.
// ❌ 여러 스레드가 같은 wireDateTime을 참조하는데, 누군가 이렇게
// 그때그때 설정을 바꿔버리면 동시에 string(from:)을 부르는 다른 스레드가
// 절반만 바뀐 설정으로 포맷팅할 수 있다
func badFormat(_ date: Date) -> String {
DateFormatter.wireDateTime.dateFormat = "MM/dd" // 공유 인스턴스를 오염시킴
return DateFormatter.wireDateTime.string(from: date)
}
// ✅ 용도가 다르면 처음부터 별도 인스턴스로 분리해 각자 불변으로 둔다
extension DateFormatter {
static let displayMonthDay: DateFormatter = {
let f = DateFormatter()
f.setLocalizedDateFormatFromTemplate("MMdd")
return f
}()
}"DateFormatter는 가벼우니 테이블뷰 셀마다 새로 만들어도 상관없다"는 실측(약 18배)과 정면으로 배치된다. "스레드 세이프하다고 문서에 적혀 있으니 여러 스레드에서 자유롭게 설정을 바꿔도 된다"도 틀렸다 — 그 보장은 "설정을 끝낸 뒤 동시에 읽기"에 대한 것이지 "동시에 쓰기"에 대한 것이 아니다. 그리고 이 보장 자체가 Darwin Foundation 한정 문서이며, Linux의 swift-corelibs-foundation은 별도로 스레드 안전성 이슈가 보고된 적이 있다는 점도 놓치기 쉽다.
DateFormatter를 만드는 건 새 통역사를 급하게 뽑아서 그 자리에서 사전을 통째로 외우게 시키는 것과 비슷하다. 회의 열 번마다 매번 새 통역사를 뽑아 사전부터 다시 외우게 하면 당연히 느리다. 한 번 사전을 다 외운 통역사를 계속 쓰면 훨씬 빠르다 — 이게 캐싱이다. 비유가 깨지는 곳: 이 통역사는 "가만히 있으면" 여러 회의실에 동시에 빌려줘도(=여러 스레드에서 동시에 읽어도) 괜찮지만, 회의 도중에 누가 "이제부터 다른 언어로 통역해"라고 지시를 바꿔버리면(=사용 중에 dateFormat을 바꾸면) 혼선이 생긴다.
꼬리 질문
DateFormatter뿐 아니라 Calendar나 Locale도 비슷하게 캐싱이 필요한가?
Calendar도 캘린더 규칙과 타임존 데이터를 들고 있어 생성이 공짜는 아니고, Locale도 문화권 데이터를 참조한다. 다만 swift-foundation 재작성 이후 swift.org가 공개한 벤치마크에 따르면 고정 Locale 조회는 ObjC 브리징 제거로 "한 자릿수 크기(order of magnitude)" 더 빨라졌다고 밝혔다 — DateFormatter만큼 큰 캐싱 이득이 항상 나는 건 아니라는 뜻이다. 그래도 반복 생성을 피하는 습관 자체는 셋 다에 유효하다.static let으로 캐싱하면 그 인스턴스는 정확히 언제 처음 만들어지는가?
static let에 처음 접근하는 순간에 지연 생성(lazy)된다. Swift 런타임이 이 최초 생성을 1회만 실행되도록 보장하는 동기화 장치를 내장하고 있어서, 여러 스레드가 동시에 처음 접근해도 초기화 클로저는 정확히 한 번만 실행된다.서버사이드 Swift처럼 요청이 몰리는 환경에서는 포맷터 하나를 공유하는 게 오히려 병목이 되지 않는가?
Q2. YYYY와 yyyy의 차이가 새해에 어떤 버그를 만드는가?
yyyy는 그레고리력 달력 연도고, YYYY는 ISO 8601 주차(week-of-year) 연도다 — 그 주가 속한 해를 기준으로 하므로 연말 며칠은 다음 해 1주차로, 연초 며칠은 지난해 마지막 주로 잡힐 수 있다. 실측으로 확인한 결과, 2025년 12월 29~31일은 yyyy로는 여전히 2025년인데 YYYY로는 이미 2026년으로 찍힌다. 2014년 12월 31일, Twitter가 이미 "2015년"으로 시간을 표시한 사건이 이 버그의 가장 유명한 실사례로 알려져 있다.
원리
Unicode UTS #35(LDML)는 날짜 포맷 패턴 심볼 표에서 소문자 y(calendar year)와 대문자 Y(week-of-year, ISO year)를 별개 필드로 정의한다. ISO 8601 주차 규칙은 "그 해의 첫 목요일이 포함된 주"를 1주차로 삼는다 — 다르게 말하면 1월 4일이 포함된 주가 항상 1주차다. 그 결과 12월 마지막 며칠이 그 해의 첫 목요일이 다음 해에 속한 주(=다음 해 1주차)로 계산될 수 있고, 반대로 1월 첫 며칠이 지난해 마지막 주로 잡힐 수도 있다.
내부 동작
실제로 이 규칙을 적용할 때 어떤 캘린더의 주 시작 요일(firstWeekday)·최소 일수(minimumDaysInFirstWeek) 설정을 쓰느냐가 결과에 영향을 준다는 걸 직접 확인했다. DateFormatter.locale만 en_US_POSIX로 고정하고 calendar는 지정하지 않으면(포맷터가 로케일의 기본 캘린더를 그대로 물려받는 경우), YYYY는 12월 28일(일요일)부터 벌써 2026을 찍는다. 반면 포맷터에 명시적으로 Calendar(identifier: .iso8601)을 붙이면, 순수 ISO 8601 규칙대로 12월 29일(월요일)부터 2026이 시작된다 — 딱 하루 차이지만, "YYYY 버그는 항상 12/29부터"라고 단정하면 실제 기본 설정에서는 틀린다.
| 날짜(요일) | yyyy | YYYY (포맷터 기본 캘린더) | YYYY (명시적 iso8601 캘린더) |
|---|---|---|---|
| 2025-12-27(토) | 2025 | 2025 | 2025 |
| 2025-12-28(일) | 2025 | 2026 ← 다름 | 2025 |
| 2025-12-29(월) | 2025 | 2026 ← 다름 | 2026 ← 다름 |
| 2025-12-30(화) | 2025 | 2026 ← 다름 | 2026 ← 다름 |
| 2025-12-31(수) | 2025 | 2026 ← 다름 | 2026 ← 다름 |
| 2026-01-01(목) | 2026 | 2026 | 2026 |
실험 · 도구
아래 코드를 그대로 실행하면 위 표의 수치를 직접 재현할 수 있다. en_US_POSIX로 로케일을 고정해도 calendar를 지정하지 않으면 로케일이 물려주는 기본 캘린더 규칙이 그대로 적용된다는 점에 주의한다.
import Foundation
var gcal = Calendar(identifier: .gregorian)
gcal.timeZone = TimeZone(identifier: "UTC")!
func testYearSymbol(formatterCalendar: Calendar?) {
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.timeZone = TimeZone(identifier: "UTC")
if let c = formatterCalendar { df.calendar = c } // nil이면 로케일 기본 캘린더를 그대로 씀
for day in 25...31 {
var c = DateComponents()
c.year = 2025; c.month = 12; c.day = day; c.hour = 12
guard let date = gcal.date(from: c) else { continue }
df.dateFormat = "yyyy-MM-dd"
let a = df.string(from: date)
df.dateFormat = "YYYY-MM-dd"
let b = df.string(from: date)
print(a, b, a == b ? "" : "<- 다름")
}
}
testYearSymbol(formatterCalendar: nil) // 포맷터 기본 캘린더: 12/28부터 어긋남
testYearSymbol(formatterCalendar: Calendar(identifier: .iso8601)) // 명시적 ISO8601: 12/29부터 어긋남프로젝트 적용
① 화면에 표시하거나 로그에 남길 "올해가 몇 년도인지"는 무조건 yyyy다. YYYY를 "그냥 대문자 버전"이라고 생각하고 습관적으로 쓰면 안 된다.
let df = DateFormatter()
df.locale = Locale(identifier: "en_US_POSIX")
df.timeZone = TimeZone(identifier: "UTC")
// ❌ "연도니까 대문자든 소문자든 상관없겠지" — 연말연시에 연도가 튄다
df.dateFormat = "YYYY-MM-dd"
// ✅ 캘린더 연도가 필요하면 항상 yyyy
df.dateFormat = "yyyy-MM-dd"② ISO 주차 자체를 진짜로 보여줘야 하는 화면(예: "2026년 W01")이라면 YYYY를 쓰되, 로케일에만 맡기지 말고 Calendar(identifier: .iso8601)을 formatter에 명시적으로 붙여야 방금 실측한 것처럼 규칙이 흔들리지 않는다.
let weekFormatter = DateFormatter()
weekFormatter.locale = Locale(identifier: "en_US_POSIX")
weekFormatter.timeZone = TimeZone(identifier: "UTC")
weekFormatter.calendar = Calendar(identifier: .iso8601) // 로케일 기본 캘린더에 맡기지 않는다
weekFormatter.dateFormat = "YYYY'년 W'ww" // "2026년 W01""YYYY나 yyyy나 어차피 연도니까 아무거나 써도 된다"는 이 챕터에서 가장 유명한 오해이자 틀린 생각이다. 그리고 "YYYY 버그는 항상 12월 29일부터 시작한다"도 정확하지 않다 — 실측대로 calendar를 지정하지 않은 기본 상태에서는 하루 더 일찍(12/28)부터 어긋날 수 있다. 어느 날짜부터 갈리는지는 그 해에 1월 1일이 무슨 요일인지, 그리고 포맷터가 어떤 캘린더의 주 규칙을 쓰는지에 따라 매년 달라진다.
달력에 적힌 숫자로서의 "올해"(yyyy)와, "이번 주가 몇 번째 주냐"고 셀 때 그 주가 속한 "해"(YYYY)는 서로 다른 질문에 대한 답이다. 학기 말 며칠이 이미 다음 학기 1주차로 잡히는 것과 비슷하다 — 달력 날짜로는 아직 이번 학기인데, "주차 세기" 기준으로는 벌써 다음 학기가 시작된 셈이다. 비유가 깨지는 곳: 학기 경계는 학교가 임의로 정하는 규칙이지만, ISO 주차 규칙은 "그 해의 첫 목요일이 포함된 주"라는 만국 공통의 고정된 정의를 따른다는 점이다.
꼬리 질문
Twitter의 2014년 12월 31일 "2015년" 표시 사건은 정확히 뭐가 원인이었나?
yyyy 대신 ISO 주차 연도를 뜻하는 YYYY를 실수로 쓴 것이 원인으로 지목된다. 2014년 12월 31일이 마침 그 해의 마지막 주가 다음 해 1주차로 계산되는 날짜에 걸려 있었던 것이다.YYYY 버그는 매년 12/29~31 근처에서만 생기는가?
YYYY를 캘린더 연도 자리에 쓰지 않는 게 유일하게 안전한 규칙이다.포맷터에 Calendar(identifier: .iso8601)을 아예 기본으로 항상 붙여버리면 안전하지 않은가?
.iso8601 캘린더는 주 시작 요일을 월요일로, weekday 컴포넌트 번호 체계도 그레고리력 기본(1=일요일)과 다르게 계산한다. 화면 어딘가에서 calendar.component(.weekday, from:) 같은 값을 그레고리력 기준으로 쓰고 있었다면, 캘린더만 바꿔치기하는 순간 그 코드가 조용히 틀어진다. 진짜 안전한 규칙은 "ISO 주차가 정말 필요한 그 포맷터 하나에만" 명시적으로 붙이는 것이지, 전역 기본값을 바꾸는 게 아니다.Q3. HH를 지정했는데 AM/PM이 섞여 나오는 이유와 en_US_POSIX의 역할은?
dateFormat에 적은 패턴 문자는 문자 그대로 찍히는 게 아니라 formatter의 locale에 상대적으로 재해석된다. locale을 명시적으로 지정하지 않으면 DateFormatter는 Locale.current(기기/시스템의 지역 설정)에 위임하는데, 그 로케일의 시간 표기 선호(hourCycle)가 12시간제면 HH를 써놨어도 AM/PM이 섞여 나온다. 실측해보니 어떤 로케일 문자열을 골랐느냐가 아니라, locale 자체를 지정하지 않고 Locale.current에 맡겼는지가 진짜 갈림길이었다 — 해법은 고정 와이어 포맷엔 항상 locale = Locale(identifier: "en_US_POSIX")를 명시하는 것이다.
원리
ICU 기반 날짜 포맷 엔진은 개발자가 준 패턴 문자열을 "이 필드들을 이 순서로 보여달라"는 의도로 해석하되, 그 필드가 몇 시간제로 표시될지 같은 세부는 로케일의 선호를 반영해 조정할 수 있다. Apple Developer Forums 스레드와 Swift Forums 스레드 둘 다 이 현상을 실제 보고 사례로 다루고 있고, "이건 버그가 아니라 지역화 원칙에서 나온 의도된 동작"이라는 게 대체적인 결론이다. 문제는 이 지역화가 "화면에 보여줄 날짜"에는 바람직해도, 서버로 보낼 고정 와이어 포맷에는 치명적이라는 데 있다.
내부 동작
직접 실측해보니 통념과 다른 지점이 있었다. Locale(identifier: "en_US"), Locale(identifier: "ko_KR"), Locale(identifier: "en_GB")처럼 어떤 로케일이든 명시적으로 식별자를 줘서 만들면, dateFormat = "HH:mm:ss"는 넷 다 정확히 24시간제(14:30:00)로 나왔다. 진짜 문제는 locale을 아예 지정하지 않은 경우였다 — 이 기기는 시스템 로케일이 ko_KR이고 Locale.current.hourCycle이 oneToTwelve(12시간제)였는데, locale을 지정하지 않은 DateFormatter가 여기 위임하면서 HH:mm:ss를 지정했음에도 오후 2:30:00이 나왔다. 즉 "12시간제 로케일을 명시적으로 설정해야 재현된다"가 아니라, "locale을 아예 안 정해서 시스템 설정에 위임하는 것 자체가 위험 지점"이라는 게 더 정확한 설명이다.
실험 · 도구
import Foundation
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
var c = DateComponents()
c.year = 2026; c.month = 1; c.day = 15; c.hour = 14; c.minute = 30
let date = cal.date(from: c)!
let df = DateFormatter()
df.dateFormat = "HH:mm:ss"
df.timeZone = TimeZone(identifier: "UTC")
for id in ["en_US", "ko_KR", "en_GB", "en_US_POSIX"] {
df.locale = Locale(identifier: id)
print(id, "->", df.string(from: date)) // 넷 다 "14:30:00" — 정상
}
df.locale = nil // Locale.current에 위임
print("locale 미지정 ->", df.string(from: date))
// 이 기기(Locale.current = ko_KR, hourCycle = oneToTwelve)에서 실측: "오후 2:30:00"프로젝트 적용
① 서버와 주고받는 고정 와이어 포맷, 로그, 파일명처럼 "무조건 이 표기법 그대로"여야 하는 곳에는 반드시 locale과 timeZone을 명시한다. en_US_POSIX는 특정 문화권 관례가 아니라 POSIX/C 표준에 대응하는 고정되고 문화권에 영향받지 않는 로케일이라 이 용도에 정확히 맞는다.
extension DateFormatter {
static let wireDateTime: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX") // 기기 설정과 무관하게 항상 24시간·고정 구분자
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return f
}()
}② 반대로 사용자에게 보여줄 화면이라면 오히려 locale을 고정하면 안 된다 — 그 사람이 평소 보는 12/24시간 표기를 그대로 따라가는 게 맞는 동작이다. 이 경우는 Locale.current에 맡기거나(혹은 Q4의 FormatStyle), 명시적 dateFormat 대신 setLocalizedDateFormatFromTemplate(_:)로 필드만 요청한다.
let displayFormatter = DateFormatter()
displayFormatter.setLocalizedDateFormatFromTemplate("yMMMdjm") // 연/월/일/시/분 필드만 요청
// 필드 순서·12/24시간 표기는 사용자 로케일 설정을 그대로 따라간다"HH를 쓰면 무조건 24시간제로 나온다"는 틀렸다 — locale을 명시하지 않으면 그렇지 않다. 그런데 이보다 더 흔히 잘못 짚는 지점은 "12시간제 로케일 문자열을 실수로 넣어서 생기는 문제"라고 생각하는 것이다. 실측 결과 명시적으로 로케일을 지정하기만 하면(그게 12시간제가 흔한 나라의 로케일이라도) 문제가 재현되지 않았다 — 진짜 원인은 locale을 아예 지정하지 않는 것이다.
시계 눈금을 "24시간 눈금 그대로 읽어줘"(HH)라고 부탁했는데, 통역사가 "이 사람은 평소 오전/오후로 보는 습관이 있으니 그렇게 바꿔서 말해줄게"라며 마음대로 바꿔버리는 것과 같다. 통역사에게 "무조건 이 나라 표기법대로"(en_US_POSIX)라고 못 박으면 부탁한 그대로 말해준다. 비유가 깨지는 곳: 이 "습관"은 통역사(로케일)를 아예 안 정해줬을 때만 시스템이 대신 골라주는 것이지, 통역사를 딱 지정해주면(설령 그 통역사 나라가 평소 12시간제를 쓰더라도) 부탁한 표기를 그대로 지킨다.
꼬리 질문
en_US_POSIX가 다른 로케일과 근본적으로 다른 점은 무엇인가?
en_US 같은 로케일은 미국이라는 실제 문화권의 관례(날짜 표기, 통화 등)를 담고 있어서 그 관례가 시간에 따라 업데이트될 수도 있다. en_US_POSIX는 POSIX/C 표준을 흉내 낸 고정된, 특정 문화권에 종속되지 않는 로케일로 취급된다 — 그래서 OS 업데이트나 지역 설정과 무관하게 항상 같은 방식으로 날짜를 해석·출력한다는 보장이 실무에서 널리 인용된다.dateFormat 직접 지정과 setLocalizedDateFormatFromTemplate의 차이는?
dateFormat은 문자 그대로 적용되는 리터럴 포맷이라 필드 순서와 구분자가 개발자가 쓴 그대로 나온다. setLocalizedDateFormatFromTemplate(_:)는 "이런 필드들을 포함해줘"라는 요청만 하고, 실제 필드 순서·구분자·12/24시간 여부는 로케일이 정한다. 서버 통신처럼 고정 포맷이 필요하면 전자, 화면 표시처럼 로케일다운 표기가 필요하면 후자를 쓴다.기기 설정의 "24시간 표기" 토글이 이 문제에 실제로 영향을 주는가?
Locale.current(또는 autoupdatingCurrent)를 거칠 때만이다. 이 토글은 Locale.current.hourCycle 값에 반영되는 시스템 설정이라, locale을 지정하지 않은 포맷터는 이 토글의 영향을 받는다. 반대로 Locale(identifier:)로 로케일을 직접 만들면 그 순간 기기 설정과 완전히 분리된 고정값이 되어, 토글을 켜고 꺼도 결과가 바뀌지 않는다.Q4. FormatStyle은 DateFormatter와 설계상 무엇이 근본적으로 다른가?
FormatStyle은 값 타입·제네릭·조합 가능·컴파일 타임 타입 검사인 API인 반면, DateFormatter는 dateFormat 같은 문자열로 설정을 주입하는 가변 참조 타입이다. .dateTime.year().month()처럼 타입 있는 심볼을 체이닝하므로 Q2의 YYYY 같은 오타가 애초에 컴파일이 안 되고, 같은 스타일 값을 ParseStrategy로 그대로 재사용해 문자열→Date 파싱까지 대칭적으로 할 수 있다. 날짜뿐 아니라 Duration·Measurement·.number·.currency·.percent 등 도메인별 스타일이 같은 설계를 공유한다. swift.org가 공개한 벤치마크는 swift-foundation 재작성에서 FormatStyle 기반 날짜 포매팅이 150% 개선(기존 대비 약 2.5배)됐다고 밝혔다.
원리
FormatStyle·ParseableFormatStyle·ParseStrategy는 모두 프로토콜이다. 값 하나를 문자열로 바꾸는 쪽(format(_:))과 문자열을 값으로 되돌리는 쪽(parse(_:))이 같은 프로토콜 계열로 묶여 있어서, "이렇게 포맷했으면 똑같은 규칙으로 파싱도 된다"는 대칭성이 API 설계 자체에 들어 있다. 이건 Formatter 서브클래스 시절엔 없던 보장이다 — DateFormatter도 string(from:)과 date(from:)을 둘 다 갖고 있지만, 그 둘이 정말 같은 규칙을 쓰는지는 개발자가 dateFormat 문자열을 양쪽에 똑같이 맞춰뒀는지에 달려 있을 뿐 타입 시스템이 보장해주지 않는다.
내부 동작
Date.FormatStyle은 struct다. .dateTime은 그냥 기본 설정으로 초기화된 Date.FormatStyle 값이고, .year()·.month(.abbreviated) 같은 메서드는 그 값을 복사해 필드 하나를 채운 새 값을 반환한다 — 참조를 공유하는 게 아니라 매 호출마다 독립된 값이 만들어지는 것이다. 필드도 "Y"·"M" 같은 문자가 아니라 Date.FormatStyle.Symbol.Month처럼 타입이 있는 열거형 케이스라서, Q2의 YYYY/yyyy처럼 "글자 하나 차이로 의미가 완전히 달라지는" 실수가 컴파일 단계에서부터 불가능하다. 그리고 값 타입이라 여러 스레드가 각자 복사본을 들고 있어도 경합이 생기지 않는다 — Q1에서 DateFormatter의 dateFormat을 실행 중에 바꾸면 경합이 되는 것과 정반대다.
| 축 | DateFormatter | Date.FormatStyle |
|---|---|---|
| 타입 종류 | class(참조 타입) | struct(값 타입) |
| 설정 방식 | dateFormat 문자열 — 런타임에 파싱 | .year().month() 체이닝 — 타입 있는 심볼 |
| 스레드 이슈 | 사용 중 설정 변경 시 경합(Q1) | 값 복사라 경합 자체가 성립하지 않음 |
| 오타 감지 시점 | 런타임, 혹은 무증상 버그(Q2) | 컴파일 타임 |
| 파싱과의 관계 | date(from:) — 별도 API, 대칭 보장 없음 | ParseStrategy로 같은 값이 formatted↔parse 왕복 |
| 적용 범위 | Date 전용 Formatter 서브클래스 | Duration·Measurement·숫자·통화 등 공통 프로토콜 |
실험 · 도구
아래 코드를 그대로 실행하면 조합·파싱 왕복·다른 도메인(Duration/Measurement/숫자)까지 전부 직접 확인할 수 있다. locale/timeZone을 명시해 재현 가능하게 고정했다.
import Foundation
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
var c = DateComponents()
c.year = 2026; c.month = 1; c.day = 15; c.hour = 14; c.minute = 30
let date = cal.date(from: c)!
// 값 타입 조합 — locale/timeZone은 생성자에서, 필드는 체이닝으로
var style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), timeZone: TimeZone(identifier: "UTC")!)
style = style.year().month(.abbreviated).day()
print(date.formatted(style)) // 실측: "Jan 15, 2026"
// ParseStrategy — 같은 style 값으로 그대로 되돌린다(대칭)
let numeric = Date.FormatStyle(date: .numeric, time: .omitted,
locale: Locale(identifier: "en_US_POSIX"),
timeZone: TimeZone(identifier: "UTC")!)
let text = date.formatted(numeric) // 실측: "1/15/2026"
let parsed = try! Date(text, strategy: numeric)
print(cal.isDate(parsed, inSameDayAs: date)) // 실측: true — 왕복 성공
// ISO 8601도 같은 방식으로 대칭
let iso = date.formatted(.iso8601) // 실측: "2026-01-15T14:30:00Z"
let backFromISO = try! Date(iso, strategy: .iso8601)
print(backFromISO == date) // 실측: true
// 같은 설계를 공유하는 다른 도메인들
print(Duration.seconds(5025).formatted(.time(pattern: .hourMinuteSecond))) // 실측: "1:23:45"
print(1234567.891.formatted(.number.precision(.fractionLength(2)))) // 실측: "1,234,567.89"
print(0.4567.formatted(.percent.precision(.fractionLength(1)))) // 실측: "45.7%"
print(12900.formatted(.currency(code: "KRW"))) // 실측: "₩12,900"프로젝트 적용
① 자주 쓰는 DateFormatter/NumberFormatter 관용구를 FormatStyle로 옮길 때 아래 대응표를 참고한다. 전부 위 실험에서 실측한 출력이다.
| DateFormatter / NumberFormatter 관용구 | FormatStyle 대응 |
|---|---|
dateFormat = "yyyy-MM-dd"(표시용 하드코딩) | date.formatted(date: .numeric, time: .omitted) |
dateStyle = .medium; timeStyle = .short | date.formatted(date: .abbreviated, time: .shortened) → "Jan 15, 2026 at 2:30 PM" |
setLocalizedDateFormatFromTemplate("yMMMd") | date.formatted(.dateTime.year().month(.abbreviated).day()) |
ISO8601DateFormatter + .withInternetDateTime | date.formatted(.iso8601) |
dateFormatter.date(from: string)(파싱) | try Date(string, strategy: .iso8601) |
NumberFormatter.numberStyle = .currency | value.formatted(.currency(code: "KRW")) |
NumberFormatter.numberStyle = .percent | value.formatted(.percent) |
// ❌ 기존: 캐시해도 여전히 참조 타입 + 문자열 설정
extension DateFormatter {
static let wireDateTime: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "UTC")
f.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
return f
}()
}
// ✅ FormatStyle: 값 타입이라 캐싱 목적의 static let이 굳이 필요 없다 —
// 매번 새로 만들어도 저렴하고, 만들 때마다 타입이 검사된다
func wireTimestamp(for date: Date) -> String {
date.formatted(.iso8601)
}② 그래도 DateFormatter가 필요한 자리는 남아 있다 — iOS 15 미만을 지원해야 하거나, Formatter 서브클래스 자체를 타입으로 요구하는 옛 ObjC 인터페이스와 맞물릴 때다. 이런 경계에서는 Q3의 setLocalizedDateFormatFromTemplate(_:) 패턴을 그대로 쓰고, 새 코드가 순수 Swift 경계 안에만 있다면 FormatStyle을 기본값으로 삼는다.
// 예: Formatter 서브클래스 인스턴스 자체를 받는 옛 API와 맞물려야 하는 경우
func legacyAPI(formatter: DateFormatter) { /* ObjC 쪽 시그니처가 Formatter를 요구 */ }
let bridge = DateFormatter()
bridge.setLocalizedDateFormatFromTemplate("yMMMdjm") // 필드만 요청, 로케일이 순서를 정함
legacyAPI(formatter: bridge)"FormatStyle은 그냥 DateFormatter의 문법만 새로워진 것"이라는 생각은 틀렸다 — 값 타입 기반이라 캐싱의 필요성 자체가 줄고, 컴파일 타임에 조합이 검사된다는 게 본질적 차이다. 그리고 swift.org의 "150% 개선"은 swift-foundation 재작성이 표준 날짜+시간 포맷 벤치마크에서 측정한 수치이지, 모든 로케일·모든 필드 조합에서 항상 2.5배 빠르다는 보장이 아니다 — 검증된 그 벤치마크 맥락 밖으로 수치를 일반화하면 안 된다.
DateFormatter는 화이트보드에 매직펜으로 규칙을 손으로 적어두는 통역사와 같다 — 다시 쓸 때마다 오타가 날 수 있다. FormatStyle은 미리 모양이 정해진 레고 블록을 조립하는 것과 같다 — 블록끼리 모양이 안 맞으면 애초에 끼워지지 않고(컴파일 에러), 조립한 결과물은 매번 새 모형이라(값 복사) 여러 사람이 각자 자기 것을 들고 있어도 서로 부딪히지 않는다. 비유가 깨지는 곳: 레고 블록도 결국 로케일 데이터라는 재료를 참조하지만, 그 재료 자체를 바꾸는 게 아니라 매번 "어떤 블록을 골랐는지"만 새로 정하는 것이라 자물쇠(락)가 필요 없다는 차이다.
꼬리 질문
ParseStrategy가 formatted()와 정확히 어떻게 대칭을 이루는가?
Date.FormatStyle 자체가 ParseStrategy를 준수한다 — 그래서 포맷에 썼던 바로 그 style 값을 Date(text, strategy: style)에 그대로 다시 넘기면 같은 규칙으로 파싱된다. 위 실험에서 numeric 스타일로 포맷한 문자열을 같은 numeric으로 되돌렸더니 원래 날짜와 정확히 일치했다(cal.isDate(parsed, inSameDayAs: date) == true). DateFormatter도 string(from:)/date(from:)을 둘 다 갖지만, 그 둘이 정말 같은 규칙을 쓰는지는 개발자가 dateFormat을 양쪽에 똑같이 맞춰뒀는지에 달려 있을 뿐 타입 시스템은 보장하지 않는다.Measurement(value: 42.5, unit: .kilometers).formatted()가 "42km"로 소수점을 잘라버리는 걸 발견했는데, 왜 이런가?
.formatted()는 "적당히 보기 좋은" 자리수를 알아서 고르는 반올림 규칙을 쓰지, 입력값을 그대로 보존하지 않는다. 정밀도가 중요하면 numberFormatStyle을 명시해야 한다 — m.formatted(.measurement(width: .abbreviated, usage: .asProvided, numberFormatStyle: .number.precision(.fractionLength(1))))로 바꾸면 실측대로 "42.5km"가 나온다. FormatStyle의 기본값도 "알아서 예쁘게"를 우선하는 것이지 항상 정확한 값 보존을 우선하지 않는다는 게 이 발견의 핵심이다.값 타입이니 이제 캐싱은 전혀 신경 쓰지 않아도 되는가?
Date.FormatStyle을 매번 새로 조립하는 비용은 작은 열거형 몇 개를 스택에 복사하는 수준이라, DateFormatter처럼 로케일 데이터를 로드하는 무거운 초기화와는 차원이 다르다. 다만 같은 스타일을 반복해서 쓴다면 가독성을 위해 let으로 이름 붙여 두는 건 여전히 좋은 습관이다 — 그건 성능이나 스레드 안전 때문이 아니라 그냥 코드 정리 차원이다.Q5. 돈 계산에 Double 대신 Decimal을 써야 하는 이유는?
Double은 2진 부동소수라 10진 소수 다수를 정확히 표현하지 못한다 — 실측대로 0.1을 열 번 더하면 정확히 1.0이 아니라 0.9999999999999999(오차 -1.11×10⁻¹⁶)가 나온다. Decimal은 10진 기반(부호·유효숫자·지수)이라 같은 연산이 정확히 1이 된다. 다만 함정이 있다 — Decimal(0.1)처럼 리터럴 하나만 볼 땐 우연히 Decimal(string: "0.1")과 똑같아 보이지만, 이미 Double 연산을 한 번이라도 거친 값을 Decimal로 감싸면(예: Decimal(0.1 + 0.2)) 그 이진 오차가 그대로 옮겨붙는다는 걸 실측으로 확인했다. 실무 규칙: 돈은 정수 최소단위나 Decimal로 저장하고 절대 Double을 거치지 말 것, 표시는 .currency(code:)로.
원리
2진 부동소수점은 2의 거듭제곱들의 합으로만 값을 표현할 수 있다. 0.1 = 1/10은 2진수로 나타내면 0.0001100110011...처럼 끝나지 않는 반복소수라, 유한한 비트로 저장하는 Double은 반드시 반올림을 거친 근사값을 담는다. Decimal은 값을 10진 기반(부호, 유효숫자, 10의 거듭제곱 지수)으로 저장해서, 사람이 손으로 쓰는 10진 소수(0.1, 19.99, 1200.50…)를 최대 38자리 유효숫자까지 정확히 표현한다 — 딱 사람이 돈을 적는 방식 그대로다.
내부 동작
실측 결과는 원리를 그대로 따른다. Double로 0.1을 열 번 더하면 0.99999999999999988898(20자리 전체 정밀도)가 나오고 1.0과의 차이는 -1.1102230246251565e-16이다. 같은 연산을 Decimal(string: "0.1")로 하면 정확히 1이다. 여기서 통념보다 더 미묘한 지점이 하나 있다 — Decimal(0.1)(리터럴을 Double로 거쳐 만든 것)을 Decimal(string: "0.1")과 비교하면 이 Swift 6.2.1 / macOS 26 환경에서는 차이가 없었다(distance == 0). 리터럴 0.1의 Double 표현이 "가장 짧게 반올림한 표현"으로 우연히 0.1과 일치하기 때문이다. 하지만 이미 연산을 거친 Double을 Decimal로 감싸면 얘기가 달라진다 — 0.1 + 0.2는 Double로 이미 0.30000000000000004(오차 포함)이고, 이 값을 Decimal(0.1 + 0.2)로 감싸면 0.3000000000000000512가 나와 Decimal(string: "0.3")과 정확히 0.0000000000000000512만큼 어긋난다. 레거시 브리지인 NSDecimalNumber(value:)도 똑같이 오염된다. 즉 진짜 위험은 "Decimal(0.1) vs Decimal(string:)"이라는 좁은 프레임이 아니라, 이미 이진 연산을 한 번이라도 거친 Double을 Decimal로 감싸는 모든 경우다 — 그리고 실무에서 돈은 거의 항상 어떤 계산(합계·세율 적용 등)을 거친 뒤에 도착하므로, 이 함정이 리터럴보다 훨씬 자주 발생한다.
| 만드는 방법 | Decimal(string:)과 비교 | 비고 |
|---|---|---|
Decimal(0.1) | 일치 | 리터럴의 Double 표현이 우연히 깨끗함 |
Decimal(0.2), Decimal(0.3), Decimal(1.1), Decimal(2.675) | 일치 | 실측한 모든 "깨끗한" 리터럴에서 재현 |
Decimal(0.1 + 0.2) | 불일치 — 0.3000000000000000512 | Double 연산이 이미 오차를 만든 뒤 감쌈 |
NSDecimalNumber(value: 0.1 + 0.2) | 불일치 — 동일하게 오염 | 레거시 ObjC 브리지도 예외 아님 |
실험 · 도구
아래 코드를 그대로 실행하면 위 표·그림의 수치를 전부 재현할 수 있다.
import Foundation
// 1) Double: 0.1을 열 번 더해도 정확히 1.0이 아니다
var sum = 0.0
for _ in 0..<10 { sum += 0.1 }
print(sum) // 실측: 0.9999999999999999
print(String(format: "%.20f", sum)) // 실측: 0.99999999999999988898
print(sum - 1.0) // 실측: -1.1102230246251565e-16
// 2) Decimal(string:)로 만들면 정확히 1
var dsum = Decimal(0)
for _ in 0..<10 { dsum += Decimal(string: "0.1")! }
print(dsum == Decimal(1)) // 실측: true
// 3) "리터럴" Decimal(0.1)은 이 환경에서 우연히 깨끗하다
let fromLiteral = Decimal(0.1)
let fromString = Decimal(string: "0.1")!
print(fromLiteral == fromString) // 실측: true (둘 다 정확히 0.1)
// 4) 진짜 함정 — 이미 Double 연산을 거친 값을 Decimal로 감싸면 오차가 옮는다
let alreadyComputed = 0.1 + 0.2 // Double: 0.30000000000000004
let wrapped = Decimal(alreadyComputed)
let clean = Decimal(string: "0.3")!
print(wrapped == clean) // 실측: false
print(wrapped - clean) // 실측: 0.0000000000000000512프로젝트 적용
① 금액은 저장 시점부터 Double을 절대 거치지 않는다 — 사용자 입력을 파싱할 때도 Double(string)이 아니라 Decimal(string:)을 쓰고, 서버 JSON도 Double 프로퍼티로 받았다가 나중에 Decimal로 바꾸지 말고 처음부터 Decimal 프로퍼티로 디코딩한다. 실측해보니 JSONDecoder가 Decimal 프로퍼티는 JSON 숫자 텍스트를 직접 파싱해 21자리 유효숫자까지 그대로 보존했지만, 같은 값을 Double 프로퍼티로 받으면 20.0으로 반올림돼버려서 그 뒤에 Decimal로 감싸도 이미 늦었다.
// ❌ Double로 먼저 받으면 정밀도가 그 순간 사라진다
struct PriceDouble: Decodable { let value: Double }
// {"value": 19.9999999999999999999} 디코딩 → value == 20.0 (실측, 정밀도 손실 확정)
// 이후 Decimal(value)로 감싸도 이미 20으로 뭉개진 뒤라 되돌릴 수 없다
// ✅ 처음부터 Decimal 프로퍼티로 디코딩하면 텍스트 그대로 보존된다
struct Price: Decodable { let value: Decimal }
// 같은 JSON 디코딩 → value == 19.9999999999999999999 (실측, 손실 없음)② 화면에 보여줄 때는 Decimal 값을 .currency(code:)로 그대로 포맷한다. 소수점 자릿수·통화 기호·천 단위 구분자를 로케일에 맞게 알아서 골라주므로 문자열을 직접 조립할 필요가 없다.
struct Money {
// 정수 최소단위(원/센트)로 들고 있거나, Decimal로 직접 들고 있는 두 방식 다 안전하다.
// 핵심은 Double을 절대 경유하지 않는 것.
let amount: Decimal
var displayString: String {
amount.formatted(.currency(code: "KRW"))
}
}
let price = Money(amount: Decimal(string: "12900.50")!)
print(price.displayString) // 실측 패턴 확인됨 — 통화 기호 12,900.50 형태로 출력"Double도 소수점 몇 자리로 반올림해서 보여주면 돈 계산에 충분하다"는 표시만 눈속임할 뿐이다 — 누적 합계·세금 계산처럼 반복 연산이 들어가면 실측처럼 오차가 쌓여 실제 저장된 값 자체가 틀어진다. 그리고 "Decimal(0.1)은 항상 Decimal(string: "0.1")과 다르다"는 흔히 도는 단순화된 주장도 이 환경에서는 정확하지 않다 — 진짜 위험은 리터럴이 아니라 이미 Double 연산을 거친 값을 Decimal로 감싸는 것이다.
Double로 돈을 다루는 건 눈금이 2등분·4등분·8등분으로만 그어진 자로 "10분의 1" 지점을 재는 것과 같다 — 아무리 애써도 정확히 그 눈금에 맞출 수 없고 근처 어딘가로 반올림된다. Decimal은 10등분 눈금이 실제로 그어진 자라서 "0.1"이 정확히 눈금 위에 떨어진다. 비유가 깨지는 곳: 10등분 눈금 자(Decimal)라도, 이미 눈금 없는 자(Double)로 한 번 잰 값을 그대로 옮겨 적으면(Decimal(계산된 Double)) 그 오차까지 같이 옮겨 적힌다는 것 — 자체가 정확해도 "옮겨 적는" 그 순간 오염될 수 있다.
꼬리 질문
그럼 Decimal(0.1)은 항상 안전하다고 봐도 되는가?
Decimal(0.1 + 0.2)에서 곧바로 오염이 재현됐다 — 계산을 한 번이라도 거친 Double을 Decimal로 감싸는 순간 그 이진 오차가 옮겨붙는다. 유일하게 안전한 규칙은 애초에 Double을 경유하지 않는 것이다 — 문자열에서 시작하면(Decimal(string:)) 또는 정수 리터럴에서 시작하면 안전하다.서버가 JSON 숫자로 19.99 같은 값을 내려주면, 그건 이미 어차피 Double 아닌가?
19.99)로 적혀 있을 뿐 Double이라는 타입이 강제되는 건 아니다. 실측 결과 JSONDecoder가 Decimal 프로퍼티로 바로 디코딩하면 그 텍스트를 직접 파싱해 19.9999999999999999999처럼 21자리 유효숫자까지 그대로 보존했지만, 같은 값을 일단 Double 프로퍼티로 받으면 그 순간 20.0으로 반올림돼 버렸다. 즉 위험은 JSON 자체가 아니라 디코딩 목적지를 Double로 잡는 순간 생긴다 — 금액 필드는 모델에서부터 Decimal로 선언해야 한다. 20장(Codable ② 전략·진단·성능)에서 디코딩 전략을 더 다룬다.정수 최소단위(원 단위 정수)로 저장하는 것과 Decimal로 그냥 저장하는 것, 뭐가 더 나은가?
Double을 거치지 않는다는 핵심 조건은 만족하지만 쓰임새가 다르다. 정수 최소단위(예: 원화면 그냥 정수, 달러면 센트 단위 정수)는 DB 컬럼이나 다른 시스템과 주고받을 때 "소수점 몇 자리인지"를 둘러싼 모호함이 없고 정수 연산이라 더 단순하다. Decimal은 Swift 코드 안에서 통화별로 소수 자릿수가 다른 계산(부가세율 적용 등)을 직접 다뤄야 할 때 더 유연하다. 실무에서는 저장·전송 경계에는 정수 최소단위를, 계산 로직 내부에서는 Decimal을 쓰는 조합도 흔하다.