본문으로 건너뛰기

토큰 관리 아키텍처

개요

DTA-WIDE 시스템의 토큰 관리 아키텍처는 사용자 토큰의 안전한 저장, 자동 갱신, 그리고 효율적인 분배를 담당합니다. 이 문서는 토큰 관리 시스템의 설계 원칙과 구현 세부사항을 설명합니다.

현재 구현 현황

구현 완료:

  • dta-wide-mcp: User Token Delegation Pattern 완전 구현
    • TokenManagerService: 메모리 기반 토큰 캐싱 및 자동 갱신
    • ApiClientService: 토큰 기반 HTTP 클라이언트 with 자동 재시도
    • CQRS 기반 토큰 갱신 (RefreshTokenCommand 연동)

계획 중:

  • Redis 기반 분산 토큰 저장소 (수평 확장 지원)
  • 토큰 암호화 강화 (AES-256-GCM)
  • 고급 감사 로깅 및 이상 탐지

아키텍처 개요

핵심 컴포넌트

1. TokenManagerService

책임:

  • 사용자별 토큰 저장 및 관리
  • 토큰 만료 감지 및 자동 갱신
  • 세션 생명주기 관리

주요 메서드:

interface TokenManagerService {
setupUserSession(userId: string, tokens: UserTokens): Promise<void>;
getValidToken(userId: string): Promise<string>;
refreshUserToken(userId: string): Promise<UserTokens>;
cleanupUserSession(userId: string): Promise<void>;
isTokenExpired(token: string): boolean;
}

구현 특징:

  • 메모리 기반 토큰 캐싱으로 빠른 액세스
  • 토큰 만료 5분 전 사전 갱신
  • 401 에러 감지시 반응적 갱신
  • 암호화된 토큰 저장

2. ApiClientService

책임:

  • HTTP 클라이언트 래퍼 제공
  • 자동 토큰 주입 및 갱신
  • 재시도 로직 구현

주요 메서드:

interface ApiClientService {
get<T>(url: string, userId?: string): Promise<T>;
post<T>(url: string, data: any, userId?: string): Promise<T>;
put<T>(url: string, data: any, userId?: string): Promise<T>;
delete<T>(url: string, userId?: string): Promise<T>;
}

구현 특징:

  • 자동 Bearer 토큰 헤더 추가
  • 토큰 만료시 자동 갱신 후 재시도
  • 환경별 설정 지원 (로컬/개발/운영)
  • 포괄적인 오류 처리

3. Token Storage Strategy

메모리 기반 저장:

interface TokenCache {
[userId: string]: {
accessToken: string;
refreshToken: string;
expiresAt: number;
encryptedAt: number;
};
}

장점:

  • 빠른 액세스 속도
  • 네트워크 오버헤드 없음
  • 단순한 구현

단점:

  • 서버 재시작시 토큰 손실
  • 메모리 사용량 증가
  • 수평 확장시 세션 공유 불가

Redis 기반 저장 (향후 확장):

interface RedisTokenStorage {
setUserTokens(userId: string, tokens: UserTokens, ttl: number): Promise<void>;
getUserTokens(userId: string): Promise<UserTokens | null>;
deleteUserTokens(userId: string): Promise<void>;
isTokenValid(userId: string): Promise<boolean>;
}

토큰 생명주기 관리

1. 토큰 생성 및 저장

구현 세부사항:

async setupUserSession(userId: string, tokens: UserTokens): Promise<void> {
// 토큰 유효성 검증
if (!this.validateTokenFormat(tokens)) {
throw new Error('Invalid token format');
}

// 토큰 암호화
const encryptedTokens = await this.encryptTokens(tokens);

// 만료 시간 계산
const expiresAt = this.calculateExpirationTime(tokens.accessToken);

// 저장
this.tokenCache[userId] = {
...encryptedTokens,
expiresAt,
encryptedAt: Date.now()
};

this.logger.log(`User session setup completed for user: ${userId}`);
}

2. 토큰 유효성 검증

구현 세부사항:

async getValidToken(userId: string): Promise<string> {
const cachedTokens = this.tokenCache[userId];

if (!cachedTokens) {
throw new Error('No active session found');
}

// 토큰 만료 확인 (5분 여유)
const bufferTime = 5 * 60 * 1000; // 5분
if (Date.now() + bufferTime >= cachedTokens.expiresAt) {
await this.refreshUserToken(userId);
return this.tokenCache[userId].accessToken;
}

return cachedTokens.accessToken;
}

3. 자동 토큰 갱신

구현 세부사항:

async refreshUserToken(userId: string): Promise<UserTokens> {
const cachedTokens = this.tokenCache[userId];

if (!cachedTokens) {
throw new Error('No session to refresh');
}

try {
// CQRS 명령을 통한 토큰 갱신
const refreshCommand = new RefreshTokenCommand(cachedTokens.refreshToken);
const newTokens = await this.commandBus.execute(refreshCommand);

// 새 토큰으로 캐시 업데이트
await this.setupUserSession(userId, newTokens);

this.logger.log(`Token refreshed successfully for user: ${userId}`);
return newTokens;

} catch (error) {
// 갱신 실패시 세션 정리
await this.cleanupUserSession(userId);
throw new Error(`Token refresh failed: ${error.message}`);
}
}

보안 구현

1. 토큰 암호화

class TokenEncryption {
private readonly algorithm = 'aes-256-gcm';
private readonly keyLength = 32;

async encryptToken(token: string): Promise<EncryptedToken> {
const key = await this.getEncryptionKey();
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipher(this.algorithm, key);
cipher.setAAD(Buffer.from('token-encryption'));

let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');

const authTag = cipher.getAuthTag();

return {
encryptedData: encrypted,
iv: iv.toString('hex'),
authTag: authTag.toString('hex')
};
}

async decryptToken(encryptedToken: EncryptedToken): Promise<string> {
const key = await this.getEncryptionKey();
const decipher = crypto.createDecipher(this.algorithm, key);

decipher.setAuthTag(Buffer.from(encryptedToken.authTag, 'hex'));
decipher.setAAD(Buffer.from('token-encryption'));

let decrypted = decipher.update(encryptedToken.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');

return decrypted;
}
}

2. 감사 로깅

interface TokenAuditLog {
userId: string;
action: 'CREATE' | 'REFRESH' | 'ACCESS' | 'DELETE';
timestamp: Date;
ipAddress?: string;
userAgent?: string;
success: boolean;
errorMessage?: string;
}

class TokenAuditLogger {
async logTokenAction(log: TokenAuditLog): Promise<void> {
const auditEntry = {
...log,
id: uuidv4(),
timestamp: new Date().toISOString()
};

// 구조화된 로깅
this.logger.log('Token action performed', auditEntry);

// 비정상적인 패턴 감지
await this.detectAnomalousPatterns(log);
}

private async detectAnomalousPatterns(log: TokenAuditLog): Promise<void> {
// 짧은 시간 내 과도한 토큰 갱신
const recentRefreshes = await this.getRecentRefreshCount(log.userId, 300000); // 5분
if (recentRefreshes > 10) {
this.logger.warn('Excessive token refresh detected', { userId: log.userId });
}

// 비정상적인 IP 주소 패턴
if (log.ipAddress && await this.isUnusualIpAddress(log.userId, log.ipAddress)) {
this.logger.warn('Unusual IP address detected', {
userId: log.userId,
ipAddress: log.ipAddress
});
}
}
}

3. 토큰 순환 정책

class TokenRotationPolicy {
private readonly maxTokenAge = 24 * 60 * 60 * 1000; // 24시간
private readonly forceRefreshThreshold = 12 * 60 * 60 * 1000; // 12시간

async enforceRotationPolicy(): Promise<void> {
const expiredSessions = await this.findExpiredSessions();

for (const session of expiredSessions) {
try {
if (this.shouldForceRefresh(session)) {
await this.forceTokenRefresh(session.userId);
} else {
await this.cleanupExpiredSession(session.userId);
}
} catch (error) {
this.logger.error('Token rotation failed', {
userId: session.userId,
error: error.message
});
}
}
}

private shouldForceRefresh(session: TokenSession): boolean {
const age = Date.now() - session.createdAt;
return age > this.forceRefreshThreshold && age < this.maxTokenAge;
}
}

성능 최적화

1. 토큰 캐싱 전략

class TokenCacheOptimizer {
private readonly cacheHitRatio = new Map<string, number>();
private readonly accessPatterns = new Map<string, number[]>();

async optimizeCacheStrategy(): Promise<void> {
// 캐시 히트율 분석
const lowHitRatioUsers = this.findLowHitRatioUsers();

// 접근 패턴 분석
const frequentUsers = this.findFrequentUsers();

// 캐시 전략 조정
await this.adjustCacheStrategy(lowHitRatioUsers, frequentUsers);
}

private findLowHitRatioUsers(): string[] {
return Array.from(this.cacheHitRatio.entries())
.filter(([_, ratio]) => ratio < 0.8)
.map(([userId]) => userId);
}

private async adjustCacheStrategy(
lowHitRatioUsers: string[],
frequentUsers: string[]
): Promise<void> {
// 자주 사용하는 사용자의 토큰은 더 오래 캐시
for (const userId of frequentUsers) {
await this.extendCacheTime(userId);
}

// 히트율이 낮은 사용자는 캐시 정책 재검토
for (const userId of lowHitRatioUsers) {
await this.reviewCachePolicy(userId);
}
}
}

2. 배치 토큰 갱신

class BatchTokenRefresh {
private readonly batchSize = 50;
private readonly refreshQueue: string[] = [];

async scheduleTokenRefresh(userId: string): Promise<void> {
this.refreshQueue.push(userId);

if (this.refreshQueue.length >= this.batchSize) {
await this.processBatch();
}
}

private async processBatch(): Promise<void> {
const batch = this.refreshQueue.splice(0, this.batchSize);

const refreshPromises = batch.map(userId =>
this.refreshUserTokenSafely(userId)
);

const results = await Promise.allSettled(refreshPromises);

// 실패한 갱신 처리
results.forEach((result, index) => {
if (result.status === 'rejected') {
this.logger.error('Batch token refresh failed', {
userId: batch[index],
error: result.reason
});
}
});
}
}

모니터링 및 메트릭

핵심 메트릭

  1. 토큰 갱신 성공률

    • 목표: > 99%
    • 알림 임계값: < 95%
  2. 평균 토큰 생명주기

    • 목표: 24시간
    • 모니터링: 비정상적으로 짧은 생명주기 감지
  3. 캐시 히트율

    • 목표: > 90%
    • 최적화 기준: < 80%
  4. API 호출 지연 시간

    • 목표: < 200ms (토큰 포함)
    • 알림 임계값: > 500ms

대시보드 구성

interface TokenMetrics {
totalActiveSessions: number;
tokenRefreshRate: number;
cacheHitRatio: number;
averageTokenLifetime: number;
failedRefreshCount: number;
securityAlerts: number;
}

class TokenMetricsCollector {
async collectMetrics(): Promise<TokenMetrics> {
return {
totalActiveSessions: await this.countActiveSessions(),
tokenRefreshRate: await this.calculateRefreshRate(),
cacheHitRatio: await this.calculateCacheHitRatio(),
averageTokenLifetime: await this.calculateAverageLifetime(),
failedRefreshCount: await this.countFailedRefreshes(),
securityAlerts: await this.countSecurityAlerts()
};
}
}

관련 문서