http://helloworld.naver.com/helloworld/textyle/645609 링크 


네이버 비즈니스 플랫폼 웹플랫폼개발랩 정상혁

Java의 기본 SDK에서 날짜와 시간을 다루는 java.util.Date 클래스와 java.util.Calendar 클래스는 사용하기 불편하다는 악평이 자자합니다. 이를 답답하게 여긴 사람들이 이 클래스를 대체하려고 Joda-Time 같은 오픈소스 라이브러리를 만들기도 했습니다. 많이 늦었지만 다행히 JDK 8에서는 개선된 날짜와 시간 API가 제공됩니다.

이 글에서는 Java의 날짜와 시간 API의 문제점이 무엇이었는지 되짚어 보고, 여러 오픈소스 라이브러리와 JDK 8에서는 문제점이 어떻게 개선되었는지 확인해 보겠습니다.

Java 클래스에 담긴 제도의 역사

우선 java.util.Calendar 클래스와 java.util.Date 클래스 등으로 간단한 예제를 작성해 보겠다. 평범한 예제보다는, 날짜와 시간 계산이 사회 제도나 과학과 복잡하게 얽혀있음을 보여주는 예제를 만들어 보았다.

1582년 10월 4일의 다음 날은?

<예제 1>에서는 UTC(Universal Time Coordinated, 세계협정시) 시간대를 기준으로 1582년 10월 4일에 하루를 더한 날짜가 10월 5일인 것을 테스트하고 있다. JUnit과 Fest Assertions 라이브러리[1]를 활용했다.

예제 1 1일 후 구하기

public class OldJdkDateTest {

@Test
public void shouldGetAfterOneDay() {
TimeZone utc = TimeZone.getTimeZone("UTC");
Calendar calendar = Calendar.getInstance(utc);
calendar.set(1582, Calendar.OCTOBER , 4);
String pattern = "yyyy.MM.dd";
String theDay = toString(calendar, pattern, utc);
assertThat(theDay).isEqualTo("1582.10.04");


calendar.add(Calendar.DATE, 1);
String nextDay = toString(calendar, pattern, utc);
assertThat(nextDay).isEqualTo("1582.10.05");
}

private String toString(Calendar calendar, String pattern, TimeZone zone) {
SimpleDateFormat format = new SimpleDateFormat(pattern);
format.setTimeZone(zone);
return format.format(calendar.getTime());
}

이 테스트는 실패한다. <예제 1>에서 계산한 1582년 10월 4일의 다음 날은 1582년 10월 15일이다. 따라서 마지막 줄을 다음과 같이 고쳐야 테스트를 통과한다.

예제 2 <예제 1>의 마지막 줄 수정

assertThat(nextDay).isEqualTo("1582.10.15");

1582년에서 실종된 10일은 그레고리력을 처음 적용하면서 율리우스력에 의해 그동안 누적된 오차를 교정하기 위해서 건너뛴 기간이다. 태양의 황경이 0도가 되는 춘분이 1582년에는 10일 정도 어긋나게 되었다. 교황 그레고리우스 13세는 더 정교한 그레고리력을 1582년 10월 15일에 처음 적용했고, 10월 5 ~ 14일의 날짜는 그 해 달력에서 제외시켰다. 율리우스력은 4년마다 윤년을 두지만, 그레고리력에서는 4년마다 윤년을 두되 매 100번째 해는 윤년이 아니고, 매 400번째 해는 윤년이라는 차이가 있다.

<예제 1>에서 Calendar.getInstance() 메서드는 java.util.GregorianCalendar 클래스의 인스턴스를 반환한다. GregorianCalendar 클래스는 그레고리력과 율리우스력을 같이 구현하고 있고, setGregorianChange() 메서드로 두 역법의 전환 시점을 지정할 수 있다. 그런데 AD 4년의 3월 1일 이전에는 윤년을 불규칙하게 두었기 때문에 GregorianCalendar 클래스로 구한 날짜는 정확하지는 않다. 이런 설명은 GregorianCalendar 클래스의 API 문서[2]에 나와 있다.

Calendar.getInstance() 메서드는 GregorianCalendar 외에도 Locale 정보에 따라서 JapaneseImperialCalendar, BuddhistCalendar 등도 반환한다. 역사와 천문학이 복합적으로 담긴 클래스라 할 만하다.

서울 1988년 5월 7일 23시의 1시간 후는?

<예제 3>에서는 'Asia/Seoul' 시간대에서 '1988.05.07 23:00'의 1시간 후가 '1988.05.08 00:00'임을 테스트하고 있다.

예제 3 1시간 후 구하기

@Test
public void shouldGetAfterOneHour() {
TimeZone seoul = TimeZone.getTimeZone("Asia/Seoul");
Calendar calendar = Calendar.getInstance(seoul);
calendar.set(1988, Calendar.MAY , 7, 23, 0);
String pattern = "yyyy.MM.dd HH:mm";
String theTime = toString(calendar, pattern, seoul);
assertThat(theTime).isEqualTo("1988.05.07 23:00");

calendar.add(Calendar.HOUR_OF_DAY, 1);
String after1Hour = toString(calendar, pattern, seoul);
assertThat(after1Hour).isEqualTo("1988.05.08 00:00");
}

이 테스트도 실패한다. 이상하게도 1시간 후는 5월 8일 새벽 1시이다. 이는 그 시기에 서울에 적용된 일광절약시간제(Daylight Saving Time), 즉 서머타임 때문이다. 서머타임이 시작되는 시점에서는 1시간을 건너뛴다. 해당 시간대가 서머타임 적용 시간대인지는 TimeZone.inDaylightTime() 메서드로 확인할 수 있다. <예제 3>의 마지막 2줄을 다음과 같이 바꾸면 테스트를 통과하고, 이 시간대에 일어난 일을 좀 더 잘 설명할 수 있다.

예제 4 <예제 3>의 마지막 줄 수정

assertThat(seoul.inDaylightTime(calendar.getTime())).isTrue(); 
assertThat(after1Hours).isEqualTo("1988.05.08 01:00");

그러나 이 결과에도 여전히 의문이 남는다. 위키백과에 정리된 한국 표준시 자료[3]나 과거의 보도 기사[4]를 찾아보면 그 해 서머타임이 시작된 시간은 5월 8일 새벽 2시였다. 시간대 데이터베이스(timezone database)의 오류일까? 아니면 시간대 데이터베이스에서 기록한 시간에 특별한 이유가 있는 것일까? 아직 정확한 답은 찾지 못했다.

우리나라는 1988년 이후로는 지금까지 서머타임을 실시하지 않고 있지만 2009년에 정부가 적극적으로 도입을 검토하기도 했었다.[5] 그리고 미국은 2007년부터 서머타임을 한 달 더 늘려서 시행하고 있다. 이렇게 서머타임은 국가마다 계속 변화하는 제도인데 Java는 그런 데이터를 어디에서 참조하고 있을까?

앞에서 말한 시간대 데이터베이스라는 곳에 그런 데이터가 정리된다. tzdata, IANA Time Zone database, Olson database 등 다양한 이름으로 불리는 이 정보는 Java 외에도 Oracle, PHP 등 다양한 플랫폼에서 활용되는 국제 표준 데이터이다. Java는 운영체제에 의존하지 않고 독립적으로 시간대 데이터를 업데이트한다. 즉, 운영체제의 시간대 패치가 업데이트되지 않아도 Java는 최신 정보를 유지할 수 있고, 운영체제의 시간대 패치는 Java의 시간대 정보에 영향을 미칠 수 없다. 그리고 TZUpdater라는 도구로 JRE 전체를 업그레이드하지 않고 시간대 데이터만 최신으로 갱신하는 방식도 지원한다.[6]

JRE의 최신 시간대 데이터의 변경 이력을 보면 요르단, 리비아 등에서 일어난 변경 사항이 2013년에도 반영된 것을 확인할 수 있다.[7] 아마 우리나라 제도에 변화가 생긴다면 이 데이터가 잘 갱신되었는지 유심히 살펴봐야 할 것이다.

서울 1961년 8월 9일 23시 59분의 1분 후는?

<예제 5>는 '1961.08.09 23:59'의 1분 후가 '1961.08.10 00:00'임을 테스트하고 있다.

예제 5 1분 후 구하기

@Test
public void shouldGetAfterOneMinute() {
TimeZone seoul = TimeZone.getTimeZone("Asia/Seoul");
Calendar calendar = Calendar.getInstance(seoul);
calendar.set(1961, Calendar.AUGUST, 9, 23, 59);
String pattern = "yyyy.MM.dd HH:mm";
String theTime = toString(calendar, pattern, seoul);
assertThat(theTime).isEqualTo("1961.08.09 23:59");

calendar.add(Calendar.MINUTE, 1);
String after1Minute = toString(calendar, pattern, seoul);
assertThat(after1Minute).isEqualTo("1961.08.10 00:00");
}

이 테스트도 실패한다. 신기하게도 23시 59분의 1분 후는 0시 30분이다. 다음과 같이 마지막 줄을 수정하면 테스트를 통과할 수 있다.

예제 6 <예제 5>의 마지막 줄 수정

assertThat(after1Minute ).isEqualTo("1961.08.10 00:30"); 

1961년 8월 10일은 대한민국의 표준시가 UTC+8:30에서 현재와 같은 UTC+9:00로 변경된 시점이다. 일제 강점기 동안 UTC+9:00이었던 표준시가 해방 이후 1954년에 UTC+8:30으로 바뀌었다가 1961년에 다시 UTC+9:00으로 바뀐다.[8]이 표준시 변경 때문에 30분을 건너뛰게 된 것이다.

1961년 당시 최고 권력 기관이었던 국가재건최고회의는 표준시를 일본과 동일하게 바꾸기로 결정했다. 최근에는 일제의 잔재에서 벗어난다는 의미에서 다시 한 번 표준시를 바꾸자는 의견도 나오고 있다.[9] 우리나라 근현대사와 관련이 있는 예제라고 할 만하다.

협정세계시 2012년 6월 30일 23시 59분 59초의 2초 후는?

<예제 7>은 UTC(협정세계시) '2012.06.30 23:59:59'의 2초 후가 '2012.07.01 00:00:01'인 것을 테스트하고 있다.

예제 7 2초 후 구하기

@Test
public void shouldGetAfterTwoSecond() {
TimeZone utc = TimeZone.getTimeZone("UTC");
Calendar calendar = Calendar.getInstance(utc);
calendar.set(2012, Calendar.JUNE, 30, 23, 59, 59);
String pattern = "yyyy.MM.dd HH:mm:ss";
String theTime = toString(calendar, pattern, utc);
assertThat(theTime).isEqualTo("2012.06.30 23:59:59");

calendar.add(Calendar.SECOND, 2);
String afterTwoSeconds = toString(calendar, pattern, utc);
assertThat(afterTwoSeconds).isEqualTo("2012.07.01 00:00:01");
}

지금까지의 예제와는 다르게 위의 테스트는 잘 통과한다. 별로 특별할 것이 없다면 이번 예제는 왜 넣었을까? 2012년 6월 30일은 가장 최근에 '윤초'가 적용된 때이다. 즉 <예제 7>의 결과는 윤년이나 서머타임과는 달리 Java에서 윤초가 Calendar 연산에 적용되지 않는다는 것을 보여 준다.

윤년보다는 다소 낯선 윤초가 필요한 이유는 다음과 같다. UTC는 세슘 원자의 진동수에 바탕을 둔 원자시계가 기준이고, UT1(세계시)은 지구의 움직임을 관찰한 결과가 기준이다. 원자시계는 일정한 반면에 지구의 움직임은 미세하게나마 불규칙적이므로 이 둘 사이에는 오차가 발생한다. 윤초는 그 둘의 오차를 보정하기 위하여 추가하는 1초이다. 국제지구자전사업(IERS, International Earth Rotation Service)이라는 기관에서 윤초 수정에 대해 결정한다.

최근에는 윤초를 폐지하자는 주장도 일어나고 있다.[10] 윤초가 컴퓨터 시스템에서 복잡한 문제를 일으킬 수 있기 때문이다. 대부분의 시스템에서 시간은 데이터 정렬, 복제에 핵심적인 키 역할을 한다. 윤초의 적용으로 만약 같은 초가 반복된다면 그 사이의 데이터가 엉킬 가능성이 크다.

윤초가 마지막으로 적용된 2012년 6월 30일에는 RedditFoursquareYelpLinkedIn 등 많은 기업이 장애를 겪었다. Linux + Java 환경의 시스템이 많았고, Cassandra, Hadoop, Elasticsearch 등 데이터 저장, 검색 플랫폼에서 CPU를 100% 사용하는 문제가 발생했다고 한다.[11] 국내에서도 Hadoop을 사용하는 시스템에서 유사한 문제가 많이 발생했다.[12] Linux 커널과 Java의 복합적인 문제로 추정된다. 대부분 시간 재설정, 서버 재시작 등으로 이 문제를 해결한 듯하다. Google은 윤초 적용 이전에 점진적으로 시간을 더해가는 'leap smear'라는 기법으로 이런 장애를 예방했다.[13]

API 문서에 따르면 Date 클래스가 UTC를 정확히 반영하는지 여부는 JVM(Java Virtual Machine)의 실행 환경에 따라 다르다고 한다. 대부분의 현대적인 운영체제에서 모든 경우에 하루는 86,400초(24 × 60 × 60초)이고, 컴퓨터 시계의 대부분은 윤초를 반영할 정도로 정교하지는 못하다고 언급하고 있다.[14] 참고로 System.currentTimeMillis() 메서드는 1970년 1월 1일 이후로 지나간 밀리초를 반환하는데, Windows, Android 등의 운영체제에서 테스트한 결과로는 그 기간 중의 윤초가 특별히 더해지진 않는다.[15]

뒤에서 소개할 Joda-Time과 JSR-310을 포함해서, 모든 환경에서 윤초를 명시적으로 지원하는 Java 라이브러리는 아직 보이지 않는다. 어쨌든 윤초 동기화 때는 운영체제, Java, 미들웨어, 애플리케이션의 상호작용이 불안정해질 가능성이 높다는 점은 분명하다.

JDK의 기본 날짜 클래스의 문제점

앞의 예제로 날짜와 시간 계산이 생각보다 어렵고 고려해야 할 것도 많으며 깊이 이해하기 위해서는 배경 지식도 많이 필요한 영역임을 확인했다. 그런데 이 분야의 어려움은 별도로 치더라도 Calendar 클래스와 Date 클래스는 문제가 많다.

불변 객체가 아니다( not immutable)

VO(Value Object)는 값에 의해 동등성이 판단되는 객체이다.[16] VO는 완전한 불변 객체일 때 별칭 문제, 스레드 불안정성 등의 부작용에서 자유롭고 여러 객체에서 공유되어도 안전하다.[17] 날짜, 돈 등의 객체는 VO의 대표적인 예로 자주 제시된다. C#, Python 같은 언어에서는 날짜 클래스가 한번 생성된 이후에는 내부 속성을 바꿀 수 없다.

불행히도 Java의 기본 날짜, 시간 클래스는 불변 객체가 아니다. 앞의 코드에서 Calendar 클래스에 set 메서드를 호출해서 날짜를 지정하고, 다시 같은 객체에 set(int,int) 메서드를 호출해서 수행한 날짜 연산 결과는 같은 인스턴스에 저장되었다. Date 클래스에도 값을 바꿀 수 있는 set 메서드가 존재한다. 이 때문에 Calendar 객체나 Date 객체가 여러 객체에서 공유되면 한 곳에서 바꾼 값이 다른 곳에 영향을 미치는 부작용이 생길 수 있다. 『Effective Java 2nd Edition』(2008)의 저자 Joshua Bloch도 Date 클래스는 불변 객체여야 했다고 지적했다.[18]

이를 안전하게 구현하려면 이들 객체를 복사해서 반환하는 기법을 권장한다. <그림 1>에서 보이는 코드의 startTime 필드는 내부의 Date 객체를 외부에서 조작할 수 있기 때문에 악의적인 클라이언트 코드에 의해서 착취당할 수 있다. endTime 필드처럼 방어복사 기법을 써서 새로운 객체를 생성해서 반환하는 구현이 바람직하다.[19]

b5ea6781add0d12bcf67d7dd07f0281f.png

그림 1 Date의 방어 복사 기법과 FindBugs의 취약점 지적

이런 취약점은 정적분석 도구로 발견해낼 수도 있다. FindBugs의 다음 규칙은 <그림 1>의 startTime 필드와 같이 취약한 코드를 경고해 준다.

  • EI_EXPOSE_REP[20]
  • EI_EXPOSE_REP2[21]

<그림 1>에서 취약성이 있는 코드의 아래에 추가한 주석은 FindBugs에서 보여 주는 경고문를 그대로 옮긴 문장이다.

int 상수 필드의 남용

Calendar를 사용한 날짜 연산은 <예제 8>과 같이 int 상수 필드를 사용한다.

예제 8 초 더하기 코드

calendar.add(Calendar.SECOND, 2); 

첫 번째 파라미터에 Calendar.JUNE과 같이, 전혀 엉뚱한 상수가 들어가도 이를 컴파일 시점에서 확인할 방법이 없다. 이 뿐만 아니라 Calendar 클래스에는 많은 int 상수가 쓰였는데, 이어서 설명할 월, 요일 지정 등에서도 많은 혼란을 유발한다.

헷갈리는 월 지정

앞에서 1582년 10월 4일을 지정하는 코드는 다음과 같았다.

예제 9 10월 지정 코드

calendar.set(1582, Calendar.OCTOBER , 4); 

그런데 월에 해당하는 Calendar.OCTOBER 값은 실제로는 '9'이다. JDK 1.0에서 Date 클래스는 1월을 0으로 표현했고, JDK 1.1부터 포함된 Calendar 클래스도 이러한 관례를 답습했다. 그래서 1582년 10월 4일을 표현하는 코드를 다음과 같이 쓰는 실수를 많은 개발자들이 반복하고 있다.

예제 10 실수로 쓰기 쉬운 10월 지정 코드

calendar.set(1582, 10 , 4); 

또는 일부러 가독성을 높이기 위해서 10월을 10-1로 표현한 기법을 쓰는 사람도 있다.

예제 11 10월 지정 코드의 한 방식

calendar.set(1582, 10 - 1 , 4); 

<예제 12>는 이로 인해서 일어날 수 있는 실수를 보여 주는 코드이다.[22] <예제 12>의 테스트는 통과한다.

예제 12 1999년 12월 31일을 지정하려다 2000년으로 넘어간 코드

@Test
public void shouldGetDate() {
Calendar calendar = Calendar.getInstance();
calendar.set(1999, 12, 31);
assertThat(calendar.get(Calendar.YEAR)).isEqualTo(2000);
assertThat(calendar.get(Calendar.MONTH)).isEqualTo(Calendar.JANUARY);
assertThat(calendar.get(Calendar.DAY_OF_MONTH)).isEqualTo(31);
}

1999년 12월 31일을 지정하려 했으나, 12월의 상수값은 11이므로 직접 숫자 12를 대입하면 2000년 1월 31일로 넘어간다. 숫자 12 대신 11 혹은 Calendar.DECEMBER 상수로 지정해야 1999년 12월 31일이 된다.

13월을 의미하는 12를 넣어도 Calendar.set() 메서드가 오류를 반환하지 않기 때문에 이런 실수를 인지하기 더욱 어렵다. calendar.setLenient(false) 메서드를 호출하면 잘못된 월이 지정된 객체에서 IllegalArgumentException을 던져 준다. 그렇게 지정해도 Calendar.set() 메서드가 호출되는 시점이 아니라, Calendar.get() 메서드가 호출될 때 Exception이 발생한다는 점도 주의해야한다.

참고로 FindBugs에서는 0 ~ 11을 벗어난 월을 지정할 때 경고를 보여 주기도 한다.

33a08b5a6c7868b3c54ffa8d048421f9.png

그림 2 Calendar에 잘못된 월 지정 코드와 FindBugs의 경고

일관성 없는 요일 상수

<예제 13>은 2013년 1월 1일이 수요일임을 확인하는 코드이다.

예제 13 요일 확인하기

@Test
@SuppressWarnings("deprecation")
public void shouldGetDayOfWeek() {
Calendar calendar = Calendar.getInstance();
calendar.set(2014, Calendar.JANUARY, 1);

int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
assertThat(dayOfWeek).isEqualTo(Calendar.WEDNESDAY);
assertThat(dayOfWeek).isEqualTo(4);
Date theDate = calendar.getTime();
assertThat(theDate.getDay()).isEqualTo(3);
}

Calendar.get(Calendar.DAY_OF_WEEK) 함수에서 반환한 요일은 int 값으로, 일요일이 1로 표현된다. 따라서 수요일은 4이고, 보통 Calendar.WEDNESDAY 상수와 비교해서 확인한다. 그런데 calendar.getTime() 메서드로 Date 객체를 얻어와서 Date.getDay() 메서드로 요일을 구하면 일요일은 0, 수요일은 3이 된다. 두 개의 클래스 사이에 요일 지정값에 일관성이 없는 것이다.

Date.getDay() 메서드는 요일을 구하는 메서드로는 이름이 모호하기도 하다. 현재는 사용하지 않는(deprecated) 메서드라서 그나마 다행이다.

Date와 Calendar의 불편한 역할 분담

JDK 1.0 시절에는 Date 클래스가 날짜 연산을 지원하는 유일한 클래스였다. JDK 1.1 이후부터 Calendar 클래스가 포함되면서 날짜간의 연산, 국제화 지원 등은 Calendar 클래스에서 주로 담당하고 Date 클래스의 많은 기능이 사용되하지 않게(deprecated)되었다.

특정 시간대의 날짜를 생성한다거나, 년/월/일 같은 날짜 단위의 계산은 Date 클래스만으로는 수행하기 어렵기 때문에 날짜 연산을 위해서 Calendar 객체를 생성하고, 다시 Calendar 객체에서 Date 객체를 생성한다. 최종 결과에는 불필요한 중간 객체를 생성해야 하는 셈인데, 쓰기에도 번거롭고, Calendar 클래스는 생성 비용이 비싼 편이기 때문에 비효율적이기도 하다.[23] 불편함을 덜기 위해 실무에서는 Date의 연산에 Apache commons Lang 라이브러리에 있는 DateUtils 클래스의 plusDays() 메서드나 plusMonth() 메서드 같은 메서드를 주로 활용한다. 그러나 DateUtils 클래스를 쓰더라도 중간 객체로 Calendar를 생성하는 것은 마찬가지다.

날짜와 시간을 모두 저장하는 클래스의 이름이 'Date'라는 점도 다소 아쉽다. Calendar.getTime() 메서드도 Date 타입을 반환하는데 메서드 이름만 봐서는 반환 타입을 예측하기가 힘들다.

오류에 둔감한 시간대 ID지정

<예제 14>는 시간대의 ID를 'Asia/Seoul'대신 'Seoul/Asia'로 잘못 지정한 코드다.

예제 14 잘못 지정한 시간대 ID

@Test
public void shouldSetGmtWhenWrongTimeZoneId(){
TimeZone zone = TimeZone.getTimeZone("Seoul/Asia");
assertThat(zone.getID()).isEqualTo("GMT");
}

그러나 이 코드는 오류가 발생하지 않고, 'GMT'가 ID인 시간대가 지정된 것처럼 테스트를 통과한다. 이런 특성 때문에 찾기 어려운 버그가 생길 수도 있다.

java.util.Date 하위 클래스의 문제

java.util.Date 클래스를 상속한 하위 클래스에도 문제가 많다.

java.sql.Date 클래스는 상위 클래스인 java.util.Date 클래스와 이름이 같다. 이 클래스를 두고 Java 플랫폼 설계자는 클래스 이름을 지으면서 깜빡 존 듯하다는 조롱까지 나왔다.[24] 그리고 이 클래스는 Comparable 인터페이스에 대한 정의를 클래스 선언에서 하지 않았기 때문에 Comparable과 관련된 Generics 선언을 복잡하게 만들었다.[25]

java.sql.TimeStamp 클래스는 java.util.Date 클래스에 나노초(nanosecond) 필드를 더한 클래스이다. 이 클래스는 equals() 선언의 대칭성을 어겼다. Date 타입과 TimeStamp 타입을 섞어 쓰면 a.equals(b)가 true라도 b.equals(a)는 false인 경우가 생길 수 있다.[26]

Java의 개선된 날짜, 시간 API

좋은 API는 오용하기 어려워야 하고, 문서가 없어도 쉽게 사용할 수 있어야 한다.[27] 그러나 Java의 기본 API는 문서를 열심히 보기 전까지는 제대로 사용하기 어렵다.

이런 문제점 때문에 JDK의 날짜, 시간 API를 대체하는 라이브러리가 많이 나와 있다. 대표적으로 다음과 같은 것들이 있다.

Joda-Time

Joda-Time은 기본 JDK를 대체하는 날짜와 시간 API 중 가장 널리 쓰인다. 앞에서 나왔던 <예제 1>, <예제 3>, <예제 5>, <예제 7>, <예제 12>, <예제 13>, <예제 14> 등을 테스트를 통과하는 상태로 Joda-Time으로 옮기면 다음과 같다.

예제 15 Joda-Time으로 날짜 연산

public class JodaTimeTest {

@Test // 예제1, 2: 1일 후 구하기
public void shouldGetAfterOneDay() {
Chronology chrono = GregorianChronology.getInstance();
LocalDate theDay = new LocalDate(1582, 10, 4, chrono);
String pattern = "yyyy.MM.dd";
assertThat(theDay.toString(pattern)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.toString(pattern)).isEqualTo("1582.10.05");
}

@Test // 예제1, 2: 1일 후 구하기.
public void shouldGetAfterOneDayWithGJChronology() {
Chronology chrono = GJChronology.getInstance();
LocalDate theDay = new LocalDate(1582, 10, 4, chrono);
String pattern = "yyyy.MM.dd";
assertThat(theDay.toString(pattern)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.toString(pattern)).isEqualTo("1582.10.15");
}

@Test // 예제3, 4: 1시간 후 구하기
public void shouldGetAfterOneHour() {
DateTimeZone seoul = DateTimeZone.forID("Asia/Seoul");
DateTime theTime = new DateTime(1988,5,7,23,0, seoul);
String pattern = "yyyy.MM.dd HH:mm";
assertThat(theTime.toString(pattern)).isEqualTo("1988.05.07 23:00");
assertThat(seoul.isStandardOffset(theTime.getMillis())).isTrue();

DateTime after1Hour = theTime.plusHours(1);
assertThat(after1Hour.toString(pattern)).isEqualTo("1988.05.08 01:00");
assertThat(seoul.isStandardOffset(after1Hour.getMillis())).isFalse();
}

@Test // 예제 5, 6: 1분 후 구하기
public void shouldGetAfterOneMinute() {
DateTimeZone seoul = DateTimeZone.forID("Asia/Seoul");
DateTime theTime = new DateTime(1961, 8, 9, 23, 59, seoul);
String pattern = "yyyy.MM.dd HH:mm";
assertThat(theTime.toString(pattern)).isEqualTo("1961.08.09 23:59");

DateTime after1Minute = theTime.plusMinutes(1);
assertThat(after1Minute.toString(pattern)).isEqualTo("1961.08.10 00:30");
}


@Test // 예제 7: 2초 후 구하기
public void shouldGetAfterTwoSecond() {
DateTimeZone utc = DateTimeZone.forID("UTC");
DateTime theTime = new DateTime(2012, 6, 30, 23, 59, 59, utc);
String pattern = "yyyy.MM.dd HH:mm:ss";
assertThat(theTime.toString(pattern)).isEqualTo("2012.06.30 23:59:59");

DateTime after2Seconds = theTime.plusSeconds(2);
assertThat(after2Seconds.toString(pattern)).isEqualTo("2012.07.01 00:00:01");
}


@Test // 예제 12: 1999년 12월 31일을 지정하는 코드
public void shouldGetDate() {
LocalDate theDay = new LocalDate(1999, 12, 31);

assertThat(theDay.getYear()).isEqualTo(1999);
assertThat(theDay.getMonthOfYear()).isEqualTo(12);
assertThat(theDay.getDayOfMonth()).isEqualTo(31);
}

@Test (expected=IllegalFieldValueException.class) // 예제 12 : 1999년 12월 31일을 지정하는 코드의 실수
public void shouldNotAcceptWrongMonth() {
new LocalDate(1999, 13, 31);
}

@Test // 예제 13: 요일 확인하기
public void shouldGetDayOfWeek() {
LocalDate theDay = new LocalDate(2014, 1, 1);

int dayOfWeek = theDay.getDayOfWeek();
assertThat(dayOfWeek).isEqualTo(DateTimeConstants.WEDNESDAY);
assertThat(dayOfWeek).isEqualTo(3);
}

@Test(expected=IllegalArgumentException.class) // 예제 14: 잘못 지정한 시간대 ID
public void shouldThrowExceptionWhenWrongTimeZoneId(){
DateTimeZone.forID("Seoul/Asia");
}
}

<예제 15>에서 볼 수 있는 특징은 아래와 같다.

  • LocalDate, DateTime 등으로 지역 시간과 시간대가 지정된 시간을 구분했다. LocalDate와 LocalTime으로 날짜와 시간을 별도의 클래스로 구분할 수도 있다.
  • plusDays, plusMinutes, plusSeconds 등 단위별 날짜 연산 메서드를 LocalDate, DateTime 클래스에서 지원한다. 메서드가 호출된 객체의 상태를 바꾸지 않고 새로운 객체를 반환한다. 불변 객체이다.
  • 월의 int 값과 명칭이 일치한다. 1월은 int 값 1이다.
  • GregorianChronology를 썼을 때는 1582년 10월을 특별하게 취급하지는 않는다. GJChronology를 사용하면 JDK의 GregorianCalendar와 같이 10월 4일 다음 날이 10월 15일로 나온다.
  • 서머타임 기간이면 DateTimeZone.isStandardOffset() 메서드의 반환값이 false이다.
  • 13월 같이 잘못 된 월이 넘어가면 객체 생성 시점에서 IllegalFieldValueException을 던진다.
  • 요일 상수는 일관되게 사용한다.
  • 잘못 된 시간대 ID 지정에는 IllegalArguementException을 던진다.

그밖에 Joda-Time에서는 시간 간격에 대한 개념을 섬세하게 정의하고 Duration, Period, Interval 등으로 역할을 분담한 클래스로 구현했다.

그레고리력과 율리우스력뿐만 아니라 불교, 이슬람교, 콥트교회, 에티오피아의 달력까지도 지원한다. 다양한 달력은 org.joda.time.chrono.BaseChronology 클래스의 하위 클래스로 구현되어 있다.

Joda-Time은 Jar 파일에 별도로 시간대 데이터베이스를 포함하고 있다. 그래서 JDK에서 참조하는 시간대 데이터베이스와는 별도로 정보를 갱신할 수도 있다. Jar의 압축을 해제하면 {root}/src/java/org/joda/time/tz/src 폴더에 시간대 정보 파일이 있다. 이 폴더를 덮어쓰면 특정 애플리케이션만의 시간대 정보를 정의할 수도 있다.

Joda-Time은 다음과 같이 다른 언어로 래핑되거나 포팅되어 있기도 하다.

Spring 프레임워크에서도 Joda-Time을 기본으로 지원한다. Spring-web-mvc 프레임워크는 사용자가 입력한 문자열을 원하는 객체로 변환할 때 Converter라는 인터페이스를 활용하는데, 클래스 패스에 Joda-Time이 포함되어 있으면 이 라이브러리의 객체를 변화하는 Converter 구현체를 자동으로 등록한다.[28]

Hibernate 프레임워크에서도 Joda-Time을 쓸 수 있다. Joda-time-hibernate 모듈(http://www.joda.org/joda-time-hibernate)을 이용하면 데이터베이스에 저장된 TIMESTAMPE 같은 타입을 Date 클래스와 같은 JDK의 기본 클래스대신 Joda-Time의 클래스로 매핑할 수 있다.

JSR-310: 새로운 Java의 날짜 API

2014년에 최종 배포되는 JDK 8에는 JSR-310이라는 표준 명세로 날짜와 시간에 대한 새로운 API가 추가되었다.[29] 앞에서 설명한 Joda-Time에 가장 많은 영향을 받았고, 그 밖에 Time and Money 라이브러리나 ICU 등 여러 오픈소스 라이브러리를 참고했다고 한다.

앞의 <예제 15>를 JDK 8의 ZonedDateTime 등을 이용해서 작성하면 다음과 같다.

예제 16 JSR-310을 이용한 날짜 연산

public class Jsr310Test {
@Test // 예제 1, 2: 1일 후 구하기
public void shouldGetAfterOneDay() {
LocalDate theDay = IsoChronology.INSTANCE.date(1582, 10, 4);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd");
assertThat(theDay.format(formatter)).isEqualTo("1582.10.04");

LocalDate nextDay = theDay.plusDays(1);
assertThat(nextDay.format(formatter)).isEqualTo("1582.10.05");
}

@Test // 예제 3, 4: 1시간 후 구하기
public void shouldGetAfterOneHour() {
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZonedDateTime theTime = ZonedDateTime.of(1988, 5, 7, 23, 0, 0, 0, seoul);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
assertThat(theTime.format(formatter)).isEqualTo("1988.05.07 23:00");
ZoneRules seoulRules = seoul.getRules();
assertThat(seoulRules.isDaylightSavings(Instant.from(theTime))).isFalse();

ZonedDateTime after1Hour = theTime.plusHours(1);
assertThat(after1Hour.format(formatter)).isEqualTo("1988.05.08 01:00");
assertThat(seoulRules.isDaylightSavings(Instant.from(after1Hour))).isTrue();
}

@Test // 예제5, 6: 1분 후 구하기
public void shouldGetAfterOneMinute() {
ZoneId seoul = ZoneId.of("Asia/Seoul");
ZonedDateTime theTime = ZonedDateTime.of(1961, 8, 9, 23, 59, 59, 0, seoul);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm");
assertThat(theTime.format(formatter)).isEqualTo("1961.08.09 23:59");

ZonedDateTime after1Minute = theTime.plusMinutes(1);
assertThat(after1Minute.format(formatter)).isEqualTo("1961.08.10 00:30");
}

@Test // 예제 7: 2초 후 구하기
public void shouldGetAfterTwoSecond() {
ZoneId utc = ZoneId.of("UTC");
ZonedDateTime theTime = ZonedDateTime.of(2012, 6, 30, 23, 59, 59, 0, utc);
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm:ss");
assertThat(theTime.format(formatter)).isEqualTo("2012.06.30 23:59:59");

ZonedDateTime after2Seconds = theTime.plusSeconds(2);
assertThat(after2Seconds.format(formatter)).isEqualTo("2012.07.01 00:00:01");
}


@Test // 예제 12: 1999년 12월 31일을 지정하는 코드
public void shouldGetDate() {
LocalDate theDay = LocalDate.of(1999, 12, 31);

assertThat(theDay.getYear()).isEqualTo(1999);
assertThat(theDay.getMonthValue()).isEqualTo(12);
assertThat(theDay.getDayOfMonth()).isEqualTo(31);
}

@Test(expected=DateTimeException.class) // 예제 12: 1999년 12월 31일을 지정하는 코드의 실수
public void shouldNotAcceptWrongDate() {
LocalDate.of(1999, 13, 31);
}

@Test // 예제 13: 요일 확인하기
public void shouldGetDayOfWeek() {
LocalDate theDay = LocalDate.of(2014, 1, 1);

DayOfWeek dayOfWeek = theDay.getDayOfWeek();
assertThat(dayOfWeek).isEqualTo(DayOfWeek.WEDNESDAY);
}
@Test(expected=ZoneRulesException.class) // 예제 14: 잘못 지정한 시간대 ID
public void shouldThrowExceptionWhenWrongTimeZoneId(){
ZoneId.of("Seoul/Asia");
}
}

'java.time.*' 패키지로 시작하지만, 거의 Joda-Time과 유사한 모습을 보여 준다. 다음과 같은 특징이 있고 Joda-Time에서 개선된 점도 많다.

  • DateTime 클래스대신 ZoneDateTime 클래스가 사용된다. 시간대 정보를 가지고 있는 클래스임을 더욱 명확히 표현하려 한 듯하다.
  • 요일 클래스는 Enum 상수로 제공한다. 잘못 지정하거나 혼동할 여지가 없다.
  • 생성자 대신 of() 메서드 같은 static factory 메서드를 많이 사용한다. DateTimeFormatter.ofPattern(), Instant.from() 등이 그 예이다. static factory 메서드는 가독성 있는 이름을 따로 붙일 수 있고, 생성자와는 달리 한번 생성된 객체를 재활용할 수도 있다.[30]
  • Joda-Time보다 클래스별 역할이 더 세분화되었다. ZoneRules 같은 클래스가 그 예이다.
  • 서머타임 기간이면 TimeZoneRules.isDaylightSavings() 메서드의 반환값이 true이다.
  • 잘못 지정돤 시간대 ID에는 ZoneRulesException을 던진다.
  • 잘못 된 월 지정에는 객체 생성 시점에서 DateTimeException을 던진다.

그 밖에도 여러 장점이 있다. Calendar, Date, Joda-Time의 시간 클래스가 밀리초(millisecond) 단위의 정밀성을 가졌던 반면, JSR-310의 클래스는 나노초까지 다룰 수 있다. 시계의 개념도 도입되어서 현재 시간과 관련된 기능을 테스트할 때도 유용한다. java.time.Clock 클래스의 하위 클래스로 SystemClock, FixedClock 등이 제공된다.

이미 Spring 프레임워크 4.0에서는 JSR-310을 기본으로 지원한다. ZoneDateTime 등의 타입이 Controller의 메서드 파라미터로 선언되면 사용자가 입력한 문자열을 날짜 객체로 변환해 준다. JDK 8과 JSR-310 명세가 논의된 지 오래되어서인지 이 기능은 2012년부터 계획되어 있었다.[31]

JSR-310을 JDK 7에서 쓸 수 있는 백포트 모듈도 존재한다. pom.xml 파일에 다음과 같이 의존성을 추가하면 사용할 수 있다.

예제 17 JSR-310의 백포트 모듈의 의존성 선언

<dependency> 
<groupId>org.threeten</groupId>
<artifactId>threetenbp</artifactId>
<version>0.8.1</version>
</dependency>

패키지가 'java.time.*' 대신 'org.threeten.bp.*'라는 점을 제외하면 대부분의 클래스가 동일하다. 다만 백포트 모듈의 ZonedDateTime.toString(DateTimeFormatter) 메서드가 java.time 패키지에서는 ZonedDateTime.format(DateTimeFormatter)으로 바뀌는 등 미묘한 차이가 존재하기는 한다.

마치며

본문에 나온 예제는 모두 다음 주소에서 전체 소스를 확인할 수 있다.

우리 생활과 밀접하게 연관되어 있으면서도 많은 역사가 반영되어 있기에, 날짜 클래스는 도메인 자체가 쉬운 편은 아니다. 시차나 각국의 제도 변경을 의식해야 하는 국제화 시대에서는 더욱 그렇다. 그런 어려움을 Java의 불편한 API들이 더 가중시키고 있었지만 너무나도 오랫동안 개선되지 않았다.

JSR-310는 지금으로부터 무려 7년 전인 2007년에 처음 제안된 명세였다. 이제서야 최종 공개를 앞두고 있는 것이 아쉽기는 하지만, Java의 다른 명세를 보더라도 이 느린 속도가 아주 놀랍지는 않다. 한번 공개된 API는 영원하고, 특히 날짜에 관해서는 초창기의 시행착오가 너무도 뼈아팠기에 그만큼 신중할 수 밖에 없지 않았을까? 필자도 API 설계를 가끔 고민하는 입장에서 그런 신중함을 누릴 수 있는 상황이 부럽기도 하다.

'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바에서 Map 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05


public class KthLargest {
 
  public static void main(String[] args) {
    int[] x = new int[] { 3, 6, 92, 34, 1, 35, 62, 13, 12, 24, 53 };
    System.out.println(getKthLargest(x, 3));
  }
 
  private static int getKthLargest(int[] x, int k) {
    int low = 0;
    int high = x.length - 1;
 
    while (true) {
      int pivot = (low + high) / 2;
      int newPiv = partition(x, low, high, pivot);
 
      if (newPiv == k) {
        return x[newPiv];
      } else if (newPiv < k) {
        low = newPiv + 1;
      } else {
        high = newPiv - 1;
      }
    }
  }
 
  private static int partition(int[] x, int left, int right, int pivot) {
    int pivValue = x[pivot];
    swap(x, pivot, right);
    int storePos = left;
 
    for (int i = left; i < right; i++) {
      if (x[i] < pivValue) {
        swap(x, i, storePos);
        storePos++;
      }
    }
    swap(x, storePos, right);
    return storePos;
  }
 
  private static void swap(int[] x, int a, int b) {
    int temp = x[a];
    x[a] = x[b];
    x[b] = temp;
  }
 
}


  1. For loop
  2. For loop (Advance)  (앵간하면 이거 쓰자) 
  3. While loop
  4. Iterator loop

              

for (int i = 0; i < list.size(); i++) {

System.out.println(list.get(i));

}

 

for (String temp : list) {

System.out.println(temp);

}

 

 int j = 0;

while (list.size() > j) {

System.out.println(list.get(j));

j++;

}

 

 Iterator<String> iterator = list.iterator();

while (iterator.hasNext()) {

System.out.println(iterator.next());

}

'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바에서 Map 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05

HashMap, TreeMap,LinkedHashMap, Hashtable 등등에 공통사용.


Method #1: 엥간하면 이거 (For Each)  사용 (java5 이상) 


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

for (Map.Entry<Integer, Integer> entry : map.entrySet()) {

          int key   = entry.getKey();

int value =  entry.getValue();

}


For-Each loop 는  NullPointerException 를 던지기때문에 null 체크를 해야한다. 



Method #2:  key, value 둘중하나만 사용하면 이거 사용


Map<Integer, Integer> map = new HashMap<Integer, Integer>();


//iterating over keys only

for (Integer key : map.keySet()) {

    System.out.println("Key = " + key);

}


//iterating over values only

for (Integer value : map.values()) {

    System.out.println("Value = " + value);

}



Method #3:  예전 자바라면 이거 사용 (iterator 를 굳이 사용할 필요없음) 


Using Generics:


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

Iterator<Map.Entry<Integer, Integer>> entries = map.entrySet().iterator();

while (entries.hasNext()) {

    Map.Entry<Integer, Integer> entry = entries.next();

    System.out.println("Key = " + entry.getKey() + ", Value = " + entry.getValue());

}


Without Generics:


Map map = new HashMap();

Iterator entries = map.entrySet().iterator();

while (entries.hasNext()) {

    Map.Entry entry = (Map.Entry) entries.next();

    Integer key = (Integer)entry.getKey();

    Integer value = (Integer)entry.getValue();

    System.out.println("Key = " + key + ", Value = " + value);

}



Method #4: 엥간하면 사용하지 말기를..


Map<Integer, Integer> map = new HashMap<Integer, Integer>();

for (Integer key : map.keySet()) {

    Integer value = map.get(key);

    System.out.println("Key = " + key + ", Value = " + value);

}


'Java' 카테고리의 다른 글

자바 스케쥴링 & 타이머 방법들  (0) 2015.05.14
Java Time/ Date / Calendar example  (0) 2015.05.13
Java Time,Data 클래스의 문제점과 JAVA 8  (0) 2015.05.13
자바 List 순회  (0) 2015.05.12
자바 성능 관련 이슈들 모음  (0) 2015.05.05


배경에 대해서 먼저 말해보면 현재 IoT 관제시스템을 만들고 있는데, 

브라우저-웹서버 - 드라이버 -   중계서버 -  집중화서버  - 컨트롤러 - 디바이스 대략 이런식이다. 

웹서버는 Spring 으로 만들어져있으며, 드라이버 컴포넌트를 임베디드한다. 

프런트엔드는 AngularJS 기반의 자바스크립트 프로그램이다. 


이 시스템은 양방향인데  간단하게, 디바이스에서 넘어오는 데이타도 처리하고 ,

브라우져에서 디바이스로 보내는 데이터도 있다는 의미이다. 


이때 브라우져에서 웹서버로 넘길때에는 Rest API 를 적절히 사용하며 

디바이스에서 넘어오는 데이타를 브라우저에 적용할때, Vert.x 와 Socket.io 를 사용한다.



Node 이 WebSocket 을 쉽게 사용할수있도록 Socket.io 가 있는것처럼 Vert.x 도 WebSocket 을 쉽게 사용할수 있는 래퍼가 있는데 (https://github.com/keesun/mod-socket-io) 이것을 사용하였다.


참고자료 : http://www.slideshare.net/daumdna/devon-2012-b3-verx-socket

 


먼저 서버부분을 보자


- mod-socket-io-131.jar / vertx-core-1.3.1.final.jar / vertx-platform-1.3.1.final.jar 추가.


SwitchService  클래스 


@Service

public class SwitchService {


private static final Logger logger = LoggerFactory.getLogger(SwitchService.class);

@Autowired PushService        pushServer;    // PushService 객체를  연결해준다.


public PushService  getPushService(){

return pushServer;

}



PushService  클래스 


@Component

public class PushService extends DefaultEmbeddableVerticle {    


private static final Logger logger = LoggerFactory.getLogger(PushService .class);

private SocketIOServer io;   // SocketIO 래퍼 



@Override

// 객체가 생성되는 동시에 start 를 호출해서  HttpServer 를 실행한다.

// DefaultEmbeddableVerticle  클래스에서 Vertx 객체를 만들어서 넘겨준다.

public void start(Vertx vertx){


System.out.println("PushService start....!!");

HttpServer server = vertx.createHttpServer();  // HTTPServer 생성

io = new DefaultSocketIOServer(vertx, server); //  Socket.IO 래퍼 생성 

io.sockets().onConnection(new Handler<SocketIOSocket>() { // Connection 이벤트

public void handle(final SocketIOSocket socket) {

System.out.println("PushService.emit('welcome');");

socket.emit("welcome");


}

});

server.listen(19999);

}


public SocketIOServer getIo() {

return io;

}

public void  responseDelay(){

 

io.sockets().emit("exception","Delaying a response..");

}

public void  changeUserMode(){  

 

io.sockets().emit("exception" ,  "Mode is abnormal..");

}

public void  tramsNotConnected(){


logger.info("//     TRAMS is disconnected..              // ");   

io.sockets().emit("exception", "TRAMS is disconnected..");

}

}



SwitchRecvHandler  클래스  ( 사물로 부터 넘어온 데이타를 처리하는 객체 ) 


public class SwitchRecvHandler implements ReceiveHandler {


SwitchService switchService;

public SwitchRecvHandler( SwitchService switchService){

this.switchService = switchService;

}

public void handle(DataInfo di){

switchInfo data = (switchInfo) di;

....


 비지니스 로직 처리 

....


JsonObject json = new JsonObject();

json.putString("SWITCH-ID",data.getSwitchID());

json.putString("STATE", String.valueOf(data.isSwitchState()));

json.putString("TYPE", data.type());  

json.putString("DESCRIPTION", data.getDescription());

 

                 //  emit 함수로 key 가 'update' 인 데이터를  브라우저로 쏴준다.

switchService.getPushService().getIo().sockets().emit("update",json); 

}

}



클라이언트 부분 



monitering.html


<script src="../..//resources/lib/socket.io.js"></script>



monitering.js



var app = angular.module('moniteringApp', ['colorpicker.module', 'ui.bootstrap']);

app.controller('moniteringCtrl',function($scope, $http,$modal){

        

var socket = io.connect("http://localhost:19999");


// key 가 'update' 인 데이터 받아서 처리 
socket.on('update', function(msg){  
  console.dir(msg);
   $scope.$apply(function(){
    ...
 
   });
   
   $scope.reloadUsage();
    var deviceid =  FindPropertyInJson(msg,"DEVICE-ID") ;
    if($scope.currentSelectedNode == deviceid)
        getSelectedSwitchesUsage(deviceid);
   
    getSelectedNodeState(deviceid, false);
});

}


'Vert.x' 카테고리의 다른 글

Vert.x 컴포넌트들의 이해  (0) 2015.05.19
Vert.x 의 Hazelcast 사용이유  (0) 2015.05.19
Vert.x 의 Thread Pool & 이벤트루프 인사이드  (0) 2015.05.19
Vert.x + playframework = playVertX  (0) 2015.05.16
Vert.x 와 Akka 의 차이 ??  (0) 2015.05.16

* 작성되는글은 Holub on Pattern 을 보고 정리한것입니다.


먼저 BNF



statement  ::= INSERT INTO INDENTIFIER [LP idList RP] VALUES LP exprList RP 

| CREATE DATABASE IDENTIFIER

| CREATE TABLE IDENTIFIER LP declarations RP

| DROP TABLE IDENTIFIER

| BEGIN | WORK | TRAN[SACTION]]

| COMMIT | WORK | TRAN[SACTION]]

| ROLLBACK [WORK | TRAN[SACTION]]

| DUMP

| USE DATABASE IDENTIFIER

| UPDATE IDENTIFIER SET IDENTIFIER EQUAL expr WHERE expr

| DELETE FROM IDENTIFIER WHERE expr

| SELECT [INTO identifier idList FROM idList [WHERE expr]


 

idList       ::= IDENTIFIER idList' | STAR

idList'       ::= COMMA IDENTIFIER idList' |  ε


declarations  ::= IDENTIFIER [type] [NOT [NULL]] declarations'

declarations'  ::= COMMA IDENTIFIER [type] declarations' 

  | COMMA PRIMARY KEY LP IDENTIFER RP  |  ε


type        ::= INTEGER [ LP expr RP ]

|  CHAR [ LP expr RP ]

| NUMERIC [ LP expr COMMA expr RP ]

| DATE


exprList     ::= expr exprList'

exprList'    ::= COMMA expr exprList'  |  ε


expr        ::= andExpr expr'

expr'       ::= OR andExpr expr'


andExpr    ::= relationalExpr andExpr'

andExpr'   ::= AND relationalExpr andExpr''


relationalExpr    ::= additiveExpr relationalExpr'

relationalExpr'    ::= RELOP additiveExpr relationalExpr'

|   EQUAL additiveExpr relationalExpr'

|  LIKE    additiveExpr relationalExpr'

|  ε



addtiveExpr     ::=  multiplicativeExpr additiveExpr'

additiveexpr'    ::= ADDITIVE multiplictiveExpr additiveExpr'


multiplicativExpr    ::= term multiplicativeExpr'

multiplicativeExpr   ::= STAR term multiplicativeExpr'

| SLASH term multiplicativeExpr'

|  ε


term             ::=  NOT factor

|     LP expr RP

|     factor


factor            ::= compoundId | STRING | NUMBER | NULL


compoundId     ::= IDENTIFIER compoundId'

compoundId'    ::= DOT IDENTIFIER

|  ε




위의 BNF 를 기준으로  이터레이터패턴을 적용하여 클래스를 만든후에 






SELECT     first, last 

FROM       people, zip 

WHERE     people.last = 'Flintstone' 

AND    people.first = 'Fred' 

OR     people.zip > (94700 + zip.margin) 


이 문장을 파싱(Recursive-descent 법으로) 해서 구문트리(추상문법트리) 를 만들어보면 아래와 같다. 



좀 구체적인 물리적 클래스에 의한 추상 문법 트리는 아래와 같다.




* 위에 그림에서 AND, OR 로 연결된 부분은  LogicalExpression 이다. (RelationalExpression 이 아님 ) 


결국 순서는 BNF 구성 ->  클래스 설계 -> 실제 구문 파싱 -> 추상문법트리 구성 -> 실제 구문 평가 



실제 작동하는 소스를 보면 


전체적 순서는 


else if( in.matchAdvance(SELECT) != null )      // SELECT 로 시작될때

{ List columns = idList();            // 컬럼들을 리스트로 얻어옴.


String into = null;

if( in.matchAdvance(INTO) != null )    // INTO 가 있다면 

into = in.required(IDENTIFIER);


in.required( FROM );                         // FROM 이 있어야한다.

List requestedTableNames = idList(); // 조인될 테이블들 리스트로 얻어옴.


  //파싱후 추상구문트리 완성

Expression where = (in.matchAdvance(WHERE) == null)  ? null : expr();

 

        // 추상구문트리를 이용하여 실제 테이블들을 평가해서 새 테이블을 만듬.

Table result = doSelect(columns, into, requestedTableNames, where );

return result;

}



파싱 


private Expression expr() throws ParseFailure

{ Expression left = andExpr();

while( in.matchAdvance(OR) != null )

left = new LogicalExpression( left, OR, andExpr());

return left;

}


private Expression andExpr() throws ParseFailure

{ Expression left = relationalExpr();

while( in.matchAdvance(AND) != null )

left = new LogicalExpression( left, AND, relationalExpr() );

return left;

}


......



평가


private Table doSelect( List columns, String into,

List requestedTableNames,

final Expression where )

throws ParseFailure

{


Iterator tableNames = requestedTableNames.iterator();


assert tableNames.hasNext() : "No tables to use in select!" ;


// The primary table is the first one listed in the

// FROM clause. The participantsInJoin are the other

// tables listed in the FROM clause. We're passed in the

// table names; use these names to get the actual Table

// objects.


Table primary = (Table) tables.get( (String) tableNames.next() );


List participantsInJoin = new ArrayList();

while( tableNames.hasNext() )

{ String participant = (String) tableNames.next();

participantsInJoin.add( tables.get(participant) );

}


// Now do the select operation. First create a Strategy

// object that picks the correct rows, then pass that

// object through to the primary table's select() method.


Selector selector = (where == null) ? Selector.ALL : //{=Database.selector}

new Selector.Adapter()

{ public boolean approve(Cursor[] tables)

{ try

{

Value result = where.evaluate(tables);


verify( result instanceof BooleanValue,

"WHERE clause must yield boolean result" );

return ((BooleanValue)result).value();

}

catch( ParseFailure e )

{ throw new ThrowableContainer(e);

}

}

};


try

{ Table result = primary.select(selector, columns, participantsInJoin);


// If this is a "SELECT INTO <table>" request, remove the 

// returned table from the UnmodifiableTable wrapper, give

// it a name, and put it into the tables Map.


if( into != null )

{ result = ((UnmodifiableTable)result).extract();

result.rename(into);

tables.put( into, result );

}

return result;

}

catch( ThrowableContainer container )

{ throw (ParseFailure) container.contents();

}

}


'인터프리터' 카테고리의 다른 글

Scala 언어를 이용한 미니언어 만들기  (0) 2015.06.06
Recursive Descent vs Lex/Parse?  (0) 2015.05.19
Recursive Descent Parsing  (0) 2015.05.19


GOF 의 디자인패턴중 하나인 인터프리터 패턴에 대해서 살펴보도록 하자. 


인터프리터 패턴은 모든 종류의 GoF 패턴들 중에 가장 어렵다. 물론 인터프리터 자체를 만드는게 어렵


기때문에 덩달아  어렵다는 이야기이고, 모습 자체는 여느 디자인패턴과 비슷하다. 


원래  디자인패턴이라는건 사실 모양은 다 비슷비슷하다.  그게 쓰여지는 의도에 따라서 패턴의 구분이 


이루어진다. 인터프리터 패턴은 느낌 그대로  어떤 해석기, 즉 "간이언어" 를 만들기 위한 패턴이다.  


예를들어 클라이언트-서버 프로그램을 만들때 ,  프로토콜로 "사람이 자연스럽게 읽을수 있는  문법형태"

  

로 하자!! 라고 정할수있는데, 그때 사용하면  되겠다.  프로토콜 예로는 "Select  * From  Table"  이렇게~



자 이 글에서는 


"program repeat 3 go right end  end"  라는 문장을 해석해서 실행하는 프로그램을 예로 들어 설명해보겠다. 


"자바언어로 배우는 디자인 패턴 입문" 이라는 책을 참고했다. 



일단 저 문장에 대해서 BNF 라고 하는 언어의 문법을 표기할때 사용되는 표기법으로 분석해보자.


<program> ::= program <command list>

<command list> ::= <command>* end

<command> ::= <repeat command> | <primitive command>

<repeat command> ::= repeat <number> <command list>

<primitive command>::= go | right | left 


사실 저 BNF 를 만드는게 가장 어렵다고 볼수있다.


만약 SQL 을 저런식으로 만든다고 상상해보자.


statement  ::= INSERT INTO INDENTIFIER [LP idList RP] VALUES LP exprList RP 

| CREATE DATABASE IDENTIFIER

| CREATE TABLE IDENTIFIER LP declarations RP

| DROP TABLE IDENTIFIER

| BEGIN | WORK | TRAN[SACTION]]

| COMMIT | WORK | TRAN[SACTION]]

| ROLLBACK [WORK | TRAN[SACTION]]

| DUMP

| USE DATABASE IDENTIFIER

| UPDATE IDENTIFIER SET IDENTIFIER EQUAL expr WHERE expr

| DELETE FROM IDENTIFIER WHERE expr

| SELECT [INTO identifier idList FROM idList [WHERE expr]


...

등등 무수히 많은 정의가 추가될것이다.



돌아가서 첫번째 BNF 를  토대로 클래스 다이어그램을 만들어보자. 






위 클래스 다이어그램에 


"program repeat 3 go right end  end" 를 파싱해서 객체트리를 만들어보자. 이 객체트리를 구문트리


라고 한다. 객체트리를 만드는 방법은 문장을 토큰화하여  한 단어씩 분석하면서 진행된다.  처음에 


program 객체를 만들고,  해당 객체가  가지고 있을수있는 CommandListNode 객체를  만들고


CommandListNode 는  CommandNode 객체(들)를 만들고 (이때 Repeat / Primitive 선택) 이런식으로~


Primitive 는 go 라는 명령어를 가지고있게되고,나중에 순회하면서 go 에 해당하는 행동을 하게함.





만들어진 구문 트리 시작 객체의 Execute 함수를 실행하면  구문트리를 순회하게 된다. 


자동차를 저 문장을 가지고 실행하게된다면  앞으로(go) 갔다가 오른쪽(right)으로 도는 행동을 


3번 연속하게된다. 만약 SQL 문으로 저런 구문트리를 만들었다고 가정하면 , 2차배열의 테이블을 


구문트리 객체에 파라미터로  넘기면 , 구문트리를 돌면서 해당 객체의 수행방법에 따라서


해당 테이블에서 특정  데이터만 뽑아낼수있을것이다.


카우치베이스의 아키텍쳐에서는 하둡 HDFS + MongoDB 의 냄새가 난다. 

Name 노드의 역할을 Cluster Manager

Data노드의 역할을 Data Manager 가 하고있다.

Cluster Manager 는 블럭 (카우치베이스에서는 버켓정보) 을 관리하고있고,

DataManager 는 메모리/물리계층의 데이터관리를 하고있다.

HDFS 처럼 클라이언트는 직접 데이터매니저로 접근해서 Query Engine 호출한다.


아래 글은 http://bcho.tistory.com/934  에서 가져온것으로 잘 정리되있다.







Couchbase Server

#6. Couchbase server 구조



 


소개


예전에 메모리 캐쉬 솔루션인 memcached에 디스크 persistence 기능을 추가하여 membase라는 솔루션이 있었는데, 이 제품에 Apache의 카우치디비(CouchDB)를 기반으로 새롭게 만든 솔루션이 카우치베이스 Server 라는 NoSQL 솔루션이다.카우치베이스는 mongoDB나, Riak과 같이 JSON document를 직접 저장할 수 있는 Document DB 형태를 가지며, NoSQL의 분산 이론인 CAP theorem에서 CP (Consistency & Partition tolerance) 의 부분에 해당하여 데이타에 대한 일관성과, 노드간의 네트워크 장애시에도 서비스를 제공할 수 있다. 근래에 들어서 600억원의 투자를 유치하는 등 가치를 인정 받고 있는데, mongoDB나 Cassandra에 가려서 그다지 주목을 받지 못하는 것 같아서, 이번 글을 통해서 소개하고자한다.


특장점

Memcached 기반의 Level 2 캐쉬를 내장하여 빠름

모바일 디바이스와 Sync

데이타 센터간 복제 가능

Indexing, Grouping ,Ordering,Join 가능

확장이 쉬움

Built in 관리 도구 제공

Memcached 프로토콜 지원


스키마가 없는 유연한 저장 구조 (Scheme-less)


이번에는 마지막으로 카우치베이스의 아키텍쳐에 대해서 알아보도록 하자

노드와 클러스터 (Node & Cluster)

노드는 물리적인 서버에서 기동하는 하나의 카우치베이스 인스턴스로, 카우치 베이스는 여러 개의 노드로 이루어진 클러스터로 구성된다


클라이언트 SDK (Client SDK)

프로그래밍 언어별로 카우치베이스에 접근하기 위한 API(SDK)를 제공한다.


vBucket 개념

카우치베이스는 실제데이타와 물리서버간의 맵핑을 vBucket이라는 것을 이용해서 관리한다. 카우치베이스는 키-밸류 스토어이다. 그래서, 각 키가 어디에 저장되어 있는지를 vBucket이라는 단위로 관리 하는데. 키에 대한 해쉬값을 계산한 후에, 각 해쉬값에 따라서 저장되는 vBucket을 맵핑한다음 각 vBucket을 노드에 맵핑한다.

아래는 서버 3대가 있었을 때, vBucket을 맵핑하는 구조에 대한 예제이다.



※ 출처 : http://docs.couchbase.com/couchbase-manual-2.5/cb-admin/#vbuckets

클라이언트 SDK는 이 vBucket와 노드에 대한 맵핑 정보를 클러스터로부터 받아서 관리한다. 즉 키에 대한 물리적인 서버 맵핑 정보를 클라이언트가 SDK를 통해 직접알 수 있기 때문에, 클라이언트가 PROXY등을 거치지 않고 직접 데이터가 저장된 노드로 접근이 가능하다.

CF. mongoDB의 경우 중간에 Proxy를 거쳐서 데이터가 저장된 물리 노드로 접근하게 된다.

만약에 노드가 추가되거나 삭제되었을 때, 물리적으로 데이터가 다른 노드로 다시 분산 배치되고, 새롭게 배치된 데이터에 따라서 vBucket to 노드간의 데이터 맵핑 정보도 업데이트 되는데, 이를 Rebalancing이라고 한다. (Rebalancing에 대한 내용은 뒤에 다시 설명)



http://docs.couchbase.com/couchbase-manual-2.5/images/vbuckets-after.png 


노드의 상세구조

그러면 각 노드는 어떤 형태로 구성이 될까? 아래는 노드의 대략적인 아키텍쳐이다.

카우치베이스의 노드는 아래 그림과 같이 크게 좌측의 Data Manager와 우측의 Cluster Manager로 나뉘어 진다.




Cluster Manager

Cluster Manager는 노드에 대한 상태와 클러스터에 대한 상태, 설정등을 관리하는 부분으로 Erlang/OTP 기반으로 구현되어 있다. 그 상위단에는 Admin Portal을 위한 Web UI가 8091 포트로 제공되고 있고, 같은 포트로 REST API가 함께 제공된다.

카우치베이스는 클라이언트 SDK는 이 8091 포트의 REST API를 통해서, 설정 정보와 앞서 설명한 vBucket 정보를 읽어온다. 여기에는 실제로 데이터에 대한 set/get이나 뷰 쿼리 수행용 포트정보도 포함이 되는데,  아래 Data Manager에서 제공되는 11211 포트나, 8092 포트가 사용된다..

그 외에도 클러스터 노드간의 통신을 위한 4389, 21100 포트등 다수의 포트가 사용되는데, 카우치베이스는 서버-클라이언트, 서버-서버간에 사용하는 포트들이 많기 때문에, 배포 전에 반드시 포트들을 확인하고 방화벽이나 네트워크 설정에 반영해야 한다.

※ http://docs.couchbase.com/couchbase-manual-2.5/cb-admin/#faqs 문서를 보면 배포시 오픈해야 하는 포트들이 설명되어 있다.


Data Manager

Data Manager 부분은 직접 데이터에 접근하는 부분으로 set/get 메서드를 이용하여 데이터를 저장하거나, 뷰에 대한 쿼리를 수행할 때 접근되는 인터페이스이다.

맨 아래단에는 멀티쓰레드 기반의 Persistence 엔진이 있으며, 디스크에 데이터를 저장하거나 읽어드릴때 사용되는 컴포넌트이다. 그 윗단에는 memcached가 있으며, 데이터를 캐슁하는데 사용된다. 또한 이 계층에서 뷰에 대한 쿼리 엔진이 제공된다.

Memcached 위에는 moxi 가 Proxy로 사용된다.


데이터 쓰기와 복제

클라이언트에서 데이터 쓰기가 발생했을 때, 카우치베이스는 어떻게 데이터를 저장할까?

먼저 클라이언트에서 Client SDK를 통해서 쓰기 요청을 하면, Client SDK는 해쉬 알고리즘에 따라데이터의 키 값에 맵핑 되는 vBucket을 찾아내고, 그 vBucket에 맵핑 되는 노드를 찾아서 쓰기 요청을 전달한다.

쓰기 요청은 해당 노드의 Listener로 전달되고, 이 Listener는 들어온 데이터를 로컬의 캐쉬에 쓰고 클러스터의 다른 노드로 복제 요청을 보낸다. 그리고 데이터는 노드의 디스크에 저장된다.




쓰기 과정중에 노드간의 복제가 발생한다.


노드별 메모리 레이아웃

그러면 각 노드별로 메모리 레이아웃은 어떻게 되어 있을까? 카우치베이스의 경우, memcached를 이용하는 만큼 서버의 메모리 공간 계산이 매우 중요하다. 앞서 글들에서도 설명하였지만, 메모리에 대해서 고려할 때, 카우치 베이스는 버킷의 키를 모두 메모리에 로딩해놓고 있다. 최소 메모리 공간은 전체키의 합보다는 최소한 커야 한다.그리고 각 도큐먼트당 60바이트의 메타 정보 저장공간이 필요하다. (키크기 + 60 바이트)*전체레코드수 / 노드수 * 3 (복제본수) 가 노드당 최소 메모리양이다. 최소 메모리란 말 그대로 최소한 돌릴 수 있는 수준을 이야기하는 것인데, 이 경우에는 캐쉬를 전혀 사용하지 못하기 때문에, 이 메모리 용량으로 서버를 운영하면 절대 안된다. (말그대로 아주 최~~소한이다.)


전체 하드웨어 공간에서 OS가 기본적으로 사용하는 용량을 제외 하면, 카우치베이스의 노드가 그 메모리 공간을 활용하는데, 카우치베이스에서는 노드에 할당된 메모리 공간을 버킷별로 다시 할당한다. (버킷을 생성할 때 설정할 수 있음)


리밸런스(Rebalance)

리밸런스 노드가 클러스터에 추가되거나, 장애등의 이유로 삭제되었을 때 데이터를 다시 노드에 분산 배치를 하는 작업이다. 노드간에 데이터 복제가 심하게 일어나기 때문에, 리밸런스는 부하가 적은 시간대에 하도록 권장하고 있다. (관리 콘솔을 보면 리밸런스를 멈추거나 시작할 수 있는 기능이 있다.) 향후에는 리밸런스를 Throttling 하는 기능이 나온다고 하니 기대해볼만하다.

NoSQL의 경우 특정 노드가 장애가 나서 시스템이 장애 나는 케이스보다 보통 노드를 추가/삭제할때 발생하는 이런 리밸런싱에 의해서 부하가 올라가거나 해서 장애가 나오는 케이스가 많기 때문에 특별히 주의를 기울일 필요가 있다.

 

XDCR

XDCR은 데이타 센터간에 카우치베이스 클러스터 데이타 복제를 지원하는 기능이다.

현재 최신 버전은 2.5 버전인데, 2.5 버전에서는 XDCR을 TLS/SSL을 이용해서 복제하기 때문에 자체적으로 보안을 지원한다. 그렇지만 무료 버전인 2.2 (Community Edition)의 경우 TLS/SSL 기반의 복제가 지원되지 않기 때문에, VPN 기반의 네트워크를 터널을 설정하고, VPN 터널을 통해서 XDCR 복제를 하도록 가이드 하고 있다.


Couchbase Gateway & CouchBase Mobile

카우치베이스의 흥미로운 점중의 하나는 모바일 디바이스에 탑재될 수 있는 Couchbase Lite버전을 제공한다는 것이다. iOS,안드로이드 버전을 제공하며, 또는 일반적인 애플리케이션에 사용할 수 있도록 자바버전과 .NET  버전도 제공된다. (모두 Community edition이 제공된다)

이 Couchbase Lite는 Couchbase 서버와 동기화가 가능하다.


다 못한 이야기

개발 관점에서는 GET/SET그리고 뷰 정도로 간편하지만, 운영과 설정에 대해서는 많아도 너무 많다. 대략적인 사용방법 아키텍쳐에 대해서 알아봤는데, 언급하지 못한 부분이 있어서 몇 가지만 언급하고자 한다.

카우치베이스는 다양한 커넥터를 이용하여 다른 솔루션과의 연동을 지원한다.

Elastic Search와 연동을 통하여 데이터에 대한 FTS (Full Text Search : 검색)을 지원할 수 있으며, Hadoop 연동을 통해서 Map & Reduce 기반의 데이터 처리가 가능하다. 또 오픈소스 ETL인 Talend 연동을 통해서 데이터를 다른 시스템으로 연동(복제)가 가능하며, 마지막으로, 데이터 암호화 솔루션인 Gazzang과 연동을 통해서 데이터를 암호화하여 저장할 수 있다.

http://www.couchbase.com/couchbase-server/connectors



'NoSQL' 카테고리의 다른 글

Neo4j - 인덱스 사용하기  (0) 2015.10.30
Neo4j 인사이드 : 파일 스토리지  (0) 2015.10.30
시계열 DB (OpenTSDB , 인플럭스 DB , Graphite ) 정리  (0) 2015.10.22
MongoDB vs Couchbase (2)  (0) 2015.09.03
MongoDB vs Couchbase (1)  (0) 2015.09.03

+ Recent posts