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 모듈 설정
AccessCodeModule에 VerifyAccessCodeHandler 및 GetAccessCodeDetailsHandler를 등록하고 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.0 | 2025-04-20 | bok@weltcorp.com | 최초 작성 |
| 0.2.0 | 2025-04-22 | bok@weltcorp.com | CQRS Command 패턴 적용 (VerifyAccessCodeCommand/Handler), 실패 이력 기록 추가 |