안동민 개발노트

본문 시작

HTTP 상태와 시간 모델

알 수 없는 HTTP 코드의 null 실패를 고치고 윤년·타임존·DST 복잡성을 바탕으로 java.time 타입 선택 지도를 세웁니다.

제한된 정수 코드도 enum으로 의미를 붙일 수 있습니다.

이어서 날짜와 시간은 숫자 하나가 아니라 달력 규칙, 지역 시간대, 경과 시간이라는 서로 다른 개념을 구분해야 합니다.

이 절은 HTTP 상태 조회의 경계 처리에서 시작해 java.time 타입 지도를 만듭니다.


알 수 없는 HTTP 상태 코드

findByCode가 찾지 못한 경우 null을 반환하고 호출자가 즉시 메서드를 호출합니다.

실패 원인은 입력 코드지만 예외는 description 접근에서 발생합니다.

lab/UnknownHttpStatusFailure.java
public final class UnknownHttpStatusFailure {
    public static void main(String[] args) {
        HttpStatus status = HttpStatus.findByCode(999);
        System.out.println(status.description());
    }

    private enum HttpStatus {
        OK(200, "OK"), BAD_REQUEST(400, "Bad Request"), NOT_FOUND(404, "Not Found");

        private final int code;
        private final String description;

        HttpStatus(int code, String description) {
            this.code = code;
            this.description = description;
        }

        String description() { return description; }

        static HttpStatus findByCode(int code) {
            for (HttpStatus status : values()) {
                if (status.code == code) return status;
            }
            return null;
        }
    }
}
실패 관찰
Exception in thread "main" java.lang.NullPointerException

조회 실패가 정상 가능한 사용자 입력이면 조회 직후 null을 확인해 안내 결과로 바꿉니다.

null을 여러 메서드로 전달하면 확인 지점을 놓치기 쉬우므로 이 짧은 경계 밖으로 내보내지 않습니다.

값의 존재 여부를 반환 타입으로 표현하는 방법은 ch33에서 정식으로 배웁니다.

내부 프로토콜 위반이라면 설명 있는 예외로 즉시 중단하는 편이 낫습니다.


명시적인 HTTP 조회 결과

src/SafeHttpStatusLookup.java
public final class SafeHttpStatusLookup {
    public static void main(String[] args) {
        print(200);
        print(999);
    }

    private static void print(int code) {
        HttpStatus found = HttpStatus.findByCode(code);
        if (found == null) {
            System.out.println("undefined=" + code);
            return;
        }
        System.out.println(found.code + "=" + found.description);
    }

    private enum HttpStatus {
        OK(200, "OK"), BAD_REQUEST(400, "Bad Request"), NOT_FOUND(404, "Not Found");

        private final int code;
        private final String description;

        HttpStatus(int code, String description) {
            this.code = code;
            this.description = description;
        }

        static HttpStatus findByCode(int code) {
            for (HttpStatus status : values()) {
                if (status.code == code) return status;
            }
            return null;
        }
    }
}
200=OK
undefined=999

상태 수가 작아 명시적 반복의 비용은 중요하지 않습니다.

값이 많고 조회가 빈번하면 ch11에서 배우는 키 기반 컬렉션을 검토할 수 있습니다.

HTTP 조회 실패와 즉시 null 검사

두 HttpStatus 조회 구현은 모두 미발견 시 null을 반환한다. 실패 예제는 곧바로 description을 호출해 NPE가 나고 안전 예제는 found를 즉시 확인하여 undefined를 출력하고 종료한다.

두 예제 모두 findByCode는 찾지 못하면 null을 반환합니다. 차이는 반환 직후의 처리입니다.

HTTP 조회 실패와 즉시 null 검사
예제와 입력조회 결과뒤따르는 동작
실패 예제 · 999nullstatus.description()에서 NullPointerException
검사한 예제 · 200HttpStatus.OK200=OK 출력
검사한 예제 · 999nullundefined=999 출력 후 return; 필드 접근 없음
실패 예제 · 999

조회 결과: null

뒤따르는 동작: status.description()에서 NullPointerException

검사한 예제 · 200

조회 결과: HttpStatus.OK

뒤따르는 동작: 200=OK 출력

검사한 예제 · 999

조회 결과: null

뒤따르는 동작: undefined=999 출력 후 return; 필드 접근 없음

이 예제의 지원 집합은 200·400·404입니다. undefined는 이 조회 표에 없다는 뜻이며, 모든 HTTP 상태 코드의 유효성 판정은 아닙니다. 미발견 값을 OK 같은 기본 상수로 바꾸지 않습니다.

실제 시스템에서 중복 코드가 생길 수 있다면 enum 초기화 시 검증해 모호한 조회를 막습니다. 이 예제에는 중복 코드 검증 로직이 구현되어 있지 않습니다.


날짜 계산의 복잡성

달마다 일수가 다르고 윤년은 4년·100년·400년 규칙을 함께 사용합니다.

지역 시각에는 타임존 규칙과 일광 절약 시간 전환이 적용됩니다.

어떤 지역에서는 시계가 한 시간을 건너뛰거나 같은 지역 시간이 두 번 나타납니다.

국제 서비스는 서울 오전 9시가 런던과 뉴욕에서 언제인지 같은 순간 기준 변환도 필요합니다.

오래된 Date는 이름과 달리 날짜·시간을 함께 표현하고, Calendar는 0부터 시작하는 월과 가변 API 때문에 실수를 만들기 쉬웠습니다.

java.time은 불변 값 타입과 명확한 개념 분리로 이 문제를 개선했습니다.

직접 윤년·타임존 표를 관리하기보다 JDK가 배포하는 규칙을 사용합니다.

src/DateComplexityObservation.java
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;

public final class DateComplexityObservation {
    public static void main(String[] args) {
        LocalDate leapDay = LocalDate.of(2024, 2, 29);
        ZonedDateTime seoul = ZonedDateTime.of(2025, 3, 10, 9, 0, 0, 0,
                ZoneId.of("Asia/Seoul"));
        ZonedDateTime london = seoul.withZoneSameInstant(ZoneId.of("Europe/London"));

        System.out.println("leap=" + leapDay.isLeapYear());
        System.out.println("seoul=" + seoul);
        System.out.println("london=" + london);
        System.out.println("same-instant=" + seoul.toInstant().equals(london.toInstant()));
    }
}
leap=true
same-instant=true

지역별 오프셋 표시는 시간대 규칙 버전에 따라 확인해야 하므로 출력 전체를 고정 문자열로 가정하지 않습니다.

같은 순간인지 Instant로 비교하면 지역 표시가 달라도 true입니다.


java.time 타입 선택

  • LocalDate: 생일, 게시글일처럼 시간대 없는 날짜
  • LocalTime: 매일 시작 시간처럼 날짜 없는 시각
  • LocalDateTime: 지역은 별도로 아는 일정의 날짜와 시각
  • ZonedDateTime: 지역 ZoneId 규칙까지 포함한 국제 일정
  • OffsetDateTime: UTC와의 고정 차이를 포함한 교환 값
  • Instant: 전 세계 공통 타임라인의 한 순간
  • Period: 연·월·일 달력 기간
  • Duration: 초·나노초 기반 경과 시간

“2025-03-10 09:00”만 저장하면 어느 지역의 9시인지 알 수 없습니다.

반대로 생일에 ZoneId를 붙이면 불필요한 복잡성이 생깁니다.

데이터가 답해야 할 질문을 먼저 정합니다.

시간 타입이 보존하는 정보와 빠진 정보

LocalDate, LocalTime, LocalDateTime은 지역이나 오프셋을 보관하지 않는다. ZonedDateTime은 지역 규칙, OffsetDateTime은 오프셋, Instant는 순간을 표현하며 Period와 Duration은 각각 달력 기간과 경과 시간량이다.

이름이 비슷해도 저장되는 정보가 다릅니다. 빠진 정보가 필요한 질문에는 타입만으로 답할 수 없습니다.

시간 타입이 보존하는 정보와 빠진 정보
타입보존하는 정보이 값만으로 알 수 없는 것
LocalDate연·월·일시각·지역·순간
LocalTime시·분·초·나노초날짜·지역·순간
LocalDateTime날짜와 시각지역·오프셋·유일한 순간
ZonedDateTime날짜·시각·지역 ID·해당 오프셋다른 지역의 표시값은 변환 필요
OffsetDateTime날짜·시각·UTC 오프셋지역 ID와 그 지역의 향후 규칙
Instant공통 타임라인의 한 순간지역 달력 날짜·지역 시각
Period연·월·일 달력 기간항상 같은 초 수라는 보장
Duration초·나노초 시간량지역 달력의 월·연 수
LocalDate

보존하는 정보: 연·월·일

이 값만으로 알 수 없는 것: 시각·지역·순간

LocalTime

보존하는 정보: 시·분·초·나노초

이 값만으로 알 수 없는 것: 날짜·지역·순간

LocalDateTime

보존하는 정보: 날짜와 시각

이 값만으로 알 수 없는 것: 지역·오프셋·유일한 순간

ZonedDateTime

보존하는 정보: 날짜·시각·지역 ID·해당 오프셋

이 값만으로 알 수 없는 것: 다른 지역의 표시값은 변환 필요

OffsetDateTime

보존하는 정보: 날짜·시각·UTC 오프셋

이 값만으로 알 수 없는 것: 지역 ID와 그 지역의 향후 규칙

Instant

보존하는 정보: 공통 타임라인의 한 순간

이 값만으로 알 수 없는 것: 지역 달력 날짜·지역 시각

Period

보존하는 정보: 연·월·일 달력 기간

이 값만으로 알 수 없는 것: 항상 같은 초 수라는 보장

Duration

보존하는 정보: 초·나노초 시간량

이 값만으로 알 수 없는 것: 지역 달력의 월·연 수

생일에는 LocalDate, 전 세계 공통 저장 시점에는 Instant처럼 질문에 맞춰 선택합니다. 지역 9시는 ZoneId를 따로 정하지 않으면 하나의 순간으로 확정되지 않습니다.


연월일 전용 값 타입

Year, Month, YearMonth, MonthDay는 완전한 날짜보다 적은 정보만 필요한 경우에 사용합니다.

Year는 연도와 윤년 규칙을, Month는 1월부터 12월까지의 열거 값을, YearMonth는 청구월이나 정산월처럼 연도와 월의 조합을 표현합니다.

MonthDay는 창립일이나 반복 기념일처럼 해마다 돌아오는 월일을 나타내며 연도를 포함하지 않습니다.

  • 연도별 정책이나 윤년 여부만 묻는다면 Year를 선택합니다.
  • 월 이름과 월 순서가 필요하지만 연도는 필요 없다면 Month를 선택합니다.
  • 월 단위 정산과 마지막 날짜 계산에는 YearMonth가 적합합니다.
  • 해마다 반복되는 월일에는 MonthDay를 사용하고, 실제 날짜가 필요한 시점에 연도를 결합합니다.
src/PartialDateTypes.java
import java.time.LocalDate;
import java.time.Month;
import java.time.MonthDay;
import java.time.Year;
import java.time.YearMonth;

public final class PartialDateTypes {
    public static void main(String[] args) {
        Year boardYear = Year.of(2024);
        Month billingMonth = Month.FEBRUARY;
        YearMonth billingPeriod = YearMonth.of(boardYear.getValue(), billingMonth);
        MonthDay anniversary = MonthDay.of(Month.FEBRUARY, 29);

        System.out.println("leap=" + boardYear.isLeap());
        System.out.println("lastDay=" + billingPeriod.atEndOfMonth());
        System.out.println("valid2023=" + anniversary.isValidYear(2023));

        if (!anniversary.isValidYear(boardYear.getValue())) {
            throw new IllegalStateException("anniversary is invalid in " + boardYear);
        }
        LocalDate anniversaryDate = anniversary.atYear(boardYear.getValue());
        System.out.println("anniversary=" + anniversaryDate);
    }
}
leap=true
lastDay=2024-02-29
valid2023=false
anniversary=2024-02-29

부분 날짜는 빠진 정보를 임의로 채우지 않습니다.

MonthDay에 시간대나 연도가 없다는 사실은 결함이 아니라 모델의 계약입니다.

특히 2월 29일을 atYear로 평년에 결합하면 말일로 조정될 수 있으므로, 기념일을 2월 28일로 옮길지 오류로 처리할지 먼저 정하고 isValidYear로 확인합니다.

데이터베이스에 YearMonth를 문자열로 저장한다면 2024-2처럼 길이가 달라지는 자체 형식 대신 ISO 형식 2024-02를 사용합니다.

부분 날짜가 보존하는 필드와 윤일 결합

PartialDateTypes에서 Year는2024, Month는FEBRUARY, YearMonth는2024-02, MonthDay는02-29만 갖는다. 2023년에는 윤일이 유효하지 않아 연도 결합 전에 정책을 정해야 한다.

PartialDateTypes의 네 값은 필요한 달력 필드만 갖습니다. 어느 값에도 시각이나 시간대는 없습니다.

부분 날짜가 보존하는 필드와 윤일 결합
타입과 값가진 정보예제에서의 사용
Year: 2024연도isLeap()은 true
Month: FEBRUARY월연도와 결합할 월 선택
YearMonth: 2024-02연도·월atEndOfMonth()는 2024-02-29
MonthDay: --02-29월·일; 연도 없음isValidYear(2023)는 false; atYear(2024)는 2024-02-29
Year: 2024

가진 정보: 연도

예제에서의 사용: isLeap()은 true

Month: FEBRUARY

가진 정보: 월

예제에서의 사용: 연도와 결합할 월 선택

YearMonth: 2024-02

가진 정보: 연도·월

예제에서의 사용: atEndOfMonth()는 2024-02-29

MonthDay: --02-29

가진 정보: 월·일; 연도 없음

예제에서의 사용: isValidYear(2023)는 false; atYear(2024)는 2024-02-29

MonthDay의 2월 29일은 연도 없이 유효합니다. 평년에 atYear로 결합하면 2월 28일로 조정되므로, 이를 허용할지 거부할지 업무 규칙을 정합니다. 본문은 isValidYear로 먼저 확인합니다.


게시글의 날짜와 순간

게시글을 작성한 날짜는 사용자의 지역 달력 값이고, 서버에 저장된 시각은 Instant로 보관할 수 있습니다.

두 값은 목적이 다릅니다.

src/BoardTimeModel.java
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

public final class BoardTimeModel {
    public static void main(String[] args) {
        ZoneId userZone = ZoneId.of("Asia/Seoul");
        Instant recordedAt = Instant.parse("2025-03-10T00:30:00Z");
        LocalDate boardDate = recordedAt.atZone(userZone).toLocalDate();
        Post entry = new Post("time", 45, boardDate, recordedAt);

        System.out.println("date=" + entry.boardDate());
        System.out.println("instant=" + entry.recordedAt());
    }

    private record Post(String title, int viewCount, LocalDate boardDate, Instant recordedAt) { }
}
date=2025-03-10
instant=2025-03-10T00:30:00Z

사용자 지역이 바뀌어도 recordedAt은 같은 순간입니다.

boardDate를 기록 당시 지역 기준으로 보존할지 조회 시점 지역으로 다시 계산할지는 업무 규칙입니다.

둘을 모두 저장하면 그 의도를 명확히 해야 합니다.

하나의 순간에서 지역 게시일을 얻는 경로

BoardTimeModel은 recordedAt 2025-03-10T00:30:00Z에 Asia/Seoul을 적용한 뒤 날짜만 추출하여 boardDate 2025-03-10을 얻는다. Post는 원래 순간과 도출한 날짜를 별도 필드에 보관한다.

BoardTimeModel에서 recordedAt.atZone(userZone).toLocalDate()를 계산합니다. userZone은 Asia/Seoul입니다.

순간에 지역을 적용한 뒤 날짜만 추출 recordedAt은2025-03-10T00:30:00Z이다. Asia/Seoul을 적용한 ZonedDateTime은2025-03-10 09:30과+09:00 오프셋을 갖는다. 날짜만 추출한 boardDate는2025-03-10이며 시각이나 지역을 보관하지 않는다. 지역 적용 날짜 추출 recordedAt · 순간 Instant 2025-03-10T00:30:00Z 원래 순간은 바뀌지 않음 지역 적용 결과 ZonedDateTime 2025-03-10T09:30+09:00 Asia/Seoul boardDate LocalDate 2025-03-10 날짜만 보관
  1. recordedAt · Instant

    2025-03-10T00:30:00Z라는 순간입니다. 지역을 적용해도 원래 값은 바뀌지 않습니다.

  2. 지역 적용 · atZone(userZone)

    Asia/Seoul 규칙으로 ZonedDateTime을 얻습니다. 이 값은 2025-03-10T09:30+09:00과 지역 ID를 갖습니다.

  3. 날짜 추출 · toLocalDate()

    boardDate는 2025-03-10입니다. 이 LocalDate 자체에는 시각·오프셋·지역이 없습니다.

Post에는 recordedAt과 boardDate를 별도 필드로 보관합니다. 지역 적용 중간값을 저장하는 필드는 없습니다.

날짜만으로 원래 순간을 복원할 수는 없습니다. 게시일을 기록 당시 지역으로 보존할지, 조회 지역에 맞춰 다시 계산할지는 별도의 업무 규칙입니다.


연습 문제

200, 400, 404를 enum으로 조회하고 성공 상태일 때만 오늘 대신 고정 날짜 2025-01-01의 BoardRecord를 만드세요.

500은 정의되지 않은 코드로 안내합니다.

해설 보기
src/HttpDatedRecordExercise.java
import java.time.LocalDate;

public final class HttpDatedRecordExercise {
    public static void main(String[] args) {
        create(200);
        create(500);
    }

    private static void create(int code) {
        HttpStatus status = null;
        for (HttpStatus value : HttpStatus.values()) {
            if (value.code == code) {
                status = value;
                break;
            }
        }
        if (status == null) {
            System.out.println("undefined=" + code);
            return;
        }
        if (status == HttpStatus.OK) {
            System.out.println(new BoardRecord("time", LocalDate.of(2025, 1, 1)));
        }
    }

    private enum HttpStatus {
        OK(200), BAD_REQUEST(400), NOT_FOUND(404);
        private final int code;
        HttpStatus(int code) { this.code = code; }
    }

    private record BoardRecord(String title, LocalDate date) { }
}
BoardRecord[title=time, date=2025-01-01]
undefined=500

예제는 null을 조회 직후 같은 메서드 안에서 검사합니다.

이 결과가 여러 계층을 지나야 한다면 ch33의 선택 결과 타입으로 계약을 더 분명히 만듭니다.