본문으로 건너뛰기

코드 관리 서비스 구현 가이드

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)을 유지하기 위해 이벤트 기반 방식을 사용합니다.

  1. 이벤트 발행: 사용자 등록 성공 등 Access Code 사용이 확정되는 시점에 해당 도메인(예: Auth 컨트롤러)은 user.registered와 같은 이벤트를 발행합니다. 이 이벤트에는 사용된 Access Code와 사용자 ID 등의 정보가 포함됩니다.

    // 예시: Auth 도메인의 회원가입 컨트롤러에서
    // ... 사용자 생성 성공 후 ...
    const eventPayload: UserRegisteredEventPayload = {
    userId: user.id,
    accessCode: dto.accessCode
    };
    this.eventEmitter.emit('user.registered', eventPayload);
  2. 이벤트 리스너 구현: 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
    });
    }
    }
    }
  3. 모듈 설정:

    • 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.02025-05-26bok@weltcorp.com최초 작성
0.2.02025-04-31bok@weltcorp.com표준 문서 형식 적용, 파일 경로 주석 추가, Prisma 스키마 참조 링크 추가