TimeMachine Flowing 모드로 인한 JWT 만료 이슈
- 발생 버전: dta-wide-api 0.0.20-rc4 (PR #2826)
- 원인 커밋:
eaff75ed5feat(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 모드 추가:
| 커밋 | 내용 |
|---|---|
eaff75ed5 | flowing 필드 + TimeTickService 추가 (Phase 1) |
4d7230689 | TimeMachine status API에 flowing 상태 추가 |
980bc16a5 | setVirtualTimeByDateTime timezone-naive 처리 수정 |
6fe55ec5a | timezone-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() 실행됨
발생 시나리오
- 테스터가 특정 유저의 TimeMachine을 미래 날짜로 설정
toggleFlowAPI로flowing=true활성화TimeTickService가 해당 유저의 virtualTime을 10초마다 전진- virtualTime이 기존 JWT의
exp를 초과 - 이후 모든 API 요청에서
TokenExpiredError발생
참고: 분산 락(
lock:time-tick)이 존재하지만,dta-wide-api와dha-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분기 제거 → 항상 실제 시간으로 검증generateTokens의currentTime파라미터를 항상Date.now()로 고정- TimeMachine은 수면 사이클, 일차 계산 등 도메인 로직에만 적용
관련 파일
libs/feature/auth/core/src/lib/application/services/access-token.service.tslibs/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.ts | TimeTickService 등록 위치 |
libs/feature/auth/core/src/lib/application/services/access-token.service.ts | authJWT + clockTimestamp |
libs/feature/auth/core/src/lib/domain/services/jwk-management.service.ts | verifyWithJwk |
apps/dta-wide-api/src/app/auth/auth.module.ts | FeatureTimeMachineModule 임포트 |