[js] Date 집중 탐구

Date는 날짜를 다루기 위해 고안된 다양한 알고리즘을 갖고 있습니다. 하지만 컴터의 세계에서 문자든 날짜든 결국 숫자입니다. 협정 세계 표준시(UTC)는 1970년 1월 1일 자정을 숫자 0으로 지정하고 그 이전은 음수로 그 이후는 양수로 시간의 경과를 표시하도록 정했습니다. 1/1000 초가 지날 때마다 1씩 더해가는 이 방식은 그 당시로서는 대단히 큰 메모리 공간을 할당해서 여유 있게 시간을 충분히 분할했다고 생각했습니다. 하지만 요즘엔 모바일의 안드로이드 조차 fps를 측정하기 위해 나노세컨을 쓰는 실정이죠 ^^

자바스크립트에서는 Date객체를 코어객체로 내장하고 있습니다. 하지만 기능이 빈약하여 실무에서 사용하려면 귀찮기 짝이 없습니다. 게다가 양키들이 만들다 보니 양키식 날짜표현이나 결과값도 짜증나죠. 자, Date를 정복해보도록 하죠.

 
 
 

Date만들기top

일단 기본은 new Date() 하면 현재 머신 시각을 기준으로 한 Date 객체가 생성됩니다. 근데 이 Date 함수에 어떤 인자를 전달하냐에 따라 생성된 Date객체에 날짜를 설정할 수 있습니다. 근데 보내는 인자 수에 따라 다르게 반응할 뿐더러 인자의 데이터 형에도 다르게 반응합니다. 따라서 짜증나고 귀찮습니다. 이걸 대한민국의 국민답게 사용할 수는 없는 걸까요!

역시 대한민국이라면 날짜표현은 이거죠.

2012-06-22 15:00:17

이거 받아주면 얼마나 인지부조화가 해결되겠습니까! 다른 건 둘째치고 일단 이것부터 먼저 구현해보겠습니다. 텍스트 파싱 과정을 생략할까 설명할까 고민하다가 일단 소개하기로 결정했습니다. 실제로는 정규식 한 바퀴지만 제어구문으로 차근차근 풀어가 보죠. 파싱은 다음과 같은 단계로 진행됩니다.

  1. 를 이용해 분리한다.
  2. 만약 마지막 요소에 스페이스가 포함되어 있으면 시간이 기술된 것이므로 시간도 파싱한다.
  3. 헌데 스페이스 분리 시 시간 뒤에도 더 기술된 숫자가 있다면 이를 밀리세컨에 반영한다.
  4. 시간 부분의 파싱은 :를 기준으로 쪼갠다.
  5. 특히 01 과 같이 0이 앞에 오는 경우 parseInt는 이를 2진수로 처리하므로 두 번째 인자에 10진법임을 명시한다.
  6. 시간과 밀리세컨의 처리가 끝나면 연, 월, 일에 대해 마찬가지 변환작업을 하고 이를 Date에 반영한다.
  7. 주의할 점은 월의 경우 1을 뺀 값을 넣어야 한다.
  8. 숫자면 곧장 Date에 인자로 넘겨 생성한다. 이 때 숫자의 의미는 협정 세계 표준시로 표현한 시간이다.
  9. Date객체라면 그대로 반환하고 그 외의 경우는 새로운 Date객체를 생성해서 반환한다.

길게 써서 그렇지 쉽습니다.

function dateGet( $date ){

    var i, temp, h, m, s, ms;

    switch( typeof( $date ) ){
    case'string' :
        ms = h = m = s = 0;

        // 1
        $date = $date.split( '-' );

        // 2
        if( $date[2].indexOf( ' ' ) > -1 ){
            temp = $date[2].split( ' ' );
            $date[2] = temp[0];

            //3
            if( temp.length == 3 ){
                ms = parseInt( temp[2], 10 );
                if( ms > 1000 ) ms = 999;
            }

            //4, 5
            temp = temp[1].split( ':' );
            h = parseInt( temp[0], 10 );
            m = parseInt( temp[1], 10 );
            s = parseInt( temp[2], 10 );
        }

        //6, 7
        return new Date(
            parseInt( $date[0], 10 ),
            parseInt( $date[1], 10 ) - 1,
            parseInt( $date[2], 10 ),
            h, m, s, ms
        );

    //8
    case'number':
        return new Date( $date );

    //9
    default:
        if( $date.constructor == Date ){
            return $date;
        }else{
            return new Date();
        }
    }
}

일단 Date를 원하는 시각을 정확하고 간단히 생성할 수 있게 되었습니다. 이제 생성해보죠.

// 날짜만 넣자
var d0 = dateGet( '2012-06-06' );

// 시간을 포함해보자
var d1 = dateGet( '2012-06-06 02:00:01' );

// 밀리세컨도 넣자
var d2 = dateGet( '2012-06-06 03:00:05 34' );

Date를 생성할 때 인자로 숫자를 하나만 넘기면 협정 세계 표준시로 변환하여 처리합니다. 헌데 자바스크립트의 모든 객체는 Object를 프로토타입 참조로 갖고 있으므로 toStringvalueOf를 갖고 있습니다. Date객체도 마찬가지로 이 두 개의 메서드를 갖고 있는데 valueOf의 기본 설정이 협정 세계 표준시 값을 반환하는 getTime()입니다. 따라서 다음 표현은 모두 같은 결과를 갖게 됩니다.

var date = new Date();
date.getTime() == date*1 == date.valueOf()
  1. 첫 번째 경우는 getTime을 통해 직접 UTC를 얻었지만
  2. 두 번째는 곱하기 1을 통해 강제로 valueOf를 호출해 UTC를 얻었습니다.
  3. 세 번째의 경우는 직접 valueOf를 호출한 거죠.

이러한 방법이 아니라도 컨텍스트 상 숫자를 원하는 자리게 들어가면 자바스크립트는 내부적으로 경우에 맞춰 toString이나 valueOf 중에 하나를 호출하게 됩니다.

var date = new Date( 2012, 0, 1 );

//기존 date를 이용해서 간단히 복제하자!
var newdate = new Date( date );

이 원리를 이용하면 위에 작성했던 dateGetDate객체가 인자로 넘어온 경우 그냥 반환하는 것보다는 복제로 넘겨주는 게 더 좋을 것입니다.

...

default:
    if( $date.constructor == Date ){
        return new Date( $date );
    }else{
    ...

 
 
 

날짜의 부분 집합 얻기top

Date객체를 생성하는 건 이제 제법 쉽게 되었습니다. 다음은 날짜의 부분 집합을 얻는 방법을 생각해보죠.

날짜의 부분 집합은 대표적으로 연월일, 시분초 등입니다.

자바스크립트에서는 OOP스타일의 getXXX 메서드를 잔뜩 준비해두고 있습니다. 하지만 역시 이 분야의 갑은 datePart 함수죠.

datePart는 표준 SQL에도 내장되어있고 PHPC진영에서도 폭 넓게 활용하고 있는 함수입니다. 부분집합을 나타내는 문자열을 기억해야 한다는 부분이 좀 귀찮지만 적당히 타협하고 사용해 봅시다.

datePart에서 사용하는 날짜 표현식은 일종의 문자기호인데 각 날짜의 부분과 일치합니다. 일일히 기호를 설명하기 보단 코드 그 자체로 표현하는 편이 오히려 이해가 편리할 것이라 곧장 코드로 가겠습니다.

function datePart( $part, $date ){
    var i;
    switch( $part ){
    case 'Y': return $date.getYear() + '';
    case 'y': return datePart( 'Y', $date ).substr( -2 );

    case 'n': return ( $date.getMonth() + 1 ) + '';
    case 'm': return ( '00' + datePart( 'n', $date ) ).substr( -2 );

    case 'j': return $date.getDate() + '';
    case 'd': return ( '00' + datePart( 'j', $date ) ).substr( -2 );

    case 'G': return $date.getHours() + '';
    case 'H': return ( '00' + datePart( 'G', $date ) ).substr( -2 );

     case 'g':
        i = ( parseInt( $date.getHours() ) % 12 ) + '';
        return i ? i : '0';
    case 'h': return ( '00' + datePart( 'g', $date ) ).substr( -2 );

    case 'i': return ( '00' + $date.getMinutes() ).substr( -2 );
    case 's': return ( '00' + $date.getSeconds() ).substr( -2 );
    case 'u': return $date.getMilliseconds() + '';

    case 'w':
        switch( $date.getDay() ){
        case 0: return '일';
        case 1: return '월';
        case 2: return '화';
        case 3: return '수';
        case 4: return '목';
        case 5: return '금';
        case 6: return '토';
        }
    default: return $part;
    }
}

대부분 자바스크립트의 네이티브 메서드를 감싸서 쓰기 편하게 만든 정도입니다. 이제 이걸 사용해보죠.

var date = dateGet( '2012-06-07 13:02:03 356' );

datePart( 'Y', date ); // 2012
datePart( 'y', date ); // 12
datePart( 'n', date ); // 6
datePart( 'm', date ); // 06
datePart( 'j', date ); // 7
datePart( 'd', date ); // 07
datePart( 'G', date ); // 13 - 24시간 표기법
datePart( 'g', date ); // 1 - 12시간 표기법
datePart( 'h', date ); // 01 - 12시간 표기법

일단 Date의 각 부분을 얻는 함수를 작성했으니 이제 날짜를 입맛대로 텍스트화 할 수 있는 수단이 필요합니다. 예를 들어 아래와 같이 다양한 표현을 하고 싶다는 거죠.

12.6.7
2012 / 06 / 07 [13:02]

위처럼 날짜를 입맛대로 텍스트화 시키는 함수의 표준은 dateFormat 입니다. datePart를 이용해 dateFormat을 구현해 보죠.

function dateFormat( $part, $date ){
    var date, i, j, result;
    date = dateGet( $date );
    result = '';
    for( i = 0, j = $part.length ; i < j ; i++ ) result += datePart( $part.charAt(i), date );
    return result;
}

뭐 고작 요정도 입니다. 그럼 이제 앞에 말했던 샘플을 직접 구현할 수 있습니다.

var date = dateGet( '2012-06-07 13:02:03 356' );

dateFormat( 'y.n.j', date ) == '12.6.7'
dateFormat( 'Y / m / d [H:i]', date ) == '2012 / 06 / 07 [13:02]'

 
 
 

날짜에 더하기top

날짜의 연산은 복잡하고 귀찮지만 가능합니다. 일단 모든 날짜는 근본을 보면 UTC 기준의 숫자입니다. 따라서 연산이 가능합니다. 예를 들어 두 날짜에 대해 사칙연산을 실시하면 자동으로 valueOf를 통해 getTime이 호출되기 때문에 숫자로 변환되어 연산이 이뤄집니다.

하지만 그 결과는 언제나 밀리세컨이 됩니다. 올바른 값이지만 밀리세컨의 차이를 알고 싶은 게 아니라 실제 날짜로서의 연산을 하고 싶은 거죠.

다행히 자바스크립트의 setXXX시리즈 메서드를 매우 똑똑해서 미리 정해진 값보다 큰 값이 들어오면 알아서 시간을 거기에 맞춰 변경하는 능력이 있습니다. 이 분야의 표준적인 함수는 dateAdd 입니다. 네이티브 메서드를 이용해 간단히 구현해보죠.

function dateAdd( $interval, $number, $date ){
    var date;
    date = dateGet( $date );

    switch( $interval.toLowerCase()){
    case'y': //year
        date.setFullYear( date.getFullYear() + $number );
        break;
    case'm': //month
        date.setMonth( date.getMonth() + $number );
        break;
    case'd': //day
        date.setDate( date.getDate() + $number );
        break;
    case'h': //hour
        date.setHours( date.getHours() + $number );
        break;
    case'i': //minute
        date.setMinutes( date.getMinutes() + $number );
        break;
    case's': //second
        date.setSeconds( date.getSeconds() + $number );
        break;
    case'ms': //msecond
        date.setMilliseconds( date.getMilliseconds() + $number );
        break;
    default:
        return null;
    }
    return date;
}

인자는 각각 단위, 수량, 기준일에 해당됩니다. 만약 3개월을 더 하고 싶다면 다음과 같이 쓸 수 있습니다.

var date = dateGet( '2012-06-07 13:02:03 356' );
var newdate = dateAdd( 'm', 3, date );

 
 
 

날짜 사이의 차이top

드디어 마지막 단계인 날짜 사이의 차이를 알아볼 차례입니다. 날짜 사이의 차이가 마지막에 등장하는 이유는 난이도와 복잡성이 최강이기 때문입니다.

날짜 간의 차이라는 개념을 생각해보면 차이를 어떤 단위로 얻는 것이냐가 매우 중요합니다. 예를 들어 2012.06.01 과 2012.06.02 의 차이는 하루라고 말할 수도 있지만 24시간이라고 말할 수도 있습니다.

모든 문제는 바로 여기서 시작하는데 만약 날짜 사이의 차이를 밀리세컨으로만 얻는다고 하면 매우 간단합니다. 아마 아래와 같겠죠.

new Date( 2012, 5, 2 ) - new Date( 2012, 5, 1 ) == 86400000

하지만 보통 86400000을 원하지는 않습니다. 그렇다고 해도 시분초는 매우 간단합니다. 저 밀리세컨을 적당히 나눠서 정수를 얻으면 그걸로 끝입니다.

// 시
parseInt( 86400000 / 3600000 );
// 분
parseInt( 86400000 / 60000 );
// 초
parseInt( 86400000 / 1000 );

또한 연도 기반으로 구하는 것도 껌입니다.

date2.getFullYear() - date1.getFullYear();

이 정도에서 해결됩니다. 월 기반의 차이는 연도 기반하고 결합하면 간단하게 해결되죠.

( date2.getFullYear() - date1.getFullYear() ) * 12 + date2.getMonth() - date1.getMonth();

근데 진짜 지옥은 일 기반으로 얻는 것입니다. 일을 기반으로 얻으려면 각 달마다의 끝 날(28, 29, 30, 31)의 차이와 윤년 계산을 포함한 매우 복잡한 계산을 해야 정확히 일 차이를 구할 수 있습니다. 우선 준비운동으로 윤년이나 구해볼까요.

윤년을 구하는 방법은

  1. 4의 배수지만 100의 배수가 아니다.
  2. 근데 400의 배수는 괜찮다.

라는 조건입니다.  뭐 윤년을 만들어낸 방법은 어떨지 몰라도 윤년 자체를 알고리즘화 하는 건 아무것도 아니죠.

function isLeapYear( $year ){
    return ( $year % 4 == 0 && $year % 100 != 0 ) || $year % 400 == 0;
}

//2012년은 윤년!
isLeapYear( 2012 ) == true;

이 윤년 공식을 이용해 각 월의 끝 날을 정의해보죠.

endDate = [31,28,31,30,31,30,31,31,30,31,30,31];

if( isLeapYear( date.getFullYear() ) ) endDate[1]++;

일단 가장 쉬운 경우부터 생각해 보겠습니다. 연도와 월이 같은데 날짜만 차이가 나는 경우죠. 이런 경우는 그냥 날짜 수준에서 뺄셈을 하면 됩니다.

date2.getDate() - date1.getDate();

그럼 월만 다른 경우는 어떨까요. 이제부터 슬슬 짜증나기 시작합니다. 일단 월이 달라지면 기간을 3개로 나눠야 합니다. 아래와 같이 나눕니다.

  1. date1이 속한 월
  2. date2가 속한 월
  3. date1date2 사이에 속한 월

마지막에 있는 date1date2 사이에 속한 월은 없을 수도 있습니다. 예를 들어 1월15일과 2월7일의 경우 3번에 해당되는 월은 없죠.

세 개로 나눈 이유는 계산 방법이 다르기 때문입니다.

  1. date1이 속한 월은 간단히 생각해보면 1월15일이라고 하면 1월15일~1월31일 까지를 구하면 됩니다. 이렇게 되면 다시 앞에서 언급한 연도와 월이 같은 경우가 됩니다. 매우 쉽게 구할 수 있죠.
  2. date2가 속한 월은 1번과 비슷하지만 1일부터 구한다는 점이 다릅니다. 따라서 2월7일의 경우 2월1일 ~ 2월7일 까지를 구하면 됩니다.
  3. 그 사이의 경우엔 결국 그 달 전체가 반영되므로 그저 그 달의 끝 날을 더해주면 됩니다.

위의 예에선 3번 경우가 없으므로 1번과 2번 결과를 합하면 거기서 끝입니다.

연도가 다른 경우를 배제하고 여기까지 말한 내용을 구현하면 다음과 같이 됩니다.

d1_year = date1.getFullYear();
d1_month = date1.getMonth();
d1_date = date1.getDate();

d2_year = date2.getFullYear();
d2_month = date2.getMonth();
d2_date = date2.getDate();


//윤년 반영
temp = [31,28,31,30,31,30,31,31,30,31,30,31];
if ( isLeapYear( d1_year ) ) temp[1]++;


//계산결과
result = 0;

//월이 다른 경우
if( d2_month - d1_month > 0 ){

    //1번 경우
    result += temp[d1_month] - d1_date + 1;

    //2번 경우
    result += d2_date - 1;

    //3번 경우
    for( i = d1_month + 1 ; i < d2_month ; i++ ) result += temp[i];

//월이 같은 경우
}else{
    result += d2_date - d1_date;
}

연도가 다른 경우만 남았습니다. 연도가 다른 경우는 월이 다른 경우와 마찬가지로 3개로 기간을 쪼갭니다. 쪼개는 방법은 완전히 동일하게 date1속한 연도, date2가 속한 연도, date1date2사이의 연도 그룹입니다.

계산방법도 대동소이합니다만 미묘한 차이가 있으니 적어보겠습니다.

  1. date1이 속한 연도의 경우 date1 ~ 그 해 12월 31일 까지의 차이를 구하면 됩니다.
  2. date2가 속한 연도는 그 해 1월 1일 ~ date2 까지의 차이를 구하면 됩니다.
  3. 그 사이 구간에선 365 또는 366을 더해주면 됩니다.

하지만 1, 2번 자체가 다시 연도가 같고 월, 일이 다른 경우로 분기됩니다. 따라서 재귀호출로 해결하는 편이 간단합니다. 이러한 날짜 간의 차이를 처리하는 표준은 dateDiff 입니다. 이제 전체 소스를 보죠.

function dateDiff( $interval, $dateOld, $dateNew ){
    var date1, date2, d1_year, d1_month, d1_date, d2_year, d2_month, d2_date, result, temp, i;
    date1 = dateGet( $dateOld );
    date2 = dateGet( $dateNew );

    switch( $interval.toLowerCase()){
    case'y': //year
        return date2.getFullYear() - date1.getFullYear();
    case'm': //month
        return ( date2.getFullYear() - date1.getFullYear() ) * 12 + date2.getMonth() - date1.getMonth();
    case'h': //hour
        return parseInt( ( date2.getTime() - date1.getTime() ) / 3600000 );
    case'i': //minute
        return parseInt( ( date2.getTime() - date1.getTime() ) / 60000 );
    case's': //second
        return parseInt( ( date2.getTime() - date1.getTime() ) / 1000 );
    case'ms': //msecond
        return date2.getTime() - date1.getTime();
    case'd': //day
        d1_year = date1.getFullYear();
        d1_month = date1.getMonth();
        d1_date = date1.getDate();

        d2_year = date2.getFullYear();
        d2_month = date2.getMonth();
        d2_date = date2.getDate();

        result = 0;

        if( d2_year - d1_year > 0 ){

            // 연도가 다른 경우 3단계 처리
            result += dateDiff( 'd', dateGet( date1 ), dateGet( d1_year + '-12-31' ) );
            result += dateDiff( 'd', dateGet( d2_year + '-1-1' ), dateGet( date2 ) );
            for( i = d1_year + 2 ; i < d2_year ; i++ ) result += 365 + ( isLeapYear( i ) ? 1 : 0 );

        }else{

            temp = [31,28,31,30,31,30,31,31,30,31,30,31];
            if ( isLeapYear( d1_year ) ) temp[1]++;

            if( d2_month - d1_month > 0 ){

                // 월이 다른 경우 3단계 처리
                result += temp[d1_month] - d1_date + 1;
                result += d2_date - 1;
                for( i = d1_month + 1 ; i < d2_month ; i++ ) result += temp[i];

            }else{
                result += d2_date - d1_date;
            }
        }
        return result * order;
    default:
        return null;
    }
}

 
 
 

결론top

  • 날짜는 매우 자주 사용되는 데이터 형식입니다만, 사람이 인지하는 것과 기계가 숫자로 처리하는 데는 큰 차이가 있습니다.
  • 개발은 사람을 위해 하는 것이기 때문에 기계적인 로직을 사람이 인지하기 편한 데이터 형태로 가공해야 합니다.
  • dateGet, datePart, dateAdd, dateDiff, isLeapYear 등은 순수한 Date객체를 확장하여 이러한 작업을 간편히 처리하도록 돕습니다.