Swift3에서 날짜와 시간을 다루기 – Cocoa Date/Time API 총정리

(Swift3) 날짜와 시간 다루기

이미 이전글에서 날짜와 시간을 다루는 데 사용되는 몇가지 클래스에 대해 간략히 알아보았는데, 이 글은 내용을 보다 축약하고 Swift3 버전에 맞게 예제 코드를 수정한 글이다.

날짜 계산은 컴퓨터에게도 상당히 까다로운 작업이다. 기본적으로 날짜라는 개념은 연도, 월, 날짜와 같은 하위 개념들의 조합으로 이루어진다. 그리고 이러한 하위 개념들은 무작위로 조합되는것이 아니라 정해진 규칙에 의해서 조합된다. 이를테면 다음과 같은 규칙들이 우리가 알고 있는 날짜의 개념이다.

  1. 하루는 24시간으로 이루어진다.
  2. 한달은 기본적으로 30일이다. 단 1,3,5,7,8,10,12월은 31일까지 있다.
  3. 2월은 28일까지 있는데 윤년의 경우에는 29일인 경우도 있다.
  4. 윤년은 4년에 한 번 찾아오는 것이 기본이다. 그래서 연도가 4의 배수이면 윤년이다. 단 연도가 100의 배수이면 윤년이 아니며, 다시 400의 배수이면 윤년이다.

하지만 이러한 계산의 개념은 어떤 달력을 사용하느냐에 따라 달라질 수 있다. 위의 규칙은 우리 달력에서 흔히 쓰이는 그레고리력의 계산법인데, 문화권에 따라서는 다른 달력을 사용하는 경우도 있다.

또한 날짜의 개념이 이러한 부분값의 조합으로 계산된다는 것은 날짜 간의 간격이나 대소비교를 하는 것이 까다롭다는 것을 의미한다. 보다 간단한 계산을 위해서 일반적인 계산에서는 날짜(datetime)를 특정한 시점으로부터 경과한 초를 이용해서 정의한다. 이렇게 정의하는 것은 기본적으로 날짜/시간이 정수단위의 숫자값이 되고 따라서 두 개의 시간 값을 쉽게 대소 비교할 수 있다는 장점이 있다.

하지만 이러한 계산의 편의를 위한 장치는 일상적 언어로 날짜를 이해하는 사용자와의 점점에서는 사용하기가 곤란하다. 특히나 날짜를 표기하는 방법을 생각할 때, 이것은 굉장히 어려운 문제로 변모한다.

우리는 “2016년 7월 1일”과 같은 일상적인 표기법을 사용하기도 하고, 2016-01-23과 같은 식으로 표시하기도 한다. 하지만 미국에서의 표기는 23/01/16과 같은 식이라서 이것도 로케일에 따라서 선호되는 방식이 다르다는 의미이다.

날짜 계산과 관련된 여러 가지 복잡성을 해결하기 위해서 날짜 계산과 관련된 타입과 클래스는 구분되어 있으며 우리는 이용하고자 하는 계산의 타입에 따라 적절한 값을 이용하면 된다. 아래에 대표적인 데이터 타입1들을 소개한다.

  • Date – 날짜/시간 데이터를 저장하는 값 타입
  • TimeInterval – 두 날짜 사이의 간격(ms)
  • DateComponents – 날짜의 구성요소별 정보를 저장할 수 있는 타입
  • Calendar – 날짜 계산엔진으로 사용되는 달력 클래스
  • DateFormatter – 날짜 표기를 전환할 수 있는 포매터
  • Locale – 타임존, 날짜 표기법등의 정보를 담는 로케일 클래스

Date

Date 타입은 Swift3로 넘어오면서 NSDate를 Swift 값 타입으로 재작성한 구조체 타입이다. Comparable 프로토콜을 따르기 때문에 >, < 연산자를 이용해서 바로 비교하는 것이 가능해졌다. (따라서 NSDate의 laterDate(), earlierDate() 등의 비교 메소드가 모두 없어졌다.)

Date()를 호출하여 현재 시점을 표현하는 날짜 객체를 만들 수 있으며, 다음의 초기화 메소드들을 제공한다.

  • init()
  • init(timeInterval:since:)
  • init(timeIntervalSince1970:)
  • init(timeIntervalSinceNow:)
  • init(timeIntervalSinceReferenceDate:)

특정한 날짜 표현으로부터 날짜를 생성하기

만약 2016년의 크리스마스를 생성하고자 하면? 두 가지 방법이 있는데 하나는 DateFormatter를 이용해서 계산하는 방법, 다른 하나는 CalendarDateComponents를 이용하는 방법이 있다.

날짜의 요소들로부터 날짜 객체를 생성하기

먼저 년, 월, 일의 조합으로 날짜를 만들어보자. 날짜의 구성요소인 년, 월, 일 정보를 DateComponent 객체에 주입한 다음, 캘린더 객체를 통해서 이 조합의 정보를 가진 날짜 객체를 생성할 수 있다.

// 날짜요소 조합을 위해 캘린더 객체를 준비한다.
let cal = Calendar(identifier: .gregorian) //1

// 날짜의 각 구성요소 정의
let comp: DateComponents = {
    var c = DateComponents() //2
    (c.year, c.month, c.day) = (2017, 12, 25)
    c.timeZone = TimeZone(secondsFromGMT: 9 * 60 * 60)
    return c
}()
if let xmas = cal.date(from:comp) { //4
    print(xmas)
    // Date = 2016-12-24 15:00:00 +0000
}

위 코드에 대한 간략한 설명이다.

  1. 달력의 식별자는 이제 enum 타입이 되었고, 이름도 간략해졌다. 또한 enum의 케이스에 의해서 정의되므로 이때 생성되는 값은 더이상 옵셔널 값이 아니다. (이전에 문자열 이름으로 캘린더를 생성했을 때는 옵셔널 객체가 생성됐다.)
  2. DateComponents 역시 Swift 값 타입이므로, 변경을 위해서 반드시 var로 선언한다.
  3. 현지의 시간으로 생성하기 위해서는 타임존을 반드시 설정해야 한다. 한국 시간이므로 GMT보다 9시간 빠른 것으로 설정했다.
  4. dateFromComponents: 라는 이름이었으나, 역시 API 리디자인으로 이름이 단순해졌다.

이때 주의해야 하는 점은 DateComponent 자체는 날짜 요소에 대한 유효성 검사를 하지 않으므로 2017-02-29와 같은 구성으로 조합을 만드는 것이 가능하나, 그레고리력에서는 2017년은 윤달이 아니므로 이러한 날짜가 존재할 수 없다. 따라서 date(from:)은  옵셔널 값을 가지게 된다.

날짜 포매터를 이용하여 서식에 맞춘 문자열 표기를 변환하기

포매터(Formatter)는 주로 특정한 서식에 맞춰 표시된 문자열을 특정한 클래스의 값과 상호 변환하는데 사용되는 클래스이다.  Foundation에는 날짜를 변환하기 위한 목적으로  DateFormatter를 정의하고 있으며, 이를 이용한 방법을 아래에 소개한다. (가장 간편하게 사용할 수 있는 방법이다.)

// 날짜 포매터 정의
let formatter = DateFormatter()
// 아래는 ISO8601 포맷의 날짜를 받을 수 있게 한다.
formatter.dateFormat = "yyyy-MM-dd'T'hh:mm:ssZ" 
formatter.timeZone = TimeZone(secondsFromGMT: 9 * 60 * 60)
if let xmas = formatter.date(from:"2017-12-25T00:00:00+09:00") {
    print(xmas)
    // 2017-12-24 15:00:00 +0000
}

위 예제에서는 ISO 8601에 맞춘 시간 표시 포맷으로 yyyy-MM-dd'T'hh:mm:ssZ를 사용했다.이는 2017-12-24와 같은 날짜 표기와 13:29:37와 같은 시간 표기를 대문자 T로 연결하고 그 뒤에 +/-00:00의 형식으로 타임존 표시를 추가한다. 따라서 한국 시간 기준의 2017년 크리스마스는 "2017-12-25T00:00:00+09:00"이 된다. 이때 한국시간 크리스마스 자정은 GMT보다는 9시간 빠르므로, 같은 순간의 GMT는 24일 15시가 되어있을 것이므로 위 출력값은 정상이다.

updated : iOS 10.0 이상의 ISO8601 포맷

iOS10.0 이상 (macOS는 10.12 이상)부터는 일반 DateFormatter를 사용하여 위의 포맷을 이용해서 ISO 8601 표준 포맷을 사용하는 경우 올바르게 날짜값으로 변환되지 않는 문제가 발생한다. 따라서 특별히 서브클래싱된 ISO8601DateFormatter를 사용해야 한다.

// iOS10.0+ let formatter = ISO8601DateFormatter() if let dday = formatter.date(from: "2015-10-14T14:00:00+09:00") {   print(dday) }

특정 날짜의 요일을 찾는 법

특정 날짜의 요일 역시 캘린더 객체를 통해서 찾는다.  해당 날짜의 DateComponent를 획득해서 해당 정보에서 .weekday 필드를 찾으면 된다. 참고로 Foundation에서 정의하는 날짜 값은 weekday 값은 일요일이 1, 토요일이 7이다. 따라서 만약 찾고다 하는 날짜의 요일이 월요일이라면 2가 나와야 한다.

import Foundation

let cal = Calendar(identifier:.gregorian)!
let now = Date()
let comps = cal.components([.weekday], from:now)
print(comps.weekday!)

특정 일로부터 100일째 되는 날 찾기

아마 우리 나라처럼 100일, 200일 째날을 구하려는 니즈가 강한 나라는 없을 것이다.

100일 후의 날짜를 찾는 방법에는 크게 두가지가 있을 수 있다. 먼저 하루를 초로 환산하면 86400초 이므로 86400 * 100초 만큼의 TimeInterval을 통해서 해당 인터벌이 경과한 날짜를 찾을 수 있다. 또 다른 방법으로는  DateCompoentsCalendar를 이용하는 방법이 있는데, 이때의 날짜 컴포넌트는 달력 상의 특정한 오프셋으로 기능하게 된다. 현재 날짜에 day 필드가 +100인 컴포넌트를 더해서 날짜를 찾는 식인데, 이 방법은 100일, 200일의 날짜를 그냥 더하는 경우에는 조금 불편한 감이 있지만, 예를 들어 31주 후, 5개월 7일이 지난 후의 날짜를 매우 쉽게 찾을 수 있는 편리함을 제공한다.

Time interval 오프셋으로 변경된 날짜 찾기

먼저 그냥 100일치의 초가 경과한 날자를 찾는 방법은 아래와 같다. Date 클래스의 addingTimeInterval(:)을 이용하여 특정 날짜로부터 초단위 값으로 오프셋을 만들어서 그 날짜를 만들 수 있다.

import Foundation

let today = Date()
//날짜 
let dDay = today.addingTimeInterval(86400*100)
print(dDay)

Calendar 객체를 이용한 변경된 날짜 찾기

Calendar 객체를 이용하면 DateComponents 값을 오프셋으로 취급하여 특정 날짜에 더한 새로운 날짜를 쉽게 구할 수 있다.  date(byAdding: to:)는 특정 오프셋 마스크 값을 특정 날짜에 더한 결과를 찾아준다. 참고로 리턴값은 옵셔널이므로 유의하자.

오늘로부터 100일 후의 날짜는 다음과 같이 구한다.

import Foundation

let cal = Calendar(identifier:.gregorian)
let today = Date()
var comps = DateComponents()
comps.day = 100
if let dDay = cal.date(byAdding:comps, to:today) {
  print(dDay)
}

참고로 오늘날짜에 100일을 더하면 100일째 되는 날이 아니라, 101일째 되는 날이다. 보통 기념일을 셀 때는 “오늘부터 1일”이기 때문에 99일, 199일, 299일 순으로 더해서 세어야 한다.

보너스

년,월,일의 복합 단위가 아닌 경우에 날짜의 계산에 매번 DateComponents 객체를 만드는 것은 제법 불편하다. macOS 10.9 / iOS8.0 이상에서는 특정한 달력 단위만을 사용하는 추가적인 메소드가 제공된다. 바로 date(byAdding:value:to:)이다. (문서) 따라서 오늘부터 100 후를 찾는 계산은 다음과 같이 쓸 수도 있다.

if let 101thDay = cal.date(byAdding: .day, value: 100, to: Date()) {   print(101thDay) }

시간의 비교

날짜의 전후 관계는 두 Date 값을 대소비교 연산자를 사용하여 바로 비교할 수 있다. (Comparable 프로토콜을 따르고 있다.) 따라서 NSDate 를 사용할 때 쓰던 compare(:), earlierDate(:), laterDate(:), isEqualTo(:)는 더 이상 사용할 필요가 없어졌다.

두 시점 간의 차이를 계산하기

두 날짜간의 거리를 구해보자. 예를 들어 미국 독립기념일인 7월 4일에서 크리스마스까지 남은 날짜를 구한다고 해보자. 코코아의 날짜/시간 관련 API가 가장 강력하다고 생각되는 지점이 바로 이 두 시점간의 차이를 계산해주는 기능을 사용할 때다.

자바스크립트의 날짜 계산

자바스크립트에서도 Date라는 객체로 날짜/시간을 표현하는데, 이 객체끼리는 뺄셈이 가능하다. 자바스크립트의 두 Date 객체를 빼면 그 결과는 두 시점 사이의 차이를 초단위로 표시하는 셈이 된다. 따라서 몇 일 차인지를 보려면 이 값을 다시 24 * 60 * 60의 값으로 나누어서 일 수를 계산할 수 있다.

파이썬의 날짜 차이 계산

파이썬의 datetime.datetime 타입도 유사하게, datetime.datetime 끼리 뺄셈하는 경우 datetime.timedelta 타입의 값이 생성되고 이를 통해서 몇 일 차이인지를 알 수 있다.

Swift의 시점 차이 계산

Swift의 시점 차이는 기본적으로 Date 클래스의 timeIntervalSince(:)를 통해서 두 시점 간의 밀리초단위 차이를 알 수 있다.  이를 원시적으로 86400000 으로 나누어 몇 일이 지났는지를 알 수 있는 방법도 있긴하다.  하지만 Calendar 클래스를 사용하면 훨씬 더 우아한 방법으로 계산해 낼 수 있다.

전통적으로 이를 위해서 캘린더(Calendar)클래스의 dateComponents(_:from:to:)를 많이 사용했다. 이 함수의 첫번째 인자는 Calendar.Component의 옵션셋인데, 이를 통해서 몇 일 단위로 차이를 계산할 것인지 몇 개월 단위 혹은 몇 년이나 몇 시간 등으로 차이를 계산하는 방법을 정할 수 있다. 이는 단순히 몇 일 차이를 다시 30으로 나눠서 대략 몇 개월이 아니라, 윤년과 큰달/작은달을 고려하여 달력 상으로 정확히 계산해낸다.

예를 들어 1980년 5월 12일 오후 5시에 태어난 사람은 2017년 7월 31일 자정 기준으로 몇 일을 살았는지 계산해보도록하자. (이 때 기준은 한국시간)

let cal = Calendar(identifier: .gregorian) let formatter = ISO8601DateFormatter() if let birthDate = formatter.date(from: "1985-05-12T17:00:00+09:00"),    let destDate = formatter.date(form: "2017-07-31T00:00:00+09:00") {   let comps = cal.dateComponents([.year, .month, .day], from: birthDate, to: destDate)   if case (y?, m?, d?) = (comps.year, comps.month, comps.day) {     print("\(y) years, \(m) months and \(d) days.")   } } // 32 years, 2 months and 18 days.

날짜 노멀라이징

날짜 계산에서 흔히 실수하는 부분이 두 가지 있는데 하나는 타임존에 대한 고려와 다른 하나는 노멀라이징이다. Date는 날짜를 가리키는 부분 외에도 시간(시,분,초 단위)을 가리키는 부분도 존재한다. 이 때 두 시점 사이의 날짜의 간격을 구할 때는 사실 두 날짜의 시점을 자정 혹은 같은 시각으로 맞춰두어야 한다. 위의 예에서 32년 2개월 18일의 값은 시작 시점이 오후 17시이기 때문이고, 만약 이를 00시로 바꿔서 계산해본다면 실질적으로는 하루가 더 추가된 32년 2개월 19일로 계산될 것이다.

이 두 시점 사이의 정확한 계산값은 18일이 맞지만, 우리가 일상적인 표현으로 날짜간의 거리를 계산하는 것은 노멀라이징 된 거리를 구하는 경우가 많다. 날짜값을 노멀라이징하는 것은 연도와 월, 일값으로만 새로운 Date 객체를 생성하는 방법이 있다.

아래 코드는 현재 시점으로부터 오늘의 날짜를 정규화하여 크리스마스까지의 날짜 차이를 계산한다. (편의상 2016년이라고 하자)

import Foundation

let cal = Calendar(identifier:.gregorian)
let now = Date()
let today: Date = {
    let comp = cal.components([.year, .month, .day], from: now)
    var comp2 = DateComponents()
    (comp2.year, comp2.month, comp2.day) = (comp.year, comp.month, comp.day)
    let result = cal.date(from:comp2)!
}()

let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
let xmas = formatter.date(from:"2016-12-25")!

let comps = cal.components([.day], from:today, to:xmas)
print(comps.day!)

참고로 이러한 날짜의 정규화를 위한 편의 API가 iOS8부터 추가되었다.바로 date(bySettingHour:minute:second:of:)로특정 날짜값의 시분초를 원하는 지점으로 고정하여 변경한 사본을 생성하는 캘린더의 메소드이다. (전체 문서 보기)

updated – 날짜를 표현하는 추가 클래스

OSX 10.12부터는 DateInteval 이라는 새로운 클래스가 등장해서 시작일과 종료일로 구성되는 특정한 시간 구간을 표현할 수 있게 되었다. 따라서 특정한 스케줄 테이블 등에서 정해진 구간이 겹치는지 등의 비교를 수월하게 할 수 있게 되어, 강의 시간표 짜는 앱등을 만들 때 편리할 때 이용할 수 있을 것 같다. 이에 대한 내용은 좀 더 공부해본 후에 추가로 정리하도록 하겠다.


  1. 이 타입들은 NS 접두어를 가지지 않는데, 이는 이들 타입이 클래스가 아니라 Swift Struct로 재작성 되었음을 의미한다. 
  • mryoon

    매우좋은 내용이네요 감사합니다.^^