자바스크립트

자바스크립트에서 타임존 다루기 (2)

NHNEnt의 기술블로그인 Toast Meetup에 연재된 ‘자바스크립트에서 타임존 다루기’의 두 번째 글이다. 1부에서는 타임존과 오프셋에 대한 개념과 IANA timezone Database 등에 대해 자세히 설명하고 있다. 이 글은 1부에서 다룬 내용을 바탕으로 자바스크립트에서의 타임존에 대해 설명하고 있으므로, 1부를 읽지 않은 분들은 꼭 먼저 읽어보길 권한다.

1부에서 자바스크립트의 타임존 지원은 다른 언어에 비해 상당히 부족한 편이라고 이야기했다. 하지만 미약하나마 자바스크립트에서도 타임존을 다룰 수 있는 방법을 제공하고는 있다. 2부에서는 자바스크립트에서 지원하는 타임존 관련 API들과 이들의 한계에 대해 좀더 자세히 알아보고, 이를 보완할 수 있는 방법을 찾아보도록 하겠다.

서버 – 클라이언트 환경에서의 타임존

먼저 타임존을 고려해야 하는 간단한 시나리오를 생각해보자. 시간 정보를 다루어야 할테니 간단한 일정 관리 프로그램이 어울릴 것 같다. 먼저, 클라이언트 환경에서 사용자가 등록 페이지에서 입력박스에 일정(날짜 및 시간) 입력하면, 그 정보를 서버로 전송해서 DB에 저장된다. 그리고 목록 페이지에서는 클라이언트가 다시 서버로부터 등록된 일정의 정보를 받아와서 화면에 보여주게 된다.

이때 유의할 점은, 서버에 저장된 동일한 데이터에 접근하는 클라이언트들이 서로 다른 타임존을 갖고 있을 수 있다는 점이다. 즉, 서울에서 2017년 3월 10일 오전 11시 30분이라는 일정을 등록하고, 뉴욕에서 해당 일정을 조회한다면 2017년 3월 10일 오후 9시 30분이라고 표시되어야 한다. 이런식으로 다양한 타임존의 클라이언트 환경을 지원하기 위해서는 서버에 저장되는 데이터가 타임존에 영향을 받지 않는 절대값이어야 한다. 서버에서 이러한 절대값을 어떤 데이터 형태로 저장하는지는 각 서버나 데이터베이스 환경에 따라 다를 것이며, 이 글의 주제를 벗어나는 내용이므로 깊게 다루지 않겠다. 다만, 이를 위해서는 클라이언트에서 서버에 전달하는 날짜 및 시간 정보가 동일한 오프셋(보통 UTC)에 맞추어진 값이거나, 해당 클라이언트 환경의 타임존 정보까지 포함된 값이어야 할 것이다.

일반적으로 이런 데이터는 UTC를 기준으로 한 유닉스 시간이나 오프셋 정보가 포함된 ISO-8601와 같은 형태로 전송하게 된다. 위의 예시에서 서울의 2017년 3월 10일 오후 9시 30분은 유닉스 시간을 이용한다면 숫자 타입의 1489113000 이 될 것이고, ISO-8601을 이용한다면 문자열 타입의 2017-03-10T11:30:00+09:00 가 될 것이다.

브라우저 환경에서 자바스크립트를 이용해 이러한 처리를 해야 한다면, 우리는 사용자의 입력 값을 위와 같은 형식으로 변환하는 작업과, 위와 같은 형식의 데이터를 받아서 사용자의 타임존에 맞게 변환하는 작업 두 가지를 모두 고려해야 한다. 흔히 사용하는 용어로 표현하자면 앞의 작업은 파싱(Parsing), 그리고 뒤의 작업은 포맷팅(Formatting)이라고 할 수 있을 것이다. 그러면, 자바스크립트에서 이들을 어떻게 처리하는지 하나씩 살펴보도록 하자.

Node.js를 이용한 서버 환경에서 자바스크립트를 사용할 때에도 경우에 따라 클라이언트로부터 전달받은 데이터를 파싱해야 하는 등의 작업이 필요할 때가 있다. 하지만, 보통 서버 환경의 타임존은 데이터베이스와 동일하게 설정되어 있고, 포맷팅을 주로 클라이언트에게 위임하는 경우가 많기 때문에 브라우저 환경보다는 고려할 요소가 적을 것이다. 이 글에서는 브라우저 환경을 중심으로 설명하도록 하겠다.

자바스크립트의 Date 객체

자바스크립트에서 날짜나 시간과 관련된 모든 작업은 Date 객체를 이용해서 처리한다. Array나 Function과 같이 ECMAScript 스펙에 정의되어 있는 네이티브 객체이며, 주로 C++과 같은 네이티브 코드로 구현된다. API는 MDN 문서에 잘 정리되어 있는데, Java의 java.util.Date 클래스에서 영향을 많이 받았다고 한다. 그래서 불변(Immutable) 데이터가 아니라는 점이나, Month가 0으로 시작하는 등의 안좋은 특징까지 같이 공유하고 있다.

자바스크립트의 Date 객체는 내부적으로 유닉스 시간과 같은 절대값으로 시간 데이터를 관리한다. 하지만 생성자나 parse() 함수, getHour(), setHour() 등의 메소드들은 모두 클라이언트의 로컬 타임존 (정확히는 브라우저가 실행되는 운영체제에 설정된 타임존)에 영향을 받는다. 그러므로 사용자가 입력한 데이터를 이용해 그대로 Date 객체를 생성하거나 값을 지정한다면 그 데이터는 클라이언트의 로컬 타임존을 그대로 반영하게 될 것이다.

1부에서 잠깐 언급했듯이, 자바스크립트는 임의로 타임존을 변경할 수 있는 방법을 제공하지 않는다. 그러므로, 일단 브라우저의 타임존 설정을 그대로 반영해도 되는 상황이라고 가정하고 설명을 이어가도록 하겠다.

사용자의 입력값을 이용한 Date 객체 생성

처음의 예시를 계속 이어가 보자. 사용자가 타임존이 서울로 설정된 기기에서 2017년 3월 11일 오전 11시 30분을 입력했다. 이 입력값을 년도, 월, 일, 시, 분 단위로 각각 숫자 형태의 2017, 2, 11, 11, 30 로 저장했다고 가정해보자. (월은 0부터 시작하기 때문에 3에서 1을 뺀 2가 되어야 한다). 생성자를 이용하면, 각 단위별 숫자값을 이용해서 간단하게 Date 객체를 생성할 수 있다.

var d1 = new Date(2017, 2, 11, 11, 30);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

d1.toString() 이 반환하는 값을 보면 생성된 객체의 절대값은 오프셋 +09:00 (KST) 기준의 2017년 3월 11일 11시 30분인 걸 알 수 있다.

생성자를 이용하는 또 하나의 방법은 문자열 데이터를 이용하는 것이다. Date 객체의 생성자에 문자열 타입의 값을 사용하면, 내부적으로 Date.parse()를 호출하여 적절한 값을 계산해내며, 이 함수는 RFC2888 스펙과 ISO-8601 스펙을 지원한다. 하지만 MDN의 Date.parse() 문서에도 나와있듯이, 이 메소드의 결과값은 브라우저마다 구현상태가 다르고, 문자열의 형태에 따라 정확한 값을 예측하기 어렵기 때문에 사용하지 않기를 권장하고 있다.

예를 들어 2015-10-12 12:00:00 같은 문자열은 사파리나 IE의 경우 NaN 을 반환하고, 크롬이나 파이어폭스의 경우 로컬 타임존의 값을 반환하며, 경우에 따라 다른 환경에서는 UTC 기준의 값을 반환하기도 한다.

서버 데이터를 이용한 Date 객체 생성

이제, 서버로부터 데이터를 전달받는 경우를 생각해보자. 만약 데이터가 숫자형의 유닉스 시간값이라면, 간단하게 생성자를 이용해서 Date 객체를 생성할 수 있다. 앞에서는 설명을 생략했지만, Date 생성자는 인자로 숫자 하나만을 받으면 년도값으로 생각하지 않고, 밀리초 단위의 유닉스 시간으로 인식한다. (주의: 자바스크립트는 유닉스 시간값을 밀리초 단위로 다룬다. 즉, 초 단위로 계산된 값이라면 1000을 곱해주어야 한다) 아래의 예제를 보면 이전 예제와 동일한 결과값을 반환하는 것을 볼 수 있다.

var d1 = new Date(1489199400000);
d1.toString(); // Sat Mar 11 2017 11:30:00 GMT+0900 (KST)

그러면 유닉스 시간이 아닌 ISO-8601과 같은 문자열 타입은 어떨까? 앞 절에서 설명했듯이 Date.parse() 메소드는 결과를 신뢰할 수 없기 때문에 사용하지 않는 것을 권장하고 있다. 하지만 ECMAScript 5판부터 ISO-8601 을 지원하도록 명시되어 있기 때문에, ECMAScript 5판을 지원하는 IE9 이상의 브라우저에서는 주의해서 사용하면 ISO-8601 형식의 문자열을 Date 생성자에 사용할 수 있다.
여기서 주의할 점은, 최신 브라우저가 아닌 경우 마지막에 Z 문자가 없으면 UTC 기준으로 해석해야 함에도 불구하고 로컬 타임을 기준으로 해석하는 경우가 있다는 점이다. 아래의 예제는 IE10 에서 실행시킨 결과이다.

var d1 = new Date('2017-03-11T11:30:00');
var d2 = new Date('2017-03-11T11:30:00Z');
d1.toString(); // "Sat Mar 11 11:30:00 UTC+0900 2017"
d2.toString(); // "Sat Mar 11 20:30:00 UTC+0900 2017"

스펙에 따르면 두 결과값이 같아야 함에도 불구하고, d1.toString()d2.toString()의 결과값이 다른 것을 볼 수 있다. 최신 브라우저에서는 두 결과가 동일할 것이므로, 브라우저 버전별로 다르게 해석되는 문제를 막기 위해서는 타임존 데이터가 없는 경우 문자열의 마지막에 항상 Z를 추가해 주어야 한다.

서버로 전달할 데이터 생성

이제 앞서 생성된 Date 객체를 이용하면 로컬 타임존을 기준으로 날짜나 시간을 더하거나 빼는 등의 연산을 자유롭게 할 수 있다. 하지만 마지막에 다시 서버로 데이터를 전송하기 위해서는 데이터를 변환하는 과정이 필요하다.

먼저 유닉스 시간 형식의 경우 getTime() 메소드를 이용해서 간단하게 수행할 수 있다. (앞에서와 마찬가지로 밀리초 단위라는 것에 유의하자)

var d1 = new Date(2017, 2, 11, 11, 30);
d1.getTime(); // 1489199400000

ISO-8601 형식의 문자열은 어떨까? 앞서 말했듯이 ECMAScript 5판 이상을 지원하는 IE9 이상의 브라우저는 ISO-8601 형식을 지원하며, toISOString()toJSON() 메소드를 이용하면 ISO-8601 형식의 문자열을 생성할 수 있다. (toJSON()JSON.stringify()등에서 재귀적으로 호출될 때도 사용될 수 있다) 두 메소드의 결과값은 동일한데, 다만 다음과 같이 유효하지 않은 데이터에 대한 처리만 조금 다르다.

var d1 = new Date(2017, 2, 11, 11, 30);
d1.toISOString(); // "2017-03-11T02:30:00.000Z"
d1.toJSON();      // "2017-03-11T02:30:00.000Z"

var d2 = new Date('Hello');
d2.toISOString(); // Error: Invalid Date
d2.toJSON();      // null

이 외에도 UTC 기준의 문자열을 생성할 수 있는 toGMTString()toUTCString() 메소드가 있는데, 이들은 RFC-1123 표준에 맞는 문자열을 반환하므로 필요에 따라 사용할 수 있을 것이다.

Date 객체에는 toString(), toLocaleString() 및 이들의 확장 메소드들이 더 있지만, 주로 로컬 타임존 기준의 문자열을 반환하는 목적으로 사용되고, 특히 브라우저나 OS 환경에 따라 같은 결과가 보장되지 않기 때문에 사실상 별로 유용하지 않다.

로컬 타임존 변경하기

지금까지 살펴본 바에 의하면 자바스크립트에서도 어느정도 타임존 지원이 이뤄지고 있는 것 같다. 하지만 만약, OS의 타임존 설정을 따르지 않고, 어플리케이션 내에서 로컬 타임존을 직접 변경하고 싶다면 어떻게 해야 할까? 아니면, 다양한 타임존에서의 시간을 하나의 어플리케이션에서 동시에 보여주어야 한다면? 여러 번 말했듯이, 자바스크립트에서는 로컬의 타임존을 직접 변경할 수 없다. 유일하게 할 수 있는 방법은 원하는 타임존의 오프셋 값을 알고 있는 경우, 오프셋값을 더하거나 빼서 직접 날짜를 계산하는 것이다. 안된다고 무작정 포기하지 말고, 일단 자바스크립트가 할 수 있는 최선의 방법에 대해 살펴보자.

먼저, 앞선 예시에서 처럼 현재 브라우저의 타임존 설정이 서울이라고 가정하자. 사용자는 서울 기준의 2017년 3월 11일 오전 11시 30분 데이터를 뉴욕의 타임존 기준으로 볼 수 있기를 원한다. 그리고 서버에서는 해당 데이터를 밀리초 단위의 유닉스 시간으로 전송해 주면서, (친절하게도) 뉴욕의 오프셋이 -05:00 이라는 것도 알려주었다. 자, 그럼 현재 로컬 타임존의 오프셋만 알면 데이터를 변환할 수 있을 것이다.

이 때 사용할 수 있는 메소드가 바로 getTimeZoneOffset() 메소드이다. 이 메소드는 자바스크립트에서 로컬 타임존 정보를 알 수 있는 유일한 API라고 할 수 있는데, 현재 타임존의 오프셋을 분 단위의 숫자로 반환한다.

var seoul = new Date(1489199400000);
seoul.getTimeZoneOffset(); // -540

반환값 -540 은 타임존이 540분 앞서 있다는 의미이다. 서울의 오프셋이 +09:00 이란 걸 생각해보면 부호가 반대로 되어 있는 걸 알 수 있는데, 왜 그런지 모르겠지만 암튼 주의해야 한다. 이 방식을 기준으로 뉴욕의 오프셋 -05:00 을 계산해 보면 60 * 5 = 300 이 될 것이다. 이 840 만큼의 차이를 밀리초로 단위로 보정해서 새로운 Date 객체를 만들면, 그 객체의 getXX 메소드들을 이용해서 원하는 형태의 데이터를 만들어낼 수 있을 것이다. 간단한 포매터 함수를 만들어 결과를 비교해 보자.

function formatDate(date) {
  return date.getFullYear() + '년 ' + 
    (date.getMonth() + 1) + '월 ' + 
    date.getDate() + '일 ' + 
    date.getHours() + '시 ' + 
    date.getMinutes() + '분';
}

var seoul = new Date(1489199400000);
var ny = new Date(1489199400000 - (840 * 60 * 1000));

formatDate(seoul);  // 2017년 3월 11일 11시 30분
formatDate(ny);     // 2017년 3월 10일 21시 30분

formatDate() 의 결과에서 서울과 뉴욕의 시간대에 맞게 날짜가 잘 표시되고 있는 걸 볼 수 있다. 의외로 간단하게 해결된 것 같다. 자, 그러면 해당 지역의 오프셋만 알고 있다면 로컬 타임존을 변경할 수 있다는 것일까? 아쉽게도 답은 ‘아니오’다. 1부에서 타임존이 단순히 오프셋이 아니라 모든 오프셋 변경의 히스토리를 담고 있는 일종의 데이터베이스라고 했던 것이 기억나는가? 정확한 타임존 계산을 위해서는 단순히 현재 시점의 오프셋이 아닌, 해당 날짜가 가르키는 시점의 오프셋을 알아야 한다.

로컬 타임존 변경의 문제

위의 예제를 조금만 더 발전시키면 쉽게 문제를 발견할 수 있을 것이다. 사용자가 뉴욕의 시간대에서 해당 시간을 확인한 후에 날짜를 11일에서 15일로 변경하려고 한다. Date 객체의 setDate() 메소드를 이용하면 다른 항목들은 유지한 채로 날짜 값만 변경할 수 있다.

ny.setDate(15);
formatDate(ny);    // 2017년 3월 15일 21시 30분

간단히 해결되는 것 처럼 보이지만, 여기에는 함정이 있다. 먼저, 이 데이터를 다시 서버로 전송해야 한다면 어떻게 해야 할까? 우리는 데이터 자체를 변경했기 때문에, getTime() 이나 getISOString() 등의 메소드를 사용할 수 없다. 그러므로 서버로 전송하기 위해서는 앞에서 했던 계산을 역으로 해서 원래 데이터를 연산해야만 한다.

var time = ny.getTime() + (840 * 60 * 1000);  // 1489631400000

어차피 반환할 때 역으로 계산해야 하는데, 왜 굳이 변환된 데이터에서 날짜를 더하는지 궁금한 사람들이 있을 것이다. 그냥 기존 데이터에 연산을 하고, 포맷팅을 할 때만 임시로 변환된 Date 객체를 생성해도 될 것 같다. 하지만, 만약 서울기준의 Date 객체에서 날짜로 15로 변경했다면 11일에서 15일이 되었으므로 4일 (24 * 4 * 60 * 60 * 1000)이 추가된 것이고, 뉴욕 기준에서는 10일에서 15일이 되었으므로 5일 (24* 5 * 60 * 60 * 1000)이 추가된 것이다. 즉, 날짜 연산을 할 때에도 해당 오프셋을 기준으로 진행해야만 정확한 연산이 가능하다.

하지만 더 중요한 문제는, 단순히 오프셋을 더하고 빼는 것만으로는 해결되지 않는 문제가 있다는 것이다. 뉴욕의 타임존은 3월 12일부터 서머타임(DST)이 적용되기 때문에 2017년 3월 15일은 오프셋이 -05:00 이 아닌 -04:00 이 되어야 한다. 즉 역으로 연산할 때에는 현재보다 60분이 적은 780분 만큼만 더해주어야 하는 것이다.

var time = ny.getTime() + (780 * 60 * 1000);  // 1489627800000

반대로 사용자의 로컬 타임존이 뉴욕일 때 서울의 타임존 기준으로 날짜 연산을 하려고 하면, 불필요하게 서머타임이 적용되어 계산이 잘못되는 문제가 발생할 것이다.

결론적으로 단순히 전달받은 오프셋 값만 가지고는 원하는 타임존 기준으로 날짜 연산을 할 수가 없다. 뿐만 아니라, 1부의 내용을 다시 기억해보면 서머타임이 적용되는 규칙까지 안다고 해도 여전히 허점이 있다는 것을 알 것이다. 즉, 정확한 날짜 연산을 위해서는 IANA timezone Database와 같이 타임존 변경 히스토리가 담긴 전체 데이터가 필요한 것이다.

해결을 위해서는 전체 타임존 데이터베이스를 저장하고 있다가, Date 객체에서 날짜나 시간 데이터를 가져올 때마다 데이터베이스에서 해당 날짜와 타임존에 맞는 오프셋을 알아낸 다음, 위와 같은 연산을 통해 결과를 반환해야 할 것이다. 물론 이론적으로 가능은 하겠지만, 이를 위해서는 너무 많은 노력이 필요하고, 실제 변환된 데이터가 정확한지 테스트를 하기도 쉽지가 않을 것이다. 하지만, 너무 낙심은 말자. 이제 우리는 자바스크립트 타임존에 어떤 문제가 있는지, 그리고 어떻게 해결할 수 있는지를 알았다. 이제, 잘 만들어진 라이브러리를 찾아서 사용해도 되는 자격을 갖춘 것이다.

Moment Timezone

Moment는 자바스크립트에서 날짜 연산을 할 때 거의 표준처럼 자리잡은 라이브러리이다. 다양한 날짜 연산 및 포맷팅 API를 제공하며, 최근 많은 사용자들에 의해 널리 사용되면서 안정성도 검증받았다고 할 수 있다. 또한 Moment Timezone 이라는 확장 모듈을 이용하면 위에서 말한 문제들을 간단하게 해결할 수 있다. 이 확장 모듈은 실제 IANA timezone Database 의 데이터를 내장하여 실제 오프셋을 정확히 계산해주며, 타임존을 변경하고 포맷팅 할 수 있는 다양한 API를 제공한다.

이 글에서는 라이브러리의 사용법이나 구조에 대해 깊이 다루지는 않을 것이다. 다만, 위에서 우리가 발견했던 문제들을 얼마나 간단하게 해결할 수 있는지만 살펴보도록 하겠다. 관심 있는 분들은 해당 Moment Timezone의 문서를 참고하길 바란다.

자, 그럼 위의 문제를 Moment Timezone 을 이용해서 해결해 보자.

var seoul = moment(1489199400000).tz('Asia/Seoul');
var ny = moment(1489199400000).tz('America/New_York');

seoul.format(); // 2017-03-11T11:30:00+09:00
ny.format();    // 2017-03-10T21:30:00-05:00

seoul.date(15).format();  // 2017-03-15T11:30:00+09:00
ny.date(15).format();     // 2017-03-15T21:30:00-04:00

결과를 보면, seoul의 오프셋은 그대로인 반면에, ny의 오프셋은 -05:00 에서 -04:00 으로 변경된 것을 볼 수 있다. 또한, format() 함수를 이용하면 해당 오프셋이 정확히 반영된 ISO-8601 문자열을 얻을 수 있다. 앞의 절에서 들였던 노력에 비해 얼마나 간단한지 한눈에 체감이 될 것이다.

결론

지금까지 자바스크립트에서 지원하는 타임존 관련 API들과, 관련된 문제점들을 살펴보았다. 로컬 타임존을 직접 변경할 필요가 없다면, IE9 이상의 브라우저에 한해서 기본 API 만으로도 필요한 기능들을 구현할 수 있을 것이다. 하지만, 로컬 타임존을 직접 변경해야 한다면 문제는 복잡해진다. 서머 타임이 없고, 타임존 정책에 변경이 거의 없는 지역에 대해서는 위에서 설명한 것 처럼 getTimezoneOffset() 을 활용한 데이터 변환으로 불완전하게나마 구현이 가능하다. 하지만, 다양한 지역에 대한 타임존을 제대로 지원하고 싶다면, 직접 구현하려는 욕심을 버리고 Moment Timezone과 같은 라이브러리를 활용하는 것이 나을 것이다.

사실 1부의 서두에서도 밝혔듯이, 이 글은 타임존을 제대로 구현해 보겠다는 노력의 실패담으로 시작되었고, 예정된 결론은 “라이브러리를 사용하라” 였다. 하지만, 글을 쓰다보니 처음 예정했던 결론과는 조금 다르게 글을 마치게 되는 것 같다. 결론적으로, 실제 자바스크립트에서 타임존 관련 기능을 얼마나 지원하는지와 어떤 문제점이 있는지를 모른 채 무작정 외부 라이브러리에 의존하는 것은 결코 바람직한 접근이 아니라고 생각한다. 항상 그렇듯이, 각자의 상황에 맞게 적절한 도구를 선택하는 것이 중요하다. 이 글이 그런 판단을 내리는 데에 조금이나마 도움이 되었기를 바란다.

참고 링크

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s