본문으로 건너뛰기

데이터 롤백 구현 가이드

개요

본 문서는 TimeMachine의 데이터 롤백 기능 구현에 대한 상세 가이드를 제공합니다. 가상 시간이 과거로 변경될 때 (낮은 dayIndex 지정으로 인해 과거 시점으로 설정될 때) 시스템 전반의 데이터 일관성을 유지하는 방법을 설명합니다.

주의: 데이터 롤백은 해당 시점 이후의 데이터를 영구적으로 삭제하는 것을 의미합니다. 별도의 백업이나 복구 기능은 제공되지 않습니다.

구현 원칙

데이터 롤백 기능은 다음 원칙에 따라 구현됩니다:

  1. 도메인 책임 원칙: 각 도메인은 자신이 생성한 데이터의 영구 삭제 롤백에 대한 책임을 가짐
  2. 이벤트 기반 롤백: TimeMachine은 롤백 이벤트(rollback.initiated, timestamp 및 선택적 dayIndex 정보 포함)만 발행하고, 각 도메인이 해당 이벤트에 반응하여 데이터 영구 삭제 롤백 수행
  3. 분산 처리 원칙: GCP Pub/Sub을 사용하여 스케일 아웃 환경에서도 일관된 롤백 처리 보장
  4. 롤백 추적 원칙: 각 도메인의 롤백 상태를 종합적으로 추적하여 전체 롤백 상태 관리

아키텍처 구성

1. 핵심 컴포넌트

데이터 롤백 시스템은 다음 컴포넌트로 구성됩니다:

  1. 롤백 코디네이터 (RollbackCoordinator): 롤백 프로세스 시작, 전체 상태 추적, 및 로컬 이벤트를 통해 각 도메인의 롤백 상태 수집
  2. 롤백 이력 저장소 (RollbackHistoryRepository): 롤백 이력 저장 및 조회
  3. 롤백 이벤트 발행기 (RollbackEventPublisher): 중앙화된 이벤트 시스템을 통해 롤백 시작 이벤트 발행 (rollback.initiated)
  4. 도메인별 롤백 처리기 (DomainRollbackHandlers): 각 도메인에서 로컬 rollback.initiated 이벤트를 수신하여 롤백 처리 로직을 수행하고, 로컬 rollback.status 이벤트를 발행하여 상태 보고

참고: 중앙화된 이벤트 시스템 아키텍처(/architecture/event-system 참조)에서는 별도의 RollbackStatusCollector 컴포넌트 대신 RollbackCoordinator가 로컬 이벤트 리스너를 통해 상태를 수집합니다.

2. 시스템 흐름도

흐름 설명: 위 다이어그램은 중앙화된 이벤트 시스템을 반영하며 subgraph 대신 참여자 정의와 노트를 사용합니다. 롤백 코디네이터가 로컬로 rollback.initiated 이벤트를 발행하면, 이벤트 브릿지를 통해 Pub/Sub 중앙 토픽으로 전달됩니다. 각 서비스의 중앙 이벤트 수신 컨트롤러가 이 이벤트를 받아 로컬로 재발행하면, 해당 도메인의 도메인 롤백 핸들러가 이벤트를 처리합니다. 핸들러는 상태를 업데이트할 때도 로컬로 rollback.status 이벤트를 발행하고, 이는 동일한 경로를 통해 최종적으로 롤백 코디네이터에 도달하여 상태를 업데이트합니다.

구현 가이드

1. 롤백 코디네이터 구현

롤백 코디네이터는 롤백 프로세스를 시작하고 전체 상태를 추적하는 서비스입니다:

import { Injectable, Logger, InternalServerErrorException } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { RollbackHistoryRepository } from './rollback-history.repository';
import { RollbackEventPublisher } from './rollback-event.publisher';
import { RollbackInitiatedEventPayload } from './interfaces/rollback-event.interface';

@Injectable()
export class RollbackCoordinator {
constructor(
private readonly rollbackHistoryRepository: RollbackHistoryRepository,
private readonly rollbackEventPublisher: RollbackEventPublisher,
private readonly logger: Logger,
) {
this.logger = new Logger(RollbackCoordinator.name);
}

async handleTimeTravel(
userId: string,
previousTime: number,
newTime: number,
targetUserId?: string,
targetUserCycleId?: string,
previousDayIndex?: number,
targetDayIndex?: number,
): Promise<RollbackResult> {
const needsRollback = newTime < previousTime;

if (!needsRollback) {
return { status: 'NO_ROLLBACK_NEEDED' };
}

try {
const rollbackId = await this.createRollbackHistory(
userId,
previousTime,
newTime,
targetUserId,
targetUserCycleId,
previousDayIndex,
targetDayIndex,
);

const affectedDomains = this.determineAffectedDomains(previousTime, newTime, targetUserCycleId);

await this.rollbackHistoryRepository.update(rollbackId, {
status: 'INITIATED',
affectedDomains,
});

const eventPayload: RollbackInitiatedEventPayload = {
rollbackId,
userId: targetUserId || userId,
triggeringUserId: userId,
userCycleId: targetUserCycleId,
previousTime,
newTime,
affectedDomains,
previousDayIndex,
targetDayIndex,
};
await this.rollbackEventPublisher.publishRollbackEvent(eventPayload);

this.logger.log(`Rollback initiated: ${rollbackId} for user ${targetUserId || userId}${targetUserCycleId ? ' cycle ' + targetUserCycleId : ''} (Day ${previousDayIndex || '?'} -> ${targetDayIndex || '?'}) affecting domains: ${affectedDomains.join(', ')}`);

return {
status: 'ROLLBACK_INITIATED',
rollbackId,
affectedDomains,
};
} catch (error) {
this.logger.error(`Error during rollback initiation: ${error.message}`, error.stack);
throw new InternalServerErrorException('롤백 초기화 중 오류가 발생했습니다.');
}
}

@OnEvent('rollback.status', { async: true })
async handleRollbackStatusUpdate(eventPayloadContainer: { payload: DomainRollbackStatus }): Promise<void> {
const domainStatus = eventPayloadContainer.payload;
const rollbackId = domainStatus.rollbackId;

this.logger.log(`Received rollback status update for ${rollbackId} from domain ${domainStatus.domain}: ${domainStatus.status}`, {
targetDayIndex: domainStatus.targetDayIndex
});

try {
const rollback = await this.rollbackHistoryRepository.findById(rollbackId);
if (!rollback) {
this.logger.error(`Rollback ID not found: ${rollbackId}`);
return;
}

const domainStatuses = rollback.domainStatuses || {};
domainStatuses[domainStatus.domain] = {
status: domainStatus.status,
completedAt: domainStatus.status === 'COMPLETED' ? new Date() : null,
errorMessage: domainStatus.errorMessage,
deletedRecordCount: domainStatus.deletedRecordCount,
};

await this.rollbackHistoryRepository.update(rollbackId, {
domainStatuses,
});

await this.checkRollbackCompletion(rollbackId, rollback.affectedDomains, domainStatuses);

} catch (error) {
this.logger.error(`Error processing rollback status update for ${rollbackId}: ${error.message}`, {
error: error.stack,
domainStatus
});
}
}

private async checkRollbackCompletion(
rollbackId: string,
affectedDomains: string[],
domainStatuses: Record<string, DomainStatus>,
): Promise<void> {
const allDomainsReported = affectedDomains.every(domain =>
domainStatuses[domain] && ['COMPLETED', 'FAILED'].includes(domainStatuses[domain].status)
);

if (!allDomainsReported) {
return;
}

const failedDomains = affectedDomains.filter(domain =>
domainStatuses[domain] && domainStatuses[domain].status === 'FAILED'
);

const overallStatus = failedDomains.length > 0 ? 'PARTIALLY_COMPLETED' : 'COMPLETED';

const aggregatedDeletedCounts = affectedDomains.reduce((counts, domain) => {
if (domainStatuses[domain] && domainStatuses[domain].deletedRecordCount) {
counts[domain] = domainStatuses[domain].deletedRecordCount;
}
return counts;
}, {} as Record<string, number>);

await this.rollbackHistoryRepository.update(rollbackId, {
status: overallStatus,
deletedRecordCounts: aggregatedDeletedCounts,
completedAt: new Date(),
});

this.logger.log(`Rollback ${rollbackId} completed with status: ${overallStatus}`);
}

private async createRollbackHistory(
userId: string,
previousTime: number,
newTime: number,
targetUserId?: string,
targetUserCycleId?: string,
previousDayIndex?: number,
targetDayIndex?: number,
): Promise<string> {
const affectedDomains = this.determineAffectedDomains(previousTime, newTime, targetUserCycleId);

const rollback = await this.rollbackHistoryRepository.create({
userId,
targetUserId,
targetUserCycleId,
previousTime,
newTime,
rollbackType: targetUserId ? (targetUserCycleId ? 'USER_CYCLE_SPECIFIC' : 'USER_SPECIFIC') : 'SYSTEM',
status: 'INITIATED',
affectedDomains,
domainStatuses: {},
deletedRecordCounts: {},
startedAt: new Date(),
previousDayIndex,
targetDayIndex,
});

return rollback.id;
}

private determineAffectedDomains(
previousTime: number,
newTime: number,
targetUserCycleId?: string,
): string[] {
const domains = ['user-activity', 'health', 'notification', 'schedule'];

const daysDifference = Math.floor((previousTime - newTime) / (1000 * 60 * 60 * 24));

if (daysDifference < 1) {
return ['user-activity', 'notification'];
}

return domains;
}
}

2. 롤백 이벤트 발행기 구현

중앙화된 이벤트 시스템 아키텍처를 사용하여 롤백 이벤트를 발행하는 서비스:

import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Logger } from '@nestjs/common';
import { RollbackEvent } from './interfaces/rollback-event.interface';
import { v4 as uuidv4 } from 'uuid';
import * as os from 'os';
import { RollbackInitiatedEventPayload } from './interfaces/rollback-event.interface';

@Injectable()
export class RollbackEventPublisher {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly logger: Logger,
) {
this.logger = new Logger(RollbackEventPublisher.name);
}

async publishRollbackEvent(eventData: RollbackInitiatedEventPayload): Promise<string> {
const eventId = uuidv4();
const timestamp = new Date().toISOString();
const source = process.env.SERVICE_NAME || 'time-machine';
const eventType = 'rollback.initiated';

const eventPayload: RollbackEvent = {
type: eventType,
eventId: eventId,
timestamp: timestamp,
source: source,
containerId: os.hostname(),
payload: eventData,
};

try {
this.eventEmitter.emit('event', eventPayload);

this.logger.log(`Published local event '${eventType}' for rollback ${eventData.rollbackId}`, {
eventId: eventId,
source: source,
targetDayIndex: eventData.targetDayIndex
});

return eventId;
} catch (error) {
this.logger.error(`Error publishing local rollback event: ${error.message}`, {
error: error.stack,
rollbackId: eventData.rollbackId
});
throw error;
}
}
}

참고: 위 코드에서 RollbackEvent 인터페이스는 실제 이벤트 구조에 맞게 정의되어야 합니다. 또한, 이 발행된 이벤트는 /architecture/event-system에 설명된 EventBridgeService에 의해 GCP Pub/Sub으로 전달됩니다.

3. 도메인별 롤백 처리기 구현 가이드

각 도메인은 다음 항목을 서비스 내부에 구현해야 합니다:

  1. 롤백 이벤트 리스너: @OnEvent('rollback.initiated')를 사용하여 롤백 시작 이벤트 수신. 이벤트 페이로드에는 롤백 대상 userId, newTime, previousTime, 그리고 **선택적으로 userCycleId, previousDayIndex, targetDayIndex**가 포함될 수 있습니다.
  2. 데이터 영구 삭제 로직: 해당 도메인의 데이터를 영구적으로 삭제하는 로직. 반드시 userIdnewTime, previousTime을 기준으로 필터링해야 하며, userCycleId가 제공된 경우 추가 필터링해야 합니다.
  3. Redis 캐시 무효화: 데이터베이스 삭제 후, 해당 사용자의 도메인 관련 개인 캐시 데이터를 무효화합니다. 이는 /infrastructure/redis-caching 문서에 정의된 캐시 키 네이밍 컨벤션에 따라 private:{userId}:{domainName}:* 패턴의 키를 삭제하는 것을 의미합니다. Redis의 SCANDEL 명령 또는 Lua 스크립트를 활용할 수 있습니다. 캐시 삭제는 데이터베이스 삭제 성공 후, COMPLETED 상태 보고 전에 수행하는 것이 좋습니다.
  4. 상태 보고: EventEmitter를 사용하여 rollback.status 이벤트를 로컬 'event' 채널로 발행. 상태 보고 시 rollbackId, domain, status, 그리고 **선택적으로 targetDayIndex**를 포함할 수 있습니다.

1. 상태 보고 형식

각 도메인은 다음 형식으로 상태를 보고해야 합니다 (payload 부분):

interface DomainRollbackStatus {
rollbackId: string;
domain: string; // 예: 'user-activity', 'health'
status: 'IN_PROGRESS' | 'COMPLETED' | 'FAILED';
errorMessage?: string;
deletedRecordCount?: number;
userId?: string; // 롤백 대상 사용자 ID (선택적)
userCycleId?: string; // 롤백 대상 주기 ID (선택적)
targetDayIndex?: number; // 목표 dayIndex (선택적, 로깅/컨텍스트용)
}

2. 도메인별 롤백 처리기 구현 예시

// 예시: 사용자 활동 도메인의 롤백 처리기 (user-activity.service.ts 등에 포함될 수 있음)
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent, EventEmitter2 } from '@nestjs/event-emitter';
import { PrismaService } from '../prisma/prisma.service';
import { RedisService } from '../redis/redis.service';
import { RollbackInitiatedEventPayload } from './interfaces/rollback-event.interface';
import { DomainRollbackStatus } from './interfaces/domain-rollback-status.interface';
import { v4 as uuidv4 } from 'uuid';
import * as os from 'os';

@Injectable()
export class UserActivityRollbackHandler {
private readonly domainName = 'user-activity';

constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
private readonly redisService: RedisService,
private readonly logger: Logger,
) {
this.logger = new Logger(UserActivityRollbackHandler.name);
}

@OnEvent('rollback.initiated', { async: true })
async handleRollbackInitiated(eventPayloadContainer: { payload: RollbackInitiatedEventPayload }): Promise<void> {
const eventPayload = eventPayloadContainer.payload;
const { rollbackId, previousTime, newTime, userId, userCycleId, affectedDomains, targetDayIndex, previousDayIndex } = eventPayload;

if (!affectedDomains || !affectedDomains.includes(this.domainName)) {
return;
}

try {
this.logger.log(`Processing rollback ${rollbackId} for domain ${this.domainName} (User: ${userId}, Cycle: ${userCycleId || 'N/A'}, DayIndex: ${targetDayIndex || '?'})`);

await this.publishStatus({ rollbackId, domain: this.domainName, status: 'IN_PROGRESS', userId, userCycleId, targetDayIndex });

const deletedCount = await this.deleteDataPermanently(userId, newTime, previousTime, userCycleId);

await this.invalidateUserCache(userId);

await this.publishStatus({ rollbackId, domain: this.domainName, status: 'COMPLETED', deletedRecordCount: deletedCount, userId, userCycleId, targetDayIndex });

this.logger.log(`Completed rollback ${rollbackId} for domain ${this.domainName} (User: ${userId}, Cycle: ${userCycleId || 'N/A'}, DayIndex: ${targetDayIndex || '?'}): ${deletedCount} records deleted, cache invalidated`);

} catch (error) {
this.logger.error(`Error during rollback ${rollbackId} for domain ${this.domainName} (User: ${userId}, Cycle: ${userCycleId || 'N/A'}, DayIndex: ${targetDayIndex || '?'}): ${error.message}`, {
error: error.stack,
rollbackId: rollbackId
});

await this.publishStatus({ rollbackId, domain: this.domainName, status: 'FAILED', errorMessage: error.message, userId, userCycleId, targetDayIndex });
}
}

private async deleteDataPermanently(
userId: string,
newTime: number,
previousTime: number,
userCycleId?: string
): Promise<number> {
const fromDate = new Date(newTime);
const toDate = new Date(previousTime);

try {
const batchSize = 1000;
let totalDeleted = 0;
let hasMore = true;

this.logger.warn(`Starting permanent deletion for domain ${this.domainName} (User: ${userId}, Cycle: ${userCycleId || 'N/A'}) from ${fromDate.toISOString()} to ${toDate.toISOString()}`);

while (hasMore) {
const whereCondition: any = {
userId: userId,
createdAt: { gte: fromDate, lte: toDate },
};
if (userCycleId) { whereCondition.userCycleId = userCycleId; }

const activitiesToDelete = await this.prisma.userActivity.findMany({
where: whereCondition,
select: { id: true },
take: batchSize,
});

if (activitiesToDelete.length === 0) {
hasMore = false;
continue;
}

const idsToDelete = activitiesToDelete.map(a => a.id);
const result = await this.prisma.userActivity.deleteMany({ where: { id: { in: idsToDelete } } });

totalDeleted += result.count;
this.logger.log(`Deleted batch of ${result.count} records. Total deleted so far: ${totalDeleted} (User: ${userId}, Cycle: ${userCycleId || 'N/A'})`);
await new Promise(resolve => setTimeout(resolve, 100));
}

this.logger.warn(`Finished permanent deletion for domain ${this.domainName}. Total ${totalDeleted} records deleted. (User: ${userId}, Cycle: ${userCycleId || 'N/A'})`);
return totalDeleted;
} catch (error) {
this.logger.error(`Permanent delete error: ${error.message} (User: ${userId}, Cycle: ${userCycleId || 'N/A'})`, error.stack);
throw new Error(`Failed to permanently delete data: ${error.message}`);
}
}

private async invalidateUserCache(userId: string): Promise<void> {
const pattern = `private:${userId}:${this.domainName}:*`;
try {
const keys = await this.redisService.keys(pattern);
if (keys.length > 0) {
await this.redisService.del(keys);
this.logger.log(`Invalidated ${keys.length} cache keys for user ${userId} in domain ${this.domainName} matching pattern: ${pattern}`);
}
} catch (error) {
this.logger.error(`Failed to invalidate cache for user ${userId} in domain ${this.domainName} (pattern: ${pattern}): ${error.message}`, error.stack);
}
}

private async publishStatus(statusPayload: DomainRollbackStatus): Promise<void> {
const eventId = uuidv4();
const timestamp = new Date().toISOString();
const source = process.env.SERVICE_NAME || this.domainName;
const eventType = 'rollback.status';

const event = {
type: eventType,
eventId: eventId,
timestamp: timestamp,
source: source,
containerId: os.hostname(),
payload: statusPayload,
};

try {
this.eventEmitter.emit('event', event);
this.logger.log(`Published local event '${eventType}' for rollback ${statusPayload.rollbackId}`, {
eventId: eventId, source: source, status: statusPayload.status, userId: statusPayload.userId, userCycleId: statusPayload.userCycleId, targetDayIndex: statusPayload.targetDayIndex
});
} catch (error) {
this.logger.error(`Error publishing local status event: ${error.message}`, { error: error.stack, rollbackId: statusPayload.rollbackId });
}
}
}

3. 롤백 관련 모듈 구성

롤백 기능을 사용하는 도메인 서비스의 모듈에는 관련 컴포넌트와 RedisService가 주입되어야 합니다.

// 예시: UserActivity 모듈
import { Module, Logger } from '@nestjs/common';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { PrismaModule } from '../prisma/prisma.module';
import { RedisModule } from '../redis/redis.module';
import { UserActivityService } from './user-activity.service';
import { UserActivityRollbackHandler } from './user-activity-rollback.handler';

@Module({
imports: [
PrismaModule,
RedisModule,
EventEmitterModule.forRoot(),
],
providers: [
UserActivityService,
UserActivityRollbackHandler,
Logger,
],
exports: [UserActivityService],
})
export class UserActivityModule {}

데이터베이스 스키마

롤백 이력을 저장하기 위한 데이터베이스 스키마는 domain-model 문서를 참조하세요.

성능 및 안정성 고려사항

1. 롤백 작업의 멱등성

각 도메인은 롤백 작업이 여러 번 실행되어도 동일한 결과를 보장하도록 멱등성을 구현해야 합니다. 이는 주로 롤백 상태를 확인하여 이미 처리된 롤백 요청을 건너뛰는 방식으로 구현할 수 있습니다:

// 롤백 이벤트 처리 전 중복 체크 (예시: 별도 Repository 사용)
// 실제 구현에서는 RollbackHistoryRepository 또는 도메인별 상태 저장소를 활용할 수 있습니다.

// 가정: this.rollbackStatusRepository.findStatus(rollbackId, this.domainName) 등으로 상태 조회
const currentStatus = await this.rollbackStatusRepository.findStatus(rollbackId, this.domainName);

if (currentStatus && ['COMPLETED', 'FAILED'].includes(currentStatus.status)) {
this.logger.log(`Rollback ${rollbackId} already processed or being processed by ${this.domainName} with status ${currentStatus.status}`);
return; // 이미 처리되었거나 실패한 경우 건너뛰기
}

// 멱등성 키 저장 (예: Redis Set 사용)
const idempotencyKey = `rollback:${rollbackId}:${this.domainName}:processing`;
const alreadyProcessing = await this.redisService.set(idempotencyKey, 'true', 'NX', 'EX', 300); // 5분 TTL

if (!alreadyProcessing) {
this.logger.warn(`Rollback ${rollbackId} is already being processed by another instance for domain ${this.domainName}`);
return; // 다른 인스턴스에서 처리 중
}

// ... 롤백 로직 수행 ...

// 처리 완료 후 멱등성 키 제거 또는 완료 상태 기록
await this.redisService.del(idempotencyKey);

주의: 멱등성 구현 방식은 시스템 아키텍처와 요구사항에 따라 달라질 수 있습니다. 위 예시는 참고용입니다.

2. 장애 복구 전략

롤백 처리 중 장애가 발생할 경우를 대비한 전략:

  1. 재시도 정책: Pub/Sub 구독 설정에서 재시도 정책을 활용하거나, 핸들러 내부에서 특정 오류에 대해 재시도 로직을 구현할 수 있습니다. 단, 이미 영구 삭제된 데이터는 복구할 수 없으므로 주의해야 합니다.
  2. 부분 완료 처리: RollbackCoordinator는 각 도메인의 최종 상태를 수집하여 전체 롤백이 PARTIALLY_COMPLETED 상태로 끝날 수 있음을 인지합니다. 실패한 도메인에 대해서는 상세 로그 분석 및 필요한 경우 수동 조치가 필요합니다.
  3. 데드 레터 큐 (DLQ): 처리 실패한 이벤트를 DLQ로 보내어 추후 분석 및 재처리를 시도할 수 있습니다.
  4. 백업 복원 불가: 데이터는 영구 삭제되므로, 롤백 작업 자체를 되돌리는 유일한 방법은 별도로 관리되는 시스템 백업을 복원하는 것뿐입니다. 이는 롤백 기능의 범위 밖입니다.

3. 모니터링

롤백 진행 상황과 시스템 상태를 효과적으로 모니터링하기 위한 방안:

  1. 구조화된 로깅: 각 롤백 단계(시작, 진행, 캐시 무효화, 완료, 실패)에서 rollbackId, userId, domain, status 등의 핵심 정보를 포함한 구조화된 로그를 기록합니다. 로그 분석 도구를 통해 특정 롤백의 진행 상황을 추적할 수 있습니다.
  2. 메트릭: Cloud Monitoring 등을 사용하여 다음 지표를 수집하고 대시보드를 구성합니다:
    • 롤백 요청 수 (전체, 도메인별)
    • 롤백 처리 시간 (평균, 최대, p95 등)
    • 롤백 상태별 개수 (성공, 실패, 부분 성공)
    • 도메인별 처리 상태 및 삭제 레코드 수
    • Redis 캐시 무효화 성공/실패율
    • Pub/Sub 메시지 처리량 및 지연 시간
  3. 알림: 중요 상태 변경 또는 오류 발생 시 (예: 롤백 실패, 특정 도메인 처리 지연, DLQ 메시지 발생 등) Alertmanager 등을 통해 담당자에게 알림을 발송합니다.

관리자 도구

복잡한 롤백 작업을 관리하고 문제를 해결하기 위한 내부 관리자 도구 또는 기능을 고려할 수 있습니다:

  1. 롤백 상태 대시보드: 모든 롤백 작업의 목록과 현재 상태(INITIATED, IN_PROGRESS, COMPLETED, PARTIALLY_COMPLETED, FAILED), 관련 사용자, 시간 범위 등을 한눈에 볼 수 있는 인터페이스.
  2. 도메인별 상세 상태 조회: 특정 rollbackId를 선택하면 해당 롤백에 참여한 각 도메인의 상세 상태(시작/완료 시간, 삭제된 레코드 수, 오류 메시지 등)를 확인할 수 있는 기능.
  3. 수동 재처리/실패 처리: 특정 도메인의 롤백 처리가 실패했을 경우, 로그 분석 후 원인을 해결하고 해당 도메인에 대한 롤백 처리를 수동으로 재시도하거나, 실패 상태로 강제 완료 처리하는 기능.
  4. 로그 조회: 특정 rollbackId와 관련된 모든 로그를 쉽게 필터링하여 조회하는 기능.

API 명세

데이터 롤백을 시작하거나 상태를 조회하는 API의 상세 명세는 데이터 롤백 관리 API 문서를 참조하세요.

정리

본 문서에서는 TimeMachine의 데이터 롤백 기능을 중앙화된 이벤트 시스템 아키텍처 (/architecture/event-system) 기반으로 구현하는 방법을 설명했습니다. dayIndex 기반 시간 변경 요구사항을 반영하여, 롤백 시작 이벤트와 상태 보고, 롤백 이력에 선택적으로 dayIndex 정보를 포함하도록 수정했습니다. 핵심 데이터 삭제 로직은 여전히 timestamp를 기준으로 동작하지만, 추가된 dayIndex 정보는 롤백 프로세스의 컨텍스트 이해와 로깅/추적에 도움을 줄 수 있습니다.