Yebali

OffsetDateTime의 시간 비교 본문

Kotlin

OffsetDateTime의 시간 비교

예발이 2023. 6. 4. 17:18

현재 일하는 회사의 시설 예약서버에서는 날짜와 시간을 한 번에 다루기 위해 'OffsetDateTime' 타입을 사용한다.

OffsetDateTime은 연월일+시분초 데이터뿐만 아니라 Timezone 정보를 함께 포함하는 데이터이다.

위 타입을 사용하면서 시간을 비교할 때 유의해야 하는 몇 가지가 있다.

동등성 비교

동등성이란 두 객체가 가지는 값이 서로 같다는 의미이다.

@Test
fun `동등성`() {
    val `UTC 1월 1일 0시` = OffsetDateTime.parse("2023-01-01T00:00:00Z")
    val `Seoul 1월 1일 9시` = OffsetDateTime.parse("2023-01-01T09:00:00+09:00")

    // Equals(Object obj)은 offset까지 같아야 한다.
    assertThat(`UTC 1월 1일 0시` == `Seoul 1월 1일 9시`).isFalse

    // isEquals(OffsetDateTime other)은 offset이 달라도 같다.
    assertThat(`UTC 1월 1일 0시`.isEqual(`Seoul 1월 1일 9시`)).isTrue
    
    // toInstant()은 offset이 달라도 같다.
    assertThat(`UTC 1월 1일 0시`.toInstant() == `Seoul 1월 1일 9시`.toInstant()).isTrue
}

먼저 동일한 시간을 각각 UTC, Asia/Seoul(+09:00)의 타임존으로 설정하고 각각 '==', 'isEqual()', 'toInstant'를 사용해 비교했다.

'=='을 사용한 동등성 비교

'=='는 두 객체가 서로 동등하지 않다고 판단한다.

@Override
public boolean equals(Object obj) {
    if (this == obj) {
        return true;
    }
    return (obj instanceof OffsetDateTime other)
            && dateTime.equals(other.dateTime)
            && offset.equals(other.offset);
}

OffsetDateTime.java 내부의 구현을 보면 위처럼 dateTime(LocalDateTime)과 offset이 모두 같아야 두 객체가 동등하다고 판단한다.

예시에서는 두 객체가 시간과 offset이 모두 다르기 때문에 'false'를 반환한다.

isEqual()을 사용한 동등성 비교

'isEqual()'은 두 객체가 서로 동등하다고 판단한다.

// OffsetDateTime.java
public boolean isEqual(OffsetDateTime other) {
    return toEpochSecond() == other.toEpochSecond() &&
            toLocalTime().getNano() == other.toLocalTime().getNano();
}

public long toEpochSecond() {
    return dateTime.toEpochSecond(offset);
}


// ChronoLocalDateTime.java
default long toEpochSecond(ZoneOffset offset) {
    Objects.requireNonNull(offset, "offset");
    long epochDay = toLocalDate().toEpochDay();
    long secs = epochDay * 86400 + toLocalTime().toSecondOfDay();
    secs -= offset.getTotalSeconds();
    return secs;
}

'==' 과는 다르게 각 객체의 값을 offset을 감안하여 'dateTime'과 'dateTime'을 LocalTime으로 변환한 값들을 각각 epoch second로 변환하여 서로 비교한다.

toInstant()를 사용한 동등성 비교

'toInstant()'은 두 객체가 서로 동등하다고 판단한다.

// OffsetDateTime.java
public Instant toInstant() {
    return dateTime.toInstant(offset);
}

// ChronoLocalDateTime.java
default Instant toInstant(ZoneOffset offset) {
    return Instant.ofEpochSecond(toEpochSecond(offset), toLocalTime().getNano());
}

'toInstant()'역시 내부의 'dateTime'을 offset을 반영하여 epoch second로 변환하여 비교하기 때문에 'isEqual()'과 동일한 결과를 반환한다.

대소 비교

@Test
fun `대소비교`() {
    val `UTC 1월 1일 0시` = OffsetDateTime.parse("2023-01-01T00:00:00Z")
    val `Seoul 1월 1일 9시` = OffsetDateTime.parse("2023-01-01T09:00:00+09:00")

    // compareTo(OffsetDateTime other)은 Offset을 고려하지 않기 때문에 Asia/seoul이 더 크다고 판단한다.
    assertThat(`UTC 1월 1일 0시` < `Seoul 1월 1일 9시`).isTrue
    assertThat(`UTC 1월 1일 0시` > `Seoul 1월 1일 9시`).isFalse

    // isBefore(OffsetDateTime other)/isAfter(OffsetDateTime other)은
    // epoch second로 변환하여 비교하기 때문에 두 객체가 동일하다고 판단한다.
    assertThat(`UTC 1월 1일 0시`.isBefore(`Seoul 1월 1일 9시`)).isFalse
    assertThat(`UTC 1월 1일 0시`.isAfter(`Seoul 1월 1일 9시`)).isFalse
}

compareTo()을 사용한 대소비교

'compareTo()'은 `Seoul 1월 1일 9시`을 더 큰 값으로 판단한다.

// OffsetDateTime.java
@Override
public int compareTo(OffsetDateTime other) {
    int cmp = compareInstant(this, other);
    if (cmp == 0) {
        cmp = toLocalDateTime().compareTo(other.toLocalDateTime());
    }
    return cmp;
}

private static int compareInstant(OffsetDateTime datetime1, OffsetDateTime datetime2) {
    if (datetime1.getOffset().equals(datetime2.getOffset())) {
        return datetime1.toLocalDateTime().compareTo(datetime2.toLocalDateTime());
    }
    int cmp = Long.compare(datetime1.toEpochSecond(), datetime2.toEpochSecond());
    if (cmp == 0) {
        cmp = datetime1.toLocalTime().getNano() - datetime2.toLocalTime().getNano();
    }
    return cmp;
}

'compareTo()'의 구현은 위와 같다.

offset이 같다면 두 객체의 LocalDate값을 비교한다.

offset이 다르다면 epoch second로 변환하여 비교하고, 변환하여 비교한 결과가 같다면 시간 값을 비교한다.

시간 값까지 같다면 'LocalDateTime.compareTo()'를 사용해 또 한 번 비교한다.

 

위 예시에서는 'compareInstant()'에서 비교한 결과가 0(같다)이기 때문에 마지막 'LocalDateTime.compareTo()'비교에서
`Seoul 1월 1일 9시`가 더 크다고 판단한다.

isBefore()/isAfter()을 사용한 대소비교

isBefore()/isAfter은 두 값이 서로 같다고 판단한다.

// OffsetDateTime.java
public boolean isBefore(OffsetDateTime other) {
    long thisEpochSec = toEpochSecond();
    long otherEpochSec = other.toEpochSecond();
    return thisEpochSec < otherEpochSec ||
        (thisEpochSec == otherEpochSec && toLocalTime().getNano() < other.toLocalTime().getNano());
}

public boolean isAfter(OffsetDateTime other) {
    long thisEpochSec = toEpochSecond();
    long otherEpochSec = other.toEpochSecond();
    return thisEpochSec > otherEpochSec ||
        (thisEpochSec == otherEpochSec && toLocalTime().getNano() > other.toLocalTime().getNano());
}

isBefore()/isAfter()은 두 객체의 'dateTime'값을 epoch second로 변환하여 비교한다.

 

결론

Timezone을 고려한 동등성, 대소비교를 하고 싶다면 isEqual(), isBefore(), isAfter()을 적절히 사용하자.

'Kotlin' 카테고리의 다른 글

Coroutine 톺아보기  (0) 2024.04.27
Java, Kotlin의 날짜와 시간  (0) 2022.11.28
Spring JPA Entity에 Data Class를 사용해도 될까?  (0) 2021.10.31
Kotlin의 Collections  (0) 2021.10.31
Kotlin의 Data/Enum 클래스  (0) 2021.09.22