
서버 애플리케이션에서 시간을 다룰 때는 DB에 UTC로 저장하고, 화면에 보여줄 때만 사용자 타임존으로 변환하는 것이 원칙입니다. 이 원칙을 지키지 않으면 서버 시간과 DB 시간이 몇 시간씩 어긋나는 버그가 배포 후에야 드러납니다. 다만 반복 일정이나 법적 기록처럼 UTC 저장이 오히려 불리해지는 예외도 분명히 있어서, 이번 글에서는 그 경계선을 실제 설정값 기준으로 짚어보겠습니다.
서버 시간과 DB 시간이 다르게 보이는 이유는 뭘까요?
시간이 어긋나는 지점은 한 곳이 아닙니다. OS 타임존, JVM 기본 타임존, DB 서버의 time_zone 설정, JDBC 드라이버의 변환 로직이 각각 별개로 동작하기 때문입니다.
예를 들어 MySQL 8.0을 쓰는 환경에서 JDBC URL에 serverTimezone 파라미터를 빼먹으면 커넥터가 서버의 타임존을 자동으로 인식하지 못해 예외를 던지거나, 의도와 다른 타임존으로 값을 변환해버립니다. 그래서 jdbc:mysql://host:3306/db?serverTimezone=UTC 처럼 명시적으로 지정하는 방식이 실무에서 자주 쓰입니다. 이건 드라이버가 세션 타임존을 알아야 자바의 java.sql.Timestamp와 DB 값 사이를 정확히 변환할 수 있기 때문이며, MySQL 공식 문서의 Time Zone Support 항목에서 서버·세션 타임존이 별개로 존재한다는 점을 확인할 수 있습니다.
문제는 이 네 지점 중 하나라도 로컬 타임존(예: Asia/Seoul)으로 남아있으면, 나머지가 전부 UTC라도 최종 값이 9시간 어긋난다는 점입니다.
MySQL DATETIME과 TIMESTAMP, UTC 저장 방식의 차이
같은 MySQL이라도 컬럼 타입에 따라 UTC 저장 여부가 완전히 달라집니다. 이 차이를 모르고 마이그레이션하면 기존 데이터가 통째로 어긋날 수 있어서 표로 정리해 봤습니다.
| 구분 | DATETIME | TIMESTAMP |
|---|---|---|
| 저장 방식 | 입력값을 문자 그대로 저장(타임존 개념 없음) | 내부적으로 UTC로 변환해 저장 |
| 조회 시 반환값 | 저장된 값 그대로 반환 | 세션 time_zone 값에 맞춰 다시 변환해 반환 |
| 표현 가능 범위 | 1000-01-01 ~ 9999-12-31 | 1970-01-01 ~ 2038-01-19 03:14:07(UTC 기준) |
| 서버 타임존 변경 영향 | 받지 않음 | 영향을 받음(변환 기준이 바뀜) |

DATETIME은 타임존 정보가 아예 없어서, 애플리케이션이 이미 KST로 계산해서 넣었다면 DB도 KST 값을 그대로 들고 있는 셈입니다. 반대로 TIMESTAMP는 저장할 때 UTC로 바꾸고 조회할 때 세션 타임존으로 다시 바꾸기 때문에, 서버의 time_zone 설정을 바꾸면 과거 데이터를 조회한 결과값도 함께 달라집니다. 이 특성을 모르고 서버 이전 후 time_zone만 UTC로 바꿨다가 기존 TIMESTAMP 값들이 전부 밀려 보이는 사고가 종종 발생합니다.
Spring Boot에서는 저장과 표시를 이렇게 분리합니다
가장 간단한 방법은 엔티티에서 java.util.Date 대신 java.time.Instant를 쓰는 것입니다. Instant는 애초에 타임존 개념이 없는 절대 시각(UTC 기준 초 단위)이라서, 별도로 신경 쓰지 않아도 UTC 저장 원칙이 코드 레벨에서 강제됩니다.
@Entity
public class OrderEntity {
@Id @GeneratedValue
private Long id;
@Column(name = "created_at")
private Instant createdAt = Instant.now(); // 절대 시각, UTC 기준으로 직렬화됨
}
DB 연결 설정과 JSON 응답 표시는 이렇게 나눕니다.
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?serverTimezone=UTC&useLegacyDatetimeCode=false
jackson:
time-zone: Asia/Seoul # 저장값과 무관, JSON 응답 표시용 변환만 담당
spring.jackson.time-zone은 저장 로직에 전혀 관여하지 않고, 컨트롤러가 응답을 내려줄 때 Instant 값을 Asia/Seoul 기준 문자열로 바꿔주는 표시 레이어 설정일 뿐입니다. 이 둘을 헷갈려서 DB 저장값 자체가 KST로 바뀐다고 오해하는 경우가 많은데, 실제로는 직렬화 단계에서만 변환이 일어납니다.
타임존 변환은 이 지점에서만 하세요

원칙은 하나입니다. 저장(엔티티·DB 컬럼)과 계산(비즈니스 로직)은 전부 UTC로 두고, 사람 눈에 보여줄 때만 딱 한 번 변환하는 것입니다.
자주 나오는 안티패턴은 리포지토리 쿼리 안에서 CONVERT_TZ 같은 함수로 미리 KST로 바꿔서 가져온 뒤, 서비스 레이어에서 또 한 번 표시용 변환을 걸어 이중 변환이 일어나는 경우입니다. 이렇게 되면 여름철 서머타임이 없는 한국 기준으로는 대개 큰 티가 안 나다가, 해외 서버 리전으로 인프라를 옮기거나 다국가 사용자를 받기 시작하는 순간 값이 꼬입니다. 변환은 컨트롤러 응답 직전이나 프론트엔드 렌더링 단계, 즉 사람이 실제로 읽는 지점 한 곳에서만 하는 편이 디버깅 범위를 좁혀줍니다.
UTC 저장 원칙이 통하지 않는 예외도 있습니다
UTC 저장이 항상 정답은 아닙니다. 대표적으로 반복되는 로컬 시각 일정이 문제입니다. “매일 오전 9시 알림”을 UTC 절대 시각으로 한 번 계산해서 고정 저장하면, 서머타임을 쓰는 지역에서는 시각이 실제로 밀려버립니다. 한국은 서머타임이 없어 체감이 덜하지만, 사용자 중 일부라도 서머타임 지역에 있다면 이런 반복 일정은 UTC 인스턴트가 아니라 ‘로컬 시각 + 타임존 ID(예: Asia/Seoul)’ 형태로 저장하고, 알림을 보낼 때마다 그 시점 기준으로 UTC를 다시 계산하는 방식이 맞습니다.
두 번째는 법적·회계 기록입니다. 세금계산서 발행 시각이나 매장 거래 시각처럼 ‘그 순간 현지에서 실제로 몇 시였는지’를 원본 그대로 남겨야 하는 경우, UTC로만 저장하면 나중에 타임존 정보 없이는 원래 로컬 시각을 복원할 수 없습니다. 이럴 땐 UTC 값과 함께 당시의 타임존 오프셋을 별도 컬럼에 같이 저장해야 합니다.
세 번째는 순수하게 비용 대비 효과 문제입니다. 사용자와 서버가 전부 한국 리전에만 있고 해외 확장 계획이 없는 소규모 내부 관리 시스템이라면, DATETIME에 KST를 그대로 넣는 기존 방식을 UTC로 전면 전환하는 마이그레이션 비용이 실익보다 클 수 있습니다. 이런 경우엔 원칙을 지키되 전환 시점을 신규 테이블부터 적용하는 식으로 점진적으로 가져가는 편이 현실적입니다.
다음 배포 전에 이것부터 점검해 보세요
핵심은 저장은 UTC, 표시는 로컬이라는 역할 분리입니다. JDBC URL의 serverTimezone 파라미터가 명시돼 있는지, 엔티티가 Date 대신 Instant나 OffsetDateTime을 쓰는지, spring.jackson.time-zone이 저장이 아니라 표시 전용으로만 쓰이는지를 순서대로 확인하면 대부분의 시차 버그는 배포 전에 걸러집니다.
반복 일정이나 법적 기록처럼 원칙에서 벗어나야 하는 지점이 있다면, 그 컬럼만 따로 ‘로컬 시각 + 타임존 ID’ 조합으로 설계했는지도 코드 리뷰 체크리스트에 넣어두는 것을 권합니다. 협정 세계시(UTC) 자체의 정의와 GMT와의 관계가 헷갈린다면 위키백과의 협정 세계시 문서를 먼저 훑어보는 것도 도움이 됩니다.
