발행일

날짜 시간대 변환을 이튿날 서버로 옮겼다 — 목록의 JS 변환은 지우고 제출 변환은 넓혔다

날짜 시간대 변환을 이튿날 서버로 옮겼다

이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.

관리자 화면의 소식지·알림창·공지사항 목록에서 등록일이 9시간 앞서 보였습니다. 오후에 등록한 글이 오전에 등록한 걸로 나와요. 설정을 보니 이랬습니다.

TIME_ZONE = "UTC"
USE_TZ = True

DB에는 UTC로 들어가고, 화면에 낼 때 서울 시간으로 바꿔 줘야 합니다. 그 변환이 어디서 일어나야 하는지가 이 글의 주제예요. 이틀에 걸쳐 답이 바뀌었습니다.

그때 변환은 세 곳에 흩어져 있었다

이력을 보니 손대기 전에도 변환이 있었습니다. 다만 전부 브라우저에서요.

자리방향방법
폼 입력칸 로드 때UTC → 로컬formFieldHandler.jsdateFields를 돌며 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_ZONEAsia/Seoul로 바꾸는 선택지를 검토하지 않았습니다. 그러면 timezone.localtime()이 그냥 서울 시간을 돌려주고 pytz를 직접 부를 일이 없어요. 그때는 설정을 건드리면 다른 곳이 어떻게 될지 몰라서 손을 안 댔고, 1년이 지난 지금도 to_kst의 docstring이 같은 이유를 적고 있습니다.
  • 표시 시간대가 서울로 고정입니다. 사용자 브라우저의 시간대를 안 봐요. 국내 시스템이라 문제가 되지 않았을 뿐입니다.
  • 제출 변환이 브라우저에 남았습니다. JS가 꺼져 있거나 다른 경로로 폼이 제출되면 9시간이 다시 밀립니다.
  • 첫날의 "터진 곳만" 판단을 이튿날 뒤집은 건 맞는 방향이었는데, 뒤집은 이유를 커밋에 안 남겼습니다. 이 글을 쓰면서 diff로 되짚어야 했어요.

관련 글: Jinja 템플릿 정리 — 문법 레퍼런스와 Django에서 실제로 쓰며 배운 것 · Jinja가 캐시 버스터를 컴파일 시점에 굳혔다 · 마이페이지를 뷰부터 URL까지 처음 짰다