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


決済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:59Z | 2026-12-31 23:59:59 | 2026-12-31 |
| 2026-12-31T15:00:00Z | 2027-01-01 00:00:00 | 2027-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で扱うほうが意図を伝えやすくなります。日付の書式や存在しない日を拒否する方法は、次の記事で扱っています。
集計日と、決済の瞬間が変わらないことを両方試す
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版
変数・条件分岐から、クラスの基本まで学ぶ一冊。
豊富な図とサンプルで、変数、条件分岐、クラス、例外処理まで順番に学べます。Javaの最初の教科書を探している人向けです。
- Javaを基礎から体系的に学びたい
- Java 25 LTS対応の入門書を選びたい
当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。
この記事とあわせて読みたい
まとめ
決済APIから受け取ったUTCの発生時刻はInstantで扱います。日本の集計日は、InstantからAsia/Tokyoへ変換して最後に取り出します。日本時間の0時をまたぐ前後と、変換した日時から元の瞬間に戻れることを確認します。



