본문으로 건너뛰기

TimeMachine Flowing 모드로 인한 JWT 만료 이슈

  • 발생 버전: dta-wide-api 0.0.20-rc4 (PR #2826)
  • 원인 커밋: eaff75ed5 feat(time-machine): add flowing field and time tick mechanism (Plan 126 Phase 1)
  • 작성일: 2026-03-31

증상

JWT verification failed for kid: e57432a3db1f848b2ae224923fc6af293cea808388e2ea55c662523ef147c28e
TokenExpiredError: jwt expired
at JwkManagementService.verifyWithJwk
at AccessTokenService.authJWT
at ValidateTokenHandler.execute
at JwtAuthGuard.canActivate

원인 분석

변경 내용 (rc3 → rc4)

Plan 126 구현의 일환으로 TimeMachine에 flowing 모드 추가:

커밋내용
eaff75ed5flowing 필드 + TimeTickService 추가 (Phase 1)
4d7230689TimeMachine status API에 flowing 상태 추가
980bc16a5setVirtualTimeByDateTime timezone-naive 처리 수정
6fe55ec5atimezone-aware datetime 파싱 toDate 적용

문제 메커니즘

[TimeTickService] onModuleInit() 실행
→ 10초마다 flowing=true 유저의 virtualTime += 10초
→ DB 업데이트 + Redis 캐시 무효화

[authJWT] 토큰 검증
→ getCurrentTimestamp() → virtualTime (점점 증가)
→ clockTimestamp = virtualTime / 1000
→ verifyWithJwk(token, jwk, clockTimestamp)
→ virtualTime > token.exp → TokenExpiredError ❌

핵심 코드 경로

1. TimeTickService — 자동 시간 전진

// libs/feature/time-machine/src/lib/application/services/time-tick.service.ts
private async tick(): Promise<void> {
const flowingUsers = await this.userTimeRepository.findAllFlowing();
for (const user of flowingUsers) {
const newVirtualTime = user.virtualTime + TICK_INCREMENT_MS; // +10,000ms
await this.userTimeRepository.updateVirtualTime(user.userId, newVirtualTime);
await this.cacheService.invalidateUserTimeResult(user.userId);
}
}

2. authJWT — virtual time을 clockTimestamp로 사용

// libs/feature/auth/core/src/lib/application/services/access-token.service.ts:436-444
let clockTimestamp: number | undefined;
if (userIdForTimeMachine) {
const currentTimeMs = await this.getCurrentTimestamp(userIdForTimeMachine, actualUserState);
clockTimestamp = Math.floor(currentTimeMs / 1000); // virtual time (초)
}
payload = await this.jwkManagementService.verifyWithJwk(token, jwk, clockTimestamp);

3. FeatureTimeMachineModule — TimeTickService 하드코딩

// libs/feature/time-machine/src/lib/feature-time-machine.module.ts
const services = [UserTimeManager, RollbackCoordinator, UserClockService, TimeTickService];
// dta-wide-api가 FeatureTimeMachineModule을 임포트 → TimeTickService 자동 시작

4. dta-wide-api의 FeatureTimeMachineModule 임포트

// apps/dta-wide-api/src/app/auth/auth.module.ts
import { FeatureTimeMachineModule } from '@feature/time-machine';
// → dta-wide-api 시작 시 TimeTickService.onModuleInit() 실행됨

발생 시나리오

  1. 테스터가 특정 유저의 TimeMachine을 미래 날짜로 설정
  2. toggleFlow API로 flowing=true 활성화
  3. TimeTickService가 해당 유저의 virtualTime을 10초마다 전진
  4. virtualTime이 기존 JWT의 exp를 초과
  5. 이후 모든 API 요청에서 TokenExpiredError 발생

참고: 분산 락(lock:time-tick)이 존재하지만, dta-wide-apidha-sleep-api 모두 TimeTickService를 실행하므로 두 서비스가 락 경쟁에 참여하게 됨.


해결 방법

즉시 적용 (Hotfix): TimeTickService 격리

변경 파일 3개, 기존 로직 변경 없음.

Step 1 — FeatureTimeMachineModule에서 TimeTickService 제거

// libs/feature/time-machine/src/lib/feature-time-machine.module.ts
-const services = [UserTimeManager, RollbackCoordinator, UserClockService, TimeTickService];
+const services = [UserTimeManager, RollbackCoordinator, UserClockService];

Step 2 — 별도 Tick 전용 모듈 생성

// libs/feature/time-machine/src/lib/feature-time-machine-tick.module.ts
import { Module } from '@nestjs/common';
import { FeatureTimeMachineModule } from './feature-time-machine.module';
import { TimeTickService } from './application/services/time-tick.service';

@Module({
imports: [FeatureTimeMachineModule],
providers: [TimeTickService],
})
export class FeatureTimeMachineTickModule {}

Step 3 — dha-sleep-api에서만 TickModule 임포트

// apps/dha-sleep-api/src/app/app.module.ts
import { FeatureTimeMachineTickModule } from '@feature/time-machine';

@Module({
imports: [
FeatureTimeMachineTickModule, // dha-sleep-api만 tick 실행
// ...
]
})

결과

서비스TimeTickService영향
dta-wide-api❌ 실행 안 함JWT 만료 문제 해결
dha-sleep-api✅ 실행기존과 동일하게 flowing 동작
기타 서비스❌ 실행 안 함-

중기 개선: JWT ↔ TimeMachine 분리

현재 설계는 JWT 발급(iat/exp)과 검증(clockTimestamp) 모두 virtual time을 사용합니다. JWT는 auth 인프라 영역으로 TimeMachine(도메인 비즈니스 로직)과 분리하는 것이 근본 해결책입니다.

변경 방향

  • authJWT에서 clockTimestamp 분기 제거 → 항상 실제 시간으로 검증
  • generateTokenscurrentTime 파라미터를 항상 Date.now()로 고정
  • TimeMachine은 수면 사이클, 일차 계산 등 도메인 로직에만 적용

관련 파일

  • libs/feature/auth/core/src/lib/application/services/access-token.service.ts
  • libs/feature/auth/core/src/lib/domain/services/jwk-management.service.ts

즉시 대응 (DB)

flowing=true인 유저가 있다면 즉시 비활성화:

UPDATE private.virtual_time
SET flowing = false
WHERE flowing = true;

관련 파일

파일역할
libs/feature/time-machine/src/lib/application/services/time-tick.service.ts문제의 TimeTickService
libs/feature/time-machine/src/lib/feature-time-machine.module.tsTimeTickService 등록 위치
libs/feature/auth/core/src/lib/application/services/access-token.service.tsauthJWT + clockTimestamp
libs/feature/auth/core/src/lib/domain/services/jwk-management.service.tsverifyWithJwk
apps/dta-wide-api/src/app/auth/auth.module.tsFeatureTimeMachineModule 임포트