"지금 몇 시야?"라는 질문이 이렇게 어려울 줄은

DB 타임존을 Asia/Seoul로 통일했더니 버그가 사라진 이야기

"지금 몇 시야?"라는 질문이 이렇게 어려울 줄은
커뮤니티(fullstackfamily.com)를 운영하면서 이상한 버그가 반복됐습니다.
"3분 전에 쓴 글인데 '9시간 전'으로 나와요."
"출석 체크를 했는데 어제 날짜로 찍혀요."
"경험치 일일 한도가 오후 3시에 초기화돼요."
전부 타임존 문제였습니다.
우리 서비스는 한국 사용자만 씁니다. 한국에서 오후 3시에 글을 쓰면, DB에는 오전 6시(UTC)로 저장됩니다. 프론트엔드에서 이걸 다시 +9시간 해서 보여줘야 하는데, 이 변환을 빠뜨리거나 두 번 하는 실수가 계속 생깁니다.
문서에 "UTC로 저장하고, 표시할 때 변환하세요"라고 적어둬도 소용없었습니다. 규칙의 문제가 아니라 구조의 문제였습니다.
왜 UTC를 쓰고 있었나
처음 프로젝트를 세팅할 때 "시간은 UTC로 저장하는 게 표준"이라는 관행을 따랐습니다. 실제로 많은 가이드에서 이렇게 권장합니다.
"DB에는 UTC로 저장하고, 표시할 때 사용자의 타임존으로 변환하세요."
글로벌 서비스라면 맞는 말입니다. 뉴욕 사용자와 도쿄 사용자가 같은 게시글을 볼 때, 각자의 시간대로 "3시간 전"이 표시되어야 하니까요.
그런데 우리 서비스는 한국어 커뮤니티입니다. 사용자 100%가 한국에 있습니다. UTC로 저장할 이유가 없었는데 "모범 사례"를 맹목적으로 따른 겁니다.
타임존 기초: UTC, KST, 그리고 오프셋
자주 등장하는 타임존 약어를 정리해 두겠습니다.
┌─────────────────────────────────────────────────────────┐ │ 시간대 관계도 │ │ │ │ UTC (협정 세계시) │ │ │ │ │ ├─ +00:00 런던 (GMT = UTC) │ │ ├─ +01:00 파리, 베를린 (CET) │ │ ├─ +05:30 뭄바이 (IST) │ │ ├─ +08:00 베이징, 싱가포르 (CST/SGT) │ │ ├─ +09:00 서울, 도쿄 (KST/JST) │ │ └─ -05:00 뉴욕 (EST) │ │ │ │ KST = UTC + 9시간 │ │ 서울 오후 3시 = UTC 오전 6시 │ └─────────────────────────────────────────────────────────┘
| 약어 | 정식 명칭 | 의미 |
|---|---|---|
| UTC | Coordinated Universal Time | 세계 기준 시각. 경도 0도(영국 그리니치) 기준 |
| GMT | Greenwich Mean Time | UTC와 사실상 동일. 역사적 이름 |
| KST | Korea Standard Time | UTC+9. 한국 표준시 |
| JST | Japan Standard Time | UTC+9. 일본 표준시 (KST와 동일) |
| EST | Eastern Standard Time | UTC-5. 미국 동부 |
| PST | Pacific Standard Time | UTC-8. 미국 서부 |
UTC는 "영국 시간"이 아닙니다. 시간대의 기준점입니다. 모든 타임존은 "UTC에서 몇 시간 차이나는가"로 정의됩니다.
우리 스택에서 시간이 흘러가는 경로
문제를 이해하려면 시간 데이터가 어떻게 흘러가는지 봐야 합니다.
변경 전 (UTC 기반) 사용자가 글 작성 (한국 시간 15:00) │ ▼ [Backend JVM] timezone=UTC │ LocalDateTime.now() → 06:00 (UTC) ▼ [MySQL] timezone=UTC │ DATETIME 컬럼에 06:00 저장 ▼ [Backend → JSON] │ "2026-02-15T06:00:00" (Z 없이) ▼ [Frontend] │ new Date("2026-02-15T06:00:00") │ → 브라우저가 로컬 시간으로 해석 │ → 한국 브라우저: 06:00 KST로 착각! │ ▼ 화면: "9시간 전" (실제로는 방금 전)
핵심 문제는 "2026-02-15T06:00:00" 이 문자열입니다. 타임존 정보가 없습니다. 이게 UTC 06시인지 KST 06시인지 알 수 없습니다. 브라우저의 new Date()는 타임존 정보가 없으면 로컬 시간으로 해석합니다. 한국 브라우저에서는 KST 06시로 읽어버립니다.
이걸 해결하려고 프론트엔드에서 'Z'를 붙이는 코드를 넣었습니다.
// "이 시간은 UTC야" 라고 브라우저에 알려주기 new Date(dateString + 'Z')
그런데 이 한 줄을 빼먹는 곳이 계속 생깁니다. 20개 넘는 파일에서 new Date()를 직접 쓰고 있었고, 새 기능을 추가할 때마다 같은 실수가 반복됐습니다.
해결: 전체 스택을 Asia/Seoul로 통일
발상을 바꿨습니다. "UTC로 저장하고 변환하자"가 아니라, "처음부터 한국 시간으로 저장하면 변환할 일이 없잖아?"
변경 후 (KST 기반) 사용자가 글 작성 (한국 시간 15:00) │ ▼ [Backend JVM] timezone=Asia/Seoul │ LocalDateTime.now() → 15:00 (KST) ▼ [MySQL] timezone=+09:00 │ DATETIME 컬럼에 15:00 저장 ▼ [Backend → JSON] │ "2026-02-15T15:00:00" ▼ [Frontend] │ parseServerTime("2026-02-15T15:00:00") │ → new Date("2026-02-15T15:00:00+09:00") │ ▼ 화면: "방금 전" ✓
변경 포인트는 3곳입니다.
┌──────────────┬────────────────────┬────────────────────┐ │ 계층 │ 변경 전 │ 변경 후 │ ├──────────────┼────────────────────┼────────────────────┤ │ MySQL │ timezone=UTC │ timezone=+09:00 │ │ JVM │ -Duser.timezone │ -Duser.timezone │ │ │ =UTC │ =Asia/Seoul │ │ Frontend │ + 'Z' (UTC 표시) │ + '+09:00' (KST) │ └──────────────┴────────────────────┴────────────────────┘
실제 변경 내용
MySQL: Cloud SQL 타임존 변경
GCP Cloud SQL에서는 데이터베이스 플래그 하나만 바꾸면 됩니다.
gcloud sql instances patch ff-mysql \ --database-flags default_time_zone=+09:00
이 한 줄로 MySQL의 NOW() 함수가 한국 시간을 반환합니다.
-- 변경 전: SELECT NOW() → 2026-02-15 06:00:00 -- 변경 후: SELECT NOW() → 2026-02-15 15:00:00
Backend: JVM 타임존 변경
build.gradle에서 JVM의 기본 타임존을 바꿉니다.
bootRun { jvmArgs '-Duser.timezone=Asia/Seoul' }
Java의 LocalDateTime.now()는 JVM 기본 타임존을 따릅니다. 이제 LocalDateTime.now()가 곧 한국 시간입니다.
그동안 KST 변환을 위해 작성했던 코드가 전부 불필요해졌습니다.
// 삭제된 코드 (더 이상 필요 없음) ZoneId KST = ZoneId.of("Asia/Seoul"); LocalDate.now(KST); today.atStartOfDay(KST) .withZoneSameInstant(ZoneOffset.UTC) .toLocalDateTime();
LocalDate.now()만 쓰면 됩니다. 변환 코드가 사라지니 실수할 여지도 사라집니다.
Frontend: parseServerTime 함수
export function parseServerTime( dateString: string | null | undefined ): Date | null { if (!dateString) return null // 타임존 정보가 이미 있으면 그대로 if (dateString.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateString)) { return new Date(dateString) } // 서버가 KST로 보내므로 +09:00 추가 return new Date(dateString + '+09:00') }
이전에는 'Z'를 붙여서 "이건 UTC야"라고 알려줬다면, 이제는 '+09:00'을 붙여서 "이건 KST야"라고 알려줍니다.
기존 데이터 마이그레이션
DB에 이미 UTC로 저장된 데이터가 수십만 건 있습니다. 전부 +9시간 해줘야 합니다.
-- 78개 테이블, 168개 DATETIME 컬럼을 변환 UPDATE users SET created_at = DATE_ADD(created_at, INTERVAL 9 HOUR) WHERE created_at IS NOT NULL; UPDATE unified_post SET created_at = DATE_ADD(created_at, INTERVAL 9 HOUR), updated_at = DATE_ADD(updated_at, INTERVAL 9 HOUR), published_at = DATE_ADD(published_at, INTERVAL 9 HOUR) WHERE created_at IS NOT NULL; -- ... 나머지 76개 테이블도 동일 패턴
여기서 삽질을 좀 했습니다.
DATETIME vs TIMESTAMP
MySQL에는 시간을 저장하는 타입이 두 가지 있습니다.
| 타입 | 저장 방식 | 타임존 변경 시 |
|---|---|---|
| DATETIME | 입력값 그대로 저장 | 영향 없음 |
| TIMESTAMP | 내부적으로 UTC 변환 저장 | 자동 변환됨 |
우리 프로젝트는 대부분 DATETIME을 씁니다. DATETIME은 "2026-02-15 06:00:00"이라는 문자열을 그대로 저장하는 것과 비슷합니다. MySQL 타임존을 바꿔도 저장된 값은 변하지 않습니다. 그래서 직접 +9시간을 더해줘야 합니다.
반면 TIMESTAMP 타입은 내부적으로 UTC로 변환해서 저장하고, 조회할 때 현재 타임존으로 변환해줍니다. 타임존 설정을 바꾸면 자동으로 반영됩니다. 우리 프로젝트에서 shedlock 테이블만 TIMESTAMP를 쓰고 있어서, 이 테이블은 마이그레이션에서 제외했습니다.
마이그레이션 중 만난 함정
Python + pymysql로 마이그레이션 SQL을 실행했는데, SQL 파일 안에 COMMIT;이 들어 있었습니다.
[Python 코드] conn.autocommit = False ← 트랜잭션 수동 관리 for stmt in sql_statements: cursor.execute(stmt) ← SQL 파일의 COMMIT; 도 실행됨! conn.rollback() ← 이미 커밋됐으므로 효과 없음
SQL 파일의 COMMIT; 문이 Python의 트랜잭션 관리를 무시하고 데이터를 커밋해버린 겁니다. 에러가 나서 롤백하려 했지만 이미 늦었습니다. 이걸 3번 실행해서 +27시간(9시간 x 3)이 되는 상황까지 갔습니다. 식은땀이 났습니다.
롤백 SQL로 -18시간을 빼서 바로잡았지만, 교훈은 확실히 남았습니다. SQL 파일에 트랜잭션 제어문을 넣으면 안 되고, 트랜잭션은 애플리케이션 레벨에서만 관리해야 합니다. 프로덕션 DB에서 배운 치고는 꽤 비싼 수업료였습니다.
변경 후 사라진 것들
1. ZoneId/ZoneOffset 변환 코드 전부 삭제
삭제 전: ZoneId KST = ZoneId.of("Asia/Seoul"); LocalDate today = LocalDate.now(KST); LocalDateTime start = today.atStartOfDay(KST) .withZoneSameInstant(ZoneOffset.UTC) .toLocalDateTime(); 삭제 후: LocalDate today = LocalDate.now(); LocalDateTime start = today.atStartOfDay();
경험치 서비스, 출석 서비스, 레벨 조회 서비스 등 여러 파일에서 이런 변환 코드가 있었는데 전부 사라졌습니다.
2. 프론트엔드 new Date() 직접 사용 20곳 이상 정리
관리자 페이지, 홈 화면, Q&A 등에서 서버 날짜를 new Date(serverDateString)으로 직접 파싱하던 코드를 전부 parseServerTime() 또는 formatRelativeTime() 같은 유틸 함수로 교체했습니다.
3. "일일 한도가 오전 9시에 초기화되는" 버그 해결
경험치 일일 한도를 체크하는 코드가 LocalDate.now()를 쓰고 있었습니다. JVM이 UTC일 때 LocalDate.now()는 UTC 자정에 날짜가 바뀝니다. UTC 자정은 한국 시간 오전 9시죠. 그래서 한국 사용자 입장에서는 오전 9시에 일일 한도가 초기화되는 것처럼 보였습니다.
JVM을 Asia/Seoul로 바꾸니 LocalDate.now()가 한국 자정 기준으로 동작합니다. 코드 한 줄 안 고치고 해결됐습니다.
글로벌 서비스라면 어떻게 해야 하나
"그러면 UTC가 쓸모없는 건가요?" 아닙니다. 한국 전용 서비스이기 때문에 Asia/Seoul로 통일한 것이고, 글로벌 서비스라면 접근이 달라야 합니다.
글로벌 서비스의 타임존 전략
┌─────────────────────────────────────────────┐ │ 글로벌 서비스 아키텍처 │ │ │ │ [DB] UTC 저장 (TIMESTAMP 타입 권장) │ │ │ │ │ ▼ │ │ [Backend] UTC 기준 처리 │ │ │ ISO 8601 형식으로 응답 │ │ │ "2026-02-15T06:00:00Z" │ │ ▼ │ │ [Frontend] 사용자 타임존으로 변환 │ │ │ │ │ ├─ 뉴욕 사용자 → "Feb 15, 01:00 AM" │ │ ├─ 서울 사용자 → "2월 15일 15:00" │ │ └─ 런던 사용자 → "15 Feb, 06:00" │ └─────────────────────────────────────────────┘
글로벌 서비스에서는 DB와 백엔드를 UTC 기준으로 운영하고, 프론트엔드에서 사용자 타임존으로 변환하는 구조가 기본입니다.
DB는 UTC로 저장합니다. 서울에서 쓴 글과 뉴욕에서 쓴 글의 순서를 정확히 비교하려면 하나의 기준이 필요한데, UTC가 그 역할을 하거든요. TIMESTAMP 타입을 쓰면 MySQL이 알아서 UTC로 변환해 저장해 줍니다.
API 응답에는 타임존 정보를 꼭 포함해야 합니다. "2026-02-15T06:00:00" 대신 "2026-02-15T06:00:00Z"를 보내는 식이죠. 끝에 붙은 Z가 "이 시간은 UTC 기준"이라는 뜻인데, 이게 없으면 앞서 겪은 것처럼 클라이언트가 혼란에 빠집니다.
프론트엔드에서는 사용자 타임존으로 변환합니다. 브라우저가 사용자의 타임존을 이미 알고 있으니, Intl.DateTimeFormat을 쓰면 자동으로 처리됩니다.
React에서 타임존을 다루는 방법
글로벌 서비스의 React 클라이언트에서는 이렇게 처리합니다.
// 서버 응답: "2026-02-15T06:00:00Z" // Z가 붙어 있으므로 Date 객체가 UTC로 정확히 파싱 function formatForUser(isoString: string) { const date = new Date(isoString) // UTC로 파싱됨 return new Intl.DateTimeFormat(navigator.language, { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(date) // 사용자 로컬 시간으로 표시 }
Intl.DateTimeFormat은 브라우저의 타임존을 자동으로 사용합니다. 서울에서는 KST로, 뉴욕에서는 EST로 표시됩니다.
Next.js SSR 환경의 함정
Next.js처럼 서버 사이드 렌더링(SSR)을 쓸 때 주의할 점이 있습니다.
[SSR 서버] → 서버의 타임존으로 렌더링 (UTC일 수도 있음) [브라우저] → 사용자의 타임존으로 렌더링 (KST일 수도 있음) → 서버/클라이언트 결과가 다르면 Hydration 에러!
이걸 해결하려면 Intl.DateTimeFormat에 timeZone 옵션을 명시합니다.
new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Asia/Seoul', // SSR/CSR 동일한 결과 보장 }).format(date)
timeZone을 명시하면 SSR 서버가 어느 리전에 있든 동일한 결과를 렌더링합니다. 우리 프로젝트도 Vercel에 배포된 Next.js 앱이라 이 방식을 쓰고 있습니다.
한국 전용 vs 글로벌: 언제 어떤 전략을 쓸까
┌──────────────────┬──────────────────┬──────────────────┐ │ │ 한국 전용 서비스 │ 글로벌 서비스 │ ├──────────────────┼──────────────────┼──────────────────┤ │ DB 타임존 │ Asia/Seoul │ UTC │ │ JVM 타임존 │ Asia/Seoul │ UTC │ │ DATETIME 타입 │ 사용 가능 │ TIMESTAMP 권장 │ │ API 응답 형식 │ T15:00:00 │ T06:00:00Z │ │ 프론트 변환 │ +09:00 붙이기 │ Z가 이미 있음 │ │ 일일 초기화 기준 │ 한국 자정 │ UTC 자정 │ │ 장점 │ 직관적, 실수 적음 │ 다국적 대응 가능 │ │ 단점 │ 글로벌 확장 시 │ 변환 코드 필요, │ │ │ 마이그레이션 필요 │ 실수 가능성 │ └──────────────────┴──────────────────┴──────────────────┘
판단 기준은 단순합니다.
- 사용자가 한 나라에 있고, 앞으로도 그럴 예정 → 그 나라 타임존 사용
- 여러 나라 사용자가 있거나 예정 → UTC 사용
"나중에 글로벌 갈 수도 있으니 UTC로 하자"는 YAGNI(You Aren't Gonna Need It)입니다. 한국 서비스가 실제로 글로벌로 갈 확률보다, 타임존 버그로 사용자가 불편해할 확률이 높습니다.
마무리
정리하면 이렇습니다.
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
LocalDateTime.now() | UTC 시간 | 한국 시간 |
| ZoneId 변환 코드 | 곳곳에 산재 | 전부 삭제 |
프론트 new Date() | +Z 붙이기 필수 | +09:00 붙이기 |
| 일일 한도 초기화 | 오전 9시 | 자정 (정상) |
| 새 기능 추가 시 | 타임존 실수 가능 | 신경 쓸 것 없음 |
기술적으로 대단한 작업은 아닙니다. MySQL 플래그 하나, JVM 옵션 하나, 프론트엔드 함수 하나. 하지만 효과는 큽니다. 코드를 쓸 때마다 머릿속에서 UTC↔KST 변환을 돌리지 않아도 됩니다. DB에 15:00이 저장되어 있으면 그냥 오후 3시입니다. 쿼리로 확인해도 3시, API 응답도 3시, 화면에도 3시.
"문서에 규칙을 쓴다"는 건 사람이 규칙을 기억하고 따라야 한다는 뜻입니다. 그보다는 구조적으로 실수할 수 없게 만드는 게 낫습니다. 이번 작업으로 그걸 배웠습니다.






댓글
댓글을 작성하려면 이 필요합니다.