HTTP 상태 코드 조회와 java.time 타입 고르기
정수 코드로 enum 상수를 찾지 못했을 때 돌아오는 null을 안전하게 처리하고, 날짜와 시간 계산이 까다로운 이유를 바탕으로 java.time의 여러 타입 가운데 알맞은 것을 고르는 기준을 세웁니다.
숫자 하나만 보고는 뜻을 알 수 없는 값이 있습니다. 웹 서버가 돌려준 404는 정수일 뿐이라서, 이것이 '찾을 수 없음'이라는 뜻이라는 것은 코드를 읽는 사람이 따로 알고 있어야 합니다. 날짜와 시간은 더 심합니다. 2025-03-10 09:00이라고만 적혀 있으면 서울의 9시인지 런던의 9시인지 알 수 없고, 두 시각은 실제로 9시간이나 차이 납니다.
이 글은 이런 값에 타입으로 뜻을 붙이는 두 가지 경우를 다룹니다. 먼저 HTTP 상태 코드를 enum으로 표현하고 정수 코드로 상수를 찾는 조회 메서드를 만듭니다. 찾지 못했을 때 돌아오는 null을 어디서 검사해야 하는지가 핵심입니다. 이어서 날짜와 시간 계산이 왜 까다로운지 살펴보고, java.time 패키지가 제공하는 여러 타입 중에서 데이터에 맞는 것을 고르는 기준을 세웁니다.
정수 코드로 enum 상수 찾기
HTTP 상태 코드는 웹 서버가 요청을 처리한 결과를 알려 주는 세 자리 숫자입니다. 200은 성공(OK), 400은 잘못된 요청(Bad Request), 404는 요청한 대상을 찾을 수 없음(Not Found)을 뜻합니다. HTTP 메시지의 구조와 상태 코드의 의미는 27장에서 자세히 다루고, 여기서는 이 세 코드만 enum으로 표현합니다. 각 상수에 코드와 설명을 필드로 두면, 정수 하나가 이름과 설명을 가진 값이 됩니다.
외부에서 받는 것은 정수 코드이므로, 코드로 상수를 찾는 findByCode 메서드가 필요합니다. 앞 글의 valueOf는 이름으로 찾았지만 이번에는 코드로 찾아야 하므로 직접 만들어야 합니다. 다음 프로그램은 values()로 모든 상수를 돌며 코드가 같은 상수를 돌려주고, 끝까지 찾지 못하면 null을 돌려줍니다. 그리고 등록되지 않은 코드 999로 조회합니다.
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: Cannot invoke "UnknownHttpStatusFailure$HttpStatus.description()" because "<local1>" is null999와 같은 코드를 가진 상수가 없으므로 반복문이 끝까지 돌고 findByCode는 null을 돌려줍니다. 여기까지는 예외가 나지 않습니다. 예외는 다음 줄에서 null인 status에 description()을 호출할 때 납니다. 메시지는 description()을 호출할 수 없다고 알려 주는데, <local1>은 main의 1번 지역 변수인 status를 가리킵니다(0번은 args). 중첩된 enum의 이름이 UnknownHttpStatusFailure$HttpStatus처럼 $로 이어져 보이는 것은 클래스 파일 안에서 중첩 클래스를 부르는 이름이기 때문입니다.
이 예외에서 눈여겨볼 점은 문제의 원인과 예외가 난 자리가 다르다는 것입니다. 원인은 등록되지 않은 코드 999였지만, 예외는 그 결과를 사용한 description() 호출에서 났습니다. 이 예제는 두 줄이 붙어 있어 금방 찾을 수 있지만, 조회 결과가 다른 메서드로 넘어가 한참 뒤에 사용된다면 스택 트레이스가 가리키는 곳에서 999라는 입력까지 거슬러 올라가기가 훨씬 어렵습니다.
찾지 못한 결과는 바로 검사한다
사용자가 입력한 코드라면 등록되지 않은 값이 들어오는 것은 정상적인 상황입니다. 그래서 조회 결과를 받자마자 null인지 검사하고, null이면 안내를 출력한 뒤 메서드를 끝냅니다.
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=999200은 OK 상수를 찾으므로 if를 건너뛰고 코드와 설명을 출력합니다. 999는 null이 돌아오므로 if 안에서 undefined=999를 출력하고 return으로 메서드를 끝냅니다. return 덕분에 그 아래의 found.code는 found가 null이 아닐 때만 실행됩니다. print가 enum의 private 필드인 code와 description을 직접 읽을 수 있는 것은 enum이 같은 최상위 클래스 안에 중첩되어 있기 때문입니다.
아래 단계 그림은 두 예제의 차이를 정리합니다. findByCode가 null을 돌려주는 것은 같고, 달라지는 것은 반환 직후의 처리입니다.
HTTP 조회 실패와 즉시 null 검사
HTTP 코드 검색 뒤 null 가드가 200의 값 읽기와 999의 조기 종료를 가르는 실제 호출 흐름입니다. 검색/검사/관찰을 구별해 미조회 값을 읽지 않는 이유를 설명할 수 있습니다.
두 HttpStatus 조회 구현은 모두 미발견 시 null을 반환한다. 실패 예제는 곧바로 description을 호출해 NPE가 나고 안전 예제는 found를 즉시 확인하여 undefined를 출력하고 종료한다.
조회 실패는 반환 직후 검사해 안내로 바꿉니다
두 예제 모두 findByCode는 찾지 못하면 null을 반환합니다. 차이는 반환 직후의 처리입니다.
| 예제와 입력 | 조회 결과 | 뒤따르는 동작 |
|---|---|---|
실패 예제 · 999 | null | status.description()에서 NullPointerException |
검사한 예제 · 200 | HttpStatus.OK | 200=OK 출력 |
검사한 예제 · 999 | null | undefined=999 출력 후 return; 필드 접근 없음 |
- 실패 예제 ·
999 조회 결과:
null뒤따르는 동작:
status.description()에서NullPointerException- 검사한 예제 ·
200 조회 결과:
HttpStatus.OK뒤따르는 동작:
200=OK출력- 검사한 예제 ·
999 조회 결과:
null뒤따르는 동작:
undefined=999출력 후return; 필드 접근 없음
이 예제의 지원 집합은 200·400·404입니다. undefined는 이 조회 표에 없다는 뜻이며, 모든 HTTP 상태 코드의 유효성 판정은 아닙니다. 미발견 값을 OK 같은 기본 상수로 바꾸지 않습니다.
이 예의 조건
- 부분 목록 200/400/404의 검색이며 HTTP 표준 전체 등록이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 부분 목록 200/400/404 검색 후 참조 있음→200의 OK 읽기, 없음→undefined=999로 종료하여 필드에 접근하지 않음. 500은 HTTP 표준에는 있지만 이 목록에는 미등록. |
| 5 | 400 |
| 6 | 404 |
| 구분 | 내용 |
|---|---|
| 설명 | 부분 목록 200/400/404 검색 후 참조 있음→200의 OK 읽기, 없음→undefined=999로 종료하여 필드에 접근하지 않음. 500은 HTTP 표준에는 있지만 이 목록에는 미등록. |
| 전체 읽기 정보 | HTTP 조회 실패와 즉시 null 검사 HTTP 조회 실패와 즉시 null 검사 두 HttpStatus 조회 구현은 모두 미발견 시 null을 반환한다. 실패 예제는 곧바로 description을 호출해 NPE가 나고 안전 예제는 found를 즉시 확인하여 undefined를 출력하고 종료한다. 조회 실패는 반환 직후 검사해 안내로 바꿉니다 두 예제 모두 findByCode 는 찾지 못하면 null 을 반환합니다. 차이는 반환 직후의 처리입니다. HTTP 조회 실패와 즉시 null 검사 예제와 입력 조회 결과 뒤따르는 동작 실패 예제 · 999 null status.description() 에서 NullPointerException 검사한 예제 · 200 HttpStatus.OK 200=OK 출력 검사한 예제 · 999 null undefined=999 출력 후 return ; 필드 접근 없음 실패 예제 · 999 조회 결과: null 뒤따르는 동작: status.description() 에서 NullPointerException 검사한 예제 · 200 조회 결과: HttpStatus.OK 뒤따르는 동작: 200=OK 출력 검사한 예제 · 999 조회 결과: null 뒤따르는 동작: undefined=999 출력 후 return ; 필드 접근 없음 이 예제의 지원 집합은 200·400·404입니다. undefined는 이 조회 표에 없다는 뜻이며, 모든 HTTP 상태 코드의 유효성 판정은 아닙니다. 미발견 값을 OK 같은 기본 상수로 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 목록 검색 code로 등록된 상수를 찾습니다. |
|
|
| 즉시 가드 조회 결과의 null 여부를 확인합니다. |
|
|
| 관찰과 종료 200은 OK를 읽고 999는 안내 후 종료합니다. |
|
|
HTTP 조회 실패와 즉시 null 검사
HTTP 코드 검색 뒤 null 가드가 200의 값 읽기와 999의 조기 종료를 가르는 실제 호출 흐름입니다. 검색/검사/관찰을 구별해 미조회 값을 읽지 않는 이유를 설명할 수 있습니다.
두 HttpStatus 조회 구현은 모두 미발견 시 null을 반환한다. 실패 예제는 곧바로 description을 호출해 NPE가 나고 안전 예제는 found를 즉시 확인하여 undefined를 출력하고 종료한다.
조회 실패는 반환 직후 검사해 안내로 바꿉니다
두 예제 모두 findByCode는 찾지 못하면 null을 반환합니다. 차이는 반환 직후의 처리입니다.
| 예제와 입력 | 조회 결과 | 뒤따르는 동작 |
|---|---|---|
실패 예제 · 999 | null | status.description()에서 NullPointerException |
검사한 예제 · 200 | HttpStatus.OK | 200=OK 출력 |
검사한 예제 · 999 | null | undefined=999 출력 후 return; 필드 접근 없음 |
- 실패 예제 ·
999 조회 결과:
null뒤따르는 동작:
status.description()에서NullPointerException- 검사한 예제 ·
200 조회 결과:
HttpStatus.OK뒤따르는 동작:
200=OK출력- 검사한 예제 ·
999 조회 결과:
null뒤따르는 동작:
undefined=999출력 후return; 필드 접근 없음
이 예제의 지원 집합은 200·400·404입니다. undefined는 이 조회 표에 없다는 뜻이며, 모든 HTTP 상태 코드의 유효성 판정은 아닙니다. 미발견 값을 OK 같은 기본 상수로 바꾸지 않습니다.
이 예의 조건
- 부분 목록 200/400/404의 검색이며 HTTP 표준 전체 등록이 아니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 부분 목록 200/400/404 검색 후 참조 있음→200의 OK 읽기, 없음→undefined=999로 종료하여 필드에 접근하지 않음. 500은 HTTP 표준에는 있지만 이 목록에는 미등록. |
| 5 | 400 |
| 6 | 404 |
| 구분 | 내용 |
|---|---|
| 설명 | 부분 목록 200/400/404 검색 후 참조 있음→200의 OK 읽기, 없음→undefined=999로 종료하여 필드에 접근하지 않음. 500은 HTTP 표준에는 있지만 이 목록에는 미등록. |
| 전체 읽기 정보 | HTTP 조회 실패와 즉시 null 검사 HTTP 조회 실패와 즉시 null 검사 두 HttpStatus 조회 구현은 모두 미발견 시 null을 반환한다. 실패 예제는 곧바로 description을 호출해 NPE가 나고 안전 예제는 found를 즉시 확인하여 undefined를 출력하고 종료한다. 조회 실패는 반환 직후 검사해 안내로 바꿉니다 두 예제 모두 findByCode 는 찾지 못하면 null 을 반환합니다. 차이는 반환 직후의 처리입니다. HTTP 조회 실패와 즉시 null 검사 예제와 입력 조회 결과 뒤따르는 동작 실패 예제 · 999 null status.description() 에서 NullPointerException 검사한 예제 · 200 HttpStatus.OK 200=OK 출력 검사한 예제 · 999 null undefined=999 출력 후 return ; 필드 접근 없음 실패 예제 · 999 조회 결과: null 뒤따르는 동작: status.description() 에서 NullPointerException 검사한 예제 · 200 조회 결과: HttpStatus.OK 뒤따르는 동작: 200=OK 출력 검사한 예제 · 999 조회 결과: null 뒤따르는 동작: undefined=999 출력 후 return ; 필드 접근 없음 이 예제의 지원 집합은 200·400·404입니다. undefined는 이 조회 표에 없다는 뜻이며, 모든 HTTP 상태 코드의 유효성 판정은 아닙니다. 미발견 값을 OK 같은 기본 상수로 바꾸지 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 목록 검색 code로 등록된 상수를 찾습니다. |
|
|
| 즉시 가드 조회 결과의 null 여부를 확인합니다. |
|
|
| 관찰과 종료 200은 OK를 읽고 999는 안내 후 종료합니다. |
|
|
null을 피하려고 찾지 못한 코드를 OK 같은 기본 상수로 바꾸어 돌려주는 방법은 쓰지 않습니다. 그러면 999를 받은 프로그램이 성공한 것처럼 동작하게 되어, 문자열 등급에서 알 수 없는 등급을 0% 할인으로 조용히 처리했던 것과 같은 문제가 생깁니다. 또 undefined는 이 enum의 목록에 없다는 뜻일 뿐, HTTP 표준에 없는 코드라는 뜻이 아닙니다. 예를 들어 500도 이 목록에는 없지만 표준에서는 서버 내부 오류(Internal Server Error)로 정의되어 있습니다.
null을 돌려받은 쪽이 바로 검사하는 방식은 지금처럼 검사하는 코드가 조회 바로 아래에 있을 때 잘 동작합니다. 하지만 null을 여러 메서드에 걸쳐 넘기면 어딘가에서 검사를 빠뜨리기 쉬우므로, 이 글에서는 반환 직후의 짧은 범위에서만 씁니다. 값이 없을 수 있다는 사실을 반환 타입으로 드러내는 Optional은 33장에서 배웁니다.
반대로 등록되지 않은 코드가 들어오는 것 자체가 버그인 경우도 있습니다. 내부 시스템끼리 주고받는 코드라면 999는 상대 시스템의 결함이므로, 안내를 출력하고 넘어가기보다 무엇이 잘못됐는지 알려 주는 예외로 즉시 멈추는 편이 낫습니다. 그럴 때는 enum에 다음과 같은 메서드를 하나 더 두고 호출합니다.
static HttpStatus requireByCode(int code) {
HttpStatus status = findByCode(code);
if (status == null) {
throw new IllegalArgumentException("unknown HTTP status code: " + code);
}
return status;
}requireByCode(404)는 NOT_FOUND를 돌려주고, requireByCode(999)는 java.lang.IllegalArgumentException: unknown HTTP status code: 999로 멈춥니다. NullPointerException과 달리 메시지에 문제의 입력값이 그대로 나오므로 원인을 바로 알 수 있습니다.
더 깊이: 상수가 많을 때의 조회와 중복 코드 검사
findByCode는 호출될 때마다 상수를 처음부터 하나씩 비교합니다. 게다가 values()는 호출할 때마다 새 배열을 만들어 돌려줍니다. 상수가 세 개뿐이라면 이 비용은 무시해도 되지만, 상수가 수백 개이고 조회가 매우 잦다면 코드를 키로 상수를 바로 찾는 표를 미리 만들어 둘 수 있습니다. 다음은 16장에서 배우는 Map을 쓴 예로, 이 코드를 쓰려면 java.util.HashMap과 java.util.Map을 import해야 합니다.
private static final Map<Integer, HttpStatus> BY_CODE = new HashMap<>();
static {
for (HttpStatus status : values()) {
HttpStatus previous = BY_CODE.put(status.code, status);
if (previous != null) {
throw new IllegalStateException("duplicate code: " + status.code);
}
}
}
static HttpStatus findByCode(int code) {
return BY_CODE.get(code);
}static { ... } 블록은 enum 클래스가 처음 사용될 때 한 번 실행되는 정적 초기화 블록입니다. enum 상수는 선언 순서상 가장 먼저 만들어지므로, 이 블록이 실행될 때는 모든 상수가 이미 준비되어 있습니다. 같은 이유로 생성자 안에서 BY_CODE.put(code, this)를 호출하는 방법은 쓸 수 없습니다. 상수의 생성자가 실행되는 시점에는 BY_CODE가 아직 초기화되지 않았기 때문에, 컴파일러가 illegal reference to static field from initializer라는 오류로 막습니다.
이 블록은 중복 코드 검사도 함께 합니다. Map.put은 같은 키에 이미 값이 있었으면 그 값을, 없었으면 null을 돌려줍니다. 실수로 두 상수에 같은 코드 200을 주면 두 번째 put에서 이전 값이 돌아오므로 예외를 던집니다. 정적 초기화 블록에서 던진 예외는 ExceptionInInitializerError로 감싸여, enum을 처음 사용하는 순간 원인인 IllegalStateException: duplicate code: 200과 함께 프로그램을 멈춥니다. 앞의 반복문 방식은 중복 코드가 있으면 아무 경고 없이 먼저 선언된 상수만 돌려주므로, 상수가 많아 실수할 가능성이 있다면 이런 검사를 두는 것이 안전합니다.
날짜와 시간 계산이 까다로운 이유
이제 두 번째 주제로 넘어갑니다. 날짜는 연, 월, 일 세 개의 정수로 충분해 보이지만, 직접 계산해 보면 예외 규칙이 계속 나옵니다.
먼저 달마다 일수가 다르고, 2월은 윤년인지에 따라 28일 또는 29일입니다. 윤년 규칙도 '4로 나누어떨어지는 해'로 끝나지 않습니다. 4로 나누어떨어지는 해는 윤년이지만 그중 100으로 나누어떨어지는 해는 평년이고, 그중에서도 400으로 나누어떨어지는 해는 다시 윤년입니다. 그래서 2024년은 윤년, 1900년과 2100년은 평년, 2000년은 윤년입니다.
시각이 끼어들면 시간대(time zone)가 문제가 됩니다. 예를 들어 1월에 서울이 오전 9시인 순간, 런던은 자정이고 뉴욕은 전날 오후 7시입니다. 게다가 여름에 시계를 한 시간 앞당기는 일광 절약 시간(daylight saving time, DST)을 쓰는 지역에서는 그 차이가 계절마다 바뀝니다. 시계를 앞당기는 날에는 건너뛴 한 시간의 시각이 아예 존재하지 않고, 되돌리는 날에는 같은 시각이 두 번 나타납니다. 이런 규칙은 나라마다 다르고 정부의 결정에 따라 바뀌기도 합니다. 지금은 일광 절약 시간을 쓰지 않는 서울도 1987년과 1988년 여름에는 이를 시행해서, 세계 시각의 기준이 되는 협정 세계시(Coordinated Universal Time, UTC)와의 차이가 평소의 9시간이 아니라 10시간이었습니다.
Java 8 이전에 쓰던 날짜 API는 이런 복잡함을 잘 감추지 못했습니다. java.util.Date는 이름과 달리 날짜가 아니라 1970년 1월 1일 0시(UTC)부터 흐른 밀리초로 한 순간을 나타내고, 출력할 때는 실행 환경의 기본 시간대로 바꾸어 보여 줍니다. 그래서 시간대가 서울로 설정된 컴퓨터에서 new Date(0)을 출력하면 Thu Jan 01 09:00:00 KST 1970이 나옵니다. java.util.Calendar는 월을 0부터 세기 때문에 Calendar.MARCH의 값이 2입니다. 또 두 클래스 모두 값을 바꾸는 메서드가 있어서, 불변 날짜 클래스와 equals·hashCode에서 본 것처럼 한 객체를 여러 곳에서 공유하면 한쪽의 변경이 다른 쪽에 번집니다.
Java 8에서 추가된 java.time 패키지는 이 문제를 두 가지 방법으로 풉니다. 첫째, 모든 타입이 불변이라서 계산 결과는 항상 새 객체로 돌아옵니다. 둘째, '날짜만', '시간대가 붙은 날짜와 시각', '한 순간'처럼 서로 다른 개념을 서로 다른 타입으로 나누었습니다. 윤년이나 시간대 규칙을 직접 표로 만들어 관리할 필요도 없습니다. JDK는 전 세계 시간대 규칙을 모은 IANA 시간대 데이터베이스를 내장하고, 규칙이 바뀌면 JDK 업데이트로 새 데이터를 배포합니다. 다음 프로그램으로 윤년 판정과 시간대 변환을 확인해 봅니다.
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
seoul=2025-03-10T09:00+09:00[Asia/Seoul]
london=2025-03-10T00:00Z[Europe/London]
same-instant=true첫 줄은 2024년이 윤년이라는 판정입니다. 둘째 줄의 ZonedDateTime은 날짜와 시각 뒤에 UTC와의 차이인 오프셋(offset) +09:00과 시간대 이름 [Asia/Seoul]을 함께 출력합니다. 셋째 줄은 withZoneSameInstant로 같은 순간을 런던 시간대로 바꾼 결과입니다. 이날 런던은 UTC와 차이가 없어서 오프셋이 +00:00 대신 Z로 표시되고, 서울보다 9시간 늦은 자정이 됩니다. 마지막 줄은 두 값을 시간대와 상관없는 순간인 Instant로 바꾸어 비교한 결과입니다. 표시는 달라도 같은 순간이므로 true입니다.
서울과 런던의 차이가 언제나 9시간인 것은 아닙니다. 런던은 2025년 3월 30일부터 일광 절약 시간에 들어가 오프셋이 +01:00이 되므로, 4월에는 서울 오전 9시가 런던 오전 1시가 됩니다. 이런 차이를 사람이 기억해서 계산하지 않고 JDK의 시간대 규칙에 맡기는 것이 ZonedDateTime을 쓰는 이유입니다.
java.time의 날짜·시간 타입 한눈에 보기
java.time의 주요 타입은 저마다 담는 정보가 다릅니다. 이름이 비슷해서 헷갈리기 쉬우니, 각 타입이 무엇을 담고 어디에 쓰는지 먼저 표로 정리해 둡니다.
| 타입 | 담는 정보 | 이런 데이터에 씁니다 |
|---|---|---|
LocalDate | 연·월·일 | 생일, 게시글 작성일 |
LocalTime | 시·분·초·나노초 | 매일 열리는 시각 |
LocalDateTime | 날짜와 시각 (시간대 없음) | 시간대를 따로 알고 있는 일정 |
ZonedDateTime | 날짜, 시각, 시간대 ID와 그때의 오프셋 | 특정 지역 기준의 회의 일정 |
OffsetDateTime | 날짜, 시각, UTC와의 고정된 차이 | 다른 시스템과 주고받는 시각 |
Instant | 전 세계 공통 타임라인의 한 순간 | 서버의 기록 시각 |
Period | 연·월·일 단위의 달력 기간 | 구독 기간 '1년 2개월' |
Duration | 초·나노초 단위의 시간 길이 | 처리에 걸린 시간 '90분' |
이름이 Local로 시작하는 세 타입에는 시간대 정보가 없습니다. 여기서 'local'은 특정 지역이 아니라 '어느 지역인지 정해지지 않은 벽시계의 값'이라는 뜻입니다. 그래서 LocalDateTime으로 2025-03-10 09:00만 저장하면 어느 지역의 9시인지 알 수 없고, 하나의 순간으로 확정되지도 않습니다. 반대로 생일에 시간대까지 붙이면 쓸데없이 복잡해집니다. 생일은 어느 나라에 있든 같은 날짜로 기억하는 값이기 때문입니다.
그러니 타입을 고를 때는 그 데이터가 답해야 할 질문을 먼저 정합니다. '며칠인가'만 답하면 되면 LocalDate, '전 세계 어디서 보든 같은 순간인가'를 답해야 하면 Instant입니다. 필요한 정보보다 적게 담는 타입을 고르면 나중에 되살릴 수 없는 정보가 사라지고, 많이 담는 타입을 고르면 쓰지 않는 정보가 의미를 흐립니다. 아래 그림은 각 타입이 보존하는 정보와, 그 값만으로는 알 수 없는 정보를 함께 보여 줍니다.
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를 따로 정하지 않으면 하나의 순간으로 확정되지 않습니다.
LocalDate부터 Instant까지는 다음 글에서 하나씩 만들어 보며 차이를 확인하고, 기간을 나타내는 Period와 Duration은 그다음 글에서 다룹니다.
연도, 월, 월일만 담는 타입
완전한 날짜보다 적은 정보만 필요할 때도 있습니다. 매월 정산은 '2024년 2월'처럼 연도와 월만 있으면 되고, 해마다 돌아오는 창립 기념일은 '2월 29일'처럼 월과 일만 있으면 됩니다. 이런 값을 LocalDate에 담으려면 쓰지도 않는 일이나 연도를 아무 값으로 채워야 하는데, 그러면 그 값이 진짜 데이터인지 채워 넣은 값인지 구별할 수 없습니다. java.time은 이런 경우를 위해 네 가지 타입을 따로 둡니다.
Year는 연도 하나를 나타내며, 그해가 윤년인지 알려 줍니다. 연도별 정책이나 윤년 여부만 필요할 때 씁니다.Month는 1월부터 12월까지를 나타내는 enum입니다. 연도 없이 월의 이름이나 순서만 필요할 때 씁니다.YearMonth는 연도와 월의 조합입니다. 청구월이나 정산월처럼 월 단위로 다루는 값과 그달의 마지막 날 계산에 알맞습니다.MonthDay는 연도 없는 월과 일입니다. 해마다 반복되는 기념일에 쓰고, 실제 날짜가 필요한 순간에 연도를 붙입니다.
다음 프로그램은 네 타입으로 2024년 2월의 정산 정보와 2월 29일 기념일을 다룹니다.
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-292024년은 윤년이므로 첫 줄은 true입니다. 둘째 줄의 atEndOfMonth()는 2024년 2월의 마지막 날을 LocalDate로 돌려주는데, 윤년이라 29일입니다. 그달이 며칠까지 있는지 직접 계산하지 않고 YearMonth에게 물으면 됩니다. 셋째 줄은 2월 29일이 2023년에 존재하는 날짜인지 묻는 isValidYear(2023)의 결과로, 2023년은 평년이므로 false입니다. 마지막으로 2024년에는 2월 29일이 유효하므로 if를 통과하고, atYear(2024)가 연도를 붙여 2024-02-29라는 완전한 날짜를 만듭니다.
atYear를 호출하기 전에 isValidYear로 확인하는 데에는 이유가 있습니다. 2월 29일을 평년에 atYear로 붙이면 예외가 나지 않고 2월 28일로 조정된 날짜가 돌아옵니다. MonthDay.of(2, 29).atYear(2023)은 2023-02-28입니다. 평년의 기념일을 2월 28일로 옮겨도 되는 서비스라면 이 동작을 그대로 쓰면 되지만, 그렇지 않다면 조용히 다른 날짜가 만들어지는 셈입니다. 그래서 평년에 기념일을 옮길지, 오류로 처리할지를 먼저 정하고 그에 맞게 isValidYear로 확인합니다. 이 예제는 오류로 처리하는 쪽을 골랐습니다.
부분 날짜 타입은 빠진 정보를 멋대로 채우지 않습니다. MonthDay에 연도가 없는 것은 결함이 아니라, 연도가 정해지지 않은 값이라는 뜻을 타입이 그대로 나타내는 것입니다. 아래 그림은 네 값이 각각 어떤 달력 필드만 가지고 있는지, 그리고 2월 29일에 연도를 붙일 때 어떤 선택이 필요한지 보여 줍니다.
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()은trueMonth: FEBRUARY가진 정보: 월
예제에서의 사용: 연도와 결합할 월 선택
YearMonth: 2024-02가진 정보: 연도·월
예제에서의 사용:
atEndOfMonth()는2024-02-29MonthDay: --02-29가진 정보: 월·일; 연도 없음
예제에서의 사용:
isValidYear(2023)는false;atYear(2024)는2024-02-29
MonthDay의 2월 29일은 연도 없이 유효합니다. 평년에 atYear로 결합하면 2월 28일로 조정되므로, 이를 허용할지 거부할지 업무 규칙을 정합니다. 본문은 isValidYear로 먼저 확인합니다.
YearMonth를 데이터베이스나 파일에 문자열로 저장한다면 toString()이 만드는 ISO 형식 2024-02를 그대로 쓰는 것이 좋습니다. 2024-2처럼 월을 한 자리로 쓰는 자체 형식은 YearMonth.parse가 읽지 못하고 DateTimeParseException을 던집니다. 또 월을 두 자리로 맞춘 문자열은 글자 순으로 정렬해도 시간 순서와 같아지지만, 2024-10과 2024-9를 글자 순으로 정렬하면 10월이 9월보다 앞에 옵니다.
게시글에 날짜와 순간을 함께 저장하기
이제 두 종류의 시간 정보를 함께 쓰는 예를 보겠습니다. 게시판에서 '작성일'은 글쓴이의 달력으로 본 날짜이고, 서버가 남기는 '기록 시각'은 전 세계 어디서 보든 같은 한 순간입니다. 그래서 기록 시각은 Instant로 보관하고, 작성일은 그 순간에 사용자의 시간대를 적용해 날짜만 꺼낸 LocalDate로 보관합니다.
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:00ZInstant.parse의 문자열 끝에 붙은 Z는 UTC 기준이라는 뜻이므로, recordedAt은 UTC로 2025년 3월 10일 0시 30분인 순간입니다. atZone(userZone)은 이 순간을 서울 시간대로 표현한 ZonedDateTime을 만들고, 서울은 UTC보다 9시간 빠르므로 그 값은 같은 날 오전 9시 30분입니다. 여기서 toLocalDate()로 날짜만 꺼내면 boardDate는 2025-03-10이 됩니다. Post 레코드는 원래의 순간과 거기서 얻은 날짜를 별도의 필드로 보관하며, 중간에 만든 ZonedDateTime은 저장하지 않습니다.
같은 순간이라도 시간대가 다르면 날짜가 달라질 수 있습니다. 뉴욕 사용자라면 recordedAt.atZone(ZoneId.of("America/New_York"))이 2025-03-09T20:30-04:00[America/New_York]이 되어 작성일이 하루 이른 3월 9일이 됩니다. 그래서 작성일을 글을 쓸 당시 사용자의 시간대로 한 번 계산해 저장할지, 조회할 때마다 보는 사람의 시간대로 다시 계산할지는 서비스가 정해야 할 규칙입니다. 이 예제처럼 둘을 모두 저장한다면 boardDate가 어느 시간대 기준인지도 함께 정해 두어야 합니다. 날짜만으로는 원래 순간을 되살릴 수 없기 때문입니다.
하나의 순간에서 지역 게시일을 얻는 경로
Instant에 서울 규칙을 적용하고 LocalDate를 도출해 두 필드를 저장하는 변환 순서입니다. 원래 순간 보존과 도출 날짜의 업무 결정을 단계별로 추적하며 저장 의미를 확인할 수 있습니다.
BoardTimeModel은 recordedAt 2025-03-10T00:30:00Z에 Asia/Seoul을 적용한 뒤 날짜만 추출하여 boardDate 2025-03-10을 얻는다. Post는 원래 순간과 도출한 날짜를 별도 필드에 보관한다.
순간에 지역을 적용하고 날짜만 추출합니다
BoardTimeModel에서 recordedAt.atZone(userZone).toLocalDate()를 계산합니다. userZone은 Asia/Seoul입니다.
recordedAt·Instant2025-03-10T00:30:00Z라는 순간입니다. 지역을 적용해도 원래 값은 바뀌지 않습니다.- 지역 적용 ·
atZone(userZone)Asia/Seoul규칙으로ZonedDateTime을 얻습니다. 이 값은2025-03-10T09:30+09:00과 지역 ID를 갖습니다. - 날짜 추출 ·
toLocalDate()boardDate는2025-03-10입니다. 이LocalDate자체에는 시각·오프셋·지역이 없습니다.
Post에는 recordedAt과 boardDate를 별도 필드로 보관합니다. 지역 적용 중간값을 저장하는 필드는 없습니다.
날짜만으로 원래 순간을 복원할 수는 없습니다. 게시일을 기록 당시 지역으로 보존할지, 조회 지역에 맞춰 다시 계산할지는 별도의 업무 규칙입니다.
이 예의 조건
- boardDate를 도출하는 지역은 Asia/Seoul이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 순간 2025-03-10T00:30Z→서울 09:30+09→LocalDate 03-10. Post는 원 순간과 도출한 날짜를 저장하며 지역 적용 중간값은 저장하지 않는다. |
| 8 | 09:30 |
| 9 | +09:00 |
| 구분 | 내용 |
|---|---|
| 설명 | 순간 2025-03-10T00:30Z→서울 09:30+09→LocalDate 03-10. Post는 원 순간과 도출한 날짜를 저장하며 지역 적용 중간값은 저장하지 않는다. |
| 전체 읽기 정보 | 하나의 순간에서 지역 게시일을 얻는 경로 하나의 순간에서 지역 게시일을 얻는 경로 BoardTimeModel은 recordedAt 2025-03-10T00:30:00Z에 Asia/Seoul을 적용한 뒤 날짜만 추출하여 boardDate 2025-03-10을 얻는다. Post는 원래 순간과 도출한 날짜를 별도 필드에 보관한다. 순간에 지역을 적용하고 날짜만 추출합니다 BoardTimeModel 에서 recordedAt.atZone(userZone).toLocalDate() 를 계산합니다. userZone 은 Asia/Seoul 입니다. recordedAt · Instant 2025-03-10T00:30:00Z 라는 순간입니다. 지역을 적용해도 원래 값은 바뀌지 않습니다. 지역 적용 · atZone(userZone) Asia/Seoul 규칙으로 ZonedDateTime 을 얻습니다. 이 값은 2025-03-10T09:30+09:00 과 지역 ID를 갖습니다. 날짜 추출 · toLocalDate() boardDate 는 2025-03-10 입니다. 이 LocalDate 자체에는 시각·오프셋·지역이 없습니다. Post 에는 recordedAt 과 boardDate 를 별도 필드로 보관합니다. 지역 적용 중간값을 저장하는 필드는 없습니다. 날짜만으로 원래 순간을 복원할 수는 없습니다. 게시일을 기록 당시 지역으로 보존할지, 조회 지역에 맞춰 다시 계산할지는 별도의 업무 규칙입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 순간 기록 순간을 보관합니다. |
|
|
| 지역 적용 서울 규칙으로 지역 날짜/시각을 얻습니다. |
|
|
| 날짜 저장 LocalDate를 꺼내 Post의 두 필드에 보관합니다. |
|
|
하나의 순간에서 지역 게시일을 얻는 경로
Instant에 서울 규칙을 적용하고 LocalDate를 도출해 두 필드를 저장하는 변환 순서입니다. 원래 순간 보존과 도출 날짜의 업무 결정을 단계별로 추적하며 저장 의미를 확인할 수 있습니다.
BoardTimeModel은 recordedAt 2025-03-10T00:30:00Z에 Asia/Seoul을 적용한 뒤 날짜만 추출하여 boardDate 2025-03-10을 얻는다. Post는 원래 순간과 도출한 날짜를 별도 필드에 보관한다.
순간에 지역을 적용하고 날짜만 추출합니다
BoardTimeModel에서 recordedAt.atZone(userZone).toLocalDate()를 계산합니다. userZone은 Asia/Seoul입니다.
recordedAt·Instant2025-03-10T00:30:00Z라는 순간입니다. 지역을 적용해도 원래 값은 바뀌지 않습니다.- 지역 적용 ·
atZone(userZone)Asia/Seoul규칙으로ZonedDateTime을 얻습니다. 이 값은2025-03-10T09:30+09:00과 지역 ID를 갖습니다. - 날짜 추출 ·
toLocalDate()boardDate는2025-03-10입니다. 이LocalDate자체에는 시각·오프셋·지역이 없습니다.
Post에는 recordedAt과 boardDate를 별도 필드로 보관합니다. 지역 적용 중간값을 저장하는 필드는 없습니다.
날짜만으로 원래 순간을 복원할 수는 없습니다. 게시일을 기록 당시 지역으로 보존할지, 조회 지역에 맞춰 다시 계산할지는 별도의 업무 규칙입니다.
이 예의 조건
- boardDate를 도출하는 지역은 Asia/Seoul이다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 순간 2025-03-10T00:30Z→서울 09:30+09→LocalDate 03-10. Post는 원 순간과 도출한 날짜를 저장하며 지역 적용 중간값은 저장하지 않는다. |
| 8 | 09:30 |
| 9 | +09:00 |
| 구분 | 내용 |
|---|---|
| 설명 | 순간 2025-03-10T00:30Z→서울 09:30+09→LocalDate 03-10. Post는 원 순간과 도출한 날짜를 저장하며 지역 적용 중간값은 저장하지 않는다. |
| 전체 읽기 정보 | 하나의 순간에서 지역 게시일을 얻는 경로 하나의 순간에서 지역 게시일을 얻는 경로 BoardTimeModel은 recordedAt 2025-03-10T00:30:00Z에 Asia/Seoul을 적용한 뒤 날짜만 추출하여 boardDate 2025-03-10을 얻는다. Post는 원래 순간과 도출한 날짜를 별도 필드에 보관한다. 순간에 지역을 적용하고 날짜만 추출합니다 BoardTimeModel 에서 recordedAt.atZone(userZone).toLocalDate() 를 계산합니다. userZone 은 Asia/Seoul 입니다. recordedAt · Instant 2025-03-10T00:30:00Z 라는 순간입니다. 지역을 적용해도 원래 값은 바뀌지 않습니다. 지역 적용 · atZone(userZone) Asia/Seoul 규칙으로 ZonedDateTime 을 얻습니다. 이 값은 2025-03-10T09:30+09:00 과 지역 ID를 갖습니다. 날짜 추출 · toLocalDate() boardDate 는 2025-03-10 입니다. 이 LocalDate 자체에는 시각·오프셋·지역이 없습니다. Post 에는 recordedAt 과 boardDate 를 별도 필드로 보관합니다. 지역 적용 중간값을 저장하는 필드는 없습니다. 날짜만으로 원래 순간을 복원할 수는 없습니다. 게시일을 기록 당시 지역으로 보존할지, 조회 지역에 맞춰 다시 계산할지는 별도의 업무 규칙입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 순간 기록 순간을 보관합니다. |
|
|
| 지역 적용 서울 규칙으로 지역 날짜/시각을 얻습니다. |
|
|
| 날짜 저장 LocalDate를 꺼내 Post의 두 필드에 보관합니다. |
|
|
정리
- 정수 코드로 enum 상수를 찾는 메서드는
values()를 돌며 비교하고, 찾지 못하면null을 돌려줄 수 있습니다. 이때 예외는 조회가 아니라 결과를 사용하는 곳에서 나므로, 결과를 받자마자 검사합니다. - 찾지 못한 코드를 OK 같은 기본 상수로 바꾸지 않습니다. 사용자 입력이면 안내로 바꾸고, 내부 시스템의 잘못이면 입력값을 담은 예외로 멈춥니다.
- 날짜와 시간은 월별 일수, 윤년의 4·100·400 규칙, 나라마다 다르고 바뀌기도 하는 시간대와 일광 절약 시간 때문에 직접 계산하기 어렵습니다.
java.time은 불변 타입과 JDK에 내장된 시간대 규칙으로 이 문제를 줄여 줍니다. Local로 시작하는 타입에는 시간대가 없고,ZonedDateTime은 시간대 규칙을,OffsetDateTime은 고정된 오프셋을,Instant는 한 순간을 담습니다. 데이터가 답해야 할 질문에 맞는 타입을 고릅니다.- 연도·월·월일만 필요하면
Year,Month,YearMonth,MonthDay를 씁니다. 2월 29일에 연도를 붙일 때는 평년을 어떻게 처리할지 먼저 정합니다.
연습 문제
문제 1: 성공 응답에만 기록 만들기
HTTP 상태 200, 400, 404를 enum으로 표현하고 코드로 상수를 찾는 findByCode를 만드세요. 코드 하나를 받는 create 메서드는 상태가 OK일 때만 제목이 "time"이고 날짜가 2025-01-01인 BoardRecord 레코드를 만들어 출력합니다. 목록에 있지만 OK가 아닌 상태는 no-record=상수이름을, 목록에 없는 코드는 undefined=코드를 출력합니다. 200, 404, 500으로 차례로 호출하세요. 날짜에 LocalDate.now() 대신 고정된 날짜를 쓰는 이유도 생각해 보세요.
해설 보기
import java.time.LocalDate;
public final class HttpDatedRecordExercise {
public static void main(String[] args) {
create(200);
create(404);
create(500);
}
private static void create(int code) {
HttpStatus status = HttpStatus.findByCode(code);
if (status == null) {
System.out.println("undefined=" + code);
return;
}
if (status == HttpStatus.OK) {
System.out.println(new BoardRecord("time", LocalDate.of(2025, 1, 1)));
} else {
System.out.println("no-record=" + status);
}
}
private enum HttpStatus {
OK(200), BAD_REQUEST(400), NOT_FOUND(404);
private final int code;
HttpStatus(int code) { this.code = code; }
static HttpStatus findByCode(int code) {
for (HttpStatus status : values()) {
if (status.code == code) return status;
}
return null;
}
}
private record BoardRecord(String title, LocalDate date) { }
}BoardRecord[title=time, date=2025-01-01]
no-record=NOT_FOUND
undefined=500create는 조회 결과를 받자마자 null인지 검사하고, null이면 안내를 출력한 뒤 return합니다. 그 아래에서는 status가 null이 아니므로 ==로 OK인지 비교할 수 있습니다. enum 상수는 하나씩만 존재하므로 == 비교가 안전합니다. 200은 OK이므로 레코드가 만들어지고, 레코드의 toString()이 BoardRecord[title=time, date=2025-01-01] 형식으로 출력합니다. 404는 목록에 있지만 OK가 아니므로 no-record=NOT_FOUND가, 500은 목록에 없으므로 undefined=500이 출력됩니다.
LocalDate.now()를 쓰면 실행하는 날마다 출력이 달라져서 결과를 예상하거나 비교할 수 없습니다. 그래서 예제와 테스트에서는 고정된 날짜를 씁니다. 또 undefined=500은 이 연습의 목록에 없다는 뜻일 뿐입니다. HTTP 표준을 정의한 RFC 9110은 500을 서버 내부 오류(Internal Server Error)로 정의합니다.
문제 2: 윤년 판정과 2월 29일
1900년, 2000년, 2028년, 2100년이 각각 윤년인지 규칙으로 판정하세요. 그리고 MonthDay.of(2, 29)에 대해 isValidYear(2100)과 atYear(2100)이 무엇을 돌려줄지 예상하세요.
해설 보기
1900년은 4와 100으로 나누어떨어지지만 400으로는 나누어떨어지지 않으므로 평년입니다. 2000년은 400으로 나누어떨어지므로 윤년입니다. 2028년은 4로 나누어떨어지고 100으로는 나누어떨어지지 않으므로 윤년입니다. 2100년은 1900년과 같은 이유로 평년입니다. Year.isLeap(1900)처럼 확인하면 차례로 false, true, true, false가 나옵니다.
2100년은 평년이므로 isValidYear(2100)은 false입니다. 그런데 atYear(2100)은 예외를 던지지 않고 2월 28일로 조정한 2100-02-28을 돌려줍니다. 이 차이 때문에 본문에서는 atYear 전에 isValidYear로 확인해 평년의 처리 방식을 직접 정했습니다.
문제 3: 데이터에 맞는 타입 고르기
다음 데이터를 저장할 타입을 고르고 이유를 한 문장으로 설명하세요.
- 회원의 생일
- 매일 아침 알림을 보내는 시각
- 서버가 남기는 로그의 기록 시각
- 월별 정산 기간
- 해마다 돌아오는 창립 기념일
- 서울 시간 기준으로 잡은 국제 화상 회의
해설 보기
LocalDate입니다. 생일은 어느 시간대에 있든 같은 날짜로 기억하는 값이라 시각과 시간대가 필요 없습니다.LocalTime입니다. 날짜와 상관없이 매일 같은 시각이 반복됩니다. 실제로 알림을 보낼 순간을 정하려면 그날의 날짜와 사용자의 시간대를 붙여야 합니다.Instant입니다. 여러 서버의 기록을 시간 순서로 비교하려면 지역과 상관없는 한 순간이어야 합니다.YearMonth입니다. 연도와 월만 있으면 되고, 그달의 마지막 날도atEndOfMonth()로 구할 수 있습니다.MonthDay입니다. 연도가 정해지지 않은 월과 일이며, 2월 29일이라면 평년의 처리 방식을 따로 정해야 합니다.ZonedDateTime입니다. 서울이라는 시간대 규칙이 있어야 다른 나라 참가자의 현지 시각으로 정확히 바꿀 수 있습니다.
다음 글
다음 글에서는 이 글의 표에 있는 LocalDate, LocalDateTime, ZonedDateTime, OffsetDateTime, Instant를 하나씩 만들어 봅니다. 존재하지 않는 날짜를 만들 때 무슨 일이 생기는지, 시계를 앞당기거나 되돌리는 날의 시각은 어떻게 처리되는지도 실제로 실행해 확인합니다.