본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
10장 : 래퍼·열거형·시간 API

학습 일정 CLI

지역 없는 국제 회의 계산의 오결과를 고치고 날짜 더하기·반복 간격·D-day·월 경계·국제 변환·달력 출력을 종합합니다.

날짜 문제는 API 이름을 외우는 것보다 질문이 달력 값인지 경과 시간인지 실제 순간인지 분류하는 능력이 중요합니다. 이번 절은 자주 만나는 연습을 하나씩 해결하고, 마지막에 월간 학습 기록 일정을 출력하는 작은 CLI 로직으로 결합합니다.

지역 시간 복사의 오류

서울 오전 9시 회의를 런던에 알리면서 ZoneId 없이 LocalDateTime을 그대로 전달했습니다. 두 값은 같은 벽시계 문자를 가지지만 같은 순간 변환이 아닙니다.

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

public final class LocalMeetingZoneBug {
    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/RepeatedStudyDates.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public final class RepeatedStudyDates {
    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/StudyDeadlineAndMonth.java
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.temporal.ChronoUnit;

public final class StudyDeadlineAndMonth {
    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/InternationalStudyMeeting.java
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;

public final class InternationalStudyMeeting {
    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 시기에 틀릴 수 있습니다.

월간 달력의 학습 횟수

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

src/StudyCalendar.java
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.YearMonth;

public final class StudyCalendar {
    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[] studyDays) {
        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 = isStudyDay(day, studyDays) ? "*" : " ";
            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 isStudyDay(int day, int[] studyDays) {
        for (int studyDay : studyDays) {
            if (studyDay == day) return true;
        }
        return false;
    }
}
2025-03
Mo Tu We Th Fr Sa Su
                1  2
 3*  4   5   6   7   8   9

실제 출력은 월 전체가 이어집니다. %2d와 한 글자 표식으로 셀 폭을 맞췄습니다. 현지화된 요일 순서와 넓은 문자 표식은 별도 프레젠테이션 책임입니다.

날짜 문제 해결 순서

  1. 입력이 날짜, 지역 시각, 순간, 기간 중 무엇인지 적습니다.
  2. 포함·제외 경계와 지역 ZoneId를 확정합니다.
  3. 그 의미를 보존하는 가장 좁은 java.time 타입을 선택합니다.
  4. plus, between, adjuster 같은 표준 연산을 사용합니다.
  5. 월말·윤년·DST·자정 넘김 사례를 실행합니다.
  6. 문자열 파싱과 출력은 formatter 경계로 분리합니다.

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

연습 문제

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

해설 보기
src/ReviewScheduleExercise.java
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.Arrays;

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

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

간격은 이전 복습일이 아니라 시작일 기준 배수로 계산했습니다. 요구가 “각 복습 후 10일”이라도 결과는 같지만 휴일 조정이 들어오면 기준이 달라질 수 있습니다.