주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
Unity 주간 출석 API 가 운영 한 달 후 월요일 칸만 무작위로 비었다. DB 에는 COMPLETED 과제가 분명히 있는데 화면은 null 이었다. 진짜 범인은 `new Date('2026-03-09')` 가 ISO 8601 로 파싱되어 UTC 자정 (= KST 09:00 월) 으로 잡힌 점이었고, batch 가 KST 00:00 으로 고정한 scheduledAt 이 쿼리 범위 *직전 9 시간* 에 들어가 통째로 누락되고 있었다. KST 자정 명시 생성자로 두 줄을 갈아끼우고, ESLint 룰과 jest mocking 가드로 재발을 막은 트러블슈팅을 정리한다.
💡 Tip. 바쁜 현대인들을 위한 본문 요약
- 증상 — Unity 주간 출석 화면에서 월요일 칸만 무작위로 null. DB 에는 같은 회원의 Monday COMPLETED 과제가 분명히 있음. 화·수·목·금은 정상.
- 표면 가설 3 건 (모두 탈락) — 상태 매핑 버그 / Map 순서 우선순위 (이미 잡힌 케이스) / 배치가 월요일 과제를 안 만든 케이스. 세 가설 모두 데이터로 반증.
- 진짜 범인 —
new Date('2026-03-09')는 ISO 8601 date-only 로 파싱되어 UTC 자정 = KST 09:00 월요일 이 된다. 배치가 KST 00:00 월요일 로 지정한scheduledAt은 쿼리 범위 직전 9 시간 에 들어가 통째로 누락.- 해결 —
new Date(year, month-1, day, 0, 0, 0)KST 자정 명시 생성자 로 두 줄 교체. 서버 TZ 가Asia/Seoul인 한 로컬 자정 = KST 자정 으로 일관.- 재발 방지 3 가드 — ESLint
no-restricted-syntax로new Date('YYYY-MM-DD')패턴 차단 + KST 자정 헬퍼 함수 표준 + jestsetSystemTime시간대 명시 단위 테스트 4 케이스.
🌱 배경 — 출석은 이미 두 번 잡힌 도메인이었다
운영 한 달이 지난 시점에서 보호자 문의 한 건이 들어왔다. Unity 회원 홈 주간 출석 표시 가 지난주 월요일 칸이 비어 있다 는 내용이었다. 보호자가 활동 기록 페이지 를 열어 해당 회원의 월요일 COMPLETED 과제 를 직접 확인해 두고 보낸 보고였다.
같은 회원 같은 주의 화·수·목·금 은 PRESENT 로 정상이었고, 토·일 은 과제가 없어 null 로 정상. 오직 월요일 칸만 빈 칸 이었다.
출석 도메인은 본 머지 직전 두 번 이미 손본 시스템 이었다.
e19108c— 주간 출석 ACTIVE 상태 우선 처리. 같은 날짜에 ACTIVE + COMPLETED 가 같이 있을 때 덮어쓰기 순서 를 정리.ba7769c— 상태 우선순위 맵 도입.ACTIVE:2 > COMPLETED:1 > 나머지:0으로 EXPIRED 가 COMPLETED 를 덮어쓰던 버그 차단.
두 번이나 손본 도메인 에서 세 번째 사고 가 났다는 뜻이고, 남은 가설은 코드 안 이 아닐 가능성이 높았다.
📌 핵심: 같은 도메인에서 2 회 이상 수정 된 영역에 또 다른 증상 이 나오면, 이미 정리된 코드 경로 안에서 찾기 전에 시간·환경·구분 시점 부터 의심한다. 본 사고의 답은 코드 로직이 아니라
Date파싱의 시간대 가정 이었다.
🔥 증상 — 월요일 칸만 무작위로 비었다
Unity Lobby 의 회원 홈 화면은 주간 출석 7 칸 배열 을 그대로 받아 그린다.
// Unity 측 응답 수신부 (NetworkManager.ParseResponse<WeeklyAttendanceResponse>)
interface WeeklyAttendanceResponse {
weekStart: string; // "2026-03-09" (월요일)
weekEnd: string; // "2026-03-15" (일요일)
statuses: (
| 'PRESENT' // 출석 (COMPLETED)
| 'ABSENT' // 결석 (EXPIRED 또는 과거 ACTIVE)
| 'PENDING' // 오늘 진행 중 (ACTIVE)
| null // 과제 없음
)[];
}
문의 회원의 실제 응답을 동일 토큰 으로 다시 받아 봤다.
# 운영 서버 — 학부모 문의 시점 (2026-03-09 10:30 KST)
curl -H "Authorization: Bearer ***" \
https://api.example.dev/v1/student/attendance/weekly | jq .
{
"weekStart": "2026-03-09",
"weekEnd": "2026-03-15",
"statuses": [null, "PRESENT", "PRESENT", "PRESENT", "PRESENT", null, null]
// [월, 화, 수, 목, 금, 토, 일]
}
월요일 (index 0) 만 null. 화·수·목·금 은 PRESENT. 토·일 은 해당 주 미도래 라 null 정상.
DB 에는 분명히 월요일 COMPLETED 과제 가 있었다.
-- 운영 DB 직접 조회 (read replica)
SELECT id, "scheduledAt" AT TIME ZONE 'Asia/Seoul' AS scheduled_kst, status, "completedAt" AT TIME ZONE 'Asia/Seoul' AS completed_kst
FROM "Assignment"
WHERE "studentId" = '***'
AND "scheduledAt" >= '2026-03-09 00:00:00+09'
AND "scheduledAt" < '2026-03-16 00:00:00+09'
ORDER BY "scheduledAt";
id | scheduled_kst | status | completed_kst
--------+-------------------------+-----------+-------------------------
18421 | 2026-03-09 00:00:00 | COMPLETED | 2026-03-09 19:42:11
18512 | 2026-03-10 00:00:00 | COMPLETED | 2026-03-10 20:13:55
18634 | 2026-03-11 00:00:00 | COMPLETED | 2026-03-11 18:48:02
18745 | 2026-03-12 00:00:00 | COMPLETED | 2026-03-12 21:05:34
18867 | 2026-03-13 00:00:00 | COMPLETED | 2026-03-13 19:30:18
(5 rows)
월요일 COMPLETED 가 분명히 있다. 그런데 API 응답에서는 빠졌다.
증상을 다섯 줄로 정리하면:
| # | 신호 | 의미 |
|---|---|---|
| 1 | 월요일 칸만 무작위로 null | 요일 한 칸 에만 발생 — 화·수·목·금은 정상 |
| 2 | DB 에 Monday COMPLETED 분명히 존재 | 데이터 자체는 정상, 읽지 못함 |
| 3 | 다른 회원도 동일 주 의 동일 요일 에서 재현 | 회원 종속 버그 아님 — 시간 구간 종속 |
| 4 | 다른 주에서는 발생하지 않은 회원도 있음 | 모든 월요일이 아닌 특정 시간대 조합 에서 발생 |
| 5 | e19108c · ba7769c 의 상태 우선순위 맵 은 이미 적용됨 | 상태 매핑 단계 가 본진이 아니다 — 그 이전 단계 의 누락 |
신호 3, 4, 5 가 결정적이었다. 회원 종속도 아니고 상태 매핑도 아닌, 시간 구간 어딘가에서 데이터가 누락 되고 있었다.
🔍 단서: 요일 한 칸 에서만 빠지는 시간 구간 버그 는 거의 항상 타임존 가정 불일치. 서버 TZ / DB TZ / 클라이언트 TZ / 라이브러리 기본 TZ 네 층 중 한 층의 가정이 다른 셋과 어긋난 지점 이 본진이다.
🔍 탐색 — 가설 셋을 모두 데이터로 반증했다
가설 1 — 상태 매핑 버그 (탈락)
본 머지 직전 두 번 잡았던 상태 매핑 의 사각지대를 먼저 봤다. Map 우선순위 코드 를 다시 펼쳤다.
// student-home.application.service.ts — getWeeklyAttendance() 일부
const STATUS_PRIORITY: Record<string, number> = { ACTIVE: 2, COMPLETED: 1 };
const assignmentByDate = new Map<string, { status: string }>();
for (const assignment of assignments) {
const dateKey = assignment.scheduledAt.toLocaleDateString('sv-SE'); // "YYYY-MM-DD"
const existing = assignmentByDate.get(dateKey);
const existingPriority = existing ? (STATUS_PRIORITY[existing.status] ?? 0) : -1;
const newPriority = STATUS_PRIORITY[assignment.status] ?? 0;
if (newPriority > existingPriority) {
assignmentByDate.set(dateKey, { status: assignment.status });
}
}
코드 자체는 우선순위 비교가 정확했다. 문제는 그 위 였다. Map 에 Monday 가 애초에 들어가지 않으면 어떤 우선순위 처리도 돌지 않는다.
console.log 한 줄로 findMany 결과 자체를 찍었다.
const assignments = await this.prisma.assignment.findMany({ /* ... */ });
this.logger.log(`Found ${assignments.length} assignments for week ${weekStart}~${weekEnd}`);
this.logger.log(JSON.stringify(assignments.map(a => ({
scheduledAt: a.scheduledAt.toISOString(),
status: a.status,
})), null, 2));
[StudentHome] Found 4 assignments for week 2026-03-09~2026-03-15
[
{ "scheduledAt": "2026-03-09T15:00:00.000Z", "status": "COMPLETED" }, ← 화요일 (KST)
{ "scheduledAt": "2026-03-10T15:00:00.000Z", "status": "COMPLETED" }, ← 수요일 (KST)
{ "scheduledAt": "2026-03-11T15:00:00.000Z", "status": "COMPLETED" }, ← 목요일 (KST)
{ "scheduledAt": "2026-03-12T15:00:00.000Z", "status": "COMPLETED" } ← 금요일 (KST)
]
4 건 만 나왔다. 5 건이어야 정상 이다. Monday 가 통째로 안 잡혔다. 가설 1 탈락 — Map 단계가 아니라 쿼리 단계 에서 빠진다.
가설 2 — 배치가 월요일 과제를 안 만들었나 (탈락)
Assignment.scheduledAt 의 원천 은 킥오프 배치 다. 월요일 배치 자체가 실패 했을 가능성을 의심했다.
// batch-process.application.service.ts — kickoff 메서드 일부
@Cron('0 6 * * *', { timeZone: 'Asia/Seoul' }) // 매일 06:00 KST
async kickoff() {
const today = new Date();
today.setHours(0, 0, 0, 0); // 로컬 자정 = KST 00:00 (서버 TZ Asia/Seoul)
// 신규 과제 생성
await this.prisma.assignment.create({
data: {
studentId: student.id,
status: 'ACTIVE',
scheduledAt: today, // KST 00:00 고정
// ...
},
});
}
배치 실행 로그를 펼쳤다.
# Cloud Run 로그 — 2026-03-09 06:00 KST
gcloud logging read 'resource.labels.service_name="academy-api" \
AND jsonPayload.context="KickoffBatch"' \
--limit=10 --format=json
[06:00:00] [KickoffBatch] Started
[06:00:08] [KickoffBatch] Created 23 assignments for 2026-03-09
[06:00:08] [KickoffBatch] Completed
23 건 생성 완료. 문의 회원도 포함됐다. 위 SQL 결과의 id=18421 이 바로 이 배치가 만든 레코드 다. 가설 2 탈락 — 데이터는 정상으로 고정됐다.
가설 3 — Prisma 가 Monday 를 필터링했나 (살아남음)
남은 가설은 Prisma findMany 의 where.scheduledAt 범위 자체가 Monday 를 포함하지 않았다 는 것이다. 쿼리 범위 계산 코드를 펼쳤다.
// student-home.application.service.ts — getWeeklyAttendance() 도입부
const now = new Date();
const learningDate = this.getLearningDate(now);
const { weekStart, weekEnd } = this.getWeekRange(learningDate);
// weekStart = "2026-03-09" ← 월요일
// weekEnd = "2026-03-15" ← 일요일
// 해당 주의 과제 조회 (월~일)
const weekStartDate = new Date(weekStart); // ← 의심 지점
const weekEndDate = new Date(weekEnd); // ← 의심 지점
weekEndDate.setHours(23, 59, 59, 999);
const assignments = await this.prisma.assignment.findMany({
where: {
studentId,
scheduledAt: {
gte: weekStartDate,
lte: weekEndDate,
},
},
// ...
});
new Date('2026-03-09') 가 정확히 어느 시점 인지를 Node REPL 로 직접 찍었다.
node -e "console.log(new Date('2026-03-09').toISOString())"
# 출력: 2026-03-09T00:00:00.000Z
node -e "console.log(new Date('2026-03-09').toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }))"
# 출력: 2026. 3. 9. 오전 9:00:00
UTC 자정 으로 잡혔다. KST 로 환산하면 월요일 09:00. 본진을 찾았다.
📌 핵심:
new Date('YYYY-MM-DD')는 date-only 로 인식되어 ECMA-262 사양에 따라 UTC 자정 으로 파싱된다.new Date('YYYY-MM-DDTHH:mm:ss')(T 포함) 는 로컬 자정 으로 파싱된다. 이 구분 한 줄 이 본 사고의 시간 구간을 만들었다.
🎯 진짜 범인 — ISO date-only 가 UTC 자정으로 파싱됐다

본진을 한 줄로 정리하면 쿼리 시작점이 KST 09:00 월요일 이고, Monday 의 scheduledAt 은 KST 00:00 월요일 이라 9 시간 격차로 통째로 잘려 나갔다.
배치가 지정한 값 (KST): 2026-03-09 00:00:00 ← Asia/Seoul 자정
DB 저장 값 (UTC): 2026-03-08 15:00:00Z ← Timestamptz 정규화
쿼리 범위 시작 (UTC): 2026-03-09 00:00:00Z ← new Date('2026-03-09')
= KST 09:00 월요일
→ scheduledAt (UTC 2026-03-08 15:00:00Z) < 범위 시작 (UTC 2026-03-09 00:00:00Z)
→ 9 시간 격차로 Monday 누락
Tuesday 의 scheduledAt 은 UTC 2026-03-09 15:00Z 라 범위 시작 (UTC 2026-03-09 00:00Z) 보다 늦어 정상 포함된다. 월요일만 누락되는 주기성 의 정체다.
왜 이 코드가 처음에는 통과했나
주간 출석 API 머지 시점 (2026-02-04) 의 단위 테스트 와 통합 테스트 가 왜 이 케이스를 못 잡았나 를 같이 풀어야 했다.
// 기존 단위 테스트 — apps/api/src/application/services/__tests__/student-home.application.service.spec.ts
describe('getWeeklyAttendance', () => {
it('월~일 7일 상태 배열을 반환한다', async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-02-05T12:00:00Z')); // ← UTC 정오 고정
// 데이터 fixture 도 ISO 8601 Z 형식으로 고정
mockPrisma.assignment.findMany.mockResolvedValue([
{ scheduledAt: new Date('2026-02-03T12:00:00Z'), status: 'COMPLETED' },
// ...
]);
const result = await service.getWeeklyAttendance('student-1');
expect(result.statuses).toEqual(['PRESENT', /* ... */]);
});
});
테스트 데이터 fixture 와 쿼리 범위 계산 이 모두 UTC 정오 (KST 21:00) 로 고정되어 있어서 9 시간 격차 안에 들어오는 시점 이 한 번도 없었다. 단위 테스트가 운영의 시간 구간을 재현하지 못한 가장 흔한 패턴이다.
⚠️ 주의: 시간 구간 버그의 단위 테스트는 데이터 fixture / 시스템 시각 / 쿼리 범위 세 축이 서로 다른 자정 시점 을 동시에 가로지르도록 짜야 한다. 셋 다 UTC 정오 면 KST 자정 직후 케이스 가 영원히 안 잡힌다.

🛠️ 해결 — KST 자정 명시 생성자로 두 줄 교체
수정은 두 줄 이었다. new Date(string) 의 ISO 파싱 가정 을 없애고, KST 자정 을 명시 생성자 로 고정했다.
// 해당 주의 과제 조회 (월~일)
- const weekStartDate = new Date(weekStart);
- const weekEndDate = new Date(weekEnd);
- weekEndDate.setHours(23, 59, 59, 999);
+ // 해당 주의 과제 조회 (월~일) — KST 기준 날짜 구간
+ const [sy, sm, sd] = weekStart.split('-').map(Number);
+ const weekStartDate = new Date(sy, sm - 1, sd, 0, 0, 0, 0); // KST 자정
+ const [ey, em, ed] = weekEnd.split('-').map(Number);
+ const weekEndDate = new Date(ey, em - 1, ed, 23, 59, 59, 999); // KST 말일
핵심은 생성자 시그니처를 바꿨다 는 점이다.
new Date(string)— 문자열을 파싱. date-only 는 UTC 자정, datetime (T 포함) 은 로컬. 가정이 한 단계 숨어 있다.new Date(year, month, day, hours, ...)— 명시 생성자. 항상 로컬 시간대 자정 으로 고정된다. 서버 TZ 가Asia/Seoul이면 KST 자정 그대로.
본 서버는 Cloud Run 환경변수 TZ=Asia/Seoul 이 깔려 있고, 로컬 개발도 동일. 명시 생성자 가 KST 자정 으로 일관되게 동작한다.
같은 머지에서 함께 잡은 자매 사고
본 머지 직후 모니터링 대시보드 의 오늘 활동 현황 위젯 도 동일 패턴 사고 를 일으켰다. scheduledAt 이 KST 00:00 으로 고정되어 있는데 필터를 06 시 KST 기준 활동일로 깔아 둔 코드 지점에서 오늘 과제 0 건 으로 잘못 잡혔던 사고였다. 같은 PR 안에 906e023 으로 묶어 scheduledAt 은 00 시 KST 기준, completedAt·claimedAt 은 06 시 KST 기준 의 이중 범위 로 분리해 함께 머지했다.
// admin-monitoring.application.service.ts — 수정 후 일부
const startOfTodayKST = this.getStartOfTodayKST(); // KST 00:00 자정
const startOfLearningDayKST = this.getStartOfLearningDayKST(); // KST 06:00 활동일 기준
const todayAssignments = await this.prisma.assignment.findMany({
where: {
OR: [
{ scheduledAt: { gte: startOfTodayKST } }, // 배치가 고정한 00시 대응
{ completedAt: { gte: startOfLearningDayKST } }, // 활동 완료 시각은 06시 기준
{ claimedAt: { gte: startOfLearningDayKST } }, // 보상 수령 시각도 06시 기준
],
},
});
🔍 단서: 같은 패턴의 사고가 한 도메인 안에서 두 번 일어나면 그 도메인은 시간대 가정을 단일 함수로 모으는 게 표준. 본 머지에서 KST 자정 과 활동일 기준 06 시 두 함수를
time/kst.ts한 모듈로 분리해 다른 서비스 코드는 직접new Date를 만지지 않도록 강제했다.
// time/kst.ts — 신규 헬퍼 모듈
/** YYYY-MM-DD 문자열을 KST 자정 Date 로 변환 */
export function parseKstMidnight(dateStr: string): Date {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0);
}
/** YYYY-MM-DD 문자열을 KST 23:59:59.999 Date 로 변환 */
export function parseKstEndOfDay(dateStr: string): Date {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d, 23, 59, 59, 999);
}
/** 활동일 (06 시 기준) 시작점 */
export function getStartOfLearningDayKST(now: Date = new Date()): Date {
const result = new Date(now);
if (now.getHours() < 6) result.setDate(result.getDate() - 1);
result.setHours(0, 0, 0, 0);
return result;
}
✅ 검증 — Monday 가 다시 잡혔다
검증은 세 단계 로 깔았다. 동일 토큰 즉시 재확인 → 단위 테스트 4 케이스 → 운영 24 시간 관찰.
1 — 동일 토큰 즉시 재확인
핫픽스 배포 직후 문의 회원과 동일 토큰 으로 다시 호출했다.
# 핫픽스 배포 직후 (2026-03-09 11:55 KST)
curl -H "Authorization: Bearer ***" \
https://api.example.dev/v1/student/attendance/weekly | jq .
{
"weekStart": "2026-03-09",
"weekEnd": "2026-03-15",
"statuses": ["PRESENT", "PRESENT", "PRESENT", "PRESENT", "PRESENT", null, null]
// [월, 화, 수, 목, 금, 토, 일]
}
월요일 칸이 PRESENT 로 정상 복원. 다른 회원 세 명 의 동일 주에서도 월요일 PRESENT 확인.
2 — 단위 테스트 4 케이스
단위 테스트가 운영 시간 구간를 재현하지 못한 점을 본 사고의 근본 원인 표면 일부 로 받아들이고, 시간 구간 4 케이스 를 jest setSystemTime 으로 명시 했다.
// student-home.application.service.spec.ts — 신규 테스트 4건
describe('getWeeklyAttendance — 시간 구간 케이스', () => {
beforeEach(() => {
jest.useFakeTimers();
process.env.TZ = 'Asia/Seoul'; // 서버 TZ 명시
});
it.each([
{
name: '월요일 KST 00:01 — 배치 직후 (월요일 데이터 포함)',
now: new Date('2026-03-09T00:01:00+09:00'),
scheduledAt: new Date('2026-03-09T00:00:00+09:00'),
expected: 'PRESENT',
},
{
name: '월요일 KST 05:59 — 활동일 시작점 직전',
now: new Date('2026-03-09T05:59:00+09:00'),
scheduledAt: new Date('2026-03-09T00:00:00+09:00'),
expected: 'PRESENT',
},
{
name: '일요일 KST 23:59 — 한 주 마지막',
now: new Date('2026-03-15T23:59:00+09:00'),
scheduledAt: new Date('2026-03-15T00:00:00+09:00'),
expected: 'PRESENT',
},
{
name: 'DST 없음 검증 (KST 는 일광절약시간 없음)',
now: new Date('2026-03-30T00:01:00+09:00'),
scheduledAt: new Date('2026-03-30T00:00:00+09:00'),
expected: 'PRESENT',
},
])('$name', async ({ now, scheduledAt, expected }) => {
jest.setSystemTime(now);
mockPrisma.assignment.findMany.mockResolvedValue([
{ scheduledAt, status: 'COMPLETED' },
]);
const result = await service.getWeeklyAttendance('member-1');
const dayIndex = scheduledAt.getDay() === 0 ? 6 : scheduledAt.getDay() - 1;
expect(result.statuses[dayIndex]).toBe(expected);
});
});
4 케이스 모두 PASS. 핵심은 +09:00 명시로 어느 시간대 기준 시각인지 를 테스트 자체가 진술 한다는 점이다. fixture 가 UTC 정오로 고정되어 있던 기존 패턴 은 폐기.
3 — 운영 24 시간 관찰
월요일 핫픽스 후 화·수·목 KST 00:00~06:00 윈도우 에서 동일 증상이 재현되는지 를 세 번 더 확인했다. 회원 5 명 (운영 OPS 가 대시보드에서 표본) 의 weekly 응답을 KST 00:30 / 03:00 / 05:30 세 시점에 떠 보고 해당 요일 칸이 정상 인지 검증.
| 시점 (KST) | 표본 | 해당 요일 결과 |
|---|---|---|
| 2026-03-10 00:30 | 5 명 | 화요일 PRESENT 5 / 5 |
| 2026-03-11 03:00 | 5 명 | 수요일 PRESENT 5 / 5 |
| 2026-03-12 05:30 | 5 명 | 목요일 PRESENT 5 / 5 |
재발 0 건.
머지는 5 라인 추가, 4 라인 삭제 가 전부였다.
fix(api): 주간 출석 쿼리 KST 타임존 버그 수정 (월요일 과제 누락 해결)
apps/api/src/application/services/student-home.application.service.ts | 9 +++++----
1 file changed, 5 insertions(+), 4 deletions(-)
🛡️ 예방 — 세 가드로 같은 사고를 봉쇄했다
재발 방지는 세 가드 로 깔았다. 코드 단계 ESLint 차단 + 공용 헬퍼 표준 + 단위 테스트 시간대 명시 가 서로 다른 시점 을 책임 분담한다.
가드 1 — ESLint no-restricted-syntax 로 new Date('YYYY-MM-DD') 차단
문자열 인자 new Date 는 항상 의심 대상 으로 두는 게 안전하다. 조용히 통과하지 못하도록 AST 패턴으로 차단했다.
// eslint.config.js — apps/api 전용 룰
{
files: ['apps/api/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "NewExpression[callee.name='Date'][arguments.length=1][arguments.0.type='Literal']",
message: 'new Date(string) 는 ISO 파싱 가정이 숨어 있다. parseKstMidnight() 같은 명시 헬퍼를 사용하라. (time/kst.ts)',
},
{
selector: "NewExpression[callee.name='Date'][arguments.length=1][arguments.0.type='TemplateLiteral']",
message: 'new Date(`...`) 도 동일 — 명시 헬퍼 사용 권장',
},
],
},
},
이 룰을 깐 직후 기존 코드 베이스에서 7 곳 이 추가로 검출됐다.
| 파일 | 라인 | 패턴 | 처리 |
|---|---|---|---|
admin-monitoring.application.service.ts | 182 | new Date(dateStr) | parseKstMidnight() 치환 |
academy-attendance.application.service.ts | 95 | new Date(startDate) | parseKstMidnight() 치환 |
academy-attendance.application.service.ts | 99 | new Date(endDate) | parseKstEndOfDay() 치환 |
student-report.application.service.ts | 410 | new Date(weekStart) | parseKstMidnight() 치환 |
student-report.application.service.ts | 423 | new Date(weekEnd) | parseKstEndOfDay() 치환 |
class-attendance-stats.application.service.ts | 67 | new Date(targetDate) | parseKstMidnight() 치환 |
class-attendance-stats.application.service.ts | 71 | new Date(targetDate) | parseKstEndOfDay() 치환 |
7 곳 모두 같은 패턴 사고를 일으킬 잠재력 이 있었다. 한 번에 정리.
가드 2 — time/kst.ts 단일 헬퍼 모듈
KST 자정 / 활동일 06 시 / 주 시작·종료 등 시간 구간 함수 는 모두 한 모듈 로 옮겼다. 서비스 코드는 new Date 를 직접 만지지 않는다 가 표준.
// time/kst.ts — 공개 API 한 페이지 요약
export function parseKstMidnight(dateStr: string): Date;
export function parseKstEndOfDay(dateStr: string): Date;
export function getStartOfLearningDayKST(now?: Date): Date;
export function getWeekRangeKST(date: Date): { weekStart: string; weekEnd: string };
export function getKstDayKey(date: Date): string; // YYYY-MM-DD (KST)
모듈 한 곳에 가정을 모은 결과 이후 시간대 가정 변경 이 모든 서비스에 일관 적용 된다. 동일 패턴 사고가 한 도메인에서 두 번 일어났을 때 의 표준 정리 패턴.
가드 3 — jest setSystemTime 시간대 명시 단위 테스트 표준
시간 구간가 들어가는 함수의 단위 테스트 는 +09:00 명시 ISO 와 process.env.TZ = 'Asia/Seoul' 을 반드시 깔도록 PR 체크리스트 에 추가했다.
# .github/PULL_REQUEST_TEMPLATE/api.md
## 시간 구간 함수 체크리스트 (해당 시 필수)
- [ ] *입력 데이터 fixture* 가 *시간대 명시 ISO* (`YYYY-MM-DDTHH:mm:ss+09:00`) 인가?
- [ ] *시스템 시각 mock* 이 *시간대 명시 ISO* 인가? (`jest.setSystemTime(new Date('...+09:00'))`)
- [ ] *KST 자정 시점 직후 케이스* (00:01) 가 포함되어 있나?
- [ ] *활동일 시작점 직전 케이스* (05:59) 가 포함되어 있나?
- [ ] *주·월 시점* 이 *명시 ISO* 로 고정되어 있나?
- [ ] `process.env.TZ = 'Asia/Seoul'` 가 *beforeEach* 에 깔려 있나?
📌 핵심: 코드 가드 + 공용 헬퍼 + 테스트 표준 의 세 층 은 서로 다른 시점을 책임진다. ESLint 룰은 작성 단계,
time/kst.ts헬퍼는 호출 단계, 테스트 체크리스트는 검증 단계. 본 사고는 작성 단계와 검증 단계의 가드 부재 가 운영 한 달 후 학부모 문의 로 이어진 사고다.
📋 정리 — 핵심 요약
본 머지에서 굳힌 결정 7 건 을 표로 정리한다. 직전 편 (devlog-61) 의 session-sync 운영 회고 가 1 인 운영의 상태 관리 를 다뤘다면, 본 사고는 그 운영에서 처음 터진 시간 구간 사고 다.
| 결정 | 안티패턴 (변경 전) | 권장 패턴 (변경 후) |
|---|---|---|
| Date 파싱 | ❌ new Date('YYYY-MM-DD') — ISO date-only 라 UTC 자정 으로 잡힘 | ✅ new Date(year, month-1, day, 0, 0, 0) — 명시 생성자 로 로컬 자정 고정 |
| 시간 함수 분리 | ❌ 서비스 코드에서 직접 new Date 사용 | ✅ time/kst.ts 한 모듈로 KST 자정 / 활동일 06 시 / 주 구간 통합 |
| 단위 테스트 시각 | ❌ new Date('2026-02-05T12:00:00Z') — UTC 정오 fixture | ✅ new Date('2026-03-09T00:01:00+09:00') — KST 시간대 명시 + 시작점 직후 케이스 |
| ESLint 룰 | ❌ 패턴 차단 없음 | ✅ no-restricted-syntax 로 new Date(literal) 한 줄 작성 단계 차단 |
| 동일 패턴 검출 | ❌ 본 사고 함수만 수정 | ✅ ESLint 룰 도입 직후 7 곳 추가 검출 + 같은 머지에서 일괄 정리 |
| 자매 사고 묶음 | ❌ 모니터링 위젯 사고를 다른 PR 로 분리 | ✅ scheduledAt 00 시 / completedAt·claimedAt 06 시 이중 범위 를 같은 PR 로 묶음 |
| 운영 검증 | ❌ “다음 주에 확인” | ✅ KST 00:30 / 03:00 / 05:30 세 시점 표본 5 명 구간 윈도우 직접 측정 |
핵심을 세 줄 로 다시 정리한다.
new Date('YYYY-MM-DD')는 UTC 자정 으로 파싱된다. ECMA-262 사양이다. date-only 는 UTC, datetime (T 포함) 은 로컬. 이 한 줄 차이 가 9 시간 격차 를 만들어 주간 출석 월요일 칸 을 통째로 잘랐다.- 시간 구간 함수는 단일 헬퍼 모듈 로 모은다. 서비스 코드는
new Date를 직접 만지지 않는다 가 표준. 한 도메인 안에서 동일 패턴 사고가 두 번 일어나면 모듈 분리 가 답이다. - 단위 테스트의 시각은
+09:00명시 ISO 로 고정한다. UTC 정오 fixture 는 KST 자정 직후 케이스 를 영원히 못 잡는다. jest setSystemTime 도 시간대 명시 가 표준.
다음 편 (devlog-63) 에서는 본 사고와 같은 변경 명세서 2026-02-03 묶음 의 연락처 포맷 통일 — BE Refine 응답과 FE 양식 입력의 포맷이 어긋나 양쪽을 동시에 손봐야 했던 일관성 사고의 증상·탐색·진짜 범인·해결을 같은 A 톤 으로 정리한다.
📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)
- 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
- 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
- 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
- 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
- 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
- 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
- 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
- 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
- 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
- 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
- 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
- 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
- 13. 번들 구조를 통째로 바꿔야 했던 이유
- 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
- 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
- 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
- 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
- 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
- 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
- 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
- 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
- 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
- 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
- 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
- 25. 멀티테넌트 누수 — tenantId 3계층 강제
- 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
- 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
- 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
- 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
- 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
- 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
- 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
- 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
- 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
- 35. Unity ↔ 웹 PostMessage 브릿지 설계기
- 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
- 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
- 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
- 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
- 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
- 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
- 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
- 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
- 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
- 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
- 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
- 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
- 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
- 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
- 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
- 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
- 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
- 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
- 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
- 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
- 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
- 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
- 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
- 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
- 60. react-hook-form + Zod 폼 표준 정착기
- 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
- 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
- 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음