본문으로 건너뛰기

Auth 도메인 캐싱 전략

개요

이 문서는 인증 도메인에서 Redis를 활용한 캐싱 전략을 설명합니다. 전체 캐싱 아키텍처와 설계 원칙은 Redis 캐싱 설계 가이드를 참조하세요.

캐싱 대상

인증 도메인에서는 다음 데이터를 캐싱 대상으로 합니다:

  • 토큰 블랙리스트: 로그아웃된 토큰 정보
  • 활성 디바이스 목록: 사용자별 접속 디바이스 정보
  • 임시 인증 코드: 비밀번호 재설정, 이메일 인증 등 일회성 코드
  • 인증 시도 기록: 로그인 시도 제한을 위한 기록

캐싱 전략

1. 캐싱 패턴

인증 도메인은 주로 Cache-Aside 패턴을 사용합니다:

async isTokenBlacklisted(token: string): Promise<boolean> {
const tokenHash = this.hashToken(token);
const cacheKey = `auth:blacklist:${tokenHash}`;

// 캐시에서 블랙리스트 확인
const exists = await this.redisService.exists(cacheKey);
return exists === 1;
}

async addToBlacklist(token: string, expiresIn: number): Promise<void> {
const tokenHash = this.hashToken(token);
const cacheKey = `auth:blacklist:${tokenHash}`;

// 토큰을 블랙리스트에 추가 (토큰 만료 시간과 동일한 TTL)
await this.redisService.set(cacheKey, '1', expiresIn);
}

2. 키 구조

인증 도메인의 캐시 키는 다음 구조를 따릅니다:

auth:blacklist:{tokenHash} - 블랙리스트 토큰
private:{userId}:auth:devices - 사용자의 활성 디바이스 목록 (Sorted Set)
auth:device:{deviceId} - 개별 디바이스 정보 (Hash)
private:{userId}:auth:code:{codeType} - 임시 인증 코드
private:{userId}:auth:attempts - 인증 시도 횟수
private:{userId}:auth:access-token:staging - 토큰 임시 연장

3. TTL 설정

데이터 유형TTL설명
토큰 블랙리스트토큰 만료 시간과 동일토큰의 원래 만료 시간까지만 블랙리스트에 유지
활성 디바이스 목록604800초 (7일)사용자의 디바이스 접속 정보
임시 인증 코드300초 (5분)일회성 인증 코드 유효 시간
인증 시도 기록1800초 (30분)로그인 시도 제한을 위한 TTL

중요 구현 세부사항

1. 토큰 블랙리스트 관리

토큰 블랙리스트는 DTA-WIDE의 stateless 아키텍처에서 매우 중요한 역할을 합니다:

@Injectable()
export class TokenBlacklistService {
constructor(private readonly redisService: RedisService) {}

private hashToken(token: string): string {
// 토큰 정보 해싱 (메모리 사용량 최적화)
return createHash('sha256').update(token).digest('hex');
}

async blacklistToken(token: string, tokenInfo: TokenPayload): Promise<void> {
const tokenHash = this.hashToken(token);
const cacheKey = `auth:blacklist:${tokenHash}`;

// 토큰 만료까지 남은 시간 계산 (초 단위)
const expiresAt = tokenInfo.exp;
const now = Math.floor(Date.now() / 1000);
const ttl = Math.max(0, expiresAt - now);

// 블랙리스트에 추가
await this.redisService.set(cacheKey, '1', ttl);

// 로깅
this.logger.debug(`Token blacklisted until ${new Date(expiresAt * 1000)}`);
}

async isBlacklisted(token: string): Promise<boolean> {
const tokenHash = this.hashToken(token);
const cacheKey = `auth:blacklist:${tokenHash}`;
const exists = await this.redisService.exists(cacheKey);
return exists === 1;
}
}

2. 단일 디바이스 접속 관리

사용자당 하나의 활성 디바이스만 허용하여 보안을 강화합니다:

@Injectable()
export class DeviceManagerService {
constructor(private readonly redisService: RedisService) {}

private readonly MAX_DEVICES_PER_USER = 1; // 사용자당 1개 디바이스만 허용

async registerDevice(userId: string, deviceId: string, deviceInfo: DeviceInfo): Promise<boolean> {
// 1. 사용자의 현재 활성 디바이스 조회 (단일 디바이스 정책)
const userDevicesKey = `private:${userId}:auth:devices`;
const userDevices = await this.redisService.zrange(userDevicesKey, 0, -1);

// 2. 기존 디바이스인지 확인
const isExistingDevice = userDevices.includes(deviceId);

// 3. 새 디바이스이고 기존 활성 디바이스가 있는 경우 (단일 디바이스 정책)
if (!isExistingDevice && userDevices.length >= this.MAX_DEVICES_PER_USER) {
// 기존 디바이스 제거 (단일 디바이스 로그인 정책에 따라)
const existingDevice = await this.redisService.zrange(userDevicesKey, 0, 0);
if (existingDevice.length > 0) {
await this.redisService.zrem(userDevicesKey, existingDevice[0]);
await this.redisService.del(`auth:device:${existingDevice[0]}`);
this.logger.info(`Removed existing device ${existingDevice[0]} for user ${userId} (single device policy)`);
}
}

// 4. 현재 디바이스 정보 저장
const now = Date.now();
await this.redisService.zadd(userDevicesKey, now, deviceId);
await this.redisService.hset(`auth:device:${deviceId}`, {
...deviceInfo,
userId,
lastActive: now,
});

// 5. TTL 설정 (7일)
await this.redisService.expire(userDevicesKey, 604800);
await this.redisService.expire(`auth:device:${deviceId}`, 604800);

return true;
}

async getDevices(userId: string): Promise<DeviceInfo[]> {
const userDevicesKey = `private:${userId}:auth:devices`;
const deviceIds = await this.redisService.zrange(userDevicesKey, 0, -1);

const devices: DeviceInfo[] = [];
for (const deviceId of deviceIds) {
const deviceInfo = await this.redisService.hgetall(`auth:device:${deviceId}`);
if (deviceInfo) {
devices.push(deviceInfo as DeviceInfo);
}
}

return devices;
}

async removeDevice(userId: string, deviceId: string): Promise<boolean> {
// 디바이스 제거 및 관련 토큰 블랙리스트 처리
await this.redisService.zrem(`private:${userId}:auth:devices`, deviceId);
await this.redisService.del(`auth:device:${deviceId}`);
return true;
}

async updateDeviceActivity(deviceId: string): Promise<void> {
// 디바이스 활동 기록 업데이트
const now = Date.now();
const deviceKey = `auth:device:${deviceId}`;

// 디바이스 정보 가져오기
const deviceInfo = await this.redisService.hgetall(deviceKey);
if (deviceInfo && deviceInfo.userId) {
// 마지막 활동 시간 업데이트
await this.redisService.hset(deviceKey, 'lastActive', now.toString());

// Sorted Set의 점수 업데이트 (최신 활동 순으로 정렬)
await this.redisService.zadd(`private:${deviceInfo.userId}:auth:devices`, now, deviceId);
}
}
}

3. 임시 인증 코드 관리

비밀번호 재설정이나 이메일 인증 등의, 임시 코드를 관리합니다:

@Injectable()
export class AuthCodeService {
constructor(private readonly redisService: RedisService) {}

async generateVerificationCode(userId: string, codeType: string): Promise<string> {
// 6자리 코드 생성
const code = Math.floor(100000 + Math.random() * 900000).toString();
const cacheKey = `private:${userId}:auth:code:${codeType}`;

// 기존 코드 삭제 후 새 코드 저장 (5분 유효)
await this.redisService.del(cacheKey);
await this.redisService.set(cacheKey, code, 300);

return code;
}

async verifyCode(userId: string, codeType: string, code: string): Promise<boolean> {
const cacheKey = `private:${userId}:auth:code:${codeType}`;
const storedCode = await this.redisService.get(cacheKey);

if (storedCode === code) {
// 확인 후 코드 삭제 (1회성)
await this.redisService.del(cacheKey);
return true;
}

return false;
}
}

4. 로그인 시도 제한

무차별 암호 대입 공격을 방지하기 위한 로그인 시도 제한:

@Injectable()
export class AuthRateLimiterService {
constructor(private readonly redisService: RedisService) {}

private readonly MAX_ATTEMPTS = 5;
private readonly LOCKOUT_TIME = 1800; // 30분 (초 단위)

async recordLoginAttempt(userId: string): Promise<boolean> {
const cacheKey = `private:${userId}:auth:attempts`;

// 시도 횟수 증가
const attempts = await this.redisService.incr(cacheKey);

// 처음 시도할 경우 TTL 설정
if (attempts === 1) {
await this.redisService.expire(cacheKey, this.LOCKOUT_TIME);
}

return attempts <= this.MAX_ATTEMPTS;
}

async isAccountLocked(userId: string): Promise<boolean> {
const cacheKey = `private:${userId}:auth:attempts`;
const attempts = await this.redisService.get(cacheKey);

return attempts !== null && parseInt(attempts, 10) >= this.MAX_ATTEMPTS;
}

async resetAttempts(userId: string): Promise<void> {
const cacheKey = `private:${userId}:auth:attempts`;
await this.redisService.del(cacheKey);
}
}

보안 고려사항

  1. 토큰 정보 보호:

    • 실제 토큰을 저장하지 않고 해시값만 저장
    • 블랙리스트 항목의 TTL을 토큰 만료 시간과 일치시켜 불필요한 데이터 제거
  2. 디바이스 관리:

    • 사용자당 최대 활성 디바이스 수 제한 (1개, 단일 디바이스 로그인 정책)
    • 디바이스 정보에 IP, 브라우저, OS 등 상세 정보 저장하여 의심스러운 접속 식별
    • 사용자에게 현재 로그인된 디바이스 정보 제공 및 원격 로그아웃 기능 제공
  3. 레이트 리미팅:

    • 로그인 시도 제한을 통한 무차별 대입 공격 방지
    • 민감한 작업(비밀번호 변경, 이메일 변경 등)에 대한 속도 제한 적용

성능 최적화

  • Sorted Set 활용: 디바이스 관리에 Sorted Set을 사용하여 최신 활동 순 정렬 및 가장 오래된 디바이스 식별
  • Hash 구조 활용: 디바이스 정보 저장에 Hash 구조를 활용하여 개별 필드 업데이트 최적화
  • 메모리 최적화: 토큰 자체 대신 해시값 저장으로 메모리 사용량 감소

변경 이력

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