본문으로 건너뛰기

Access Code 검증 및 사용 처리 구현 가이드 (CQRS Command)

1. 개요

1.1 목적

본 문서는 Access Code 도메인의 코드 검증 및 사용 처리 기능 구현에 대한 가이드라인을 제공합니다. 이 기능은 CQRS 패턴의 Command(VerifyAccessCodeCommand)를 사용하여 구현됩니다. 핸들러(VerifyAccessCodeHandler)는 제출된 접근 코드의 유효성(존재 여부, 상태, 만료일)을 검증하고, 유효한 경우 해당 코드의 사용 상태를 기록하는 핵심 로직을 담당합니다.

1.2 범위

  • 코드 검증/사용 Command 및 Handler의 구조
  • 단일 코드 검증/사용 API 구현 (Controller, CommandBus)
  • 코드 유효성 검증 로직 (상태, 만료일 등)
  • 코드 사용 처리 (상태 업데이트) 및 이력 기록
  • 테스트 전략 (단위 테스트, 통합 테스트)

1.3 참조 문서

TBD

2. 아키텍처 (CQRS Command 적용)

2.1 컴포넌트 구조

libs/feature/access-code/src/lib/
├── application/
│ ├── commands/
│ │ ├── impl/ # Command 정의
│ │ │ └── verify-access-code.command.ts
│ │ └── handlers/ # Command Handler 정의
│ │ └── verify-access-code.handler.ts
│ └── queries/ # Query 관련 (별도 문서 참조)
├── domain/
│ ├── entities/
│ │ └── access-code.entity.ts
│ ├── repositories/
│ │ └── access-code.repository.ts
│ │ └── usage-history.repository.ts
│ └── value-objects/
│ │ └── code-status.vo.ts
│ └── errors/ # 도메인 에러
│ └── access-code.errors.ts
├── infrastructure/
│ └── repositories/
│ └── prisma-access-code.repository.ts
│ └── prisma-usage-history.repository.ts
└── access-code.module.ts

apps/dta-wide-api/src/app/access-code/
└── controllers/
└── access-code-verification.controller.ts # CommandBus 사용

2.2 의존성 (Handler 기준)

  • AccessCodeRepository: 코드 조회 및 상태 업데이트
  • UsageHistoryRepository: 검증 및 사용 이력 기록
  • Logger: 작업 로깅

3. 데이터 모델

3.1 관련 Prisma 스키마

Access Code 도메인에서 코드 검증과 관련된 주요 엔티티(AccessCode, UsageHistory 등)의 상세한 Prisma 스키마 정의는 다음 문서를 참조하세요:

4. Access Code 정보 조회 (CQRS Query)

다른 도메인(예: auth)에서 Access Code의 현재 상태(유효성, 만료 여부 등)를 단순히 조회해야 할 때 사용하는 Query입니다. 이 Query는 코드 상태를 변경하지 않습니다.

4.1 GetAccessCodeDetailsQuery 정의

Access Code 문자열을 입력받아 해당 코드의 상세 정보 조회를 요청하는 쿼리 객체입니다.

// 파일 경로 예시: libs/shared/queries/src/lib/access-code/get-access-code-details.query.ts
// 또는 libs/feature/access-code/src/lib/application/queries/impl/get-access-code-details.query.ts
export class GetAccessCodeDetailsQuery {
constructor(public readonly code: string) {}
}

4.2 AccessCodeDetailsDto 정의 (반환 타입)

GetAccessCodeDetailsHandler가 반환할 데이터 전송 객체(DTO)입니다. auth 도메인 등 외부에서 Access Code 정보를 조회할 때 필요한 최소한의 정보를 포함합니다. 엔티티 전체를 노출하는 대신 DTO를 사용하여 도메인 간 결합도를 낮춥니다.

// 파일 경로 예시: libs/shared/queries/src/lib/access-code/access-code-details.dto.ts
// (GetAccessCodeDetailsQuery와 같은 위치 또는 별도 DTO 파일)
export interface AccessCodeDetailsDto {
id: string;
status: string; // 예: 'ACTIVE', 'USED', 'EXPIRED', 'BLOCKED'
expiresAt: Date;
isUsed: boolean;
// 필요에 따라 추가 필드 (예: createdAt, metadata 등)
}

4.3 GetAccessCodeDetailsHandler 정의

GetAccessCodeDetailsQuery를 처리하여 AccessCodeRepository를 통해 데이터를 조회하고, 결과를 AccessCodeDetailsDto 형태로 변환하여 반환합니다.

// 파일 경로: libs/feature/access-code/src/lib/application/queries/handlers/get-access-code-details.handler.ts
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { Inject } from '@nestjs/common';
import { GetAccessCodeDetailsQuery } from '../impl/get-access-code-details.query'; // Query 경로 확인
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository'; // Domain Repository Interface
import { AccessCode } from '../../../domain/entities/access-code.entity'; // Domain Entity
import { Logger } from '@nestjs/common'; // 또는 LoggerService 사용
import { AccessCodeDetailsDto } from '../dtos/access-code-details.dto'; // DTO 임포트 경로 확인

@QueryHandler(GetAccessCodeDetailsQuery)
export class GetAccessCodeDetailsHandler implements IQueryHandler<GetAccessCodeDetailsQuery, AccessCodeDetailsDto | null> { // 반환 타입 DTO로 변경
private readonly logger = new Logger(GetAccessCodeDetailsHandler.name);

constructor(
@Inject(AccessCodeRepository) // 실제 Repository 토큰 사용 확인
private readonly repository: AccessCodeRepository,
// 필요 시 LoggerService 주입
) {}

async execute(query: GetAccessCodeDetailsQuery): Promise<AccessCodeDetailsDto | null> {
const normalizedCode = this.normalizeCode(query.code);
this.logger.log(`Executing GetAccessCodeDetailsQuery for code prefix: ${normalizedCode.substring(0, 4)}...`);

const accessCodeEntity = await this.repository.findByCode(normalizedCode);

if (!accessCodeEntity) {
this.logger.warn(`Access code not found for prefix: ${normalizedCode.substring(0, 4)}`);
return null;
}

// 엔티티를 DTO로 매핑
const dto: AccessCodeDetailsDto = {
id: accessCodeEntity.getId,
status: accessCodeEntity.getStatus.value,
expiresAt: accessCodeEntity.getExpirationDate,
isUsed: accessCodeEntity.getStatus.equals(CodeStatus.USED),
// 필요 시 다른 필드 매핑 추가
};

this.logger.log(`Returning AccessCodeDetailsDto for codeId: ${dto.id}`);
return dto;
}

// 코드 정규화 (Verify 핸들러와 중복 시 유틸리티 함수로 분리 고려)
private normalizeCode(code: string): string {
return code.trim().toUpperCase();
}
}

5. 코드 검증 및 사용 처리 (CQRS Command)

5.1 VerifyAccessCodeCommand 정의

코드 검증 및 사용 처리를 요청하는 Command 객체입니다.

// 파일 경로: libs/feature/access-code/src/lib/application/commands/impl/verify-access-code.command.ts
export class VerifyAccessCodeCommand {
constructor(
public readonly code: string,
public readonly ipAddress?: string,
public readonly userAgent?: string,
) {}
}

5.2 VerifyAccessCodeHandler 정의

VerifyAccessCodeCommand를 처리하여 실제 코드 검증, 상태 업데이트, 이력 기록 로직을 수행합니다. 성공 시 사용된 코드 ID를 반환합니다.

// 파일 경로: libs/feature/access-code/src/lib/application/commands/handlers/verify-access-code.handler.ts
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { Injectable, Logger } from '@nestjs/common';
import { VerifyAccessCodeCommand } from '../impl/verify-access-code.command';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { UsageHistoryRepository } from '../../../domain/repositories/usage-history.repository';
import { AccessCode } from '../../../domain/entities/access-code.entity';
import { CodeStatus } from '../../../domain/value-objects/code-status.vo';
// 도메인 특화 오류 클래스 import
import {
InvalidAccessCodeError,
AccessCodeExpiredError,
AccessCodeBlockedError,
AccessCodeAlreadyUsedError,
AccessCodeRevokedError,
} from '../../../domain/errors/access-code.errors';

@CommandHandler(VerifyAccessCodeCommand)
export class VerifyAccessCodeHandler implements ICommandHandler<VerifyAccessCodeCommand, string> { // 반환 타입: codeId (string)
private readonly logger = new Logger(VerifyAccessCodeHandler.name);

constructor(
private readonly accessCodeRepository: AccessCodeRepository,
private readonly usageHistoryRepository: UsageHistoryRepository,
) {}

async execute(command: VerifyAccessCodeCommand): Promise<string> {
const { code, ipAddress, userAgent } = command;
const normalizedCode = this.normalizeCode(code);
let accessCode: AccessCode | null = null;

try {
// 1. 코드 조회
accessCode = await this.accessCodeRepository.findByCode(normalizedCode);

// 2. 코드 존재 여부 확인
if (!accessCode) {
// 실패 이력 기록 추가
await this.recordFailureHistory(normalizedCode, 'NOT_FOUND', ipAddress, userAgent);
throw new InvalidAccessCodeError({ attemptedCode: normalizedCode });
}

// 3. 코드 유효성 검증 (상태, 만료일 등)
const validationErrorReason = this.validateCodeLogic(accessCode);
if (validationErrorReason) {
// 실패 이력 기록 추가
await this.recordFailureHistory(normalizedCode, validationErrorReason.toUpperCase().replace(' ', '_'), ipAddress, userAgent, accessCode.getId);
this.throwValidationError(validationErrorReason, accessCode.getId);
}

// 4. 코드 사용 처리 (상태 업데이트)
accessCode.use(); // 도메인 엔티티 메소드 호출
const savedCode = await this.accessCodeRepository.save(accessCode);

// 5. 성공 이력 기록
await this.recordSuccessHistory(savedCode, ipAddress, userAgent);

this.logger.log(`Code verified and used successfully: ${savedCode.getId}`);
return savedCode.getId; // 성공 시 Code ID 반환

} catch (error) {
// 핸들러에서 발생한 도메인 에러 또는 예측 못한 시스템 에러는 Global Exception Filter에서 처리하도록 그대로 전파
this.logger.error(`Error during VerifyAccessCodeCommand for code prefix ${normalizedCode.substring(0, 4)}: ${error.message}`, error.stack);
throw error;
}
}

// 코드 유효성 검증 로직
private validateCodeLogic(accessCode: AccessCode, currentTime: Date = new Date()): string | null {
// 차단 상태 확인
if (accessCode.getStatus.equals(CodeStatus.BLOCKED)) {
return 'Code blocked';
}
// 만료 확인
if (currentTime >= accessCode.getExpirationDate) {
return 'Code expired';
}
// 사용 완료 상태 확인
if (accessCode.getStatus.equals(CodeStatus.USED)) {
return 'Code already used';
}
// 취소 상태 확인
if (accessCode.getStatus.equals(CodeStatus.REVOKED)) {
return 'Code revoked';
}
// 사용 가능한 상태 확인 (ACTIVE 또는 CREATED)
if (!accessCode.getStatus.equals(CodeStatus.ACTIVE) && !accessCode.getStatus.equals(CodeStatus.CREATED)) {
return `Code in invalid state (status: ${accessCode.getStatus.value})`;
}

// 시작일 확인 (필요시)
const startDate = accessCode.getStartDate;
if (startDate && currentTime < startDate) {
return 'Code not yet valid (start date)';
}

return null; // 유효함
}

// 코드 정규화
private normalizeCode(code: string): string {
return code.trim().toUpperCase();
}

// 성공 이력 기록
private async recordSuccessHistory(accessCode: AccessCode, ipAddress?: string, userAgent?: string): Promise<void> {
try {
await this.usageHistoryRepository.create({
codeId: accessCode.getId,
usedAt: accessCode.getUsedAt || new Date(),
ipAddress: ipAddress,
userAgent: userAgent,
result: 'SUCCESS',
});
} catch (e) {
this.logger.error(`Failed to record success usage history for code ${accessCode.getId}: ${e.message}`, e.stack);
// 이력 기록 실패가 주 로직 실패를 의미하진 않으므로 에러를 다시 throw하지 않음
}
}

// 실패 이력 기록
private async recordFailureHistory(attemptedCode: string, reason: string, ipAddress?: string, userAgent?: string, codeId?: string): Promise<void> {
try {
await this.usageHistoryRepository.create({
codeId: codeId, // codeId가 있을 경우 기록
attemptedCode: codeId ? undefined : attemptedCode, // 코드를 찾지 못한 경우 시도된 코드 기록
usedAt: new Date(),
ipAddress: ipAddress,
userAgent: userAgent,
result: 'FAILURE',
failureReason: reason,
});
} catch (e) {
this.logger.error(`Failed to record failure usage history for attempted code ${this.maskCode(attemptedCode)}: ${e.message}`, e.stack);
}
}

// 코드 마스킹 (로깅용)
private maskCode(code: string): string {
if (!code || code.length < 8) return '****';
return `${code.substring(0, 4)}****${code.substring(code.length - 4)}`;
}

// validationErrorReason에 따라 적절한 도메인 에러 throw
private throwValidationError(reason: string, codeId: string): never {
if (reason === 'Code blocked') throw new AccessCodeBlockedError({ codeId });
if (reason === 'Code expired') throw new AccessCodeExpiredError({ codeId });
if (reason === 'Code already used') throw new AccessCodeAlreadyUsedError({ codeId });
if (reason === 'Code revoked') throw new AccessCodeRevokedError({ codeId });
// 기타 상태 및 시작일 관련 에러 처리
throw new InvalidAccessCodeError({ codeId, reason });
}
}

5.3 API Controller 수정

Controller는 CommandBus를 주입받아 VerifyAccessCodeCommand를 실행합니다.

// 파일 경로: apps/dta-wide-api/src/app/access-code/controllers/access-code-verification.controller.ts
import { Controller, Post, Body, Req } from '@nestjs/common';
import { CommandBus } from '@nestjs/cqrs'; // CommandBus import
import { Request } from 'express';
// import { AccessCodeVerificationService } from '@feature/access-code'; // Service 제거
import { VerifyCodeDto, VerificationResponseDto } from '@shared-contracts/auth/dto/access-code'; // DTO 경로
import { VerifyAccessCodeCommand } from '@feature/access-code'; // Command import (경로 확인)

@Controller('v1/access-codes') // API 버전 명시 (예시)
export class AccessCodeVerificationController {
constructor(
// private readonly verificationService: AccessCodeVerificationService // Service 주입 제거
private readonly commandBus: CommandBus, // CommandBus 주입
) {}

@Post('verify')
async verifyCode(
@Body() dto: VerifyCodeDto,
@Req() request: Request,
): Promise<VerificationResponseDto> {

const command = new VerifyAccessCodeCommand(
dto.code,
request.ip, // 실제 IP 추출 로직 확인 (e.g., X-Forwarded-For 헤더 등)
request.headers['user-agent'],
);

// CommandBus를 통해 Command 실행, 핸들러의 반환값(codeId)을 받음
const codeId = await this.commandBus.execute<VerifyAccessCodeCommand, string>(command);

// 성공 응답 반환 (핸들러에서 에러 발생 시 GlobalExceptionFilter 처리)
return {
success: true,
codeId: codeId,
};
}
}

5.4 모듈 설정

AccessCodeModuleVerifyAccessCodeHandlerGetAccessCodeDetailsHandler를 등록하고 CqrsModule을 import 합니다.

// 파일 경로: libs/feature/access-code/src/lib/access-code.module.ts (일부)
import { Module } from '@nestjs/common';
import { CqrsModule } from '@nestjs/cqrs'; // CQRS 모듈 import
import { AccessCodeRepository } from './domain/repositories/access-code.repository';
import { UsageHistoryRepository } from './domain/repositories/usage-history.repository';
import { PrismaAccessCodeRepository } from './infrastructure/repositories/prisma-access-code.repository';
import { PrismaUsageHistoryRepository } from './infrastructure/repositories/prisma-usage-history.repository';
import { VerifyAccessCodeHandler } from './application/commands/handlers/verify-access-code.handler';
import { GetAccessCodeDetailsHandler } from './application/queries/handlers/get-access-code-details.handler'; // Query Handler import
// ... 기타 Providers

export const CommandHandlers = [VerifyAccessCodeHandler];
export const QueryHandlers = [GetAccessCodeDetailsHandler]; // Query Handler 등록

@Module({
imports: [CqrsModule], // CQRS 모듈 import
providers: [
...CommandHandlers,
...QueryHandlers, // Query Handler 등록
// Repository 인터페이스와 구현체 바인딩
{ provide: AccessCodeRepository, useClass: PrismaAccessCodeRepository },
{ provide: UsageHistoryRepository, useClass: PrismaUsageHistoryRepository },
// ... Logger 등 기타 Provider
],
exports: [AccessCodeRepository, UsageHistoryRepository] // 필요 시 Repository export
})
export class AccessCodeModule {}

6. 보안 및 성능 고려사항 (간략)

// ... (기존 내용 유지, 핸들러 레벨에서 적용 고려) ...

7. 테스트

7.1 단위 테스트 (Unit Tests)

VerifyAccessCodeHandler를 대상으로 단위 테스트를 작성합니다. Repository는 Mocking 합니다.

// 파일 경로: libs/feature/access-code/src/lib/application/commands/handlers/verify-access-code.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { VerifyAccessCodeHandler } from './verify-access-code.handler';
import { VerifyAccessCodeCommand } from '../impl/verify-access-code.command';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { UsageHistoryRepository } from '../../../domain/repositories/usage-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
import {
InvalidAccessCodeError,
AccessCodeExpiredError,
AccessCodeBlockedError,
AccessCodeAlreadyUsedError
} from '../../../domain/errors/access-code.errors';

type MockRepository<T = any> = Partial<Record<keyof T, jest.Mock>>;

describe('VerifyAccessCodeHandler', () => {
let handler: VerifyAccessCodeHandler;
let accessCodeRepository: MockRepository<AccessCodeRepository>;
let usageHistoryRepository: MockRepository<UsageHistoryRepository>;

beforeEach(async () => {
accessCodeRepository = {
findByCode: jest.fn(),
save: jest.fn(),
};
usageHistoryRepository = {
create: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
VerifyAccessCodeHandler,
// Repository Provider 토큰 확인 (실제 프로젝트 설정에 맞게)
{ provide: AccessCodeRepository, useValue: accessCodeRepository },
{ provide: UsageHistoryRepository, useValue: usageHistoryRepository },
{ provide: Logger, useValue: { log: jest.fn(), error: jest.fn(), warn: jest.fn() } },
],
}).compile();

handler = module.get<VerifyAccessCodeHandler>(VerifyAccessCodeHandler);
});

const command = new VerifyAccessCodeCommand('VALIDCODE1234567', '127.0.0.1', 'test-agent');
const mockCode = new AccessCode({
id: 'code-id',
code: 'VALIDCODE1234567',
status: 'ACTIVE',
expirationDate: new Date(Date.now() + 3600000),
// ... 기타 필수 필드
});
// Mock entity methods
jest.spyOn(mockCode, 'use');
jest.spyOn(mockCode, 'getId', 'get').mockReturnValue('code-id');
jest.spyOn(mockCode, 'getStatus', 'get').mockReturnValue({ value: 'ACTIVE', equals: (s) => s.value === 'ACTIVE' });
jest.spyOn(mockCode, 'getExpirationDate', 'get').mockReturnValue(new Date(Date.now() + 3600000));
jest.spyOn(mockCode, 'getStartDate', 'get').mockReturnValue(null);
jest.spyOn(mockCode, 'getUsedAt', 'get').mockReturnValue(new Date()); // 사용 시간 Mock


it('should verify a valid code and return code ID', async () => {
accessCodeRepository.findByCode.mockResolvedValue(mockCode);
accessCodeRepository.save.mockResolvedValue(mockCode);

const result = await handler.execute(command);

expect(result).toEqual('code-id');
expect(accessCodeRepository.findByCode).toHaveBeenCalledWith('VALIDCODE1234567');
expect(mockCode.use).toHaveBeenCalledTimes(1);
expect(accessCodeRepository.save).toHaveBeenCalledWith(mockCode);
expect(usageHistoryRepository.create).toHaveBeenCalledWith(expect.objectContaining({
codeId: 'code-id',
result: 'SUCCESS',
}));
});

it('should throw InvalidAccessCodeError and record failure for non-existent code', async () => {
accessCodeRepository.findByCode.mockResolvedValue(null);

await expect(handler.execute(command)).rejects.toThrow(InvalidAccessCodeError);
expect(usageHistoryRepository.create).toHaveBeenCalledWith(expect.objectContaining({
result: 'FAILURE',
failureReason: 'NOT_FOUND',
attemptedCode: 'VALIDCODE1234567'
}));
});

it('should throw AccessCodeExpiredError and record failure for expired code', async () => {
const expiredDate = new Date(Date.now() - 1000);
const expiredCode = new AccessCode({ ...mockCode, expirationDate: expiredDate });
jest.spyOn(expiredCode, 'getExpirationDate', 'get').mockReturnValue(expiredDate);
jest.spyOn(expiredCode, 'getId', 'get').mockReturnValue('expired-code-id'); // 다른 ID 사용
accessCodeRepository.findByCode.mockResolvedValue(expiredCode);

await expect(handler.execute(command)).rejects.toThrow(AccessCodeExpiredError);
expect(usageHistoryRepository.create).toHaveBeenCalledWith(expect.objectContaining({
result: 'FAILURE',
failureReason: 'CODE_EXPIRED',
codeId: 'expired-code-id'
}));
});

// ... (AccessCodeAlreadyUsedError, AccessCodeBlockedError 등 다른 실패 케이스 테스트 추가)

});

GetAccessCodeDetailsHandler 단위 테스트 (예시):

// 파일 경로: libs/feature/access-code/src/lib/application/queries/handlers/get-access-code-details.handler.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { GetAccessCodeDetailsHandler } from './get-access-code-details.handler';
import { GetAccessCodeDetailsQuery } from '../impl/get-access-code-details.query';
import { AccessCodeRepository } from '../../../domain/repositories/access-code.repository';
import { AccessCode } from '../../../domain/entities/access-code.entity';
import { Logger } from '@nestjs/common';

type MockRepository<T = any> = Partial<Record<keyof T, jest.Mock>>;

describe('GetAccessCodeDetailsHandler', () => {
let handler: GetAccessCodeDetailsHandler;
let repository: MockRepository<AccessCodeRepository>;

beforeEach(async () => {
repository = {
findByCode: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
GetAccessCodeDetailsHandler,
{ provide: AccessCodeRepository, useValue: repository },
{ provide: Logger, useValue: { log: jest.fn(), warn: jest.fn() } },
],
}).compile();

handler = module.get<GetAccessCodeDetailsHandler>(GetAccessCodeDetailsHandler);
});

it('should return access code entity when found', async () => {
const code = 'EXISTINGCODE123';
const mockEntity = new AccessCode({ /* ... mock data ... */ });
repository.findByCode.mockResolvedValue(mockEntity);

const result = await handler.execute(new GetAccessCodeDetailsQuery(code));

expect(result).toBe(mockEntity);
expect(repository.findByCode).toHaveBeenCalledWith('EXISTINGCODE123');
});

it('should return null when access code not found', async () => {
const code = 'NONEXISTENTCODE';
repository.findByCode.mockResolvedValue(null);

const result = await handler.execute(new GetAccessCodeDetailsQuery(code));

expect(result).toBeNull();
expect(repository.findByCode).toHaveBeenCalledWith('NONEXISTENTCODE');
});
});

7.2 통합 테스트 (Integration Tests)

  • 실제 DB와 연동된 환경에서 POST /v1/access-codes/verify 엔드포인트를 호출하여 테스트합니다.
  • 사전에 다양한 상태(ACTIVE, USED, EXPIRED, BLOCKED, 존재하지 않음)의 코드를 준비합니다.
  • 각 상태의 코드로 API를 호출하여 예상되는 응답(200 OK + codeId 또는 적절한 HTTP 오류 상태 코드)과 DB 상태 변경(USED 상태로 변경, UsageHistory 기록)을 확인합니다. Global Exception Filter가 도메인 오류를 적절한 HTTP 오류로 변환하는지 확인합니다.

8. 변경 이력

버전날짜작성자변경 내용
0.1.02025-04-20bok@weltcorp.com최초 작성
0.2.02025-04-22bok@weltcorp.comCQRS Command 패턴 적용 (VerifyAccessCodeCommand/Handler), 실패 이력 기록 추가