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

안동민 개발노트

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

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에서 배우는 키 기반 컬렉션을 검토할 수 있습니다. 중복 코드는 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를 붙이면 불필요한 복잡성이 생깁니다. 데이터가 답해야 할 질문을 먼저 정합니다.

연월일 전용 값 타입

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 studyYear = Year.of(2024);
        Month billingMonth = Month.FEBRUARY;
        YearMonth billingPeriod = YearMonth.of(studyYear.getValue(), billingMonth);
        MonthDay anniversary = MonthDay.of(Month.FEBRUARY, 29);

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

        if (!anniversary.isValidYear(studyYear.getValue())) {
            throw new IllegalStateException("anniversary is invalid in " + studyYear);
        }
        LocalDate anniversaryDate = anniversary.atYear(studyYear.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를 사용합니다.

학습 기록의 날짜와 순간

학습한 날짜는 사용자의 지역 달력 값이고, 서버에 기록된 시각은 Instant로 보관할 수 있습니다. 두 값은 목적이 다릅니다.

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

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

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

    private record StudyEntry(String topic, int minutes, LocalDate studyDate, Instant recordedAt) { }
}
date=2025-03-10
instant=2025-03-10T00:30:00Z

사용자 지역이 바뀌어도 recordedAt은 같은 순간입니다. studyDate를 기록 당시 지역 기준으로 보존할지 조회 시점 지역으로 다시 계산할지는 업무 규칙입니다. 둘을 모두 저장하면 그 의도를 명확히 해야 합니다.

연습 문제

200, 400, 404를 enum으로 조회하고 성공 상태일 때만 오늘 대신 고정 날짜 2025-01-01의 StudyRecord를 만드세요. 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 StudyRecord("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 StudyRecord(String topic, LocalDate date) { }
}
StudyRecord[topic=time, date=2025-01-01]
undefined=500

예제는 null을 조회 직후 같은 메서드 안에서 검사합니다. 이 결과가 여러 계층을 지나야 한다면 ch33의 선택 결과 타입으로 계약을 더 분명히 만듭니다.