안동민 개발노트

본문 시작

게시글 예약 일정 CLI

예약 공개 시각의 지역 변환 오류를 고치고 날짜 더하기·반복 간격·마감일·월 경계·국제 변환·게시 달력 출력을 종합합니다.

날짜 문제는 API 이름을 외우는 것보다 질문이 달력 값인지 경과 시간인지 실제 순간인지 분류하는 능력이 중요합니다.

이번 절은 게시글 예약과 마감에서 자주 만나는 문제를 하나씩 해결하고, 마지막에 월간 공개 일정을 출력하는 작은 CLI 로직으로 결합합니다.


지역 시간 복사의 오류

서울 오전 9시 예약 공개 시각을 런던 관리자에게 알리면서 ZoneId 없이 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);
        System.out.println("expected-london-hour=0");
    }
}
잘못된 결과
seoul=2025-01-01T09:00
london=2025-01-01T09:00
expected-london-hour=0

지역 없는 LocalDateTime에는 변환 근거가 없습니다.

서울 ZoneId를 결합해 실제 순간을 정하고 withZoneSameInstant로 런던 표현을 얻어야 합니다.

이 문제는 포맷 변경이 아니라 정보 누락 버그입니다.


날짜 연산과 반복 간격

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

연·월·일을 순서대로 더한 결과는 순서를 바꾸면 월말에서 달라질 수 있습니다.

업무에서 하나의 달력 기간이라면 Period로 묶어 의도를 나타냅니다.

반복 간격은 2주라는 단위가 코드에 그대로 보입니다.


D-day와 월 경계 계산

날짜 사이 전체 일수는 ChronoUnit.DAYS, 해당 월의 시작 요일과 마지막 날은 LocalDate 메서드로 구합니다.

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

YearMonth는 연·월만 필요한 개념을 정확히 표현하고 월 길이와 윤년을 처리합니다.

종료일 포함 일수를 원하면 between 결과에 1을 더하는 등 포함 규칙을 명시합니다.


같은 순간의 지역별 표현

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

뉴욕은 날짜까지 전날로 바뀝니다.

예약 공개 정보에는 ZoneId 또는 UTC 순간을 보관하고 관리자의 지역에서 렌더링합니다.

현재 오프셋을 고정 상수로 빼는 계산은 DST 시기에 틀릴 수 있습니다.

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

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

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

지역 없는 복사에는 순간 변환의 근거가 없다
원본·경로실제 표시보존 정보
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일 서울 예약에 대한 것입니다.


월간 게시글 공개 달력

YearMonth와 DayOfWeek를 이용하면 월 길이와 시작 위치를 직접 하드코딩하지 않고 달력 행을 만들 수 있습니다.

src/BoardCalendar.java
import java.time.DayOfWeek;
import java.time.LocalDate;
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");
        LocalDate first = month.atDay(1);
        int offset = first.getDayOfWeek().getValue() - DayOfWeek.MONDAY.getValue();
        System.out.print("   ".repeat(offset));

        for (int day = 1; day <= month.lengthOfMonth(); day++) {
            String mark = isPublishDay(day, publishDays) ? "*" : " ";
            System.out.printf("%2d%s", day, mark);
            LocalDate date = month.atDay(day);
            if (date.getDayOfWeek() == DayOfWeek.SUNDAY) System.out.println();
            else System.out.print(" ");
        }
        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

실제 출력은 월 전체가 이어집니다.

%2d와 한 글자 표식은 세 글자이고, 일요일 외에는 공백을 하나 더 출력합니다. 현재 첫 주 들여쓰기와 요일 머리글은 세 글자 간격이므로 열이 정확히 정렬되지는 않습니다. 다음 자료는 이 공백 배치가 아니라 날짜와 공개일 데이터의 달력 구조를 보여 줍니다.

현지화된 요일 순서와 넓은 문자 표식은 별도 프레젠테이션 책임입니다.

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

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

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

월 범위와 공개일 데이터를 달력에 배치한다
주월화수목금토일
첫째—————12
둘째3*456789
셋째10*111213141516
넷째17*181920212223
다섯째24*252627282930
여섯째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. 포함·제외 경계와 지역 ZoneId를 확정합니다.
  3. 그 의미를 보존하는 가장 좁은 java.time 타입을 선택합니다.
  4. plus, between, adjuster 같은 표준 연산을 사용합니다.
  5. 월말·윤년·DST·자정 넘김 사례를 실행합니다.
  6. 문자열 파싱과 출력은 formatter 경계로 분리합니다.

현재 시각을 직접 호출하지 않고 Clock 또는 고정 입력을 쓰면 같은 문제를 반복 검증할 수 있습니다.


연습 문제

시작일 2025-03-01부터 10일 간격으로 세 번의 예약 공개일을 만들고 마지막 공개일까지 총 일수를 출력하세요.

해설 보기
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

간격은 이전 공개일이 아니라 시작일 기준 배수로 계산했습니다.

요구가 “각 공개 후 10일”이라도 결과는 같지만 휴일 조정이 들어오면 기준이 달라질 수 있습니다.