← 개발 로그 목록

website: 어드민 메일·감사로그 시각을 KST로 표시

/ 5분 분량 / 개발 로그

방금 받은 비밀번호 재설정 메일에 "2026.08.04 06:40까지만 유효해요"라고 떴는데, 실제 KST로는 9시간이나 이른 값이었다. 어드민 admin.likelion-khu.com 링크 관련 작업을 하던 중에 발견한 문제였고, 장찬욱이 감사로그 화면에서도 같은 유형의 문제를 보고했다.

원인을 확인해보니 서버·컨테이너 어디에도 TZ 설정이 없어서 JVM 기본 타임존이 UTC로 잡혀 있었다. 백엔드 전체가 LocalDateTime.now()로 시각을 만드는데, 이게 전부 UTC 벽시계 값이라는 뜻이다. 그런데 이 값을 다루는 두 곳이 서로 반대 방향으로 틀려 있었다. EmailService는 이 UTC 값을 변환 없이 그대로 포맷해서 보여줬고, AuditLogViewer.tsx의 formatKst()는 반대로 "타임존 없는 문자열이면 이미 KST일 것"이라고 잘못 가정하고 +09:00을 붙여 파싱하고 있었다. 하나는 변환을 안 해서 9시간 이르게, 하나는 잘못된 변환을 해서 9시간 늦게 나오는 셈이다.

가장 먼저 든 생각은 "그냥 JVM 타임존 자체를 KST로 바꾸면 되는 거 아닌가"였다. 하지만 바로 걸리는 게 있었다. infra/scripts/push-email-failure-metric.py(#113, 이메일 실패 알람 크론)가 5분마다 서버에서 sqlite3로 직접 WHERE sent_at >= datetime('now', '-5 minutes')를 돌린다. 이 datetime('now')는 호스트 OS의 UTC고, 지금은 email_log.sent_at도 LocalDateTime.now()로 만들어진 UTC라 딱 맞아떨어지고 있는 상태였다. 여기서 JVM만 KST로 바꾸면 sent_at이 9시간 앞선 값으로 찍히게 되고, "최근 5분" 필터가 사실상 "최근 9시간 5분"이 되어 알람이 오탐투성이로 망가진다. 그래서 내부 저장은 UTC로 그대로 두고, 사람이 보는 지점(메일 렌더링, 감사로그 프론트)에서만 KST로 변환하는 쪽으로 정리했다.

백엔드 쪽은 EmailService에 formatForDisplay() 메서드를 하나 추가해서, expiresAt을 포맷하기 직전에 atZone(UTC).withZoneSameInstant(Asia/Seoul)로 변환하도록 했다.

private String formatForDisplay(LocalDateTime expiresAt) {
    return DISPLAY_FORMAT.format(expiresAt.atZone(SERVER_ZONE).withZoneSameInstant(DISPLAY_ZONE));
}

sendInviteEmail, sendPasswordResetEmail 두 곳에서 DISPLAY_FORMAT.format(expiresAt)를 직접 호출하던 걸 이 메서드 호출로 바꿨다. 왜 이렇게 저장값은 안 건드리고 렌더링 직전에만 변환하는 방식을 택했냐면, 이렇게 해야 감사로그 등 다른 소비자가 여전히 UTC를 전제로 값을 쓰는 부분과 충돌하지 않기 때문이다.

프론트 쪽은 반대로, 잘못된 가정을 걷어내는 작업이었다. formatKst()가 타임존 정보 없는 ISO 문자열을 받으면 +09:00을 붙여서 "이미 KST"라고 파싱하고 있었는데, 실제로는 UTC 벽시계 값이니 Z를 붙여야 맞다.

const date = new Date(hasZone ? iso : `${iso}Z`);

한 줄 수정이지만 이 부분은 값의 실제 출처를 착각하고 있던 거라, 원인을 EmailService 쪽에서 먼저 파악하지 않았으면 여기도 "왜 9시간 차이가 나지"만 보고 엉뚱하게 고쳤을 수 있다.

테스트는 값이 바뀌는 만큼 기대값 갱신이 필요했다. EmailServiceTest(유닛)는 로컬에서 돌려서 2026.07.09 00:30, 2026.07.09 18:00처럼 변환된 KST 값으로 통과하는 걸 확인했다. EmailServiceIntegrationTest는 Testcontainers(Docker)가 필요한데 로컬에 Docker가 없어서 실행 자체는 스킵했다 — 이건 이번 변경과 무관하게 이전 PR에서도 같은 상황이었다. 대신 기대값(2026.07.10 21:00, 2026.07.11 18:30)만 로직에 맞게 갱신해뒀다. AuditLogViewer.test.tsx는 4/4 통과.

작업하면서 눈에 띈 건데, 같은 "타임존 없는 문자열에 +09:00을 붙인다"는 패턴이 AnalyticsDashboard.tsx, ContentImpactAnalyticsPanel.tsx, RecruitmentApplicationCard.tsx에도 있었다. 다만 이쪽은 시각(LocalDateTime)이 아니라 날짜(LocalDate) 단위 값이라 이번 버그와 성격이 다를 수 있어서, 이번 PR에서는 건드리지 않고 남겨뒀다. 값의 출처를 하나씩 확인해서 같은 문제가 있는지는 따로 봐야 할 것 같다.

코드는 배포되면 바로 반영되지만, 방금 실제로 발송된 4명(김우진, 안시현, 장찬욱, 신선우)의 메일 링크는 이미 나간 뒤라 소급 적용이 안 된다. 필요하면 비밀번호 찾기로 재발송하도록 안내가 필요한 부분이다.

PR은 6시 31분에 생성해서 6시 35분에 바로 병합됐다. 원인 파악에 시간이 걸렸을 뿐 수정 자체는 크지 않은 변경(+26/-7)이었다.