날짜와 시간을 다루기 – Swift Date/Time

날짜와 관련된 계산은 사실 알고보면 굉장히 복잡하고 어려운 작업이다. 날짜와 시간의 상관관계에 대해서 몇 가지 규칙들을 나열해보면 우리가 일상적으로 사용하는 날짜나 시간에 관한 규칙이 사실은 엄청나게 복잡하다는 것을 알게 된다.

  1. 기본적으로 초(second)를 가장 기본적인 시간의 단위로 생각한다. (더 작은 단위로 밀리초나 나노초가 있지만, 이들은 10진법 기반이므로 따로 생각하지 않아도 된다.)
  2. 초와 분은 60도법으로 계산된다. 60초는 1분, 60분은 1시간이다.
  3. 하루는 24시간으로 이루어진다. (그리고 놀랍게도 시계가 표시하는 시간은 지구상에서의 위치에 따라 또 다르다.)
  4. 사실 여기까지 생각했을 때는 별로 어렵지 않다. 하지만 날짜가 개입하면 복잡해지기 시작한다.
  5. 한달은 대략 30일 가량의 날로 이루어진다. 어떤 달은 30일, 어떤 달은 31일인데 이것은 나름 정해져있다.
  6. 2월은 보통 28일이다. 단 윤년인 경우 2월이 29일까지 있다.

우리가 “날짜를 계산한다”는 것은 따라서 사실 쉬운일이 아니다. 다음은 날짜와 관련된 몇 가지 질문이다. 달력을 다 외우고 있는 사람이 없을테니 당장 답을 하기는 어렵겠지만, 어떤식으로 계산해야할지 곰곰히 생각해보자. 날짜 계산은 정말로 가장 까다로운 분야 중 하나이다. 아래 질문 중 몇 가지는 정말 간단하게 계산할 수 있는 문제이고, 또 몇 가지는 제법 까다로운 문제이며 또 어떤 것은 알고보면 굉장히 어려운 문제이다.

  1. 2018년 3월 1일과 2020년 7월 15일은 몇 일 차이가 나는가?
  2. 2018년 3월 1일은 목요일이다. 그렇다면 2020년 7월 15일은 무슨 요일인가?
  3. 다시, 2018년 3월 1일과 2020년 7월 15일은 몇 개월 일 차이가 나는가?
  4. 다시 몇 년, 몇 개월, 몇 일 차이가 나는가?
  5. 올해의 크리스마스는 무슨 요일인가?
  6. 오늘로부터 100일째 되는날(+99일)은 언제인가, 또 무슨요일인가?

주로 이러한 질문들이 날짜 계산과 관련된 내용인데, 언뜻보더라도 뭔가 하나의 계산법에 의해서 계산될 것 같지는 않아보인다. 대신 위 질문들을 프로그래밍이 아닌 실생활에서 대답하기 위해서는 하나의 도구가 필요하다. 그것은 바로 달력이다. 우리는 컴퓨터가 없다면 날짜의 차이나 요일을 확인하는 것을 달력을 통해서 수행할 것이다. 그렇기에 코코아에서 날짜와 관련된 계산을 수행하기 위해서는 ‘달력’이 필요하다. 그리고 날짜를 표현하기 위한 여러 데이터 형식도 필요할 것이다. 또한 달력은 “로케일”에 의존한다. 우리가 흔히 사용하는 양력은 그레고리력인데, 우리는 부지불식간에 이 그레고리력이 달력의 세계 표준이라고 생각하기 쉬운데 그렇지 않다. 요일체계나 달력은 국가와 문화권에 따라 상당한 차이가 있다. 먼저 날짜와 관련된 데이터 타입에 대해서 살펴보자.

 

날짜 – Date

먼저 날짜를 표현하는 타입인 Date에 대해서 살펴보자. Date는 우리 말로 그냥 “날짜”로 번역되지만 실질적으로는 날짜와 시간을 모두 포함하고 있다. Date의 개념은 타임라인상의 특정한 지점이며, 이는 수직선 상의 한 점과 같이 “단일값”이어야 한다. 따라서 Date는 년월일시분초로 구성되는 정보가 아니라, 단일 숫자값이다. 타임라인상의 특정한 기준 시점으로부터 몇초가 경과했는지에 대한 정보로 Date가 표현하는 시점을 정의하는 것이다. 이 때 기준시점은  epoch이라고 하는 유닉스 기준 시점으로, 1970년 1월 1일 00시 (UTC 기준)이다. 따라서 Date(NSDate)를 규정하는 정확한 개념은 날짜보다는 “시점”이라고 생각해야 한다.

생성하기

Swift에서는 예전 Foundation의 NSDate를 브릿징한 Date 타입(structure이다)을 사용한다.  Date는 기준시점으로부터 경과한 초를 표현하기 때문에 다음의 방법으로 생성할 수 있다.

  • init() : 생성되는 시점을 가리킨다.
  • init(timeIntervalSinceNow:) : 현 시점으로부터 몇 초 이전, 이후의 시점을 생성할 때 쓴다.
  • init(timeInterval:since:) : 다른 Date 객체로부터 몇 초 이전/이후의 시점을 생성한다.
  • init(timeIntervalSinceReferenceDate:) : 21세기의 첫날(2001년 1월 1일)을 기준으로 경과한 초로 시점을 생성한다.
  • init(timeIntervalSince1970:) : epoch를 기준으로 경과한 초로 시점을 생성한다.
let today = Date()
// 하루는 초로 86400이다. (60*60*24 = 86400)
let tomorrow = Date(timeIntervalSinceNow:86400)
let yesterday = Date(timeIntervalSinceNow:-86400)

참고로 위 코드들은 모두 “현재 시점”을 기준으로하기 때문에 해당 날짜의 00시00분이 아닌 현재 시분의 값을 갖게된다.

TimeInterval은 특정 시점과 시점 사이의 간격을 나타내는데, 사실은 Double 타입의 별칭이다. 타입의 특성상 현재 시점이나, 특정 시점으로부터 전후 몇 ‘초’의 시점을 정의하는 것은 편리하다. 그런데 우리는 실질적으로 특정한 날짜를 이렇게 만들고 싶지 않다. 2018년의 크리스마스를 만들고자한다면 2001년 1월 1일부터 2018년 12월 25일까지 몇 일인지 세어서 그걸 초로 환산한 다음 init(timeIntervalSinceReferenceDate:)를 통해서 생성하기는 어렵지 않겠나. 이렇게 날짜 표현을 이용한 Date의 생성은 Date만으로는 어렵고 다른 클래스/타입들의 도움을 받아야하는데, 이에 대해서는 뒤에서 곧 소개하겠다.

Date의 연산

Date는 일종의 단일 스칼라값이므로 전후의 비교나 두 시점의 차이를 계산하는 것이 가능하다. NSDate의 경우에는 이런 연산을 각각의 메소드에 의존해야 했지만, 연산자 오버로드가 가능한 Swift에서는 다음과 같은 연산을 지원한다.

  • <, <=, => >, ==, != 을 이용하여 비교할 수 있다.
  • compare(_:)를 사용해서 전후 관계를 비교한다. (NSDate의 유산이다.)
  • TimeInterval 값과 +, - 연산자를 이용해서 더하거나 뺄 수 있다.
  • +=, -= 연산도 지원한다.
  • 비슷한 역할을 하는 addTimeInterval(_:), addingTimeInteravl(_:)이 있다.
  • ..., ..< 을 사용해서 두 시점 사이의 기간을 생성할 수 있다.
  • ...이 후위 연산자로 쓰이면 특정 시점 이후의 미래를 의미한다.

두 시점 사이의 기간을 표현하는데에는 DateInterval이라는 타입이 쓰이기도 한다. 이는 시작과 끝을 가리키는 두 개의 Date 객체를 가지고 그 사이의 기간을 표현한다.

날짜 포매터 – DateFormatter

추상 클래스인 포매터(NSFormatter)와 그 자식 클래스들은 흔히 특정한 타입의 값을 출력을 목적으로 문자열로 변환하는 용도로 사용한다고 알고 있는데, 사실 포매터들은 특정한 타입의 값과 문자열을 양방향으로 변환하는 도구이다. DateFormatterDate 타입과 문자열을 양방향으로 변환하므로 특정한 포맷을 정해놓고 그 포맷에 맞게 문자열을 입력해서 날짜값을 구할 수 있다.  참고로 포맷에서 시분초 단위가 생략되면 생략된 단위는 모두 0의 값이 된다.

let formatter = DateFormatter()
formatter.locale = Locale(identifier:"ko_KR")
formatter.dateFormat = "yyyy-MM-dd"
let xmas = formatter.date(from: "2018-12-25")

위 예는 2018년 크리스마스를 구하는 예이다.

  1. 포매터의 로케일은 해당 로케일의 국가정보를 기준으로 타임존과 날짜 표현 포맷을 결정한다.
  2. 날짜포맷은 "yyyy-MM-dd" 이다. 소문자 mm은 시분초의 분을 의미하니 주의.

위 코드에서 구해진 날짜는 한국을 기준으로 2018년 12월 25일 자정이다. 이는 UTC 기준으로는 2018-12-24 15:00 이다. (우리나라가 표준시보다 9시간 빠르므로 우리나라에서 크리스마스 자정은 그리니치에서는 24일 오후 3시이다.) 로케일은 Locale.current를 이용해서 기기의 현재 로케일을 사용하거나, 타임존만 설정하려는 경우에는  TimeZone.current를 이용할 수도 있다. 참고로 우리 나라의 타임존 약어는 KST이므로 TimeZone(abbreviation: "KST")를 이용해서 생성할 수도 있다.

날짜를 생성하는 다른 한가지 방법으로는 캘린더를 이용하는 방법이 있다. 캘린더를 이용해서 년, 월, 일의 각 요소 값을 지정해서 그 날짜를 달력에서 찾는 원리이다. 날짜의 각 요소는 DateComponents라는 타입을 이용해서 구성 단위별로 기록할 수 있다. 단, DateComponent는 각 단위별 값을 저장하는 구조화된 저장매체이므로 2월 30일 같은 날짜를 넣을 수도 있고, 그 자체로는 올바른 날짜인지 판단할 수 없다. 따라서 이렇게 조합된 날짜 구성 값은 달력을 이용해서 Date로 변환해야 한다.

캘린더와 날짜 컴포넌트

캘린더는 날짜 계산에 핵심이 되는 요소라 할 수 있다. 역법에 따른 온갖 복잡한 날짜 계산들을 여러분을 대신해서 다 수행해주는 클래스이다. 캘린더는 사용되고 있는 종류별로 미리 정의되어 있고, 우리는 주로 그레고리언 달력을 사용할 것이다. 그레고리언 달력은 다음과 같이 생성한다.

let calendar = Calendar(identifier: .gregorian)  // 예전에는 식별자를 문자열로 했는데, 별도 타입으로 정의해서 자동완성이 지원된다.

날짜 요소들을 저장하는 타입은 DateComponents이다. 참고로 특정 날짜의 여러 요소들에는 연,월,일외에도 주, 요일, 시, 분, 초 등 여러 정보 항목들이 들어갈 자리가 있다. 하지만 우리는 “필요한” 항목만 사용하면 된다. 자 2018년 3월 1일에 해당하는 날짜를 캘린더를 사용해서 만들어보자.

let comps = DateComponents(year:2018, month:3, day:1)

이를 다시 날짜로 변환하기 위해서는 calendar의 도움이 필요할 것이다. 캘린더 객체의 핵심은 Date라는 단일 값을 달력 체계에 맞게끔 날짜 컴포넌트 값으로 분해하거나, 반대로 날짜 컴포넌트에 구성된 각 단위를 조합해서 날짜값을 만드는데 사용된다. 따라서 년,월,일의 숫자로 컴포넌트를 만들고 달력을 이용해서 날짜를 만들면 된다.

if let targetDate = calendar.date(from:comps) {
  print(dateFormatter.string(from:targetDate))
}
// 2018-03-01
// 만약 데이터포매터의 타임존이 현지로 설정되지 않으면 UTC 표준시 기준으로 출력되어
// 한국의 경우 2018-02-28로 출력될 것이다.

그런데 여기까지는 NSDateComponents + NSCalendar를 사용하던 시절의 이야기이다. 현재 DateComponents는 생성시에 캘린더 객체를 넘겨줄 수 있고, 따라서 스스로 날짜를 판단하거나 생성하는 일이 가능하다.

let targetDate: Date? = {
  let comps = DateComponents(calendar:calendar, year:2018, month:3, day:1)
  return comps.date
}()

두 날짜 사이의 차이 구하기

두 날짜 사이의 차이를 구하는 것에 대해 생각해보자. Date는 기준일로부터의 누적초를 나타내는 숫자라고 했다. 따라서 두 날짜 객체 사이의 차이 값은 timeIntervaleSince(_:)라는 메소드를 이용해서 구할 수 있다. 이 값은 초단위로 계산될 것이므로 두 날짜가 몇 일만큼 차이나는지는 초단위로 나온 차이값을 86400으로 나눠보면 알 수 있을 것이다. 단, “초단위”라는 것은 두 날짜가 완전히 동일한 시분초를 가지지 않는다면 약간의 오차가 발생할 수 있다는 이야기이다. 따라서 두 날짜에 대해서는 노멀라이징을 먼저수행하거나, 아니면 애초에 자정을 기준으로 날짜를 생성하면 된다. 그렇다면 첫 문제인 2018년의 3월 1일부터 2020년 7월 15일까지의 몇일 차이가 나는지 계산해보자.

let startDate = dateFormatter.date(from:"2018-03-01")!
let endDate = dateFormatter.date(from:"2020-07-15")!

let interval = endDate.timeIntervalSince(startDate)
let days = Int(interval / 86400)
print("\(days)일만큼 차이납니다.")

단지 “출력만하면 되는 상황”이면 NSDateComponentsFormatter를 사용해도 된다. 사실 이쪽이 여러가지 상황에서 번거롭지 않고 깔끔한 결과를 얻을 수 있다. 이 포매터는 흔히 트위터 비슷한 앱들에서 날짜 대신에 “3일전”, “2주전”, “4개월전”과 같이 표현되는 기능에 사용되는 것인데, TimeInterval이나 두 개의 Date 값을 이용해서 그 차이를 설명하는 문자열을 만들어 낸다.

do {
  let formatter = DateComponentsFormatter()
  formatter.allowedUnits = [.day]
  formatter.unitsStyle = .full   // 이유는 모르겠으나 꼭 필요하다!
  if let daysString = formatter.string(from: startDate, to: endDate) {
    print("\(daysString)만큼 차이납니다.")
  }
}

대신에 두 번째 방법은 실질적인 두 날짜의 차이를 값으로 구하지 않고 출력될 문자열로만 구할 수 있다는 차이가 있다.

두 날짜의 차이에 단위를 붙이기

그런데 두 시점 사이의 거리, 즉 기간은 다양한 단위로 묘사할 수 있을 뿐 아니라, 두 개 이상의 단위로 설명할 수 있다. 즉 위에서 제시한 두 번째 문제가 이에 해당한다. 2018년 3월 1일에서 2018년 7월 15일은 136일 차이가 있고, 달력을 봤을 때는 4개월 14일만큼 차이가 난다.

2020년을 생각해보면, 3월1일과 7월 15일은 똑같이 4개월 14일만큼 차이나지만, 일수로 계산하면 137일 차이가 난다. 여러분 이렇게 날짜 계산이 건강에 해롭습니다.

어, 그런데 위에서 DateComponentsFormatter는 allowedUnits라는 옵션이 있다. 여기에 일별, 월별 단위를 넣어서 차이값을 계산하던데, 그렇다면 여기에 .month, .year를 넣어서 추가할 수 있지 않을까? 당연히 된다.

do {
  let formatter = DateComponentsFormatter()
  formatter.allowedUnits = [.year, .month, .day]
  formatter.unitsStyle = .full   // 이유는 모르겠으나 꼭 필요하다!
  if let daysString = formatter.string(from: startDate, to: endDate) {
    print("\(daysString)만큼 차이남") // 2년 4개월 14일
  }
}

위 코드는 완전하게 작동한다. 단지 우리가 월, 일의 값을 따로 얻을 수 없다 뿐이지, 정답을 출력해준다.

참고로 포매터는 string(from: TimeInterval)을 사용해서 특정 기간의 초간격을 단위로 환산해준다. 단 이 때는 시작날짜의 위치값을 알 수 없으므로 단순히 30일을 1개월로 처리해서 4개월 16일이라고 말하게된다. (보통 이것이 게시물의 발행시점을 상대적인 값으로 표현해주는데 쓰이는 예라 할 수 있다.)

실제 각 단위의 값을 구하는 방법 – 캘린더를 이용해서 두 날짜의 차이를 구하기

바로 앞에서 두 날짜 사이의 간격을 알아내는 방법을 살펴보았는데, 정확하게는 “두 날짜 사이의 간격을 표현하는 문자열”을 알아내는 방법을 살펴보았는데, 실제로 각 단위의 값을 추출하는 방법을 알아보자. 이를 위해서는 캘린더를 사용해야 한다. 캘린더는 단일 날짜를 컴포넌트로 분해할 수 있는데, 부가적으로 두 개의 날짜값을 받아서 원하는 단위로 구분해줄 수도 있다. 여기에 사용되는 메소드는 dateComponents(_:from:to:)이다. 두 날짜의 차이값을 계산해서 각 단위를 DateComponents에 담아서 구한 다음, year, month, day 프로퍼티를 참조하면 된다. 다시 앞서서 두 날짜 간의 년, 월, 일이 각각 얼마나 차이나는지를 보려면 다음과 실행한다.

let offsetComps = calendar.dateComponents([.year,.month,.day], from:startDate, to:endDate)
if case let (y?, m?, d?) = (offsetComps.year, offsetCompos.month, offsetComps.day) {
  print("\(y)년\(m)월\(d)일만큼 차이남")
}

캘린더는 두 날짜의 기간 뿐만 아니라, 하나의 Date 값에 대해서도 원하는 컴포넌트 필드를 뽑아낼 수 있다. 예를 들면 요일과 같은 요소 값은 Date 객체는 알 수 없다. 2018년 크리스마스의 요일은 다음과 같이 구한다.

let xmas = dateFormatter.date(from:"2018-12-25")!
let wd = calendar.dateComponents([.weekday], from: xmas)
if let wd = wd.weekday {
  print(wd)
}
// 3

결과는 3이고, weekday 속성은 1이 일요일, 6이 토요일이다. 따라서 3은 “화요일”을 나타낸다.

100일 째 되는 날 구하기

100일째 되는 날은 캘린더를 이용해서 쉽게 구할 수 있다. 만약 여러분이 여친을 만난나고 하면 보통 “오늘부터 1일”이라고 세기 때문에 실제로 100일을 기념해야 하는 날은 +99일을 하는 날이다. 기준 날짜가 있고, 기준 날짜에 더해져야 하는 컴포넌트값이 있으면 캘린더가 합산을 수행해준다.

let dayOffset = DateComponents(day: 99)
if let d100 = calendar.date(byAdding: dayOffset to: today) {
  print(dateFormatter.string(from: d100))
}

날짜를 정규화하기

앞서 날짜를 비교하는데 있어서, 일수를 정확하게 계산하기 위해서는 두 날짜가 동일한 시분초를 가져야 한다고 했다. 그러기 위해서는 각 날짜가 자정 혹은 정해진 시간을 기준으로 시계를 맞춰서 24시간의 배수만큼씩만 차이가 나도록 정규화해야 한다. 정규화하는 방법은 다음과 같다.

  1. 날짜를 DateComponents로 분해한다.
  2. DateComponents의 시, 분, 초를 0으로 변경한다.
  3. 변경한 컴포넌트를 다시 Date로 변환한다.