1. 설정은 맞았지만, 최선은 아니었다
지난 포스팅(https://wlals916.tistory.com/6)에서는
DB와 서버의 타임존을 UTC로 통일하는 과정을 정리했다.
당시에는 “이 정도면 충분히 잘 맞췄다”고 생각했다.
하지만 코드 리뷰를 받으면서 생각이 바뀌었다.
분명 동작은 하고 있었지만,
- 같은 설정이 여러 곳에서 반복되고 있었고
- Hibernate 6에서 이미 제공하는 더 현대적인 해결책을 활용하지 못하고 있었다.
이번 글에서는
타임존을 안전하게 다루기 위해 내가 놓치고 있던 지점들과
이를 정리하면서 얻은 설계적 통찰을 공유한다.
2. 문제 제기: “왜 이 코드는 반복되는가?”
다음은 실제 DTO에 존재하던 코드 일부다.
@Schema(description = "일기 작성 날짜 및 시간 (UTC 기준, ISO-8601 형식). 프론트엔드에서는 사용자의 로컬 타임존으로 변환하여 표시해야 합니다.", example = "2024-05-20T10:00:00.000Z")
private final OffsetDateTime dateTime;
@Schema(description = "일기 생성 날짜 및 시간 (UTC 기준, ISO-8601 형식). 프론트엔드에서는 사용자의 로컬 타임존으로 변환하여 표시해야 합니다.", example = "2024-05-20T10:00:00.000Z")
private final OffsetDateTime createdAt;
여기에 더해, 실제 코드에서는 모든 시간 필드마다
@JsonFormat까지 함께 붙어 있었다.
통찰
- 모든 시간 필드에 동일한 설명과 포맷을 반복해서 적고 있다.
- 만약 이런 필드가 100개라면, 이 반복은 과연 합리적인가?
핵심 질문
- DB 저장 시 타임존을 더 안전하게 강제할 방법은 없을까?
- AttributeConverter가 정말 최선의 선택일까?
이 질문에서부터 개선이 시작됐다.
3. 본론: 비판적 검토와 해결책
① Hibernate 6의 현대적인 처리: NORMALIZE_UTC
리뷰에서 가장 먼저 지적받은 부분은
OffsetDateTime과 MySQL 타입 간의 미묘한 위험성이었다.
MySQL의 DATETIME, TIMESTAMP 타입은
offset 정보를 저장하지 않는다.
이로 인해 다음과 같은 문제가 발생할 수 있다.
- offset 정보 손실
- DB ↔ 애플리케이션 간 자동 변환 오류
- Hibernate 버전에 따른 런타임 매핑 에러
이에 대한 한 가지 해결책으로, 다음과 같은
AttributeConverter를 사용하는 방법이 제안됐다.
@Converter(autoApply = true)
public class OffsetDateTimeAttributeConverter
implements AttributeConverter<OffsetDateTime, Timestamp> {
@Override
public Timestamp convertToDatabaseColumn(OffsetDateTime odt) {
return odt == null ? null : Timestamp.from(odt.toInstant());
}
@Override
public OffsetDateTime convertToEntityAttribute(Timestamp ts) {
return ts == null ? null
: OffsetDateTime.ofInstant(ts.toInstant(), ZoneOffset.UTC);
}
}
이 방식은 DB 스키마 변경 없이 문제를 해결할 수 있어 안정적이다.
다만, 여기서 한 번 더 생각해볼 필요가 있었다.
정말 이 코드가 필요할까?
Hibernate 6.2 이상에서는
다음과 같은 설정 하나로 같은 효과를 낼 수 있다.
spring:
jpa:
properties:
hibernate:
type:
default_storage: NORMALIZE_UTC
이 옵션을 사용하면,
- 모든 OffsetDateTime이
- DB 저장 전에 자동으로 UTC 기준으로 정규화된다.
즉,
- 별도의 Converter 클래스를 만들 필요가 없고
- 도메인 코드가 프레임워크 설정으로부터 깔끔하게 분리된다.
같은 문제를 “코드”가 아니라 “설정”으로 해결할 수 있다면,
그쪽이 더 유지보수에 유리하다고 판단했다.
② Jackson 전역 설정을 통한 DTO 다이어트
다음 문제는 JSON 직렬화였다.
기존에는 DTO마다 @JsonFormat이 붙어 있었다.
이는 곧,
- 포맷 변경 시 모든 DTO를 수정해야 하고
- 표현 책임이 DTO에 과도하게 몰린다는 의미였다.
그래서 Jackson 설정을 전역으로 옮겼다.
builder
.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.simpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
여기서 중요한 점은
단순히 false 설정만 한 것이 아니라는 점이다.
- 프론트엔드와 이미 약속된 포맷이 있었고
- 밀리초 + offset(SSSXXX) 형식을 유지해야 했다.
따라서 전역 설정이더라도 명시적인 패턴 선언이 필요했다.
이 판단이야말로, 단순 설정이 아닌 설계의 영역이라고 생각한다.
4. 지식 정리: Hibernate와 Jackson의 역할 분담
이번 리팩토링을 통해 두 라이브러리의 역할이 명확해졌다.
- Hibernate
- 애플리케이션 ↔ DB 사이
- “시간을 어떻게 저장할 것인가”를 책임진다.
- Jackson
- 애플리케이션 ↔ 클라이언트(JSON) 사이
- “시간을 어떻게 보여줄 것인가”를 책임진다.
이 둘을 분리해서 사고하지 않으면,
설정은 점점 중복되고 코드에는 책임이 뒤섞이게 된다.
'Backend > Trouble Shooting' 카테고리의 다른 글
| JPA 자동 스키마 관리에서 Flyway 기반 DB 마이그레이션 (0) | 2026.02.03 |
|---|---|
| [Spring Boot] Swagger(Springdoc)에서 제네릭 공통 응답 스키마가 꼬이는 문제 해결기 (0) | 2026.01.22 |
| [SpringBoot] 검증 로직의 위치(@Valid vs Service) : 테스트 코드로 드러난 설계 문제 (0) | 2026.01.14 |
| [SpringBoot] 날짜와 시간 처리를 UTC로 표준화하기 (0) | 2026.01.10 |
| [SpringBoot] 프로젝트에 SonarCloud 연동하기: Gradle + GitHub Actions (0) | 2026.01.05 |