同じ決済時刻がUTCでは12月31日、JSTでは1月1日に当たることを二つのカレンダーで示したイラスト

実装・エラー解決

JavaでUTCをJSTに変換する|LocalDateTimeで集計日がずれる原因と直し方

この記事でわかること

UTCで12月31日15:00の決済は、日本では1月1日0:00です。UTCのLocalDateTimeにAsia/Tokyoを付けると、決済の瞬間まで変わる理由を再現し、Instantを保ったまま日本の集計日を取り出します。

新人SE
新人SE
日本時間だと元日の決済なのに、売上レポートでは大みそかに入っていました。Asia/Tokyoを指定したつもりです。
ポンコツSE
ポンコツSE
ゾーンを指定する前に、どの型が値を持っているかを見ましょう。決済が起きた瞬間を保ったまま、日本の時計とカレンダーに表示します。

決済APIの発生時刻を取り込み、日本の日別売上へ振り分ける処理を作ります。決済APIからはUTCの時刻が届き、レポートは日本時間の0時で日を切り替える約束です。入力が2026-12-31T15:00:00Zなら、2027年1月1日分に入ります。

この例では、日本の暦日と売上の集計日が同じです。店舗の締め時間や休日で営業日を決める場合は、日本時間へ変換した後に、その運用ルールを適用します。

実行サンプル

修正前後のJavaコード、日本時間の0時をまたぐJUnit、pom.xml、READMEを収録。JDK 17とMavenで、展開したディレクトリから mvn clean test を実行できます。

同じ決済でも、UTCと日本時間では日付が変わる

Z付きの発生時刻をInstantへ読み取ると、決済が起きた一つの瞬間として扱えます。その瞬間を日本の時計で見た日時は、UTCの日時と時刻も日付も異なります。期待する集計日は、日本で0時をまたぐ前後の2件で確認します。

決済APIのUTC時刻日本の日時集計日
2026-12-31T14:59:59Z2026-12-31 23:59:592026-12-31
2026-12-31T15:00:00Z2027-01-01 00:00:002027-01-01

UTCのLocalDateTimeに東京のゾーンを付け直すと、別の瞬間になる

次は問題を再現するBrokenSalesDay.javaの全文です。最初の行でLocalDateTimeへ取り出したのは、UTCの時計で見た「12月31日15:00」という表示です。LocalDateTimeにはゾーンやUTCからの差が含まれません。

package example.report;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;

final class BrokenSalesDay {
    static ZonedDateTime inTokyo(Instant paidAt) {
        LocalDateTime utcClock = LocalDateTime.ofInstant(paidAt, ZoneOffset.UTC);
        return utcClock.atZone(ZoneId.of("Asia/Tokyo"));
    }
}

次の行のutcClock.atZone(Asia/Tokyo)は、この例では時計の15:00を保ったまま、東京の15:00として扱います。その結果は2026-12-31T15:00+09:00[Asia/Tokyo]。toInstant()で戻すと2026-12-31T06:00:00Zとなり、元の決済より9時間前を指します。日別売上はこの値から12月31日を取り出すため、日付の振り分けも変わります。

LocalDateTime.atZoneは、入力が「すでにその地域の時計で表示した日時」と分かっているときに使えます。たとえば契約上、入力欄の「15:00」は東京の15:00です。その場合に東京のゾーンを付けることと、UTCの15:00を東京へ変換することを分けます。Java 17のLocalDateTime.atZone。

Instantのまま東京へ変換し、最後にLocalDateを取り出す

次は修正版SalesDay.javaの全文です。Instant.atZone(TOKYO)で決済の瞬間を東京の日時として表示し、その後のtoLocalDate()で集計日を取り出します。先にUTCの日付だけにすると、その日の何時に決済したかが分からず、日本の日付を決められません。

package example.report;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;

final class SalesDay {
    private static final ZoneId TOKYO = ZoneId.of("Asia/Tokyo");

    static ZonedDateTime inTokyo(Instant paidAt) {
        return paidAt.atZone(TOKYO);
    }

    static LocalDate forTokyo(Instant paidAt) {
        return inTokyo(paidAt).toLocalDate();
    }
}

同じatZoneでも、呼び出し元がInstantかLocalDateTimeかで出発点が違います。今回はInstantがすでに決済の瞬間を持っているので、東京へ変換した後もtoInstant()は元の時刻と一致します。すでにZonedDateTimeを受け取っているなら、withZoneSameInstant(TOKYO)で同じ瞬間を保てます。Java 17のInstant.atZone · ZonedDateTime.withZoneSameInstant。

時差が書かれていない入力は、送信元の約束を確認する

2026-12-31T15:00:00のようにゾーンやUTCとの差が書かれていない文字列だけでは、どの瞬間かは決まりません。このサンプルのInstant.parseでは、その入力でDateTimeParseExceptionが発生します。連携仕様に「この形式はUTC」と明記されている場合は、その仕様でUTCとして読む処理を用意できます。仕様がない場合は、稼働サーバーの既定ゾーンを補う前に、送信元へ確認します。

カレンダー上の年月日だけを受け取る仕様なら、LocalDateで扱うほうが意図を伝えやすくなります。日付の書式や存在しない日を拒否する方法は、次の記事で扱っています。

関連記事LocalDate.parseの原因と対処|日付形式・存在しない日・STRICTの違い 時刻のない年月日を受け取る仕様なら、書式や存在しない日付をどこで拒否するかを確認できます。 記事を読む

集計日と、決済の瞬間が変わらないことを両方試す

ZIPを展開し、pom.xmlがあるディレクトリでmvn clean testを実行します。SalesDayTest.javaは、修正前の誤った集計日と瞬間を1件、日本時間の0時をまたぐ前後を2件、toInstant()で元へ戻ることを1件、時差が書かれていない文字列の拒否を1件確認します。

SalesDayTest.javaの全文を見る
package example.report;

import java.time.Instant;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.time.format.DateTimeParseException;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;

class SalesDayTest {
    @Test
    void attachingTokyoToTheUtcClockChangesTheInstantAndSalesDay() {
        Instant paidAt = Instant.parse("2026-12-31T15:00:00Z");

        ZonedDateTime wrong = BrokenSalesDay.inTokyo(paidAt);

        assertEquals(LocalDate.parse("2026-12-31"), wrong.toLocalDate());
        assertEquals(Instant.parse("2026-12-31T06:00:00Z"), wrong.toInstant());
        assertEquals(LocalDate.parse("2027-01-01"), SalesDay.forTokyo(paidAt));
    }

    @ParameterizedTest
    @CsvSource({
            "2026-12-31T14:59:59Z, 2026-12-31T23:59:59+09:00[Asia/Tokyo], 2026-12-31",
            "2026-12-31T15:00:00Z, 2027-01-01T00:00:00+09:00[Asia/Tokyo], 2027-01-01"
    })
    void usesTheTokyoDateOnBothSidesOfMidnight(String utc, String tokyo, String date) {
        Instant paidAt = Instant.parse(utc);

        assertEquals(ZonedDateTime.parse(tokyo), SalesDay.inTokyo(paidAt));
        assertEquals(LocalDate.parse(date), SalesDay.forTokyo(paidAt));
    }

    @Test
    void keepsTheSameInstantWhenTheDisplayZoneChanges() {
        Instant paidAt = Instant.parse("2026-12-31T15:00:00Z");

        assertEquals(paidAt, SalesDay.inTokyo(paidAt).toInstant());
    }

    @Test
    void doesNotAssumeAZoneForAnUnqualifiedTimestamp() {
        assertThrows(DateTimeParseException.class, () -> Instant.parse("2026-12-31T15:00:00"));
    }
}
pom.xmlの全文を見る
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>example</groupId>
  <artifactId>java-utc-to-jst-date-shift</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>17</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>5.12.2</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.14.1</version>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.5.3</version>
      </plugin>
    </plugins>
  </build>
</project>

通常のZIPでは5件が成功します。mvn -DargLine=-Duser.timezone=UTC clean testと、mvn -DargLine=-Duser.timezone=America/Los_Angeles clean testでも、それぞれ5件成功しました。処理が日本時間を明示しているので、JVMの既定ゾーンが違っても、売上レポートの集計日は同じです。

修正版をLocalDateTimeに戻してから東京のゾーンを付ける実装へ差し替えると、5件のうち4件が失敗します。とくにUTC 14:59:59のケースは、誤った実装でも日付だけなら12月31日です。日付に加えて日本の表示時刻と元の瞬間を確認しておくと、日付に表れなかった変換の間違いも見つかります。

レビューでは、いつの時刻をどの地域で見るか確認する

レビューでは「この15:00はUTCの時計表示です。東京の15:00として扱うと、決済の瞬間が9時間前になります。元のInstantから東京の日付を出して、0時の前後とtoInstant()の一致をテストできますか?」と伝えます。

  • 入力の時刻にゾーンまたはUTCとの差があるか確認した。
  • 日付を取り出す前に、集計対象のゾーンを明示した。
  • 日付が変わる直前と直後、元の瞬間が変わらないことを確認した。
  • 時差が書かれていない入力は、送信元との約束に沿って扱う。

日時の型とメソッドの読み方を復習するなら

Javaで文字列や日時を扱う練習から始めたいときは、『スッキリわかるJava入門 第5版』が候補です。今回の変換コードでは、atZoneの前後で変数の型を声に出して読み、どの型から日付を取るかまで説明できると、日時の処理を修正するときに役立ちます。

書籍「スッキリわかるJava入門 第5版」の表紙
商品画像:Amazon.co.jp
PR Java初学者向け

スッキリわかるJava入門 第5版

中山 清喬 (著), 国本 大悟 (著), 株式会社フレアリンク (監修)

変数・条件分岐から、クラスの基本まで学ぶ一冊。

豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。

  • Javaを基礎から体系的に学びたい
  • Java 25 LTS対応の入門書を選びたい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

関連記事Javaの現在日時をテストで固定する方法|Clock.fixedで期限判定を確認する 入力が決済の時刻ではなく「いま」なら、Clockで時刻と地域を指定し、期限の境界をテストできます。 記事を読む 関連記事Java初心者におすすめの最初の1冊|Spring Bootに進むまでの学び方 メソッドの呼び出しや型の読み方から復習するとき、最初の1冊と次へ進む目安を確認できます。 記事を読む

まとめ

決済APIから受け取ったUTCの発生時刻はInstantで扱います。日本の集計日は、InstantからAsia/Tokyoへ変換して最後に取り出します。日本時間の0時をまたぐ前後と、変換した日時から元の瞬間に戻れることを確認します。

-実装・エラー解決
-, ,