같은 시간 버그가 세 번 났다, 그래서 규칙을 코드로 옮겼다
서버가 준 UTC 시각을 화면에 그대로 찍어 9시간이 어긋났다. 자정 근처에서는 날짜가 하루 틀렸다. 날짜용으로 만든 문자열 자르기 함수에 시각이 들어오면서 생긴 일이다. 리뷰와 문서로 두 번 막으려다 실패하고, 세 번째에 검사 스크립트를 붙였다.
배경 — 9시간이 어긋났다
사내 HR 앱은 서버에서 시각을 받아 화면에 보여준다. 출퇴근 기록, 신청 날짜, 결재 시각 같은 것들이다.
서버는 시각을 UTC로 준다. 화면은 한국 시각으로 보여줘야 한다. 변환을 빼먹으면 이렇게 된다.
| 서버가 준 값 | 화면에 나와야 할 것 | 변환을 빼먹으면 |
|---|---|---|
2026-08-18T01:30:00Z | 8월 18일 오전 10:30 | 8월 18일 오전 1:30 |
2026-08-17T16:10:00Z | 8월 18일 오전 1:10 | 8월 17일 오후 4:10 |
두 번째 줄이 더 나쁘다. 시각뿐 아니라 날짜도 하루 밀린다. 밤 9시 이후 기록은 전날 것으로 보인다. 근태 앱에서는 사소한 표시 오류가 아니다.
이 결함이 세 번 반복됐다. 근태 시각에서 한 번, 신청일에서 한 번, 결재 시각에서 한 번. 매번 다른 화면, 매번 다른 개발 시점, 매번 같은 원인이었다.
왜 자동으로 변환되지 않았나
여기까지 읽으면 이런 생각이 든다. 서버가 Z를 붙여 UTC로 주면 new Date()가 알아서 기기 시각으로 바꿔줄 텐데, 왜 손으로 9시간을 더하나?
이 앱의 표시 함수가 Date를 아예 만들지 않기 때문이다. 문자열에서 숫자를 뽑아 쓸 뿐이다.
export function dateTimeText(iso: string) {
const d = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso); // 앞에서 날짜를 잘라내고
const t = /[T ](\d{2}):(\d{2})/.exec(iso); // 시:분을 잘라낸다
return `${d[1]}.${d[2]}.${d[3]} ${t[1]}:${t[2]}`;
}
Z가 붙어 있어도 그냥 무시된다. 시간대라는 개념이 이 경로에 없다. 그래서 01:30Z를 넣으면 화면에 01:30이 찍힌다.
문자열을 자르는 게 원래는 맞았다
이 함수들은 날짜만 있는 값을 위해 만들어졌다. 근무일, 신청 시작일, 입사일 같은 것들이다. 그리고 그런 값에는 문자열 자르기가 오히려 안전하다.
new Date('2026-06-11')은 UTC 자정으로 해석된다. 한국에서는 6월 11일 오전 9시가 되니 날짜가 맞지만, 시간대가 음수인 곳에서는 6월 10일이 된다. 근무일에 시간대를 개입시키면 이렇게 하루가 밀린다. 그래서 날짜는 글자로 다루는 게 맞다.
문제는 같은 함수에 시각이 있는 값이 들어오기 시작한 것이다. createdAt, decidedAt 같은 필드는 날짜와 달리 시간대를 타는데, 함수는 그걸 구분하지 않고 앞자리만 잘라 썼다.
원인은 문자열 자르기 자체가 아니었다. 날짜용 도구를 시각에 그대로 쓴 것이다.
기기 시각에 맡길 수도 없었다
Date를 쓰도록 고친다 해도 기기 시간대에 맡기면 안 된다. 근태 기록은 사업장 기준 시각이어야 한다. 직원이 해외에 있거나 폰 시계를 바꿔 두면 회사 기록과 다른 시각이 보인다.
한 가지 더 있다. 서버가 오프셋을 빼고 줄 때가 있는데, new Date('2026-06-11T20:00:00')은 오프셋이 없으면 기기 로컬 시각으로 해석된다. 같은 값이 기기마다 다른 시각이 된다는 뜻이다. 실제로 우리 변환 함수는 오프셋이 없으면 UTC로 간주하도록 못 박고 있다.
const hasTz = /([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
const ms = Date.parse(hasTz ? value : `${value}Z`);
선택 — 리뷰·문서·타입, 그리고 검사
세 번째 결함이 났을 때 후보는 다섯이었다.
⓪ 표시 함수를 걷어내고 시간대를 명시해 찍는다. 가장 표준적인 답이다.
new Intl.DateTimeFormat('ko-KR', { timeZone: 'Asia/Seoul', dateStyle: 'short', timeStyle: 'short' })
.format(new Date(iso))
시간대를 글자로 못 박으니 기기 시계와 무관하고, 손으로 9시간을 더하는 계산도 사라진다. 나라가 늘어도 값만 바꾸면 된다. 다만 표시 함수를 쓰는 자리를 전부 찾아 고쳐야 하고, 날짜만 있는 값은 여전히 따로 다뤄야 한다. 이걸 세 번째 사고가 난 그 주에 할 수는 없었다.
① 코드 리뷰에서 잡는다. 두 번 실패한 방법이다. 변환 유무는 한 줄 안에서 눈에 안 띈다. dateTimeText(req.createdAt)과 dateTimeText(kstIso(req.createdAt))는 리뷰 화면에서 거의 같아 보인다.
② 문서에 적는다. 이미 적혀 있었다. 새 화면을 만들 때 그 문서를 다시 읽는 사람은 없다.
③ 타입으로 막는다. 가장 튼튼하다. 서버 시각을 ServerTime 같은 별도 타입으로 두고, 표시 함수가 KstTime만 받게 하면 컴파일이 막는다. 다만 서버 응답 타입은 API 명세에서 자동 생성된다. 여기에 우리 타입을 끼우려면 생성 파이프라인을 손대고, 이미 만든 화면 47개도 모두 고쳐야 한다.
④ 검사 스크립트를 붙인다. 소스를 훑어 “변환 없이 표시 함수에 넣은 자리”를 찾아 실패시킨다.
④를 골랐다. ⓪이나 ③이 더 옳다. 다만 둘 다 이미 만든 화면을 훑어야 하는 일이고, ④는 반나절이면 되면서 나중에 어느 쪽으로 가든 버릴 것이 없다. 급한 건 재발을 멈추는 것이지 구조를 고치는 게 아니었다.
한 가지를 분명히 했다. 변환 경로를 하나로 못 박는다. 검사는 “이 함수를 거쳤는가”만 보면 된다. 시간 계산도 그 함수 한 곳에만 둔다.
export function kstIso(value: string | null | undefined): string | null {
if (!value) return null;
const isDateTime = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(value);
if (!isDateTime) return value; // 날짜만 온 값은 그대로 둔다
const hasTz = /([zZ]|[+-]\d{2}:?\d{2})$/.test(value);
const ms = Date.parse(hasTz ? value : `${value}Z`);
if (Number.isNaN(ms)) return value;
const kst = new Date(ms + 9 * 60 * 60 * 1000);
// ...KST로 옮긴 뒤 다시 문자열로
}
적용 — 40줄로 무엇을 보나
검사는 세 가지만 정하면 된다.
무엇이 위험한 값인가. 서버가 UTC로 주는 시각 필드다.
const DENY_FIELDS = [
'createdAt', 'updatedAt', 'decidedAt', 'approvedAt', 'rejectedAt',
'requestedAt', 'submittedAt', 'processedAt', 'cancelledAt', 'canceledAt',
];
startDate나 workDate처럼 날짜만 있는 값은 넣지 않았다. 시간이 없으면 시간대도 없다. 넣으면 멀쩡한 코드가 걸린다.
어디로 새는가. 변환을 하지 않는 표시 함수들이다.
const FORMATTERS = ['dateTimeText', 'ymd', 'mmdd', 'toMonthDay'];
어디를 볼 것인가. 서버 응답을 화면 모양으로 바꾸는 adapters 폴더만 본다. 화면 파일 전체를 훑지 않는 이유는 뒤에 나온다.
판정은 한 줄이다. 표시 함수 인자에 위험한 필드가 있고 같은 줄에 kstIso가 없으면 걸린다.
if (m && FIELD.test(m[1]) && !line.includes('kstIso')) {
violations.push(`${path.relative(ROOT, file)}:${i + 1} ${raw.trim()}`);
}
걸리면 파일과 줄 번호, 고치는 법을 함께 뱉는다. 검사기가 “틀렸다”만 말하면 사람은 검사를 미워한다.
✗ 서버 시각 KST 가드 실패 — 1건
src/features/approval/adapters/approval.ts:88 dateTimeText(item.decidedAt)
→ 수정: 해당 필드를 kstIso()로 감싸 표시하세요(예: dateTimeText(kstIso(req.createdAt))).
통과하면 몇 개를 봤는지도 말한다. 다음 항목과 이어진다.
✓ 서버 시각 KST 가드 통과 — adapters 24개 스캔, 모든 서버 date-time 필드가 kstIso를 거쳐 표시됨.
실수한 것들
검사가 아무것도 못 찾았는데 통과라고 말했다. 처음 버전은 대상 파일이 0개여도 조용히 성공했다. 폴더 이름을 바꾸거나 경로를 잘못 적으면 검사는 영원히 통과한다. 통과와 미검사가 같은 결과로 보인다. 알람이 안 오는 것과 문제가 없는 것이 같아 보이는 셈이다.
그래서 대상이 0개면 실패시킨다.
if (files.length === 0) {
console.error('✗ 스캔 대상 adapters 파일 0개(경로 오류 의심). 검사 미수행으로 실패 처리.');
process.exit(1);
}
성공 메시지에 adapters 24개를 넣은 이유도 같다. 숫자가 갑자기 3으로 줄면 사람이 알아챈다.
주석의 예시가 검사에 걸렸다. 코드 옆에 “이렇게 쓰면 안 된다”는 예시를 적었는데, 검사기가 위반으로 잡았다. 줄 주석을 지운 뒤 검사하게 고쳤다.
const line = raw.replace(/\/\/.*$/, '');
날짜만 있는 필드를 위험 목록에 넣었다가 멀쩡한 코드를 막았다. 입사일에는 시간이 없다. 시간이 없으면 변환할 것도 없다. 목록에 넣으면 개발자는 의미 없는 변환을 붙이거나 검사를 꺼버린다. 오탐이 잦은 검사는 반드시 무시당한다.
결과
| 항목 | before | after |
|---|---|---|
| 같은 결함 재발 | 3회 | 0회 |
| 발견 시점 | 사용자 신고 | 코드를 올리기 전 |
| 시간 변환 경로 | 화면마다 제각각 | 함수 1개 |
| 검사 대상 | — | adapters 24개 |
| 검사 코드 | — | 약 40줄 |
남는 것
같은 실수가 두 번 나면 사람 문제가 아니다. 세 번이면 확실하다. 필요한 건 “다음엔 조심하자”가 아니라 조심하지 않아도 되게 만드는 장치다.
검사기는 자기가 일한다는 걸 증명해야 한다. 대상 0개를 통과로 처리하면 그 검사는 있으나 마나다. 지켜지고 있다는 착각만 만들어 더 나쁘다.
아직 해결 못 한 것도 있다.
adapters밖은 안 본다. 화면 파일에서 직접 시각을 찍으면 검사가 못 잡는다. 지금은 별도 구조 검사가 그런 코드를 다른 이유로 막는다. 두 검사가 서로를 떠받쳐 하나가 느슨해지면 같이 뚫린다.- 문자열을 훑는 방식이라 한계가 있다. 변수에 담았다가 다음 줄에서 쓰면 못 잡는다. 정확히 하려면 코드를 구문 트리로 파싱해야 하는데, 그 부담은 아직 감당하지 않았다.
- 결국은 타입으로 가야 한다. 검사는 잘못 쓴 코드를 잡고, 타입은 잘못 쓸 수 없게 만든다. 지금은 전자에 머물러 있다.
- 문자열 자르기를 걷어내는 게 진짜 해법이다. 검사는 잘못된 값이 그 함수에 들어가는 것을 막을 뿐, 함수가 시간대를 모른다는 사실은 그대로다. 시간대를 명시해 찍도록 바꾸면 이 검사도,
kstIso도 필요 없어진다.
한국만 쓰는 앱이라 9시간 하나만 다루면 됐다. 나라가 늘면 이 방식은 그대로는 못 쓴다. 그때는 화면에 찍기 직전까지 시각을 시간대와 함께 들고 다녀야 한다.