- 발행일
날짜 시간대 변환을 이튿날 서버로 옮겼다 — 목록의 JS 변환은 지우고 제출 변환은 넓혔다
날짜 시간대 변환을 이튿날 서버로 옮겼다
이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.
관리자 화면의 소식지·알림창·공지사항 목록에서 등록일이 9시간 앞서 보였습니다. 오후에 등록한 글이 오전에 등록한 걸로 나와요. 설정을 보니 이랬습니다.
TIME_ZONE = "UTC"
USE_TZ = True
DB에는 UTC로 들어가고, 화면에 낼 때 서울 시간으로 바꿔 줘야 합니다. 그 변환이 어디서 일어나야 하는지가 이 글의 주제예요. 이틀에 걸쳐 답이 바뀌었습니다.
그때 변환은 세 곳에 흩어져 있었다
이력을 보니 손대기 전에도 변환이 있었습니다. 다만 전부 브라우저에서요.
| 자리 | 방향 | 방법 |
|---|---|---|
| 폼 입력칸 로드 때 | UTC → 로컬 | formFieldHandler.js가 dateFields를 돌며 UTCToLocal |
| 목록 표시 | UTC → 로컬 | 목록 템플릿마다 innerText를 읽어 new Date(...)로 파싱 후 UTCToLocal |
| 폼 제출 때 | 없음 | 로컬 값이 그대로 서버로 |
폼은 로드할 때 로컬로 바꿔 보여 주는데, 제출할 때는 안 바꿨습니다. 그러니 사용자가 화면에서 본 시각 그대로 서버로 가고, 서버는 그걸 UTC로 저장해요. 등록할 때마다 9시간이 밀리는 구조였습니다.
10월 27일 저녁 — 제출 때 UTC로 바꾸는 14줄
20시 18분 커밋(91d8ee29)입니다. formFieldHandler.js의 제출 핸들러에 변환을 넣었어요.
// 특정 날짜 필드만 로컬 시간에서 UTC로 변환
// (소식지/알림창의 시작일/종료일, 공지사항의 등록일/수정일)
const fieldsToConvert = ["start_date", "end_date", "create_date", "modify_date"];
for (const fieldName of dateFields) {
// 지정된 필드만 변환
if (!fieldsToConvert.includes(fieldName)) continue;
for (const el of document.getElementsByName(fieldName)) {
if (!el.value || el.type !== "datetime-local") continue;
const localDate = new Date(el.value);
el.value = convertToDateFormat(localToUTC(localDate));
}
}
문제가 난 필드 넷의 이름을 박아 넣었습니다. 다른 화면의 날짜 필드는 건드리기 무서워서요. 이 시점의 판단은 "터진 곳만 막자"였습니다.
10월 28일 오후 — 목록 변환을 서버로
다음 날 14시 34분 커밋(faa8f517)에서 방향을 바꿨습니다. 6개 파일, 31줄 추가에 65줄 삭제예요.
먼저 Jinja 필터에 변환을 넣었습니다.
def format_datetime(date, format):
from django.utils import timezone
import pytz
# UTC로 저장된 aware datetime을 Asia/Seoul 시간대로 변환
if date and timezone.is_aware(date):
kst = pytz.timezone('Asia/Seoul')
date = date.astimezone(kst)
return str(date.strftime(format))
그리고 목록 템플릿 세 곳에서 JS를 걷어냈습니다. 공지사항 목록의 경우 이런 블록이 통째로 빠졌어요.
window.addEventListener("load", () => {
for (const fieldName of dateFields) {
for (const el of document.getElementsByName(fieldName)) {
if (el.innerText === "None") continue;
el.innerText = convertToDateFormat(
UTCToLocal(new Date(el.innerText.split("+")[0])),
);
}
}
});
<td>에 찍힌 문자열을 다시 읽어서 + 앞까지 자르고 Date로 만들어 9시간을 더하던 코드입니다. 서버가 처음부터 서울 시간으로 찍으면 이 코드가 필요 없어요. 세 목록이 format_datetime(object.create_date, "%Y-%m-%d %H:%M:%S")로 바뀌었습니다.
지금 보면 이 JS는 위험했습니다. 시간대 표기가 없는 "2025-10-27 11:18:12" 같은 문자열을 new Date에 넣으면 브라우저는 그걸 로컬 시각으로 읽습니다. 거기에 UTCToLocal이 오프셋을 한 번 더 더해요. 그때 그 점을 알고 지운 건지, 그냥 서버로 옮기고 싶어서 지운 건지는 커밋에 남아 있지 않습니다.
제출 쪽은 반대로 넓혔습니다. 전날 넣은 fieldsToConvert 필터 여섯 줄을 지워서 dateFields에 든 모든 datetime-local 입력이 제출 때 UTC로 바뀌게 했어요. 하루 만에 "터진 곳만"에서 "규칙을 하나로"가 된 겁니다. 같은 커밋에서 사용자 폼의 date_joined·last_login 위젯을 datetime-local로 맞춰 그 규칙에 태웠습니다.
표시는 서버, 입력은 브라우저
정리하면 이틀 뒤의 구조는 이렇습니다.
| 자리 | 방향 | 어디서 |
|---|---|---|
| 목록 표시 | UTC → KST | 서버 format_datetime |
| 폼 입력칸 로드 | UTC → 로컬 | 브라우저 UTCToLocal |
| 폼 제출 | 로컬 → UTC | 브라우저 localToUTC |
표시는 서버가, 입력은 브라우저가 맡습니다. 반쪽입니다. 그때는 datetime-local 입력을 서버에서 시간대까지 다루는 법을 몰라서 브라우저 쪽을 남겼어요.
이 구조는 지금도 거의 같습니다. 2026년 7월에 변환 부분이 to_kst()로 따로 빠졌는데, 그 함수 docstring이 이렇게 시작해요.
def to_kst(date):
"""UTC로 저장된 aware datetime 을 Asia/Seoul 로.
settings.TIME_ZONE 이 UTC 라 `timezone.localtime()` 은 UTC 를 돌려준다 —
화면 시각은 여기를 거쳐야 한다.
"""
TIME_ZONE은 여전히 "UTC"이고, 제출 변환은 여전히 formFieldHandler.js에 있습니다.
검증
- 소식지·알림창·공지사항 목록에서 등록일이 실제 등록한 시각으로 보이는 것을 손으로 확인했습니다.
- 소식지 시작일·종료일을 입력해 저장한 뒤 다시 열어 같은 값이 나오는 것을 확인했습니다.
- 다른 화면의 날짜 필드는 제출 변환이 넓어진 뒤에 전수로 확인하지 않았습니다.
- 테스트 코드는 없습니다.
남은 것 · 한계
TIME_ZONE을Asia/Seoul로 바꾸는 선택지를 검토하지 않았습니다. 그러면timezone.localtime()이 그냥 서울 시간을 돌려주고pytz를 직접 부를 일이 없어요. 그때는 설정을 건드리면 다른 곳이 어떻게 될지 몰라서 손을 안 댔고, 1년이 지난 지금도to_kst의 docstring이 같은 이유를 적고 있습니다.- 표시 시간대가 서울로 고정입니다. 사용자 브라우저의 시간대를 안 봐요. 국내 시스템이라 문제가 되지 않았을 뿐입니다.
- 제출 변환이 브라우저에 남았습니다. JS가 꺼져 있거나 다른 경로로 폼이 제출되면 9시간이 다시 밀립니다.
- 첫날의 "터진 곳만" 판단을 이튿날 뒤집은 건 맞는 방향이었는데, 뒤집은 이유를 커밋에 안 남겼습니다. 이 글을 쓰면서 diff로 되짚어야 했어요.
관련 글: Jinja 템플릿 정리 — 문법 레퍼런스와 Django에서 실제로 쓰며 배운 것 · Jinja가 캐시 버스터를 컴파일 시점에 굳혔다 · 마이페이지를 뷰부터 URL까지 처음 짰다