본문으로 건너뛰기

Access Code API 구현 가이드

관련 문서

1. 구현 개요

Access Code API는 일회용 접근 코드의 생성, 검증, 관리를 위한 API입니다. 이 문서는 API의 구현 세부사항과 가이드라인을 제공합니다.

1.1 아키텍처 구조

src/
├── controllers/ # API 엔드포인트 정의
├── services/ # 비즈니스 로직
├── models/ # 데이터 모델 및 변환 로직
├── dtos/ # 데이터 전송 객체
└── common/ # 공통 유틸리티

1.2 주요 컴포넌트

  • AccessCodeController: API 엔드포인트 처리
  • AccessCodeService: 비즈니스 로직 처리
  • PrismaService: CoreDatabaseModule에서 제공하는 데이터베이스 연동 서비스
  • TimeMachineService: 시간 관리
  • EmailService: 이메일 발송

2. 데이터 모델

2.1 엔티티 정의

// schema.prisma
model AccessCode {
id String @id @default(uuid())
code String @unique
type AccessCodeType @default(TREATMENT)
status AccessCodeStatus @default(UNUSED)
expiresAt DateTime
userId String?
deviceId String?
creatorId String
accountId String?
treatmentPeriod Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usedAt DateTime?
metadata Json? @db.JsonB

@@index([status, expiresAt])
@@index([userId, status])
}

enum AccessCodeType {
TREATMENT
REGISTRATION
VERIFICATION
}

enum AccessCodeStatus {
UNUSED
USED
REVOKED
}

2.2 DTO 정의

export class CreateAccessCodeDto {
@ApiProperty()
@IsString()
name: string;

@ApiProperty({ enum: AccessCodeScopes })
@IsEnum(AccessCodeScopes)
@IsOptional()
scope?: AccessCodeScopes;

@ApiProperty()
@IsNumber()
@Min(1)
maxUses: number;

@ApiProperty()
@IsDate()
expiresAt: Date;
}

3. 권한 관리

3.1 권한 정의

export enum AccessCodePermissions {
CREATE_CODE = 'access-code:create',
READ_CODE = 'access-code:read',
VERIFY_CODE = 'access-code:verify',
REVOKE_CODE = 'access-code:revoke',
MANAGE_POLICY = 'access-code:manage-policy',
}

export enum AccessCodeScopes {
GLOBAL = 'access-code:global',
ORGANIZATION = 'access-code:org',
TEAM = 'access-code:team',
}

3.2 권한 가드

@Injectable()
export class AccessCodePermissionGuard implements CanActivate {
constructor(
private readonly reflector: Reflector,
private readonly iamService: IamService,
private readonly auditService: AuditService,
) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const requiredPermissions = this.reflector.get<string[]>('permissions', context.getHandler());
if (!requiredPermissions) {
return true;
}

const request = context.switchToHttp().getRequest();
const user = request.user;

switch (user.type) {
case 'SYSTEM_ADMIN':
await this.auditService.logAccess(user, 'ACCESS_CODE_ADMIN_ACCESS', request.path);
return true;

case 'IAM_ADMIN':
const hasPermission = await this.validateIamAdminAccess(user, requiredPermissions, request);
await this.auditService.logAccess(user, 'ACCESS_CODE_IAM_ACCESS', request.path);
return hasPermission;

case 'SERVICE_ACCOUNT':
return this.validateServiceAccountAccess(user, requiredPermissions);

case 'REGULAR_USER':
return this.validateRegularUserAccess(user, requiredPermissions, request);

default:
throw new ForbiddenException('Unknown user type');
}
}
}

4. 비즈니스 로직 구현

4.1 코드 생성

@Injectable()
export class AccessCodeService {
constructor(
private readonly prisma: PrismaService,
private readonly timeMachineService: TimeMachineService,
private readonly emailService: EmailService,
private readonly eventEmitter: EventEmitter2,
@Inject(CACHE_MANAGER)
private readonly cacheManager: Cache
) {}

async createCode(dto: CreateAccessCodeDto): Promise<AccessCode> {
const currentTime = await this.timeMachineService.getCurrentTime();
const code = await this.generateUniqueCode();
const expiresAt = addDays(currentTime, dto.usagePeriod);

const accessCode = await this.prisma.accessCode.create({
data: {
code,
type: dto.type,
creatorId: dto.creatorId,
accountId: dto.accountId,
treatmentPeriod: dto.treatmentPeriod,
expiresAt,
metadata: {
registrationChannel: dto.registrationChannel,
randomizationCode: dto.randomizationCode,
privacyConsent: dto.privacyConsent
}
}
});

// 이메일 발송 (필요한 경우)
if (dto.email && dto.deliveryMethod === DeliveryMethod.EMAIL) {
await this.emailService.sendAccessCode(dto.email, code);
}

// 이벤트 발행
this.eventEmitter.emit('event', {
type: 'access-code-created', // 실제 이벤트 타입은 페이로드에 포함
payload: {
codeId: accessCode.id,
type: accessCode.type,
expiresAt: accessCode.expiresAt.getTime()
},
timestamp: new Date().toISOString()
});

return accessCode;
}
}

4.2 코드 검증

async validateCode(code: string, deviceId: string): Promise<ValidationResult> {
const cachedResult = await this.cacheManager.get(`validation:${code}`);
if (cachedResult) {
return cachedResult as ValidationResult;
}

const accessCode = await this.prisma.accessCode.findUnique({
where: { code }
});

if (!accessCode) {
throw new ErrorResponseDto(
ErrorCode.INVALID_CODE,
'Code not found'
);
}

const currentTime = await this.timeMachineService.getCurrentTime();

if (accessCode.status !== AccessCodeStatus.UNUSED) {
throw new ErrorResponseDto(
ErrorCode.CODE_ALREADY_USED,
'Code has already been used'
);
}

if (currentTime > accessCode.expiresAt) {
throw new ErrorResponseDto(
ErrorCode.CODE_EXPIRED,
'Code has expired'
);
}

const result = {
isValid: true,
codeInfo: {
id: accessCode.id,
treatmentPeriod: accessCode.treatmentPeriod,
expiresAt: accessCode.expiresAt.getTime()
}
};

await this.cacheManager.set(
`validation:${code}`,
result,
{ ttl: 300 }
);

return result;
}

5. 성능 최적화

5.1 캐싱 전략

@Module({
imports: [
CacheModule.registerAsync({
useFactory: () => ({
store: redisStore,
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
ttl: 300
})
})
]
})

5.2 데이터베이스 최적화

// schema.prisma
model AccessCode {
id String @id @default(uuid())
code String @unique
type AccessCodeType
status AccessCodeStatus
expiresAt DateTime
userId String?
deviceId String?
creatorId String
accountId String?
treatmentPeriod Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usedAt DateTime?
metadata Json? @db.JsonB

@@index([status, expiresAt])
@@index([userId, status])
}

5.3 시간 데이터 처리

// API 응답을 위한 데이터 변환 유틸리티
export function formatAccessCodeForResponse(accessCode: AccessCode): AccessCodeResponseDto {
return {
id: accessCode.id,
code: accessCode.code,
type: accessCode.type,
status: accessCode.status,
expiresAt: accessCode.expiresAt.getTime(), // 타임스탬프로 변환
treatmentPeriod: accessCode.treatmentPeriod,
createdAt: accessCode.createdAt.getTime(), // 타임스탬프로 변환
updatedAt: accessCode.updatedAt.getTime(), // 타임스탬프로 변환
usedAt: accessCode.usedAt ? accessCode.usedAt.getTime() : null, // Optional 타임스탬프
metadata: accessCode.metadata
};
}

// 사용 예시
@Get(':id')
async getAccessCodeById(@Param('id') id: string): Promise<AccessCodeResponseDto> {
const accessCode = await this.accessCodeService.findById(id);
if (!accessCode) {
throw new NotFoundException(`Access code with ID ${id} not found`);
}
return formatAccessCodeForResponse(accessCode);
}

모든 시간 데이터는 다음 원칙을 따릅니다:

  • 데이터베이스에는 DateTime 타입으로 저장
  • API 응답에서는 항상 13자리 유닉스 타임스탬프(밀리초)로 변환하여 반환
  • 모든 시간은 UTC 기준으로 처리하고 클라이언트에서 필요에 따라 지역 시간으로 변환

6. 에러 처리

6.1 에러 코드

export enum ErrorCode {
// 컨트롤러 계층 (1001-1099)
INVALID_INPUT = 1001,
UNAUTHORIZED = 1002,
FORBIDDEN = 1003,

// 서비스 계층 (1101-1199)
INVALID_CODE = 1101,
CODE_ALREADY_USED = 1102,
CODE_EXPIRED = 1103,

// 리포지토리 계층 (1201-1299)
DATABASE_ERROR = 1201,
DUPLICATE_CODE = 1202
}

6.2 에러 응답

export class ErrorResponseDto {
constructor(
public code: ErrorCode,
public message: string,
public detail?: string
) {}

static fromError(error: Error): ErrorResponseDto {
if (error instanceof ErrorResponseDto) {
return error;
}

return new ErrorResponseDto(
ErrorCode.DATABASE_ERROR,
'Internal server error',
error.message
);
}
}

7. 모니터링

7.1 로깅

const logger = new Logger('AccessCodeService');

logger.error('Failed to create access code', {
error: error.message,
dto: maskSensitiveData(dto)
});

logger.info('Access code created', {
codeId: accessCode.id,
type: accessCode.type
});

7.2 메트릭스

@Injectable()
export class MetricsService {
private readonly counter = new Counter({
name: 'access_code_operations_total',
help: 'Total number of access code operations',
labelNames: ['operation', 'status']
});

private readonly histogram = new Histogram({
name: 'access_code_operation_duration_seconds',
help: 'Duration of access code operations',
labelNames: ['operation']
});
}

8. 테스트

8.1 단위 테스트

describe('AccessCodeService', () => {
let service: AccessCodeService;
let prisma: MockPrismaService;
let timeMachineService: MockTimeMachineService;

beforeEach(async () => {
prisma = {
accessCode: {
create: jest.fn(),
findUnique: jest.fn(),
findFirst: jest.fn(),
update: jest.fn(),
count: jest.fn(),
}
} as any;

timeMachineService = {
getCurrentTime: jest.fn()
} as any;

service = new AccessCodeService(
prisma,
timeMachineService,
mockEmailService,
mockPubSubClient,
mockCacheManager
);
});

it('should verify code is not expired', async () => {
const testTime = new Date(1710924000000);
const expiryTime = new Date(1711010400000); // 하루 뒤

timeMachineService.getCurrentTime.mockResolvedValue(testTime);

const mockAccessCode = {
id: 'test-id',
code: 'ABC123',
status: AccessCodeStatus.UNUSED,
expiresAt: expiryTime,
treatmentPeriod: 30
};

prisma.accessCode.findUnique.mockResolvedValue(mockAccessCode);

const result = await service.validateCode('ABC123', 'device-123');

expect(prisma.accessCode.findUnique).toHaveBeenCalledWith({
where: { code: 'ABC123' }
});

expect(result.isValid).toBe(true);
expect(result.codeInfo.id).toBe('test-id');
expect(result.codeInfo.expiresAt).toBe(expiryTime.getTime());
});

it('should throw error for expired code', async () => {
const testTime = new Date(1711010400000);
const expiryTime = new Date(1710924000000); // 하루 전

timeMachineService.getCurrentTime.mockResolvedValue(testTime);

const mockAccessCode = {
id: 'test-id',
code: 'ABC123',
status: AccessCodeStatus.UNUSED,
expiresAt: expiryTime,
treatmentPeriod: 30
};

prisma.accessCode.findUnique.mockResolvedValue(mockAccessCode);

await expect(service.validateCode('ABC123', 'device-123'))
.rejects
.toThrow(ErrorResponseDto);
});
});

9. 변경 이력

버전날짜작성자변경 내용
0.1.02025-03-19bok@weltcorp.com최초 작성

10. 이벤트 시스템 통합

10.1 이벤트 리스너 구현

@Injectable()
export class AccessCodeEventListener implements OnModuleInit {
constructor(
private readonly eventEmitter: EventEmitter2,
private readonly accessCodeService: AccessCodeService,
private readonly redisService: RedisService,
private readonly logger: Logger
) {}

onModuleInit() {
// API 엔드포인트 구현 (Pub/Sub Push 구독을 통해 이벤트 수신)
// 이 엔드포인트는 이벤트를 수신하여 로컬 이벤트로 변환합니다.
}
}

// 모듈 설정
@Module({
imports: [
// 기존 모듈들...
],
controllers: [
AccessCodeController,
EventsController
],
providers: [
AccessCodeService,
AccessCodeEventListener,
// 기타 서비스들...
],
})
export class AccessCodeModule {}

10.2 내부 이벤트 리스너 구현

@Injectable()
export class AccessCodeInternalEventListener implements OnModuleInit {
private readonly relevantEvents = ['user-created', 'treatment-started', 'treatment-ended'];

constructor(
private readonly eventEmitter: EventEmitter2,
private readonly accessCodeService: AccessCodeService,
private readonly logger: Logger
) {}

onModuleInit() {
// 내부 이벤트 구독 설정
this.setupEventListeners();
}

private setupEventListeners() {
// 사용자 생성 이벤트 구독
this.eventEmitter.on('user-created', this.handleUserCreated.bind(this));

// 치료 시작 이벤트 구독
this.eventEmitter.on('treatment-started', this.handleTreatmentStarted.bind(this));

// 치료 종료 이벤트 구독
this.eventEmitter.on('treatment-ended', this.handleTreatmentEnded.bind(this));

this.logger.log('AccessCode event listeners initialized');
}

private async handleUserCreated(payload: any) {
try {
this.logger.debug('Processing user-created event', { userId: payload.userId });

// 사용자 생성 시 필요한 Access Code 처리 로직
// 예: 초기 접근 코드 생성, 상태 업데이트 등
await this.accessCodeService.handleUserCreation(payload.userId, payload.userType);

} catch (error) {
this.logger.error('Error handling user-created event', { error, userId: payload.userId });
}
}

private async handleTreatmentStarted(payload: any) {
try {
this.logger.debug('Processing treatment-started event', {
userId: payload.userId,
treatmentId: payload.treatmentId
});

// 치료 시작 시 필요한 Access Code 처리 로직
await this.accessCodeService.handleTreatmentStart(
payload.userId,
payload.treatmentId,
payload.treatmentPeriod
);

} catch (error) {
this.logger.error('Error handling treatment-started event', {
error,
userId: payload.userId,
treatmentId: payload.treatmentId
});
}
}

private async handleTreatmentEnded(payload: any) {
try {
this.logger.debug('Processing treatment-ended event', {
userId: payload.userId,
treatmentId: payload.treatmentId
});

// 치료 종료 시 필요한 Access Code 처리 로직
await this.accessCodeService.handleTreatmentEnd(
payload.userId,
payload.treatmentId,
payload.reason
);

} catch (error) {
this.logger.error('Error handling treatment-ended event', {
error,
userId: payload.userId,
treatmentId: payload.treatmentId
});
}
}
}