애플리케이션 시간 표준에서 실제 사건의 시점은 UTC Instant로 표현한다고 정했다. 하지만 Java의 Instant를 그대로 필드에 썼다고 DB 왕복까지 같은 시점이 되는 것은 아니다. 컬럼 타입, 연결 세션 시간대, 저장 정밀도 중 하나만 어긋나도 저장 전과 조회 후 값이 달라진다.

또 createdAt과 updatedAt을 누가 만들지도 비어 있었다. JPA와 MyBatis, 운영 SQL(운영자가 DB에 직접 치는 SQL)이 같은 테이블을 고치므로, 애플리케이션에 맡기든 DB에 맡기든 대가가 따른다.

정해야 했던 건 여섯 가지였다. 어느 DB에 맞출지, DB 타입, UTC를 강제할 경계, 마이크로초 정규화 시점, 감사 시각의 소유자, 실제 왕복을 증명할 테스트다.

이 표준은 처음에 MariaDB를 전제로 정해졌다가 2026-10-20에 PostgreSQL로 옮겨 다시 썼다. 옮긴 경위와 MariaDB 시절의 결정은 「이 표준을 정하기까지」에 남겼다.


먼저: 업무 사건 시점과 행의 감사 시각은 소유자가 다르다

orderedAt과 canceledAt은 업무에서 언제 일어났는지를 뜻한다. Service가 UTC Clock으로 만들고 Domain의 판단과 기록에 같은 값을 전달한다.

createdAt과 updatedAt은 행이 DB에 언제 생기고 바뀌었는지를 뜻한다. Domain에는 없고 Entity에만 존재하는 영속화 메타데이터다 — Entity는 DB 테이블 한 줄을 자바 클래스로 옮겨 그린 것이고, 영속화는 프로그램이 꺼져도 이 값이 DB에 남아 있게 하는 일이다. 업무 의미가 있는 시간은 Service가 Domain을 거쳐 만들고, 행의 생성·변경 기록은 Domain을 거치지 않고 저장하는 쪽이 채운다 — 처음 값은 DB 기본값이, 갱신은 JPA면 리스너가, MyBatis면 Repository 구현체가 넘긴 값이 채운다. 그래서 감사 시각으로 업무를 판단하지 않는다. 마감이나 만료 같은 업무 판단은 Domain에 들어오는 사건 시점(xxxAt)으로 한다.

이 구분 때문에 모든 시간을 DB가 만들지도 않고, 모든 시간을 Service가 만들지도 않는다. 감사 시각 두 개를 저장하는 쪽의 누가 채우는지는 원칙 5에서 가른다.


원칙 1. DB는 PostgreSQL로 둔다

정해야 했던 건 이거였다 — 표준이 어느 DB를 전제로 할 것인가. 컬럼 타입도 세션 설정도 DB마다 다르므로 이것부터 정해야 나머지를 정할 수 있다. 이 표준을 쓰는 프로젝트는 둘이다. 한 프로젝트는 PostgreSQL 17이고, 다른 하나(farm-predict)는 MariaDB다.

안 A — MariaDB로 계속 간다. 이 표준은 원래 MariaDB 11.8을 전제로 쓰였다. 얻는 것은 MariaDB 소스 코드가 이 저장소의 open-source/ 폴더에 있어 DB 동작을 소스로 따라가 증명할 수 있다는 점이다. 버린 이유는 증명이 이미 PostgreSQL에서 돌고 있었다는 것이다. 결정하던 날(2026-10-20) proofs/ 폴더의 실험 21개를 세어 보니 DB를 띄운 것이 PostgreSQL 6개, MariaDB 3개였다. 표준을 MariaDB에 두면 PostgreSQL에서 확인한 사실을 MariaDB에서 다시 재야 하고, 표준을 실제로 쓰는 그 PostgreSQL 프로젝트와도 어긋난다.

안 B — DB마다 규칙을 나눈다. 두 프로젝트가 각자 자기 DB 규칙을 받는다. 얻는 것은 어느 프로젝트도 DB를 바꾸지 않아도 된다는 점이다. 버린 이유는 DB에 걸리는 규칙마다 두 벌이 붙는다는 것이다. 이 글만 해도 타입·세션 설정·감사 시각이 전부 DB마다 다르다. 두 벌이 한 파일에 섞이면 읽는 쪽이 자기 DB 줄을 골라 읽어야 하고, 한쪽만 고쳐지면 조용히 어긋난다.

안 C — PostgreSQL로 바꾸고 MariaDB 규칙은 갈래로 남기지 않는다. 표준의 DB 규칙을 전부 PostgreSQL 것으로 바꾼다. farm-predict는 DB 규칙만 표준 밖에 두고 자기대로 간다. DB와 무관한 표준은 farm-predict에도 그대로 통한다.

골랐다 — 안 C. 안 A가 버리는 이미 돈 PostgreSQL 실험을 MariaDB에서 다시 돌리지 않고 그대로 쓰고, 안 B가 만드는 규칙 두 벌을 만들지 않는다. 대가는 farm-predict가 DB 규칙에서 표준의 도움을 받지 못한다는 것이다. 다만 DB를 옮기자 PostgreSQL에서 어느 시간 타입이 맞는지는 이미 돈 실험에 없었다. 그것을 보려고 실험 하나를 새로 돌렸다(원칙 2).

이 표준을 MariaDB에 둘 때는 최소 버전도 원칙이었다. MariaDB TIMESTAMP의 상한이 버전에 따라 2038년이나 2106년이라 버전을 11.8로 묶었다. PostgreSQL로 오면서 이 원칙을 지웠다. PostgreSQL의 시각 타입은 범위가 4713 BC ~ 294276 AD라, 2038년이나 2106년처럼 업무 데이터가 실제로 닿을 상한이 없다(원칙 2). 「PostgreSQL 17 이상」으로 고정하는 안도 있었지만 버렸다 — 근거가 「지금 쓰는 프로젝트가 17이다」뿐인 숫자라, 그 숫자를 지킬 이유를 글에 적을 수 없다. 대신 테스트가 운영과 같은 메이저 버전(17.10의 17처럼 버전 맨 앞 숫자)을 쓰게 했다(원칙 6).

범위 상한을 지운 대가도 있다. 294276년이라는 끝은 남아 있지만 애플리케이션이 미리 검사하지 않으므로, 그 끝을 넘는 엉뚱한 연도가 외부 입력으로 들어오면 DB가 오류로 거절하고 사용자는 서버 오류(HTTP 500)를 받는다. 그 오류가 SQLState 22008(날짜·시각 값이 범위를 넘었다)로 올 것이라고 보지만, 실제로 그 오류가 나는지도, 얼마나 자주 들어오는지도 재지 않았다. 외부 입력에서만 생기고 드물다고 보고 받아들였다.


원칙 2. 사건 시점은 timestamptz(6)에 저장한다

실측(supported): PostgreSQL 17 timestamptz(6) 에 Asia/Seoul 세션으로 넣고 UTC 세션으로 읽었다 — (1) 같은 순간=yes(epoch 1792465200.123456, 기대 1792465200.123456) · (2) .123456 보존=yes(2026-10-20 03:00:00.123456+00) · (3) 3000-01-01Z 저장=yes(3000-01-01 00:00:00+00) · timestamp(6) 은 2026-10-20 12:00:00.123456 로 읽혀 +32400.000000초 어긋남 · 7자리 .1234567 은 tz 2026-10-20 03:00:00.123457+00 — 증명

정해야 했던 건 이거였다 — UTC Instant를 PostgreSQL의 어떤 타입으로 저장할 것인가.

안 A — timestamptz(6)에 저장한다.

timestamptz는 이름과 달리 시간대를 저장하지 않는다. 넣을 때 값에 붙은 시간대(없으면 세션 시간대)로 UTC로 바꿔 저장하고, 읽을 때 세션 시간대로 바꿔 보여 준다. 그래서 컬럼 자체가 「이 값은 한 순간이다」라는 뜻을 가진다. (6)은 소수점 아래 6자리, 마이크로초까지 둔다는 뜻이다. PostgreSQL 17 문서 8.5에 범위는 4713 BC ~ 294276 AD, 정밀도는 1마이크로초로 적혀 있다.

실측이 이것을 받친다. 서울 시간대 세션으로 2026-10-20 12:00:00.123456+09를 넣고 UTC 세션으로 읽으니 2026-10-20 03:00:00.123456+00이 나왔다 — 같은 순간이고 .123456도 그대로다. 3000년 1월 1일도 오류 없이 들어갔다.

안 B — timestamp(6)(without time zone)에 UTC 모양을 저장한다.

시간대 변환이 없어 넣은 글자가 그대로 나온다. 그러나 문서는 이 타입이 입력에 붙은 시간대 표시를 말없이 버린다고 적는다. 컬럼에는 UTC라는 뜻이 없어서 운영 SQL이나 다른 애플리케이션이 지역 시각을 넣어도 정상 값으로 저장되고, 같은 숫자가 서로 다른 시점을 뜻하게 된다. MariaDB 시절 DATETIME(6)(시간대 없이 날짜와 시각 글자만 담는 MariaDB 타입)을 버린 이유와 같다.

실측에서 그대로 깨졌다. 같은 문자열을 이 칸에 넣었더니 +09가 버려지고 2026-10-20 12:00:00.123456이 저장됐다. UTC로 읽은 순간은 기대보다 +32400초(9시간) 늦었다.

안 C — BIGINT에 epoch 값을 저장한다.

시간대 변환과 DB 날짜 범위에서 자유롭다. 대신 사람이 SQL 결과를 바로 읽을 수 없고 DB 날짜 함수, 범위 검색, 운영 쿼리마다 변환이 필요하다.

골랐다 — 안 A. 안 B가 잃는 「한 순간」이라는 뜻과 안 C가 잃는 날짜 연산을 모두 지킨다. 대신 시간대가 붙지 않은 값이 세션 시간대로 해석되므로 모든 연결 세션을 UTC로 맞춰야 한다(원칙 3).

ordered_at timestamptz(6) NOT NULL

한 줄씩 따라가면, ordered_at은 이 컬럼의 이름이고, timestamptz(6)은 그 컬럼에 한 순간을 UTC로 저장하되 소수점 6자리(마이크로초)까지 둔다는 뜻이다. NOT NULL은 이 컬럼을 비워둔 채 행을 저장할 수 없게 막는 DB 규칙이다.

그 PostgreSQL 프로젝트는 지금 사건 시점 컬럼이 timestamp(6)이다. MariaDB에서 옮길 때 DATETIME을 「둘 다 시간대를 담지 않는다」며 그대로 옮긴 것이라, 이 원칙에 맞추려면 timestamptz(6)으로 옮겨야 한다. 그 마이그레이션은 그 PostgreSQL 프로젝트 쪽 일이다.


원칙 3. 모든 DB 연결은 세션 시간대를 UTC로 초기화한다

정해야 했던 건 이거였다 — 세션 시간대를 어디에서 통제할 것인가.

timestamptz를 골랐으니 시간대가 붙은 값은 세션 시간대와 상관없이 같은 순간으로 저장된다. 그런데 시간대가 붙지 않은 값은 세션 시간대로 해석된다. 문서 8.5는 timestamptz에 대해 「입력 글자에 시간대가 없으면 세션의 TimeZone 설정에 적힌 시간대로 보고 UTC로 바꾼다」고 적는다. 운영 SQL에 '2026-10-20 12:00'처럼 적은 시각이 서울 세션에서는 서울 12시, UTC 세션에서는 UTC 12시가 된다. 세션 시간대가 연결마다 다르면 같은 SQL이 연결마다 다른 순간을 저장한다.

그리고 세션 시간대는 맞추지 않으면 우리가 모르는 값이 된다. 실험에는 세션이 두 종류 있었다. psql(DB에 SQL을 치는 명령 프로그램)로 연 세션은 실험자가 SET TIME ZONE으로 서울과 UTC를 골라 걸었다. 원칙 2의 실측이 그것이다. 따로 돈 JDBC(자바가 DB와 이야기하는 표준 방법) 프로그램은 세션 시간대를 고르지 않았다. 이 프로그램은 JVM 기본 시간대를 Asia/Seoul로 두고 돌렸는데, DB 드라이버(JDBC 요청을 그 DB의 말로 옮겨 주는 라이브러리)가 연 세션의 시간대가 Asia/Seoul로 찍혔다. 그 세션에서 timestamp(6) 칸에 시간대가 붙은 자바 시각(OffsetDateTime, UTC 새벽 3시)을 넣었더니 서울 시각 12시 글자가 저장됐고, UTC로 읽은 순간이 9시간 어긋났다. psql 경우의 9시간은 입력의 +09를 버려서 생겼지만, 이 경우는 세션 시간대로 바꾼 글자가 남아서 생겼다. 이 실험은 JVM 시간대 하나로만 돌렸고 드라이버 소스도 보지 않았으므로 「언제나 JVM을 따른다」까지는 말할 수 없다. 말할 수 있는 것은 설정하지 않은 세션 시간대가 실험자가 고르지 않은 값이었고, 그 값이 결과를 바꿨다는 것이다.

안 A — DB 서버 전역 시간대를 UTC로 둔다. 설정 한 곳으로 모든 연결이 맞춰진다. 하지만 다른 시스템이나 DBA가 서버 설정을 바꾸면 같은 애플리케이션 SQL의 의미도 함께 바뀐다. 위 실측처럼 연결마다 서버 설정과 다른 시간대가 걸릴 수도 있어서, 서버 설정만 믿을 수도 없다.

안 B — 커넥션 풀에서 연결마다 UTC 세션을 초기화한다. 애플리케이션이 사용하는 JPA와 MyBatis 연결을 다른 시스템과 독립적으로 통제한다. 대신 새 DataSource나 커넥션 풀을 추가할 때 초기화 설정을 빠뜨리면 그 경로만 다른 시간대로 동작한다.

안 C — Java에서 UTC LocalDateTime으로 바꿔 저장한다. DB 설정과 무관해 보이지만 Instant의 시간대 의미를 잃고 저장·조회 경로마다 변환 코드가 생긴다. 한 경로가 서버 기본 시간대를 사용하면 같은 값이 다른 시점이 된다.

골랐다 — 안 B. 안 A의 운영 환경 의존과 안 C의 반복 변환을 피하면서 JPA와 MyBatis에 같은 규칙을 적용한다. 대신 DataSource 설정과 실제 세션 값을 통합 테스트로 감시한다.

HikariCP를 쓴다면 다음처럼 둔다. 커넥션 풀은 DB 연결을 미리 몇 개 만들어 두고 요청마다 빌려 준다. 그 미리 만드는 연결 하나하나가 「물리 연결」이라, 만들 때 한 번 UTC로 맞춰 두면 그 연결을 몇 번 빌려 써도 UTC다.

spring:
  datasource:
    hikari:
      connection-init-sql: "SET TIME ZONE 'UTC'"
  jpa:
    properties:
      hibernate.jdbc.time_zone: UTC

한 줄씩 따라가면, YAML은 들여쓰기로 상위 설정 아래 속한 하위 설정을 나타낸다. spring.datasource.hikari.connection-init-sql은 HikariCP가 새 물리 연결을 만들 때마다 가장 먼저 실행할 SQL을 지정하는 자리이고, "SET TIME ZONE 'UTC'"가 그 SQL이다 — 이 연결의 시간대를 UTC로 맞추라는 PostgreSQL 문장이다. spring.jpa.properties.hibernate.jdbc.time_zone: UTC는 Hibernate가 JDBC로 값을 주고받을 때 쓰는 시간대를 UTC로 고정하는 설정이다.

connection-init-sql은 JPA와 MyBatis가 공유하는 DB 세션을 UTC로 만든다. 여기서 “바인딩”은 자바 값을 SQL의 ? 자리에 실제로 밀어 넣는 동작을 뜻한다 — 가진 값을 어떤 시간대 기준으로 그 자리에 채우느냐에 따라 같은 값도 DB에 다른 시각으로 들어갈 수 있다. hibernate.jdbc.time_zone은 세션과 다른 층의 설정이다. 세션 시간대는 DB가 값을 해석하는 기준이고, 이 설정은 Hibernate가 자바 값을 DB로 보낼 때 쓰는 기준이다. 이것을 UTC로 두어 Hibernate가 보낼 때 JVM 기본 시간대에 기대지 않게 한다. 다만 이 설정도 만능은 아니다. 그 PostgreSQL 프로젝트의 BaseEntity 주석은 이 설정이 「시간대를 가진 타입에만 옳게 걸린다」고 적는다 — 시간대가 없는 LocalDateTime은 이 설정이 있어도 9시간 어긋났다(원칙 5의 시각 보정 사고). 그래서 필드를 Instant로 두는 것이 함께 가야 한다. MyBatis 쪽은 이런 설정을 두지 않고 규칙으로 막는다 — Instant를 그대로 바인딩하고, 중간에 LocalDateTime으로 바꾸지 않는다. MyBatis에 같은 역할의 설정이 있는지는 찾지 않았다.


원칙 4. 사건 시점은 애플리케이션 경계에서 마이크로초로 절삭한다

정해야 했던 건 이거였다 — Java의 나노초와 timestamptz(6)의 마이크로초 차이를 어디에서 없앨 것인가.

안 A — DB의 자동 처리에 맡긴다. 애플리케이션 코드는 가장 적다. 그러나 저장 전 Domain 값과 재조회 값이 달라져, 같은 값인지 비교하는 코드가 저장 여부에 따라 다른 답을 낸다. PostgreSQL에서는 그 처리가 잘라 내기도 아니었다. 실측에서 7자리 .1234567을 넣으니 .123457로 반올림됐다. 문서 8.5 페이지에는 반올림인지 절삭인지가 적혀 있지 않다. 그 페이지에 적히지 않은 동작에 값을 맡기는 셈이다.

안 B — 저장 코드가 저장 직전에만 절삭한다. DB에 들어가는 값은 예측할 수 있지만 Domain은 여전히 나노초 원본을 가진다. 같은 객체를 저장 전후로 비교하면 값이 달라지고, 저장 코드가 업무 값을 몰래 바꾼다.

안 C — Service와 외부 Adapter 경계에서 절삭한다. Domain에 들어오는 순간부터 저장·재조회 값이 같다. 대신 DB에 저장하지 않는 사건 시점도 마이크로초까지만 가진다.

골랐다 — 안 C. 안 A와 안 B가 만드는 저장 전후 불일치를 Domain에 들어오기 전에 없애고, DB의 반올림이 끼어들 자리도 없앤다. 나노초 정밀도를 잃는 것은 받아들인다 — 시각을 오래 남기는 곳이 DB이고 DB가 마이크로초까지만 담으니, 그보다 잘게 들고 있어도 저장하면 사라진다.

Instant canceledAt = clock.instant()
        .truncatedTo(ChronoUnit.MICROS);
order.cancel(canceledAt);

한 줄씩 따라가면, clock.instant()는 지금 이 순간의 Instant 값을 나노초 단위까지 얻어 온다. .truncatedTo(ChronoUnit.MICROS)는 그 값에서 마이크로초보다 더 잘게 쪼개진 부분(나노초 나머지)을 잘라 버리는 메서드다 — timestamptz(6)이 마이크로초까지만 저장하고 넘치는 자리는 반올림하므로, 여기서 미리 잘라 둬야 저장 전후 값이 달라지지 않는다. order.cancel(canceledAt)은 이렇게 정규화한 값을 Domain에 전달해 취소 시점으로 기록한다.

외부 API에서 받은 Instant도 Adapter에서 같은 방식으로 정규화한 뒤 Command와 Domain에 전달한다. 하나의 사건에는 절삭이 끝난 같은 값을 재사용한다.


원칙 5. 감사 시각의 처음 값은 DB 기본값이, updatedAt 갱신은 애플리케이션이 맡는다

정해야 했던 건 이거였다 — Domain에 없는 행 감사 시각을 어느 쓰기 경로가 만들 것인가.

MariaDB 시절의 답은 「둘 다 DB가 만든다」였다. DEFAULT CURRENT_TIMESTAMP(6)와 ON UPDATE CURRENT_TIMESTAMP(6)를 컬럼에 걸어, JPA든 MyBatis든 운영 SQL이든 누가 고쳐도 빠짐없이 updated_at이 바뀌게 했다. 그때 버린 안이 「쓰기 경로마다 애플리케이션이 채운다」였고, 버린 이유는 「쓰기 경로마다 같은 규칙을 반복해야 한다」였다. 그런데 PostgreSQL에는 ON UPDATE가 없다. 같은 일을 하려면 트리거(행이 바뀔 때마다 DB가 스스로 실행하도록 걸어 두는 SQL)를 만들어야 하고, 그 앞에 그 PostgreSQL 프로젝트가 이미 겪은 사고가 있었다.

시각 보정 사고(2026-08-10). 시간대 정보가 없는 자바 타입 LocalDateTime을 쓰다가 시각이 9시간 어긋난 채 저장되는 버그가 있었다. 고치면서, 이미 잘못 들어간 시각들을 9시간씩 되돌리는 마이그레이션(DB 구조나 데이터를 바꾸는 SQL 파일) V7을 돌렸다. V7은 updated_at도 9시간 되돌리려 했지만, 그보다 앞서 created_at만 바꾸는 UPDATE에서도 ON UPDATE CURRENT_TIMESTAMP가 작동해 DB가 updated_at을 「V7을 돌린 그 순간」으로 먼저 덮었다. 그 위에 V7의 updated_at 보정이 한 번 더 얹혀, 원래 들어 있던 수정 시각은 되짚을 근거까지 사라졌다(그 PostgreSQL 프로젝트의 커밋 160f6ed). 그 PostgreSQL 프로젝트의 다음 마이그레이션 V8 주석이 그 일을 이렇게 적는다.

UPDATED_AT 컬럼에는 ON UPDATE CURRENT_TIMESTAMP 가 걸려 있다. V7 이 다른 컬럼만 바꾸는 UPDATE 를 날려도 DB 가 UPDATED_AT 을 그 순간의 KST 로 덮었다. 그 위에 V7 의 보정이 한 번 더 얹혀 원래 값이 사라졌다.

원래 값은 되살리지 못했고, V8은 UPDATED_AT을 CREATED_AT과 같게 맞추는 것으로 정리했다. PostgreSQL로 옮기면서(2026-08-12) 그 PostgreSQL 프로젝트는 자동 갱신을 일부러 만들지 않았다. 스키마 파일(V202608120002__create_schema.sql 32–36행)에 이유가 남아 있다.

ON UPDATE CURRENT_TIMESTAMP → 없앤다 PostgreSQL 에는 같은 것이 없고 트리거로 흉내 내야 한다. 일부러 만들지 않는다. [그 사고]에서 터진 것이 바로 이 자동 갱신이었다 — 다른 컬럼만 바꾸는 UPDATE 에도 DB 가 UPDATED_AT 을 덮어써서 보정이 두 번 얹혔다. 지금은 애플리케이션이 명시적으로 넣는다. 기본값(DEFAULT CURRENT_TIMESTAMP)은 최초 INSERT 를 위해 남긴다.

이 사고를 놓고 안을 다시 셋으로 세웠다.

안 A — 트리거로 DB가 updated_at을 채운다. MariaDB 시절 원칙 5가 지키던 것을 그대로 지킨다 — JPA·MyBatis·운영 SQL 누가 고쳐도 빠짐없다. 버린 이유는 데이터를 바로잡는 SQL — 운영자가 직접 치는 SQL과 보정 마이그레이션 — 까지 수정으로 친다는 것이다. 단순한 트리거는 「행이 바뀌었다」만 보고, 그 UPDATE가 업무 수정인지 보정인지 모른다. 그 PostgreSQL 프로젝트의 V7이 겪은 일이 그대로 재현된다. 보정일 때를 따로 표시해 트리거가 건너뛰게 하는 변형은 검토하지 않았다.

안 B — Service가 모든 쓰기에 감사 시각을 전달한다. 업무 시점과 같은 Clock을 명시적으로 쓸 수 있다. 하지만 Domain이 몰라도 되는 행 메타데이터가 Command와 저장 메서드를 통과하고, SQL 하나가 인자를 빠뜨리면 감사 시각이 갱신되지 않는다.

안 C — 처음 값은 DB 기본값이, updatedAt 갱신은 저장하는 쪽 애플리케이션 코드가 맡는다. 두 컬럼 모두 INSERT 때는 DB 기본값으로 채운다. 그 뒤 updatedAt은 JPA라면 Entity에 붙인 리스너가 @PreUpdate 콜백으로 UPDATE 직전에 채우고, MyBatis로 업무 데이터를 고치는 UPDATE라면 Repository 구현체가 시각을 얻어 SQL 파라미터로 넘긴다. 둘 다 시각은 주입받은 Clock에서 얻는다 — 어떻게 얻는지는 이 원칙 뒤쪽에서 가른다. 데이터를 바로잡는 SQL(운영자 SQL과 보정 마이그레이션)은 updated_at을 건드리지 않는다 — 이것은 저절로 되는 것이 아니라 보정 SQL을 쓰는 사람이 지키는 규칙이다.

골랐다 — 안 C. 안 A처럼 보정 SQL이 원래 값을 덮는 일이 없고, 안 B처럼 감사 시각이 Domain과 Command를 지나가지도 않는다. 대가는 MariaDB 시절에 버렸던 이유 — 쓰기 경로마다 규칙 반복 — 가 MyBatis 쪽에서 돌아온다는 것이다. JPA는 리스너가 UPDATE마다 저절로 불리지만, MyBatis UPDATE는 구현체가 updated_at 값을 넘기는 줄을 손으로 적어야 한다. 빠뜨리면 그 행의 updated_at은 바뀌지 않고 직전 값에 머문다. 그래도 받아들인 이유는 두 사고의 크기가 달라서다. 「먼저」에서 정한 대로 감사 시각으로는 업무를 판단하지 않으므로, 빠뜨린 피해는 기록이 틀리는 데서 그친다. 트리거 쪽 사고는 원래 값을 지워 되살릴 수도 없다. 운영자가 보정이 아니라 업무 데이터를 직접 고치는 SQL에서 updated_at을 어떻게 할지는 정하지 않았다.

created_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP

한 줄씩 따라가면, 두 컬럼 모두 DEFAULT CURRENT_TIMESTAMP로 처음 INSERT 때 DB가 지금 시각을 넣는다. ON UPDATE 같은 자동 갱신은 없다 — 그 뒤 updated_at을 바꾸는 것은 애플리케이션이다.

JPA Entity에는 두 필드를 다르게 매핑한다. 감사 시각은 모든 Entity에 같이 있으므로 공통 부모 클래스 하나에 둔다.

@MappedSuperclass
@EntityListeners(AuditTimeListener.class)
abstract class BaseEntity {

    @Column(name = "created_at", nullable = false, insertable = false, updatable = false)
    private Instant createdAt;

    @Column(name = "updated_at", nullable = false, insertable = false)
    private Instant updatedAt;

    void touch(Instant now) {
        this.updatedAt = now;
    }
}

한 줄씩 따라가면, @MappedSuperclass는 이 클래스 자체는 테이블이 아니고, 이 클래스를 상속한 Entity들이 여기 적힌 필드를 자기 컬럼으로 물려받는다는 JPA 표시다. @EntityListeners(AuditTimeListener.class)는 이 Entity들에 저장 전후로 불릴 메서드를 AuditTimeListener라는 별도 클래스에서 찾으라는 뜻이다 — 그 클래스는 아래에 있다. @Column(name = "created_at", ...)은 이 자바 필드가 created_at 컬럼과 짝이라고 알려주는 표시다. insertable = false와 updatable = false는 JPA가 INSERT·UPDATE SQL을 만들 때 이 필드를 쓰기 목록에서 빼라는 뜻이다. 그래서 createdAt은 DB 기본값만 만들고 그 뒤로는 바뀌지 않는다. updatedAt은 insertable = false만 있어서, INSERT 때는 빠져 DB 기본값이 채우고 UPDATE 때는 쓰기 목록에 들어간다. touch는 밖에서 받은 시각을 updatedAt에 담기만 한다. Entity 안에는 지금 시각을 얻는 코드가 없다.

INSERT 직후의 자바 객체에는 두 필드가 비어 있다. 값은 DB가 만들었고 자바 쪽에는 아직 오지 않았기 때문이다. 그 값이 필요하면 DB에서 다시 읽어 채운다(refresh하거나 다시 조회한다).

JPA Auditing의 @CreatedDate와 @LastModifiedDate는 사용하지 않는다. createdAt은 DB 기본값이 맡고 updatedAt은 리스너 하나가 맡으므로, 같은 일을 하는 길을 둘로 두지 않는다. MyBatis INSERT에서는 두 컬럼을 쓰기 목록에서 빼고, 업무 데이터를 고치는 MyBatis UPDATE에는 구현체가 Clock에서 얻은 값을 updated_at 파라미터로 넘긴다. UPDATE SQL에 CURRENT_TIMESTAMP를 적지 않는다 — 갱신 시각을 JPA 경로(리스너의 Clock)와 같은 시계로 맞추고, 테스트에서 고정한 Clock을 따르게 하기 위해서다. @CreatedBy와 @LastModifiedBy처럼 “누가” 바꿨는지는 시간과 다른 질문이므로, 인증 주체 표준이 정한 대로 AuditorAware가 따로 읽는 경로를 그대로 둔다 — 이 표준은 “언제”만 다루고 “누가”는 건드리지 않는다.

updatedAt을 애플리케이션이 채우면서 생긴 것이 둘 더 있다. 하나는 원칙 4가 여기에도 걸린다는 것이다. 애플리케이션이 만든 시각이 DB로 가므로, 절삭하지 않으면 DB가 반올림해 자바 쪽 값과 어긋난다. 그래서 리스너와 MyBatis 구현체가 넣는 값도 마이크로초로 절삭한다. 다른 하나는 시계가 둘이 된다는 것이다. createdAt과 처음 updatedAt은 DB 서버의 시계로, 그 뒤 updatedAt은 JPA든 MyBatis든 애플리케이션 서버의 Clock으로 찍힌다. 두 서버의 시계가 어긋나면 그만큼 감사 시각끼리 어긋난다. 얼마나 어긋나는지는 재지 않았다.

남은 질문은 JPA 경로에서 UPDATE 직전에 지금 시각을 어디서 얻느냐였다. 애플리케이션 시간 표준은 Instant.now()를 직접 부르지 않고 주입받은 Clock을 쓰게 한다. 주입은 Spring이 객체를 만들 때 그 객체에 필요한 부품(여기서는 Clock)을 넣어 주는 것이다. Entity는 Spring이 아니라 JPA가 만드는 객체라 이 방법으로 Clock을 받지 못한다.

안 가 — Entity 안의 @PreUpdate 메서드에서 Instant.now()를 부르고, 시간 표준에 updated_at만 예외로 둔다. 그 PostgreSQL 프로젝트의 BaseEntity가 이 모양이다. 얻는 것은 JPA 경로에서 빠뜨릴 수 없다는 점이다. JPA가 UPDATE마다 콜백을 알아서 부르므로, 수정하는 코드를 새로 짜도 따로 적을 줄이 없다. 버린 이유는 시간 표준에 예외가 생기고, 테스트에서 Clock을 고정해도 이 값만 실제 시계를 따라가 정확한 값을 검사할 수 없다는 것이다. 처음에는 이 안을 골랐다. 「Entity가 Clock을 못 받으니 콜백도 못 받는다」고 봤는데, 콜백을 Entity 밖 클래스에 두면 그 전제가 맞지 않았다(안 다).

안 나 — Repository 구현체가 Clock을 주입받아 entity.touch(clock.instant())를 부른다. Repository 구현체는 Spring이 만드는 객체라 Clock을 받을 수 있다. 얻는 것은 시간 표준에 예외가 생기지 않는다는 점과, 테스트에서 고정한 Clock으로 updated_at의 정확한 값을 검사할 수 있다는 점이다. 버린 이유는 수정하는 Repository마다 그 한 줄을 손으로 적어야 하고, 빠뜨려도 컴파일도 테스트도 잡지 않는다는 것이다. 빠뜨린 구현체가 고친 행은 updated_at이 직전 값 — 처음 값이거나 다른 경로가 앞서 남긴 값 — 에 머문다.

안 다 — @EntityListeners로 붙인 리스너 클래스가 Clock을 주입받아 @PreUpdate에서 채운다.

@Component
@RequiredArgsConstructor
class AuditTimeListener {

    private final Clock clock;

    @PreUpdate
    void onUpdate(BaseEntity entity) {
        entity.touch(clock.instant().truncatedTo(ChronoUnit.MICROS));
    }
}

한 줄씩 따라가면, @Component는 이 클래스를 Spring이 만들고 관리하는 객체(빈)로 등록하는 표시이고, @RequiredArgsConstructor는 final 필드 clock을 받는 생성자를 만들어 Spring이 거기로 Clock을 넣게 한다. @PreUpdate는 JPA가 UPDATE SQL을 보내기 직전에 이 메서드를 부르게 하는 표시이고, 리스너에 둔 콜백은 고쳐지는 Entity를 인자로 받는다. clock.instant()로 지금 시각을 얻고 .truncatedTo(ChronoUnit.MICROS)로 원칙 4대로 절삭해 touch로 넘기면, 그 값이 UPDATE와 함께 나간다.

이 안이 서는 것은 리스너가 Entity와 달리 Spring에게서 만들어질 수 있어서다. Hibernate는 리스너 객체를 직접 new하지 않고 「빈 저장소」에서 꺼내 쓴다(Hibernate ListenerCallback.java 40행 — beanRegistry.getBean(listenerClass)). Spring Boot는 그 빈 저장소를 Spring에 연결하는 SpringBeanContainer(spring-orm)를 기본으로 꽂아 둔다. Boot는 이것을 beanContainerIsConfiguredByDefault 테스트로 지킨다(HibernateJpaAutoConfigurationTests.java 797–800행). 내가 읽은 소스는 저장소 open-source/에 있는 개발 버전(Boot 4 계열)이고, 실제로 리스너에 Clock이 들어오는지 돌려 보지는 않았다.

골랐다 — 안 다. 안 가처럼 JPA가 UPDATE마다 저절로 불러 빠뜨리지 않고, 안 나처럼 주입받은 Clock을 써서 시간 표준에 예외가 없고 테스트에서 시각을 고정할 수 있다. 안 가와 안 나가 하나씩만 주던 것을 둘 다 준다. 대가는 리스너 클래스가 하나 는다는 것과, 「Boot가 리스너에 주입해 준다」는 동작에 기댄다는 것이다 — Boot가 테스트로 지키는 기본 동작이고 소스로 확인했지만 돌려 보지는 않았다. 이 리스너는 JPA를 거치는 UPDATE에서만 돈다. MyBatis UPDATE는 앞에서 정한 대로 구현체가 Clock에서 얻은 값을 넘기고, 그 줄을 빠뜨릴 수 있다는 대가는 그대로 남는다.


원칙 6. 실제 PostgreSQL에서 JPA와 MyBatis 왕복을 각각 검증한다

정해야 했던 건 이거였다 — 타입 변환 단위 테스트로 충분한가.

안 A — 변환기 단위 테스트만 둔다. 빠르지만 PostgreSQL 컬럼, 드라이버, 연결 세션의 실제 변환을 하나도 통과하지 않는다.

안 B — JPA 경로만 통합 테스트한다. Hibernate 설정은 검증하지만 같은 테이블을 쓰는 MyBatis 경로의 TypeHandler와 세션 설정은 비어 있다.

안 C — PostgreSQL Testcontainers에서 두 경로를 각각 왕복한다. Testcontainers는 테스트를 돌릴 때마다 실제 PostgreSQL을 Docker 컨테이너로 잠깐 띄웠다가 테스트가 끝나면 지워 주는 도구다. 운영과 같은 DB·드라이버 변환을 검증한다. 대신 컨테이너 시작 비용이 생긴다.

골랐다 — 안 C. 안 A와 안 B가 건너뛰는 실제 저장 경로를 모두 통과시킨다. Testcontainers 이미지는 운영과 같은 PostgreSQL 메이저 버전으로 둔다. 원칙 1에서 버전 숫자를 표준에 박지 않은 대신, 테스트가 운영과 다른 버전에서 통과하는 일을 이 줄로 막는다. 다음 항목은 선택 테스트가 아니라 표준의 증거다.

  • 나노초가 마이크로초로 절삭된 Instant가 JPA와 MyBatis에서 각각 같은 값으로 왕복한다.
  • JVM 기본 시간대가 UTC가 아니어도 결과가 같다.
  • 각 연결의 세션 시간대(SHOW TimeZone으로 읽는다)가 UTC다.
  • INSERT에서 createdAt·updatedAt이 생성되고, JPA UPDATE와 업무용 MyBatis UPDATE에서 updatedAt만 바뀐다.

MariaDB 시절에는 「최소·최대 허용 시점을 저장하고 조회할 수 있다」는 경계값 항목이 있었다. 원칙 1에서 업무 데이터가 닿을 상한이 없어져 함께 뺐다. 294276년이라는 끝을 테스트로 지키지는 않는다.


판단 기준 정리

질문 답 결론
표준의 DB는 실험이 이미 돈 DB, 규칙은 한 벌 PostgreSQL. 버전 숫자는 고정하지 않는다
사건 시점의 DB 타입은 한 순간이라는 뜻을 컬럼이 가진 타입 timestamptz(6)
UTC는 어디서 강제하나 모든 물리 연결 커넥션 세션 SET TIME ZONE 'UTC' + hibernate.jdbc.time_zone=UTC
나노초는 어디서 줄이나 Domain에 들어오기 전 ChronoUnit.MICROS
업무 사건 시점은 누가 만드나 Service UTC Clock
감사 시각의 처음 값은 누가 만드나 DB DEFAULT CURRENT_TIMESTAMP
updatedAt은 누가 바꾸나 저장하는 쪽 애플리케이션 코드 JPA는 @EntityListeners 리스너의 @PreUpdate · MyBatis는 구현체가 값을 넘긴다. 트리거는 만들지 않는다
그 시각은 어디서 얻나 주입받은 Clock. 시간 표준에 예외를 두지 않는다 리스너는 Spring이 주입해 줄 수 있다. Entity 안 Instant.now()는 쓰지 않는다
무엇으로 증명하나 운영과 같은 메이저 버전의 실제 PostgreSQL 왕복 JPA·MyBatis 통합 테스트

이 표준을 정하기까지 — Instant라는 타입에서 실제 왕복 계약까지 내려갔다

시작은 애플리케이션 시간 표준이 남긴 저장 빈칸이었다. 사건 시점을 Instant로 정했지만 DB에 어떤 타입으로 넣을지, 다시 읽었을 때 같은 값인지까지는 답하지 못했다. 그때 표준의 DB는 MariaDB였다.

첫 갈림길은 MariaDB의 TIMESTAMP(6), DATETIME(6), BIGINT였다. DATETIME(6)은 넓지만 UTC라는 의미를 컬럼이 보장하지 못하고, BIGINT는 날짜 연산과 운영 가독성을 잃는다. UTC 세션이라는 조건을 감수하고 TIMESTAMP(6)을 골랐다.

타입을 고르자 2038년 문제가 버전 문제로 이어졌다. MariaDB 11.5 미만의 TIMESTAMP는 1970년부터 흐른 초를 4바이트 정수로 세서 2038년 1월 19일 언저리를 넘지 못한다. 상한이 2106년으로 늘어난 11.5는 rolling release였다. 기능이 있는 최초 버전이 아니라 그 기능을 안정적으로 운영할 LTS가 기준이어야 해서 MariaDB 11.8 이상으로 올렸고, 허용 범위를 1970-01-01T00:00:01Z ~ 2106-02-07T06:28:15Z로 적었다.

그다음에는 UTC를 서버 전체가 아니라 연결마다 강제했다. 서버 전역 설정은 다른 시스템의 변경에 함께 흔들리고 Java 변환은 LocalDateTime을 다시 내부로 들인다. JPA와 MyBatis가 공유하는 DataSource의 세션을 UTC(SET time_zone = '+00:00')로 초기화하는 쪽을 골랐다.

나노초를 DB가 조용히 자르게 둘 수 없었다. 저장 전과 조회 후 Domain 값이 달라지기 때문이다. 저장 직전도 너무 늦었다. Service와 외부 Adapter에서 마이크로초로 정규화해 Domain이 처음부터 왕복 가능한 값만 갖게 했다.

감사 시각을 논의하며 시간의 소유권이 둘로 갈린다는 것을 확인했다. canceledAt은 업무 사건이라 애플리케이션이 만들고, createdAt·updatedAt은 행의 기록이라 그때는 DB가 DEFAULT와 ON UPDATE로 만들게 했다. 쓰기 경로가 JPA·MyBatis·운영 SQL 셋이라 컬럼 정의 한 곳에 모으는 것이 빠짐없다고 봤다.

마지막에는 설정 문서가 아니라 왕복 테스트를 증거로 남겼다. JPA 하나만 통과해서는 MyBatis를 보장하지 못한다. 실제 MariaDB 11.8에서 두 경로와 비UTC JVM, 최소·최대 경계값, 감사 시각을 함께 검증하게 했다.

11.8로 묶은 것이 뜻밖의 설정 하나를 끌고 왔다(2026-10-20, #169). 11.8은 innodb_snapshot_isolation이 처음부터 켜져 있어서, 내가 읽은 뒤 남이 고친 행을 고치려 하면 DB가 오류 1020으로 먼저 거절했다. 이 거절이 낙관적 락보다 앞서 예외의 종류 표준의 낙관락 catch가 한 번도 실행되지 않았다. 이 글을 쓸 때는 몰랐고 실측(MariaDB 스냅숏 격리 증명)으로 드러났다. 그래서 원칙 3의 시간대처럼 우리 프로그램의 접속마다 끄는 규칙(sessionVariables=innodb_snapshot_isolation=0)을 이 표준에 더했다. 이 규칙은 PostgreSQL로 옮기며 지웠다 — PostgreSQL에는 그 설정이 없다. 같은 함정은 이제 예외의 종류 표준 원칙 2-3이 「격리 수준은 READ COMMITTED에 두고 올리지 않는다」로 막는다.

그다음 DB를 다시 골랐다(2026-10-20, #185). 표준은 MariaDB 11.8로 묶여 있는데 표준을 실제로 쓰는 한 프로젝트는 PostgreSQL 17이었다. 나는 처음에 「MariaDB로 계속 간다」고 했다. 소스가 저장소 open-source/에 있어 DB 동작을 소스로 증명할 수 있다는 이유였다. 그런데 그날 proofs/의 실험 21개를 세어 보니 DB를 띄운 것이 PostgreSQL 6개, MariaDB 3개였다. 증명은 이미 PostgreSQL에서 돌고 있었다. 그래서 「PostgreSQL로 간다, 이미 돈 실험은 다시 돌리지 않는다」로 바꿨다. 그 사이 Claude가 「DB마다 규칙을 나누자」를 꺼냈는데, 규칙마다 두 벌이 붙어 섞인다는 이유로 버렸다. MariaDB 전용 줄은 갈래로 남기지 않고 전부 PostgreSQL 것으로 바꿨고, MariaDB를 쓰는 farm-predict는 DB 규칙만 표준 밖에 두었다.

옮기자 MariaDB 시절의 결정이 하나씩 다시 열렸다. 타입은 timestamptz(6)으로 갔다. MariaDB 시절 TIMESTAMP를 고른 이유 — UTC라는 뜻을 컬럼이 가진다 — 를 그대로 잇는 타입이고, timestamp(without time zone)는 DATETIME과 같은 뜻이라 같은 이유로 버렸다. 이것은 이미 돈 실험에 없어서 새로 하나 돌렸다 — timestamptz(6)은 서울 세션으로 넣고 UTC 세션으로 읽어도 같은 순간이었고, timestamp(6)은 9시간 어긋났다. 같은 실험에서 7자리 소수가 잘리지 않고 반올림되는 것도 나와 원칙 4의 근거가 하나 늘었고, 설정하지 않은 JDBC 세션이 실험자가 고르지 않은 시간대로 열리는 것도 나와 원칙 3의 근거가 늘었다. 세션을 맞추는 SQL은 SET time_zone = '+00:00'에서 PostgreSQL 문장인 SET TIME ZONE 'UTC'로 바꿨다. 이 실험과, 같은 날 예외의 종류 표준을 고치며 PostgreSQL에서 새로 돌린 실험 하나가 더해져 지금 proofs/의 실험은 23개, 그중 PostgreSQL이 8개다.

버전을 묶던 원칙은 이유가 사라져 지웠다. timestamptz의 범위는 4713 BC ~ 294276 AD라 2106년처럼 업무가 닿을 상한이 없다. 「PostgreSQL 17 이상」으로 고정하는 안은 근거가 「지금 쓰는 프로젝트가 17이다」뿐이라 버렸고, 대신 원칙 6에 「Testcontainers 이미지는 운영과 같은 메이저 버전」 한 줄을 넣었다. 닿을 상한이 없어졌으니 테스트의 경계값 항목도 뺐다. 대가로 엉뚱한 연도가 들어오면 DB가 거절해 500이 된다는 것을 적어 두었다.

마지막으로 updated_at의 주인이 바뀌었다. PostgreSQL에는 ON UPDATE가 없어 MariaDB 시절의 「DB가 만든다」를 지키려면 트리거가 필요했다. 그런데 그 PostgreSQL 프로젝트의 시각 보정 사고에서 바로 그 자동 갱신이 보정 마이그레이션의 updated_at을 덮어 원래 값을 지웠고, 그 PostgreSQL 프로젝트는 PostgreSQL로 옮기며 자동 갱신을 일부러 만들지 않았다. 트리거는 그 사고를 그대로 재현하므로 버렸다. 처음 값은 DB 기본값에 남기고 updatedAt 갱신은 애플리케이션이 하게 했다. MariaDB 시절에 버렸던 「쓰기 경로마다 규칙 반복」이 대가로 돌아왔지만, 빠뜨렸을 때의 피해가 「기록이 틀린다」라서 원래 값을 지우는 쪽보다 작다고 봤다.

그다음 날 @PreUpdate가 지금 시각을 어디서 얻을지를 정했다(2026-10-21). 원칙 5에 「아직 정하지 않았다」로 남겨 두었던 자리다. 애플리케이션 시간 표준은 주입받은 Clock을 쓰라 하는데 Entity는 주입을 받지 못한다. Repository 구현체가 Clock을 받아 entity.touch(clock.instant())를 부르는 안은 예외가 없고 정확한 시각까지 검사할 수 있었지만, 수정하는 구현체마다 그 줄을 손으로 적어야 하고 빠뜨려도 아무것도 잡지 않았다. Clock 규칙이 지키려던 것은 업무 판단에 쓰는 시각인데 updated_at은 판단에 쓰지 않는 기록이라, 처음에는 Entity의 @PreUpdate에서 Instant.now()를 부르고 시간 표준에 updated_at만 예외로 두는 쪽을 골랐다.

같은 날 그 전제가 틀린 것을 알았다(2026-10-21). 주니어 독자 검토가 「@EntityListeners로 붙인 리스너 클래스는 주입받을 수 있지 않나」를 짚었다. 소스를 따라가 보니 Hibernate는 리스너를 빈 저장소에서 꺼내 쓰고, Spring Boot는 그 저장소에 Spring을 기본으로 연결해 둔다. 「Entity가 Clock을 못 받는다」는 맞았지만 「그러니 콜백도 못 받는다」는 아니었다. 콜백을 리스너로 옮기면 JPA가 저절로 부르는 장점과 Clock을 쓰는 장점을 함께 가질 수 있어서, 리스너가 Clock을 주입받는 안으로 바꾸고 시간 표준의 예외를 없앴다. 그때까지 MyBatis UPDATE가 updated_at에 어떤 시각을 넣을지는 정하지 않았었고, 주니어 검토가 그 빈칸도 짚었다. 그래서 SQL의 CURRENT_TIMESTAMP가 아니라 구현체가 Clock에서 얻은 값을 넘기게 정했다. 소스로만 확인했고 돌려 보지는 않았다.


정리

  • 표준의 DB는 PostgreSQL이다. MariaDB 규칙은 갈래로 남기지 않았고, 버전 숫자는 고정하지 않는다.
  • 사건 시점은 timestamptz(6)에 저장한다. timestamp(6)은 시간대 표시를 버려 9시간 어긋났다.
  • JPA와 MyBatis가 쓰는 모든 DB 연결 세션을 SET TIME ZONE 'UTC'로 초기화한다. 시간대가 없는 값은 세션 시간대로 해석된다.
  • Service와 외부 Adapter가 Instant를 마이크로초로 절삭한 뒤 Domain에 전달한다. DB는 넘치는 자리를 반올림한다.
  • 감사 시각의 처음 값은 DB 기본값이, updatedAt 갱신은 JPA 리스너와 MyBatis 구현체가 주입받은 Clock으로 맡는다. 트리거는 만들지 않고, 운영자 SQL과 보정 마이그레이션은 updated_at을 건드리지 않는다. 시간 표준에 예외는 없다.
  • 운영과 같은 PostgreSQL 메이저 버전에서 JPA와 MyBatis 왕복을 각각 검증한다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기