자바에서 날짜와 시간 다루기
오늘은 개발을 하면서 피할 수 없는 날짜와 시간 다루기에 대해서 써볼까 한다. 엄청난 레거시 속에서 일하는 나는 두 가지의 질문을 항상 가지고 있었다.
- java.util.Date를 권장하지 않는다고 하던데, 그러면 도대체 뭘 써야 할까?
- 시간은 어떻게 테스트해야 할까?
이번 글은 첫번째 질문에 대한 간단한 대답이 될 것 같다. 테스트하는 방법에 대해서는 현 시점까지 시도해본 방법에 대해서 짤막하게 설명하고 언젠가 쓸 글의 주제로 하려고 한다.
java.util.Date를 쓰지 말라는 이유
Date를 사용하지 말고 다른 라이브러리를 사용하라는 이야기를 많이 들어봤을 것이다. Date는 흔히 잘못 설계된 라이브러리의 대표적인 사례로 언급된다. 실제로 라이브러리 코드를 찾아보면 @Deprecated가 엄청 많이 붙어 있다. Date의 대표적인 실수들을 언급해보자면 다음과 같다. (이 글에서 좀 더 자세한 내용을 알 수 있다.)
- 이름부터 혼란을 준다. 이름만 보면 '날짜'를 다룰 것 같지만, new Date()를 했을 때 얻는 값은 어느 한 시점(instant), 즉 timestamp이다. 이 값은 시간대와 역법(calendar)의 영향을 받지 않는 절대적인 값이다. 그래서 java.time에서는 이런 Date 대신 좀 더 적절한 이름인 Instant라는 클래스가 설계되었다.
- mutable하다. 리턴받은 Date 객체에 대해서 사용자가 setTime을 사용하면 안의 timestamp가 바뀌어버린다. 그래서 Date 객체는 절대로 직접 리턴하지 말고 복사해서 리턴해야 한다.
- 내부적으로 시스템의 시간대를 사용하기 때문에 혼란을 야기한다. timestamp라는 것은 전 세계 어디에 있으나 달라지지 않는 값이지만, Date 객체를 프린트해보면 (내 컴퓨터는 한국 시간대이니까) KST에 맞는 값으로 변환되어 나온다. 실제로는 시간대가 전혀 고려되지 않는 클래스이지만, toString() 등의 메소드의 동작 때문에 개발자가 충분히 착각할 수 있다.
- 자세한 스펙을 모르면 개발자가 무조건 실수하게 되어 있다. 가령 Date에서 year은 1900년에서 시작하고, 1월은 0에서 시작한다. 이런 혼란을 야기하는 메서드들은 deprecate되었다.
레거시 시스템에서는 어쩔 수 없이 오늘날에도 Date를 많이 사용하고 있다. 만약 여전히 Java 7 이하를 사용하고 있다면, Joda time 등의 대체 라이브러리가 권장된다. 참고로 java.util.Date의 문제를 해결(?)하기 위해 나온 java.util.Calendar라는 라이브러리도 있지만, 이 라이브러리는 오히려 사용하기 어려우며 Date의 본질적인 문제도 해결하지 못했다.
Java8부터는 built-in java.time 라이브러리를 사용하세요
만약 Java 8 이상을 사용하고 있다면 마음 편하게 java.time 라이브러리를 사용하면 된다.
- 한 시점을 표현하고 싶다면 Instant를 사용하면 된다.
- 시간대가 고려되지 않은 날짜와 시간을 표현하기 위해서는 LocalDateTime을 사용하면 된다. LocalDateTime의 내부를 보면 날짜(연, 월, 일 단위)를 표현하기 위해 LocalDate를 사용하고 있고, 시간(시, 분, 초 단위)을 표현하기 위해 LocalTime을 사용하고 있다. 용도에 따라 LocalDateTime이 아니라 LocalDate와 LocalTime만을 사용할 수도 있을 것이다. 이들 클래스는 static method를 통해 불변 객체를 리턴하며, 여러가지 편리한 메서드(시간을 빼고 더하거나, parse, format 등)를 제공한다.
- 시간대를 고려한 시간을 표현하려면 ZonedDateTime을 사용하면 된다. 앞서 말한 LocalDateTime를 ZonedDateTime으로 변환하려면 시간대를 명시해줘야 한다. 비유하자면 LocalDateTime는 그저 벽에 걸린 달력과 시계일 뿐이고, 그 안에는 우리가 지구상 어디에 위치해있는지에 대한 정보가 전혀 없기 때문이다. 마찬가지로 ZonedDateTime을 Instant로 바로 변환하는 것은 가능하지만, LocalDateTime에서는 시간대 정보랄 명시해줘야 가능하다.
사실 이들 라이브러리의 처음 사용해봤을 때에는 잘 모르고 사용해서 이것 저것 너무 헷갈렸다. 그런데 Instant, LocalDateTime, ZonedDateTime의 개념을 이해하고 나니 조금은 더 명확해진 것 같다. Instant가 말그대로 한 시점을 의미한다는 것, LocalDateTime에는 시간대의 개념이 없다는 사실만 명심하면 된다. 다음의 코드를 보면 의미가 더욱 분명해질 것이다.
// 현재 한국 시각은 오후 22시
// 2022-03-20T22:02:17.972529
LocalDateTime now1 = LocalDateTime.now();
System.out.println(now1);
// 현재 런던 시각은 13시
// 2022-03-20T13:02:17.973882
LocalDateTime now2 = LocalDateTime.now(ZoneId.of("Europe/London"));
System.out.println(now2);
ZoneOffset offset = ZoneId.of("Asia/Seoul").getRules().getOffset(Instant.now());
// '2022년 3월 20일 22시'에 한국 시간대를 적용하면 UTC 기준 13시
// 2022-03-20T13:02:17.972529Z
System.out.println(now1.toInstant(offset));
// '2022년 3월 20일 13시'에 한국 시간대를 적용하면 UTC 기준 4시
// 2022-03-20T04:02:17.973882Z
System.out.println(now2.toInstant(offset));
// ZonedDateTime은 시간대 정보가 있으므로 바로 toInstant() 사용 가능
// 2022-03-20T13:02:17.972529Z
ZonedDateTime now1WithZone = now1.atZone(ZoneId.of("Asia/Seoul"));
System.out.println(now1WithZone.toInstant());
시간을 제대로 테스트하는 법을 알고 싶어요
시간 라이브러리들은 생성자(Date) 또는 정적 메서드(java.time 라이브러리)를 통해 현재 시각을 리턴한다. 그런데 현재 시각은 그야말로 현재 시각이기 때문에 시시각각 변하는 값이다. 따라서 테스트 프레임워크의 기능을 사용하여 생성자 또는 정적 메서드를 mocking 해줌으로써 일정한 시각을 리턴하도록 해줘야 한다. 그런데 이 방법은 클래스를 조작하는 것이기 때문에 너무 인위적이고, 테스트 프레임워크를 사용하지 않는다면 불가능하다. 만약 내가 작성한 코드에서 이처럼 인위적인 방법이 필요하다면, 혹시 코드의 구조가 잘못 디자인된 것은 아닌지 검토해보라는 조언도 어디에서 읽은 것 같다.
그래서 보통 추천하는 방법이 java.time.Clock 객체를 넣어주는 방법이다. LocalDateTime의 생성 방식을 보면 Clock을 인자로 받는 정적 메서드도 있다. 그래서 메서드를 잘 설계한다면 이처럼 테스트가 가능한 방식으로 코드를 작성할 수도 있을 것이다. 직접 작성해보니 나쁘지 않은 방법인 것 같다.
혹시 이 외에도 더 좋은 방법이 있을지 궁금하다. 시간을 테스트하는 방법에 대한 노하우가 더 쌓이면 또 글을 써 보는 것으로!