코드 관리 서비스 구현 가이드
1. 개요
1.1 목적
본 문서는 Access Code 도메인의 코드 관리 서비스 구현에 대한 기술적인 가이드를 제공합니다. 이 서비스는 생성된 접근 코드의 상태 관리(취소, 차단 해제 해제), 조회(단일, 목록), 만료일 변경 등 코드의 생명주기 후반 단계를 관리하는 API 및 관련 로직을 담당합니다.
1.2 범위
- 코드 관리 서비스 아키텍처 및 의존성
- 코드 조회 API 구현 (ID, 값, 필터 기반)
- 코드 상태 관리 API 구현 (취소, 차단 해제, 만료일 변경)
- 대량 코드 관리(일괄 취소 등) 및 배치 처리 구현
- 코드 내보내기 기능 구현
- 성능 최적화 및 보안 고려사항
- 테스트 전략
1.3 참조 문서
TBD
2. 아키텍처
2.1 컴포넌트 구조 (예상)
libs/feature/access-code/src/lib/
├── application/
│ ├── commands/
│ │ └── handlers/revoke-code.handler.ts
│ │ └── handlers/update-expiration.handler.ts
│ │ └── handlers/revoke-bulk-codes.handler.ts
│ ├── queries/
│ │ └── handlers/get-code-by-id.handler.ts
│ │ └── handlers/get-codes.handler.ts
│ └── services/
│ └── access-code-management-app.service.ts # 컨트롤러와 직접 상호작용
├── domain/
│ ├── services/
│ │ └── code-management.service.ts # 실제 코드 관리 로직
│ ├── entities/
│ │ └── access-code.entity.ts
│ ├── repositories/
│ │ └── access-code.repository.ts
│ │ └── status-history.repository.ts # 상태 변경 이력
│ │ └── usage-history.repository.ts # 사용 이력 (조회 시 사용)
│ └── value-objects/
│ └── code-status.vo.ts
├── infrastructure/
│ ├── adapters/
│ │ └── pubsub.adapter.ts # 이벤트 발행
│ │ └── queue.adapter.ts # 대량 처리용
│ │ └── file-storage.adapter.ts # 내보내기용
│ └── repositories/
│ └── prisma-access-code.repository.ts
│ └── prisma-status-history.repository.ts
│ └── prisma-usage-history.repository.ts
│ └── prisma-batch-job.repository.ts # 대량 작업 상태 관리
└── access-code.module.ts
참고: 실제 경로는 프로젝트 구성에 따라 다를 수 있습니다.
2.2 의존성
- AccessCodeRepository: 코드 데이터 조회 및 업데이트
- StatusHistoryRepository: 코드 상태 변경 이력 기록
- UsageHistoryRepository: 코드 상세 조회 시 사용 이력 포함
- BatchRepository / BatchJobRepository: 대량 작업(취소, 내보내기) 상태 관리
- QueueService: 대량 작업 비동기 처리
- FileStorageAdapter: 코드 내보내기 파일 저장 (예: GCS, S3)
- PubSubClient: 코드 상태 변경 이벤트 발행
- Logger: 작업 로깅
- ConfigService: 설정값(캐시 TTL, 청크 크기 등) 조회
- CacheManager: 조회 성능 향상을 위한 캐싱
3. 데이터 모델
3.1 관련 Prisma 스키마
Access Code 도메인에서 코드 관리와 관련된 주요 엔티티(AccessCode, StatusHistory, UsageHistory, BatchJob 등)의 상세한 Prisma 스키마 정의는 다음 문서를 참조하세요:
4. API 구현
4.1 코드 조회 API
4.1.1 단일 코드 조회 (ID 기준 - GET /v1/access-codes/management/:id)
// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code-management.controller.ts
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import { AuthGuard } from '@core/auth';
import { RolesGuard, Roles, UserRole } from '@core/iam';
import { GetCodeByIdQuery } from '@feature/access-code'; // Query 경로
import { AccessCodeDetailDto } from '@shared-contracts/auth/dto/access-code'; // DTO 경로
@Controller('access-codes/management')
@UseGuards(AuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MANAGER) // 예시 역할
export class AccessCodeManagementController {
constructor(private readonly queryBus: QueryBus) {}
@Get(':id')
async getCodeById(@Param('id') id: string): Promise<AccessCodeDetailDto> {
const query = new GetCodeByIdQuery(id);
return this.queryBus.execute<GetCodeByIdQuery, AccessCodeDetailDto>(query);
}
// ... other endpoints
}
// 파일 경로: libs/feature/access-code/src/lib/application/queries/handlers/get-code-by-id.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetCodeByIdQuery } from '../impl/get-code-by-id.query';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { StatusHistoryRepository } from '../../../domain/repositories/status-history.repository';
import { UsageHistoryRepository } from '../../../domain/repositories/usage-history.repository';
import { AccessCodeDetailDto } from '@shared-contracts/auth/dto/access-code';
import { AccessCodeNotFoundError } from '../../../domain/errors/access-code.errors';
import { AccessCodeMapper } from '../../../infrastructure/mappers/access-code.mapper';
@QueryHandler(GetCodeByIdQuery)
export class GetCodeByIdHandler implements IQueryHandler<GetCodeByIdQuery, AccessCodeDetailDto> {
constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly statusHistoryRepository: StatusHistoryRepository,
private readonly usageHistoryRepository: UsageHistoryRepository,
) {}
async execute(query: GetCodeByIdQuery): Promise<AccessCodeDetailDto> {
const accessCode = await this.accessCodeRepository.findById(query.codeId);
if (!accessCode) {
throw new AccessCodeNotFoundError(query.codeId);
}
// 상세 정보 조회를 위해 관련 이력 로드
const statusHistory = await this.statusHistoryRepository.findByCodeId(query.codeId);
const usageHistory = await this.usageHistoryRepository.findByCodeId(query.codeId);
return AccessCodeMapper.toDetailDto(accessCode, statusHistory, usageHistory);
}
}
4.1.2 단일 코드 조회 (값 기준 - GET /v1/access-codes/management/code/:code)
유사한 CQRS 패턴으로 구현.
GetCodeByValueQuery및 핸들러 필요.
4.1.3 목록 조회 (필터/페이지네이션 - GET /v1/access-codes/management)
// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code-management.controller.ts
import { Controller, Get, Query, UseGuards, ParseIntPipe, DefaultValuePipe } from '@nestjs/common';
// ... other imports
import { GetCodesQuery } from '@feature/access-code'; // Query 경로
import { CodeFilterQueryDto, CodeFilterDto, PaginationDto, PaginatedResultDto, AccessCodeDto } from '@shared-contracts/auth/dto/access-code'; // DTO 경로
@Controller('access-codes/management')
@UseGuards(AuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MANAGER)
export class AccessCodeManagementController {
// ... constructor
@Get()
async getCodes(
@Query() queryDto: CodeFilterQueryDto, // Swagger 등 편의를 위해 별도 Query DTO 사용
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
@Query('sortBy', new DefaultValuePipe('createdAt')) sortBy: string,
@Query('sortDirection', new DefaultValuePipe('DESC')) sortDirection: 'ASC' | 'DESC',
): Promise<PaginatedResultDto<AccessCodeDto>> {
// Query DTO를 Domain/Application에서 사용할 Filter DTO로 변환
const filters: CodeFilterDto = {
status: queryDto.status?.split(',').map(s => s.trim()),
type: queryDto.type?.split(',').map(t => t.trim()),
issuerId: queryDto.issuerId,
sourceId: queryDto.sourceId,
issueSourceType: queryDto.issueSourceType?.split(',').map(t => t.trim()),
createdFrom: queryDto.createdFrom ? new Date(queryDto.createdFrom) : undefined,
createdTo: queryDto.createdTo ? new Date(queryDto.createdTo) : undefined,
expiresFrom: queryDto.expiresFrom ? new Date(queryDto.expiresFrom) : undefined,
expiresTo: queryDto.expiresTo ? new Date(queryDto.expiresTo) : undefined,
};
const pagination: PaginationDto = { page, limit, sortBy, sortDirection };
const query = new GetCodesQuery(filters, pagination);
return this.queryBus.execute<GetCodesQuery, PaginatedResultDto<AccessCodeDto>>(query);
}
// ... other endpoints
}
// 파일 경로: libs/feature/access-code/src/lib/application/queries/handlers/get-codes.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { GetCodesQuery } from '../impl/get-codes.query';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { PaginatedResultDto, AccessCodeDto } from '@shared-contracts/auth/dto/access-code';
import { AccessCodeMapper } from '../../../infrastructure/mappers/access-code.mapper';
@QueryHandler(GetCodesQuery)
export class GetCodesHandler implements IQueryHandler<GetCodesQuery, PaginatedResultDto<AccessCodeDto>> {
constructor(private readonly accessCodeRepository: AccessCodeRepository) {}
async execute(query: GetCodesQuery): Promise<PaginatedResultDto<AccessCodeDto>> {
const { filters, pagination } = query;
const [accessCodes, total] = await this.accessCodeRepository.findByFilters(
filters,
pagination
);
const pages = Math.ceil(total / pagination.limit);
return {
items: accessCodes.map(AccessCodeMapper.toDto), // 기본 DTO로 매핑
total,
page: pagination.page,
limit: pagination.limit,
pages,
};
}
}
4.2 코드 상태 관리 API
4.2.1 코드 취소 (POST /v1/access-codes/management/:id/revoke)
// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code-management.controller.ts
import { Controller, Post, Param, Body, UseGuards } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs';
// ... other imports
import { User } from '@core/auth/decorators';
import { UserDto } from '@shared-contracts/auth/dto';
import { RevokeCodeCommand } from '@feature/access-code'; // Command 경로
import { RevokeCodeBodyDto, AccessCodeDto } from '@shared-contracts/auth/dto/access-code'; // DTO 경로
@Controller('access-codes/management')
@UseGuards(AuthGuard, RolesGuard)
@Roles(UserRole.ADMIN, UserRole.MANAGER)
export class AccessCodeManagementController {
// ... constructor, getCodes
@Post(':id/revoke')
async revokeCode(
@Param('id') id: string,
@Body() body: RevokeCodeBodyDto, // 요청 본문 DTO
@User() user: UserDto, // 요청 사용자 정보
): Promise<AccessCodeDto> {
const command = new RevokeCodeCommand(id, user.id, body.reason);
const updatedAccessCode = await this.commandBus.execute<RevokeCodeCommand, AccessCode>(command); // Domain Entity 반환 가정
return AccessCodeMapper.toDto(updatedAccessCode); // DTO로 변환하여 반환
}
// ... other endpoints
}
// 파일 경로: libs/feature/access-code/src/lib/application/commands/handlers/revoke-code.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { RevokeCodeCommand } from '../impl/revoke-code.command';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { StatusHistoryRepository } from '../../../domain/repositories/status-history.repository';
import { AccessCode } from '../../../domain/entities/access-code.entity';
import { CodeStatus } from '../../../domain/value-objects/code-status.vo';
import { Logger } from '@nestjs/common';
import {
AccessCodeNotFoundError,
InvalidCodeStateError,
AccessCodeServerError
} from '../../../domain/errors/access-code.errors';
@CommandHandler(RevokeCodeCommand)
export class RevokeCodeHandler implements ICommandHandler<RevokeCodeCommand, AccessCode> {
private readonly logger = new Logger(RevokeCodeHandler.name);
constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly statusHistoryRepository: StatusHistoryRepository,
) {}
async execute(command: RevokeCodeCommand): Promise<AccessCode> {
const { codeId, adminId, reason } = command;
this.logger.log(`Attempting to revoke code: ${codeId} by admin: ${adminId}`);
const accessCode = await this.accessCodeRepository.findById(codeId);
if (!accessCode) {
throw new AccessCodeNotFoundError(codeId);
}
try {
const originalStatus = accessCode.getStatus.value;
accessCode.revoke(adminId, reason);
const savedCode = await this.accessCodeRepository.save(accessCode);
await this.statusHistoryRepository.create({
codeId: savedCode.getId,
fromStatus: originalStatus,
toStatus: CodeStatus.REVOKED.value,
changedBy: adminId,
changedAt: new Date(),
reason: reason,
});
this.logger.log(`Code ${codeId} revoked successfully by ${adminId}`);
return savedCode;
} catch (error) {
if (error instanceof InvalidCodeStateError) {
this.logger.warn(`Failed to revoke code ${codeId}: ${error.message}`);
throw error;
}
this.logger.error(`Error revoking code ${codeId}: ${error.message}`, error.stack);
throw new AccessCodeServerError(error, { operation: 'revokeCode', codeId });
}
}
}
4.2.2 코드 사용 처리 (이벤트 기반)
Access Code가 실제로 사용되는 시점(예: 사용자 회원 가입 완료)은 외부 도메인(Auth 또는 User 도메인)에서 발생합니다. 이 때, 해당 도메인과 Access Code 도메인 간의 직접적인 의존성을 줄이고 느슨한 결합(loose coupling)을 유지하기 위해 이벤트 기반 방식을 사용합니다.
-
이벤트 발행: 사용자 등록 성공 등 Access Code 사용이 확정되는 시점에 해당 도메인(예: Auth 컨트롤러)은
user.registered와 같은 이벤트를 발행합니다. 이 이벤트에는 사용된 Access Code와 사용자 ID 등의 정보가 포함됩니다.// 예시: Auth 도메인의 회원가입 컨트롤러에서
// ... 사용자 생성 성공 후 ...
const eventPayload: UserRegisteredEventPayload = {
userId: user.id,
accessCode: dto.accessCode
};
this.eventEmitter.emit('user.registered', eventPayload); -
이벤트 리스너 구현: Access Code 도메인 내부에
@nestjs/event-emitter의@OnEvent()데코레이터를 사용하여 해당 이벤트를 수신하는 리스너를 구현합니다. 이 리스너는AccessCodeService(또는 CommandBus 주입 후 UseCodeCommand 사용)를 호출하여 코드 상태를 'USED'로 변경하고 관련 이력을 기록합니다.// 파일 경로: libs/feature/access-code/src/lib/listeners/access-code.listener.ts (예시)
import { Injectable, Inject } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { UserRegisteredEventPayload } from '@shared-contracts/auth/dto'; // 이벤트 페이로드 DTO 경로
import { AccessCodeService } from '../services/access-code.service'; // 또는 CommandBus 주입 후 UseCodeCommand 사용
import { ErrorLoggerService } from '@core/error-handling';
@Injectable()
export class AccessCodeListener {
constructor(
@Inject(AccessCodeService) private readonly accessCodeService: AccessCodeService,
private readonly errorLogger: ErrorLoggerService
) {}
@OnEvent('user.registered')
async handleUserRegisteredEvent(payload: UserRegisteredEventPayload): Promise<void> {
try {
this.errorLogger.logInfo('Handling user.registered event for AccessCode', {
context: AccessCodeListener.name,
userId: payload.userId,
});
await this.accessCodeService.markCodeAsUsed(payload.accessCode, payload.userId);
} catch (error) {
this.errorLogger.logError(error as Error, {
context: AccessCodeListener.name,
message: 'Failed to mark access code as used after user registration event',
eventPayload: payload
});
}
}
} -
모듈 설정:
AccessCodeListener클래스는@Injectable()데코레이터를 가지고AccessCodeModule의providers배열에 등록되어야 합니다.- 애플리케이션 루트 모듈(
AppModule) 또는AccessCodeModule에서EventEmitterModule.forRoot()또는.forFeature()를 임포트해야 합니다.
이 방식을 통해 Access Code 도메인은 외부 도메인의 구체적인 프로세스(회원가입 등)를 알 필요 없이, 정의된 이벤트(user.registered)에만 반응하여 자신의 책임(코드 사용 처리)을 수행할 수 있습니다.
4.2.3 코드 상태 변경 (차단 해제, 만료일 변경 등)
기존의
POST /v1/access-codes/management/:id/unblock,PATCH /v1/access-codes/management/:id/expiration등의 API 구현 방식과 유사하게 CQRS 패턴(Command/Handler)을 사용하여 구현합니다.
4.2.4 대량 코드 취소 (POST /v1/access-codes/management/bulk-revoke)
CQRS 패턴을 사용하여
RevokeBulkCodesCommand및 핸들러 구현. 핸들러 내에서는 대량 처리를 위해 비동기 큐(BullMQ 등)에 작업을 위임하거나,BulkOperationService같은 유틸리티 서비스를 사용하여 청크 단위로RevokeCodeCommand를 실행.
4.3 배치 작업 관리 API
4.3.1 배치 상태 조회 (GET /v1/access-codes/management/batch/:batchId)
GetBatchStatusQuery및 핸들러 사용.BatchJobRepository에서 상태 조회.
4.3.2 배치 작업 취소 (POST /v1/access-codes/management/batch/:batchId/cancel)
CancelBatchJobCommand및 핸들러 구현.QueueService를 통해 작업 취소 요청 및BatchJobRepository상태 업데이트.
4.4 코드 내보내기 API
4.4.1 내보내기 시작 (POST /v1/access-codes/management/export)
StartExportCommand및 핸들러 구현. 내보내기 작업을 비동기 큐에 추가하고 작업 ID 반환.
4.4.2 내보내기 상태 조회 (GET /v1/access-codes/management/export/:exportId)
GetExportStatusQuery및 핸들러 사용.BatchJobRepository(또는 별도 ExportJob 테이블)에서 상태 조회.
4.4.3 내보내기 파일 다운로드 (GET /v1/access-codes/management/export/:exportId/download)
GetExportDownloadUrlQuery및 핸들러 사용.FileStorageAdapter를 통해 서명된 URL 등 생성.
5. 성능 최적화
5.1 조회 성능 최적화 (Caching, DB Indexing)
// 파일 경로: libs/feature/access-code/src/lib/infrastructure/repositories/prisma-access-code.repository.ts
import { Injectable, Inject } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { ConfigService } from '@nestjs/config';
import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; // cache-manager 사용 예시
// ... other imports
@Injectable()
export class PrismaAccessCodeRepository implements AccessCodeRepository {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) {}
// ID로 조회 시 캐싱 적용 예시
async findById(id: string): Promise<AccessCode | null> {
const cacheKey = `access_code:id:${id}`;
const cached = await this.cacheManager.get<string>(cacheKey);
if (cached) {
try {
return AccessCodeMapper.toDomain(JSON.parse(cached)); // 역직렬화
} catch (e) { /* logger.warn(...) */ }
}
const result = await this.prisma.accessCode.findUnique({
where: { id },
include: { /* 필요한 최소 관계 */ },
});
if (!result) return null;
const entity = AccessCodeMapper.toDomain(result);
const ttl = this.configService.get<number>('cache.accessCodeTtl', 300) * 1000; // ms 단위
await this.cacheManager.set(cacheKey, JSON.stringify(result), ttl); // 직렬화하여 저장
return entity;
}
// 필터링 조회 최적화 (DB 인덱싱 필수)
async findByFilters(
filters: CodeFilterDto,
pagination: PaginationDto,
): Promise<[AccessCode[], number]> {
const where = AccessCodeMapper.buildFilterConditions(filters); // 매퍼에서 조건 생성
const skip = (pagination.page - 1) * pagination.limit;
const take = pagination.limit;
const orderBy = AccessCodeMapper.buildOrderByCondition(pagination);
// DB 호출 최적화 (count와 findMany 병렬 실행)
const [results, total] = await this.prisma.$transaction([
this.prisma.accessCode.findMany({
where,
skip,
take,
orderBy,
include: { /* 필요한 최소 관계 (예: issuer) */ },
}),
this.prisma.accessCode.count({ where }),
]);
return [results.map(AccessCodeMapper.toDomain), total];
}
// ... 기타 메소드
}
DB 인덱싱:
status,type,issueSourceType,issuerAccountId,sourceSiteId,expirationDate,createdAt등 자주 사용되는 필터 조건 및 정렬 기준 컬럼에 인덱스를 생성해야 합니다.
5.2 일괄 처리 최적화 (Chunking, Concurrency)
// 파일 경로: libs/feature/access-code/src/lib/application/services/bulk-operation.service.ts (가정)
import { Injectable, Logger } from '@nestjs/common';
import { PromisePool } from '@supercharge/promise-pool'; // 동시성 제어 라이브러리 예시
@Injectable()
export class BulkOperationService {
private readonly logger = new Logger(BulkOperationService.name);
// 청크 및 동시성 제어를 이용한 대량 작업 처리
async processBulkOperation<InputType, OutputType>(
items: InputType[],
operation: (item: InputType) => Promise<OutputType>,
options: {
chunkSize?: number;
concurrency?: number;
onProgress?: (processed: number, total: number) => void | Promise<void>;
} = {}
): Promise<{ results: (OutputType | { item: InputType; error: string })[], success: number; failure: number }> {
const concurrency = options.concurrency || 10; // 동시 작업 수
let processed = 0;
let success = 0;
let failure = 0;
const total = items.length;
const { results, errors } = await PromisePool
.for(items)
.withConcurrency(concurrency)
.process(async (item) => {
try {
const result = await operation(item);
success++;
return result;
} catch (error) {
failure++;
this.logger.error(`Error processing item: ${error.message}`, error.stack); // 개별 항목 ID 로깅 필요
// 오류 발생 시 item 정보와 에러 메시지를 포함하여 반환
return { item, error: error.message };
} finally {
processed++;
if (options.onProgress && (processed % (options.chunkSize || 100) === 0 || processed === total)) {
await options.onProgress(processed, total);
}
}
});
// 에러가 발생한 경우 결과 배열에서 분리
const successResults = results.filter(r => !(r && typeof r === 'object' && 'error' in r));
const failureResults = results.filter(r => r && typeof r === 'object' && 'error' in r) as { item: InputType; error: string }[];
return { results: [...successResults, ...failureResults], success, failure };
}
}
revokeCodes등의 핸들러에서 이BulkOperationService를 사용하여 실제 코드 취소 로직(revokeCode호출 등)을 병렬 및 청크 단위로 실행할 수 있습니다.
6. 보안 고려사항
6.1 접근 제어 (Role-Based Access Control)
- NestJS의
@UseGuards(RolesGuard)와@Roles(...)데코레이터를 사용하여 엔드포인트별로 필요한 역할(ADMIN, MANAGER 등)을 명시합니다. core/iam또는core/auth모듈의 공통 가드를 활용합니다.
6.2 데이터 마스킹
- 코드 조회 API 응답 시, 실제 코드 값(
code)은 부분 마스킹 처리하여 반환합니다. 민감할 수 있는 메타데이터 필드도 필요시 마스킹합니다. - 공통 보안 인터셉터(
DataMaskingInterceptor)를 사용하여 응답 데이터를 자동으로 마스킹할 수 있습니다.
// 파일 경로: apps/dta-wide-api/src/app/access-code/interceptors/access-code-masking.interceptor.ts (예시)
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class AccessCodeMaskingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
map(data => this.maskData(data))
);
}
private maskData(data: any): any {
if (Array.isArray(data)) {
return data.map(item => this.maskData(item));
} else if (data && typeof data === 'object') {
// PaginatedResultDto 형식 처리
if (data.items && Array.isArray(data.items)) {
return { ...data, items: data.items.map(item => this.maskData(item)) };
}
// AccessCodeDto 또는 AccessCodeDetailDto 처리
if (data.code && typeof data.code === 'string') {
const maskedCode = data.code.length > 8
? `${data.code.substring(0, 4)}****${data.code.substring(data.code.length - 4)}`
: '********';
return { ...data, code: maskedCode };
}
// 재귀적으로 내부 객체 처리 (필요시)
// for (const key in data) {
// if (Object.prototype.hasOwnProperty.call(data, key)) {
// data[key] = this.maskData(data[key]);
// }
// }
}
return data;
}
}
이 인터셉터는
CodeManagementController또는 전역으로 적용될 수 있습니다.
6.3 감사 로깅 (Audit Logging)
- 코드 상태 변경(취소, 차단 해제, 만료일 변경) 시, 어떤 관리자(
adminId)가 언제, 어떤 사유(reason)로 변경했는지 상세히 기록합니다 (StatusHistory테이블 활용). - 민감한 작업(대량 취소, 내보내기)에 대해서는 별도의 감사 로그 시스템(Audit Domain 등)에 로그를 전송하는 것을 고려합니다.
7. 테스트
7.1 단위 테스트 (Unit Tests)
- 각 서비스, 핸들러, 리포지토리에 대해 의존성을 Mocking하여 개별 로직을 테스트합니다.
- 특히 상태 변경 로직, 필터 조건 생성, 데이터 매핑 로직의 정확성을 검증합니다.
- 역할 기반 접근 제어 로직(예:
canRevoke등)을 테스트합니다.
// 파일 경로: libs/feature/access-code/src/lib/application/commands/handlers/revoke-code.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { RevokeCodeHandler } from './revoke-code.handler';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { StatusHistoryRepository } from '../../../domain/repositories/status-history.repository';
import { AccessCode } from '../../../domain/entities/access-code.entity';
import { CodeStatus } from '../../../domain/value-objects/code-status.vo';
import { RevokeCodeCommand } from '../impl/revoke-code.command';
import { AccessCodeNotFoundError } from '../../../domain/errors/access-code.errors';
type MockRepository<T = any> = Partial<Record<keyof T, jest.Mock>>;
describe('RevokeCodeHandler', () => {
let handler: RevokeCodeHandler;
let accessCodeRepository: MockRepository<AccessCodeRepository>;
let statusHistoryRepository: MockRepository<StatusHistoryRepository>;
beforeEach(async () => {
accessCodeRepository = {
findById: jest.fn(),
save: jest.fn(),
};
statusHistoryRepository = {
create: jest.fn(),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
RevokeCodeHandler,
{ provide: AccessCodeRepository, useValue: accessCodeRepository },
{ provide: StatusHistoryRepository, useValue: statusHistoryRepository },
// Logger Mock 추가 필요
],
}).compile();
handler = module.get<RevokeCodeHandler>(RevokeCodeHandler);
});
it('should revoke an active code successfully', async () => {
const codeId = 'test-id';
const adminId = 'admin-user';
const reason = 'Test revoke';
const mockCode = new AccessCode({ /* ACTIVE 상태의 목 데이터 */ id: codeId, status: CodeStatus.ACTIVE.value /*...*/ });
jest.spyOn(mockCode, 'revoke'); // 엔티티 메소드 spy
accessCodeRepository.findById.mockResolvedValue(mockCode);
accessCodeRepository.save.mockResolvedValue(mockCode); // 저장 후 엔티티 반환
const command = new RevokeCodeCommand(codeId, adminId, reason);
await handler.execute(command);
expect(accessCodeRepository.findById).toHaveBeenCalledWith(codeId);
expect(mockCode.revoke).toHaveBeenCalledWith(adminId, reason);
expect(accessCodeRepository.save).toHaveBeenCalledWith(mockCode);
expect(statusHistoryRepository.create).toHaveBeenCalledWith(expect.objectContaining({
codeId,
fromStatus: CodeStatus.ACTIVE.value,
toStatus: CodeStatus.REVOKED.value,
changedBy: adminId,
reason,
}));
});
it('should throw NotFoundException if code does not exist', async () => {
accessCodeRepository.findById.mockResolvedValue(null);
const command = new RevokeCodeCommand('not-found-id', 'admin', 'test');
await expect(handler.execute(command)).rejects.toThrow(AccessCodeNotFoundError);
});
it('should throw BadRequestException if code cannot be revoked', async () => {
const mockCode = new AccessCode({ /* USED 상태의 목 데이터 */ id: 'used-id', status: CodeStatus.USED.value /*...*/ });
// revoke 메소드가 예외를 던지도록 mocking (또는 canRevoke 같은 검증 메소드 mocking)
jest.spyOn(mockCode, 'revoke').mockImplementation(() => {
throw new InvalidCodeStateError('Cannot revoke code with status USED');
});
accessCodeRepository.findById.mockResolvedValue(mockCode);
const command = new RevokeCodeCommand('used-id', 'admin', 'test');
await expect(handler.execute(command)).rejects.toThrow(InvalidCodeStateError);
});
});
7.2 통합 테스트 (Integration Tests)
- 실제 DB와 연결된 테스트 환경에서 컨트롤러 엔드포인트를 호출하여 API 요청부터 DB 상태 변경까지 전체 흐름을 검증합니다.
- 필터링 및 페이지네이션 기능이 DB 쿼리와 함께 올바르게 동작하는지 확인합니다.
- 상태 변경 API 호출 후, 해당 코드의 상태와 상태 변경 이력이 정확히 기록되었는지 DB에서 확인합니다.
- 대량 처리 API의 경우, 작업 완료 후 성공/실패 건수가 예상과 일치하는지 확인합니다.
- 역할 기반 접근 제어가 올바르게 동작하는지, 다른 역할의 사용자가 접근 시 403 Forbidden 에러가 발생하는지 확인합니다.
// 파일 경로: apps/dta-wide-api/test/access-code-management.e2e-spec.ts
import * as request from 'supertest';
import { Test } from '@nestjs/testing';
import { INestApplication } from '@nestjs/common';
import { PrismaService } from '@core/database';
import { createTestUserToken } from './utils/auth.utils'; // 테스트 유틸리티
import { UserRole } from '@core/iam';
describe('AccessCodeManagementController (e2e)', () => {
let app: INestApplication;
let prisma: PrismaService;
let adminToken: string;
let managerToken: string;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
prisma = app.get(PrismaService);
// 테스트 토큰 생성
adminToken = await createTestUserToken(app, { roles: [UserRole.ADMIN] });
managerToken = await createTestUserToken(app, { roles: [UserRole.MANAGER] });
// 테스트 데이터 초기화
await prisma.statusHistory.deleteMany();
await prisma.accessCode.deleteMany();
});
afterAll(async () => {
await prisma.$disconnect();
await app.close();
});
it('should allow ADMIN to revoke a code', async () => {
// 1. 테스트용 코드 생성
const code = await prisma.accessCode.create({
data: { /* 필요한 데이터 */ code: 'REVOKE_TEST', status: 'ACTIVE', /*...*/ },
});
// 2. 취소 API 호출
await request(app.getHttpServer())
.post(`/access-codes/management/${code.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ reason: 'E2E Test Revoke' })
.expect(200);
// 3. DB 상태 검증
const updatedCode = await prisma.accessCode.findUnique({ where: { id: code.id } });
expect(updatedCode?.status).toBe('REVOKED');
const history = await prisma.statusHistory.findFirst({ where: { accessCodeId: code.id } });
expect(history?.toStatus).toBe('REVOKED');
expect(history?.reason).toBe('E2E Test Revoke');
});
it('should allow MANAGER to get codes list', async () => {
await request(app.getHttpServer())
.get('/access-codes/management?limit=5&status=ACTIVE')
.set('Authorization', `Bearer ${managerToken}`)
.expect(200)
.then(res => {
expect(res.body.items).toBeDefined();
expect(res.body.total).toBeDefined();
expect(res.body.limit).toBe(5);
});
});
// ... 추가적인 상태 변경, 권한 테스트 케이스
});
8. 변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-05-26 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-04-31 | bok@weltcorp.com | 표준 문서 형식 적용, 파일 경로 주석 추가, Prisma 스키마 참조 링크 추가 |