마감 시각 같은 정책은 Domain이 판단해야 한다. 그런데 Domain 안에서 LocalDateTime.now()를 부르면 테스트가 실행 시각에 흔들리고, 같은 요청 안에서도 판단 시각과 기록 시각이 달라진다. 그렇다고 생일과 영업일까지 전부 UTC 시점으로 바꾸면 원래 없던 시각과 시간대를 만들어낸다.
정해야 했던 건 두 가지였다. 현재 시각을 누가 얻어 Domain에 어떻게 전달할 것인가. 그리고 사건의 시점, 달력 날짜, 지역 시각을 어떤 타입과 이름으로 구분할 것인가. DB 컬럼과 JDBC 설정은 저장 기술의 질문이므로 이 글에서 분리했다.
먼저: UTC로 통일하는 것은 모든 시간 값이 아니라 시간축의 시점이다
결제 시각은 서울에서 2026-08-02 12:00, 뉴욕에서 2026-08-01 23:00으로 보여도 같은 사건이다. 이런 값은 시간축의 한 점이므로 Instant로 표현하고 UTC로 저장·비교·전송할 수 있다.
반면 생일 1990-08-02에는 시각도 시간대도 없다. 여기에 임의로 UTC 자정을 붙이면 뉴욕 화면에서 전날로 바뀐다. UTC로 통일할 수 있는 것은 모든 시간 관련 값이 아니라 실제로 발생한 시점이다.
Java의 Local도 한국 시간을 뜻하지 않는다. LocalDate, LocalTime, LocalDateTime의 Local은 시간대 정보가 없다는 뜻이다.
원칙 1. 사건은 Instant, 날짜는 LocalDate, 하루 중 시각은 LocalTime으로 표현한다
정해야 했던 건 이거였다 — 타입 하나로 통일할지, 값의 의미에 따라 타입을 나눌지.
안 A — 모든 시간 관련 값을 UTC Instant로 바꾼다.
타입이 하나라 개발자가 고를 것이 없어진다. 정렬과 비교도 한 시간축에서 끝난다. 하지만 생일과 정산일에 임의의 자정을 붙이는 순간 원래 없던 정보를 만든다. 사용자의 시간대로 변환하면 날짜가 전날이나 다음 날로 바뀌어 원래 의미가 깨진다.
안 B — 익숙한 LocalDateTime 하나로 통일한다.
DB의 일시 컬럼과 모양이 비슷하고 날짜와 시각을 한 타입에 담을 수 있다. 그러나 2026-08-02T10:00:00만으로는 서울 10시인지 UTC 10시인지 알 수 없다. 서버 기본 시간대로 보완하면 배포 지역이 바뀔 때 같은 값이 다른 시점이 된다.
안 C — 값의 의미를 타입으로 나눈다.
Instant paidAt;
LocalDate birthDate;
LocalTime openingTime;
ZoneId businessZone;
Duration paymentTimeout;
골랐다 — 안 C. 안 A가 훼손하는 달력 날짜와 안 B가 잃어버리는 시간대를 모두 보존한다. 대신 개발자가 값의 의미를 먼저 판단해야 한다. 그 판단을 매번 새로 하지 않도록 이름도 함께 고정한다.
| 의미 | 타입 | 접미사 | 예시 |
|---|---|---|---|
| 시간축의 한 시점 | Instant |
At |
orderedAt, canceledAt |
| 달력 날짜 | LocalDate |
Date |
birthDate, settlementDate |
| 하루 중 시각 | LocalTime |
Time |
openingTime |
| 지역 시간대 | ZoneId |
Zone |
businessZone |
| 시간 간격 | Duration |
Duration |
paymentTimeoutDuration |
Instant birthDate나 LocalDateTime createdAt처럼 접미사와 타입이 어긋나는 이름은 쓰지 않는다. 화면은 Instant만 사용자 시간대로 바꾸고, LocalDate와 LocalTime은 임의 변환하지 않는다.
원칙 2. Service는 업무 사건 직전에 기준 시각을 한 번 얻어 Domain에 값으로 전달한다
정해야 했던 건 이거였다 — 현재 시각을 Domain이 직접 구할지, Service가 값으로 공급할지.
안 A — Domain이 LocalDateTime.now() 또는 Instant.now()를 부른다.
호출부가 간단하고 Domain 메서드의 인자가 늘지 않는다. 그러나 현재 시각이 숨은 입력이 되어 테스트가 실행 시각에 의존한다. 주문과 이력이 각각 now()를 호출하면 같은 취소 사건인데도 시각이 달라진다.
안 B — Domain에 Clock을 전달하거나 보관한다.
Clock.fixed(...)로 테스트할 수 있고 직접 now()를 부르는 문제도 없어진다. 하지만 Domain이 정책 판단뿐 아니라 시간 공급자까지 알아야 한다. 여러 Domain이 각각 Clock을 읽으면 하나의 사건에 서로 다른 시각이 생기는 문제도 남는다.
안 C — Service가 Clock을 사용하고 Domain에는 Instant를 전달한다.
다음 코드는 취소 판단, 상태 기록, 이력이 모두 같은 사건 시각을 공유하는 모양을 보여준다.
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase {
private final Clock clock;
private final OrderRepository orderRepository;
@Override
@Transactional
public void cancelOrder(CancelOrderCommand command) {
OrderDomain order = orderRepository.findById(command.getOrderId())
.orElseThrow(() -> ErrorCodeException.of(ErrorCode.ORDER_NOT_FOUND));
Instant canceledAt = clock.instant()
.truncatedTo(ChronoUnit.MICROS);
order.cancel(canceledAt);
order.recordCancellation(canceledAt);
}
}
public void cancel(Instant canceledAt) {
if (!canceledAt.isBefore(cancelableUntil)) {
throw ErrorCodeException.of(ErrorCode.ORDER_CANCELLATION_CLOSED);
}
status = OrderStatus.CANCELED;
this.canceledAt = canceledAt;
}
골랐다 — 안 C. 안 A의 숨은 입력과 안 B의 시간 공급자 의존을 Domain 밖으로 밀어낸다. 대신 Domain 메서드 인자가 하나 늘어난다. 그 비용으로 판단 시각이 호출부에 드러나고 테스트가 실제 시간에서 독립한다. DB 시간 저장 표준에 따라 Domain에 전달하기 전 마이크로초로 절삭한다.
“한 번”은 메서드당 무조건 한 번이라는 뜻이 아니다. 하나의 업무 사건당 한 번이다. 외부 결제 요청과 완료는 서로 다른 사건이므로 각각 paymentRequestedAt, paymentCompletedAt을 얻는다. 시각은 메서드 첫 줄이 아니라 조회나 외부 호출 뒤, 해당 사건을 판단하고 기록하기 직전에 얻는다.
원칙 3. LocalDateTime은 내부에서 금지하고 바꿀 수 없는 외부 경계에서만 허용한다
정해야 했던 건 이거였다 — 시간대 없는 일시를 완전히 금지할지, 현실적인 예외를 둘지.
안 A — LocalDateTime을 자유롭게 쓴다.
익숙하고 외부의 2026-08-02T10:00:00을 그대로 받을 수 있다. 그러나 UTC, 한국, 뉴욕의 10시가 모두 같은 타입이다. 잘못 섞어도 컴파일러가 잡지 못하고 변수명에만 의존하게 된다.
안 B — 저장소 전체에서 완전히 금지한다.
규칙은 가장 단순해진다. 하지만 변경할 수 없는 외부 API나 기존 DB가 시간대 없는 일시만 주면 그 계약을 표현할 타입까지 사라진다.
안 C — Domain과 Application에서는 금지하고 외부 경계에서만 허용한다.
다음 코드는 계약상 한국 시간이라고 확인된 레거시 값을 Adapter가 내부 시점으로 바꾸는 경계다.
public Instant toInstant(LocalDateTime externalDateTime) {
return externalDateTime
.atZone(legacySystemZone)
.toInstant();
}
골랐다 — 안 C. 안 A처럼 모호성을 내부 전체로 퍼뜨리지 않으면서 안 B가 막는 레거시 연동도 처리한다. 대신 Adapter에 변환 코드와 외부 시스템별 시간대 설정이 생긴다. 변환이 끝난 뒤 Command, Service, Domain에는 Instant만 전달한다.
외부 계약에도 기준 시간대가 없다면 임의로 설정하지 않는다. 설정은 누락된 의미를 추측하는 장치가 아니라 계약에서 확인한 시간대를 코드 밖에 명시하는 장치다.
원칙 4. UTC Clock과 업무 ZoneId는 서로 다른 설정으로 관리한다
정해야 했던 건 이거였다 — 한국 업무 날짜를 누가 결정할지.
안 A — ZoneId.systemDefault()를 쓴다.
설정이 필요 없다. 그러나 개발 PC는 Asia/Seoul, 운영 컨테이너는 UTC일 수 있다. 서버 배포 위치가 업무 날짜를 조용히 바꾸므로 버렸다.
안 B — 코드마다 ZoneId.of("Asia/Seoul")을 쓴다.
코드만 봐도 한국 기준임을 알 수 있다. 하지만 정산, 쿠폰, 배치에 문자열이 흩어지고 정책의 출처가 여러 곳이 된다.
안 C — 서비스 공통 업무 시간대를 필수 설정으로 둔다.
app:
business-zone: Asia/Seoul
@Bean
Clock clock() {
return Clock.systemUTC();
}
현재 순간은 UTC Clock으로 얻고, 그 순간이 어느 업무 날짜인지는 businessZone으로 해석한다.
Instant evaluatedAt = clock.instant();
LocalDate businessDate = evaluatedAt
.atZone(businessZone)
.toLocalDate();
안 D — 시간대를 Domain 데이터로 둔다.
매장마다 서울, 뉴욕, 도쿄 시간대를 가진다면 시간대는 전역 설정이 아니라 매장의 업무 데이터다. 한 프로세스가 여러 지역을 처리할 때도 이 안만 정확하다.
골랐다 — 현재 서비스에는 안 C. 안 A의 환경 의존과 안 B의 중복을 없앤다. 기본값은 두지 않아 설정 누락 시 시작 단계에서 실패하게 한다. 대신 다국가·다매장으로 바뀌면 안 D로 옮겨야 한다. 고객이나 매장마다 달라지는 시간대를 전역 설정으로 덮지 않는다.
원칙 5. 신규 API는 시점을 확정할 수 없는 일시 입력을 거부한다
정해야 했던 건 이거였다 — 2026-08-02T10:00:00처럼 오프셋이 없는 사건 시각을 어떻게 처리할지.
안 A — 서버 기본 시간대로 해석한다. 실행 환경마다 결과가 달라져 같은 요청이 다른 Instant로 저장된다.
안 B — 공통 업무 시간대 Asia/Seoul로 해석한다. 한국 전용 서비스에서는 편하지만 클라이언트가 그 암묵적 계약을 모르면 잘못된 시점을 정상 입력으로 저장한다.
안 C — 신규 API에서는 거부한다. 사건 시각은 2026-08-02T01:00:00Z 또는 2026-08-02T10:00:00+09:00처럼 Z나 오프셋을 포함해서 받는다. 둘 다 내부에서는 같은 Instant로 정규화한다.
골랐다 — 안 C. 안 A와 안 B가 추측으로 메우는 정보를 API 계약에 명시한다. 대신 기존 클라이언트가 시간대 없는 값을 보내고 있다면 계약 변경이 필요하다.
지역의 미래 일정은 단일 사건 시각과 다르다. “매일 한국 오전 10시”를 보존해야 한다면 날짜·시각·지역을 분리해 받는다.
{
"scheduledDate": "2026-08-02",
"scheduledTime": "10:00:00",
"zoneId": "Asia/Seoul"
}
바꿀 수 없는 외부 시스템만 연동별 ZoneId 설정을 가진 Adapter에서 해석한다. 전역 businessZone을 모든 연동에 재사용하지 않는다.
판단 기준 정리
| 질문 | 답 | 코드 |
|---|---|---|
| 실제 사건이 언제 일어났나 | UTC 시점 | Instant xxxAt |
| 달력의 어느 날짜인가 | 시간대 없는 날짜 | LocalDate xxxDate |
| 하루 중 몇 시인가 | 시간대 없는 시각 | LocalTime xxxTime |
| 어느 지역 규칙인가 | 명시적인 지역 | ZoneId xxxZone |
| 현재 시각은 누가 얻나 | Service | clock.instant() |
| 사건 시점 정밀도는 | DB 왕복 가능한 마이크로초 | truncatedTo(ChronoUnit.MICROS) |
| Domain은 무엇을 받나 | 시간 공급자가 아닌 값 | cancel(Instant canceledAt) |
LocalDateTime은 어디서 쓰나 |
바꿀 수 없는 외부 경계만 | Adapter에서 즉시 변환 |
| 신규 API의 사건 시각 형식은 | Z 또는 오프셋 필수 |
내부에서 Instant |
| 서버 기본 시간대를 쓰나 | 쓰지 않는다 | ZoneId.systemDefault() 금지 |
DB의 TIMESTAMP 타입, 저장 정밀도, JDBC·Hibernate UTC 설정, 감사 시각 생성 주체는 DB 시간 저장 표준에서 정한다. 애플리케이션 시간의 의미와 DB 저장 기술은 질문이 다르므로 글을 분리했다.
이 표준을 정하기까지 — UTC 하나로 시작해 시간의 의미를 셋으로 나눴다
시작은 Domain에서 현재 시각을 어떻게 얻느냐였다. Domain이 POJO라 Clock을 주입할 수 없다고 생각했지만, 기술적으로 불가능한 것이 아니라 시간 공급자까지 Domain의 책임으로 둘지가 진짜 질문이었다.
Service가 Clock을 알고 Domain은 Instant 값만 받는 구조를 골랐다. 기존 검증 표준에서 조회는 Service가 하고 판단은 Domain이 하듯, 현재 시각 공급은 Service가 하고 그 시각에 가능한지는 Domain이 판단한다. 같은 사건에는 같은 값을 전달하되 서로 다른 사건에는 각각 시각을 얻기로 했다.
백엔드의 모든 시간을 UTC로 통일하려다가 날짜가 시점이 아니라는 것을 발견했다. 결제 시각은 UTC 시점이지만 생일과 영업일은 달력 날짜다. 전부 Instant로 만들면 임의의 자정과 시간대를 보태 원래 의미를 훼손한다. 그래서 At, Date, Time, Zone 접미사와 타입을 함께 고정했다.
LocalDateTime을 완전히 없애는 안도 검토했다. 내부 모호성을 없애는 데는 맞지만 레거시 API와 DB가 시간대 없는 값을 주는 현실까지 지울 수는 없었다. 변경할 수 없는 경계에서만 잠시 허용하고 확인된 ZoneId로 즉시 Instant로 바꾸는 안을 골랐다.
마지막으로 UTC Clock과 한국 업무 시간대를 분리했다. Clock은 지금이 언제인지 답하고 Asia/Seoul은 그 순간이 어느 업무 날짜인지 답한다. 현재는 필수 설정 하나로 두되, 매장마다 달라지면 Domain 데이터로 옮기는 대가까지 남겼다.
DB 저장 문제는 다음 표준으로 넘겨 닫았다. TIMESTAMP(6)과 MariaDB 11.8 LTS, UTC 연결 세션, 마이크로초 정규화, DB 소유 감사 시각을 DB 시간 저장 표준에서 실제 왕복 규칙으로 확정했다.
정리
- 모든 시간 값이 아니라 모든 사건 시점을 UTC
Instant로 통일한다. - Service가 사건 직전에 Clock을 한 번 읽고 같은 사건에 같은 Instant를 전달한다.
- Domain에 전달하기 전에 사건 시점을 마이크로초로 절삭한다.
At은 Instant,Date는 LocalDate,Time은 LocalTime,Zone은 ZoneId다.- LocalDateTime은 바꿀 수 없는 외부 경계에서만 허용하고 즉시 Instant로 바꾼다.
- UTC Clock과 업무 ZoneId를 분리하고 서버 기본 시간대는 사용하지 않는다.
- 신규 API의 사건 시각에는 Z나 오프셋을 반드시 포함한다.
AI 코드 어시스턴트에 바로 적용하기
Claude Code — SKILL.md
---
name: application-time-standard
description: 애플리케이션에서 현재 시각을 얻고 시간 타입과 시간대를 다루는 규칙
---
- 시간축의 한 시점은 `Instant`로 표현하고 변수명에 `At` 접미사를 붙인다.
- 달력 날짜는 `LocalDate`와 `Date`, 하루 중 시각은 `LocalTime`과 `Time`, 지역 시간대는 `ZoneId`와 `Zone`을 사용한다.
- `LocalDateTime`을 Domain, Command, Query, Service 내부 모델에 사용하지 않는다.
- Service가 주입받은 UTC `Clock`으로 업무 사건 직전에 사건 시각을 한 번 얻는다.
- Service와 외부 Adapter는 `Instant`를 Domain에 전달하기 전에 `ChronoUnit.MICROS`로 절삭한다.
- 같은 업무 사건을 판단하고 기록하는 모든 Domain과 이력에 동일한 `Instant`를 전달한다.
- 서로 다른 업무 사건은 각각 의미가 드러나는 `Instant`를 얻는다. `now`보다 `canceledAt`, `paymentCompletedAt`처럼 이름 짓는다.
- Domain에 `Clock`을 전달하거나 보관하지 않는다. Domain에는 판단에 필요한 `Instant` 값만 전달한다.
- `Clock`은 `Clock.systemUTC()`로 구성한다. `Instant.now()`, `LocalDateTime.now()`, `ZoneId.systemDefault()`를 직접 호출하지 않는다.
- 서비스 공통 업무 시간대는 기본값 없는 필수 설정으로 관리한다. 현재 값은 `Asia/Seoul`이다.
- 고객·매장마다 시간대가 다르면 전역 업무 시간대를 재사용하지 않고 해당 Domain 데이터로 관리한다.
- 신규 API의 사건 시각은 `Z` 또는 오프셋이 포함된 값만 받고 내부에서 `Instant`로 정규화한다.
- 지역 일정을 입력받을 때는 날짜, 시각, `ZoneId`를 함께 받는다.
- 변경할 수 없는 외부 시스템의 시간대 없는 일시는 Adapter DTO에서만 `LocalDateTime`으로 받고, 연동별 `ZoneId` 설정으로 즉시 `Instant`로 변환한다.
- 외부 계약에 기준 시간대가 없으면 추측하지 않는다.
GitHub Copilot — instructions.md
---
description: 현재 시각, 시간 타입, 시간대와 외부 일시 입력을 다루는 규칙
applyTo: "**/*.java"
---
- 시간축의 한 시점은 `Instant`로 표현하고 변수명에 `At` 접미사를 붙인다.
- 달력 날짜는 `LocalDate`와 `Date`, 하루 중 시각은 `LocalTime`과 `Time`, 지역 시간대는 `ZoneId`와 `Zone`을 사용한다.
- `LocalDateTime`을 Domain, Command, Query, Service 내부 모델에 사용하지 않는다.
- Service가 주입받은 UTC `Clock`으로 업무 사건 직전에 사건 시각을 한 번 얻는다.
- Service와 외부 Adapter는 `Instant`를 Domain에 전달하기 전에 `ChronoUnit.MICROS`로 절삭한다.
- 같은 업무 사건을 판단하고 기록하는 모든 Domain과 이력에 동일한 `Instant`를 전달한다.
- 서로 다른 업무 사건은 각각 의미가 드러나는 `Instant`를 얻는다. `now`보다 `canceledAt`, `paymentCompletedAt`처럼 이름 짓는다.
- Domain에 `Clock`을 전달하거나 보관하지 않는다. Domain에는 판단에 필요한 `Instant` 값만 전달한다.
- `Clock`은 `Clock.systemUTC()`로 구성한다. `Instant.now()`, `LocalDateTime.now()`, `ZoneId.systemDefault()`를 직접 호출하지 않는다.
- 서비스 공통 업무 시간대는 기본값 없는 필수 설정으로 관리한다. 현재 값은 `Asia/Seoul`이다.
- 고객·매장마다 시간대가 다르면 전역 업무 시간대를 재사용하지 않고 해당 Domain 데이터로 관리한다.
- 신규 API의 사건 시각은 `Z` 또는 오프셋이 포함된 값만 받고 내부에서 `Instant`로 정규화한다.
- 지역 일정을 입력받을 때는 날짜, 시각, `ZoneId`를 함께 받는다.
- 변경할 수 없는 외부 시스템의 시간대 없는 일시는 Adapter DTO에서만 `LocalDateTime`으로 받고, 연동별 `ZoneId` 설정으로 즉시 `Instant`로 변환한다.
- 외부 계약에 기준 시간대가 없으면 추측하지 않는다.
자신만의 철학을 만들어가는 중입니다.
댓글남기기