오늘도 어김없이 java와 관련된 이야기를 포스팅 하려한다. 요 근래 자주 java와 관련있는 포스팅만 하는 것 같다. 다른 것들도 해야 되는데.. 아무튼 오늘은 java의 날짜와 관련된 포스팅을 한다.

java의 날짜 관련API(Date, Calendar 기타 등) 설계부터가 잘못 되었고 구현조차 잘못된 클래스로 java의 가장 악명 높은 대표적인 클래스이다. 기존의 날짜 관련 API들은 문서없이 사용하기 쉽지 않고 일관성도 없는데다 알 수 없는 상수들을 남발한다. 한개씩 살펴보자.

이상한 월 상수

@Test
public void constantTest() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, 6, 18);
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
  assertThat(format.format(calendar.getTime())).isEqualTo("2017-06-18");
}

위는 Calendar 클래스를 이용해서 오늘날짜인 2017.6.18일을 지정하였다. 누가 봐도 올바른 코드라고 생각할 듯 하다. 하지만 이상하게도 이 테스트는 통과하지 못한다. 왜나면 월을 0부터 시작했기 때문이다. 아무리 개발자라고 1월을 0으로 표시하는 건 좀 그렇다. 이 코드를 정상 작동 시키려면 아래와 같이 코드를 변경해야 한다.

calendar.set(2017, 5, 18);

그래서 IEDA에서는 경고를 보여준다. Calendar에 있는 상수를 사용하라는 경고인 듯 싶다. 월을 지정할 때는 Calendar의 상수를 가져와서 쓰는 것이 정신건강상 나을 듯하다.

calendar.set(2017, Calendar.JUNE, 18);

실질적으로 Calendar.JUNE 값은 5를 의미한다. 헷갈린다 헷갈려..

일관성 없는 요일 상수

생각하는 사람 나름이겠지만 요일을 상수로 지정하여 개발한다고 치자. 월요일부터 일요일까지의 숫자로 표현한다면 어떤게 가장 이상적일까? 필자와 생각이 다를 수도 있겠지만 필자 같은 경우에는 1을 월요일 7을 일요일로 지정했을 듯 하다. 물론 0을 일요일로 생각할 수도 있겠지만 필자생각엔 그닥 좋은 방법은 아니라고 생각된다.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(7);
}

위의 코드는 요일을 상수로 가져오는 그런 코드이다. 여기서도 마음에 들지 않는 코드가 있다. 바로 Calendar.DAY_OF_WEEK 이 아이다. 저 아이도 7이라는 상수 값이다. 7은 뭘 의미할까? 요일이 7개라 7로 했나. 차라리 Enum 타입이였다면 좋았겠지만 저 클래스는 Enum 타입이 나오기전 아주아주 오래된 클래스라 그럴수 없었던 것으로 보인다.
아무튼 다시 돌아와 위 코드를 테스트를 해보자. 위의 테스트는 성공할까? 다들 알겠지만 위는 테스트를 실패한다. 음 그럼 아주 드물게 일요일을 0 으로 생각할 수도 있으니 0으로 코드를 바꾸어서 테스트를 해보자.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(0);
}

0으로 바꾸어봤지만 또 다시 테스트에 실패한다. 이게 무슨 일인가.. 위 테스트를 성공시키려면 아래와 같이 변경해야 된다.

@Test
public void constantTest1() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
  assertThat(dayOfWeek).isEqualTo(1);
}

위에서 말했다시피 2017년 6월 18일은 일요일인데 요일 상수는 1을 리턴한다. 그래 이것까지 넓고 넓은 우리 개발자들은 이해 할 수 있었다고 치자. 하지만 더 어이 없는건 다음에 있다.

@Test
public void constantTest2() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(2017, Calendar.JUNE, 18);
  assertThat(calendar.getTime().getDay()).isEqualTo(1);
}

위의 코드는 calendar를 Date로 반환하여 다시 요일을 테스트하는 그런 코드이다. 요일에 일요일이 1이라고 했으니 당연히 테스트를 통과 하겠지? 안타깝게 이 테스트는 다시 실패로 돌아간다. 그 이유는 Date의 getDay() 메서드는 우리가 생각했듯이 일요일을 0으로 판단하다. (하지만 필자는 7이 더 나은듯) 이렇게 날짜 관련 API의 일관성이 매우 떨어진다.
참고로 Date 에 getDay() Deprecated 메서드이다.

동등성 (equality)

자바 개발자라면 필독서인 effective java에는 이런 내용이 있다. equals 메서드 규약을 설명하는 내용이다. 5가지 정도 되니 잠깐 살펴보자.

  1. 반사성 : null이 아닌 참조 x가 있을때 x.equals(x)는 true를 반환한다. x.equals(x) = true
  2. 대칭성 : null이 아닌 참조 x와 y가 있을 때 x.equals(y)는 y.equals(x) 가 true일때만 true를 반환한다. x.equals(y) = true , y.equals(x) = true
  3. 추이성 : null이 아닌 참조 x,y,z가 있을 때 x.equals(y)가 true이고 x.equals(z)가 true면 x.equals(z)도 true이다. x.equals(y) = true, y.equals(z) = true, x.equals(z) = true
  4. 일관성 : null이 아닌 참조 x,y가 있을 때 equals 통해 비교되는 정보에 아무 변화가 없다면 호출 결과는 횟수에 상관 없이 항상 같아야 된다. x.equals(y) = true
  5. null 아닌 참조 x에 대하여 x.equals(null)은 항상 false이다. x.equals(null) = false

위의 언급한 5가지가 equals를 구현하는 메서드 규약이다. 일반적으로 상속관계에서 깨지기 쉽다. (그래서 상속이 그리 좋은 것만은 아니다. 상속보다는 조립을..) java의 Date와 Timestamp 클래스가 그 대표적인 예이다.

@Test
public void symmetryTest() {
  long now = System.currentTimeMillis();
  Timestamp timestamp = new Timestamp(now);
  Date date = new Date(now);
  assertThat(date.equals(timestamp)).isTrue();
  assertThat(timestamp.equals(date)).isTrue();
}

Date와 Timestamp 클래스는 상속 관계의 클래스이다. Timestamp 클래스의 상위 클래스가 Date 클래스가 이다. 그래서 비교를 했을경우 date.equals(timestamp)true 라면 timestamp.equals(date)true를 반환해야 한다. 하지만 위의 테스트 코드는 실패로 돌아간다. date.equals(timestamp)true로 리턴하지만 timestamp.equals(date) 경우에는 false를 리턴한다. 대칭성이 맞지 않아 equals 규약을 어긴 꼴이 된 셈이다. 둘다 true 이거나 둘다 false 이어야만 대칭성을 어기지 않는 셈이 되는 것이다.

불변이 아니다. (mutable)

가장 개발이 잘못 이루워진 부분이다. Date와 Calendar 이외에 날짜 관련 API들은 불변이 아니다. 불변이 아니기 때문에 멀티 스레드에 안전하지 못하다. 보통은 다른 언어의 경우에는 날짜와 돈 관련 API들은 모두 불변으로 개발 되어 있다. 하지만 java에서는 그렇지 못하다. effective java를 다시 언급하자면 저자인 Joshua Bloch도 Date 클래스는 불변이였어야 했다고 지적했다. 그래서 만약 필요 하다면 방어적 본사본을 만들라 라고 한다. 아래 코드처럼 말이다. 아래의 Period는 불변의 객체이다. 생성자를 호출할 경우에도 다시 Date를 생성하고 getter를 호출할 경우에도 다시 Date를 생성해서 만든다.

public class Period {
  private final Date start;
  private final Date end;
  public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
  }

  public Date getStart() {
    return new Date(start.getTime());
  }

  public Date getEnd() {
    return new Date(end.getTime());
  }
}

위와 같이 경우에는 좀 더 안전한 클래스가 된다. 가능한 불변의 객체를 만드는 것이 가장 이상적이다.

SimpleDateFormat

java 의 Date 클래스를 특정 포맷에 맞게 변환해주는 아주 괜찮은 API이다. 사용하기도 쉽게 개발자가 원하는 포맷 그대로 변환해 줘서 더할 것도 없다. 하지만 여기도 문제점이 많다.

@Test
public void simpleDateFormatTest()  {
  Date date = new Date();
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
  String now = format.format(date);
  try {
    format.parse(now);
  } catch (ParseException e) {
    e.printStackTrace();
  }
}

일단 첫 번째로 불필요한 checked exception을 사용한다. 굳이 저기에 checked exception 을 던질 필요가 있나 싶다. 그리고 제대로 되지 않는 값을 넘겨도 exception을 던지지 않는다. 그럼 뭐하러 checked exception으로 던지는지 의문이다. checked exception은 자제하는 것이 좋다. 아니 특별한 이유가 아니라면 쓰지 않는게 정신건강상 좋다.

@Test
public void simpleDateFormatTest()  {
  try {
    Date parse = format.parse("2012-18-22 33:11:37");
    System.out.println(parse);
  } catch (ParseException e) {
    e.printStackTrace();
  }
}

위 코드를 보긴엔 이상한 코드이다. 우리 달력에는 18월달이 없으므로 에러를 내뱉는다고 생각할 수도 있다. 그게 ParseException의 목적이 아닌가? 생각되는데 말이다. 하지만 이 코드는 정상 작동하며 실제로는 2013년 6월이 출력된다.

SimpleDateFormat 클래스의 두번째 문제인데 Date 클래스와 동일하다. 멀티 스레드에 안전하지 못하다. 가장 큰 문제가 아닌가 싶다. SimpleDateFormat 문서에는 아래와 같은 주의가 있다.

Date formats are not synchronized.
It is recommended to create separate format instances for each thread.
If multiple threads access a format concurrently, it must be synchronized externally.

Date formats은 동기화되지 않는다. 각각의 스레드별로 인스턴스를 만드는 것이 낫다. 만약 multiple threads가 동시에 접근할 경우에는 외부에서 동기화 해야 한다. 라고 설명한다.

아래의 예를 살펴보자.

private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Test
public void threadNotSafeTest() {
  Runnable r[] = new Runnable[10];
  for (int i = 0; i < r.length; i++) {
    r[i] = () -> {
        String str = "2017-06-18 20:12:22";
        try {
          Date d = format.parse(str);
          String str2 = format.format(d);
        } catch (ParseException e) {
          e.printStackTrace();
        }
    };
    new Thread(r[i]).start();
  }
}

thread 10개를 생성하여 실행하였다. 하지만 이 코드는 아래와 같은 에러를 발생한다.

java.lang.NumberFormatException: multiple points

멀티 스레드에서 안전하지 못하다. 이 경우에는 외부에서 동기화 시켜줘야 한다.

private SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

@Test
public void threadNotSafeTest() {
  Runnable r[] = new Runnable[10];
  for (int i = 0; i < r.length; i++) {
    r[i] = () -> {
      String str = "2017-06-18 20:12:22";
      synchronized (format) {
        try {
          Date d = format.parse(str);
          String str2 = format.format(d);
        } catch (ParseException e) {
          e.printStackTrace();
        }
      }
    };
    new Thread(r[i]).start();
  }
}

그레고리력

그레고리력은 현재 세계적으로 통용되는 양력으로, 1582년에 교황 그레고리오 13세가 율리우스력을 개정하여 이 역법을 시행했기 때문에 그레고리력이라고 부른다. 율리우스력의 1년 길이는 365.25일이므로 천문학의 회귀년 365.2422일보다 0.0078일(11분 14초)이 길어서 128년마다 1일의 편차가 났다. 위키 출처. 자세한 내용은 위키를 참고하길 바란다.

율리우스력에 의해 누적된 오차를 교정하기 위해 10일 정도를 건너 뛰었다. 그래서 1582년 10월 4일 다음일이 1582년 10월 15일이다.

@Test
public void gregorianCalendarTest() {
  Calendar calendar = Calendar.getInstance();
  calendar.set(1582, Calendar.OCTOBER , 4);
  calendar.add(Calendar.DATE, 1);
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd");
  String gregorian = format.format(calendar.getTime());
  assertThat(gregorian).isEqualTo("1582-10-05");
}

위와 같이 1582년 10월 4일 다음날을 계산하는 코드이다. 당연히 성공할 것 처럼 보이지만 테스트는 실패를 한다. 왜냐하면 java의 Date는 그레고리력을 이용해서 계산하기 때문에 1582년 10월 4일 다음날은 1582년 10월 15일이다. 그래서 위의 코드는 아래 코드처럼 변경해야 된다.

assertThat(gregorian).isEqualTo("1582-10-15");

java의 Date 클래스는 이렇게 많은 문제점들이 있다. 이 외에도 timeZone Id를 만들때도 이상한 값을 넣어도 에러를 내뱉지 않고 java.sql의 Date와 java.util의 Date가 클래스명이 같다(java.sql.Date는 java.util.Date를 상속 받고 있다.)는 이유로 이름을 지을때 깜빡 존 듯하다는 조롱까지 나왔다.

이렇게 java의 Date 클래스는 많은 문제가 있다. 되도록이면 사용하지 않는 것을 추천하고 java8에 나온 LocalDateTime이라는 클래스가 위의 모든 문제들을 해결했다고 해도 과언이 아니다. 만약 java8을 쓰지 못한다면 joda-time을 사용하길 권장한다. 물론 joda time 만드신 분이 오라클 주도하에 java8의 LocalDateTime을 만들어서 비슷한 구석이 많다. android 개발자들은 date4j를 사용하길 추천한다. joda-time 보다 가벼워서 android 개발자한테는 joda-time보다는 좀 더 낫지 않을까 생각된다.

이렇게 오늘 java의 Date 클래스의 문제점들을 살펴봤다.