본문 바로가기

날짜 조회·조정과 DateTimeFormatter

날짜에서 요일 같은 값을 읽고, plus·with·TemporalAdjusters로 새 날짜를 만들고, DateTimeFormatter로 문자열과 날짜를 서로 바꾸는 방법을 배웁니다.

게시판에 예약 기능을 붙인다고 생각해 보겠습니다. 목록에는 '2025-03-10 14:25'처럼 정해진 모양으로 날짜를 보여 줘야 하고, '이번 달 마지막 금요일'이나 '다음 월요일' 같은 날짜를 계산해야 하며, 사용자가 입력한 문자열을 날짜로 읽어야 합니다. 날짜에서 값을 읽는 일, 날짜에서 새 날짜를 만드는 일, 날짜와 문자열을 서로 바꾸는 일이 모두 필요한 셈입니다.

이 글에서는 이 세 가지 일을 차례로 다룹니다. java.time의 날짜 값은 불변 날짜 클래스와 equals·hashCode에서 직접 만들어 본 날짜 클래스처럼 불변이라서, 날짜를 '바꾸는' 메서드는 원래 값을 그대로 두고 새 값을 돌려준다는 점을 기억하며 읽어 주세요. 기간을 나타내는 Period와 단위·필드를 나타내는 ChronoUnit·ChronoField는 앞 글에서 배웠습니다.

날짜에서 값 읽기

날짜에서 값을 읽는 방법은 두 가지입니다. 무엇을 읽을지 코드를 쓸 때 이미 정해져 있다면 getYear(), getMonth(), getDayOfWeek() 같은 전용 메서드를 씁니다. 어떤 칸을 읽을지 실행 중에 정해진다면 앞 글에서 본 get(ChronoField)를 씁니다.

src/DateTimeFieldQuery.java
import java.time.LocalDateTime;
import java.time.temporal.ChronoField;

public final class DateTimeFieldQuery {
    public static void main(String[] args) {
        LocalDateTime value = LocalDateTime.of(2025, 3, 10, 14, 25, 30);

        System.out.println("year=" + value.getYear());
        System.out.println("day-of-week=" + value.getDayOfWeek());
        System.out.println("hour=" + value.get(ChronoField.HOUR_OF_DAY));
        System.out.println("day-range=" + value.range(ChronoField.DAY_OF_MONTH));
        System.out.println("offset-supported=" + value.isSupported(ChronoField.OFFSET_SECONDS));
    }
}
year=2025
day-of-week=MONDAY
hour=14
day-range=1 - 31
offset-supported=false

getDayOfWeek()는 숫자가 아니라 DayOfWeek라는 enum 상수를 돌려주므로 MONDAY가 출력됩니다. 요일을 숫자로 비교하면 일요일이 0인지 7인지 헷갈리기 쉬운데, enum이면 day == DayOfWeek.SUNDAY처럼 뜻이 분명하게 드러납니다. 숫자가 꼭 필요하면 getValue()를 쓰며, ISO 표준에 따라 월요일이 1이고 일요일이 7입니다.

넷째 줄의 range는 이 날짜에서 그 칸이 가질 수 있는 값의 범위를 알려 줍니다. 3월이라서 1 - 31이 나왔고, 2025년 2월의 날짜였다면 1 - 28이 나옵니다. 특정 날짜와 상관없이 필드 자체에 물으면 ChronoField.DAY_OF_MONTH.range()는 1 - 28/31을 돌려주는데, 최솟값은 1이고 최댓값은 달에 따라 28에서 31 사이라는 뜻입니다.

마지막 줄은 LocalDateTime에 오프셋 칸이 없다는 것을 보여 줍니다. 앞 글에서 본 대로 LocalDateTime은 시간대 정보가 없는 벽시계 값이기 때문입니다. 이 상태에서 get(ChronoField.OFFSET_SECONDS)를 호출하면 UnsupportedTemporalTypeException이 발생합니다. 오프셋이 필요한 프로그램이라면 예외를 피하려고 0 같은 값을 지어내지 말고, 처음부터 ZonedDateTime이나 OffsetDateTime으로 시간대 정보를 함께 다뤄야 합니다. 서울 시간대의 ZonedDateTime이라면 같은 호출이 9시간을 초로 나타낸 32400을 돌려줍니다.

plus는 더하고, with는 바꾼다

새 날짜를 만드는 메서드는 크게 두 무리입니다. plus와 minus로 시작하는 메서드는 지금 값에서 일정한 양만큼 앞뒤로 이동합니다. with로 시작하는 메서드는 칸 하나를 지정한 값으로 바꿔 끼웁니다. '3시간 뒤'는 plusHours(3)이고 '같은 날 18시'는 withHour(18)입니다.

src/DateTimeManipulation.java
import java.time.LocalDateTime;
import java.time.Period;

public final class DateTimeManipulation {
    public static void main(String[] args) {
        LocalDateTime original = LocalDateTime.of(2025, 1, 31, 9, 0);
        LocalDateTime plusMonth = original.plusMonths(1);
        LocalDateTime nextYear = original.plus(Period.ofYears(1));
        LocalDateTime changedHour = original.withHour(18);

        System.out.println("original=" + original);
        System.out.println("plus-month=" + plusMonth);
        System.out.println("next-year=" + nextYear);
        System.out.println("changed-hour=" + changedHour);
    }
}
original=2025-01-31T09:00
plus-month=2025-02-28T09:00
next-year=2026-01-31T09:00
changed-hour=2025-01-31T18:00

세 연산은 모두 같은 original에서 출발하고, 첫 줄에서 보듯 original은 끝까지 바뀌지 않습니다. 앞 연산의 결과에 이어서 계산하는 것이 아니라, 연산마다 새 값을 하나씩 만들어 돌려줬기 때문입니다. plus(Period.ofYears(1))처럼 Period를 넘겨도 plusYears(1)과 같은 결과를 얻습니다.

둘째 줄은 눈여겨볼 만합니다. 1월 31일에 한 달을 더하면 2월 31일이 되어야 하지만 그런 날은 없습니다. 이때 plusMonths는 월을 먼저 더한 뒤, 결과가 없는 날짜라면 일을 그달의 마지막 유효한 날로 맞춥니다. 그래서 2월 28일이 됩니다. 결과적으로 이 '한 달' 동안 지나는 날은 28일입니다. 같은 방식으로 3월 31일에 한 달을 더하면 4월 30일이 되어 30일이 지납니다. 정확히 30일 뒤가 필요하다면 plusDays(30)을 써야 합니다. '다음 달 같은 청구일'처럼 월말 처리 방식이 업무마다 다른 경우라면, 이 동작이 그 업무 규칙과 맞는지 먼저 확인해야 합니다.

같은 원본에서 상대 이동과 필드 교체를 비교한다

DateTimeManipulation의 같은 원본에서 계산한 세 반환값과 변경되지 않은 원본을 비교한다.

같은 원본에서 이동하거나 필드를 교체한 새 값을 만듭니다

DateTimeManipulation의 세 연산은 모두 original = 2025-01-31T09:00에서 각각 시작합니다. 앞 연산의 결과에 이어 더하는 체인이 아닙니다.

같은 원본에서 이동하거나 필드를 교체한 새 값을 만듭니다 같은 original2025-01-31 09에서1month02-28 09/1year2026-01-31 09/withHour18동일18로 각각 계산. original은 바뀌지 않는다. 같은 original 2025-01-31 / 09:00 plusMonths(1) 2025-02-28 / 09:00 plus(Period 1년) 2026-01-31 / 09:00 withHour(18) 2025-01-31 / 18:00 각 연산은 같은 원본에서 시작 · 한 달은 고정30일이 아님
같은 원본에서 이동하거나 필드를 교체한 새 값을 만듭니다 같은 original2025-01-31 09에서1month02-28 09/1year2026-01-31 09/withHour18동일18로 각각 계산. original은 바뀌지 않는다. 같은 original 2025-01-31 / 09:00 plusMonths(1) 2025-02-28 / 09:00 plus(Period 1년) 2026-01-31 / 09:00 withHour(18) 2025-01-31 / 18:00 original은 원래 값 유지 각 반환값은 독립 계산 결과
같은 원본에서 상대 이동과 필드 교체를 비교한다
조회·연산원문 프로그램 출력값의미
original2025-01-31T09:00원본은 그대로
original.plusMonths(1)2025-02-28T09:00한 달 이동 후 유효한 월말로 조정
original.plus(Period.ofYears(1))2026-01-31T09:00같은 원본에서 1년 이동
original.withHour(18)2025-01-31T18:00시 필드를 18로 교체
original
원문 프로그램 출력값: 2025-01-31T09:00
의미: 원본은 그대로
original.plusMonths(1)
원문 프로그램 출력값: 2025-02-28T09:00
의미: 한 달 이동 후 유효한 월말로 조정
original.plus(Period.ofYears(1))
원문 프로그램 출력값: 2026-01-31T09:00
의미: 같은 원본에서 1년 이동
original.withHour(18)
원문 프로그램 출력값: 2025-01-31T18:00
의미: 시 필드를 18로 교체

plusMonths(1)은 고정 30일을 더하는 연산이 아닙니다. 반환값을 받아야 변경된 값을 사용할 수 있으며, 별도 청구일 정책까지 이 연산이 결정하지는 않습니다.

with 쪽에도 비슷한 상황이 있지만 처리 방식이 메서드마다 다릅니다. 1월 31일에 withMonth(2)를 호출하면 plusMonths처럼 일을 맞춰 2월 28일을 돌려줍니다. 반면 2월 10일에 withDayOfMonth(31)을 호출하면 DateTimeException: Invalid date 'FEBRUARY 31'이 발생합니다. 월을 바꾸는 쪽은 일을 그달에 맞게 조정해 주지만, 일을 직접 지정하는 쪽은 그 일이 없으면 예외로 알려 주는 것입니다.

돌려받은 값을 버리면 아무 일도 일어나지 않는다

불변 객체를 처음 다룰 때 가장 흔한 실수는 메서드가 돌려준 새 값을 받지 않는 것입니다. plusDays라는 이름이 due 자체를 하루 뒤로 옮기라는 명령처럼 읽히기 때문에 이 실수가 특히 잦습니다.

lab/IgnoredResult.java
import java.time.LocalDate;

public final class IgnoredResult {
    public static void main(String[] args) {
        LocalDate due = LocalDate.of(2025, 1, 31);
        due.plusDays(1);              // 새 날짜를 만들지만 받는 변수가 없다
        System.out.println(due);      // 2025-01-31
    }
}
2025-01-31

due.plusDays(1)은 2월 1일을 나타내는 새 LocalDate를 만들지만, 그 값을 아무 변수에도 담지 않았으므로 곧바로 버려집니다. due는 여전히 1월 31일입니다. 컴파일러는 이 코드를 오류로 보지 않으므로, due = due.plusDays(1);처럼 결과를 다시 대입했는지 직접 확인하는 습관을 들여야 합니다.

다음 월요일, 이번 달 마지막 금요일

'이번 달 마지막 날'이나 '다음 월요일'은 더할 양이 정해져 있지 않습니다. 3월의 마지막 날까지는 며칠을 더해야 하는지 날짜마다 다르고, 다음 월요일까지의 거리도 오늘이 무슨 요일인지에 따라 달라집니다. 이런 날짜를 반복문으로 하루씩 넘겨 가며 직접 찾을 수도 있지만, java.time은 자주 쓰는 규칙을 TemporalAdjusters 클래스에 미리 만들어 두었습니다. 규칙 객체를 with 메서드에 넘기면 그 규칙에 맞는 새 날짜가 나옵니다.

src/BoardDateAdjusters.java
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjusters;

public final class BoardDateAdjusters {
    public static void main(String[] args) {
        LocalDate base = LocalDate.of(2025, 3, 10);
        LocalDate endOfMonth = base.with(TemporalAdjusters.lastDayOfMonth());
        LocalDate nextMonday = base.with(TemporalAdjusters.next(DayOfWeek.MONDAY));
        LocalDate lastFriday = base.with(TemporalAdjusters.lastInMonth(DayOfWeek.FRIDAY));

        System.out.println("end=" + endOfMonth);
        System.out.println("next-monday=" + nextMonday);
        System.out.println("last-friday=" + lastFriday);
    }
}
end=2025-03-31
next-monday=2025-03-17
last-friday=2025-03-28

기준일 2025년 3월 10일은 월요일입니다. lastDayOfMonth()는 그달의 마지막 날인 3월 31일을, lastInMonth(DayOfWeek.FRIDAY)는 그달의 마지막 금요일인 3월 28일을 돌려줍니다. 둘째 줄의 next(DayOfWeek.MONDAY)는 기준일 다음에 오는 월요일을 찾는데, 기준일 자신은 후보에서 빠지므로 3월 10일이 이미 월요일이어도 일주일 뒤인 3월 17일이 나옵니다. 기준일도 후보에 넣고 싶다면 nextOrSame을 씁니다. 같은 기준일에 nextOrSame(DayOfWeek.MONDAY)를 적용하면 3월 10일이 그대로 나옵니다.

다음 월요일은 기준일 포함 여부에 따라 달라진다

2025년3월10일 월요일을 기준으로 한 달력조정 결과와 nextOrSame의 기준일 포함 경계를 비교한다.

next는 기준일을 제외하고 nextOrSame은 포함합니다

기준일은 2025-03-10, 이미 월요일입니다.

next는 기준일을 제외하고 nextOrSame은 포함합니다 월요일03-10의 같은 값에서 next03-17/nextOrSame03-10/월말03-31/마지막금요일03-28을 각각 계산. 기준일 포함 여부를 구별한다. 기준: 월요일 2025-03-10 next(MONDAY) 2025-03-17 nextOrSame 2025-03-10 lastDayOfMonth 2025-03-31 lastInMonth 2025-03-28 / 금요일 화살표는 같은 기준값에서 시작 · 반환값을 연쇄 적용하지 않음
next는 기준일을 제외하고 nextOrSame은 포함합니다 월요일03-10의 같은 값에서 next03-17/nextOrSame03-10/월말03-31/마지막금요일03-28을 각각 계산. 기준일 포함 여부를 구별한다. 기준: 월요일 2025-03-10 next(MONDAY) 2025-03-17 nextOrSame 2025-03-10 lastDayOfMonth 2025-03-31 lastInMonth 2025-03-28 / 금요일 각 화살표는 같은 기준에서 독립적으로 계산 next는 기준일을 제외
다음 월요일은 기준일 포함 여부에 따라 달라진다
달력 규칙반환 날짜기준일과의 관계
lastDayOfMonth()2025-03-31같은 달의 마지막 날
next(MONDAY)2025-03-17기준일을 제외한 다음 월요일
lastInMonth(FRIDAY)2025-03-28같은 달의 마지막 금요일
nextOrSame(MONDAY)2025-03-10기준일도 허용하는 경계 비교
lastDayOfMonth()
반환 날짜: 2025-03-31
기준일과의 관계: 같은 달의 마지막 날
next(MONDAY)
반환 날짜: 2025-03-17
기준일과의 관계: 기준일을 제외한 다음 월요일
lastInMonth(FRIDAY)
반환 날짜: 2025-03-28
기준일과의 관계: 같은 달의 마지막 금요일
nextOrSame(MONDAY)
반환 날짜: 2025-03-10
기준일과의 관계: 기준일도 허용하는 경계 비교

앞의 세 항목은 BoardDateAdjusters의 출력입니다. 마지막 항목은 본문의 ‘이미 월요일이면 유지’ 설명을 같은 기준일에 적용한 추가 경계 비교입니다. 연습 코드의 입력은 별도로 2025-03-12입니다.

'다음 월요일'이라는 요구 사항이 오늘이 월요일일 때 오늘을 뜻하는지 다음 주를 뜻하는지는 문장만으로는 알 수 없습니다. 그래서 next와 nextOrSame 가운데 하나를 고르기 전에, 기준일이 바로 그 요일인 경우에 어떤 답을 원하는지 먼저 정해야 합니다. 자주 쓰는 규칙을 같은 기준일에 적용한 결과를 표로 정리하면 다음과 같습니다.

규칙뜻2025-03-10(월)에 적용한 결과
firstDayOfMonth()그달의 첫날2025-03-01
lastDayOfMonth()그달의 마지막 날2025-03-31
firstDayOfNextMonth()다음 달의 첫날2025-04-01
next(MONDAY)기준일 뒤의 첫 월요일2025-03-17
nextOrSame(MONDAY)기준일을 포함해 처음 오는 월요일2025-03-10
previous(MONDAY)기준일 앞의 마지막 월요일2025-03-03
firstInMonth(MONDAY)그달의 첫 월요일2025-03-03
lastInMonth(FRIDAY)그달의 마지막 금요일2025-03-28
dayOfWeekInMonth(2, TUESDAY)그달의 둘째 화요일2025-03-11

더 깊이: 조정 규칙을 직접 만들기

with가 받는 규칙의 타입은 TemporalAdjuster 인터페이스입니다. 이 인터페이스에는 날짜나 시각 값 하나를 받아 조정된 값을 돌려주는 추상 메서드 adjustInto 하나만 있어서, 표준 규칙에 없는 규칙도 직접 만들 수 있습니다. 다음은 다음 평일, 즉 다음 날부터 세어 토요일과 일요일을 건너뛴 첫날을 찾는 규칙입니다. date -> { … } 부분은 30장의 람다 문법과 실행 시점에서 배우는 람다식이며, ofDateAdjuster는 LocalDate를 받아 LocalDate를 돌려주는 함수를 규칙 객체로 감싸 줍니다.

src/NextWorkday.java
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.temporal.TemporalAdjuster;
import java.time.temporal.TemporalAdjusters;

public final class NextWorkday {
    private static final TemporalAdjuster NEXT_WORKDAY = TemporalAdjusters.ofDateAdjuster(date -> {
        LocalDate next = date.plusDays(1);
        while (next.getDayOfWeek() == DayOfWeek.SATURDAY || next.getDayOfWeek() == DayOfWeek.SUNDAY) {
            next = next.plusDays(1);
        }
        return next;
    });

    public static void main(String[] args) {
        System.out.println(LocalDate.of(2025, 3, 12).with(NEXT_WORKDAY));
        System.out.println(LocalDate.of(2025, 3, 14).with(NEXT_WORKDAY));
    }
}
2025-03-13
2025-03-17

수요일인 3월 12일의 다음 평일은 목요일인 13일이고, 금요일인 14일의 다음 평일은 주말을 건너뛴 월요일 17일입니다. 규칙을 상수로 만들어 두면 date.with(NEXT_WORKDAY)처럼 표준 규칙과 같은 모양으로 쓸 수 있습니다. 다만 이 규칙은 공휴일을 모릅니다. 실제 영업일 계산에는 공휴일 목록 같은 데이터가 추가로 필요합니다.

DateTimeFormatter로 날짜와 문자열 바꾸기

날짜를 화면에 보여 주거나 사용자가 입력한 문자열을 날짜로 읽으려면 둘 사이를 오가는 규칙이 필요합니다. 그 규칙을 담는 객체가 DateTimeFormatter입니다. 날짜 값의 format(formatter)는 날짜를 문자열로 바꾸고, LocalDate.parse(text, formatter) 같은 parse 메서드는 문자열을 읽어 날짜 값을 만듭니다.

규칙은 DateTimeFormatter.ofPattern에 패턴 문자열을 넘겨 만듭니다. 다음 코드는 '2025년 03월 10일 14:25' 모양의 입력을 읽은 뒤 '2025-03-10 14:25' 모양으로 다시 씁니다.

src/BoardDateFormatting.java
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public final class BoardDateFormatting {
    public static void main(String[] args) {
        DateTimeFormatter input = DateTimeFormatter.ofPattern("yyyy년 MM월 dd일 HH:mm", Locale.KOREAN);
        DateTimeFormatter output = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

        LocalDateTime parsed = LocalDateTime.parse("2025년 03월 10일 14:25", input);
        String formatted = parsed.format(output);

        System.out.println("parsed=" + parsed);
        System.out.println("formatted=" + formatted);
    }
}
parsed=2025-03-10T14:25
formatted=2025-03-10 14:25

첫 줄은 읽어 들인 LocalDateTime을 그대로 출력한 것입니다. 이때는 toString()이 쓰이는데, toString()은 언제나 날짜와 시각 사이에 T를 넣는 ISO 8601 표준 형식을 씁니다. 둘째 줄은 같은 값을 output 규칙으로 바꾼 문자열입니다. 값은 하나이고 어떤 포매터로 바꾸느냐에 따라 보이는 모양만 달라진다는 점이 중요합니다. 날짜를 문자열 상태로 들고 다니지 말고, 읽을 때 한 번 parse해서 날짜 값으로 다루다가 보여 줄 때 format하는 것이 좋습니다.

패턴에서 영문자는 날짜의 칸을 뜻하고, 같은 문자를 몇 번 반복하느냐가 자릿수나 표기 방식을 정합니다. 자주 쓰는 문자는 다음과 같습니다.

패턴뜻2025-03-10 14:25:00의 출력
uuuu, yyyy연도2025
MM / M월(두 자리 / 필요한 만큼)03 / 3
dd / d일(두 자리 / 필요한 만큼)10 / 10
HH시(0부터 23까지)14
hh오전·오후 기준 시(1부터 12까지)02
a오전·오후오후(한국어) / PM(영어)
mm분25
ss초00
E요일 약칭월(한국어) / Mon(영어)

자릿수 규칙은 읽을 때도 적용됩니다. MM은 정확히 두 자리를 요구하므로 yyyy-MM-dd로는 2025-3-09를 읽지 못하고, yyyy-M-d는 2025-3-9와 2025-03-09를 모두 읽습니다. 연도의 u와 y는 2025년처럼 평범한 날짜에서는 같은 결과를 내지만, 뒤에서 볼 STRICT 모드에서는 차이가 생깁니다.

영문자 A부터 Z까지와 a부터 z까지는 모두 패턴 문자로 예약되어 있습니다. 그래서 년, 월, 일 같은 한글이나 -, :, 공백은 그대로 출력되지만, at 같은 영어 단어를 글자 그대로 넣으려면 'at'처럼 작은따옴표로 감싸야 합니다. 공식 문서는 글자 그대로 넣을 문자는 모두 작은따옴표로 감싸기를 권합니다. 위 입력 패턴이라면 yyyy'년' MM'월' dd'일' HH:mm처럼 쓰는 편이 안전합니다.

ofPattern의 두 번째 인수인 Locale은 언어와 지역을 나타냅니다. 이 값에 따라 달라지는 것은 주로 요일 이름(E)이나 오전·오후(a)처럼 글자로 표시되는 칸입니다. 위 예제의 입력 패턴은 숫자 칸만 쓰므로 Locale.KOREAN을 빼도 결과가 같습니다. 반대로 Locale 없이 E를 쓰면 프로그램을 실행하는 컴퓨터의 기본 언어 설정에 따라 월이 나오기도 하고 Mon이 나오기도 합니다. 글자로 표시되는 칸이 있다면 Locale을 직접 지정하는 것이 좋습니다.

패턴 문자를 잘못 고르면

패턴 문자는 대소문자를 구분하기 때문에, 한 글자만 잘못 써도 예외 없이 엉뚱한 값이 출력됩니다. 다음 코드는 같은 시각인 2025년 3월 10일 14시 25분을 다섯 가지 패턴으로 출력합니다. 패턴 문자열의 글자 하나하나를 결과의 각 자리와 맞춰 보세요.

lab/PatternLetterMistakes.java
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public final class PatternLetterMistakes {
    public static void main(String[] args) {
        LocalDateTime value = LocalDateTime.of(2025, 3, 10, 14, 25);

        System.out.println(value.format(DateTimeFormatter.ofPattern("yyyy-mm-dd")));
        System.out.println(value.format(DateTimeFormatter.ofPattern("hh:mm")));
        System.out.println(value.format(DateTimeFormatter.ofPattern("a h:mm", Locale.KOREAN)));
        System.out.println(value.format(DateTimeFormatter.ofPattern("M/d E", Locale.ENGLISH)));
        System.out.println(value.format(DateTimeFormatter.ofPattern("M/d E", Locale.KOREAN)));
    }
}
2025-25-10
02:25
오후 2:25
3/10 Mon
3/10 월

첫 줄은 월 자리에 소문자 mm을 써서 분인 25가 들어갔습니다. 둘째 줄의 hh는 12시간제라서 오후 2시가 02로 나오는데, 오전 2시도 똑같이 02로 나오므로 오전·오후를 함께 보여 주지 않으면 두 시각을 구별할 수 없습니다. 셋째 줄처럼 a를 함께 쓰거나 24시간제 HH를 써야 합니다. 마지막 두 줄은 같은 패턴이 Locale에 따라 요일을 다른 언어로 보여 준다는 것을 확인시켜 줍니다.

입력 형식이 다르면 DateTimeParseException

이번에는 문자열을 읽다가 실패하는 경우입니다. 사용자는 연·월·일을 점으로 구분해 입력했지만, 포매터는 하이픈을 기대합니다.

lab/DatePatternMismatchFailure.java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;

public final class DatePatternMismatchFailure {
    public static void main(String[] args) {
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        LocalDate date = LocalDate.parse("2025.03.10", formatter);
        System.out.println(date);
    }
}
출력의 첫 줄
Exception in thread "main" java.time.format.DateTimeParseException: Text '2025.03.10' could not be parsed at index 4

메시지 끝의 at index 4는 문자열의 4번 위치에서 읽기에 실패했다는 뜻입니다. 위치는 0부터 세므로 4번은 다섯 번째 글자인 첫 번째 점입니다. 포매터는 yyyy로 2025를 읽은 다음 하이픈을 기대했는데 점을 만났습니다. 예외를 잡지 않았으므로 프로그램은 여기서 끝납니다. 실제 입력을 처리하는 코드라면 입력 검증과 재입력 반복에서 배운 대로 DateTimeParseException을 catch로 잡아 다시 입력받아야 합니다.

이런 실패를 줄이려면 입력 칸 옆에 '예: 2025-03-10'처럼 기대하는 형식을 보여 주고, 같은 형식의 포매터로 읽습니다. 여러 형식을 받아야 한다면 replace로 점을 하이픈으로 바꾸는 식의 문자열 고치기보다, 허용할 형식마다 포매터를 만들어 차례로 시도하는 편이 낫습니다. 문자열을 고치는 방식은 2025.03-10처럼 구분자가 섞인 입력까지 받아들이게 됩니다. 또 03/04/2025처럼 나라마다 3월 4일로도, 4월 3일로도 읽히는 형식은 짐작해서 읽지 말고 거절하는 것이 안전합니다. 포매터 목록을 차례로 시도하는 코드는 연습 문제 2에서 직접 만들어 봅니다.

형식은 맞는데 없는 날짜라면: SMART와 STRICT

구분자와 자릿수가 맞는다고 해서 실제로 있는 날짜인 것은 아닙니다. 2025-02-30은 yyyy-MM-dd 형식에 꼭 맞지만, 2025년 2월에는 30일이 없습니다. 이런 입력을 어떻게 처리할지는 포매터의 해석 방식(ResolverStyle)이 정합니다. ofPattern으로 만든 포매터의 기본값은 SMART입니다.

src/StrictDateInput.java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;

public final class StrictDateInput {
    public static void main(String[] args) {
        DateTimeFormatter smart = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        DateTimeFormatter strict = DateTimeFormatter.ofPattern("uuuu-MM-dd")
                .withResolverStyle(ResolverStyle.STRICT);
        DateTimeFormatter strictWithY = DateTimeFormatter.ofPattern("yyyy-MM-dd")
                .withResolverStyle(ResolverStyle.STRICT);

        print("smart", "2025-02-30", smart);
        print("strict", "2025-02-30", strict);
        print("strict", "2024-02-29", strict);
        print("strict-yyyy", "2024-02-29", strictWithY);
    }

    private static void print(String label, String text, DateTimeFormatter formatter) {
        try {
            System.out.println(label + " " + text + " -> " + LocalDate.parse(text, formatter));
        } catch (DateTimeParseException error) {
            System.out.println(label + " " + text + " -> rejected");
        }
    }
}
smart 2025-02-30 -> 2025-02-28
strict 2025-02-30 -> rejected
strict 2024-02-29 -> 2024-02-29
strict-yyyy 2024-02-29 -> rejected

첫 줄에서 SMART는 2월 30일을 오류로 보지 않고 2월의 마지막 날인 28일로 바꿔 버렸습니다. SMART는 일이 1부터 31 사이이기만 하면, 그달에 없는 날을 그달의 마지막 날로 맞춥니다. 2025-02-32나 2025-13-01처럼 범위를 아예 벗어난 값은 SMART도 거절합니다. 사용자가 입력한 날짜가 조용히 다른 날짜로 바뀌면 사용자는 그 사실을 알아채기 어렵습니다. 그래서 입력을 검사할 때는 withResolverStyle(ResolverStyle.STRICT)로 STRICT 모드를 지정해, 둘째 줄처럼 없는 날짜를 거절해야 합니다. withResolverStyle도 기존 포매터를 바꾸지 않고 새 포매터를 돌려줍니다.

셋째 줄은 STRICT가 윤년 규칙도 지킨다는 것을 보여 줍니다. 2024년은 윤년이라 2월 29일이 있으므로 그대로 읽힙니다. 같은 STRICT 포매터에 평년인 2025년의 2025-02-29를 넣으면 거절됩니다.

마지막 줄은 의외의 결과입니다. 2024년 2월 29일은 실제로 있는 날짜인데도, 연도를 yyyy로 쓴 STRICT 포매터는 거절했습니다. STRICT 모드에서 연도를 읽을 때는 yyyy 대신 uuuu를 써야 한다는 뜻이고, 그 이유는 바로 다음 '더 깊이'에서 설명합니다. 출력용 패턴이나 SMART 모드에서는 yyyy를 써도 문제없습니다.

파싱의 입력 계약과 출력 형식을 분리한다

문자열 parse→LocalDateTime→format의 실제 변환과 별도 SMART/STRICT 입력 판정을 보여 줍니다. 정상 왕복 단계와 별도 거부 지점을 분리해 타입과 출력 변화를 따라갈 수 있습니다.

파싱의 입력 계약과 출력 형식을 분리한다

패턴 구분자 불일치 실패와 정상 한국어 날짜 파싱, 출력 포맷을 서로 다른 단계의 계약으로 비교한다.

parse는 입력을 읽고, format은 출력 형식을 정합니다

parse는 문자열을 값으로 읽고, format은 값을 문자열로 표현합니다.

parse는 입력을 읽고, format은 출력 형식을 정합니다 원 입력2025년03월10일14:25→LocalDateTime→출력2025-03-10 14:25. 별도02-30 입력은 SMART02-28/STRICT예외로 형식과 달력 유효성 구분. 입력 문자열 2025년 03월 10일 14:25 parse의 값 LocalDateTime 2025-03-10 / 14:25 format의 문자열 2025-03-10 14:25 별도 STRICT 입력 · 패턴 일치와 달력 유효성은 별도 검사 2025-02-30 SMART: 2025-02-28 같은 입력 STRICT: 예외 DateTimeParseException 화살표는 parse/format 실행 · 아래 결과는 별도 입력 해석
parse는 입력을 읽고, format은 출력 형식을 정합니다 원 입력2025년03월10일14:25→LocalDateTime→출력2025-03-10 14:25. 별도02-30 입력은 SMART02-28/STRICT예외로 형식과 달력 유효성 구분. 입력 문자열 2025년 03월 10일 14:25 parse의 값 LocalDateTime 2025-03-10 / 14:25 format의 문자열 2025-03-10 14:25 입력 패턴 출력 패턴 별도 STRICT 입력 2025-02-30 SMART: 02-28 STRICT: 예외 DateTimeParseException 구분자 일치와 달력 검사는 별도 각 입력 예제는 독립
파싱의 입력 계약과 출력 형식을 분리한다
경계·원본입력과 형식관찰 결과
실패 입력 · DatePatternMismatchFailure문자열 2025.03.10

패턴 yyyy-MM-dd

점과 하이픈이 달라 · DateTimeParseException
정상 입력 · BoardDateFormatting2025년 03월 10일 14:25

yyyy년 MM월 dd일 HH:mm

한국어 로케일

2025-03-10T14:25
출력 표현 · 같은 정상 프로그램파싱한 값에 적용

yyyy-MM-dd HH:mm

2025-03-10 14:25
실패 입력 · DatePatternMismatchFailure
입력과 형식: 문자열 2025.03.10 · 패턴 yyyy-MM-dd
관찰 결과: 점과 하이픈이 달라 · DateTimeParseException
정상 입력 · BoardDateFormatting
입력: 2025년 03월 10일 14:25
패턴: yyyy년 MM월 dd일 HH:mm
로케일: 한국어
관찰 결과: 2025-03-10T14:25
출력 표현 · 같은 정상 프로그램
입력과 형식: 파싱한 값에 · yyyy-MM-dd HH:mm 적용
관찰 결과: 2025-03-10 14:25

MM은 월, mm은 분입니다. ofPattern의 기본 날짜 해석은 SMART이므로, 구분자 실패 한 사례가 모든 잘못된 달력 날짜의 엄격한 거부를 증명하지는 않습니다.

이 예의 조건

  • 02-30은 정상 왕복과 독립 입력이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 2025년 03월 10일 14:25→LocalDateTime→출력 2025-03-10 14:25. 별도 02-30 입력은 SMART의 02-28/STRICT의 예외로 형식과 달력 유효성을 구분.
82025-03-10
원 도표의 설명과 주석
구분내용
설명원래 입력 2025년 03월 10일 14:25→LocalDateTime→출력 2025-03-10 14:25. 별도 02-30 입력은 SMART의 02-28/STRICT의 예외로 형식과 달력 유효성을 구분.
전체 읽기 정보파싱의 입력 계약과 출력 형식을 분리한다 파싱의 입력 계약과 출력 형식을 분리한다 패턴 구분자 불일치 실패와 정상 한국어 날짜 파싱, 출력 포맷을 서로 다른 단계의 계약으로 비교한다. parse는 입력을 읽고, format은 출력 형식을 정합니다 parse 는 문자열을 값으로 읽고, format 은 값을 문자열로 표현합니다. 파싱의 입력 계약과 출력 형식을 분리한다 경계·원본 입력과 형식 관찰 결과 실패 입력 · DatePatternMismatchFailure 문자열 2025.03.10 패턴 yyyy-MM-dd 점과 하이픈이 달라 · DateTimeParseException 정상 입력 · BoardDateFormatting 2025년 03월 10일 14:25 yyyy년 MM월 dd일 HH:mm 한국어 로케일 2025-03-10T14:25 출력 표현 · 같은 정상 프로그램 파싱한 값에 적용 yyyy-MM-dd HH:mm 2025-03-10 14:25 실패 입력 · DatePatternMismatchFailure 입력과 형식: 문자열 2025.03.10 · 패턴 yyyy-MM-dd 관찰 결과: 점과 하이픈이 달라 · DateTimeParseException 정상 입력 · BoardDateFormatting 입력: 2025년 03월 10일 14:25 패턴: yyyy년 MM월 dd일 HH:mm 로케일: 한국어 관찰 결과: 2025-03-10T14:25 출력 표현 · 같은 정상 프로그램 입력과 형식: 파싱한 값에 · yyyy-MM-dd HH:mm 적용 관찰 결과: 2025-03-10 14:25 MM 은 월, mm 은 분입니다. ofPattern 의 기본 날짜 해석은 SMART 이므로, 구분자 실패 한 사례가 모든 잘못된 달력 날짜의 엄격한 거부를 증명하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
문자 입력

원 형식의 날짜/시각을 받습니다.

  • 문자열: 2025년 03월 10일 14:25
  • 입력 formatter가 문자열 형식을 해석한다.
parse 값

LocalDateTime을 만들며 별도 달력 오류도 판정합니다.

  • 정상 값: 2025-03-10 14:25
  • 02-30 SMART: 2025-02-28
  • 02-30 STRICT: DateTimeParseException
  • 형식 일치만으로 달력 유효성을 보장하지 않는다.
format 출력

정상 값을 다른 형식의 문자열로 만듭니다.

  • 출력: 2025-03-10 14:25
  • 값 타입: LocalDateTime 유지
  • format은 저장 값이 아닌 외부 표현을 정한다.

파싱의 입력 계약과 출력 형식을 분리한다

문자열 parse→LocalDateTime→format의 실제 변환과 별도 SMART/STRICT 입력 판정을 보여 줍니다. 정상 왕복 단계와 별도 거부 지점을 분리해 타입과 출력 변화를 따라갈 수 있습니다.

파싱의 입력 계약과 출력 형식을 분리한다

패턴 구분자 불일치 실패와 정상 한국어 날짜 파싱, 출력 포맷을 서로 다른 단계의 계약으로 비교한다.

parse는 입력을 읽고, format은 출력 형식을 정합니다

parse는 문자열을 값으로 읽고, format은 값을 문자열로 표현합니다.

parse는 입력을 읽고, format은 출력 형식을 정합니다 원 입력2025년03월10일14:25→LocalDateTime→출력2025-03-10 14:25. 별도02-30 입력은 SMART02-28/STRICT예외로 형식과 달력 유효성 구분. 입력 문자열 2025년 03월 10일 14:25 parse의 값 LocalDateTime 2025-03-10 / 14:25 format의 문자열 2025-03-10 14:25 별도 STRICT 입력 · 패턴 일치와 달력 유효성은 별도 검사 2025-02-30 SMART: 2025-02-28 같은 입력 STRICT: 예외 DateTimeParseException 화살표는 parse/format 실행 · 아래 결과는 별도 입력 해석
parse는 입력을 읽고, format은 출력 형식을 정합니다 원 입력2025년03월10일14:25→LocalDateTime→출력2025-03-10 14:25. 별도02-30 입력은 SMART02-28/STRICT예외로 형식과 달력 유효성 구분. 입력 문자열 2025년 03월 10일 14:25 parse의 값 LocalDateTime 2025-03-10 / 14:25 format의 문자열 2025-03-10 14:25 입력 패턴 출력 패턴 별도 STRICT 입력 2025-02-30 SMART: 02-28 STRICT: 예외 DateTimeParseException 구분자 일치와 달력 검사는 별도 각 입력 예제는 독립
파싱의 입력 계약과 출력 형식을 분리한다
경계·원본입력과 형식관찰 결과
실패 입력 · DatePatternMismatchFailure문자열 2025.03.10

패턴 yyyy-MM-dd

점과 하이픈이 달라 · DateTimeParseException
정상 입력 · BoardDateFormatting2025년 03월 10일 14:25

yyyy년 MM월 dd일 HH:mm

한국어 로케일

2025-03-10T14:25
출력 표현 · 같은 정상 프로그램파싱한 값에 적용

yyyy-MM-dd HH:mm

2025-03-10 14:25
실패 입력 · DatePatternMismatchFailure
입력과 형식: 문자열 2025.03.10 · 패턴 yyyy-MM-dd
관찰 결과: 점과 하이픈이 달라 · DateTimeParseException
정상 입력 · BoardDateFormatting
입력: 2025년 03월 10일 14:25
패턴: yyyy년 MM월 dd일 HH:mm
로케일: 한국어
관찰 결과: 2025-03-10T14:25
출력 표현 · 같은 정상 프로그램
입력과 형식: 파싱한 값에 · yyyy-MM-dd HH:mm 적용
관찰 결과: 2025-03-10 14:25

MM은 월, mm은 분입니다. ofPattern의 기본 날짜 해석은 SMART이므로, 구분자 실패 한 사례가 모든 잘못된 달력 날짜의 엄격한 거부를 증명하지는 않습니다.

이 예의 조건

  • 02-30은 정상 왕복과 독립 입력이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2원래 입력 2025년 03월 10일 14:25→LocalDateTime→출력 2025-03-10 14:25. 별도 02-30 입력은 SMART의 02-28/STRICT의 예외로 형식과 달력 유효성을 구분.
82025-03-10
원 도표의 설명과 주석
구분내용
설명원래 입력 2025년 03월 10일 14:25→LocalDateTime→출력 2025-03-10 14:25. 별도 02-30 입력은 SMART의 02-28/STRICT의 예외로 형식과 달력 유효성을 구분.
전체 읽기 정보파싱의 입력 계약과 출력 형식을 분리한다 파싱의 입력 계약과 출력 형식을 분리한다 패턴 구분자 불일치 실패와 정상 한국어 날짜 파싱, 출력 포맷을 서로 다른 단계의 계약으로 비교한다. parse는 입력을 읽고, format은 출력 형식을 정합니다 parse 는 문자열을 값으로 읽고, format 은 값을 문자열로 표현합니다. 파싱의 입력 계약과 출력 형식을 분리한다 경계·원본 입력과 형식 관찰 결과 실패 입력 · DatePatternMismatchFailure 문자열 2025.03.10 패턴 yyyy-MM-dd 점과 하이픈이 달라 · DateTimeParseException 정상 입력 · BoardDateFormatting 2025년 03월 10일 14:25 yyyy년 MM월 dd일 HH:mm 한국어 로케일 2025-03-10T14:25 출력 표현 · 같은 정상 프로그램 파싱한 값에 적용 yyyy-MM-dd HH:mm 2025-03-10 14:25 실패 입력 · DatePatternMismatchFailure 입력과 형식: 문자열 2025.03.10 · 패턴 yyyy-MM-dd 관찰 결과: 점과 하이픈이 달라 · DateTimeParseException 정상 입력 · BoardDateFormatting 입력: 2025년 03월 10일 14:25 패턴: yyyy년 MM월 dd일 HH:mm 로케일: 한국어 관찰 결과: 2025-03-10T14:25 출력 표현 · 같은 정상 프로그램 입력과 형식: 파싱한 값에 · yyyy-MM-dd HH:mm 적용 관찰 결과: 2025-03-10 14:25 MM 은 월, mm 은 분입니다. ofPattern 의 기본 날짜 해석은 SMART 이므로, 구분자 실패 한 사례가 모든 잘못된 달력 날짜의 엄격한 거부를 증명하지는 않습니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
문자 입력

원 형식의 날짜/시각을 받습니다.

  • 문자열: 2025년 03월 10일 14:25
  • 입력 formatter가 문자열 형식을 해석한다.
parse 값

LocalDateTime을 만들며 별도 달력 오류도 판정합니다.

  • 정상 값: 2025-03-10 14:25
  • 02-30 SMART: 2025-02-28
  • 02-30 STRICT: DateTimeParseException
  • 형식 일치만으로 달력 유효성을 보장하지 않는다.
format 출력

정상 값을 다른 형식의 문자열로 만듭니다.

  • 출력: 2025-03-10 14:25
  • 값 타입: LocalDateTime 유지
  • format은 저장 값이 아닌 외부 표현을 정한다.

위 단계 그림은 이 절의 흐름을 한 번 더 정리합니다. 정상 입력은 parse로 LocalDateTime이 되고, 그 값이 format으로 다른 모양의 문자열이 됩니다. 별도의 입력 2025-02-30은 형식이 맞더라도 SMART에서는 2월 28일로 바뀌고 STRICT에서는 DateTimeParseException이 됩니다. 형식 검사와 날짜가 실제로 있는지 검사하는 일은 서로 다른 단계라는 점이 핵심입니다.

더 깊이: y와 u의 차이, 그리고 ISO 포매터

y는 연호 안의 연도(year-of-era)이고, u는 연호 없이 이어지는 연도(proleptic year)입니다. ISO 달력에는 기원전(BCE)과 기원후(CE)라는 두 연호(era)가 있고, 연호 안의 연도는 연호마다 1부터 셉니다. 그래서 기원전 1년은 연호 안의 연도로는 1이지만, 연호 없이 이어서 세면 0년입니다. 기원후 날짜에서는 두 값이 같기 때문에 평소에는 차이가 드러나지 않습니다.

차이가 드러나는 곳이 STRICT 해석입니다. yyyy로 읽은 2024는 '어느 연호의 2024년'인지 정해져야 실제 연도가 됩니다. SMART와 LENIENT 모드는 연호가 없으면 기원후라고 가정하지만, STRICT 모드는 입력에 없는 정보를 지어내지 않으므로 연도를 확정하지 못하고 실패합니다. 연호 칸(G)이 없는 패턴을 STRICT로 읽을 때 uuuu를 쓰는 이유가 이것입니다.

포매터 없이 LocalDate.parse("2025-02-30")처럼 호출하면 표준 포매터인 DateTimeFormatter.ISO_LOCAL_DATE가 쓰입니다. 이 포매터는 처음부터 STRICT로 만들어져 있어서 이 입력을 거절합니다. 참고로 세 번째 해석 방식인 LENIENT는 넘치는 값을 다음 달로 넘기므로 2025-02-30을 3월 2일로 읽습니다. 날짜를 검증하려는 입력 처리에서는 SMART와 LENIENT 모두 피하는 것이 좋습니다.

포매터는 한 번 만들어 함께 쓴다

DateTimeFormatter는 불변이고 여러 스레드가 동시에 써도 안전합니다. 그래서 메서드를 호출할 때마다 패턴 문자열로 새로 만들 필요 없이, 클래스 상수로 한 번 만들어 두고 계속 쓰면 됩니다.

포매터 상수 선언
private static final DateTimeFormatter INPUT_DATE =
        DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT);

Java 8 이전에 쓰던 java.text.SimpleDateFormat은 그렇지 않았습니다. 이 클래스는 동기화되어 있지 않아서, 공식 문서도 스레드마다 따로 만들거나 외부에서 동기화하라고 안내합니다. 오래된 코드에서 이 클래스를 상수로 공유하고 있다면 DateTimeFormatter로 바꿀 이유가 하나 더 있는 셈입니다.

어떤 형식을 고를지도 생각해 볼 문제입니다. 화면에 보여 줄 때는 사용자에게 익숙한 형식을 쓰면 되지만, 다른 프로그램과 주고받는 JSON이나 로그, 파일에는 ISO_LOCAL_DATE나 ISO_LOCAL_DATE_TIME 같은 ISO 8601 표준 형식이 좋습니다. 표준 형식은 언어 설정에 따라 모양이 바뀌지 않고, 앞에서 본 03/04/2025 같은 모호함도 없기 때문입니다.

어떤 메서드를 쓸까

하고 싶은 일쓸 메서드
정해진 칸 읽기getYear(), getDayOfWeek() 같은 전용 메서드
실행 중에 고른 칸 읽기get(field), 먼저 isSupported(field)
지금 값에서 앞뒤로 이동plusDays, minusMonths, plus(Period)
칸 하나를 다른 값으로 바꾸기withHour, withMonth, withDayOfMonth
달력 규칙에 맞는 날짜 찾기with(TemporalAdjusters.lastDayOfMonth())
문자열을 날짜로LocalDate.parse(text, formatter)
날짜를 문자열로date.format(formatter)

어떤 메서드를 쓰든 결과는 새 값으로 돌아오므로 반드시 변수에 받아야 합니다. 그리고 날짜 계산을 확인할 때는 평범한 날짜만 넣어 보지 말고, 월말, 윤년의 2월 29일, 기준일이 찾는 요일과 같은 날 같은 까다로운 입력을 꼭 함께 넣어 보세요.

정리

  • 정해진 칸은 전용 getter로, 실행 중에 고른 칸은 get(field)로 읽습니다. 타입에 없는 칸의 값을 지어내지 않습니다.
  • plus와 minus는 지금 값에서 이동하고, with는 칸 하나를 바꿉니다. 둘 다 원래 값은 그대로 두고 새 값을 돌려주므로 결과를 변수에 받아야 합니다.
  • 월을 더하거나 바꿔서 없는 날짜가 되면 그달의 마지막 날로 맞춰지지만, withDayOfMonth로 없는 날을 지정하면 예외가 발생합니다.
  • TemporalAdjusters는 '그달의 마지막 날', '다음 월요일' 같은 규칙을 제공합니다. next는 기준일을 빼고, nextOrSame은 기준일을 포함합니다.
  • DateTimeFormatter는 날짜와 문자열 사이의 규칙입니다. 패턴 문자는 대소문자를 구분하고(MM은 월, mm은 분), 글자로 표시되는 칸은 Locale의 영향을 받습니다.
  • ofPattern의 기본 해석 방식인 SMART는 2025-02-30을 2월 28일로 바꿉니다. 입력 검증에는 uuuu와 STRICT를 함께 씁니다.

연습 문제

  1. 입력 날짜가 월요일이면 그 날짜를, 아니면 다음 월요일을 골라 yyyy/MM/dd E 형식으로 출력하세요. 요일은 영어로 표시하고, 입력은 2025년 3월 12일로 합니다.
  2. 2025-03-10, 2025.03.10, 2025/03/10 세 형식을 모두 받아들이는 날짜 입력 메서드를 만드세요. 구분자가 섞인 2025.03-10과 없는 날짜인 2025-02-30은 거절해야 합니다.
  3. LocalDate.of(2025, 1, 31).plusMonths(1).plusMonths(1)과 LocalDate.of(2025, 1, 31).plusMonths(2)의 결과를 예상한 뒤 실행해 확인하세요. 두 결과가 다르다면, 매달 같은 날 반복되는 일정은 어떻게 계산해야 할까요?
해설 보기
  1. '이미 월요일이면 그대로'라는 요구는 nextOrSame이 그대로 표현합니다.
src/NextBoardMondayExercise.java
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.Locale;

public final class NextBoardMondayExercise {
    public static void main(String[] args) {
        LocalDate input = LocalDate.of(2025, 3, 12);
        LocalDate monday = input.with(TemporalAdjusters.nextOrSame(DayOfWeek.MONDAY));
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy/MM/dd E", Locale.ENGLISH);
        System.out.println(monday.format(formatter));
    }
}
2025/03/17 Mon

3월 12일은 수요일이므로 그다음 월요일인 3월 17일이 나옵니다. 입력을 월요일인 3월 10일로 바꾸면 2025/03/10 Mon이 나와서, 기준일이 포함된다는 것을 확인할 수 있습니다. Locale.ENGLISH를 Locale.KOREAN으로 바꾸면 2025/03/17 월이 됩니다. 요일 번호를 직접 빼고 더하는 계산으로도 같은 답을 낼 수 있지만, 일요일을 0으로 볼지 7로 볼지 같은 실수가 끼어들기 쉽고 코드만 보고 의도를 알기도 어렵습니다.

  1. 허용할 형식마다 STRICT 포매터를 만들어 배열에 담고, 앞에서부터 차례로 시도합니다.
src/FlexibleDateInputExercise.java
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;

public final class FlexibleDateInputExercise {
    private static final DateTimeFormatter[] FORMATS = {
            DateTimeFormatter.ofPattern("uuuu-MM-dd").withResolverStyle(ResolverStyle.STRICT),
            DateTimeFormatter.ofPattern("uuuu.MM.dd").withResolverStyle(ResolverStyle.STRICT),
            DateTimeFormatter.ofPattern("uuuu/MM/dd").withResolverStyle(ResolverStyle.STRICT),
    };

    public static void main(String[] args) {
        String[] inputs = {"2025-03-10", "2025.03.10", "2025/03/10", "2025.03-10", "2025-02-30"};
        for (String input : inputs) {
            LocalDate date = parseFlexible(input);
            System.out.println(input + " -> " + (date == null ? "rejected" : date));
        }
    }

    private static LocalDate parseFlexible(String text) {
        for (DateTimeFormatter format : FORMATS) {
            try {
                return LocalDate.parse(text, format);
            } catch (DateTimeParseException ignored) {
                // 이 형식이 아니면 다음 형식을 시도한다
            }
        }
        return null;
    }
}
2025-03-10 -> 2025-03-10
2025.03.10 -> 2025-03-10
2025/03/10 -> 2025-03-10
2025.03-10 -> rejected
2025-02-30 -> rejected

세 형식은 각각 맞는 포매터에서 읽히고, 결과는 모두 같은 LocalDate가 됩니다. 2025.03-10은 어느 포매터의 구분자와도 맞지 않아 모든 시도가 실패합니다. 2025-02-30은 첫 번째 포매터와 형식은 맞지만 STRICT가 없는 날짜로 거절하고, 나머지 포매터와는 구분자가 맞지 않습니다. 이 예제는 실패를 null로 알렸지만, 실제 프로그램에서는 사용자에게 보여 줄 오류 메시지를 함께 돌려주는 편이 좋습니다. 포매터 배열은 상수로 한 번만 만들었으므로 입력이 아무리 많아도 다시 만들지 않습니다.

  1. 다음 코드로 확인할 수 있습니다.
src/MonthChainExercise.java
import java.time.LocalDate;

public final class MonthChainExercise {
    public static void main(String[] args) {
        LocalDate start = LocalDate.of(2025, 1, 31);
        System.out.println("chained=" + start.plusMonths(1).plusMonths(1));
        System.out.println("at-once=" + start.plusMonths(2));
    }
}
chained=2025-03-28
at-once=2025-03-31

한 달씩 두 번 더하면 첫 단계에서 2월 28일로 맞춰지고, 그 순간 원래 날짜가 31일이었다는 정보가 사라집니다. 두 번째 단계는 2월 28일에서 출발하므로 3월 28일이 됩니다. 한 번에 두 달을 더하면 1월 31일에서 바로 3월 31일로 가므로 이런 일이 없습니다. 그래서 매달 반복되는 일정은 직전 일정에서 한 달씩 더해 가지 말고, 처음 시작일에서 start.plusMonths(n)으로 n번째 일정을 계산해야 날짜가 점점 앞당겨지지 않습니다.

다음 글

날짜 API로 게시글 예약 일정 만들기에서는 지금까지 배운 시간 API를 모아 예약 공개 시각을 다른 나라 시간으로 바꾸고, 반복 일정과 마감일을 계산하고, 한 달 치 공개 달력을 출력합니다.