Lilyrs

2020년 12월 30일은 몇번째 주인가 feat. Java

Happy new year!

이 블로그를 단순한 새해 인사가 아니라, 2020년 12월 마지막 주를 고통 받게 만든 이슈를 정리함과 함께 시작하게 되어 매우 즐겁다. 사실 12월 31일도 이 문제를 해결하고 어떻게 해결했다고 정리해야할까를 무척 고민하다가 잠들었는데, 오늘은 명쾌하게 정리되어 작성할 수 있게 되었다.

이번 이슈는 보통 사람이 통상적으로 생각하는 한 주(one week)이 해가 넘어가는 달에 컴퓨터에서, 정확히는 Java에서 어떤 문제를 일으킬 수 있는지 살펴봄과 동시에 정확하게 해당 API를 사용해야함을 배울 수 있는 이슈였다.

그리고 막연히 Java에서 문자열을 날짜 타입(LocalDate)으로 파싱할 때 형식 문자로 YYYY가 아니라 yyyy를 써야하는지에 대해 알기 보단, 정확한 개념을 이해하는 것이 중요하기에 이 글로 정리하게 되었다.

더불어 이 문서는 Java 8의 Date/Time API를 백포팅(backporting)한 ThreeTen BP를 사용하는 경우에도 해당되는 내용이므로, 관련 문제를 겪은 분들에게 도움이 되었으면 한다.

본문을 모두 읽기 어려운 경우, 마지막의 결론만 살펴보는 걸로 코드 짜는데는 충분하다.

문제 발생

현재 운영하고 있는 웹 업무 중에는 주 단위로 게시물을 관리하는 서비스가 존재한다. 이 경로에 아무런 조회조건 없이 접속할 경우, 각 사용자 소속 부서에 맞는 한 주 단위 기간의 게시물이 표시되도록 구성되어 있다.

12월 24일까지 별 문제 없이 운영되고 있었는데, 그 다음주 업무 시작일 12월 27일에 갑자기 티켓이 올라왔다.

갑자기 2019년 마지막 주가 표시됩니다. 2020년 12월 27일이 포함된 주로 돌아갈 방법은 없나요?

처음엔 무슨 소리인가 해서 해당 페이지를 함께 열어봤는데, 아뿔싸 진짜 2019년 12월 29일 ~ 2020년 1월 4일 글이 조회되고 있었다. 해당 페이지에는 달력을 사용해 이동할 수 있는 방법이 없었고 주 단위로만 이동이 가능하기 때문에 2020년 12월로 오기 위해선 52번 이상의 클릭이 필요했다.

최초 설계 때 그 누구도 특정 년월로 이동할 계획이 없는 업무 특성상 고려가 안된 부분도 있지만, 설령 그 방법이 있더라도 매번 접속할 때마다 모든 사용자가 달력을 이용해서 다시 조회해야한다는 것이니 티켓 접수 후 원인 파악에 들어갔다.

2020년 1주차가 되는 마법

개발/스테이징 환경에서도 쉽게 재현되어서 어렵지 않게 확인할 수 있었다. 디버거 물리고 날짜를 이동해보며 확인해보니, 2020년 12월 마지막 업무일들이 속한 주가 되면 2020년 1주차로 회귀하는 문제가 생긴다.

해당 게시물을 조회하는 조건으로는 문자열로 표현된 년도와 주차 번호가 하이픈으로 구분된, 즉 2020-52 와 같은 파라미터가 들어온다. 여기에서 모종의 문제로 2020-01 이 입력되었고 시간 여행이 이루어진 것.

디버거를 통해 날짜를 이동시켜보니 내년 1월 3일이 되면 해당 문제가 해소되었다. 이 문제는 해가 넘어가면서 몇번째 주인지 계산하는 과정에서 문제가 생긴 건데, 두 가지 중 하나가 범인이라고 생각하고 추적을 시작하게 되었다.

  1. 년도 계산의 착오로 다음 해로 넘어가지 않았다.
  2. 주차 계산의 착오로 53주차가 아니라 1주차가 표시되었다.

당시의 코드는 아래와 같다. 내부 업무용 코드라 약식으로 변형하여 기재한다. 이 코드에서 yearWeekParam 을 이후에 요리조리 요리해서 게시물을 보여주게 된다.

1
2
3
4
5
6
7
8
// ... omitted ...
if (yearWeekParam == null) {
LocalDate today = LocalDate.now();
WeekFields weekFields = WeekFields.of(Locale.getDefault());

yearWeekParam = String.format("%d-%02d", today.getYear(), today.get(weekFields.weekOfWeekBasedYear()));
}
// ... omitted ...

디버거 물려서 현상만 살펴본다면 앞의 %d 자리에 2020 이 들어갔을 거고 뒤의 %02d 자리에 1 이 들어가서 나온 현상이다. 왜 2020년 12월 30일은 53번째주인데 첫번째주가 되어버린 걸까?

느낌상 이 첫번째 주는 2021년도의 첫번째 주이다. 실제로 분석 결과도 그렇다. 그렇지만 LocalDate가 갖고 있는 2020년이란 정보도 틀린 정보는 아니다. 현재 시각은 분명 2020년 12월 30일이기 때문이다. 그러면 2021년이란 가정은 틀린걸까?

알아야할 것들

그 전에 weekFields.weekOfWeekBasedYear() 가 무엇을 의미하는지를 알아야 한다. 그리고 해가 넘어가는 한 주를 어떻게 계산하는지를 알아야 한다.

weekOfYear vs weekOfWeekBasedYear

이 두 값은 LocalDate 또는 LocalDateTime 객체에게 몇번째 주에 속하는지 물어보기 위한 쿼리 파라미터다. 하지만 이름이 다르니 둘의 기능은 미묘하게 다르다.

이 둘은 이번 해 연말/다음 해 연초만 아니면 쿼리 시에 항상 같은 값을 반환한다. 문제는 이번 해 연말/다음 해 연초에서 다르게 나오는 것 때문에 문제가 생긴다.

weekOfYear의 Javadoc을 보면,

This represents the concept of the count of weeks within the year where weeks start on a fixed day-of-week, such as Monday.

라는 표현이 있고, weekOfWeekBasedYear의 Javadoc에는 비슷한 위치에

This represents the concept of the count of weeks within the year where weeks start on a fixed day-of-week, such as Monday and each week belongs to exactly one year.

라는 표현이 있다. 전자에는 ‘월요일과 같은 주의 시작일을 기준으로 주차를 계산한다’ 표현이 있고 후자에는 비슷한 표현 뒤에 ‘각 한 주는 정확히 한 해에 속한다‘ 라는 표현이 존재한다.

그래서 차이가 있다는 건가 없다는 건가

간단히 정리하면, 당해 12월이 일요일로 끝나지 않고 걸쳐서 한 주가 넘어가는 경우(2020년 12월 28일 ~ 12월 31일과 2021년 1월 1일 ~ 1월 3일처럼)에는 차이가 생긴다. 그 외에는 weekOfYearweekOfWeekBasedYear 를 통해 쿼리한 현재 주차는 항상 동일하다.

이 차이가 생기는건 Week based year 를 다루기 때문에 생긴다.

weekOfYear 는 기본 동작은, Week based year 를 고려하지 않고 칼같이 해당 년도의 주차를 계산한다.

만약 목요일이 반드시 포함되어야 한 주로 인정받는 규칙을 따른다고 가정하면, 2020년 12월 28일부터 12월 31일까지는 2020년의 53주차이며, 2021년 1월 1일부터 1월 3일은 2021년의 버려진 주차(혹은 2021년 0주차)가 되고 2021년 1월 첫 월요일부터 2021년의 1주차가 시작된다.

weekOfYear 방식으로 쿼리할 경우 프랑스에선 실제로 0주차가 발생된다.

weekOfWeekBasedYear 는 무조건 한 주는 7일로 채워서 끊는 방법을 사용하는 방식이다. (보통 사람의 통념과 일치되는 방식) 따라서 그 주를 2020년으로 볼거냐 2021년으로 볼거냐만 결정한다.

만약 목요일이 반드시 포함되어야 한 주로 인정받는 규칙을 따른다고 가정하면, 2020년 12월 28일 ~ 2021년 1월 3일의 목요일은 2020년에 있으므로 따라서 2020년의 53주차로 계산되면서, 자투리 1월 1일 ~ 1월 3일 또한 2020년의 53주차에 편입되어 weekOfYear 에서 나온 기괴한 0주차가 없어진다. 무조건 1주차부터 시작할 수 있게 된다.

weekOfWeekBasedYear 방식으로 쿼리할 경우 각 나라 혹은 표준에 맞게 1주차부터 계산되어 0주차가 없어진다.

그러면 이어서 이 Week based year를 다루는 ISO 표준도 살펴보자. 앞에서 말한 ‘목요일이 포함되어야 한다’는 이 표현은 ISO 표준에서 정의하는 한 주의 기준이다.

ISO-8601

날짜와 시각 데이터를 교환하는 형식에 대한 국제 표준은 ISO-8601에 정의되어있고 한국에서도 KS X ISO 8601 표준을 통해 ISO-8601과 유사하게 데이터 교환 방식에 대해 규정하고 있다.

위키피디아에선 이 표준 중 ISO week date로 따로 떼어, ‘Leap week calendar (매해 같은 요일로 시작할 수 있게 만드는 달력 체계의 종류)’ 체계 중 정부와 산업 표준으로 공고해진 주차 계산 방법에 대해 정리하고 있다. 이 방식은, 2020년의 1주차든 2021년의 1주차든 항상 월요일(혹은 일요일)로 시작할 수 있게 달력을 구성하는 방법을 의미하는 것 같다.

실제로 우리의 삶은 올해만 살고 지구를 터뜨릴게 아니라면 모든 업무는 다음 해로도 연속되어 있으며, 여러 비지니스에서는 주 단위로 업무 진행현황과 일정을 체크한다. 그런 용도로 사용되기에 한 주의 특정 날짜 전후로 년도가 갈릴 때 주차가 나눠져선 안된다.

예를 들어 올해의 경우 2020년 12월 31일까지는 53주차라고 이야기하고 2021년 1월 1일부터 3일까진 2021년 1주차라고 부르자고 하면 사업하는 입장에서 많이 곤란해질 것이다. 통상적으로 그 두 기간을 합쳐 하나의 주로 생각하기 때문이다.

이렇게 계산하기 위해 WeekFields에서 제공하는 쿼리 방식이 weekBasedYearweekOfWeekBasedYear 이다. 실제로 weekOfYear로만 계산하면 위의 문제가 나타날 수도 있다.[1]

특히 이번처럼 황금 연휴가 구성된다면, 2021년 1주차 실적은 어떻게 될지 매우 기대되는 것이다. 그리고 완전하지 않은 주를 1주차로 부른 대가로, 돌아오는 온전한 첫 주를 2주차라고 부르게 되는 우스꽝스러운 상황이 펼쳐진다.

본론으로 돌아와서, 이러한 문제를 해소하고자 ‘그럼 2020년 12월 30일은 몇번째 주입니까? 이 날짜가 속한 주는 몇번째주로 봐야할까요?’라는 의문이 생기게 되고 서로 다른 나라와 서로 다른 사람이 이에 대한 기준을 제시할 수 있다.

이에 대한 한가지 해결 방법이 정부 기관의 일정관리, 산업 표준 등으로 쓰이는 ‘ISO week date’이다.

ISO가 말하는 2021년의 첫번째 주

Week based year 를 다루는 방법 중 하나인 ISO week date의 First week 문단을 보면 첫번째 주를 판단하는 기준에 대해 인용하고 있다.

The ISO 8601 definition for week 01 is the week with the first Thursday of the Gregorian year (i.e. of January) in it. The following definitions based on properties of this week are mutually equivalent, since the ISO week starts with Monday:

그레고리 달력(현재 쓰고 있는 달력)에서 첫 목요일이 나타나는 주를 Week 01, 1주차로 부르겠다고 하는데 현재 달력으로는 당연히 1월의 첫 목요일을 포함한 주가 되겠다. 나머지 표현들은 상호 동치되는 표현이며, ‘1월 4일을 포함하고 있는 주를 첫 주로 본다’ 도 같은 표현이다.

마지막에 언급한 표현이 가능한 건, ISO 정의에서 한 주 달력의 시작은 월요일이기 때문이다. 한국와 미국은 참고로 달력의 시작이 일요일이다.[2] 프랑스를 보면 월요일부터 한 주를 시작한다.

즉, ISO 표준대로 하면 2020년 12월 31일은 목요일이고, 2021년은 첫 목요일을 1월 4일 이내에 가져가지 못했기 때문에 2020년에게 빼앗긴다. 이렇게 계산하면 2020년 12월 30일은 2021년 1주차가 아니라 2020년의 53주차(2021년의 일부분 포함한다)가 된다. 그러면 이 표준에 따라서,

1
2
3
4
5
6
7
8
// ... omitted ...
if (yearWeekParam == null) {
LocalDate today = LocalDate.now();
WeekFields weekFields = WeekFields.of(Locale.getDefault());

yearWeekParam = String.format("%d-%02d", today.getYear(), today.get(weekFields.weekOfWeekBasedYear()));
}
// ... omitted ...

처음 언급한 코드에서 weekFields.weekOfWeekBasedYear()는 주단위로 53이 나와야했을까? 정답은 아니다이다. 그 이유를 Java의 WeekFields는 이러한 정보를 쿼리하기 위해 정의된 규격일 뿐 이것 자체가 ISO 표준은 아니기 때문이다.

Java의 WeekFields 구현 != ISO 표준 주 계산

Java의 WeekFields 는 기본적으로 팩토리(Factory) 형태로 구성되어, Locale 정보를 주고 호출하는 메소드를 통해서만 객체를 받을 수 있는 구현이고, 이를 통해 반환된 객체를 바탕으로 알맞은 주차를 계산한다.

Week based year를 고려해서 몇주차인지 계산해달라고 요청하는 파라미터인 weekOfWeekBasedYear 또한 Java의 추상화된 기능 중 일부일뿐, 이것 자체가 곧바로 ‘ISO 표준에 맞춰 이번 주가 몇번째 주인지 구해줘!’ 를 의미하진 않는다.

ISO 표준에 대해서는 별도로 WeekFields.ISO 이름으로 공개하여 미리 생성해 제공하고 있다.

WeekFields 는 여러 국가와 표준 방식에 따라 달라지는 한 주의 계산, 특히 그 해의 첫번째 주를 판단하는 기준 등을 유연하게 대처할 수 있게 구현해둔 클래스이다. 이 특성은 private 으로 숨겨둔 생성자를 보면 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//-----------------------------------------------------------------------
/**
* Creates an instance of the definition.
*
* @param firstDayOfWeek the first day of the week, not null
* @param minimalDaysInFirstWeek the minimal number of days in the first week, from 1 to 7
* @throws IllegalArgumentException if the minimal days value is invalid
*/
private WeekFields(DayOfWeek firstDayOfWeek, int minimalDaysInFirstWeek) {
Objects.requireNonNull(firstDayOfWeek, "firstDayOfWeek");
if (minimalDaysInFirstWeek < 1 || minimalDaysInFirstWeek > 7) {
throw new IllegalArgumentException("Minimal number of days is invalid");
}
this.firstDayOfWeek = firstDayOfWeek;
this.minimalDays = minimalDaysInFirstWeek;
}

WeekFields를 사용해 LocalDateLocalDateTime에서 원하는 정보를 쿼리하는 과정은 이 정보를 바탕으로 계산된다. 만약 계산에 쓰인 WeekFields 객체가 ISO 표준에 관한 정보를 담고 있지 않다면 weekOfYearweekBasedYearweekOfWeekBasedYear 도 모두 ISO 표준에 따라 계산되지 않고, 별도로 내부에 정의된 기준에 따라 계산된다.

ISO 표준의 경우 다음과 같이 Java 코드로 정의되어있다.

1
public static final WeekFields ISO = WeekFields.of(DayOfWeek.MONDAY, 4);

생성자 시그니처와 비교해보면 이 의미를 간단히 알 수 있다. 한 주의 시작은 월요일이고, 첫번째 주가 되기 위해서는 최소 4일, 다시 말하면 반드시 목요일을 포함해야한다는 뜻이다. (목금토일, 월화수목)

개별 로케일에 대해서는 sun.util.resources 패키지 내부의 CalendarData 클래스를 바탕으로 정의된 CalendarData_(로케일 코드) 클래스를 개별적으로 불러와 WeekFields 객체를 생성한다.

원본이 되는 CalendarData 는 이렇다. (Java 11) 기본 정의는 매주는 일요일에 시작하고, 다음 해의 첫번째 주로 인정받기 위해선 전체 7일에서 다음 해에 1일 이상만 있어도 된다는 것.

1
2
3
4
5
6
7
8
9
10
11
12
package sun.util.resources;

import java.util.ListResourceBundle;

public final class CalendarData extends sun.util.resources.LocaleNamesBundle {
protected final Object[][] getContents() {
return new Object[][] {
{ "firstDayOfWeek", "1" },
{ "minimalDaysInFirstWeek", "1" },
};
}
}

Locale.KOREA를 선택할 때 사용되는 CalendarData_ko 클래스는 이렇다. 참고로 미국과 동일하니 CalendarData_en 은 열어보지 않아도 좋다. (Java 11) 모든 정의가 비어있으므로, CalendarData 기본 정의를 따른다. 일요일에 시작하고, 다음 해의 첫번째 주로 인정 받으려면 전체 7일에서 다음 해에 1일만 걸쳐있어도 된다.

1
2
3
4
5
6
7
8
9
10
package sun.util.resources.ext;

import java.util.ListResourceBundle;

public final class CalendarData_ko extends sun.util.resources.LocaleNamesBundle {
protected final Object[][] getContents() {
return new Object[][] {
};
}
}

Locale.FRANCE를 선택할 때 사용되는 CalendarData_fr 클래스는 이렇다. (Java 11) 프랑스는 정의가 ISO 정의와 비슷하다. 매주는 월요일에 시작하며, 다음 해의 첫번째 주로 인정 받으려면 전체 7일에서 최소 4일 이상 다음 해에 걸쳐있어야 한다.

1
2
3
4
5
6
7
8
9
10
11
12
package sun.util.resources.ext;

import java.util.ListResourceBundle;

public final class CalendarData_fr extends sun.util.resources.LocaleNamesBundle {
protected final Object[][] getContents() {
return new Object[][] {
{ "firstDayOfWeek", "2" },
{ "minimalDaysInFirstWeek", "4" },
};
}
}

2020년 12월 30일이 포함된 주가 2021년 1주차가 된 사연

Java의 기본 구현을 위의 문단에서 확인해보았는데, 한국만 봤을 땐 ISO에서 정의한 표준과는 거리가 멀다. 매주 시작은 일요일에 하며, 한 주의 7일 중 하루만 다음 해로 넘어가 있어도 다음 해의 첫번째 주로 인정 받는다.

이 부분을 다시 소리내서 읽어보고 달력을 살펴보자.

매주 시작은 일요일에 하며, 한 주의 7일 중 하루만 다음 해로 넘어가 있어도 다음 해의 첫번째 주로 인정 받는다.

2020년 12월 30일이 포함된 한 주는 2020년 12월 27일 일요일에 시작되어 2021년 1월 2일 토요일에 끝난다. 무려 2일이나 2021년에 속해있으므로, Week based year로 계산해볼때 weekOfWeekBasedYear는 1주차가 맞다.

직접 실행해보자

코드는 아래와 같다. Java 11에서만 돌려보고 나머지는 확인을 못했다. 적어도 8 밑에선 Time API를 모두 ThreeTen BP로 대체하는 작업이 필요하고, Stream<T> API 또한 없기 때문에 for each 루프로 풀어내는 작업이 필요할듯 하다. 코드는 자유롭게 사용해도 좋다. (copyleft)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.WeekFields;
import java.util.Locale;
import java.util.stream.Stream;

class Pair<FirstType, SecondType> {
private FirstType first;
private SecondType second;

private Pair() {
first = null;
second = null;
}

private Pair(FirstType first, SecondType second) {
this.first = first;
this.second = second;
}

public static <T, S> Pair make(T first, S second) {
return new Pair(first, second);
}

public FirstType getFirst() {
return first;
}

public SecondType getSecond() {
return second;
}
}

public class WeekNumbering {
public static void main(String[] args) {
// 아래의 날짜를 수정해서 돌린다.
LocalDate today = LocalDate.parse("2021-01-01");

System.out.println(" >> Today : " + today.format(DateTimeFormatter.ISO_LOCAL_DATE));
Stream.<Pair<WeekFields, String>>of(
Pair.make(WeekFields.ISO, "ISO"),
Pair.make(WeekFields.of(Locale.US), "US"),
Pair.make(WeekFields.of(Locale.FRANCE), "FRANCE"),
Pair.make(WeekFields.of(Locale.KOREA), "KOREA")
).sequential().forEach((pair) -> {
System.out.println("==== " + pair.getSecond() + " ====");

WeekFields weekFields = pair.getFirst();

System.out.println("MinimalDaysInFirstWeek = " + weekFields.getMinimalDaysInFirstWeek());
System.out.println("FirstDayOfWeek = " + weekFields.getFirstDayOfWeek());
System.out.println("weekOfYear = " + today.get(weekFields.weekOfYear()));
System.out.println("weekBasedYear = " + today.get(weekFields.weekBasedYear()));
System.out.println("weekOfWeekBasedYear = " + today.get(weekFields.weekOfWeekBasedYear()));
});
}
}

2020-12-31의 경우

ISO와 프랑스는 weekOfYear와 weekOfWeekBasedYear 모두 2020년 53주차로 계산해준다. 그리고 weekOfYear 또한 53주차를 표시하고 있다.

한국와 미국의 경우는 이미 2021년 1주차로 바뀌어버렸다. 위에서 말한 계산 방식에 따라 1주차로 점프한 것이다. 다만 단순히 그 해 달력의 주차를 카운트하는 weekOfYear는 53주차로 표시하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 >> Today : 2020-12-31
==== ISO ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 53
weekBasedYear = 2020
weekOfWeekBasedYear = 53
==== US ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 53
weekBasedYear = 2021
weekOfWeekBasedYear = 1
==== FRANCE ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 53
weekBasedYear = 2020
weekOfWeekBasedYear = 53
==== KOREA ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 53
weekBasedYear = 2021
weekOfWeekBasedYear = 1

2021-01-01의 경우

해를 넘기면 어떻게 될까? ISO와 프랑스의 weekOfYear 항목을 보면 흥미진진하다. 앞에서 말한대로 이 남은 일수는 순수하게 주차만 계산했을 때 어느 년도에도 속하지 못해서 2021년도의 0주차에 편입되었다. 0주차로 처리하지 않으면 그 다음주가 2주차가 되어 계산이 복잡해진다.

하지만 weekBasedYearweekOfWeekBasedYear는 7일 가득 채워 한 주를 계산하는 특성에 맞춰 계산을 완료했기 때문에 무사히 남은 날짜도 2020년 53주차에 편입되었다. 2021년 1월 4일이 되면 당당히 2021년도 1주차를 시작할 수 있을 것이다.

이와 반대로 미국과 한국은 해가 바뀌면서 weekOfYear가 1주차로 바뀌었다. 달력을 볼때 하루라도 다음해에 걸쳐있으면 1주차가 될 수 있다. 이런 주를 partial week 라고 부른다고 한다.

미국과 한국의 weekBasedYearweekOfWeekBasedYear는 여전히 한 주가 바뀌진 않았으므로 같은 주차를 유지하고 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 >> Today : 2021-01-01
==== ISO ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 0
weekBasedYear = 2020
weekOfWeekBasedYear = 53
==== US ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 1
weekBasedYear = 2021
weekOfWeekBasedYear = 1
==== FRANCE ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 0
weekBasedYear = 2020
weekOfWeekBasedYear = 53
==== KOREA ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 1
weekBasedYear = 2021
weekOfWeekBasedYear = 1

2021-01-04의 경우

월요일이다. WeekFields 값의 차이로 인해 엄청난 차이가 발생되었다.

한국과 미국은 달력 상으로 1월의 두번째 줄에 와버렸으므로 달력 상의 주차(weekOfYear)는 2주차가 되었다. 또한 일요일을 넘겼으므로 weekBasedYearweekOfWeekBasedYear 또한 한 주차씩 증가해서 2주차가 되었다.

하지만 ISO와 프랑스 기준으로는 달력 상으로도 Week based year 기준으로도 1주차를 온전히 맞이 했다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
 >> Today : 2021-01-04
==== ISO ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 1
weekBasedYear = 2021
weekOfWeekBasedYear = 1
==== US ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 2
weekBasedYear = 2021
weekOfWeekBasedYear = 2
==== FRANCE ====
MinimalDaysInFirstWeek = 4
FirstDayOfWeek = MONDAY
weekOfYear = 1
weekBasedYear = 2021
weekOfWeekBasedYear = 1
==== KOREA ====
MinimalDaysInFirstWeek = 1
FirstDayOfWeek = SUNDAY
weekOfYear = 2
weekBasedYear = 2021
weekOfWeekBasedYear = 2

덤: Java Time API의 YYYY는 Week based year용

위의 테스트에서 weekBasedYear와 그냥 year (year-of-era)가 연말/연초에 달라질 수 있음을 직접 확인했는데, 이는 LocalDateLocalDateTime 객체로 부터 날짜 문자열을 구성하거나(format) 역으로 문자열로부터 날짜/시각 정보 객체를 구성하는(parse)하는 과정에서도 주의가 필요함을 의미한다.

Java 8이상부터 제공되는 DateTimeFormatter 클래스의 문서를 보면 포매팅과 파싱에 쓰이는 형식 문자열에 대해 정의하고 있는데 대문자 Y 심볼과 소문자 y 심볼 두 개를 살펴보면 다름을 알 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Symbol Meaning Presentation Examples
------ ------- ------------ -------
G era text AD; Anno Domini; A
u year year 2004; 04
y year-of-era year 2004; 04
D day-of-year number 189
M/L month-of-year number/text 7; 07; Jul; July; J
d day-of-month number 10

Q/q quarter-of-year number/text 3; 03; Q3; 3rd quarter
Y week-based-year year 1996; 96
w week-of-week-based-year number 27
W week-of-month number 4
E day-of-week text Tue; Tuesday; T
e/c localized day-of-week number/text 2; 02; Tue; Tuesday; T
F week-of-month number 3

(생략)

이 글을 작성하던 도중에 값이 다르게 나온다고 yyyy (혹은 uuuu)를 써야한다고 적은 블로그 글이 있었는데 정확한 과정을 알지 못한 상태에서 막연히 피하기하만 하면 안되기에 생각나서 덤으로 작성하였다.

결론

Java Date/Time API를 이용할 때 두 가지에 주의가 필요한 것 같다.

  1. 기본 로케일을 가져오기 위한 Locale.getDefault() 사용을 지양한다. 단일 국가 사용자만 고려한다면 그 지역 로케일을, 여러 국가를 지원해야한다면 상황에 맞게 로케일을 사용한다.
  2. Date/Time API로 질의할 때 년도와 주차를 동시에 알아내야한다면, 일관성 있는 쿼리 방식을 선택한다.

이번 문제에 있어서 LocalDateWeekFields 사용시 주의점을 정리하면,

  1. weekFields.weekOfYear() (주) 와 localDate.getYear() (년)는 세트다. 우리가 생각하는 연도와 달력으로 보는 기준으로 몇번째 주인지 보여준다.
    • Locale 에 따라 0주차가 표시될 수 있다. (예: 2021년 1월 1일의 Locale.FRANCE)
    • 하지만 minimalDaysInFirstWeek1 로 지정해버린 로케일의 CalendarData를 활용한 계산에서는 0주차를 볼 일이 없을 것이다. 이런 이유로 0주차를 없애서 달력과 비슷하게 몇번째 주인지 계산되도록 하기 위해 1로 설정한거라 추측 중.
  2. weekFields.weekBasedYear() (주)와 weekFields.weekOfWeekBasedYear() (년)는 세트다. 비지니스 관점에서 보는 주차(Week order)는 이것이다.
    • Locale 에 따라 2020년 12월 30일은 2021년 1주차(Locale.US, Locale.KO)로 계산되거나 2020년의 53주차(Locale.FRANCEWeekFields.ISO 등)로 계산될 수 있다.
    • 0주차는 나오지 않는다. 한 해의 끝은 52주차 혹은 53주차, 시작은 반드시 1주차.
  3. (중요) weekFields.weekBasedYear()weekFields.weekOfWeekBasedYear()각 나라별로 달라지는 Week based year에 대한 기준을 표현하기 위해 추상화 해둔 구조이며, 이것을 사용하는 것 자체가 곧 ISO 표준 Week based year로의 계산을 의미하진 않는다. 구체적 구현은 Locale에 따라 달라진다.
    • 해당 Javadoc의 설명은 예시 때문에 큰 혼란을 주고 있다고 생각한다. 상황별로 바뀌는 내용에 대해선 간접적으로 설명하고 있기에, 동작 원리를 잘 알고 있지 않으면 ‘the last week of the previous year’에서 통상적으로 갖고 있는 개념으로 혼동하기 때문이다.

      Week one(1) is the week starting on the {@link WeekFields#getFirstDayOfWeek} where there are at least {@link WeekFields#getMinimalDaysInFirstWeek()} days in the year.

문제 해결

Week based year 로 주차를 계산하기로 했으면 년도도 맞춰서 구하는게 답이다. today에서 바로 getYear() 메소드를 호출하는게 아니라, weekOfWeekBasedYear 가져오듯 계산된 년도를 가져오도록 수정했다. 실질적으로 코드 수정은 한 줄만 이루어진 셈.

1
2
3
4
5
6
7
8
// ... omitted ...
if (yearWeekParam == null) {
LocalDate today = LocalDate.now();
WeekFields weekFields = WeekFields.of(Locale.getDefault());

yearWeekParam = String.format("%d-%02d", today.get(weekFields.weekBasedYear()), today.get(weekFields.weekOfWeekBasedYear()));
}
// ... omitted ...

근데 고치고보니 한 주가 끝나버려서 별 소용 없게 되었다.

이 글을 마치며

글 쓰는데 4시간을 넘겨버렸다. 이 글의 작성시간이라고 표시되는 시간이 hexo new 를 실행한 시간이기 때문에 새벽 2시가 되어가니 4시간 넘어가는 것 같다.

실질적으로 글 쓰기 위해 체계적으로 정리하는 시간까지 생각하면 72시간 이상 사용한 것 같은데, 방이 추워서 손이 너무 시리다. 중간중간 손난로로 녹여가며 작성했더니 새벽 2시다.

이런 글을 작성하며 오개념이나 혼란을 줄 수 있는 개념들이 있어, 그런 부분을 줄이고자 고민하다보니 시간이 많이 걸렸는데 나도 잘못 이해하고 있는 부분들이 있을 수 있고 설명에 좀 더 쉬운 용어를 쓸 수 있음에도 좀 더 고민하고 쓰지 못한 부분들이 있다. 많은 양해 부탁드린다.

날짜와 시간 처리는 인간의 삶의 요소 중 컴퓨터로 옮기기 어려운 문제 중 하나라고 생각한다. 이렇게 연말과 연초 사이에 껴있는 한 주를 어느 해의 한 주로 볼 것인지 결정하는 문제도 있지만 윤년, Java 기반 서비스를 멈추게 만든 윤초, 매번 관찰해서 보정해야하는 음력, 일광 절약 시간, 지역별로 존재하는 시차, 그리고 우주로 나아가면 인공위성과 지구 상의 시간 차이 보정을 위한 노력 등.

해당 문제는 결국 간단한 코드로 해결되었고, 어쩌면 감으로 두들겨 맞춰서 대충 year 들어간 놈 넣어서 해결했으면 더 빨리 해결하고 낮잠 잘 수 있는 그런 문제였을 수도 있다. 하지만 근본부터 자료를 찾아서 접근하는 방법은 정말 쉽지 않았다. 그렇기 때문에 실제 업무 환경에는 임시 처리 코드로 문제를 우회해둔 상태에서 작업을 했다. 무사히 해 넘기기 전에 해결해서 다행이었다.

이런 문제는 연말/연초를 지나면 더이상 생기지 않는 문제이기 때문에 가능한 이번주를 넘기고 싶지 않았다. 그리고 해내서 기쁘다. 그리고 언제나 이런 막대한 노력이 들어간 문제는 간단한 코드로 수정이 끝나는게 허무하다.

어쨌든 즐겁게 한 해를 시작할 수 있게 되었다. 2021년도 많은 것들을 배우고 일하며 부지런히 뛰어다닐 수 있기를.


  1. 1.'나타날 수도 있다' 라고 표현한 이유는, Java의 CalendarData 구성과 WeekFields.ISO 의 정의에 따라 동작이 달라지기 때문이다. 후에 추가로 설명한다.
  2. 2.혼란을 줄이기 위해 '달력'을 언급했는데, 한국도 실질적으로 한 주의 시작은 '월요일'이기 때문이다. 후에 Java의 CalendarData 를 언급하면서도 큰 혼란을 줄 수 있어 주석을 추가한다.