코드 생성 서비스 구현 가이드
1. 개요
1.1 목적
본 문서는 Access Code 도메인의 코드 생성 서비스 구현에 대한 기술적인 가이드라인을 제공합니다. 이 서비스는 단일 또는 대량으로 고유하고 안전한 접근 코드를 생성하는 핵심 로직, 관련 API 엔드포인트, 백그라운드 처리, 이벤트 발행 및 TimeMachine 연동 등을 포함한 구현 방법을 상세히 설명합니다.
1.2 범위
- 코드 생성 서비스 아키텍처 및 컴포넌트 구조
- 단일 및 대량 코드 생성 API 구현 (Controller, Service, Repository)
- 대량 생성을 위한 비동기 처리 (Queue Processor) 구현
- 코드 생성 보안 및 중복 방지 전략
- 성능 최적화 방안 (비동기 처리, 청크 단위 처리, DB 최적화)
- 테스트 전략 (단위 테스트, 통합 테스트)
- 모니터링 및 로깅 방안
이 문서는 Access Code 도메인의 코드 생성 서비스 구현에 대한 기술적인 가이드를 제공합니다. 이 서비스는 단일 또는 대량으로 고유하고 안전한 접근 코드를 생성하는 로직을 담당하며, TimeMachine 연동 및 발급 출처별 메타데이터 처리, 이벤트 발행 기능을 포함합니다.
2. 아키텍처
2.1 컴포넌트 구조 (예상)
libs/feature/access-code/src/lib/
├── application/
│ ├── commands/
│ │ ├── index.ts # Commands 및 Handlers export
│ │ ├── impl/generate-code.command.ts # GenerateCodeCommand 정의
│ │ └── handlers/generate-code.handler.ts # GenerateCodeHandler 구현
│ │ # ... (GenerateBulkCodesCommand 및 Handler 포함)
│ └── services/
│ └── access-code-app.service.ts # (선택적) 컨트롤러와 핸들러 사이의 추가 로직
├── domain/
│ ├── services/
│ │ └── code-generation.service.ts # 실제 코드 생성 핵심 로직
│ ├── entities/
│ │ └── access-code.entity.ts
│ ├── repositories/
│ │ └── access-code.repository.ts
│ └── value-objects/
│ └── issue-source-type.vo.ts
├── infrastructure/
│ ├── adapters/
│ │ └── pubsub.adapter.ts # 이벤트 발행
│ │ └── queue.adapter.ts # 비동기 처리 (BullMQ 등)
│ └── repositories/
│ └── prisma-access-code.repository.ts
│ └── prisma-batch-job.repository.ts # 배치 작업 상태 관리
└── access-code.module.ts # CQRS 관련 Provider 등록
참고: 위 구조는 CQRS 패턴 적용을 가정한 예시입니다. Controller는
CommandBus를 통해GenerateCodeCommand를 보내고,CommandBus는 등록된GenerateCodeHandler를 찾아 실행합니다.GenerateCodeHandler는 실제 로직 수행을 위해CodeGenerationService를 사용합니다.
2.2 의존성
- AccessCodeRepository: 생성된 코드 데이터 영속화
- BatchJobRepository: 대량 생성 작업 상태 관리
- IssueSourceValidator: 발급 출처 메타데이터 유효성 검증
- EventEmitter2: 도메인 이벤트 발행 (로컬)
- ConfigService: 코드 생성 관련 설정값 (재시도 횟수, 청크 크기 등) 조회
- QueueService: 대량 코드 생성 작업을 비동기 큐(BullMQ 등)에 추가
참고: 실제 GCP Pub/Sub 발행은
EventBridgeService가 로컬 이벤트를 수신하여 처리합니다. 상세 내용은 분산 이벤트 시스템 아키텍처 문서를 참조하세요.
3. 데이터 모델
3.1 관련 Prisma 스키마
Access Code 도메인에서 코드 생성과 관련된 주요 엔티티(AccessCode, BatchJob 등)의 상세한 Prisma 스키마 정의는 다음 문서를 참조하세요:
- Access Code 도메인 모델
- 참고:
AccessCode모델에는useTimeMachine(Boolean)과virtualTimeStartDate(BigInt?) 필드가 포함되어 있어야 합니다.Batch모델에는useTimeMachineForAll(Boolean)과commonVirtualTimeStartDate(BigInt?) 필드가 포함되어 있을 수 있습니다. Prisma 스키마에서는 Unix timestamp(ms)를 저장하기 위해BigInt타입을 사용합니다.
4. API 구현
4.1 단일 코드 생성 API (POST /v1/access-codes)
4.1.1 Controller
Controller는 HTTP 요청을 받아 유효성을 검사하고, 적절한 Command 객체를 생성하여 CommandBus로 전달하는 역할을 합니다. TimeMachine 관련 필드 (useTimeMachine, virtualTimeStartDate)를 DTO에서 받아 Command로 전달합니다.
// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code.controller.ts
import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common'; // BadRequestException 추가
import { CommandBus } from '@nestjs/cqrs';
import { AuthGuard } from '@core/auth';
import { RolesGuard, Roles } from '@core/iam';
import { User } from '@core/auth/decorators';
import { UserDto } from '@shared-contracts/auth/dto';
import { GenerateCodeCommand } from '@feature/access-code'; // Command 경로 (tsconfig.base.json 확인 필요)
import { GenerateCodeDto, AccessCodeResponseDto } from '@shared-contracts/auth/dto/access-code'; // DTO에 useTimeMachine, virtualTimeStartDate 필드 추가 가정
import { AccessCode } from '@feature/access-code'; // AccessCode 엔티티 경로 (tsconfig.base.json 확인 필요)
// import { AccessCodeMapper } from '../mappers/access-code.mapper'; // 필요 시 매퍼 사용
@Controller('access-codes')
export class AccessCodeController {
constructor(private readonly commandBus: CommandBus) {}
@Post()
@UseGuards(AuthGuard, RolesGuard)
@Roles('OPERATOR', 'ADMIN')
async generateCode(
@Body() dto: GenerateCodeDto,
@User() user: UserDto,
): Promise<AccessCodeResponseDto> {
// TimeMachine 옵션 유효성 검사 (기본)
if (dto.useTimeMachine && (dto.virtualTimeStartDate === undefined || dto.virtualTimeStartDate === null)) {
throw new BadRequestException('TimeMachine 사용 시 가상 시작일(virtualTimeStartDate)은 필수입니다.');
}
// 1. Command 객체 생성 (TimeMachine 필드 포함)
const command = new GenerateCodeCommand(
dto.type,
dto.issueSourceType,
dto.expirationDate,
user.accountId, // 요청을 보낸 사용자 정보
dto.sourceSiteId,
dto.startDate,
dto.metadata,
dto.useTimeMachine ?? false, // DTO 필드명 확인 필요 (기본값 false)
dto.useTimeMachine ? Number(dto.virtualTimeStartDate) : undefined, // number 타입으로 전달
);
// 2. CommandBus를 통해 Handler 실행 요청
const accessCode = await this.commandBus.execute<GenerateCodeCommand, AccessCode>(command);
// 3. Handler 실행 결과(AccessCode 엔티티)를 DTO로 변환하여 반환
return {
id: accessCode.id,
code: accessCode.code,
type: accessCode.type.value,
status: accessCode.status.value,
expirationDate: accessCode.expirationDate,
startDate: accessCode.startDate,
// TimeMachine 정보도 응답에 포함할 수 있음 (선택적)
useTimeMachine: accessCode.getUseTimeMachine,
virtualTimeStartDate: accessCode.getVirtualTimeStartDate,
};
}
// ... (대량 생성 및 상태 조회 엔드포인트 - TimeMachine 관련 내용 추가 필요)
}
4.1.2 CQRS Command Handling
CommandBus로부터 전달받은 GenerateCodeCommand를 실제로 처리하는 핸들러입니다. Command 객체에 추가된 TimeMachine 관련 필드를 Service로 전달합니다.
// 파일 경로: libs/feature/access-code/src/lib/application/commands/impl/generate-code.command.ts (수정 가정)
export class GenerateCodeCommand {
constructor(
public readonly type: string,
public readonly issueSourceType: string,
public readonly expirationDate: Date,
public readonly issuerAccountId: string,
public readonly sourceSiteId: string,
public readonly startDate?: Date,
public readonly metadata?: Record<string, any>,
public readonly useTimeMachine?: boolean, // TimeMachine 사용 여부
public readonly virtualTimeStartDate?: number, // Unix timestamp (ms)
) {}
}
// 파일 경로: libs/feature/access-code/src/lib/application/commands/handlers/generate-code.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { GenerateCodeCommand } from '../impl/generate-code.command';
import { CodeGenerationService } from '../../../domain/services/code-generation.service';
import { AccessCode } from '../../../domain/entities/access-code.entity';
import { LoggerService } from '@core/logging'; // CoreLoggingModule의 LoggerService 경로 (tsconfig.base.json 확인)
// import { AccessCodeServerError } from '../../../domain/errors/access-code.errors'; // 필요시 사용
@CommandHandler(GenerateCodeCommand)
export class GenerateCodeHandler implements ICommandHandler<GenerateCodeCommand, AccessCode> {
constructor(
private readonly codeGenerationService: CodeGenerationService,
private readonly logger: LoggerService,
) {
// this.logger.setContext(GenerateCodeHandler.name);
}
async execute(command: GenerateCodeCommand): Promise<AccessCode> {
this.logger.log(`Executing GenerateCodeCommand for issuer: ${command.issuerAccountId}`, GenerateCodeHandler.name);
// Command 객체에서 필요한 데이터(TimeMachine 필드 포함)를 추출하여 Service 메소드 호출
const accessCode = await this.codeGenerationService.generateCode({
type: command.type,
issueSourceType: command.issueSourceType,
expirationDate: command.expirationDate,
issuerAccountId: command.issuerAccountId,
sourceSiteId: command.sourceSiteId,
startDate: command.startDate,
metadata: command.metadata,
useTimeMachine: command.useTimeMachine, // TimeMachine 필드 전달
virtualTimeStartDate: command.virtualTimeStartDate, // number 타입 timestamp 전달
});
this.logger.log(`Successfully generated AccessCode: ${accessCode.id} (TimeMachine: ${accessCode.getUseTimeMachine})`, GenerateCodeHandler.name);
return accessCode;
}
}
참고:
GenerateCodeHandler는AccessCodeModule의providers배열에 등록되어야CommandBus가 찾을 수 있습니다.
4.1.3 Domain Service (CodeGenerationService)
이 서비스는 코드 생성의 핵심 비즈니스 로직을 담당합니다. generateCode 메서드 파라미터에 TimeMachine 필드를 추가하고, 엔티티 생성 시 해당 값을 사용하며, 관련 유효성 검사를 수행합니다.
// 파일 경로: libs/feature/access-code/src/lib/domain/services/interfaces.ts (수정 가정)
export interface GenerateCodeParams {
type: string;
issueSourceType: string;
expirationDate: Date;
issuerAccountId: string;
sourceSiteId: string;
startDate?: Date;
metadata?: Record<string, any>;
useTimeMachine?: boolean; // TimeMachine 사용 여부
virtualTimeStartDate?: number; // Unix timestamp (ms)
}
// 파일 경로: libs/feature/access-code/src/lib/domain/services/code-generation.service.ts
import { Injectable, BadRequestException, InternalServerErrorException } from '@nestjs/common';
import { AccessCodeRepository } from '../repositories/access-code.repository';
import { AccessCode } from '../entities/access-code.entity';
import { IssueSourceType } from '../value-objects/issue-source-type.vo'; // 경로 수정 필요
import { GenerateCodeParams } from './interfaces'; // 인터페이스 경로 수정
import { v4 as uuidv4 } from 'uuid';
import * as crypto from 'crypto';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AccessCodeIssueSourceError,
DuplicateAccessCodeError,
AccessCodeServerError,
AccessCodeValidationError, // 추가
} from '../errors/access-code.errors';
@Injectable()
export class CodeGenerationService {
constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly eventEmitter: EventEmitter2,
) {}
async generateCode(params: GenerateCodeParams): Promise<AccessCode> {
// 발급 출처 메타데이터 유효성 검사
this.validateIssueSourceMetadata(params.issueSourceType, params.metadata);
// TimeMachine 시작일 유효성 검사
this.validateVirtualTimeStartDate(params.useTimeMachine, params.virtualTimeStartDate);
const code = await this.generateUniqueCodeValue();
const codeId = uuidv4();
const accessCode = new AccessCode({ // 엔티티 생성 시 TimeMachine 필드 포함
id: codeId,
code,
type: params.type,
issueSourceType: params.issueSourceType,
expirationDate: params.expirationDate,
startDate: params.startDate,
issuerAccountId: params.issuerAccountId,
sourceSiteId: params.sourceSiteId,
metadata: params.metadata,
useTimeMachine: params.useTimeMachine ?? false, // 기본값 false
virtualTimeStartDate: params.useTimeMachine ? params.virtualTimeStartDate : undefined,
});
const savedCode = await this.accessCodeRepository.save(accessCode);
await this.publishCodeGeneratedEvents(savedCode);
return savedCode;
}
// ... (generateUniqueCodeValue, generateRandomCode, validateIssueSourceMetadata 생략) ...
// 가상 시작일 유효성 검사 로직 추가
private validateVirtualTimeStartDate(useTimeMachine?: boolean, virtualTimeStartDateMs?: number): void {
if (useTimeMachine && (virtualTimeStartDateMs === undefined || virtualTimeStartDateMs === null)) {
throw new AccessCodeValidationError(
AccessCodeErrorCode.MISSING_VIRTUAL_TIME_START_DATE,
'TimeMachine 사용 시 가상 시작일은 필수입니다.'
);
}
// 요구사항에 따라 추가적인 날짜 범위 검증 로직은 여기서 제거됨
}
// 코드 생성 관련 이벤트 발행 로직 (이벤트 페이로드에 TimeMachine 정보 추가 가능)
private async publishCodeGeneratedEvents(accessCode: AccessCode): Promise<void> {
const eventId = uuidv4();
const timestamp = new Date().toISOString();
const basePayload = {
codeId: accessCode.getId,
code: accessCode.getCode,
type: accessCode.getType.value,
issueSourceType: accessCode.getIssueSourceType.value,
expirationDate: accessCode.getExpirationDate.toISOString(),
issuerAccountId: accessCode.getIssuerAccountId,
sourceSiteId: accessCode.getSourceSiteId,
useTimeMachine: accessCode.getUseTimeMachine, // TimeMachine 정보 포함
virtualTimeStartDate: accessCode.getVirtualTimeStartDate, // number 타입 timestamp 포함
createdAt: accessCode.getCreatedAt.toISOString(),
};
// 기본 이벤트 발행
await this.publishLocalEvent('access-code-generated', eventId, timestamp, basePayload);
// 출처별 특화 이벤트 발행
const issueSourceType = accessCode.getIssueSourceType().value;
switch (issueSourceType) {
case IssueSourceType.CLINIC_DASHBOARD:
const clinicMetadata = accessCode.getMetadata() as any;
if (clinicMetadata) {
await this.publishLocalEvent('access-code-issued-from-clinic', uuidv4(), timestamp, {
...basePayload,
clinicId: clinicMetadata.clinicId,
doctorId: clinicMetadata.doctorId,
patientId: clinicMetadata.patientId,
appointmentId: clinicMetadata.appointmentId,
});
}
break;
// ... (다른 출처 타입) ...
}
}
// ... (publishLocalEvent 생략) ...
}
4.2 대량 코드 생성 API (POST /v1/access-codes/bulk)
4.2.1 Controller
대량 생성 DTO (GenerateBulkCodesDto)에 배치 레벨의 TimeMachine 옵션 (useTimeMachineForAll, commonVirtualTimeStartDate)을 포함하고 Command로 전달합니다.
// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code.controller.ts
// ... (imports 생략)
import { GenerateBulkCodesDto, BulkGenerationResponseDto, BatchStatusDto } from '@shared-contracts/auth/dto/access-code'; // DTO에 TimeMachine 필드 추가 가정
import { GenerateBulkCodesCommand, GetBatchStatusQuery } from '@feature/access-code'; // Command/Query 경로
import { QueryBus } from '@nestjs/cqrs'; // QueryBus 추가
import { NotFoundException } from '@nestjs/common'; // NotFoundException 추가
@Controller('access-codes')
export class AccessCodeController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus // QueryBus 주입
) {}
// ... (generateCode 생략)
@Post('bulk')
@UseGuards(AuthGuard, RolesGuard)
@Roles('OPERATOR', 'ADMIN')
async generateBulkCodes(@Body() dto: GenerateBulkCodesDto, @User() user: UserDto): Promise<BulkGenerationResponseDto> {
// TimeMachine 옵션 유효성 검사 (배치 레벨 - 기본)
if (dto.useTimeMachineForAll && (dto.commonVirtualTimeStartDate === undefined || dto.commonVirtualTimeStartDate === null)) {
throw new BadRequestException('TimeMachine 일괄 사용 시 공통 가상 시작일(commonVirtualTimeStartDate)은 필수입니다.');
}
// 추가적인 날짜 범위 검증 로직은 제거됨
const command = new GenerateBulkCodesCommand(
dto.count,
dto.type,
dto.issueSourceType,
dto.expirationDate,
user.accountId,
dto.sourceSiteId,
dto.startDate,
dto.metadata,
dto.useTimeMachineForAll ?? false, // 배치 레벨 TimeMachine 옵션
dto.useTimeMachineForAll ? Number(dto.commonVirtualTimeStartDate) : undefined, // number 타입으로 전달
);
const batchId = await this.commandBus.execute<GenerateBulkCodesCommand, string>(command);
return {
batchId,
status: 'PENDING',
message: `Batch job for generating ${dto.count} codes created with ID ${batchId}. Check status using GET /access-codes/bulk/${batchId}`,
};
}
@Get('bulk/:batchId')
@UseGuards(AuthGuard, RolesGuard)
@Roles('OPERATOR', 'ADMIN')
async getBatchStatus(@Param('batchId') batchId: string): Promise<BatchStatusDto> {
const query = new GetBatchStatusQuery(batchId);
// QueryBus 실행 결과를 반환 (핸들러 구현 필요)
const status = await this.queryBus.execute<GetBatchStatusQuery, BatchStatusDto | null>(query);
if (!status) {
throw new NotFoundException(`Batch job with ID ${batchId} not found.`);
}
return status;
}
}
4.2.2 Service (Application Layer - Orchestration)
initiateBulkCodeGeneration 메서드 파라미터에 TimeMachine 관련 필드를 추가하고, 배치 작업 생성 시 저장하며, 큐 작업 데이터로 전달합니다.
// 파일 경로: libs/feature/access-code/src/lib/domain/services/interfaces.ts (수정 가정)
export interface GenerateBulkCodesParams extends GenerateCodeParams {
count: number;
useTimeMachineForAll?: boolean; // 배치 레벨 TimeMachine 사용 여부
commonVirtualTimeStartDate?: number; // Unix timestamp (ms)
}
// 파일 경로: libs/feature/access-code/src/lib/application/services/access-code-app.service.ts (가정)
import { Injectable, NotFoundException } from '@nestjs/common'; // NotFoundException 추가
import { BatchJobRepository } from '../../domain/repositories/batch-job.repository'; // 배치 레포지토리 경로
import { QueueService } from '../../infrastructure/adapters/queue.adapter'; // 큐 서비스 경로
import { GenerateBulkCodesParams } from '../../domain/services/interfaces'; // 인터페이스 경로
import { BatchJob } from '../../domain/entities/batch-job.entity'; // BatchJob 엔티티 타입
import { BatchStatusDto } from '@shared-contracts/auth/dto/access-code'; // 공유 DTO 경로
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AccessCodeAppService {
constructor(
private readonly batchRepository: BatchJobRepository,
private readonly queueService: QueueService,
) {}
async initiateBulkCodeGeneration(params: GenerateBulkCodesParams): Promise<string> {
// 기본 TimeMachine 유효성 검사 (Service 레벨에서도 수행 가능)
if (params.useTimeMachineForAll && (params.commonVirtualTimeStartDate === undefined || params.commonVirtualTimeStartDate === null)) {
throw new BadRequestException('TimeMachine 일괄 사용 시 공통 가상 시작일은 필수입니다.');
}
// 추가적인 날짜 범위 검증 로직은 제거됨
const batchId = uuidv4();
// 배치 작업 초기 정보 저장 (TimeMachine 관련 파라미터 포함)
await this.batchRepository.save({ // save 메소드는 BatchJob 엔티티를 받는다고 가정
id: batchId,
type: 'CODE_GENERATION',
status: 'PENDING',
totalCount: params.count,
processedCount: 0,
completedCount: 0,
failedCount: 0,
startTime: new Date(),
params: JSON.stringify({ // TimeMachine 정보 포함 저장
...params,
// Date 객체는 JSON 직렬화 시 문자열로 변환되므로 주의
expirationDate: params.expirationDate.toISOString(),
startDate: params.startDate?.toISOString(),
commonVirtualTimeStartDate: params.commonVirtualTimeStartDate,
}),
// Batch 모델에 해당 필드가 있다면 직접 저장
// useTimeMachineForAll: params.useTimeMachineForAll,
// commonVirtualTimeStartDate: params.commonVirtualTimeStartDate,
});
// 실제 코드 생성 작업을 큐에 추가 (TimeMachine 정보 포함)
await this.queueService.add('generate-access-codes', { batchId, params }); // 작업 이름 통일
return batchId;
}
// ... (getBatchJobStatus, calculateEstimatedCompletion 생략) ...
}
4.2.3 Queue Processor (Background Job)
Job 데이터에서 TimeMachine 관련 파라미터를 추출하고, AccessCode 엔티티 생성 시 배치 설정을 적용합니다.
// 파일 경로: libs/feature/access-code/src/lib/infrastructure/queue/bulk-code-generation.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { Injectable, Logger } from '@nestjs/common';
import { AccessCodeRepository } from '../../domain/repositories/access-code.repository';
import { BatchJobRepository } from '../../domain/repositories/batch-job.repository';
import { AccessCode } from '../../domain/entities/access-code.entity';
import { GenerateBulkCodesParams } from '../../domain/services/interfaces';
import { CodeGenerationService } from '../../domain/services/code-generation.service';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AccessCodeBatchError,
AccessCodeErrorCode
} from '../../domain/errors/access-code.errors';
import { BatchJob } from '../../domain/entities/batch-job.entity'; // BatchJob 엔티티 타입
@Injectable()
@Processor('code-generation') // 큐 이름 확인
export class BulkCodeGenerationProcessor {
private readonly logger = new Logger(BulkCodeGenerationProcessor.name);
constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly batchRepository: BatchJobRepository,
private readonly codeGenerationService: CodeGenerationService,
private readonly eventEmitter: EventEmitter2,
) {}
@Process('generate-access-codes') // 작업 이름 확인
async handleBulkGeneration(job: Job<{ batchId: string; params: GenerateBulkCodesParams }>): Promise<void> {
const { batchId, params } = job.data;
// Job 파라미터의 날짜 필드는 문자열일 수 있으므로 Date 객체로 변환 필요
const parsedParams = {
...params,
expirationDate: new Date(params.expirationDate),
startDate: params.startDate ? new Date(params.startDate) : undefined,
commonVirtualTimeStartDate: params.commonVirtualTimeStartDate,
};
this.logger.log(`Processing batch job: ${batchId} with TimeMachine: ${parsedParams.useTimeMachineForAll}`);
try {
await this.updateBatchStatus(batchId, 'PROCESSING');
// TimeMachine 파라미터 추출
const { count, type, expirationDate, startDate, issuerAccountId, sourceSiteId, metadata, issueSourceType, useTimeMachineForAll, commonVirtualTimeStartDate } = parsedParams;
const chunkSize = 100;
const totalChunks = Math.ceil(count / chunkSize);
let totalCompleted = 0;
let totalFailed = 0;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const currentChunkSize = Math.min(chunkSize, count - chunkIndex * chunkSize);
let completedInChunk = 0;
let failedInChunk = 0;
try {
const codesToGenerate = await this.generateUniqueCodesForChunk(currentChunkSize);
// 엔티티 생성 시 배치 레벨 TimeMachine 설정 적용
const accessCodeEntities = codesToGenerate.map(code => new AccessCode({
id: uuidv4(),
code,
type,
expirationDate,
startDate,
issuerAccountId,
sourceSiteId,
metadata,
issueSourceType,
useTimeMachine: useTimeMachineForAll ?? false, // 배치 설정 적용
virtualTimeStartDate: useTimeMachineForAll ? commonVirtualTimeStartDate : undefined, // number timestamp 적용
batchId: batchId, // 배치 ID 연결
}));
await this.accessCodeRepository.saveAll(accessCodeEntities);
completedInChunk = accessCodeEntities.length;
// ... (개별 이벤트 발행 로직 - 페이로드에 TimeMachine 정보 포함 가능) ...
} catch (chunkError) {
this.logger.error(`Error processing chunk ${chunkIndex + 1} for batch ${batchId}: ${chunkError.message}`, chunkError.stack);
failedInChunk = currentChunkSize;
}
totalCompleted += completedInChunk;
totalFailed += failedInChunk;
const processedCount = Math.min((chunkIndex + 1) * chunkSize, count);
await this.updateBatchProgress(batchId, processedCount, completedInChunk, failedInChunk);
this.logger.log(`Batch ${batchId}: Chunk ${chunkIndex + 1}/${totalChunks} processed. Completed: ${completedInChunk}, Failed: ${failedInChunk}`);
}
// ... (최종 상태 업데이트 로직) ...
const finalStatus = totalFailed > 0 ? (totalCompleted > 0 ? 'PARTIALLY_COMPLETED' : 'FAILED') : 'COMPLETED';
await this.updateBatchStatus(batchId, finalStatus);
} catch (error) {
this.logger.error(`Failed to process batch job ${batchId}: ${error.message}`, error.stack);
await this.updateBatchStatus(batchId, 'FAILED', error.message);
// 오류 재발생시켜 BullMQ 재시도 유도 또는 특정 오류 처리
throw new AccessCodeBatchError(AccessCodeErrorCode.BATCH_PROCESSING_ERROR, `Batch job ${batchId} failed.`, { underlyingError: error.message });
}
}
// ... (generateUniqueCodesForChunk 생략) ...
private async updateBatchStatus(batchId: string, status: string, error?: string): Promise<void> {
// Repository를 통해 BatchJob 엔티티 업데이트
await this.batchRepository.updateStatus(batchId, status, error);
}
private async updateBatchProgress(batchId: string, processedCount: number, completedDelta: number, failedDelta: number): Promise<void> {
// Repository를 통해 BatchJob 엔티티 업데이트
await this.batchRepository.updateProgress(batchId, processedCount, completedDelta, failedDelta);
}
}
5. 코드 생성 보안 및 중복 방지
5.1 암호학적으로 안전한 난수 사용
코드 생성 시 crypto.randomBytes (Node.js) 또는 유사한 CSPRNG(Cryptographically Secure Pseudo-Random Number Generator)를 사용하여 예측 불가능성을 보장합니다.
// 파일 경로: libs/feature/access-code/src/lib/domain/services/code-generation.service.ts (부분)
private generateRandomCode(): string {
const length = 16;
const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const randomBytes = crypto.randomBytes(length); // 안전한 난수 생성
let result = '';
for (let i = 0; i < length; i++) {
result += charset[randomBytes[i] % charset.length];
}
return result;
}
5.2 데이터베이스 고유 제약 조건 활용
AccessCode 모델의 code 필드에 @unique 제약 조건을 설정하여 데이터베이스 수준에서 중복을 방지합니다.
// 파일 경로: libs/core/database/prisma/schema.prisma (부분)
model AccessCode {
// ...
code String @unique // DB 고유 제약 조건
// ...
}
5.3 생성 시 중복 확인 및 재시도 로직
코드를 생성한 후 데이터베이스에 저장하기 전에 해당 코드가 이미 존재하는지 확인합니다. 중복될 경우, 설정된 횟수만큼 재시도합니다.
// 파일 경로: libs/feature/access-code/src/lib/domain/services/code-generation.service.ts (부분)
private async generateUniqueCodeValue(maxRetries = 3): Promise<string> {
let retries = 0;
while (retries < maxRetries) {
const code = this.generateRandomCode();
const existingCode = await this.accessCodeRepository.findByCode(code);
if (!existingCode) {
return code; // 중복 없음
}
retries++; // 중복 시 재시도
}
throw new DuplicateAccessCodeError({ attempts: maxRetries });
}
6. 성능 최적화
6.1 대량 생성 시 비동기 처리 (Background Jobs)
대량 코드 생성 요청은 즉시 처리하지 않고, BullMQ 같은 메시지 큐 시스템을 사용하여 백그라운드 작업으로 위임합니다. 이를 통해 API 응답 시간을 단축하고 서버 부하를 분산시킵니다.
- Controller: 대량 생성 요청을 받으면 배치 작업 정보를 DB에 기록하고 작업을 큐에 추가한 후, 배치 ID를 즉시 반환합니다.
- Queue Processor: 큐에서 작업을 받아 실제 코드 생성 로직을 수행합니다.
6.2 청크(Chunk) 단위 처리
대량 생성 백그라운드 작업 내에서도 모든 코드를 한 번에 생성 및 저장하지 않고, 작은 크기(예: 100개)의 청크로 나누어 처리합니다. 이는 단일 트랜잭션의 크기를 줄이고, 메모리 사용량을 관리하며, 작업 실패 시 영향을 최소화하는 데 도움이 됩니다.
// 파일 경로: libs/feature/access-code/src/lib/infrastructure/queue/bulk-code-generation.processor.ts
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';
import { Injectable, Logger } from '@nestjs/common';
import { AccessCodeRepository } from '../../domain/repositories/access-code.repository';
import { BatchJobRepository } from '../../domain/repositories/batch-job.repository';
import { AccessCode } from '../../domain/entities/access-code.entity';
import { GenerateBulkCodesParams } from '../../domain/services/interfaces';
import { CodeGenerationService } from '../../domain/services/code-generation.service';
import { v4 as uuidv4 } from 'uuid';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
AccessCodeBatchError,
AccessCodeErrorCode
} from '../../domain/errors/access-code.errors';
import { BatchJob } from '../../domain/entities/batch-job.entity'; // BatchJob 엔티티 타입
@Injectable()
@Processor('code-generation') // 큐 이름 확인
export class BulkCodeGenerationProcessor {
private readonly logger = new Logger(BulkCodeGenerationProcessor.name);
constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly batchRepository: BatchJobRepository,
private readonly codeGenerationService: CodeGenerationService,
private readonly eventEmitter: EventEmitter2,
) {}
@Process('generate-access-codes') // 작업 이름 확인
async handleBulkGeneration(job: Job<{ batchId: string; params: GenerateBulkCodesParams }>): Promise<void> {
const { batchId, params } = job.data;
// Job 파라미터의 날짜 필드는 문자열일 수 있으므로 Date 객체로 변환 필요
const parsedParams = {
...params,
expirationDate: new Date(params.expirationDate),
startDate: params.startDate ? new Date(params.startDate) : undefined,
commonVirtualTimeStartDate: params.commonVirtualTimeStartDate,
};
this.logger.log(`Processing batch job: ${batchId} with TimeMachine: ${parsedParams.useTimeMachineForAll}`);
try {
await this.updateBatchStatus(batchId, 'PROCESSING');
// TimeMachine 파라미터 추출
const { count, type, expirationDate, startDate, issuerAccountId, sourceSiteId, metadata, issueSourceType, useTimeMachineForAll, commonVirtualTimeStartDate } = parsedParams;
const chunkSize = 100;
const totalChunks = Math.ceil(count / chunkSize);
let totalCompleted = 0;
let totalFailed = 0;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const currentChunkSize = Math.min(chunkSize, count - chunkIndex * chunkSize);
let completedInChunk = 0;
let failedInChunk = 0;
try {
const codesToGenerate = await this.generateUniqueCodesForChunk(currentChunkSize);
// 엔티티 생성 시 배치 레벨 TimeMachine 설정 적용
const accessCodeEntities = codesToGenerate.map(code => new AccessCode({
id: uuidv4(),
code,
type,
expirationDate,
startDate,
issuerAccountId,
sourceSiteId,
metadata,
issueSourceType,
useTimeMachine: useTimeMachineForAll ?? false, // 배치 설정 적용
virtualTimeStartDate: useTimeMachineForAll ? commonVirtualTimeStartDate : undefined, // number timestamp 적용
batchId: batchId, // 배치 ID 연결
}));
await this.accessCodeRepository.saveAll(accessCodeEntities);
completedInChunk = accessCodeEntities.length;
// ... (개별 이벤트 발행 로직 - 페이로드에 TimeMachine 정보 포함 가능) ...
} catch (chunkError) {
this.logger.error(`Error processing chunk ${chunkIndex + 1} for batch ${batchId}: ${chunkError.message}`, chunkError.stack);
failedInChunk = currentChunkSize;
}
totalCompleted += completedInChunk;
totalFailed += failedInChunk;
const processedCount = Math.min((chunkIndex + 1) * chunkSize, count);
await this.updateBatchProgress(batchId, processedCount, completedInChunk, failedInChunk);
this.logger.log(`Batch ${batchId}: Chunk ${chunkIndex + 1}/${totalChunks} processed. Completed: ${completedInChunk}, Failed: ${failedInChunk}`);
}
// ... (최종 상태 업데이트 로직) ...
const finalStatus = totalFailed > 0 ? (totalCompleted > 0 ? 'PARTIALLY_COMPLETED' : 'FAILED') : 'COMPLETED';
await this.updateBatchStatus(batchId, finalStatus);
} catch (error) {
this.logger.error(`Failed to process batch job ${batchId}: ${error.message}`, error.stack);
await this.updateBatchStatus(batchId, 'FAILED', error.message);
// 오류 재발생시켜 BullMQ 재시도 유도 또는 특정 오류 처리
throw new AccessCodeBatchError(AccessCodeErrorCode.BATCH_PROCESSING_ERROR, `Batch job ${batchId} failed.`, { underlyingError: error.message });
}
}
// ... (generateUniqueCodesForChunk 생략) ...
private async updateBatchStatus(batchId: string, status: string, error?: string): Promise<void> {
// Repository를 통해 BatchJob 엔티티 업데이트
await this.batchRepository.updateStatus(batchId, status, error);
}
private async updateBatchProgress(batchId: string, processedCount: number, completedDelta: number, failedDelta: number): Promise<void> {
// Repository를 통해 BatchJob 엔티티 업데이트
await this.batchRepository.updateProgress(batchId, processedCount, completedDelta, failedDelta);
}
}
7. 테스트
7.1 단위 테스트 (Unit Tests)
CodeGenerationService 테스트
TimeMachine 옵션 관련 테스트 케이스 추가:
// 파일 경로: libs/feature/access-code/src/lib/domain/services/code-generation.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { CodeGenerationService } from './code-generation.service';
import { AccessCodeRepository } from '../repositories/access-code.repository';
import { EventEmitter2 } from '@nestjs/event-emitter'; // EventEmitter2 import
import { GenerateCodeParams } from './interfaces';
import { AccessCode } from '../entities/access-code.entity';
import { AccessCodeValidationError, DuplicateAccessCodeError, AccessCodeErrorCode } from '../errors/access-code.errors'; // 에러 타입 import
// Mock 타입 정의
type MockType<T> = {
[P in keyof T]?: jest.Mock;
};
describe('CodeGenerationService', () => {
let service: CodeGenerationService;
let accessCodeRepository: MockType<AccessCodeRepository>;
let eventEmitter: MockType<EventEmitter2>; // EventEmitter 목킹
beforeEach(async () => {
accessCodeRepository = {
save: jest.fn(),
findByCode: jest.fn(),
};
eventEmitter = {
emit: jest.fn(),
};
const moduleFixture = await Test.createTestingModule({
providers: [
CodeGenerationService,
{ provide: AccessCodeRepository, useValue: accessCodeRepository },
{ provide: EventEmitter2, useValue: eventEmitter }, // EventEmitter 제공
],
}).compile();
service = moduleFixture.get<CodeGenerationService>(CodeGenerationService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('generateCode', () => {
const baseParams: Omit<GenerateCodeParams, 'useTimeMachine' | 'virtualTimeStartDate'> = { // 기본 파라미터
type: 'STANDARD',
issueSourceType: 'SYSTEM',
expirationDate: new Date(Date.now() + 86400000),
issuerAccountId: 'sys-admin',
sourceSiteId: 'internal',
};
// 원본 테스트 유지: 고유 코드 생성, 저장, 이벤트 발행 기본 검증
it('should generate a unique code, save it, and publish events', async () => {
accessCodeRepository.findByCode.mockResolvedValue(null);
const mockSavedCode = new AccessCode({ ...baseParams, id: 'uuid', code: 'RANDOMCODE1234567', useTimeMachine: false });
accessCodeRepository.save.mockResolvedValue(mockSavedCode);
const result = await service.generateCode(baseParams);
expect(result).toBeInstanceOf(AccessCode);
expect(result.getCode).toMatch(/^[A-Z0-9]{16}$/); // 실제 생성 로직 반영 필요
expect(accessCodeRepository.save).toHaveBeenCalledTimes(1);
expect(eventEmitter.emit).toHaveBeenCalledWith('event', expect.objectContaining({ type: 'access-code-generated' }));
});
// 원본 테스트 유지: 중복 시 재시도 검증
it('should retry generating code if a duplicate is found', async () => {
const mockExistingCode = new AccessCode({ ...baseParams, id: 'uuid-exist', code: 'DUPLICATECODE123', useTimeMachine: false });
const mockNewCode = new AccessCode({ ...baseParams, id: 'uuid-new', code: 'NEWUNIQUECODE123', useTimeMachine: false });
accessCodeRepository.findByCode
.mockResolvedValueOnce(mockExistingCode) // 첫 시도 중복
.mockResolvedValueOnce(null); // 두 번째 시도 성공
accessCodeRepository.save.mockResolvedValue(mockNewCode);
await service.generateCode(baseParams);
expect(accessCodeRepository.findByCode).toHaveBeenCalledTimes(2);
expect(accessCodeRepository.save).toHaveBeenCalledTimes(1);
});
// 원본 테스트 유지: 최대 재시도 후 에러 발생 검증
it('should throw validation error if TimeMachine is enabled but start date is missing', async () => {
const params: GenerateCodeParams = { ...baseParams, useTimeMachine: true, virtualTimeStartDate: undefined };
accessCodeRepository.findByCode.mockResolvedValue(null);
await expect(service.generateCode(params)).rejects.toThrow(AccessCodeValidationError);
await expect(service.generateCode(params)).rejects.toHaveProperty('code', AccessCodeErrorCode.MISSING_VIRTUAL_TIME_START_DATE);
expect(accessCodeRepository.save).not.toHaveBeenCalled();
});
// TimeMachine 관련 테스트 케이스 추가
it('should generate code with TimeMachine enabled and valid start date', async () => {
const startTimestamp = Date.now() + 7 * 24 * 60 * 60 * 1000; // 7일 후 timestamp
const params: GenerateCodeParams = {
...baseParams,
useTimeMachine: true,
virtualTimeStartDate: startTimestamp,
};
accessCodeRepository.findByCode.mockResolvedValue(null);
accessCodeRepository.save.mockResolvedValue(mockSavedCode);
const result = await service.generateCode(params);
expect(result).toBeInstanceOf(AccessCode);
expect(result.getUseTimeMachine).toBe(true);
expect(result.getVirtualTimeStartDate).toEqual(startTimestamp);
expect(accessCodeRepository.save).toHaveBeenCalledTimes(1);
const savedEntityArg = accessCodeRepository.save.mock.calls[0][0] as AccessCode;
expect(savedEntityArg.getUseTimeMachine).toBe(true);
expect(savedEntityArg.getVirtualTimeStartDate).toEqual(startTimestamp);
// 이벤트 페이로드 검증 추가 가능
});
it('should generate code with TimeMachine disabled', async () => {
const params: GenerateCodeParams = { ...baseParams, useTimeMachine: false };
accessCodeRepository.findByCode.mockResolvedValue(null);
accessCodeRepository.save.mockResolvedValue(mockSavedCode);
const result = await service.generateCode(params);
expect(result.getUseTimeMachine).toBe(false);
expect(result.getVirtualTimeStartDate).toBeUndefined();
expect(accessCodeRepository.save.mock.calls[0][0] as AccessCode).toEqual(mockSavedCode);
});
});
// ... (오류 케이스 등 나머지 테스트) ...
});
7.2 통합 테스트 (Integration Tests)
TimeMachine 옵션을 포함한 API 호출 테스트 케이스 추가:
// 예시: e2e 테스트 (부분)
// 파일 경로: apps/dta-wide-api/test/access-code.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module'; // 실제 앱 모듈
import { INestApplication } from '@nestjs/common';
import { PrismaService } from '@core/database'; // PrismaService 경로
import { Queue } from 'bull'; // BullMQ Queue 타입
import { getQueueToken } from '@nestjs/bull'; // BullMQ Token 가져오기
describe('AccessCode API (e2e)', () => { // describe 이름 수정
let app: INestApplication;
let prisma: PrismaService;
let generationQueue: Queue; // 작업 큐 인스턴스
let authToken: string; // 테스트용 인증 토큰
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule], // 실제 애플리케이션 모듈 사용
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = moduleFixture.get<PrismaService>(PrismaService);
// 큐 이름 확인 ('code-generation')
generationQueue = moduleFixture.get<Queue>(getQueueToken('code-generation'));
// 테스트 데이터 정리
await prisma.accessCode.deleteMany();
// BatchJob 테이블 이름 확인 필요
// await prisma.batchJob.deleteMany();
await generationQueue.clean(0, 'wait'); // 대기 중인 작업 정리
await generationQueue.clean(0, 'active'); // 활성 작업 정리
await generationQueue.clean(0, 'completed'); // 완료된 작업 정리
await generationQueue.clean(0, 'failed'); // 실패한 작업 정리
// 테스트용 인증 토큰 발급 (실제 인증 방식에 맞게 구현)
authToken = 'test-auth-token'; // 실제 테스트에서는 유효한 토큰 필요
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
// 원본 통합 테스트 유지 (Bulk Generation)
describe('POST /access-codes/bulk', () => {
it('should create a batch job, process it (mocked or real worker), and verify generated codes', async () => {
const count = 5;
const bulkDto = {
count,
type: 'TEST',
issueSourceType: 'SYSTEM',
expirationDate: new Date(Date.now() + 3600000).toISOString(),
sourceSiteId: 'e2e-site',
// TimeMachine 기본값 (false)
};
const response = await request(app.getHttpServer())
.post('/access-codes/bulk')
.set('Authorization', `Bearer ${authToken}`)
.send(bulkDto)
.expect(201);
const { batchId } = response.body;
expect(batchId).toBeDefined();
// 배치 파라미터 확인 (DB 직접 조회 또는 상태 조회 API 확장)
// BatchJob 테이블 이름 확인 필요
// const batchJob = await prisma.batchJob.findUnique({ where: { id: batchId } });
// expect(batchJob).toBeDefined();
// const jobParams = JSON.parse(batchJob!.params as string); // params 필드가 JSON 문자열이라고 가정
// expect(jobParams.useTimeMachineForAll).toBe(true);
// expect(jobParams.commonVirtualTimeStartDate).toBe(startDate.toISOString());
// ... (워커 완료 후 생성된 코드 검증 로직 추가) ...
}, 10000); // 타임아웃 증가
});
// TimeMachine 관련 테스트 추가
describe('POST /access-codes', () => {
it('should generate a single code with TimeMachine enabled', async () => {
const startTimestamp = Date.now() + 10 * 24 * 60 * 60 * 1000; // 10일 후 timestamp
const dto = {
type: 'E2E_TEST_TM',
issueSourceType: 'SYSTEM',
expirationDate: new Date(Date.now() + 3600000).toISOString(),
sourceSiteId: 'e2e-site',
useTimeMachine: true,
virtualTimeStartDate: startTimestamp,
};
const response = await request(app.getHttpServer())
.post('/access-codes')
.set('Authorization', `Bearer ${authToken}`)
.send(dto)
.expect(201); // 상태 코드 확인
expect(response.body.id).toBeDefined();
expect(response.body.code).toHaveLength(16);
expect(response.body.useTimeMachine).toBe(true);
expect(response.body.virtualTimeStartDate).toEqual(startTimestamp);
// DB에서 직접 확인 (선택적)
const dbCode = await prisma.accessCode.findUnique({ where: { id: response.body.id } });
expect(dbCode?.useTimeMachine).toBe(true);
expect(Number(dbCode?.virtualTimeStartDate)).toEqual(startTimestamp);
});
it('should fail to generate a single code if TimeMachine is enabled without start date', async () => {
const dto = {
type: 'E2E_FAIL_TM',
issueSourceType: 'SYSTEM',
expirationDate: new Date(Date.now() + 3600000).toISOString(),
sourceSiteId: 'e2e-site',
useTimeMachine: true,
// virtualTimeStartDate 누락
};
await request(app.getHttpServer())
.post('/access-codes')
.set('Authorization', `Bearer ${authToken}`)
.send(dto)
.expect(400); // BadRequest 예상
});
});
describe('POST /access-codes/bulk (TimeMachine)', () => {
it('should create a batch job with TimeMachine enabled for all', async () => {
const count = 3;
const startTimestamp = Date.now() + 5 * 24 * 60 * 60 * 1000; // 5일 후 timestamp
const bulkDto = {
count,
type: 'TEST_BULK_TM',
issueSourceType: 'SYSTEM',
expirationDate: new Date(Date.now() + 3600000).toISOString(),
sourceSiteId: 'e2e-site',
useTimeMachineForAll: true,
commonVirtualTimeStartDate: startDate.toISOString(),
};
const response = await request(app.getHttpServer())
.post('/access-codes/bulk')
.set('Authorization', `Bearer ${authToken}`)
.send(bulkDto)
.expect(201);
const { batchId } = response.body;
expect(batchId).toBeDefined();
// 배치 파라미터 확인 (DB 직접 조회 또는 상태 조회 API 확장)
// BatchJob 테이블 이름 확인 필요
// const batchJob = await prisma.batchJob.findUnique({ where: { id: batchId } });
// expect(batchJob).toBeDefined();
// const jobParams = JSON.parse(batchJob!.params as string); // params 필드가 JSON 문자열이라고 가정
// expect(jobParams.useTimeMachineForAll).toBe(true);
// expect(jobParams.commonVirtualTimeStartDate).toBe(startDate.toISOString());
// ... (워커 완료 후 생성된 코드 검증 로직 추가) ...
}, 10000);
});
});
8. 모니터링 및 로깅
8.1 로깅
- NestJS의
LoggerService를 사용하여 주요 로직 실행, 오류 발생 시 상세 정보를 로깅합니다. - 대량 생성 작업 시 배치 ID, 처리된 청크 수, 오류 내용 등을 로깅하여 추적 가능성을 높입니다.
8.2 메트릭
- Prometheus 같은 모니터링 도구를 사용하여 코드 생성 관련 메트릭을 수집합니다.
- 단일/대량 코드 생성 요청 수
- 코드 생성 성공/실패율
- 대량 생성 작업 평균 처리 시간
- 큐 대기열 길이
- 코드 중복 발생 및 재시도 횟수
@nestjs/prometheus등의 라이브러리를 활용하여 메트릭 노출 엔드포인트를 구현할 수 있습니다.
9. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-07-25 | bok@weltcorp.com | 최초 작성 |
| ... | ... | ... | ... |
| 0.4.0 | 2025-07-31 | bok@weltcorp.com | CQRS 흐름 설명 및 GenerateCodeHandler 예시 추가, 섹션 구조 개선 |
| 0.5.0 | 2025-08-01 | bok@weltcorp.com | 이벤트 발행 방식을 로컬 EventEmitter 사용으로 변경 (event-system.md 참조) |
| 0.6.0 | 2025-08-02 | bok@weltcorp.com | Access Code 생성 시 TimeMachine 옵션 (useTimeMachine, virtualTimeStartDate) 처리 추가 |