타임머신 가상 시간 관리 구현 가이드
개요
이 문서는 TimeMachine 도메인의 사용자별 가상 시간 관리 기능을 구현하기 위한 기술적 가이드를 제공합니다. 가상 시간 관리는 테스터가 개별 사용자의 시간을 독립적으로 조작하여 시간에 민감한 기능을 테스트할 수 있게 하는 핵심 기능입니다.
이 구현에서는 사용자의 치료 주기 일차(dayIndex)를 기준으로 시간을 설정하며, implementation-user-virtual-time.md 문서의 접근 방식을 기반으로 합니다.
1. 아키텍처
1.1 컴포넌트 구조
1.2 주요 컴포넌트
- UserTimeMiddleware: HTTP 요청 시 사용자 ID를 기반으로
UserTimeContext를 설정합니다. - VirtualTimeController: 사용자별 가상 시간 관련 API 엔드포인트를 처리합니다. (
dayIndex기반) - VirtualTimeService: 사용자별 가상 시간 관리의 핵심 비즈니스 로직을 담당합니다. (
dayIndex기반 시간 설정 포함) - UserTimeContext:
AsyncLocalStorage를 사용하여 현재 요청의 사용자 가상 시간 컨텍스트를 관리합니다. - UserTimeRepository: Prisma와 Redis를 사용하여 사용자별 가상 시간 데이터 및 변경 이력을 저장/조회하고 캐싱합니다.
- UserTimeMapper: 데이터베이스 모델과 도메인 객체/DTO 간 변환을 담당합니다.
- TimeChangeEventPublisher: 시간 변경 이벤트를 GCP Pub/Sub으로 발행합니다.
- DataRollbackService: 시간 변경 시 데이터 롤백을 처리합니다.
- UserCycleService: 사용자의 주기 시작일 및 중단/재개 이력을 제공합니다.
- PrismaService: Prisma ORM을 통한 데이터베이스 접근을 제공합니다.
- RedisCacheService: 빠른 조회를 위해 사용자별 가상 시간 정보를 캐싱합니다.
- UserService: 사용자 정보 조회 및 권한 검증을 담당합니다.
- ConfigService: 환경 설정 값을 관리합니다.
2. 데이터 모델
📌 참고: 상세 데이터 모델은 TimeMachine 도메인 모델 문서를 참조하세요.
4. 구현 가이드
4.2 VirtualTimeService 구현 (dayIndex 기반 로직)
import { Injectable, BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { UserTimeRepository } from '../../infrastructure/repositories/user-time.repository'; // 경로 수정
import { TimeChangeEventPublisher } from '../events/time-change-event.publisher'; // 경로 수정
import { DataRollbackService } from './data-rollback.service'; // 경로 수정
import { UserService } from '@feature/user'; // 경로 수정
import { UserCycleService, UserCycleSuspensionHistory } from '@feature/user-cycle'; // 경로 및 타입 수정
import { ConfigService } from '@nestjs/config';
import { RedisCacheService } from '@core/caching'; // 경로 수정
import { LoggerService } from '@core/logging'; // 경로 수정
import { UserTimeMapper } from '../mappers/user-time.mapper'; // 경로 수정
import { TimeChangeHistoryMapper } from '../mappers/time-change-history.mapper'; // 경로 수정
import { UserVirtualTime, UserVirtualTimeChangeResult, VirtualTime, TimeChangeRecord, PageMeta, Duration } from '../../domain/types'; // 타입 경로 수정
import { CannotSetTimeBeforeDayOneError } from '../../domain/errors/time-machine.errors'; // 에러 경로 수정
import { Prisma } from '@prisma/client';
import {
createZonedDateTimeWithTimestamp,
ZonedDateTime,
nowZonedDateTime,
DEFAULT_TIMEZONE,
toTimestampMs,
calculateTargetDateFromDayIndex,
getStartOfDayZonedDateTime
} from '@shared/utils'; // 유틸리티 함수
@Injectable()
export class VirtualTimeService {
constructor(
private virtualTimeRepository: UserTimeRepository,
private timeChangeEventPublisher: TimeChangeEventPublisher,
private dataRollbackService: DataRollbackService,
private userService: UserService,
private userCycleService: UserCycleService, // UserCycleService 주입
private configService: ConfigService,
private userTimeMapper: UserTimeMapper,
private timeChangeHistoryMapper: TimeChangeHistoryMapper,
private redisCache: RedisCacheService,
private logger: LoggerService, // LoggerService 사용
) {
this.logger.setContext(VirtualTimeService.name);
}
/**
* 현재 사용자의 가상 시간을 조회합니다.
* DTO 캐시를 우선 확인하고, 없으면 DB에서 조회합니다.
*/
async getCurrentTime(userId: string): Promise<number> {
// 1. 서비스 레벨 DTO 캐시에서 가상 시간 DTO 조회 시도
const cacheKey = `virtual_time_dto:${userId}`;
const cachedData = await this.redisCache.get(cacheKey);
if (cachedData) {
try {
const cachedDto = JSON.parse(cachedData) as UserVirtualTime;
this.logger.debug(`[Service] Cache hit for getCurrentTime (user ${userId})`);
return cachedDto.active ? cachedDto.currentTime : Date.now(); // 활성화 상태 확인
} catch (error) {
this.logger.warn(`[Service] Failed to parse cached DTO for user ${userId}`, error);
await this.redisCache.invalidate(cacheKey);
}
}
this.logger.debug(`[Service] Cache miss for getCurrentTime (user ${userId})`);
// 2. 캐시 미스 시 DB에서 엔티티 조회
const virtualTimeEntity = await this.getUserVirtualTimeInternal(userId);
// 3. 활성화 상태에 따라 시간 반환
if (!virtualTimeEntity.active) {
return Date.now(); // 비활성화 시 실제 시간
}
// 4. 조회된 엔티티 기준으로 시간 반환 (캐시는 getUserVirtualTime에서 저장됨)
return virtualTimeEntity.currentTime.getTime();
}
/**
* 사용자의 가상 시간 정보를 조회합니다.
*/
async getUserVirtualTime(userId: string): Promise<VirtualTime> {
let virtualTime = await this.virtualTimeRepository.findByUserId(userId);
// 없으면 새로 생성
if (!virtualTime) {
virtualTime = await this.createVirtualTime(userId);
}
return virtualTime;
}
/**
* 새로운 가상 시간을 생성합니다.
*/
private async createVirtualTime(userId: string): Promise<VirtualTime> {
const now = new Date();
return await this.virtualTimeRepository.create({
userId,
currentTime: now.getTime(),
lastChangeTimestamp: now.getTime(),
changedById: userId,
changeReason: 'Initial creation',
active: false // 기본값은 비활성화
});
}
/**
* 특정 dayIndex로 사용자의 가상 시간을 설정합니다.
*/
async setVirtualTimeByDayIndex(
adminId: string,
userId: string,
targetDayIndex: number,
targetTimezoneId?: string,
reason?: string
): Promise<VirtualTimeChangeResult> { // 반환 타입 수정
this.logger.log(`Setting virtual time for user ${userId} to dayIndex ${targetDayIndex}`, { adminId, targetTimezoneId, reason });
// 1. 테스터 권한 검증
await this.verifyTesterPermission(adminId);
// 2. dayIndex 유효성 검증 (요구사항: 2일차 이상)
if (targetDayIndex < 2) {
this.logger.warn(`Attempt to set virtual time to invalid dayIndex (${targetDayIndex}) for user ${userId}`, { adminId });
throw new CannotSetTimeBeforeDayOneError({ userId, requestedDayIndex: targetDayIndex });
}
// 3. 사용자 주기 정보 조회
let cycleStartDate: Date;
let suspensionHistory: UserCycleSuspensionHistory[];
try {
const cycleInfo = await this.userCycleService.getActiveOrDefaultCycleWithSuspensions(userId);
if (!cycleInfo || !cycleInfo.startDate) {
throw new NotFoundException(`Active or default UserCycle not found for user ${userId}`);
}
cycleStartDate = cycleInfo.startDate;
suspensionHistory = cycleInfo.suspensions || [];
this.logger.debug(`UserCycle info retrieved for user ${userId}`, { startDate: cycleStartDate, suspensions: suspensionHistory.length });
} catch (error) {
this.logger.error(`Failed to get UserCycle info for user ${userId}`, error.stack);
throw new Error(`Failed to get UserCycle info: ${error.message}`);
}
// 4. 사용자 타임존 결정
const finalTimezoneId = targetTimezoneId || await this.getUserDefaultTimezone(userId);
// 5. 목표 날짜 계산
const targetDate = calculateTargetDateFromDayIndex(cycleStartDate, targetDayIndex, suspensionHistory);
if (!targetDate) {
this.logger.error(`Failed to calculate target date for dayIndex ${targetDayIndex}`, {userId, cycleStartDate, suspensionHistory});
throw new Error(`Could not calculate target date for dayIndex ${targetDayIndex}.`);
}
// 6. 목표 날짜 00:00 ZonedDateTime 생성 및 UTC timestamp 계산
const newZonedDateTime = getStartOfDayZonedDateTime(targetDate, finalTimezoneId);
const newTimeMs = toTimestampMs(newZonedDateTime);
// 7. 현재 가상 시간 조회 (DB 또는 캐시)
const currentEntity = await this.virtualTimeRepository.findByUserId(userId);
const previousTimeMs = currentEntity ? Number(currentEntity.virtualTime) : Date.now();
const previousTimezoneId = currentEntity?.timezoneId || await this.getUserDefaultTimezone(userId);
const wasActive = currentEntity?.active || false;
const previousZonedDateTime = createZonedDateTimeWithTimestamp(previousTimeMs, previousTimezoneId);
// 8. 트랜잭션으로 가상 시간 업데이트 및 이력 기록
// 참고: Repository의 saveUserTime 메서드가 upsert 로직 포함 가정
const savedEntity = await this.virtualTimeRepository.saveUserTime({
userId,
virtualTime: newTimeMs,
timezoneId: finalTimezoneId,
active: true,
changedBy: adminId,
reason,
});
// 9. 과거로 이동 시 데이터 롤백 (timestamp 비교)
if (newTimeMs < previousTimeMs) {
this.logger.info(`Rolling back time for user ${userId} due to dayIndex change`);
// DataRollbackService의 메서드명 확인 필요
await this.dataRollbackService.initiateRollback( // 메서드명 예시
adminId,
previousTimeMs,
newTimeMs,
userId,
// targetUserCycleId?
);
}
// 10. 날짜가 변경된 경우 이벤트 발행
let eventId: string | null = null;
if (this.isDateChanged(previousZonedDateTime.date, newZonedDateTime.date)) { // Date 객체 비교
eventId = await this.timeChangeEventPublisher.publishTimeChangeEvent({
userId,
previousDate: previousZonedDateTime.date, // Date 객체 전달
newDate: newZonedDateTime.date,
timestamp: new Date(),
changedById: adminId,
reason,
// previousDayIndex: calculatedPreviousDayIndex,
// newDayIndex: targetDayIndex,
});
}
// 11. 시간 변경 이력 기록 (Repository 사용)
await this.virtualTimeRepository.createTimeChangeHistory({
userId: adminId,
targetUserId: userId,
previousTime: previousTimeMs,
newTime: newTimeMs,
changeType: 'SET_BY_DAY_INDEX',
reason,
eventId,
});
// 12. 캐시 무효화
await this.invalidateCache(userId);
// 13. 결과 반환
return {
userId,
previousZonedDateTime,
currentZonedDateTime: newZonedDateTime,
changeTimestamp: savedEntity.updatedAt.getTime(),
wasActive,
isActive: true,
eventPublished: !!eventId,
};
}
/**
* 실제 시간으로 재설정합니다.
*/
async resetToRealTime(
userId: string,
changedById: string,
reason: string
): Promise<VirtualTime> {
return await this.setVirtualTime(
userId,
new Date(),
changedById,
`실제 시간으로 재설정: ${reason}`
);
}
/**
* 시간 변경 이력을 조회합니다.
*/
async getTimeChangeHistory(
userId: string,
page = 1,
limit = 10,
fromDate?: Date,
toDate?: Date
): Promise<{ items: TimeChangeRecord[], metadata: PageMeta }> {
// 1. 가상 시간 조회
const virtualTime = await this.getUserVirtualTime(userId);
// 2. 조건 객체 생성
const where: Prisma.TimeChangeRecordWhereInput = {
virtualTimeId: virtualTime.id
};
// 3. 날짜 필터 적용
if (fromDate) {
where.timestamp = {
...where.timestamp,
gte: fromDate
};
}
if (toDate) {
where.timestamp = {
...where.timestamp,
lte: toDate
};
}
// 4. 페이지네이션 적용 및 조회
const skip = (page - 1) * limit;
const [items, totalCount] = await Promise.all([
this.virtualTimeRepository.findTimeChangeHistory(
userId,
page,
limit,
fromDate,
toDate
),
this.virtualTimeRepository.countTimeChangeHistory(userId)
]);
// 5. 결과 반환
return {
items,
metadata: {
totalCount,
currentPage: page,
pageSize: limit,
totalPages: Math.ceil(totalCount / limit)
}
};
}
// 헬퍼 메서드들...
private async verifyTesterPermission(userId: string): Promise<void> {
const hasPermission = await this.userService.hasPermission(userId, 'TIMEMACHINE_MANAGE');
if (!hasPermission) {
throw new ForbiddenException('TimeMachine 관리 권한이 없습니다.');
}
}
private async getUserDefaultTimezone(userId: string): Promise<string> {
try {
const user = await this.userService.getUserById(userId);
return user?.timezone || DEFAULT_TIMEZONE;
} catch (e) {
this.logger.warn(`Failed to get timezone for user ${userId}, using default.`, e.stack);
return DEFAULT_TIMEZONE;
}
}
private isDateChanged(previousDate: Date, newDate: Date): boolean {
const prevDay = new Date(Date.UTC(previousDate.getUTCFullYear(), previousDate.getUTCMonth(), previousDate.getUTCDate()));
const newDay = new Date(Date.UTC(newDate.getUTCFullYear(), newDate.getUTCMonth(), newDate.getUTCDate()));
return prevDay.getTime() !== newDay.getTime();
}
private async invalidateCache(userId: string): Promise<void> {
const cacheKey = `virtual_time_dto:${userId}`;
const entityCacheKey = `virtual_time_entity:${userId}`;
try {
await Promise.all([
this.redisCache.invalidate(cacheKey),
this.redisCache.invalidate(entityCacheKey)
]);
this.logger.debug(`Invalidated virtual time caches for user ${userId}`);
} catch (e) {
this.logger.error(`Failed to invalidate virtual time caches for user ${userId}`, e.stack);
}
}
}
4.3 VirtualTimeController 구현
컨트롤러도 dayIndex를 받도록 수정합니다.
import { Controller, Get, Post, Body, Param, Query, UseGuards, Req, ParseIntPipe, NotFoundException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { VirtualTimeService } from '../services/virtual-time.service'; // 경로 수정
import { RolesGuard, Roles } from '@feature/iam'; // 경로 수정
import { AuthGuard } from '@nestjs/passport';
import { UserVirtualTimeDto, SetUserVirtualTimeByDayIndexDto, ResetUserVirtualTimeDto, UserVirtualTimeChangeResultDto, GetAllUserVirtualTimesDto, PaginatedUserVirtualTimesDto, TimeChangeHistoryDto, PaginatedTimeChangeHistoryDto } from '../dtos'; // DTO 경로 수정
import { UserVirtualTimeChangeResult } from '../../domain/types'; // 타입 경로 수정
import { toTimestampMs, formatZonedDateTime } from '@shared/utils'; // 유틸리티
@ApiTags('Time Machine - Virtual Time')
@ApiBearerAuth()
@Controller('time-machine') // 기본 경로 변경 가능
@UseGuards(AuthGuard('jwt'), RolesGuard)
export class VirtualTimeController {
constructor(private virtualTimeService: VirtualTimeService) {}
// getCurrentTime은 UserTimeController에서 처리하는 것이 더 적합할 수 있음
@Post('users/:userId/time/day-index') // 엔드포인트 변경
@Roles('ADMIN', 'TESTER')
@ApiOperation({ summary: '사용자 가상 시간을 특정 Day Index로 설정' })
@ApiBody({ type: SetUserVirtualTimeByDayIndexDto })
@ApiResponse({ status: 200, type: UserVirtualTimeChangeResultDto })
async setVirtualTimeByDayIndex(
@Req() req,
@Param('userId') userId: string,
@Body() body: SetUserVirtualTimeByDayIndexDto,
): Promise<UserVirtualTimeChangeResultDto> {
const adminId = req.user.id;
const result = await this.virtualTimeService.setVirtualTimeByDayIndex(
adminId,
userId,
body.dayIndex,
body.timezoneId,
body.reason,
);
return this.mapToChangeResultDto(result);
}
// Day X, Week X 엔드포인트 제거
// ... (resetToRealTime, getTimeChangeHistory 등 유지 또는 UserTimeController로 이동)
// --- Helper Methods ---
private mapToChangeResultDto(result: UserVirtualTimeChangeResult): UserVirtualTimeChangeResultDto {
// implementation-user-virtual-time.md 와 동일한 DTO 매핑 로직
return {
userId: result.userId,
previousTime: toTimestampMs(result.previousZonedDateTime),
previousTimezoneId: result.previousZonedDateTime.timezoneId,
previousLocalTimeString: formatZonedDateTime(result.previousZonedDateTime, 'yyyy-MM-dd HH:mm:ss'),
currentTime: toTimestampMs(result.currentZonedDateTime),
currentTimezoneId: result.currentZonedDateTime.timezoneId,
currentLocalTimeString: formatZonedDateTime(result.currentZonedDateTime, 'yyyy-MM-dd HH:mm:ss'),
changeTimestamp: result.changeTimestamp,
wasActive: result.wasActive,
isActive: result.isActive,
eventPublished: result.eventPublished,
};
}
}
4.4 DTO 정의
dayIndex를 받는 DTO를 정의합니다.
// 파일 경로: apps/dta-wide-api/src/app/time-machine/dtos/set-user-virtual-time-by-day-index.dto.ts
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SetUserVirtualTimeByDayIndexDto {
@IsInt()
@Min(2) // 요구사항: 2일차 이상
@ApiProperty({ description: '설정할 치료 주기 일차 (2 이상)', example: 10 })
dayIndex: number;
@IsOptional()
@IsString()
@ApiProperty({ description: '설정할 타임존 ID (IANA 형식, 예: Asia/Seoul). 생략 시 사용자 기본 타임존 사용.', required: false, example: 'Europe/Berlin' })
timezoneId?: string;
@IsString()
@ApiProperty({ description: '시간 변경 사유', example: '10일차 테스트 시나리오 실행' })
reason: string;
}
// UserVirtualTimeChangeResultDto 등 다른 DTO 정의는
// implementation-user-virtual-time.md 와 동일하게 유지 또는 dayIndex 관련 정보 추가
5. 캐싱 전략
(기존 내용과 동일 - 사용자 ID 기반 키 사용)
6. 에러 처리
(기존 내용과 동일 - CannotSetTimeBeforeDayOneError 등 사용)
7. 테스트 전략
(기존 내용과 동일 - dayIndex 기반 API 테스트 케이스 추가)
8. 보안 고려사항
(기존 내용과 동일)
9. 성능 최적화
(기존 내용과 동일)