뒤로
나그
나그 ·

[TroubleShooting] Spring Boot 2에서 Instant 사용하기 1탄

들어가며

부크부크에서는 LocalDateTime 클래스로 생성 시간, 수정 시간을 관리하고 있었다.

여기서 LocalDateTime은 서버 시간(Spring 타임존)의 영향을 받기에 유저, DB 등 특정 시간대에 영향을 받지 않는 시간을 저장해 타임존 의존성을 제거하고 싶었다.

타임존 의존성을 제거하여 유저가 영국에 있던 한국에 있던 상관 없이 유저의 위치에 알맞은 시간을 표시하고 싶었다. (다만 돌이켜보면 타임존이 꼭 고쳐야하는 문제는 아닌 것 같다. 현재 상황으로는 굳이 고치지 않아도 될 부분을 고친 것 같다.)

그래서 Instant라는, 특정 시점을 UTC 기준으로 저장하는 Java의 시간 클래스로 LocalDateTime을 대체했다.

이 과정에서 정말 많은 삽질이 있었는데, 이 과정에서 배운 걸 정리해보려고 한다.

Spring Boot 2의 JPA에서는 기본적으로 Instant를 지원하지 않는다.

미안하다... 왠지 모르겠는데 없다...

깃허브의 jarkartaee의 persistence 레포지토리에는 Instant와 관련하여 아래와 같은 이슈가 있다.

(이슈 링크: https://github.com/jakartaee/persistence/issues/163)

글을 쓰신 분은 JPA의 contributor이다.

여기서 중요한 부분을 번역해보면,

이슈 #63에서 java.time.Instant에 대한 지원에 관해 언급되어 있는데 최종 2.2 버전에는 없다. 나도 이게 왜 빠졌는지 모르겠다.
다만 Hibernate에서는 Instant를 이미 지원한다. 그리고 hibernate.jdbc.time_zone을 변경하여 데이터베이스의 타임존을 변경할 수 있다.

만약 JPA 2.2에서 특정 시점을 다루고 싶다면 다음과 같은 옵션이 있다.
1. java.util.Date 클래스 사용하기
2.OffsetDateTime 클래스 사용하기 - 그런데 이 방법은 offset을 골라야하는데, ZoneOffset.UTC를 offset으로 하면 offsetDateTime을 Instant처럼 사용하는 것이다. 다만 이 경우 DB에서 timestamp 칼럼에 offset을 저장하는 걸 지원하지 않을 수 있는데, 이럴 때는 @javax.persistence.TemporalTimeZoneColumn을 사용하여 칼럼을 2개로 쪼개 저장해라.
3. Instant를 사용하기 - JPA 2.2 버전 이전부터 사용하고 있겠지만, AttributeContributer와 함께 사용해라.


그래도 각각 옵션보다 Instant를 지원하는게 가장 좋은거 같다.

그래서 어떤 JPA 버전부터 Instant를 지원해 주는데...?

예... 23년 11월 8일 출시된 3.2.0 버전부터 지원한다.

한마디로 Spring Boot 2에서는 못쓴다.

이제 사용해 보자.

만약 이렇게 코드를 짜고 (소스코드는 여기에 다 여기에 있습니다, 커밋 별로 기능이 바뀝니다.)

  • DateTimeController.java

package nag.laboratory.datetime;

import java.util.List;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

@RestController
@RequestMapping("/date-time")
@RequiredArgsConstructor
public class DateTImeController {

    private final DateTimeService dateTimeService;

    @PostMapping
    public ResponseEntity<DateTimeResponse> createDateTime() {
        DateTimeResponse response = dateTimeService.createDateTime();

        return ResponseEntity.ok()
                .body(response);
    }

}
  • DateTimeService.java

package nag.laboratory.datetime;

import java.time.Instant;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class DateTimeService {

    private final DateTimeRepository dateTimeRepository;

    @Transactional
    public DateTimeResponse createDateTime() {
        DateTime dateTime = dateTimeRepository.save(new DateTime(Instant.now()));

        return new DateTimeResponse(dateTime.getCreatedTime());
    }

}
  • DateTimeRepository.java

package nag.laboratory.datetime;

import org.springframework.data.jpa.repository.JpaRepository;

public interface DateTimeRepository extends JpaRepository<DateTime, Long> {

}
  • DateTime.java

package nag.laboratory.datetime;

import java.time.Instant;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class DateTime {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(columnDefinition = "TIMESTAMP")
    private Instant createdTime;

}
  • DateTimeResponse.java

package nag.laboratory.datetime;

import java.time.Instant;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
@Getter
public class DateTimeResponse {

    private final Instant createdTime;

}
  • DateTimeTest.java

package nag.laboratory;

import static org.assertj.core.api.Assertions.*;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;

import io.restassured.RestAssured;
import io.restassured.response.Response;
import nag.laboratory.datetime.DateTime;
import nag.laboratory.datetime.DateTimeResponse;

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) // RestAssured를 사용하기 위해 스프링을 띄웁니다.
public class DateTimeTest {

    @LocalServerPort
    private int port;

    @BeforeEach 
    public void setUp() {
    	// RestAssured에 스프링 포트를 설정합니다.
        RestAssured.port = port;
    }

    @Test
    void post() {
        // when
        Response response = RestAssured
                .given().log().all()
                .when()
                .post("/date-time")
                .then().log().all()
                .extract().response();

        // then
        assertThat(response.jsonPath().getString("createdTime")).isNotNull();
    }

}

위의 테스트 코드를 실행하면... 테스트가 통과한다.

???????????? 뭐죠? 왜죠? 왜 통과하죠?

Hibernate에서는 Instant를 지원한다.

여러분들은 테스트가 통과하지 않을 거라 예상하셨겠지만, 실제로는 통과한다.

이 테스트가 성공하는 이유는 Hibernate에서 Instant를 지원하기 때문이다.

위의 표는 Hibernate 5와 JPA 2.2의 시간 객체 지원 여부를 보여주는 표다.

Hibernate와 JPA는 공통으로 LocalDate, LocalTime, LocalDateTime, OffSetTime(UTC 시간에 시간 오프셋이 적용된 시간 ex: 16:10:14.368917+09:00), 'OffsetDateTime'(ex: 2024-05-20T16:11:31.266075+09:00)을 지원한다.

HIbernate에서만 Duration(시간의 크기), ZonedDateTime(`OffsetDateTIme`에 타임존 위치 정보가 추가된 클래스), Instant를 지원한다.

Hibernate가 JPA의 구현체이기 때문에, JPA가 지원하지 않는대도 Hibernate에서 지원하면 쓸 수 있다.

그리고 내가 쓰고 있는 Spring Boot 2.7.18 에서 Hibernate 5.6.15 Final 버전을 지원하니까 Hibernate를 통하여 Instant를 사용할 수 있다.

생성시간을 자동으로 관리해보자.

이제 Instant를 자동으로 관리하도록 코드를 짜보자.

1. JPA Auditing을 사용하기

JPA Auditing은 Spring Data JPA에서 제공해주는 누가, 언제 엔티티를 생성, 변경했는지 자동으로 추적해주는 기능이다.

이 기능을 사용하면 상속을 통하여 내가 원하는 엔티티에 자동으로 생성 시간을 넣어 줄 수 있다.

코드는 위의 코드에서 일부분만 변경하면 된다.

아래는 변경된 부분 중 핵심 부분이다.

  • BaseTimeEntity.java

package nag.laboratory.datetime;

import java.time.Instant;

import javax.persistence.EntityListeners;
import javax.persistence.MappedSuperclass;

import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import lombok.Getter;

@MappedSuperclass // 엔티티 간의 공통 정보를 정의하기 위해 쓰인다. BaseTimeEntity를 상속 받으면 createdTime이 자동으로 엔티티에 추가된다.
@EntityListeners(AuditingEntityListener.class) // 이벤트의 종류에 따라 자동으로 이벤트를 처리한다.
@Getter
public abstract class BaseTimeEntity {

    @CreatedDate // 생성 시간을 넣어준다.
    private Instant createdTime;

}
  • LaboratoryApplication.java

package nag.laboratory;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing // 어노테이션을 추가해야 JPA Auditing을 사용할 수 있다.
public class LaboratoryApplication {

    public static void main(String[] args) {
        SpringApplication.run(LaboratoryApplication.class, args);
    }

}

2. Hibernate의 @CreationTimestamp 사용하기

Hibernate에서 제공 해주는 @CreationTimestamp를 사용해도 JPA Auditing과 똑같이 생성시간을 자동화해서 넣어줄 수 있다.

JPA Auditing과 비교하면 어노테이션 하나만 추가하면 되기에 간단하다.

BaseTimeEntity라는 클래스를 생성 하지않고 어노테이션 하나만 추가하면 된다.

  • DateTime.java

package nag.laboratory.datetime;

import java.time.Instant;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

import org.hibernate.annotations.CreationTimestamp;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class DateTime {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 어노테이션 하나면 된다.
    @CreationTimestamp
    private Instant createdTime;

}

마무리하며

글을 요약해보면 3줄이 나온다.

  1. Spring Boot 2에서 Instant를 사용할 수 있는 건 Hibernate 덕분이다.

  2. JPA Auditing과 @CreationTimestamp를 통해 생성 시간을 자동으로 넣어줄 수 있다.

  3. JPA Auditing은 상속을 통하여 필드를 입력하고, @CreationTimestamp는 필드에 어노테이션을 붙여 자동으로 입력한다.

그리고 무엇보다 아직 끝난 건 아니다.

저렇게 JPA Auditing, @CreationTimstamp를 활용해도 DB에 UTC 적용 시간이 저장되지 않는 이슈가 있다.

저... 선생님...? 왜 그러시죠? 불만이 있으시면 말로하세요...

그리고 이 이슈에 관해서는 다음 글에서 다루겠다. (솔직히 저 이슈를 파다보니까 이 글을 작성하게 되었다. 이럴 줄 알았으면  LocalDateTime쓸 걸...)

Reference

- https://stackoverflow.com/questions/49309076/why-jpa-does-not-support-java-time-instant (JPA에서 왜 Instant를 지원해주지 않는가?)

- https://github.com/jakartaee/persistence/issues/163 (JPA에서 Instant를 지원해주지 않는 것에 대한 이슈)

- https://docs.oracle.com/en/java/javase/11/docs/api/index.html (오라클 Javadoc, Instant, OffsetTime, ZonedDateTime 등 시간 클래스 참고)

- https://suhwan.dev/2019/02/24/jpa-vs-hibernate-vs-spring-data-jpa/ (위의 JPA, HIbernate 관계 그림 출처)

- https://thorben-janssen.com/hibernate-jpa-date-and-time/ (Hibernate와 JPA의 시간 객체 지원 차이)

2

댓글

로그인 후 댓글을 남길 수 있습니다.

아직 댓글이 없습니다.