본문 바로가기

날짜 API로 게시글 예약 일정 만들기

예약 공개 시각을 다른 나라 시간으로 옮길 때 생기는 버그를 고치고, 반복 일정·마감일·월 경계를 계산해 한 달 치 공개 달력을 출력하며 java.time을 종합합니다.

시간 API를 쓰다가 생기는 버그는 대부분 메서드 이름을 몰라서가 아니라 질문을 잘못 분류해서 생깁니다. '3월 10일'은 달력의 날짜이고, '9시 정각'은 벽시계의 시각이며, '서울에서 9시 정각이 되는 순간'은 전 세계 어디서나 같은 하나의 순간입니다. 같은 '9시'라도 셋 가운데 무엇을 묻는지에 따라 알맞은 타입과 계산이 달라집니다.

이 글에서는 게시판의 예약 공개 기능을 만들면서 그동안 배운 시간 API를 한데 모읍니다. 먼저 예약 시각을 다른 나라 관리자에게 알릴 때 생기는 버그를 고치고, 반복 일정과 마감일까지 남은 날을 계산한 뒤, 마지막에 한 달 치 공개 일정을 달력 모양으로 출력합니다. 타입별로 담는 정보는 LocalDateTime·ZonedDateTime·Instant의 차이에서, 기간 계산은 Period와 Duration으로 기간 계산하기에서, 날짜 조정과 포매터는 앞 글에서 다뤘습니다.

서울 9시를 런던에 그대로 알리면

서울 운영자가 새해 공지를 2025년 1월 1일 오전 9시에 공개하도록 예약했습니다. 이 시각을 런던에 있는 관리자 화면에도 보여 주려고, 시간대 정보가 없는 LocalDateTime 값을 그대로 넘겼습니다.

lab/LocalPublishZoneBug.java
import java.time.LocalDateTime;

public final class LocalPublishZoneBug {
    public static void main(String[] args) {
        LocalDateTime seoulMeeting = LocalDateTime.of(2025, 1, 1, 9, 0);
        LocalDateTime londonDisplay = seoulMeeting;

        System.out.println("seoul=" + seoulMeeting);
        System.out.println("london=" + londonDisplay);
    }
}
잘못된 결과
seoul=2025-01-01T09:00
london=2025-01-01T09:00

런던 관리자 화면에도 9시가 표시됐습니다. 하지만 1월의 런던은 서울보다 9시간 늦으므로, 서울에서 9시가 되는 순간 런던은 1월 1일 0시입니다. 런던 관리자는 실제보다 9시간 늦게 공개된다고 알게 되는 셈입니다.

코드에 오타가 있는 것은 아닙니다. LocalDateTime은 '2025-01-01 09:00'이라는 벽시계 값만 담고 어느 지역의 9시인지는 담지 않으므로, 이 값만으로는 다른 지역의 몇 시인지 계산할 방법이 없습니다. 날짜를 보여 주는 형식을 바꾼다고 해결되는 문제가 아니라, 계산에 필요한 정보가 처음부터 빠져 있는 문제입니다. 따라서 고치려면 먼저 이 값이 서울 시각이라는 정보를 붙여 실제 순간을 정하고, 그 순간을 런던 시각으로 바꿔야 합니다.

고친 코드의 핵심
ZonedDateTime seoul = seoulMeeting.atZone(ZoneId.of("Asia/Seoul"));
ZonedDateTime london = seoul.withZoneSameInstant(ZoneId.of("Europe/London"));

atZone은 벽시계 값에 서울이라는 시간대를 붙여 ZonedDateTime을 만듭니다. 이제 이 값은 '서울에서 9시가 되는 바로 그 순간'을 가리킵니다. withZoneSameInstant는 순간은 그대로 두고 같은 순간을 런던의 날짜와 시각으로 나타냅니다. london.toLocalDateTime()을 출력하면 2025-01-01T00:00이 나옵니다. 여러 도시에서 같은 순간이 어떻게 보이는지는 뒤의 '한 순간을 여러 나라 시간으로 보여 주기'에서 실행해 봅니다.

반복 일정 만들기

이번에는 일정을 계산해 보겠습니다. 다음 코드는 시작일에서 1년 2개월 3일 뒤의 날짜를 구하고, 2주 간격으로 다섯 번 반복되는 공개일을 만듭니다.

src/RepeatedBoardDates.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public final class RepeatedBoardDates {
    public static void main(String[] args) {
        LocalDate start = LocalDate.of(2025, 1, 1);
        LocalDate afterYearMonthsDays = start.plusYears(1).plusMonths(2).plusDays(3);

        System.out.println("combined=" + afterYearMonthsDays);
        for (int index = 0; index < 5; index++) {
            LocalDate date = start.plus(2L * index, ChronoUnit.WEEKS);
            System.out.println((index + 1) + "=" + date);
        }
    }
}
combined=2026-03-04
1=2025-01-01
2=2025-01-15
3=2025-01-29
4=2025-02-12
5=2025-02-26

첫 줄은 2025년 1월 1일에서 1년, 2개월, 3일을 차례로 더한 결과입니다. 2026년 1월 1일, 2026년 3월 1일을 거쳐 3월 4일이 됐습니다. 반복문은 index번째 공개일을 직전 공개일이 아니라 시작일에서 2 * index주 뒤로 계산합니다. 2L처럼 long 리터럴을 쓰면 곱셈이 long으로 계산되므로, 반복 횟수가 아주 커져도 int 범위를 넘치지 않습니다. plus의 첫 번째 인수도 long 타입입니다. '2주'라는 간격이 ChronoUnit.WEEKS로 코드에 그대로 드러난다는 점도 장점입니다.

이 예제의 날짜에서는 문제가 없지만, 연·월·일을 하나씩 차례로 더하면 월말에서 더하는 순서가 결과를 바꿀 수 있습니다. 2025년 1월 30일에 한 달을 먼저 더하면 2월 28일이 되고, 거기에 하루를 더하면 3월 1일입니다. 하루를 먼저 더하면 1월 31일이 되고, 거기에 한 달을 더하면 2월 28일입니다. 앞 글에서 본 대로 월을 더해 없는 날짜가 되면 그달의 마지막 날로 맞춰지기 때문입니다.

이런 차이가 생기는 이유는 중간 결과가 월말에 맞춰지는 순간 원래 날짜의 정보가 사라지기 때문입니다. 업무에서 '1년 2개월 3일'이 하나의 기간이라면 Period.of(1, 2, 3)으로 묶어 한 번에 더하는 편이 의도를 잘 드러냅니다. LocalDate에 Period를 더하면 연과 월을 먼저 함께 더하고 일을 나중에 더하므로, 순서를 고민할 필요도 없습니다. 예를 들어 2024년 2월 29일에서 plusYears(1).plusMonths(2)를 하면 첫 단계에서 2025년 2월 28일로 맞춰진 탓에 4월 28일이 되지만, Period.of(1, 2, 0)을 더하면 14개월을 한 번에 더해 4월 29일이 됩니다.

마감일까지 남은 날과 그달의 첫날·마지막 날

공지 아래에 '마감까지 22일'처럼 남은 날을 보여 주고, 공개 달력을 그리기 위해 그달의 첫날과 마지막 날이 무슨 요일인지도 알아야 한다고 해 보겠습니다. 다음 코드는 앞부분에서 오늘부터 마감일까지 남은 일수를 세고, 뒷부분에서 2025년 3월의 첫날과 마지막 날을 구해 요일과 함께 출력합니다. 출력의 첫 줄이 앞부분의 결과이고, 나머지 두 줄이 뒷부분의 결과입니다.

src/BoardDeadlineAndMonth.java
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;

public final class BoardDeadlineAndMonth {
    public static void main(String[] args) {
        LocalDate today = LocalDate.of(2025, 3, 10);
        LocalDate deadline = LocalDate.of(2025, 4, 1);
        long days = ChronoUnit.DAYS.between(today, deadline);

        YearMonth month = YearMonth.of(2025, 3);
        LocalDate first = month.atDay(1);
        LocalDate last = month.atEndOfMonth();

        System.out.println("d-day=" + days);
        System.out.println("first=" + first + ":" + first.getDayOfWeek());
        System.out.println("last=" + last + ":" + last.getDayOfWeek());
    }
}
d-day=22
first=2025-03-01:SATURDAY
last=2025-03-31:MONDAY

ChronoUnit.DAYS.between(today, deadline)은 3월 10일부터 4월 1일까지 날짜를 몇 번 넘기는지 세므로 22가 나옵니다. 시작일은 세고 끝 날짜는 세지 않는 방식이라서, 3월 10일과 4월 1일을 모두 포함한 일수를 원한다면 1을 더해 23으로 계산해야 합니다. 'D-22'인지, 이벤트 기간이 '23일간'인지는 요구 사항마다 다르므로 포함 규칙을 먼저 정하고 코드에 드러내야 합니다.

YearMonth는 '2025년 3월'처럼 연과 월만 있는 값을 나타냅니다. 월간 달력이나 월별 통계처럼 일이 필요 없는 개념을 LocalDate로 다루면, 아무 의미 없는 일 칸에 1 같은 값을 억지로 채워 넣어야 합니다. YearMonth의 atDay(1)은 그달의 1일을, atEndOfMonth()는 그달의 마지막 날을 LocalDate로 돌려줍니다. lengthOfMonth()는 그달의 일수를 돌려주므로 2025년 2월은 28, 2024년 2월은 29입니다. 달마다 다른 길이와 윤년을 직접 계산하지 않아도 됩니다.

오늘 날짜는 Clock으로 받는다

위 예제는 오늘 날짜를 LocalDate.of(2025, 3, 10)으로 고정했습니다. 실제 서비스라면 LocalDate.now()로 오늘을 구하겠지만, 그러면 실행하는 날마다 결과가 달라져서 같은 상황을 다시 확인할 수 없습니다. 게다가 now()는 프로그램이 실행되는 컴퓨터의 기본 시간대를 쓰므로, 같은 순간에도 서버가 어디에 있느냐에 따라 '오늘'이 다를 수 있습니다.

java.time.Clock은 이 두 문제를 함께 풉니다. Clock은 현재 순간과 시간대를 알려 주는 객체이고, LocalDate.now(clock)처럼 넘겨서 쓸 수 있습니다. Clock.fixed로 만든 시계는 언제 실행해도 같은 순간을 가리킵니다.

src/DdayWithClock.java
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.temporal.ChronoUnit;

public final class DdayWithClock {
    static long daysUntil(LocalDate deadline, Clock clock) {
        LocalDate today = LocalDate.now(clock);
        return ChronoUnit.DAYS.between(today, deadline);
    }

    public static void main(String[] args) {
        LocalDate deadline = LocalDate.of(2025, 4, 1);
        Instant moment = Instant.parse("2025-03-09T20:00:00Z");

        Clock seoul = Clock.fixed(moment, ZoneId.of("Asia/Seoul"));
        Clock london = Clock.fixed(moment, ZoneId.of("Europe/London"));

        System.out.println("seoul-today=" + LocalDate.now(seoul) + " d-day=" + daysUntil(deadline, seoul));
        System.out.println("london-today=" + LocalDate.now(london) + " d-day=" + daysUntil(deadline, london));
    }
}
seoul-today=2025-03-10 d-day=22
london-today=2025-03-09 d-day=23

두 시계는 같은 순간 2025-03-09T20:00:00Z, 즉 UTC로 3월 9일 20시를 가리킵니다. 그 순간 서울은 3월 10일 새벽 5시이고 런던은 아직 3월 9일 저녁 8시이므로, '오늘'이 서로 달라 남은 날도 22일과 23일로 갈립니다. daysUntil은 오늘을 스스로 구하지 않고 바깥에서 Clock을 받기 때문에, 고정된 시계를 넘기면 언제든 같은 결과를 다시 얻을 수 있습니다. 실제 서비스에서는 사용자의 시간대를 담은 Clock.system(zone)을 넘기면 됩니다.

한 순간을 여러 나라 시간으로 보여 주기

이제 첫 절의 버그를 제대로 고친 프로그램을 실행해 보겠습니다. 예약 시각을 서울 시간대의 ZonedDateTime으로 만들고, 같은 순간을 런던과 뉴욕 시간으로 바꿔 출력합니다.

src/InternationalPublishTime.java
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

public final class InternationalPublishTime {
    public static void main(String[] args) {
        ZonedDateTime seoul = ZonedDateTime.of(
                2025, 1, 1, 9, 0, 0, 0, ZoneId.of("Asia/Seoul"));
        DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm VV");

        print("seoul", seoul, format);
        print("london", seoul.withZoneSameInstant(ZoneId.of("Europe/London")), format);
        print("new-york", seoul.withZoneSameInstant(ZoneId.of("America/New_York")), format);
    }

    private static void print(String label, ZonedDateTime value, DateTimeFormatter format) {
        System.out.println(label + "=" + value.format(format));
    }
}
seoul=2025-01-01 09:00 Asia/Seoul
london=2025-01-01 00:00 Europe/London
new-york=2024-12-31 19:00 America/New_York

패턴의 VV는 Asia/Seoul 같은 시간대 ID를 출력하는 문자이며, 반드시 두 글자로 써야 합니다. 세 줄은 모두 UTC로 2025년 1월 1일 0시라는 같은 순간입니다. 런던은 1월에 UTC와 같은 시각을 쓰므로 0시이고, 뉴욕은 5시간 늦으므로 날짜까지 바뀌어 전날인 2024년 12월 31일 19시가 됩니다. 시각만 바꿔 계산하면 날짜와 연도가 바뀌는 것을 놓치기 쉽다는 점에서 눈여겨볼 결과입니다.

지역 없는 복사에는 순간 변환의 근거가 없다

LocalPublishZoneBug의 값 복사와 InternationalPublishTime의 순간 보존 변환을 원본 결과와 정보 유무로 비교한다.

지역 변환에는 순간을 정할 지역 정보가 필요합니다

두 프로그램의 차이는 출력 포맷이 아니라 지역 규칙으로 순간을 정했는가입니다.

지역 변환에는 순간을 정할 지역 정보가 필요합니다 정상 프로그램의 같은2025-01-01T00Z를 서울동일09/런던동일00/뉴욕전일19로 표시. 지역 없는 복사 반례는 아래 원 비교표에 보존하며 이 변환 그래프와 구별한다. 같은 Instant 2025-01-01 00:00:00Z Asia/Seoul 2025-01-01 09:00 Europe/London 2025-01-01 00:00 America/New_York 2024-12-31 19:00 화살표는 같은 순간의 표시 · 원1월 입력의 결과
지역 변환에는 순간을 정할 지역 정보가 필요합니다 정상 프로그램의 같은2025-01-01T00Z를 서울동일09/런던동일00/뉴욕전일19로 표시. 지역 없는 복사 반례는 아래 원 비교표에 보존하며 이 변환 그래프와 구별한다. 같은 Instant 2025-01-01 00:00:00Z Asia/Seoul 2025-01-01 09:00 Europe/London 2025-01-01 00:00 America/New_York 2024-12-31 19:00 각 화살표는 같은 순간을 해당 지역에 맞춰 표시 지역 간 실행 사슬이 아님
지역 없는 복사에는 순간 변환의 근거가 없다
원본·경로실제 표시보존 정보
LocalPublishZoneBug

londonDisplay = seoulMeeting

서울·런던 모두 · 2025-01-01T09:00두 변수 모두 지역 없는 값. 런던 순간으로 변환하지 않음
InternationalPublishTime · 서울 생성 후 런던 변환
서울 2025-01-01 09:00
런던 2025-01-01 00:00
withZoneSameInstant가 같은 순간을 유지
같은 정상 프로그램 · 뉴욕 변환2024-12-31 19:00 · America/New_York같은 순간인데 현지 날짜는 전년도
LocalPublishZoneBug

londonDisplay = seoulMeeting

실제 표시: 서울·런던 모두 · 2025-01-01T09:00
보존 정보: 두 변수 모두 지역 없는 값. 런던 순간으로 변환하지 않음
InternationalPublishTime · 서울 생성 후 런던 변환
실제 표시:
서울 2025-01-01 09:00
런던 2025-01-01 00:00
보존 정보: withZoneSameInstant가 같은 순간을 유지
같은 정상 프로그램 · 뉴욕 변환
실제 표시: 2024-12-31 19:00 · America/New_York
보존 정보: 같은 순간인데 현지 날짜는 전년도

지역 없는 두 값 자체를 ‘서로 다른 순간’이라고 단정하지 않습니다. 각 값에 실제로 지역을 해석해 붙여야 순간을 비교할 수 있습니다. 위 변환 결과는 2025년 1월 1일 서울 예약에 대한 것입니다.

그림은 첫 절의 잘못된 복사와 이 절의 변환을 비교합니다. 잘못된 복사에서는 두 변수가 모두 시간대 없는 9시일 뿐이라 어떤 순간인지 정해지지 않습니다. 변환에서는 같은 순간 하나가 도시마다 다른 날짜와 시각으로 표시됩니다. 따라서 예약 공개 정보에는 벽시계 값과 함께 시간대 ID를 보관하고, 화면에 보여 줄 때 그 값이 가리키는 순간을 보는 사람의 시간대로 바꿔야 합니다. 예약 시각을 Instant로만 보관하면 공개 전에 그 지역의 시간대 규칙이 바뀌었을 때 운영자가 정한 '서울 9시'라는 뜻을 잃습니다.

시차를 '서울과 런던은 9시간'처럼 상수로 정해 빼는 방법은 피해야 합니다. 런던은 일광 절약 시간을 쓰기 때문에, 같은 예약을 7월 1일 9시로 바꾸면 런던 시각은 0시가 아니라 1시가 됩니다. 이 차이는 연습 문제 2에서 직접 확인합니다.

월간 공개 달력 출력하기

마지막으로 한 달 동안의 공개일을 달력 모양으로 출력해 보겠습니다. 2025년 3월에는 매주 월요일인 3, 10, 17, 24, 31일에 글을 공개하기로 했습니다. 달력을 그리려면 첫 줄을 몇 칸 비우고 시작할지와 그달이 며칠까지 있는지를 알아야 하는데, 둘 다 앞에서 본 YearMonth와 DayOfWeek로 구할 수 있습니다.

src/BoardCalendar.java
import java.time.DayOfWeek;
import java.time.YearMonth;

public final class BoardCalendar {
    public static void main(String[] args) {
        printMonth(YearMonth.of(2025, 3), new int[]{3, 10, 17, 24, 31});
    }

    private static void printMonth(YearMonth month, int[] publishDays) {
        System.out.println(month);
        System.out.println("  Mo  Tu  We  Th  Fr  Sa  Su");

        int offset = month.atDay(1).getDayOfWeek().getValue() - 1;
        System.out.print("    ".repeat(offset));

        for (int day = 1; day <= month.lengthOfMonth(); day++) {
            String mark = isPublishDay(day, publishDays) ? "*" : "";
            System.out.printf(" %3s", mark + day);
            if (month.atDay(day).getDayOfWeek() == DayOfWeek.SUNDAY) {
                System.out.println();
            }
        }
        if (month.atEndOfMonth().getDayOfWeek() != DayOfWeek.SUNDAY) {
            System.out.println();
        }
    }

    private static boolean isPublishDay(int day, int[] publishDays) {
        for (int publishDay : publishDays) {
            if (publishDay == day) {
                return true;
            }
        }
        return false;
    }
}
2025-03
  Mo  Tu  We  Th  Fr  Sa  Su
                       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

달력의 모든 칸은 너비가 4글자입니다. 날짜 칸은 printf(" %3s", …)가 공백 하나와 오른쪽으로 맞춘 3글자를 출력해서 만들고, 공개일이면 숫자 앞에 *를 붙여 *3이나 *10처럼 씁니다. %3s는 문자열이 3글자보다 짧으면 왼쪽을 공백으로 채우므로, 5든 *10이든 칸의 오른쪽 끝이 맞습니다. 머리글의 요일 이름도 Mo처럼 공백 두 개와 두 글자로 4칸을 차지합니다.

첫 줄의 빈칸 수는 1일의 요일에서 나옵니다. getDayOfWeek().getValue()는 월요일이 1, 일요일이 7이므로 1을 빼면 1일 앞에 비워 둘 칸 수가 됩니다. 2025년 3월 1일은 토요일(6)이라 월요일부터 금요일까지 5칸, 즉 공백 20개를 먼저 출력합니다. 그다음 1일부터 lengthOfMonth()인 31일까지 출력하면서 일요일을 출력한 뒤에는 줄을 바꿉니다. 3월 31일은 월요일이라 반복문 안에서 줄이 바뀌지 않으므로, 반복문 뒤의 if가 마지막 줄을 마무리합니다. 그 결과 공개일 표시 *는 모두 월요일 열에 놓입니다.

월 범위와 공개일 데이터를 달력에 배치한다

BoardCalendar의2025년3월 날짜와공개일3·10·17·24·31을 월요일시작 달력에 배치한다. 터미널문자간격을복제한그림이아니다.

2025년 3월 공개일은 매주 월요일입니다

BoardCalendar의 데이터: YearMonth.of(2025, 3), 공개일 {3, 10, 17, 24, 31}. *는 공개일이며, 빈칸은 이 달에 속하지 않습니다.

2025년 3월 공개일은 매주 월요일입니다 2025년3월 월요일 시작 달력. 1일 토요일/2일 일요일,3·10·17·24·31일 월요일 공개. 빈칸은 이 달 밖이다. 날짜와 별표를 다른 줄에 두어 같은 요일 열을 유지한다. 월 화 수 목 금 토 일 — — — — — 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 * — — — — — — *: 공개일 / —: 이 달 밖
2025년 3월 공개일은 매주 월요일입니다 2025년3월 월요일 시작 달력. 1일 토요일/2일 일요일,3·10·17·24·31일 월요일 공개. 빈칸은 이 달 밖이다. 날짜와 별표를 다른 줄에 두어 같은 요일 열을 유지한다. 월 화 수 목 금 토 일 — — — — — 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 * — — — — — — *: 공개일 / —: 이 달 밖
월 범위와 공개일 데이터를 달력에 배치한다
주월화수목금토일
첫째—————12
둘째3*456789
셋째10*111213141516
넷째17*181920212223
다섯째24*252627282930
여섯째31*——————
2025년 3월, 월요일 시작 달력 1일은 토요일이며 마지막 날은31일 월요일이다. 공개일은3일,10일,17일,24일,31일로 모두 월요일이다. 별표는 공개일, 대시는 이 달 밖의 빈칸이다. 월 화 수 목 금 토 일 — — — — — 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* — — — — — —
첫째 주
월 이 달에 속하지 않음 · 화 이 달에 속하지 않음 · 수 이 달에 속하지 않음 · 목 이 달에 속하지 않음 · 금 이 달에 속하지 않음 · 토 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 공개일 · 화 이 달에 속하지 않음 · 수 이 달에 속하지 않음 · 목 이 달에 속하지 않음 · 금 이 달에 속하지 않음 · 토 이 달에 속하지 않음 · 일 이 달에 속하지 않음

atDay(1)은 토요일이고 lengthOfMonth()는 31입니다. 요일별 위치는 원본 데이터의 달력 의미를 보여 주며 CLI의 공백 배치를 그대로 재현한 출력이 아닙니다.

그림은 같은 데이터를 표로 그린 것입니다. 1일과 2일이 토요일과 일요일 칸에 놓이고, 공개일 다섯 개가 모두 월요일 열에 놓인다는 점이 출력과 같습니다. 그림은 터미널의 공백 배치를 그대로 옮긴 것이 아니라 날짜가 어느 요일 칸에 놓이는지를 보여 주는 자료입니다.

이 출력이 열을 맞출 수 있는 것은 영문 요일 이름과 숫자, *가 모두 터미널에서 한 칸 너비이기 때문입니다. 머리글을 월 화 수처럼 한글로 바꾸면 한글 한 글자가 보통 두 칸을 차지해서 열이 어긋납니다. 일요일부터 시작하는 달력이 필요하다면 빈칸 수를 getValue() % 7로 계산하고 토요일에서 줄을 바꾸면 됩니다. 이처럼 화면 표시 방식은 날짜 계산과 따로 설계해야 합니다.

날짜 문제를 푸는 순서

이 장에서 다룬 시간 문제는 대부분 다음 순서로 정리할 수 있습니다.

  1. 입력이 날짜인지, 벽시계 시각인지, 실제 순간인지, 기간인지 먼저 적습니다.
  2. 끝 날짜를 포함하는지, 어느 시간대 기준인지 같은 조건을 확정합니다.
  3. 그 의미를 담을 수 있는 가장 좁은 java.time 타입을 고릅니다. 날짜만 필요하면 LocalDate, 연월만 필요하면 YearMonth처럼 고릅니다.
  4. 직접 계산하지 말고 plus, between, TemporalAdjusters 같은 표준 연산을 씁니다.
  5. 월말, 윤년, 일광 절약 시간, 자정을 넘는 경우를 넣어 실행해 봅니다.
  6. 문자열을 읽고 쓰는 일은 DateTimeFormatter에 맡기고, 계산은 날짜 값으로 합니다.

현재 시각이 필요하면 now()를 곳곳에서 직접 호출하지 말고 Clock을 받아서 쓰세요. 그래야 고정된 시계로 같은 상황을 언제든 다시 재현할 수 있습니다.

정리

  • LocalDateTime에는 시간대가 없으므로 다른 지역의 시각을 계산할 수 없습니다. atZone으로 시간대를 붙여 순간을 정한 뒤 withZoneSameInstant로 바꿉니다.
  • 반복 일정은 직전 일정이 아니라 시작일에서 계산합니다. 하나의 기간이라면 Period로 묶어 한 번에 더해야 월말에서 결과가 흔들리지 않습니다.
  • ChronoUnit.DAYS.between은 끝 날짜를 세지 않습니다. 끝 날짜를 포함하는지는 요구 사항으로 정하고 코드에 드러냅니다.
  • 연과 월만 필요한 값은 YearMonth로 다룹니다. atDay, atEndOfMonth, lengthOfMonth가 달마다 다른 길이와 윤년을 처리합니다.
  • 오늘 날짜는 Clock을 받아 구하면 시간대를 명시할 수 있고, 같은 결과를 언제든 다시 얻을 수 있습니다.
  • 달력 출력의 열 맞춤 같은 화면 표시는 날짜 계산과 따로 다룹니다.

연습 문제

  1. 시작일 2025년 3월 1일에서 10일 간격으로 예약 공개일을 세 번 만들고, 시작일부터 마지막 공개일까지 며칠인지 출력하세요. 시작일 자체는 공개일에 넣지 않고 10일 뒤, 20일 뒤, 30일 뒤를 공개일로 삼습니다. 코드를 쓰기 전에 각 날짜와 총 일수를 먼저 계산해 보세요.
  2. InternationalPublishTime의 예약 시각을 2025년 7월 1일 9시로 바꾸면 세 줄이 어떻게 출력될지 예상한 뒤 실행해 확인하세요. 1월과 결과가 달라지는 이유는 무엇일까요?
  3. BoardCalendar를 고쳐 2024년 2월 달력에 매주 목요일을 공개일로 표시하세요. 공개일 배열을 손으로 적지 말고, TemporalAdjusters.firstInMonth로 첫 목요일을 찾은 뒤 일주일씩 더해 계산합니다.
해설 보기
  1. 공개일은 3월 11일, 21일, 31일이고 총 일수는 30일입니다.
src/PublishScheduleExercise.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;

public final class PublishScheduleExercise {
    public static void main(String[] args) {
        LocalDate start = LocalDate.of(2025, 3, 1);
        LocalDate[] publishDates = new LocalDate[3];
        for (int index = 0; index < publishDates.length; index++) {
            publishDates[index] = start.plusDays(10L * (index + 1));
        }

        System.out.println("publishDates=" + Arrays.toString(publishDates));
        System.out.println("days=" + ChronoUnit.DAYS.between(start, publishDates[2]));
    }
}
publishDates=[2025-03-11, 2025-03-21, 2025-03-31]
days=30

index가 0, 1, 2일 때 시작일에 10일, 20일, 30일을 더했습니다. 3월은 31일까지 있으므로 30일 뒤도 3월 안에 들어갑니다. 이 코드는 간격을 직전 공개일이 아니라 시작일 기준의 배수로 계산했습니다. 요구가 '공개할 때마다 10일 뒤'라고 해도 지금은 결과가 같습니다. 하지만 '공개일이 공휴일이면 다음 날로 미룬다'는 규칙이 생기면, 직전 공개일을 기준으로 할 때와 시작일을 기준으로 할 때 그다음 날짜들이 달라집니다. 그래서 어느 쪽을 기준으로 삼는지 요구 사항으로 정해 두어야 합니다.

  1. 출력은 다음과 같습니다.
seoul=2025-07-01 09:00 Asia/Seoul
london=2025-07-01 01:00 Europe/London
new-york=2025-06-30 20:00 America/New_York

서울은 일광 절약 시간을 쓰지 않아 1년 내내 UTC보다 9시간 빠릅니다. 반면 런던과 뉴욕은 여름에 시계를 한 시간 앞당깁니다. 그래서 7월의 런던은 서울보다 8시간, 뉴욕은 13시간 늦습니다. 1월보다 시차가 한 시간씩 줄었으므로, 같은 서울 9시가 런던에서는 0시가 아니라 1시, 뉴욕에서는 19시가 아니라 20시로 표시됩니다. 시차를 상수로 빼는 코드였다면 이 차이를 놓쳤겠지만, ZoneId의 지역 규칙을 쓰는 withZoneSameInstant는 날짜에 맞는 오프셋을 알아서 고릅니다.

  1. 공개일을 계산하는 메서드를 추가하고, main에서 그 결과를 printMonth에 넘깁니다. printMonth와 isPublishDay는 본문 코드 그대로입니다.
BoardCalendar에 추가하거나 바꿀 부분
public static void main(String[] args) {
    YearMonth month = YearMonth.of(2024, 2);
    printMonth(month, weekdaysOf(month, DayOfWeek.THURSDAY));
}

private static int[] weekdaysOf(YearMonth month, DayOfWeek dayOfWeek) {
    LocalDate first = month.atDay(1).with(TemporalAdjusters.firstInMonth(dayOfWeek));
    int count = 0;
    for (LocalDate date = first; date.getMonth() == first.getMonth(); date = date.plusWeeks(1)) {
        count++;
    }
    int[] days = new int[count];
    for (int i = 0; i < count; i++) {
        days[i] = first.plusWeeks(i).getDayOfMonth();
    }
    return days;
}

파일 위쪽에는 import java.time.LocalDate;와 import java.time.temporal.TemporalAdjusters;를 추가해야 합니다. 실행하면 다음과 같이 출력됩니다.

2024-02
  Mo  Tu  We  Th  Fr  Sa  Su
              *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

2024년 2월 1일은 목요일이라 첫 목요일은 1일이고, 일주일씩 더해 가면 8일, 15일, 22일, 29일이 됩니다. 그다음 목요일은 3월 7일로 달이 바뀌므로 첫 번째 반복문이 멈추고, 공개일은 다섯 개가 됩니다. 2024년은 윤년이라 lengthOfMonth()가 29를 돌려주므로 29일까지 출력되고, 마지막 목요일인 29일에도 표시가 붙습니다. 평년인 2025년 2월로 바꾸면 28일까지만 출력되고 공개일도 6, 13, 20, 27일의 네 개로 줄어듭니다.

다음 글

이것으로 10장의 시간 API를 마칩니다. 다음 장의 중첩 클래스의 종류와 정적 중첩 클래스에서는 클래스 안에 다른 클래스를 선언하는 방법과, 바깥 클래스와의 관계에 따라 중첩 클래스를 어떻게 나누는지 배웁니다.