주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅

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-syntaxnew Date('YYYY-MM-DD') 패턴 차단 + KST 자정 헬퍼 함수 표준 + jest setSystemTime 시간대 명시 단위 테스트 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요일 한 칸 에만 발생 — 화·수·목·금은 정상
2DB 에 Monday COMPLETED 분명히 존재데이터 자체는 정상, 읽지 못함
3다른 회원도 동일 주동일 요일 에서 재현회원 종속 버그 아님시간 구간 종속
4다른 주에서는 발생하지 않은 회원도 있음모든 월요일이 아닌 특정 시간대 조합 에서 발생
5e19108c · 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 포함) 는 로컬 자정 으로 파싱된다. 이 구분 한 줄 이 본 사고의 시간 구간을 만들었다.

developer.mozilla.org

🎯 진짜 범인 — ISO date-only 가 UTC 자정으로 파싱됐다

직접 정리한 new Date ISO 파싱 UTC 자정 vs KST 자정 차이 9시간으로 월요일 scheduledAt 누락 흐름도와 KST 자정 명시 생성자 해결 비교표
직접 정리한 new Date ISO 파싱 UTC 자정 vs KST 자정 차이 9시간으로 월요일 scheduledAt 누락 흐름도와 KST 자정 명시 생성자 해결 비교표

본진을 한 줄로 정리하면 쿼리 시작점이 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 누락

TuesdayscheduledAtUTC 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 자정 직후 케이스 가 영원히 안 잡힌다.

과거 두 번이나 손본 출석 도메인에서 세 번째 버그가 나온 진짜 원인이 코드가 아니라 ISO 8601 파싱의 시간대 가정에 있었다는 사실을 깨달은 순간
과거 두 번이나 손본 출석 도메인에서 세 번째 버그가 나온 진짜 원인이 코드가 아니라 ISO 8601 파싱의 시간대 가정에 있었다는 사실을 깨달은 순간


🛠️ 해결 — 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-onlyUTC 자정, datetime (T 포함)로컬. 가정이 한 단계 숨어 있다.
  • new Date(year, month, day, hours, ...)명시 생성자. 항상 로컬 시간대 자정 으로 고정된다. 서버 TZ 가 Asia/Seoul 이면 KST 자정 그대로.

본 서버는 Cloud Run 환경변수 TZ=Asia/Seoul 이 깔려 있고, 로컬 개발도 동일. 명시 생성자KST 자정 으로 일관되게 동작한다.

developer.mozilla.org

같은 머지에서 함께 잡은 자매 사고

본 머지 직후 모니터링 대시보드오늘 활동 현황 위젯동일 패턴 사고 를 일으켰다. scheduledAt 이 KST 00:00 으로 고정되어 있는데 필터를 06 시 KST 기준 활동일로 깔아 둔 코드 지점에서 오늘 과제 0 건 으로 잘못 잡혔던 사고였다. 같은 PR 안에 906e023 으로 묶어 scheduledAt00 시 KST 기준, completedAt·claimedAt06 시 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:305 명화요일 PRESENT 5 / 5
2026-03-11 03:005 명수요일 PRESENT 5 / 5
2026-03-12 05:305 명목요일 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-syntaxnew 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.ts182new Date(dateStr)parseKstMidnight() 치환
academy-attendance.application.service.ts95new Date(startDate)parseKstMidnight() 치환
academy-attendance.application.service.ts99new Date(endDate)parseKstEndOfDay() 치환
student-report.application.service.ts410new Date(weekStart)parseKstMidnight() 치환
student-report.application.service.ts423new Date(weekEnd)parseKstEndOfDay() 치환
class-attendance-stats.application.service.ts67new Date(targetDate)parseKstMidnight() 치환
class-attendance-stats.application.service.ts71new Date(targetDate)parseKstEndOfDay() 치환

7 곳 모두 같은 패턴 사고를 일으킬 잠재력 이 있었다. 한 번에 정리.

eslint.org

가드 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 명시 ISOprocess.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 헬퍼는 호출 단계, 테스트 체크리스트는 검증 단계. 본 사고는 작성 단계와 검증 단계의 가드 부재운영 한 달 후 학부모 문의 로 이어진 사고다.

prisma.io

📋 정리 — 핵심 요약

본 머지에서 굳힌 결정 7 건 을 표로 정리한다. 직전 편 (devlog-61)session-sync 운영 회고1 인 운영의 상태 관리 를 다뤘다면, 본 사고는 그 운영에서 처음 터진 시간 구간 사고 다.

결정안티패턴 (변경 전)권장 패턴 (변경 후)
Date 파싱new Date('YYYY-MM-DD')ISO date-onlyUTC 자정 으로 잡힘new Date(year, month-1, day, 0, 0, 0)명시 생성자로컬 자정 고정
시간 함수 분리❌ 서비스 코드에서 직접 new Date 사용time/kst.ts 한 모듈로 KST 자정 / 활동일 06 시 / 주 구간 통합
단위 테스트 시각new Date('2026-02-05T12:00:00Z')UTC 정오 fixturenew Date('2026-03-09T00:01:00+09:00')KST 시간대 명시 + 시작점 직후 케이스
ESLint 룰❌ 패턴 차단 없음no-restricted-syntaxnew Date(literal) 한 줄 작성 단계 차단
동일 패턴 검출❌ 본 사고 함수만 수정✅ ESLint 룰 도입 직후 7 곳 추가 검출 + 같은 머지에서 일괄 정리
자매 사고 묶음❌ 모니터링 위젯 사고를 다른 PR 로 분리scheduledAt 00 시 / completedAt·claimedAt 06 시 이중 범위같은 PR 로 묶음
운영 검증❌ “다음 주에 확인”KST 00:30 / 03:00 / 05:30 세 시점 표본 5 명 구간 윈도우 직접 측정

핵심을 세 줄 로 다시 정리한다.

  1. new Date('YYYY-MM-DD')UTC 자정 으로 파싱된다. ECMA-262 사양이다. date-only 는 UTC, datetime (T 포함) 은 로컬. 이 한 줄 차이9 시간 격차 를 만들어 주간 출석 월요일 칸 을 통째로 잘랐다.
  2. 시간 구간 함수는 단일 헬퍼 모듈 로 모은다. 서비스 코드는 new Date 를 직접 만지지 않는다 가 표준. 한 도메인 안에서 동일 패턴 사고가 두 번 일어나면 모듈 분리 가 답이다.
  3. 단위 테스트의 시각은 +09:00 명시 ISO 로 고정한다. UTC 정오 fixtureKST 자정 직후 케이스 를 영원히 못 잡는다. jest setSystemTime시간대 명시 가 표준.

다음 편 (devlog-63) 에서는 본 사고와 같은 변경 명세서 2026-02-03 묶음연락처 포맷 통일BE Refine 응답과 FE 양식 입력의 포맷이 어긋나 양쪽을 동시에 손봐야 했던 일관성 사고의 증상·탐색·진짜 범인·해결을 같은 A 톤 으로 정리한다.


📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (63편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로
  55. 55. Cloud SQL 리전 트랩 — US→Taiwan 71% 트러블슈팅
  56. 56. QR 배치고사 + Firebase Hosting 멀티 사이트 배포
  57. 57. 1,974줄 풀 백업 — 1인 개발에서 상태 관리하는 법
  58. 58. 주간 출석 KST 타임존 — 월요일이 사라진 트러블슈팅
  59. 59. 연락처 포맷 통일 — 저장은 숫자만, 표시는 하이픈
  60. 60. react-hook-form + Zod 폼 표준 정착기
  61. 61. Soft Delete 구현 — deletedAt 한 컬럼이 닿은 27곳의 설계
  62. 62. 교육과정 자동 승급의 늪 — 도메인 버그 3 건 트러블슈팅
  63. 63. 교육과정 도메인 BE 완성과 같은 날 핫픽스 7 건 — NestJS @Cron 2 중 실행 묶음