본문으로 건너뛰기

코드 생성 서비스 구현 가이드

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.02025-07-25bok@weltcorp.com최초 작성
............
0.4.02025-07-31bok@weltcorp.comCQRS 흐름 설명 및 GenerateCodeHandler 예시 추가, 섹션 구조 개선
0.5.02025-08-01bok@weltcorp.com이벤트 발행 방식을 로컬 EventEmitter 사용으로 변경 (event-system.md 참조)
0.6.02025-08-02bok@weltcorp.comAccess Code 생성 시 TimeMachine 옵션 (useTimeMachine, virtualTimeStartDate) 처리 추가