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);
}
}
보안 고려사항
-
토큰 정보 보호:
- 실제 토큰을 저장하지 않고 해시값만 저장
- 블랙리스트 항목의 TTL을 토큰 만료 시간과 일치시켜 불필요한 데이터 제거
-
디바이스 관리:
- 사용자당 최대 활성 디바이스 수 제한 (1개, 단일 디바이스 로그인 정책)
- 디바이스 정보에 IP, 브라우저, OS 등 상세 정보 저장하여 의심스러운 접속 식별
- 사용자에게 현재 로그인된 디바이스 정보 제공 및 원격 로그아웃 기능 제공
-
레이트 리미팅:
- 로그인 시도 제한을 통한 무차별 대입 공격 방지
- 민감한 작업(비밀번호 변경, 이메일 변경 등)에 대한 속도 제한 적용
성능 최적화
- Sorted Set 활용: 디바이스 관리에 Sorted Set을 사용하여 최신 활동 순 정렬 및 가장 오래된 디바이스 식별
- Hash 구조 활용: 디바이스 정보 저장에 Hash 구조를 활용하여 개별 필드 업데이트 최적화
- 메모리 최적화: 토큰 자체 대신 해시값 저장으로 메모리 사용량 감소
변경 이력
| 버전 | 날짜 | 작성자 | 변경 내용 |
|---|---|---|---|
| 0.1.0 | 2025-04-08 | bok@weltcorp.com | 최초 작성 |