이전 글 요약
첫 번째 문제는 대회 스케쥴에 대한 Custom Validator에서 NullPointerException이 발생했습니다. @NotNull보다 Custom Validator가 먼저 실행되기 때문에 올바른 필드 값이 들어오지 않으면 null을 참조하게 되는 것이 원인이었습니다.
두 번쨰 문제는 Null Check하는 로직을 추가하면서 날짜 형식에 대한 Validation(@JsonFormat)을 추가했는데 해당 부분에서 데이터가 바인딩 되지 않는 문제가 추가로 발생했습니다. 프론트에서는 "yyyy-MM-dd'T'HH:mm" 패턴 형식으로 보내고, 백엔드에서는 "yyyy-MM-dd HH:mm" 형식으로 설정했기 때문이었습니다.
두 번째 문제를 해결하면서 Timezone과 관련된 문제점을 발견했습니다. 프론트에서 DateTime을 보낼때 "yyyy-MM-dd'T'HH:mm:ss.SSSXXXZ"와 같이 UTC 시간을 사용하고 있었지만, 백엔드 서버는 LocalDateTime을 사용하고 있었기 떄문에 UTC 시간이 적절히 변환되지 않은 상태로 DB에 저장되고 있었습니다.
Timezone 관련해서는 이전 프로젝트에서도 어떻게 할지 난감했는데, 이번 기회에 정리해야겠다고 생각했습니다.
Timezone
Timezone을 이해하기 위해서는 UTC(Coordinated Universal Time)를 알아야합니다.
UTC란 세계 협정시입니다. 시간은 경도를 기준으로 위치에 따라 달라지기 때문에 표준으로 사용할 시간 기준이 필요합니다. UTC는 그리니치 천문대 평균시(GMT, Greenwich Mean Time)를 기준으로 합니다. 즉, GMT와 UTC 시간은 동일합니다.
참고로 우리나라 시간은 UTC 기준으로 9시간을 더해야하고, UTC+9와 같이 표기합니다.
LocalDateTime
public final class LocalDateTime
implements Temporal, TemporalAdjuster, ChronoLocalDateTime<LocalDate>, Serializable {
public static final LocalDateTime MIN = LocalDateTime.of(LocalDate.MIN, LocalTime.MIN);
public static final LocalDateTime MAX = LocalDateTime.of(LocalDate.MAX, LocalTime.MAX);
@java.io.Serial
private static final long serialVersionUID = 6207766400415563566L;
/**
* The date part.
*/
private final LocalDate date;
/**
* The time part.
*/
private final LocalTime time;
// ...
}
LocalDateTime은 날짜를 표현하는 클래스인 LocalDate와 시간을 표현하는 클래스인 LocalTime을 합쳐놓은 클래스입니다. LocalDate에 atTime() 메서드를 사용하거나, LocalTime에 atDate() 메서드를 사용하면 LocalDateTime을 얻을 수 있습니다.
2024-08-30T10:51:51.360998
LocalDateTime의 toString() 메서드는 ISO-8601 형식을 따르기 때문에 위와 같이 출력됩니다.
ZonedDateTime
@jdk.internal.ValueBased
public final class ZonedDateTime
implements Temporal, ChronoZonedDateTime<LocalDate>, Serializable {
/**
* Serialization version.
*/
@java.io.Serial
private static final long serialVersionUID = -6260982410461394882L;
/**
* The local date-time.
*/
private final LocalDateTime dateTime;
/**
* The offset from UTC/Greenwich.
*/
private final ZoneOffset offset;
/**
* The time-zone.
*/
private final ZoneId zone;
// ...
}
ZonedDateTime은 LocalDateTime에 ZoneId가 합쳐진 클래스 입니다. 따라서 LocalDate나 LocalTime과 같이 LocalDateTime에 atZone() 메서드를 사용하면 ZonedDateTime을 얻을 수 있습니다.
2024-08-30T10:51:51.360998+09:00[Asia/Seoul] # ZoneOffset과 ZoneId가 다른경우
2024-08-30T10:53:21.158788+09:00 # ISO-8601 호환
ZonedDateTime은 기본적으로 2024-08-30T10:51:51.360998+09:00[Asia/Seoul]와 같이 출력되지만, ZoneOffset과 ZoneId가 같으면 ISO-8601과 호환되는 2024-08-30T10:53:21.158788+09:00 형식으로 출력됩니다.
uuuu-MM-dd'T'HH:mm:ss.SSSSSSZ
uuuu-MM-dd'T'HH:mm:ss.SSSSSS+09:00
ISO-8601에서는 UTC 시간대의 경우 Z를 붙여 2024-08-30T10:53:21.158788Z로 표기하며, Timezone에 따라서 2024-08-30T10:53:21.158788+09:00와 같이 뒤에 오프셋 값을 표기합니다.
LocalDateTime과 ZonedDateTime
public class Main {
public static void main(String[] args) {
ZonedDateTime zonedDateTime = ZonedDateTime.now();
ZonedDateTime utcDateTime = zonedDateTime.withZoneSameInstant(ZoneId.of("UTC"));
System.out.println(zonedDateTime);
System.out.println(utcDateTime);
}
}
2024-08-30T01:35:20.033657Z[UTC]
2024-08-30T10:35:20.033657+09:00[Asia/Seoul] # UTC 시간대 기준으로 9시간이 추가되었음.
당연하지만 LocalDateTime에는 시간대에 대한 정보가 없기 떄문에 UTC를 기준으로 받아온 시간 정보에 ZoneId를 Asia/Seoul로 추가하더라도 Offset을 더하거나 뺴주지 않습니다. 특정 시간대의 ZonedDateTime을 다른 ZonedDateTime으로 변환하기 위해서는 Instant 클래스를 사용하여 Epoch 시간으로 변환하여 계산합니다.
@Override
public ZonedDateTime withZoneSameInstant(ZoneId zone) {
Objects.requireNonNull(zone, "zone");
return this.zone.equals(zone) ? this :
create(dateTime.toEpochSecond(offset), dateTime.getNano(), zone);
}
private static ZonedDateTime create(long epochSecond, int nanoOfSecond, ZoneId zone) {
ZoneRules rules = zone.getRules();
Instant instant = Instant.ofEpochSecond(epochSecond, nanoOfSecond); // TODO: rules should be queryable by epochSeconds
ZoneOffset offset = rules.getOffset(instant);
LocalDateTime ldt = LocalDateTime.ofEpochSecond(epochSecond, nanoOfSecond, offset);
return new ZonedDateTime(ldt, offset, zone);
}
ZonedDateTime 클래스에서 시간대를 변경하기 위한 withZoneSameInstant() 메서드를 보면 내부적으로 Instant를 사용하는 것을 볼 수있습니다.
Instant
Instant는 EPOCH TIME(1970-01-01 00:00:00 UTC)부터 경과된 시간을 나노초 단위로 표현한 것입니다. 이는 단일 진법으로만 계산하기 때문에 데이터베이스 등에 사용됩니다. Instant는 항상 UTC를 기준으로 하기 때문에 시간대 (time-zone)을 고려해야하는 경우 OffsetDateTime을 사용합니다.
public class Main {
public static void main(String[] args) {
Instant instant = Instant.now();
OffsetDateTime offsetDateTime = instant.atOffset(ZoneOffset.ofHours(9));
System.out.println("OffsetDateTime: " + offsetDateTime);
}
}
Instant를 OffsetDateTime으로 변환하기 위해서는 위와같이 atOffset() 메서드를 사용하여 ZoneOffset을 더하면 됩니다.
정리
Java에서 Timezone과 관련된 LocalDateTime, ZonedDateTime 그리고 Instant 클래스에 대해 알아보았습니다.
LocalDateTime은 Timezone에 관련된 정보가 없기 때문에 어떤 시간이 들어와도 시스템에 기본적으로 설정된 Timezone을 사용하게 됩니다. 여러 시간대를 사용하기 위해서는 Timezone 정보가 포함된 ZonedDateTime을 사용해야합니다. 또한 특정 시간대의 ZonedDateTime을 다른 시간대로 변환하기 위해서는 Instant 클래스를 사용합니다.
@Configuration
public class TimeZoneConfiguration {
@PostConstruct
public void init() {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
}
}
현재 저희 프로젝트에서는 Timezone을 Asia/Seoul로 설정하고 LocalDateTime을 사용하고 있습니다. 따라서 프론트에서 날짜 정보를 전달할 때 UTC 시간을 적절히 변환해서 보내주어야합니다.
추가: Typescript 날짜 변환
백엔드에서는 LocalDateTime을 사용하고 Timezone을 Asia/Seoul로 설정했기 떄문에 프론트엔드에서 적절하게 시간을 변환해서 보내야합니다. 이를 위해 Typescript에서 어떻게 날짜를 변환하는지 알아보았습니다.
String.prototype.replace()
var today = new Date();
today.setHours(today.getHours() + 9);
today.toISOString().replace("T", " ").substring(0, 19);
2024-08-30T10:51:51.360998 // toISOString() 결과
2024-08-30 10:51:51 // 문자열 변환 결과
첫 번째 방법은 문자열을 변환하여 날짜 형식을 맞춰주는 것입니다. 위 예시는 new Date()로 생성한 UTC 시간에 오프셋(+09:00)을 적용하고, 형식에 맞게 변환하는 예제입니다. 여기서는 문자 T와 나노초를 제거했습니다.
Day.js 라이브러리
두 번째 방법은 Day.js 라이브러리를 활용하는 것입니다. 자세한 내용은 해당 라이브러리를 참고하면 될 것 같습니다.
https://github.com/iamkun/dayjs
'회고록 > Project' 카테고리의 다른 글
[42GG] Custom Validator 실행 순서 (0) | 2024.08.04 |
---|---|
[42GG] 데이터 플로우 다이어그램 작성 (0) | 2024.07.26 |
[42GG] 효율적인 TestFixture 관리 (0) | 2024.07.22 |
[42GG] UpdateDto와 TestFixture (0) | 2024.07.18 |
[42GG] JPA UPDATE 로직 (영속성 컨텍스트, @DynamicUpdate, 벌크 연산) (0) | 2024.07.17 |