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

updated

Swift를 사용해서 Date를 다루는 방법은 새로 작성된 글을 참고하세요.

 

들어가며

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

코코아가 (혹은 코코아터치가) 날짜/시간을 다루는 방식을 이해하려면 NSCalendarNSDateComponents 객체에 대해서도 알아둘 필요가 있다. NSDateComponents는 몇 월, 몇 일 등 날짜라는 정보를 구성하는 달력에서의 단위들에 대한 정보를 기술하는데 사용하며, 실제 NSDate 객체와 NSDateComponents 사이를 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"];

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

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

추가

날짜 표현으로부터 실제 특정 시점을 계산하는 것은 년/월/일의 구성 요소로부터 특정 시점을 구하는 것과는 사실 약간 다른 작업이다. 이는 본질적으로 NSString과 NSDate간의 상호변환이며, 특정 타입을 문자열로 혹은 그 반대로 변환하는 것은 보통 NSFormatter들이 하는 역할이다. 따라서 날짜를 표현하는 문자열로부터 NSDate 타입의 시점 값을 얻기 위해서는 NSDateFormatter를 사용하는 것을 권장한다.

NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setFormatString: @"yyyy-MM-dd"];
NSDate *someDay = [formatter dateFromString:@"2001-12-25"];

특히 포매터를 이용하면 입력되는 일자표현의 형식을 자유롭게 지정할 수 있다는 장점이 있다.

NSCalendar

앞서 설명하였지만, NSDate는 특정 시점을 나타내는 절대적인 숫자값이다. 하지만 실제로 이는 기준시로부터 몇 초가 지났는지를 일일이 계산해야 하기 때문에 우리에게 익숙한 날짜/시간 단위와는 무관하다. 우리가 인지하는 날짜나 시간 개념의 단위들은 NSDateComponents를 통해서 구조화할 수 있다. 하지만 앞서도 간략히 설명했듯이, 이는 각 시간 단위들을 표시하기 위한 컨테이너이지, 실제 날짜로 계산하는 로직을 포함하지는 않는다. (심지어 각 구성요소간의 유효성을 검증하지도 않는다. 2015년 4월 31일과 같은 식으로 달력에 존재하지 않을 날짜값을 지정하는 것도 실제로 가능하다.). 이는 달력을 통해서 계산되어야 하며, 제법 복잡한 로직에 의해 계산되어야 하는데, 애플의 엔지니어들은 이 힘든 작업을 이미 다 처리해서 NSCalendar로 만들어 두었다. NSDateNSDateComponents 사이를 오가며 변환하는 작업에는 달력이 필요하고 여기에 쓰이는 클래스가 NSCalendar이다.

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

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

이러한 달력의 종류는 문화권의 지배하에 있다. 따라서 특정한 달력을 위와 같이 생성하는 방법도 있지만, 기기의 지역/언어와 같은 로케일 설정을 따라 기본 달력을 얻어서 쓰는 것이 가장 일반적이라 하겠다.

NSCalendar *cal = [NSCalendar currentCalendar];

날짜와 시점을 변환하기

NSDateComponents는 날짜를 이루는 달력상의 요소인 년, 월, 일, 시, 분, 초 등의 정보를 구조적으로 표현하기 위한 객체라 하였다. 그리고 이 객체가 가리키는 시점이 언제인지는 달력을 통해서 알 수 있다.  여기서 특이한 부분은 날짜를 구성하는 월, 일의 정보는 0이 아닌 1로 시작하여 우리가 사용하는 달력에서의 그것과 동일하다는 것이다.  다음은 “2004년 2월 20일”이라는 날짜에 대한 정보를 년, 월, 일로 구분하여 구성하는 코드이다.

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

 

그리고 이러한 날짜정보를 실제 특정한 타임라인상의 시점으로 변경하는데에는 -dateFromComponents:를 사용한다.

NSDate *theDay = [cal dateFromComponents:someday];

반대로 특정한 시점에 대한 달력 상의 날짜 정보를 얻는 방법도 있다. -components:fromDate:를 이용한다. 여기서 흥미로운 점은 우리가 어떤 시점에 대해 알고 싶은 내용이 단순히 년/월/일의 정보일 수도 있고, 날짜와는 무관한 시/분 정보일 수도 있고 혹은 그저 어떤 요일인지만 궁금할 수도 있다는 점이다. 이를 위해서 -components:fromDate:는 필요한 컴포넌트만을 취해서 NSDateComponents의 각 요소 값을 채운다.

예를 들어서 위의 theDay가 무슨 요일인지 확인하고 싶다면, 해당 날짜를 시점으로 변환한 후, 다시 그 시점의 weeday(NSWeekdayCalendarUnit) 컴포넌트를 얻어내면 된다.

NSDateComponents *wdComps = [cal components:(NSWeekdayComponent) fromDate:theDay];
NSInteger weekday = [wdCompons weekday];

여기서 weekday는 요일을 가리키는 값인데, 역시 1부터 시작하며 시작은 일요일이다. 따라서 일요일을 시작으로 1=일요일, 2=월요일… 과 같이 진행된다.

연도 없이 날짜만 고려하는 경우

NSDate는 정확히는 타임 라인 상의 정확한 한 지점, 즉 시점을 의미한다. 하지만 우리는 실생활에서 ‘날짜’를 정확한 한 시점으로만 사용하지는 않는다. 예를 들어 친구의 생일 등을 저장하기 위해서는 년도나 일시는 중요하지 않고 그저 월과 일자만으로 날짜를 생성할 필요가 있다. 일반적으로 NSDate의 메소드들을 통해서는 년도가 없는 날짜 객체를 만들 수 없지만(특정한 시점으로 한정해야 하므로), 다음과 같이 월/일의 정보만을 사용해서 생성하는 것은 가능하다. 단, 이 때에는 다른 모든 컴포넌트값이 0인 상태로 주어짐에 주의할 것.

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

특정 일자를 찾기

금주의 일요일이 언제였더라? 에 대한 답을 찾아보자. weekday값을 보면 일요일은 1이고, 다른 요일은 1씩 증가하는 순서를 갖는다. 따라서 오늘의 weekday와 1의 차이를 구하면 지난 일요일과 오늘의 날짜간의 offset 값이 된다. 이 값을 다시 오늘 날짜로부터 빼서 일요일을 구할 수 있다. 날짜 오프셋을 알고 있다면 캘린더가 아닌 NSDate의 +dateWithTimeInterval:sinceDate:를 통해서 얻는 것도 가능하다. (NSTimeInterval은 두 시점 사이의 거리를 초 단위로 나타내는 값이며, 하루는 86400초이다.)

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

// 오늘 날짜의 요일을 뽑아 이를 사용해 일요일의 날짜를 구하기
NSTimeInterval offset = 86400 * (1 - weekdayComponents.weekday);
NSDate *lastSunday = [NSDate dateWithTimeInterval:offset sinceDate:today];

몇 일 남았는지 계산해보기

NSDate-timeIntervalSinceDate:를 통해서 다른 날짜와 몇 초 차이가 나는지 알 수 있다. 하루가 86400초라 하였으므로, 시점차이값을 86400으로 나눈 몫이 그 날짜까지 남은 날 수가 된다.

(사실 보다 엄밀하게는 두 날짜의 시간값을 0시0분으로 세팅해야 한다. 현재 시점을 기준으로 삼으면 다음날까지 남은 시간이 24시간이 안되기 때문에 하루가 부족할 수 있기 때문이다.)

다른 방법으로는 NSCalendar를 사용하는 방법이 있다. 이 방법은 우리가 특정한 날짜까지 남은 기간을 ‘세어볼 때’ 쓰는 방법과 유사하다. 즉 우리는 특정 날짜까지 ‘몇 일’ 남았다고 할 때도 있지만 ‘몇 개월 몇 일’ 남았다고 하거나 ‘몇 년 몇 개월이 남았다’라고 할 때도 있는 등, 기간에 대해서도 달력의 단위를 쓰는 경우가 있다. 이러한 필요를 충족하기 위해서 -components:fromDate:toDate:options:를 사용하여 두 시점 사이의 차이를 구할 수 있다.

// 필요한 객체들을 준비
NSCalendar *cal = [NSCalendar currentCalendar];
NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
NSDate *today = [NSDate date];
[formatter setFormatString:@"yy/MM/dd"];
NSDate *christmas = [formatter dateFromString:@"18/12/25"];
NSUInteger units = NSMonthCalendarUnit | NSDayCalendarUnit;
// 크리스마스까지 몇 개월, 몇 일이 남았는가?
NSDateComponents *components = [cal components:units 
                                      fromDate:today 
                                        toDate:christmas 
                                       options:0];
NSInteger months = components.month;
NSInteger days = components.day + 1; // today가 오늘 자정이 아니기 때문에 1을 더한다.

30일을 1개월로 치고 시점 차이를 86400으로 나눈 값을 다시 30으로 나눈 몫과 나머지로 사용해도 되겠지만, 달력 상의 몇 월, 몇 일은 이렇게 간단한 산수로를 계산할 수 없다. NSCalendar를 사용하는 것이 코드는 복잡해 보이지만 상대적으로 매우 간편한 셈이다.

그런데 이 코드는 약간 주먹구구식으로 동작하는데, 날짜를 구할 때 1을 더해준다. 왜냐하면 today는 자정이 아닌 코드가 실행되는 현재 시점의 시/분/초 값을 가지는데 비해서 목적 날짜는 자정이다. 따라서 실제로 남은 날짜는 하루가 부족한 (왜냐면, 시/분 단위로 남은 값이 따로 남기 때문에) 상태이기 때문에 이를 보정하기 위한 것이다. 문제는 이 코드가 정말 자정에 실행되었을 때이다.

노멀라이징하기

이러한 문제를 해결하기 위해서는 두 시간을 노멀라이징할 필요가 있다. 즉 비교하는 두 날짜의 시간을 같은 시간으로 맞추면 24시간 단위로 딱 떨어지기 때문에 항상 정확하게 두 날짜 사이의 간격을 구하기가 용이해지는 것이다.

이 때 사용하기 좋은 메소드는 -ordinalityOfUnit: inUnit: forDate: 이다. 달력의 큰 단위와 작은 단위를 받아서, 주어진 시점이 큰 단위 내에서 작은 단위로 몇 번째인지를 구하는 것이다. 예를 들면 이번달의 몇 번째 날인지, 혹은 올해의 몇번째 날인지 (올해의 몇 번째 시간인지 등등) 여기서 작은 단위를 ‘일’로 하고 큰 단위를 Era로 잡으면 시분초가 무시되고 달력의 기준일(언제인지는 알 수 없지만)로부터 몇 일이 경과했는지가 나올 것이다. 두 날짜에 대해서 이 값의 차이를 구하면 날짜로 얼마가 경과되었는지 알 수 있다.

@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;
}

참고로 이 방법은 초 단위로 계산되는데, 32비트 정수에서는 오버플로우가 생길 수 있어서 예전 iOS에서는 사용할 수 없었지만, 지금은 모든 iOS/macOS가 64비트 플랫폼이므로 상관없다. 언급만 하자면, 아이폰에서는 두 날짜의 차이를 계산하기 위해서는 NSDateComponents를 이용해서 시, 분, 초를 0으로 변경한 새로운 NSDate값을 만들어서 비교하는 방식을 사용할 수 있었다.

추가 : NSDateComponentsFormatter

macOS 10.10 (요세미티) 때 새로 추가된 NSDateComponentsFormatter는 두 날짜 사이의 간격을 원하는 포맷으로 환산하여 문자열로 만들어주는 기능을 제공한다. -stringFromDate:toDate:를 사용한다. 혹은 -stringFromTimeInterval:을 이용하면 달력 기준이 아닌 정규화된 시간간격을 사용할 수 있다. SNS 앱에서 게시물의 작성 시점을 몇 분전, 몇 시간 전, 몇 일전 .. 등으로 표시하는 곳에 쓰일 수 있다고 보면 된다. 대신 이는 ‘포매터’를 이용하기 때문에 컴포넌트를 얻는 대신에 이를 표현한 문자열을 얻게된다.

NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
[formatter setFormatString:@"yy/MM/dd"];
NSDate *startDate = [formatter dateFromString:@"11/04/01"];
NSDate *endDate = [formatter dateFromString:@"19/12/13"];

NSDateComponentsFormatter *cFormatter = 
           [[NSDateComponentsFormatter alloc] init];
NSString *result = [cFormatter stringFromDate:startDate, toDate:endDate];
NSLog(@"%@", result);

 

정리

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