본문으로 건너뛰기

Security API 구현 가이드

개요

Security API의 구현 방법과 예시 코드를 제공합니다.

프로젝트 구조

🔗 프로젝트 전체 구조에 대한 상세 설명은 NX 모노레포 가이드라인을 참조하세요.

Security API 관련 파일은 다음과 같이 구성됩니다:

libs/feature/security/
├── src/
│ ├── lib/
│ │ ├── controllers/
│ │ │ ├── policy.controller.ts
│ │ │ ├── event.controller.ts
│ │ │ └── audit.controller.ts
│ │ ├── services/
│ │ │ ├── policy.service.ts
│ │ │ ├── event.service.ts
│ │ │ └── audit.service.ts
│ │ ├── dtos/
│ │ │ ├── policy.dto.ts
│ │ │ ├── event.dto.ts
│ │ │ └── audit.dto.ts
│ │ ├── models/
│ │ │ ├── policy.model.ts
│ │ │ ├── event.model.ts
│ │ │ └── audit.model.ts
│ │ └── interfaces/
│ │ ├── policy.interface.ts
│ │ ├── event.interface.ts
│ │ └── audit.interface.ts

구현 예시

1. 엔티티 정의

SecurityPolicy 모델(Prisma 스키마)

// schema.prisma
model SecurityPolicy {
id Int @id @default(autoincrement())
type SecurityPolicyType
name String
value String
description String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

enum SecurityPolicyType {
PASSWORD
SESSION
MFA
IP_WHITELIST
}

SecurityEvent 모델(Prisma 스키마)

// schema.prisma
model SecurityEvent {
id Int @id @default(autoincrement())
type SecurityEventType
severity SecurityEventSeverity
source String
description String
metadata Json @db.JsonB
timestamp DateTime
}

enum SecurityEventType {
LOGIN_ATTEMPT
PASSWORD_CHANGE
MFA_CHALLENGE
ACCESS_DENIED
}

enum SecurityEventSeverity {
LOW
MEDIUM
HIGH
CRITICAL
}

2. DTO 정의

CreateSecurityPolicyDto

import { IsEnum, IsString, IsNotEmpty, IsBoolean } from 'class-validator';

export class CreateSecurityPolicyDto {
@IsEnum(SecurityPolicyType)
type: SecurityPolicyType;

@IsString()
@IsNotEmpty()
name: string;

@IsString()
@IsNotEmpty()
value: string;

@IsString()
description: string;

@IsBoolean()
isActive: boolean;
}

CreateSecurityEventDto

import { IsEnum, IsString, IsNotEmpty, IsObject } from 'class-validator';

export class CreateSecurityEventDto {
@IsEnum(SecurityEventType)
type: SecurityEventType;

@IsEnum(SecurityEventSeverity)
severity: SecurityEventSeverity;

@IsString()
@IsNotEmpty()
source: string;

@IsString()
@IsNotEmpty()
description: string;

@IsObject()
metadata: Record<string, any>;
}

3. 서비스 구현

SecurityPolicyService

@Injectable()
export class SecurityPolicyService {
constructor(
private readonly prisma: PrismaService,
private cacheManager: Cache,
) {}

async createPolicy(dto: CreateSecurityPolicyDto): Promise<SecurityPolicy> {
const policy = await this.prisma.securityPolicy.create({
data: dto
});

await this.invalidateCache(policy.type);
return policy;
}

async getPolicy(type: SecurityPolicyType): Promise<SecurityPolicy> {
const cacheKey = `security_policy:${type}`;
const cached = await this.cacheManager.get<SecurityPolicy>(cacheKey);

if (cached) {
return cached;
}

const policy = await this.prisma.securityPolicy.findFirst({
where: {
type,
isActive: true
},
});

if (policy) {
await this.cacheManager.set(cacheKey, policy, { ttl: 3600 });
}

return policy;
}

private async invalidateCache(type: SecurityPolicyType): Promise<void> {
const cacheKey = `security_policy:${type}`;
await this.cacheManager.del(cacheKey);
}
}

SecurityEventService

@Injectable()
export class SecurityEventService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: Logger,
) {}

async createEvent(dto: CreateSecurityEventDto): Promise<SecurityEvent> {
const event = await this.prisma.securityEvent.create({
data: {
...dto,
timestamp: new Date(),
}
});

if (event.severity >= SecurityEventSeverity.HIGH) {
await this.notifySecurityTeam(event);
}

return event;
}

private async notifySecurityTeam(event: SecurityEvent): Promise<void> {
try {
// 알림 로직 구현
this.logger.warn(`Security event: ${event.type} - ${event.description}`);
} catch (error) {
this.logger.error('Failed to notify security team', error);
}
}
}

4. 컨트롤러 구현

SecurityPolicyController

@Controller('security/policies')
@UseGuards(JwtAuthGuard, RolesGuard)
export class SecurityPolicyController {
constructor(private readonly policyService: SecurityPolicyService) {}

@Post()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: '보안 정책 생성' })
async createPolicy(
@Body() dto: CreateSecurityPolicyDto,
): Promise<SecurityPolicy> {
return this.policyService.createPolicy(dto);
}

@Get(':type')
@ApiOperation({ summary: '보안 정책 조회' })
async getPolicy(
@Param('type') type: SecurityPolicyType,
): Promise<SecurityPolicy> {
const policy = await this.policyService.getPolicy(type);

// 날짜를 timestamp로 변환
return {
...policy,
createdAt: policy.createdAt.getTime(),
updatedAt: policy.updatedAt.getTime()
};
}
}

SecurityEventController

@Controller('security/events')
@UseGuards(JwtAuthGuard)
export class SecurityEventController {
constructor(private readonly eventService: SecurityEventService) {}

@Post()
@ApiOperation({ summary: '보안 이벤트 생성' })
async createEvent(
@Body() dto: CreateSecurityEventDto,
): Promise<SecurityEvent> {
const event = await this.eventService.createEvent(dto);

// 날짜를 timestamp로 변환
return {
...event,
timestamp: event.timestamp.getTime()
};
}

@Get()
@Roles(UserRole.ADMIN)
@ApiOperation({ summary: '보안 이벤트 목록 조회' })
async getEvents(
@Query() query: GetEventsQueryDto,
): Promise<CollectionResponse<SecurityEvent>> {
const { items, totalCount } = await this.eventService.getEvents(query);
const { page = 1, size = 10 } = query;

// 날짜를 timestamp로 변환하고 컬렉션 응답 형식 준수
return {
items: items.map(event => ({
...event,
timestamp: event.timestamp.getTime()
})),
metadata: {
totalCount,
currentPage: page,
pageSize: size,
totalPages: Math.ceil(totalCount / size)
}
};
}
}

5. 미들웨어 구현

SecurityAuditMiddleware

@Injectable()
export class SecurityAuditMiddleware implements NestMiddleware {
constructor(private readonly auditService: AuditService) {}

async use(req: Request, res: Response, next: NextFunction) {
const startTime = Date.now();

res.on('finish', () => {
const endTime = Date.now();
const duration = endTime - startTime;

this.auditService.createAuditLog({
userId: req.user?.id,
action: req.method,
resourceType: this.getResourceType(req.path),
resourceId: this.getResourceId(req.path),
metadata: {
ip: req.ip,
userAgent: req.headers['user-agent'],
duration,
statusCode: res.statusCode,
},
});
});

next();
}

private getResourceType(path: string): string {
const parts = path.split('/');
return parts[2] || 'unknown';
}

private getResourceId(path: string): string {
const parts = path.split('/');
return parts[3] || 'unknown';
}
}

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: number,
public message: string,
public detail?: string,
public errors?: {
field: string;
message: string;
}[]
) {}

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

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

6.3 예외 필터

@Catch(HttpException)
export class SecurityExceptionFilter implements ExceptionFilter {
catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const status = exception.getStatus();

let errorResponse: ErrorResponseDto;

if (exception instanceof SecurityException) {
errorResponse = exception.getErrorResponse();
} else {
const exceptionResponse = exception.getResponse() as Record<string, any>;

if (exceptionResponse.errors?.length > 0) {
// 유효성 검증 에러 처리
errorResponse = new ErrorResponseDto(
ErrorCode.INVALID_INPUT,
'INVALID_INPUT',
'Validation failed',
exceptionResponse.errors.map(error => ({
field: error.property,
message: Object.values(error.constraints)[0]
}))
);
} else {
// 일반 HTTP 예외 처리
errorResponse = new ErrorResponseDto(
exceptionResponse.code || ErrorCode.INVALID_INPUT,
exceptionResponse.message || 'Bad Request',
exceptionResponse.detail
);
}
}

response.status(status).json(errorResponse);
}
}

7. 캐시 설정

@Module({
imports: [
CacheModule.registerAsync({
useFactory: () => ({
store: redisStore,
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT,
ttl: 3600,
max: 100,
}),
}),
],
})
export class SecurityModule {}

8. 테스트 구현

8.1 SecurityPolicyService 테스트

describe('SecurityPolicyService', () => {
let service: SecurityPolicyService;
let prisma: any;
let cacheManager: Cache;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
SecurityPolicyService,
{
provide: PrismaService,
useValue: {
securityPolicy: {
create: jest.fn(),
findFirst: jest.fn(),
}
},
},
{
provide: CACHE_MANAGER,
useValue: {
get: jest.fn(),
set: jest.fn(),
del: jest.fn(),
},
},
],
}).compile();

service = module.get<SecurityPolicyService>(SecurityPolicyService);
prisma = module.get<PrismaService>(PrismaService);
cacheManager = module.get<Cache>(CACHE_MANAGER);
});

it('should create a policy', async () => {
const dto = {
type: SecurityPolicyType.PASSWORD,
name: 'Password Policy',
value: 'min_length:8',
description: 'Minimum password length',
isActive: true,
};

const policy = {
id: 1,
...dto,
createdAt: new Date(),
updatedAt: new Date(),
};

prisma.securityPolicy.create.mockResolvedValue(policy);
jest.spyOn(cacheManager, 'del').mockResolvedValue(undefined);

const result = await service.createPolicy(dto);
expect(result).toEqual(policy);
expect(prisma.securityPolicy.create).toHaveBeenCalledWith({
data: dto
});
});
});

9. 배포 고려사항

9.1 환경 변수 설정

export const config = {
database: {
url: process.env.DATABASE_URL,
},
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10),
password: process.env.REDIS_PASSWORD,
},
jwt: {
secret: process.env.JWT_SECRET,
expiresIn: process.env.JWT_EXPIRES_IN,
},
monitoring: {
enabled: process.env.MONITORING_ENABLED === 'true',
endpoint: process.env.MONITORING_ENDPOINT,
},
};

9.2 로깅 설정

const logger = new Logger('SecurityModule');

// 정책 업데이트 로깅
logger.log('Security policy updated', {
type: policy.type,
name: policy.name,
updatedBy: user.id,
timestamp: Date.now(),
});

// 오류 로깅
logger.error('Failed to process security event', {
eventId: event.id,
error: error.message,
stack: error.stack,
timestamp: Date.now(),
});

9.3 모니터링 설정

@Injectable()
export class SecurityMetricsService {
private readonly counter: Counter;
private readonly histogram: Histogram;

constructor() {
this.counter = new Counter({
name: 'security_events_total',
help: 'Total number of security events',
labelNames: ['type', 'severity'],
});

this.histogram = new Histogram({
name: 'security_policy_check_duration_seconds',
help: 'Duration of security policy checks',
labelNames: ['type'],
});
}

recordEvent(event: SecurityEvent): void {
this.counter.inc({ type: event.type, severity: event.severity });
}

measurePolicyCheck(type: SecurityPolicyType, duration: number): void {
this.histogram.observe({ type }, duration);
}
}

10. API 접근 권한 매트릭스

Security API의 엔드포인트별 역할 접근 권한은 다음과 같습니다:

엔드포인트System AdminIAM AdminOrg AdminTeam AdminRegular User
POST /security/policies
GET /security/policies/:type
PUT /security/policies/:type
POST /security/events
GET /security/events범위 내
GET /security/audit-logs범위 내범위 내(제한적)자신만

10.1 권한 설명

  • System Admin: 모든 Security API에 대한 전체 접근 권한
  • IAM Admin: Security 정책 관리 및 이벤트 조회 권한
  • Org Admin: 자신의 조직 내 이벤트 및 감사 로그 조회 권한
  • Team Admin: 자신의 팀 내 제한된 감사 로그 조회 권한
  • Regular User: 자신의 감사 로그만 조회 가능, 이벤트 생성 가능

변경 이력

버전날짜작성자변경 내용
1.0.02025-03-21System Team최초 작성
1.1.02025-04-05System TeamTypeORM에서 Prisma로 마이그레이션

11. 클라이언트 사용 예시

11.1 보안 정책 조회 예시 (React)

import axios from 'axios';
import { useState, useEffect } from 'react';

function SecurityPolicyViewer() {
const [policy, setPolicy] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const fetchPolicy = async () => {
try {
setLoading(true);
const response = await axios.get('/security/policies/PASSWORD');
setPolicy(response.data);
setError(null);
} catch (err) {
setError(err.response?.data || { message: '정책을 불러오는데 실패했습니다.' });
} finally {
setLoading(false);
}
};

fetchPolicy();
}, []);

if (loading) return <div>정책 불러오는 중...</div>;
if (error) return <div>오류: {error.message}</div>;

return (
<div className="policy-container">
<h2>{policy.name}</h2>
<p>{policy.description}</p>
<div className="policy-details">
<p>유형: {policy.type}</p>
<p>상태: {policy.isActive ? '활성화' : '비활성화'}</p>
<p>: {policy.value}</p>
<p>생성일: {new Date(policy.createdAt).toLocaleString()}</p>
<p>수정일: {new Date(policy.updatedAt).toLocaleString()}</p>
</div>
</div>
);
}

11.2 보안 이벤트 생성 예시 (TypeScript)

import axios from 'axios';

async function reportSecurityEvent(
eventType: string,
userId: number,
ipAddress: string,
details: Record<string, any>
): Promise<void> {
try {
await axios.post('/security/events', {
type: eventType,
userId,
ipAddress,
severity: 'MEDIUM',
details
});
console.log('보안 이벤트가 성공적으로 기록되었습니다.');
} catch (error) {
console.error('보안 이벤트 기록 실패:', error.response?.data || error.message);
throw error;
}
}

// 사용 예시
async function logFailedLogin(userId: number, ipAddress: string, attemptCount: number): Promise<void> {
await reportSecurityEvent(
'FAILED_LOGIN_ATTEMPT',
userId,
ipAddress,
{
attemptCount,
timestamp: Date.now(),
userAgent: navigator.userAgent
}
);
}

11.3 보안 이벤트 조회 예시 (Vue.js)

<template>
<div class="security-events">
<h2>보안 이벤트 목록</h2>

<div v-if="loading" class="loading">
데이터 로딩 중...
</div>

<div v-else-if="error" class="error">
{{ error.message }}
</div>

<div v-else>
<table class="events-table">
<thead>
<tr>
<th>유형</th>
<th>중요도</th>
<th>IP 주소</th>
<th>사용자 ID</th>
<th>발생 시간</th>
<th>상세 정보</th>
</tr>
</thead>
<tbody>
<tr v-for="event in events" :key="event.id">
<td>{{ event.type }}</td>
<td>{{ event.severity }}</td>
<td>{{ event.ipAddress }}</td>
<td>{{ event.userId }}</td>
<td>{{ formatDate(event.timestamp) }}</td>
<td>
<button @click="showDetails(event)">상세 정보</button>
</td>
</tr>
</tbody>
</table>

<div class="pagination">
<button
:disabled="currentPage === 1"
@click="changePage(currentPage - 1)"
>
이전
</button>
<span>{{ currentPage }} / {{ totalPages }}</span>
<button
:disabled="currentPage === totalPages"
@click="changePage(currentPage + 1)"
>
다음
</button>
</div>
</div>

<modal v-if="selectedEvent" @close="selectedEvent = null">
<h3>이벤트 상세 정보</h3>
<pre>{{ JSON.stringify(selectedEvent.details, null, 2) }}</pre>
</modal>
</div>
</template>

<script>
import axios from 'axios';

export default {
data() {
return {
events: [],
loading: true,
error: null,
currentPage: 1,
totalPages: 1,
selectedEvent: null
};
},

created() {
this.fetchEvents();
},

methods: {
async fetchEvents() {
try {
this.loading = true;
const response = await axios.get('/security/events', {
params: {
page: this.currentPage,
limit: 10
}
});

this.events = response.data.items;
this.totalPages = Math.ceil(response.data.totalItems / response.data.pageSize);
} catch (err) {
this.error = err.response?.data || { message: '이벤트를 불러오는데 실패했습니다.' };
} finally {
this.loading = false;
}
},

changePage(page) {
this.currentPage = page;
this.fetchEvents();
},

showDetails(event) {
this.selectedEvent = event;
},

formatDate(timestamp) {
return new Date(timestamp).toLocaleString();
}
}
};
</script>