발행일

시각을 찍는 자리를 서버 한 곳으로 모았다 — 11개월 전 글이 '남은 것'에 적어 둔 TIME_ZONE 한 줄

시각을 찍는 자리를 서버 한 곳으로 모았다

9월 10일 기준으로, 관리자 화면 여러 곳의 변경 이력 표가 이런 모양의 값을 그대로 찍고 있었습니다.

2026-09-09 16:47:55.613497+00:00

마이크로초와 UTC 오프셋이 붙은 파이썬 datetime 의 문자열 표현 그대로입니다. 역할 배정 상세·목록, 사용자 상세, 권한그룹 상세, 계정 삭제 확인 화면, 그리고 가입 승인 메일이 전부 이 모양이었어요.

같은 날 같은 시간대에 설문 관리 화면에서는 정반대 증상이 나왔습니다. 제출일시를 서버가 포맷해서 찍게 바꿨더니, 화면 값이 9시간 앞으로 갔어요.

한쪽은 시간대 변환이 아예 안 걸리고, 다른 쪽은 두 번 걸립니다. 증상이 반대인데 원인은 하나였습니다.

변환 책임이 두 곳에 나뉘어 있었다

이 시스템은 2025년 10월에 한 번 같은 문제를 겪었습니다. 그때 정리한 구조가 이랬어요.

자리방향어디서
목록 표시UTC → KST서버 format_datetime
폼 입력칸 로드UTC → 로컬브라우저 UTCToLocal
폼 제출로컬 → UTC브라우저 localToUTC

표시는 서버가, 입력은 브라우저가 맡는 반쪽짜리 구조입니다. 그 글의 '남은 것'에 이렇게 적어 뒀어요. "TIME_ZONEAsia/Seoul로 바꾸는 선택지를 검토하지 않았습니다." 11개월이 지나 그 문장 그대로 청구서가 날아온 셈입니다.

반쪽으로 두면 화면이 하나 늘 때마다 "이 값은 어느 쪽이 바꾸지?"를 사람이 기억해야 합니다. 기억이 틀리는 두 가지 방향이 위의 두 증상이에요.

왜 하필 9시간이 앞으로 갔나

걷어낸 JS는 이렇게 생겼습니다.

fieldEl.innerText = convertToDateFormat(
  UTCToLocal(new Date(fieldEl.innerText.split("+")[0]))
);

화면에 이미 찍힌 글자를 다시 읽어서 + 앞까지 자르고 Date 로 만든 뒤 오프셋을 더합니다. 여기서 split("+")[0] 이 결정적이에요. 오프셋 표기를 떼어 내면 브라우저는 남은 문자열을 로컬 시각으로 읽습니다.

서버가 UTC 원시값을 찍던 시절에는 이 조합이 우연히 맞아떨어졌습니다. 16:47:55+00:00 에서 오프셋을 떼면 브라우저가 16:47:55 를 KST로 읽고, UTCToLocal 이 9시간을 더해 01:47:55 가 됩니다. 실제 KST 값과 같아요. 두 번 틀려서 맞는 값이었던 겁니다.

그래서 서버가 KST를 찍기 시작하는 순간 깨집니다. 이미 01:47 인 값에 9시간이 또 더해져 10:47 이 돼요. 설문 화면에서 본 게 이겁니다.

원인은 설정 한 줄이었다

TIME_ZONE = "UTC"

이 한 줄이 있는 한 서버는 시간대에 관해 아무 말도 하지 않는 상태입니다. 따라오는 것들이 있어요.

  • timezone.localdate() 가 UTC 날짜를 돌려줍니다. 한국 시각 오전 9시 이전 기록은 하루 앞으로 밀립니다.
  • 폼이 제출된 naive 값을 UTC로 해석합니다. 그래서 입력 쪽 보정이 브라우저에 남아 있었습니다.
  • timezone.localtime() 이 쓸모가 없어 변환 함수가 pytz 를 직접 불러야 합니다.

그래서 설정을 바꿨습니다. 저장 형식은 그대로예요.

# 저장은 UTC 그대로(USE_TZ) 두고 표시·입력 해석만 KST 로 맞춘다. UTC 로 두면
# timezone.localdate() 가 UTC 날짜를 돌려줘 오전 9시 이전 기록이 하루 앞으로 밀리고,
# 폼이 naive 입력을 UTC 로 해석해 화면마다 시간대 보정 코드를 따로 두게 된다.
TIME_ZONE = "Asia/Seoul"

버린 대안 둘

화면마다 고치기. 처음 두 커밋이 실제로 이 방향이었습니다. UTC 원시값이 새는 자리를 찾아 format_datetime 을 씌우고, 이중 변환이 난 자리에서 JS를 뺐어요. 몇 군데 하다가 그만뒀습니다. 고칠 자리를 찾는 방법이 "열어 보고 이상하면"이라 끝이 없고, 새 화면이 생기면 같은 실수가 다시 들어옵니다.

브라우저 시간대를 따르기. 표시 시간대를 서울로 고정하는 대신 보는 사람의 시간대를 쓰는 방법입니다. 안 골랐어요. 그러면 화면 값이 서버 로그·내보내기 파일과 어긋나고, 국내 사용자만 쓰는 시스템에서 얻는 게 없습니다. 서울 고정은 11개월 전 글에도 적혀 있던 한계인데, 이번에도 그대로 뒀습니다.

지운 것과 훑은 것

설정을 바꾸면 브라우저 쪽 보정이 전부 잉여가 됩니다. 같이 걷어냈어요.

걷어낸 경로있던 자리
date_fields 컨텍스트뷰 24곳(FAQ·소식지·알림창·공지·사용자·설문·표준데이터)
폼 로드/제출 시각 왕복formFieldHandler.js
변환 함수 3종utils.jsUTCToLocal·localToUTC·convertToDateFormat
목록·상세 인라인 변환 스크립트설문 화면 4곳(그중 2곳은 앞선 화면별 수정 때 먼저 걷음)

설정을 바꾼 커밋 하나가 18개 파일에서 19줄을 넣고 137줄을 뺐습니다.

그다음이 서버 쪽 누수입니다. 모델의 datetime 은 UTC aware 라 .strftime() 을 직접 부르면 UTC가 그대로 나갑니다. 찾아낸 자리를 성격별로 묶으면 이래요.

  • 표시 — 표준데이터·카탈로그 상세의 변경 이력, 설문 PDF 2종, 설문 결과 Excel 제출일시, 홈 공지 날짜, 물리 아키텍처 표시값.
  • 날짜 경계 — 설문 기간 판정과 응답 표시가 timezone.now().date() 를 쓰고 있었습니다. timezone.localdate() 로 바꿨어요. 연간 집계의 1월 1일·12월 31일 경계도 같은 기준으로 맞췄습니다.
  • 식별자 — 알림 메일의 신청번호가 REQ-%Y-%m%d-{pk} 규칙인데 UTC 날짜로 만들어지고 있었습니다. 화면 머리말은 KST로 찍히니, 오전 9시 이전에 들어온 신청은 메일과 화면의 신청번호가 하루 어긋날 수 있었습니다.
  • ISO 문자열 — OID 카드의 수정일은 직렬화한 ISO 문자열의 앞 10자를 날짜로 씁니다. UTC ISO를 그대로 주면 같은 이유로 하루 전이 돼요. 주석으로 남겼습니다.
# 화면은 앞 10자를 날짜로 쓴다 — UTC ISO 를 그대로 주면 KST 오전 9시 이전 수정분이 하루 전으로 보인다
return timezone.localtime(max(revised)).isoformat() if revised else ""

PDF 생성일시와 내보내기 파일명이 datetime.now() 로 서버 시계를 직접 읽던 것도 같이 없앴습니다.

표기를 두 형식으로 줄였다

여기까지 하고 나니 값은 맞는데 모양이 제각각이었습니다. %Y. %m. %d, %H:%M:%S, %Y-%m-%d %H:%M:%S 등 여러 가지가 돌아다녔어요. 템플릿 26개와 테스트 1개에서 39줄을 두 형식으로 통일했습니다.

종류형식보기
날짜%Y. %m. %d2026. 09. 10
일시%Y. %m. %d %H:%M2026. 09. 10 08:46

초는 화면에서 뺐습니다. 이력 표는 이미 시간순으로 정렬돼 나오고, 초까지 보고 판단하는 자리가 없어요. 감사 목적으로 초가 필요한 설문 결과 Excel에는 남겼습니다. 다만 이번 통일에서 빠져 초가 그대로 찍히는 자리도 남았습니다(아래 한계). 신청번호의 %Y-%m%d 는 표기가 아니라 식별자라 예외로 뒀고요.

마지막으로 규칙을 docs/ui-conventions.md §18에 적었습니다. 핵심은 세 줄입니다. Jinja2는 Django 템플릿과 달리 datetime 을 자동 지역화하지 않으니 반드시 format_datetime 을 거칠 것, 파이썬에서는 timezone.localtimetimezone.localdate 를 쓸 것, 화면에서 시간대를 되돌리는 코드를 되살리지 말 것. 마지막 줄에는 되살리면 어떻게 되는지(9시간)를 같이 적었습니다. 규칙만 적으면 다음 사람이 "왜 안 되지?"를 물을 수 없으니까요.

검증

  • 폼 왕복 실측 — DB에 2026-09-03 07:10 UTC 로 든 값이 입력칸에 16:10 으로 뜨고, 그대로 다시 제출하면 07:10 UTC 로 되돌아옵니다. 저장값이 변하지 않아요. Django의 DateTimeField.prepare_value() 가 현재 시간대로 바꿔 넣고 제출된 naive 값을 같은 시간대로 해석하기 때문에, 브라우저 보정이 없어도 왕복이 맞습니다.
  • 날짜가 넘어가는 경계 — 팀에서 9월 5일에 넣어 둔 표시 검증 테스트가 있었습니다. last_login2026-09-01 23:45 UTC 로 박고 목록 화면이 그다음 날 08:45 로 그리는지, 표 안에 None 이나 Invalid Date 가 없는지를 봅니다. 표기를 바꾸면서 이 테스트의 기대값을 2026. 09. 02 08:45 로 갱신했어요. 남이 걸어 둔 회귀 잠금 덕에 형식 변경이 경계 조건까지 건드리지 않았는지 바로 확인됐습니다.
  • 잔재 확인date_fields·dateFields·UTCToLocal·localToUTC·convertToDateFormat 을 코드 전체에서 검색해 0건임을 확인했습니다. 템플릿에 남은 .strftime( 직접 호출도 0건입니다.

남은 것 · 한계

  • 설정 변경 자체에는 회귀 테스트를 못 붙였습니다. TIME_ZONE 은 전역이라 "이 값이 KST여야 한다"를 잠그는 테스트는 설정을 복창하는 것에 가깝고, 실제로 지켜야 할 건 화면 쪽 결과입니다. 지금 그 잠금은 팀이 만든 표시 테스트 하나뿐이라 얇아요. 화면이 늘면 같은 실수가 다시 들어올 수 있습니다.
  • 폼 왕복은 손으로 확인했습니다. 값 하나를 넣고 되받는 테스트를 붙였어야 합니다.
  • 표시 시간대는 서울 고정입니다. 보는 사람의 시간대를 안 봅니다. 11개월 전에 적어 둔 한계를 그대로 남겼어요.
  • 시간대를 안 타는 값이 섞여 있습니다. 인스턴스 메타데이터 슬롯은 값 칸이 TextField 라 사용자가 친 문자열이 그대로 저장·표시됩니다. 이번 규칙이 적용되지 않아요. 화면에서는 둘이 똑같이 날짜로 보이는데 뒤가 다릅니다. §18에 구분해 적어 뒀고, 그 슬롯에 날짜 형식을 강제하는 일은 같은 날 오후에 따로 했습니다(날짜 칸 글).
  • 초가 남은 자리가 있습니다. 삭제 계정 화면 다섯 곳과 물리 아키텍처 폼 한 곳은 아직 %H:%M:%S로 찍습니다. 두 형식으로 줄였다는 말은 이 여섯 곳을 빼고 한 말이에요.
  • 초를 뺀 판단은 화면 기준입니다. 같은 분에 두 건이 들어오면 이력 표에서 순서를 눈으로 구분할 수 없습니다. 정렬이 보장하니 지금은 문제가 아니지만, 이력을 근거로 다투는 자리가 생기면 다시 봐야 합니다.
  • 11개월을 끌었습니다. 2025년 10월에 "설정을 건드리면 다른 곳이 어떻게 될지 몰라서" 미룬 한 줄인데, 실제로 바꿔 보니 한 시간이었고 코드는 오히려 100줄 넘게 줄었습니다. 그때 안 한 이유가 틀렸다기보다, 미룬 항목을 다시 꺼내 볼 자리가 없었다는 게 문제입니다.

관련 글: 날짜 시간대 변환을 이튿날 서버로 옮겼다 · 승인 시각이 승인한 때가 아니었다 · Jinja가 캐시 버스터를 컴파일 시점에 굳혔다 · 팔레트에 없는 색은 조용히 안 칠해진다