애플리케이션 시간 표준에서 실제 사건의 시점은 UTC Instant로 표현한다고 정했다. 하지만 Java의 Instant를 그대로 필드에 썼다고 DB 왕복까지 같은 시점이 되는 것은 아니다. MariaDB 컬럼 타입, 연결 세션 시간대, 저장 정밀도 중 하나만 달라도 저장 전과 조회 후 값이 달라진다.
또 createdAt과 updatedAt을 JPA Auditing이 만들지 DB가 만들지도 비어 있었다. JPA와 MyBatis, 운영 SQL이 같은 테이블을 고치는데 생성 주체가 애플리케이션이면 쓰기 경로마다 규칙을 반복해야 한다.
정해야 했던 건 여섯 가지였다. DB 타입, 지원 버전, UTC를 강제할 경계, 마이크로초 정규화 시점, 감사 시각의 소유자, 실제 왕복을 증명할 테스트다.
먼저: 업무 사건 시점과 행의 감사 시각은 소유자가 다르다
orderedAt과 canceledAt은 업무에서 언제 일어났는지를 뜻한다. Service가 UTC Clock으로 만들고 Domain의 판단과 기록에 같은 값을 전달한다.
createdAt과 updatedAt은 행이 DB에 언제 생기고 바뀌었는지를 뜻한다. Domain에는 없고 Entity에만 존재하는 영속화 메타데이터다. 업무 의미가 있는 시간은 애플리케이션이, 행의 생성·변경 기록은 DB가 소유한다.
이 구분 때문에 모든 시간을 DB가 만들지도 않고, 모든 시간을 JPA Auditing이 만들지도 않는다.
원칙 1. 사건 시점은 TIMESTAMP(6)에 저장한다
정해야 했던 건 이거였다 — UTC Instant를 어떤 물리 타입으로 저장할 것인가.
안 A — TIMESTAMP(6)에 저장한다.
세션 시간대에 따라 입력과 출력을 변환하므로 UTC 세션을 고정하면 Instant와 같은 시간축의 시점으로 다룰 수 있다. 마이크로초도 보존한다. 다만 MariaDB 11.5 미만은 상한이 2038년이라 버전 조건 없이 쓰면 미래 예약 시점을 저장하지 못한다.
안 B — DATETIME(6)에 UTC 모양을 저장한다.
지원 범위가 넓고 세션 시간대 변환이 없다. 그러나 컬럼 자체에는 UTC라는 의미가 없다. 운영 SQL이나 다른 애플리케이션이 지역 시각을 넣어도 정상 값으로 저장되어 같은 숫자가 서로 다른 시점을 뜻하게 된다.
안 C — BIGINT에 epoch 값을 저장한다.
시간대 변환과 DB 날짜 범위에서 자유롭다. 대신 사람이 SQL 결과를 바로 읽을 수 없고 DB 날짜 함수, 범위 검색, 운영 쿼리마다 변환이 필요하다.
골랐다 — 안 A. 안 B가 잃는 시간대 변환 의미와 안 C가 잃는 날짜 연산을 모두 유지한다. 대신 DB 최소 버전을 MariaDB 11.8 LTS로 올리고 모든 연결 세션을 UTC로 고정해야 한다.
ordered_at TIMESTAMP(6) NOT NULL
TIMESTAMP(6)의 저장 범위는 MariaDB 버전에 따라 달라진다. 타입을 하나로 골랐으므로 이 타입이 2038년 이후에도 같은 계약을 지키게 할 최소 버전을 이어서 정해야 한다.
원칙 2. 최소 버전은 MariaDB 11.8 LTS로 고정한다
안 A — MariaDB 10.11 또는 11.4 LTS도 허용한다. 지원 기간은 안정적이지만 TIMESTAMP의 2038년 상한이 남는다. 장기 계약이나 예약 시점이 2038년을 넘는 순간 같은 At 규칙을 쓸 수 없다.
안 B — MariaDB 11.8 LTS 이상만 허용한다. 2106년 상한과 LTS 운영 기준을 함께 만족한다. 대신 기존 10.11·11.4 프로젝트는 DB를 올리기 전까지 이 표준을 적용할 수 없다.
안 C — 배포 환경의 버전에 따라 상한을 다르게 둔다. 기존 환경을 유지할 수 있지만 같은 코드의 유효성 검사가 배포 DB 버전에 따라 달라진다. 테스트에서 통과한 시점이 다른 환경에서는 저장에 실패한다.
골랐다 — 안 B. 안 A의 2038년 재검토와 안 C의 환경별 계약을 없애고 하나의 범위를 갖는다. 그 대가로 기존 DB에는 예외 타입을 두지 않고 업그레이드를 요구한다.
MariaDB의 TIMESTAMP 문서는 11.5부터 상한이 2106년으로 확장됐다고 명시한다. 기능이 처음 들어온 11.5는 rolling release이므로 운영 기준은 LTS인 11.8로 잡는다. 허용 범위는 1970-01-01T00:00:01Z 이상 2106-02-07T06:28:15Z 이하다.
원칙 3. 모든 DB 연결은 세션 시간대를 UTC로 초기화한다
정해야 했던 건 이거였다 — TIMESTAMP(6)의 시간대 변환을 어디에서 통제할 것인가.
안 A — DB 서버 전역 시간대를 UTC로 둔다. 설정 한 곳으로 모든 연결이 맞춰진다. 하지만 다른 시스템이나 DBA가 서버 설정을 바꾸면 같은 애플리케이션 SQL의 의미도 함께 바뀐다.
안 B — 커넥션 풀에서 연결마다 UTC 세션을 초기화한다. 애플리케이션이 사용하는 JPA와 MyBatis 연결을 다른 시스템과 독립적으로 통제한다. 대신 새 DataSource나 커넥션 풀을 추가할 때 초기화 설정을 빠뜨리면 그 경로만 지역 시간대로 동작한다.
안 C — Java에서 UTC LocalDateTime으로 바꿔 저장한다. DB 설정과 무관해 보이지만 Instant의 시간대 의미를 잃고 저장·조회 경로마다 변환 코드가 생긴다. 한 경로가 서버 기본 시간대를 사용하면 같은 값이 다른 시점이 된다.
골랐다 — 안 B. 안 A의 운영 환경 의존과 안 C의 반복 변환을 피하면서 JPA와 MyBatis에 같은 규칙을 적용한다. 대신 DataSource 설정과 실제 세션 값을 통합 테스트로 감시한다.
HikariCP를 쓴다면 새 물리 연결마다 다음 SQL을 실행한다.
spring:
datasource:
hikari:
connection-init-sql: "SET time_zone = '+00:00'"
jpa:
properties:
hibernate.jdbc.time_zone: UTC
connection-init-sql은 JPA와 MyBatis가 공유하는 DB 세션을 UTC로 만든다. hibernate.jdbc.time_zone도 UTC로 명시해 Hibernate의 JDBC 바인딩이 JVM 기본 시간대에 기대지 않게 한다. MyBatis는 Instant를 직접 바인딩하며 중간에 LocalDateTime으로 바꾸지 않는다.
원칙 4. 사건 시점은 애플리케이션 경계에서 마이크로초로 절삭한다
정해야 했던 건 이거였다 — Java의 나노초와 TIMESTAMP(6)의 마이크로초 차이를 어디에서 없앨 것인가.
안 A — DB의 자동 절삭에 맡긴다. 애플리케이션 코드는 가장 적다. 그러나 저장 전 Domain 값과 재조회 값이 달라져 동등성 검사와 이벤트 비교가 저장 여부에 따라 달라진다.
안 B — persistence가 저장 직전에만 절삭한다. DB에 들어가는 값은 예측할 수 있지만 Domain은 여전히 나노초 원본을 가진다. 같은 객체를 저장 전후로 비교하면 값이 달라지고 persistence가 업무 값을 몰래 바꾼다.
안 C — Service와 외부 Adapter 경계에서 절삭한다. Domain에 들어오는 순간부터 저장·재조회 값이 같다. 대신 DB에 저장하지 않는 사건 시점도 마이크로초까지만 가진다.
골랐다 — 안 C. 안 A와 안 B가 만드는 저장 전후 불일치를 Domain에 들어오기 전에 없앤다. 나노초 정밀도를 잃는 대가는 MariaDB를 시스템의 영속 시계로 선택한 결과로 받아들인다.
Instant canceledAt = clock.instant()
.truncatedTo(ChronoUnit.MICROS);
order.cancel(canceledAt);
외부 API에서 받은 Instant도 Adapter에서 같은 방식으로 정규화한 뒤 Command와 Domain에 전달한다. 하나의 사건에는 절삭이 끝난 같은 값을 재사용한다.
원칙 5. createdAt과 updatedAt은 DB가 생성하고 갱신한다
정해야 했던 건 이거였다 — Domain에 없는 행 감사 시각을 어느 쓰기 경로가 만들 것인가.
안 A — JPA Auditing과 MyBatis 코드가 각각 만든다. JPA의 @CreatedDate와 @LastModifiedDate를 바로 쓸 수 있다. 그러나 MyBatis와 운영 SQL에는 같은 로직을 별도로 넣어야 하며 각 경로가 서로 다른 시각을 만들 수 있다.
안 B — Service가 모든 쓰기에 감사 시각을 전달한다. 업무 시점과 같은 Clock을 명시적으로 쓸 수 있다. 하지만 Domain이 몰라도 되는 행 메타데이터가 Command와 저장 메서드를 통과하고, SQL 하나가 인자를 빠뜨리면 감사 시각이 갱신되지 않는다.
안 C — DB 기본값과 자동 갱신으로 관리한다. JPA, MyBatis, 운영 SQL이 모두 같은 규칙을 거친다. 대신 INSERT나 UPDATE 직후 Entity에서 DB 생성값이 필요하면 refresh하거나 다시 조회해야 한다.
골랐다 — 안 C. 안 A와 안 B가 쓰기 경로마다 반복하는 규칙을 컬럼 정의 한 곳으로 모은다. 행 감사 시각을 즉시 메모리에서 얻는 편의는 포기한다.
created_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6)
JPA Entity에는 필드를 두되 애플리케이션이 쓰지 못하게 매핑한다.
@Column(name = "created_at", nullable = false, insertable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false, insertable = false, updatable = false)
private Instant updatedAt;
@CreatedDate와 @LastModifiedDate는 사용하지 않는다. MyBatis INSERT·UPDATE에서도 두 컬럼을 쓰기 목록에서 뺀다. @CreatedBy와 @LastModifiedBy처럼 “누가” 바꿨는지는 시간과 다른 질문이므로 기존 인증 주체 규칙을 유지한다.
원칙 6. 실제 MariaDB에서 JPA와 MyBatis 왕복을 각각 검증한다
정해야 했던 건 이거였다 — 타입 변환 단위 테스트로 충분한가.
안 A — 변환기 단위 테스트만 둔다. 빠르지만 MariaDB 컬럼, 드라이버, 연결 세션의 실제 변환을 하나도 통과하지 않는다.
안 B — JPA 경로만 통합 테스트한다. Hibernate 설정은 검증하지만 같은 테이블을 쓰는 MyBatis 경로의 TypeHandler와 세션 설정은 비어 있다.
안 C — MariaDB 11.8 Testcontainers에서 두 경로를 각각 왕복한다. 운영과 같은 DB·드라이버 변환을 검증한다. 대신 컨테이너 시작 비용이 생긴다.
골랐다 — 안 C. 안 A와 안 B가 건너뛰는 실제 저장 경로를 모두 통과시킨다. 다음 항목은 선택 테스트가 아니라 표준의 증거다.
- 나노초가 마이크로초로 절삭된
Instant가 JPA와 MyBatis에서 각각 같은 값으로 왕복한다. - JVM 기본 시간대가 UTC가 아니어도 결과가 같다.
- 각 연결의
@@session.time_zone이+00:00이다. - 최소·최대 허용 시점을 저장하고 조회할 수 있다.
- INSERT에서
createdAt·updatedAt이 생성되고 실제 UPDATE에서updatedAt만 증가한다.
판단 기준 정리
| 질문 | 답 | 결론 |
|---|---|---|
| 사건 시점의 DB 타입은 | UTC 세션의 시점 타입 | TIMESTAMP(6) |
| MariaDB 최소 버전은 | 확장 범위를 지원하는 LTS | 11.8 이상 |
| 허용 범위는 | 단일 시스템 계약 | 1970-01-01T00:00:01Z ~ 2106-02-07T06:28:15Z |
| UTC는 어디서 강제하나 | 모든 물리 연결 | 커넥션 세션 +00:00 |
| 나노초는 어디서 줄이나 | Domain에 들어오기 전 | ChronoUnit.MICROS |
| 업무 사건 시점은 누가 만드나 | Service | UTC Clock |
| 행 감사 시각은 누가 만드나 | DB | DEFAULT와 ON UPDATE |
| 무엇으로 증명하나 | 실제 MariaDB 왕복 | JPA·MyBatis 통합 테스트 |
이 표준을 정하기까지 — Instant라는 타입에서 실제 왕복 계약까지 내려갔다
시작은 애플리케이션 시간 표준이 남긴 저장 빈칸이었다. 사건 시점을 Instant로 정했지만 MariaDB에 어떤 타입으로 넣을지, 다시 읽었을 때 같은 값인지까지는 답하지 못했다.
첫 갈림길은 TIMESTAMP(6), DATETIME(6), BIGINT였다. DATETIME(6)은 넓지만 UTC라는 의미를 컬럼이 보장하지 못하고, BIGINT는 날짜 연산과 운영 가독성을 잃는다. UTC 세션이라는 조건을 감수하고 TIMESTAMP(6)을 골랐다.
타입을 고르자 2038년 문제가 버전 문제로 이어졌다. 범위가 확장된 11.5는 rolling release였다. 기능이 있는 최초 버전이 아니라 그 기능을 안정적으로 운영할 LTS가 기준이어야 해서 MariaDB 11.8 이상으로 올렸다.
그다음에는 UTC를 서버 전체가 아니라 연결마다 강제했다. 서버 전역 설정은 다른 시스템의 변경에 함께 흔들리고 Java 변환은 LocalDateTime을 다시 내부로 들인다. JPA와 MyBatis가 공유하는 DataSource의 세션을 UTC로 초기화하는 쪽을 골랐다.
나노초를 DB가 조용히 자르게 둘 수 없었다. 저장 전과 조회 후 Domain 값이 달라지기 때문이다. persistence 직전도 너무 늦었다. Service와 외부 Adapter에서 마이크로초로 정규화해 Domain이 처음부터 왕복 가능한 값만 갖게 했다.
감사 시각을 논의하며 시간의 소유권이 둘로 갈린다는 것을 확인했다. canceledAt은 업무 사건이라 애플리케이션이 만들고 createdAt·updatedAt은 행의 기록이라 DB가 만든다. “DB에만 존재하는 것은 DB가 관리한다”가 아니라 “DB에만 존재하는 영속화 메타데이터는 DB가 관리한다”가 정확한 경계다.
마지막에는 설정 문서가 아니라 왕복 테스트를 증거로 남겼다. JPA 하나만 통과해서는 MyBatis를 보장하지 못한다. 실제 MariaDB 11.8에서 두 경로와 비UTC JVM, 경계값, 감사 시각을 함께 검증해야 이 규칙이 선다.
정리
- MariaDB 11.8 LTS 이상에서 사건 시점을
TIMESTAMP(6)으로 저장한다. - JPA와 MyBatis가 쓰는 모든 DB 연결 세션을 UTC로 초기화한다.
- Service와 외부 Adapter가
Instant를 마이크로초로 절삭한 뒤 Domain에 전달한다. - 업무 사건 시점은 애플리케이션이,
createdAt·updatedAt은 DB가 만든다. - 실제 MariaDB에서 JPA와 MyBatis 왕복을 각각 검증한다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code — SKILL.md
---
name: database-instant-storage-standard
description: Java Instant를 MariaDB에 저장하고 JPA·MyBatis로 같은 시점을 왕복하는 규칙
---
- MariaDB 11.8 LTS 이상을 사용한다.
- 사건 시점인 `Instant xxxAt`은 `TIMESTAMP(6) NOT NULL`에 저장한다.
- `TIMESTAMP(6)` 사건 시점의 허용 범위는 `1970-01-01T00:00:01Z` 이상 `2106-02-07T06:28:15Z` 이하다.
- 모든 DataSource의 새 물리 연결에서 `SET time_zone = '+00:00'`을 실행한다.
- Hibernate에는 `hibernate.jdbc.time_zone=UTC`를 함께 설정한다.
- JPA와 MyBatis 모두 `Instant`를 직접 바인딩한다. 저장을 위해 `LocalDateTime`으로 바꾸지 않는다.
- Service가 `Clock`에서 얻은 사건 시점과 외부 Adapter가 변환한 사건 시점은 Domain에 전달하기 전에 `ChronoUnit.MICROS`로 절삭한다.
- 하나의 업무 사건에는 절삭이 끝난 같은 `Instant`를 판단과 기록에 재사용한다.
- 업무 사건 시점은 애플리케이션이 생성한다. DB 기본값으로 대신 만들지 않는다.
- Entity 전용 `createdAt`·`updatedAt`은 DB의 `DEFAULT CURRENT_TIMESTAMP(6)`와 `ON UPDATE CURRENT_TIMESTAMP(6)`로 관리한다.
- `createdAt`·`updatedAt` Entity 필드는 `insertable = false, updatable = false`로 매핑한다.
- 시간 감사 필드에 `@CreatedDate`·`@LastModifiedDate`를 사용하지 않는다.
- MyBatis INSERT·UPDATE에서 `created_at`·`updated_at`을 쓰기 컬럼에서 제외한다.
- INSERT나 UPDATE 직후 DB 생성 감사 시각이 필요하면 refresh하거나 다시 조회한다.
- MariaDB 11.8 Testcontainers에서 JPA와 MyBatis 왕복을 각각 검증한다.
- 왕복 테스트는 마이크로초 보존, 비UTC JVM, 세션 `+00:00`, 최소·최대 시점, 감사 시각 생성·갱신을 검증한다.
GitHub Copilot — instructions.md
---
description: Instant를 MariaDB TIMESTAMP(6)에 저장하고 UTC로 왕복하는 규칙
applyTo: "**/*.java, **/*.yml, **/*.yaml, **/*.sql, **/*Mapper.xml"
---
- MariaDB 11.8 LTS 이상을 사용한다.
- 사건 시점인 `Instant xxxAt`은 `TIMESTAMP(6) NOT NULL`에 저장한다.
- `TIMESTAMP(6)` 사건 시점의 허용 범위는 `1970-01-01T00:00:01Z` 이상 `2106-02-07T06:28:15Z` 이하다.
- 모든 DataSource의 새 물리 연결에서 `SET time_zone = '+00:00'`을 실행한다.
- Hibernate에는 `hibernate.jdbc.time_zone=UTC`를 함께 설정한다.
- JPA와 MyBatis 모두 `Instant`를 직접 바인딩한다. 저장을 위해 `LocalDateTime`으로 바꾸지 않는다.
- Service와 외부 Adapter는 `Instant`를 Domain에 전달하기 전에 `ChronoUnit.MICROS`로 절삭한다.
- 업무 사건 시점은 애플리케이션이 생성하고, Entity 전용 `createdAt`·`updatedAt`은 DB가 생성·갱신한다.
- `createdAt`·`updatedAt` Entity 필드는 `insertable = false, updatable = false`로 매핑하고 `@CreatedDate`·`@LastModifiedDate`를 사용하지 않는다.
- MyBatis INSERT·UPDATE에서 `created_at`·`updated_at`을 쓰기 컬럼에서 제외한다.
- MariaDB 11.8 Testcontainers에서 JPA와 MyBatis 왕복을 각각 검증한다.
- 왕복 테스트는 마이크로초 보존, 비UTC JVM, 세션 `+00:00`, 최소·최대 시점, 감사 시각 생성·갱신을 검증한다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기