본문 바로가기
BackEnd/JAVA

LocalDateTime 타임존에 대한 이해

by sorryisme 2024. 11. 5.

 

JSR-310 관련해서 멘토링을 진행 하던 중 과연 LocalDateTime 동작원리에 대한 부분을 질문 받았으나 대답하지 못하였다. 단지 불변객체이며 기존 Date와 Calendar를 대신한다는 점이지만 실제로 모르고 사용했을 때 발생되는 문제에 대한 시나리오를 제공받았으며 이를 테스트를 해보고 해결하기 위한 방향으로 학습을 하게되었다

 

가장 먼저 학습한 것은 과연 LocalDateTime.now()는 어떻게 타임존은 어떻게 가져오는지이다

 

 

LocalDateTime 타임존은 어떻게 가져올까

LocalDateTime.now();

 

now 메소드가 호출하는 스태틱 메소드

 

메소드를 타고 들어가면 Clock의 SystemDefaultZone을 가져온다. 실제로 그렇다면 어떻게 이 값이 지정되는 지 찾아가보았다

 

private static synchronized TimeZone setDefaultZone() {
        TimeZone tz;
        // get the time zone ID from the system properties
        Properties props = GetPropertyAction.privilegedGetProperties();
        String zoneID = props.getProperty("user.timezone");

        // if the time zone ID is not set (yet), perform the
        // platform to Java time zone ID mapping.
        if (zoneID == null || zoneID.isEmpty()) {
            zoneID = getSystemTimeZoneID(StaticProperty.javaHome());
            if (zoneID == null) {
                zoneID = GMT_ID;
            }
        }

        // Get the time zone for zoneID. But not fall back to
        // "GMT" here.
        tz = getTimeZone(zoneID, false);

        if (tz == null) {
            // If the given zone ID is unknown in Java, try to
            // get the GMT-offset-based time zone ID,
            // a.k.a. custom time zone ID (e.g., "GMT-08:00").
            String gmtOffsetID = getSystemGMTOffsetID();
            if (gmtOffsetID != null) {
                zoneID = gmtOffsetID;
            }
            tz = getTimeZone(zoneID, true);
        }
        assert tz != null;

        final String id = zoneID;
        props.setProperty("user.timezone", id);

        defaultTimeZone = tz;
        return tz;
    }

 

위 코드에 내용을 하나씩 디버깅해보면 Props에서 타임존을 가져온다. 그리고 그 내용이 없을 경우 GMT 시간으로 설정되는 걸 볼 수 있는데 다양한 환경에서 타임존을 검색한다. 그렇기에 혼동이 발생될 수 있기에 아래와 같이 우선순위는 정리해보았다

 

우선순위(리눅스 기준)

  1. JVM 매개변수
    1. Duser.timezone
  2. TZ 환경 변수 설정
    1. export TZ=Asia/Shanghai
  3. /etc/timezone
  4. /etc/localtime

 

출력을 위한 테스트 코드
public class Main {
    public static void main(String[] args) {
        System.out.println(TimeZone.getDefault().getID());
        System.out.println(LocalDateTime.now());
    }
}

 

 

 

 

테스트1. VM Option에서 Timzone을 지정한다

 

 

인텔리제이와 리눅스에서 모두 정상적으로 타임존이 적용되는 걸 확인할 수 있었다

 

 

테스트2. VM Option과 Profile 우선순위 확인

리눅스 상에 ~/.bash_profile에 TZ를 적용한 것과 VM Option을 비교했을 때 우선순위가 VM Option이 높다는 걸 확인할 수 있었다

 

 

 

시나리오 테스트

  1. 한국유저 한테 요청을 받음
  2. 서버는 한국에 위치해있다.
  3. 데이터베이스 서버도 한국에 위치해있다
  4. 대만 관리자가 데이터를 조회한다. 이 경우 대만 시간으로 조회되어야 한다.

 

 

Client 코드 ( 타임존 Asia/Taipei)

    @PostMapping("/request")
    public String request() {

        LocalDateTime localDateTime = LocalDateTime.now();
        RestTemplate restTemplate = new RestTemplate();
        ResponseEntity<String> response = restTemplate.postForEntity("http://192.168.0.42:8080/insert", localDateTime, String.class);

        // 현재 시스템의 시간대 가져오기
        TimeZone timeZone = TimeZone.getDefault();
        ZoneId zoneId = timeZone.toZoneId();
        log.info("System Zone ID: " + zoneId);

        return response.getBody();
    }

 

Server 코드 ( 타임존 Asia/Seoul)

  @PostMapping("/insert")
  public String insert(@RequestBody LocalDateTime localDateTime) {
      try (Connection conn = DriverManager.getConnection("jdbc:mysql://192.168.0.47:3306/localdate?serverTimezone=Asia/Seoul", "root", "rlawltjd39")) {

          log.info(localDateTime.toString());

          String insertQuery = "INSERT INTO tb_timezone (fd_datetime, fd_timestamp) VALUES (?, ?)";
          PreparedStatement pstmt = conn.prepareStatement(insertQuery);

          Timestamp timestamp = Timestamp.valueOf(localDateTime);

          pstmt.setTimestamp(1, timestamp);  // fd_datetime 컬럼
          pstmt.setTimestamp(2, timestamp);  // fd_timestamp 컬럼

          // 쿼리 실행
          int rowsInserted = pstmt.executeUpdate();

          if (rowsInserted > 0) {
              Statement stmt = conn.createStatement();
              ResultSet resultSet = stmt.executeQuery("select fd_datetime, fd_timestamp from tb_timezone");

              if (resultSet.next()) {
                  log.info(resultSet.getTimestamp("fd_datetime").toString());
                  log.info(resultSet.getTimestamp("fd_timestamp").toString());
              }
          }

      } catch (Exception e) {
          e.printStackTrace();
      }

      return "";
  }

 

최종 결과

  • 타임존을 포함하지 않는 값이 전달됨
  • 타이페이 타임존 시간으로 dateTime, Timestamp 모두 들어가버림
  • 허나 추후 다시 조회할 경우 서울 시간으로 인식하기 때문에 문제가 발생함

해결방안1 (ZonedDateTime)

Client (Taipei)

@PostMapping("/request")
public String request() {

    ZonedDateTime zonedDateTime = ZonedDateTime.now();
    RestTemplate restTemplate = new RestTemplate();
    ResponseEntity<String> response = restTemplate.postForEntity("<http://192.168.0.42:8080/insert>", zonedDateTime, String.class);

    TimeZone timeZone = TimeZone.getDefault();
    ZoneId zoneId = timeZone.toZoneId();
    log.info("System Zone ID: " + zoneId);

    // log.info("Response from server: " + response.getBody());
    return response.getBody();
}

Server(Seoul)

  @PostMapping("/insert")
  public String insert(@RequestBody ZonedDateTime zonedDateTime) {
      try (Connection conn = DriverManager.getConnection("jdbc:mysql://192.168.0.47:3306/localdate?serverTimezone=Asia/Seoul", "root", "rlawltjd39")) {

          log.info(zonedDateTime.toString());

          String insertQuery = "INSERT INTO tb_timezone (fd_datetime, fd_timestamp) VALUES (?, ?)";
          PreparedStatement pstmt = conn.prepareStatement(insertQuery);

          Timestamp timestamp = Timestamp.from(zonedDateTime.toInstant());
          log.info(timestamp.toString());

          pstmt.setTimestamp(1, timestamp);  // fd_datetime 컬럼
          pstmt.setTimestamp(2, timestamp);  // fd_timestamp 컬럼

          // 쿼리 실행
          int rowsInserted = pstmt.executeUpdate();

          if (rowsInserted > 0) {
              Statement stmt = conn.createStatement();
              ResultSet resultSet = stmt.executeQuery("select fd_datetime, fd_timestamp from tb_timezone");

              if (resultSet.next()) {
                  log.info(resultSet.getTimestamp("fd_datetime").toString());
                  log.info(resultSet.getTimestamp("fd_timestamp").toString());
              }
          }

      } catch (Exception e) {
          e.printStackTrace();
      }

      return "";
  }

  • 로그에 찍히는 값 ⇒ 2024-10-28T09:40:43.775161856Z
  • 허나  이 방식은 결국 UTC 시간으로 처리한다
  • 그렇다면 만약에 Sequence를 타임스탬프로 했을 때 태국과 서울의 시간차이는 어떻게 극복할 건지 (운영적인 문제)
    • 운영적으로 편할려면 결국 타임존을 하나로 일치시키는 것도 하나의 방법으로 볼 수 있다

 


MySQL DATE VS DATETIME VS TIMESTAMP

  1. Date :시간을 제외한 날짜를 저장하는 타입
  2. DateTime: 날짜와 시간을 저장할 수 있는 타입
  3. TimeStamp: 날짜와 시간을 타임스탬프로 저장하는 형태
    1. 시스템의 타임존에 의존한다
    2. DateTime은 문자형으로 저장, 타임스탬프는 숫자형으로 저장
SET GLOBAL time_zone='Asia/Taipei';
SET time_zone='Asia/Taipei';
select * from tb_timezone; 
SELECT @@global.time_zone, @@session.time_zone; 

  • timestamp의 경우에는Mysql 타임존에 따라서 시간이 변경된다

JDBC와의 연동 테스트

코드

    @GetMapping("/mysql")
    public String mysql() {

        try (Connection conn = DriverManager.getConnection("jdbc:mysql://192.168.0.47:3306/localdate?serverTimezone=Asia/Seoul", "root", "rlawltjd39");){

            Statement stmt = conn.createStatement();
            ResultSet resultSet = stmt.executeQuery("select fd_datetime, fd_timestamp from tb_timezone");

            if(resultSet.next()) {
                log.info(resultSet.getTimestamp("fd_datetime").toString());
                log.info(resultSet.getTimestamp("fd_timestamp").toString());
            }

        } catch (Exception e) {
            e.printStackTrace();
        }

        return "";
    }
  • Mysql Timezone은 Asia/Taipei
  • JDBC Timezone설정은 Asia/Seoul

서버의 타임존과 상관없이 JDBC Timezone의 파라미터를 따라간다

 

참고

https://velog.io/@ashappyasikonw/Java-프로젝트에서-Default-Time-Zone은-어떻게-설정되는가

  •