(Cocoa) 날짜와 시간을 다루기

updated

Swift3에 맞게 작성된 새 글이 있으니, 해당 포스트를 참고해주세요.

 

들어가며

날짜와 시간을 위한 프로그래밍을 위해서는 기본적으로 NSDate를 사용한다. NSDate는 2001년 1월 1일 자정을 기점으로 현재시간 (혹은 특정 시점)까지의 초단위로 경과한 시간을 저장하고 있는 객체이다. 이렇게 단순히 누적된 초 시간으로는 두 시점의 선/후 관계를 파악하는 등의 단순 비교 작업은 가능하지만, 구체적인 날짜나 요일에 연관된 작업을 하기는 매우 어렵다. 예를 들어 올해 크리스마스가 무슨 요일인지를 구하는 일은 NSDate 객체 만으로는 사실상 매우 힘들다.

코코아가 (혹은 코코아터치가) 날짜/시간을 다루는 방식을 이해하려면 NSCalendarNSDateComponent 객체에 대해서도 알아둘 필요가 있다. NSDateComponent는 몇 월, 몇 일 등 날짜라는 정보를 구성하는 달력에서의 단위들에 대한 정보를 기술하는데 사용하며, 실제 NSDate 객체와 NSDateComponent 사이를 NSCalendar 객체가 연결해준다고 생각하면 된다. 즉, 기준일로부터 흐른 시간으로 오늘이 몇 월 몇 일인지를 알려면, “달력”이 있어야 한다. 왜냐하면 달력에 따라 오늘이 몇 년, 몇 월, 몇 일인지가 결정될 수 있기 때문이다.

NSDate

NSDate는 특정한 시점이며, 이는 기준시 (2001년 1월 1일 자정)로부터 경과한 시간의 합이다. 즉, 기준시로부터 지금 이 순간까지 몇 초나 흘렀나하는 것을 세어서 지금이 언제인지를 아는 것이다. NSDate 객체를 생성하면 기본적으로 생성되는 현재 시점의 정보를 가지게 된다.

NSDate *now = [[NSDate alloc] init];

혹은 생성자 메소드를 사용해 간단히 아래와 같이 쓸 수도 있다.

NSDate *now = [NSDate date];

NSDate 객체를 생성할 때는 이와 더불어 현재 시점을 기준으로 경과한 초를 가지고 특정한 시점의 객체를 생성할 수도 있다. 어제와 내일에 대한 날짜는 다음과 같이 생성한다.

NSTimeInterval secondsPerDay = 24 * 60 * 60;
NSDate *today = [NSDate date];
NSDate *tomorrow, *yesterday;

tomorrow = [[NSDate alloc] initWithTimeIntervalSinceNow:secondsPerDay];
yesterday = [[NSDate alloc] initWithTimeIntervalSinceNow:-secondsPerDay];

혹은 현재 시점인 today를 가지고,

tomorrow = [today dateByAddingTimeInterval:secondsPerDay];
yesterday = [today dateByAddingTimeInterval:-secondsPerDay];

와 같이 생성이 가능하다.

NSDate 는 별도로 특정한 날짜를 기준으로 생성될 수도 있다. 이를 테면 다음과 같이

NSDate *someday = [NSDate dateWithNaturalLanguageString:@"12/25/01"];

이라고 하면 2012년의 크리스마스를 나타내는 NSDate 객체를 생성할 수 있다. 단지 미국에서 사용하는 “월/일/년”의 순서이기 때문에 조금 불편하거나 헷갈릴수는 있겠다.

(* 이 메소드는 OSX에서만 동작한다. iOS에서 NSCalendar는 이 메소드를 사용할 수 없다.)

NSCalendar

앞서 설명하였지만, NSDate는 특정 시점을 나타내는 절대적인 숫자값이다. 하지만 실제로 이는 기준시로부터 몇 초가 지났는지를 일일이 계산해야 하기 때문에 우리에게 익숙한 날짜/시간 단위와는 무관하다. 우리가 인지하는 날짜나 시간 개념의 단위들은 NSDateComponents에서 제공한다. 하지만 앞서도 간략히 설명했듯이, NSDateComponents는 각 시간 단위들을 표시하기 위한 컨테이너이지, 실제 날짜로 계산되지는 않는다. 즉 무언가 년/월/일/요일 등에 대한 상관관계를 알아내기가 힘들기 때문이다. 하지만 애플의 엔지니어들은 이 힘든 작업을 이미 다 처리해서 NSCalendar로 만들어 두었다.

NSDate로부터 우리가 흔히 인식하는 달력에서의 단위를 추출하기 위해서는 NSCalendar 객체의 도움을 받아야 한다. NSCalendar는 NSDate로부터 NSDateComponent 객체를 생성할 수 있고, 또 그 반대의 역할도 수행할 수 있다.

그런데 달력이 세상에는 한가지 단위만 존재하는 것이 아니다. 흔히 쓰는 양력은 그레고리언력인데, 그 외에도 히브리달력, 이슬람달력, 일본달력(음력인가? 어쨌거나 우리 나라의 음력 날짜는 일본이나 중국과 또 다르다!) 등의 다양한 달력이 있다. 각 달력마다 한 달에 들어있는 날의 수라든지 그런 정보가 다르기 때문에 어떤 달력을 사용하느냐에 따라서 NSDateComponent에서 구하는 값이 달라질 수 있다. 이런 달력의 종류는 상수로 지정되어 있다. 몇 가지 달력을 생성하는 방법은 아래를 참고한다.

NSCalendar *gregorian = [[NSCalendar alloc] initWithCalendarIdentifier:NSGregorianCalendar];
NSCalendar *japanese = [[NSCalendar alloc] initWithCalendarIdentifier:NSJapaneseCalendar];
NSCalendar *currentCalendar = [[NSLocale currentLocale] objectForKey:NSLocaleCalendar];
NSCalendar *currentUserCalendar = [NSCalendar currentCalendar];

특히 사용하는 달력은 사용자의 언어나 국가와 관련이 있어서 로케일 정보로부터 현재 달력을 가져올 수 있음을 주목하자.

날짜 요소와 달력

NSDateComponent는 날짜를 이루는 달력상의 요소인 년, 월, 일, 시, 분, 초 등의 정보를 표현하기 위한 객체이다. 아쉽게도 이 객체는 년, 월, 일 등의 값을 표현하기만 할 뿐이지, 스스로 날짜에 대한 비교나 계산을 수행할 수 없다. 예를 들어 2012년 2월 20일을 표현하는 NSDateComponent를 생성한다고 하자.

NSDateComponent *someday = [[NSDateComponent alloc] init];
someday.day = 20;
someday.month = 2;
someday.year = 2004;

특이점이라면 특이점인데, 날짜 요소에서의 월, 일 등의 값은 실제 날짜의 값과 일치한다. 배열에서 인덱스가 0에서 시작하는 것과 달리 날짜 요소는 1에서 시작하게 된다. 어쨌든 이렇게 생성된 날짜 요소는 그저 달력에서 어떤 날짜에 해당하는 표현일 뿐이므로, 이렇게 만들어진 someday에 대해서 weekday 프로퍼티로 요일 값을 받아오려해도 아무 것도 얻을 수 없다. 특정 날짜를 사용해서 해당 요일을 구하는 것은 뒤에서 다시 다뤄보도록 하겠다.

NSDateComponents의 각 속성들은 실제로 서로 연관되어 계산되지 않는다. 즉 “2012년 2월 31일”과 같은 식의 표기로도 날짜 요소 객체를 생성할 수 있다. 날짜 속성으로 지정한 날짜를 실제 날짜로 계산하기 위해서는 이를 바탕으로 한 NSDate 객체를 만들어 내어야 한다.

이 때 NSCalendar의 -components:fromDate: 메소드를 이용하는데, 여기서 컴퍼넌트에 들어가는 인자는 날짜에 요소 단위에 대한 비트마스크 값이다. 오늘 날짜에 대해서 요일과 일자는 다음과 같이 구해야 한다. (요일은 일요일을 시작으로 1=일요일, 2=월요일… 과 같이 진행된다. 단, 현재 달력이 그레고리안력이라는 것을 전제해야 한다!)

NSDate *today = [NSDate date];
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDateComponents *weekdayComponent = [calendar components:(NSDayCalendarUnit | NSWeekdayCalendarUnit) fromDate:today];

NSInteger day = [weekdayComponent day];
NSInteger weekday = [weekdayComponent weekday];

날짜만을 고려하기

친구의 생일 등을 저장하기 위해서는 년도나 일시는 중요하지 않고 그저 월과 일자만으로 날짜를 생성할 필요가 있다. 일반적으로 NSDate의 메소드들을 통해서는 년도가 없는 날짜 객체를 만들 수 없지만 다음과 같이 사용하는 것은 가능하다.

NSDateComponents *components = [[NSDateComponents alloc] init];
components.month = 11;
components.day = 7;
NSCalendar *calendar  = [NSCalendar currentCalendar];
NSDate *birthday = [calendar dateFromComponents:components];

이 코드는 (아마도) 서기 1년을 기준으로 한 날짜 객체를 생성하는데, 나중에 이 날짜를 사용할 때는 월과 일의 정보만 사용하는 것이 좋다. (그렇지 않으면 천 살이 넘는 친구를 갖게 된다.) 나중에 연도를 빼고 표시하기 위해서는** NSDateFormatter**의 +dateFormatFromTemplate: options: locale: 메소드를 활용하면 간단하게 날짜를 포매팅할 수 있다. 포매팅 템플릿에 대한 정보는 다음 링크에서 확인할 수 있다.

유니코드 날짜 포맷 패턴

날짜를 계산하기

금주의 일요일을 구하는 예를 생각해보자. NSDateFormatter는 특정한 시점을 나타내지 않고 달력의 단위를 표현하기만 한다고 했다. 이를 사용하여 몇일 앞, 몇 일 뒤 혹은 몇 달 몇 일 앞뒤의 날짜를 계산할 수 있다. 즉 날짜의 오프셋으로 활용할 수 있다.

NSDate *today = [NSDate date];
NSCalendar *calendar = [NSCalendar currentCalendar];
NSDateComponents *weekdayComponents = [calendar components:NSWeekdayCalendarUnit 
                                                  fromDate:today];

// 오늘 날짜의 요일을 뽑아 이를 사용해 일요일의 날짜를 구하기
NSDateComponents *offsetComponents = [[NSDateComponents alloc] init];
offsetComponents.day = 0 - ([weekdayComponents weekday] - 1);
NSDate *beginningOfThisWeek = [calendar 
                          dateByAddingComponents:offsetComponents 
                                          toDate:today 
                                         options:0];

weekday = 1 인 날이 일요일이므로 오늘의 weekday에서 1을 뺀값의 음수를 취해 이를 오프셋으로 활용하여 오늘로부터 거꾸로 올라간 날짜를 구하면 된다.

몇 일 남았는지 계산해보기

NSDate의 timeInterval을 서로 빼서 몇 일이 남았는지 계산해보는 것이 가능하다 하겠다. 하지만 이는 초 단위의 차이이므로 보다 사람이 알기 쉽게 몇 개월, 몇 일이 남았는지를 계산하는 것은 다음과 같이 계산할 수 있다. 올 해 크리스마스까지 몇 일이 남았나 계산해보자.

NSDate *today = [NSDate date];
NSDate *christmas = [NSDate dateWithNaturalString:@"12/25/12"];
NSCalendar *cal = [NSCalendar currentCalendar];
NSUInteger units = NSMonthCalendarUnit | NSDayCalendarUnit;
NSDateComponents *components = [cal components:units 
                                      fromDate:today 
                                        toDate:christmas 
                                       options:0];

NSInteger months = components.month;
NSInteger days = components.day + 1;

이 때 만약 지금 시각이 오후 8시라한다면 자정까지는 4시간 가량이 남은게 된다. 이는 24시간보다 작으므로, 두 시간 간격은 1일을 채 못미치게 된다. 따라서 이 간극을 해소하기 위해서 날짜에는 1일을 더해주어야 올바른 날짜 사이의 값을 알게 된다. 만약 이 계산을 timeInterval을 사용해서 구해야 한다면 상당히 번거로운 단위 변환 작업을 반복해야 했을 것이다.

시간 차이를 구하는 다른 방법 – 노멀라이징

시간 차이를 구할 때 이미 경과해버린 오늘의 날짜를 무시해야하려면 비교하는 각 시점의 날짜의 자정으로 (반드시 자정이 아니더라도) 시간을 노멀라이징할 필요가 있다. 즉 비교하는 두 날짜의 시간을 같은 시간으로 맞추면 24시간 단위로 딱 떨어지기 때문에 항상 정확하게 두 날짜 사이의 간격을 구하기가 용이해지는 것이다.

이 때 사용하기 좋은 메소드는 -ordinalityOfUnit: inUnit: forDate: 이다. 이 메소드는 inUnit:의 인자의 단위를 기준으로 작은 단위의 경과를 구한다. 다음 예는 NSCalendar에서 두 날짜 사이의 날짜 수 차이를 구해준다. 이 때 경과한 시간은 무시하므로 오늘과 내일을 전달하면 1일 구할 수 있다.

@implementation NSCalendar (MyNormalizedCalculation)
    -(NSInteger)daysWithinEraFromDate:(NSDate *)startDate
    toDate:(NSDate *)endDate
{
    NSInteger startDay = [self ordinalityOfUnit:NSDayCalendarUnit
         inUnit:NSEraCalendarUnit
        forDate:startDate];
    NSInteger endDay = [self ordinalityOfUnit:NSDayCalendarUnit
         inUnit:NSEraCalendarUnit
        forDate:endDate];
    return endDay - startDay;
}

위 메소드는 NSCalendar에 대해 카테고리로 구현되었다. 각각의 날짜를 2001년 1월 1일에서부터 구해 노멀라이징하였다. 즉 NSEraCalendarUnit 단위에서 경과한 NSDayCalendar 단위의 수를 구하는 것이다. 만약 두 번째 파라미터를 NSYearCalendarUnit을 썼다면 그 해의 1월 1일로부터 경과한 일 수를 구하게 되는 것이다.

그런데 이 방법은 초단위의 NSTimeInterval을 사용한다. 만약 초단위의 값을 구해 이 방법을 사용한다면 32비트 시스템에서는 사용할 수 없게된다. (오버플로우되어 작은 값이 나온다) 따라서 아이폰에서는 이 경우에는 보다 조금 더 노가다가 들어간 방식으로 돌려 구현한다. 로직은 앞서 말한 것과 완전히 동일하다.

@implementation NSCalendar (MyNormalizedCalculation)
    -(NSInteger)daysFromDate:(NSDate *)startDate
    toDate:(NSDate *)endDate
{
    NSCalendarUnits units = NSEraCalendarUnit|NSYearCalendarUnit|NSMonthCalendarUnit|NSDayCalendarUnit;
    NSDateComponents *cp1, *cp2;
    cp1 = [self components:units fromDate:startDate];
    cp2 = [self components:units fromDate:endDate];
    cp1.hour = 12;
    cp2.hour = 12;
    NSDate *date1 = [self dateFromComponents:cp1];
    NSDate *date2 = [self dateFromComponents:cp2];
    return [[self components:NSDayCalendarUnits
        fromDate:date1
        toDate:date2
        options:0] day];
}
@end

즉 두 날짜를 NSDateComponents를 통해 수동으로 노멀라이징하여 날짜 간격을 구하도록 할 수도 있다.

정리

날짜를 다루는 일은 사실, 아주 특별한 경우를 제외하고는 그렇게 많지 않다. 대부분은 DB나 코어데이터에서 레코드를 추가하거나, 정렬하거나 수정할 때 현재 날짜를 사용하는 수준에서 완료되기 때문에 날짜에 대해 비교하고 계산하는 작업을 그리 자주하게 되지는 않을 것이다. 하지만 이런 날짜와 같은 부분에 대해서는 국가마다 사용하는 달력이 다를 수도 있고, 엄청나게 많은 것들을 고려해야 (윤달같은 특별한 규칙들) 할 수도 있다. 하지만 이런 작업은 NSCalendar에 의해서 이미 많은 부분이 쉽고 편리하게 이용할 수 있도록 다 만들어져 있다. 그외에도 이 포스트에서 다루지 못한 내용들도 많은데, 자세한 사항은 TIme & Date Programming Guide를 참고하면 되겠다.

  • 이재훈

    깔끔하게 정리된 좋은 정보 매우 감사합니다.
    “날짜를 계산하기” 부분에서 안되는 부분이 있어서 요렇게 바꾸니 되네요^^
    공유 드립니다.

    offsetComponents.day = 0 – (weekdayComponents – 1);

    offsetComponents.day = 0 – ([weekdayComponents weekday] – 1);

    • 예전에 뭐도 모르던 시절에 써놓고 저도 꼼꼼히 보지 않았던 내용이라, 잘못된 부분이 있었는지도 몰랐네요. 수정 감사드립니다.

  • 오남

    좋은 글 감사드립니다 잘 정리하셨네요 🙂

  • 크루즈

    감사합니다