Javaの日時設計入門|APIとデータベース間のタイムゾーン変換

Javaの日時設計入門|APIとデータベース間のタイムゾーン変換

APIで受け取った日時をDBへ保存し、画面に表示したら9時間ずれていた。このような問題を防ぐには、日時の型だけでなく、入力・保存・表示の各境界で、どの情報を保持して何を変換するかを決める必要があります。

この記事では、イベントの発生日時をoffset付き文字列で受け取り、Java内部でInstantへ変換し、DBから取得した値を利用者のタイムゾーンで表示する流れを整理します。型の一覧比較ではなく、APIとDBをつなぐ設計、保存精度、夏時間の扱いに焦点を当てます。

掲載コードではJavaのjava.time APIを使います。DBとの接続では、採用するDB・JDBCドライバーに合わせて、日時の型と変換方法を確認してください。

目次

日時の不具合はAPI・DB・画面の境界で起きやすい

同じ日時でもタイムゾーンが違うと指す瞬間が変わる

同じ「2025年1月15日9時」でも、UTCの9時と、日本の標準時であるUTC+09:00の9時では、指す瞬間が9時間違います。年月日と時刻の数字が同じでも、その数字をどの地域の時計として読むかで意味が変わります。

日時の表現UTCで表した同じ瞬間
2025-01-15T09:00:00+09:002025-01-15T00:00:00Z
2025-01-15T09:00:00Z2025-01-15T09:00:00Z

末尾のZはUTCからのoffsetがゼロであることを表します。APIでoffsetを取り除いたり、DBから取得した値にサーバーの既定タイムゾーンを当てはめたりすると、意図した瞬間を復元できなくなることがあります。

LocalDateTimeだけでは世界共通の時点を特定できない

LocalDateTimeは年月日と時刻を持ちますが、offsetや地域のタイムゾーンを持ちません。2025-01-15T09:00:00という値だけでは、東京の9時なのか、別の地域の9時なのか判断できません。

たとえば、受け取ったOffsetDateTimeにtoLocalDateTime()を呼ぶと、時計の数字は残りますがoffset情報は失われます。その後でUTCとして解釈すると、元の入力とは異なる瞬間になる可能性があります。イベント発生時刻を扱うなら、offsetを保持した状態からtoInstant()で変換します。

一方、「ある店舗の現地時刻で毎朝9時」のような予定では、地域の時計の数字そのものが業務上の意味を持ちます。この場合は時刻や日付とZoneIdを組み合わせ、夏時間の扱いも含めて予定を定義します。すべての日時を受信直後にInstantだけへ置き換える設計が適切とは限りません。

タイムゾーン付きのAPI入力をInstantとして扱い、データベースに保存して表示用のZoneIdへ変換する流れ
API入力・Instant・データベース・表示用ZoneIdのデータフロー

APIではタイムゾーン情報を含む日時を受け取る

offset付き日時をInstantへ変換する

イベント発生時刻を受け取るAPIでは、ISO-8601のoffset付き日時を入力形式にすると、送信側が意図した瞬間を特定できます。たとえば2025-01-15T09:00:00+09:00を受け取り、内部では2025-01-15T00:00:00Zに相当するInstantとして扱います。

import java.time.Instant;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;

public final class EventTimeParser {
    private EventTimeParser() {}

    public static Instant parse(String input) {
        if (input == null || input.trim().isEmpty()) {
            throw new IllegalArgumentException("occurredAt is required");
        }
        try {
            return OffsetDateTime.parse(
                input, DateTimeFormatter.ISO_OFFSET_DATE_TIME
            ).toInstant();
        } catch (DateTimeParseException ex) {
            throw new IllegalArgumentException(
                "occurredAt must be an ISO offset date-time"
            );
        }
    }
}

この例は、offsetのない2025-01-15T09:00:00を受け入れません。空白を取り除いて日時を補正する処理もしていません。API層では、この入力不正をHTTP 400などの決めた応答へ変換し、サーバーのタイムゾーンで勝手に補完しないようにします。

ただし、このメソッドはAPI仕様全体の検証ではありません。JSONの値を文字列に限定する設定、入力長、許容する年月日の範囲、小数秒の桁数は別に決めます。また、ISO_OFFSET_DATE_TIMEが受理する範囲と、「秒は必須」「小数秒は3桁まで」といった独自の契約は同一ではありません。狭い形式を要求するなら追加の検証が必要です。

利用者の地域・業務上のZoneIdは別に扱う

+09:00はUTCとの差を表すoffsetです。一方、Asia/TokyoやEurope/ParisのようなZoneIdは、地域の時刻ルールを参照するための識別子です。同じoffsetを使う地域は複数あるため、offsetだけから利用者の地域を特定することはできません。

保存済みイベントを利用者の設定に合わせて表示するなら、イベントのInstantと、プロフィールなどで管理する表示用ZoneIdを分けます。店舗の営業時間や締め日の判定なら、閲覧者の設定ではなく、対象業務に定めたZoneIdを使います。

入力にoffset付き日時とZoneIdの両方を含める場合は、ZoneIdの用途を明確にします。表示先の指定なら入力offsetと一致する必要はありません。同じ地域で入力された日時だと保証する契約なら、その地域のルールで日時とoffsetが整合しているかを検証します。

Instantへ変換すると、入力に使われたoffsetや地域名は残りません。元の表記を再現する必要がある場合は、業務上必要なoffset・ZoneId・入力表記を別に保持する設計にします。

データベースでは保存する意味と精度を決める

イベント発生時刻と日付だけの値を区別する

DBの列を決める前に、その値が何を表すかを整理します。注文の受付時刻のような「発生した瞬間」と、誕生日のような「日付だけの値」では、保持すべき意味が違います。

業務上の値保持する意味設計の注意点
イベントの発生時刻時間軸上の瞬間DB往復後も同じInstantへ復元できるようにする
誕生日・対象日年月日便宜的な午前0時を付けてタイムゾーン変換しない
地域時刻で決まる将来の予定現地の日時と地域のルールLocalDateTimeとZoneIdなどを保持し、ギャップ・重複時の方針を決める

日付だけの値に時刻を付けてInstantへ変換すると、表示先によって前日や翌日になる可能性があります。業務上の日付なら、日付のまま受け渡す設計を優先します。

また、「将来の現地時刻を守る予定」と「すでに確定した瞬間を守る予定」も区別します。地域の時刻ルールが変更されたとき、前者はInstantの再計算が必要になる場合があります。何を正本として保存するかを、予定の意味に合わせて決めてください。

DB型やJDBCドライバーの挙動は利用環境で確認する

「UTCで保存する」という方針だけでは、実装は確定しません。DBの列型、JDBCで渡すJava型、読み取り時のJava型、接続セッションのタイムゾーンまで対応を決める必要があります。DB型の名前にタイムゾーンという語が含まれていても、元のZoneIdまで保持できるとは限りません。

確認箇所確認する内容
DBの列型瞬間と地域時刻のどちらを表すか、offsetや地域名を保持するか、値の範囲
JDBCの入出力採用ドライバーが対応するJava型と、書き込み・読み取り時の変換
MyBatisなどの変換層選ばれるTypeHandlerと、内部で利用するJDBCの型・メソッド
環境設定JVM、DBサーバー、接続セッションのタイムゾーンが変換に影響するか
小数秒保存可能な桁数と、桁数超過時の切り捨て・丸め・拒否の扱い

InstantをそのままsetObject()へ渡せば、どのJDBCドライバーでも同じように保存できるとは考えないようにします。アプリケーション内部ではInstantを使い、DB境界で採用環境に対応した型へ変換する構成も選択肢です。offsetを持たない列へUTCの年月日時分秒を格納する方式なら、「この列はUTCとして解釈する」という約束を全書き込み・読み取り経路で統一します。

Instantはナノ秒単位の値を表せますが、DB列やAPIが同じ精度を保持できるとは限りません。たとえばミリ秒へ切り捨てる契約なら、DBに任せず境界でinstant.truncatedTo(ChronoUnit.MILLIS)のように正規化する方法があります。これは業務要件として切り捨てを選んだ場合の例です。正規化後の値をDB往復テストの期待値にします。

日時の変換を決めた後も、SQLへ値を安全に渡す設計は必要です。Spring BootとMyBatisで入力検証からMapperまでを整理する場合は、次の記事が補足になります。

画面表示では保存値を表示先のタイムゾーンへ変換する

UTC相当のInstantから表示用日時を組み立てる

Instant自体は、東京やパリといった表示用タイムゾーンを保持しません。DBから同じ瞬間を表すInstantを復元し、表示するときにatZone()でZoneIdを適用します。

import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;

public final class EventTimeDisplay {
    private static final DateTimeFormatter FORMAT =
        DateTimeFormatter.ofPattern(
            "uuuu-MM-dd HH:mm:ss XXX '['VV']'", Locale.ROOT
        );

    public static void main(String[] args) {
        Instant occurredAt = Instant.parse("2025-01-15T00:00:00Z");
        ZoneId displayZone = ZoneId.of("Asia/Tokyo");

        String text = FORMAT.format(occurredAt.atZone(displayZone));
        System.out.println(text);
    }
}

この例の期待される表示は2025-01-15 09:00:00 +09:00 [Asia/Tokyo]です。表示の年月日時分秒が変わっても、Instantが表す瞬間は変わりません。

変換をバックエンドとフロントエンドのどちらで行うかも決めます。フロントエンドで表示するなら、APIからZやoffsetを含む日時を返し、画面側で表示先のZoneIdを適用します。バックエンドが地域時刻へ変換した文字列からoffsetを落とし、画面がそれをUTCとして再解釈するような二重変換を避けます。

表示用ZoneIdが未設定の場合の既定値も、アプリケーションの契約として定めます。ZoneId.systemDefault()に依存すると、実行環境の設定変更で表示が変わり得ます。利用者設定、業務設定、明示した既定値のどれを使うかを決め、無効なZoneIdの扱いも統一します。

夏時間の開始・終了では存在しない時刻や重複する時刻がある

夏時間を採用する地域では、時計を進める際に存在しない地域時刻が生まれます。これがギャップです。時計を戻す際には同じ地域時刻が二度現れ、異なるoffsetに対応することがあります。これが重複です。

Instantから地域時刻への変換では、その瞬間に適用するoffsetを決められます。一方、LocalDateTimeとZoneIdから瞬間を決める方向では、有効なoffsetが存在しない、または複数存在する場合があります。フォームで現地時刻を入力させる設計では、この違いが重要です。

LocalDateTimeのatZone()は、ギャップや重複を常にエラーにするAPIではありません。ギャップでは時刻を前方へ補正し、重複では通常、早い側のoffsetを選びます。予約などで自動補正を許さない場合は、変換前に地域のルールを調べます。

次は、ギャップと重複をどちらも拒否し、有効なoffsetが一つの場合だけ変換する例です。重複時に利用者へoffsetを選ばせる設計なら、その選択が有効な候補に含まれることを検証してから変換します。

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Objects;

public final class LocalTimeResolver {
    private LocalTimeResolver() {}

    public static Instant resolveUnique(
            LocalDateTime local, ZoneId zone) {
        Objects.requireNonNull(local, "local");
        Objects.requireNonNull(zone, "zone");

        List<ZoneOffset> offsets =
            zone.getRules().getValidOffsets(local);

        if (offsets.isEmpty()) {
            throw new IllegalArgumentException(
                "Local time does not exist in this zone"
            );
        }
        if (offsets.size() != 1) {
            throw new IllegalArgumentException(
                "Local time requires an explicit offset choice"
            );
        }
        return local.toInstant(offsets.get(0));
    }
}

ZoneRules.getValidOffsets()で有効なoffsetを調べることで、暗黙の補正に任せず業務上の方針を適用できます。拒否する、利用者に選択させる、補正内容を示して受け付けるなど、操作の意味に合わせて決めてください。

毎日同じ現地時刻に動かす予定と、24時間間隔で動かす予定も、夏時間の切り替わりでは一致しないことがあります。定期処理では時刻の決め方に加え、同時起動や再処理への対策も必要です。その実行制御は次の記事で扱っています。

日時データフローを設計するときの確認リスト

入力形式・保存値・表示ZoneId・精度を一貫して決める

設計書には「日時はUTC」とだけ書くのではなく、APIから画面までの値の流れを残します。イベント発生時刻であれば、次の項目を一つの契約として整理できます。

  • 入力:offset必須の日時文字列とし、欠落・不正形式・範囲外の応答を決める。
  • 内部:offset付き日時からInstantへ変換し、比較や保存処理へ渡す。
  • 保存:DB列型、JDBCの入出力型、接続設定を決め、同じ瞬間へ復元できるようにする。
  • 精度:小数秒の上限と、超過分を拒否・切り捨て・丸めのどれで扱うかを決める。
  • 表示:利用者または業務のZoneIdを適用し、画面には必要に応じてoffsetや地域名も示す。
  • 地域時刻の入力:ギャップと重複の扱いを決め、暗黙の補正を許すか明確にする。

検索条件にも同じ考え方を適用します。「利用者の地域での1月15日」を検索するなら、その地域の当日開始と翌日開始をそれぞれInstantへ変換し、開始以上・終了未満の範囲で検索する方法があります。夏時間がある地域では、開始に固定の24時間を足して翌日開始とみなさないことが大切です。日付の開始が補正される地域もあるため、日付境界の扱いも仕様に含めます。

異なるタイムゾーンと境界時刻をテストする

以下の観点で、Javaの変換だけを確認する単体テストと、実際のDB・JDBC・マッピング設定を使う結合テストを分けて用意します。

観点確認すること
同じ瞬間の別表記2025-01-15T09:00:00+09:00と2025-01-15T00:00:00Zが同じInstantになる
入力不正offsetなし、空文字、null、不正な日付、契約を超える小数秒を決めた応答にする
日付の境界UTCと表示先で日付が異なるケースでも、表示日と検索対象が契約どおりになる
DB往復保存後に再取得したInstantが、精度の契約を適用した期待値と一致する
環境の違いJVMや接続セッションのタイムゾーンを変えた別構成でも、同じ瞬間を保持する
夏時間のギャップ・重複採用するZoneRulesで該当する地域時刻を用意し、拒否・選択・補正の方針を確認する
小数秒の境界最大桁数と超過桁数、丸める場合の秒・日付の繰り上がりを確認する

DB往復の検証では、管理ツールに表示された文字列だけで判断しないようにします。管理ツール側で表示変換される可能性があるため、アプリケーションが取得した値をInstantへ戻して照合します。異なるoffset表記でも同じ瞬間なら一致と扱うのか、元の入力表記まで保存すべきなのかも区別してください。

再現に必要な情報として、Java、DB、JDBCドライバー、利用するマッピングライブラリのバージョンと接続設定を記録します。地域の時刻ルールに関係するテストでは、JDKが利用するタイムゾーンデータも確認対象です。現在時刻に依存する処理はClockを注入して固定すると、日付境界の期待値を安定させやすくなります。

Java側の仕様を確認する公式資料は、OffsetDateTime、Instant、LocalDateTime、ZoneRulesです。リンク先はJava SE 17の資料です。実装時は採用するJavaの版に対応する資料と、DB・ドライバーの公式資料を確認してください。

まとめ

イベント発生時刻を扱う基本の流れは、APIでoffset付き日時を受け取り、Instantへ変換し、DB往復後も同じ瞬間を保持したうえで、表示時にZoneIdを適用することです。offsetを失ったLocalDateTimeを別のタイムゾーンとして読み直さないよう、各境界の変換を明示します。

日付だけの値や、地域時刻を基準にした将来の予定は、その意味に合った保存方法を選びます。DB型・JDBC・小数秒の精度を利用環境で確認し、夏時間のギャップと重複を含めて入力から表示までをテストすることで、日時の意味が途中で変わる不具合を防ぎやすくなります。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

CAPTCHA


目次