사용자별 가상 시간 구현 가이드 (dayIndex 기반)
개요
본 문서는 TimeMachine의 사용자별 가상 시간 기능 구현에 대한 상세 가이드를 제공합니다. 이 기능을 통해 테스터는 개별 사용자에게 치료 주기 일차(dayIndex) 또는 특정 일시 정지 기간 내 날짜를 기준으로 서로 다른 가상 시간을 설정하여 다양한 시간 조건에서 테스트를 수행할 수 있습니다.
이 구현에서는 @shared/utils의 ZonedDateTime 인터페이스와 관련 유틸리티를 사용하여 타임존을 명시적으로 관리합니다.
또한, 이벤트 발행은 중앙화된 이벤트 시스템 아키텍처 (/architecture/event-system) 가이드라인을 따릅니다.
구현 원칙
사용자별 가상 시간 기능은 다음 원칙에 따라 구현됩니다:
- 독립성 원칙: 각 사용자는 독립적인 가상 시간 컨텍스트를 가집니다. (UTC 시점 + Timezone ID)
- 일관성 원칙: 사용자의 모든 작업은 해당 사용자의 가상 시간 컨텍스트 내에서 일관되게 처리됩니다.
- 격리성 원칙: 한 사용자의 가상 시간 변경이 다른 사용자에게 영향을 주지 않습니다.
- 투명성 원칙: 사용자는 자신의 가상 시간 상태 (시점, 타임존, 현재
dayIndex)를 명확히 확인할 수 있습니다. dayIndex기반 변경 원칙: 시간 변경은 주로 사용자의 치료 주기 진행 상황(dayIndex) 또는 일시 정지 기간을 기준으로 이루어집니다.- 중앙 이벤트 발행 원칙: 시간 변경 관련 이벤트는 로컬
EventEmitter의 단일 채널('event')을 통해 발행되며, 중앙EventBridgeService에 의해 GCP Pub/Sub으로 전달됩니다.
아키텍처 구성
1. 핵심 컴포넌트
사용자별 가상 시간 시스템은 다음 컴포넌트로 구성됩니다:
- 사용자 시간 관리자 (UserTimeManager): 사용자별 가상 시간 및 타임존 관리,
dayIndex기반 시간 설정/계산 로직 포함 - 사용자 시간 저장소 (UserTimeRepository): 사용자별 가상 시간 데이터 (UTC 시점, 타임존 ID,
dayIndex등 메타데이터) 저장 - 사용자 시간 컨텍스트 (UserTimeContext): 요청 처리 시 사용자의
ZonedDateTime컨텍스트 제공 - 사용자 시간 이벤트 발행기 (UserTimeEventPublisher): 사용자 시간 변경 관련 이벤트를 로컬
'event'채널로 발행 (실제 Pub/Sub 발행은EventBridgeService담당) - 시간 유틸리티 (
@shared/utils):ZonedDateTime생성, 포맷팅, 계산 및dayIndex계산 로직 포함 - UserCycle 서비스/쿼리 핸들러: 사용자 주기 시작일, 중단/재개 이력 정보 조회 (외부 도메인 의존성)
- 롤백 관리자 (RollbackManager): 과거 시점(낮은
dayIndex) 이동 시 데이터 롤백 처리 - EventBridgeService (Core/Shared): 로컬
'event'채널을 구독하여 GCP Pub/Sub으로 이벤트를 브릿징 (TimeMachine 모듈 외부)
2. 시스템 흐름도 (dayIndex 기반 변경)
참고: 위 흐름도에서 EventBridge는 TimeMachine 모듈 외부의 코어 컴포넌트를 나타냅니다.
데이터 모델 (Prisma)
VirtualTime 모델 등 TimeMachine 도메인과 관련된 상세 Prisma 스키마 정의는 다음 문서를 참조하세요:
주요 필드:
userId: 사용자 ID (PK)virtualTime: BigInt - 현재 설정된 가상 시간의 UTC 타임스탬프 (ms)timezoneId: String? - 사용자의 타임존 ID (IANA 포맷)active: Boolean - TimeMachine 활성 여부 (관리자가 명시적으로 설정/변경했는지)changedBy: String? - 마지막 변경자 IDreason: String? - 마지막 변경 사유createdAt: DateTimeupdatedAt: DateTimeinitialVirtualTimeStartDate: BigInt? - TimeMachine Access Code로 생성된 사용자의 경우, 가상 생성 시점 (이후 시간 흐름 없음)
참고: dayIndex 자체는 VirtualTime 테이블에 직접 저장하지 않고, 필요시 UserTimeManager 또는 TimeUtils에서 현재 가상 시간(virtualTime), 사용자 타임존(timezoneId), UserCycle 정보를 바탕으로 계산합니다. TimeChangeRecord에는 변경 시점의 dayIndex를 기록할 수 있습니다.
도메인 타입 및 인터페이스 (수정)
ZonedDateTime 및 dayIndex 관련 정보를 반영하도록 관련 도메인 타입을 수정합니다.
// 파일 경로: libs/feature/time-machine/src/lib/domain/types.ts (예시)
import { ZonedDateTime } from '@shared/utils'; // 시간 유틸리티 import
// 표준 이벤트 구조 인터페이스 (event-system.md 참조)
export interface StandardEvent<T> {
type: string; // 실제 이벤트 타입 (예: 'user.time.changed')
eventId: string; // UUID
timestamp: string; // ISO 8601
source: string; // 발행 서비스 이름 (예: 'time-machine')
containerId?: string; // 발행 컨테이너 ID
payload: T; // 실제 이벤트 데이터
// version?: string; // 스키마 버전 (선택적)
}
// 사용자 시간 변경 이벤트 페이로드
export interface UserTimeChangedEventPayload {
adminId: string;
userId: string;
previousZonedDateTime: ZonedDateTime;
previousDayIndex: number;
currentZonedDateTime: ZonedDateTime;
currentDayIndex: number;
reason?: string;
}
// 사용자 시간 리셋 이벤트 페이로드
export interface UserTimeResetEventPayload {
adminId: string;
userId: string;
previousZonedDateTime: ZonedDateTime;
previousDayIndex: number;
currentZonedDateTime: ZonedDateTime; // 리셋된 시간 (실제 시간 기준)
currentDayIndex: number; // 리셋된 시간의 dayIndex
reason?: string;
}
// UserVirtualTime 인터페이스 (변경 없음)
export interface UserVirtualTime {
userId: string;
userName?: string; // 목록 조회 시 사용될 수 있음
zonedDateTime: ZonedDateTime; // 현재 가상 시간 (UTC 시점 + 타임존)
currentDayIndex: number; // 현재 가상 시간에 해당하는 dayIndex
active: boolean; // TimeMachine 활성 여부
lastChangeTimestamp: number; // 마지막 변경 시점 (UTC timestamp ms)
changedById?: string;
}
// dayIndex 기반 변경 결과 (변경 없음)
export interface UserVirtualTimeChangeResultByDayIndex {
userId: string;
previousZonedDateTime: ZonedDateTime; // 이전 가상 시간
previousDayIndex: number; // 이전 dayIndex
currentZonedDateTime: ZonedDateTime; // 현재 가상 시간
currentDayIndex: number; // 현재 dayIndex
changeTimestamp: number; // 변경 작업 시점 (UTC timestamp ms)
wasActive: boolean;
isActive: boolean;
rollbackTriggered: boolean; // 롤백 발생 여부
eventPublished: boolean; // 이벤트 발행 시도 여부 (실제 발행은 비동기)
}
// 시간 리셋 결과 (변경 없음)
export interface UserVirtualTimeResetResult {
userId: string;
previousZonedDateTime: ZonedDateTime;
previousDayIndex: number;
currentZonedDateTime: ZonedDateTime; // 실제 시간 기준 ZonedDateTime
currentDayIndex: number; // 실제 시간 기준 dayIndex
changeTimestamp: number;
wasActive: boolean;
isActive: boolean; // 항상 false
dataCleaned: boolean; // 비활성화 시 데이터 정리 여부
eventPublished: boolean; // 이벤트 발행 시도 여부
}
// 가상 시간 업데이트 입력 (Manager 내부용) (변경 없음)
export interface VirtualTimeUpdateInput {
userId: string;
virtualTime: number; // UTC timestamp ms (계산된 결과)
timezoneId: string;
active: boolean;
changedBy: string;
reason?: string;
}
// 시간 변경 이력 생성 입력 (dayIndex 포함) (변경 없음)
export interface TimeChangeHistoryCreateInput {
userId: string; // 변경 수행자 ID
targetUserId: string; // 변경 대상 사용자 ID
previousTime: number; // UTC timestamp ms
newTime: number; // UTC timestamp ms
previousDayIndex?: number; // 변경 전 dayIndex (선택적)
newDayIndex?: number; // 변경 후 dayIndex (선택적)
changeType: TimeChangeType; // 'SET_BY_DAY_INDEX', 'SET_BY_SUSPENSION', 'RESET' 등
reason?: string;
eventId?: string; // 발행된 이벤트 ID (선택적)
}
export enum TimeChangeType {
SET_BY_DAY_INDEX = 'SET_BY_DAY_INDEX',
SET_BY_SUSPENSION = 'SET_BY_SUSPENSION',
RESET = 'RESET',
INITIAL = 'INITIAL', // TimeMachine 생성 시 초기 설정
}
// ... 기타 필요한 타입 정의 (Paginated 결과 등) ...
구현 가이드
1. 사용자 시간 관리자 구현 (UserTimeManager)
dayIndex 기반 시간 설정 로직, UserCycle 연동, ZonedDateTime 사용 등을 포함합니다. 이벤트 발행 시 UserTimeEventPublisher를 호출합니다.
// 파일 경로: libs/feature/time-machine/src/lib/application/services/user-time.manager.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { UserTimeRepository } from '../../infrastructure/repositories/user-time.repository';
import { UserTimeEventPublisher } from '../events/user-time-event.publisher';
import { RollbackManager } from './rollback.manager';
import { LoggerService } from '@core/logging';
import {
UserVirtualTime,
UserVirtualTimeChangeResultByDayIndex,
UserVirtualTimeResetResult,
VirtualTimeUpdateInput,
TimeChangeType,
} from '../../domain/types';
import {
createZonedDateTimeWithTimestamp,
ZonedDateTime,
nowZonedDateTime,
DEFAULT_TIMEZONE,
toTimestampMs,
calculateDayIndex, // dayIndex 계산 유틸리티
calculateDateFromDayIndex, // dayIndex -> 날짜 계산 유틸리티
calculateDateFromSuspension, // 일시 정지 기간 -> 날짜 계산 유틸리티
setTimeToStartOfDay, // 날짜의 시작 시각으로 설정
} from '@shared/utils';
import { QueryBus } from '@nestjs/cqrs';
import { GetUserTimezoneQuery, GetUserTimezoneDto } from '@feature/user';
import { GetUserCycleInfoQuery, UserCycleInfoDto } from '@feature/user-cycle'; // 가칭
@Injectable()
export class UserTimeManager {
constructor(
private readonly userTimeRepository: UserTimeRepository,
private readonly queryBus: QueryBus,
private readonly userTimeEventPublisher: UserTimeEventPublisher,
private readonly rollbackManager: RollbackManager,
private readonly logger: LoggerService,
) {
this.logger.setContext(UserTimeManager.name);
}
/**
* 사용자의 현재 가상 시간 정보 (ZonedDateTime 및 dayIndex 포함)를 조회합니다.
* TimeMachine 활성 여부, 생성 방식 등을 고려하여 실제 시간 또는 가상 시간을 반환합니다.
*/
async getUserVirtualTime(userId: string): Promise<UserVirtualTime> {
this.logger.debug(`Getting virtual time for user ${userId}`);
const entity = await this.userTimeRepository.findByUserId(userId);
let zonedDateTime: ZonedDateTime;
let isActive = false;
let lastChangeTimestamp: number = Date.now();
let changedById: string | null = null;
if (!entity) {
// 레코드가 없으면 실제 시간 기준 기본값 생성
this.logger.info(`No virtual time record for user ${userId}, returning default (real time).`);
const userTimezone = await this.getUserDefaultTimezone(userId);
zonedDateTime = nowZonedDateTime(userTimezone);
// 실제 시간 기준 dayIndex 계산 필요 (UserCycle 정보 조회)
// const userCycleInfo = await this.getUserCycleInfo(userId);
// const currentDayIndex = calculateDayIndex(zonedDateTime, userCycleInfo);
// 임시로 0 또는 에러 처리
const currentDayIndex = 0;
return { userId, zonedDateTime, currentDayIndex, active: false, lastChangeTimestamp, changedById: 'system' };
// 또는 createDefaultUserTime 호출 (DB 저장 포함 시)
}
// 1. 관리자가 명시적으로 설정하고 활성화한 경우
if (entity.active) {
const timezoneId = entity.timezoneId || await this.getUserDefaultTimezone(userId);
zonedDateTime = createZonedDateTimeWithTimestamp(Number(entity.virtualTime), timezoneId);
isActive = true;
this.logger.debug(`Using admin-set active virtual time for user ${userId}`);
// 2. TimeMachine으로 생성되었고 관리자 설정이 없는 경우 (시간 고정)
} else if (entity.initialVirtualTimeStartDate) {
const timezoneId = entity.timezoneId || await this.getUserDefaultTimezone(userId);
zonedDateTime = createZonedDateTimeWithTimestamp(Number(entity.initialVirtualTimeStartDate), timezoneId);
isActive = true; // 가상 시간 컨텍스트 활성
this.logger.debug(`Using fixed virtual time from TimeMachine creation for user ${userId}`);
// 3. 그 외 (리셋되었거나 비활성화된 경우 - 실제 시간)
} else {
const userTimezone = entity.timezoneId || await this.getUserDefaultTimezone(userId);
zonedDateTime = nowZonedDateTime(userTimezone);
isActive = false;
this.logger.debug(`Using real time for user ${userId}`);
}
lastChangeTimestamp = entity.updatedAt.getTime();
changedById = entity.changedBy;
// 현재 가상 시간 기준 dayIndex 계산
let currentDayIndex = 0;
if (isActive) {
try {
const userCycleInfo = await this.getUserCycleInfo(userId);
currentDayIndex = calculateDayIndex(zonedDateTime, userCycleInfo);
} catch (error) {
this.logger.warn(`Failed to calculate dayIndex for user ${userId}`, { error: error.message });
}
} else {
// 비활성(실제 시간) 상태의 dayIndex도 계산 가능하면 추가
try {
const userCycleInfo = await this.getUserCycleInfo(userId);
currentDayIndex = calculateDayIndex(zonedDateTime, userCycleInfo);
} catch(error) {}
}
return {
userId,
zonedDateTime,
currentDayIndex,
active: isActive,
lastChangeTimestamp,
changedById,
};
}
/**
* 사용자의 가상 시간을 특정 dayIndex 기준으로 설정합니다.
*/
async setVirtualTimeByDayIndex(
adminId: string,
userId: string,
targetDayIndex: number,
reason?: string,
): Promise<UserVirtualTimeChangeResultByDayIndex> {
this.logger.log(`Setting virtual time by dayIndex for user ${userId}`, { adminId, targetDayIndex, reason });
if (targetDayIndex < 2) {
throw new Error('dayIndex는 2 이상이어야 합니다.'); // 또는 BadRequestException
}
// 1. 현재 상태 조회
const currentVirtualTimeInfo = await this.getUserVirtualTime(userId); // ZonedDateTime, currentDayIndex 포함
const previousZonedDateTime = currentVirtualTimeInfo.zonedDateTime;
const previousDayIndex = currentVirtualTimeInfo.currentDayIndex;
const wasActive = currentVirtualTimeInfo.active;
// 2. 사용자 주기 정보 조회
const userCycleInfo = await this.getUserCycleInfo(userId);
// 3. 목표 날짜 계산 (dayIndex -> ZonedDateTime)
const targetZonedDateTimeRaw = calculateDateFromDayIndex(targetDayIndex, userCycleInfo);
// 계산된 날짜의 시작 시각으로 설정 (사용자 타임존 기준)
const targetZonedDateTime = setTimeToStartOfDay(targetZonedDateTimeRaw);
const newTimeMs = toTimestampMs(targetZonedDateTime);
const newTimezoneId = targetZonedDateTime.timezoneId; // 계산 결과의 타임존 사용
// 4. 과거 시간 이동 여부 확인 및 롤백 처리
let rollbackTriggered = false;
if (newTimeMs < toTimestampMs(previousZonedDateTime)) {
this.logger.info(`Rolling back time for user ${userId} due to dayIndex change`);
rollbackTriggered = true;
// 비동기 처리 고려
await this.rollbackManager.handleTimeTravel(
adminId,
toTimestampMs(previousZonedDateTime),
newTimeMs,
userId,
);
}
// 5. DB 업데이트
const updateInput: VirtualTimeUpdateInput = {
userId,
virtualTime: newTimeMs,
timezoneId: newTimezoneId,
active: true, // TimeMachine 활성화
changedBy: adminId,
reason,
};
const savedEntity = await this.userTimeRepository.saveUserTime(updateInput);
// 6. 이벤트 발행 (UserTimeEventPublisher 호출)
const eventId = await this.userTimeEventPublisher.publishUserTimeChangedEvent({
adminId,
userId,
previousZonedDateTime,
previousDayIndex,
currentZonedDateTime: targetZonedDateTime, // 변경된 시간 전달
currentDayIndex: targetDayIndex, // 목표 dayIndex 전달 (계산 결과 대신)
reason,
});
// 7. 시간 변경 이력 기록 (dayIndex 포함)
await this.userTimeRepository.saveTimeChangeHistory({
userId: adminId,
targetUserId: userId,
previousTime: toTimestampMs(previousZonedDateTime),
newTime: newTimeMs,
previousDayIndex: previousDayIndex,
newDayIndex: targetDayIndex, // 목표 dayIndex 저장
changeType: TimeChangeType.SET_BY_DAY_INDEX,
reason,
eventId, // 발행된 이벤트 ID 기록 (선택적)
});
return {
userId,
previousZonedDateTime,
previousDayIndex,
currentZonedDateTime: targetZonedDateTime,
currentDayIndex: targetDayIndex, // 목표 dayIndex 반환
changeTimestamp: savedEntity.updatedAt.getTime(),
wasActive,
isActive: true,
rollbackTriggered,
eventPublished: !!eventId, // 이벤트 발행 시도 여부
};
}
/**
* 사용자의 가상 시간을 특정 일시 정지 기간 내 날짜 기준으로 설정합니다.
* (setVirtualTimeByDayIndex 와 유사하게 구현)
*/
async setVirtualTimeBySuspensionDay(
adminId: string,
userId: string,
suspensionSequence: number,
dayWithinSuspension: number,
reason?: string,
): Promise<UserVirtualTimeChangeResultByDayIndex> { // 반환 타입 재사용 또는 별도 정의
this.logger.log(`Setting virtual time by suspension day for user ${userId}`, { adminId, suspensionSequence, dayWithinSuspension, reason });
// 1. 현재 상태 조회
const currentVirtualTimeInfo = await this.getUserVirtualTime(userId);
const previousZonedDateTime = currentVirtualTimeInfo.zonedDateTime;
const previousDayIndex = currentVirtualTimeInfo.currentDayIndex; // 이전 dayIndex도 필요 시 계산
const wasActive = currentVirtualTimeInfo.active;
// 2. 사용자 주기 정보 조회 (정지 이력 포함)
const userCycleInfo = await this.getUserCycleInfo(userId);
// 3. 목표 날짜 계산 (정지 기간 정보 -> ZonedDateTime)
const targetZonedDateTimeRaw = calculateDateFromSuspension(suspensionSequence, dayWithinSuspension, userCycleInfo);
const targetZonedDateTime = setTimeToStartOfDay(targetZonedDateTimeRaw);
const newTimeMs = toTimestampMs(targetZonedDateTime);
const newTimezoneId = targetZonedDateTime.timezoneId;
// 4. 현재 dayIndex 계산 (목표 시간에 해당하는)
const currentDayIndex = calculateDayIndex(targetZonedDateTime, userCycleInfo);
// 5. 과거 시간 이동 여부 확인 및 롤백 처리
let rollbackTriggered = false;
if (newTimeMs < toTimestampMs(previousZonedDateTime)) {
this.logger.info(`Rolling back time for user ${userId} due to suspension day change`);
rollbackTriggered = true;
await this.rollbackManager.handleTimeTravel(
adminId,
toTimestampMs(previousZonedDateTime),
newTimeMs,
userId,
);
}
// 6. DB 업데이트
const updateInput: VirtualTimeUpdateInput = { /* ... */ };
const savedEntity = await this.userTimeRepository.saveUserTime(updateInput);
// 7. 이벤트 발행
const eventId = await this.userTimeEventPublisher.publishUserTimeChangedEvent({
adminId, userId, previousZonedDateTime, previousDayIndex,
currentZonedDateTime: targetZonedDateTime, currentDayIndex, reason,
});
// 8. 시간 변경 이력 기록
await this.userTimeRepository.saveTimeChangeHistory({
/* ... */
changeType: TimeChangeType.SET_BY_SUSPENSION,
/* ... */
});
// 9. 결과 반환
return { /* ... */ };
}
/**
* 사용자의 가상 시간을 실제 시간으로 리셋하고 TimeMachine을 비활성화합니다.
* 비활성화 시점은 사용자의 Timezone 기준 현재 실제 날짜의 00:00 입니다.
* 재설정된 시간 이후의 데이터는 삭제됩니다.
*/
async resetUserVirtualTime(
adminId: string,
userId: string,
reason?: string,
): Promise<UserVirtualTimeResetResult> {
this.logger.log(`Resetting virtual time for user ${userId}`, { adminId, reason });
// 1. 현재 상태 조회
const currentVirtualTimeInfo = await this.getUserVirtualTime(userId);
const previousZonedDateTime = currentVirtualTimeInfo.zonedDateTime;
const previousDayIndex = currentVirtualTimeInfo.currentDayIndex;
const wasActive = currentVirtualTimeInfo.active;
if (!wasActive) {
this.logger.info(`Virtual time already inactive for user ${userId}. No reset needed.`);
// 이미 비활성이면 현재 실제 시간 정보 반환
const currentRealZoned = nowZonedDateTime(await this.getUserDefaultTimezone(userId));
const currentRealDayIndex = calculateDayIndex(currentRealZoned, await this.getUserCycleInfo(userId)); // 실제 시간 기준 dayIndex
return {
userId,
previousZonedDateTime,
previousDayIndex,
currentZonedDateTime: currentRealZoned,
currentDayIndex: currentRealDayIndex,
changeTimestamp: Date.now(),
wasActive: false,
isActive: false,
dataCleaned: false,
eventPublished: false,
};
}
// 2. 목표 시간 계산: 현재 실제 날짜의 00:00 (사용자 타임존 기준)
const userTimezone = await this.getUserDefaultTimezone(userId);
const nowRealZoned = nowZonedDateTime(userTimezone);
const targetZonedDateTime = setTimeToStartOfDay(nowRealZoned); // 오늘 날짜의 00:00
const newTimeMs = toTimestampMs(targetZonedDateTime);
// 3. 리셋된 시간 기준 dayIndex 계산
const userCycleInfo = await this.getUserCycleInfo(userId);
const currentDayIndex = calculateDayIndex(targetZonedDateTime, userCycleInfo);
// 4. 데이터 정리 (롤백 메커니즘 활용) - 리셋 시간 이후 데이터 삭제
this.logger.info(`Cleaning future data for user ${userId} after reset`);
// RollbackManager에 비활성화 시 데이터 정리 기능 필요
const dataCleaned = await this.rollbackManager.handleTimeMachineDisable(
adminId,
toTimestampMs(previousZonedDateTime), // 이전 가상 시간
newTimeMs, // 리셋된 시간 (이후 데이터 삭제)
userId,
);
// 5. DB 업데이트 (active=false, 시간은 리셋된 시간)
const updateInput: VirtualTimeUpdateInput = {
userId,
virtualTime: newTimeMs,
timezoneId: userTimezone,
active: false, // 비활성화
changedBy: adminId,
reason: reason || '타임머신 리셋',
};
const savedEntity = await this.userTimeRepository.saveUserTime(updateInput);
// 6. 이벤트 발행 (UserTimeEventPublisher 호출)
const eventId = await this.userTimeEventPublisher.publishUserTimeResetEvent({
adminId,
userId,
previousZonedDateTime,
previousDayIndex,
currentZonedDateTime: targetZonedDateTime, // 리셋된 시간의 ZonedDateTime
currentDayIndex: currentDayIndex, // 리셋된 시간의 dayIndex
reason,
});
// 7. 시간 변경 이력 기록
await this.userTimeRepository.saveTimeChangeHistory({
userId: adminId,
targetUserId: userId,
previousTime: toTimestampMs(previousZonedDateTime),
newTime: newTimeMs,
reason: reason || '타임머신 리셋',
eventId,
changeType: TimeChangeType.RESET, // 변경 유형 추가
});
return {
userId,
previousZonedDateTime,
previousDayIndex,
currentZonedDateTime: targetZonedDateTime,
currentDayIndex: currentDayIndex,
changeTimestamp: savedEntity.updatedAt.getTime(),
wasActive: true,
isActive: false,
dataCleaned,
eventPublished: !!eventId,
};
}
/**
* 모든 사용자의 가상 시간 목록 조회 (페이지네이션)
*/
async getAllUserVirtualTimes(
options: { page: number; pageSize: number; active?: boolean; }
): Promise<PaginatedResult<UserVirtualTime>> { // 반환 타입 수정
this.logger.debug(`Getting all user virtual times`, { options });
const { items, totalCount } = await this.userTimeRepository.findAll(options);
const userTimes: UserVirtualTime[] = await Promise.all(items.map(async (item) => {
const timezoneId = item.timezoneId || await this.getUserDefaultTimezone(item.userId);
const zonedDateTime = createZonedDateTimeWithTimestamp(Number(item.virtualTime), timezoneId);
let currentDayIndex = 0;
if (item.active || item.initialVirtualTimeStartDate) { // 활성 또는 TimeMachine 생성 사용자
try {
const userCycleInfo = await this.getUserCycleInfo(item.userId);
currentDayIndex = calculateDayIndex(zonedDateTime, userCycleInfo);
} catch (error) {
this.logger.warn(`Failed to calculate dayIndex for user ${item.userId} in list`, { error: error.message });
}
} else {
// 비활성 사용자 dayIndex (실제 시간 기준)
try {
const userCycleInfo = await this.getUserCycleInfo(item.userId);
currentDayIndex = calculateDayIndex(nowZonedDateTime(timezoneId), userCycleInfo);
} catch(error) {}
}
return {
userId: item.userId,
userName: item.userName,
zonedDateTime,
currentDayIndex,
active: item.active || !!item.initialVirtualTimeStartDate, // 최종 활성 상태
lastChangeTimestamp: item.updatedAt.getTime(),
changedById: item.changedBy,
};
}));
return { items: userTimes, metadata: { totalCount, currentPage: options.page, pageSize: options.pageSize, totalPages: Math.ceil(totalCount / options.pageSize) } };
}
// --- Helper Methods ---
// 사용자의 기본 타임존 조회
private async getUserDefaultTimezone(userId: string): Promise<string> {
this.logger.debug(`Fetching default timezone for user ${userId}`);
try {
const query = new GetUserTimezoneQuery(userId, 'system', true);
const result = await this.queryBus.execute<GetUserTimezoneQuery, GetUserTimezoneDto | null>(query);
const timezone = result?.timezone;
if (timezone && Intl.supportedValuesOf('timeZone').includes(timezone)) return timezone;
return DEFAULT_TIMEZONE;
} catch (error) {
this.logger.warn(`Error getting default timezone`, { userId, error: error.message });
return DEFAULT_TIMEZONE;
}
}
// 사용자 주기 정보 조회 (QueryBus 사용)
private async getUserCycleInfo(userId: string): Promise<UserCycleInfoDto> {
this.logger.debug(`Fetching user cycle info for user ${userId}`);
try {
const query = new GetUserCycleInfoQuery(userId, 'system');
const result = await this.queryBus.execute<GetUserCycleInfoQuery, UserCycleInfoDto | null>(query);
if (!result || !result.startDate) throw new Error('User cycle info or start date missing');
return result;
} catch (error) {
this.logger.warn(`Error getting user cycle info`, { userId, error: error.message });
// 기본값 반환 또는 에러 throw
throw new Error(`Cannot calculate dayIndex without UserCycle info: ${error.message}`);
}
}
// 기본 가상 시간 생성 (필요시)
private async createDefaultUserTime(userId: string): Promise<UserVirtualTime> {
// 구현 생략 (getUserVirtualTime 에서 처리)
throw new Error('Not implemented: createDefaultUserTime');
}
}
2. 사용자 시간 이벤트 발행기 구현 (UserTimeEventPublisher)
EventEmitter2를 사용하여 로컬 'event' 채널로 표준 형식의 이벤트를 발행합니다. GCP Pub/Sub 클라이언트는 직접 사용하지 않습니다.
// 파일 경로: libs/feature/time-machine/src/lib/application/events/user-time-event.publisher.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { LoggerService } from '@core/logging'; // 경로 확인
import { StandardEvent, UserTimeChangedEventPayload, UserTimeResetEventPayload } from '../../domain/types';
import { v4 as uuidv4 } from 'uuid';
import * as os from 'os';
@Injectable()
export class UserTimeEventPublisher {
private readonly source = process.env.SERVICE_NAME || 'time-machine';
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: LoggerService,
) {
this.logger.setContext(UserTimeEventPublisher.name);
}
async publishUserTimeChangedEvent(payload: UserTimeChangedEventPayload): Promise<string> {
const eventId = uuidv4();
const eventType = 'user.time.changed'; // 페이로드 내 타입 정의
const event: StandardEvent<UserTimeChangedEventPayload> = {
type: eventType,
eventId: eventId,
timestamp: new Date().toISOString(),
source: this.source,
containerId: os.hostname(),
payload: payload, // 실제 데이터를 payload 안에 넣음
};
try {
// 로컬 'event' 채널로 표준 이벤트 객체 발행
this.eventEmitter.emit('event', event);
this.logger.log(`Published local event '${eventType}' for user ${payload.userId}`, {
eventId: eventId,
source: this.source,
targetDayIndex: payload.currentDayIndex,
});
return eventId;
} catch (error) {
this.logger.error(`Error publishing local event '${eventType}': ${error.message}`, {
error: error.stack,
userId: payload.userId,
});
throw error; // 에러를 다시 던져 상위에서 처리하도록 할 수 있음
}
}
async publishUserTimeResetEvent(payload: UserTimeResetEventPayload): Promise<string> {
const eventId = uuidv4();
const eventType = 'user.time.reset'; // 페이로드 내 타입 정의
const event: StandardEvent<UserTimeResetEventPayload> = {
type: eventType,
eventId: eventId,
timestamp: new Date().toISOString(),
source: this.source,
containerId: os.hostname(),
payload: payload,
};
try {
// 로컬 'event' 채널로 표준 이벤트 객체 발행
this.eventEmitter.emit('event', event);
this.logger.log(`Published local event '${eventType}' for user ${payload.userId}`, {
eventId: eventId,
source: this.source,
targetDayIndex: payload.currentDayIndex,
});
return eventId;
} catch (error) {
this.logger.error(`Error publishing local event '${eventType}': ${error.message}`, {
error: error.stack,
userId: payload.userId,
});
throw error;
}
}
}
설명:
UserTimeEventPublisher는 이제EventEmitter2만 주입받습니다.publish...메서드들은 표준 이벤트 객체(StandardEvent)를 생성합니다.- 실제 이벤트 타입(
user.time.changed등)은type필드에 들어갑니다. - 이벤트 데이터는
payload필드 안에 캡슐화됩니다. eventId,timestamp,source등 표준 메타데이터가 포함됩니다.
- 실제 이벤트 타입(
- 생성된 이벤트 객체는
this.eventEmitter.emit('event', event)를 통해 로컬'event'채널로 발행됩니다. - 실제 GCP Pub/Sub 발행은 이 로컬 이벤트를 구독하는
EventBridgeService(Core 또는 Shared 모듈)가 담당합니다.
3. 사용자 시간 저장소 구현 (UserTimeRepository)
(기존 내용과 동일 - 이벤트 발행 로직 변경과 직접적인 관련 없음)
4. 매퍼 구현 (UserTimeMapper, TimeChangeHistoryMapper)
(기존 내용과 동일 - 이벤트 발행 로직 변경과 직접적인 관련 없음)
5. 사용자 시간 컨텍스트 구현 (UserTimeContext)
(기존 내용과 동일 - 이벤트 발행 로직 변경과 직접적인 관련 없음)
6. 사용자 시간 미들웨어 구현 (UserTimeMiddleware)
(기존 내용과 동일 - 이벤트 발행 로직 변경과 직접적인 관련 없음)
7. API 컨트롤러 구현 (UserTimeController)
(기존 내용과 동일 - UserTimeManager의 인터페이스 변경이 없으므로 컨트롤러 변경 불필요)
8. 시간 유틸리티 활용 강조
(기존 내용과 동일)
9. TimeMachine 모듈 설정 (time-machine.module.ts)
UserTimeEventPublisher를 providers에 포함하고, PubSubService 직접 주입 부분을 제거합니다 (만약 있었다면). EventEmitterModule.forRoot()가 import 되어 있는지 확인합니다.
// 파일 경로: libs/feature/time-machine/src/lib/time-machine.module.ts (일부)
import { Module, Logger } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter'; // EventEmitter 모듈 import 확인
// ... 다른 import ...
import { UserTimeEventPublisher } from './application/events/user-time-event.publisher'; // 발행기 import
// ... 다른 컴포넌트 import ...
@Module({
imports: [
// ...
EventEmitterModule.forRoot(), // 설정 확인
// ...
],
providers: [
// ...
UserTimeEventPublisher, // 발행기 provider 등록
// ...
],
// ...
})
export class TimeMachineModule {}